From 33573ceee40ce7bedb05b9d0a1188b0c4303386b Mon Sep 17 00:00:00 2001 From: 4gray Date: Sat, 18 Jul 2026 12:16:31 +0200 Subject: [PATCH] feat(packaging): ship Linux frame-copy runtimes --- .github/workflows/build-and-make.yaml | 60 ++- AGENTS.md | 45 +- CLAUDE.md | 41 +- apps/electron-backend/native/binding.gyp | 2 +- .../native/helper/frame_helper_gl.h | 2 +- ...edded-mpv-frame-copy-platform.util.spec.ts | 23 + .../embedded-mpv-frame-copy-platform.util.ts | 2 +- .../embedded-mpv-frame-copy-runtime.ts | 2 + .../contracts.ts | 4 +- .../flatpak-runtime.spec.ts | 148 +++++++ .../helper-environment.spec.ts | 291 +++++++++++- .../helper-environment.ts | 258 +++++++++-- .../helper-launch.spec.ts | 203 +++++++++ .../helper-launch.ts | 103 +++++ .../probe-orchestration.spec.ts | 217 ++++++++- .../embedded-mpv-frame-copy-runtime/probe.ts | 35 +- .../runtime-fixtures.test-helpers.ts | 4 +- .../trusted-snap-root.ts | 51 +++ .../embedded-mpv-frame-copy-runtime/types.ts | 1 + .../embedded-mpv-frame-copy.adapter.spec.ts | 250 ++++++++++- .../embedded-mpv-frame-copy.adapter.ts | 79 ++-- .../embedded-mpv-native-source.spec.ts | 16 +- .../embedded-mpv-runtime-diagnostic.spec.ts | 110 +++++ .../embedded-mpv-runtime-diagnostic.ts | 36 ++ apps/electron-backend/src/main.ts | 37 +- docs/architecture/embedded-mpv-native.md | 143 ++++-- ...mbedded-mpv-frame-copy-packaging-design.md | 60 ++- electron-builder.json | 16 + tools/embedded-mpv/README.md | 86 +++- tools/packaging/asar-dependency-closure.mjs | 29 +- .../asar-dependency-closure.test.mjs | 29 ++ .../configure-linux-frame-copy-build.test.mjs | 148 ++++++- tools/packaging/electron-after-pack.cjs | 36 ++ .../electron-package-identity.test.mjs | 13 + tools/packaging/embedded-mpv-arch.test.mjs | 67 ++- tools/packaging/linux-frame-copy-profile.cjs | 4 +- .../linux-frame-copy-profile.test.mjs | 4 +- .../verify-electron-package-layout.mjs | 35 +- .../verify-linux-frame-copy-runtime.mjs | 417 +++++++++++++++++- .../verify-linux-frame-copy-runtime.test.mjs | 416 ++++++++++++++--- vendor/embedded-mpv/README.md | 7 + 41 files changed, 3213 insertions(+), 317 deletions(-) create mode 100644 apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/flatpak-runtime.spec.ts create mode 100644 apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/helper-launch.spec.ts create mode 100644 apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/helper-launch.ts create mode 100644 apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/trusted-snap-root.ts create mode 100644 apps/electron-backend/src/app/services/embedded-mpv-runtime-diagnostic.spec.ts create mode 100644 apps/electron-backend/src/app/services/embedded-mpv-runtime-diagnostic.ts diff --git a/.github/workflows/build-and-make.yaml b/.github/workflows/build-and-make.yaml index 146fbe4b7..defeeeaef 100644 --- a/.github/workflows/build-and-make.yaml +++ b/.github/workflows/build-and-make.yaml @@ -339,7 +339,6 @@ jobs: libegl-dev \ libgbm-dev \ libgl-dev \ - libopengl-dev \ libx11-dev \ libxext-dev \ mpv \ @@ -872,7 +871,7 @@ jobs: bash -euo pipefail -c ' apt-get update DEBIAN_FRONTEND=noninteractive apt-get install --no-install-recommends -y \ - binutils libegl1 libgbm1 libgl1-mesa-dri libmpv2 libopengl0 \ + binutils libegl1 libgbm1 libgl1 libgl1-mesa-dri libmpv2 \ nodejs squashfs-tools xauth xvfb xvfb-run -a env LIBGL_ALWAYS_SOFTWARE=1 \ node tools/packaging/verify-linux-frame-copy-runtime.mjs \ @@ -906,7 +905,7 @@ jobs: fedora:latest \ bash -euo pipefail -c ' dnf install -y \ - binutils bsdtar libglvnd-egl libglvnd-opengl mesa-dri-drivers \ + binutils bsdtar libglvnd-egl libglvnd-glx mesa-dri-drivers \ mesa-libgbm mpv-libs nodejs rpm xorg-x11-server-Xvfb \ xorg-x11-xauth xvfb-run -a env LIBGL_ALWAYS_SOFTWARE=1 \ @@ -970,19 +969,53 @@ jobs: 2>&1 | tee /dev/stderr )" if printf '%s\n' "${verification}" | grep -Fq 'Verified snap x64 Linux'; then + snap list mesa-core22 >/dev/null 2>&1 || sudo snap install mesa-core22 + snap list gnome-3-28-1804 >/dev/null 2>&1 || sudo snap install gnome-3-28-1804 sudo snap install --dangerous "${artifact}" installed_x64=true fi done test "${found}" = true test "${installed_x64}" = true - xvfb-run -a env LIBGL_ALWAYS_SOFTWARE=1 \ - snap run --shell iptvnator -c ' - set -euo pipefail - helper="$(find "${SNAP}" -type f -path "*/app.asar.unpacked/electron-backend/native/iptvnator_mpv_helper" -print -quit)" - test -n "${helper}" - "${helper}" --runtime-probe - ' + sudo snap connect iptvnator:graphics-core22 mesa-core22:graphics-core22 + sudo snap connect iptvnator:gnome-3-28-1804 gnome-3-28-1804:gnome-3-28-1804 + sudo snap disconnect iptvnator:graphics-core22 mesa-core22:graphics-core22 + snap connections iptvnator | awk \ + '$2 == "iptvnator:graphics-core22" && $3 == "-" { found=1 } END { exit !found }' + set +e + disconnected_probe="$( + xvfb-run -a env LIBGL_ALWAYS_SOFTWARE=1 \ + snap run iptvnator --embedded-mpv-runtime-probe 2>&1 + )" + disconnected_status=$? + set -e + printf '%s\n' "${disconnected_probe}" + test "${disconnected_status}" -eq 1 + printf '%s\n' "${disconnected_probe}" | \ + grep -Fx '{"usable":false,"reason":"snap-graphics-provider-unavailable"}' + sudo snap connect iptvnator:graphics-core22 mesa-core22:graphics-core22 + snap connections iptvnator | awk \ + '$2 == "iptvnator:graphics-core22" && $3 == "mesa-core22:graphics-core22" { found=1 } END { exit !found }' + snap connections iptvnator | awk \ + '$2 == "iptvnator:gnome-3-28-1804" && $3 == "gnome-3-28-1804:gnome-3-28-1804" { found=1 } END { exit !found }' + xvfb-run -a env \ + LIBGL_ALWAYS_SOFTWARE=1 \ + __EGL_VENDOR_LIBRARY_FILENAMES=/tmp/hostile-egl-vendor.json \ + GBM_BACKEND=/tmp/hostile-gbm \ + MESA_LOADER_DRIVER_OVERRIDE=/tmp/hostile-dri \ + LIBVA_DRIVER_NAME=/tmp/hostile-va \ + VDPAU_DRIVER_PATH=/tmp/hostile-vdpau \ + VK_DRIVER_FILES=/tmp/hostile-vulkan-driver.json \ + VK_ICD_FILENAMES=/tmp/hostile-vulkan-icd.json \ + VK_ADD_DRIVER_FILES=/tmp/hostile-vulkan-add-driver.json \ + VK_ADD_LAYER_PATH=/tmp/hostile-vulkan-layers \ + VK_IMPLICIT_LAYER_PATH=/tmp/hostile-vulkan-implicit-layers \ + VK_ADD_IMPLICIT_LAYER_PATH=/tmp/hostile-vulkan-add-implicit-layers \ + XDG_CONFIG_HOME=/tmp/hostile-xdg-config-home \ + XDG_CONFIG_DIRS=/tmp/hostile-xdg-config-dirs \ + XDG_DATA_HOME=/tmp/hostile-xdg-data-home \ + XDG_DATA_DIRS=/tmp/hostile-xdg-data-dirs \ + snap run iptvnator --embedded-mpv-runtime-probe - name: Run packaged x64 frame-copy and fallback smoke if: matrix.os == 'linux' && matrix.linux_profile == 'portable' @@ -1053,10 +1086,11 @@ jobs: test -f "${LAUNCHER_PATH}.bin" grep -q '\''readlink -f "$SCRIPT_PATH"'\'' "${LAUNCHER_PATH}" grep -q '\''exec "$SCRIPT_DIR/iptvnator.bin"'\'' "${LAUNCHER_PATH}" - HELPER_PATH="$(find /app -type f -path '\''*/app.asar.unpacked/electron-backend/native/iptvnator_mpv_helper'\'' -print -quit)" - test -n "${HELPER_PATH}" - "${HELPER_PATH}" --runtime-probe ' + xvfb-run -a flatpak run \ + --env=LIBGL_ALWAYS_SOFTWARE=1 \ + com.fourgray.iptvnator \ + --embedded-mpv-runtime-probe - name: Upload artifacts (macOS) if: matrix.os == 'macos' diff --git a/AGENTS.md b/AGENTS.md index c3c3d0f15..546d70669 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -224,8 +224,8 @@ Key files: overrides. - Packaging runs three isolated profiles: - `system`: DEB/RPM/Pacman, no private `native/lib`, with package - dependencies DEB=`libmpv2,libegl1,libopengl0,libgbm1`, - RPM=`mpv-libs,libglvnd-egl,libglvnd-opengl,mesa-libgbm`, and + dependencies DEB=`libmpv2,libegl1,libgl1,libgbm1`, + RPM=`mpv-libs,libglvnd-egl,libglvnd-glx,mesa-libgbm`, and Pacman=`mpv,libglvnd,mesa` - `portable`: AppImage/Snap with the pinned LGPL-compatible closure - `flatpak`: Flatpak with the same pinned closure @@ -236,6 +236,14 @@ Key files: Electron libraries, `embedded_mpv.node`, and `embedded_mpv_frame_reader.node` must not load or link it. Preserve this process-isolation contract in build, package, and smoke checks. +- `electron-backend/native{,/**/*}` is excluded from `app.asar`; `afterPack` + exclusively writes the profile-normalized unpacked native tree. Layout and + final-artifact checks must reject every archived + `/electron-backend/native/**` entry so system and marker-only packages cannot + hide stale x64 artifacts. +- Packaged addon, frame-reader, and helper discovery is package-owned + `app.asar.unpacked` only. Writable cwd/dist candidates are development-only + and must never satisfy packaged native-view support or the frame-copy gate. - Pristine afterPack/unpacked layouts scan Electron libraries recursively. Extracted Snap payloads exclude only the package-manager `lib/**` and `usr/lib/**` trees that Snap overlays into the same root; every other @@ -246,10 +254,35 @@ Key files: `--runtime-probe` must all succeed before frame-copy can relax the renderer sandbox. Any failure reports a stable reason and falls back to native-view without crashing; an environment flag never bypasses this gate. -- Snap uses an exact private `shared-memory` plug. The probe and playback - helper share one sanitized loader environment: ambient audit, preload, and - library paths are removed, the validated private closure wins, and trusted - Snap GL roots precede generic in-snap library roots. +- Snap is `core22`/strict and uses an exact private `shared-memory` plug plus + the `graphics-core22` content plug at an empty mode-0755 `$SNAP/graphics`, + with `mesa-core22` as default provider. It declares only the canonical + provider layouts: `/usr/share/libdrm` binds from + `$SNAP/graphics/libdrm`, and `/usr/share/drirc.d` symlinks to + `$SNAP/graphics/drirc.d`. The provider is external shared content, not part + of IPTVnator's package size, source archive, or notices. Installed-Snap CI + must prove controlled unavailable exit after disconnect, then reconnect and + prove success. The helper links `libGL.so.1` rather than `libOpenGL.so.0`. +- The probe and playback helper share one sanitized loader environment: + ambient audit, preload, library, graphics-driver, and shell-startup overrides + are removed; the validated private closure wins; trusted Snap GL, + `graphics-core22`, and exact GNOME-platform roots precede generic in-snap + roots. The extracted-artifact verifier removes the identical unsafe + loader/graphics/shell set before direct helper smoke while preserving + feature/debug selectors such as `LIBGL_ALWAYS_SOFTWARE`. Snap fixes the + wrapper `PATH`, removes exported `BASH_FUNC_*` functions, and launches + probe/playback through the regular executable + `$SNAP/graphics/bin/graphics-core22-provider-wrapper`; a missing or + disconnected provider returns `snap-graphics-provider-unavailable` before + helper spawn. The packaging-only `--embedded-mpv-runtime-probe` app switch + runs the complete cached manifest/hash/helper gate before BrowserWindow + startup and exits with one availability JSON line. Any loader failure remains + a stable native-view fallback, never a flag-enabled success. +- In the exact packaged Flatpak `/app` context, reconstruct only Freedesktop + Platform 24.08's immutable `__EGL_EXTERNAL_PLATFORM_CONFIG_DIRS`; its GL + extension loader path comes from the sandbox cache. Flatpak CI must invoke + the application-level `--embedded-mpv-runtime-probe`, not a direct helper + probe that bypasses capability detection. - Bundled Linux releases must publish the exact source archives/git records, checksums, licenses, flags, patches, build scripts, and the pinned hwdata `pnp.ids` input. Each bundled package carries diff --git a/CLAUDE.md b/CLAUDE.md index 962c35ae6..36cab5444 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -631,21 +631,46 @@ engine` (restart required) or Electron libraries recursively; extracted Snap payloads exclude only the package-manager `lib/**` and `usr/lib/**` trees overlaid into the same root. Every other directory remains recursive, and Electron-library symlinks still - fail closed. Official x64 packages use three separate profiles: + fail closed. `electron-backend/native{,/**/*}` is excluded from `app.asar`; + `afterPack` alone owns the profile-normalized unpacked native tree, and + package checks reject every archived `/electron-backend/native/**` entry. + Packaged addon, frame-reader, and helper discovery uses only package-owned + `app.asar.unpacked` paths; cwd/dist candidates remain development-only. + Official x64 packages use three separate 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 + EGL/GL/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 + 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`. 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 + back to native-view without crashing. Snap is `core22`/strict and uses an + exact private `shared-memory` plug plus the `graphics-core22` content plug at + a real empty mode-0755 `$SNAP/graphics`, with external `mesa-core22` as the + default provider. Its only provider-data layouts bind `/usr/share/libdrm` + from `$SNAP/graphics/libdrm` and symlink `/usr/share/drirc.d` to + `$SNAP/graphics/drirc.d`. Installed-Snap CI requires controlled unavailable + status after disconnect, then reconnects and requires success. The helper + links `libGL.so.1`, and probe/playback share a sanitized loader environment + in which ambient audit, preload, library, graphics-driver, and shell-startup + overrides are removed; the validated private closure plus trusted host GL, + graphics-content, and exact GNOME-platform roots have explicit precedence. + The extracted-artifact verifier removes the identical unsafe + loader/graphics/shell set before direct helper smoke while preserving + selectors such as `LIBGL_ALWAYS_SOFTWARE`. Snap fixes the wrapper `PATH`, + removes exported `BASH_FUNC_*` functions, and + launches probe/playback through the regular executable + `$SNAP/graphics/bin/graphics-core22-provider-wrapper`; a missing or + disconnected provider returns `snap-graphics-provider-unavailable` before + helper spawn. The packaging-only + `--embedded-mpv-runtime-probe` app switch runs the complete packaged gate + before BrowserWindow startup and emits one availability JSON line. The exact + packaged Flatpak `/app` context reconstructs only Freedesktop Platform + 24.08's immutable `__EGL_EXTERNAL_PLATFORM_CONFIG_DIRS`; its CI smoke invokes + that application-level probe instead of the helper directly. Bundled Linux packages carry hash-validated `embedded-mpv-notices.json`, `THIRD_PARTY_NOTICES.txt`, and `licenses/**`. CI caches the staged runtime plus immutable source inputs, never finished diff --git a/apps/electron-backend/native/binding.gyp b/apps/electron-backend/native/binding.gyp index 488b3fddd..fa8bd4b2c 100644 --- a/apps/electron-backend/native/binding.gyp +++ b/apps/electron-backend/native/binding.gyp @@ -165,7 +165,7 @@ "-L ({ app: mockElectronApp })); import { + getEmbeddedMpvAddonCandidatePaths, getFrameCopyRuntimeAvailability, isFrameCopyPlatformSupported, isFrameCopyRuntimeUsable, @@ -158,6 +159,28 @@ describe('embedded-mpv-frame-copy-platform.util', () => { expect(resolveFrameCopyHelperPath()).toBe(packagedHelper); }); + + it('limits packaged native-view addon discovery to package-owned paths', () => { + mockElectronApp.isPackaged = true; + const resourcesPath = path.join(tempDir, 'IPTVnator', 'Resources'); + Object.defineProperty(process, 'resourcesPath', { + configurable: true, + value: resourcesPath, + }); + mockElectronApp.getAppPath.mockReturnValue( + path.join(resourcesPath, 'app.asar') + ); + + expect(getEmbeddedMpvAddonCandidatePaths()).toEqual([ + path.join( + resourcesPath, + 'app.asar.unpacked', + 'electron-backend', + 'native', + 'embedded_mpv.node' + ), + ]); + }); }); describe('isFrameCopyRuntimeUsable', () => { diff --git a/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-platform.util.ts b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-platform.util.ts index 8c71e4dc3..f4844d0df 100644 --- a/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-platform.util.ts +++ b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-platform.util.ts @@ -82,7 +82,7 @@ export function getEmbeddedMpvAddonCandidatePaths(): string[] { return dedupeDefinedPaths( app.isPackaged - ? [...packagedAddonPaths, ...distAddonPaths, localBuildAddonPath] + ? packagedAddonPaths : [localBuildAddonPath, ...distAddonPaths, ...packagedAddonPaths] ); } diff --git a/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime.ts b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime.ts index d84826589..5adbf5d02 100644 --- a/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime.ts +++ b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime.ts @@ -1,4 +1,5 @@ export { createLinuxFrameCopyHelperEnvironment } from './embedded-mpv-frame-copy-runtime/helper-environment'; +export { createLinuxFrameCopyHelperLaunch } from './embedded-mpv-frame-copy-runtime/helper-launch'; export { createEmbeddedMpvFrameCopyRuntimeProbe, probeEmbeddedMpvFrameCopyRuntime, @@ -11,3 +12,4 @@ export type { EmbeddedMpvFrameCopyRuntimeMode, EmbeddedMpvFrameCopyRuntimeResult, } from './embedded-mpv-frame-copy-runtime/types'; +export type { LinuxFrameCopyHelperLaunchFileSystem } from './embedded-mpv-frame-copy-runtime/helper-launch'; 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 cd469336e..2058d9c1e 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 @@ -258,11 +258,11 @@ export const DEVELOPMENT_MANIFEST_FIELDS = [ ] as const; export const SYSTEM_PACKAGE_DEPENDENCIES = Object.freeze({ - deb: Object.freeze(['libmpv2', 'libegl1', 'libopengl0', 'libgbm1']), + deb: Object.freeze(['libmpv2', 'libegl1', 'libgl1', 'libgbm1']), rpm: Object.freeze([ 'mpv-libs', 'libglvnd-egl', - 'libglvnd-opengl', + 'libglvnd-glx', 'mesa-libgbm', ]), pacman: Object.freeze(['mpv', 'libglvnd', 'mesa']), diff --git a/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/flatpak-runtime.spec.ts b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/flatpak-runtime.spec.ts new file mode 100644 index 000000000..27b743f6e --- /dev/null +++ b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/flatpak-runtime.spec.ts @@ -0,0 +1,148 @@ +import { accessSync, lstatSync, readFileSync, readdirSync } from 'fs'; +import path from 'path'; +import { createLinuxFrameCopyHelperEnvironment } from '../embedded-mpv-frame-copy-runtime'; +import { createFixture } from './runtime-fixtures.test-helpers'; +import { + createRuntimeTestContext, + type RuntimeTestContext, +} from './runtime-harness.test-helpers'; + +const FLATPAK_NATIVE_DIR = + '/app/iptvnator/resources/app.asar.unpacked/electron-backend/native'; +const FREEDESKTOP_EGL_EXTERNAL_PLATFORM_CONFIG_DIRS = [ + '/etc/egl/egl_external_platform.d', + '/usr/lib/x86_64-linux-gnu/GL/egl/egl_external_platform.d', + '/usr/share/egl/egl_external_platform.d', +].join(':'); + +describe('Flatpak embedded MPV frame-copy runtime', () => { + it('reconstructs the immutable Freedesktop GL metadata inside the packaged app', () => { + expect( + createLinuxFrameCopyHelperEnvironment( + { + PATH: '/app/bin:/usr/bin', + FLATPAK_ID: 'com.fourgray.iptvnator', + __EGL_EXTERNAL_PLATFORM_CONFIG_DIRS: + '/tmp/hostile-egl-platform', + __EGL_EXTERNAL_PLATFORM_CONFIG_FILENAMES: + '/tmp/hostile-egl-platform.json', + GBM_BACKENDS_PATH: '/tmp/hostile-gbm', + LIBGL_DRIVERS_PATH: '/tmp/hostile-dri', + LIBVA_DRIVERS_PATH: '/tmp/hostile-va', + __EGL_VENDOR_LIBRARY_DIRS: '/tmp/hostile-egl-vendor', + VK_LAYER_PATH: '/tmp/hostile-vulkan', + }, + FLATPAK_NATIVE_DIR, + 'bundled' + ) + ).toEqual({ + PATH: '/app/bin:/usr/bin', + FLATPAK_ID: 'com.fourgray.iptvnator', + __EGL_EXTERNAL_PLATFORM_CONFIG_DIRS: + FREEDESKTOP_EGL_EXTERNAL_PLATFORM_CONFIG_DIRS, + LD_LIBRARY_PATH: path.join(FLATPAK_NATIVE_DIR, 'lib'), + }); + }); + + it.each([ + ['wrong app id', 'com.example.other', '/app/iptvnator/native'], + [ + 'helper outside /app', + 'com.fourgray.iptvnator', + '/opt/iptvnator/native', + ], + ])( + 'does not reconstruct Flatpak GL metadata for %s', + (_label, flatpakId, nativeDir) => { + expect( + createLinuxFrameCopyHelperEnvironment( + { + FLATPAK_ID: flatpakId, + __EGL_EXTERNAL_PLATFORM_CONFIG_DIRS: + '/tmp/hostile-egl-platform', + }, + nativeDir, + 'bundled' + ) + ).toEqual({ + FLATPAK_ID: flatpakId, + LD_LIBRARY_PATH: path.join(nativeDir, 'lib'), + }); + } + ); + + describe('packaged capability probe', () => { + let context: RuntimeTestContext; + + beforeEach(() => { + context = createRuntimeTestContext(); + }); + + afterEach(() => { + context.dispose(); + }); + + it('uses reconstructed Freedesktop GL metadata through the application gate', () => { + const fixture = createFixture(context.rootDir, 'flatpak'); + const virtualHelperPath = path.join( + FLATPAK_NATIVE_DIR, + 'iptvnator_mpv_helper' + ); + const translatePath = (candidatePath: string): string => { + if ( + candidatePath === FLATPAK_NATIVE_DIR || + candidatePath.startsWith(`${FLATPAK_NATIVE_DIR}${path.sep}`) + ) { + return path.join( + fixture.nativeDir, + path.relative(FLATPAK_NATIVE_DIR, candidatePath) + ); + } + return candidatePath; + }; + const probeRuntime = context.createProbe({ + env: { + PATH: '/app/bin:/usr/bin', + FLATPAK_ID: 'com.fourgray.iptvnator', + __EGL_EXTERNAL_PLATFORM_CONFIG_DIRS: + '/tmp/hostile-egl-platform', + __EGL_EXTERNAL_PLATFORM_CONFIG_FILENAMES: + '/tmp/hostile-egl-platform.json', + GBM_BACKENDS_PATH: '/tmp/hostile-gbm', + LIBGL_DRIVERS_PATH: '/tmp/hostile-dri', + }, + fileSystem: { + accessSync: (candidatePath, mode) => + accessSync(translatePath(candidatePath), mode), + lstatSync: (candidatePath) => + lstatSync(translatePath(candidatePath)), + readFileSync: (candidatePath) => + readFileSync(translatePath(candidatePath)), + readdirSync: (candidatePath) => + readdirSync(translatePath(candidatePath)), + }, + }); + + expect(probeRuntime(virtualHelperPath)).toEqual( + expect.objectContaining({ + usable: true, + profile: 'flatpak', + runtimeMode: 'bundled', + }) + ); + expect(context.spawnRuntimeProbe).toHaveBeenCalledWith( + virtualHelperPath, + ['--runtime-probe'], + expect.objectContaining({ + env: { + PATH: '/app/bin:/usr/bin', + FLATPAK_ID: 'com.fourgray.iptvnator', + __EGL_EXTERNAL_PLATFORM_CONFIG_DIRS: + FREEDESKTOP_EGL_EXTERNAL_PLATFORM_CONFIG_DIRS, + LD_LIBRARY_PATH: path.join(FLATPAK_NATIVE_DIR, 'lib'), + }, + }) + ); + }); + }); +}); diff --git a/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/helper-environment.spec.ts b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/helper-environment.spec.ts index a5abdf900..16da423e3 100644 --- a/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/helper-environment.spec.ts +++ b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/helper-environment.spec.ts @@ -1,6 +1,51 @@ import path from 'path'; import { createLinuxFrameCopyHelperEnvironment } from '../embedded-mpv-frame-copy-runtime'; +const HOSTILE_LOADER_ENVIRONMENT = { + BASH_ENV: '/tmp/hostile-bash-env', + ENV: '/tmp/hostile-shell-env', + BASHOPTS: 'extdebug', + SHELLOPTS: 'xtrace', + PS4: '$(/tmp/hostile-trace-hook)', + BASH_XTRACEFD: '9', + CDPATH: '/tmp/hostile-cdpath', + 'BASH_FUNC_dirname%%': '() { printf /tmp/hostile-provider-root; exit 0; }', + LD_AUDIT: '/tmp/audit.so', + LD_LIBRARY_PATH: '/tmp/hostile-libs', + LD_ORIGIN_PATH: '/tmp/hostile-origin', + LD_PRELOAD: '/tmp/inject.so', + __EGL_VENDOR_LIBRARY_FILENAMES: '/tmp/hostile-egl-vendor.json', + __EGL_VENDOR_LIBRARY_DIRS: '/tmp/hostile-egl-vendor-dir', + __EGL_EXTERNAL_PLATFORM_CONFIG_DIRS: '/tmp/hostile-egl-platform', + __EGL_EXTERNAL_PLATFORM_CONFIG_FILENAMES: '/tmp/hostile-egl-platform.json', + GBM_BACKEND: '../../../../../tmp/hostile-gbm', + GBM_BACKENDS_PATH: '/tmp/hostile-gbm-path', + LIBGL_DRIVERS_PATH: '/tmp/hostile-dri-path', + MESA_LOADER_DRIVER_OVERRIDE: '../../../../../tmp/hostile-dri', + LIBVA_DRIVER_NAME: '../../../../../tmp/hostile-va', + LIBVA_DRIVERS_PATH: '/tmp/hostile-va-path', + VDPAU_DRIVER_PATH: '/tmp/hostile-vdpau', + VK_DRIVER_FILES: '/tmp/hostile-vulkan-driver.json', + VK_ICD_FILENAMES: '/tmp/hostile-vulkan-icd.json', + VK_ADD_DRIVER_FILES: '/tmp/hostile-vulkan-add-driver.json', + VK_ADD_LAYER_PATH: '/tmp/hostile-vulkan-layers', + VK_IMPLICIT_LAYER_PATH: '/tmp/hostile-vulkan-implicit-layers', + VK_ADD_IMPLICIT_LAYER_PATH: '/tmp/hostile-vulkan-add-implicit-layers', + VK_LAYER_PATH: '/tmp/hostile-vulkan-layer-path', +} as const; + +const GRAPHICS_SELECTOR_ENVIRONMENT = { + LIBGL_ALWAYS_SOFTWARE: '1', + EGL_PLATFORM: 'x11', + DRI_PRIME: '1', + GALLIUM_DRIVER: 'llvmpipe', + VDPAU_DRIVER: 'mesa', + __GLX_VENDOR_LIBRARY_NAME: 'mesa', + __GLX_FORCE_VENDOR_LIBRARY_0: 'mesa', + VK_INSTANCE_LAYERS: 'VK_LAYER_MESA_overlay', + VK_LOADER_DRIVERS_SELECT: '*mesa*', +} as const; + describe('createLinuxFrameCopyHelperEnvironment', () => { it('removes ambient loader overrides for system packages', () => { expect( @@ -8,9 +53,7 @@ describe('createLinuxFrameCopyHelperEnvironment', () => { { PATH: '/usr/bin', HOME: '/home/user', - LD_AUDIT: '/tmp/audit.so', - LD_LIBRARY_PATH: '/tmp/hostile-libs', - LD_PRELOAD: '/tmp/inject.so', + ...HOSTILE_LOADER_ENVIRONMENT, }, '/opt/iptvnator/native', 'system' @@ -21,6 +64,16 @@ describe('createLinuxFrameCopyHelperEnvironment', () => { }); }); + it('preserves graphics feature and debug selectors', () => { + expect( + createLinuxFrameCopyHelperEnvironment( + GRAPHICS_SELECTOR_ENVIRONMENT, + '/opt/iptvnator/native', + 'system' + ) + ).toEqual(GRAPHICS_SELECTOR_ENVIRONMENT); + }); + it('keeps trusted Snap GL roots ahead of generic Snap libraries', () => { const snapRoot = '/snap/iptvnator/42'; const nativeDir = path.join( @@ -42,26 +95,176 @@ describe('createLinuxFrameCopyHelperEnvironment', () => { '/var/lib/snapd/lib/gl/nvidia', '/var/lib/snapd/lib/gl-evil', ].join(':'), - LD_AUDIT: '/tmp/audit.so', - LD_LIBRARY_PATH: '/tmp/hostile-libs', - LD_PRELOAD: '/tmp/inject.so', + SNAP_DESKTOP_ARCH_TRIPLET: 'hostile-linux-gnu', + SNAP_DESKTOP_RUNTIME: path.join(snapRoot, 'gnome-platform'), + GBM_BACKENDS_PATH: '/tmp/hostile-gbm', + LIBGL_DRIVERS_PATH: '/tmp/hostile-dri', + LIBVA_DRIVERS_PATH: '/tmp/hostile-va', + __EGL_EXTERNAL_PLATFORM_CONFIG_DIRS: + '/tmp/hostile-egl-platform', + __EGL_VENDOR_LIBRARY_DIRS: '/tmp/hostile-egl-vendor', + VK_LAYER_PATH: '/tmp/hostile-vulkan', + XDG_CONFIG_HOME: '/tmp/hostile-xdg-config-home', + XDG_CONFIG_DIRS: '/tmp/hostile-xdg-config-dirs', + XDG_DATA_HOME: '/tmp/hostile-xdg-data-home', + XDG_DATA_DIRS: '/tmp/hostile-xdg-data-dirs', + ...HOSTILE_LOADER_ENVIRONMENT, + ...GRAPHICS_SELECTOR_ENVIRONMENT, }, nativeDir, 'bundled' ) ).toEqual({ - PATH: '/snap/bin:/usr/bin', + PATH: '/usr/sbin:/usr/bin:/sbin:/bin', SNAP: snapRoot, SNAP_LIBRARY_PATH: [ '/var/lib/snapd/lib/gl', - '/tmp/hostile-gl', '/var/lib/snapd/lib/gl/nvidia', - '/var/lib/snapd/lib/gl-evil', + ].join(':'), + SNAP_ARCH: 'amd64', + SNAP_DESKTOP_ARCH_TRIPLET: 'x86_64-linux-gnu', + SNAP_DESKTOP_RUNTIME: path.join(snapRoot, 'gnome-platform'), + ...GRAPHICS_SELECTOR_ENVIRONMENT, + GBM_BACKENDS_PATH: [ + path.join( + snapRoot, + 'graphics', + 'usr', + 'lib', + 'x86_64-linux-gnu', + 'gbm' + ), + '/var/lib/snapd/lib/gl/gbm', + ].join(':'), + LIBGL_DRIVERS_PATH: path.join( + snapRoot, + 'graphics', + 'usr', + 'lib', + 'x86_64-linux-gnu', + 'dri' + ), + LIBVA_DRIVERS_PATH: path.join( + snapRoot, + 'graphics', + 'usr', + 'lib', + 'x86_64-linux-gnu', + 'dri' + ), + __EGL_EXTERNAL_PLATFORM_CONFIG_DIRS: path.join( + snapRoot, + 'graphics', + 'usr', + 'share', + 'egl', + 'egl_external_platform.d' + ), + __EGL_VENDOR_LIBRARY_DIRS: [ + '/var/lib/snapd/lib/glvnd/egl_vendor.d', + path.join( + snapRoot, + 'graphics', + 'usr', + 'share', + 'glvnd', + 'egl_vendor.d' + ), + ].join(':'), + VK_LAYER_PATH: [ + path.join( + snapRoot, + 'graphics', + 'usr', + 'share', + 'vulkan', + 'implicit_layer.d' + ), + path.join( + snapRoot, + 'graphics', + 'usr', + 'share', + 'vulkan', + 'explicit_layer.d' + ), + ].join(':'), + XDG_CONFIG_HOME: path.join(snapRoot, 'etc', 'xdg'), + XDG_CONFIG_DIRS: [ + path.join(snapRoot, 'etc', 'xdg'), + '/etc/xdg', + ].join(':'), + XDG_DATA_HOME: path.join(snapRoot, 'usr', 'share'), + XDG_DATA_DIRS: [ + path.join(snapRoot, 'graphics', 'usr', 'share'), + path.join(snapRoot, 'gnome-platform', 'usr', 'share'), + path.join(snapRoot, 'usr', 'share'), + '/usr/share', ].join(':'), LD_LIBRARY_PATH: [ path.join(nativeDir, 'lib'), '/var/lib/snapd/lib/gl', '/var/lib/snapd/lib/gl/nvidia', + path.join( + snapRoot, + 'graphics', + 'usr', + 'lib', + 'x86_64-linux-gnu' + ), + path.join( + snapRoot, + 'graphics', + 'usr', + 'lib', + 'x86_64-linux-gnu', + 'vdpau' + ), + path.join( + snapRoot, + 'gnome-platform', + 'lib', + 'x86_64-linux-gnu' + ), + path.join( + snapRoot, + 'gnome-platform', + 'usr', + 'lib', + 'x86_64-linux-gnu' + ), + path.join( + snapRoot, + 'gnome-platform', + 'usr', + 'lib', + 'x86_64-linux-gnu', + 'mesa' + ), + path.join( + snapRoot, + 'gnome-platform', + 'usr', + 'lib', + 'x86_64-linux-gnu', + 'mesa-egl' + ), + path.join( + snapRoot, + 'gnome-platform', + 'usr', + 'lib', + 'x86_64-linux-gnu', + 'dri' + ), + path.join( + snapRoot, + 'gnome-platform', + 'usr', + 'lib', + 'x86_64-linux-gnu', + 'pulseaudio' + ), path.join(snapRoot, 'lib'), path.join(snapRoot, 'usr', 'lib'), path.join(snapRoot, 'lib', 'x86_64-linux-gnu'), @@ -70,6 +273,76 @@ describe('createLinuxFrameCopyHelperEnvironment', () => { }); }); + it.each([ + '/tmp/gnome-platform', + '/snap/iptvnator/42/gnome-platform-evil', + 'gnome-platform', + ])( + 'ignores an untrusted Snap desktop runtime declaration: %s', + (declaredDesktopRuntime) => { + const snapRoot = '/snap/iptvnator/42'; + const nativeDir = path.join( + snapRoot, + 'resources', + 'app.asar.unpacked', + 'electron-backend', + 'native' + ); + + const helperEnvironment = createLinuxFrameCopyHelperEnvironment( + { + SNAP: snapRoot, + SNAP_DESKTOP_ARCH_TRIPLET: 'hostile-linux-gnu', + SNAP_DESKTOP_RUNTIME: declaredDesktopRuntime, + GBM_BACKENDS_PATH: '/tmp/hostile-gbm', + LIBGL_DRIVERS_PATH: '/tmp/hostile-dri', + LIBVA_DRIVERS_PATH: '/tmp/hostile-va', + __EGL_EXTERNAL_PLATFORM_CONFIG_DIRS: + '/tmp/hostile-egl-platform', + __EGL_VENDOR_LIBRARY_DIRS: '/tmp/hostile-egl-vendor', + VK_LAYER_PATH: '/tmp/hostile-vulkan', + LD_LIBRARY_PATH: '/tmp/hostile-libs', + }, + nativeDir, + 'bundled' + ); + + expect(helperEnvironment.LD_LIBRARY_PATH?.split(':')).toEqual([ + path.join(nativeDir, 'lib'), + path.join( + snapRoot, + 'graphics', + 'usr', + 'lib', + 'x86_64-linux-gnu' + ), + path.join( + snapRoot, + 'graphics', + 'usr', + 'lib', + 'x86_64-linux-gnu', + 'vdpau' + ), + path.join(snapRoot, 'lib'), + path.join(snapRoot, 'usr', 'lib'), + path.join(snapRoot, 'lib', 'x86_64-linux-gnu'), + path.join(snapRoot, 'usr', 'lib', 'x86_64-linux-gnu'), + ]); + expect(helperEnvironment.LD_LIBRARY_PATH).not.toContain( + declaredDesktopRuntime + ); + expect(helperEnvironment.LD_LIBRARY_PATH).not.toContain( + 'hostile-linux-gnu' + ); + expect(helperEnvironment.SNAP_DESKTOP_RUNTIME).toBeUndefined(); + expect(helperEnvironment.SNAP_DESKTOP_ARCH_TRIPLET).toBe( + 'x86_64-linux-gnu' + ); + expect(helperEnvironment.SNAP_ARCH).toBe('amd64'); + } + ); + it('does not trust Snap loader paths when nativeDir is outside the declared mount', () => { expect( createLinuxFrameCopyHelperEnvironment( diff --git a/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/helper-environment.ts b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/helper-environment.ts index 860efdd8b..fb9715748 100644 --- a/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/helper-environment.ts +++ b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/helper-environment.ts @@ -1,8 +1,51 @@ import path from 'path'; import type { EmbeddedMpvFrameCopyRuntimeMode } from './types'; +import { resolveTrustedSnapRoot } from './trusted-snap-root'; -const TRUSTED_SNAP_MOUNT_ROOTS = ['/snap', '/var/lib/snapd/snap'] as const; const TRUSTED_SNAP_GL_ROOT = '/var/lib/snapd/lib/gl'; +const TRUSTED_SNAP_EGL_VENDOR_ROOT = '/var/lib/snapd/lib/glvnd/egl_vendor.d'; +const SNAP_DESKTOP_RUNTIME_DIRECTORY = 'gnome-platform'; +const SNAP_GRAPHICS_RUNTIME_DIRECTORY = 'graphics'; +const SNAP_X64_LIBRARY_TRIPLET = 'x86_64-linux-gnu'; +const TRUSTED_SNAP_HELPER_PATH = '/usr/sbin:/usr/bin:/sbin:/bin'; +const TRUSTED_FLATPAK_APP_ID = 'com.fourgray.iptvnator'; +const TRUSTED_FLATPAK_APP_ROOT = '/app'; +const TRUSTED_FLATPAK_EGL_EXTERNAL_PLATFORM_CONFIG_DIRS = [ + '/etc/egl/egl_external_platform.d', + '/usr/lib/x86_64-linux-gnu/GL/egl/egl_external_platform.d', + '/usr/share/egl/egl_external_platform.d', +].join(':'); +const UNSAFE_HELPER_ENVIRONMENT_VARIABLES = [ + 'BASH_ENV', + 'ENV', + 'BASHOPTS', + 'SHELLOPTS', + 'PS4', + 'BASH_XTRACEFD', + 'CDPATH', + 'LD_AUDIT', + 'LD_LIBRARY_PATH', + 'LD_ORIGIN_PATH', + 'LD_PRELOAD', + '__EGL_EXTERNAL_PLATFORM_CONFIG_DIRS', + '__EGL_EXTERNAL_PLATFORM_CONFIG_FILENAMES', + '__EGL_VENDOR_LIBRARY_DIRS', + '__EGL_VENDOR_LIBRARY_FILENAMES', + 'GBM_BACKEND', + 'GBM_BACKENDS_PATH', + 'LIBGL_DRIVERS_PATH', + 'MESA_LOADER_DRIVER_OVERRIDE', + 'LIBVA_DRIVER_NAME', + 'LIBVA_DRIVERS_PATH', + 'VDPAU_DRIVER_PATH', + 'VK_DRIVER_FILES', + 'VK_ICD_FILENAMES', + 'VK_ADD_DRIVER_FILES', + 'VK_ADD_LAYER_PATH', + 'VK_IMPLICIT_LAYER_PATH', + 'VK_ADD_IMPLICIT_LAYER_PATH', + 'VK_LAYER_PATH', +] as const; function isPathInside( parentPath: string, @@ -20,43 +63,39 @@ function isPathInside( ); } -function resolveTrustedSnapRoot( - environment: NodeJS.ProcessEnv, - nativeDir: string -): string | null { - const declaredSnapRoot = environment.SNAP; - if ( - !declaredSnapRoot || - !path.isAbsolute(declaredSnapRoot) || - !path.isAbsolute(nativeDir) - ) { - return null; - } - - const normalizedSnapRoot = path.resolve(declaredSnapRoot); - const resemblesReadOnlySnapMount = TRUSTED_SNAP_MOUNT_ROOTS.some( - (mountRoot) => { - const relativePath = path.relative(mountRoot, normalizedSnapRoot); - return ( - isPathInside(mountRoot, normalizedSnapRoot, false) && - relativePath.split(path.sep).filter(Boolean).length >= 2 - ); - } - ); - if ( - !resemblesReadOnlySnapMount || - !isPathInside(normalizedSnapRoot, path.resolve(nativeDir), false) - ) { - return null; - } - return normalizedSnapRoot; -} - function getTrustedSnapLibraryPaths( environment: NodeJS.ProcessEnv, snapRoot: string ): string[] { - const snapLibraryPaths = (environment.SNAP_LIBRARY_PATH ?? '') + const snapLibraryPaths = getTrustedSnapHostGlLibraryPaths(environment); + const graphicsLibraryRoot = path.join( + snapRoot, + SNAP_GRAPHICS_RUNTIME_DIRECTORY, + 'usr', + 'lib', + SNAP_X64_LIBRARY_TRIPLET + ); + const desktopLibraryPaths = getTrustedSnapDesktopLibraryPaths( + environment, + snapRoot + ); + + return [ + ...snapLibraryPaths, + graphicsLibraryRoot, + path.join(graphicsLibraryRoot, 'vdpau'), + ...desktopLibraryPaths, + path.join(snapRoot, 'lib'), + path.join(snapRoot, 'usr', 'lib'), + path.join(snapRoot, 'lib', SNAP_X64_LIBRARY_TRIPLET), + path.join(snapRoot, 'usr', 'lib', SNAP_X64_LIBRARY_TRIPLET), + ]; +} + +function getTrustedSnapHostGlLibraryPaths( + environment: NodeJS.ProcessEnv +): string[] { + return (environment.SNAP_LIBRARY_PATH ?? '') .split(':') .filter(Boolean) .filter((libraryPath) => path.isAbsolute(libraryPath)) @@ -64,21 +103,140 @@ function getTrustedSnapLibraryPaths( .filter((libraryPath) => isPathInside(TRUSTED_SNAP_GL_ROOT, libraryPath, true) ); +} +function getTrustedSnapDesktopLibraryPaths( + environment: NodeJS.ProcessEnv, + snapRoot: string +): string[] { + const desktopRuntime = resolveTrustedSnapDesktopRuntime( + environment, + snapRoot + ); + if (!desktopRuntime) { + return []; + } + + const desktopLibraryRoot = path.join( + desktopRuntime, + 'usr', + 'lib', + SNAP_X64_LIBRARY_TRIPLET + ); return [ - ...snapLibraryPaths, - path.join(snapRoot, 'lib'), - path.join(snapRoot, 'usr', 'lib'), - path.join(snapRoot, 'lib', 'x86_64-linux-gnu'), - path.join(snapRoot, 'usr', 'lib', 'x86_64-linux-gnu'), + path.join(desktopRuntime, 'lib', SNAP_X64_LIBRARY_TRIPLET), + desktopLibraryRoot, + path.join(desktopLibraryRoot, 'mesa'), + path.join(desktopLibraryRoot, 'mesa-egl'), + path.join(desktopLibraryRoot, 'dri'), + path.join(desktopLibraryRoot, 'pulseaudio'), ]; } +function resolveTrustedSnapDesktopRuntime( + environment: NodeJS.ProcessEnv, + snapRoot: string +): string | null { + const expectedDesktopRuntime = path.join( + snapRoot, + SNAP_DESKTOP_RUNTIME_DIRECTORY + ); + const declaredDesktopRuntime = environment.SNAP_DESKTOP_RUNTIME; + if ( + !declaredDesktopRuntime || + !path.isAbsolute(declaredDesktopRuntime) || + path.resolve(declaredDesktopRuntime) !== expectedDesktopRuntime + ) { + return null; + } + return expectedDesktopRuntime; +} + +function isTrustedFlatpakRuntime( + environment: NodeJS.ProcessEnv, + nativeDir: string +): boolean { + return ( + environment.FLATPAK_ID === TRUSTED_FLATPAK_APP_ID && + path.isAbsolute(nativeDir) && + isPathInside(TRUSTED_FLATPAK_APP_ROOT, path.resolve(nativeDir), false) + ); +} + +function applyTrustedSnapGraphicsEnvironment( + helperEnvironment: NodeJS.ProcessEnv, + sourceEnvironment: NodeJS.ProcessEnv, + snapRoot: string +): void { + const graphicsRuntime = path.join( + snapRoot, + SNAP_GRAPHICS_RUNTIME_DIRECTORY, + 'usr' + ); + const graphicsLibraryRoot = path.join( + graphicsRuntime, + 'lib', + SNAP_X64_LIBRARY_TRIPLET + ); + const graphicsDriRoot = path.join(graphicsLibraryRoot, 'dri'); + + helperEnvironment.GBM_BACKENDS_PATH = [ + path.join(graphicsLibraryRoot, 'gbm'), + path.join(TRUSTED_SNAP_GL_ROOT, 'gbm'), + ].join(':'); + helperEnvironment.LIBGL_DRIVERS_PATH = graphicsDriRoot; + helperEnvironment.LIBVA_DRIVERS_PATH = graphicsDriRoot; + helperEnvironment.__EGL_EXTERNAL_PLATFORM_CONFIG_DIRS = path.join( + graphicsRuntime, + 'share', + 'egl', + 'egl_external_platform.d' + ); + helperEnvironment.__EGL_VENDOR_LIBRARY_DIRS = [ + TRUSTED_SNAP_EGL_VENDOR_ROOT, + path.join(graphicsRuntime, 'share', 'glvnd', 'egl_vendor.d'), + ].join(':'); + helperEnvironment.VK_LAYER_PATH = [ + path.join(graphicsRuntime, 'share', 'vulkan', 'implicit_layer.d'), + path.join(graphicsRuntime, 'share', 'vulkan', 'explicit_layer.d'), + ].join(':'); + + const snapConfigRoot = path.join(snapRoot, 'etc', 'xdg'); + const snapDataRoot = path.join(snapRoot, 'usr', 'share'); + const desktopRuntime = resolveTrustedSnapDesktopRuntime( + sourceEnvironment, + snapRoot + ); + const snapLibraryPaths = + getTrustedSnapHostGlLibraryPaths(sourceEnvironment); + if (snapLibraryPaths.length > 0) { + helperEnvironment.SNAP_LIBRARY_PATH = snapLibraryPaths.join(':'); + } else { + delete helperEnvironment.SNAP_LIBRARY_PATH; + } + helperEnvironment.SNAP_ARCH = 'amd64'; + helperEnvironment.SNAP_DESKTOP_ARCH_TRIPLET = SNAP_X64_LIBRARY_TRIPLET; + if (desktopRuntime) { + helperEnvironment.SNAP_DESKTOP_RUNTIME = desktopRuntime; + } else { + delete helperEnvironment.SNAP_DESKTOP_RUNTIME; + } + helperEnvironment.XDG_CONFIG_HOME = snapConfigRoot; + helperEnvironment.XDG_CONFIG_DIRS = [snapConfigRoot, '/etc/xdg'].join(':'); + helperEnvironment.XDG_DATA_HOME = snapDataRoot; + helperEnvironment.XDG_DATA_DIRS = [ + path.join(graphicsRuntime, 'share'), + ...(desktopRuntime ? [path.join(desktopRuntime, 'usr', 'share')] : []), + snapDataRoot, + '/usr/share', + ].join(':'); +} + /** * Builds the loader environment shared by the bounded startup probe and each * real Linux helper session. The validated package profile is authoritative: * system packages use the system loader, while bundled packages start at - * native/lib and may add only immutable-looking Snap runtime/GL roots. + * native/lib and add only fixed trusted Snap or Flatpak graphics roots. */ export function createLinuxFrameCopyHelperEnvironment( environment: NodeJS.ProcessEnv, @@ -86,9 +244,14 @@ export function createLinuxFrameCopyHelperEnvironment( runtimeMode: EmbeddedMpvFrameCopyRuntimeMode ): NodeJS.ProcessEnv { const helperEnvironment = { ...environment }; - delete helperEnvironment.LD_AUDIT; - delete helperEnvironment.LD_LIBRARY_PATH; - delete helperEnvironment.LD_PRELOAD; + for (const variableName of UNSAFE_HELPER_ENVIRONMENT_VARIABLES) { + delete helperEnvironment[variableName]; + } + for (const variableName of Object.keys(helperEnvironment)) { + if (variableName.startsWith('BASH_FUNC_')) { + delete helperEnvironment[variableName]; + } + } if (runtimeMode === 'system') { return helperEnvironment; @@ -97,9 +260,18 @@ export function createLinuxFrameCopyHelperEnvironment( const libraryPaths = [path.join(nativeDir, 'lib')]; const trustedSnapRoot = resolveTrustedSnapRoot(environment, nativeDir); if (trustedSnapRoot) { + helperEnvironment.PATH = TRUSTED_SNAP_HELPER_PATH; libraryPaths.push( ...getTrustedSnapLibraryPaths(environment, trustedSnapRoot) ); + applyTrustedSnapGraphicsEnvironment( + helperEnvironment, + environment, + trustedSnapRoot + ); + } else if (isTrustedFlatpakRuntime(environment, nativeDir)) { + helperEnvironment.__EGL_EXTERNAL_PLATFORM_CONFIG_DIRS = + TRUSTED_FLATPAK_EGL_EXTERNAL_PLATFORM_CONFIG_DIRS; } helperEnvironment.LD_LIBRARY_PATH = [...new Set(libraryPaths)].join(':'); return helperEnvironment; diff --git a/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/helper-launch.spec.ts b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/helper-launch.spec.ts new file mode 100644 index 000000000..ffded9066 --- /dev/null +++ b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/helper-launch.spec.ts @@ -0,0 +1,203 @@ +import type { Stats } from 'fs'; +import path from 'path'; +import { createLinuxFrameCopyHelperLaunch } from '../embedded-mpv-frame-copy-runtime'; + +function fakeStat( + kind: 'directory' | 'file' | 'symlink' +): Pick { + return { + isDirectory: () => kind === 'directory', + isFile: () => kind === 'file', + isSymbolicLink: () => kind === 'symlink', + }; +} + +describe('createLinuxFrameCopyHelperLaunch', () => { + const helperArgs = ['--runtime-probe']; + + it.each([ + ['system', '/opt/iptvnator/native/iptvnator_mpv_helper'], + [ + 'bundled', + '/tmp/.mount-IPTVnator/resources/app.asar.unpacked/electron-backend/native/iptvnator_mpv_helper', + ], + ] as const)( + 'launches a non-Snap %s helper directly', + (runtimeMode, helperPath) => { + expect( + createLinuxFrameCopyHelperLaunch({ + environment: { + PATH: '/usr/bin', + LD_LIBRARY_PATH: '/tmp/hostile', + }, + helperPath, + helperArgs, + runtimeMode, + }) + ).toEqual({ + usable: true, + command: helperPath, + args: helperArgs, + env: + runtimeMode === 'system' + ? { PATH: '/usr/bin' } + : { + PATH: '/usr/bin', + LD_LIBRARY_PATH: path.join( + path.dirname(helperPath), + 'lib' + ), + }, + }); + } + ); + + it('launches a trusted Snap helper through the connected provider wrapper', () => { + const snapRoot = '/snap/iptvnator/42'; + const nativeDir = path.join( + snapRoot, + 'resources', + 'app.asar.unpacked', + 'electron-backend', + 'native' + ); + const helperPath = path.join(nativeDir, 'iptvnator_mpv_helper'); + const graphicsRoot = path.join(snapRoot, 'graphics'); + const wrapperPath = path.join( + graphicsRoot, + 'bin', + 'graphics-core22-provider-wrapper' + ); + const lstatSync = jest.fn((candidatePath: string) => { + if (candidatePath === graphicsRoot) { + return fakeStat('directory') as Stats; + } + if (candidatePath === wrapperPath) { + return fakeStat('file') as Stats; + } + throw Object.assign(new Error('missing'), { code: 'ENOENT' }); + }); + const accessSync = jest.fn(); + + expect( + createLinuxFrameCopyHelperLaunch({ + environment: { + PATH: '/snap/bin:/usr/bin', + SNAP: snapRoot, + }, + helperPath, + helperArgs, + runtimeMode: 'bundled', + fileSystem: { lstatSync, accessSync }, + }) + ).toEqual({ + usable: true, + command: wrapperPath, + args: [helperPath, ...helperArgs], + env: expect.objectContaining({ + PATH: '/usr/sbin:/usr/bin:/sbin:/bin', + SNAP: snapRoot, + LD_LIBRARY_PATH: expect.stringContaining( + path.join(nativeDir, 'lib') + ), + }), + }); + expect(lstatSync).toHaveBeenCalledWith(graphicsRoot); + expect(lstatSync).toHaveBeenCalledWith(wrapperPath); + expect(accessSync).toHaveBeenCalledWith( + wrapperPath, + expect.any(Number) + ); + }); + + it.each([ + ['missing mount', 'missing', 'file'], + ['symlink mount', 'symlink', 'file'], + ['missing wrapper', 'directory', 'missing'], + ['symlink wrapper', 'directory', 'symlink'], + ] as const)( + 'fails closed for a trusted Snap with a %s', + (_label, mountKind, wrapperKind) => { + const snapRoot = '/snap/iptvnator/42'; + const nativeDir = path.join( + snapRoot, + 'resources', + 'app.asar.unpacked', + 'electron-backend', + 'native' + ); + const helperPath = path.join(nativeDir, 'iptvnator_mpv_helper'); + const graphicsRoot = path.join(snapRoot, 'graphics'); + const wrapperPath = path.join( + graphicsRoot, + 'bin', + 'graphics-core22-provider-wrapper' + ); + + expect( + createLinuxFrameCopyHelperLaunch({ + environment: { SNAP: snapRoot }, + helperPath, + helperArgs, + runtimeMode: 'bundled', + fileSystem: { + lstatSync: (candidatePath) => { + const kind = + candidatePath === graphicsRoot + ? mountKind + : wrapperKind; + if (kind === 'missing') { + throw Object.assign(new Error('missing'), { + code: 'ENOENT', + }); + } + return fakeStat(kind) as Stats; + }, + accessSync: () => undefined, + }, + }) + ).toEqual({ + usable: false, + reason: 'snap-graphics-provider-unavailable', + }); + + expect(wrapperPath).toContain('/graphics/bin/'); + } + ); + + it('fails closed when the provider wrapper is not executable', () => { + const snapRoot = '/var/lib/snapd/snap/iptvnator/42'; + const nativeDir = path.join( + snapRoot, + 'resources', + 'app.asar.unpacked', + 'electron-backend', + 'native' + ); + + expect( + createLinuxFrameCopyHelperLaunch({ + environment: { SNAP: snapRoot }, + helperPath: path.join(nativeDir, 'iptvnator_mpv_helper'), + helperArgs, + runtimeMode: 'bundled', + fileSystem: { + lstatSync: (candidatePath) => + fakeStat( + candidatePath.endsWith('/graphics') + ? 'directory' + : 'file' + ) as Stats, + accessSync: () => { + throw Object.assign(new Error('denied'), { + code: 'EACCES', + }); + }, + }, + }) + ).toEqual({ + usable: false, + reason: 'snap-graphics-provider-unavailable', + }); + }); +}); diff --git a/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/helper-launch.ts b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/helper-launch.ts new file mode 100644 index 000000000..2e8cf8c60 --- /dev/null +++ b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/helper-launch.ts @@ -0,0 +1,103 @@ +import { + accessSync as nodeAccessSync, + constants as fileSystemConstants, + lstatSync as nodeLstatSync, +} from 'fs'; +import type * as nodeFileSystem from 'fs'; +import path from 'path'; +import { createLinuxFrameCopyHelperEnvironment } from './helper-environment'; +import { resolveTrustedSnapRoot } from './trusted-snap-root'; +import type { + EmbeddedMpvFrameCopyRuntimeFailureReason, + EmbeddedMpvFrameCopyRuntimeMode, +} from './types'; + +export interface LinuxFrameCopyHelperLaunchFileSystem { + lstatSync(filePath: string): nodeFileSystem.Stats; + accessSync(filePath: string, mode: number): void; +} + +interface CreateLinuxFrameCopyHelperLaunchOptions { + environment: NodeJS.ProcessEnv; + helperPath: string; + helperArgs: string[]; + runtimeMode: EmbeddedMpvFrameCopyRuntimeMode; + fileSystem?: LinuxFrameCopyHelperLaunchFileSystem; +} + +export type LinuxFrameCopyHelperLaunch = + | { + usable: true; + command: string; + args: string[]; + env: NodeJS.ProcessEnv; + } + | { + usable: false; + reason: EmbeddedMpvFrameCopyRuntimeFailureReason; + }; + +export function createLinuxFrameCopyHelperLaunch( + options: CreateLinuxFrameCopyHelperLaunchOptions +): LinuxFrameCopyHelperLaunch { + const nativeDir = path.dirname(options.helperPath); + const env = createLinuxFrameCopyHelperEnvironment( + options.environment, + nativeDir, + options.runtimeMode + ); + const trustedSnapRoot = + options.runtimeMode === 'bundled' + ? resolveTrustedSnapRoot(options.environment, nativeDir) + : null; + if (!trustedSnapRoot) { + return { + usable: true, + command: options.helperPath, + args: options.helperArgs, + env, + }; + } + + const graphicsRoot = path.join(trustedSnapRoot, 'graphics'); + const providerWrapperPath = path.join( + graphicsRoot, + 'bin', + 'graphics-core22-provider-wrapper' + ); + const fileSystem = options.fileSystem ?? { + lstatSync: nodeLstatSync, + accessSync: nodeAccessSync, + }; + try { + const graphicsRootStat = fileSystem.lstatSync(graphicsRoot); + const providerWrapperStat = fileSystem.lstatSync(providerWrapperPath); + if ( + !graphicsRootStat.isDirectory() || + graphicsRootStat.isSymbolicLink() || + !providerWrapperStat.isFile() || + providerWrapperStat.isSymbolicLink() + ) { + return { + usable: false, + reason: 'snap-graphics-provider-unavailable', + }; + } + fileSystem.accessSync( + providerWrapperPath, + fileSystemConstants.R_OK | fileSystemConstants.X_OK + ); + } catch { + return { + usable: false, + reason: 'snap-graphics-provider-unavailable', + }; + } + + return { + usable: true, + command: providerWrapperPath, + args: [options.helperPath, ...options.helperArgs], + env, + }; +} diff --git a/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/probe-orchestration.spec.ts b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/probe-orchestration.spec.ts index 3cc19e611..a9ecad0d3 100644 --- a/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/probe-orchestration.spec.ts +++ b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/probe-orchestration.spec.ts @@ -1,4 +1,12 @@ -import { chmodSync, lstatSync, readFileSync, writeFileSync } from 'fs'; +import { + accessSync, + chmodSync, + lstatSync, + mkdirSync, + readFileSync, + readdirSync, + writeFileSync, +} from 'fs'; import path from 'path'; import { cloneManifest, @@ -23,7 +31,47 @@ describe('embedded-mpv frame-copy runtime probe orchestration', () => { it('validates a system package, sanitizes loader overrides, and caches by helper/manifest identity', () => { const fixture = createFixture(context.rootDir); - const probeRuntime = context.createProbe(); + const probeRuntime = context.createProbe({ + env: { + PATH: '/usr/bin', + LIBGL_ALWAYS_SOFTWARE: '1', + GALLIUM_DRIVER: 'llvmpipe', + BASH_ENV: '/tmp/hostile-bash-env', + ENV: '/tmp/hostile-shell-env', + BASHOPTS: 'extdebug', + SHELLOPTS: 'xtrace', + PS4: '$(/tmp/hostile-trace-hook)', + BASH_XTRACEFD: '9', + CDPATH: '/tmp/hostile-cdpath', + 'BASH_FUNC_dirname%%': + '() { printf /tmp/hostile-provider-root; exit 0; }', + LD_AUDIT: '/tmp/audit.so', + LD_LIBRARY_PATH: '/ambient/libs', + LD_ORIGIN_PATH: '/tmp/hostile-origin', + LD_PRELOAD: '/tmp/inject.so', + __EGL_VENDOR_LIBRARY_FILENAMES: '/tmp/hostile-egl-vendor.json', + __EGL_VENDOR_LIBRARY_DIRS: '/tmp/hostile-egl-vendor-dir', + __EGL_EXTERNAL_PLATFORM_CONFIG_DIRS: + '/tmp/hostile-egl-platform', + __EGL_EXTERNAL_PLATFORM_CONFIG_FILENAMES: + '/tmp/hostile-egl-platform.json', + GBM_BACKEND: '../../../../../tmp/hostile-gbm', + GBM_BACKENDS_PATH: '/tmp/hostile-gbm-path', + LIBGL_DRIVERS_PATH: '/tmp/hostile-dri-path', + MESA_LOADER_DRIVER_OVERRIDE: '../../../../../tmp/hostile-dri', + LIBVA_DRIVER_NAME: '../../../../../tmp/hostile-va', + LIBVA_DRIVERS_PATH: '/tmp/hostile-va-path', + VDPAU_DRIVER_PATH: '/tmp/hostile-vdpau', + VK_DRIVER_FILES: '/tmp/hostile-vulkan-driver.json', + VK_ICD_FILENAMES: '/tmp/hostile-vulkan-icd.json', + VK_ADD_DRIVER_FILES: '/tmp/hostile-vulkan-add-driver.json', + VK_ADD_LAYER_PATH: '/tmp/hostile-vulkan-layers', + VK_IMPLICIT_LAYER_PATH: '/tmp/hostile-vulkan-implicit-layers', + VK_ADD_IMPLICIT_LAYER_PATH: + '/tmp/hostile-vulkan-add-implicit-layers', + VK_LAYER_PATH: '/tmp/hostile-vulkan-layer-path', + }, + }); expect(probeRuntime(fixture.helperPath)).toEqual({ usable: true, @@ -44,6 +92,8 @@ describe('embedded-mpv frame-copy runtime probe orchestration', () => { windowsHide: true, env: { PATH: '/usr/bin', + LIBGL_ALWAYS_SOFTWARE: '1', + GALLIUM_DRIVER: 'llvmpipe', }, } ); @@ -105,6 +155,169 @@ describe('embedded-mpv frame-copy runtime probe orchestration', () => { } ); + it('runs a packaged Snap probe through the connected graphics provider wrapper', () => { + const actualSnapRoot = path.join(context.rootDir, 'snap-root'); + const actualFixtureRoot = path.join(actualSnapRoot, 'fixture'); + const fixture = createFixture(actualFixtureRoot, 'portable'); + const actualGraphicsRoot = path.join(actualSnapRoot, 'graphics'); + const actualProviderWrapper = path.join( + actualGraphicsRoot, + 'bin', + 'graphics-core22-provider-wrapper' + ); + mkdirSync(path.dirname(actualProviderWrapper), { recursive: true }); + writeFileSync(actualProviderWrapper, '#!/bin/sh\nexec "$@"\n', { + mode: 0o755, + }); + + const virtualSnapRoot = '/snap/iptvnator/42'; + const virtualNativeDir = path.join( + virtualSnapRoot, + 'resources', + 'app.asar.unpacked', + 'electron-backend', + 'native' + ); + const virtualHelperPath = path.join( + virtualNativeDir, + 'iptvnator_mpv_helper' + ); + const virtualGraphicsRoot = path.join(virtualSnapRoot, 'graphics'); + const virtualProviderWrapper = path.join( + virtualGraphicsRoot, + 'bin', + 'graphics-core22-provider-wrapper' + ); + const translatePath = (candidatePath: string): string => { + if ( + candidatePath === virtualNativeDir || + candidatePath.startsWith(`${virtualNativeDir}${path.sep}`) + ) { + return path.join( + fixture.nativeDir, + path.relative(virtualNativeDir, candidatePath) + ); + } + if ( + candidatePath === virtualGraphicsRoot || + candidatePath.startsWith(`${virtualGraphicsRoot}${path.sep}`) + ) { + return path.join( + actualGraphicsRoot, + path.relative(virtualGraphicsRoot, candidatePath) + ); + } + return candidatePath; + }; + const virtualFileSystem = { + accessSync: jest.fn((candidatePath: string, mode: number) => + accessSync(translatePath(candidatePath), mode) + ), + lstatSync: jest.fn((candidatePath: string) => + lstatSync(translatePath(candidatePath)) + ), + readFileSync: jest.fn((candidatePath: string) => + readFileSync(translatePath(candidatePath)) + ), + readdirSync: jest.fn((candidatePath: string) => + readdirSync(translatePath(candidatePath)) + ), + }; + const probeRuntime = context.createProbe({ + env: { + PATH: '/snap/bin:/usr/bin', + SNAP: virtualSnapRoot, + SNAP_DESKTOP_RUNTIME: path.join( + virtualSnapRoot, + 'gnome-platform' + ), + }, + fileSystem: virtualFileSystem, + }); + + expect(probeRuntime(virtualHelperPath)).toEqual( + expect.objectContaining({ + usable: true, + profile: 'portable', + runtimeMode: 'bundled', + }) + ); + expect(context.spawnRuntimeProbe).toHaveBeenCalledWith( + virtualProviderWrapper, + [virtualHelperPath, '--runtime-probe'], + expect.objectContaining({ + env: expect.objectContaining({ + SNAP: virtualSnapRoot, + LD_LIBRARY_PATH: expect.stringContaining( + path.join(virtualNativeDir, 'lib') + ), + }), + }) + ); + }); + + it('reports a stable unavailable reason when the Snap graphics provider is disconnected', () => { + const actualSnapRoot = path.join(context.rootDir, 'snap-root'); + const fixture = createFixture( + path.join(actualSnapRoot, 'fixture'), + 'portable' + ); + const actualGraphicsRoot = path.join(actualSnapRoot, 'graphics'); + mkdirSync(actualGraphicsRoot, { recursive: true }); + + const virtualSnapRoot = '/snap/iptvnator/42'; + const virtualNativeDir = path.join( + virtualSnapRoot, + 'resources', + 'app.asar.unpacked', + 'electron-backend', + 'native' + ); + const translatePath = (candidatePath: string): string => { + if ( + candidatePath === virtualNativeDir || + candidatePath.startsWith(`${virtualNativeDir}${path.sep}`) + ) { + return path.join( + fixture.nativeDir, + path.relative(virtualNativeDir, candidatePath) + ); + } + const virtualGraphicsRoot = path.join(virtualSnapRoot, 'graphics'); + if ( + candidatePath === virtualGraphicsRoot || + candidatePath.startsWith(`${virtualGraphicsRoot}${path.sep}`) + ) { + return path.join( + actualGraphicsRoot, + path.relative(virtualGraphicsRoot, candidatePath) + ); + } + return candidatePath; + }; + const probeRuntime = context.createProbe({ + env: { SNAP: virtualSnapRoot }, + fileSystem: { + accessSync: (candidatePath, mode) => + accessSync(translatePath(candidatePath), mode), + lstatSync: (candidatePath) => + lstatSync(translatePath(candidatePath)), + readFileSync: (candidatePath) => + readFileSync(translatePath(candidatePath)), + readdirSync: (candidatePath) => + readdirSync(translatePath(candidatePath)), + }, + }); + + expect( + probeRuntime(path.join(virtualNativeDir, 'iptvnator_mpv_helper')) + ).toEqual({ + usable: false, + reason: 'snap-graphics-provider-unavailable', + }); + expect(context.spawnRuntimeProbe).not.toHaveBeenCalled(); + }); + it('reprobes when the helper identity changes', () => { const fixture = createFixture(context.rootDir); const probeRuntime = context.createProbe(); diff --git a/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/probe.ts b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/probe.ts index 23b7c9a9a..0196369cd 100644 --- a/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/probe.ts +++ b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/probe.ts @@ -6,7 +6,7 @@ import { RUNTIME_PROBE_PROTOCOL, RUNTIME_PROBE_TIMEOUT_MS, } from './contracts'; -import { createLinuxFrameCopyHelperEnvironment } from './helper-environment'; +import { createLinuxFrameCopyHelperLaunch } from './helper-launch'; import { validatePackage } from './package-validator'; import type { EmbeddedMpvFrameCopyManifestContract, @@ -78,25 +78,26 @@ function runHelperProbe( runtimePackage: ValidatedPackage, dependencies: EmbeddedMpvFrameCopyRuntimeDependencies ): EmbeddedMpvFrameCopyRuntimeResult { - const probeEnvironment = createLinuxFrameCopyHelperEnvironment( - dependencies.env, - runtimePackage.nativeDir, - runtimePackage.manifest.runtimeMode - ); + const launch = createLinuxFrameCopyHelperLaunch({ + environment: dependencies.env, + helperPath: runtimePackage.helperPath, + helperArgs: ['--runtime-probe'], + runtimeMode: runtimePackage.manifest.runtimeMode, + fileSystem: dependencies.fileSystem, + }); + if (launch.usable === false) { + return failure(launch.reason); + } let result: ReturnType; try { - result = dependencies.spawnSync( - runtimePackage.helperPath, - ['--runtime-probe'], - { - encoding: 'utf8', - timeout: RUNTIME_PROBE_TIMEOUT_MS, - killSignal: 'SIGKILL', - windowsHide: true, - env: probeEnvironment, - } - ); + result = dependencies.spawnSync(launch.command, launch.args, { + encoding: 'utf8', + timeout: RUNTIME_PROBE_TIMEOUT_MS, + killSignal: 'SIGKILL', + windowsHide: true, + env: launch.env, + }); } catch { return failure('helper-probe-spawn-error'); } 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 984dd4d64..5e5e1a8a8 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,11 +160,11 @@ export function createFixture( packageDependencies: bundled ? {} : { - deb: ['libmpv2', 'libegl1', 'libopengl0', 'libgbm1'], + deb: ['libmpv2', 'libegl1', 'libgl1', 'libgbm1'], rpm: [ 'mpv-libs', 'libglvnd-egl', - 'libglvnd-opengl', + 'libglvnd-glx', 'mesa-libgbm', ], pacman: ['mpv', 'libglvnd', 'mesa'], diff --git a/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/trusted-snap-root.ts b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/trusted-snap-root.ts new file mode 100644 index 000000000..c83432f38 --- /dev/null +++ b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/trusted-snap-root.ts @@ -0,0 +1,51 @@ +import path from 'path'; + +const TRUSTED_SNAP_MOUNT_ROOTS = ['/snap', '/var/lib/snapd/snap'] as const; + +function isPathInside( + parentPath: string, + candidatePath: string, + allowEqual: boolean +): boolean { + const relativePath = path.relative(parentPath, candidatePath); + if (relativePath === '') { + return allowEqual; + } + return ( + relativePath !== '..' && + !relativePath.startsWith(`..${path.sep}`) && + !path.isAbsolute(relativePath) + ); +} + +export function resolveTrustedSnapRoot( + environment: NodeJS.ProcessEnv, + nativeDir: string +): string | null { + const declaredSnapRoot = environment.SNAP; + if ( + !declaredSnapRoot || + !path.isAbsolute(declaredSnapRoot) || + !path.isAbsolute(nativeDir) + ) { + return null; + } + + const normalizedSnapRoot = path.resolve(declaredSnapRoot); + const resemblesReadOnlySnapMount = TRUSTED_SNAP_MOUNT_ROOTS.some( + (mountRoot) => { + const relativePath = path.relative(mountRoot, normalizedSnapRoot); + return ( + isPathInside(mountRoot, normalizedSnapRoot, false) && + relativePath.split(path.sep).filter(Boolean).length >= 2 + ); + } + ); + if ( + !resemblesReadOnlySnapMount || + !isPathInside(normalizedSnapRoot, path.resolve(nativeDir), false) + ) { + return null; + } + return normalizedSnapRoot; +} diff --git a/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/types.ts b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/types.ts index e7172af3a..931ff0b5a 100644 --- a/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/types.ts +++ b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/types.ts @@ -14,6 +14,7 @@ export type EmbeddedMpvFrameCopyRuntimeFailureReason = | 'runtime-library-invalid' | 'runtime-library-size-mismatch' | 'runtime-library-hash-mismatch' + | 'snap-graphics-provider-unavailable' | 'helper-probe-timeout' | 'helper-probe-spawn-error' | 'helper-probe-signaled' diff --git a/apps/electron-backend/src/app/services/embedded-mpv-frame-copy.adapter.spec.ts b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy.adapter.spec.ts index 3900a055a..4900c9f58 100644 --- a/apps/electron-backend/src/app/services/embedded-mpv-frame-copy.adapter.spec.ts +++ b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy.adapter.spec.ts @@ -1,4 +1,5 @@ import { EventEmitter } from 'events'; +import type { Stats } from 'fs'; import path from 'path'; const spawnMock = jest.fn(); @@ -9,6 +10,54 @@ jest.mock('child_process', () => ({ import { EmbeddedMpvFrameCopyAdapter } from './embedded-mpv-frame-copy.adapter'; import type { EmbeddedMpvFrameCopyRuntimeMode } from './embedded-mpv-frame-copy-runtime'; +const HOSTILE_LOADER_ENVIRONMENT = { + BASH_ENV: '/tmp/hostile-bash-env', + ENV: '/tmp/hostile-shell-env', + BASHOPTS: 'extdebug', + SHELLOPTS: 'xtrace', + PS4: '$(/tmp/hostile-trace-hook)', + BASH_XTRACEFD: '9', + CDPATH: '/tmp/hostile-cdpath', + 'BASH_FUNC_dirname%%': '() { printf /tmp/hostile-provider-root; exit 0; }', + LD_AUDIT: '/tmp/audit.so', + LD_LIBRARY_PATH: '/tmp/hostile-libs', + LD_ORIGIN_PATH: '/tmp/hostile-origin', + LD_PRELOAD: '/tmp/inject.so', + __EGL_VENDOR_LIBRARY_FILENAMES: '/tmp/hostile-egl-vendor.json', + __EGL_VENDOR_LIBRARY_DIRS: '/tmp/hostile-egl-vendor-dir', + __EGL_EXTERNAL_PLATFORM_CONFIG_DIRS: '/tmp/hostile-egl-platform', + __EGL_EXTERNAL_PLATFORM_CONFIG_FILENAMES: '/tmp/hostile-egl-platform.json', + GBM_BACKEND: '../../../../../tmp/hostile-gbm', + GBM_BACKENDS_PATH: '/tmp/hostile-gbm-path', + LIBGL_DRIVERS_PATH: '/tmp/hostile-dri-path', + MESA_LOADER_DRIVER_OVERRIDE: '../../../../../tmp/hostile-dri', + LIBVA_DRIVER_NAME: '../../../../../tmp/hostile-va', + LIBVA_DRIVERS_PATH: '/tmp/hostile-va-path', + VDPAU_DRIVER_PATH: '/tmp/hostile-vdpau', + VK_DRIVER_FILES: '/tmp/hostile-vulkan-driver.json', + VK_ICD_FILENAMES: '/tmp/hostile-vulkan-icd.json', + VK_ADD_DRIVER_FILES: '/tmp/hostile-vulkan-add-driver.json', + VK_ADD_LAYER_PATH: '/tmp/hostile-vulkan-layers', + VK_IMPLICIT_LAYER_PATH: '/tmp/hostile-vulkan-implicit-layers', + VK_ADD_IMPLICIT_LAYER_PATH: '/tmp/hostile-vulkan-add-implicit-layers', + VK_LAYER_PATH: '/tmp/hostile-vulkan-layer-path', +} as const; + +const GRAPHICS_SELECTOR_ENVIRONMENT = { + LIBGL_ALWAYS_SOFTWARE: '1', + GALLIUM_DRIVER: 'llvmpipe', +} as const; + +function fakeStat( + kind: 'directory' | 'file' +): Pick { + return { + isDirectory: () => kind === 'directory', + isFile: () => kind === 'file', + isSymbolicLink: () => false, + }; +} + class FakeHelperProcess extends EventEmitter { exitCode: number | null = null; readonly stdout = new EventEmitter(); @@ -42,9 +91,14 @@ describe('EmbeddedMpvFrameCopyAdapter', () => { { runtimeMode = 'system', environment, + helperLaunchFileSystem, }: { runtimeMode?: EmbeddedMpvFrameCopyRuntimeMode | null; environment?: NodeJS.ProcessEnv; + helperLaunchFileSystem?: { + lstatSync(filePath: string): Stats; + accessSync(filePath: string, mode: number): void; + }; } = {} ) => { frameSourceChanges = []; @@ -52,10 +106,14 @@ describe('EmbeddedMpvFrameCopyAdapter', () => { resolveHelperPath: () => helperPath, resolveRuntimeMode: () => runtimeMode, environment, + helperLaunchFileSystem, getScaleFactor: () => 2, onFrameSourceChanged: (sessionId, source) => - frameSourceChanges.push({ sessionId, shmName: source.shmName }), - }); + frameSourceChanges.push({ + sessionId, + shmName: source.shmName, + }), + } as ConstructorParameters[0]); }; beforeEach(() => { @@ -117,9 +175,8 @@ describe('EmbeddedMpvFrameCopyAdapter', () => { environment: { PATH: '/usr/bin', HOME: '/home/user', - LD_AUDIT: '/tmp/audit.so', - LD_LIBRARY_PATH: '/tmp/hostile-libs', - LD_PRELOAD: '/tmp/inject.so', + ...HOSTILE_LOADER_ENVIRONMENT, + ...GRAPHICS_SELECTOR_ENVIRONMENT, }, }); @@ -130,6 +187,7 @@ describe('EmbeddedMpvFrameCopyAdapter', () => { env: { PATH: '/usr/bin', HOME: '/home/user', + ...GRAPHICS_SELECTOR_ENVIRONMENT, }, }); }); @@ -145,27 +203,199 @@ describe('EmbeddedMpvFrameCopyAdapter', () => { ); adapter = createAdapter(path.join(nativeDir, 'helper'), { runtimeMode: 'bundled', + helperLaunchFileSystem: { + lstatSync: (candidatePath) => + fakeStat( + candidatePath.endsWith('/graphics') + ? 'directory' + : 'file' + ) as Stats, + accessSync: () => undefined, + }, environment: { PATH: '/snap/bin:/usr/bin', SNAP: snapRoot, SNAP_LIBRARY_PATH: '/var/lib/snapd/lib/gl:/tmp/hostile-gl', - LD_AUDIT: '/tmp/audit.so', - LD_LIBRARY_PATH: '/tmp/hostile-libs', - LD_PRELOAD: '/tmp/inject.so', + SNAP_DESKTOP_ARCH_TRIPLET: 'hostile-linux-gnu', + SNAP_DESKTOP_RUNTIME: path.join(snapRoot, 'gnome-platform'), + GBM_BACKENDS_PATH: '/tmp/hostile-gbm', + LIBGL_DRIVERS_PATH: '/tmp/hostile-dri', + LIBVA_DRIVERS_PATH: '/tmp/hostile-va', + __EGL_EXTERNAL_PLATFORM_CONFIG_DIRS: + '/tmp/hostile-egl-platform', + __EGL_VENDOR_LIBRARY_DIRS: '/tmp/hostile-egl-vendor', + VK_LAYER_PATH: '/tmp/hostile-vulkan', + XDG_CONFIG_HOME: '/tmp/hostile-xdg-config-home', + XDG_CONFIG_DIRS: '/tmp/hostile-xdg-config-dirs', + XDG_DATA_HOME: '/tmp/hostile-xdg-data-home', + XDG_DATA_DIRS: '/tmp/hostile-xdg-data-dirs', + ...HOSTILE_LOADER_ENVIRONMENT, + ...GRAPHICS_SELECTOR_ENVIRONMENT, }, }); createSession(); + expect(spawnMock.mock.calls[0][0]).toBe( + path.join( + snapRoot, + 'graphics', + 'bin', + 'graphics-core22-provider-wrapper' + ) + ); + expect(spawnMock.mock.calls[0][1][0]).toBe( + path.join(nativeDir, 'helper') + ); expect(spawnMock.mock.calls[0][2]).toEqual({ stdio: ['pipe', 'pipe', 'pipe'], env: { - PATH: '/snap/bin:/usr/bin', + PATH: '/usr/sbin:/usr/bin:/sbin:/bin', SNAP: snapRoot, - SNAP_LIBRARY_PATH: '/var/lib/snapd/lib/gl:/tmp/hostile-gl', + SNAP_LIBRARY_PATH: '/var/lib/snapd/lib/gl', + SNAP_ARCH: 'amd64', + SNAP_DESKTOP_ARCH_TRIPLET: 'x86_64-linux-gnu', + SNAP_DESKTOP_RUNTIME: path.join(snapRoot, 'gnome-platform'), + ...GRAPHICS_SELECTOR_ENVIRONMENT, + GBM_BACKENDS_PATH: [ + path.join( + snapRoot, + 'graphics', + 'usr', + 'lib', + 'x86_64-linux-gnu', + 'gbm' + ), + '/var/lib/snapd/lib/gl/gbm', + ].join(':'), + LIBGL_DRIVERS_PATH: path.join( + snapRoot, + 'graphics', + 'usr', + 'lib', + 'x86_64-linux-gnu', + 'dri' + ), + LIBVA_DRIVERS_PATH: path.join( + snapRoot, + 'graphics', + 'usr', + 'lib', + 'x86_64-linux-gnu', + 'dri' + ), + __EGL_EXTERNAL_PLATFORM_CONFIG_DIRS: path.join( + snapRoot, + 'graphics', + 'usr', + 'share', + 'egl', + 'egl_external_platform.d' + ), + __EGL_VENDOR_LIBRARY_DIRS: [ + '/var/lib/snapd/lib/glvnd/egl_vendor.d', + path.join( + snapRoot, + 'graphics', + 'usr', + 'share', + 'glvnd', + 'egl_vendor.d' + ), + ].join(':'), + VK_LAYER_PATH: [ + path.join( + snapRoot, + 'graphics', + 'usr', + 'share', + 'vulkan', + 'implicit_layer.d' + ), + path.join( + snapRoot, + 'graphics', + 'usr', + 'share', + 'vulkan', + 'explicit_layer.d' + ), + ].join(':'), + XDG_CONFIG_HOME: path.join(snapRoot, 'etc', 'xdg'), + XDG_CONFIG_DIRS: [ + path.join(snapRoot, 'etc', 'xdg'), + '/etc/xdg', + ].join(':'), + XDG_DATA_HOME: path.join(snapRoot, 'usr', 'share'), + XDG_DATA_DIRS: [ + path.join(snapRoot, 'graphics', 'usr', 'share'), + path.join(snapRoot, 'gnome-platform', 'usr', 'share'), + path.join(snapRoot, 'usr', 'share'), + '/usr/share', + ].join(':'), LD_LIBRARY_PATH: [ path.join(nativeDir, 'lib'), '/var/lib/snapd/lib/gl', + path.join( + snapRoot, + 'graphics', + 'usr', + 'lib', + 'x86_64-linux-gnu' + ), + path.join( + snapRoot, + 'graphics', + 'usr', + 'lib', + 'x86_64-linux-gnu', + 'vdpau' + ), + path.join( + snapRoot, + 'gnome-platform', + 'lib', + 'x86_64-linux-gnu' + ), + path.join( + snapRoot, + 'gnome-platform', + 'usr', + 'lib', + 'x86_64-linux-gnu' + ), + path.join( + snapRoot, + 'gnome-platform', + 'usr', + 'lib', + 'x86_64-linux-gnu', + 'mesa' + ), + path.join( + snapRoot, + 'gnome-platform', + 'usr', + 'lib', + 'x86_64-linux-gnu', + 'mesa-egl' + ), + path.join( + snapRoot, + 'gnome-platform', + 'usr', + 'lib', + 'x86_64-linux-gnu', + 'dri' + ), + path.join( + snapRoot, + 'gnome-platform', + 'usr', + 'lib', + 'x86_64-linux-gnu', + 'pulseaudio' + ), path.join(snapRoot, 'lib'), path.join(snapRoot, 'usr', 'lib'), path.join(snapRoot, 'lib', 'x86_64-linux-gnu'), diff --git a/apps/electron-backend/src/app/services/embedded-mpv-frame-copy.adapter.ts b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy.adapter.ts index c2c453f52..7c14720b0 100644 --- a/apps/electron-backend/src/app/services/embedded-mpv-frame-copy.adapter.ts +++ b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy.adapter.ts @@ -7,8 +7,11 @@ import { ResolvedPortalPlayback, } from '@iptvnator/shared/interfaces'; import { isFrameCopyPlatformSupported } from './embedded-mpv-frame-copy-platform.util'; -import { createLinuxFrameCopyHelperEnvironment } from './embedded-mpv-frame-copy-runtime'; -import type { EmbeddedMpvFrameCopyRuntimeMode } from './embedded-mpv-frame-copy-runtime'; +import { createLinuxFrameCopyHelperLaunch } from './embedded-mpv-frame-copy-runtime'; +import type { + EmbeddedMpvFrameCopyRuntimeMode, + LinuxFrameCopyHelperLaunchFileSystem, +} from './embedded-mpv-frame-copy-runtime'; import type { NativeEmbeddedMpvAddon, NativeEmbeddedMpvSessionSnapshot, @@ -32,6 +35,7 @@ export interface EmbeddedMpvFrameCopyAdapterOptions { resolveHelperPath: () => string | null; resolveRuntimeMode: () => EmbeddedMpvFrameCopyRuntimeMode | null; environment?: NodeJS.ProcessEnv; + helperLaunchFileSystem?: LinuxFrameCopyHelperLaunchFileSystem; getScaleFactor: () => number; onFrameSourceChanged: ( sessionId: string, @@ -112,6 +116,27 @@ export class EmbeddedMpvFrameCopyAdapter implements NativeEmbeddedMpvAddon { const scale = this.options.getScaleFactor(); const width = Math.max(16, Math.round(bounds.width * scale)); const height = Math.max(16, Math.round(bounds.height * scale)); + const helperArgs = [ + '--shm-base', + `/${sessionId}`, + '--width', + String(width), + '--height', + String(height), + '--volume', + String(Math.min(Math.max(initialVolume ?? 1, 0), 1)), + // Lip-sync compensation for the video path's added latency + // (~10 ms measured on M1 Pro); tunable until calibration + // lands, see the architecture doc. + ...(process.env.IPTVNATOR_EMBEDDED_MPV_AUDIO_DELAY + ? [ + '--audio-delay', + process.env.IPTVNATOR_EMBEDDED_MPV_AUDIO_DELAY, + ] + : []), + ]; + let helperCommand = helperPath; + let resolvedHelperArgs = helperArgs; let helperEnvironment: NodeJS.ProcessEnv | undefined; if (process.platform === 'linux') { const runtimeMode = this.options.resolveRuntimeMode(); @@ -120,39 +145,27 @@ export class EmbeddedMpvFrameCopyAdapter implements NativeEmbeddedMpvAddon { 'A validated Linux frame-copy runtime is not available.' ); } - helperEnvironment = createLinuxFrameCopyHelperEnvironment( - this.options.environment ?? process.env, - path.dirname(helperPath), - runtimeMode - ); + const launch = createLinuxFrameCopyHelperLaunch({ + environment: this.options.environment ?? process.env, + helperPath, + helperArgs, + runtimeMode, + fileSystem: this.options.helperLaunchFileSystem, + }); + if (!launch.usable) { + throw new Error( + 'The connected Snap graphics provider is not available.' + ); + } + helperCommand = launch.command; + resolvedHelperArgs = launch.args; + helperEnvironment = launch.env; } - const child = spawn( - helperPath, - [ - '--shm-base', - `/${sessionId}`, - '--width', - String(width), - '--height', - String(height), - '--volume', - String(Math.min(Math.max(initialVolume ?? 1, 0), 1)), - // Lip-sync compensation for the video path's added latency - // (~10 ms measured on M1 Pro); tunable until calibration - // lands, see the architecture doc. - ...(process.env.IPTVNATOR_EMBEDDED_MPV_AUDIO_DELAY - ? [ - '--audio-delay', - process.env.IPTVNATOR_EMBEDDED_MPV_AUDIO_DELAY, - ] - : []), - ], - { - stdio: ['pipe', 'pipe', 'pipe'], - ...(helperEnvironment ? { env: helperEnvironment } : {}), - } - ); + const child = spawn(helperCommand, resolvedHelperArgs, { + stdio: ['pipe', 'pipe', 'pipe'], + ...(helperEnvironment ? { env: helperEnvironment } : {}), + }); console.log( `[embedded-mpv-fc][${sessionId}] spawn ${width}x${height} (pid pending)` diff --git a/apps/electron-backend/src/app/services/embedded-mpv-native-source.spec.ts b/apps/electron-backend/src/app/services/embedded-mpv-native-source.spec.ts index a91254818..3588ed41d 100644 --- a/apps/electron-backend/src/app/services/embedded-mpv-native-source.spec.ts +++ b/apps/electron-backend/src/app/services/embedded-mpv-native-source.spec.ts @@ -739,9 +739,7 @@ describe('Embedded MPV native source recording invariants', () => { expect(buildAndMakeWorkflowSource).toContain( 'Download pinned Linux Embedded MPV runtime' ); - expect(buildAndMakeWorkflowSource).not.toContain( - 'libmpv-dev mpv pkg-config libegl-dev libgl-dev libopengl-dev libgbm-dev' - ); + expect(buildAndMakeWorkflowSource).not.toContain('libopengl-dev'); expect(buildAndMakeWorkflowSource).not.toContain( 'Stage Linux embedded MPV build inputs' ); @@ -795,9 +793,16 @@ describe('Embedded MPV native source recording invariants', () => { ); }); - it('keeps Electron Builder defaults and requests private Snap shared memory', () => { + it('keeps Electron Builder defaults and exact Snap runtime plugs', () => { expect(electronBuilderConfig.snap?.plugs).toEqual([ 'default', + { + 'graphics-core22': { + interface: 'content', + target: '$SNAP/graphics', + 'default-provider': 'mesa-core22', + }, + }, { 'shared-memory': { interface: 'shared-memory', @@ -1048,8 +1053,9 @@ describe('Embedded MPV native build configuration', () => { expect(linuxAddonConfig?.libraries).not.toContain('-lmpv'); expect(JSON.stringify(linuxAddonConfig)).not.toContain('-lmpv'); expect(linuxHelperConfig?.libraries).toEqual( - expect.arrayContaining(['-lEGL', '-lOpenGL', '-lgbm', '-ldl']) + expect.arrayContaining(['-lEGL', '-lGL', '-lgbm', '-ldl']) ); + expect(linuxHelperConfig?.libraries).not.toContain('-lOpenGL'); expect(linuxHelperConfig?.libraries).not.toContain('-lmpv'); expect( linuxHelperConfig?.libraries.find((library: string) => diff --git a/apps/electron-backend/src/app/services/embedded-mpv-runtime-diagnostic.spec.ts b/apps/electron-backend/src/app/services/embedded-mpv-runtime-diagnostic.spec.ts new file mode 100644 index 000000000..9b2ef763e --- /dev/null +++ b/apps/electron-backend/src/app/services/embedded-mpv-runtime-diagnostic.spec.ts @@ -0,0 +1,110 @@ +const mockElectronApp = { + isPackaged: true, +}; + +jest.mock('electron', () => ({ app: mockElectronApp })); + +import { + EMBEDDED_MPV_RUNTIME_PROBE_SWITCH, + runEmbeddedMpvRuntimeDiagnosticOrContinue, +} from './embedded-mpv-runtime-diagnostic'; +import type { FrameCopyRuntimeAvailability } from './embedded-mpv-frame-copy-platform.util'; + +interface DiagnosticHarness { + continueStartup: jest.Mock; + exit: jest.Mock; + getRuntimeAvailability: jest.Mock; + writeStdout: jest.Mock; +} + +function createHarness( + availability: FrameCopyRuntimeAvailability = { + usable: false, + reason: 'runtime-artifact-missing', + } +): DiagnosticHarness { + return { + continueStartup: jest.fn(), + exit: jest.fn(), + getRuntimeAvailability: jest.fn(() => availability), + writeStdout: jest.fn(), + }; +} + +function runDiagnostic( + argv: readonly string[], + harness: DiagnosticHarness +): void { + runEmbeddedMpvRuntimeDiagnosticOrContinue(argv, harness.continueStartup, { + exit: harness.exit, + getRuntimeAvailability: harness.getRuntimeAvailability, + writeStdout: harness.writeStdout, + }); +} + +describe('embedded MPV runtime diagnostic', () => { + it.each([ + [['electron', 'main.js']], + [['electron', 'main.js', `${EMBEDDED_MPV_RUNTIME_PROBE_SWITCH}=1`]], + [['electron', 'main.js', 'embedded-mpv-runtime-probe']], + ])('continues normal startup for argv %j', (argv) => { + const harness = createHarness(); + + runDiagnostic(argv, harness); + + expect(harness.continueStartup).toHaveBeenCalledTimes(1); + expect(harness.getRuntimeAvailability).not.toHaveBeenCalled(); + expect(harness.writeStdout).not.toHaveBeenCalled(); + expect(harness.exit).not.toHaveBeenCalled(); + }); + + it('prints the usable availability as one JSON line, exits zero, and skips startup', () => { + const availability: FrameCopyRuntimeAvailability = { + usable: true, + profile: 'portable', + runtimeMode: 'bundled', + libmpv: '2.3', + renderApi: 'egl', + }; + const harness = createHarness(availability); + + runDiagnostic( + ['electron', 'main.js', EMBEDDED_MPV_RUNTIME_PROBE_SWITCH], + harness + ); + + expect(harness.getRuntimeAvailability).toHaveBeenCalledTimes(1); + expect(harness.writeStdout).toHaveBeenCalledWith( + `${JSON.stringify(availability)}\n` + ); + expect(harness.writeStdout).toHaveBeenCalledTimes(1); + expect(harness.exit).toHaveBeenCalledWith(0); + expect(harness.exit).toHaveBeenCalledTimes(1); + expect(harness.writeStdout.mock.invocationCallOrder[0]).toBeLessThan( + harness.exit.mock.invocationCallOrder[0] + ); + expect(harness.continueStartup).not.toHaveBeenCalled(); + }); + + it('prints the unavailable reason as one JSON line, exits nonzero, and skips startup', () => { + const availability: FrameCopyRuntimeAvailability = { + usable: false, + reason: 'helper-probe-failed', + }; + const harness = createHarness(availability); + + runDiagnostic( + [EMBEDDED_MPV_RUNTIME_PROBE_SWITCH, 'electron', 'main.js'], + harness + ); + + expect(harness.getRuntimeAvailability).toHaveBeenCalledTimes(1); + expect(harness.writeStdout).toHaveBeenCalledWith( + '{"usable":false,"reason":"helper-probe-failed"}\n' + ); + expect(harness.writeStdout).toHaveBeenCalledTimes(1); + expect(harness.exit).toHaveBeenCalledWith(1); + expect(harness.exit).toHaveBeenCalledTimes(1); + expect(harness.continueStartup).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/electron-backend/src/app/services/embedded-mpv-runtime-diagnostic.ts b/apps/electron-backend/src/app/services/embedded-mpv-runtime-diagnostic.ts new file mode 100644 index 000000000..4c6f23941 --- /dev/null +++ b/apps/electron-backend/src/app/services/embedded-mpv-runtime-diagnostic.ts @@ -0,0 +1,36 @@ +import { writeSync } from 'fs'; +import { + getFrameCopyRuntimeAvailability, + type FrameCopyRuntimeAvailability, +} from './embedded-mpv-frame-copy-platform.util'; + +export const EMBEDDED_MPV_RUNTIME_PROBE_SWITCH = '--embedded-mpv-runtime-probe'; + +interface EmbeddedMpvRuntimeDiagnosticDependencies { + exit(code: number): void; + getRuntimeAvailability(): FrameCopyRuntimeAvailability; + writeStdout(output: string): void; +} + +const defaultDependencies: EmbeddedMpvRuntimeDiagnosticDependencies = { + exit: (code) => process.exit(code), + getRuntimeAvailability: getFrameCopyRuntimeAvailability, + writeStdout: (output) => { + writeSync(process.stdout.fd, output); + }, +}; + +export function runEmbeddedMpvRuntimeDiagnosticOrContinue( + argv: readonly string[], + continueStartup: () => void, + dependencies: EmbeddedMpvRuntimeDiagnosticDependencies = defaultDependencies +): void { + if (!argv.includes(EMBEDDED_MPV_RUNTIME_PROBE_SWITCH)) { + continueStartup(); + return; + } + + const availability = dependencies.getRuntimeAvailability(); + dependencies.writeStdout(`${JSON.stringify(availability)}\n`); + dependencies.exit(availability.usable ? 0 : 1); +} diff --git a/apps/electron-backend/src/main.ts b/apps/electron-backend/src/main.ts index f59e96a90..ea9b08923 100644 --- a/apps/electron-backend/src/main.ts +++ b/apps/electron-backend/src/main.ts @@ -36,6 +36,7 @@ import { shouldPromotePersistedFrameCopyOptIn, } from './app/services/embedded-mpv-frame-copy-platform.util'; import { isEmbeddedMpvFeatureEnabled } from './app/services/embedded-mpv-runtime-policy.util'; +import { runEmbeddedMpvRuntimeDiagnosticOrContinue } from './app/services/embedded-mpv-runtime-diagnostic'; import { EMBEDDED_MPV_FRAME_COPY, store } from './app/services/store.service'; app.setName('iptvnator'); @@ -189,23 +190,25 @@ export default class Main { } } -// handle setup events as quickly as possible -Main.initialize(); +runEmbeddedMpvRuntimeDiagnosticOrContinue(process.argv, () => { + // handle setup events as quickly as possible + Main.initialize(); -// bootstrap app -Main.bootstrapApp(); + // bootstrap app + Main.bootstrapApp(); -// Bootstrap app events after Electron app is ready -app.whenReady().then(async () => { - if (isStartupTraceEnabled()) { - trace('startup', 'app.whenReady'); - } - await Main.bootstrapAppEvents(); -}); - -app.on('before-quit', () => { - shutdownEmbeddedMpv(); - shutdownMpvSession(); - shutdownVlcSession(); - void databaseWorkerClient.shutdown(); + // Bootstrap app events after Electron app is ready + app.whenReady().then(async () => { + if (isStartupTraceEnabled()) { + trace('startup', 'app.whenReady'); + } + await Main.bootstrapAppEvents(); + }); + + app.on('before-quit', () => { + shutdownEmbeddedMpv(); + shutdownMpvSession(); + shutdownVlcSession(); + void databaseWorkerClient.shutdown(); + }); }); diff --git a/docs/architecture/embedded-mpv-native.md b/docs/architecture/embedded-mpv-native.md index 16012b8d3..c7b243dab 100644 --- a/docs/architecture/embedded-mpv-native.md +++ b/docs/architecture/embedded-mpv-native.md @@ -77,24 +77,25 @@ 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 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` | +| Profile | Formats | Frame-copy runtime strategy | +| ---------- | ---------------- | -------------------------------------------------------------------------------------------------------- | +| `system` | DEB, RPM, Pacman | System `libmpv.so.2` plus the helper's direct EGL/GL/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` +- DEB: `libmpv2`, `libegl1`, `libgl1`, `libgbm1` +- RPM: `mpv-libs`, `libglvnd-egl`, `libglvnd-glx`, `mesa-libgbm` - Pacman: `mpv`, `libglvnd`, `mesa` The DEB contract deliberately names `libmpv2`, not a loose `libmpv` -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. +alternative, and explicitly names the GLVND `libGL.so.1` interface because +`libmpv2` does not pull it in for the helper. The helper uses `-lGL`, not +`-lOpenGL`; this matches the graphics interface supplied by distributions and +Snap's `mesa-core22`. 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, @@ -106,6 +107,14 @@ native directory also contains hash-validated `embedded-mpv-notices.json`, marker-only packages intentionally contain neither a private `native/lib` directory nor the bundled-runtime legal payload. +The build-time `electron-backend/native` tree is excluded from `app.asar`. +`afterPack` is the only owner of +`resources/app.asar.unpacked/electron-backend/native`, so each profile receives +exactly its normalized payload and ARM packages cannot retain a hidden x64 +helper, runtime, manifest, or notice copy in the archive. Both unpacked-layout +and final Linux artifact verification enumerate `app.asar` and fail if any +entry remains below `/electron-backend/native/`. + The pristine `afterPack` and unpacked-layout checks recursively inspect Electron-owned shared libraries. An extracted Snap has already overlaid its package-manager `lib/**` and `usr/lib/**` runtime trees onto that same payload @@ -270,29 +279,84 @@ JSON line and return zero. The startup probe and every playback helper session use the same sanitized loader environment selected by the validated manifest's cached `runtimeMode`. -Both remove ambient `LD_AUDIT`, `LD_PRELOAD`, and `LD_LIBRARY_PATH`; the system -profile then uses the default system loader without a private path. Bundled -profiles put the validated packaged `native/lib` first. AppImage and Flatpak -resolve the declared external graphics/audio interfaces through their normal -host or sandbox loader. +Both remove ambient ELF audit/preload/origin/library overrides, direct +EGL/GBM/GL/VA/Vulkan driver and layer paths, shell startup/options, tracing +hooks, and exported Bash functions. The extracted-artifact verifier applies +the same deny-set before its direct helper smoke, while preserving +feature/debug selectors such as `LIBGL_ALWAYS_SOFTWARE`. The system profile +then uses the default system loader without a private path. Bundled profiles +put the validated packaged `native/lib` first. AppImage and Flatpak resolve the +declared external graphics/audio interfaces through their normal host or +sandbox loader. +For the exact `com.fourgray.iptvnator` Flatpak payload under `/app`, the helper +reconstructs Freedesktop Platform 24.08's immutable +`__EGL_EXTERNAL_PLATFORM_CONFIG_DIRS` value: +`/etc/egl/egl_external_platform.d:/usr/lib/x86_64-linux-gnu/GL/egl/egl_external_platform.d:/usr/share/egl/egl_external_platform.d`. +All ambient EGL/GBM/GL/VA/Vulkan path overrides remain removed. Freedesktop's +GL extension `add-ld-path` is supplied through the sandbox loader cache, so no +ambient `LD_LIBRARY_PATH` is needed. Flatpak CI runs the application-level +`--embedded-mpv-runtime-probe`; direct helper execution is only a package +layout check and cannot substitute for the real gate. Inside a genuine Snap mount, filtered absolute `SNAP_LIBRARY_PATH` entries below -`/var/lib/snapd/lib/gl` follow `native/lib` and precede the generic -`$SNAP/lib`, `$SNAP/usr/lib`, and x64 multiarch roots. This preserves the pinned -runtime closure while ensuring the host GL/NVIDIA dispatch libraries win over -generic GL libraries staged in the snap. Out-of-root entries are discarded, and -the adapter refuses to start a Linux helper without the validated cached mode. +`/var/lib/snapd/lib/gl` follow `native/lib`. The exact +`$SNAP/graphics/usr/lib/x86_64-linux-gnu` content-provider roots come next, +followed by the GNOME platform's fixed x64 library, Mesa, DRI, and PulseAudio +roots only when `SNAP_DESKTOP_RUNTIME` resolves exactly to +`$SNAP/gnome-platform`; generic `$SNAP` roots remain last. The helper also +rebuilds the GBM, GL/VA driver, EGL vendor/platform, and Vulkan layer variables +from those trusted roots. Caller-provided triplets, graphics-driver paths, and +out-of-root loader entries are ignored. +Both the bounded probe and playback execute the helper through +`$SNAP/graphics/bin/graphics-core22-provider-wrapper`. Before either launch, +the app requires the mounted graphics root to be a real directory and the +wrapper to be a regular, non-symlinked, readable executable. A missing or +disconnected provider therefore reports the stable +`snap-graphics-provider-unavailable` reason instead of attempting a partial +loader setup. Because that provider wrapper is a non-interactive Bash script, +the child environment also removes shell startup/options, exported +`BASH_FUNC_*` functions, and tracing hooks, and fixes `PATH` to core22 system +directories. This prevents ambient shell configuration from replacing the +probe or its `dirname` lookup before the helper executes. -The strict Snap keeps Electron Builder's default plugs and adds an -auto-connected private `shared-memory` plug. Private shared memory gives the -app a confined, snap-specific POSIX shm namespace rather than global -cross-snap access. Consequently the installed-Snap `--runtime-probe` validates -the actual confinement and shm lifecycle required by frame-copy, not only the -loader and graphics contexts. -Packaged discovery is limited to packaged resource locations and never falls -through to writable cwd/dist development paths. A disabled base experiment or -any failed capability check keeps the renderer sandbox enabled and falls back -to the native engine. The result is cached by helper/manifest identity for the -process lifetime, so the startup and service gates cannot disagree. +The Snap is `base: core22` with strict confinement. It keeps Electron Builder's +default plugs and adds an auto-connected private `shared-memory` plug plus the +`graphics-core22` content plug targeting a real empty mode-0755 +`$SNAP/graphics`, with `mesa-core22` as default provider. `mesa-core22` +supplies the shared +EGL/GL/GLX/GBM/DRM/VA userspace; the existing GNOME content runtime supplies +ALSA/PulseAudio. These providers are external shared snaps, so their binaries, +source, notices, and installed bytes are not part of the IPTVnator Snap or its +compliance archive. CI installs and explicitly connects both providers for a +locally installed `--dangerous` artifact, then runs the application-level +probe under strict confinement. + +The package carries the empty content target itself because core22 does not +create `$SNAP` content targets while packing. Metadata verification rejects a +missing, non-directory, symlinked, non-empty, or incorrectly permissioned +target. It also requires exactly the canonical provider-data layouts: +`/usr/share/libdrm` binds from `$SNAP/graphics/libdrm`, and +`/usr/share/drirc.d` symlinks to `$SNAP/graphics/drirc.d`. No additional or +duplicate layout entry is accepted. + +Private shared memory gives the app a confined, snap-specific POSIX shm +namespace rather than global cross-snap access. The packaging-only +`--embedded-mpv-runtime-probe` application switch invokes the same complete +manifest, mode, hash, linkage, environment, and bounded helper probe used at +startup before any BrowserWindow is created. It writes exactly one availability +JSON line and exits zero only when frame-copy is usable. Consequently the +installed-Snap smoke validates the actual confinement and shared-memory +lifecycle required by playback, not only direct helper execution. The smoke +first disconnects `graphics-core22` and requires the application diagnostic to +emit `usable:false` with reason `snap-graphics-provider-unavailable` and +controlled exit code `1`; it then reconnects the provider and requires the +same diagnostic to succeed. +Packaged addon, frame-reader, and helper discovery is limited to package-owned +`app.asar.unpacked` resource locations and never falls through to writable +cwd/dist development paths. Those fallbacks are development-only. A disabled +base experiment or any failed capability check keeps the renderer sandbox +enabled and falls back to the native engine. The result is cached by +helper/manifest identity for the process lifetime, so the startup and service +gates cannot disagree. Changing the toggle requires an app restart because web preferences are fixed at window creation. @@ -338,7 +402,7 @@ Trade-offs and constraints: Intel Macs keep the native-view engine. Official Linux frame-copy is x64 — headless EGL, works under native Wayland since nothing embeds into a window; local system builds need `libmpv-dev`, `libegl-dev`, `libgl-dev`, - `libopengl-dev` and `libgbm-dev`. The helper links libmpv, which is legal + and `libgbm-dev`. The helper links libmpv, which is legal out-of-process; the in-process-libmpv ban still binds the addon and frame reader. The helper logs the chosen EGL display tier and the GL renderer string to stderr. If an early tier selects Mesa software rendering (for @@ -591,19 +655,24 @@ Current development behavior: `iptvnator_mpv_helper` must link exactly the declared `libmpv.so.2`, while the addon and frame reader must not. - `afterPack` copies `dist/apps/electron-backend/native/` into `app.asar.unpacked/electron-backend/native/` on macOS, Windows, and Linux so the addon, manifest, and runtime libraries are filesystem-addressable. +- Electron Builder excludes `electron-backend/native{,/**/*}` from `app.asar`; + package verification rejects any archived native entry so `afterPack` + remains the single profile-aware owner. 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 the exact - libmpv plus EGL/OpenGL/GBM package set listed above. The DEB path is verified + libmpv plus EGL/GL/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 - source-built closure and manifest origin. + 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 @@ -634,7 +703,7 @@ 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 their declared libmpv and EGL/OpenGL/GBM +frame-copy system packages need their declared libmpv and EGL/GL/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 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 346627252..94745ccaf 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,12 +57,14 @@ profile. System package dependencies are: -- DEB: `libmpv2`, `libegl1`, `libopengl0`, `libgbm1` -- RPM: `mpv-libs`, `libglvnd-egl`, `libglvnd-opengl`, `mesa-libgbm` +- DEB: `libmpv2`, `libegl1`, `libgl1`, `libgbm1` +- RPM: `mpv-libs`, `libglvnd-egl`, `libglvnd-glx`, `mesa-libgbm` - Pacman: `mpv`, `libglvnd`, `mesa` These names match the current Debian, Fedora, and Arch package databases and -cover every direct helper interface: libmpv, EGL, OpenGL, and GBM. System +cover every direct helper interface: libmpv, EGL, GL, and GBM. The helper +links `libGL.so.1` rather than `libOpenGL.so.0`, matching both the distro +contracts and Snap's graphics provider. 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. @@ -86,6 +88,21 @@ relationship. Release source bundles must include the exact hwdata archive and its dual-license notice (`GPL-2.0-or-later OR XFree86-1.0`) alongside the MIT-licensed libdisplay-info source. +The strict Snap uses `base: core22`, a private `shared-memory` plug, and an +exact `graphics-core22` content plug targeting a real empty mode-0755 +`$SNAP/graphics` with external `mesa-core22` as default provider. The graphics provider supplies +EGL/GL/GLX/GBM/DRM/VA; Electron Builder's GNOME content runtime supplies +ALSA/PulseAudio. Those shared providers are not copied into IPTVnator's Snap or +source/notices archive. Because core22 does not synthesize `$SNAP` content +targets, the package hook creates the empty directory and extracted-artifact +validation checks its type and emptiness. The metadata also declares exactly +the canonical graphics layouts: `/usr/share/libdrm` binds from +`$SNAP/graphics/libdrm`, and `/usr/share/drirc.d` symlinks to +`$SNAP/graphics/drirc.d`. Locally installed `--dangerous` artifacts explicitly +install and connect both providers in CI, disconnect `graphics-core22` to +require an unavailable application diagnostic with exit code `1`, then +reconnect it and require success. + ## Runtime Layout And Linkage The x64 packaged native directory is: @@ -117,7 +134,10 @@ 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. `iptvnator_mpv_helper` must have one. Process isolation is an invariant, not a -profile-specific choice. The pristine Electron tree is scanned recursively +profile-specific choice. The source `electron-backend/native{,/**/*}` tree is +excluded from `app.asar`; `afterPack` is the sole owner of the normalized +unpacked native directory, and package checks reject every archived native +entry. The pristine Electron tree is scanned recursively before target packaging. Because Snap later overlays package-manager `lib/**`/`usr/lib/**` trees into the payload root, its extracted-target scan excludes exactly those two target-provided trees while remaining recursive @@ -143,15 +163,28 @@ The helper gains a side-effect-free `--runtime-probe` mode. It must: dependencies resolve; 2. create and initialize an idle libmpv handle with `vo=libmpv`; 3. create the platform render pipeline far enough to prove EGL/OpenGL/GBM - availability without opening media or shared memory; -4. emit one versioned JSON result and exit promptly with status zero only on + availability without opening media; +4. create, map, validate, and destroy the minimal shared-memory ring required + by playback; +5. emit one versioned JSON result and exit promptly with status zero only on success. The main process invokes this probe synchronously with a bounded timeout before BrowserWindow creation. Probe success is cached for the process lifetime. The probe environment prepends the packaged `native/lib` directory only when the manifest declares a bundled runtime. The system profile does not inject a -private loader path. +private loader path. Snap additionally rebuilds its loader and graphics-driver +variables from validated host GL, `$SNAP/graphics`, exact GNOME-platform, and +generic core22 roots; ambient preload/audit/library/driver overrides and +caller-provided architecture triplets are not inherited. The direct provider +wrapper launch also removes shell startup/options, tracing hooks, and exported +functions and fixes `PATH` to immutable core22 system directories. + +Packaging CI invokes the full main-process gate with the exact +`--embedded-mpv-runtime-probe` application switch. It executes before +BrowserWindow startup, writes one availability JSON line, and exits zero only +for a usable runtime. CI does not treat a direct helper invocation or an +environment opt-in as proof of packaged capability. Frame-copy is usable only when all of the following are true: @@ -181,6 +214,8 @@ Unit and packaging tests cover: malformed/incomplete manifests; - capability probe timeout, nonzero exit, invalid JSON, manifest mismatch, missing dependency, and successful result caching; +- exclusion of all native payloads from `app.asar`, including marker-only ARM + and system-package stale x64 artifacts; - helper probe protocol and failure behavior; - package metadata dependencies for DEB/RPM/Pacman. @@ -193,8 +228,9 @@ Linux CI must: 4. package the three profiles independently; 5. unpack or mount each produced format and validate its real payload, modes, manifest, RPATH, dependency closure, and profile; -6. install/run a lightweight helper probe inside the actual Snap and Flatpak - sandboxes and from AppImage; +6. install/run the application-level packaged gate inside the actual Snap and + Flatpak (with the exact Freedesktop 24.08 EGL external-platform path + reconstructed inside `/app`), and probe the AppImage payload; 7. install system packages in matching disposable distro containers and run the helper probe after the declared libmpv dependency is installed; 8. run a packaged Electron smoke test that confirms frame-copy capability, @@ -221,5 +257,7 @@ archives/metadata required by the recorded LGPL source-distribution statement. The libplacebo payload is a VCS-metadata-free working-tree snapshot with exact commit/submodule records, so clone-local `.git` state cannot perturb the compliance tar. Automated Snap publication must wait for a public `v*` release -that already contains both the Snap assets and the exact source archive. No -publication, push, pull request, or merge is part of this task. +that already contains both the Snap assets and the exact source archive. Snap +Store publication remains outside this implementation and requires its +separate release workflow; repository integration follows explicit maintainer +authorization. diff --git a/electron-builder.json b/electron-builder.json index 06b36b2c9..fca1b100e 100644 --- a/electron-builder.json +++ b/electron-builder.json @@ -20,6 +20,7 @@ "filter": ["**/*"] }, "electron-backend/**/*", + "!electron-backend/native{,/**/*}", "web/**/*", "!**/*.map" ], @@ -132,6 +133,13 @@ "executableArgs": ["--ozone-platform=x11"], "plugs": [ "default", + { + "graphics-core22": { + "interface": "content", + "target": "$SNAP/graphics", + "default-provider": "mesa-core22" + } + }, { "shared-memory": { "interface": "shared-memory", @@ -141,6 +149,14 @@ ], "environment": { "DISABLE_WAYLAND": "1" + }, + "layout": { + "/usr/share/libdrm": { + "bind": "$SNAP/graphics/libdrm" + }, + "/usr/share/drirc.d": { + "symlink": "$SNAP/graphics/drirc.d" + } } }, "win": { diff --git a/tools/embedded-mpv/README.md b/tools/embedded-mpv/README.md index 8175fde5c..d884cf67a 100644 --- a/tools/embedded-mpv/README.md +++ b/tools/embedded-mpv/README.md @@ -116,13 +116,17 @@ Set one exact `IPTVNATOR_LINUX_FRAME_COPY_PROFILE` per packaging pass: | `portable` | AppImage, Snap | Retain the pinned LGPL closure under `native/lib` | | `flatpak` | Flatpak | Retain the same pinned LGPL closure under `native/lib` | -The system helper directly links libmpv, EGL, OpenGL, and GBM. Package metadata +The system helper directly links libmpv, EGL, GL, and GBM. Package metadata therefore declares the full interface set: -- DEB: `libmpv2`, `libegl1`, `libopengl0`, `libgbm1` -- RPM: `mpv-libs`, `libglvnd-egl`, `libglvnd-opengl`, `mesa-libgbm` +- DEB: `libmpv2`, `libegl1`, `libgl1`, `libgbm1` +- RPM: `mpv-libs`, `libglvnd-egl`, `libglvnd-glx`, `mesa-libgbm` - Pacman: `mpv`, `libglvnd`, `mesa` +The helper links `libGL.so.1` (`-lGL`) rather than `libOpenGL.so.0`; the +former is the direct GL interface supplied by all three system contracts and +Snap's `mesa-core22`. + 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 @@ -130,21 +134,58 @@ 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 -POSIX shm namespace without granting global cross-snap shared-memory access. +The Snap is `base: core22` with strict confinement. It retains Electron +Builder's default plugs and adds an auto-connected private `shared-memory` +plug plus `graphics-core22`, targeting a real empty mode-0755 `$SNAP/graphics` +with external `mesa-core22` as default provider. The graphics provider supplies +EGL/GL/GLX/GBM/DRM/VA, while Electron Builder's exact GNOME content runtime +supplies ALSA/PulseAudio. Neither provider is bundled into IPTVnator's Snap, +source archive, notices, or package-size accounting. The package hook creates +the empty content target because core22 does not synthesize one; the extracted +artifact verifier rejects a missing, redirected, non-empty, or wrongly +permissioned target. Snap metadata must also contain exactly the canonical +graphics-provider layouts: bind `/usr/share/libdrm` from +`$SNAP/graphics/libdrm`, and symlink `/usr/share/drirc.d` to +`$SNAP/graphics/drirc.d`. The bounded probe and every playback helper share one sanitized loader environment derived from the validated, cached runtime mode. Ambient -`LD_AUDIT`, `LD_PRELOAD`, and `LD_LIBRARY_PATH` are removed. System packages -then use the default loader; bundled packages put their validated `native/lib` -first. +ELF audit/preload/origin/library overrides, direct EGL/GBM/GL/VA/Vulkan paths, +shell startup/options, tracing hooks, exported Bash functions, and +caller-provided architecture triplets are removed or replaced. The +extracted-artifact verifier uses the same deny-set for its direct helper smoke +and preserves feature/debug selectors such as `LIBGL_ALWAYS_SOFTWARE`. System +packages then use the default loader; bundled packages put their validated +`native/lib` first. Packaged addon/helper lookup is package-owned +`app.asar.unpacked` only; cwd/dist candidates are development-only. AppImage and Flatpak use normal host/sandbox lookup for the declared external -interfaces. In a genuine Snap mount, filtered `SNAP_LIBRARY_PATH` GL roots -under `/var/lib/snapd/lib/gl` come next, ahead of generic `$SNAP` library and -x64 multiarch roots, so host GL/NVIDIA dispatch cannot be shadowed by -snap-staged generic GL libraries. A Linux session without the validated cached -mode is rejected before spawn. +interfaces. Inside the exact packaged Flatpak `/app` context, the helper +reconstructs only Freedesktop Platform 24.08's immutable +`__EGL_EXTERNAL_PLATFORM_CONFIG_DIRS` value; the GL extension's +`add-ld-path` remains available through the sandbox loader cache. Flatpak CI +therefore invokes `flatpak run com.fourgray.iptvnator +--embedded-mpv-runtime-probe` instead of executing the helper around the +application gate. In a genuine Snap mount, filtered `SNAP_LIBRARY_PATH` GL roots +under `/var/lib/snapd/lib/gl` come next, then the fixed x64 +`$SNAP/graphics` roots, then exact `$SNAP/gnome-platform` graphics/audio roots, +and finally generic `$SNAP` library roots. The helper rebuilds GBM, GL/VA +driver, EGL vendor/platform, and Vulkan layer variables from those trusted +locations. A Linux session without the validated cached mode is rejected +before spawn. +Both the bounded probe and playback execute through +`$SNAP/graphics/bin/graphics-core22-provider-wrapper`. The graphics mount must +be a real directory and the wrapper a regular, non-symlinked, readable +executable. Otherwise the gate returns the stable +`snap-graphics-provider-unavailable` reason before spawning the helper. The +wrapper child also drops shell startup/options, tracing hooks, and exported +`BASH_FUNC_*` functions, and uses a fixed core22 system `PATH`; ambient Bash +configuration therefore cannot replace the probe before helper execution. + +Installed-Snap CI disconnects `graphics-core22`, requires the application-level +diagnostic to emit `snap-graphics-provider-unavailable` and exit with the +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 @@ -193,6 +234,18 @@ missing file, hash mismatch, unusable graphics path, or shm lifecycle failure returns a stable reason and keeps the BrowserWindow sandbox enabled. The installed-Snap probe therefore tests the private shared-memory confinement needed by playback rather than only loader and graphics startup. +Packaging CI invokes the same gate through +`snap run iptvnator --embedded-mpv-runtime-probe`. This packaging-only +application switch runs before BrowserWindow startup, emits one availability +JSON line, and returns zero only for a usable runtime; it never directly loads +libmpv in Electron. + +Electron Builder excludes `electron-backend/native{,/**/*}` from `app.asar`. +Only `afterPack` writes the profile-normalized +`app.asar.unpacked/electron-backend/native` tree. Layout and final-artifact +verification enumerate `app.asar` and reject any stale native entry, preventing +hidden x64 helpers, bundled libraries, or notices in system and marker-only +packages. ## CI And Source Distribution @@ -201,6 +254,9 @@ verifies `system`, `portable`, and `flatpak` independently. Every artifact is extracted for manifest, mode, package-metadata, ELF-isolation, and helper-probe checks. System formats are probed after their declared dependency is installed; Snap and Flatpak also require a sandboxed probe where the runner supports it. +For a locally installed `--dangerous` Snap, CI explicitly installs and +connects `mesa-core22` and `gnome-3-28-1804`, verifies both connections, and +then runs the application-level diagnostic under Xvfb. The Linux packaging matrix alone depends on the runtime-builder job. macOS and Windows use an independent matrix, while both matrices share the same anchored step list; draft release assembly remains atomic and requires both matrices. @@ -244,7 +300,7 @@ license notices with the binary. ## Local Development Linux can use distribution development packages for an unshipped local build -(`libmpv-dev`, EGL/OpenGL/GBM development files, and X11 headers). Overrides: +(`libmpv-dev`, EGL/GL/GBM development files, and X11 headers). Overrides: `LIBMPV_INCLUDE_DIR` selects the header root. `LINUX_NATIVE_LIBRARY_DIR` selects a link-time library directory that must already be visible to the system dynamic loader; it is not inherited as a helper `LD_LIBRARY_PATH`. diff --git a/tools/packaging/asar-dependency-closure.mjs b/tools/packaging/asar-dependency-closure.mjs index 7f7768a0f..ece3d60ed 100644 --- a/tools/packaging/asar-dependency-closure.mjs +++ b/tools/packaging/asar-dependency-closure.mjs @@ -19,6 +19,29 @@ import path from 'node:path'; const PACKAGE_MANIFEST = 'package.json'; const NODE_MODULES_SEGMENT = '/node_modules/'; +const EMBEDDED_MPV_NATIVE_ARCHIVE_ROOT = '/electron-backend/native'; + +/** + * Embedded MPV's native payload is profile-specific and is written only by + * afterPack. Any copy retained in app.asar predates that mutation and can leak + * x64 helpers, runtimes, manifests, or notices into marker-only/system builds. + */ +export function collectEmbeddedMpvNativeArchiveEntries( + asarEntries, + pathSep = path.sep +) { + const toPosix = (value) => + pathSep === '\\' ? value.replaceAll('\\', '/') : value; + + return asarEntries + .map(toPosix) + .map((entry) => (entry.startsWith('/') ? entry : `/${entry}`)) + .filter( + (entry) => + entry === EMBEDDED_MPV_NATIVE_ARCHIVE_ROOT || + entry.startsWith(`${EMBEDDED_MPV_NATIVE_ARCHIVE_ROOT}/`) + ); +} /** * A genuine installed package lives directly under a node_modules directory as @@ -70,7 +93,11 @@ export function collectAsarPackageDirs(asarEntries) { * as `/electron-backend/node_modules/foo`. Returns the resolved package * directory or null when the dependency is absent. */ -export function resolvePackagedDependency(fromDir, dependencyName, packageDirs) { +export function resolvePackagedDependency( + fromDir, + dependencyName, + packageDirs +) { let current = fromDir; while (true) { diff --git a/tools/packaging/asar-dependency-closure.test.mjs b/tools/packaging/asar-dependency-closure.test.mjs index 0c5fc81fe..14adc3ed5 100644 --- a/tools/packaging/asar-dependency-closure.test.mjs +++ b/tools/packaging/asar-dependency-closure.test.mjs @@ -2,6 +2,7 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { + collectEmbeddedMpvNativeArchiveEntries, collectAsarPackageDirs, findMissingPackagedDependencies, inspectPackagedDependencyClosure, @@ -16,6 +17,34 @@ function manifestReader(manifests) { return (packageDir) => manifests[packageDir] ?? null; } +test('collectEmbeddedMpvNativeArchiveEntries finds stale native payloads on every host separator', () => { + assert.deepEqual( + collectEmbeddedMpvNativeArchiveEntries( + [ + '/electron-backend/main.js', + '/electron-backend/native/iptvnator_mpv_helper', + '/electron-backend/native/lib/libmpv.so.2', + '/web/index.html', + ], + '/' + ), + [ + '/electron-backend/native/iptvnator_mpv_helper', + '/electron-backend/native/lib/libmpv.so.2', + ] + ); + assert.deepEqual( + collectEmbeddedMpvNativeArchiveEntries( + [ + '\\electron-backend\\native\\embedded-mpv-unavailable.txt', + '\\electron-backend\\node_modules\\package.json', + ], + '\\' + ), + ['/electron-backend/native/embedded-mpv-unavailable.txt'] + ); +}); + test('collectAsarPackageDirs keeps only genuine package roots', () => { const dirs = collectAsarPackageDirs([ '/package.json', diff --git a/tools/packaging/configure-linux-frame-copy-build.test.mjs b/tools/packaging/configure-linux-frame-copy-build.test.mjs index b83484281..06bc7b1d0 100644 --- a/tools/packaging/configure-linux-frame-copy-build.test.mjs +++ b/tools/packaging/configure-linux-frame-copy-build.test.mjs @@ -53,13 +53,13 @@ test('configures an x64-only system pass with exact package dependencies', () => assert.deepEqual(configured.deb.fpm, [ '--depends=libmpv2', '--depends=libegl1', - '--depends=libopengl0', + '--depends=libgl1', '--depends=libgbm1', ]); assert.deepEqual(configured.rpm.fpm, [ '--depends=mpv-libs', '--depends=libglvnd-egl', - '--depends=libglvnd-opengl', + '--depends=libglvnd-glx', '--depends=mesa-libgbm', ]); assert.deepEqual(configured.pacman.fpm, [ @@ -84,8 +84,25 @@ test('configures portable and flatpak passes without mixing targets', () => { assert.deepEqual(configuredTargets(flatpak), [ { target: 'flatpak', arch: ['x64'] }, ]); + assert.equal(portable.snap.base, 'core22'); + assert.equal(portable.snap.confinement, 'strict'); + assert.deepEqual(portable.snap.layout, { + '/usr/share/libdrm': { + bind: '$SNAP/graphics/libdrm', + }, + '/usr/share/drirc.d': { + symlink: '$SNAP/graphics/drirc.d', + }, + }); assert.deepEqual(portable.snap.plugs, [ 'default', + { + 'graphics-core22': { + interface: 'content', + target: '$SNAP/graphics', + 'default-provider': 'mesa-core22', + }, + }, { 'shared-memory': { interface: 'shared-memory', @@ -257,7 +274,7 @@ test('preserves unrelated fpm dependencies and normalizes only frame-copy depend '--depends=unrelated-runtime', '--depends=mesa', '--depends=libmpv2 >= 2', - '--depends=libopengl0', + '--depends=libgl1', ], }; const system = configureLinuxFrameCopyBuild(customized, { @@ -268,7 +285,7 @@ test('preserves unrelated fpm dependencies and normalizes only frame-copy depend '--depends=mesa', '--depends=libmpv2', '--depends=libegl1', - '--depends=libopengl0', + '--depends=libgl1', '--depends=libgbm1', ]); @@ -458,6 +475,10 @@ test('Linux runtime toolchain installs fontconfig generators without network wra }); test('Linux CI verifies every package family and exercises intended environments', () => { + const flatpakVerificationStep = workflowStep( + 'Verify Flatpak payload, launcher, and sandboxed runtime' + ); + for (const suffix of [ 'AppImage', 'deb', @@ -477,11 +498,23 @@ test('Linux CI verifies every package family and exercises intended environments assert.match(buildWorkflow, /ubuntu:24\.04/); assert.match(buildWorkflow, /fedora:latest/); assert.match(buildWorkflow, /archlinux:latest/); - assert.match(buildWorkflow, /snap run --shell iptvnator/); + assert.match( + buildWorkflow, + /snap run iptvnator --embedded-mpv-runtime-probe/ + ); + assert.doesNotMatch(buildWorkflow, /snap run --shell iptvnator/); assert.match( buildWorkflow, /flatpak run --command=sh com\.fourgray\.iptvnator/ ); + assert.match( + flatpakVerificationStep, + /flatpak run\s+\\\s+--env=LIBGL_ALWAYS_SOFTWARE=1\s+\\\s+com\.fourgray\.iptvnator\s+\\\s+--embedded-mpv-runtime-probe/ + ); + assert.doesNotMatch( + flatpakVerificationStep, + /HELPER_PATH=.*iptvnator_mpv_helper/ + ); assert.doesNotMatch(buildWorkflow, /\bldd\b/); }); @@ -523,7 +556,7 @@ test('foreign DEB CI explicitly selects both marker-only ARM architectures', () 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']) { + for (const dependency of ['libmpv2', 'libegl1', 'libgl1', 'libgbm1']) { assert.match(debStep, new RegExp(`\\b${dependency}\\b`)); } @@ -531,7 +564,7 @@ test('system package smoke environments install every direct helper runtime depe for (const dependency of [ 'mpv-libs', 'libglvnd-egl', - 'libglvnd-opengl', + 'libglvnd-glx', 'mesa-libgbm', ]) { assert.match(rpmStep, new RegExp(`\\b${dependency}\\b`)); @@ -564,15 +597,116 @@ test('Snap verifier preserves fail-closed status while exposing captured diagnos snapStep.indexOf("grep -Fq 'Verified snap x64 Linux'") < snapStep.indexOf('sudo snap install --dangerous') ); + assert.match( + snapStep, + /snap list mesa-core22 >\/dev\/null 2>&1 \|\| sudo snap install mesa-core22/ + ); + assert.match( + snapStep, + /snap list gnome-3-28-1804 >\/dev\/null 2>&1 \|\| sudo snap install gnome-3-28-1804/ + ); + assert.ok( + snapStep.indexOf('sudo snap install mesa-core22') < + snapStep.indexOf('sudo snap install --dangerous') + ); + assert.ok( + snapStep.indexOf('sudo snap install gnome-3-28-1804') < + snapStep.indexOf('sudo snap install --dangerous') + ); assert.ok( snapStep.indexOf('sudo snap install --dangerous') < snapStep.indexOf('installed_x64=true') ); + assert.match( + snapStep, + /sudo snap connect iptvnator:graphics-core22 mesa-core22:graphics-core22/ + ); + assert.match( + snapStep, + /sudo snap connect iptvnator:gnome-3-28-1804 gnome-3-28-1804:gnome-3-28-1804/ + ); + assert.match( + snapStep, + /\$2 == "iptvnator:graphics-core22" && \$3 == "mesa-core22:graphics-core22"/ + ); + assert.match( + snapStep, + /\$2 == "iptvnator:gnome-3-28-1804" && \$3 == "gnome-3-28-1804:gnome-3-28-1804"/ + ); + assert.match( + snapStep, + /sudo snap disconnect iptvnator:graphics-core22 mesa-core22:graphics-core22/ + ); + assert.match( + snapStep, + /\$2 == "iptvnator:graphics-core22" && \$3 == "-" \{ found=1 \} END \{ exit !found \}/ + ); + assert.match(snapStep, /disconnected_probe="\$\(/); + assert.match(snapStep, /disconnected_status=\$\?/); + assert.match(snapStep, /test "\$\{disconnected_status\}" -eq 1/); + assert.ok( + snapStep.includes( + `grep -Fx '{"usable":false,"reason":"snap-graphics-provider-unavailable"}'` + ) + ); + const firstGraphicsConnect = snapStep.indexOf( + 'sudo snap connect iptvnator:graphics-core22' + ); + const graphicsDisconnect = snapStep.indexOf( + 'sudo snap disconnect iptvnator:graphics-core22' + ); + const secondGraphicsConnect = snapStep.indexOf( + 'sudo snap connect iptvnator:graphics-core22', + firstGraphicsConnect + 1 + ); + assert.ok(firstGraphicsConnect < graphicsDisconnect); + assert.ok(graphicsDisconnect < snapStep.indexOf('disconnected_probe="$(')); + assert.ok( + snapStep.indexOf('test "${disconnected_status}" -eq 1') < + secondGraphicsConnect + ); + const firstRuntimeProbe = snapStep.indexOf( + 'snap run iptvnator --embedded-mpv-runtime-probe' + ); + const successfulRuntimeProbe = snapStep.lastIndexOf( + 'snap run iptvnator --embedded-mpv-runtime-probe' + ); + assert.ok(firstRuntimeProbe < secondGraphicsConnect); + assert.ok(firstRuntimeProbe < successfulRuntimeProbe); + assert.ok(secondGraphicsConnect < successfulRuntimeProbe); + assert.match(snapStep, /snap run iptvnator --embedded-mpv-runtime-probe/); + for (const hostileOverride of [ + '__EGL_VENDOR_LIBRARY_FILENAMES', + 'GBM_BACKEND', + 'MESA_LOADER_DRIVER_OVERRIDE', + 'LIBVA_DRIVER_NAME', + 'VDPAU_DRIVER_PATH', + 'VK_DRIVER_FILES', + 'VK_ICD_FILENAMES', + 'VK_ADD_DRIVER_FILES', + 'VK_ADD_LAYER_PATH', + 'VK_IMPLICIT_LAYER_PATH', + 'VK_ADD_IMPLICIT_LAYER_PATH', + 'XDG_CONFIG_HOME', + 'XDG_CONFIG_DIRS', + 'XDG_DATA_HOME', + 'XDG_DATA_DIRS', + ]) { + assert.match( + snapStep.slice(secondGraphicsConnect), + new RegExp(`${hostileOverride}=/tmp/hostile`) + ); + } + assert.doesNotMatch(snapStep, /snap run --shell/); + assert.doesNotMatch(snapStep, /iptvnator_mpv_helper/); + assert.doesNotMatch(snapStep, /LD_LIBRARY_PATH/); }); test('dedicated packaged x64 smoke cannot silently skip', () => { const linuxDependencies = workflowStep('Install Linux system dependencies'); assert.match(linuxDependencies, /--no-install-recommends/); + assert.match(linuxDependencies, /^\s+libgl-dev\s*\\?$/m); + assert.doesNotMatch(linuxDependencies, /\blibopengl-dev\b/); assert.match(linuxDependencies, /^\s+xauth\s*\\?$/m); assert.match(linuxDependencies, /^\s+xvfb\s*\\?$/m); const packagedSmoke = workflowStep( diff --git a/tools/packaging/electron-after-pack.cjs b/tools/packaging/electron-after-pack.cjs index 7e2cdc79b..ee6f9d640 100644 --- a/tools/packaging/electron-after-pack.cjs +++ b/tools/packaging/electron-after-pack.cjs @@ -162,6 +162,30 @@ function resolveLinuxFrameCopyPackagingContext( }; } +function ensureSnapGraphicsContentMount(appOutDir, targetNames) { + if (!targetNames.includes('snap')) { + return null; + } + + const mountPath = path.join(appOutDir, 'graphics'); + if (!fs.existsSync(mountPath)) { + fs.mkdirSync(mountPath, { mode: 0o755 }); + } + const stat = fs.lstatSync(mountPath); + if (!stat.isDirectory() || stat.isSymbolicLink()) { + throw new Error( + `Snap graphics content mount must be a real empty directory: ${mountPath}` + ); + } + if (fs.readdirSync(mountPath).length > 0) { + throw new Error( + `Snap graphics content mount directory must be empty: ${mountPath}` + ); + } + fs.chmodSync(mountPath, 0o755); + return mountPath; +} + async function afterPackHook(params) { const requireEmbeddedMpv = isTruthy( process.env.IPTVNATOR_REQUIRE_EMBEDDED_MPV @@ -175,6 +199,17 @@ async function afterPackHook(params) { ); await linuxAfterPack(params); + if (linuxPackagingContext) { + const graphicsMountPath = ensureSnapGraphicsContentMount( + params.appOutDir, + linuxPackagingContext.targetNames + ); + if (graphicsMountPath) { + log( + `prepared empty Snap graphics content mount at ${graphicsMountPath}` + ); + } + } log( requireEmbeddedMpv @@ -251,6 +286,7 @@ function getResourceDir(params) { } module.exports = afterPackHook; +module.exports.ensureSnapGraphicsContentMount = ensureSnapGraphicsContentMount; module.exports.resolveLinuxFrameCopyPackagingContext = resolveLinuxFrameCopyPackagingContext; module.exports.writeEmbeddedMpvUnavailableMarker = diff --git a/tools/packaging/electron-package-identity.test.mjs b/tools/packaging/electron-package-identity.test.mjs index e7224a1af..64d934b8d 100644 --- a/tools/packaging/electron-package-identity.test.mjs +++ b/tools/packaging/electron-package-identity.test.mjs @@ -340,6 +340,19 @@ test('embedded MPV runtime binaries are unpacked on every supported desktop plat } }); +test('embedded MPV native payload is owned exclusively by afterPack outside app.asar', () => { + assert.ok( + electronBuilderConfig.files.includes( + '!electron-backend/native{,/**/*}' + ), + 'electron-builder files must exclude the entire pre-afterPack native payload from app.asar' + ); + assert.match( + packageLayoutVerifier, + /collectEmbeddedMpvNativeArchiveEntries/ + ); +}); + test('embedded MPV package validation accepts Windows runtime files and Linux process isolation', () => { const tempDir = fs.mkdtempSync(join(os.tmpdir(), 'iptvnator-mpv-package-')); diff --git a/tools/packaging/embedded-mpv-arch.test.mjs b/tools/packaging/embedded-mpv-arch.test.mjs index ddcf8a810..67c72016a 100644 --- a/tools/packaging/embedded-mpv-arch.test.mjs +++ b/tools/packaging/embedded-mpv-arch.test.mjs @@ -31,13 +31,14 @@ const { generateLinuxRuntimeNotices, } = require('../embedded-mpv/generate-linux-runtime-notices.cjs'); const { + ensureSnapGraphicsContentMount, resolveLinuxFrameCopyPackagingContext, } = require('./electron-after-pack.cjs'); const X64_ADDON_ENV = { IPTVNATOR_EMBEDDED_MPV_ARCH: 'x64' }; const SYSTEM_PACKAGE_DEPENDENCIES = { - deb: ['libmpv2', 'libegl1', 'libopengl0', 'libgbm1'], - rpm: ['mpv-libs', 'libglvnd-egl', 'libglvnd-opengl', 'mesa-libgbm'], + deb: ['libmpv2', 'libegl1', 'libgl1', 'libgbm1'], + rpm: ['mpv-libs', 'libglvnd-egl', 'libglvnd-glx', 'mesa-libgbm'], pacman: ['mpv', 'libglvnd', 'mesa'], }; const FRAME_COPY_ARTIFACTS = { @@ -455,6 +456,68 @@ test('validates a provided Linux profile even when embedded MPV is optional', () ); }); +test('creates an exact empty Snap graphics content mount directory', (t) => { + const fixtureRoot = fs.mkdtempSync( + join(os.tmpdir(), 'iptvnator-snap-graphics-mount-') + ); + t.after(() => fs.rmSync(fixtureRoot, { recursive: true, force: true })); + + assert.equal( + ensureSnapGraphicsContentMount(fixtureRoot, ['appimage']), + null + ); + assert.equal(fs.existsSync(join(fixtureRoot, 'graphics')), false); + + const mountPath = ensureSnapGraphicsContentMount(fixtureRoot, [ + 'appimage', + 'snap', + ]); + assert.equal(mountPath, join(fixtureRoot, 'graphics')); + const mountStat = fs.lstatSync(mountPath); + assert.equal(mountStat.isDirectory(), true); + assert.equal(mountStat.isSymbolicLink(), false); + assert.equal(mountStat.mode & 0o777, 0o755); + assert.deepEqual(fs.readdirSync(mountPath), []); + + fs.chmodSync(mountPath, 0o700); + assert.equal( + ensureSnapGraphicsContentMount(fixtureRoot, ['snap']), + mountPath + ); + assert.equal(fs.lstatSync(mountPath).mode & 0o777, 0o755); +}); + +test('rejects a non-empty or redirected Snap graphics content mount', (t) => { + const fixtureRoot = fs.mkdtempSync( + join(os.tmpdir(), 'iptvnator-snap-graphics-invalid-') + ); + t.after(() => fs.rmSync(fixtureRoot, { recursive: true, force: true })); + const mountPath = join(fixtureRoot, 'graphics'); + + fs.writeFileSync(mountPath, 'not a directory'); + assert.throws( + () => ensureSnapGraphicsContentMount(fixtureRoot, ['snap']), + /real empty directory/ + ); + + fs.rmSync(mountPath); + fs.mkdirSync(mountPath); + fs.writeFileSync(join(mountPath, 'unexpected'), 'not empty'); + assert.throws( + () => ensureSnapGraphicsContentMount(fixtureRoot, ['snap']), + /must be empty/ + ); + + fs.rmSync(mountPath, { recursive: true }); + const outsidePath = join(fixtureRoot, 'outside'); + fs.mkdirSync(outsidePath); + fs.symlinkSync(outsidePath, mountPath, 'dir'); + assert.throws( + () => ensureSnapGraphicsContentMount(fixtureRoot, ['snap']), + /real empty directory/ + ); +}); + test('prepares a normalized system profile with no private runtime', (t) => { const fixture = createNativeFixture(); t.after(() => diff --git a/tools/packaging/linux-frame-copy-profile.cjs b/tools/packaging/linux-frame-copy-profile.cjs index 8d5eece55..0276e94c6 100644 --- a/tools/packaging/linux-frame-copy-profile.cjs +++ b/tools/packaging/linux-frame-copy-profile.cjs @@ -34,11 +34,11 @@ const SUPPORTED_PROFILE_NAMES = Object.freeze( ); const LINUX_SYSTEM_PACKAGE_DEPENDENCIES = Object.freeze({ - deb: Object.freeze(['libmpv2', 'libegl1', 'libopengl0', 'libgbm1']), + deb: Object.freeze(['libmpv2', 'libegl1', 'libgl1', 'libgbm1']), rpm: Object.freeze([ 'mpv-libs', 'libglvnd-egl', - 'libglvnd-opengl', + 'libglvnd-glx', 'mesa-libgbm', ]), pacman: Object.freeze(['mpv', 'libglvnd', 'mesa']), diff --git a/tools/packaging/linux-frame-copy-profile.test.mjs b/tools/packaging/linux-frame-copy-profile.test.mjs index e21578f52..8d3f858e0 100644 --- a/tools/packaging/linux-frame-copy-profile.test.mjs +++ b/tools/packaging/linux-frame-copy-profile.test.mjs @@ -60,8 +60,8 @@ test('defines the exact immutable Linux frame-copy profile matrix', () => { test('defines immutable system-package helper runtime dependencies', () => { assert.deepEqual(LINUX_SYSTEM_PACKAGE_DEPENDENCIES, { - deb: ['libmpv2', 'libegl1', 'libopengl0', 'libgbm1'], - rpm: ['mpv-libs', 'libglvnd-egl', 'libglvnd-opengl', 'mesa-libgbm'], + deb: ['libmpv2', 'libegl1', 'libgl1', 'libgbm1'], + rpm: ['mpv-libs', 'libglvnd-egl', 'libglvnd-glx', 'mesa-libgbm'], pacman: ['mpv', 'libglvnd', 'mesa'], }); assert.equal(Object.isFrozen(LINUX_SYSTEM_PACKAGE_DEPENDENCIES), true); diff --git a/tools/packaging/verify-electron-package-layout.mjs b/tools/packaging/verify-electron-package-layout.mjs index 839fe08be..ab94e048c 100644 --- a/tools/packaging/verify-electron-package-layout.mjs +++ b/tools/packaging/verify-electron-package-layout.mjs @@ -3,7 +3,10 @@ import { createRequire } from 'module'; import path from 'path'; import { buildElectronBuilderMetadata } from './generate-electron-builder-metadata.mjs'; -import { inspectPackagedDependencyClosure } from './asar-dependency-closure.mjs'; +import { + collectEmbeddedMpvNativeArchiveEntries, + inspectPackagedDependencyClosure, +} from './asar-dependency-closure.mjs'; const require = createRequire(import.meta.url); const { extractFile, listPackage } = require('@electron/asar'); @@ -621,6 +624,35 @@ function verifyPackagedDependencyClosure(resourceDir, errors) { ); } +function verifyNoEmbeddedMpvNativeArchiveEntries(resourceDir, errors) { + const asarPath = path.join(resourceDir, 'app.asar'); + if (!fileExists(asarPath)) { + // Missing archive is already reported by verifyPackagedPackageMetadata. + return; + } + + let nativeEntries; + try { + nativeEntries = collectEmbeddedMpvNativeArchiveEntries( + listPackage(asarPath) + ); + } catch (error) { + errors.push( + `Unable to inspect embedded MPV archive ownership in ${asarPath}: ${error.message}` + ); + return; + } + + if (nativeEntries.length > 0) { + errors.push( + [ + `Packaged app.asar must not contain embedded MPV native payloads; afterPack exclusively owns the profile-specific unpacked directory in ${asarPath}.`, + ...nativeEntries.map((entry) => `- ${entry}`), + ].join('\n') + ); + } +} + // Official Linux frame-copy support is x64-only. Every arm64/armv7l output // must carry the unavailable marker instead of native frame-copy artifacts. function isForeignArchLinuxResourceDir(resourceDir) { @@ -652,6 +684,7 @@ function verifyResourceDir(resourceDir) { verifyPackagedPackageMetadata(resourceDir, errors); verifyPackagedDependencyClosure(resourceDir, errors); + verifyNoEmbeddedMpvNativeArchiveEntries(resourceDir, errors); if (missingWorkers.length > 0) { errors.push( diff --git a/tools/packaging/verify-linux-frame-copy-runtime.mjs b/tools/packaging/verify-linux-frame-copy-runtime.mjs index cfe6856a6..13acaac61 100644 --- a/tools/packaging/verify-linux-frame-copy-runtime.mjs +++ b/tools/packaging/verify-linux-frame-copy-runtime.mjs @@ -7,7 +7,10 @@ import { spawnSync } from 'node:child_process'; import { createRequire } from 'node:module'; import { fileURLToPath } from 'node:url'; +import { collectEmbeddedMpvNativeArchiveEntries } from './asar-dependency-closure.mjs'; + const require = createRequire(import.meta.url); +const { listPackage: defaultListAsarPackage } = require('@electron/asar'); const { parseReadelfDynamic, } = require('../embedded-mpv/build-linux-runtime.cjs'); @@ -25,6 +28,40 @@ const { const scriptPath = fileURLToPath(import.meta.url); const LIBMPV_DEPENDENCY_PATTERN = /^libmpv\.so(?:\.|$)/; +// Keep this set identical to the packaged helper sanitizer in +// embedded-mpv-frame-copy-runtime/helper-environment.ts. Feature/debug +// selectors such as LIBGL_ALWAYS_SOFTWARE remain intentionally available. +const UNSAFE_RUNTIME_PROBE_ENVIRONMENT_VARIABLES = [ + 'BASH_ENV', + 'ENV', + 'BASHOPTS', + 'SHELLOPTS', + 'PS4', + 'BASH_XTRACEFD', + 'CDPATH', + 'LD_AUDIT', + 'LD_LIBRARY_PATH', + 'LD_ORIGIN_PATH', + 'LD_PRELOAD', + '__EGL_EXTERNAL_PLATFORM_CONFIG_DIRS', + '__EGL_EXTERNAL_PLATFORM_CONFIG_FILENAMES', + '__EGL_VENDOR_LIBRARY_DIRS', + '__EGL_VENDOR_LIBRARY_FILENAMES', + 'GBM_BACKEND', + 'GBM_BACKENDS_PATH', + 'LIBGL_DRIVERS_PATH', + 'MESA_LOADER_DRIVER_OVERRIDE', + 'LIBVA_DRIVER_NAME', + 'LIBVA_DRIVERS_PATH', + 'VDPAU_DRIVER_PATH', + 'VK_DRIVER_FILES', + 'VK_ICD_FILENAMES', + 'VK_ADD_DRIVER_FILES', + 'VK_ADD_LAYER_PATH', + 'VK_IMPLICIT_LAYER_PATH', + 'VK_ADD_IMPLICIT_LAYER_PATH', + 'VK_LAYER_PATH', +]; function defaultRunCommand(command, args, options = {}) { return spawnSync(command, args, { @@ -464,6 +501,50 @@ function directYamlMappingEntries(lines, parent, indent) { return entries; } +function directYamlAbsolutePathMappingEntries(lines, parent, indent) { + const entries = []; + for (let index = parent.index + 1; index < parent.end; index += 1) { + const line = lines[index]; + if (!line.trim() || line.trimStart().startsWith('#')) { + continue; + } + const lineIndent = line.length - line.trimStart().length; + if (lineIndent !== indent) { + continue; + } + const match = line + .slice(indent) + .match(/^(\/[A-Za-z0-9._/-]+):(?:\s*(.*))?$/); + if (!match) { + return null; + } + let blockEnd = parent.end; + for ( + let candidateIndex = index + 1; + candidateIndex < parent.end; + candidateIndex += 1 + ) { + const candidate = lines[candidateIndex]; + if (!candidate.trim() || candidate.trimStart().startsWith('#')) { + continue; + } + const candidateIndent = + candidate.length - candidate.trimStart().length; + if (candidateIndent <= indent) { + blockEnd = candidateIndex; + break; + } + } + entries.push({ + key: match[1], + value: stripYamlTrailingComment(match[2] ?? ''), + index, + end: blockEnd, + }); + } + return entries; +} + function yamlScalarEquals(value, expected) { return ( value === expected || @@ -617,6 +698,134 @@ export function validateExtractedSnapMetadata(extractionRoot) { ]; } const errors = []; + const graphicsMountPath = path.join(extractionRoot, 'graphics'); + let graphicsMountStat; + try { + graphicsMountStat = fs.lstatSync(graphicsMountPath); + } catch { + errors.push( + `Extracted Snap must contain an empty graphics content mount directory: ${graphicsMountPath}` + ); + } + if ( + graphicsMountStat && + (!graphicsMountStat.isDirectory() || graphicsMountStat.isSymbolicLink()) + ) { + errors.push( + `Extracted Snap graphics content mount must be a real directory: ${graphicsMountPath}` + ); + } else if (graphicsMountStat) { + if ((graphicsMountStat.mode & 0o777) !== 0o755) { + errors.push( + `Extracted Snap graphics content mount directory must have mode 0755: ${graphicsMountPath}` + ); + } + try { + if (fs.readdirSync(graphicsMountPath).length > 0) { + errors.push( + `Extracted Snap graphics content mount directory must be empty: ${graphicsMountPath}` + ); + } + } catch (error) { + errors.push( + `Unable to inspect extracted Snap graphics content mount at ${graphicsMountPath}: ${ + error instanceof Error ? error.message : String(error) + }` + ); + } + } + const base = singleYamlMappingEntry(lines, 'base', 0, 0, lines.length); + if (!base || !yamlScalarEquals(base.value, 'core22')) { + errors.push('Extracted Snap metadata must declare base: core22.'); + } + const confinement = singleYamlMappingEntry( + lines, + 'confinement', + 0, + 0, + lines.length + ); + if (!confinement || !yamlScalarEquals(confinement.value, 'strict')) { + errors.push( + 'Extracted Snap metadata must declare confinement: strict.' + ); + } + const layout = singleYamlMappingEntry(lines, 'layout', 0, 0, lines.length); + if (!layout || layout.value) { + errors.push( + 'Extracted Snap metadata must declare the exact Snap graphics layout contract.' + ); + } else { + const layoutEntries = directYamlAbsolutePathMappingEntries( + lines, + layout, + 2 + ); + const expectedLayouts = [ + { + path: '/usr/share/libdrm', + field: 'bind', + target: '$SNAP/graphics/libdrm', + label: 'libdrm', + }, + { + path: '/usr/share/drirc.d', + field: 'symlink', + target: '$SNAP/graphics/drirc.d', + label: 'drirc.d', + }, + ]; + if ( + layoutEntries && + new Set(layoutEntries.map(({ key }) => key)).size !== + layoutEntries.length + ) { + errors.push('Extracted Snap layout paths must be unique.'); + } + if ( + !layoutEntries || + layoutEntries.length !== expectedLayouts.length || + expectedLayouts.some( + ({ path: expectedPath }) => + layoutEntries.filter(({ key }) => key === expectedPath) + .length !== 1 + ) + ) { + errors.push( + 'Extracted Snap graphics layout must contain exactly the libdrm and drirc.d entries.' + ); + } + for (const expected of expectedLayouts) { + const entry = layoutEntries?.find( + ({ key }) => key === expected.path + ); + const fields = + entry && !entry.value + ? directYamlMappingEntries(lines, entry, 4) + : null; + if ( + !fields || + fields.length !== 1 || + fields[0].key !== expected.field + ) { + errors.push( + `Extracted Snap ${expected.label} layout must contain exactly ${expected.field}.` + ); + } + if ( + !fields || + fields.filter( + ({ key, value }) => + key === expected.field && + yamlScalarEquals(value, expected.target) + ).length !== 1 + ) { + errors.push( + `Extracted Snap ${expected.label} layout must declare ${expected.field}: ${expected.target}.` + ); + } + } + } const plugs = singleYamlMappingEntry(lines, 'plugs', 0, 0, lines.length); const sharedMemory = plugs && @@ -627,6 +836,15 @@ export function validateExtractedSnapMetadata(extractionRoot) { plugs.index + 1, plugs.end ); + const graphicsCore22 = + plugs && + singleYamlMappingEntry( + lines, + 'graphics-core22', + 2, + plugs.index + 1, + plugs.end + ); if (!plugs || plugs.value || !sharedMemory || sharedMemory.value) { errors.push( 'Extracted Snap metadata must declare exactly one top-level shared-memory plug.' @@ -701,6 +919,116 @@ export function validateExtractedSnapMetadata(extractionRoot) { } } + if (!plugs || plugs.value || !graphicsCore22 || graphicsCore22.value) { + errors.push( + 'Extracted Snap metadata must declare exactly one top-level graphics-core22 plug.' + ); + } else { + const fields = directYamlMappingEntries(lines, graphicsCore22, 4); + const interfaceEntries = + fields?.filter(({ key }) => key === 'interface') ?? []; + const targetEntries = + fields?.filter(({ key }) => key === 'target') ?? []; + const providerEntries = + fields?.filter(({ key }) => key === 'default-provider') ?? []; + const interfaceEntry = interfaceEntries[0]; + const targetEntry = targetEntries[0]; + const providerEntry = providerEntries[0]; + if ( + !fields || + fields.length !== 3 || + interfaceEntries.length !== 1 || + targetEntries.length !== 1 || + providerEntries.length !== 1 + ) { + errors.push( + 'Extracted Snap graphics-core22 plug must contain exactly interface, target, and default-provider.' + ); + } + if ( + !interfaceEntry || + !yamlScalarEquals(interfaceEntry.value, 'content') + ) { + errors.push( + 'Extracted Snap graphics-core22 plug must declare interface: content.' + ); + } + if ( + !targetEntry || + !yamlScalarEquals(targetEntry.value, '$SNAP/graphics') + ) { + errors.push( + 'Extracted Snap graphics-core22 plug must declare target: $SNAP/graphics.' + ); + } + if ( + !providerEntry || + !yamlScalarEquals(providerEntry.value, 'mesa-core22') + ) { + errors.push( + 'Extracted Snap graphics-core22 plug must declare default-provider: mesa-core22.' + ); + } + + const plugEntries = directYamlMappingEntries(lines, plugs, 2); + const graphicsContracts = + plugEntries?.filter((plugEntry) => { + const plugFields = directYamlMappingEntries( + lines, + plugEntry, + 4 + ); + if (!plugFields || plugFields.length !== 3) { + return false; + } + return [ + ['interface', 'content'], + ['target', '$SNAP/graphics'], + ['default-provider', 'mesa-core22'], + ].every( + ([key, expected]) => + plugFields.filter( + ({ key: fieldKey, value }) => + fieldKey === key && + yamlScalarEquals(value, expected) + ).length === 1 + ); + }) ?? []; + if ( + !plugEntries || + graphicsContracts.length !== 1 || + graphicsContracts[0].key !== 'graphics-core22' + ) { + errors.push( + 'Extracted Snap metadata must declare exactly one plug with the graphics-core22 contract.' + ); + } + const graphicsTargets = + plugEntries?.filter((plugEntry) => { + const plugFields = directYamlMappingEntries( + lines, + plugEntry, + 4 + ); + return ( + plugFields?.some( + ({ key, value }) => + key === 'target' && + yamlScalarEquals(value, '$SNAP/graphics') + ) ?? false + ); + }) ?? []; + if ( + !plugEntries || + graphicsTargets.length !== 1 || + graphicsTargets[0].key !== 'graphics-core22' + ) { + errors.push( + 'Extracted Snap metadata must declare exactly one plug with target $SNAP/graphics.' + ); + } + } + const slotsEntries = yamlMappingEntries(lines, 'slots', 0, 0, lines.length); let hasSharedMemorySlot = slotsEntries.length > 1; let invalidSlotsShape = slotsEntries.some(({ value }) => value.length > 0); @@ -743,6 +1071,18 @@ export function validateExtractedSnapMetadata(extractionRoot) { const appPlugs = app && singleYamlMappingEntry(lines, 'plugs', 4, app.index + 1, app.end); + const appEnvironment = + app && + singleYamlMappingEntry(lines, 'environment', 4, app.index + 1, app.end); + const snapDesktopRuntime = + appEnvironment && + singleYamlMappingEntry( + lines, + 'SNAP_DESKTOP_RUNTIME', + 6, + appEnvironment.index + 1, + appEnvironment.end + ); if ( !apps || apps.value || @@ -755,6 +1095,28 @@ export function validateExtractedSnapMetadata(extractionRoot) { 'Extracted Snap iptvnator app scalar sequence must contain the shared-memory plug.' ); } + if ( + !apps || + apps.value || + !app || + app.value || + !appPlugs || + !yamlSequenceIncludes(lines, appPlugs, 'graphics-core22') + ) { + errors.push( + 'Extracted Snap iptvnator app scalar sequence must contain the graphics-core22 plug.' + ); + } + if ( + !appEnvironment || + appEnvironment.value || + !snapDesktopRuntime || + !yamlScalarEquals(snapDesktopRuntime.value, '$SNAP/gnome-platform') + ) { + errors.push( + 'Extracted Snap iptvnator app environment must declare SNAP_DESKTOP_RUNTIME: $SNAP/gnome-platform.' + ); + } return errors; } @@ -1104,15 +1466,52 @@ export function createRuntimeProbeEnvironment({ runtimeMode, }) { const probeEnvironment = { ...(environment ?? {}) }; - delete probeEnvironment.LD_AUDIT; - delete probeEnvironment.LD_LIBRARY_PATH; - delete probeEnvironment.LD_PRELOAD; + for (const variableName of UNSAFE_RUNTIME_PROBE_ENVIRONMENT_VARIABLES) { + delete probeEnvironment[variableName]; + } + for (const variableName of Object.keys(probeEnvironment)) { + if (variableName.startsWith('BASH_FUNC_')) { + delete probeEnvironment[variableName]; + } + } if (runtimeMode === 'bundled') { probeEnvironment.LD_LIBRARY_PATH = path.join(nativeDir, 'lib'); } return probeEnvironment; } +function validateNoEmbeddedMpvNativeArchiveEntries( + resourceDir, + asarListPackage +) { + const asarPath = path.join(resourceDir, 'app.asar'); + if (!fs.existsSync(asarPath)) { + return []; + } + + let nativeEntries; + try { + nativeEntries = collectEmbeddedMpvNativeArchiveEntries( + asarListPackage(asarPath) + ); + } catch (error) { + return [ + `Unable to inspect embedded MPV archive ownership in ${asarPath}: ${ + error instanceof Error ? error.message : String(error) + }`, + ]; + } + if (nativeEntries.length === 0) { + return []; + } + return [ + [ + `Packaged app.asar must not contain embedded MPV native payloads; afterPack exclusively owns the profile-specific unpacked directory in ${asarPath}.`, + ...nativeEntries.map((entry) => `- ${entry}`), + ].join('\n'), + ]; +} + export function verifyExtractedLinuxFrameCopyRuntime({ resourceDir, artifactFormat, @@ -1122,6 +1521,7 @@ export function verifyExtractedLinuxFrameCopyRuntime({ elfInspector = defaultElfInspector, probeRunner = defaultRunCommand, environment = process.env, + asarListPackage = defaultListAsarPackage, }) { const errors = []; let profile; @@ -1135,13 +1535,22 @@ export function verifyExtractedLinuxFrameCopyRuntime({ `Linux frame-copy profile "${profile.name}" does not include target "${artifactFormat}".`, ]; } + errors.push( + ...validateNoEmbeddedMpvNativeArchiveEntries( + resourceDir, + asarListPackage + ) + ); const electronPath = path.join(path.dirname(resourceDir), 'iptvnator.bin'); let packageArch; try { packageArch = readElfArchitecture(electronPath); } catch (error) { - return [error instanceof Error ? error.message : String(error)]; + return [ + ...errors, + error instanceof Error ? error.message : String(error), + ]; } const foreignArch = packageArch !== 'x64'; if (declaredArch && declaredArch !== packageArch) { diff --git a/tools/packaging/verify-linux-frame-copy-runtime.test.mjs b/tools/packaging/verify-linux-frame-copy-runtime.test.mjs index 0844e948d..e26671edf 100644 --- a/tools/packaging/verify-linux-frame-copy-runtime.test.mjs +++ b/tools/packaging/verify-linux-frame-copy-runtime.test.mjs @@ -77,8 +77,8 @@ const SYSTEM_MANIFEST = { nativeViewFallback: 'process-isolated mpv --wid', libmpvSoname: 'libmpv.so.2', packageDependencies: { - deb: ['libmpv2', 'libegl1', 'libopengl0', 'libgbm1'], - rpm: ['mpv-libs', 'libglvnd-egl', 'libglvnd-opengl', 'mesa-libgbm'], + deb: ['libmpv2', 'libegl1', 'libgl1', 'libgbm1'], + rpm: ['mpv-libs', 'libglvnd-egl', 'libglvnd-glx', 'mesa-libgbm'], pacman: ['mpv', 'libglvnd', 'mesa'], }, runtimeFiles: [], @@ -87,7 +87,7 @@ const SYSTEM_MANIFEST = { const DEB_SYSTEM_PACKAGE_DEPENDENCIES = [ 'libmpv2', 'libegl1', - 'libopengl0', + 'libgl1', 'libgbm1', ]; @@ -624,7 +624,7 @@ test('requires every direct helper runtime dependency for DEB, RPM, and Pacman', validateSystemPackageDependencies('deb', [ 'libmpv2 (>= 0.35)', 'libegl1', - 'libopengl0', + 'libgl1', 'libgbm1', 'libc6', ]), @@ -634,7 +634,7 @@ test('requires every direct helper runtime dependency for DEB, RPM, and Pacman', validateSystemPackageDependencies('rpm', [ 'mpv-libs', 'libglvnd-egl', - 'libglvnd-opengl', + 'libglvnd-glx', 'mesa-libgbm', 'glibc', ]), @@ -655,7 +655,7 @@ test('requires every direct helper runtime dependency for DEB, RPM, and Pacman', 'libegl1', 'libgbm1', ])[0], - /libopengl0/ + /libgl1/ ); }); @@ -758,7 +758,7 @@ test('validates an x64 system payload and executes one bounded helper probe', () packageDependencies: [ 'libmpv2 (>= 0.35)', 'libegl1', - 'libopengl0', + 'libgl1', 'libgbm1', ], elfInspector: validElfInspector, @@ -792,6 +792,32 @@ test('validates an x64 system payload and executes one bounded helper probe', () } }); +test('rejects any stale embedded MPV native payload hidden inside app.asar', () => { + const fixture = createSystemPayload(); + fs.writeFileSync(path.join(fixture.resourceDir, 'app.asar'), 'fixture'); + try { + const errors = verifyExtractedLinuxFrameCopyRuntime({ + resourceDir: fixture.resourceDir, + artifactFormat: 'deb', + profileName: 'system', + packageDependencies: DEB_SYSTEM_PACKAGE_DEPENDENCIES, + elfInspector: validElfInspector, + probeRunner: successfulProbeRunner, + asarListPackage: () => [ + '/electron-backend/main.js', + '/electron-backend/native/iptvnator_mpv_helper', + '/electron-backend/native/lib/libmpv.so.2', + ], + }); + assert.match( + errors.join('\n'), + /app\.asar must not contain embedded MPV native payloads.*iptvnator_mpv_helper.*libmpv\.so\.2/s + ); + } finally { + fs.rmSync(fixture.root, { recursive: true, force: true }); + } +}); + test('excludes only Snap template library roots from Electron isolation checks', () => { const fixture = createSystemPayload({ architecture: 'arm64' }); const packageLibraryDirs = [ @@ -969,27 +995,42 @@ test('rejects helper probes terminated by a signal or hard timeout', () => { } }); -test('requires a private top-level shared-memory plug used by the Snap app', () => { +test('requires exact Snap graphics layouts and plugs used by the app', () => { const root = fs.mkdtempSync( path.join(os.tmpdir(), 'iptvnator-verifier-snap-metadata-') ); const snapYamlPath = path.join(root, 'meta', 'snap.yaml'); fs.mkdirSync(path.dirname(snapYamlPath), { recursive: true }); + fs.mkdirSync(path.join(root, 'graphics')); const validSnapYaml = [ 'name: iptvnator', + 'base: core22', + 'confinement: strict', 'summary: "*literal &anchor !tag <<: is quoted"', '# *commented-alias &commented-anchor !commented-tag', 'apps:', ' iptvnator:', ' command: iptvnator', + ' environment:', + ' SNAP_DESKTOP_RUNTIME: $SNAP/gnome-platform', ' plugs:', ' - desktop', ' - shared-memory', + ' - graphics-core22', 'plugs:', ' shared-memory:', ' interface: shared-memory', ' private: true', + ' graphics-core22:', + ' interface: content', + ' target: $SNAP/graphics', + ' default-provider: mesa-core22', + 'layout:', + ' /usr/share/libdrm:', + ' bind: $SNAP/graphics/libdrm', + ' /usr/share/drirc.d:', + ' symlink: $SNAP/graphics/drirc.d', '', ].join('\n'); @@ -997,7 +1038,111 @@ test('requires a private top-level shared-memory plug used by the Snap app', () fs.writeFileSync(snapYamlPath, validSnapYaml); assert.deepEqual(validateExtractedSnapMetadata(root), []); + fs.rmSync(path.join(root, 'graphics'), { recursive: true }); + assert.match( + validateExtractedSnapMetadata(root).join('\n'), + /empty graphics content mount directory/i + ); + fs.mkdirSync(path.join(root, 'graphics')); + fs.writeFileSync(path.join(root, 'graphics', 'unexpected'), 'data'); + assert.match( + validateExtractedSnapMetadata(root).join('\n'), + /graphics content mount directory must be empty/i + ); + fs.rmSync(path.join(root, 'graphics'), { recursive: true }); + const outsideGraphics = path.join(root, 'outside-graphics'); + fs.mkdirSync(outsideGraphics); + fs.symlinkSync(outsideGraphics, path.join(root, 'graphics'), 'dir'); + assert.match( + validateExtractedSnapMetadata(root).join('\n'), + /graphics content mount.*real directory/i + ); + fs.rmSync(path.join(root, 'graphics')); + fs.mkdirSync(path.join(root, 'graphics')); + fs.chmodSync(path.join(root, 'graphics'), 0o700); + assert.match( + validateExtractedSnapMetadata(root).join('\n'), + /graphics content mount directory must have mode 0755/i + ); + fs.chmodSync(path.join(root, 'graphics'), 0o755); + for (const [mutate, expected] of [ + [ + (contents) => contents.replace('base: core22', 'base: core20'), + /base: core22/i, + ], + [ + (contents) => + contents.replace( + 'confinement: strict', + 'confinement: classic' + ), + /confinement: strict/i, + ], + [ + (contents) => + contents.replace( + 'layout:\n /usr/share/libdrm:\n bind: $SNAP/graphics/libdrm\n /usr/share/drirc.d:\n symlink: $SNAP/graphics/drirc.d\n', + '' + ), + /exact Snap graphics layout contract/i, + ], + [ + (contents) => + contents.replace( + ' /usr/share/drirc.d:\n', + ' /usr/share/extra:\n bind: $SNAP/graphics/extra\n /usr/share/drirc.d:\n' + ), + /exactly the libdrm and drirc.d entries/i, + ], + [ + (contents) => + contents.replace( + ' /usr/share/drirc.d:\n', + ' /usr/share/libdrm:\n bind: $SNAP/graphics/libdrm\n /usr/share/drirc.d:\n' + ), + /layout paths must be unique/i, + ], + [ + (contents) => + contents.replace( + ' bind: $SNAP/graphics/libdrm', + ' bind: $SNAP/graphics/wrong-libdrm' + ), + /libdrm.*bind: \$SNAP\/graphics\/libdrm/i, + ], + [ + (contents) => + contents.replace( + ' symlink: $SNAP/graphics/drirc.d', + ' symlink: $SNAP/graphics/wrong-drirc.d' + ), + /drirc.d.*symlink: \$SNAP\/graphics\/drirc.d/i, + ], + [ + (contents) => + contents.replace( + ' bind: $SNAP/graphics/libdrm\n', + ' bind: $SNAP/graphics/libdrm\n type: directory\n' + ), + /libdrm layout.*exactly bind/i, + ], + [ + (contents) => + contents.replace( + ' SNAP_DESKTOP_RUNTIME: $SNAP/gnome-platform\n', + '' + ), + /SNAP_DESKTOP_RUNTIME.*\$SNAP\/gnome-platform/i, + ], + [ + (contents) => + contents.replace( + '$SNAP/gnome-platform', + '$SNAP/hostile-platform' + ), + /SNAP_DESKTOP_RUNTIME.*\$SNAP\/gnome-platform/i, + ], [ (contents) => contents.replace(' private: true', ' private: false'), @@ -1007,6 +1152,10 @@ test('requires a private top-level shared-memory plug used by the Snap app', () (contents) => contents.replace(' - shared-memory\n', ''), /app.*shared-memory plug/i, ], + [ + (contents) => contents.replace(' - graphics-core22\n', ''), + /app.*graphics-core22 plug/i, + ], [ (contents) => contents.replace( @@ -1015,6 +1164,79 @@ test('requires a private top-level shared-memory plug used by the Snap app', () ), /top-level shared-memory plug/i, ], + [ + (contents) => + contents.replace( + ' graphics-core22:\n interface: content\n target: $SNAP/graphics\n default-provider: mesa-core22\n', + '' + ), + /top-level graphics-core22 plug/i, + ], + [ + (contents) => + contents.replace( + ' interface: content', + ' interface: opengl' + ), + /graphics-core22.*interface: content/i, + ], + [ + (contents) => + contents.replace( + ' target: $SNAP/graphics', + ' target: $SNAP/wrong-graphics' + ), + /graphics-core22.*target: \$SNAP\/graphics/i, + ], + [ + (contents) => + contents.replace( + ' default-provider: mesa-core22', + ' default-provider: wrong-provider' + ), + /graphics-core22.*default-provider: mesa-core22/i, + ], + [ + (contents) => + contents.replace( + ' default-provider: mesa-core22\n', + ' default-provider: mesa-core22\n source: graphics-core22\n' + ), + /graphics-core22 plug.*exactly interface, target, and default-provider/i, + ], + [ + (contents) => + contents.replace( + '\nplugs:\n', + [ + '', + 'plugs:', + ' duplicate-graphics:', + ' interface: content', + ' target: $SNAP/graphics', + ' default-provider: mesa-core22', + '', + ].join('\n') + ), + /exactly one plug.*graphics-core22 contract/i, + ], + [ + (contents) => + contents.replace( + '\nplugs:\n', + [ + '', + 'plugs:', + ' competing-graphics:', + ' interface: content', + ' target: $SNAP/graphics', + ' default-provider: hostile-provider', + ' content: alternate-graphics', + '', + ].join('\n') + ), + /exactly one plug.*target \$SNAP\/graphics/i, + ], [ (contents) => contents.replace( @@ -1150,18 +1372,32 @@ test('requires a private top-level shared-memory plug used by the Snap app', () const SNAP_METADATA_WITH_LITERAL_HASHES = [ 'name: iptvnator', + 'base: core22', + 'confinement: strict', 'summary: "quoted # is scalar data"', 'description: | # block scalar header comment', ' Block scalar # stays literal.', 'apps:', ' iptvnator:', ' command: iptvnator', + ' environment:', + ' SNAP_DESKTOP_RUNTIME: $SNAP/gnome-platform', ' plugs:', ' - shared-memory', + ' - graphics-core22', 'plugs:', ' shared-memory:', ' interface: shared-memory', ' private: true', + ' graphics-core22:', + ' interface: content', + ' target: $SNAP/graphics', + ' default-provider: mesa-core22', + 'layout:', + ' /usr/share/libdrm:', + ' bind: $SNAP/graphics/libdrm', + ' /usr/share/drirc.d:', + ' symlink: $SNAP/graphics/drirc.d', '', ].join('\n'); @@ -1194,6 +1430,7 @@ for (const [kind, mutate, expected] of [ ); const snapYamlPath = path.join(root, 'meta', 'snap.yaml'); fs.mkdirSync(path.dirname(snapYamlPath), { recursive: true }); + fs.mkdirSync(path.join(root, 'graphics')); try { fs.writeFileSync(snapYamlPath, SNAP_METADATA_WITH_LITERAL_HASHES); @@ -1213,82 +1450,131 @@ for (const [kind, mutate, expected] of [ }); } -test('artifact verification rejects Snap metadata before accepting its payload', () => { - const fixture = createSystemPayload(); - const artifactPath = path.join(fixture.root, 'package.snap'); - const snapYamlPath = path.join(fixture.root, 'meta', 'snap.yaml'); - fs.writeFileSync(artifactPath, 'fixture'); - fs.mkdirSync(path.dirname(snapYamlPath), { recursive: true }); - fs.writeFileSync( - snapYamlPath, - [ - 'name: iptvnator', - 'apps:', - ' iptvnator:', - ' command: iptvnator', - ' plugs:', - ' - desktop', - 'plugs:', - ' shared-memory:', - ' interface: shared-memory', - ' private: true', - '', - ].join('\n') - ); - let payloadVerifierCalls = 0; - - const verify = () => - verifyLinuxFrameCopyArtifact({ - artifactPath, - profileName: 'portable', - extractArtifact() { - return fixture.root; - }, - metadataReader() { - return { declaredArch: 'x64', dependencies: [] }; - }, - payloadVerifier() { - payloadVerifierCalls += 1; - return []; - }, - }); - - try { - assert.throws(verify, /app.*shared-memory plug/i); - assert.equal(payloadVerifierCalls, 0); - +test('artifact verification enforces Snap metadata for x64 and ARM payloads', () => { + for (const architecture of ['x64', 'arm64', 'armv7l']) { + const fixture = createSystemPayload({ architecture }); + const artifactPath = path.join(fixture.root, 'package.snap'); + const snapYamlPath = path.join(fixture.root, 'meta', 'snap.yaml'); + fs.writeFileSync(artifactPath, 'fixture'); + fs.mkdirSync(path.dirname(snapYamlPath), { recursive: true }); + fs.mkdirSync(path.join(fixture.root, 'graphics')); fs.writeFileSync( snapYamlPath, - fs - .readFileSync(snapYamlPath, 'utf8') - .replace(' - desktop\n', ' - shared-memory\n') + [ + 'name: iptvnator', + 'base: core22', + 'confinement: strict', + 'apps:', + ' iptvnator:', + ' command: iptvnator', + ' environment:', + ' SNAP_DESKTOP_RUNTIME: $SNAP/gnome-platform', + ' plugs:', + ' - desktop', + ' - graphics-core22', + 'plugs:', + ' shared-memory:', + ' interface: shared-memory', + ' private: true', + ' graphics-core22:', + ' interface: content', + ' target: $SNAP/graphics', + ' default-provider: mesa-core22', + 'layout:', + ' /usr/share/libdrm:', + ' bind: $SNAP/graphics/libdrm', + ' /usr/share/drirc.d:', + ' symlink: $SNAP/graphics/drirc.d', + '', + ].join('\n') ); - assert.deepEqual(verify(), { - artifactPath, - format: 'snap', - profileName: 'portable', - architecture: 'x64', - }); - assert.equal(payloadVerifierCalls, 1); - } finally { - fs.rmSync(fixture.root, { recursive: true, force: true }); + let payloadVerifierCalls = 0; + + const verify = () => + verifyLinuxFrameCopyArtifact({ + artifactPath, + profileName: 'portable', + extractArtifact() { + return fixture.root; + }, + metadataReader() { + return { declaredArch: architecture, dependencies: [] }; + }, + payloadVerifier() { + payloadVerifierCalls += 1; + return []; + }, + }); + + try { + assert.throws(verify, /app.*shared-memory plug/i); + assert.equal(payloadVerifierCalls, 0); + + fs.writeFileSync( + snapYamlPath, + fs + .readFileSync(snapYamlPath, 'utf8') + .replace(' - desktop\n', ' - shared-memory\n') + ); + assert.deepEqual(verify(), { + artifactPath, + format: 'snap', + profileName: 'portable', + architecture, + }); + assert.equal(payloadVerifierCalls, 1); + } finally { + fs.rmSync(fixture.root, { recursive: true, force: true }); + } } }); -test('bundled probes use only the packaged library directory', () => { +test('bundled probes remove ambient loader paths and use only packaged libraries', () => { assert.deepEqual( createRuntimeProbeEnvironment({ environment: { PATH: '/usr/bin', + BASH_ENV: '/host/hostile-bash-env', + ENV: '/host/hostile-shell-env', + BASHOPTS: 'extdebug', + SHELLOPTS: 'xtrace', + PS4: '$(/host/hostile-trace-hook)', + BASH_XTRACEFD: '9', + CDPATH: '/host/hostile-cdpath', + 'BASH_FUNC_dirname%%': + '() { printf /host/hostile-provider-root; }', LD_AUDIT: '/host/can-inject-audit.so', LD_LIBRARY_PATH: '/host/can-mask-missing-dependencies', + LD_ORIGIN_PATH: '/host/can-change-origin', LD_PRELOAD: '/host/can-inject.so', + __EGL_EXTERNAL_PLATFORM_CONFIG_DIRS: + '/host/egl/external-platform', + __EGL_EXTERNAL_PLATFORM_CONFIG_FILENAMES: + '/host/egl/external-platform.json', + __EGL_VENDOR_LIBRARY_DIRS: '/host/egl/vendor', + __EGL_VENDOR_LIBRARY_FILENAMES: '/host/egl/vendor/host.json', + GBM_BACKEND: 'host-gbm', + GBM_BACKENDS_PATH: '/host/gbm', + LIBGL_DRIVERS_PATH: '/host/dri', + MESA_LOADER_DRIVER_OVERRIDE: 'host-dri', + LIBVA_DRIVER_NAME: 'host-va', + LIBVA_DRIVERS_PATH: '/host/va', + VDPAU_DRIVER_PATH: '/host/vdpau', + VK_DRIVER_FILES: '/host/vulkan/driver.json', + VK_ICD_FILENAMES: '/host/vulkan/icd.json', + VK_ADD_DRIVER_FILES: '/host/vulkan/add-driver.json', + VK_ADD_LAYER_PATH: '/host/vulkan/add-layer', + VK_IMPLICIT_LAYER_PATH: '/host/vulkan/implicit-layer', + VK_ADD_IMPLICIT_LAYER_PATH: '/host/vulkan/add-implicit-layer', + VK_LAYER_PATH: '/host/vulkan/layer', + LIBGL_ALWAYS_SOFTWARE: '1', }, nativeDir: '/package/resources/native', runtimeMode: 'bundled', }), { PATH: '/usr/bin', + LIBGL_ALWAYS_SOFTWARE: '1', LD_LIBRARY_PATH: '/package/resources/native/lib', } ); diff --git a/vendor/embedded-mpv/README.md b/vendor/embedded-mpv/README.md index eda5a014a..6943b7936 100644 --- a/vendor/embedded-mpv/README.md +++ b/vendor/embedded-mpv/README.md @@ -26,10 +26,17 @@ Linux package profiles consume that one staged x64 source runtime differently: - AppImage/Snap/Flatpak retain the manifest-declared closure under `app.asar.unpacked/electron-backend/native/lib/` and flatten the validated notice manifest, aggregate notice, and `licenses/**` tree beside it. +- The strict Snap resolves the helper's remaining graphics interfaces through + the external `mesa-core22` content provider at `$SNAP/graphics`; that shared + provider is not staged below `vendor/embedded-mpv/`, bundled into IPTVnator, + or included in IPTVnator's source/notices archive. - DEB/RPM/Pacman and marker-only packages do not retain bundled-runtime notices or license files. - Non-x64 Linux packages retain no native artifacts and ship only the unavailable marker. +- Electron Builder excludes the staged native tree from `app.asar`; only + `afterPack` writes the profile-specific unpacked payload, and package + verification rejects archived native entries. The DEB dependency is specifically `libmpv2` (verified on Ubuntu 24.04+). Ubuntu 22.04 provides `libmpv1`; use the x64 AppImage there.