mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-12 12:06:16 -08:00
ci: verify Linux frame-copy packages
This commit is contained in:
1 parent
3387f7817d
commit
5c49deb77a
7 files changed
+2687
-106
No files matched your search
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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/
|
||||
);
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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}"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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(', ') : '<none>'
|
||||
}.`
|
||||
);
|
||||
}
|
||||
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 || '<empty>'
|
||||
}.`
|
||||
);
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user