diff --git a/.github/workflows/build-and-make.yaml b/.github/workflows/build-and-make.yaml index bd0abee32..da7a9b5ec 100644 --- a/.github/workflows/build-and-make.yaml +++ b/.github/workflows/build-and-make.yaml @@ -12,8 +12,216 @@ on: workflow_dispatch: jobs: + linux-embedded-mpv-runtime: + name: Build pinned Linux Embedded MPV runtime + runs-on: ubuntu-22.04 + timeout-minutes: 120 + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + + - name: Resolve Linux runtime toolchain cache key + id: linux-runtime-cache-key + shell: bash + run: | + set -euo pipefail + + sudo apt-get update + { + apt-cache policy \ + binutils build-essential cmake curl git \ + libasound2-dev libdrm-dev libegl-dev libgbm-dev \ + libgl-dev libpulse-dev libva-dev make nasm \ + ninja-build patchelf perl pkg-config python3-pip \ + tar xz-utils + echo 'meson=1.7.2' + } > "${RUNNER_TEMP}/linux-runtime-toolchain.txt" + TOOLCHAIN_SHA256="$(sha256sum "${RUNNER_TEMP}/linux-runtime-toolchain.txt" | cut -d ' ' -f 1)" + SOURCE_SHA256="${{ hashFiles('tools/embedded-mpv/build-linux-runtime.cjs', 'tools/embedded-mpv/build-linux-runtime.mjs', 'tools/embedded-mpv/linux-runtime-manifest.cjs', 'tools/embedded-mpv/stage-runtime.mjs') }}" + echo "toolchain-sha256=${TOOLCHAIN_SHA256}" >> "${GITHUB_OUTPUT}" + echo "key=linux-frame-copy-runtime-v4-ubuntu-22.04-${TOOLCHAIN_SHA256}-${SOURCE_SHA256}" >> "${GITHUB_OUTPUT}" + + - name: Restore pinned Linux runtime and source compliance bundle + id: linux-runtime-cache + uses: actions/cache@v4 + with: + path: | + vendor/embedded-mpv/linux-x64 + dist/compliance/linux-frame-copy-runtime-sources.tar.xz + key: ${{ steps.linux-runtime-cache-key.outputs.key }} + + - name: Install pinned Linux runtime build dependencies + if: steps.linux-runtime-cache.outputs.cache-hit != 'true' + shell: bash + run: | + set -euo pipefail + + sudo apt-get install --no-install-recommends -y \ + binutils \ + build-essential \ + cmake \ + curl \ + git \ + libasound2-dev \ + libdrm-dev \ + libegl-dev \ + libgbm-dev \ + libgl-dev \ + libpulse-dev \ + libva-dev \ + make \ + nasm \ + ninja-build \ + patchelf \ + perl \ + pkg-config \ + python3-pip \ + tar \ + xz-utils + python3 -m pip install --user 'meson==1.7.2' + + - name: Build and stage pinned LGPL Linux runtime + if: steps.linux-runtime-cache.outputs.cache-hit != 'true' + shell: bash + run: | + set -euo pipefail + + export PATH="${HOME}/.local/bin:${PATH}" + export IPTVNATOR_EMBEDDED_MPV_LINUX_BUILD_ROOT="${RUNNER_TEMP}/linux-frame-copy-runtime-build" + export RUNTIME_PREFIX="${RUNNER_TEMP}/linux-frame-copy-runtime-prefix" + + node tools/embedded-mpv/build-linux-runtime.mjs "${RUNTIME_PREFIX}" + node tools/embedded-mpv/stage-runtime.mjs linux x64 "${RUNTIME_PREFIX}" + + export SOURCE_BUNDLE_ROOT="${RUNNER_TEMP}/linux-frame-copy-runtime-sources" + rm -rf "${SOURCE_BUNDLE_ROOT}" + mkdir -p \ + "${SOURCE_BUNDLE_ROOT}/archives" \ + "${SOURCE_BUNDLE_ROOT}/git" \ + "${SOURCE_BUNDLE_ROOT}/metadata" \ + "${SOURCE_BUNDLE_ROOT}/notices" \ + "${SOURCE_BUNDLE_ROOT}/tooling" + + cp -a "${IPTVNATOR_EMBEDDED_MPV_LINUX_BUILD_ROOT}/archives/." "${SOURCE_BUNDLE_ROOT}/archives/" + cp -a "${IPTVNATOR_EMBEDDED_MPV_LINUX_BUILD_ROOT}/sources/libplacebo" "${SOURCE_BUNDLE_ROOT}/git/libplacebo" + cp "${RUNTIME_PREFIX}/runtime-manifest.json" "${SOURCE_BUNDLE_ROOT}/metadata/runtime-manifest.json" + cp \ + tools/embedded-mpv/build-linux-runtime.cjs \ + tools/embedded-mpv/build-linux-runtime.mjs \ + tools/embedded-mpv/linux-runtime-manifest.cjs \ + tools/embedded-mpv/stage-runtime.mjs \ + "${SOURCE_BUNDLE_ROOT}/tooling/" + find "${IPTVNATOR_EMBEDDED_MPV_LINUX_BUILD_ROOT}/sources/hwdata" \ + -maxdepth 1 -type f -iname 'license*' \ + -exec cp '{}' "${SOURCE_BUNDLE_ROOT}/notices/hwdata-LICENSE" ';' + test -f "${SOURCE_BUNDLE_ROOT}/notices/hwdata-LICENSE" + git rev-parse HEAD > "${SOURCE_BUNDLE_ROOT}/metadata/iptvnator-git-revision.txt" + git diff --binary HEAD -- tools/embedded-mpv > "${SOURCE_BUNDLE_ROOT}/metadata/local-embedded-mpv-changes.patch" + node <<'NODE' + const childProcess = require('node:child_process'); + const crypto = require('node:crypto'); + const fs = require('node:fs'); + const path = require('node:path'); + + const manifest = JSON.parse( + fs.readFileSync(path.join(process.env.RUNTIME_PREFIX, 'runtime-manifest.json'), 'utf8') + ); + const archivesDirectory = path.join(process.env.SOURCE_BUNDLE_ROOT, 'archives'); + const archives = fs.readdirSync(archivesDirectory).sort().map((name) => { + const contents = fs.readFileSync(path.join(archivesDirectory, name)); + return { + name, + sha256: crypto.createHash('sha256').update(contents).digest('hex'), + }; + }); + const archiveHashes = new Set(archives.map(({ sha256 }) => sha256)); + for (const [packageName, source] of Object.entries(manifest.packages)) { + if (source.sourceSha256 && !archiveHashes.has(source.sourceSha256)) { + throw new Error( + `Source bundle is missing ${packageName} archive ${source.sourceSha256}.` + ); + } + } + + const libplaceboCheckout = path.join( + process.env.SOURCE_BUNDLE_ROOT, + 'git', + 'libplacebo' + ); + const gitOutput = (...args) => + childProcess.execFileSync('git', ['-C', libplaceboCheckout, ...args], { + encoding: 'utf8', + }).trim(); + const sourceGitCommit = gitOutput('rev-parse', 'HEAD'); + if (sourceGitCommit !== manifest.packages.libplacebo.sourceGitCommit) { + throw new Error('Bundled libplacebo checkout commit does not match the runtime manifest.'); + } + const sourceSubmodules = gitOutput('submodule', 'status', '--recursive') + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean); + if ( + JSON.stringify(sourceSubmodules) !== + JSON.stringify(manifest.packages.libplacebo.sourceSubmodules) + ) { + throw new Error('Bundled libplacebo submodules do not match the runtime manifest.'); + } + + fs.writeFileSync( + path.join(process.env.SOURCE_BUNDLE_ROOT, 'metadata', 'source-index.json'), + `${JSON.stringify( + { + schemaVersion: 1, + sourcePackages: manifest.packages, + archives, + libplacebo: { + sourceGitCommit, + sourceSubmodules, + }, + }, + null, + 2 + )}\n` + ); + NODE + ( + cd "${SOURCE_BUNDLE_ROOT}/archives" + sha256sum * > "../metadata/archive-sha256.txt" + ) + + mkdir -p dist/compliance + tar \ + --create \ + --xz \ + --file dist/compliance/linux-frame-copy-runtime-sources.tar.xz \ + --directory "${SOURCE_BUNDLE_ROOT}" \ + . + + - name: Upload staged Linux runtime + uses: actions/upload-artifact@v4 + with: + name: linux-embedded-mpv-runtime + path: vendor/embedded-mpv/linux-x64 + if-no-files-found: error + retention-days: 7 + + - name: Upload Linux runtime source compliance + uses: actions/upload-artifact@v4 + with: + name: linux-frame-copy-runtime-sources + path: dist/compliance/linux-frame-copy-runtime-sources.tar.xz + if-no-files-found: error + retention-days: 7 + build: name: Build on ${{ matrix.os }} ${{ matrix.arch }} + needs: linux-embedded-mpv-runtime runs-on: ${{ matrix.runner }} timeout-minutes: 120 strategy: @@ -32,10 +240,16 @@ jobs: embedded_mpv_platform: darwin embedded_mpv_arch: arm64 embedded_mpv_build_runtime: true - # Linux and Windows + # Linux frame-copy profiles and Windows - os: linux runner: ubuntu-22.04 - linux_profile: standard + linux_profile: system + embedded_mpv_platform: linux + embedded_mpv_arch: x64 + embedded_mpv_build_runtime: false + - os: linux + runner: ubuntu-22.04 + linux_profile: portable embedded_mpv_platform: linux embedded_mpv_arch: x64 embedded_mpv_build_runtime: false @@ -68,38 +282,50 @@ jobs: if: matrix.os == 'linux' run: | sudo apt-get update - sudo apt-get install --no-install-recommends -y rpm libarchive-tools flatpak flatpak-builder appstream libx11-dev libxext-dev libmpv-dev mpv pkg-config libegl-dev libgl-dev libopengl-dev libgbm-dev + sudo apt-get install --no-install-recommends -y \ + appstream \ + binutils \ + flatpak \ + flatpak-builder \ + libarchive-tools \ + libegl-dev \ + libgbm-dev \ + libgl-dev \ + libopengl-dev \ + libx11-dev \ + libxext-dev \ + mpv \ + pkg-config \ + rpm \ + snapd \ + squashfs-tools \ + xauth \ + xvfb + + - name: Configure Flatpak build runtime + if: matrix.os == 'linux' && matrix.linux_profile == 'flatpak' + run: | + set -euo pipefail - # Configure Flatpak - # 1. Add the Flathub repository (source of runtimes) flatpak remote-add --if-not-exists --user flathub https://flathub.org/repo/flathub.flatpakrepo - - # 2. Install the standard Freedesktop Platform and SDK (required by electron-builder) - # We install version 24.08 as a safe default, electron-builder might pick what it needs flatpak install --user -y flathub org.freedesktop.Platform//24.08 org.freedesktop.Sdk//24.08 - name: Select Linux packaging targets for CI profile if: matrix.os == 'linux' run: | - node -e " - const fs = require('fs'); - const path = 'electron-builder.json'; - const config = JSON.parse(fs.readFileSync(path, 'utf8')); - const targets = Array.isArray(config.linux?.target) ? config.linux.target : []; - const profile = '${{ matrix.linux_profile }}'; - - if (profile === 'standard') { - config.linux.target = targets.filter((entry) => String(entry.target).toLowerCase() !== 'flatpak'); - } else if (profile === 'flatpak') { - config.linux.target = targets.filter((entry) => String(entry.target).toLowerCase() === 'flatpak'); - } - - fs.writeFileSync(path, JSON.stringify(config, null, 4) + '\n'); - " + cp electron-builder.json "${RUNNER_TEMP}/electron-builder.base.json" + node tools/packaging/configure-linux-frame-copy-build.mjs --profile "${{ matrix.linux_profile }}" - name: Install dependencies run: pnpm install --frozen-lockfile + - name: Download pinned Linux Embedded MPV runtime + if: matrix.os == 'linux' + uses: actions/download-artifact@v4 + with: + name: linux-embedded-mpv-runtime + path: vendor/embedded-mpv/linux-x64 + - name: Inject TMDB API key # No-op when the secret is unavailable (e.g. fork PRs) — the # app then requires a user-provided key for TMDB enrichment. @@ -113,7 +339,7 @@ jobs: - name: Resolve embedded MPV runtime cache key # TEMPORARY ARTIFACT TEST: remove `|| github.event_name == 'pull_request' || github.ref == 'refs/heads/master'` # after the macOS Embedded MPV artifacts are built and manually tested. - if: matrix.embedded_mpv_platform && !startsWith(github.ref, 'refs/tags/v') && (github.event_name == 'pull_request' || github.ref == 'refs/heads/master') + if: matrix.embedded_mpv_platform && matrix.os != 'linux' && !startsWith(github.ref, 'refs/tags/v') && (github.event_name == 'pull_request' || github.ref == 'refs/heads/master') id: embedded-mpv-runtime-cache-key shell: bash env: @@ -186,7 +412,7 @@ jobs: - name: Restore embedded MPV runtime cache # TEMPORARY ARTIFACT TEST: remove `|| github.event_name == 'pull_request' || github.ref == 'refs/heads/master'` # after the macOS Embedded MPV artifacts are built and manually tested. - if: matrix.embedded_mpv_platform && !startsWith(github.ref, 'refs/tags/v') && (github.event_name == 'pull_request' || github.ref == 'refs/heads/master') + if: matrix.embedded_mpv_platform && matrix.os != 'linux' && !startsWith(github.ref, 'refs/tags/v') && (github.event_name == 'pull_request' || github.ref == 'refs/heads/master') id: embedded-mpv-runtime-cache uses: actions/cache/restore@v4 with: @@ -199,7 +425,7 @@ jobs: - name: Clear stale embedded MPV runtime files # TEMPORARY ARTIFACT TEST: remove `|| github.event_name == 'pull_request' || github.ref == 'refs/heads/master'` # after the macOS Embedded MPV artifacts are built and manually tested. - if: matrix.embedded_mpv_platform && !startsWith(github.ref, 'refs/tags/v') && (github.event_name == 'pull_request' || github.ref == 'refs/heads/master') && steps.embedded-mpv-runtime-cache.outputs.cache-hit != 'true' + if: matrix.embedded_mpv_platform && matrix.os != 'linux' && !startsWith(github.ref, 'refs/tags/v') && (github.event_name == 'pull_request' || github.ref == 'refs/heads/master') && steps.embedded-mpv-runtime-cache.outputs.cache-hit != 'true' shell: bash run: | set -euo pipefail @@ -254,47 +480,11 @@ jobs: pnpm embedded-mpv:stage-runtime:windows-archive -- "${WINDOWS_RUNTIME_URL}" "${WINDOWS_RUNTIME_SHA256}" - - name: Stage Linux embedded MPV build inputs - if: matrix.os == 'linux' - shell: bash - run: | - set -euo pipefail - - RUNTIME_PREFIX="${RUNNER_TEMP}/embedded-mpv-runtime/linux-x64/prefix" - rm -rf "${RUNTIME_PREFIX}" - mkdir -p "${RUNTIME_PREFIX}/include" - - cp -a /usr/include/mpv "${RUNTIME_PREFIX}/include/" - - LIBMPV_DEV_VERSION="$(dpkg-query -W -f='${Version}' libmpv-dev)" - MPV_VERSION="$(dpkg-query -W -f='${Version}' mpv)" - export RUNTIME_PREFIX LIBMPV_DEV_VERSION MPV_VERSION - node <<'NODE' - const fs = require('fs'); - const path = require('path'); - - const manifest = { - linuxBackend: 'process-isolated mpv --wid', - buildInputs: { - libmpvDevPackage: process.env.LIBMPV_DEV_VERSION, - mpvPackage: process.env.MPV_VERSION, - }, - sourceDistribution: - 'Linux CI build inputs come from Ubuntu runner packages. Runtime playback uses the system mpv executable; IPTVnator does not bundle or load libmpv in the Electron process on Linux.', - }; - - fs.writeFileSync( - path.join(process.env.RUNTIME_PREFIX, 'runtime-manifest.json'), - `${JSON.stringify(manifest, null, 2)}\n` - ); - NODE - - pnpm embedded-mpv:stage-runtime -- linux x64 "${RUNTIME_PREFIX}" - - name: Build backend env: IPTVNATOR_EMBEDDED_MPV_PLATFORM: ${{ matrix.embedded_mpv_platform || '' }} IPTVNATOR_EMBEDDED_MPV_ARCH: ${{ matrix.embedded_mpv_arch || matrix.arch || '' }} + IPTVNATOR_LINUX_FRAME_COPY_PROFILE: ${{ matrix.linux_profile || '' }} IPTVNATOR_REQUIRE_EMBEDDED_MPV: ${{ (matrix.os == 'linux' || matrix.os == 'windows' || (matrix.os == 'macos' && (startsWith(github.ref, 'refs/tags/v') || github.event_name == 'pull_request' || github.ref == 'refs/heads/master'))) && '1' || '0' }} run: pnpm run build:backend @@ -331,27 +521,29 @@ jobs: find dist/apps/electron-backend/native -maxdepth 1 \( -name 'mpv-2.dll' -o -name 'libmpv-2.dll' -o -name 'mpv.dll' -o -name 'libmpv.dll' \) -print -quit | grep -q . ;; linux) - node -e "const manifest = require('./dist/apps/electron-backend/native/embedded-mpv-runtime.json'); if (manifest.origin !== 'external-mpv-process') { throw new Error('Linux embedded MPV manifest must use external-mpv-process origin.'); }" - if find dist/apps/electron-backend/native/lib -name 'libmpv.so*' -print -quit 2>/dev/null | grep -q .; then - echo "::error::Linux embedded MPV packages must not bundle libmpv" - find dist/apps/electron-backend/native/lib -name 'libmpv.so*' -print - exit 1 - fi - if ldd dist/apps/electron-backend/native/embedded_mpv.node | grep -q 'libmpv'; then - echo "::error::Linux embedded MPV addon must not link directly to libmpv" - ldd dist/apps/electron-backend/native/embedded_mpv.node - exit 1 - fi - # The frame-copy helper is the inverse: a separate process - # that MUST link libmpv (dev-mode engine; stripped from - # packages until the bundled-runtime staging lands). - # test -f, not -x: the webpack dist asset copy drops file - # modes; consumers restore the bit (after-pack) or require - # it via the X_OK support probe. + node -e "const manifest = require('./dist/apps/electron-backend/native/embedded-mpv-runtime.json'); if (manifest.origin !== 'linux-frame-copy-build' || manifest.sourceRuntimeValidated !== true) { throw new Error('Linux embedded MPV build manifest must describe the validated source runtime.'); }" + test -f dist/apps/electron-backend/native/lib/libmpv.so.2 test -f dist/apps/electron-backend/native/iptvnator_mpv_helper - if ! ldd dist/apps/electron-backend/native/iptvnator_mpv_helper | grep -q 'libmpv'; then - echo "::error::Linux frame-copy helper must link libmpv" - ldd dist/apps/electron-backend/native/iptvnator_mpv_helper + test -f dist/apps/electron-backend/native/embedded_mpv_frame_reader.node + if readelf -d dist/apps/electron-backend/native/embedded_mpv.node | grep -Eq 'Shared library:.*libmpv\.so'; then + echo "::error::Linux embedded MPV addon must not link directly to libmpv" + readelf -d dist/apps/electron-backend/native/embedded_mpv.node + exit 1 + fi + if readelf -d dist/apps/electron-backend/native/embedded_mpv_frame_reader.node | grep -Eq 'Shared library:.*libmpv\.so'; then + echo "::error::Linux frame reader must not link directly to libmpv" + readelf -d dist/apps/electron-backend/native/embedded_mpv_frame_reader.node + exit 1 + fi + HELPER_DYNAMIC="$(readelf -d dist/apps/electron-backend/native/iptvnator_mpv_helper)" + if ! printf '%s\n' "${HELPER_DYNAMIC}" | grep -Eq 'Shared library: \[libmpv\.so\.2\]'; then + echo "::error::Linux frame-copy helper must need libmpv.so.2" + printf '%s\n' "${HELPER_DYNAMIC}" + exit 1 + fi + if ! printf '%s\n' "${HELPER_DYNAMIC}" | grep -Fq 'Library runpath: [$ORIGIN/lib]'; then + echo "::error::Linux frame-copy helper must keep only the relative runtime path" + printf '%s\n' "${HELPER_DYNAMIC}" exit 1 fi ;; @@ -557,6 +749,7 @@ jobs: env: IPTVNATOR_EMBEDDED_MPV_PLATFORM: ${{ matrix.embedded_mpv_platform || '' }} IPTVNATOR_EMBEDDED_MPV_ARCH: ${{ matrix.embedded_mpv_arch || matrix.arch || '' }} + IPTVNATOR_LINUX_FRAME_COPY_PROFILE: ${{ matrix.linux_profile || '' }} # TEMPORARY PR TEST: change this back to '0' after manually # testing the macOS PR artifact with Embedded MPV included. IPTVNATOR_REQUIRE_EMBEDDED_MPV: ${{ (matrix.os == 'linux' || matrix.os == 'windows' || (matrix.os == 'macos' && github.event_name == 'pull_request')) && '1' || '0' }} @@ -567,11 +760,189 @@ jobs: env: PACKAGE_OS: ${{ matrix.os }} PACKAGE_ARCH: ${{ matrix.arch || matrix.embedded_mpv_arch || '' }} + IPTVNATOR_LINUX_FRAME_COPY_PROFILE: ${{ matrix.linux_profile || '' }} # TEMPORARY ARTIFACT TEST: remove `|| github.event_name == 'pull_request' || github.ref == 'refs/heads/master'` # after the macOS Embedded MPV artifacts are built and manually tested. IPTVNATOR_REQUIRE_EMBEDDED_MPV: ${{ (matrix.os == 'linux' || matrix.os == 'windows' || (matrix.os == 'macos' && (startsWith(github.ref, 'refs/tags/v') || github.event_name == 'pull_request' || github.ref == 'refs/heads/master'))) && '1' || '0' }} run: pnpm run verify:package-layout -- "$PACKAGE_OS" "$PACKAGE_ARCH" + - name: Make marker-only foreign-architecture DEB packages + if: matrix.os == 'linux' && matrix.linux_profile == 'system' + shell: bash + env: + IPTVNATOR_EMBEDDED_MPV_PLATFORM: linux + IPTVNATOR_EMBEDDED_MPV_ARCH: x64 + IPTVNATOR_LINUX_FRAME_COPY_PROFILE: '' + IPTVNATOR_REQUIRE_EMBEDDED_MPV: '1' + run: | + set -euo pipefail + + cp "${RUNNER_TEMP}/electron-builder.base.json" electron-builder.json + node tools/packaging/configure-linux-frame-copy-build.mjs --foreign-deb + pnpm nx run electron-backend:make \ + --outputPath=dist/executables-linux-foreign \ + --publishPolicy=never + find dist/executables-linux-foreign -maxdepth 1 -type f -name '*.deb' \ + -exec mv '{}' dist/executables/ ';' + + - name: Verify DEB payloads and x64 system runtime + if: matrix.os == 'linux' && matrix.linux_profile == 'system' + shell: bash + run: | + set -euo pipefail + + found=false + for artifact in dist/executables/*.deb; do + test -f "${artifact}" || continue + found=true + case "$(dpkg-deb --field "${artifact}" Architecture)" in + amd64) + docker run --rm \ + --volume "${GITHUB_WORKSPACE}:/workspace:ro" \ + --volume "$(realpath "${artifact}"):/artifact.deb:ro" \ + --workdir /workspace \ + ubuntu:24.04 \ + bash -euo pipefail -c ' + apt-get update + DEBIAN_FRONTEND=noninteractive apt-get install --no-install-recommends -y \ + binutils libegl1 libgl1-mesa-dri libmpv2 nodejs squashfs-tools xauth xvfb + xvfb-run -a env LIBGL_ALWAYS_SOFTWARE=1 \ + node tools/packaging/verify-linux-frame-copy-runtime.mjs \ + --artifact /artifact.deb --profile system + ' + ;; + arm64|armhf) + node tools/packaging/verify-linux-frame-copy-runtime.mjs \ + --artifact "${artifact}" --profile system + ;; + *) + echo "::error::Unexpected DEB architecture in ${artifact}" + exit 1 + ;; + esac + done + test "${found}" = true + + - name: Verify RPM payload and x64 system runtime + if: matrix.os == 'linux' && matrix.linux_profile == 'system' + shell: bash + run: | + set -euo pipefail + + artifact="$(find dist/executables -maxdepth 1 -type f -name '*.rpm' -print -quit)" + test -n "${artifact}" + docker run --rm \ + --volume "${GITHUB_WORKSPACE}:/workspace:ro" \ + --volume "$(realpath "${artifact}"):/artifact.rpm:ro" \ + --workdir /workspace \ + fedora:latest \ + bash -euo pipefail -c ' + dnf install -y \ + binutils bsdtar mesa-dri-drivers mpv-libs nodejs rpm \ + xorg-x11-server-Xvfb xorg-x11-xauth + xvfb-run -a env LIBGL_ALWAYS_SOFTWARE=1 \ + node tools/packaging/verify-linux-frame-copy-runtime.mjs \ + --artifact /artifact.rpm --profile system + ' + + - name: Verify Pacman payload and x64 system runtime + if: matrix.os == 'linux' && matrix.linux_profile == 'system' + shell: bash + run: | + set -euo pipefail + + artifact="$(find dist/executables -maxdepth 1 -type f \( -name '*.pacman' -o -name '*.pkg.tar.*' \) -print -quit)" + test -n "${artifact}" + docker run --rm \ + --volume "${GITHUB_WORKSPACE}:/workspace:ro" \ + --volume "$(realpath "${artifact}"):/artifact.pacman:ro" \ + --workdir /workspace \ + archlinux:latest \ + bash -euo pipefail -c ' + pacman -Syu --noconfirm \ + binutils libarchive mesa mpv nodejs xorg-server-xvfb xorg-xauth + xvfb-run -a env LIBGL_ALWAYS_SOFTWARE=1 \ + node tools/packaging/verify-linux-frame-copy-runtime.mjs \ + --artifact /artifact.pacman --profile system + ' + + - name: Verify AppImage payloads and bundled runtime + if: matrix.os == 'linux' && matrix.linux_profile == 'portable' + shell: bash + run: | + set -euo pipefail + + found=false + for artifact in dist/executables/*.AppImage; do + test -f "${artifact}" || continue + found=true + xvfb-run -a env LIBGL_ALWAYS_SOFTWARE=1 \ + node tools/packaging/verify-linux-frame-copy-runtime.mjs \ + --artifact "${artifact}" --profile portable + done + test "${found}" = true + + - name: Verify Snap payloads and strict-confinement runtime + if: matrix.os == 'linux' && matrix.linux_profile == 'portable' + shell: bash + run: | + set -euo pipefail + + found=false + installed_x64=false + for artifact in dist/executables/*.snap; do + test -f "${artifact}" || continue + found=true + verification="$( + xvfb-run -a env LIBGL_ALWAYS_SOFTWARE=1 \ + node tools/packaging/verify-linux-frame-copy-runtime.mjs \ + --artifact "${artifact}" --profile portable + )" + printf '%s\n' "${verification}" + if printf '%s\n' "${verification}" | grep -Fq 'Verified snap x64 Linux'; then + 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 + ' + + - name: Run packaged x64 frame-copy and fallback smoke + if: matrix.os == 'linux' && matrix.linux_profile == 'portable' + env: + IPTVNATOR_E2E_REQUIRE_PACKAGED_FRAME_COPY: '1' + IPTVNATOR_E2E_PACKAGED_EXECUTABLE: ${{ github.workspace }}/dist/executables/linux-unpacked/iptvnator + LIBGL_ALWAYS_SOFTWARE: '1' + run: | + xvfb-run -a pnpm nx run \ + electron-backend-e2e:packaged-frame-copy-smoke \ + --skip-nx-cache + + - name: Diagnose packaged x64 frame-copy hardware path + if: matrix.os == 'linux' && matrix.linux_profile == 'portable' + continue-on-error: true + env: + IPTVNATOR_E2E_REQUIRE_PACKAGED_FRAME_COPY: '1' + IPTVNATOR_E2E_PACKAGED_EXECUTABLE: ${{ github.workspace }}/dist/executables/linux-unpacked/iptvnator + run: | + set -euo pipefail + + if [ ! -e /dev/dri/renderD128 ]; then + echo "::notice::No /dev/dri/renderD128 is available; skipping the non-blocking hardware-path diagnostic." + exit 0 + fi + ls -la /dev/dri + xvfb-run -a pnpm nx run \ + electron-backend-e2e:packaged-frame-copy-smoke \ + --skip-nx-cache + - name: Save embedded MPV runtime cache # TEMPORARY ARTIFACT TEST: remove `|| github.ref == 'refs/heads/master'` # after the macOS Embedded MPV artifacts are built and manually tested. @@ -584,7 +955,7 @@ jobs: vendor/embedded-mpv/${{ matrix.embedded_mpv_platform }}-${{ matrix.embedded_mpv_arch }}/runtime-manifest.json key: ${{ steps.embedded-mpv-runtime-cache-key.outputs.key }} - - name: Smoke test packaged Flatpak launcher + - name: Verify Flatpak payload, launcher, and sandboxed runtime if: matrix.os == 'linux' && matrix.linux_profile == 'flatpak' shell: bash run: | @@ -596,8 +967,12 @@ jobs: exit 1 fi + xvfb-run -a env LIBGL_ALWAYS_SOFTWARE=1 \ + node tools/packaging/verify-linux-frame-copy-runtime.mjs \ + --artifact "${FLATPAK_BUNDLE}" --profile flatpak flatpak install --user --noninteractive -y "${FLATPAK_BUNDLE}" - flatpak run --command=sh com.fourgray.iptvnator -c ' + xvfb-run -a env LIBGL_ALWAYS_SOFTWARE=1 \ + flatpak run --command=sh com.fourgray.iptvnator -c ' set -euo pipefail test -f /app/share/metainfo/com.fourgray.iptvnator.metainfo.xml @@ -608,6 +983,9 @@ 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 ' - name: Upload artifacts (macOS) @@ -622,23 +1000,31 @@ jobs: dist/executables/**/*.blockmap retention-days: 7 - - name: Upload artifacts (Linux) - if: matrix.os == 'linux' && matrix.linux_profile == 'standard' + - name: Upload system-runtime Linux artifacts + if: matrix.os == 'linux' && matrix.linux_profile == 'system' uses: actions/upload-artifact@v4 with: - name: linux-artifacts + name: linux-system-artifacts path: | - dist/executables/**/*.deb - dist/executables/**/*.rpm - dist/executables/**/*.snap - dist/executables/**/*.AppImage - dist/executables/**/*.tar.gz - dist/executables/**/*.pacman + dist/executables/*.deb + dist/executables/*.rpm + dist/executables/*.pacman + dist/executables/*.pkg.tar.* + retention-days: 7 + + - name: Upload portable-runtime Linux artifacts + if: matrix.os == 'linux' && matrix.linux_profile == 'portable' + uses: actions/upload-artifact@v4 + with: + name: linux-portable-artifacts + path: | + dist/executables/*.AppImage + dist/executables/*.snap dist/executables/**/latest-linux*.yml dist/executables/**/*.blockmap retention-days: 7 - - name: Upload artifacts (Flatpak) + - name: Upload Flatpak-runtime Linux artifacts if: matrix.os == 'linux' && matrix.linux_profile == 'flatpak' uses: actions/upload-artifact@v4 with: @@ -813,15 +1199,16 @@ jobs: artifacts/macos-arm64-artifacts/*-arm64.zip artifacts/macos-arm64-artifacts/*.blockmap artifacts/latest-mac.yml - artifacts/linux-artifacts/*.AppImage - artifacts/linux-artifacts/*.deb - artifacts/linux-artifacts/*.rpm - artifacts/linux-artifacts/*.snap - artifacts/linux-artifacts/*.tar.gz - artifacts/linux-artifacts/*.pacman - artifacts/linux-artifacts/latest-linux*.yml - artifacts/linux-artifacts/*.blockmap + artifacts/linux-system-artifacts/*.deb + artifacts/linux-system-artifacts/*.rpm + artifacts/linux-system-artifacts/*.pacman + artifacts/linux-system-artifacts/*.pkg.tar.* + artifacts/linux-portable-artifacts/*.AppImage + artifacts/linux-portable-artifacts/*.snap + artifacts/linux-portable-artifacts/latest-linux*.yml + artifacts/linux-portable-artifacts/*.blockmap artifacts/linux-flatpak-artifacts/*.flatpak + artifacts/linux-frame-copy-runtime-sources/linux-frame-copy-runtime-sources.tar.xz artifacts/windows-artifacts/*-setup.exe artifacts/windows-artifacts/*.msi artifacts/windows-artifacts/*.zip @@ -842,7 +1229,7 @@ jobs: - name: Download snap artifact uses: actions/download-artifact@v4 with: - name: linux-artifacts + name: linux-portable-artifacts path: artifacts - name: Setup Snapcraft diff --git a/tools/packaging/configure-linux-frame-copy-build.mjs b/tools/packaging/configure-linux-frame-copy-build.mjs new file mode 100644 index 000000000..bdfc90495 --- /dev/null +++ b/tools/packaging/configure-linux-frame-copy-build.mjs @@ -0,0 +1,223 @@ +#!/usr/bin/env node + +import fs from 'node:fs'; +import path from 'node:path'; +import { createRequire } from 'node:module'; +import { fileURLToPath } from 'node:url'; + +const require = createRequire(import.meta.url); +const { + LINUX_SYSTEM_PACKAGE_DEPENDENCIES, + resolveLinuxFrameCopyProfile, +} = require('./linux-frame-copy-profile.cjs'); +const scriptPath = fileURLToPath(import.meta.url); + +function cloneJson(value) { + return JSON.parse(JSON.stringify(value)); +} + +function targetName(target) { + const value = + typeof target === 'string' + ? target + : target && typeof target === 'object' + ? target.target + : null; + if (typeof value !== 'string' || value.trim() === '') { + throw new Error( + 'Electron Builder Linux targets must have a non-empty target name.' + ); + } + return value.trim().toLowerCase(); +} + +function targetObject(target) { + return typeof target === 'string' ? { target } : { ...target }; +} + +function fpmDependencyName(option) { + return String(option).match( + /^--depends(?:=|\s+)([A-Za-z0-9+_.-]+)(?:$|\s|[<>=])/ + )?.[1]; +} + +function configureSystemDependencies(config) { + const frameCopyDependencies = new Set( + Object.values(LINUX_SYSTEM_PACKAGE_DEPENDENCIES) + ); + for (const [format, dependency] of Object.entries( + LINUX_SYSTEM_PACKAGE_DEPENDENCIES + )) { + const formatConfig = { ...(config[format] ?? {}) }; + const otherFpmOptions = (formatConfig.fpm ?? []).filter( + (option) => !frameCopyDependencies.has(fpmDependencyName(option)) + ); + formatConfig.fpm = [...otherFpmOptions, `--depends=${dependency}`]; + config[format] = formatConfig; + } +} + +function removeForeignFrameCopyDependency(config, format) { + const dependency = LINUX_SYSTEM_PACKAGE_DEPENDENCIES[format]; + const formatConfig = { ...(config[format] ?? {}) }; + const retainedFpmOptions = (formatConfig.fpm ?? []).filter( + (option) => fpmDependencyName(option) !== dependency + ); + if (retainedFpmOptions.length > 0) { + formatConfig.fpm = retainedFpmOptions; + } else { + delete formatConfig.fpm; + } + config[format] = formatConfig; +} + +export function configureLinuxFrameCopyBuild( + electronBuilderConfig, + { profileName, foreignDeb = false } = {} +) { + if ( + !electronBuilderConfig || + typeof electronBuilderConfig !== 'object' || + Array.isArray(electronBuilderConfig) + ) { + throw new TypeError( + 'Electron Builder configuration must be an object.' + ); + } + if (foreignDeb && profileName) { + throw new Error( + 'The marker-only foreign DEB pass must not select a frame-copy profile.' + ); + } + const configured = cloneJson(electronBuilderConfig); + const targets = configured.linux?.target; + if (!Array.isArray(targets)) { + throw new Error('Electron Builder linux.target must be an array.'); + } + + if (foreignDeb) { + const debTarget = targets.find( + (target) => targetName(target) === 'deb' + ); + if (!debTarget) { + throw new Error( + 'Electron Builder has no DEB target for the foreign-architecture pass.' + ); + } + const configuredDebTarget = targetObject(debTarget); + const configuredArches = Array.isArray(configuredDebTarget.arch) + ? configuredDebTarget.arch + : [configuredDebTarget.arch].filter(Boolean); + const foreignArches = configuredArches.filter( + (architecture) => architecture !== 'x64' + ); + if (foreignArches.length === 0) { + throw new Error( + 'Electron Builder DEB target has no foreign architectures.' + ); + } + configuredDebTarget.arch = foreignArches; + configured.linux.target = [configuredDebTarget]; + configured.directories = { + ...(configured.directories ?? {}), + output: 'dist/executables-linux-foreign', + }; + removeForeignFrameCopyDependency(configured, 'deb'); + return configured; + } + + const profile = resolveLinuxFrameCopyProfile(profileName); + const allowedTargets = new Set(profile.targets); + const targetsByName = new Map( + profile.targets.map((profileTarget) => [profileTarget, []]) + ); + for (const target of targets) { + const name = targetName(target); + if (allowedTargets.has(name)) { + targetsByName.get(name).push(target); + } + } + const duplicateTargets = profile.targets.filter( + (profileTarget) => targetsByName.get(profileTarget).length > 1 + ); + const missingTargets = profile.targets.filter( + (profileTarget) => targetsByName.get(profileTarget).length === 0 + ); + if (duplicateTargets.length > 0 || missingTargets.length > 0) { + throw new Error( + [ + `Invalid Electron Builder targets for Linux profile "${profile.name}".`, + ...(duplicateTargets.length > 0 + ? [ + `Found duplicate target "${duplicateTargets.join( + '", "' + )}".`, + ] + : []), + ...(missingTargets.length > 0 + ? [`Missing targets: ${missingTargets.join(', ')}.`] + : []), + ].join(' ') + ); + } + configured.linux.target = profile.targets.map((profileTarget) => { + const target = targetsByName.get(profileTarget)[0]; + const configuredTarget = targetObject(target); + if (profile.name === 'system') { + configuredTarget.arch = ['x64']; + } + return configuredTarget; + }); + if (profile.name === 'system') { + configureSystemDependencies(configured); + } + return configured; +} + +function parseArguments(argv) { + const normalized = argv[0] === '--' ? argv.slice(1) : argv; + let configPath = 'electron-builder.json'; + let profileName; + let foreignDeb = false; + for (let index = 0; index < normalized.length; index += 1) { + const argument = normalized[index]; + if (argument === '--config') { + configPath = normalized[++index]; + } else if (argument === '--profile') { + profileName = normalized[++index]; + } else if (argument === '--foreign-deb') { + foreignDeb = true; + } else { + throw new Error(`Unsupported configurator argument: ${argument}`); + } + } + if (!foreignDeb && !profileName) { + throw new Error('--profile or --foreign-deb is required.'); + } + return { + configPath: path.resolve(configPath), + profileName, + foreignDeb, + }; +} + +function main() { + const options = parseArguments(process.argv.slice(2)); + const config = JSON.parse(fs.readFileSync(options.configPath, 'utf8')); + const configured = configureLinuxFrameCopyBuild(config, options); + fs.writeFileSync( + options.configPath, + `${JSON.stringify(configured, null, 4)}\n` + ); +} + +if (process.argv[1] && path.resolve(process.argv[1]) === scriptPath) { + try { + main(); + } catch (error) { + process.stderr.write( + `${error instanceof Error ? error.message : String(error)}\n` + ); + process.exitCode = 1; + } +} diff --git a/tools/packaging/configure-linux-frame-copy-build.test.mjs b/tools/packaging/configure-linux-frame-copy-build.test.mjs new file mode 100644 index 000000000..b3c63fa90 --- /dev/null +++ b/tools/packaging/configure-linux-frame-copy-build.test.mjs @@ -0,0 +1,301 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import test from 'node:test'; + +import { configureLinuxFrameCopyBuild } from './configure-linux-frame-copy-build.mjs'; + +const workspaceRoot = path.resolve( + path.dirname(new URL(import.meta.url).pathname), + '..', + '..' +); +const electronBuilderConfig = JSON.parse( + fs.readFileSync(path.join(workspaceRoot, 'electron-builder.json'), 'utf8') +); +const buildWorkflow = fs.readFileSync( + path.join(workspaceRoot, '.github', 'workflows', 'build-and-make.yaml'), + 'utf8' +); + +function workflowStep(name) { + const marker = ` - name: ${name}\n`; + const start = buildWorkflow.indexOf(marker); + assert.notEqual(start, -1, `Missing workflow step: ${name}`); + const nextStep = buildWorkflow.indexOf('\n - name:', start + 1); + return buildWorkflow.slice( + start, + nextStep === -1 ? buildWorkflow.length : nextStep + ); +} + +function configuredTargets(config) { + return config.linux.target.map(({ target, arch }) => ({ + target: target.toLowerCase(), + arch, + })); +} + +test('configures an x64-only system pass with exact package dependencies', () => { + const configured = configureLinuxFrameCopyBuild(electronBuilderConfig, { + profileName: 'system', + }); + + assert.deepEqual(configuredTargets(configured), [ + { target: 'deb', arch: ['x64'] }, + { target: 'rpm', arch: ['x64'] }, + { target: 'pacman', arch: ['x64'] }, + ]); + assert.deepEqual(configured.deb.fpm, ['--depends=libmpv2']); + assert.deepEqual(configured.rpm.fpm, ['--depends=mpv-libs']); + assert.deepEqual(configured.pacman.fpm, ['--depends=mpv']); +}); + +test('configures portable and flatpak passes without mixing targets', () => { + const portable = configureLinuxFrameCopyBuild(electronBuilderConfig, { + profileName: 'portable', + }); + assert.deepEqual(configuredTargets(portable), [ + { target: 'appimage', arch: ['x64', 'armv7l', 'arm64'] }, + { target: 'snap', arch: ['x64', 'armv7l'] }, + ]); + + const flatpak = configureLinuxFrameCopyBuild(electronBuilderConfig, { + profileName: 'flatpak', + }); + assert.deepEqual(configuredTargets(flatpak), [ + { target: 'flatpak', arch: ['x64'] }, + ]); +}); + +test('configures a separate marker-only foreign DEB pass without libmpv metadata', () => { + const configured = configureLinuxFrameCopyBuild(electronBuilderConfig, { + foreignDeb: true, + }); + + assert.deepEqual(configuredTargets(configured), [ + { target: 'deb', arch: ['armv7l', 'arm64'] }, + ]); + assert.equal( + configured.deb.fpm?.some((entry) => + entry.includes('--depends=libmpv2') + ) ?? false, + false + ); + assert.equal( + configured.directories.output, + 'dist/executables-linux-foreign' + ); +}); + +test('does not mutate the shared electron-builder configuration', () => { + const before = JSON.stringify(electronBuilderConfig); + configureLinuxFrameCopyBuild(electronBuilderConfig, { + profileName: 'system', + }); + assert.equal(JSON.stringify(electronBuilderConfig), before); +}); + +test('rejects an unknown profile and conflicting foreign/profile modes', () => { + assert.throws( + () => + configureLinuxFrameCopyBuild(electronBuilderConfig, { + profileName: 'standard', + }), + /Unsupported Linux frame-copy profile/ + ); + assert.throws( + () => + configureLinuxFrameCopyBuild(electronBuilderConfig, { + profileName: 'system', + foreignDeb: true, + }), + /must not select a frame-copy profile/ + ); +}); + +test('rejects duplicate profile targets even when target count looks complete', () => { + const malformed = structuredClone(electronBuilderConfig); + malformed.linux.target = malformed.linux.target.map((target) => + target.target === 'Snap' ? { ...target, target: 'AppImage' } : target + ); + assert.throws( + () => + configureLinuxFrameCopyBuild(malformed, { + profileName: 'portable', + }), + /duplicate target "appimage".*missing.*snap/is + ); +}); + +test('preserves unrelated fpm dependencies and normalizes only frame-copy dependencies', () => { + const customized = structuredClone(electronBuilderConfig); + customized.deb.fpm = [ + '--depends=unrelated-runtime', + '--depends=libmpv2 >= 2', + ]; + const system = configureLinuxFrameCopyBuild(customized, { + profileName: 'system', + }); + assert.deepEqual(system.deb.fpm, [ + '--depends=unrelated-runtime', + '--depends=libmpv2', + ]); + + const foreign = configureLinuxFrameCopyBuild(customized, { + foreignDeb: true, + }); + assert.deepEqual(foreign.deb.fpm, ['--depends=unrelated-runtime']); +}); + +test('Linux CI builds one cached source runtime and packages three isolated profiles', () => { + assert.match(buildWorkflow, /^ {4}linux-embedded-mpv-runtime:$/m); + for (const profile of ['system', 'portable', 'flatpak']) { + assert.match( + buildWorkflow, + new RegExp(`linux_profile: ${profile}(?:\\s|$)`) + ); + } + assert.doesNotMatch(buildWorkflow, /linux_profile: standard/); + assert.match( + buildWorkflow, + /configure-linux-frame-copy-build\.mjs --profile/ + ); + assert.match( + buildWorkflow, + /configure-linux-frame-copy-build\.mjs --foreign-deb/ + ); + assert.match( + buildWorkflow, + /apt-cache policy[\s\S]*meson=1\.7\.2[\s\S]*toolchain-sha256/ + ); + assert.match( + buildWorkflow, + /key: \$\{\{ steps\.linux-runtime-cache-key\.outputs\.key \}\}/ + ); + assert.match( + buildWorkflow, + /IPTVNATOR_LINUX_FRAME_COPY_PROFILE: \$\{\{ matrix\.linux_profile/ + ); + const makeStep = workflowStep('Make Electron app'); + assert.match( + makeStep, + /IPTVNATOR_LINUX_FRAME_COPY_PROFILE: \$\{\{ matrix\.linux_profile/ + ); + assert.match(buildWorkflow, /linux-frame-copy-runtime-sources\.tar\.xz/); + assert.match(buildWorkflow, /name: linux-frame-copy-runtime-sources/); + assert.match(buildWorkflow, /sourceSha256[\s\S]*source-index\.json/); +}); + +test('Linux CI verifies every package family and exercises intended environments', () => { + for (const suffix of [ + 'AppImage', + 'deb', + 'rpm', + 'pacman', + 'snap', + 'flatpak', + ]) { + assert.match( + buildWorkflow, + new RegExp(`\\.${suffix.replace('.', '\\.')}(?:['"*:/\\s]|$)`) + ); + } + assert.ok( + buildWorkflow.match(/verify-linux-frame-copy-runtime\.mjs/g).length >= 6 + ); + 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, + /flatpak run --command=sh com\.fourgray\.iptvnator/ + ); + assert.doesNotMatch(buildWorkflow, /\bldd\b/); +}); + +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+xauth\s*\\?$/m); + assert.match(linuxDependencies, /^\s+xvfb\s*\\?$/m); + const packagedSmoke = workflowStep( + 'Run packaged x64 frame-copy and fallback smoke' + ); + assert.match( + packagedSmoke, + /IPTVNATOR_E2E_REQUIRE_PACKAGED_FRAME_COPY: '1'/ + ); + assert.match( + packagedSmoke, + /IPTVNATOR_E2E_PACKAGED_EXECUTABLE: \$\{\{ github\.workspace \}\}\/dist\/executables\/linux-unpacked\/iptvnator/ + ); + assert.match( + packagedSmoke, + /electron-backend-e2e:packaged-frame-copy-smoke/ + ); + const hardwareDiagnostic = workflowStep( + 'Diagnose packaged x64 frame-copy hardware path' + ); + assert.match(hardwareDiagnostic, /continue-on-error: true/); + assert.match(hardwareDiagnostic, /\/dev\/dri\/renderD128/); + assert.match( + hardwareDiagnostic, + /IPTVNATOR_E2E_REQUIRE_PACKAGED_FRAME_COPY: '1'/ + ); + assert.doesNotMatch(hardwareDiagnostic, /LIBGL_ALWAYS_SOFTWARE/); +}); + +test('release and Snap publication consume split Linux artifacts and source compliance', () => { + for (const artifactName of [ + 'linux-system-artifacts', + 'linux-portable-artifacts', + 'linux-flatpak-artifacts', + 'linux-frame-copy-runtime-sources', + ]) { + assert.match(buildWorkflow, new RegExp(artifactName)); + } + const systemUpload = workflowStep('Upload system-runtime Linux artifacts'); + for (const artifactGlob of [ + 'dist/executables/*.deb', + 'dist/executables/*.rpm', + 'dist/executables/*.pacman', + 'dist/executables/*.pkg.tar.*', + ]) { + assert.ok(systemUpload.includes(artifactGlob)); + } + const portableUpload = workflowStep( + 'Upload portable-runtime Linux artifacts' + ); + for (const artifactGlob of [ + 'dist/executables/*.AppImage', + 'dist/executables/*.snap', + 'dist/executables/**/latest-linux*.yml', + 'dist/executables/**/*.blockmap', + ]) { + assert.ok(portableUpload.includes(artifactGlob)); + } + assert.ok( + workflowStep('Upload Flatpak-runtime Linux artifacts').includes( + 'dist/executables/**/*.flatpak' + ) + ); + const release = workflowStep('Create Draft Release'); + for (const releasePath of [ + 'artifacts/linux-system-artifacts/*.deb', + 'artifacts/linux-system-artifacts/*.rpm', + 'artifacts/linux-system-artifacts/*.pacman', + 'artifacts/linux-system-artifacts/*.pkg.tar.*', + 'artifacts/linux-portable-artifacts/*.AppImage', + 'artifacts/linux-portable-artifacts/*.snap', + 'artifacts/linux-flatpak-artifacts/*.flatpak', + 'artifacts/linux-frame-copy-runtime-sources/linux-frame-copy-runtime-sources.tar.xz', + ]) { + assert.ok(release.includes(releasePath)); + } + assert.match( + workflowStep('Download snap artifact'), + /name: linux-portable-artifacts/ + ); +}); diff --git a/tools/packaging/electron-package-identity.test.mjs b/tools/packaging/electron-package-identity.test.mjs index f40d1671b..833b7c844 100644 --- a/tools/packaging/electron-package-identity.test.mjs +++ b/tools/packaging/electron-package-identity.test.mjs @@ -196,7 +196,7 @@ test('GitHub Releases auto-update metadata is generated and uploaded', () => { ); assert.match( buildAndMakeWorkflow, - /artifacts\/linux-artifacts\/latest-linux\*\.yml/ + /artifacts\/linux-portable-artifacts\/latest-linux\*\.yml/ ); assert.match( buildAndMakeWorkflow, diff --git a/tools/packaging/project.json b/tools/packaging/project.json index 5041fe974..c767f6e23 100644 --- a/tools/packaging/project.json +++ b/tools/packaging/project.json @@ -21,8 +21,12 @@ "{workspaceRoot}/tools/packaging/asar-dependency-closure.test.mjs", "{workspaceRoot}/tools/packaging/embedded-mpv-packaging.cjs", "{workspaceRoot}/tools/packaging/embedded-mpv-arch.test.mjs", + "{workspaceRoot}/tools/packaging/configure-linux-frame-copy-build.mjs", + "{workspaceRoot}/tools/packaging/configure-linux-frame-copy-build.test.mjs", "{workspaceRoot}/tools/packaging/linux-frame-copy-profile.cjs", "{workspaceRoot}/tools/packaging/linux-frame-copy-profile.test.mjs", + "{workspaceRoot}/tools/packaging/verify-linux-frame-copy-runtime.mjs", + "{workspaceRoot}/tools/packaging/verify-linux-frame-copy-runtime.test.mjs", "{workspaceRoot}/tools/embedded-mpv/build-linux-runtime.cjs", "{workspaceRoot}/tools/embedded-mpv/build-linux-runtime.mjs", "{workspaceRoot}/tools/embedded-mpv/build-linux-runtime.test.mjs", @@ -32,7 +36,7 @@ "{workspaceRoot}/tools/embedded-mpv/stage-windows-runtime-archive.mjs" ], "options": { - "command": "node --test tools/packaging/electron-package-identity.test.mjs tools/packaging/asar-dependency-closure.test.mjs tools/packaging/embedded-mpv-arch.test.mjs tools/packaging/linux-frame-copy-profile.test.mjs tools/embedded-mpv/build-linux-runtime.test.mjs tools/embedded-mpv/linux-runtime-manifest.test.mjs", + "command": "node --test tools/packaging/electron-package-identity.test.mjs tools/packaging/asar-dependency-closure.test.mjs tools/packaging/embedded-mpv-arch.test.mjs tools/packaging/configure-linux-frame-copy-build.test.mjs tools/packaging/linux-frame-copy-profile.test.mjs tools/packaging/verify-linux-frame-copy-runtime.test.mjs tools/embedded-mpv/build-linux-runtime.test.mjs tools/embedded-mpv/linux-runtime-manifest.test.mjs", "cwd": "{workspaceRoot}" } }, diff --git a/tools/packaging/verify-linux-frame-copy-runtime.mjs b/tools/packaging/verify-linux-frame-copy-runtime.mjs new file mode 100644 index 000000000..87d4d6197 --- /dev/null +++ b/tools/packaging/verify-linux-frame-copy-runtime.mjs @@ -0,0 +1,897 @@ +#!/usr/bin/env node + +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { createRequire } from 'node:module'; +import { fileURLToPath } from 'node:url'; + +const require = createRequire(import.meta.url); +const { + parseReadelfDynamic, +} = require('../embedded-mpv/build-linux-runtime.cjs'); +const { validatePackagedEmbeddedMpv } = require('./embedded-mpv-packaging.cjs'); +const { + LINUX_SYSTEM_PACKAGE_DEPENDENCIES, + resolveLinuxFrameCopyProfile, +} = require('./linux-frame-copy-profile.cjs'); + +const scriptPath = fileURLToPath(import.meta.url); +const RUNTIME_PROBE_TIMEOUT_MS = 3000; +const LIBMPV_DEPENDENCY_PATTERN = /^libmpv\.so(?:\.|$)/; + +function defaultRunCommand(command, args, options = {}) { + return spawnSync(command, args, { + cwd: options.cwd, + env: options.env, + encoding: 'utf8', + stdio: 'pipe', + timeout: options.timeout, + windowsHide: true, + }); +} + +function assertCommandSucceeded(command, args, result) { + if (result?.error) { + throw new Error( + `Unable to run ${command}: ${ + result.error instanceof Error + ? result.error.message + : String(result.error) + }` + ); + } + if (result?.status !== 0) { + const details = [result?.stdout, result?.stderr] + .filter(Boolean) + .join('\n') + .trim(); + throw new Error( + `${command} ${args.join(' ')} failed with status ${ + result?.status ?? 'unknown' + }.${details ? `\n${details}` : ''}` + ); + } + return result; +} + +export function detectLinuxArtifactFormat(artifactPath) { + const fileName = path.basename(artifactPath); + if (/\.AppImage$/i.test(fileName)) { + return 'appimage'; + } + if (/\.deb$/i.test(fileName)) { + return 'deb'; + } + if (/\.rpm$/i.test(fileName)) { + return 'rpm'; + } + if (/\.(?:pacman|pkg\.tar(?:\.[A-Za-z0-9]+)*)$/i.test(fileName)) { + return 'pacman'; + } + if (/\.snap$/i.test(fileName)) { + return 'snap'; + } + if (/\.flatpak$/i.test(fileName)) { + return 'flatpak'; + } + throw new Error(`Unsupported Linux package artifact: ${artifactPath}`); +} + +export function parseVerifierArguments(argv) { + const normalizedArgs = argv[0] === '--' ? argv.slice(1) : [...argv]; + let artifactValue; + let profileValue; + for (let index = 0; index < normalizedArgs.length; index += 1) { + const argument = normalizedArgs[index]; + if (argument === '--artifact') { + if (artifactValue !== undefined) { + throw new Error('Received duplicate --artifact argument.'); + } + artifactValue = normalizedArgs[++index]; + continue; + } + if (argument === '--profile') { + if (profileValue !== undefined) { + throw new Error('Received duplicate --profile argument.'); + } + profileValue = normalizedArgs[++index]; + continue; + } + throw new Error(`Unsupported verifier argument: ${argument}`); + } + if (!artifactValue) { + throw new Error('--artifact is required.'); + } + if (!profileValue) { + throw new Error('--profile is required.'); + } + const artifactPath = path.resolve(artifactValue); + const stat = fs.lstatSync(artifactPath); + if (!stat.isFile() || stat.isSymbolicLink()) { + throw new Error( + `Linux package artifact must be a regular file: ${artifactPath}` + ); + } + const profile = resolveLinuxFrameCopyProfile(profileValue); + detectLinuxArtifactFormat(artifactPath); + return { + artifactPath, + profileName: profile.name, + }; +} + +export function findAppImageSquashfsOffsets(artifactPath) { + const magic = Buffer.from('hsqs'); + const chunkSize = 1024 * 1024; + const descriptor = fs.openSync(artifactPath, 'r'); + const offsets = []; + let position = 0; + let overlap = Buffer.alloc(0); + try { + while (true) { + const chunk = Buffer.alloc(chunkSize); + const bytesRead = fs.readSync( + descriptor, + chunk, + 0, + chunk.length, + position + ); + if (bytesRead === 0) { + break; + } + const contents = Buffer.concat([ + overlap, + chunk.subarray(0, bytesRead), + ]); + const contentsStart = position - overlap.length; + let searchOffset = 0; + while (searchOffset <= contents.length - magic.length) { + const matchOffset = contents.indexOf(magic, searchOffset); + if (matchOffset === -1) { + break; + } + const absoluteOffset = contentsStart + matchOffset; + if (offsets.at(-1) !== absoluteOffset) { + offsets.push(absoluteOffset); + } + searchOffset = matchOffset + 1; + } + overlap = contents.subarray( + Math.max(0, contents.length - (magic.length - 1)) + ); + position += bytesRead; + } + } finally { + fs.closeSync(descriptor); + } + return offsets; +} + +export function extractLinuxArtifact({ + artifactPath, + format, + destination, + runCommand = defaultRunCommand, +}) { + fs.mkdirSync(destination, { recursive: true }); + const run = (command, args, options = {}) => + assertCommandSucceeded( + command, + args, + runCommand(command, args, options) + ); + + switch (format) { + case 'appimage': { + const squashfsOffsets = findAppImageSquashfsOffsets(artifactPath); + if (squashfsOffsets.length === 0) { + throw new Error( + `AppImage contains no SquashFS payload: ${artifactPath}` + ); + } + const failures = []; + for (const offset of squashfsOffsets) { + fs.rmSync(destination, { recursive: true, force: true }); + fs.mkdirSync(destination, { recursive: true }); + const args = [ + '-no-progress', + '-offset', + String(offset), + '-dest', + destination, + artifactPath, + ]; + const result = runCommand('unsquashfs', args); + if (!result?.error && result?.status === 0) { + return destination; + } + failures.push( + [result?.stdout, result?.stderr] + .filter(Boolean) + .join('\n') + .trim() + ); + } + throw new Error( + [ + `Unable to extract the AppImage SquashFS payload at any candidate offset: ${artifactPath}`, + ...failures.filter(Boolean), + ].join('\n') + ); + } + case 'deb': + run('dpkg-deb', ['--extract', artifactPath, destination]); + return destination; + case 'rpm': + case 'pacman': + run('bsdtar', [ + '--extract', + '--file', + artifactPath, + '--directory', + destination, + ]); + return destination; + case 'snap': + run('unsquashfs', [ + '-no-progress', + '-dest', + destination, + artifactPath, + ]); + return destination; + case 'flatpak': { + const repository = path.join(destination, '.ostree-repository'); + const checkout = path.join(destination, 'checkout'); + fs.mkdirSync(repository, { recursive: true }); + run('flatpak', ['build-import-bundle', repository, artifactPath]); + const refsResult = run('ostree', ['refs', `--repo=${repository}`]); + const refs = String(refsResult.stdout ?? '') + .split(/\r?\n/) + .map((line) => line.trim()) + .filter((line) => line.startsWith('app/')); + if (refs.length !== 1) { + throw new Error( + `Flatpak bundle must import exactly one application ref; received ${ + refs.length > 0 ? refs.join(', ') : '' + }.` + ); + } + run('ostree', [ + 'checkout', + `--repo=${repository}`, + refs[0], + checkout, + ]); + return checkout; + } + default: + throw new Error(`Unsupported Linux package format: ${format}`); + } +} + +export function findExtractedResourceDir(extractionRoot) { + const candidates = []; + + function visit(directoryPath) { + let entries; + try { + entries = fs.readdirSync(directoryPath, { withFileTypes: true }); + } catch (error) { + throw new Error( + `Unable to inspect extracted package directory ${directoryPath}: ${ + error instanceof Error ? error.message : String(error) + }` + ); + } + for (const entry of entries) { + if (!entry.isDirectory() || entry.isSymbolicLink()) { + continue; + } + const entryPath = path.join(directoryPath, entry.name); + if (entry.name === 'resources') { + const nativeDir = path.join( + entryPath, + 'app.asar.unpacked', + 'electron-backend', + 'native' + ); + let nativeStat; + try { + nativeStat = fs.lstatSync(nativeDir); + } catch { + nativeStat = null; + } + if (nativeStat?.isDirectory() && !nativeStat.isSymbolicLink()) { + candidates.push(entryPath); + continue; + } + } + visit(entryPath); + } + } + + visit(extractionRoot); + if (candidates.length !== 1) { + throw new Error( + `Expected exactly one embedded MPV native payload in ${extractionRoot}; found ${candidates.length}.` + ); + } + return candidates[0]; +} + +export function readElfArchitecture(binaryPath) { + const descriptor = fs.openSync(binaryPath, 'r'); + try { + const header = Buffer.alloc(20); + const bytesRead = fs.readSync(descriptor, header, 0, header.length, 0); + if ( + bytesRead !== header.length || + !header.subarray(0, 4).equals(Buffer.from([0x7f, 0x45, 0x4c, 0x46])) + ) { + throw new Error(`Not an ELF binary: ${binaryPath}`); + } + const byteOrder = header[5]; + const machine = + byteOrder === 1 + ? header.readUInt16LE(18) + : byteOrder === 2 + ? header.readUInt16BE(18) + : null; + const architecture = new Map([ + [62, 'x64'], + [183, 'arm64'], + [40, 'armv7l'], + ]).get(machine); + if (!architecture) { + throw new Error( + `Unsupported ELF machine ${String(machine)} in ${binaryPath}.` + ); + } + return architecture; + } finally { + fs.closeSync(descriptor); + } +} + +function normalizePackageArchitecture(value) { + const normalized = String(value ?? '') + .trim() + .toLowerCase(); + const architecture = { + amd64: 'x64', + x86_64: 'x64', + arm64: 'arm64', + aarch64: 'arm64', + armhf: 'armv7l', + armv7h: 'armv7l', + armv7hl: 'armv7l', + }[normalized]; + if (!architecture) { + throw new Error( + `Unsupported Linux package architecture: ${ + normalized || '' + }.` + ); + } + return architecture; +} + +function splitNonEmptyLines(value) { + return String(value ?? '') + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean); +} + +function findPackageInfoFiles(extractionRoot) { + const packageInfoPaths = []; + + function visit(directoryPath) { + for (const entry of fs.readdirSync(directoryPath, { + withFileTypes: true, + })) { + const entryPath = path.join(directoryPath, entry.name); + if (entry.isDirectory() && !entry.isSymbolicLink()) { + visit(entryPath); + } else if (entry.isFile() && entry.name === '.PKGINFO') { + packageInfoPaths.push(entryPath); + } + } + } + + visit(extractionRoot); + return packageInfoPaths; +} + +export function readLinuxArtifactMetadata({ + artifactPath, + format, + extractionRoot, + runCommand = defaultRunCommand, +}) { + const run = (command, args) => + assertCommandSucceeded(command, args, runCommand(command, args)); + if (format === 'deb') { + const architectureResult = run('dpkg-deb', [ + '--field', + artifactPath, + 'Architecture', + ]); + const dependencyResult = run('dpkg-deb', [ + '--field', + artifactPath, + 'Depends', + ]); + return { + declaredArch: normalizePackageArchitecture( + architectureResult.stdout + ), + dependencies: String(dependencyResult.stdout ?? '') + .split(',') + .map((dependency) => dependency.trim()) + .filter(Boolean), + }; + } + if (format === 'rpm') { + const architectureResult = run('rpm', [ + '-qp', + '--queryformat', + '%{ARCH}\\n', + artifactPath, + ]); + const dependencyResult = run('rpm', [ + '-qp', + '--requires', + artifactPath, + ]); + return { + declaredArch: normalizePackageArchitecture( + architectureResult.stdout + ), + dependencies: splitNonEmptyLines(dependencyResult.stdout), + }; + } + if (format === 'pacman') { + const packageInfoPaths = findPackageInfoFiles(extractionRoot); + if (packageInfoPaths.length !== 1) { + throw new Error( + `Pacman payload must contain exactly one .PKGINFO; found ${packageInfoPaths.length}.` + ); + } + const fields = fs + .readFileSync(packageInfoPaths[0], 'utf8') + .split(/\r?\n/) + .map((line) => line.match(/^([^=]+?)\s*=\s*(.*)$/)) + .filter(Boolean) + .map((match) => ({ + name: match[1].trim(), + value: match[2].trim(), + })); + const architectures = fields + .filter(({ name }) => name === 'arch') + .map(({ value }) => value); + if (architectures.length !== 1) { + throw new Error( + `Pacman .PKGINFO must declare exactly one architecture; found ${architectures.length}.` + ); + } + return { + declaredArch: normalizePackageArchitecture(architectures[0]), + dependencies: fields + .filter(({ name }) => name === 'depend') + .map(({ value }) => value), + }; + } + return { + declaredArch: null, + dependencies: [], + }; +} + +function dependencyMatches(dependency, expected) { + const escapedExpected = expected.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + return new RegExp(`^${escapedExpected}(?:$|\\s|[<>=])`).test( + dependency.trim() + ); +} + +export function validateSystemPackageDependencies(format, dependencies) { + const expectedDependency = LINUX_SYSTEM_PACKAGE_DEPENDENCIES[format]; + if (!expectedDependency) { + return []; + } + if (!Array.isArray(dependencies)) { + return [ + `Linux ${format} package dependency metadata must be an array.`, + ]; + } + if ( + !dependencies.some((dependency) => + dependencyMatches(String(dependency), expectedDependency) + ) + ) { + return [ + `Linux x64 ${format} package must declare ${expectedDependency}.`, + ]; + } + return []; +} + +function validateForeignPackageDependencies(format, dependencies) { + const forbiddenDependency = LINUX_SYSTEM_PACKAGE_DEPENDENCIES[format]; + if ( + forbiddenDependency && + dependencies.some((dependency) => + dependencyMatches(String(dependency), forbiddenDependency) + ) + ) { + return [ + `Linux foreign-architecture ${format} package must not declare frame-copy dependency ${forbiddenDependency}.`, + ]; + } + return []; +} + +function dependencyFileName(dependencyName) { + return String(dependencyName).replaceAll('\\', '/').split('/').at(-1) ?? ''; +} + +function listElectronLibraries(resourceDir) { + const appDir = path.dirname(resourceDir); + const resolvedResourceDir = path.resolve(resourceDir); + const libraries = []; + + function visit(directoryPath) { + for (const entry of fs.readdirSync(directoryPath, { + withFileTypes: true, + })) { + const entryPath = path.join(directoryPath, entry.name); + if (path.resolve(entryPath) === resolvedResourceDir) { + continue; + } + if (entry.isDirectory()) { + visit(entryPath); + continue; + } + if ( + (entry.isFile() || entry.isSymbolicLink()) && + /\.so(?:\.\d+)*$/.test(entry.name) + ) { + libraries.push(entryPath); + } + } + } + + visit(appDir); + return libraries.sort(); +} + +function validateElectronIsolation(resourceDir, elfInspector) { + const errors = []; + const electronPath = path.join(path.dirname(resourceDir), 'iptvnator.bin'); + const binaries = [ + { label: 'Electron binary', binaryPath: electronPath }, + ...listElectronLibraries(resourceDir).map((binaryPath) => ({ + label: 'Electron library', + binaryPath, + })), + ]; + for (const { label, binaryPath } of binaries) { + let stat; + try { + stat = fs.lstatSync(binaryPath); + } catch { + errors.push(`Missing ${label}: ${binaryPath}`); + continue; + } + if (!stat.isFile() || stat.isSymbolicLink()) { + errors.push(`${label} must be a regular file: ${binaryPath}`); + continue; + } + let dynamic; + try { + dynamic = elfInspector(binaryPath); + } catch (error) { + errors.push( + `Unable to inspect ${label} at ${binaryPath}: ${ + error instanceof Error ? error.message : String(error) + }` + ); + continue; + } + if (!dynamic || !Array.isArray(dynamic.needed)) { + errors.push( + `ELF inspection for ${label} must provide a needed array: ${binaryPath}` + ); + continue; + } + const libmpvDependencies = dynamic.needed.filter((dependencyName) => + LIBMPV_DEPENDENCY_PATTERN.test(dependencyFileName(dependencyName)) + ); + if (libmpvDependencies.length > 0) { + errors.push( + `${label} must not link libmpv; found ${libmpvDependencies.join( + ', ' + )} in ${binaryPath}.` + ); + } + } + return errors; +} + +function defaultElfInspector(binaryPath) { + const result = assertCommandSucceeded( + 'readelf', + ['-d', binaryPath], + defaultRunCommand('readelf', ['-d', binaryPath]) + ); + return parseReadelfDynamic(result.stdout); +} + +function validateProbeResult(result) { + if (result?.error) { + return [ + `Unable to execute Linux frame-copy runtime probe: ${ + result.error instanceof Error + ? result.error.message + : String(result.error) + }`, + ]; + } + if (result?.signal) { + return [ + `Linux frame-copy runtime probe terminated by signal ${result.signal}.`, + ]; + } + if (result?.status !== 0) { + const details = [result?.stdout, result?.stderr] + .filter(Boolean) + .join('\n') + .trim(); + return [ + `Linux frame-copy runtime probe failed with status ${ + result?.status ?? 'unknown' + }.${details ? `\n${details}` : ''}`, + ]; + } + const stdout = result.stdout; + if (typeof stdout !== 'string' || !/^[^\r\n]+\n$/.test(stdout)) { + return [ + 'Linux frame-copy runtime probe must emit exactly one newline-terminated JSON line.', + ]; + } + let payload; + try { + payload = JSON.parse(stdout.slice(0, -1)); + } catch (error) { + return [ + `Linux frame-copy runtime probe emitted invalid JSON: ${ + error instanceof Error ? error.message : String(error) + }`, + ]; + } + if ( + !payload || + typeof payload !== 'object' || + Array.isArray(payload) || + JSON.stringify(Object.keys(payload).sort()) !== + JSON.stringify(['libmpv', 'protocol', 'renderApi', 'usable']) || + payload.protocol !== 1 || + payload.usable !== true || + typeof payload.libmpv !== 'string' || + payload.libmpv.trim() === '' || + payload.renderApi !== 'egl' + ) { + return [ + 'Linux frame-copy runtime probe did not return protocol 1 usable EGL capability.', + ]; + } + return []; +} + +export function createRuntimeProbeEnvironment({ + environment, + nativeDir, + runtimeMode, +}) { + const probeEnvironment = { ...(environment ?? {}) }; + delete probeEnvironment.LD_LIBRARY_PATH; + delete probeEnvironment.LD_PRELOAD; + if (runtimeMode === 'bundled') { + probeEnvironment.LD_LIBRARY_PATH = path.join(nativeDir, 'lib'); + } + return probeEnvironment; +} + +export function verifyExtractedLinuxFrameCopyRuntime({ + resourceDir, + artifactFormat, + profileName, + packageDependencies = [], + declaredArch = null, + elfInspector = defaultElfInspector, + probeRunner = defaultRunCommand, + environment = process.env, +}) { + const errors = []; + let profile; + try { + profile = resolveLinuxFrameCopyProfile(profileName); + } catch (error) { + return [error instanceof Error ? error.message : String(error)]; + } + if (!profile.targets.includes(artifactFormat)) { + return [ + `Linux frame-copy profile "${profile.name}" does not include target "${artifactFormat}".`, + ]; + } + + 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)]; + } + const foreignArch = packageArch !== 'x64'; + if (declaredArch && declaredArch !== packageArch) { + errors.push( + `Package metadata architecture ${declaredArch} does not match Electron ELF architecture ${packageArch}.` + ); + } + if (profile.runtimeMode === 'system') { + errors.push( + ...(foreignArch + ? validateForeignPackageDependencies( + artifactFormat, + packageDependencies + ) + : validateSystemPackageDependencies( + artifactFormat, + packageDependencies + )) + ); + } + + errors.push( + ...validatePackagedEmbeddedMpv(resourceDir, { + platform: 'linux', + required: true, + foreignArch, + targetArch: packageArch, + profile: profile.name, + targetNames: profile.targets, + hostPlatform: 'linux', + executableName: 'iptvnator', + elfInspector, + }) + ); + errors.push(...validateElectronIsolation(resourceDir, elfInspector)); + if (foreignArch || errors.length > 0) { + return errors; + } + + const nativeDir = path.join( + resourceDir, + 'app.asar.unpacked', + 'electron-backend', + 'native' + ); + const probeEnvironment = createRuntimeProbeEnvironment({ + environment, + nativeDir, + runtimeMode: profile.runtimeMode, + }); + const probeResult = probeRunner( + path.join(nativeDir, 'iptvnator_mpv_helper'), + ['--runtime-probe'], + { + encoding: 'utf8', + env: probeEnvironment, + timeout: RUNTIME_PROBE_TIMEOUT_MS, + windowsHide: true, + } + ); + errors.push(...validateProbeResult(probeResult)); + return errors; +} + +export function verifyLinuxFrameCopyArtifact({ + artifactPath, + profileName, + runCommand = defaultRunCommand, + extractArtifact = extractLinuxArtifact, + metadataReader = readLinuxArtifactMetadata, + payloadVerifier = verifyExtractedLinuxFrameCopyRuntime, + elfInspector = defaultElfInspector, + probeRunner = defaultRunCommand, + environment = process.env, +}) { + const resolvedArtifactPath = path.resolve(artifactPath); + const artifactStat = fs.lstatSync(resolvedArtifactPath); + if (!artifactStat.isFile() || artifactStat.isSymbolicLink()) { + throw new Error( + `Linux package artifact must be a regular file: ${resolvedArtifactPath}` + ); + } + const profile = resolveLinuxFrameCopyProfile(profileName); + const format = detectLinuxArtifactFormat(resolvedArtifactPath); + if (!profile.targets.includes(format)) { + throw new Error( + `Linux frame-copy profile "${profile.name}" does not include target "${format}".` + ); + } + + const temporaryRoot = fs.mkdtempSync( + path.join(os.tmpdir(), 'iptvnator-linux-package-verifier-') + ); + try { + const extractionDestination = path.join(temporaryRoot, 'payload'); + const extractionRoot = extractArtifact({ + artifactPath: resolvedArtifactPath, + format, + destination: extractionDestination, + runCommand, + }); + const resourceDir = findExtractedResourceDir(extractionRoot); + const metadata = metadataReader({ + artifactPath: resolvedArtifactPath, + format, + extractionRoot, + runCommand, + }); + const errors = payloadVerifier({ + resourceDir, + artifactFormat: format, + profileName: profile.name, + packageDependencies: metadata.dependencies, + declaredArch: metadata.declaredArch, + elfInspector, + probeRunner, + environment, + }); + if (errors.length > 0) { + throw new Error( + [ + `Linux frame-copy package verification failed for ${resolvedArtifactPath}:`, + ...errors.map((error) => `- ${error}`), + ].join('\n') + ); + } + const electronPath = path.join( + path.dirname(resourceDir), + 'iptvnator.bin' + ); + return { + artifactPath: resolvedArtifactPath, + format, + profileName: profile.name, + architecture: readElfArchitecture(electronPath), + }; + } finally { + fs.rmSync(temporaryRoot, { recursive: true, force: true }); + } +} + +function main() { + const options = parseVerifierArguments(process.argv.slice(2)); + const result = verifyLinuxFrameCopyArtifact(options); + process.stdout.write( + `Verified ${result.format} ${result.architecture} Linux frame-copy package (${result.profileName}): ${result.artifactPath}\n` + ); +} + +if (process.argv[1] && path.resolve(process.argv[1]) === scriptPath) { + try { + main(); + } catch (error) { + process.stderr.write( + `${error instanceof Error ? error.message : String(error)}\n` + ); + process.exitCode = 1; + } +} diff --git a/tools/packaging/verify-linux-frame-copy-runtime.test.mjs b/tools/packaging/verify-linux-frame-copy-runtime.test.mjs new file mode 100644 index 000000000..ae5cb831a --- /dev/null +++ b/tools/packaging/verify-linux-frame-copy-runtime.test.mjs @@ -0,0 +1,769 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; + +import { + createRuntimeProbeEnvironment, + detectLinuxArtifactFormat, + extractLinuxArtifact, + findAppImageSquashfsOffsets, + findExtractedResourceDir, + parseVerifierArguments, + readLinuxArtifactMetadata, + readElfArchitecture, + validateSystemPackageDependencies, + verifyExtractedLinuxFrameCopyRuntime, + verifyLinuxFrameCopyArtifact, +} from './verify-linux-frame-copy-runtime.mjs'; + +const SYSTEM_TARGETS = ['deb', 'pacman', 'rpm']; +const SYSTEM_MANIFEST = { + schemaVersion: 1, + origin: 'system-libmpv-frame-copy', + generatedAt: '2026-07-17T00:00:00.000Z', + platform: 'linux', + arch: 'x64', + profile: 'system', + runtimeMode: 'system', + targets: SYSTEM_TARGETS, + artifacts: { + addon: { + name: 'embedded_mpv.node', + regularFile: true, + readable: true, + }, + frameReader: { + name: 'embedded_mpv_frame_reader.node', + regularFile: true, + readable: true, + }, + helper: { + name: 'iptvnator_mpv_helper', + regularFile: true, + readable: true, + executable: true, + }, + }, + processIsolation: { + addonLoadsLibmpv: false, + readerLoadsLibmpv: false, + electronLoadsLibmpv: false, + helperLinksLibmpv: true, + helperRunpath: ['$ORIGIN/lib'], + }, + nativeViewFallback: 'process-isolated mpv --wid', + libmpvSoname: 'libmpv.so.2', + packageDependencies: { + deb: 'libmpv2', + rpm: 'mpv-libs', + pacman: 'mpv', + }, + runtimeFiles: [], + runtimeTotalBytes: 0, +}; + +function elfHeader(architecture) { + const machines = { + x64: 62, + arm64: 183, + armv7l: 40, + }; + const image = Buffer.alloc(64); + image.set([0x7f, 0x45, 0x4c, 0x46, 2, 1], 0); + image.writeUInt16LE(machines[architecture], 18); + return image; +} + +function createSystemPayload({ architecture = 'x64' } = {}) { + const root = fs.mkdtempSync( + path.join(os.tmpdir(), 'iptvnator-verifier-layout-') + ); + const appDir = path.join(root, 'opt', 'IPTVnator'); + const resourceDir = path.join(appDir, 'resources'); + const nativeDir = path.join( + resourceDir, + 'app.asar.unpacked', + 'electron-backend', + 'native' + ); + fs.mkdirSync(nativeDir, { recursive: true }); + fs.writeFileSync( + path.join(appDir, 'iptvnator.bin'), + elfHeader(architecture) + ); + + if (architecture === 'x64') { + fs.writeFileSync(path.join(nativeDir, 'embedded_mpv.node'), 'addon', { + mode: 0o644, + }); + fs.writeFileSync( + path.join(nativeDir, 'embedded_mpv_frame_reader.node'), + 'reader', + { mode: 0o644 } + ); + fs.writeFileSync( + path.join(nativeDir, 'iptvnator_mpv_helper'), + 'helper', + { mode: 0o755 } + ); + fs.writeFileSync( + path.join(nativeDir, 'embedded-mpv-runtime.json'), + `${JSON.stringify(SYSTEM_MANIFEST, null, 2)}\n`, + { mode: 0o644 } + ); + } else { + fs.writeFileSync( + path.join(nativeDir, 'embedded-mpv-unavailable.txt'), + `Unavailable for ${architecture}\n` + ); + } + + return { root, appDir, resourceDir, nativeDir }; +} + +function validElfInspector(binaryPath) { + const name = path.basename(binaryPath); + if (name === 'iptvnator_mpv_helper') { + return { + soname: null, + needed: ['libc.so.6', 'libmpv.so.2'], + rpath: [], + runpath: ['$ORIGIN/lib'], + }; + } + return { + soname: null, + needed: ['libc.so.6'], + rpath: [], + runpath: [], + }; +} + +function successfulProbeRunner() { + return { + status: 0, + signal: null, + stdout: '{"protocol":1,"usable":true,"libmpv":"0.41.0","renderApi":"egl"}\n', + stderr: '', + }; +} + +test('detects every supported Linux package payload format', () => { + assert.equal(detectLinuxArtifactFormat('IPTVnator.AppImage'), 'appimage'); + assert.equal(detectLinuxArtifactFormat('IPTVnator.deb'), 'deb'); + assert.equal(detectLinuxArtifactFormat('IPTVnator.rpm'), 'rpm'); + assert.equal(detectLinuxArtifactFormat('IPTVnator.pacman'), 'pacman'); + assert.equal(detectLinuxArtifactFormat('IPTVnator.pkg.tar.zst'), 'pacman'); + assert.equal(detectLinuxArtifactFormat('IPTVnator.snap'), 'snap'); + assert.equal(detectLinuxArtifactFormat('IPTVnator.flatpak'), 'flatpak'); + assert.throws( + () => detectLinuxArtifactFormat('IPTVnator.tar.gz'), + /Unsupported Linux package artifact/ + ); +}); + +test('parses the required artifact and profile arguments without evaluating paths', () => { + const directory = fs.mkdtempSync( + path.join(os.tmpdir(), 'iptvnator-verifier-args-') + ); + const artifactPath = path.join(directory, 'package $(touch owned).deb'); + fs.writeFileSync(artifactPath, 'fixture'); + + try { + assert.deepEqual( + parseVerifierArguments([ + '--artifact', + artifactPath, + '--profile', + 'system', + ]), + { + artifactPath: path.resolve(artifactPath), + profileName: 'system', + } + ); + assert.throws( + () => parseVerifierArguments(['--artifact', artifactPath]), + /--profile/ + ); + assert.throws( + () => + parseVerifierArguments([ + '--artifact', + artifactPath, + '--profile', + 'standard', + ]), + /Unsupported Linux frame-copy profile/ + ); + assert.throws( + () => + parseVerifierArguments([ + '--artifact', + artifactPath, + '--artifact', + artifactPath, + '--profile', + 'system', + ]), + /duplicate --artifact/ + ); + assert.throws( + () => + parseVerifierArguments([ + '--artifact', + artifactPath, + '--profile', + 'system', + '--profile', + 'system', + ]), + /duplicate --profile/ + ); + } finally { + fs.rmSync(directory, { recursive: true, force: true }); + } +}); + +test('extracts every payload format with argument arrays and no shell', () => { + const root = fs.mkdtempSync( + path.join(os.tmpdir(), 'iptvnator-verifier-extract-') + ); + const invocations = []; + const runCommand = (command, args, options = {}) => { + invocations.push({ command, args: [...args], cwd: options.cwd }); + if (command === 'ostree' && args[0] === 'refs') { + return { + status: 0, + stdout: 'app/com.fourgray.iptvnator/x86_64/stable\n', + stderr: '', + }; + } + return { status: 0, stdout: '', stderr: '' }; + }; + + try { + for (const [format, fileName] of [ + ['appimage', 'IPTVnator.AppImage'], + ['deb', 'IPTVnator.deb'], + ['rpm', 'IPTVnator.rpm'], + ['pacman', 'IPTVnator.pacman'], + ['snap', 'IPTVnator.snap'], + ['flatpak', 'IPTVnator.flatpak'], + ]) { + const artifactPath = path.join(root, fileName); + const destination = path.join(root, `${format}-payload`); + fs.writeFileSync( + artifactPath, + format === 'appimage' + ? Buffer.concat([Buffer.alloc(4096), Buffer.from('hsqs')]) + : 'fixture' + ); + fs.mkdirSync(destination); + extractLinuxArtifact({ + artifactPath, + format, + destination, + runCommand, + }); + } + + assert.deepEqual( + invocations.map(({ command }) => command), + [ + 'unsquashfs', + 'dpkg-deb', + 'bsdtar', + 'bsdtar', + 'unsquashfs', + 'flatpak', + 'ostree', + 'ostree', + ] + ); + assert.equal( + invocations.some( + ({ command, args }) => + ['sh', 'bash'].includes(command) || args.includes('-c') + ), + false + ); + assert.deepEqual(invocations[1].args.slice(0, 2), [ + '--extract', + path.join(root, 'IPTVnator.deb'), + ]); + assert.deepEqual(invocations[2].args.slice(0, 2), [ + '--extract', + '--file', + ]); + assert.deepEqual(invocations[4].args.slice(0, 2), [ + '-no-progress', + '-dest', + ]); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test('locates AppImage SquashFS payloads without executing a foreign-arch runtime', () => { + const root = fs.mkdtempSync( + path.join(os.tmpdir(), 'iptvnator-verifier-appimage-') + ); + const artifactPath = path.join(root, 'arm64.AppImage'); + fs.writeFileSync( + artifactPath, + Buffer.concat([ + Buffer.alloc(128, 0x41), + Buffer.from('hsqs'), + Buffer.alloc(64), + Buffer.from('hsqs'), + ]) + ); + try { + assert.deepEqual(findAppImageSquashfsOffsets(artifactPath), [128, 196]); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test('finds one packaged native payload under arbitrary format roots', () => { + const root = fs.mkdtempSync( + path.join(os.tmpdir(), 'iptvnator-verifier-payload-') + ); + const resourceDir = path.join(root, 'opt', 'IPTVnator', 'resources'); + const nativeDir = path.join( + resourceDir, + 'app.asar.unpacked', + 'electron-backend', + 'native' + ); + fs.mkdirSync(nativeDir, { recursive: true }); + + try { + assert.equal(findExtractedResourceDir(root), resourceDir); + const duplicateNativeDir = path.join( + root, + 'duplicate', + 'resources', + 'app.asar.unpacked', + 'electron-backend', + 'native' + ); + fs.mkdirSync(duplicateNativeDir, { recursive: true }); + assert.throws( + () => findExtractedResourceDir(root), + /exactly one embedded MPV native payload/ + ); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test('reads x64, arm64, and armv7 ELF architectures without host execution', () => { + const root = fs.mkdtempSync( + path.join(os.tmpdir(), 'iptvnator-verifier-elf-') + ); + try { + for (const architecture of ['x64', 'arm64', 'armv7l']) { + const binaryPath = path.join(root, architecture); + fs.writeFileSync(binaryPath, elfHeader(architecture)); + assert.equal(readElfArchitecture(binaryPath), architecture); + } + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test('requires the exact system package dependency for DEB, RPM, and Pacman', () => { + assert.deepEqual( + validateSystemPackageDependencies('deb', [ + 'libmpv2 (>= 0.35)', + 'libc6', + ]), + [] + ); + assert.deepEqual( + validateSystemPackageDependencies('rpm', ['mpv-libs', 'glibc']), + [] + ); + assert.deepEqual( + validateSystemPackageDependencies('pacman', ['mpv>=0.35', 'glibc']), + [] + ); + assert.match( + validateSystemPackageDependencies('deb', ['libmpv1'])[0], + /libmpv2/ + ); +}); + +test('reads DEB and RPM metadata without shell pipelines', () => { + const invocations = []; + const runCommand = (command, args) => { + invocations.push({ command, args: [...args] }); + if (command === 'dpkg-deb' && args.at(-1) === 'Architecture') { + return { status: 0, stdout: 'amd64\n', stderr: '' }; + } + if (command === 'dpkg-deb') { + return { + status: 0, + stdout: 'libmpv2 (>= 0.35), libc6\n', + stderr: '', + }; + } + if (command === 'rpm' && args.includes('%{ARCH}\\n')) { + return { status: 0, stdout: 'x86_64\n', stderr: '' }; + } + return { + status: 0, + stdout: 'mpv-libs\nglibc\n', + stderr: '', + }; + }; + + assert.deepEqual( + readLinuxArtifactMetadata({ + artifactPath: '/tmp/package name.deb', + format: 'deb', + extractionRoot: '/tmp/payload', + runCommand, + }), + { + declaredArch: 'x64', + dependencies: ['libmpv2 (>= 0.35)', 'libc6'], + } + ); + assert.deepEqual( + readLinuxArtifactMetadata({ + artifactPath: '/tmp/package name.rpm', + format: 'rpm', + extractionRoot: '/tmp/payload', + runCommand, + }), + { + declaredArch: 'x64', + dependencies: ['mpv-libs', 'glibc'], + } + ); + assert.equal( + invocations.some( + ({ command, args }) => + ['sh', 'bash'].includes(command) || args.includes('-c') + ), + false + ); +}); + +test('reads Pacman architecture and dependencies from the extracted .PKGINFO', () => { + const root = fs.mkdtempSync( + path.join(os.tmpdir(), 'iptvnator-verifier-pacman-') + ); + fs.writeFileSync( + path.join(root, '.PKGINFO'), + [ + 'pkgname = iptvnator', + 'arch = x86_64', + 'depend = mpv>=0.35', + 'depend = glibc', + '', + ].join('\n') + ); + try { + assert.deepEqual( + readLinuxArtifactMetadata({ + artifactPath: '/tmp/package.pacman', + format: 'pacman', + extractionRoot: root, + }), + { + declaredArch: 'x64', + dependencies: ['mpv>=0.35', 'glibc'], + } + ); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test('validates an x64 system payload and executes one bounded helper probe', () => { + const fixture = createSystemPayload(); + const probeCalls = []; + try { + const errors = verifyExtractedLinuxFrameCopyRuntime({ + resourceDir: fixture.resourceDir, + artifactFormat: 'deb', + profileName: 'system', + packageDependencies: ['libmpv2 (>= 0.35)'], + elfInspector: validElfInspector, + probeRunner(command, args, options) { + probeCalls.push({ command, args, options }); + return successfulProbeRunner(); + }, + environment: { + PATH: '/usr/bin', + LD_LIBRARY_PATH: '/host/can-mask-missing-dependencies', + LD_PRELOAD: '/host/can-inject.so', + }, + }); + assert.deepEqual(errors, []); + assert.equal(probeCalls.length, 1); + assert.equal( + probeCalls[0].command, + path.join(fixture.nativeDir, 'iptvnator_mpv_helper') + ); + assert.deepEqual(probeCalls[0].args, ['--runtime-probe']); + assert.equal(probeCalls[0].options.timeout, 3000); + assert.deepEqual(probeCalls[0].options.env, { PATH: '/usr/bin' }); + } finally { + fs.rmSync(fixture.root, { recursive: true, force: true }); + } +}); + +test('bundled probes use only the packaged library directory', () => { + assert.deepEqual( + createRuntimeProbeEnvironment({ + environment: { + PATH: '/usr/bin', + LD_LIBRARY_PATH: '/host/can-mask-missing-dependencies', + LD_PRELOAD: '/host/can-inject.so', + }, + nativeDir: '/package/resources/native', + runtimeMode: 'bundled', + }), + { + PATH: '/usr/bin', + LD_LIBRARY_PATH: '/package/resources/native/lib', + } + ); +}); + +test('rejects helper probe framing and fields that runtime capability rejects', () => { + const validPayload = + '{"protocol":1,"usable":true,"libmpv":"0.41.0","renderApi":"egl"}'; + for (const stdout of [ + validPayload, + `${validPayload}\n\n`, + `${validPayload.slice(0, -1)},"extra":true}\n`, + ]) { + const fixture = createSystemPayload(); + try { + const errors = verifyExtractedLinuxFrameCopyRuntime({ + resourceDir: fixture.resourceDir, + artifactFormat: 'deb', + profileName: 'system', + packageDependencies: ['libmpv2'], + elfInspector: validElfInspector, + probeRunner() { + return { + status: 0, + signal: null, + stdout, + stderr: '', + }; + }, + }); + assert.match( + errors.join('\n'), + /runtime probe (?:must emit|did not return)/ + ); + } finally { + fs.rmSync(fixture.root, { recursive: true, force: true }); + } + } +}); + +test('reports missing helper, wrong mode, wrong profile, isolation, and loader failures', () => { + const cases = [ + { + mutate({ nativeDir }) { + fs.rmSync(path.join(nativeDir, 'iptvnator_mpv_helper')); + }, + expected: /Missing embedded MPV frame-copy helper/, + }, + { + mutate({ nativeDir }) { + fs.chmodSync( + path.join(nativeDir, 'iptvnator_mpv_helper'), + 0o644 + ); + }, + expected: /must have mode 0755/, + }, + { + profileName: 'portable', + expected: /does not include target "deb"/, + }, + { + elfInspector(binaryPath) { + if (path.basename(binaryPath) === 'embedded_mpv.node') { + return { + soname: null, + needed: ['libmpv.so.2'], + rpath: [], + runpath: [], + }; + } + return validElfInspector(binaryPath); + }, + expected: /addon must not link libmpv/, + }, + { + probeRunner() { + return { + status: 127, + signal: null, + stdout: '', + stderr: 'error while loading shared libraries: libmpv.so.2', + }; + }, + expected: /runtime probe failed with status 127.*libmpv\.so\.2/s, + }, + ]; + + for (const testCase of cases) { + const fixture = createSystemPayload(); + try { + testCase.mutate?.(fixture); + const errors = verifyExtractedLinuxFrameCopyRuntime({ + resourceDir: fixture.resourceDir, + artifactFormat: 'deb', + profileName: testCase.profileName ?? 'system', + packageDependencies: ['libmpv2'], + elfInspector: testCase.elfInspector ?? validElfInspector, + probeRunner: testCase.probeRunner ?? successfulProbeRunner, + }); + assert.match(errors.join('\n'), testCase.expected); + } finally { + fs.rmSync(fixture.root, { recursive: true, force: true }); + } + } +}); + +test('rejects an x64 package manifest produced from a target subset', () => { + const fixture = createSystemPayload(); + const manifestPath = path.join( + fixture.nativeDir, + 'embedded-mpv-runtime.json' + ); + const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); + manifest.targets = ['deb']; + fs.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`); + + try { + const errors = verifyExtractedLinuxFrameCopyRuntime({ + resourceDir: fixture.resourceDir, + artifactFormat: 'deb', + profileName: 'system', + packageDependencies: ['libmpv2'], + elfInspector: validElfInspector, + probeRunner: successfulProbeRunner, + }); + assert.match( + errors.join('\n'), + /manifest targets.*must equal \["deb","pacman","rpm"\]/ + ); + } finally { + fs.rmSync(fixture.root, { recursive: true, force: true }); + } +}); + +test('requires marker-only foreign packages, scans Electron, and never probes', () => { + const fixture = createSystemPayload({ architecture: 'arm64' }); + let probeCalls = 0; + try { + assert.deepEqual( + verifyExtractedLinuxFrameCopyRuntime({ + resourceDir: fixture.resourceDir, + artifactFormat: 'appimage', + profileName: 'portable', + packageDependencies: [], + elfInspector: validElfInspector, + probeRunner() { + probeCalls += 1; + return successfulProbeRunner(); + }, + }), + [] + ); + assert.equal(probeCalls, 0); + + const isolationErrors = verifyExtractedLinuxFrameCopyRuntime({ + resourceDir: fixture.resourceDir, + artifactFormat: 'appimage', + profileName: 'portable', + packageDependencies: [], + elfInspector(binaryPath) { + if (path.basename(binaryPath) === 'iptvnator.bin') { + return { + soname: null, + needed: ['/tmp/libmpv.so.2'], + rpath: [], + runpath: [], + }; + } + return validElfInspector(binaryPath); + }, + probeRunner() { + probeCalls += 1; + return successfulProbeRunner(); + }, + }); + assert.match( + isolationErrors.join('\n'), + /Electron binary must not link libmpv/ + ); + assert.equal(probeCalls, 0); + + const dependencyErrors = verifyExtractedLinuxFrameCopyRuntime({ + resourceDir: fixture.resourceDir, + artifactFormat: 'deb', + profileName: 'system', + packageDependencies: ['libmpv2', 'libc6'], + declaredArch: 'arm64', + elfInspector: validElfInspector, + probeRunner: successfulProbeRunner, + }); + assert.match( + dependencyErrors.join('\n'), + /must not declare frame-copy dependency libmpv2/ + ); + } finally { + fs.rmSync(fixture.root, { recursive: true, force: true }); + } +}); + +test('always removes its temporary extraction root after a verifier failure', () => { + const root = fs.mkdtempSync( + path.join(os.tmpdir(), 'iptvnator-verifier-cleanup-parent-') + ); + const artifactPath = path.join(root, 'package.deb'); + fs.writeFileSync(artifactPath, 'fixture'); + let extractionDestination; + + try { + assert.throws( + () => + verifyLinuxFrameCopyArtifact({ + artifactPath, + profileName: 'system', + extractArtifact({ destination }) { + extractionDestination = destination; + fs.writeFileSync( + path.join(path.dirname(destination), 'sentinel'), + 'temporary' + ); + throw new Error('intentional extraction failure'); + }, + }), + /intentional extraction failure/ + ); + assert.equal( + fs.existsSync(path.dirname(extractionDestination)), + false, + 'temporary verifier root must be removed' + ); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +});