diff --git a/.github/workflows/build-and-make.yaml b/.github/workflows/build-and-make.yaml index 2e0166460..146fbe4b7 100644 --- a/.github/workflows/build-and-make.yaml +++ b/.github/workflows/build-and-make.yaml @@ -825,16 +825,32 @@ jobs: run: | set -euo pipefail - cp "${RUNNER_TEMP}/electron-builder.base.json" electron-builder.json - node tools/packaging/configure-linux-frame-copy-build.mjs --foreign-deb for foreign_arch in armv7l arm64; do + rm -rf dist/executables-linux-foreign + cp "${RUNNER_TEMP}/electron-builder.base.json" electron-builder.json + node tools/packaging/configure-linux-frame-copy-build.mjs \ + --foreign-deb \ + --foreign-arch "${foreign_arch}" pnpm nx run electron-backend:make \ --arch="${foreign_arch}" \ --outputPath=dist/executables-linux-foreign \ --publishPolicy=never + mapfile -t foreign_debs < <( + find dist/executables-linux-foreign -maxdepth 1 -type f -name '*.deb' -print + ) + test "${#foreign_debs[@]}" -eq 1 + case "${foreign_arch}" in + armv7l) expected_deb_arch=armhf ;; + arm64) expected_deb_arch=arm64 ;; + *) + echo "::error::Unexpected foreign DEB build architecture ${foreign_arch}" + exit 1 + ;; + esac + actual_deb_arch="$(dpkg-deb --field "${foreign_debs[0]}" Architecture)" + test "${actual_deb_arch}" = "${expected_deb_arch}" + mv "${foreign_debs[0]}" dist/executables/ done - find dist/executables-linux-foreign -maxdepth 1 -type f -name '*.deb' \ - -exec mv '{}' dist/executables/ ';' - name: Verify DEB payloads and x64 system runtime if: matrix.os == 'linux' && matrix.linux_profile == 'system' @@ -856,7 +872,8 @@ jobs: bash -euo pipefail -c ' apt-get update DEBIAN_FRONTEND=noninteractive apt-get install --no-install-recommends -y \ - binutils libegl1 libgl1-mesa-dri libmpv2 nodejs squashfs-tools xauth xvfb + binutils libegl1 libgbm1 libgl1-mesa-dri libmpv2 libopengl0 \ + nodejs squashfs-tools xauth xvfb xvfb-run -a env LIBGL_ALWAYS_SOFTWARE=1 \ node tools/packaging/verify-linux-frame-copy-runtime.mjs \ --artifact /artifact.deb --profile system @@ -889,8 +906,9 @@ jobs: fedora:latest \ bash -euo pipefail -c ' dnf install -y \ - binutils bsdtar mesa-dri-drivers mpv-libs nodejs rpm \ - xorg-x11-server-Xvfb xorg-x11-xauth + binutils bsdtar libglvnd-egl libglvnd-opengl mesa-dri-drivers \ + mesa-libgbm mpv-libs nodejs rpm xorg-x11-server-Xvfb \ + xorg-x11-xauth xvfb-run -a env LIBGL_ALWAYS_SOFTWARE=1 \ node tools/packaging/verify-linux-frame-copy-runtime.mjs \ --artifact /artifact.rpm --profile system @@ -911,7 +929,8 @@ jobs: archlinux:latest \ bash -euo pipefail -c ' pacman -Syu --noconfirm \ - binutils libarchive mesa mpv nodejs xorg-server-xvfb xorg-xauth + binutils libarchive libglvnd mesa mpv nodejs xorg-server-xvfb \ + xorg-xauth xvfb-run -a env LIBGL_ALWAYS_SOFTWARE=1 \ node tools/packaging/verify-linux-frame-copy-runtime.mjs \ --artifact /artifact.pacman --profile system @@ -947,9 +966,9 @@ jobs: verification="$( xvfb-run -a env LIBGL_ALWAYS_SOFTWARE=1 \ node tools/packaging/verify-linux-frame-copy-runtime.mjs \ - --artifact "${artifact}" --profile portable + --artifact "${artifact}" --profile portable \ + 2>&1 | tee /dev/stderr )" - printf '%s\n' "${verification}" if printf '%s\n' "${verification}" | grep -Fq 'Verified snap x64 Linux'; then sudo snap install --dangerous "${artifact}" installed_x64=true diff --git a/AGENTS.md b/AGENTS.md index f24e10d8f..11419600b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -224,7 +224,9 @@ Key files: overrides. - Packaging runs three isolated profiles: - `system`: DEB/RPM/Pacman, no private `native/lib`, with package - dependencies `libmpv2`/`mpv-libs`/`mpv` + dependencies DEB=`libmpv2,libegl1,libopengl0,libgbm1`, + RPM=`mpv-libs,libglvnd-egl,libglvnd-opengl,mesa-libgbm`, and + Pacman=`mpv,libglvnd,mesa` - `portable`: AppImage/Snap with the pinned LGPL-compatible closure - `flatpak`: Flatpak with the same pinned closure - The DEB system-runtime contract is Ubuntu 24.04+ (`libmpv2`). Ubuntu 22.04 diff --git a/CLAUDE.md b/CLAUDE.md index e995a4999..ff9c73760 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -628,15 +628,18 @@ engine` (restart required) or UI; native-view retains the legacy dock. On Linux, only `iptvnator_mpv_helper` may link libmpv; Electron, its shipped libraries, the addon, and frame reader must not. Official x64 packages use three separate - profiles: DEB/RPM/Pacman depend on system libmpv, AppImage/Snap bundle the - pinned LGPL closure, and Flatpak bundles the same closure. The DEB contract - requires `libmpv2` and is verified on Ubuntu 24.04+; Ubuntu 22.04 users need - the x64 AppImage because Jammy provides `libmpv1`. ARM packages are - marker-only. Stored or explicit opt-ins cannot bypass the fail-closed - packaged manifest/file/hash gate and bounded `--runtime-probe`; any failure - keeps the sandbox enabled, records a stable reason, and falls back to - native-view without crashing. Snap uses an exact private `shared-memory` - plug; probe and playback share a sanitized loader environment in which + profiles: DEB/RPM/Pacman depend on system libmpv plus the helper's direct + EGL/OpenGL/GBM interfaces, AppImage/Snap bundle the pinned LGPL closure, and + Flatpak bundles the same closure. Exact system dependencies are + DEB=`libmpv2,libegl1,libopengl0,libgbm1`, + RPM=`mpv-libs,libglvnd-egl,libglvnd-opengl,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`. + ARM packages are marker-only. Stored or explicit opt-ins cannot bypass the + fail-closed packaged manifest/file/hash gate and bounded `--runtime-probe`; + any failure keeps the sandbox enabled, records a stable reason, and falls + back to native-view without crashing. Snap uses an exact private + `shared-memory` plug; probe and playback share a sanitized loader environment in which ambient audit, preload, and library paths are removed and the validated private closure and trusted Snap GL roots have explicit precedence. Bundled Linux packages carry hash-validated diff --git a/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/contracts.ts b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/contracts.ts index 4ea7a7e69..cd469336e 100644 --- a/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/contracts.ts +++ b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/contracts.ts @@ -257,11 +257,16 @@ export const DEVELOPMENT_MANIFEST_FIELDS = [ 'sourceRuntimeValidated', ] as const; -export const SYSTEM_PACKAGE_DEPENDENCIES = { - deb: 'libmpv2', - rpm: 'mpv-libs', - pacman: 'mpv', -}; +export const SYSTEM_PACKAGE_DEPENDENCIES = Object.freeze({ + deb: Object.freeze(['libmpv2', 'libegl1', 'libopengl0', 'libgbm1']), + rpm: Object.freeze([ + 'mpv-libs', + 'libglvnd-egl', + 'libglvnd-opengl', + 'mesa-libgbm', + ]), + pacman: Object.freeze(['mpv', 'libglvnd', 'mesa']), +}); export const PROFILE_CONTRACTS = { system: { diff --git a/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/runtime-fixtures.test-helpers.ts b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/runtime-fixtures.test-helpers.ts index 7bec5b101..984dd4d64 100644 --- a/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/runtime-fixtures.test-helpers.ts +++ b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/runtime-fixtures.test-helpers.ts @@ -160,9 +160,14 @@ export function createFixture( packageDependencies: bundled ? {} : { - deb: 'libmpv2', - rpm: 'mpv-libs', - pacman: 'mpv', + deb: ['libmpv2', 'libegl1', 'libopengl0', 'libgbm1'], + rpm: [ + 'mpv-libs', + 'libglvnd-egl', + 'libglvnd-opengl', + 'mesa-libgbm', + ], + pacman: ['mpv', 'libglvnd', 'mesa'], }, runtimeFiles, runtimeTotalBytes: runtimeFiles.reduce( diff --git a/docs/architecture/embedded-mpv-native.md b/docs/architecture/embedded-mpv-native.md index 79c7ba35a..f3eb52623 100644 --- a/docs/architecture/embedded-mpv-native.md +++ b/docs/architecture/embedded-mpv-native.md @@ -77,16 +77,24 @@ or `ELECTRON_OZONE_PLATFORM_HINT` is never overridden. Linux packages are built in separate passes because Electron Builder reuses one unpacked application layout per pass: -| Profile | Formats | Frame-copy libmpv strategy | -| ---------- | ---------------- | --------------------------------------------------------------------------------------------- | -| `system` | DEB, RPM, Pacman | System `libmpv.so.2`; package dependencies are `libmpv2`, `mpv-libs`, and `mpv`, respectively | -| `portable` | AppImage, Snap | Bundled pinned LGPL-compatible runtime under `native/lib` | -| `flatpak` | Flatpak | The same bundled pinned LGPL-compatible runtime under `native/lib` | +| Profile | Formats | Frame-copy runtime strategy | +| ---------- | ---------------- | ------------------------------------------------------------------------------------------------------------ | +| `system` | DEB, RPM, Pacman | System `libmpv.so.2` plus the helper's direct EGL/OpenGL/GBM interfaces; exact dependencies are listed below | +| `portable` | AppImage, Snap | Bundled pinned LGPL-compatible runtime under `native/lib` | +| `flatpak` | Flatpak | The same bundled pinned LGPL-compatible runtime under `native/lib` | + +System package dependencies are fail-closed and format-specific: + +- DEB: `libmpv2`, `libegl1`, `libopengl0`, `libgbm1` +- RPM: `mpv-libs`, `libglvnd-egl`, `libglvnd-opengl`, `mesa-libgbm` +- Pacman: `mpv`, `libglvnd`, `mesa` The DEB contract deliberately names `libmpv2`, not a loose `libmpv` -alternative. Release CI verifies that contract on Ubuntu 24.04 (Noble). -Ubuntu 22.04 (Jammy) provides `libmpv1`, so its DEB cannot enable this -system-runtime frame-copy path; use the x64 AppImage there instead. +alternative, and explicitly names the GLVND OpenGL interface because +`libmpv2` does not pull it in for the helper. Release CI verifies that contract +on Ubuntu 24.04 (Noble). Ubuntu 22.04 (Jammy) provides `libmpv1`, so its DEB +cannot enable this system-runtime frame-copy path; use the x64 AppImage there +instead. Every x64 layout contains the addon, frame reader, helper, and a normalized `embedded-mpv-runtime.json`. The Electron executable, Electron libraries, @@ -580,10 +588,10 @@ Linux release profiles: - `IPTVNATOR_LINUX_FRAME_COPY_PROFILE=system` builds DEB, RPM, and Pacman. `afterPack` removes the private `lib` directory, writes a - `system-libmpv-frame-copy` manifest, and package metadata requires - `libmpv2`, `mpv-libs`, or `mpv`. The DEB path is verified on Ubuntu 24.04+; - Ubuntu 22.04 users need the x64 AppImage because Jammy only provides - `libmpv1`. + `system-libmpv-frame-copy` manifest, and package metadata requires the exact + libmpv plus EGL/OpenGL/GBM package set listed above. The DEB path is verified + on Ubuntu 24.04+; Ubuntu 22.04 users need the x64 AppImage because Jammy only + provides `libmpv1`. - `IPTVNATOR_LINUX_FRAME_COPY_PROFILE=portable` builds AppImage and Snap with the pinned source-built closure and a `bundled-lgpl-frame-copy` manifest. - `IPTVNATOR_LINUX_FRAME_COPY_PROFILE=flatpak` builds Flatpak with the same @@ -618,10 +626,11 @@ Release packaging must: Users on macOS and Windows do not need the MPV GUI application for this architecture. Linux native-view still requires an `mpv` executable. Linux -frame-copy system packages need the declared libmpv package, while AppImage, -Snap, and Flatpak carry their own runtime closure. If frame-copy prerequisites -are missing, x64 falls back to native-view; if all Embedded MPV prerequisites -are unavailable, the existing inline/external players remain available. +frame-copy system packages need their declared libmpv and EGL/OpenGL/GBM +packages, while AppImage, Snap, and Flatpak carry their own runtime closure. +If frame-copy prerequisites are missing, x64 falls back to native-view; if all +Embedded MPV prerequisites are unavailable, the existing inline/external +players remain available. ## Runtime Staging diff --git a/docs/superpowers/specs/2026-07-17-linux-embedded-mpv-frame-copy-packaging-design.md b/docs/superpowers/specs/2026-07-17-linux-embedded-mpv-frame-copy-packaging-design.md index 370ddcd12..a8d55c119 100644 --- a/docs/superpowers/specs/2026-07-17-linux-embedded-mpv-frame-copy-packaging-design.md +++ b/docs/superpowers/specs/2026-07-17-linux-embedded-mpv-frame-copy-packaging-design.md @@ -57,14 +57,15 @@ profile. System package dependencies are: -- DEB: `libmpv2` -- RPM: `mpv-libs` -- Pacman: `mpv` +- DEB: `libmpv2`, `libegl1`, `libopengl0`, `libgbm1` +- RPM: `mpv-libs`, `libglvnd-egl`, `libglvnd-opengl`, `mesa-libgbm` +- Pacman: `mpv`, `libglvnd`, `mesa` -These names match the current Debian, Fedora, and Arch package databases. -System packages do not copy libmpv into IPTVnator. The helper keeps an -`$ORIGIN/lib` RPATH first for a consistent binary, but naturally resolves the -system SONAME when the private directory is absent. +These names match the current Debian, Fedora, and Arch package databases and +cover every direct helper interface: libmpv, EGL, OpenGL, and GBM. System +packages do not copy libmpv into IPTVnator. The helper keeps an `$ORIGIN/lib` +RUNPATH first for a consistent binary, but naturally resolves the system SONAME +when the private directory is absent. Portable and sandboxed packages use a source-built runtime rather than copying the Ubuntu runner's mpv package. The runtime build is checksum/version pinned, diff --git a/tools/embedded-mpv/README.md b/tools/embedded-mpv/README.md index d990830bc..b47b15144 100644 --- a/tools/embedded-mpv/README.md +++ b/tools/embedded-mpv/README.md @@ -110,15 +110,25 @@ Before publication, the Linux builder verifies: Set one exact `IPTVNATOR_LINUX_FRAME_COPY_PROFILE` per packaging pass: -| Profile | Formats | Runtime handling | -| ---------- | ---------------- | ------------------------------------------------------------ | -| `system` | DEB, RPM, Pacman | Remove `native/lib`; require `libmpv2`, `mpv-libs`, or `mpv` | -| `portable` | AppImage, Snap | Retain the pinned LGPL closure under `native/lib` | -| `flatpak` | Flatpak | Retain the same pinned LGPL closure under `native/lib` | +| Profile | Formats | Runtime handling | +| ---------- | ---------------- | ---------------------------------------------------------------------------- | +| `system` | DEB, RPM, Pacman | Remove `native/lib`; require the format-specific system runtime listed below | +| `portable` | AppImage, Snap | Retain the pinned LGPL closure under `native/lib` | +| `flatpak` | Flatpak | Retain the same pinned LGPL closure under `native/lib` | -The DEB metadata requires `libmpv2` and is release-tested on Ubuntu 24.04 -(Noble). Ubuntu 22.04 (Jammy) only provides `libmpv1`; use the x64 AppImage on -that distribution rather than relaxing the runtime contract. +The system helper directly links libmpv, EGL, OpenGL, and GBM. Package metadata +therefore declares the full interface set: + +- DEB: `libmpv2`, `libegl1`, `libopengl0`, `libgbm1` +- RPM: `mpv-libs`, `libglvnd-egl`, `libglvnd-opengl`, `mesa-libgbm` +- Pacman: `mpv`, `libglvnd`, `mesa` + +The DEB metadata is release-tested on Ubuntu 24.04 (Noble). Ubuntu 22.04 +(Jammy) only provides `libmpv1`; use the x64 AppImage on that distribution +rather than relaxing the runtime contract. CI explicitly installs the distro +Mesa software renderer for headless smoke. IPTVnator does not add DRI-driver +packages as direct dependencies; any transitive graphics-driver stack remains +under the distro's dependency policy. The strict Snap retains Electron Builder's default plugs and adds an auto-connected private `shared-memory` plug. This supplies a snap-specific diff --git a/tools/packaging/configure-linux-frame-copy-build.mjs b/tools/packaging/configure-linux-frame-copy-build.mjs index bdfc90495..6cd570418 100644 --- a/tools/packaging/configure-linux-frame-copy-build.mjs +++ b/tools/packaging/configure-linux-frame-copy-build.mjs @@ -42,26 +42,29 @@ function fpmDependencyName(option) { } function configureSystemDependencies(config) { - const frameCopyDependencies = new Set( - Object.values(LINUX_SYSTEM_PACKAGE_DEPENDENCIES) - ); - for (const [format, dependency] of Object.entries( + for (const [format, dependencies] of Object.entries( LINUX_SYSTEM_PACKAGE_DEPENDENCIES )) { + const frameCopyDependencies = new Set(dependencies); const formatConfig = { ...(config[format] ?? {}) }; const otherFpmOptions = (formatConfig.fpm ?? []).filter( (option) => !frameCopyDependencies.has(fpmDependencyName(option)) ); - formatConfig.fpm = [...otherFpmOptions, `--depends=${dependency}`]; + formatConfig.fpm = [ + ...otherFpmOptions, + ...dependencies.map((dependency) => `--depends=${dependency}`), + ]; config[format] = formatConfig; } } function removeForeignFrameCopyDependency(config, format) { - const dependency = LINUX_SYSTEM_PACKAGE_DEPENDENCIES[format]; + const dependencies = new Set( + LINUX_SYSTEM_PACKAGE_DEPENDENCIES[format] ?? [] + ); const formatConfig = { ...(config[format] ?? {}) }; const retainedFpmOptions = (formatConfig.fpm ?? []).filter( - (option) => fpmDependencyName(option) !== dependency + (option) => !dependencies.has(fpmDependencyName(option)) ); if (retainedFpmOptions.length > 0) { formatConfig.fpm = retainedFpmOptions; @@ -73,8 +76,9 @@ function removeForeignFrameCopyDependency(config, format) { export function configureLinuxFrameCopyBuild( electronBuilderConfig, - { profileName, foreignDeb = false } = {} + { profileName, foreignDeb = false, foreignArch } = {} ) { + const hasForeignArch = foreignArch !== undefined; if ( !electronBuilderConfig || typeof electronBuilderConfig !== 'object' || @@ -89,6 +93,11 @@ export function configureLinuxFrameCopyBuild( 'The marker-only foreign DEB pass must not select a frame-copy profile.' ); } + if (hasForeignArch && !foreignDeb) { + throw new Error( + 'A marker-only foreign architecture requires --foreign-deb.' + ); + } const configured = cloneJson(electronBuilderConfig); const targets = configured.linux?.target; if (!Array.isArray(targets)) { @@ -116,7 +125,20 @@ export function configureLinuxFrameCopyBuild( 'Electron Builder DEB target has no foreign architectures.' ); } - configuredDebTarget.arch = foreignArches; + if ( + hasForeignArch && + (typeof foreignArch !== 'string' || + !foreignArches.includes(foreignArch)) + ) { + throw new Error( + `Unsupported marker-only foreign DEB architecture "${foreignArch}". Expected one of: ${foreignArches.join( + ', ' + )}.` + ); + } + configuredDebTarget.arch = hasForeignArch + ? [foreignArch] + : foreignArches; configured.linux.target = [configuredDebTarget]; configured.directories = { ...(configured.directories ?? {}), @@ -179,14 +201,31 @@ function parseArguments(argv) { let configPath = 'electron-builder.json'; let profileName; let foreignDeb = false; + let foreignArch; + const nextValue = (flag, index) => { + const value = normalized[index + 1]; + if ( + typeof value !== 'string' || + value.trim() === '' || + value.startsWith('--') + ) { + throw new Error(`${flag} requires a value.`); + } + return value; + }; for (let index = 0; index < normalized.length; index += 1) { const argument = normalized[index]; if (argument === '--config') { - configPath = normalized[++index]; + configPath = nextValue(argument, index); + index += 1; } else if (argument === '--profile') { - profileName = normalized[++index]; + profileName = nextValue(argument, index); + index += 1; } else if (argument === '--foreign-deb') { foreignDeb = true; + } else if (argument === '--foreign-arch') { + foreignArch = nextValue(argument, index); + index += 1; } else { throw new Error(`Unsupported configurator argument: ${argument}`); } @@ -198,6 +237,7 @@ function parseArguments(argv) { configPath: path.resolve(configPath), profileName, foreignDeb, + foreignArch, }; } diff --git a/tools/packaging/configure-linux-frame-copy-build.test.mjs b/tools/packaging/configure-linux-frame-copy-build.test.mjs index 427e81518..b83484281 100644 --- a/tools/packaging/configure-linux-frame-copy-build.test.mjs +++ b/tools/packaging/configure-linux-frame-copy-build.test.mjs @@ -1,5 +1,7 @@ import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; import fs from 'node:fs'; +import os from 'node:os'; import path from 'node:path'; import test from 'node:test'; import { parse as parseYaml } from 'yaml'; @@ -48,9 +50,23 @@ test('configures an x64-only system pass with exact package dependencies', () => { target: 'rpm', arch: ['x64'] }, { target: 'pacman', arch: ['x64'] }, ]); - assert.deepEqual(configured.deb.fpm, ['--depends=libmpv2']); - assert.deepEqual(configured.rpm.fpm, ['--depends=mpv-libs']); - assert.deepEqual(configured.pacman.fpm, ['--depends=mpv']); + assert.deepEqual(configured.deb.fpm, [ + '--depends=libmpv2', + '--depends=libegl1', + '--depends=libopengl0', + '--depends=libgbm1', + ]); + assert.deepEqual(configured.rpm.fpm, [ + '--depends=mpv-libs', + '--depends=libglvnd-egl', + '--depends=libglvnd-opengl', + '--depends=mesa-libgbm', + ]); + assert.deepEqual(configured.pacman.fpm, [ + '--depends=mpv', + '--depends=libglvnd', + '--depends=mesa', + ]); }); test('configures portable and flatpak passes without mixing targets', () => { @@ -99,6 +115,76 @@ test('configures a separate marker-only foreign DEB pass without libmpv metadata ); }); +test('configures exactly one requested marker-only foreign DEB architecture', () => { + for (const foreignArch of ['armv7l', 'arm64']) { + const configured = configureLinuxFrameCopyBuild(electronBuilderConfig, { + foreignDeb: true, + foreignArch, + }); + + assert.deepEqual(configuredTargets(configured), [ + { target: 'deb', arch: [foreignArch] }, + ]); + } +}); + +test('CLI writes an exact marker-only foreign DEB architecture', (t) => { + const temporaryDirectory = fs.mkdtempSync( + path.join(os.tmpdir(), 'iptvnator-linux-build-config-') + ); + t.after(() => + fs.rmSync(temporaryDirectory, { recursive: true, force: true }) + ); + const configPath = path.join(temporaryDirectory, 'electron-builder.json'); + fs.writeFileSync( + configPath, + `${JSON.stringify(electronBuilderConfig, null, 4)}\n` + ); + + const result = spawnSync( + process.execPath, + [ + path.join( + workspaceRoot, + 'tools', + 'packaging', + 'configure-linux-frame-copy-build.mjs' + ), + '--config', + configPath, + '--foreign-deb', + '--foreign-arch', + 'arm64', + ], + { encoding: 'utf8' } + ); + + assert.equal(result.status, 0, result.stderr); + assert.deepEqual( + configuredTargets(JSON.parse(fs.readFileSync(configPath, 'utf8'))), + [{ target: 'deb', arch: ['arm64'] }] + ); + + const missingValue = spawnSync( + process.execPath, + [ + path.join( + workspaceRoot, + 'tools', + 'packaging', + 'configure-linux-frame-copy-build.mjs' + ), + '--config', + configPath, + '--foreign-deb', + '--foreign-arch', + ], + { encoding: 'utf8' } + ); + assert.notEqual(missingValue.status, 0); + assert.match(missingValue.stderr, /--foreign-arch requires a value/); +}); + test('does not mutate the shared electron-builder configuration', () => { const before = JSON.stringify(electronBuilderConfig); configureLinuxFrameCopyBuild(electronBuilderConfig, { @@ -123,6 +209,31 @@ test('rejects an unknown profile and conflicting foreign/profile modes', () => { }), /must not select a frame-copy profile/ ); + assert.throws( + () => + configureLinuxFrameCopyBuild(electronBuilderConfig, { + foreignArch: 'arm64', + }), + /foreign architecture requires --foreign-deb/ + ); + assert.throws( + () => + configureLinuxFrameCopyBuild(electronBuilderConfig, { + foreignDeb: true, + foreignArch: 'x64', + }), + /Unsupported marker-only foreign DEB architecture/ + ); + for (const foreignArch of ['', 64]) { + assert.throws( + () => + configureLinuxFrameCopyBuild(electronBuilderConfig, { + foreignDeb: true, + foreignArch, + }), + /Unsupported marker-only foreign DEB architecture/ + ); + } }); test('rejects duplicate profile targets even when target count looks complete', () => { @@ -142,20 +253,32 @@ test('rejects duplicate profile targets even when target count looks complete', test('preserves unrelated fpm dependencies and normalizes only frame-copy dependencies', () => { const customized = structuredClone(electronBuilderConfig); customized.deb = { - fpm: ['--depends=unrelated-runtime', '--depends=libmpv2 >= 2'], + fpm: [ + '--depends=unrelated-runtime', + '--depends=mesa', + '--depends=libmpv2 >= 2', + '--depends=libopengl0', + ], }; const system = configureLinuxFrameCopyBuild(customized, { profileName: 'system', }); assert.deepEqual(system.deb.fpm, [ '--depends=unrelated-runtime', + '--depends=mesa', '--depends=libmpv2', + '--depends=libegl1', + '--depends=libopengl0', + '--depends=libgbm1', ]); const foreign = configureLinuxFrameCopyBuild(customized, { foreignDeb: true, }); - assert.deepEqual(foreign.deb.fpm, ['--depends=unrelated-runtime']); + assert.deepEqual(foreign.deb.fpm, [ + '--depends=unrelated-runtime', + '--depends=mesa', + ]); }); test('Linux CI builds one cached source runtime and packages three isolated profiles', () => { @@ -173,7 +296,7 @@ test('Linux CI builds one cached source runtime and packages three isolated prof ); assert.match( buildWorkflow, - /configure-linux-frame-copy-build\.mjs --foreign-deb/ + /configure-linux-frame-copy-build\.mjs[\s\S]*--foreign-deb/ ); assert.match( buildWorkflow, @@ -368,12 +491,85 @@ test('foreign DEB CI explicitly selects both marker-only ARM architectures', () ); assert.match(foreignDebStep, /for foreign_arch in armv7l arm64; do/); + assert.match( + foreignDebStep, + /--foreign-deb\s+\\\s+--foreign-arch "\$\{foreign_arch\}"/ + ); assert.match(foreignDebStep, /--arch="\$\{foreign_arch\}"/); + assert.match( + foreignDebStep, + /for foreign_arch[\s\S]*cp "\$\{RUNNER_TEMP\}\/electron-builder\.base\.json" electron-builder\.json[\s\S]*configure-linux-frame-copy-build\.mjs/ + ); + assert.match(foreignDebStep, /rm -rf dist\/executables-linux-foreign/); + assert.match( + foreignDebStep, + /mapfile -t foreign_debs < <\(\s*find dist\/executables-linux-foreign[\s\S]*-name '\*\.deb' -print\s*\)/ + ); + assert.match(foreignDebStep, /test "\$\{#foreign_debs\[@\]\}" -eq 1/); + assert.match(foreignDebStep, /armv7l\) expected_deb_arch=armhf/); + assert.match(foreignDebStep, /arm64\) expected_deb_arch=arm64/); + assert.match( + foreignDebStep, + /actual_deb_arch="\$\(dpkg-deb --field "\$\{foreign_debs\[0\]\}" Architecture\)"[\s\S]*test "\$\{actual_deb_arch\}" = "\$\{expected_deb_arch\}"/ + ); + assert.match( + foreignDebStep, + /mv "\$\{foreign_debs\[0\]\}" dist\/executables\// + ); assert.match(foreignDebStep, /IPTVNATOR_LINUX_FRAME_COPY_PROFILE: ''/); assert.match(foreignDebStep, /IPTVNATOR_REQUIRE_EMBEDDED_MPV: '1'/); assert.doesNotMatch(foreignDebStep, /IPTVNATOR_REQUIRE_EMBEDDED_MPV: '0'/); }); +test('system package smoke environments install every direct helper runtime dependency', () => { + const debStep = workflowStep('Verify DEB payloads and x64 system runtime'); + for (const dependency of ['libmpv2', 'libegl1', 'libopengl0', 'libgbm1']) { + assert.match(debStep, new RegExp(`\\b${dependency}\\b`)); + } + + const rpmStep = workflowStep('Verify RPM payload and x64 system runtime'); + for (const dependency of [ + 'mpv-libs', + 'libglvnd-egl', + 'libglvnd-opengl', + 'mesa-libgbm', + ]) { + assert.match(rpmStep, new RegExp(`\\b${dependency}\\b`)); + } + + const pacmanStep = workflowStep( + 'Verify Pacman payload and x64 system runtime' + ); + for (const dependency of ['mpv', 'libglvnd', 'mesa']) { + assert.match(pacmanStep, new RegExp(`\\b${dependency}\\b`)); + } +}); + +test('Snap verifier preserves fail-closed status while exposing captured diagnostics', () => { + const snapStep = workflowStep( + 'Verify Snap payloads and strict-confinement runtime' + ); + + assert.match(snapStep, /set -euo pipefail/); + assert.match(snapStep, /2>&1 \| tee \/dev\/stderr/); + assert.doesNotMatch( + snapStep, + /^\s+printf '%s\\n' "\$\{verification\}"\s*$/m + ); + assert.ok( + snapStep.indexOf('verification="$(') < + snapStep.indexOf("grep -Fq 'Verified snap x64 Linux'") + ); + assert.ok( + snapStep.indexOf("grep -Fq 'Verified snap x64 Linux'") < + snapStep.indexOf('sudo snap install --dangerous') + ); + assert.ok( + snapStep.indexOf('sudo snap install --dangerous') < + snapStep.indexOf('installed_x64=true') + ); +}); + test('dedicated packaged x64 smoke cannot silently skip', () => { const linuxDependencies = workflowStep('Install Linux system dependencies'); assert.match(linuxDependencies, /--no-install-recommends/); diff --git a/tools/packaging/embedded-mpv-arch.test.mjs b/tools/packaging/embedded-mpv-arch.test.mjs index 7b969270a..14d15c659 100644 --- a/tools/packaging/embedded-mpv-arch.test.mjs +++ b/tools/packaging/embedded-mpv-arch.test.mjs @@ -36,9 +36,9 @@ const { const X64_ADDON_ENV = { IPTVNATOR_EMBEDDED_MPV_ARCH: 'x64' }; const SYSTEM_PACKAGE_DEPENDENCIES = { - deb: 'libmpv2', - rpm: 'mpv-libs', - pacman: 'mpv', + deb: ['libmpv2', 'libegl1', 'libopengl0', 'libgbm1'], + rpm: ['mpv-libs', 'libglvnd-egl', 'libglvnd-opengl', 'mesa-libgbm'], + pacman: ['mpv', 'libglvnd', 'mesa'], }; const FRAME_COPY_ARTIFACTS = { addon: 'embedded_mpv.node', diff --git a/tools/packaging/linux-frame-copy-profile.cjs b/tools/packaging/linux-frame-copy-profile.cjs index 85d505f82..8d5eece55 100644 --- a/tools/packaging/linux-frame-copy-profile.cjs +++ b/tools/packaging/linux-frame-copy-profile.cjs @@ -34,9 +34,14 @@ const SUPPORTED_PROFILE_NAMES = Object.freeze( ); const LINUX_SYSTEM_PACKAGE_DEPENDENCIES = Object.freeze({ - deb: 'libmpv2', - rpm: 'mpv-libs', - pacman: 'mpv', + deb: Object.freeze(['libmpv2', 'libegl1', 'libopengl0', 'libgbm1']), + rpm: Object.freeze([ + 'mpv-libs', + 'libglvnd-egl', + 'libglvnd-opengl', + 'mesa-libgbm', + ]), + pacman: Object.freeze(['mpv', 'libglvnd', 'mesa']), }); function expectedProfileNames() { diff --git a/tools/packaging/linux-frame-copy-profile.test.mjs b/tools/packaging/linux-frame-copy-profile.test.mjs index 7f4c50db9..e21578f52 100644 --- a/tools/packaging/linux-frame-copy-profile.test.mjs +++ b/tools/packaging/linux-frame-copy-profile.test.mjs @@ -58,13 +58,18 @@ test('defines the exact immutable Linux frame-copy profile matrix', () => { } }); -test('defines immutable system-package libmpv dependencies', () => { +test('defines immutable system-package helper runtime dependencies', () => { assert.deepEqual(LINUX_SYSTEM_PACKAGE_DEPENDENCIES, { - deb: 'libmpv2', - rpm: 'mpv-libs', - pacman: 'mpv', + deb: ['libmpv2', 'libegl1', 'libopengl0', 'libgbm1'], + rpm: ['mpv-libs', 'libglvnd-egl', 'libglvnd-opengl', 'mesa-libgbm'], + pacman: ['mpv', 'libglvnd', 'mesa'], }); assert.equal(Object.isFrozen(LINUX_SYSTEM_PACKAGE_DEPENDENCIES), true); + for (const dependencies of Object.values( + LINUX_SYSTEM_PACKAGE_DEPENDENCIES + )) { + assert.equal(Object.isFrozen(dependencies), true); + } }); test('resolves each supported profile as an immutable defensive value', () => { @@ -158,7 +163,7 @@ test('rejects a non-array profile target list', () => { }); test('keeps frame-copy package dependencies out of the base Electron Builder config', () => { - for (const [target, dependency] of Object.entries( + for (const [target, dependencies] of Object.entries( LINUX_SYSTEM_PACKAGE_DEPENDENCIES )) { assert.equal( @@ -166,14 +171,16 @@ test('keeps frame-copy package dependencies out of the base Electron Builder con undefined, `${target}.depends must remain unset so electron-builder keeps its defaults` ); - assert.equal( - hasSystemFrameCopyDependency( - electronBuilderConfig, - target, - dependency - ), - false - ); + for (const dependency of dependencies) { + assert.equal( + hasSystemFrameCopyDependency( + electronBuilderConfig, + target, + dependency + ), + false + ); + } } }); @@ -191,13 +198,15 @@ test('keeps frame-copy package dependencies out of non-system passes', () => { ]; for (const config of configs) { - for (const [format, dependency] of Object.entries( + for (const [format, dependencies] of Object.entries( LINUX_SYSTEM_PACKAGE_DEPENDENCIES )) { - assert.equal( - hasSystemFrameCopyDependency(config, format, dependency), - false - ); + for (const dependency of dependencies) { + assert.equal( + hasSystemFrameCopyDependency(config, format, dependency), + false + ); + } } } }); diff --git a/tools/packaging/verify-linux-frame-copy-runtime.mjs b/tools/packaging/verify-linux-frame-copy-runtime.mjs index 302e937f0..8f57aca82 100644 --- a/tools/packaging/verify-linux-frame-copy-runtime.mjs +++ b/tools/packaging/verify-linux-frame-copy-runtime.mjs @@ -932,8 +932,8 @@ function dependencyMatches(dependency, expected) { } export function validateSystemPackageDependencies(format, dependencies) { - const expectedDependency = LINUX_SYSTEM_PACKAGE_DEPENDENCIES[format]; - if (!expectedDependency) { + const expectedDependencies = LINUX_SYSTEM_PACKAGE_DEPENDENCIES[format]; + if (!expectedDependencies) { return []; } if (!Array.isArray(dependencies)) { @@ -941,31 +941,29 @@ export function validateSystemPackageDependencies(format, dependencies) { `Linux ${format} package dependency metadata must be an array.`, ]; } - if ( - !dependencies.some((dependency) => + return expectedDependencies.flatMap((expectedDependency) => + dependencies.some((dependency) => dependencyMatches(String(dependency), expectedDependency) ) - ) { - return [ - `Linux x64 ${format} package must declare ${expectedDependency}.`, - ]; - } - return []; + ? [] + : [ + `Linux x64 ${format} package must declare ${expectedDependency}.`, + ] + ); } function validateForeignPackageDependencies(format, dependencies) { - const forbiddenDependency = LINUX_SYSTEM_PACKAGE_DEPENDENCIES[format]; - if ( - forbiddenDependency && + const forbiddenDependencies = + LINUX_SYSTEM_PACKAGE_DEPENDENCIES[format] ?? []; + return forbiddenDependencies.flatMap((forbiddenDependency) => dependencies.some((dependency) => dependencyMatches(String(dependency), forbiddenDependency) ) - ) { - return [ - `Linux foreign-architecture ${format} package must not declare frame-copy dependency ${forbiddenDependency}.`, - ]; - } - return []; + ? [ + `Linux foreign-architecture ${format} package must not declare frame-copy dependency ${forbiddenDependency}.`, + ] + : [] + ); } function dependencyFileName(dependencyName) { diff --git a/tools/packaging/verify-linux-frame-copy-runtime.test.mjs b/tools/packaging/verify-linux-frame-copy-runtime.test.mjs index 1899e7568..7e9954b26 100644 --- a/tools/packaging/verify-linux-frame-copy-runtime.test.mjs +++ b/tools/packaging/verify-linux-frame-copy-runtime.test.mjs @@ -77,13 +77,19 @@ const SYSTEM_MANIFEST = { nativeViewFallback: 'process-isolated mpv --wid', libmpvSoname: 'libmpv.so.2', packageDependencies: { - deb: 'libmpv2', - rpm: 'mpv-libs', - pacman: 'mpv', + deb: ['libmpv2', 'libegl1', 'libopengl0', 'libgbm1'], + rpm: ['mpv-libs', 'libglvnd-egl', 'libglvnd-opengl', 'mesa-libgbm'], + pacman: ['mpv', 'libglvnd', 'mesa'], }, runtimeFiles: [], runtimeTotalBytes: 0, }; +const DEB_SYSTEM_PACKAGE_DEPENDENCIES = [ + 'libmpv2', + 'libegl1', + 'libopengl0', + 'libgbm1', +]; test('uses the shared frozen 3000 ms runtime probe timeout contract', async () => { assert.equal( @@ -613,25 +619,43 @@ test('reads x64, arm64, and armv7 ELF architectures without host execution', () } }); -test('requires the exact system package dependency for DEB, RPM, and Pacman', () => { +test('requires every direct helper runtime dependency for DEB, RPM, and Pacman', () => { assert.deepEqual( validateSystemPackageDependencies('deb', [ 'libmpv2 (>= 0.35)', + 'libegl1', + 'libopengl0', + 'libgbm1', 'libc6', ]), [] ); assert.deepEqual( - validateSystemPackageDependencies('rpm', ['mpv-libs', 'glibc']), + validateSystemPackageDependencies('rpm', [ + 'mpv-libs', + 'libglvnd-egl', + 'libglvnd-opengl', + 'mesa-libgbm', + 'glibc', + ]), [] ); assert.deepEqual( - validateSystemPackageDependencies('pacman', ['mpv>=0.35', 'glibc']), + validateSystemPackageDependencies('pacman', [ + 'mpv>=0.35', + 'libglvnd', + 'mesa', + 'glibc', + ]), [] ); assert.match( - validateSystemPackageDependencies('deb', ['libmpv1'])[0], - /libmpv2/ + validateSystemPackageDependencies('deb', [ + 'libmpv2', + 'libegl1', + 'libgbm1', + ])[0], + /libopengl0/ ); }); @@ -731,7 +755,12 @@ test('validates an x64 system payload and executes one bounded helper probe', () resourceDir: fixture.resourceDir, artifactFormat: 'deb', profileName: 'system', - packageDependencies: ['libmpv2 (>= 0.35)'], + packageDependencies: [ + 'libmpv2 (>= 0.35)', + 'libegl1', + 'libopengl0', + 'libgbm1', + ], elfInspector: validElfInspector, probeRunner(command, args, options) { probeCalls.push({ command, args, options }); @@ -787,7 +816,7 @@ test('rejects helper probes terminated by a signal or hard timeout', () => { resourceDir: fixture.resourceDir, artifactFormat: 'deb', profileName: 'system', - packageDependencies: ['libmpv2'], + packageDependencies: DEB_SYSTEM_PACKAGE_DEPENDENCIES, elfInspector: validElfInspector, probeRunner() { return probeResult; @@ -1142,7 +1171,7 @@ test('rejects helper probe framing and fields that runtime capability rejects', resourceDir: fixture.resourceDir, artifactFormat: 'deb', profileName: 'system', - packageDependencies: ['libmpv2'], + packageDependencies: DEB_SYSTEM_PACKAGE_DEPENDENCIES, elfInspector: validElfInspector, probeRunner() { return { @@ -1219,7 +1248,7 @@ test('reports missing helper, wrong mode, wrong profile, isolation, and loader f resourceDir: fixture.resourceDir, artifactFormat: 'deb', profileName: testCase.profileName ?? 'system', - packageDependencies: ['libmpv2'], + packageDependencies: DEB_SYSTEM_PACKAGE_DEPENDENCIES, elfInspector: testCase.elfInspector ?? validElfInspector, probeRunner: testCase.probeRunner ?? successfulProbeRunner, }); @@ -1245,7 +1274,7 @@ test('rejects an x64 package manifest produced from a target subset', () => { resourceDir: fixture.resourceDir, artifactFormat: 'deb', profileName: 'system', - packageDependencies: ['libmpv2'], + packageDependencies: DEB_SYSTEM_PACKAGE_DEPENDENCIES, elfInspector: validElfInspector, probeRunner: successfulProbeRunner, }); @@ -1305,19 +1334,23 @@ test('requires marker-only foreign packages, scans Electron, and never probes', ); assert.equal(probeCalls, 0); - const dependencyErrors = verifyExtractedLinuxFrameCopyRuntime({ - resourceDir: fixture.resourceDir, - artifactFormat: 'deb', - profileName: 'system', - packageDependencies: ['libmpv2', 'libc6'], - declaredArch: 'arm64', - elfInspector: validElfInspector, - probeRunner: successfulProbeRunner, - }); - assert.match( - dependencyErrors.join('\n'), - /must not declare frame-copy dependency libmpv2/ - ); + for (const forbiddenDependency of DEB_SYSTEM_PACKAGE_DEPENDENCIES) { + const dependencyErrors = verifyExtractedLinuxFrameCopyRuntime({ + resourceDir: fixture.resourceDir, + artifactFormat: 'deb', + profileName: 'system', + packageDependencies: [forbiddenDependency, 'libc6'], + declaredArch: 'arm64', + elfInspector: validElfInspector, + probeRunner: successfulProbeRunner, + }); + assert.match( + dependencyErrors.join('\n'), + new RegExp( + `must not declare frame-copy dependency ${forbiddenDependency}` + ) + ); + } } finally { fs.rmSync(fixture.root, { recursive: true, force: true }); }