Merge origin/master into claude/release-draft-ci-audit-dbcef8

Re-apply the draft-release rework onto the workflow restructured by
#1200 (build split into linux-embedded-mpv-runtime + build-cross-platform
+ build-linux with YAML anchors; snap publishing moved to
publish-snap.yaml): job-level concurrency on all three build jobs,
serialized never-cancelled release job, and the full compose/pr-state/
prune/release/metadata step set in create-release, preserving the new
artifact files list (incl. linux-frame-copy-runtime-sources.tar.xz).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5 committed 2026-07-18 17:33:47 +02:00
commit 0703f9dacb
176 files changed
+38583 -916

No files matched your search

+678 -153
View File
@@ -12,18 +12,310 @@ on:
workflow_dispatch:
jobs:
build:
linux-embedded-mpv-runtime:
name: Build pinned Linux Embedded MPV runtime
runs-on: ubuntu-22.04
timeout-minutes: 120
# Concurrency lives on the build jobs, not the workflow: cancelling a
# whole run could interrupt action-gh-release mid-update and leave the
# rolling draft with missing assets. Build slots cancel superseded PR
# work; the release job only serializes and is never cancelled.
concurrency:
group: ${{ github.workflow }}-build-linux-runtime-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
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 gperf \
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/generate-linux-runtime-notices.cjs', 'tools/embedded-mpv/linux-runtime-manifest.cjs', 'tools/embedded-mpv/linux-source-archive-contract.cjs', 'tools/embedded-mpv/stage-runtime.mjs', 'tools/packaging/prepare-linux-runtime-source-snapshot.cjs') }}"
echo "toolchain-sha256=${TOOLCHAIN_SHA256}" >> "${GITHUB_OUTPUT}"
echo "key=linux-frame-copy-runtime-v5-ubuntu-22.04-${TOOLCHAIN_SHA256}-${SOURCE_SHA256}" >> "${GITHUB_OUTPUT}"
- name: Restore pinned Linux runtime and immutable source inputs
id: linux-runtime-cache
uses: actions/cache@v4
with:
path: |
vendor/embedded-mpv/linux-x64/include
vendor/embedded-mpv/linux-x64/lib
vendor/embedded-mpv/linux-x64/runtime-manifest.json
dist/linux-frame-copy-runtime-source-inputs
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 \
gperf \
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_INPUT_ROOT="${GITHUB_WORKSPACE}/dist/linux-frame-copy-runtime-source-inputs"
rm -rf "${SOURCE_INPUT_ROOT}"
mkdir -p \
"${SOURCE_INPUT_ROOT}/archives" \
"${SOURCE_INPUT_ROOT}/git"
git -C "${IPTVNATOR_EMBEDDED_MPV_LINUX_BUILD_ROOT}/sources/libplacebo" \
submodule foreach --recursive git clean -ffdqx
git -C "${IPTVNATOR_EMBEDDED_MPV_LINUX_BUILD_ROOT}/sources/libplacebo" \
clean -ffdqx
cp -a "${IPTVNATOR_EMBEDDED_MPV_LINUX_BUILD_ROOT}/archives/." "${SOURCE_INPUT_ROOT}/archives/"
cp -a "${IPTVNATOR_EMBEDDED_MPV_LINUX_BUILD_ROOT}/sources/libplacebo" "${SOURCE_INPUT_ROOT}/git/libplacebo"
node tools/embedded-mpv/generate-linux-runtime-notices.cjs collect \
--runtime-manifest "${RUNTIME_PREFIX}/runtime-manifest.json" \
--source-root "${IPTVNATOR_EMBEDDED_MPV_LINUX_BUILD_ROOT}/sources" \
--output-root "${SOURCE_INPUT_ROOT}/license-inputs"
- name: Generate Linux runtime notices and assemble source compliance
shell: bash
run: |
set -euo pipefail
export RUNTIME_ROOT="${GITHUB_WORKSPACE}/vendor/embedded-mpv/linux-x64"
export SOURCE_INPUT_ROOT="${GITHUB_WORKSPACE}/dist/linux-frame-copy-runtime-source-inputs"
export SOURCE_BUNDLE_ROOT="${RUNNER_TEMP}/linux-frame-copy-runtime-sources"
export LIBPLACEBO_SOURCE_RECORD="${RUNNER_TEMP}/libplacebo-source-record.json"
test -f "${RUNTIME_ROOT}/runtime-manifest.json"
test -d "${SOURCE_INPUT_ROOT}/archives"
test -d "${SOURCE_INPUT_ROOT}/git/libplacebo"
test -f "${SOURCE_INPUT_ROOT}/license-inputs/linux-runtime-license-inputs.json"
rm -rf "${RUNTIME_ROOT}/notices" "${SOURCE_BUNDLE_ROOT}"
node tools/embedded-mpv/generate-linux-runtime-notices.cjs generate \
--runtime-manifest "${RUNTIME_ROOT}/runtime-manifest.json" \
--license-input-root "${SOURCE_INPUT_ROOT}/license-inputs" \
--output-root "${RUNTIME_ROOT}/notices"
mkdir -p \
"${SOURCE_BUNDLE_ROOT}/archives" \
"${SOURCE_BUNDLE_ROOT}/git" \
"${SOURCE_BUNDLE_ROOT}/license-inputs" \
"${SOURCE_BUNDLE_ROOT}/metadata" \
"${SOURCE_BUNDLE_ROOT}/notices" \
"${SOURCE_BUNDLE_ROOT}/tooling"
cp -a "${SOURCE_INPUT_ROOT}/archives/." "${SOURCE_BUNDLE_ROOT}/archives/"
cp -a "${SOURCE_INPUT_ROOT}/license-inputs/." "${SOURCE_BUNDLE_ROOT}/license-inputs/"
cp -a "${RUNTIME_ROOT}/notices/." "${SOURCE_BUNDLE_ROOT}/notices/"
cp "${RUNTIME_ROOT}/runtime-manifest.json" "${SOURCE_BUNDLE_ROOT}/metadata/runtime-manifest.json"
node tools/packaging/prepare-linux-runtime-source-snapshot.cjs prepare \
--runtime-manifest "${RUNTIME_ROOT}/runtime-manifest.json" \
--checkout "${SOURCE_INPUT_ROOT}/git/libplacebo" \
--output "${SOURCE_BUNDLE_ROOT}/git/libplacebo" \
--record-output "${LIBPLACEBO_SOURCE_RECORD}"
cp \
tools/embedded-mpv/build-linux-runtime.cjs \
tools/embedded-mpv/build-linux-runtime.mjs \
tools/embedded-mpv/generate-linux-runtime-notices.cjs \
tools/embedded-mpv/linux-runtime-manifest.cjs \
tools/embedded-mpv/linux-source-archive-contract.cjs \
tools/embedded-mpv/stage-runtime.mjs \
tools/packaging/prepare-linux-runtime-source-snapshot.cjs \
"${SOURCE_BUNDLE_ROOT}/tooling/"
test -f "${SOURCE_BUNDLE_ROOT}/notices/THIRD_PARTY_NOTICES.txt"
test -f "${SOURCE_BUNDLE_ROOT}/notices/embedded-mpv-notices.json"
git rev-parse HEAD > "${SOURCE_BUNDLE_ROOT}/metadata/iptvnator-git-revision.txt"
git diff --binary HEAD > "${SOURCE_BUNDLE_ROOT}/metadata/local-changes.patch"
node <<'NODE'
const crypto = require('node:crypto');
const fs = require('node:fs');
const path = require('node:path');
const {
EXPECTED_LIBPLACEBO_V7_360_1_SOURCE_SNAPSHOT_SHA256,
validateLinuxRuntimeSourceSnapshot,
} = require('./tools/packaging/prepare-linux-runtime-source-snapshot.cjs');
const manifest = JSON.parse(
fs.readFileSync(path.join(process.env.RUNTIME_ROOT, '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 expectedArchiveHashes = Object.values(manifest.packages)
.map(({ sourceSha256 }) => sourceSha256)
.filter(Boolean)
.sort();
const actualArchiveHashes = archives.map(({ sha256 }) => sha256).sort();
if (
new Set(expectedArchiveHashes).size !== expectedArchiveHashes.length ||
new Set(actualArchiveHashes).size !== actualArchiveHashes.length ||
archives.length !== expectedArchiveHashes.length ||
JSON.stringify(actualArchiveHashes) !== JSON.stringify(expectedArchiveHashes)
) {
throw new Error(
'Source bundle archives must match the exact unique pinned archive hash set.'
);
}
const libplacebo = JSON.parse(
fs.readFileSync(process.env.LIBPLACEBO_SOURCE_RECORD, 'utf8')
);
if (
libplacebo.sourceGitCommit !== manifest.packages.libplacebo.sourceGitCommit ||
JSON.stringify(libplacebo.sourceSubmodules) !==
JSON.stringify(manifest.packages.libplacebo.sourceSubmodules)
) {
throw new Error('Prepared libplacebo source identity does not match the runtime manifest.');
}
validateLinuxRuntimeSourceSnapshot(libplacebo.sourceSnapshot, {
expectedSha256:
EXPECTED_LIBPLACEBO_V7_360_1_SOURCE_SNAPSHOT_SHA256,
});
const notices = JSON.parse(
fs.readFileSync(
path.join(process.env.SOURCE_BUNDLE_ROOT, 'notices', 'embedded-mpv-notices.json'),
'utf8'
)
);
const repositoryRevision = fs
.readFileSync(
path.join(
process.env.SOURCE_BUNDLE_ROOT,
'metadata',
'iptvnator-git-revision.txt'
),
'utf8'
)
.trim();
fs.writeFileSync(
path.join(process.env.SOURCE_BUNDLE_ROOT, 'metadata', 'source-index.json'),
`${JSON.stringify(
{
schemaVersion: 3,
repositoryRevision,
sourcePackages: manifest.packages,
archives,
libplacebo,
legal: {
manifest: 'notices/embedded-mpv-notices.json',
noticeFile: notices.noticeFile,
packages: notices.packages,
},
},
null,
2
)}\n`
);
NODE
(
cd "${SOURCE_BUNDLE_ROOT}/archives"
sha256sum * > "../metadata/archive-sha256.txt"
)
node tools/packaging/prepare-linux-runtime-source-snapshot.cjs assert-vcs-free \
--directory "${SOURCE_BUNDLE_ROOT}"
mkdir -p dist/compliance
rm -f dist/compliance/linux-frame-copy-runtime-sources.tar.xz
tar \
--create \
--xz \
--sort=name \
--mtime='UTC 1970-01-01' \
--owner=0 \
--group=0 \
--numeric-owner \
--file dist/compliance/linux-frame-copy-runtime-sources.tar.xz \
--directory "${SOURCE_BUNDLE_ROOT}" \
.
rm -f "${RUNTIME_ROOT}/source-archive-binding.json"
node tools/embedded-mpv/linux-source-archive-contract.cjs create \
--archive dist/compliance/linux-frame-copy-runtime-sources.tar.xz \
--repository-revision "$(git rev-parse HEAD)" \
--output "${RUNTIME_ROOT}/source-archive-binding.json"
test -s "${RUNTIME_ROOT}/source-archive-binding.json"
- 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-cross-platform:
name: Build on ${{ matrix.os }} ${{ matrix.arch }}
runs-on: ${{ matrix.runner }}
timeout-minutes: 120
# Concurrency lives on the jobs, not the workflow: cancelling a whole
# run could interrupt action-gh-release mid-update and leave the
# rolling draft with missing assets. Build slots cancel superseded PR
# work; the release job below only serializes and is never cancelled.
concurrency:
group: ${{ github.workflow }}-build-${{ matrix.os }}-${{ matrix.arch }}${{ matrix.linux_profile }}-${{ github.event.pull_request.number || github.ref }}
group: ${{ github.workflow }}-build-${{ matrix.os }}-${{ matrix.arch }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
strategy:
fail-fast: false
matrix:
include:
# macOS builds - separate runners to avoid native module conflicts
@@ -39,26 +331,14 @@ jobs:
embedded_mpv_platform: darwin
embedded_mpv_arch: arm64
embedded_mpv_build_runtime: true
# Linux and Windows
- os: linux
runner: ubuntu-22.04
linux_profile: standard
embedded_mpv_platform: linux
embedded_mpv_arch: x64
embedded_mpv_build_runtime: false
- os: linux
runner: ubuntu-24.04
linux_profile: flatpak
embedded_mpv_platform: linux
embedded_mpv_arch: x64
embedded_mpv_build_runtime: false
- os: windows
runner: windows-2022
arch: x64
embedded_mpv_platform: win32
embedded_mpv_arch: x64
embedded_mpv_build_runtime: false
steps:
steps: &electron-build-steps
- name: Checkout code
uses: actions/checkout@v4
@@ -75,38 +355,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 \
dbus-daemon \
flatpak \
flatpak-builder \
libarchive-tools \
libegl-dev \
libgbm-dev \
libgl-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.
@@ -120,12 +412,12 @@ 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:
IPTVNATOR_WINDOWS_EMBEDDED_MPV_RUNTIME_SHA256: ${{ vars.IPTVNATOR_WINDOWS_EMBEDDED_MPV_RUNTIME_SHA256 || secrets.IPTVNATOR_WINDOWS_EMBEDDED_MPV_RUNTIME_SHA256 || '' }}
IPTVNATOR_DEFAULT_WINDOWS_EMBEDDED_MPV_RUNTIME_SHA256: a26e28fa55c15ac5b6209d8a9f9c3c8cb39649631a53d3c4774beeae559a078c
IPTVNATOR_DEFAULT_WINDOWS_EMBEDDED_MPV_RUNTIME_SHA256: 6014aa0e6d8e98cdba90f5288295a7105d7d14ab0ca906f51465eeb478d5fea0
run: |
set -euo pipefail
@@ -193,7 +485,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:
@@ -206,7 +498,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
@@ -236,8 +528,8 @@ jobs:
env:
IPTVNATOR_WINDOWS_EMBEDDED_MPV_RUNTIME_URL: ${{ vars.IPTVNATOR_WINDOWS_EMBEDDED_MPV_RUNTIME_URL || secrets.IPTVNATOR_WINDOWS_EMBEDDED_MPV_RUNTIME_URL || '' }}
IPTVNATOR_WINDOWS_EMBEDDED_MPV_RUNTIME_SHA256: ${{ vars.IPTVNATOR_WINDOWS_EMBEDDED_MPV_RUNTIME_SHA256 || secrets.IPTVNATOR_WINDOWS_EMBEDDED_MPV_RUNTIME_SHA256 || '' }}
IPTVNATOR_DEFAULT_WINDOWS_EMBEDDED_MPV_RUNTIME_URL: https://github.com/zhongfly/mpv-winbuild/releases/download/2026-06-14-7d245fd100/mpv-dev-lgpl-x86_64-20260614-git-7d245fd100.7z
IPTVNATOR_DEFAULT_WINDOWS_EMBEDDED_MPV_RUNTIME_SHA256: a26e28fa55c15ac5b6209d8a9f9c3c8cb39649631a53d3c4774beeae559a078c
IPTVNATOR_DEFAULT_WINDOWS_EMBEDDED_MPV_RUNTIME_URL: https://github.com/zhongfly/mpv-winbuild/releases/download/2026-07-17-94335ab87a/mpv-dev-lgpl-x86_64-20260717-git-94335ab87a.7z
IPTVNATOR_DEFAULT_WINDOWS_EMBEDDED_MPV_RUNTIME_SHA256: 6014aa0e6d8e98cdba90f5288295a7105d7d14ab0ca906f51465eeb478d5fea0
run: |
set -euo pipefail
@@ -261,47 +553,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
@@ -338,27 +594,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 { execFileSync } = require('node:child_process'); const manifest = require('./dist/apps/electron-backend/native/embedded-mpv-runtime.json'); const revision = execFileSync('git', ['rev-parse', 'HEAD'], { encoding: 'utf8' }).trim(); if (manifest.origin !== 'linux-frame-copy-build' || manifest.sourceRuntimeValidated !== true || manifest.sourceArchive?.schemaVersion !== 1 || manifest.sourceArchive?.name !== 'linux-frame-copy-runtime-sources.tar.xz' || !/^[a-f0-9]{64}$/.test(manifest.sourceArchive?.sha256 ?? '') || manifest.sourceArchive?.repositoryRevision !== revision) { throw new Error('Linux embedded MPV build manifest must describe the validated source runtime and exact source archive.'); }"
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
;;
@@ -564,6 +822,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' }}
@@ -574,11 +833,250 @@ 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
for foreign_arch in armv7l arm64; do
rm -rf dist/executables-linux-foreign
cp "${RUNNER_TEMP}/electron-builder.base.json" electron-builder.json
node tools/packaging/configure-linux-frame-copy-build.mjs \
--foreign-deb \
--foreign-arch "${foreign_arch}"
pnpm nx run electron-backend:make \
--arch="${foreign_arch}" \
--outputPath=dist/executables-linux-foreign \
--publishPolicy=never
mapfile -t foreign_debs < <(
find dist/executables-linux-foreign -maxdepth 1 -type f -name '*.deb' -print
)
test "${#foreign_debs[@]}" -eq 1
case "${foreign_arch}" in
armv7l) expected_deb_arch=armhf ;;
arm64) expected_deb_arch=arm64 ;;
*)
echo "::error::Unexpected foreign DEB build architecture ${foreign_arch}"
exit 1
;;
esac
actual_deb_arch="$(dpkg-deb --field "${foreign_debs[0]}" Architecture)"
test "${actual_deb_arch}" = "${expected_deb_arch}"
mv "${foreign_debs[0]}" dist/executables/
done
- 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 libgbm1 libgl1 libgl1-mesa-dri libmpv2 \
nodejs squashfs-tools xauth xvfb
xvfb-run -a env LIBGL_ALWAYS_SOFTWARE=1 \
node tools/packaging/verify-linux-frame-copy-runtime.mjs \
--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 libglvnd-egl libglvnd-glx mesa-dri-drivers \
mesa-libgbm mpv-libs nodejs rpm xorg-x11-server-Xvfb \
xorg-x11-xauth
xvfb-run -a env LIBGL_ALWAYS_SOFTWARE=1 \
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 libglvnd mesa mpv nodejs xorg-server-xvfb \
xorg-xauth
xvfb-run -a env LIBGL_ALWAYS_SOFTWARE=1 \
node tools/packaging/verify-linux-frame-copy-runtime.mjs \
--artifact /artifact.pacman --profile system
'
- 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 \
2>&1 | tee /dev/stderr
)"
if printf '%s\n' "${verification}" | grep -Fq 'Verified snap x64 Linux'; then
snap list mesa-core22 >/dev/null 2>&1 || sudo snap install mesa-core22
snap list gnome-3-28-1804 >/dev/null 2>&1 || sudo snap install gnome-3-28-1804
sudo snap install --dangerous "${artifact}"
installed_x64=true
fi
done
test "${found}" = true
test "${installed_x64}" = true
sudo snap connect iptvnator:graphics-core22 mesa-core22:graphics-core22
sudo snap connect iptvnator:gnome-3-28-1804 gnome-3-28-1804:gnome-3-28-1804
sudo snap disconnect iptvnator:graphics-core22 mesa-core22:graphics-core22
snap connections iptvnator | awk \
'$2 == "iptvnator:graphics-core22" && $3 == "-" { found=1 } END { exit !found }'
set +e
disconnected_probe="$(
xvfb-run -a env LIBGL_ALWAYS_SOFTWARE=1 \
snap run iptvnator --embedded-mpv-runtime-probe 2>&1
)"
disconnected_status=$?
set -e
printf '%s\n' "${disconnected_probe}"
test "${disconnected_status}" -eq 1
printf '%s\n' "${disconnected_probe}" | \
grep -Fx '{"usable":false,"reason":"snap-graphics-provider-unavailable"}'
sudo snap connect iptvnator:graphics-core22 mesa-core22:graphics-core22
snap connections iptvnator | awk \
'$2 == "iptvnator:graphics-core22" && $3 == "mesa-core22:graphics-core22" { found=1 } END { exit !found }'
snap connections iptvnator | awk \
'$2 == "iptvnator:gnome-3-28-1804" && $3 == "gnome-3-28-1804:gnome-3-28-1804" { found=1 } END { exit !found }'
snap connections iptvnator | awk \
'$1 == "shared-memory" && $2 == "iptvnator:shared-memory" && $3 == ":shared-memory" { found=1 } END { exit !found }'
xvfb-run -a env \
LIBGL_ALWAYS_SOFTWARE=1 \
IPTVNATOR_TRACE_PLAYER=1 \
EGL_LOG_LEVEL=debug \
LIBGL_DEBUG=verbose \
__EGL_VENDOR_LIBRARY_FILENAMES=/tmp/hostile-egl-vendor.json \
GBM_BACKEND=/tmp/hostile-gbm \
MESA_LOADER_DRIVER_OVERRIDE=/tmp/hostile-dri \
LIBVA_DRIVER_NAME=/tmp/hostile-va \
VDPAU_DRIVER_PATH=/tmp/hostile-vdpau \
VK_DRIVER_FILES=/tmp/hostile-vulkan-driver.json \
VK_ICD_FILENAMES=/tmp/hostile-vulkan-icd.json \
VK_ADD_DRIVER_FILES=/tmp/hostile-vulkan-add-driver.json \
VK_ADD_LAYER_PATH=/tmp/hostile-vulkan-layers \
VK_IMPLICIT_LAYER_PATH=/tmp/hostile-vulkan-implicit-layers \
VK_ADD_IMPLICIT_LAYER_PATH=/tmp/hostile-vulkan-add-implicit-layers \
XDG_CONFIG_HOME=/tmp/hostile-xdg-config-home \
XDG_CONFIG_DIRS=/tmp/hostile-xdg-config-dirs \
XDG_DATA_HOME=/tmp/hostile-xdg-data-home \
XDG_DATA_DIRS=/tmp/hostile-xdg-data-dirs \
snap run iptvnator --embedded-mpv-runtime-probe
- name: Run packaged x64 frame-copy and fallback smoke
if: matrix.os == 'linux' && matrix.linux_profile == 'portable'
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.
@@ -591,7 +1089,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: |
@@ -603,8 +1101,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
@@ -616,6 +1118,10 @@ jobs:
grep -q '\''readlink -f "$SCRIPT_PATH"'\'' "${LAUNCHER_PATH}"
grep -q '\''exec "$SCRIPT_DIR/iptvnator.bin"'\'' "${LAUNCHER_PATH}"
'
xvfb-run -a dbus-run-session -- flatpak run \
--env=LIBGL_ALWAYS_SOFTWARE=1 \
com.fourgray.iptvnator \
--embedded-mpv-runtime-probe
- name: Upload artifacts (macOS)
if: matrix.os == 'macos'
@@ -629,23 +1135,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:
@@ -667,9 +1181,47 @@ jobs:
dist/executables/**/*.blockmap
retention-days: 7
build-linux:
name: Build on ${{ matrix.os }} ${{ matrix.arch }} (${{ matrix.linux_profile }})
needs: linux-embedded-mpv-runtime
runs-on: ${{ matrix.runner }}
timeout-minutes: 120
concurrency:
group: ${{ github.workflow }}-build-${{ matrix.os }}-${{ matrix.linux_profile }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
strategy:
fail-fast: false
matrix:
include:
- os: linux
runner: ubuntu-22.04
arch: x64
linux_profile: system
embedded_mpv_platform: linux
embedded_mpv_arch: x64
embedded_mpv_build_runtime: false
- os: linux
runner: ubuntu-22.04
arch: x64
linux_profile: portable
embedded_mpv_platform: linux
embedded_mpv_arch: x64
embedded_mpv_build_runtime: false
- os: linux
runner: ubuntu-24.04
arch: x64
linux_profile: flatpak
embedded_mpv_platform: linux
embedded_mpv_arch: x64
embedded_mpv_build_runtime: false
steps: *electron-build-steps
create-release:
name: Create Draft Release
needs: build
needs:
- build-cross-platform
- build-linux
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }}
runs-on: ubuntu-latest
concurrency:
@@ -927,15 +1479,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
@@ -992,31 +1545,3 @@ jobs:
--arg body "${FULL_BODY}" \
'{name: $name, target_commitish: $commitish, body: ($body | .[0:120000])}' |
gh api -X PATCH "repos/${GITHUB_REPOSITORY}/releases/${RELEASE_ID}" --input - > /dev/null
publish-snap:
name: Publish to Snapcraft Store
needs: build
runs-on: ubuntu-latest
if: startsWith(github.ref, 'refs/tags/v')
env:
SNAPCRAFT_STORE_CREDENTIALS: ${{ secrets.snapcraft_token }}
steps:
- name: Download snap artifact
uses: actions/download-artifact@v4
with:
name: linux-artifacts
path: artifacts
- name: Setup Snapcraft
uses: samuelmeuli/action-snapcraft@v3
- name: Publish all snaps to edge channel
run: |
# Find and publish all snap files
for SNAP_FILE in artifacts/*.snap; do
if [ -f "$SNAP_FILE" ]; then
echo "Publishing: $SNAP_FILE"
snapcraft upload --release=edge "$SNAP_FILE"
fi
done
+269
View File
@@ -0,0 +1,269 @@
name: Publish Snap after public release
on:
release:
types:
- published
permissions:
contents: read
jobs:
verify-snap:
name: Verify public-release Snap assets
if: ${{ startsWith(github.event.release.tag_name, 'v') && github.event.release.draft == false }}
runs-on: ubuntu-latest
timeout-minutes: 45
env:
SOURCE_ARCHIVE_NAME: linux-frame-copy-runtime-sources.tar.xz
outputs:
receipt-sha256: ${{ steps.bind-transfer.outputs.receipt-sha256 }}
steps:
- name: Checkout released tooling
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
with:
ref: ${{ github.event.release.tag_name }}
persist-credentials: false
- name: Install release source verifier
shell: bash
run: |
set -euo pipefail
sudo apt-get update
sudo apt-get install --no-install-recommends -y \
binutils \
squashfs-tools \
xz-utils
- name: Select exact public release assets
shell: bash
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
gh api \
--paginate \
--slurp \
"repos/${GITHUB_REPOSITORY}/releases/${{ github.event.release.id }}/assets?per_page=100" \
> "${RUNNER_TEMP}/snap-release-assets.json"
node tools/packaging/release-snap-assets.cjs select \
--assets-json "${RUNNER_TEMP}/snap-release-assets.json" \
--output-json "${RUNNER_TEMP}/selected-snap-release-assets.json"
- name: Download exact public release assets
shell: bash
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
ASSET_DIRECTORY="${RUNNER_TEMP}/snap-release-downloads"
rm -rf "${ASSET_DIRECTORY}"
mkdir -p "${ASSET_DIRECTORY}"
node -e \
"const fs=require('node:fs'); const selected=JSON.parse(fs.readFileSync(process.argv[1],'utf8')); for (const asset of [...selected.snapAssets, selected.sourceAsset]) console.log([asset.id, asset.name].join('\\t'));" \
"${RUNNER_TEMP}/selected-snap-release-assets.json" |
while IFS=$'\t' read -r ASSET_ID ASSET_NAME; do
gh api \
--header "Accept: application/octet-stream" \
"repos/${GITHUB_REPOSITORY}/releases/assets/${ASSET_ID}" \
> "${ASSET_DIRECTORY}/${ASSET_NAME}"
done
- name: Verify downloaded public release assets
shell: bash
run: |
set -euo pipefail
VERIFIED_ASSET_STAGING="${RUNNER_TEMP}/verified-snap-release-assets"
SEALED_ASSET_PARENT="/var/lib/iptvnator-snap-release"
SEALED_ASSET_DIRECTORY="${SEALED_ASSET_PARENT}/assets"
test -s "${RUNNER_TEMP}/snap-release-downloads/${SOURCE_ARCHIVE_NAME}"
test ! -e "${VERIFIED_ASSET_STAGING}"
sudo test ! -e "${SEALED_ASSET_PARENT}"
node tools/packaging/release-snap-assets.cjs verify \
--manifest "${RUNNER_TEMP}/selected-snap-release-assets.json" \
--directory "${RUNNER_TEMP}/snap-release-downloads" \
--repository-revision "$(git rev-parse HEAD)" \
--verified-directory "${VERIFIED_ASSET_STAGING}"
sudo install -d -m 0700 -o root -g root "${SEALED_ASSET_PARENT}"
sudo mv "${VERIFIED_ASSET_STAGING}" "${SEALED_ASSET_DIRECTORY}"
sudo chown -R root:root "${SEALED_ASSET_DIRECTORY}"
sudo find "${SEALED_ASSET_DIRECTORY}" -type d -exec chmod 0555 {} +
sudo find "${SEALED_ASSET_DIRECTORY}" -type f -exec chmod 0444 {} +
sudo chmod 0555 "${SEALED_ASSET_PARENT}"
- name: Reverify sealed public release assets
shell: bash
run: |
set -euo pipefail
VERIFIED_ASSET_DIRECTORY="/var/lib/iptvnator-snap-release/assets"
node tools/packaging/release-snap-assets.cjs verify-sealed \
--manifest "${RUNNER_TEMP}/selected-snap-release-assets.json" \
--directory "${VERIFIED_ASSET_DIRECTORY}" \
--receipt "${VERIFIED_ASSET_DIRECTORY}/verified-release-assets.json" \
--repository-revision "$(git rev-parse HEAD)"
- name: Bind verified release transfer
id: bind-transfer
shell: bash
run: |
set -euo pipefail
RECEIPT_PATH="/var/lib/iptvnator-snap-release/assets/verified-release-assets.json"
RECEIPT_RECORD="$(/usr/bin/sha256sum --binary "${RECEIPT_PATH}")"
RECEIPT_SHA256="${RECEIPT_RECORD%% *}"
[[ "${RECEIPT_SHA256}" =~ ^[a-f0-9]{64}$ ]]
printf 'receipt-sha256=%s\n' "${RECEIPT_SHA256}" >> "${GITHUB_OUTPUT}"
- name: Transfer verified release assets
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
with:
name: verified-snap-release-assets
path: /var/lib/iptvnator-snap-release/assets
if-no-files-found: error
retention-days: 1
compression-level: 0
include-hidden-files: true
publish-snap:
name: Publish verified public-release Snap to edge
needs: verify-snap
if: ${{ needs.verify-snap.result == 'success' && startsWith(github.event.release.tag_name, 'v') && github.event.release.draft == false }}
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- name: Download verified release assets
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093
with:
name: verified-snap-release-assets
path: ${{ runner.temp }}/verified-snap-release-assets
- name: Seal transferred public release assets
shell: bash
env:
EXPECTED_RECEIPT_SHA256: ${{ needs.verify-snap.outputs.receipt-sha256 }}
run: |
set -euo pipefail
TRANSFERRED_ASSET_DIRECTORY="${RUNNER_TEMP}/verified-snap-release-assets"
SEALED_ASSET_PARENT="/var/lib/iptvnator-snap-release"
SEALED_ASSET_DIRECTORY="${SEALED_ASSET_PARENT}/assets"
test -d "${TRANSFERRED_ASSET_DIRECTORY}"
test ! -L "${TRANSFERRED_ASSET_DIRECTORY}"
shopt -s nullglob dotglob
TRANSFERRED_FILES=("${TRANSFERRED_ASSET_DIRECTORY}"/*)
TRANSFERRED_SNAPS=("${TRANSFERRED_ASSET_DIRECTORY}"/*.snap)
test "${#TRANSFERRED_SNAPS[@]}" -gt 0
test "${#TRANSFERRED_FILES[@]}" -eq "$(( ${#TRANSFERRED_SNAPS[@]} + 2 ))"
test -f "${TRANSFERRED_ASSET_DIRECTORY}/linux-frame-copy-runtime-sources.tar.xz"
test ! -L "${TRANSFERRED_ASSET_DIRECTORY}/linux-frame-copy-runtime-sources.tar.xz"
test -f "${TRANSFERRED_ASSET_DIRECTORY}/verified-release-assets.json"
test ! -L "${TRANSFERRED_ASSET_DIRECTORY}/verified-release-assets.json"
for ASSET_FILE in "${TRANSFERRED_FILES[@]}"; do
test -f "${ASSET_FILE}"
test ! -L "${ASSET_FILE}"
done
RECEIPT_PATH="${TRANSFERRED_ASSET_DIRECTORY}/verified-release-assets.json"
RECEIPT_RECORD="$(/usr/bin/sha256sum --binary "${RECEIPT_PATH}")"
ACTUAL_RECEIPT_SHA256="${RECEIPT_RECORD%% *}"
[[ "${EXPECTED_RECEIPT_SHA256}" =~ ^[a-f0-9]{64}$ ]]
test "${ACTUAL_RECEIPT_SHA256}" = "${EXPECTED_RECEIPT_SHA256}"
/usr/bin/jq --exit-status '
type == "object" and
(keys == ["assets", "repositoryRevision", "schemaVersion"]) and
(.schemaVersion == 1) and
(.repositoryRevision |
type == "string" and test("^[a-f0-9]{40,64}$")) and
(.assets | type == "array" and length >= 2) and
(.assets | all(.[];
type == "object" and
(keys == ["id", "name", "sha256", "size"]) and
(.id |
type == "number" and . > 0 and
. <= 9007199254740991 and . == floor) and
(.name |
type == "string" and length > 0 and
. != "." and . != ".." and
(contains("/") | not) and
(contains("\\") | not) and
(explode | all(.[]; . > 31 and . != 127))) and
(.sha256 |
type == "string" and test("^[a-f0-9]{64}$")) and
(.size |
type == "number" and . > 0 and
. <= 9007199254740991 and . == floor))) and
([.assets[].name] | length == (unique | length)) and
([.assets[] |
select(.name == "linux-frame-copy-runtime-sources.tar.xz")] |
length == 1) and
([.assets[] | select(.name | endswith(".snap"))] |
length >= 1) and
(.assets | all(.[];
.name == "linux-frame-copy-runtime-sources.tar.xz" or
(.name | endswith(".snap"))))
' "${RECEIPT_PATH}" > /dev/null
RECEIPT_ASSET_COUNT="$(/usr/bin/jq --raw-output '.assets | length' "${RECEIPT_PATH}")"
test "${RECEIPT_ASSET_COUNT}" -eq "$(( ${#TRANSFERRED_SNAPS[@]} + 1 ))"
SIZE_MANIFEST="${RUNNER_TEMP}/verified-release-asset-sizes.tsv"
CHECKSUM_MANIFEST="${RUNNER_TEMP}/verified-release-asset-checksums.txt"
umask 077
/usr/bin/jq --raw-output \
'.assets[] | [.name, (.size | tostring)] | @tsv' \
"${RECEIPT_PATH}" > "${SIZE_MANIFEST}"
while IFS=$'\t' read -r ASSET_NAME EXPECTED_SIZE; do
ASSET_PATH="${TRANSFERRED_ASSET_DIRECTORY}/${ASSET_NAME}"
ACTUAL_SIZE="$(/usr/bin/stat --format=%s -- "${ASSET_PATH}")"
test "${ACTUAL_SIZE}" = "${EXPECTED_SIZE}"
done < "${SIZE_MANIFEST}"
/usr/bin/jq --raw-output \
'.assets[] | "\(.sha256) \(.name)"' \
"${RECEIPT_PATH}" > "${CHECKSUM_MANIFEST}"
(
cd "${TRANSFERRED_ASSET_DIRECTORY}"
/usr/bin/sha256sum --strict --check "${CHECKSUM_MANIFEST}"
)
rm -f "${SIZE_MANIFEST}" "${CHECKSUM_MANIFEST}"
shopt -u nullglob dotglob
sudo test ! -e "${SEALED_ASSET_PARENT}"
sudo install -d -m 0700 -o root -g root "${SEALED_ASSET_PARENT}"
sudo mv "${TRANSFERRED_ASSET_DIRECTORY}" "${SEALED_ASSET_DIRECTORY}"
sudo chown -R root:root "${SEALED_ASSET_DIRECTORY}"
sudo find "${SEALED_ASSET_DIRECTORY}" -type d -exec chmod 0555 {} +
sudo find "${SEALED_ASSET_DIRECTORY}" -type f -exec chmod 0444 {} +
sudo chmod 0555 "${SEALED_ASSET_PARENT}"
- name: Install Snapcraft
shell: bash
run: |
set -euo pipefail
sudo snap install snapcraft --classic --channel=stable
- name: Publish all public-release snaps to edge
shell: bash
env:
SNAPCRAFT_STORE_CREDENTIALS: ${{ secrets.snapcraft_token }}
run: |
set -euo pipefail
VERIFIED_ASSET_DIRECTORY="/var/lib/iptvnator-snap-release/assets"
STORE_CREDENTIALS="${SNAPCRAFT_STORE_CREDENTIALS}"
unset SNAPCRAFT_STORE_CREDENTIALS
shopt -s nullglob dotglob
SNAP_FILES=("${VERIFIED_ASSET_DIRECTORY}"/*.snap)
test "${#SNAP_FILES[@]}" -gt 0
for SNAP_FILE in "${SNAP_FILES[@]}"; do
SNAP_NAME="${SNAP_FILE##*/}"
echo "Publishing public release asset: ${SNAP_NAME}"
# Candidate/stable promotion is manual after installed-Snap frame-copy and missing-runtime fallback smoke.
# GitHub Actions never promotes automatically.
SNAPCRAFT_STORE_CREDENTIALS="${STORE_CREDENTIALS}" /snap/bin/snapcraft upload --release=edge "${SNAP_FILE}"
done
unset STORE_CREDENTIALS
shopt -u nullglob dotglob
+1
View File
@@ -84,4 +84,5 @@ apps/electron-backend/src/app/options/electron-builder.metadata.generated.json
vendor/embedded-mpv/*/bin/
vendor/embedded-mpv/*/include/
vendor/embedded-mpv/*/lib/
vendor/embedded-mpv/*/notices/
vendor/embedded-mpv/*/runtime-manifest.json
@@ -0,0 +1,90 @@
# Linux Embedded MPV Frame-Copy Packaging Plan
## Audited baseline
- Linux frame-copy already has an isolated `iptvnator_mpv_helper`, a frame
reader addon, shared controls, native-view fallback, and runtime capability
probes.
- Existing packaged Linux builds intentionally remove the helper/runtime and
retain only system `mpv --wid` native-view.
- Electron, Electron libraries, `embedded_mpv.node`, and
`embedded_mpv_frame_reader.node` must never load or link libmpv. Only the
helper may link it.
- Electron Builder produces AppImage, DEB, RPM, Pacman, Snap, and Flatpak
Linux targets. The available reproducible native/runtime toolchain is x64.
## Decisions
1. Support official frame-copy artifacts on Linux x64 only. Keep every non-x64
artifact marker-only and fail closed to native-view; never accept an
architecture override that injects x64 native files.
2. Use three isolated packaging profiles:
- `system`: DEB/RPM/Pacman use declared distribution libmpv/GL dependencies
and contain no private `native/lib`.
- `portable`: AppImage/Snap contain a pinned LGPL-compatible shared-library
closure with `$ORIGIN`-relative helper loading.
- `flatpak`: Flatpak contains the same pinned closure, validated in the
exact `/app` runtime context.
3. Treat the package manifest as necessary but insufficient. Frame-copy is
available only after exact manifest/schema/profile checks, executable-mode
checks, artifact hashes, dependency-closure/process-isolation checks, and a
bounded helper runtime probe. No environment flag bypasses this gate.
4. Publish exact source archives, recursive source identities, build flags,
licenses, notices, patches/tooling, and pinned display data for bundled
runtimes. Bind every bundled x64 package manifest to the final compliance
archive bytes and released repository revision.
5. Keep Snap Store credentials isolated from release-tag code on a fresh
runner. Store publication is edge-only; candidate/stable promotion remains
manual after installed-package smoke.
## TDD implementation phases
1. Add failing tests for target/profile partitioning, x64 and marker-only
layouts, exact dependency declarations, RPATH/SONAME rules, executable
modes, and Electron/libmpv isolation.
2. Implement profile-aware build and packaging hooks that stage the helper,
frame reader, runtime manifest, private closure where applicable, and legal
payload without weakening native-view.
3. Add failing runtime-policy tests for missing/tampered files, wrong
architecture/profile, malformed manifests, loader failures, hostile
environments, probe timeout/output bounds, and stable fallback reasons.
4. Implement one sanitized helper environment shared by probe and playback,
including Snap graphics-provider handling and Flatpak runtime paths.
5. Add failing compliance/release tests for exact recursive submodule records,
VCS-free source inventory, archive member/type layout, source checksums,
license/notices completeness, package-to-source byte binding, sealed asset
receipts, and credential boundaries.
6. Implement deterministic source generation, package bindings, static Snap
inspection, fresh-runner artifact transfer, and minimal direct Store
upload.
7. Add packaged x64 smoke for actual frame-copy playback plus missing-runtime
native-view fallback. Run fixture-contract tests before the smoke and allow
CI llvmpipe through Chromium's GPU blocklist without bypassing the runtime
gate.
8. Update canonical architecture/maintenance documentation and mirrored
`AGENTS.md`/`CLAUDE.md` contracts.
## Acceptance and verification matrix
- Local/macOS:
- Nx discovery
- packaging and Electron backend unit/integration tests
- packaged-smoke fixture tests
- affected lint targets
- production backend build
- formatting, syntax, and `git diff --check`
- Linux x64 CI:
- build the pinned runtime/helper/frame reader
- verify helper links/resolves libmpv and Electron/addons do not
- extract and statically validate all six package families
- run system, portable, Snap-installed, and Flatpak application probes
- run packaged frame-copy playback and missing-runtime native-view fallback
- regenerate and bind the exact compliance source archive
- Non-x64 CI:
- build selected ARM package targets independently
- require marker-only layout and absence of every x64 native/runtime artifact
- Merge gate:
- exact-head CI green
- no unresolved review findings
- fresh code review clean
- no automatic Snap promotion beyond edge
+138 -1
View File
@@ -70,7 +70,7 @@ IPTVNATOR_TRACE_STARTUP=1 nx serve electron-backend
- `IPTVNATOR_TRACE_DB=1` traces DB worker requests and request-scoped DB events
- `IPTVNATOR_TRACE_SQL=1` traces SQLite statements in the main process and DB worker
- `IPTVNATOR_TRACE_WINDOW=1` traces BrowserWindow lifecycle and unresponsive events
- `IPTVNATOR_TRACE_PLAYER=1` traces external-player launch/reuse/polling debug output
- `IPTVNATOR_TRACE_PLAYER=1` traces external-player activity and bounded Embedded MPV runtime-probe stderr
- `IPTVNATOR_TRACE_RENDERER_CONSOLE=1` mirrors renderer console output into the Electron terminal
- GPU/compositor debugging:
@@ -216,6 +216,143 @@ Key files:
- Canonical docs: `docs/architecture/player-controls-contract.md` and
`docs/architecture/embedded-mpv-native.md`
## Linux Embedded MPV Packaging
- Official Linux frame-copy artifacts are x64-only. AppImage, DEB, RPM,
Pacman, Snap, and Flatpak are supported; non-x64 Linux packages must remain
marker-only and must never inherit x64 native artifacts from environment
overrides.
- Packaging runs three isolated profiles:
- `system`: DEB/RPM/Pacman, no private `native/lib`, with package
dependencies DEB=`libmpv2,libegl1,libgl1,libgbm1`,
RPM=`mpv-libs,libglvnd-egl,libglvnd-glx,mesa-libgbm`, and
Pacman=`mpv,libglvnd,mesa`
- `portable`: AppImage/Snap with the pinned LGPL-compatible closure
- `flatpak`: Flatpak with the same pinned closure
- The DEB system-runtime contract is Ubuntu 24.04+ (`libmpv2`). Ubuntu 22.04
provides `libmpv1`, so use the x64 AppImage on Jammy instead of weakening the
package dependency or advertising frame-copy without a compatible runtime.
- Only `iptvnator_mpv_helper` may link libmpv. The Electron executable,
Electron libraries, `embedded_mpv.node`, and
`embedded_mpv_frame_reader.node` must not load or link it. Preserve this
process-isolation contract in build, package, and smoke checks.
- `electron-backend/native{,/**/*}` is excluded from `app.asar`; `afterPack`
exclusively writes the profile-normalized unpacked native tree. Layout and
final-artifact checks must reject every archived
`/electron-backend/native/**` entry so system and marker-only packages cannot
hide stale x64 artifacts.
- Packaged addon, frame-reader, and helper discovery is package-owned
`app.asar.unpacked` only. Writable cwd/dist candidates are development-only
and must never satisfy packaged native-view support or the frame-copy gate.
- Pristine afterPack/unpacked layouts scan Electron libraries recursively.
Extracted Snap payloads exclude only the package-manager `lib/**` and
`usr/lib/**` trees that Snap overlays into the same root; every other
directory remains recursive, and Electron-library symlinks still fail
closed.
- Linux frame-copy availability is fail-closed. The packaged manifest,
artifact modes, declared bundled hashes/closure, and bounded
`--runtime-probe` must all succeed before frame-copy can relax the renderer
sandbox. Any failure reports a stable reason and falls back to native-view
without crashing; an environment flag never bypasses this gate.
- Snap is `core22`/strict and uses an exact private `shared-memory` plug plus
the `graphics-core22` content plug at an empty mode-0755 `$SNAP/graphics`,
with `mesa-core22` as default provider. It declares only the canonical
provider layouts: `/usr/share/libdrm` binds from
`$SNAP/graphics/libdrm`, and `/usr/share/drirc.d` symlinks to
`$SNAP/graphics/drirc.d`. The provider is external shared content, not part
of IPTVnator's package size, source archive, or notices. Installed-Snap CI
must prove controlled unavailable exit after disconnect, then reconnect and
prove success. The helper links `libGL.so.1` rather than `libOpenGL.so.0`.
- The probe and playback helper share one sanitized loader environment:
ambient audit, preload, library, graphics-driver, and shell-startup overrides
are removed; the validated private closure wins; trusted Snap GL,
`graphics-core22`, the core22 base x64 root, and exact GNOME-platform roots
precede generic in-snap roots. The core22 base must precede GNOME so its
`libedit.so.2` cannot be replaced by the older copy requiring
`libtinfo.so.5`. The extracted-artifact verifier removes the identical
unsafe loader/graphics/shell set before direct helper smoke while preserving
feature/debug selectors such as `LIBGL_ALWAYS_SOFTWARE`. Snap fixes the
wrapper `PATH`, removes exported `BASH_FUNC_*` functions, and launches
probe/playback through the regular executable
`$SNAP/graphics/bin/graphics-core22-provider-wrapper`; a missing or
disconnected provider returns `snap-graphics-provider-unavailable` before
helper spawn. The packaging-only `--embedded-mpv-runtime-probe` app switch
runs the complete cached manifest/hash/helper gate before BrowserWindow
startup and exits with one availability JSON line. A nonzero helper exit
keeps top-level reason `helper-probe-failed`; `helperReason` is present only
for an exact protocol-v1 line carrying a fixed allowlisted reason, and its
optional `helperDetail` must be 1–1024 printable ASCII characters. Invalid
detail suppresses both helper fields. Every probe uses an explicit 16 MiB
aggregate captured-output ceiling independent of tracing. With
`IPTVNATOR_TRACE_PLAYER=1`, a non-empty helper stderr capture is emitted
separately as one JSON-escaped stderr line whose `stderr` field is limited
to 16,384 characters and whose `truncated` field is always explicit;
trace-write failure cannot change the capability result. Installed-Snap CI
enables Mesa EGL/GL diagnostics through this bounded channel. Any loader
failure remains a stable native-view fallback, never a flag-enabled success.
- In the exact packaged Flatpak `/app` context, reconstruct only Freedesktop
Platform 24.08's immutable `__EGL_EXTERNAL_PLATFORM_CONFIG_DIRS`; its GL
extension loader path comes from the sandbox cache. Flatpak CI must invoke
the application-level `--embedded-mpv-runtime-probe`, not a direct helper
probe that bypasses capability detection.
- The packaged x64 Playwright smoke runs its fixture-contract target first and
passes Chromium `--ignore-gpu-blocklist` so CI llvmpipe can expose WebGL2.
This launch-only flag does not bypass the manifest, hash, loader, or helper
capability gate; `--no-sandbox` remains root-only.
- Bundled Linux releases must publish the exact source archives/git records,
checksums, licenses, flags, patches, build scripts, and the pinned hwdata
`pnp.ids` input. Each bundled package carries
`embedded-mpv-notices.json`, `THIRD_PARTY_NOTICES.txt`, and the exact
`licenses/**` files. CI may cache immutable source inputs, but regenerates
notices and a VCS-metadata-free
`linux-frame-copy-runtime-sources.tar.xz` for the current checkout on every
run while retaining the exact pinned six recursive libplacebo submodule
records. Each record is canonical `full-commit safe/path`; clone-depth
dependent `git describe` annotations are discarded and never form part of
the provenance identity. Its source index carries the globally sorted libplacebo
directory/file/symlink inventory; file hashes, sizes, executable bits, link
targets, aggregates, and canonical tree digest must match the trusted pinned
checkout. The archive has an exact member/type layout and its
`metadata/archive-sha256.txt` records must match the actual source archives.
Concatenated tar/xz streams are inspected past every end marker. The final
archive's SHA-256 and repository revision are copied into every bundled x64
package manifest; system and marker-only packages carry no source-archive
binding.
Automated Snap Store publication is allowed only after a public `v*` GitHub
release contains both the Snap assets and exactly one matching source
archive. Before any upload, the workflow hashes and inspects that archive,
verifies its exact member/type set and size bounds, clean tag revision,
pinned sources including the six recursive submodule records and exact
libplacebo tree digest, legal files, and exact released tooling, then
performs bounded extraction and static package validation for every Snap.
That public-release boundary independently revalidates the exact strict
`meta/snap.yaml` graphics/shared-memory contract and enumerates
`resources/app.asar`, rejecting any archived
`electron-backend/native/**` payload before publication. Its bounded ASAR
header reader uses only Node built-ins and released local tooling, so the
clean tag checkout does not require `node_modules`.
Exactly one x64 Snap must have matching
`sourceArchive` and `sourceRuntime`; any non-x64 Snap must remain
marker-only. Checkout and the artifact-transfer actions are pinned to full
commits; checkout does not persist credentials, and repository credentials
are limited to download steps. A secretless verification job copies assets
through no-follow descriptors, checks pre/post hashes, writes an exact
receipt, repeats the complete source/package verification on a root-owned
read-only snapshot, and transfers only that data through the pinned artifact
service while its receipt digest travels separately through a job output.
The dependent publish job runs on a bounded `ubuntu-latest` runner with no
checkout or release-tag code, verifies that digest plus the exact receipt,
asset hashes, and file-only layout, root-seals the data again, and installs
Snapcraft directly. Store credentials exist only in its final fixed shell
step, which resolves no PATH command, executes no released code, and exposes
the credential only to each exact
`/snap/bin/snapcraft upload --release=edge` process.
Candidate/stable promotion is manual after installed-Snap frame-copy and
missing-runtime fallback smoke; GitHub Actions never promotes automatically.
Canonical maintenance docs:
`docs/architecture/embedded-mpv-native.md` and
`tools/embedded-mpv/README.md`.
## Repo Skills
- `iptvnator-ui-design`
+128 -2
View File
@@ -127,7 +127,7 @@ Useful narrower flags:
- `IPTVNATOR_TRACE_DB=1` traces DB worker requests and DB progress events
- `IPTVNATOR_TRACE_SQL=1` traces SQLite statements in both main and worker connections
- `IPTVNATOR_TRACE_WINDOW=1` traces BrowserWindow navigation/load lifecycle
- `IPTVNATOR_TRACE_PLAYER=1` traces external-player launch/reuse/polling debug output
- `IPTVNATOR_TRACE_PLAYER=1` traces external-player activity and bounded Embedded MPV runtime-probe stderr
- `IPTVNATOR_TRACE_RENDERER_CONSOLE=1` mirrors renderer console logs into the Electron terminal
For GPU/compositor debugging:
@@ -617,7 +617,131 @@ This project uses modern Angular signal-based APIs and patterns. **ALWAYS** use
- Built-in web players: HTML5+hls.js, Video.js, and ArtPlayer
- External players: MPV, VLC (via IPC to Electron backend)
- Embedded MPV (experimental, macOS/Windows/Linux): renders mpv video inside the Electron window through a native addon. macOS uses the libmpv render API in an `NSOpenGLView`; Windows uses in-process libmpv with `--wid` against an app-owned child `HWND`; Linux spawns an out-of-process `mpv --wid=<x11-window>` controlled over a JSON IPC socket (X11/XWayland only, requires system `mpv` on PATH; subtitles/speed/aspect/recording are not exported there). mpv's own screensaver inhibition does not apply to any of these paths, so `EmbeddedMpvNativeService` holds an Electron `powerSaveBlocker` (`prevent-display-sleep`) whenever any session's status is `playing`, and releases it on pause, dispose, or shutdown. Service: `apps/electron-backend/src/app/services/embedded-mpv-native.service.ts`; full architecture: `docs/architecture/embedded-mpv-native.md`.
- Embedded MPV frame-copy engine (experimental, macOS Apple Silicon + Linux + Windows; enabled via `Settings > Playback > Embedded MPV: frame-copy engine` (restart required) or `IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY=1` on top of the embedded MPV experiment flag): a per-session helper renders mpv offscreen at viewport size (headless CGL on macOS, headless EGL on Linux, WGL against a hidden window on Windows) and publishes BGRA frames into a shm ring (POSIX shm; a `Local\` named file mapping on Windows); the preload frame pump uploads them onto a renderer `<canvas data-embedded-mpv-frame>`, so controls/dialogs are ordinary DOM above the video. Frame-copy is the first runtime consumer of shared `app-player-controls`: `PlayerControlsComponent` and its surface/shortcut/fullscreen collaborators own the DOM UI interactions, while the component-scoped `EmbeddedMpvControlsAdapter` maps session state and commands and coordinates correlated recording state; native-view retains the legacy fixed dock. Stored and explicit opt-ins relax the sandbox only while the base embedded-MPV feature is enabled and a platform-supported packaged runtime contains both the regular-file helper (`iptvnator_mpv_helper` / `.exe`) and readable regular frame-reader addon; packaged discovery is restricted to packaged resources. A disabled base experiment keeps embedded MPV unavailable with the sandbox intact, while a missing, mode-stripped, or incomplete frame-copy runtime falls back to the native engine without relaxing the sandbox. On Linux the engine is dev-build-only for now: the helper links system libmpv (build deps: `libmpv-dev`, `libegl-dev`, `libgl-dev`, `libopengl-dev`, `libgbm-dev`) and is stripped from packages until bundled-runtime staging lands. On Windows the helper links vendored libmpv and package validation requires the exact MPV DLL named in the helper's PE import table beside the executable. Backend process adapter: `apps/electron-backend/src/app/services/embedded-mpv-frame-copy.adapter.ts`; shared-controls adapter: `libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-controls.adapter.ts`; helper: `apps/electron-backend/native/helper/`; details in `docs/architecture/embedded-mpv-native.md` ("Frame-Copy Engine").
- Embedded MPV frame-copy engine (experimental, macOS Apple Silicon + Linux
x64 + Windows; enabled via `Settings > Playback > Embedded MPV: frame-copy
engine` (restart required) or
`IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY=1` on top of the embedded MPV
experiment flag): a per-session helper renders mpv offscreen (CGL on macOS,
EGL on Linux, WGL on Windows), publishes BGRA frames into a shm ring, and the
preload frame pump uploads them to
`<canvas data-embedded-mpv-frame>`. Shared `app-player-controls` owns the DOM
UI; native-view retains the legacy dock. On Linux, only
`iptvnator_mpv_helper` may link libmpv; Electron, its shipped libraries, the
addon, and frame reader must not. Pristine afterPack/unpacked layouts scan
Electron libraries recursively; extracted Snap payloads exclude only the
package-manager `lib/**` and `usr/lib/**` trees overlaid into the same root.
Every other directory remains recursive, and Electron-library symlinks still
fail closed. `electron-backend/native{,/**/*}` is excluded from `app.asar`;
`afterPack` alone owns the profile-normalized unpacked native tree, and
package checks reject every archived `/electron-backend/native/**` entry.
Packaged addon, frame-reader, and helper discovery uses only package-owned
`app.asar.unpacked` paths; cwd/dist candidates remain development-only.
Official x64 packages use three separate profiles:
DEB/RPM/Pacman depend on system libmpv plus the helper's direct
EGL/GL/GBM interfaces, AppImage/Snap bundle the pinned LGPL closure, and
Flatpak bundles the same closure. Exact system dependencies are
DEB=`libmpv2,libegl1,libgl1,libgbm1`,
RPM=`mpv-libs,libglvnd-egl,libglvnd-glx,mesa-libgbm`, and
Pacman=`mpv,libglvnd,mesa`. The DEB contract is verified on Ubuntu 24.04+;
Ubuntu 22.04 users need the x64 AppImage because Jammy provides `libmpv1`.
ARM packages are marker-only. Stored or explicit opt-ins cannot bypass the
fail-closed packaged manifest/file/hash gate and bounded `--runtime-probe`;
any failure keeps the sandbox enabled, records a stable reason, and falls
back to native-view without crashing. Snap is `core22`/strict and uses an
exact private `shared-memory` plug plus the `graphics-core22` content plug at
a real empty mode-0755 `$SNAP/graphics`, with external `mesa-core22` as the
default provider. Its only provider-data layouts bind `/usr/share/libdrm`
from `$SNAP/graphics/libdrm` and symlink `/usr/share/drirc.d` to
`$SNAP/graphics/drirc.d`. Installed-Snap CI requires controlled unavailable
status after disconnect, then reconnects and requires success. The helper
links `libGL.so.1`, and probe/playback share a sanitized loader environment
in which ambient audit, preload, library, graphics-driver, and shell-startup
overrides are removed; the validated private closure plus trusted host GL,
graphics-content, core22 base x64, and exact GNOME-platform roots have
explicit precedence. The core22 base stays ahead of GNOME so the older
`libedit.so.2` requiring `libtinfo.so.5` cannot shadow the base ABI. The
extracted-artifact verifier removes the identical unsafe loader/graphics/
shell set before direct helper smoke while preserving selectors such as
`LIBGL_ALWAYS_SOFTWARE`. Snap fixes the wrapper `PATH`,
removes exported `BASH_FUNC_*` functions, and
launches probe/playback through the regular executable
`$SNAP/graphics/bin/graphics-core22-provider-wrapper`; a missing or
disconnected provider returns `snap-graphics-provider-unavailable` before
helper spawn. The packaging-only
`--embedded-mpv-runtime-probe` app switch runs the complete packaged gate
before BrowserWindow startup and emits one availability JSON line. A nonzero
helper exit keeps top-level reason `helper-probe-failed`; `helperReason` is
present only for an exact protocol-v1 line carrying a fixed allowlisted
reason, and its optional `helperDetail` must be 1–1024 printable ASCII
characters. Invalid detail suppresses both helper fields. Every probe uses
an explicit 16 MiB aggregate captured-output ceiling independent of tracing.
With `IPTVNATOR_TRACE_PLAYER=1`, non-empty helper stderr is emitted separately
as one JSON-escaped stderr line with a 16,384-character `stderr` limit and an
explicit `truncated` field; trace-write failure cannot change availability.
Installed-Snap CI enables Mesa EGL/GL diagnostics through this bounded
channel. The exact packaged Flatpak `/app` context reconstructs only
Freedesktop Platform 24.08's immutable
`__EGL_EXTERNAL_PLATFORM_CONFIG_DIRS`; its CI smoke invokes that
application-level probe instead of the helper directly. The packaged x64
Playwright smoke runs its fixture-contract target first and passes Chromium
`--ignore-gpu-blocklist` so CI llvmpipe exposes WebGL2; this does not bypass
the runtime gate, and `--no-sandbox` remains root-only. Bundled Linux
packages carry hash-validated
`embedded-mpv-notices.json`, `THIRD_PARTY_NOTICES.txt`, and `licenses/**`.
CI caches the staged runtime plus immutable source inputs, never finished
notices or the compliance tarball; it regenerates those notices and the
VCS-metadata-free `linux-frame-copy-runtime-sources.tar.xz` for the current
checkout while preserving the exact pinned six recursive libplacebo
submodule records. Each record is canonical `full-commit safe/path`;
clone-depth dependent `git describe` annotations are discarded and never
form part of the provenance identity. Its source index carries the globally sorted libplacebo
directory/file/symlink inventory; file hashes, sizes, executable bits, link
targets, aggregates, and canonical tree digest must match the trusted pinned
checkout. The archive has an exact member/type layout and its
`metadata/archive-sha256.txt` records must match the actual source archives.
Concatenated tar/xz streams are inspected past every end marker. Every
bundled x64 package manifest binds the final archive's SHA-256 and repository
revision; system and marker-only packages do not carry that binding. Snap
Store
publication runs only from a public `v*` GitHub release that already
contains the Snap assets and exactly one source archive. Before any upload,
the workflow hashes and checks the archive's exact member/type set and size
bounds, verifies its clean tag revision, pinned sources including the six
recursive submodule records and exact libplacebo tree digest, legal payload,
and exact released tooling, then performs bounded extraction and static
validation for every Snap. That public-release boundary independently
revalidates the exact strict `meta/snap.yaml` graphics/shared-memory
contract and enumerates `resources/app.asar`, rejecting any archived
`electron-backend/native/**` payload before publication. Its bounded ASAR
header reader uses only Node built-ins and released local tooling, so the
clean tag checkout does not require `node_modules`. Exactly one x64 Snap
must have matching
`sourceArchive` and `sourceRuntime`; any non-x64 Snap remains marker-only.
Checkout and artifact-transfer actions are pinned to full commits; checkout
does not persist credentials, and repository credentials are scoped to
download steps. A secretless verification job copies assets through
no-follow descriptors, checks them before and after inspection, writes an
exact receipt, fully reverifies a root-owned read-only snapshot, and
transfers only that data through the pinned artifact service while passing
the receipt digest separately through a job output. The dependent publish
job uses a bounded `ubuntu-latest` runner with no checkout or release-tag
code, verifies that digest plus the exact receipt, asset hashes, and
file-only layout, root-seals the data again, and installs Snapcraft directly.
Its final fixed shell step alone receives the Store credential, resolves no
PATH command, executes no released code, and exposes that credential only to
each exact
`/snap/bin/snapcraft upload --release=edge` process. Candidate/stable
promotion is manual after installed-Snap frame-copy and missing-runtime
fallback smoke; GitHub Actions never promotes automatically. On Windows,
package validation requires the exact MPV DLL named by the helper's PE import
table beside the executable.
Backend adapter:
`apps/electron-backend/src/app/services/embedded-mpv-frame-copy.adapter.ts`;
shared-controls adapter:
`libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-controls.adapter.ts`;
helper: `apps/electron-backend/native/helper/`; canonical packaging/runtime
contracts: `docs/architecture/embedded-mpv-native.md` and
`tools/embedded-mpv/README.md`.
- Shared player-controls layer: `libs/ui/playback/src/lib/player-controls/` exports the engine-neutral `PlayerController` contract, standalone `app-player-controls`, a generic web-video adapter/helper, and component-scoped `WEB_PLAYER_SHARED_CONTROLS` rollout token. Persisted `Settings.webPlayerSharedControls` is default-off, and its checkbox appears only when HTML5, Video.js, or ArtPlayer is selected. `WebPlayerViewComponent` snapshots the preference into the immutable token for each new player host. The parent `/workspace` route awaits the initial `SettingsStore` load, including cold-start direct links, before this snapshot can occur. Saving applies to the next host without an application restart; an existing session never changes controls mode in place. Embedded MPV ignores the web-player preference: frame-copy always uses shared DOM controls through `EmbeddedMpvControlsAdapter`, native-view retains its compositor-safe legacy dock, and external MPV/VLC retain their own UI. The Embedded MPV host selects exactly one controls UI for its reported engine. `showControls=false` detaches the shared surface, modal overlays gate frame-copy playback shortcuts, fullscreen remains DOM-based with Embedded MPV bounds sync, and a playback/session transition key prevents engine or session handoff from presenting stale recording feedback while timers and pending commands are cancelled. Same-session IPC replies yield to a broadcast snapshot received while the command was pending, so a successful recording acknowledgement cannot be rolled back by a stale reply. The built-in HTML5/hls.js player is the second guarded consumer: `HtmlVideoPlayerComponent` provides a component-scoped `WebVideoControlsAdapter`, while its neutral `web-video-support` bridge is shared with ArtPlayer and owns HLS/native tracks, MPEG-TS VOD duration correction, caption preference, and source cleanup. `HtmlVideoElementSession` owns native video-event lifecycle, persisted volume, start-time/time/ended propagation, and legacy post-play caption suppression. Video.js is the third guarded consumer: `VjsPlayerComponent` provides a component-scoped `WebVideoControlsAdapter`; its bridge rebinds the current Tech video after `playerreset`, exposes source-stable audio/subtitle IDs, preserves caption preference and explicit subtitle-off state, and reads Video.js duration. Reset-driven raw MPEG-TS changes pause first, coalesce to the latest desired source, preserve actual volume across Video.js's reset, and restart when authoritative live/VOD metadata changes. In shared-controls mode, Video.js native controls, click/double-click/hotkey actions, and spatial navigation are disabled. ArtPlayer is the fourth guarded consumer: `ArtPlayerComponent` provides a component-scoped `WebVideoControlsAdapter`; `ArtPlayerSourceSession` owns HLS/MPEG-TS/native sources, the neutral web-video bridge, exact cleanup, and a destroyed-session guard for delayed `customType` callbacks, while `ArtPlayerVideoSession` owns native media/ArtPlayer events. Shared ArtPlayer mode uses authoritative live/VOD metadata, HLS/native tracks and caption preference, MPEG-TS VOD duration correction, and reapplies app volume directly after ArtPlayer restores its own stored volume. Vendor chrome/hotkeys are disabled, and a transparent capture layer gives shared controls exclusive click and double-click ownership. `WebPlayerViewComponent.resolvedIsLive` supplies authoritative metadata; visible playback diagnostics disable shared pointer/keyboard ownership and exit only the active HTML5, Video.js, or ArtPlayer shell's own fullscreen so retry/fallback actions remain visible. On the preference-off path, all three web players retain their existing controls, source behavior, and legacy series navigation. Contract: `docs/architecture/player-controls-contract.md`.
- Shared web picture-in-picture stays inside that default-off rollout.
`PlayerController` exposes capability `pictureInPicture`, state
@@ -646,8 +770,10 @@ This project uses modern Angular signal-based APIs and patterns. **ALWAYS** use
- Xtream and Stalker detail pages use the shared `PortalDetailShellComponent` (`libs/ui/components/src/lib/portal-detail-shell/`) with two states: **Browse** (hero with poster/metadata/actions, episodes below) and **Watch** (hero collapses with a ~300ms morph, the inline player takes the full content width, metadata moves to an About block below the episodes)
- Watch state derives from `inlinePlayback() !== null` only; external MPV/VLC playback keeps the browse layout. Esc and "Close player" exit to browse without navigation; the now-playing back arrow is route-level back (straight to the list via the host's `goBack()`)
- A successful external MPV/VLC episode launch immediately persists the selected episode as the latest playback-position entry and retargets the series CTA to `Play episode N`; real player telemetry overwrites that marker when available, so episode identity is reliable while exact external timestamps remain best-effort.
- Hosts pass hero chips/meta/actions as `*appDetailTags`/`*appDetailMeta`/`*appDetailActions` templates; the shell stamps them into both the hero and the About block
- Seasons are tabs (`SeasonTabsComponent`, dropdown beyond 6 seasons) with auto-selection (playing episode's season → resume season → first) that fires the same `seasonSelected` lazy-load/enrichment hooks as manual clicks; grid/list episode view toggle persists to localStorage; season descriptions come from `get_series_info` (Xtream) or TMDB (Stalker)
- Dashboard hero/Continue Watching clicks for an Xtream series carry a one-shot resume target through the global-recent inline-detail handoff; after series metadata and playback positions load, the exact saved episode starts at its stored position. A failed positions load leaves the target unconsumed and the handoff detail-only, so a transient storage error never starts the episode from the beginning. Ordinary global-recent grid clicks remain detail-only.
- See `docs/architecture/embedded-inline-playback.md` ("Two-State Detail Layout")
**Radio Player**:
@@ -0,0 +1,27 @@
import { defineConfig } from '@playwright/test';
import baseConfig from './playwright.config';
export default defineConfig({
...baseConfig,
outputDir:
'../../dist/test-results/electron-backend-e2e/packaged-frame-copy-smoke',
reporter: [
['list'],
[
'html',
{
outputFolder:
'../../dist/playwright-report/electron-backend-e2e/packaged-frame-copy-smoke',
},
],
[
'json',
{
outputFile:
'../../dist/test-results/electron-backend-e2e/packaged-frame-copy-smoke/results.json',
},
],
],
timeout: 120000,
webServer: [],
});
+20
View File
@@ -12,6 +12,26 @@
"e2e": {
"dependsOn": ["electron-backend:build-e2e"]
},
"packaged-frame-copy-smoke": {
"dependsOn": ["test-packaged-frame-copy-fixtures"],
"executor": "nx:run-commands",
"cache": false,
"outputs": [
"{workspaceRoot}/dist/playwright-report/electron-backend-e2e/packaged-frame-copy-smoke",
"{workspaceRoot}/dist/test-results/electron-backend-e2e/packaged-frame-copy-smoke"
],
"options": {
"cwd": "apps/electron-backend-e2e",
"command": "pnpm exec playwright test --config=playwright.packaged.config.ts src/embedded-mpv-frame-copy-packaged.e2e.ts"
}
},
"test-packaged-frame-copy-fixtures": {
"executor": "nx:run-commands",
"options": {
"cwd": "apps/electron-backend-e2e",
"command": "pnpm exec tsx --test src/embedded-mpv-frame-copy-packaged-fixtures.spec.ts"
}
},
"lint": {
"executor": "@nx/eslint:lint"
}
@@ -9,14 +9,18 @@ import {
} from '@playwright/test';
import { createServer, Server } from 'http';
import {
accessSync,
constants as fsConstants,
existsSync,
mkdtempSync,
readdirSync,
readFileSync,
rmSync,
statSync,
writeFileSync,
} from 'fs';
import { tmpdir } from 'os';
import { join, resolve } from 'path';
import { dirname, join, resolve } from 'path';
export const workspaceRoot = resolve(__dirname, '../../..');
export const electronMainPath = join(
@@ -70,7 +74,7 @@ type ElectronFixtures = {
dataDir: string;
};
type LaunchElectronAppOptions = {
export type LaunchElectronAppOptions = {
env?: Record<string, string | undefined>;
};
@@ -171,7 +175,142 @@ export async function launchElectronApp(
};
}
function attachElectronProcessDiagnostics(electronApp: ElectronApplication): void {
/**
* Resolve the x64 unpacked Linux executable produced by electron-builder.
* An explicit path wins so CI can point at an AppImage/Flatpak extraction
* without relying on electron-builder's local output directory names.
*/
export function resolvePackagedLinuxExecutable(
explicitPath = process.env['IPTVNATOR_E2E_PACKAGED_EXECUTABLE']
): string | undefined {
if (explicitPath?.trim()) {
return resolve(explicitPath.trim());
}
const executablesRoot = join(workspaceRoot, 'dist', 'executables');
if (!existsSync(executablesRoot)) {
return undefined;
}
const unpackedDirectories = readdirSync(executablesRoot, {
withFileTypes: true,
})
.filter(
(entry) =>
entry.isDirectory() &&
entry.name.startsWith('linux') &&
entry.name.endsWith('-unpacked') &&
!entry.name.includes('arm')
)
.sort((left, right) => {
const leftPriority = left.name === 'linux-unpacked' ? 0 : 1;
const rightPriority = right.name === 'linux-unpacked' ? 0 : 1;
return (
leftPriority - rightPriority ||
left.name.localeCompare(right.name)
);
});
for (const directory of unpackedDirectories) {
for (const executableName of ['IPTVnator', 'iptvnator']) {
const candidate = join(
executablesRoot,
directory.name,
executableName
);
try {
accessSync(candidate, fsConstants.X_OK);
if (statSync(candidate).isFile()) {
return candidate;
}
} catch {
// Keep looking for the next unpacked x64 layout.
}
}
}
return undefined;
}
export function getPackagedLinuxNativeDir(executablePath: string): string {
return join(
dirname(resolve(executablePath)),
'resources',
'app.asar.unpacked',
'electron-backend',
'native'
);
}
export function resolvePackagedElectronLaunchArgs(
getuid: (() => number) | undefined
): string[] {
const args = ['--ignore-gpu-blocklist'];
if (typeof getuid === 'function' && getuid() === 0) {
args.push('--no-sandbox');
}
return args;
}
/**
* Launch a real packaged Linux executable. Unlike the regular source E2E
* launcher, this deliberately keeps Chromium's GPU path enabled and ignores
* its GPU blocklist: the frame-copy smoke sets LIBGL_ALWAYS_SOFTWARE=1, and
* CI's llvmpipe WebGL2 context must prove that the shared-memory frame reaches
* the renderer canvas.
*/
export async function launchPackagedElectronApp(
executablePath: string,
dataDir: string,
options: LaunchElectronAppOptions = {}
): Promise<LaunchedElectronApp> {
if (process.platform !== 'linux') {
throw new Error(
'The packaged embedded-MPV launcher is available on Linux only.'
);
}
const resolvedExecutablePath = resolve(executablePath);
try {
accessSync(resolvedExecutablePath, fsConstants.X_OK);
if (!statSync(resolvedExecutablePath).isFile()) {
throw new Error('not a regular file');
}
} catch (error) {
throw new Error(
`Packaged Linux executable is not a regular executable file at ${resolvedExecutablePath}: ${
error instanceof Error ? error.message : String(error)
}`
);
}
const electronApp = await electron.launch({
executablePath: resolvedExecutablePath,
args: resolvePackagedElectronLaunchArgs(process.getuid),
env: {
...process.env,
IPTVNATOR_ALLOW_PRIVATE_NETWORK_URLS:
process.env['IPTVNATOR_ALLOW_PRIVATE_NETWORK_URLS'] ?? '1',
...options.env,
ELECTRON_IS_DEV: '0',
IPTVNATOR_E2E_DATA_DIR: dataDir,
NODE_ENV: 'test',
},
});
attachElectronProcessDiagnostics(electronApp);
const mainWindow = await findMainWindow(electronApp);
await waitForAppReady(mainWindow);
return {
electronApp,
mainWindow,
};
}
function attachElectronProcessDiagnostics(
electronApp: ElectronApplication
): void {
if (!process.env['CI']) {
return;
}
@@ -235,10 +374,7 @@ async function waitForPromiseWithTimeout(
return await Promise.race([
promise.then(() => true),
new Promise<boolean>((resolvePromise) => {
timeoutId = setTimeout(
() => resolvePromise(false),
timeoutMs
);
timeoutId = setTimeout(() => resolvePromise(false), timeoutMs);
}),
]);
} finally {
@@ -297,11 +433,11 @@ async function waitForAppReady(page: Page): Promise<void> {
} catch (error) {
const diagnostics = await page.evaluate(() => ({
appRootLength:
document.querySelector('app-root')?.innerHTML.trim().length ?? 0,
document.querySelector('app-root')?.innerHTML.trim().length ??
0,
baseHref:
document
.querySelector('base')
?.getAttribute('href') ?? '<missing>',
document.querySelector('base')?.getAttribute('href') ??
'<missing>',
readyState: document.readyState,
title: document.title,
url: location.href,
@@ -357,7 +493,7 @@ export async function importM3uPlaylistFromNativeDialog(
const fileInput = dialog.locator('input[type="file"][name="playlist"]');
await fileInput.evaluate((element, selectedFilePath) => {
(element as HTMLInputElement).dataset.filePathOverride =
(element as HTMLInputElement).dataset['filePathOverride'] =
selectedFilePath;
}, filePath);
await fileInput.setInputFiles(filePath);
@@ -501,15 +637,17 @@ async function clickDialogMethodOption(
label: RegExp,
legacySelector?: string
): Promise<void> {
const optionByRadio = dialog
.getByRole('radio', { name: label })
.first();
const optionByRadio = dialog.getByRole('radio', { name: label }).first();
if ((await optionByRadio.count()) > 0) {
await optionByRadio.click();
return;
}
for (const tablistLabel of ['Source method', 'Playlist category', 'M3U source']) {
for (const tablistLabel of [
'Source method',
'Playlist category',
'M3U source',
]) {
const tablist = dialog
.locator(`[role="tablist"][aria-label="${tablistLabel}"]`)
.first();
@@ -541,9 +679,7 @@ async function clickDialogMethodOption(
}
if (!legacySelector) {
throw new Error(
`Could not find dialog option matching ${label}.`
);
throw new Error(`Could not find dialog option matching ${label}.`);
}
await dialog.locator(legacySelector).click();
@@ -704,9 +840,7 @@ export function buildM3uContent(channels: M3uTestChannel[]): string {
const attributes = [
channel.tvgId ? `tvg-id="${channel.tvgId}"` : '',
channel.tvgCountry ? `tvg-country="${channel.tvgCountry}"` : '',
channel.tvgLanguage
? `tvg-language="${channel.tvgLanguage}"`
: '',
channel.tvgLanguage ? `tvg-language="${channel.tvgLanguage}"` : '',
channel.tvgName ? `tvg-name="${channel.tvgName}"` : '',
channel.logo ? `tvg-logo="${channel.logo}"` : '',
channel.groupTitle ? `group-title="${channel.groupTitle}"` : '',
@@ -889,9 +1023,7 @@ export async function switchUnifiedCollectionContent(
await clickButtonToggleOption(toggleGroup, contentLabel);
}
export async function clearCurrentUnifiedCollection(
page: Page
): Promise<void> {
export async function clearCurrentUnifiedCollection(page: Page): Promise<void> {
await page
.getByRole('button', {
name: /Clear .* (favorites|recently viewed)/i,
@@ -0,0 +1,288 @@
import assert = require('node:assert/strict');
import {
chmodSync,
existsSync,
lstatSync,
mkdtempSync,
mkdirSync,
readFileSync,
readlinkSync,
rmSync,
statSync,
symlinkSync,
writeFileSync,
} from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, describe, it } from 'node:test';
import {
createDisposablePackagedLinuxApp,
createPackagedEntryGuard,
readPackagedRuntimeIdentity,
} from './embedded-mpv-frame-copy-packaged-filesystem';
const temporaryDirectories = new Set<string>();
type PackageFixture = {
executablePath: string;
libmpvAliasPath: string;
libmpvSonamePath: string;
nativeDir: string;
packageRoot: string;
payloadPath: string;
runtimeManifestPath: string;
};
function createPackageFixture(): PackageFixture {
const packageRoot = mkdtempSync(
join(tmpdir(), 'iptvnator-packaged-fixture-source-')
);
temporaryDirectories.add(packageRoot);
const executablePath = join(packageRoot, 'IPTVnator');
const nativeDir = join(
packageRoot,
'resources',
'app.asar.unpacked',
'electron-backend',
'native'
);
const payloadPath = join(packageRoot, 'resources', 'payload.bin');
const runtimeManifestPath = join(nativeDir, 'embedded-mpv-runtime.json');
const runtimeLibraryDir = join(nativeDir, 'lib');
const libmpvSonamePath = join(runtimeLibraryDir, 'libmpv.so.2');
const libmpvAliasPath = join(runtimeLibraryDir, 'libmpv.so');
mkdirSync(runtimeLibraryDir, { recursive: true });
writeFileSync(executablePath, '#!/bin/sh\nexit 0\n');
chmodSync(executablePath, 0o755);
writeFileSync(payloadPath, 'packaged payload');
chmodSync(payloadPath, 0o640);
writeFileSync(libmpvSonamePath, 'packaged libmpv');
symlinkSync('libmpv.so.2', libmpvAliasPath);
writeFileSync(
runtimeManifestPath,
JSON.stringify({
arch: 'x64',
libmpvSoname: 'libmpv.so.2',
platform: 'linux',
profile: 'portable',
runtimeMode: 'bundled',
})
);
if (process.platform !== 'win32') {
symlinkSync(
'payload.bin',
join(packageRoot, 'resources', 'payload-link')
);
}
return {
executablePath,
libmpvAliasPath,
libmpvSonamePath,
nativeDir,
packageRoot,
payloadPath,
runtimeManifestPath,
};
}
function entryExists(entryPath: string): boolean {
try {
lstatSync(entryPath);
return true;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return false;
}
throw error;
}
}
afterEach(() => {
for (const directory of temporaryDirectories) {
rmSync(directory, { force: true, recursive: true });
}
temporaryDirectories.clear();
});
describe('disposable unpacked package clone', () => {
it('requires a safe versioned libmpv target in the packaged manifest', () => {
const source = createPackageFixture();
assert.equal(
readPackagedRuntimeIdentity(source.nativeDir).libmpvSoname,
'libmpv.so.2'
);
writeFileSync(
source.runtimeManifestPath,
JSON.stringify({
arch: 'x64',
libmpvSoname: '../libmpv.so.2',
platform: 'linux',
profile: 'portable',
runtimeMode: 'bundled',
})
);
assert.throws(
() => readPackagedRuntimeIdentity(source.nativeDir),
/libmpvSoname/
);
});
it('hides and restores a cloned regular dependency without changing the source package', () => {
const source = createPackageFixture();
const clone = createDisposablePackagedLinuxApp(source.executablePath);
temporaryDirectories.add(clone.temporaryRoot);
const clonedLibmpvPath = join(clone.nativeDir, 'lib', 'libmpv.so.2');
const guard = createPackagedEntryGuard(clonedLibmpvPath, {
expectedKind: 'regular-file',
hiddenDirectory: clone.temporaryRoot,
});
try {
assert.equal(lstatSync(clonedLibmpvPath).isFile(), true);
assert.equal(
statSync(clonedLibmpvPath).ino,
statSync(source.libmpvSonamePath).ino
);
guard.hide();
assert.equal(entryExists(clonedLibmpvPath), false);
assert.equal(lstatSync(source.libmpvSonamePath).isFile(), true);
assert.equal(
readFileSync(source.libmpvSonamePath, 'utf8'),
'packaged libmpv'
);
guard.restore();
assert.equal(lstatSync(clonedLibmpvPath).isFile(), true);
assert.equal(
statSync(clonedLibmpvPath).ino,
statSync(source.libmpvSonamePath).ino
);
} finally {
guard.restore();
clone.cleanup();
temporaryDirectories.delete(clone.temporaryRoot);
}
assert.equal(entryExists(source.libmpvSonamePath), true);
});
it('hides and restores a cloned symbolic link without dereferencing it', () => {
const source = createPackageFixture();
const clone = createDisposablePackagedLinuxApp(source.executablePath);
temporaryDirectories.add(clone.temporaryRoot);
const clonedAliasPath = join(clone.nativeDir, 'lib', 'libmpv.so');
const guard = createPackagedEntryGuard(clonedAliasPath, {
expectedKind: 'symbolic-link',
hiddenDirectory: clone.temporaryRoot,
});
try {
guard.hide();
assert.equal(entryExists(clonedAliasPath), false);
assert.equal(
lstatSync(source.libmpvAliasPath).isSymbolicLink(),
true
);
assert.equal(readlinkSync(source.libmpvAliasPath), 'libmpv.so.2');
guard.restore();
assert.equal(lstatSync(clonedAliasPath).isSymbolicLink(), true);
assert.equal(readlinkSync(clonedAliasPath), 'libmpv.so.2');
} finally {
guard.restore();
clone.cleanup();
temporaryDirectories.delete(clone.temporaryRoot);
}
});
it('hardlinks regular files and preserves modes, symlinks, and the source manifest', () => {
const source = createPackageFixture();
const clone = createDisposablePackagedLinuxApp(source.executablePath);
temporaryDirectories.add(clone.temporaryRoot);
try {
assert.notEqual(clone.packageRoot, source.packageRoot);
assert.equal(
statSync(clone.executablePath).mode & 0o777,
statSync(source.executablePath).mode & 0o777
);
const clonedPayloadPath = join(
clone.packageRoot,
'resources',
'payload.bin'
);
assert.equal(
statSync(clonedPayloadPath).ino,
statSync(source.payloadPath).ino
);
assert.equal(statSync(clonedPayloadPath).mode & 0o777, 0o640);
if (process.platform !== 'win32') {
const clonedLinkPath = join(
clone.packageRoot,
'resources',
'payload-link'
);
assert.equal(lstatSync(clonedLinkPath).isSymbolicLink(), true);
assert.equal(readlinkSync(clonedLinkPath), 'payload.bin');
}
const clonedRuntimeManifestPath = join(
clone.nativeDir,
'embedded-mpv-runtime.json'
);
assert.equal(existsSync(clonedRuntimeManifestPath), true);
assert.equal(existsSync(source.runtimeManifestPath), true);
} finally {
clone.cleanup();
temporaryDirectories.delete(clone.temporaryRoot);
}
assert.equal(existsSync(clone.temporaryRoot), false);
assert.equal(existsSync(source.runtimeManifestPath), true);
});
it('copies a regular file when hardlinking is unavailable', () => {
const source = createPackageFixture();
const clone = createDisposablePackagedLinuxApp(source.executablePath, {
linkFile() {
throw Object.assign(new Error('cross-device hardlink'), {
code: 'EXDEV',
});
},
});
temporaryDirectories.add(clone.temporaryRoot);
try {
assert.equal(statSync(clone.executablePath).mode & 0o777, 0o755);
const clonedPayloadPath = join(
clone.packageRoot,
'resources',
'payload.bin'
);
assert.equal(
readFileSync(clonedPayloadPath, 'utf8'),
readFileSync(source.payloadPath, 'utf8')
);
assert.notEqual(
statSync(clonedPayloadPath).ino,
statSync(source.payloadPath).ino
);
assert.equal(statSync(clonedPayloadPath).mode & 0o777, 0o640);
} finally {
clone.cleanup();
temporaryDirectories.delete(clone.temporaryRoot);
}
});
});
@@ -0,0 +1,255 @@
import {
chmodSync,
copyFileSync,
linkSync,
lstatSync,
mkdirSync,
mkdtempSync,
readFileSync,
readdirSync,
readlinkSync,
renameSync,
rmSync,
symlinkSync,
type Stats,
} from 'fs';
import { tmpdir } from 'os';
import { basename, dirname, join, resolve } from 'path';
import { getPackagedLinuxNativeDir } from './electron-test-fixtures';
export type DisposablePackagedLinuxApp = {
cleanup: () => void;
executablePath: string;
nativeDir: string;
packageRoot: string;
temporaryRoot: string;
};
export type DisposablePackagedLinuxAppOptions = {
linkFile?: (existingPath: string, newPath: string) => void;
};
export type PackagedRuntimeIdentity = {
arch: string;
libmpvSoname: string;
platform: string;
profile: string;
runtimeMode: string;
};
export type PackagedEntryKind = 'regular-file' | 'symbolic-link';
export type PackagedEntryGuard = {
hide: () => void;
restore: () => void;
};
export type PackagedEntryGuardOptions = {
expectedKind: PackagedEntryKind;
hiddenDirectory: string;
};
const HARDLINK_COPY_FALLBACK_CODES = new Set([
'EACCES',
'EMLINK',
'ENOSYS',
'ENOTSUP',
'EPERM',
'EXDEV',
]);
const VERSIONED_LIBMPV_PATTERN = /^libmpv\.so\.\d+(?:\.\d+)*$/;
function entryKindMatches(
stats: Stats,
expectedKind: PackagedEntryKind
): boolean {
return expectedKind === 'regular-file'
? stats.isFile() && !stats.isSymbolicLink()
: stats.isSymbolicLink();
}
function entryExistsByLstat(entryPath: string): boolean {
try {
lstatSync(entryPath);
return true;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return false;
}
throw error;
}
}
function clonePackagedEntry(
sourcePath: string,
destinationPath: string,
linkFile: (existingPath: string, newPath: string) => void
): void {
const sourceStats = lstatSync(sourcePath);
if (sourceStats.isDirectory()) {
mkdirSync(destinationPath);
for (const entry of readdirSync(sourcePath)) {
clonePackagedEntry(
join(sourcePath, entry),
join(destinationPath, entry),
linkFile
);
}
chmodSync(destinationPath, sourceStats.mode & 0o7777);
return;
}
if (sourceStats.isSymbolicLink()) {
symlinkSync(readlinkSync(sourcePath), destinationPath);
return;
}
if (!sourceStats.isFile()) {
throw new Error(
`Unsupported packaged runtime entry type at ${sourcePath}.`
);
}
try {
linkFile(sourcePath, destinationPath);
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (!code || !HARDLINK_COPY_FALLBACK_CODES.has(code)) {
throw error;
}
copyFileSync(sourcePath, destinationPath);
chmodSync(destinationPath, sourceStats.mode & 0o7777);
}
}
export function createDisposablePackagedLinuxApp(
executablePath: string,
options: DisposablePackagedLinuxAppOptions = {}
): DisposablePackagedLinuxApp {
const sourceExecutablePath = resolve(executablePath);
const sourcePackageRoot = dirname(sourceExecutablePath);
const temporaryRoot = mkdtempSync(
join(tmpdir(), 'iptvnator-packaged-frame-copy-')
);
const packageRoot = join(temporaryRoot, basename(sourcePackageRoot));
let cleaned = false;
try {
clonePackagedEntry(
sourcePackageRoot,
packageRoot,
options.linkFile ?? linkSync
);
} catch (error) {
rmSync(temporaryRoot, { force: true, recursive: true });
throw error;
}
const clonedExecutablePath = join(
packageRoot,
basename(sourceExecutablePath)
);
return {
cleanup() {
if (cleaned) {
return;
}
rmSync(temporaryRoot, { force: true, recursive: true });
cleaned = true;
},
executablePath: clonedExecutablePath,
nativeDir: getPackagedLinuxNativeDir(clonedExecutablePath),
packageRoot,
temporaryRoot,
};
}
export function createPackagedEntryGuard(
entryPath: string,
options: PackagedEntryGuardOptions
): PackagedEntryGuard {
const guardedEntryPath = resolve(entryPath);
const hiddenDirectory = resolve(options.hiddenDirectory);
const hiddenEntryPath = join(
hiddenDirectory,
`.${basename(guardedEntryPath)}.e2e-hidden-${process.pid}`
);
let hidden = false;
return {
hide() {
const entryStats = lstatSync(guardedEntryPath);
if (!entryKindMatches(entryStats, options.expectedKind)) {
throw new Error(
`Packaged entry is not a ${options.expectedKind}: ${guardedEntryPath}`
);
}
const hiddenDirectoryStats = lstatSync(hiddenDirectory);
if (
hiddenDirectoryStats.isSymbolicLink() ||
!hiddenDirectoryStats.isDirectory()
) {
throw new Error(
`Packaged entry stash is not a regular directory: ${hiddenDirectory}`
);
}
if (entryExistsByLstat(hiddenEntryPath)) {
throw new Error(
`Stale hidden packaged entry exists: ${hiddenEntryPath}`
);
}
renameSync(guardedEntryPath, hiddenEntryPath);
hidden = true;
},
restore() {
if (!hidden) {
return;
}
if (!entryExistsByLstat(hiddenEntryPath)) {
throw new Error(
`Hidden packaged entry disappeared before restore: ${hiddenEntryPath}`
);
}
if (entryExistsByLstat(guardedEntryPath)) {
throw new Error(
`Refusing to overwrite packaged entry during restore: ${guardedEntryPath}`
);
}
renameSync(hiddenEntryPath, guardedEntryPath);
hidden = false;
},
};
}
export function readPackagedRuntimeIdentity(
nativeDir: string
): PackagedRuntimeIdentity {
const runtimeManifestPath = join(nativeDir, 'embedded-mpv-runtime.json');
const parsed = JSON.parse(
readFileSync(runtimeManifestPath, 'utf8')
) as Partial<PackagedRuntimeIdentity>;
for (const field of [
'arch',
'platform',
'profile',
'runtimeMode',
] as const) {
if (typeof parsed[field] !== 'string' || !parsed[field]) {
throw new Error(
`Packaged runtime manifest ${field} is invalid at ${runtimeManifestPath}.`
);
}
}
if (
typeof parsed.libmpvSoname !== 'string' ||
!VERSIONED_LIBMPV_PATTERN.test(parsed.libmpvSoname)
) {
throw new Error(
`Packaged runtime manifest libmpvSoname is invalid at ${runtimeManifestPath}.`
);
}
return parsed as PackagedRuntimeIdentity;
}
@@ -0,0 +1,191 @@
import assert = require('node:assert/strict');
import { readFileSync } from 'node:fs';
import { join, resolve } from 'node:path';
import { describe, it } from 'node:test';
import { isMeaningfulNativePlaybackSnapshot } from './embedded-mpv-frame-copy-packaged-fixtures';
import './embedded-mpv-frame-copy-packaged-filesystem.tests';
import { resolvePackagedElectronLaunchArgs } from './electron-test-fixtures';
import packagedPlaywrightConfig from '../playwright.packaged.config';
const projectRoot = resolve(__dirname, '..');
describe('packaged Electron launch arguments', () => {
it('keeps WebGL enabled for software rendering while disabling the sandbox only as root', () => {
assert.deepEqual(
resolvePackagedElectronLaunchArgs(() => 1000),
['--ignore-gpu-blocklist']
);
assert.deepEqual(resolvePackagedElectronLaunchArgs(undefined), [
'--ignore-gpu-blocklist',
]);
assert.deepEqual(
resolvePackagedElectronLaunchArgs(() => 0),
['--ignore-gpu-blocklist', '--no-sandbox']
);
});
});
describe('native-view playback proof', () => {
it('requires the loaded URL, a playing or paused state, and positive duration', () => {
const expectedUrl = 'http://127.0.0.1:3210/fixture.y4m';
const baseSnapshot = {
durationSeconds: 2,
status: 'playing',
streamUrl: expectedUrl,
};
assert.equal(
isMeaningfulNativePlaybackSnapshot(baseSnapshot, expectedUrl),
true
);
assert.equal(
isMeaningfulNativePlaybackSnapshot(
{ ...baseSnapshot, status: 'paused' },
`${expectedUrl}#ignored`
),
true
);
assert.equal(
isMeaningfulNativePlaybackSnapshot(
{ ...baseSnapshot, durationSeconds: null },
expectedUrl
),
false
);
assert.equal(
isMeaningfulNativePlaybackSnapshot(
{ ...baseSnapshot, status: 'idle' },
expectedUrl
),
false
);
assert.equal(
isMeaningfulNativePlaybackSnapshot(
{ ...baseSnapshot, streamUrl: `${expectedUrl}?other=1` },
expectedUrl
),
false
);
});
it('loads the fixture and observes playback before disposing the native session', () => {
const source = readFileSync(
join(projectRoot, 'src', 'embedded-mpv-frame-copy-packaged.e2e.ts'),
'utf8'
);
const fallbackStart = source.indexOf('const launchedFallbackApp');
const captureIndex = source.indexOf(
'installEmbeddedMpvSessionCapture',
fallbackStart
);
const loadIndex = source.indexOf(
'loadEmbeddedMpvPlayback',
fallbackStart
);
const proofIndex = source.indexOf(
'isMeaningfulNativePlaybackSnapshot',
fallbackStart
);
const exitCodeIndex = source.indexOf(
'electronApp.process().exitCode',
fallbackStart
);
const disposeIndex = source.indexOf(
'disposeEmbeddedMpvSession',
fallbackStart
);
assert.ok(fallbackStart >= 0);
assert.ok(captureIndex > fallbackStart);
assert.ok(loadIndex > captureIndex);
assert.ok(proofIndex > loadIndex);
assert.ok(exitCodeIndex > proofIndex);
assert.ok(disposeIndex > exitCodeIndex);
});
it('removes the manifest-declared libmpv target and expects the stable missing-library reason', () => {
const source = readFileSync(
join(projectRoot, 'src', 'embedded-mpv-frame-copy-packaged.e2e.ts'),
'utf8'
);
const manifestIdentityIndex = source.indexOf(
'const runtimeIdentity = readPackagedRuntimeIdentity'
);
const guardIndex = source.indexOf(
'createPackagedEntryGuard(',
manifestIdentityIndex
);
const sonameIndex = source.indexOf(
'runtimeIdentity.libmpvSoname',
guardIndex
);
const hideIndex = source.indexOf('.hide()', sonameIndex);
const fallbackStart = source.indexOf(
'const launchedFallbackApp',
hideIndex
);
const missingReasonIndex = source.indexOf(
"frameCopyUnavailableReason: 'runtime-library-missing'",
fallbackStart
);
assert.ok(manifestIdentityIndex >= 0);
assert.ok(guardIndex > manifestIdentityIndex);
assert.ok(sonameIndex > guardIndex);
assert.ok(hideIndex > sonameIndex);
assert.ok(fallbackStart > hideIndex);
assert.ok(missingReasonIndex > fallbackStart);
assert.doesNotMatch(source, /createRuntimeManifestGuard/);
assert.doesNotMatch(source, /runtimeManifest\.hide/);
});
});
describe('dedicated packaged smoke target', () => {
it('inherits the GL mode from the workflow environment', () => {
const source = readFileSync(
join(projectRoot, 'src', 'embedded-mpv-frame-copy-packaged.e2e.ts'),
'utf8'
);
assert.doesNotMatch(source, /LIBGL_ALWAYS_SOFTWARE\s*:/);
});
it('does not build the backend or start portal mock servers', () => {
const project = JSON.parse(
readFileSync(join(projectRoot, 'project.json'), 'utf8')
) as {
targets?: Record<
string,
{
cache?: boolean;
dependsOn?: unknown;
options?: { command?: string };
}
>;
};
const target = project.targets?.['packaged-frame-copy-smoke'];
const packagedConfig = readFileSync(
join(projectRoot, 'playwright.packaged.config.ts'),
'utf8'
);
if (!target) {
throw new Error('The packaged frame-copy smoke target is missing.');
}
assert.equal(target.cache, false);
assert.deepEqual(target.dependsOn, [
'test-packaged-frame-copy-fixtures',
]);
assert.match(
target.options?.command ?? '',
/playwright\.packaged\.config\.ts/
);
assert.match(
target.options?.command ?? '',
/embedded-mpv-frame-copy-packaged\.e2e\.ts/
);
assert.match(packagedConfig, /timeout:\s*120000/);
assert.match(packagedConfig, /webServer:\s*\[\]/);
assert.deepEqual(packagedPlaywrightConfig.webServer, []);
});
});
@@ -0,0 +1,344 @@
import type {
EmbeddedMpvSession,
EmbeddedMpvSupport,
} from '@iptvnator/shared/interfaces';
import { spawnSync } from 'child_process';
import { createServer, type Server } from 'http';
import sharp = require('sharp');
import {
closeElectronApp,
expect,
type LaunchedElectronApp,
} from './electron-test-fixtures';
import type {
DisposablePackagedLinuxApp,
PackagedEntryGuard,
} from './embedded-mpv-frame-copy-packaged-filesystem';
declare global {
interface Window {
__packagedEmbeddedMpvSessions?: EmbeddedMpvSession[];
__packagedEmbeddedMpvUnsubscribe?: () => void;
}
}
export type LocalMediaServer = {
close: () => Promise<void>;
url: string;
};
function createTwoSecondY4mFixture(): Buffer {
const width = 64;
const height = 36;
const framesPerSecond = 10;
const frameCount = framesPerSecond * 2;
const yPlaneBytes = width * height;
const chromaPlaneBytes = (width / 2) * (height / 2);
const chunks: Buffer[] = [
Buffer.from(
`YUV4MPEG2 W${width} H${height} F${framesPerSecond}:1 Ip A1:1 C420jpeg\n`,
'ascii'
),
];
for (let index = 0; index < frameCount; index += 1) {
const evenFrame = index % 2 === 0;
chunks.push(
Buffer.from('FRAME\n', 'ascii'),
Buffer.alloc(yPlaneBytes, evenFrame ? 76 : 150),
Buffer.alloc(chromaPlaneBytes, evenFrame ? 84 : 44),
Buffer.alloc(chromaPlaneBytes, evenFrame ? 255 : 21)
);
}
return Buffer.concat(chunks);
}
async function listen(server: Server): Promise<void> {
await new Promise<void>((resolvePromise, rejectPromise) => {
const onError = (error: Error) => {
server.off('listening', onListening);
rejectPromise(error);
};
const onListening = () => {
server.off('error', onError);
resolvePromise();
};
server.once('error', onError);
server.once('listening', onListening);
server.listen(0, '127.0.0.1');
});
}
async function closeServer(server: Server): Promise<void> {
await new Promise<void>((resolvePromise, rejectPromise) => {
server.close((error) => {
if (error) {
rejectPromise(error);
return;
}
resolvePromise();
});
});
}
export async function createLocalMediaServer(): Promise<LocalMediaServer> {
const body = createTwoSecondY4mFixture();
const resourcePath = '/embedded-mpv-frame-copy-smoke.y4m';
const server = createServer((request, response) => {
const pathname = (request.url ?? '').split('?')[0];
if (pathname !== resourcePath) {
response.writeHead(404).end();
return;
}
const range = request.headers.range?.match(/^bytes=(\d+)-(\d*)$/);
if (!range) {
response.writeHead(200, {
'Accept-Ranges': 'bytes',
'Content-Length': body.length,
'Content-Type': 'video/x-yuv4mpeg',
});
response.end(request.method === 'HEAD' ? undefined : body);
return;
}
const start = Number(range[1]);
const requestedEnd = range[2] ? Number(range[2]) : body.length - 1;
const end = Math.min(requestedEnd, body.length - 1);
if (
!Number.isSafeInteger(start) ||
!Number.isSafeInteger(end) ||
start < 0 ||
start > end ||
start >= body.length
) {
response.writeHead(416, {
'Content-Range': `bytes */${body.length}`,
});
response.end();
return;
}
response.writeHead(206, {
'Accept-Ranges': 'bytes',
'Content-Length': end - start + 1,
'Content-Range': `bytes ${start}-${end}/${body.length}`,
'Content-Type': 'video/x-yuv4mpeg',
});
response.end(
request.method === 'HEAD'
? undefined
: body.subarray(start, end + 1)
);
});
await listen(server);
const address = server.address();
if (!address || typeof address === 'string') {
await closeServer(server);
throw new Error('Unable to resolve the local media server address.');
}
return {
close: () => closeServer(server),
url: `http://127.0.0.1:${address.port}${resourcePath}`,
};
}
export function assertNativeFallbackPrerequisites(): void {
if (!process.env['DISPLAY']) {
throw new Error(
'The packaged native-view fallback smoke requires DISPLAY (run it under Xvfb/X11).'
);
}
const mpv = spawnSync('mpv', ['--version'], {
stdio: 'ignore',
timeout: 3000,
});
if (mpv.status !== 0) {
throw new Error(
'The packaged native-view fallback smoke requires a working system mpv CLI on PATH.'
);
}
}
export async function installEmbeddedMpvSessionCapture(
app: LaunchedElectronApp
): Promise<void> {
await app.mainWindow.evaluate(() => {
window.__packagedEmbeddedMpvUnsubscribe?.();
window.__packagedEmbeddedMpvSessions = [];
window.__packagedEmbeddedMpvUnsubscribe =
window.electron.onEmbeddedMpvSessionUpdate?.((session) => {
window.__packagedEmbeddedMpvSessions?.push(session);
});
});
}
export async function installFrameCanvasAndSessionCapture(
app: LaunchedElectronApp
): Promise<void> {
await installEmbeddedMpvSessionCapture(app);
await app.mainWindow.evaluate(() => {
document.querySelector('canvas[data-embedded-mpv-frame]')?.remove();
const canvas = document.createElement('canvas');
canvas.dataset['embeddedMpvFrame'] = '';
canvas.dataset['testId'] = 'packaged-embedded-mpv-frame';
Object.assign(canvas.style, {
background: '#000',
height: '180px',
left: '0',
position: 'fixed',
top: '0',
width: '320px',
zIndex: '2147483647',
});
document.body.append(canvas);
});
}
export async function getEmbeddedMpvSupport(
app: LaunchedElectronApp
): Promise<EmbeddedMpvSupport> {
return app.mainWindow.evaluate(async () => {
return window.electron.getEmbeddedMpvSupport();
});
}
export async function getLatestSession(
app: LaunchedElectronApp,
sessionId: string
): Promise<EmbeddedMpvSession | null> {
return app.mainWindow.evaluate((id) => {
const sessions =
window.__packagedEmbeddedMpvSessions?.filter(
(session) => session.id === id
) ?? [];
return sessions.at(-1) ?? null;
}, sessionId);
}
export function isMeaningfulNativePlaybackSnapshot(
snapshot: {
durationSeconds: number | null;
error?: string;
status: string;
streamUrl: string;
} | null,
expectedUrl: string
): boolean {
if (
!snapshot ||
snapshot.error ||
!['paused', 'playing'].includes(snapshot.status) ||
typeof snapshot.durationSeconds !== 'number' ||
!Number.isFinite(snapshot.durationSeconds) ||
snapshot.durationSeconds <= 0
) {
return false;
}
const normalizeUrl = (value: string): string => {
try {
const url = new URL(value);
url.hash = '';
return url.href;
} catch {
return value.trim();
}
};
return normalizeUrl(snapshot.streamUrl) === normalizeUrl(expectedUrl);
}
export async function renderedFrameSignal(
app: LaunchedElectronApp
): Promise<number> {
const canvas = app.mainWindow.getByTestId('packaged-embedded-mpv-frame');
const png = await canvas.screenshot();
const { data, info } = await sharp(png)
.removeAlpha()
.raw()
.toBuffer({ resolveWithObject: true });
let signal = 0;
for (let offset = 0; offset < data.length; offset += info.channels) {
if (data[offset] + data[offset + 1] + data[offset + 2] > 30) {
signal += 1;
}
}
return signal;
}
export async function closeAndWaitForExit(
app: LaunchedElectronApp
): Promise<void> {
const processHandle = app.electronApp.process();
await closeElectronApp(app);
await expect
.poll(
() =>
processHandle.exitCode !== null ||
processHandle.signalCode !== null,
{ timeout: 10000 }
)
.toBe(true);
}
export async function cleanupPackagedFrameCopySmoke(options: {
apps: Array<LaunchedElectronApp | undefined>;
hiddenRuntimeEntry?: PackagedEntryGuard;
media?: LocalMediaServer;
packageClone?: DisposablePackagedLinuxApp;
}): Promise<void> {
const errors: unknown[] = [];
for (const app of options.apps) {
if (!app) {
continue;
}
try {
await closeAndWaitForExit(app);
} catch (error) {
errors.push(error);
}
}
if (options.hiddenRuntimeEntry) {
try {
options.hiddenRuntimeEntry.restore();
} catch (error) {
errors.push(error);
}
}
if (options.media) {
try {
await options.media.close();
} catch (error) {
errors.push(error);
}
}
if (options.packageClone) {
try {
options.packageClone.cleanup();
} catch (error) {
errors.push(error);
}
}
if (errors.length > 0) {
throw new Error(
`Packaged frame-copy smoke cleanup failed: ${errors
.map((error) =>
error instanceof Error ? error.message : String(error)
)
.join('; ')}`
);
}
}
@@ -0,0 +1,311 @@
import {
expect,
launchPackagedElectronApp,
resolvePackagedLinuxExecutable,
test,
type LaunchedElectronApp,
} from './electron-test-fixtures';
import { join } from 'path';
import {
assertNativeFallbackPrerequisites,
cleanupPackagedFrameCopySmoke,
closeAndWaitForExit,
createLocalMediaServer,
getEmbeddedMpvSupport,
getLatestSession,
installEmbeddedMpvSessionCapture,
installFrameCanvasAndSessionCapture,
isMeaningfulNativePlaybackSnapshot,
renderedFrameSignal,
type LocalMediaServer,
} from './embedded-mpv-frame-copy-packaged-fixtures';
import {
createDisposablePackagedLinuxApp,
createPackagedEntryGuard,
readPackagedRuntimeIdentity,
type DisposablePackagedLinuxApp,
type PackagedEntryGuard,
} from './embedded-mpv-frame-copy-packaged-filesystem';
const PACKAGED_FRAME_COPY_REQUIRED_ENV =
'IPTVNATOR_E2E_REQUIRE_PACKAGED_FRAME_COPY';
const FRAME_COPY_OPT_IN_ENV = 'IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY';
const packagedExecutable = resolvePackagedLinuxExecutable();
const packagedFrameCopyRequired = isTruthy(
process.env[PACKAGED_FRAME_COPY_REQUIRED_ENV]
);
function isTruthy(value: string | undefined): boolean {
return ['1', 'true', 'yes', 'on'].includes(
(value ?? '').trim().toLowerCase()
);
}
// This smoke drives the public preload API directly so it exercises the real
// packaged main process, helper, frame reader, WebGL pump, and pause controls
// without coupling runtime validation to playlist import/settings UI state.
test.describe('Packaged Linux embedded MPV frame-copy runtime', () => {
test.skip(
process.platform !== 'linux',
'The packaged frame-copy runtime is Linux-only.'
);
test.skip(
!packagedExecutable && !packagedFrameCopyRequired,
`Set IPTVNATOR_E2E_PACKAGED_EXECUTABLE or ${PACKAGED_FRAME_COPY_REQUIRED_ENV}=1 in the dedicated packaged-runtime job.`
);
test('@critical @electron @embedded-mpv uses packaged frame-copy and fails closed to native-view', async ({
dataDir,
}) => {
expect(
process.arch,
'The official Linux frame-copy runtime is x64-only.'
).toBe('x64');
expect(
packagedExecutable,
`The dedicated packaged-runtime job must provide a real unpacked x64 executable with IPTVNATOR_E2E_PACKAGED_EXECUTABLE when ${PACKAGED_FRAME_COPY_REQUIRED_ENV}=1.`
).toBeTruthy();
const sourceExecutablePath = packagedExecutable as string;
assertNativeFallbackPrerequisites();
let packageClone: DisposablePackagedLinuxApp | undefined;
let hiddenRuntimeEntry: PackagedEntryGuard | undefined;
let media: LocalMediaServer | undefined;
let frameCopyApp: LaunchedElectronApp | undefined;
let fallbackApp: LaunchedElectronApp | undefined;
try {
packageClone =
createDisposablePackagedLinuxApp(sourceExecutablePath);
const executablePath = packageClone.executablePath;
const nativeDir = packageClone.nativeDir;
const runtimeIdentity = readPackagedRuntimeIdentity(nativeDir);
hiddenRuntimeEntry = createPackagedEntryGuard(
join(nativeDir, 'lib', runtimeIdentity.libmpvSoname),
{
expectedKind: 'regular-file',
hiddenDirectory: packageClone.temporaryRoot,
}
);
const mediaServer = await createLocalMediaServer();
media = mediaServer;
expect(runtimeIdentity).toMatchObject({
arch: 'x64',
platform: 'linux',
runtimeMode: 'bundled',
});
expect(['portable', 'flatpak']).toContain(runtimeIdentity.profile);
const launchedFrameCopyApp = await launchPackagedElectronApp(
executablePath,
dataDir,
{
env: {
[FRAME_COPY_OPT_IN_ENV]: '1',
},
}
);
frameCopyApp = launchedFrameCopyApp;
await expect
.poll(() => getEmbeddedMpvSupport(launchedFrameCopyApp))
.toMatchObject({
engine: 'frame-copy',
frameCopyAvailable: true,
platform: 'linux',
supported: true,
});
await installFrameCanvasAndSessionCapture(launchedFrameCopyApp);
const created = await launchedFrameCopyApp.mainWindow.evaluate(
async () => {
return window.electron.createEmbeddedMpvSession(
{ x: 0, y: 0, width: 320, height: 180 },
'Packaged frame-copy smoke',
0
);
}
);
await launchedFrameCopyApp.mainWindow.evaluate(
async ({ sessionId, streamUrl }) => {
await window.electron.setEmbeddedMpvPaused(sessionId, true);
await window.electron.loadEmbeddedMpvPlayback(sessionId, {
streamUrl,
title: 'Two-second generated Y4M fixture',
isLive: false,
});
},
{ sessionId: created.id, streamUrl: mediaServer.url }
);
await expect
.poll(
() => getLatestSession(launchedFrameCopyApp, created.id),
{
timeout: 15000,
}
)
.toMatchObject({
status: 'paused',
streamUrl: mediaServer.url,
videoHeight: 36,
videoWidth: 64,
});
const attached = await launchedFrameCopyApp.mainWindow.evaluate(
async (sessionId) => {
return window.electron.attachEmbeddedMpvFrameView?.(
sessionId
);
},
created.id
);
expect(attached).toBe(true);
await expect(
launchedFrameCopyApp.mainWindow.getByTestId(
'packaged-embedded-mpv-frame'
)
).toHaveAttribute('width', '320');
await expect(
launchedFrameCopyApp.mainWindow.getByTestId(
'packaged-embedded-mpv-frame'
)
).toHaveAttribute('height', '180');
await expect
.poll(() => renderedFrameSignal(launchedFrameCopyApp), {
timeout: 15000,
})
.toBeGreaterThan(0);
await launchedFrameCopyApp.mainWindow.evaluate(
(sessionId) =>
window.electron.setEmbeddedMpvPaused(sessionId, false),
created.id
);
await expect
.poll(
async () =>
(
await getLatestSession(
launchedFrameCopyApp,
created.id
)
)?.status,
{ timeout: 10000 }
)
.toBe('playing');
await launchedFrameCopyApp.mainWindow.evaluate(
(sessionId) =>
window.electron.setEmbeddedMpvPaused(sessionId, true),
created.id
);
await expect
.poll(
async () =>
(
await getLatestSession(
launchedFrameCopyApp,
created.id
)
)?.status,
{ timeout: 10000 }
)
.toBe('paused');
await launchedFrameCopyApp.mainWindow.evaluate(
async (sessionId) => {
window.electron.detachEmbeddedMpvFrameView?.();
await window.electron.disposeEmbeddedMpvSession(sessionId);
window.__packagedEmbeddedMpvUnsubscribe?.();
},
created.id
);
await closeAndWaitForExit(launchedFrameCopyApp);
frameCopyApp = undefined;
hiddenRuntimeEntry.hide();
expect(readPackagedRuntimeIdentity(nativeDir)).toEqual(
runtimeIdentity
);
const launchedFallbackApp = await launchPackagedElectronApp(
executablePath,
dataDir,
{
env: {
[FRAME_COPY_OPT_IN_ENV]: '1',
},
}
);
fallbackApp = launchedFallbackApp;
const fallbackSupport =
await getEmbeddedMpvSupport(launchedFallbackApp);
expect(fallbackSupport).toMatchObject({
engine: 'native',
frameCopyAvailable: false,
frameCopyUnavailableReason: 'runtime-library-missing',
platform: 'linux',
supported: true,
});
await installEmbeddedMpvSessionCapture(launchedFallbackApp);
const nativeSession = await launchedFallbackApp.mainWindow.evaluate(
async () => {
return window.electron.createEmbeddedMpvSession(
{ x: 0, y: 0, width: 320, height: 180 },
'Native-view fallback smoke',
0
);
}
);
expect(nativeSession.id).toMatch(/^embedded-mpv-/);
await launchedFallbackApp.mainWindow.evaluate(
async ({ sessionId, streamUrl }) => {
await window.electron.loadEmbeddedMpvPlayback(sessionId, {
streamUrl,
title: 'Native-view generated Y4M fixture',
isLive: false,
});
},
{ sessionId: nativeSession.id, streamUrl: mediaServer.url }
);
await expect
.poll(
async () =>
isMeaningfulNativePlaybackSnapshot(
await getLatestSession(
launchedFallbackApp,
nativeSession.id
),
mediaServer.url
),
{ timeout: 15000 }
)
.toBe(true);
expect(
launchedFallbackApp.electronApp.process().exitCode
).toBeNull();
expect(
launchedFallbackApp.electronApp.process().signalCode
).toBeNull();
await launchedFallbackApp.mainWindow.evaluate(async (sessionId) => {
await window.electron.disposeEmbeddedMpvSession(sessionId);
window.__packagedEmbeddedMpvUnsubscribe?.();
}, nativeSession.id);
await expect
.poll(() => launchedFallbackApp.mainWindow.title())
.toContain('IPTVnator');
} finally {
await cleanupPackagedFrameCopySmoke({
apps: [frameCopyApp, fallbackApp],
hiddenRuntimeEntry,
media,
packageClone,
});
}
});
});
+448 -96
View File
@@ -1,3 +1,4 @@
const crypto = require('crypto');
const fs = require('fs');
const path = require('path');
const { spawnSync } = require('child_process');
@@ -10,6 +11,25 @@ const {
const {
removeStaleFrameCopyArtifacts,
} = require('../../tools/packaging/embedded-mpv-frame-copy-files.cjs');
const {
isLinuxSystemBuildInputManifest,
validateLinuxRuntimeManifest,
validateLinuxSystemBuildInputManifest,
} = require('../../tools/embedded-mpv/linux-runtime-manifest.cjs');
const {
SOURCE_ARCHIVE_BINDING_NAME,
validateLinuxSourceArchiveBinding,
} = require('../../tools/embedded-mpv/linux-source-archive-contract.cjs');
const {
resolveLinuxFrameCopyLinkageInputs,
resolveVerifiedLinuxLibMpvSoname,
runWithCleanup,
validateLinuxFrameCopyLinkage,
} = require('./embedded-mpv-linux-linkage.cjs');
const LINUX_PACKAGE_RUNTIME_MODES = Object.freeze(['system', 'bundled']);
const LINUX_STAGED_RUNTIME_ORIGIN = 'vendored-lgpl';
const LINUX_SOURCE_RUNTIME_ORIGIN = 'vendored-lgpl-source-build';
const workspaceRoot = process.cwd();
const addonRoot = path.join(
@@ -109,6 +129,158 @@ function readRuntimeManifest(runtimeRoot) {
return JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
}
function readLinuxSourceArchiveBinding(runtimeRoot, errors) {
const bindingPath = path.join(runtimeRoot, SOURCE_ARCHIVE_BINDING_NAME);
if (!fs.existsSync(bindingPath)) {
return null;
}
let binding;
try {
const stat = fs.lstatSync(bindingPath);
if (!stat.isFile() || stat.isSymbolicLink()) {
throw new Error('binding must be a regular file');
}
binding = JSON.parse(fs.readFileSync(bindingPath, 'utf8'));
} catch (error) {
errors.push(
`source archive binding is invalid: ${
error instanceof Error ? error.message : String(error)
}`
);
return null;
}
errors.push(
...validateLinuxSourceArchiveBinding(binding).map(
(error) => `source archive binding is invalid: ${error}`
)
);
return binding;
}
function validatedLinuxSourceRuntime(runtimeRoot) {
const stagedManifest = readRuntimeManifest(runtimeRoot);
if (
stagedManifest === null ||
typeof stagedManifest !== 'object' ||
Array.isArray(stagedManifest)
) {
throw new Error(
`Staged Linux runtime manifest must be an object: ${path.join(
runtimeRoot,
'runtime-manifest.json'
)}`
);
}
const envelopeErrors = [];
if (stagedManifest.origin !== LINUX_STAGED_RUNTIME_ORIGIN) {
envelopeErrors.push(`origin must be "${LINUX_STAGED_RUNTIME_ORIGIN}"`);
}
if (stagedManifest.platform !== 'linux') {
envelopeErrors.push('platform must be "linux"');
}
if (stagedManifest.arch !== targetArch) {
envelopeErrors.push(`arch must be "${targetArch}"`);
}
if (
typeof stagedManifest.stagedAt !== 'string' ||
stagedManifest.stagedAt.trim().length === 0
) {
envelopeErrors.push('stagedAt must be a non-empty string');
}
if (!Array.isArray(stagedManifest.runtimeFiles)) {
envelopeErrors.push('runtimeFiles must be an array');
}
const systemBuildInputs = isLinuxSystemBuildInputManifest(stagedManifest);
let buildInputMode;
let sourceArchive = null;
let sourceRuntimeManifest;
if (systemBuildInputs) {
buildInputMode = 'system-build-inputs';
sourceRuntimeManifest = {
linuxBackend: stagedManifest.linuxBackend,
buildInputs: stagedManifest.buildInputs,
sourceDistribution: stagedManifest.sourceDistribution,
};
if (
Array.isArray(stagedManifest.runtimeFiles) &&
stagedManifest.runtimeFiles.length !== 0
) {
envelopeErrors.push(
'system build inputs must not declare staged runtime files'
);
}
if (stagedManifest.sourceBuildOrigin !== undefined) {
envelopeErrors.push(
'system build inputs must not declare sourceBuildOrigin'
);
}
} else {
buildInputMode = 'bundled-runtime';
sourceArchive = readLinuxSourceArchiveBinding(
runtimeRoot,
envelopeErrors
);
const sourceBuildOrigin = stagedManifest.sourceBuildOrigin;
const sourceMetadata = { ...stagedManifest };
delete sourceMetadata.sourceBuildOrigin;
delete sourceMetadata.stagedAt;
sourceRuntimeManifest = {
...sourceMetadata,
origin: sourceBuildOrigin,
};
if (sourceBuildOrigin !== LINUX_SOURCE_RUNTIME_ORIGIN) {
envelopeErrors.push(
`sourceBuildOrigin must be "${LINUX_SOURCE_RUNTIME_ORIGIN}"`
);
}
}
const sourceManifestErrors =
buildInputMode === 'system-build-inputs'
? validateLinuxSystemBuildInputManifest(sourceRuntimeManifest)
: validateLinuxRuntimeManifest(sourceRuntimeManifest);
const declaredRuntimeFileNames = Array.isArray(
sourceRuntimeManifest.runtimeFiles
)
? sourceRuntimeManifest.runtimeFiles
.map((runtimeFile) => runtimeFile.name)
.sort()
: [];
const stagedRuntimeFileNames = listRuntimeFiles(
path.join(runtimeRoot, 'lib'),
runtimeFilePredicate
)
.map((runtimeFile) => path.basename(runtimeFile))
.sort();
if (
JSON.stringify(stagedRuntimeFileNames) !==
JSON.stringify(declaredRuntimeFileNames)
) {
envelopeErrors.push(
'staged lib directory must exactly match manifest runtimeFiles'
);
}
const errors = [...envelopeErrors, ...sourceManifestErrors];
if (errors.length > 0) {
throw new Error(
[
`Invalid staged Linux runtime at ${runtimeRoot}:`,
...errors.map((error) => `- ${error}`),
].join('\n')
);
}
return {
buildInputMode,
sourceArchive,
sourceRuntimeManifest,
sourceRuntimeValidated: buildInputMode === 'bundled-runtime',
};
}
function fileExists(filePath) {
return fs.existsSync(filePath) && fs.statSync(filePath).isFile();
}
@@ -183,9 +355,9 @@ function findWindowsLibMpv(runtimeRoot) {
}
/* Debian/Ubuntu install linker targets under the multiarch triple dir. The
* compiler's built-in search paths cover it for -l resolution either way;
* this keeps the -L flag and the helper's baked rpath pointing somewhere
* real. */
* compiler's built-in search paths cover it for -l resolution either way.
* This directory is a link-time input only; the helper runtime intentionally
* stays on the sanitized system loader contract. */
function defaultLinuxSystemLibDir() {
const multiarchTriples = {
arm: 'arm-linux-gnueabihf',
@@ -211,15 +383,19 @@ function findLinuxLibMpv(libDir) {
}
function resolveRuntime() {
const vendoredHeader = path.join(vendoredIncludeDir, 'mpv', 'client.h');
const stagedLinuxRuntime =
targetPlatform === 'linux' && fs.existsSync(vendoredHeader)
? validatedLinuxSourceRuntime(vendoredRuntimeRoot)
: null;
const vendoredLibMpv =
targetPlatform === 'darwin'
? findLibMpv(vendoredLibDir)
: targetPlatform === 'win32'
? findWindowsLibMpv(vendoredRuntimeRoot)
: targetPlatform === 'linux'
? true
? stagedLinuxRuntime
: null;
const vendoredHeader = path.join(vendoredIncludeDir, 'mpv', 'client.h');
if (vendoredLibMpv && fs.existsSync(vendoredHeader)) {
const windowsImportLib =
@@ -237,17 +413,23 @@ function resolveRuntime() {
binDir: vendoredBinDir,
manifest: readRuntimeManifest(vendoredRuntimeRoot),
windowsImportLib,
...(stagedLinuxRuntime ?? {}),
};
}
if (targetPlatform === 'linux') {
// Dev-first Linux flow (frame-copy helper links system libmpv): a
// distro libmpv-dev install is a full runtime — no staging needed.
// LIBMPV_INCLUDE_DIR / LINUX_NATIVE_LIBRARY_DIR override the system
// paths for machines with a local (non-root) libmpv prefix.
// LIBMPV_INCLUDE_DIR overrides the header path.
// LINUX_NATIVE_LIBRARY_DIR overrides the link-time library directory,
// which must already be visible to the system dynamic loader.
const systemIncludeDir =
process.env.LIBMPV_INCLUDE_DIR || '/usr/include';
if (fs.existsSync(path.join(systemIncludeDir, 'mpv', 'client.h'))) {
const sourceRuntimeManifest = {
linuxBackend: 'process-isolated mpv --wid',
warning: 'Development-only unmanaged system libmpv toolchain.',
};
return {
origin: 'system-dev',
includeDir: systemIncludeDir,
@@ -255,10 +437,10 @@ function resolveRuntime() {
process.env.LINUX_NATIVE_LIBRARY_DIR ||
defaultLinuxSystemLibDir(),
binDir: undefined,
manifest: {
warning:
'Development-only system libmpv toolchain. Release packaging keeps the external-mpv-process contract.',
},
manifest: sourceRuntimeManifest,
buildInputMode: 'system-dev',
sourceRuntimeManifest,
sourceRuntimeValidated: false,
windowsImportLib: null,
};
}
@@ -288,19 +470,141 @@ function resolveRuntime() {
return null;
}
function writeLinuxProcessRuntimeManifest(runtime) {
function hasStagedLinuxLibMpvLinkerInput(runtime) {
return (
Array.isArray(runtime.sourceRuntimeManifest?.runtimeFiles) &&
runtime.sourceRuntimeManifest.runtimeFiles.some(
(runtimeFile) => runtimeFile.name === 'libmpv.so'
)
);
}
function assertRequiredLinuxFrameCopyRuntime(runtime) {
if (targetPlatform !== 'linux' || !embeddedMpvRequired) {
return;
}
if (
runtime.buildInputMode !== 'bundled-runtime' ||
runtime.sourceRuntimeValidated !== true ||
!runtime.sourceArchive ||
!hasStagedLinuxLibMpvLinkerInput(runtime)
) {
cleanOutput();
throw new Error(
'Required Linux builds must use the validated bundled source runtime containing staged libmpv.'
);
}
}
function copyLinuxRuntimeClosureToNativeBuild(runtime) {
fs.rmSync(outputLibDir, { recursive: true, force: true });
const declaredRuntimeFiles =
runtime.buildInputMode === 'bundled-runtime'
? runtime.sourceRuntimeManifest.runtimeFiles
: [];
if (declaredRuntimeFiles.length === 0) {
return [];
}
fs.mkdirSync(outputLibDir, { recursive: true });
const copiedRuntimeFiles = [];
for (const runtimeFile of declaredRuntimeFiles) {
const sourcePath = path.join(runtime.libDir, runtimeFile.name);
let descriptor;
try {
descriptor = fs.openSync(
sourcePath,
fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW
);
const stat = fs.fstatSync(descriptor);
if (!stat.isFile()) {
throw new Error(
`Staged Linux runtime path is not a regular file: ${sourcePath}`
);
}
const contents = fs.readFileSync(descriptor);
if (contents.byteLength !== runtimeFile.size) {
throw new Error(
`Size mismatch for staged Linux runtime file ${runtimeFile.name}: expected ${runtimeFile.size}, received ${contents.byteLength}.`
);
}
const actualSha256 = crypto
.createHash('sha256')
.update(contents)
.digest('hex');
if (actualSha256 !== runtimeFile.sha256) {
throw new Error(
`SHA-256 mismatch for staged Linux runtime file ${runtimeFile.name}: expected ${runtimeFile.sha256}, received ${actualSha256}.`
);
}
const destinationPath = path.join(outputLibDir, runtimeFile.name);
fs.writeFileSync(destinationPath, contents, { mode: 0o755 });
copiedRuntimeFiles.push({ ...runtimeFile });
} finally {
if (descriptor !== undefined) {
fs.closeSync(descriptor);
}
}
}
return copiedRuntimeFiles;
}
function writeLinuxFrameCopyBuildManifest(runtime) {
const copiedRuntimeFiles = copyLinuxRuntimeClosureToNativeBuild(runtime);
const libmpvSoname =
runtime.sourceRuntimeValidated === true &&
runtime.buildInputMode === 'bundled-runtime' &&
copiedRuntimeFiles.length > 0
? resolveVerifiedLinuxLibMpvSoname({
outputLibDir,
runtimeFiles: copiedRuntimeFiles,
runtimeDependencyClosure:
runtime.sourceRuntimeManifest.runtimeDependencyClosure,
readDynamicSection: readLinuxDynamicSection,
})
: null;
const packageRuntimeAvailable =
runtime.sourceRuntimeValidated === true &&
runtime.buildInputMode === 'bundled-runtime' &&
copiedRuntimeFiles.length > 0 &&
libmpvSoname !== null;
const manifest = {
...runtime.manifest,
origin: 'external-mpv-process',
schemaVersion: 1,
origin: 'linux-frame-copy-build',
generatedAt: new Date().toISOString(),
runtimeFiles: [],
linuxBackend:
runtime.manifest.linuxBackend ?? 'process-isolated mpv --wid',
mpvExecutable: 'mpv',
platform: targetPlatform,
targetArch,
arch: targetArch,
buildInputMode: runtime.buildInputMode,
sourceRuntimeValidated: runtime.sourceRuntimeValidated,
allowedPackageRuntimeModes: [...LINUX_PACKAGE_RUNTIME_MODES],
packageRuntimeAvailability: {
system: packageRuntimeAvailable,
bundled: packageRuntimeAvailable,
},
artifacts: {
addon: 'embedded_mpv.node',
frameReader: 'embedded_mpv_frame_reader.node',
helper: 'iptvnator_mpv_helper',
},
processIsolation: {
addonLoadsLibmpv: false,
helperLinksLibmpv: true,
helperRunpath: ['$ORIGIN/lib'],
},
nativeViewFallback: 'process-isolated mpv --wid',
libmpvSoname,
runtimeFiles: copiedRuntimeFiles,
runtimeTotalBytes: copiedRuntimeFiles.reduce(
(total, runtimeFile) => total + runtimeFile.size,
0
),
sourceArchive: runtime.sourceArchive ?? null,
sourceRuntime: runtime.sourceRuntimeManifest,
};
fs.writeFileSync(
@@ -428,12 +732,33 @@ function runNodeGyp(command, env) {
}
}
function readLinuxDynamicSection(filePath) {
const result = spawnSync('readelf', ['-d', filePath], {
cwd: workspaceRoot,
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
});
if (result.error) {
throw new Error(
`Unable to run readelf for ${filePath}: ${result.error.message}`
);
}
if (result.status !== 0) {
throw new Error(
`readelf -d failed for ${filePath} with status ${
result.status ?? 1
}: ${(result.stderr ?? '').trim()}`
);
}
return result.stdout;
}
function main() {
fs.mkdirSync(outputDir, { recursive: true });
cleanDistNativeOutput();
cleanOutput();
if (targetPlatform !== process.platform) {
cleanOutput();
if (embeddedMpvRequired) {
throw new Error(
`Embedded MPV is required for ${targetPlatform}-${targetArch}, but this host is ${process.platform}-${process.arch}.`
@@ -465,58 +790,114 @@ function main() {
return;
}
const runtimeManifest =
targetPlatform === 'darwin'
? copyRuntimeToNativeBuild({
runtimeLibDir: runtime.libDir,
outputLibDir,
runtimeOrigin: runtime.origin,
runtimeManifest: {
targetArch,
...runtime.manifest,
},
})
: targetPlatform === 'linux'
? writeLinuxProcessRuntimeManifest(runtime)
: copyGenericRuntimeToNativeBuild(runtime);
fs.rmSync(unavailableMarkerFile, { force: true });
assertRequiredLinuxFrameCopyRuntime(runtime);
const electronPackageJson = require(
path.join(workspaceRoot, 'node_modules', 'electron', 'package.json')
);
const electronVersion = electronPackageJson.version;
const env = {
...process.env,
npm_config_runtime: 'electron',
npm_config_target: electronVersion,
npm_config_arch: targetArch,
npm_config_disturl: 'https://electronjs.org/headers',
npm_config_build_from_source: 'true',
npm_config_update_binary: 'false',
LIBMPV_INCLUDE_DIR: runtime.includeDir,
...(targetPlatform === 'linux'
? {
LINUX_NATIVE_LIBRARY_DIR:
process.env.LINUX_NATIVE_LIBRARY_DIR || runtime.libDir,
}
: { LIBMPV_LIBRARY_DIR: outputLibDir }),
...(runtime.windowsImportLib
? {
LIBMPV_IMPORT_LIB: path.join(
const buildNativeArtifacts = () => {
const runtimeManifest =
targetPlatform === 'darwin'
? copyRuntimeToNativeBuild({
runtimeLibDir: runtime.libDir,
outputLibDir,
path.basename(runtime.windowsImportLib)
),
}
: {}),
};
runtimeOrigin: runtime.origin,
runtimeManifest: {
targetArch,
...runtime.manifest,
},
})
: targetPlatform === 'linux'
? writeLinuxFrameCopyBuildManifest(runtime)
: copyGenericRuntimeToNativeBuild(runtime);
const linuxLinkageInputs =
targetPlatform === 'linux'
? resolveLinuxFrameCopyLinkageInputs({
buildInputMode: runtime.buildInputMode,
outputLibDir,
packagedLibmpvSoname: runtimeManifest.libmpvSoname,
readDynamicSection: readLinuxDynamicSection,
runtimeLibDir: runtime.libDir,
})
: null;
log(
`Building native addon against Electron ${electronVersion} using ${runtime.origin} runtime for ${targetPlatform}-${targetArch}...`
);
cleanNativeBuildIntermediates();
try {
const electronPackageJson = require(
path.join(workspaceRoot, 'node_modules', 'electron', 'package.json')
);
const electronVersion = electronPackageJson.version;
const env = {
...process.env,
npm_config_runtime: 'electron',
npm_config_target: electronVersion,
npm_config_arch: targetArch,
npm_config_disturl: 'https://electronjs.org/headers',
npm_config_build_from_source: 'true',
npm_config_update_binary: 'false',
LIBMPV_INCLUDE_DIR: runtime.includeDir,
...(targetPlatform === 'linux'
? {
LINUX_VERIFIED_RUNTIME_LIBRARY_DIR:
linuxLinkageInputs.linkerLibraryDir,
}
: { LIBMPV_LIBRARY_DIR: outputLibDir }),
...(runtime.windowsImportLib
? {
LIBMPV_IMPORT_LIB: path.join(
outputLibDir,
path.basename(runtime.windowsImportLib)
),
}
: {}),
};
log(
`Building native addon against Electron ${electronVersion} using ${runtime.origin} runtime for ${targetPlatform}-${targetArch}...`
);
cleanNativeBuildIntermediates();
runNodeGyp('configure', env);
runNodeGyp('build', env);
if (!fs.existsSync(outputFile)) {
throw new Error(`Build finished without producing ${outputFile}.`);
}
if (targetPlatform === 'linux') {
validateLinuxFrameCopyLinkage({
expectedLibmpvSoname: linuxLinkageInputs.expectedLibmpvSoname,
outputDir,
readDynamicSection: readLinuxDynamicSection,
});
}
if (targetPlatform === 'darwin') {
patchAddonForBundledRuntime(outputFile, outputLibDir);
// The frame-copy helper executable links libmpv too and sits next
// to the same lib/ directory, so it gets the identical
// dependency-path rewrite + ad-hoc re-sign.
const frameHelperFile = path.join(
outputDir,
'iptvnator_mpv_helper'
);
if (fs.existsSync(frameHelperFile)) {
patchAddonForBundledRuntime(frameHelperFile, outputLibDir);
}
const forbiddenLinkErrors = validateNoForbiddenRuntimeLinks([
outputFile,
...(fs.existsSync(frameHelperFile) ? [frameHelperFile] : []),
...runtimeManifest.dylibs.map((dylib) =>
path.join(outputLibDir, dylib)
),
]);
if (
runtime.origin === 'vendored-lgpl' &&
forbiddenLinkErrors.length > 0
) {
throw new Error(forbiddenLinkErrors.join('\n'));
}
}
fs.rmSync(unavailableMarkerFile, { force: true });
};
try {
runWithCleanup(buildNativeArtifacts, cleanOutput);
} catch (error) {
if (!embeddedMpvRequired && runtime.origin === 'system-dev') {
// The system-dev fallback triggers on any machine with
@@ -528,40 +909,11 @@ function main() {
error instanceof Error ? error.message : String(error)
}`
);
cleanOutput();
return;
}
throw error;
}
if (!fs.existsSync(outputFile)) {
throw new Error(`Build finished without producing ${outputFile}.`);
}
if (targetPlatform === 'darwin') {
patchAddonForBundledRuntime(outputFile, outputLibDir);
// The frame-copy helper executable links libmpv too and sits next to
// the same lib/ directory, so it gets the identical dependency-path
// rewrite + ad-hoc re-sign.
const frameHelperFile = path.join(outputDir, 'iptvnator_mpv_helper');
if (fs.existsSync(frameHelperFile)) {
patchAddonForBundledRuntime(frameHelperFile, outputLibDir);
}
const forbiddenLinkErrors = validateNoForbiddenRuntimeLinks([
outputFile,
...(fs.existsSync(frameHelperFile) ? [frameHelperFile] : []),
...runtimeManifest.dylibs.map((dylib) =>
path.join(outputLibDir, dylib)
),
]);
if (
runtime.origin === 'vendored-lgpl' &&
forbiddenLinkErrors.length > 0
) {
throw new Error(forbiddenLinkErrors.join('\n'));
}
}
log(`Built ${path.relative(workspaceRoot, outputFile)}.`);
}
@@ -0,0 +1,340 @@
'use strict';
const crypto = require('node:crypto');
const fs = require('node:fs');
const path = require('node:path');
const VERSIONED_LIBMPV_PATTERN = /^libmpv\.so\.\d+(?:\.\d+)*$/;
const LIBMPV_NEEDED_PATTERN = /^libmpv\.so(?:\..*)?$/;
const SHA256_PATTERN = /^[a-f0-9]{64}$/;
const LINUX_FRAME_COPY_ARTIFACTS = Object.freeze([
Object.freeze({
fileName: 'embedded_mpv.node',
label: 'embedded MPV addon',
mayLinkLibmpv: false,
}),
Object.freeze({
fileName: 'embedded_mpv_frame_reader.node',
label: 'embedded MPV frame reader',
mayLinkLibmpv: false,
}),
Object.freeze({
fileName: 'iptvnator_mpv_helper',
label: 'embedded MPV frame-copy helper',
mayLinkLibmpv: true,
}),
]);
function parseReadelfDynamic(output) {
if (typeof output !== 'string') {
throw new TypeError('readelf dynamic output must be a string.');
}
const dynamic = {
needed: [],
rpath: [],
runpath: [],
soname: [],
};
const dynamicEntryPattern =
/\((NEEDED|RPATH|RUNPATH|SONAME)\)[^[]*\[([^\]]*)\]/g;
for (const [, tag, value] of output.matchAll(dynamicEntryPattern)) {
if (tag === 'NEEDED') {
dynamic.needed.push(value);
continue;
}
if (tag === 'SONAME') {
dynamic.soname.push(value);
continue;
}
dynamic[tag.toLowerCase()].push(
...value.split(':').filter((entry) => entry.length > 0)
);
}
return dynamic;
}
function exactlyOneRuntimeFile(runtimeFiles, name) {
if (!Array.isArray(runtimeFiles)) {
throw new Error('Linux runtimeFiles metadata must be an array.');
}
const matchingRecords = runtimeFiles.filter(
(runtimeFile) => runtimeFile?.name === name
);
if (matchingRecords.length !== 1) {
throw new Error(
`Linux runtime must contain exactly one exact runtimeFiles record for ${name}.`
);
}
const [runtimeFile] = matchingRecords;
if (
!Number.isInteger(runtimeFile.size) ||
runtimeFile.size <= 0 ||
typeof runtimeFile.sha256 !== 'string' ||
!SHA256_PATTERN.test(runtimeFile.sha256)
) {
throw new Error(
`Linux runtimeFiles record for ${name} has invalid size or SHA-256 metadata.`
);
}
return runtimeFile;
}
function readVerifiedRuntimeFile(filePath, runtimeFile) {
let stat;
try {
stat = fs.lstatSync(filePath);
} catch {
throw new Error(`Missing copied Linux runtime file: ${filePath}`);
}
if (!stat.isFile() || stat.isSymbolicLink()) {
throw new Error(
`Copied Linux runtime file must be a regular non-symbolic-link file: ${filePath}`
);
}
let descriptor;
try {
descriptor = fs.openSync(
filePath,
fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0)
);
const descriptorStat = fs.fstatSync(descriptor);
if (!descriptorStat.isFile()) {
throw new Error(
`Copied Linux runtime path is not a regular file: ${filePath}`
);
}
const contents = fs.readFileSync(descriptor);
if (contents.byteLength !== runtimeFile.size) {
throw new Error(
`Size mismatch for copied Linux runtime file ${runtimeFile.name}: expected ${runtimeFile.size}, received ${contents.byteLength}.`
);
}
const actualSha256 = crypto
.createHash('sha256')
.update(contents)
.digest('hex');
if (actualSha256 !== runtimeFile.sha256) {
throw new Error(
`SHA-256 mismatch for copied Linux runtime file ${runtimeFile.name}: expected ${runtimeFile.sha256}, received ${actualSha256}.`
);
}
} finally {
if (descriptor !== undefined) {
fs.closeSync(descriptor);
}
}
}
function closureLibMpvSoname(runtimeDependencyClosure) {
if (!Array.isArray(runtimeDependencyClosure?.entries)) {
throw new Error(
'Validated Linux runtime dependency closure entries are required.'
);
}
const libMpvEntries = runtimeDependencyClosure.entries.filter(
(entry) =>
entry?.name === 'libmpv.so' ||
VERSIONED_LIBMPV_PATTERN.test(entry?.name)
);
const declaredSonames = libMpvEntries.map((entry) => entry.soname);
const uniqueSonames = new Set(declaredSonames);
if (
declaredSonames.length === 0 ||
declaredSonames.some(
(soname) =>
typeof soname !== 'string' ||
!VERSIONED_LIBMPV_PATTERN.test(soname)
) ||
uniqueSonames.size !== 1
) {
throw new Error(
'Validated Linux runtime closure must declare exactly one versioned libmpv SONAME.'
);
}
return declaredSonames[0];
}
function resolveVerifiedLinuxLibMpvSoname({
outputLibDir,
runtimeFiles,
runtimeDependencyClosure,
readDynamicSection,
}) {
if (typeof readDynamicSection !== 'function') {
throw new TypeError('Linux readelf dynamic reader is required.');
}
const expectedSoname = closureLibMpvSoname(runtimeDependencyClosure);
const linkerInputRecord = exactlyOneRuntimeFile(runtimeFiles, 'libmpv.so');
const exactSonameRecord = exactlyOneRuntimeFile(
runtimeFiles,
expectedSoname
);
const linkerInputPath = path.join(outputLibDir, 'libmpv.so');
const exactSonamePath = path.join(outputLibDir, expectedSoname);
readVerifiedRuntimeFile(linkerInputPath, linkerInputRecord);
readVerifiedRuntimeFile(exactSonamePath, exactSonameRecord);
const dynamic = parseReadelfDynamic(readDynamicSection(linkerInputPath));
if (
dynamic.soname.length !== 1 ||
!VERSIONED_LIBMPV_PATTERN.test(dynamic.soname[0])
) {
throw new Error(
'Copied Linux libmpv.so must contain exactly one DT_SONAME with a versioned libmpv basename.'
);
}
if (dynamic.soname[0] !== expectedSoname) {
throw new Error(
`Copied Linux libmpv.so DT_SONAME ${dynamic.soname[0]} does not match validated closure SONAME ${expectedSoname}.`
);
}
return expectedSoname;
}
function resolveLinuxFrameCopyLinkageInputs({
buildInputMode,
outputLibDir,
packagedLibmpvSoname,
readDynamicSection,
runtimeLibDir,
}) {
const systemDevelopment = buildInputMode === 'system-dev';
const linkerLibraryDir = systemDevelopment ? runtimeLibDir : outputLibDir;
if (
typeof linkerLibraryDir !== 'string' ||
linkerLibraryDir.trim().length === 0
) {
throw new Error(
'Linux frame-copy linkage requires a non-empty linker library directory.'
);
}
if (!systemDevelopment) {
return {
expectedLibmpvSoname: packagedLibmpvSoname,
linkerLibraryDir,
};
}
if (typeof readDynamicSection !== 'function') {
throw new TypeError('Linux readelf dynamic reader is required.');
}
const linkerInputPath = path.join(linkerLibraryDir, 'libmpv.so');
const dynamic = parseReadelfDynamic(readDynamicSection(linkerInputPath));
if (
dynamic.soname.length !== 1 ||
!VERSIONED_LIBMPV_PATTERN.test(dynamic.soname[0])
) {
throw new Error(
'The system-development libmpv linker input must contain exactly one versioned libmpv SONAME.'
);
}
return {
expectedLibmpvSoname: dynamic.soname[0],
linkerLibraryDir,
};
}
function assertRegularArtifact(filePath, label) {
let stat;
try {
stat = fs.lstatSync(filePath);
} catch {
throw new Error(`Missing ${label}: ${filePath}`);
}
if (!stat.isFile() || stat.isSymbolicLink()) {
throw new Error(
`${label} must be a regular non-symbolic-link file: ${filePath}`
);
}
}
function validateLinuxFrameCopyLinkage({
expectedLibmpvSoname,
outputDir,
readDynamicSection,
}) {
if (
typeof expectedLibmpvSoname !== 'string' ||
!VERSIONED_LIBMPV_PATTERN.test(expectedLibmpvSoname)
) {
throw new Error(
'Linux frame-copy linkage validation requires an exact versioned libmpv SONAME.'
);
}
if (typeof readDynamicSection !== 'function') {
throw new TypeError('Linux readelf dynamic reader is required.');
}
for (const artifact of LINUX_FRAME_COPY_ARTIFACTS) {
const artifactPath = path.join(outputDir, artifact.fileName);
assertRegularArtifact(artifactPath, artifact.label);
const dynamic = parseReadelfDynamic(readDynamicSection(artifactPath));
const libMpvDependencies = dynamic.needed.filter((dependency) =>
LIBMPV_NEEDED_PATTERN.test(dependency)
);
if (!artifact.mayLinkLibmpv) {
if (libMpvDependencies.length > 0) {
throw new Error(
`${artifact.label} must not have a direct libmpv DT_NEEDED entry; found ${libMpvDependencies.join(', ')}.`
);
}
continue;
}
if (
libMpvDependencies.length !== 1 ||
libMpvDependencies[0] !== expectedLibmpvSoname
) {
throw new Error(
`${artifact.label} DT_NEEDED must contain exactly ${expectedLibmpvSoname}; found ${
libMpvDependencies.join(', ') || '<none>'
}.`
);
}
if (dynamic.rpath.length !== 0) {
throw new Error(
`${artifact.label} must not contain RPATH; found ${dynamic.rpath.join(':')}.`
);
}
if (
dynamic.runpath.length !== 1 ||
dynamic.runpath[0] !== '$ORIGIN/lib'
) {
throw new Error(
`${artifact.label} RUNPATH must be exactly $ORIGIN/lib; found ${
dynamic.runpath.join(':') || '<none>'
}.`
);
}
}
}
function runWithCleanup(operation, cleanup) {
try {
return operation();
} catch (error) {
cleanup();
throw error;
}
}
module.exports = {
parseReadelfDynamic,
resolveLinuxFrameCopyLinkageInputs,
resolveVerifiedLinuxLibMpvSoname,
runWithCleanup,
validateLinuxFrameCopyLinkage,
};
+5 -5
View File
@@ -158,14 +158,14 @@
],
"ldflags": [
"-pthread",
"-Wl,-rpath,'$$ORIGIN/lib'",
"-Wl,-rpath,<!(node -p \"process.env.LINUX_NATIVE_LIBRARY_DIR || '/usr/lib'\")"
"-Wl,--enable-new-dtags",
"-Wl,-rpath,'$$ORIGIN/lib'"
],
"libraries": [
"-L<!(node -p \"process.env.LINUX_NATIVE_LIBRARY_DIR || '/usr/lib'\")",
"-lmpv",
"-L<!(node -e \"const dir = process.env.LINUX_VERIFIED_RUNTIME_LIBRARY_DIR; if (!dir) { throw new Error('Missing LINUX_VERIFIED_RUNTIME_LIBRARY_DIR'); } process.stdout.write(dir)\")",
"<!(node -e \"const path = require('path'); const dir = process.env.LINUX_VERIFIED_RUNTIME_LIBRARY_DIR; if (!dir) { throw new Error('Missing LINUX_VERIFIED_RUNTIME_LIBRARY_DIR'); } process.stdout.write(path.join(dir, 'libmpv.so'))\")",
"-lEGL",
"-lOpenGL",
"-lGL",
"-lgbm",
"-ldl"
]
@@ -11,7 +11,7 @@
* earlier software renderer is recreated only if no hardware tier works.
* Each tier uses a desktop-GL 3.2 core context bound surfaceless (1x1 pbuffer
* fallback for drivers without EGL_KHR_surfaceless_context). The helper's
* own GL calls resolve at link time through libOpenGL (glvnd); mpv resolves
* own GL calls resolve at link time through libGL (glvnd); mpv resolves
* core symbols from that linked library and falls back to eglGetProcAddress
* for extensions.
*
@@ -188,6 +188,7 @@ public:
GlGetProcAddressFn procLoader() const { return glDlsymGetProcAddress; }
void* procLoaderCtx() const { return glDylib_; }
const char* renderApiName() const { return "cgl"; }
private:
CGLContextObj cgl_ = nullptr;
@@ -337,6 +338,7 @@ public:
GlGetProcAddressFn procLoader() const { return eglWrapGetProcAddress; }
void* procLoaderCtx() const { return nullptr; }
const char* renderApiName() const { return "egl"; }
private:
enum class DisplayTier { SurfacelessMesa, Default, Gbm };
@@ -780,6 +782,7 @@ public:
GlGetProcAddressFn procLoader() const { return wglLoaderGetProcAddress; }
void* procLoaderCtx() const { return opengl32_; }
const char* renderApiName() const { return "wgl"; }
private:
HWND window_ = nullptr;
@@ -41,6 +41,7 @@ namespace {
using frame_helper::Command;
using frame_helper::emitLine;
using frame_helper::GlContext;
using frame_helper::JsonWriter;
using frame_helper::RenderPipeline;
@@ -612,6 +613,7 @@ struct HelperArgs {
int width = 1280;
int height = 720;
double volume = 1;
bool runtimeProbe = false;
};
HelperArgs parseArgs(int argc, char** argv) {
@@ -627,12 +629,136 @@ HelperArgs parseArgs(int argc, char** argv) {
else if (arg == "--volume") args.volume = std::atof(next().c_str());
else if (arg == "--hwdec") args.hwdec = next();
else if (arg == "--audio-delay") args.audioDelay = next();
else if (arg == "--runtime-probe") args.runtimeProbe = true;
}
args.width = std::max(16, args.width);
args.height = std::max(16, args.height);
return args;
}
int runtimeProbeFailure(const std::string& reason,
const std::string& detail = "") {
JsonWriter writer;
writer.num("protocol", 1)
.boolean("usable", false)
.str("reason", reason);
if (!detail.empty()) writer.str("detail", detail);
emitLine(writer.finish());
return 1;
}
std::string libmpvClientApiVersion() {
const unsigned long version = mpv_client_api_version();
return std::to_string(version >> 16) + "." +
std::to_string(version & 0xffff);
}
std::string runtimeProbeShmName() {
#if defined(_WIN32)
const uint64_t processId = (uint64_t)GetCurrentProcessId();
#else
const uint64_t processId = (uint64_t)getpid();
#endif
return "/impv-fc-runtime-probe-" + std::to_string(processId);
}
/*
* Bounded startup capability probe: initialize an idle libmpv client and
* create the platform GL + mpv OpenGL render contexts, then create, validate,
* and destroy a minimal shared-memory ring. It deliberately does not create
* an FBO, open media, or enter either command loop.
*/
int runRuntimeProbe() {
mpv_handle* mpv = mpv_create();
if (!mpv) {
return runtimeProbeFailure("mpv-create-failed");
}
mpv_set_option_string(mpv, "vo", "libmpv");
mpv_set_option_string(mpv, "idle", "yes");
mpv_set_option_string(mpv, "input-default-bindings", "no");
mpv_set_option_string(mpv, "osc", "no");
const int initializeResult = mpv_initialize(mpv);
if (initializeResult < 0) {
const std::string error = mpv_error_string(initializeResult);
mpv_destroy(mpv);
return runtimeProbeFailure("mpv-initialize-failed", error);
}
GlContext gl;
std::string error;
if (!gl.create(error)) {
gl.destroy();
mpv_terminate_destroy(mpv);
return runtimeProbeFailure("gl-context-create-failed", error);
}
if (!gl.makeCurrent(error)) {
gl.destroy();
mpv_terminate_destroy(mpv);
return runtimeProbeFailure("gl-context-bind-failed", error);
}
mpv_opengl_init_params glInit = {
gl.procLoader(),
gl.procLoaderCtx(),
};
mpv_render_param renderParams[] = {
{MPV_RENDER_PARAM_API_TYPE,
const_cast<char*>(MPV_RENDER_API_TYPE_OPENGL)},
{MPV_RENDER_PARAM_OPENGL_INIT_PARAMS, &glInit},
{MPV_RENDER_PARAM_INVALID, nullptr},
};
mpv_render_context* renderContext = nullptr;
const int renderResult =
mpv_render_context_create(&renderContext, mpv, renderParams);
if (renderResult < 0 || !renderContext) {
const std::string renderError =
renderResult < 0 ? mpv_error_string(renderResult)
: "render context unavailable";
if (renderContext) mpv_render_context_free(renderContext);
gl.destroy();
mpv_terminate_destroy(mpv);
return runtimeProbeFailure("mpv-render-context-failed", renderError);
}
frame_helper::ShmRing runtimeProbeRing;
const std::string shmName = runtimeProbeShmName();
if (!runtimeProbeRing.create(shmName, 16, 16, 1)) {
runtimeProbeRing.destroy();
mpv_render_context_free(renderContext);
gl.destroy();
mpv_terminate_destroy(mpv);
return runtimeProbeFailure("shared-memory-create-failed");
}
const bool sharedMemoryInitialized =
runtimeProbeRing.base != nullptr &&
runtimeProbeRing.header != nullptr &&
runtimeProbeRing.header->magic == FRAME_SHM_MAGIC &&
runtimeProbeRing.header->version == FRAME_SHM_VERSION &&
runtimeProbeRing.header->width == 16 &&
runtimeProbeRing.header->height == 16 &&
runtimeProbeRing.header->generation == 1;
runtimeProbeRing.destroy();
if (!sharedMemoryInitialized) {
mpv_render_context_free(renderContext);
gl.destroy();
mpv_terminate_destroy(mpv);
return runtimeProbeFailure("shared-memory-initialize-failed");
}
const std::string libmpvVersion = libmpvClientApiVersion();
mpv_render_context_free(renderContext);
gl.destroy();
mpv_terminate_destroy(mpv);
emitLine(JsonWriter()
.num("protocol", 1)
.boolean("usable", true)
.str("libmpv", libmpvVersion)
.str("renderApi", gl.renderApiName())
.finish());
return 0;
}
} // namespace
int main(int argc, char** argv) {
@@ -641,6 +767,9 @@ int main(int argc, char** argv) {
signal(SIGPIPE, SIG_IGN);
#endif
const HelperArgs args = parseArgs(argc, argv);
if (args.runtimeProbe) {
return runRuntimeProbe();
}
g_state.mpv = mpv_create();
if (!g_state.mpv) {
+27 -3
View File
@@ -36,7 +36,11 @@
"inputs": [
"production",
"^production",
"{workspaceRoot}/apps/electron-backend/native/build/Release/**"
"{workspaceRoot}/apps/electron-backend/native/build/Release/**",
"{workspaceRoot}/tools/embedded-mpv/linux-source-archive-contract.cjs",
"{workspaceRoot}/tools/embedded-mpv/linux-source-archive-contract.d.cts",
"{workspaceRoot}/tools/embedded-mpv/runtime-probe-contract.cjs",
"{workspaceRoot}/tools/embedded-mpv/runtime-probe-contract.d.cts"
],
"options": {
"outputPath": "dist/apps/electron-backend",
@@ -98,7 +102,11 @@
"inputs": [
"production",
"^production",
"{workspaceRoot}/apps/electron-backend/native/build/Release/**"
"{workspaceRoot}/apps/electron-backend/native/build/Release/**",
"{workspaceRoot}/tools/embedded-mpv/linux-source-archive-contract.cjs",
"{workspaceRoot}/tools/embedded-mpv/linux-source-archive-contract.d.cts",
"{workspaceRoot}/tools/embedded-mpv/runtime-probe-contract.cjs",
"{workspaceRoot}/tools/embedded-mpv/runtime-probe-contract.d.cts"
],
"options": {
"outputPath": "dist/apps/electron-backend",
@@ -193,11 +201,27 @@
}
},
"lint": {
"command": "eslint apps/electron-backend/**/*.ts"
"inputs": [
"default",
"{workspaceRoot}/tools/embedded-mpv/linux-source-archive-contract.cjs",
"{workspaceRoot}/tools/embedded-mpv/linux-source-archive-contract.d.cts",
"{workspaceRoot}/tools/embedded-mpv/runtime-probe-contract.cjs",
"{workspaceRoot}/tools/embedded-mpv/runtime-probe-contract.d.cts"
],
"command": "eslint apps/electron-backend/**/*.ts \"apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime.ts\" \"apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/**/*.ts\""
},
"test": {
"executor": "@nx/jest:jest",
"outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
"inputs": [
"default",
"^production",
"{workspaceRoot}/jest.preset.js",
"{workspaceRoot}/tools/embedded-mpv/linux-source-archive-contract.cjs",
"{workspaceRoot}/tools/embedded-mpv/linux-source-archive-contract.d.cts",
"{workspaceRoot}/tools/embedded-mpv/runtime-probe-contract.cjs",
"{workspaceRoot}/tools/embedded-mpv/runtime-probe-contract.d.cts"
],
"options": {
"jestConfig": "apps/electron-backend/jest.config.ts"
}
+29 -3
View File
@@ -133,8 +133,34 @@ describe('Electron app security helpers', () => {
it('keeps the renderer sandboxed when frame-copy is requested without a usable runtime', () => {
process.env.IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY = '1';
mockIsEmbeddedMpvFeatureEnabled.mockReturnValue(true);
expect(getMainWindowWebPreferences()?.sandbox).toBe(true);
expect(mockIsFrameCopyRuntimeUsable).toHaveBeenCalledTimes(1);
});
it.each([undefined, '0'])(
'does not probe frame-copy runtime for an inactive %s opt-in',
(explicitFrameCopy) => {
mockIsEmbeddedMpvFeatureEnabled.mockReturnValue(true);
if (explicitFrameCopy === undefined) {
delete process.env.IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY;
} else {
process.env.IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY =
explicitFrameCopy;
}
expect(getMainWindowWebPreferences()?.sandbox).toBe(true);
expect(mockIsFrameCopyRuntimeUsable).not.toHaveBeenCalled();
}
);
it('probes frame-copy runtime for an explicit opt-in', () => {
process.env.IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY = '1';
mockIsEmbeddedMpvFeatureEnabled.mockReturnValue(true);
expect(getMainWindowWebPreferences()?.sandbox).toBe(true);
expect(mockIsFrameCopyRuntimeUsable).toHaveBeenCalledTimes(1);
});
it('keeps the renderer sandboxed when frame-copy is requested but embedded MPV is disabled', () => {
@@ -215,9 +241,9 @@ describe('Electron app security helpers', () => {
expect(mainWindow.loadFile).toHaveBeenCalledWith(
expect.stringContaining('index.html')
);
expect(
mockClearStorageData.mock.invocationCallOrder[0]
).toBeLessThan(mainWindow.loadFile.mock.invocationCallOrder[0]);
expect(mockClearStorageData.mock.invocationCallOrder[0]).toBeLessThan(
mainWindow.loadFile.mock.invocationCallOrder[0]
);
});
it('continues packaged renderer loading when Electron service worker cleanup fails', async () => {
@@ -10,10 +10,17 @@ const mockElectronApp = {
jest.mock('electron', () => ({ app: mockElectronApp }));
import {
getEmbeddedMpvAddonCandidatePaths,
getFrameCopyRuntimeAvailability,
isFrameCopyPlatformSupported,
isFrameCopyRuntimeUsable,
resolveFrameCopyHelperPath,
shouldPromotePersistedFrameCopyOptIn,
} from './embedded-mpv-frame-copy-platform.util';
import * as frameCopyPlatform from './embedded-mpv-frame-copy-platform.util';
import type {
EmbeddedMpvFrameCopyManifestContract,
EmbeddedMpvFrameCopyRuntimeResult,
} from './embedded-mpv-frame-copy-runtime';
describe('embedded-mpv-frame-copy-platform.util', () => {
describe('isFrameCopyPlatformSupported', () => {
@@ -31,7 +38,8 @@ describe('embedded-mpv-frame-copy-platform.util', () => {
['darwin', 'arm64', true],
['darwin', 'x64', false],
['linux', 'x64', true],
['linux', 'arm64', true],
['linux', 'arm64', false],
['linux', 'arm', false],
['win32', 'x64', true],
['freebsd', 'x64', false],
])('%s/%s -> %s', (platform, arch, expected) => {
@@ -61,8 +69,7 @@ describe('embedded-mpv-frame-copy-platform.util', () => {
process.platform === 'win32'
? 'iptvnator_mpv_helper.exe'
: 'iptvnator_mpv_helper';
const helperPath = () =>
path.join(releaseDir(), helperFileName());
const helperPath = () => path.join(releaseDir(), helperFileName());
const readerPath = () =>
path.join(releaseDir(), 'embedded_mpv_frame_reader.node');
@@ -152,24 +159,184 @@ describe('embedded-mpv-frame-copy-platform.util', () => {
expect(resolveFrameCopyHelperPath()).toBe(packagedHelper);
});
it('limits packaged native-view addon discovery to package-owned paths', () => {
mockElectronApp.isPackaged = true;
const resourcesPath = path.join(tempDir, 'IPTVnator', 'Resources');
Object.defineProperty(process, 'resourcesPath', {
configurable: true,
value: resourcesPath,
});
mockElectronApp.getAppPath.mockReturnValue(
path.join(resourcesPath, 'app.asar')
);
expect(getEmbeddedMpvAddonCandidatePaths()).toEqual([
path.join(
resourcesPath,
'app.asar.unpacked',
'electron-backend',
'native',
'embedded_mpv.node'
),
]);
});
});
it('promotes a stored opt-in only without an explicit env override and with a usable runtime', () => {
const shouldPromote = (
frameCopyPlatform as typeof frameCopyPlatform & {
shouldPromotePersistedFrameCopyOptIn?: (
storedEnabled: boolean,
explicitEnv: string | undefined,
runtimeUsable: boolean
) => boolean;
}
).shouldPromotePersistedFrameCopyOptIn;
describe('isFrameCopyRuntimeUsable', () => {
const originalPlatform = process.platform;
const originalArch = process.arch;
expect(shouldPromote).toBeDefined();
expect(shouldPromote?.(true, undefined, true)).toBe(true);
expect(shouldPromote?.(true, undefined, false)).toBe(false);
expect(shouldPromote?.(true, '0', true)).toBe(false);
expect(shouldPromote?.(true, '1', true)).toBe(false);
expect(shouldPromote?.(false, undefined, true)).toBe(false);
beforeEach(() => {
mockElectronApp.isPackaged = false;
});
afterEach(() => {
Object.defineProperty(process, 'platform', {
value: originalPlatform,
});
Object.defineProperty(process, 'arch', { value: originalArch });
mockElectronApp.isPackaged = false;
});
it('requires a successful Linux x64 runtime probe', () => {
Object.defineProperty(process, 'platform', { value: 'linux' });
Object.defineProperty(process, 'arch', { value: 'x64' });
const resolveHelper = jest.fn(() => '/native/iptvnator_mpv_helper');
const probeRuntime = jest.fn<
EmbeddedMpvFrameCopyRuntimeResult,
[string, EmbeddedMpvFrameCopyManifestContract]
>(() => ({
usable: true,
profile: 'system',
runtimeMode: 'system',
libmpv: '2.3',
renderApi: 'egl',
}));
expect(isFrameCopyRuntimeUsable(resolveHelper, probeRuntime)).toBe(
true
);
expect(probeRuntime).toHaveBeenCalledWith(
'/native/iptvnator_mpv_helper',
'development'
);
probeRuntime.mockReturnValueOnce({
usable: false,
reason: 'helper-probe-failed',
});
expect(
getFrameCopyRuntimeAvailability(resolveHelper, probeRuntime)
).toEqual({
usable: false,
reason: 'helper-probe-failed',
});
});
it('selects the packaged manifest contract only from app.isPackaged', () => {
Object.defineProperty(process, 'platform', { value: 'linux' });
Object.defineProperty(process, 'arch', { value: 'x64' });
mockElectronApp.isPackaged = true;
const resolveHelper = jest.fn(() => '/native/iptvnator_mpv_helper');
const probeRuntime = jest.fn<
EmbeddedMpvFrameCopyRuntimeResult,
[string, EmbeddedMpvFrameCopyManifestContract]
>(() => ({
usable: true,
profile: 'system',
runtimeMode: 'system',
libmpv: '2.3',
renderApi: 'egl',
}));
expect(isFrameCopyRuntimeUsable(resolveHelper, probeRuntime)).toBe(
true
);
expect(probeRuntime).toHaveBeenCalledWith(
'/native/iptvnator_mpv_helper',
'packaged'
);
});
it.each<[NodeJS.Platform, string]>([
['darwin', 'arm64'],
['win32', 'x64'],
])(
'keeps the existing helper-presence gate on %s',
(platform, arch) => {
Object.defineProperty(process, 'platform', {
value: platform,
});
Object.defineProperty(process, 'arch', { value: arch });
const resolveHelper = jest.fn(
() => '/native/iptvnator_mpv_helper'
);
const probeRuntime = jest.fn();
expect(
isFrameCopyRuntimeUsable(resolveHelper, probeRuntime)
).toBe(true);
expect(probeRuntime).not.toHaveBeenCalled();
}
);
it('rejects Linux ARM before helper discovery or probing', () => {
Object.defineProperty(process, 'platform', { value: 'linux' });
Object.defineProperty(process, 'arch', { value: 'arm64' });
const resolveHelper = jest.fn(() => '/native/iptvnator_mpv_helper');
const probeRuntime = jest.fn();
expect(isFrameCopyRuntimeUsable(resolveHelper, probeRuntime)).toBe(
false
);
expect(resolveHelper).not.toHaveBeenCalled();
expect(probeRuntime).not.toHaveBeenCalled();
});
it('reports unsupported architecture for Intel macOS', () => {
Object.defineProperty(process, 'platform', { value: 'darwin' });
Object.defineProperty(process, 'arch', { value: 'x64' });
const resolveHelper = jest.fn(() => '/native/iptvnator_mpv_helper');
const probeRuntime = jest.fn();
expect(
getFrameCopyRuntimeAvailability(resolveHelper, probeRuntime)
).toEqual({
usable: false,
reason: 'unsupported-architecture',
});
expect(resolveHelper).not.toHaveBeenCalled();
expect(probeRuntime).not.toHaveBeenCalled();
});
});
it('lazily probes only a stored opt-in without an explicit env override', () => {
const runtimeUsable = jest.fn(() => true);
expect(
shouldPromotePersistedFrameCopyOptIn(
false,
undefined,
runtimeUsable
)
).toBe(false);
expect(
shouldPromotePersistedFrameCopyOptIn(true, '0', runtimeUsable)
).toBe(false);
expect(
shouldPromotePersistedFrameCopyOptIn(true, '1', runtimeUsable)
).toBe(false);
expect(runtimeUsable).not.toHaveBeenCalled();
expect(
shouldPromotePersistedFrameCopyOptIn(true, undefined, runtimeUsable)
).toBe(true);
expect(runtimeUsable).toHaveBeenCalledTimes(1);
runtimeUsable.mockReturnValue(false);
expect(
shouldPromotePersistedFrameCopyOptIn(true, undefined, runtimeUsable)
).toBe(false);
expect(runtimeUsable).toHaveBeenCalledTimes(2);
});
});
@@ -1,6 +1,11 @@
import { app } from 'electron';
import { accessSync, constants as fsConstants, statSync } from 'fs';
import path from 'path';
import { probeEmbeddedMpvFrameCopyRuntime } from './embedded-mpv-frame-copy-runtime';
import type {
EmbeddedMpvFrameCopyManifestContract,
EmbeddedMpvFrameCopyRuntimeResult,
} from './embedded-mpv-frame-copy-runtime';
/**
* Platform gate + helper discovery for the embedded MPV frame-copy engine,
@@ -9,15 +14,15 @@ import path from 'path';
* callable before app.whenReady().
*
* macOS: Apple Silicon only (owner decision 2026-07-10) — Intel Macs keep
* the docked native engine. Linux: any arch — the helper renders offscreen
* through headless EGL and links libmpv out of process, so neither window
* embedding nor the in-process-libmpv ban constrains it. Windows: any arch
* with a helper binary (WGL offscreen render; in practice x64, the only
* vendored runtime) — the helper-presence check below is the real gate.
* the docked native engine. Linux: x64 only — official packages validate a
* profile manifest and run the helper's bounded EGL/libmpv capability probe;
* ARM packages remain honestly unavailable. Windows: any arch with a helper
* binary (WGL offscreen render; in practice x64, the only vendored runtime)
* — the helper-presence check below is the real gate.
*/
export function isFrameCopyPlatformSupported(): boolean {
return (
process.platform === 'linux' ||
(process.platform === 'linux' && process.arch === 'x64') ||
process.platform === 'win32' ||
(process.platform === 'darwin' && process.arch === 'arm64')
);
@@ -77,7 +82,7 @@ export function getEmbeddedMpvAddonCandidatePaths(): string[] {
return dedupeDefinedPaths(
app.isPackaged
? [...packagedAddonPaths, ...distAddonPaths, localBuildAddonPath]
? packagedAddonPaths
: [localBuildAddonPath, ...distAddonPaths, ...packagedAddonPaths]
);
}
@@ -104,10 +109,7 @@ export function resolveFrameCopyHelperPath(): string | null {
.map((candidatePath) => path.dirname(candidatePath))
.map((nativeDir) => ({
helper: path.join(nativeDir, helperFileName),
reader: path.join(
nativeDir,
'embedded_mpv_frame_reader.node'
),
reader: path.join(nativeDir, 'embedded_mpv_frame_reader.node'),
}))
.find(({ helper, reader }) => {
try {
@@ -127,16 +129,80 @@ export function resolveFrameCopyHelperPath(): string | null {
);
}
type FrameCopyRuntimeProbe = (
helperPath: string,
manifestContract: EmbeddedMpvFrameCopyManifestContract
) => EmbeddedMpvFrameCopyRuntimeResult;
export type FrameCopyRuntimeAvailability =
| EmbeddedMpvFrameCopyRuntimeResult
| { usable: true };
let cachedDefaultRuntimeAvailability: FrameCopyRuntimeAvailability | undefined;
export function getFrameCopyRuntimeAvailability(
resolveHelper: () => string | null = resolveFrameCopyHelperPath,
probeRuntime: FrameCopyRuntimeProbe = probeEmbeddedMpvFrameCopyRuntime
): FrameCopyRuntimeAvailability {
const usesProcessDecision =
resolveHelper === resolveFrameCopyHelperPath &&
probeRuntime === probeEmbeddedMpvFrameCopyRuntime;
if (usesProcessDecision && cachedDefaultRuntimeAvailability !== undefined) {
return cachedDefaultRuntimeAvailability;
}
let availability: FrameCopyRuntimeAvailability;
if (!isFrameCopyPlatformSupported()) {
availability = {
usable: false,
reason:
process.platform === 'linux' || process.platform === 'darwin'
? 'unsupported-architecture'
: 'unsupported-platform',
};
} else {
const helperPath = resolveHelper();
if (!helperPath) {
availability = {
usable: false,
reason: 'runtime-artifact-missing',
};
} else if (process.platform !== 'linux') {
availability = { usable: true };
} else {
try {
// app.isPackaged is the trusted boundary. Environment flags
// can request the feature, but cannot weaken its manifest
// contract or make development artifacts package-trusted.
availability = probeRuntime(
helperPath,
app.isPackaged ? 'packaged' : 'development'
);
} catch {
availability = {
usable: false,
reason: 'runtime-probe-internal-error',
};
}
}
}
if (usesProcessDecision) {
cachedDefaultRuntimeAvailability = availability;
}
return availability;
}
export function isFrameCopyRuntimeUsable(
resolveHelper: () => string | null = resolveFrameCopyHelperPath
resolveHelper: () => string | null = resolveFrameCopyHelperPath,
probeRuntime: FrameCopyRuntimeProbe = probeEmbeddedMpvFrameCopyRuntime
): boolean {
return isFrameCopyPlatformSupported() && resolveHelper() !== null;
return getFrameCopyRuntimeAvailability(resolveHelper, probeRuntime).usable;
}
export function shouldPromotePersistedFrameCopyOptIn(
storedEnabled: boolean,
explicitEnv: string | undefined,
runtimeUsable = isFrameCopyRuntimeUsable()
runtimeUsable: () => boolean = isFrameCopyRuntimeUsable
): boolean {
return explicitEnv === undefined && storedEnabled && runtimeUsable;
return explicitEnv === undefined && storedEnabled && runtimeUsable();
}
@@ -0,0 +1,16 @@
export { createLinuxFrameCopyHelperEnvironment } from './embedded-mpv-frame-copy-runtime/helper-environment';
export { createLinuxFrameCopyHelperLaunch } from './embedded-mpv-frame-copy-runtime/helper-launch';
export {
createEmbeddedMpvFrameCopyRuntimeProbe,
probeEmbeddedMpvFrameCopyRuntime,
} from './embedded-mpv-frame-copy-runtime/probe';
export type {
EmbeddedMpvFrameCopyManifestContract,
EmbeddedMpvFrameCopyRuntimeDependencies,
EmbeddedMpvFrameCopyRuntimeFailureReason,
EmbeddedMpvFrameCopyRuntimeFileSystem,
EmbeddedMpvFrameCopyRuntimeMode,
EmbeddedMpvFrameCopyRuntimeResult,
EmbeddedMpvHelperRuntimeProbeFailureReason,
} from './embedded-mpv-frame-copy-runtime/types';
export type { LinuxFrameCopyHelperLaunchFileSystem } from './embedded-mpv-frame-copy-runtime/helper-launch';
@@ -0,0 +1,330 @@
import runtimeProbeContract = require('../../../../../../tools/embedded-mpv/runtime-probe-contract.cjs');
import sourceArchiveContract = require('../../../../../../tools/embedded-mpv/linux-source-archive-contract.cjs');
import type { EmbeddedMpvFrameCopyRuntimeMode, RuntimeProfile } from './types';
interface RuntimeProfileContract {
origin: string;
runtimeMode: EmbeddedMpvFrameCopyRuntimeMode;
targets: ReadonlySet<string>;
}
export const RUNTIME_MANIFEST_NAME = 'embedded-mpv-runtime.json';
export const FRAME_COPY_ADDON_NAME = 'embedded_mpv.node';
export const FRAME_COPY_READER_NAME = 'embedded_mpv_frame_reader.node';
export const FRAME_COPY_HELPER_NAME = 'iptvnator_mpv_helper';
export const RUNTIME_PROBE_PROTOCOL = 1;
export const { RUNTIME_PROBE_MAX_BUFFER_BYTES, RUNTIME_PROBE_TIMEOUT_MS } =
runtimeProbeContract;
export const {
SOURCE_ARCHIVE_BINDING_SCHEMA_VERSION,
SOURCE_ARCHIVE_NAME,
validateLinuxSourceArchiveBinding,
} = sourceArchiveContract;
export const VERSIONED_LIBMPV_PATTERN = /^libmpv\.so\.\d+(?:\.\d+)*$/;
export const SAFE_RUNTIME_NAME_PATTERN = /^[A-Za-z0-9_+.-]+$/;
export const SHARED_LIBRARY_PATTERN = /\.so(?:\.\d+)*$/;
export const PINNED_LIBPLACEBO_SOURCE_SUBMODULES = [
'450bd2232225d6c7728a4108055ac2e37cef6475 3rdparty/Vulkan-Headers',
'97b54ca9e75f5303507699d27c6b4f4efe4641a1 3rdparty/fast_float',
'73db193f853e2ee079bf3ca8a64aa2eaf6459043 3rdparty/glad',
'15206881c006c79667fe5154fe80c01c65410679 3rdparty/jinja',
'297fc8e356e6836a62087949245d09a28e9f1b13 3rdparty/markupsafe',
'242f35efa067a46c595645eeda7b1771ea1f83b1 demos/3rdparty/nuklear',
] as const;
export const SHA256_PATTERN = /^[a-f0-9]{64}$/;
export const GIT_COMMIT_PATTERN = /^[a-f0-9]{40,64}$/;
export const SUBMODULE_RECORD_PATTERN =
/^[a-f0-9]{40,64}\s+([A-Za-z0-9_+./-]+)$/;
export const VERSION_PATTERN = /^\d+(?:\.\d+)+$/;
export const GLIBC_TOOLCHAIN_ALLOWLIST = [
'ld-linux-x86-64.so.2',
'libc.so.6',
'libdl.so.2',
'libgcc_s.so.1',
'libm.so.6',
'libpthread.so.0',
'librt.so.1',
'libstdc++.so.6',
] as const;
export const EXPECTED_EXTERNAL_SYSTEM_LIBRARIES = [
{
name: 'libEGL.so.1',
interface: 'EGL',
reason: 'System graphics-driver interface used by the frame-copy helper.',
},
{
name: 'libGL.so.1',
interface: 'OpenGL',
reason: 'System OpenGL compatibility interface supplied by the graphics stack.',
},
{
name: 'libGLX.so.0',
interface: 'OpenGL',
reason: 'GLVND OpenGL dispatch interface supplied by the graphics stack.',
},
{
name: 'libOpenGL.so.0',
interface: 'OpenGL',
reason: 'GLVND OpenGL interface supplied by the graphics stack.',
},
{
name: 'libasound.so.2',
interface: 'ALSA',
reason: 'Linux system audio interface intentionally used by libmpv.',
},
{
name: 'libdrm.so.2',
interface: 'DRM',
reason: 'Kernel graphics interface used by system GBM and VA-API drivers.',
},
{
name: 'libgbm.so.1',
interface: 'GBM',
reason: 'System graphics-buffer interface used by headless EGL rendering.',
},
{
name: 'libpulse.so.0',
interface: 'PulseAudio',
reason: 'Linux desktop audio interface intentionally used by libmpv.',
},
{
name: 'libva-drm.so.2',
interface: 'VA-API DRM',
reason: 'System VA-API DRM interface used for hardware decoding.',
},
{
name: 'libva.so.2',
interface: 'VA-API',
reason: 'System video-acceleration interface used for hardware decoding.',
},
] as const;
export const ALLOWED_EXTERNAL_LIBRARY_NAMES = new Set<string>([
...GLIBC_TOOLCHAIN_ALLOWLIST,
...EXPECTED_EXTERNAL_SYSTEM_LIBRARIES.map(({ name }) => name),
]);
export const PORTABLE_ABI_BASELINE = {
distribution: 'Ubuntu 22.04',
glibcMaximum: '2.35',
glibcxxMaximum: '3.4.30',
} as const;
export const PINNED_SOURCE_PACKAGE_IDENTITIES = {
freetype: {
version: '2.13.3',
sourceUrl:
'https://download.savannah.gnu.org/releases/freetype/freetype-2.13.3.tar.xz',
sourceSha256:
'0550350666d427c74daeb85d5ac7bb353acba5f76956395995311a9c6f063289',
license: 'FreeType License (FTL)',
},
fribidi: {
version: '1.0.16',
sourceUrl:
'https://github.com/fribidi/fribidi/releases/download/v1.0.16/fribidi-1.0.16.tar.xz',
sourceSha256:
'1b1cde5b235d40479e91be2f0e88a309e3214c8ab470ec8a2744d82a5a9ea05c',
license: 'LGPL-2.1-or-later',
},
harfbuzz: {
version: '8.5.0',
sourceUrl:
'https://github.com/harfbuzz/harfbuzz/releases/download/8.5.0/harfbuzz-8.5.0.tar.xz',
sourceSha256:
'77e4f7f98f3d86bf8788b53e6832fb96279956e1c3961988ea3d4b7ca41ddc27',
license: 'MIT',
},
expat: {
version: '2.8.2',
sourceUrl:
'https://github.com/libexpat/libexpat/releases/download/R_2_8_2/expat-2.8.2.tar.xz',
sourceSha256:
'3ad89b8588e6644bd4e49981480d48b21289eebbcd4f0a1a4afb1c29f99b6ab4',
license: 'MIT',
},
fontconfig: {
version: '2.16.0',
sourceUrl:
'https://www.freedesktop.org/software/fontconfig/release/fontconfig-2.16.0.tar.xz',
sourceSha256:
'6a33dc555cc9ba8b10caf7695878ef134eeb36d0af366041f639b1da9b6ed220',
license: 'MIT',
},
libass: {
version: '0.17.3',
sourceUrl:
'https://github.com/libass/libass/releases/download/0.17.3/libass-0.17.3.tar.xz',
sourceSha256:
'eae425da50f0015c21f7b3a9c7262a910f0218af469e22e2931462fed3c50959',
license: 'ISC',
},
openssl: {
version: '3.5.7',
sourceUrl:
'https://github.com/openssl/openssl/releases/download/openssl-3.5.7/openssl-3.5.7.tar.gz',
sourceSha256:
'a8c0d28a529ca480f9f36cf5792e2cd21984552a3c8e4aa11a24aa31aeac98e8',
license: 'Apache-2.0',
},
ffmpeg: {
version: '8.1',
sourceUrl: 'https://ffmpeg.org/releases/ffmpeg-8.1.tar.xz',
sourceSha256:
'b072aed6871998cce9b36e7774033105ca29e33632be5b6347f3206898e0756a',
license: 'LGPL-2.1-or-later',
},
libplacebo: {
version: '7.360.1',
sourceUrl: 'https://github.com/haasn/libplacebo.git',
sourceTag: 'v7.360.1',
sourceGitCommit: 'cee9b076f2c63104ccfd497fa79c39a867293ec4',
sourceSubmodules: PINNED_LIBPLACEBO_SOURCE_SUBMODULES,
license: 'LGPL-2.1-or-later',
},
hwdata: {
version: '0.409',
sourceUrl:
'https://github.com/vcrhonek/hwdata/archive/refs/tags/v0.409.tar.gz',
sourceSha256:
'23006accc0f931dd5187d0307a57d0744e2b8feb85e73c37bc0f5229fb31eadd',
buildInput: {
consumer: 'libdisplay-info',
relativePath: 'pnp.ids',
purpose: 'PNP vendor lookup table compiled into libdisplay-info.',
},
license: 'GPL-2.0-or-later OR XFree86-1.0',
},
'libdisplay-info': {
version: '0.1.1',
sourceUrl:
'https://gitlab.freedesktop.org/emersion/libdisplay-info/-/releases/0.1.1/downloads/libdisplay-info-0.1.1.tar.xz',
sourceSha256:
'0d8731588e9f82a9cac96324a3d7c82e2ba5b1b5e006143fefe692c74069fb60',
license: 'MIT',
},
mpv: {
version: '0.41.0',
sourceUrl:
'https://github.com/mpv-player/mpv/archive/refs/tags/v0.41.0.tar.gz',
sourceSha256:
'ee21092a5ee427353392360929dc64645c54479aefdb5babc5cfbb5fad626209',
license: 'LGPL-2.1-or-later with -Dgpl=false',
},
} as const;
export const EXPECTED_ARTIFACTS = {
addon: {
name: FRAME_COPY_ADDON_NAME,
regularFile: true,
readable: true,
},
frameReader: {
name: FRAME_COPY_READER_NAME,
regularFile: true,
readable: true,
},
helper: {
name: FRAME_COPY_HELPER_NAME,
regularFile: true,
readable: true,
executable: true,
},
};
export const EXPECTED_PROCESS_ISOLATION = {
addonLoadsLibmpv: false,
readerLoadsLibmpv: false,
electronLoadsLibmpv: false,
helperLinksLibmpv: true,
helperRunpath: ['$ORIGIN/lib'],
};
export const EXPECTED_DEVELOPMENT_ARTIFACTS = {
addon: FRAME_COPY_ADDON_NAME,
frameReader: FRAME_COPY_READER_NAME,
helper: FRAME_COPY_HELPER_NAME,
};
export const EXPECTED_DEVELOPMENT_PROCESS_ISOLATION = {
addonLoadsLibmpv: false,
helperLinksLibmpv: true,
helperRunpath: ['$ORIGIN/lib'],
};
export const DEVELOPMENT_MANIFEST_FIELDS = [
'allowedPackageRuntimeModes',
'arch',
'artifacts',
'buildInputMode',
'generatedAt',
'libmpvSoname',
'nativeViewFallback',
'origin',
'packageRuntimeAvailability',
'platform',
'processIsolation',
'runtimeFiles',
'runtimeTotalBytes',
'schemaVersion',
'sourceArchive',
'sourceRuntime',
'sourceRuntimeValidated',
] as const;
export const SYSTEM_PACKAGE_DEPENDENCIES = Object.freeze({
deb: Object.freeze(['libmpv2', 'libegl1', 'libgl1', 'libgbm1']),
rpm: Object.freeze([
'mpv-libs',
'libglvnd-egl',
'libglvnd-glx',
'mesa-libgbm',
]),
pacman: Object.freeze(['mpv', 'libglvnd', 'mesa']),
});
export const PROFILE_CONTRACTS = {
system: {
origin: 'system-libmpv-frame-copy',
runtimeMode: 'system',
targets: new Set(['deb', 'rpm', 'pacman']),
},
portable: {
origin: 'bundled-lgpl-frame-copy',
runtimeMode: 'bundled',
targets: new Set(['appimage', 'snap']),
},
flatpak: {
origin: 'bundled-lgpl-frame-copy',
runtimeMode: 'bundled',
targets: new Set(['flatpak']),
},
} as const satisfies Record<RuntimeProfile, RuntimeProfileContract>;
export const BASE_MANIFEST_FIELDS = [
'arch',
'artifacts',
'generatedAt',
'libmpvSoname',
'nativeViewFallback',
'origin',
'packageDependencies',
'platform',
'processIsolation',
'profile',
'runtimeFiles',
'runtimeMode',
'runtimeTotalBytes',
'schemaVersion',
'targets',
] as const;
export const BUNDLED_MANIFEST_FIELDS = [
...BASE_MANIFEST_FIELDS,
'externalSystemLibraries',
'runtimeDependencyClosure',
'sourceArchive',
'sourceRuntime',
] as const;
@@ -0,0 +1,172 @@
import { mkdirSync } from 'fs';
import path from 'path';
import {
cloneManifest,
createDevelopmentFixture,
probeDevelopmentRuntime,
writeManifest,
} from './runtime-fixtures.test-helpers';
import {
createRuntimeTestContext,
type RuntimeTestContext,
} from './runtime-harness.test-helpers';
describe('embedded-mpv frame-copy development manifest', () => {
let context: RuntimeTestContext;
beforeEach(() => {
context = createRuntimeTestContext();
});
afterEach(() => {
context.dispose();
});
it.each(['system-dev', 'system-build-inputs', 'bundled-runtime'] as const)(
'accepts an exact unpackaged %s build manifest and still runs the helper probe',
(buildInputMode) => {
const fixture = createDevelopmentFixture(
context.rootDir,
buildInputMode
);
expect(
probeDevelopmentRuntime(
context.createProbe(),
fixture.helperPath
)
).toEqual(
expect.objectContaining({
usable: true,
runtimeMode:
buildInputMode === 'bundled-runtime'
? 'bundled'
: 'system',
})
);
expect(context.spawnRuntimeProbe).toHaveBeenCalledWith(
fixture.helperPath,
['--runtime-probe'],
expect.objectContaining({
env:
buildInputMode === 'bundled-runtime'
? {
PATH: '/usr/bin',
LD_LIBRARY_PATH: path.join(
fixture.nativeDir,
'lib'
),
}
: {
PATH: '/usr/bin',
},
})
);
}
);
it('keeps the packaged manifest contract strict when a development manifest is present', () => {
const fixture = createDevelopmentFixture(
context.rootDir,
'bundled-runtime'
);
expect(context.createProbe()(fixture.helperPath)).toEqual({
usable: false,
reason: 'runtime-manifest-invalid',
});
expect(context.spawnRuntimeProbe).not.toHaveBeenCalled();
});
it('accepts a local bundled runtime before a release source archive is assembled', () => {
const fixture = createDevelopmentFixture(
context.rootDir,
'bundled-runtime'
);
const manifest = cloneManifest(fixture.manifest);
manifest.sourceArchive = null;
writeManifest(fixture.manifestPath, manifest);
expect(
probeDevelopmentRuntime(context.createProbe(), fixture.helperPath)
).toEqual(
expect.objectContaining({
usable: true,
runtimeMode: 'bundled',
})
);
expect(context.spawnRuntimeProbe).toHaveBeenCalled();
});
it.each([
{
label: 'origin',
mutate(manifest: Record<string, unknown>) {
manifest.origin = 'system-libmpv-frame-copy';
},
},
{
label: 'architecture',
mutate(manifest: Record<string, unknown>) {
manifest.arch = 'arm64';
},
},
{
label: 'artifact set',
mutate(manifest: Record<string, unknown>) {
const artifacts = manifest.artifacts as Record<string, unknown>;
artifacts.helper = 'other-helper';
},
},
{
label: 'package availability',
mutate(manifest: Record<string, unknown>) {
manifest.packageRuntimeAvailability = {
system: true,
bundled: false,
};
},
},
{
label: 'unexpected field',
mutate(manifest: Record<string, unknown>) {
manifest.manifestContract = 'packaged';
},
},
])(
'rejects a development manifest with an invalid $label',
({ mutate }) => {
const fixture = createDevelopmentFixture(
context.rootDir,
'system-dev'
);
const manifest = cloneManifest(fixture.manifest);
mutate(manifest);
writeManifest(fixture.manifestPath, manifest);
expect(
probeDevelopmentRuntime(
context.createProbe(),
fixture.helperPath
)
).toEqual({
usable: false,
reason: 'runtime-manifest-invalid',
});
expect(context.spawnRuntimeProbe).not.toHaveBeenCalled();
}
);
it('rejects a system development manifest with a private runtime directory', () => {
const fixture = createDevelopmentFixture(context.rootDir, 'system-dev');
mkdirSync(path.join(fixture.nativeDir, 'lib'));
expect(
probeDevelopmentRuntime(context.createProbe(), fixture.helperPath)
).toEqual({
usable: false,
reason: 'runtime-library-directory-invalid',
});
expect(context.spawnRuntimeProbe).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,148 @@
import { accessSync, lstatSync, readFileSync, readdirSync } from 'fs';
import path from 'path';
import { createLinuxFrameCopyHelperEnvironment } from '../embedded-mpv-frame-copy-runtime';
import { createFixture } from './runtime-fixtures.test-helpers';
import {
createRuntimeTestContext,
type RuntimeTestContext,
} from './runtime-harness.test-helpers';
const FLATPAK_NATIVE_DIR =
'/app/iptvnator/resources/app.asar.unpacked/electron-backend/native';
const FREEDESKTOP_EGL_EXTERNAL_PLATFORM_CONFIG_DIRS = [
'/etc/egl/egl_external_platform.d',
'/usr/lib/x86_64-linux-gnu/GL/egl/egl_external_platform.d',
'/usr/share/egl/egl_external_platform.d',
].join(':');
describe('Flatpak embedded MPV frame-copy runtime', () => {
it('reconstructs the immutable Freedesktop GL metadata inside the packaged app', () => {
expect(
createLinuxFrameCopyHelperEnvironment(
{
PATH: '/app/bin:/usr/bin',
FLATPAK_ID: 'com.fourgray.iptvnator',
__EGL_EXTERNAL_PLATFORM_CONFIG_DIRS:
'/tmp/hostile-egl-platform',
__EGL_EXTERNAL_PLATFORM_CONFIG_FILENAMES:
'/tmp/hostile-egl-platform.json',
GBM_BACKENDS_PATH: '/tmp/hostile-gbm',
LIBGL_DRIVERS_PATH: '/tmp/hostile-dri',
LIBVA_DRIVERS_PATH: '/tmp/hostile-va',
__EGL_VENDOR_LIBRARY_DIRS: '/tmp/hostile-egl-vendor',
VK_LAYER_PATH: '/tmp/hostile-vulkan',
},
FLATPAK_NATIVE_DIR,
'bundled'
)
).toEqual({
PATH: '/app/bin:/usr/bin',
FLATPAK_ID: 'com.fourgray.iptvnator',
__EGL_EXTERNAL_PLATFORM_CONFIG_DIRS:
FREEDESKTOP_EGL_EXTERNAL_PLATFORM_CONFIG_DIRS,
LD_LIBRARY_PATH: path.join(FLATPAK_NATIVE_DIR, 'lib'),
});
});
it.each([
['wrong app id', 'com.example.other', '/app/iptvnator/native'],
[
'helper outside /app',
'com.fourgray.iptvnator',
'/opt/iptvnator/native',
],
])(
'does not reconstruct Flatpak GL metadata for %s',
(_label, flatpakId, nativeDir) => {
expect(
createLinuxFrameCopyHelperEnvironment(
{
FLATPAK_ID: flatpakId,
__EGL_EXTERNAL_PLATFORM_CONFIG_DIRS:
'/tmp/hostile-egl-platform',
},
nativeDir,
'bundled'
)
).toEqual({
FLATPAK_ID: flatpakId,
LD_LIBRARY_PATH: path.join(nativeDir, 'lib'),
});
}
);
describe('packaged capability probe', () => {
let context: RuntimeTestContext;
beforeEach(() => {
context = createRuntimeTestContext();
});
afterEach(() => {
context.dispose();
});
it('uses reconstructed Freedesktop GL metadata through the application gate', () => {
const fixture = createFixture(context.rootDir, 'flatpak');
const virtualHelperPath = path.join(
FLATPAK_NATIVE_DIR,
'iptvnator_mpv_helper'
);
const translatePath = (candidatePath: string): string => {
if (
candidatePath === FLATPAK_NATIVE_DIR ||
candidatePath.startsWith(`${FLATPAK_NATIVE_DIR}${path.sep}`)
) {
return path.join(
fixture.nativeDir,
path.relative(FLATPAK_NATIVE_DIR, candidatePath)
);
}
return candidatePath;
};
const probeRuntime = context.createProbe({
env: {
PATH: '/app/bin:/usr/bin',
FLATPAK_ID: 'com.fourgray.iptvnator',
__EGL_EXTERNAL_PLATFORM_CONFIG_DIRS:
'/tmp/hostile-egl-platform',
__EGL_EXTERNAL_PLATFORM_CONFIG_FILENAMES:
'/tmp/hostile-egl-platform.json',
GBM_BACKENDS_PATH: '/tmp/hostile-gbm',
LIBGL_DRIVERS_PATH: '/tmp/hostile-dri',
},
fileSystem: {
accessSync: (candidatePath, mode) =>
accessSync(translatePath(candidatePath), mode),
lstatSync: (candidatePath) =>
lstatSync(translatePath(candidatePath)),
readFileSync: (candidatePath) =>
readFileSync(translatePath(candidatePath)),
readdirSync: (candidatePath) =>
readdirSync(translatePath(candidatePath)),
},
});
expect(probeRuntime(virtualHelperPath)).toEqual(
expect.objectContaining({
usable: true,
profile: 'flatpak',
runtimeMode: 'bundled',
})
);
expect(context.spawnRuntimeProbe).toHaveBeenCalledWith(
virtualHelperPath,
['--runtime-probe'],
expect.objectContaining({
env: {
PATH: '/app/bin:/usr/bin',
FLATPAK_ID: 'com.fourgray.iptvnator',
__EGL_EXTERNAL_PLATFORM_CONFIG_DIRS:
FREEDESKTOP_EGL_EXTERNAL_PLATFORM_CONFIG_DIRS,
LD_LIBRARY_PATH: path.join(FLATPAK_NATIVE_DIR, 'lib'),
},
})
);
});
});
});
@@ -0,0 +1,369 @@
import path from 'path';
import { createLinuxFrameCopyHelperEnvironment } from '../embedded-mpv-frame-copy-runtime';
const HOSTILE_LOADER_ENVIRONMENT = {
BASH_ENV: '/tmp/hostile-bash-env',
ENV: '/tmp/hostile-shell-env',
BASHOPTS: 'extdebug',
SHELLOPTS: 'xtrace',
PS4: '$(/tmp/hostile-trace-hook)',
BASH_XTRACEFD: '9',
CDPATH: '/tmp/hostile-cdpath',
'BASH_FUNC_dirname%%': '() { printf /tmp/hostile-provider-root; exit 0; }',
LD_AUDIT: '/tmp/audit.so',
LD_LIBRARY_PATH: '/tmp/hostile-libs',
LD_ORIGIN_PATH: '/tmp/hostile-origin',
LD_PRELOAD: '/tmp/inject.so',
__EGL_VENDOR_LIBRARY_FILENAMES: '/tmp/hostile-egl-vendor.json',
__EGL_VENDOR_LIBRARY_DIRS: '/tmp/hostile-egl-vendor-dir',
__EGL_EXTERNAL_PLATFORM_CONFIG_DIRS: '/tmp/hostile-egl-platform',
__EGL_EXTERNAL_PLATFORM_CONFIG_FILENAMES: '/tmp/hostile-egl-platform.json',
GBM_BACKEND: '../../../../../tmp/hostile-gbm',
GBM_BACKENDS_PATH: '/tmp/hostile-gbm-path',
LIBGL_DRIVERS_PATH: '/tmp/hostile-dri-path',
MESA_LOADER_DRIVER_OVERRIDE: '../../../../../tmp/hostile-dri',
LIBVA_DRIVER_NAME: '../../../../../tmp/hostile-va',
LIBVA_DRIVERS_PATH: '/tmp/hostile-va-path',
VDPAU_DRIVER_PATH: '/tmp/hostile-vdpau',
VK_DRIVER_FILES: '/tmp/hostile-vulkan-driver.json',
VK_ICD_FILENAMES: '/tmp/hostile-vulkan-icd.json',
VK_ADD_DRIVER_FILES: '/tmp/hostile-vulkan-add-driver.json',
VK_ADD_LAYER_PATH: '/tmp/hostile-vulkan-layers',
VK_IMPLICIT_LAYER_PATH: '/tmp/hostile-vulkan-implicit-layers',
VK_ADD_IMPLICIT_LAYER_PATH: '/tmp/hostile-vulkan-add-implicit-layers',
VK_LAYER_PATH: '/tmp/hostile-vulkan-layer-path',
} as const;
const GRAPHICS_SELECTOR_ENVIRONMENT = {
LIBGL_ALWAYS_SOFTWARE: '1',
EGL_PLATFORM: 'x11',
DRI_PRIME: '1',
GALLIUM_DRIVER: 'llvmpipe',
VDPAU_DRIVER: 'mesa',
__GLX_VENDOR_LIBRARY_NAME: 'mesa',
__GLX_FORCE_VENDOR_LIBRARY_0: 'mesa',
VK_INSTANCE_LAYERS: 'VK_LAYER_MESA_overlay',
VK_LOADER_DRIVERS_SELECT: '*mesa*',
} as const;
describe('createLinuxFrameCopyHelperEnvironment', () => {
it('removes ambient loader overrides for system packages', () => {
expect(
createLinuxFrameCopyHelperEnvironment(
{
PATH: '/usr/bin',
HOME: '/home/user',
...HOSTILE_LOADER_ENVIRONMENT,
},
'/opt/iptvnator/native',
'system'
)
).toEqual({
PATH: '/usr/bin',
HOME: '/home/user',
});
});
it('preserves graphics feature and debug selectors', () => {
expect(
createLinuxFrameCopyHelperEnvironment(
GRAPHICS_SELECTOR_ENVIRONMENT,
'/opt/iptvnator/native',
'system'
)
).toEqual(GRAPHICS_SELECTOR_ENVIRONMENT);
});
it('keeps trusted Snap GL and core22 roots ahead of older and generic libraries', () => {
const snapRoot = '/snap/iptvnator/42';
const nativeDir = path.join(
snapRoot,
'resources',
'app.asar.unpacked',
'electron-backend',
'native'
);
expect(
createLinuxFrameCopyHelperEnvironment(
{
PATH: '/snap/bin:/usr/bin',
SNAP: snapRoot,
SNAP_LIBRARY_PATH: [
'/var/lib/snapd/lib/gl',
'/tmp/hostile-gl',
'/var/lib/snapd/lib/gl/nvidia',
'/var/lib/snapd/lib/gl-evil',
].join(':'),
SNAP_DESKTOP_ARCH_TRIPLET: 'hostile-linux-gnu',
SNAP_DESKTOP_RUNTIME: path.join(snapRoot, 'gnome-platform'),
GBM_BACKENDS_PATH: '/tmp/hostile-gbm',
LIBGL_DRIVERS_PATH: '/tmp/hostile-dri',
LIBVA_DRIVERS_PATH: '/tmp/hostile-va',
__EGL_EXTERNAL_PLATFORM_CONFIG_DIRS:
'/tmp/hostile-egl-platform',
__EGL_VENDOR_LIBRARY_DIRS: '/tmp/hostile-egl-vendor',
VK_LAYER_PATH: '/tmp/hostile-vulkan',
XDG_CONFIG_HOME: '/tmp/hostile-xdg-config-home',
XDG_CONFIG_DIRS: '/tmp/hostile-xdg-config-dirs',
XDG_DATA_HOME: '/tmp/hostile-xdg-data-home',
XDG_DATA_DIRS: '/tmp/hostile-xdg-data-dirs',
...HOSTILE_LOADER_ENVIRONMENT,
...GRAPHICS_SELECTOR_ENVIRONMENT,
},
nativeDir,
'bundled'
)
).toEqual({
PATH: '/usr/sbin:/usr/bin:/sbin:/bin',
SNAP: snapRoot,
SNAP_LIBRARY_PATH: [
'/var/lib/snapd/lib/gl',
'/var/lib/snapd/lib/gl/nvidia',
].join(':'),
SNAP_ARCH: 'amd64',
SNAP_DESKTOP_ARCH_TRIPLET: 'x86_64-linux-gnu',
SNAP_DESKTOP_RUNTIME: path.join(snapRoot, 'gnome-platform'),
...GRAPHICS_SELECTOR_ENVIRONMENT,
GBM_BACKENDS_PATH: [
path.join(
snapRoot,
'graphics',
'usr',
'lib',
'x86_64-linux-gnu',
'gbm'
),
'/var/lib/snapd/lib/gl/gbm',
].join(':'),
LIBGL_DRIVERS_PATH: path.join(
snapRoot,
'graphics',
'usr',
'lib',
'x86_64-linux-gnu',
'dri'
),
LIBVA_DRIVERS_PATH: path.join(
snapRoot,
'graphics',
'usr',
'lib',
'x86_64-linux-gnu',
'dri'
),
__EGL_EXTERNAL_PLATFORM_CONFIG_DIRS: path.join(
snapRoot,
'graphics',
'usr',
'share',
'egl',
'egl_external_platform.d'
),
__EGL_VENDOR_LIBRARY_DIRS: [
'/var/lib/snapd/lib/glvnd/egl_vendor.d',
path.join(
snapRoot,
'graphics',
'usr',
'share',
'glvnd',
'egl_vendor.d'
),
].join(':'),
VK_LAYER_PATH: [
path.join(
snapRoot,
'graphics',
'usr',
'share',
'vulkan',
'implicit_layer.d'
),
path.join(
snapRoot,
'graphics',
'usr',
'share',
'vulkan',
'explicit_layer.d'
),
].join(':'),
XDG_CONFIG_HOME: path.join(snapRoot, 'etc', 'xdg'),
XDG_CONFIG_DIRS: [
path.join(snapRoot, 'etc', 'xdg'),
'/etc/xdg',
].join(':'),
XDG_DATA_HOME: path.join(snapRoot, 'usr', 'share'),
XDG_DATA_DIRS: [
path.join(snapRoot, 'graphics', 'usr', 'share'),
path.join(snapRoot, 'gnome-platform', 'usr', 'share'),
path.join(snapRoot, 'usr', 'share'),
'/usr/share',
].join(':'),
LD_LIBRARY_PATH: [
path.join(nativeDir, 'lib'),
'/var/lib/snapd/lib/gl',
'/var/lib/snapd/lib/gl/nvidia',
path.join(
snapRoot,
'graphics',
'usr',
'lib',
'x86_64-linux-gnu'
),
path.join(
snapRoot,
'graphics',
'usr',
'lib',
'x86_64-linux-gnu',
'vdpau'
),
'/usr/lib/x86_64-linux-gnu',
path.join(
snapRoot,
'gnome-platform',
'lib',
'x86_64-linux-gnu'
),
path.join(
snapRoot,
'gnome-platform',
'usr',
'lib',
'x86_64-linux-gnu'
),
path.join(
snapRoot,
'gnome-platform',
'usr',
'lib',
'x86_64-linux-gnu',
'mesa'
),
path.join(
snapRoot,
'gnome-platform',
'usr',
'lib',
'x86_64-linux-gnu',
'mesa-egl'
),
path.join(
snapRoot,
'gnome-platform',
'usr',
'lib',
'x86_64-linux-gnu',
'dri'
),
path.join(
snapRoot,
'gnome-platform',
'usr',
'lib',
'x86_64-linux-gnu',
'pulseaudio'
),
path.join(snapRoot, 'lib'),
path.join(snapRoot, 'usr', 'lib'),
path.join(snapRoot, 'lib', 'x86_64-linux-gnu'),
path.join(snapRoot, 'usr', 'lib', 'x86_64-linux-gnu'),
].join(':'),
});
});
it.each([
'/tmp/gnome-platform',
'/snap/iptvnator/42/gnome-platform-evil',
'gnome-platform',
])(
'ignores an untrusted Snap desktop runtime declaration: %s',
(declaredDesktopRuntime) => {
const snapRoot = '/snap/iptvnator/42';
const nativeDir = path.join(
snapRoot,
'resources',
'app.asar.unpacked',
'electron-backend',
'native'
);
const helperEnvironment = createLinuxFrameCopyHelperEnvironment(
{
SNAP: snapRoot,
SNAP_DESKTOP_ARCH_TRIPLET: 'hostile-linux-gnu',
SNAP_DESKTOP_RUNTIME: declaredDesktopRuntime,
GBM_BACKENDS_PATH: '/tmp/hostile-gbm',
LIBGL_DRIVERS_PATH: '/tmp/hostile-dri',
LIBVA_DRIVERS_PATH: '/tmp/hostile-va',
__EGL_EXTERNAL_PLATFORM_CONFIG_DIRS:
'/tmp/hostile-egl-platform',
__EGL_VENDOR_LIBRARY_DIRS: '/tmp/hostile-egl-vendor',
VK_LAYER_PATH: '/tmp/hostile-vulkan',
LD_LIBRARY_PATH: '/tmp/hostile-libs',
},
nativeDir,
'bundled'
);
expect(helperEnvironment.LD_LIBRARY_PATH?.split(':')).toEqual([
path.join(nativeDir, 'lib'),
path.join(
snapRoot,
'graphics',
'usr',
'lib',
'x86_64-linux-gnu'
),
path.join(
snapRoot,
'graphics',
'usr',
'lib',
'x86_64-linux-gnu',
'vdpau'
),
'/usr/lib/x86_64-linux-gnu',
path.join(snapRoot, 'lib'),
path.join(snapRoot, 'usr', 'lib'),
path.join(snapRoot, 'lib', 'x86_64-linux-gnu'),
path.join(snapRoot, 'usr', 'lib', 'x86_64-linux-gnu'),
]);
expect(helperEnvironment.LD_LIBRARY_PATH).not.toContain(
declaredDesktopRuntime
);
expect(helperEnvironment.LD_LIBRARY_PATH).not.toContain(
'hostile-linux-gnu'
);
expect(helperEnvironment.SNAP_DESKTOP_RUNTIME).toBeUndefined();
expect(helperEnvironment.SNAP_DESKTOP_ARCH_TRIPLET).toBe(
'x86_64-linux-gnu'
);
expect(helperEnvironment.SNAP_ARCH).toBe('amd64');
}
);
it('does not trust Snap loader paths when nativeDir is outside the declared mount', () => {
expect(
createLinuxFrameCopyHelperEnvironment(
{
PATH: '/usr/bin',
SNAP: '/snap/iptvnator/42',
SNAP_LIBRARY_PATH: '/var/lib/snapd/lib/gl:/tmp/hostile-gl',
LD_AUDIT: '/tmp/audit.so',
LD_LIBRARY_PATH: '/tmp/hostile-libs',
LD_PRELOAD: '/tmp/inject.so',
},
'/opt/iptvnator/native',
'bundled'
)
).toEqual({
PATH: '/usr/bin',
SNAP: '/snap/iptvnator/42',
SNAP_LIBRARY_PATH: '/var/lib/snapd/lib/gl:/tmp/hostile-gl',
LD_LIBRARY_PATH: '/opt/iptvnator/native/lib',
});
});
});
@@ -0,0 +1,281 @@
import path from 'path';
import type { EmbeddedMpvFrameCopyRuntimeMode } from './types';
import { resolveTrustedSnapRoot } from './trusted-snap-root';
const TRUSTED_SNAP_GL_ROOT = '/var/lib/snapd/lib/gl';
const TRUSTED_SNAP_EGL_VENDOR_ROOT = '/var/lib/snapd/lib/glvnd/egl_vendor.d';
const SNAP_DESKTOP_RUNTIME_DIRECTORY = 'gnome-platform';
const SNAP_GRAPHICS_RUNTIME_DIRECTORY = 'graphics';
const SNAP_X64_LIBRARY_TRIPLET = 'x86_64-linux-gnu';
const TRUSTED_SNAP_BASE_LIBRARY_ROOT = '/usr/lib/x86_64-linux-gnu';
const TRUSTED_SNAP_HELPER_PATH = '/usr/sbin:/usr/bin:/sbin:/bin';
const TRUSTED_FLATPAK_APP_ID = 'com.fourgray.iptvnator';
const TRUSTED_FLATPAK_APP_ROOT = '/app';
const TRUSTED_FLATPAK_EGL_EXTERNAL_PLATFORM_CONFIG_DIRS = [
'/etc/egl/egl_external_platform.d',
'/usr/lib/x86_64-linux-gnu/GL/egl/egl_external_platform.d',
'/usr/share/egl/egl_external_platform.d',
].join(':');
const UNSAFE_HELPER_ENVIRONMENT_VARIABLES = [
'BASH_ENV',
'ENV',
'BASHOPTS',
'SHELLOPTS',
'PS4',
'BASH_XTRACEFD',
'CDPATH',
'LD_AUDIT',
'LD_LIBRARY_PATH',
'LD_ORIGIN_PATH',
'LD_PRELOAD',
'__EGL_EXTERNAL_PLATFORM_CONFIG_DIRS',
'__EGL_EXTERNAL_PLATFORM_CONFIG_FILENAMES',
'__EGL_VENDOR_LIBRARY_DIRS',
'__EGL_VENDOR_LIBRARY_FILENAMES',
'GBM_BACKEND',
'GBM_BACKENDS_PATH',
'LIBGL_DRIVERS_PATH',
'MESA_LOADER_DRIVER_OVERRIDE',
'LIBVA_DRIVER_NAME',
'LIBVA_DRIVERS_PATH',
'VDPAU_DRIVER_PATH',
'VK_DRIVER_FILES',
'VK_ICD_FILENAMES',
'VK_ADD_DRIVER_FILES',
'VK_ADD_LAYER_PATH',
'VK_IMPLICIT_LAYER_PATH',
'VK_ADD_IMPLICIT_LAYER_PATH',
'VK_LAYER_PATH',
] as const;
function isPathInside(
parentPath: string,
candidatePath: string,
allowEqual: boolean
): boolean {
const relativePath = path.relative(parentPath, candidatePath);
if (relativePath === '') {
return allowEqual;
}
return (
relativePath !== '..' &&
!relativePath.startsWith(`..${path.sep}`) &&
!path.isAbsolute(relativePath)
);
}
function getTrustedSnapLibraryPaths(
environment: NodeJS.ProcessEnv,
snapRoot: string
): string[] {
const snapLibraryPaths = getTrustedSnapHostGlLibraryPaths(environment);
const graphicsLibraryRoot = path.join(
snapRoot,
SNAP_GRAPHICS_RUNTIME_DIRECTORY,
'usr',
'lib',
SNAP_X64_LIBRARY_TRIPLET
);
const desktopLibraryPaths = getTrustedSnapDesktopLibraryPaths(
environment,
snapRoot
);
return [
...snapLibraryPaths,
graphicsLibraryRoot,
path.join(graphicsLibraryRoot, 'vdpau'),
// The core22 libedit ABI must win over gnome-3-28's libtinfo5 build.
TRUSTED_SNAP_BASE_LIBRARY_ROOT,
...desktopLibraryPaths,
path.join(snapRoot, 'lib'),
path.join(snapRoot, 'usr', 'lib'),
path.join(snapRoot, 'lib', SNAP_X64_LIBRARY_TRIPLET),
path.join(snapRoot, 'usr', 'lib', SNAP_X64_LIBRARY_TRIPLET),
];
}
function getTrustedSnapHostGlLibraryPaths(
environment: NodeJS.ProcessEnv
): string[] {
return (environment.SNAP_LIBRARY_PATH ?? '')
.split(':')
.filter(Boolean)
.filter((libraryPath) => path.isAbsolute(libraryPath))
.map((libraryPath) => path.resolve(libraryPath))
.filter((libraryPath) =>
isPathInside(TRUSTED_SNAP_GL_ROOT, libraryPath, true)
);
}
function getTrustedSnapDesktopLibraryPaths(
environment: NodeJS.ProcessEnv,
snapRoot: string
): string[] {
const desktopRuntime = resolveTrustedSnapDesktopRuntime(
environment,
snapRoot
);
if (!desktopRuntime) {
return [];
}
const desktopLibraryRoot = path.join(
desktopRuntime,
'usr',
'lib',
SNAP_X64_LIBRARY_TRIPLET
);
return [
path.join(desktopRuntime, 'lib', SNAP_X64_LIBRARY_TRIPLET),
desktopLibraryRoot,
path.join(desktopLibraryRoot, 'mesa'),
path.join(desktopLibraryRoot, 'mesa-egl'),
path.join(desktopLibraryRoot, 'dri'),
path.join(desktopLibraryRoot, 'pulseaudio'),
];
}
function resolveTrustedSnapDesktopRuntime(
environment: NodeJS.ProcessEnv,
snapRoot: string
): string | null {
const expectedDesktopRuntime = path.join(
snapRoot,
SNAP_DESKTOP_RUNTIME_DIRECTORY
);
const declaredDesktopRuntime = environment.SNAP_DESKTOP_RUNTIME;
if (
!declaredDesktopRuntime ||
!path.isAbsolute(declaredDesktopRuntime) ||
path.resolve(declaredDesktopRuntime) !== expectedDesktopRuntime
) {
return null;
}
return expectedDesktopRuntime;
}
function isTrustedFlatpakRuntime(
environment: NodeJS.ProcessEnv,
nativeDir: string
): boolean {
return (
environment.FLATPAK_ID === TRUSTED_FLATPAK_APP_ID &&
path.isAbsolute(nativeDir) &&
isPathInside(TRUSTED_FLATPAK_APP_ROOT, path.resolve(nativeDir), false)
);
}
function applyTrustedSnapGraphicsEnvironment(
helperEnvironment: NodeJS.ProcessEnv,
sourceEnvironment: NodeJS.ProcessEnv,
snapRoot: string
): void {
const graphicsRuntime = path.join(
snapRoot,
SNAP_GRAPHICS_RUNTIME_DIRECTORY,
'usr'
);
const graphicsLibraryRoot = path.join(
graphicsRuntime,
'lib',
SNAP_X64_LIBRARY_TRIPLET
);
const graphicsDriRoot = path.join(graphicsLibraryRoot, 'dri');
helperEnvironment.GBM_BACKENDS_PATH = [
path.join(graphicsLibraryRoot, 'gbm'),
path.join(TRUSTED_SNAP_GL_ROOT, 'gbm'),
].join(':');
helperEnvironment.LIBGL_DRIVERS_PATH = graphicsDriRoot;
helperEnvironment.LIBVA_DRIVERS_PATH = graphicsDriRoot;
helperEnvironment.__EGL_EXTERNAL_PLATFORM_CONFIG_DIRS = path.join(
graphicsRuntime,
'share',
'egl',
'egl_external_platform.d'
);
helperEnvironment.__EGL_VENDOR_LIBRARY_DIRS = [
TRUSTED_SNAP_EGL_VENDOR_ROOT,
path.join(graphicsRuntime, 'share', 'glvnd', 'egl_vendor.d'),
].join(':');
helperEnvironment.VK_LAYER_PATH = [
path.join(graphicsRuntime, 'share', 'vulkan', 'implicit_layer.d'),
path.join(graphicsRuntime, 'share', 'vulkan', 'explicit_layer.d'),
].join(':');
const snapConfigRoot = path.join(snapRoot, 'etc', 'xdg');
const snapDataRoot = path.join(snapRoot, 'usr', 'share');
const desktopRuntime = resolveTrustedSnapDesktopRuntime(
sourceEnvironment,
snapRoot
);
const snapLibraryPaths =
getTrustedSnapHostGlLibraryPaths(sourceEnvironment);
if (snapLibraryPaths.length > 0) {
helperEnvironment.SNAP_LIBRARY_PATH = snapLibraryPaths.join(':');
} else {
delete helperEnvironment.SNAP_LIBRARY_PATH;
}
helperEnvironment.SNAP_ARCH = 'amd64';
helperEnvironment.SNAP_DESKTOP_ARCH_TRIPLET = SNAP_X64_LIBRARY_TRIPLET;
if (desktopRuntime) {
helperEnvironment.SNAP_DESKTOP_RUNTIME = desktopRuntime;
} else {
delete helperEnvironment.SNAP_DESKTOP_RUNTIME;
}
helperEnvironment.XDG_CONFIG_HOME = snapConfigRoot;
helperEnvironment.XDG_CONFIG_DIRS = [snapConfigRoot, '/etc/xdg'].join(':');
helperEnvironment.XDG_DATA_HOME = snapDataRoot;
helperEnvironment.XDG_DATA_DIRS = [
path.join(graphicsRuntime, 'share'),
...(desktopRuntime ? [path.join(desktopRuntime, 'usr', 'share')] : []),
snapDataRoot,
'/usr/share',
].join(':');
}
/**
* Builds the loader environment shared by the bounded startup probe and each
* real Linux helper session. The validated package profile is authoritative:
* system packages use the system loader, while bundled packages start at
* native/lib and add only fixed trusted Snap or Flatpak graphics roots.
*/
export function createLinuxFrameCopyHelperEnvironment(
environment: NodeJS.ProcessEnv,
nativeDir: string,
runtimeMode: EmbeddedMpvFrameCopyRuntimeMode
): NodeJS.ProcessEnv {
const helperEnvironment = { ...environment };
for (const variableName of UNSAFE_HELPER_ENVIRONMENT_VARIABLES) {
delete helperEnvironment[variableName];
}
for (const variableName of Object.keys(helperEnvironment)) {
if (variableName.startsWith('BASH_FUNC_')) {
delete helperEnvironment[variableName];
}
}
if (runtimeMode === 'system') {
return helperEnvironment;
}
const libraryPaths = [path.join(nativeDir, 'lib')];
const trustedSnapRoot = resolveTrustedSnapRoot(environment, nativeDir);
if (trustedSnapRoot) {
helperEnvironment.PATH = TRUSTED_SNAP_HELPER_PATH;
libraryPaths.push(
...getTrustedSnapLibraryPaths(environment, trustedSnapRoot)
);
applyTrustedSnapGraphicsEnvironment(
helperEnvironment,
environment,
trustedSnapRoot
);
} else if (isTrustedFlatpakRuntime(environment, nativeDir)) {
helperEnvironment.__EGL_EXTERNAL_PLATFORM_CONFIG_DIRS =
TRUSTED_FLATPAK_EGL_EXTERNAL_PLATFORM_CONFIG_DIRS;
}
helperEnvironment.LD_LIBRARY_PATH = [...new Set(libraryPaths)].join(':');
return helperEnvironment;
}
@@ -0,0 +1,338 @@
import { SUCCESS_OUTPUT } from './runtime.spec-data';
import { createFixture } from './runtime-fixtures.test-helpers';
import {
createRuntimeTestContext,
type RuntimeTestContext,
} from './runtime-harness.test-helpers';
describe('embedded-mpv frame-copy helper failures', () => {
let context: RuntimeTestContext;
beforeEach(() => {
context = createRuntimeTestContext();
});
afterEach(() => {
context.dispose();
});
it('converts a thrown spawn failure into a stable result', () => {
const fixture = createFixture(context.rootDir);
context.spawnRuntimeProbe.mockImplementation(() => {
throw new Error('spawn exploded');
});
expect(context.createProbe()(fixture.helperPath)).toEqual({
usable: false,
reason: 'helper-probe-spawn-error',
});
});
it.each([
{
label: 'timeout',
spawnResult: {
status: null,
signal: 'SIGTERM',
stdout: '',
stderr: '',
error: Object.assign(new Error('timed out'), {
code: 'ETIMEDOUT',
}),
},
reason: 'helper-probe-timeout',
},
{
label: 'spawn error',
spawnResult: {
status: null,
signal: null,
stdout: '',
stderr: '',
error: Object.assign(new Error('spawn failed'), {
code: 'EACCES',
}),
},
reason: 'helper-probe-spawn-error',
},
{
label: 'nonzero exit',
spawnResult: {
status: 1,
signal: null,
stdout: '{"protocol":1,"usable":false,"reason":"egl-unavailable"}\n',
stderr: '',
},
reason: 'helper-probe-failed',
},
{
label: 'signal',
spawnResult: {
status: null,
signal: 'SIGKILL',
stdout: '',
stderr: '',
},
reason: 'helper-probe-signaled',
},
{
label: 'invalid JSON',
spawnResult: {
status: 0,
signal: null,
stdout: 'not-json\n',
stderr: '',
},
reason: 'helper-probe-invalid-output',
},
{
label: 'multiple lines',
spawnResult: {
status: 0,
signal: null,
stdout: `${SUCCESS_OUTPUT}${SUCCESS_OUTPUT}`,
stderr: '',
},
reason: 'helper-probe-invalid-output',
},
{
label: 'wrong protocol',
spawnResult: {
status: 0,
signal: null,
stdout: '{"protocol":2,"usable":true,"libmpv":"2.3","renderApi":"egl"}\n',
stderr: '',
},
reason: 'helper-probe-protocol-mismatch',
},
{
label: 'unusable success',
spawnResult: {
status: 0,
signal: null,
stdout: '{"protocol":1,"usable":false,"reason":"egl-unavailable"}\n',
stderr: '',
},
reason: 'helper-probe-unusable',
},
])('fails closed on helper $label', ({ spawnResult, reason }) => {
const fixture = createFixture(context.rootDir);
context.spawnRuntimeProbe.mockReturnValue(spawnResult);
expect(context.createProbe()(fixture.helperPath)).toEqual(
expect.objectContaining({ usable: false, reason })
);
});
it.each([
'mpv-create-failed',
'mpv-initialize-failed',
'gl-context-create-failed',
'gl-context-bind-failed',
'mpv-render-context-failed',
'shared-memory-create-failed',
'shared-memory-initialize-failed',
])('preserves the allowlisted helper reason %s', (helperReason) => {
const fixture = createFixture(context.rootDir);
const helperDetail =
helperReason === 'mpv-create-failed'
? undefined
: 'EGL initialization failed: display unavailable';
context.spawnRuntimeProbe.mockReturnValue({
status: 1,
signal: null,
stdout: `${JSON.stringify({
protocol: 1,
usable: false,
reason: helperReason,
...(helperDetail ? { detail: helperDetail } : {}),
})}\n`,
stderr: '',
});
expect(context.createProbe()(fixture.helperPath)).toEqual({
usable: false,
reason: 'helper-probe-failed',
helperReason,
...(helperDetail ? { helperDetail } : {}),
});
});
it.each([
['one printable ASCII character', 'x'],
['1024 printable ASCII characters', 'x'.repeat(1024)],
])('preserves %s as helper detail', (_label, helperDetail) => {
const fixture = createFixture(context.rootDir);
context.spawnRuntimeProbe.mockReturnValue({
status: 1,
signal: null,
stdout: `${JSON.stringify({
protocol: 1,
usable: false,
reason: 'gl-context-create-failed',
detail: helperDetail,
})}\n`,
stderr: '',
});
expect(context.createProbe()(fixture.helperPath)).toEqual({
usable: false,
reason: 'helper-probe-failed',
helperReason: 'gl-context-create-failed',
helperDetail,
});
});
it.each([
['malformed JSON', 'not-json\n'],
[
'multiple lines',
'{"protocol":1,"usable":false,"reason":"mpv-create-failed"}\nignored\n',
],
[
'wrong protocol',
'{"protocol":2,"usable":false,"reason":"mpv-create-failed"}\n',
],
[
'wrong usable value',
'{"protocol":1,"usable":true,"reason":"mpv-create-failed"}\n',
],
[
'non-allowlisted reason',
'{"protocol":1,"usable":false,"reason":"loader-injected"}\n',
],
[
'unexpected field',
'{"protocol":1,"usable":false,"reason":"mpv-create-failed","extra":true}\n',
],
[
'invalid detail',
'{"protocol":1,"usable":false,"reason":"mpv-create-failed","detail":1}\n',
],
])('does not propagate a helper reason from %s', (_label, stdout) => {
const fixture = createFixture(context.rootDir);
context.spawnRuntimeProbe.mockReturnValue({
status: 1,
signal: null,
stdout,
stderr: '',
});
expect(context.createProbe()(fixture.helperPath)).toEqual({
usable: false,
reason: 'helper-probe-failed',
});
});
it.each([
['empty', ''],
['control character', 'EGL\tfailure'],
['trailing line feed', 'EGL failure\n'],
['DEL character', `EGL${String.fromCharCode(0x7f)}failure`],
['non-ASCII character', 'EGL échoué'],
['1025 characters', 'x'.repeat(1025)],
])(
'does not propagate any helper fields from %s detail',
(_label, detail) => {
const fixture = createFixture(context.rootDir);
context.spawnRuntimeProbe.mockReturnValue({
status: 1,
signal: null,
stdout: `${JSON.stringify({
protocol: 1,
usable: false,
reason: 'gl-context-create-failed',
detail,
})}\n`,
stderr: '',
});
expect(context.createProbe()(fixture.helperPath)).toEqual({
usable: false,
reason: 'helper-probe-failed',
});
}
);
it('does not trace helper stderr without the exact player trace flag', () => {
const fixture = createFixture(context.rootDir);
context.spawnRuntimeProbe.mockReturnValue({
status: 1,
signal: null,
stdout: '{"protocol":1,"usable":false,"reason":"gl-context-create-failed"}\n',
stderr: 'libEGL debug output\n',
});
expect(context.createProbe()(fixture.helperPath)).toEqual({
usable: false,
reason: 'helper-probe-failed',
helperReason: 'gl-context-create-failed',
});
expect(context.writeRuntimeProbeStderr).not.toHaveBeenCalled();
});
it('traces helper stderr as one JSON-escaped line when player tracing is enabled', () => {
const fixture = createFixture(context.rootDir);
const helperStderr =
'libEGL warning: vendor "mesa"\nfailed path: C:\\driver';
context.spawnRuntimeProbe.mockReturnValue({
status: 1,
signal: null,
stdout: '{"protocol":1,"usable":false,"reason":"gl-context-create-failed"}\n',
stderr: helperStderr,
});
expect(
context.createProbe({
env: {
PATH: '/usr/bin',
IPTVNATOR_TRACE_PLAYER: '1',
},
})(fixture.helperPath)
).toEqual({
usable: false,
reason: 'helper-probe-failed',
helperReason: 'gl-context-create-failed',
});
expect(context.writeRuntimeProbeStderr).toHaveBeenCalledWith(
`${JSON.stringify({
event: 'embedded-mpv-helper-runtime-probe-stderr',
stderr: helperStderr,
truncated: false,
})}\n`
);
expect(context.writeRuntimeProbeStderr).toHaveBeenCalledTimes(1);
expect(
context.writeRuntimeProbeStderr.mock.calls[0][0].split('\n')
).toHaveLength(2);
});
it('bounds traced helper stderr to 16384 characters and reports truncation', () => {
const fixture = createFixture(context.rootDir);
const helperStderr = `${'x'.repeat(16_384)}discarded`;
context.spawnRuntimeProbe.mockReturnValue({
status: 1,
signal: null,
stdout: '{"protocol":1,"usable":false,"reason":"gl-context-create-failed"}\n',
stderr: helperStderr,
});
context.createProbe({
env: {
PATH: '/usr/bin',
IPTVNATOR_TRACE_PLAYER: '1',
},
})(fixture.helperPath);
const trace = JSON.parse(
context.writeRuntimeProbeStderr.mock.calls[0][0]
);
expect(trace).toEqual({
event: 'embedded-mpv-helper-runtime-probe-stderr',
stderr: 'x'.repeat(16_384),
truncated: true,
});
expect(trace.stderr).toHaveLength(16_384);
expect(context.writeRuntimeProbeStderr).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,203 @@
import type { Stats } from 'fs';
import path from 'path';
import { createLinuxFrameCopyHelperLaunch } from '../embedded-mpv-frame-copy-runtime';
function fakeStat(
kind: 'directory' | 'file' | 'symlink'
): Pick<Stats, 'isDirectory' | 'isFile' | 'isSymbolicLink'> {
return {
isDirectory: () => kind === 'directory',
isFile: () => kind === 'file',
isSymbolicLink: () => kind === 'symlink',
};
}
describe('createLinuxFrameCopyHelperLaunch', () => {
const helperArgs = ['--runtime-probe'];
it.each([
['system', '/opt/iptvnator/native/iptvnator_mpv_helper'],
[
'bundled',
'/tmp/.mount-IPTVnator/resources/app.asar.unpacked/electron-backend/native/iptvnator_mpv_helper',
],
] as const)(
'launches a non-Snap %s helper directly',
(runtimeMode, helperPath) => {
expect(
createLinuxFrameCopyHelperLaunch({
environment: {
PATH: '/usr/bin',
LD_LIBRARY_PATH: '/tmp/hostile',
},
helperPath,
helperArgs,
runtimeMode,
})
).toEqual({
usable: true,
command: helperPath,
args: helperArgs,
env:
runtimeMode === 'system'
? { PATH: '/usr/bin' }
: {
PATH: '/usr/bin',
LD_LIBRARY_PATH: path.join(
path.dirname(helperPath),
'lib'
),
},
});
}
);
it('launches a trusted Snap helper through the connected provider wrapper', () => {
const snapRoot = '/snap/iptvnator/42';
const nativeDir = path.join(
snapRoot,
'resources',
'app.asar.unpacked',
'electron-backend',
'native'
);
const helperPath = path.join(nativeDir, 'iptvnator_mpv_helper');
const graphicsRoot = path.join(snapRoot, 'graphics');
const wrapperPath = path.join(
graphicsRoot,
'bin',
'graphics-core22-provider-wrapper'
);
const lstatSync = jest.fn((candidatePath: string) => {
if (candidatePath === graphicsRoot) {
return fakeStat('directory') as Stats;
}
if (candidatePath === wrapperPath) {
return fakeStat('file') as Stats;
}
throw Object.assign(new Error('missing'), { code: 'ENOENT' });
});
const accessSync = jest.fn();
expect(
createLinuxFrameCopyHelperLaunch({
environment: {
PATH: '/snap/bin:/usr/bin',
SNAP: snapRoot,
},
helperPath,
helperArgs,
runtimeMode: 'bundled',
fileSystem: { lstatSync, accessSync },
})
).toEqual({
usable: true,
command: wrapperPath,
args: [helperPath, ...helperArgs],
env: expect.objectContaining({
PATH: '/usr/sbin:/usr/bin:/sbin:/bin',
SNAP: snapRoot,
LD_LIBRARY_PATH: expect.stringContaining(
path.join(nativeDir, 'lib')
),
}),
});
expect(lstatSync).toHaveBeenCalledWith(graphicsRoot);
expect(lstatSync).toHaveBeenCalledWith(wrapperPath);
expect(accessSync).toHaveBeenCalledWith(
wrapperPath,
expect.any(Number)
);
});
it.each([
['missing mount', 'missing', 'file'],
['symlink mount', 'symlink', 'file'],
['missing wrapper', 'directory', 'missing'],
['symlink wrapper', 'directory', 'symlink'],
] as const)(
'fails closed for a trusted Snap with a %s',
(_label, mountKind, wrapperKind) => {
const snapRoot = '/snap/iptvnator/42';
const nativeDir = path.join(
snapRoot,
'resources',
'app.asar.unpacked',
'electron-backend',
'native'
);
const helperPath = path.join(nativeDir, 'iptvnator_mpv_helper');
const graphicsRoot = path.join(snapRoot, 'graphics');
const wrapperPath = path.join(
graphicsRoot,
'bin',
'graphics-core22-provider-wrapper'
);
expect(
createLinuxFrameCopyHelperLaunch({
environment: { SNAP: snapRoot },
helperPath,
helperArgs,
runtimeMode: 'bundled',
fileSystem: {
lstatSync: (candidatePath) => {
const kind =
candidatePath === graphicsRoot
? mountKind
: wrapperKind;
if (kind === 'missing') {
throw Object.assign(new Error('missing'), {
code: 'ENOENT',
});
}
return fakeStat(kind) as Stats;
},
accessSync: () => undefined,
},
})
).toEqual({
usable: false,
reason: 'snap-graphics-provider-unavailable',
});
expect(wrapperPath).toContain('/graphics/bin/');
}
);
it('fails closed when the provider wrapper is not executable', () => {
const snapRoot = '/var/lib/snapd/snap/iptvnator/42';
const nativeDir = path.join(
snapRoot,
'resources',
'app.asar.unpacked',
'electron-backend',
'native'
);
expect(
createLinuxFrameCopyHelperLaunch({
environment: { SNAP: snapRoot },
helperPath: path.join(nativeDir, 'iptvnator_mpv_helper'),
helperArgs,
runtimeMode: 'bundled',
fileSystem: {
lstatSync: (candidatePath) =>
fakeStat(
candidatePath.endsWith('/graphics')
? 'directory'
: 'file'
) as Stats,
accessSync: () => {
throw Object.assign(new Error('denied'), {
code: 'EACCES',
});
},
},
})
).toEqual({
usable: false,
reason: 'snap-graphics-provider-unavailable',
});
});
});
@@ -0,0 +1,103 @@
import {
accessSync as nodeAccessSync,
constants as fileSystemConstants,
lstatSync as nodeLstatSync,
} from 'fs';
import type * as nodeFileSystem from 'fs';
import path from 'path';
import { createLinuxFrameCopyHelperEnvironment } from './helper-environment';
import { resolveTrustedSnapRoot } from './trusted-snap-root';
import type {
EmbeddedMpvFrameCopyRuntimeFailureReason,
EmbeddedMpvFrameCopyRuntimeMode,
} from './types';
export interface LinuxFrameCopyHelperLaunchFileSystem {
lstatSync(filePath: string): nodeFileSystem.Stats;
accessSync(filePath: string, mode: number): void;
}
interface CreateLinuxFrameCopyHelperLaunchOptions {
environment: NodeJS.ProcessEnv;
helperPath: string;
helperArgs: string[];
runtimeMode: EmbeddedMpvFrameCopyRuntimeMode;
fileSystem?: LinuxFrameCopyHelperLaunchFileSystem;
}
export type LinuxFrameCopyHelperLaunch =
| {
usable: true;
command: string;
args: string[];
env: NodeJS.ProcessEnv;
}
| {
usable: false;
reason: EmbeddedMpvFrameCopyRuntimeFailureReason;
};
export function createLinuxFrameCopyHelperLaunch(
options: CreateLinuxFrameCopyHelperLaunchOptions
): LinuxFrameCopyHelperLaunch {
const nativeDir = path.dirname(options.helperPath);
const env = createLinuxFrameCopyHelperEnvironment(
options.environment,
nativeDir,
options.runtimeMode
);
const trustedSnapRoot =
options.runtimeMode === 'bundled'
? resolveTrustedSnapRoot(options.environment, nativeDir)
: null;
if (!trustedSnapRoot) {
return {
usable: true,
command: options.helperPath,
args: options.helperArgs,
env,
};
}
const graphicsRoot = path.join(trustedSnapRoot, 'graphics');
const providerWrapperPath = path.join(
graphicsRoot,
'bin',
'graphics-core22-provider-wrapper'
);
const fileSystem = options.fileSystem ?? {
lstatSync: nodeLstatSync,
accessSync: nodeAccessSync,
};
try {
const graphicsRootStat = fileSystem.lstatSync(graphicsRoot);
const providerWrapperStat = fileSystem.lstatSync(providerWrapperPath);
if (
!graphicsRootStat.isDirectory() ||
graphicsRootStat.isSymbolicLink() ||
!providerWrapperStat.isFile() ||
providerWrapperStat.isSymbolicLink()
) {
return {
usable: false,
reason: 'snap-graphics-provider-unavailable',
};
}
fileSystem.accessSync(
providerWrapperPath,
fileSystemConstants.R_OK | fileSystemConstants.X_OK
);
} catch {
return {
usable: false,
reason: 'snap-graphics-provider-unavailable',
};
}
return {
usable: true,
command: providerWrapperPath,
args: [options.helperPath, ...options.helperArgs],
env,
};
}
@@ -0,0 +1,149 @@
import { unlinkSync, writeFileSync } from 'fs';
import {
cloneManifest,
createFixture,
mirrorBundledManifestFields,
writeManifest,
} from './runtime-fixtures.test-helpers';
import {
createRuntimeTestContext,
type RuntimeTestContext,
} from './runtime-harness.test-helpers';
describe('embedded-mpv frame-copy packaged manifest policy', () => {
let context: RuntimeTestContext;
beforeEach(() => {
context = createRuntimeTestContext();
});
afterEach(() => {
context.dispose();
});
it('rejects a missing or malformed manifest without throwing', () => {
const missing = createFixture(context.rootDir);
unlinkSync(missing.manifestPath);
expect(context.createProbe()(missing.helperPath)).toEqual({
usable: false,
reason: 'runtime-manifest-missing',
});
const malformed = createFixture(context.rootDir, 'portable');
writeFileSync(malformed.manifestPath, '{broken\n', { mode: 0o644 });
expect(context.createProbe()(malformed.helperPath)).toEqual({
usable: false,
reason: 'runtime-manifest-invalid',
});
expect(context.spawnRuntimeProbe).not.toHaveBeenCalled();
});
it.each([
['origin', 'system-libmpv-frame-copy'],
['arch', 'arm64'],
['runtimeMode', 'system'],
['targets', ['deb']],
['sourceArchive', null],
['unexpectedField', true],
])('rejects a bundled profile mismatch in %s', (field, value) => {
const fixture = createFixture(context.rootDir, 'portable');
const manifest = cloneManifest(fixture.manifest);
manifest[field] = value;
writeManifest(fixture.manifestPath, manifest);
expect(context.createProbe()(fixture.helperPath)).toEqual({
usable: false,
reason: 'runtime-manifest-invalid',
});
expect(context.spawnRuntimeProbe).not.toHaveBeenCalled();
});
it.each([
['system', ['deb', 'pacman']],
['portable', ['appimage']],
] as const)(
'rejects an allowed subset of the exact %s profile targets',
(profile, targets) => {
const fixture = createFixture(context.rootDir, profile);
const manifest = cloneManifest(fixture.manifest);
manifest.targets = targets;
writeManifest(fixture.manifestPath, manifest);
expect(context.createProbe()(fixture.helperPath)).toEqual({
usable: false,
reason: 'runtime-manifest-invalid',
});
expect(context.spawnRuntimeProbe).not.toHaveBeenCalled();
}
);
it('rejects a bundled closure dependency outside the deterministic system allowlist', () => {
const fixture = createFixture(context.rootDir, 'portable');
const manifest = cloneManifest(fixture.manifest);
const closure = manifest.runtimeDependencyClosure as {
entries: Array<{ needed: string[] }>;
externalDependencies: string[];
};
closure.entries[0].needed = ['libambient-only.so.1'];
closure.externalDependencies = ['libambient-only.so.1'];
mirrorBundledManifestFields(manifest);
writeManifest(fixture.manifestPath, manifest);
expect(context.createProbe()(fixture.helperPath)).toEqual({
usable: false,
reason: 'runtime-manifest-invalid',
});
expect(context.spawnRuntimeProbe).not.toHaveBeenCalled();
});
it('requires externalDependencies to exactly equal the sorted external closure', () => {
const fixture = createFixture(context.rootDir, 'portable');
const manifest = cloneManifest(fixture.manifest);
const closure = manifest.runtimeDependencyClosure as {
entries: Array<{ needed: string[] }>;
externalDependencies: string[];
};
closure.entries[0].needed = ['libEGL.so.1', 'libc.so.6'];
closure.externalDependencies = [];
mirrorBundledManifestFields(manifest);
writeManifest(fixture.manifestPath, manifest);
expect(context.createProbe()(fixture.helperPath)).toEqual({
usable: false,
reason: 'runtime-manifest-invalid',
});
expect(context.spawnRuntimeProbe).not.toHaveBeenCalled();
});
it('accepts only the exact deterministic external-system library declaration', () => {
const fixture = createFixture(context.rootDir, 'portable');
const manifest = cloneManifest(fixture.manifest);
(manifest.externalSystemLibraries as unknown[]).pop();
mirrorBundledManifestFields(manifest);
writeManifest(fixture.manifestPath, manifest);
expect(context.createProbe()(fixture.helperPath)).toEqual({
usable: false,
reason: 'runtime-manifest-invalid',
});
expect(context.spawnRuntimeProbe).not.toHaveBeenCalled();
});
it('accepts bundled dependencies on declared system interfaces and the glibc toolchain', () => {
const fixture = createFixture(context.rootDir, 'portable');
const manifest = cloneManifest(fixture.manifest);
const closure = manifest.runtimeDependencyClosure as {
entries: Array<{ needed: string[] }>;
externalDependencies: string[];
};
closure.entries[0].needed = ['libEGL.so.1', 'libc.so.6'];
closure.externalDependencies = ['libEGL.so.1', 'libc.so.6'];
mirrorBundledManifestFields(manifest);
writeManifest(fixture.manifestPath, manifest);
expect(context.createProbe()(fixture.helperPath)).toEqual(
expect.objectContaining({ usable: true, runtimeMode: 'bundled' })
);
expect(context.spawnRuntimeProbe).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,267 @@
import { isDeepStrictEqual } from 'util';
import {
BASE_MANIFEST_FIELDS,
BUNDLED_MANIFEST_FIELDS,
DEVELOPMENT_MANIFEST_FIELDS,
EXPECTED_ARTIFACTS,
EXPECTED_DEVELOPMENT_ARTIFACTS,
EXPECTED_DEVELOPMENT_PROCESS_ISOLATION,
EXPECTED_PROCESS_ISOLATION,
PROFILE_CONTRACTS,
validateLinuxSourceArchiveBinding,
SYSTEM_PACKAGE_DEPENDENCIES,
VERSIONED_LIBMPV_PATTERN,
} from './contracts';
import { validateRuntimeClosure } from './runtime-closure-validator';
import { validateSourceRuntimePolicy } from './source-runtime-validator';
import type { RuntimeProfile, ValidManifest, ValidationResult } from './types';
import {
hasExactFields,
isObject,
validateRuntimeFiles,
validateTargets,
validationFailure,
} from './validation-primitives';
export function validatePackagedManifest(
manifest: Record<string, unknown>
): ValidationResult<ValidManifest> {
if (
manifest.schemaVersion !== 1 ||
manifest.platform !== 'linux' ||
manifest.arch !== 'x64' ||
typeof manifest.profile !== 'string' ||
!Object.prototype.hasOwnProperty.call(
PROFILE_CONTRACTS,
manifest.profile
)
) {
return validationFailure('runtime-manifest-invalid');
}
const profile = manifest.profile as RuntimeProfile;
const contract = PROFILE_CONTRACTS[profile];
if (
manifest.origin !== contract.origin ||
manifest.runtimeMode !== contract.runtimeMode ||
!hasExactFields(
manifest,
contract.runtimeMode === 'bundled'
? BUNDLED_MANIFEST_FIELDS
: BASE_MANIFEST_FIELDS
) ||
typeof manifest.generatedAt !== 'string' ||
manifest.generatedAt.trim() === '' ||
Number.isNaN(Date.parse(manifest.generatedAt)) ||
!validateTargets(manifest.targets, contract.targets) ||
!isDeepStrictEqual(manifest.artifacts, EXPECTED_ARTIFACTS) ||
!isDeepStrictEqual(
manifest.processIsolation,
EXPECTED_PROCESS_ISOLATION
) ||
manifest.nativeViewFallback !== 'process-isolated mpv --wid' ||
typeof manifest.libmpvSoname !== 'string' ||
!VERSIONED_LIBMPV_PATTERN.test(manifest.libmpvSoname)
) {
return validationFailure('runtime-manifest-invalid');
}
if (contract.runtimeMode === 'system') {
if (
!isDeepStrictEqual(
manifest.packageDependencies,
SYSTEM_PACKAGE_DEPENDENCIES
) ||
!isDeepStrictEqual(manifest.runtimeFiles, []) ||
manifest.runtimeTotalBytes !== 0
) {
return validationFailure('runtime-manifest-invalid');
}
return {
value: {
profile,
runtimeMode: 'system',
runtimeFiles: [],
},
};
}
const runtimeFiles = validateRuntimeFiles(manifest.runtimeFiles);
if (
!runtimeFiles ||
!isDeepStrictEqual(manifest.packageDependencies, {}) ||
!runtimeFiles.some(({ name }) => name === 'libmpv.so') ||
!runtimeFiles.some(({ name }) => name === manifest.libmpvSoname) ||
manifest.runtimeTotalBytes !==
runtimeFiles.reduce(
(total, runtimeFile) => total + runtimeFile.size,
0
) ||
!validateRuntimeClosure(
manifest.runtimeDependencyClosure,
runtimeFiles,
manifest.libmpvSoname,
manifest.externalSystemLibraries
) ||
validateLinuxSourceArchiveBinding(manifest.sourceArchive).length !==
0 ||
!validateSourceRuntimePolicy(
manifest.sourceRuntime,
runtimeFiles,
manifest.runtimeDependencyClosure,
manifest.externalSystemLibraries
)
) {
return validationFailure('runtime-manifest-invalid');
}
return {
value: {
profile,
runtimeMode: 'bundled',
runtimeFiles,
},
};
}
function validateSystemDevelopmentSource(
value: unknown,
buildInputMode: 'system-dev' | 'system-build-inputs'
): boolean {
if (buildInputMode === 'system-dev') {
return isDeepStrictEqual(value, {
linuxBackend: 'process-isolated mpv --wid',
warning: 'Development-only unmanaged system libmpv toolchain.',
});
}
if (
!isObject(value) ||
!hasExactFields(value, [
'buildInputs',
'linuxBackend',
'sourceDistribution',
]) ||
value.linuxBackend !== 'process-isolated mpv --wid' ||
typeof value.sourceDistribution !== 'string' ||
value.sourceDistribution.trim() === '' ||
!isObject(value.buildInputs) ||
!hasExactFields(value.buildInputs, ['libmpvDevPackage', 'mpvPackage'])
) {
return false;
}
return ['libmpvDevPackage', 'mpvPackage'].every((packageField) => {
const packageName = value.buildInputs[packageField];
return typeof packageName === 'string' && packageName.trim().length > 0;
});
}
export function validateDevelopmentManifest(
manifest: Record<string, unknown>
): ValidationResult<ValidManifest> {
const buildInputMode = manifest.buildInputMode;
if (
!hasExactFields(manifest, DEVELOPMENT_MANIFEST_FIELDS) ||
manifest.schemaVersion !== 1 ||
manifest.origin !== 'linux-frame-copy-build' ||
manifest.platform !== 'linux' ||
manifest.arch !== 'x64' ||
typeof manifest.generatedAt !== 'string' ||
manifest.generatedAt.trim() === '' ||
Number.isNaN(Date.parse(manifest.generatedAt)) ||
!['system-dev', 'system-build-inputs', 'bundled-runtime'].includes(
String(buildInputMode)
) ||
!isDeepStrictEqual(manifest.allowedPackageRuntimeModes, [
'system',
'bundled',
]) ||
!isDeepStrictEqual(
manifest.artifacts,
EXPECTED_DEVELOPMENT_ARTIFACTS
) ||
!isDeepStrictEqual(
manifest.processIsolation,
EXPECTED_DEVELOPMENT_PROCESS_ISOLATION
) ||
manifest.nativeViewFallback !== 'process-isolated mpv --wid'
) {
return validationFailure('runtime-manifest-invalid');
}
if (
buildInputMode === 'system-dev' ||
buildInputMode === 'system-build-inputs'
) {
if (
manifest.sourceRuntimeValidated !== false ||
!isDeepStrictEqual(manifest.packageRuntimeAvailability, {
system: false,
bundled: false,
}) ||
manifest.libmpvSoname !== null ||
!isDeepStrictEqual(manifest.runtimeFiles, []) ||
manifest.runtimeTotalBytes !== 0 ||
manifest.sourceArchive !== null ||
!validateSystemDevelopmentSource(
manifest.sourceRuntime,
buildInputMode
)
) {
return validationFailure('runtime-manifest-invalid');
}
return {
value: {
profile: 'development',
runtimeMode: 'system',
runtimeFiles: [],
},
};
}
const sourceRuntime = manifest.sourceRuntime;
const runtimeFiles = validateRuntimeFiles(manifest.runtimeFiles);
if (
buildInputMode !== 'bundled-runtime' ||
manifest.sourceRuntimeValidated !== true ||
!isDeepStrictEqual(manifest.packageRuntimeAvailability, {
system: true,
bundled: true,
}) ||
typeof manifest.libmpvSoname !== 'string' ||
!VERSIONED_LIBMPV_PATTERN.test(manifest.libmpvSoname) ||
!runtimeFiles ||
!runtimeFiles.some(({ name }) => name === 'libmpv.so') ||
!runtimeFiles.some(({ name }) => name === manifest.libmpvSoname) ||
manifest.runtimeTotalBytes !==
runtimeFiles.reduce(
(total, runtimeFile) => total + runtimeFile.size,
0
) ||
!isObject(sourceRuntime) ||
(manifest.sourceArchive !== null &&
validateLinuxSourceArchiveBinding(manifest.sourceArchive).length !==
0) ||
!validateRuntimeClosure(
sourceRuntime.runtimeDependencyClosure,
runtimeFiles,
manifest.libmpvSoname,
sourceRuntime.externalSystemLibraries
) ||
!validateSourceRuntimePolicy(
sourceRuntime,
runtimeFiles,
sourceRuntime.runtimeDependencyClosure,
sourceRuntime.externalSystemLibraries
)
) {
return validationFailure('runtime-manifest-invalid');
}
return {
value: {
profile: 'development',
runtimeMode: 'bundled',
runtimeFiles,
},
};
}
@@ -0,0 +1,246 @@
import {
chmodSync,
constants as fsConstants,
mkdirSync,
readFileSync,
symlinkSync,
unlinkSync,
writeFileSync,
} from 'fs';
import path from 'path';
import type { RuntimeFile, RuntimeFixture } from './runtime.spec-data';
import {
cloneManifest,
createFixture,
writeManifest,
} from './runtime-fixtures.test-helpers';
import {
createRuntimeTestContext,
type RuntimeTestContext,
} from './runtime-harness.test-helpers';
describe('embedded-mpv frame-copy package integrity', () => {
let context: RuntimeTestContext;
beforeEach(() => {
context = createRuntimeTestContext();
});
afterEach(() => {
context.dispose();
});
it('rejects system manifests with a private library directory', () => {
const fixture = createFixture(context.rootDir);
mkdirSync(path.join(fixture.nativeDir, 'lib'));
expect(context.createProbe()(fixture.helperPath)).toEqual({
usable: false,
reason: 'runtime-library-directory-invalid',
});
});
it.each([
{
label: 'missing addon',
mutate(fixture: RuntimeFixture) {
unlinkSync(path.join(fixture.nativeDir, 'embedded_mpv.node'));
},
reason: 'runtime-artifact-missing',
},
{
label: 'non-executable helper',
mutate(fixture: RuntimeFixture) {
chmodSync(fixture.helperPath, 0o644);
},
reason: 'runtime-artifact-invalid',
},
{
label: 'setuid helper',
mutate(fixture: RuntimeFixture) {
chmodSync(fixture.helperPath, 0o4755);
},
reason: 'runtime-artifact-invalid',
},
{
label: 'setgid helper',
mutate(fixture: RuntimeFixture) {
chmodSync(fixture.helperPath, 0o2755);
},
reason: 'runtime-artifact-invalid',
},
{
label: 'sticky helper',
mutate(fixture: RuntimeFixture) {
chmodSync(fixture.helperPath, 0o1755);
},
reason: 'runtime-artifact-invalid',
},
{
label: 'wrong reader mode',
mutate(fixture: RuntimeFixture) {
chmodSync(
path.join(
fixture.nativeDir,
'embedded_mpv_frame_reader.node'
),
0o600
);
},
reason: 'runtime-artifact-invalid',
},
{
label: 'symlinked helper',
mutate(fixture: RuntimeFixture) {
const target = `${fixture.helperPath}.real`;
writeFileSync(target, '#!/bin/sh\n', { mode: 0o755 });
unlinkSync(fixture.helperPath);
symlinkSync(target, fixture.helperPath);
},
reason: 'runtime-artifact-invalid',
},
{
label: 'reader directory',
mutate(fixture: RuntimeFixture) {
const readerPath = path.join(
fixture.nativeDir,
'embedded_mpv_frame_reader.node'
);
unlinkSync(readerPath);
mkdirSync(readerPath);
},
reason: 'runtime-artifact-invalid',
},
])('rejects a $label', ({ mutate, reason }) => {
const fixture = createFixture(context.rootDir);
mutate(fixture);
expect(context.createProbe()(fixture.helperPath)).toEqual({
usable: false,
reason,
});
expect(context.spawnRuntimeProbe).not.toHaveBeenCalled();
});
it.each([
{
label: 'missing declared library',
mutate(fixture: RuntimeFixture) {
unlinkSync(path.join(fixture.nativeDir, 'lib', 'libmpv.so.2'));
},
reason: 'runtime-library-missing',
},
{
label: 'undeclared library',
mutate(fixture: RuntimeFixture) {
writeFileSync(
path.join(fixture.nativeDir, 'lib', 'libextra.so'),
'extra'
);
},
reason: 'runtime-library-undeclared',
},
{
label: 'library size mismatch',
mutate(fixture: RuntimeFixture) {
writeFileSync(
path.join(fixture.nativeDir, 'lib', 'libmpv.so.2'),
'different-length'
);
},
reason: 'runtime-library-size-mismatch',
},
{
label: 'library hash mismatch',
mutate(fixture: RuntimeFixture) {
const runtimePath = path.join(
fixture.nativeDir,
'lib',
'libmpv.so.2'
);
const original = readFileSync(runtimePath);
writeFileSync(
runtimePath,
Buffer.from(original.map((value) => value ^ 0xff))
);
},
reason: 'runtime-library-hash-mismatch',
},
{
label: 'symlinked library',
mutate(fixture: RuntimeFixture) {
const runtimePath = path.join(
fixture.nativeDir,
'lib',
'libmpv.so.2'
);
const targetPath = path.join(
fixture.nativeDir,
'libmpv-real.so.2'
);
writeFileSync(
targetPath,
fixture.runtimeContents['libmpv.so.2']
);
unlinkSync(runtimePath);
symlinkSync(targetPath, runtimePath);
},
reason: 'runtime-library-invalid',
},
{
label: 'library directory',
mutate(fixture: RuntimeFixture) {
const runtimePath = path.join(
fixture.nativeDir,
'lib',
'libmpv.so.2'
);
unlinkSync(runtimePath);
mkdirSync(runtimePath);
},
reason: 'runtime-library-invalid',
},
])('rejects a $label', ({ mutate, reason }) => {
const fixture = createFixture(context.rootDir, 'portable');
mutate(fixture);
expect(context.createProbe()(fixture.helperPath)).toEqual({
usable: false,
reason,
});
expect(context.spawnRuntimeProbe).not.toHaveBeenCalled();
});
it.each(['../libmpv.so.2', '/tmp/libmpv.so.2', 'sub/libmpv.so.2'])(
'rejects unsafe runtime path %s',
(unsafeName) => {
const fixture = createFixture(context.rootDir, 'portable');
const manifest = cloneManifest(fixture.manifest);
const runtimeFiles = manifest.runtimeFiles as RuntimeFile[];
runtimeFiles[0].name = unsafeName;
writeManifest(fixture.manifestPath, manifest);
expect(context.createProbe()(fixture.helperPath)).toEqual({
usable: false,
reason: 'runtime-manifest-invalid',
});
expect(context.spawnRuntimeProbe).not.toHaveBeenCalled();
}
);
it('uses read and execute access checks for declared artifacts', () => {
const fixture = createFixture(context.rootDir);
const probeRuntime = context.createProbe();
expect(probeRuntime(fixture.helperPath).usable).toBe(true);
const accessMock = context.fileSystem.accessSync as jest.Mock;
expect(accessMock).toHaveBeenCalledWith(
fixture.helperPath,
fsConstants.R_OK | fsConstants.X_OK
);
expect(accessMock).toHaveBeenCalledWith(
path.join(fixture.nativeDir, 'embedded_mpv_frame_reader.node'),
fsConstants.R_OK
);
});
});
@@ -0,0 +1,238 @@
import { createHash } from 'crypto';
import * as nodeFileSystem from 'fs';
import path from 'path';
import {
FRAME_COPY_ADDON_NAME,
FRAME_COPY_HELPER_NAME,
FRAME_COPY_READER_NAME,
} from './contracts';
import {
validateDevelopmentManifest,
validatePackagedManifest,
} from './manifest-validator';
import type {
EmbeddedMpvFrameCopyManifestContract,
EmbeddedMpvFrameCopyRuntimeFileSystem,
RuntimeFile,
ValidatedPackage,
ValidationResult,
} from './types';
import {
isMissingFileError,
isValidationFailure,
readManifest,
validationFailure,
} from './validation-primitives';
function validateRegularArtifact(
filePath: string,
accessMode: number,
expectedMode: number | null,
fileSystem: EmbeddedMpvFrameCopyRuntimeFileSystem
): ValidationResult<nodeFileSystem.Stats> {
let stat: nodeFileSystem.Stats;
try {
stat = fileSystem.lstatSync(filePath);
} catch (error) {
return validationFailure(
isMissingFileError(error)
? 'runtime-artifact-missing'
: 'runtime-artifact-invalid'
);
}
if (
stat.isSymbolicLink() ||
!stat.isFile() ||
(expectedMode !== null && (stat.mode & 0o7777) !== expectedMode)
) {
return validationFailure('runtime-artifact-invalid');
}
try {
fileSystem.accessSync(filePath, accessMode);
} catch {
return validationFailure('runtime-artifact-invalid');
}
return { value: stat };
}
function pathExistsByLstat(
filePath: string,
fileSystem: EmbeddedMpvFrameCopyRuntimeFileSystem
): boolean {
try {
fileSystem.lstatSync(filePath);
return true;
} catch (error) {
if (isMissingFileError(error)) {
return false;
}
throw error;
}
}
function validateBundledRuntimeFiles(
nativeDir: string,
runtimeFiles: RuntimeFile[],
fileSystem: EmbeddedMpvFrameCopyRuntimeFileSystem
): ValidationResult<true> {
const libDir = path.join(nativeDir, 'lib');
let libStat: nodeFileSystem.Stats;
try {
libStat = fileSystem.lstatSync(libDir);
} catch {
return validationFailure('runtime-library-directory-invalid');
}
if (libStat.isSymbolicLink() || !libStat.isDirectory()) {
return validationFailure('runtime-library-directory-invalid');
}
let packagedNames: string[];
try {
packagedNames = fileSystem.readdirSync(libDir) as string[];
} catch {
return validationFailure('runtime-library-directory-invalid');
}
const declaredNames = new Set(runtimeFiles.map(({ name }) => name));
if (packagedNames.some((name) => !declaredNames.has(name))) {
return validationFailure('runtime-library-undeclared');
}
for (const runtimeFile of runtimeFiles) {
const runtimePath = path.join(libDir, runtimeFile.name);
let stat: nodeFileSystem.Stats;
try {
stat = fileSystem.lstatSync(runtimePath);
} catch (error) {
return validationFailure(
isMissingFileError(error)
? 'runtime-library-missing'
: 'runtime-library-invalid'
);
}
if (stat.isSymbolicLink() || !stat.isFile()) {
return validationFailure('runtime-library-invalid');
}
try {
fileSystem.accessSync(runtimePath, nodeFileSystem.constants.R_OK);
} catch {
return validationFailure('runtime-library-invalid');
}
if (stat.size !== runtimeFile.size) {
return validationFailure('runtime-library-size-mismatch');
}
let contents: Buffer;
try {
contents = fileSystem.readFileSync(runtimePath);
} catch {
return validationFailure('runtime-library-invalid');
}
if (contents.length !== runtimeFile.size) {
return validationFailure('runtime-library-size-mismatch');
}
const actualSha256 = createHash('sha256')
.update(contents)
.digest('hex');
if (actualSha256 !== runtimeFile.sha256) {
return validationFailure('runtime-library-hash-mismatch');
}
}
return { value: true };
}
export function validatePackage(
helperPath: string,
manifestPath: string,
fileSystem: EmbeddedMpvFrameCopyRuntimeFileSystem,
manifestContract: EmbeddedMpvFrameCopyManifestContract
): ValidationResult<ValidatedPackage> {
const nativeDir = path.dirname(helperPath);
if (
path.basename(helperPath) !== FRAME_COPY_HELPER_NAME ||
path.dirname(manifestPath) !== nativeDir
) {
return validationFailure('runtime-artifact-invalid');
}
const manifestArtifact = validateRegularArtifact(
manifestPath,
nodeFileSystem.constants.R_OK,
0o644,
fileSystem
);
if (isValidationFailure(manifestArtifact)) {
return validationFailure(
manifestArtifact.reason === 'runtime-artifact-missing'
? 'runtime-manifest-missing'
: 'runtime-manifest-invalid'
);
}
const manifestResult = readManifest(manifestPath, fileSystem);
if (isValidationFailure(manifestResult)) {
return manifestResult;
}
const validManifest =
manifestContract === 'packaged'
? validatePackagedManifest(manifestResult.value)
: validateDevelopmentManifest(manifestResult.value);
if (isValidationFailure(validManifest)) {
return validManifest;
}
for (const [artifactPath, accessMode, expectedMode] of [
[
path.join(nativeDir, FRAME_COPY_ADDON_NAME),
nodeFileSystem.constants.R_OK,
null,
],
[
path.join(nativeDir, FRAME_COPY_READER_NAME),
nodeFileSystem.constants.R_OK,
0o644,
],
[
helperPath,
nodeFileSystem.constants.R_OK | nodeFileSystem.constants.X_OK,
0o755,
],
] as const) {
const artifact = validateRegularArtifact(
artifactPath,
accessMode,
expectedMode,
fileSystem
);
if (isValidationFailure(artifact)) {
return artifact;
}
}
const libDir = path.join(nativeDir, 'lib');
if (validManifest.value.runtimeMode === 'system') {
try {
if (pathExistsByLstat(libDir, fileSystem)) {
return validationFailure('runtime-library-directory-invalid');
}
} catch {
return validationFailure('runtime-library-directory-invalid');
}
} else {
const bundledRuntime = validateBundledRuntimeFiles(
nativeDir,
validManifest.value.runtimeFiles,
fileSystem
);
if (isValidationFailure(bundledRuntime)) {
return bundledRuntime;
}
}
return {
value: {
manifest: validManifest.value,
helperPath,
nativeDir,
},
};
}
@@ -0,0 +1,391 @@
import {
accessSync,
chmodSync,
lstatSync,
mkdirSync,
readFileSync,
readdirSync,
writeFileSync,
} from 'fs';
import path from 'path';
import {
cloneManifest,
createFixture,
writeManifest,
} from './runtime-fixtures.test-helpers';
import {
createRuntimeTestContext,
type RuntimeTestContext,
} from './runtime-harness.test-helpers';
describe('embedded-mpv frame-copy runtime probe orchestration', () => {
let context: RuntimeTestContext;
beforeEach(() => {
context = createRuntimeTestContext();
});
afterEach(() => {
context.dispose();
});
it('validates a system package, sanitizes loader overrides, and caches by helper/manifest identity', () => {
const fixture = createFixture(context.rootDir);
const probeRuntime = context.createProbe({
env: {
PATH: '/usr/bin',
LIBGL_ALWAYS_SOFTWARE: '1',
GALLIUM_DRIVER: 'llvmpipe',
BASH_ENV: '/tmp/hostile-bash-env',
ENV: '/tmp/hostile-shell-env',
BASHOPTS: 'extdebug',
SHELLOPTS: 'xtrace',
PS4: '$(/tmp/hostile-trace-hook)',
BASH_XTRACEFD: '9',
CDPATH: '/tmp/hostile-cdpath',
'BASH_FUNC_dirname%%':
'() { printf /tmp/hostile-provider-root; exit 0; }',
LD_AUDIT: '/tmp/audit.so',
LD_LIBRARY_PATH: '/ambient/libs',
LD_ORIGIN_PATH: '/tmp/hostile-origin',
LD_PRELOAD: '/tmp/inject.so',
__EGL_VENDOR_LIBRARY_FILENAMES: '/tmp/hostile-egl-vendor.json',
__EGL_VENDOR_LIBRARY_DIRS: '/tmp/hostile-egl-vendor-dir',
__EGL_EXTERNAL_PLATFORM_CONFIG_DIRS:
'/tmp/hostile-egl-platform',
__EGL_EXTERNAL_PLATFORM_CONFIG_FILENAMES:
'/tmp/hostile-egl-platform.json',
GBM_BACKEND: '../../../../../tmp/hostile-gbm',
GBM_BACKENDS_PATH: '/tmp/hostile-gbm-path',
LIBGL_DRIVERS_PATH: '/tmp/hostile-dri-path',
MESA_LOADER_DRIVER_OVERRIDE: '../../../../../tmp/hostile-dri',
LIBVA_DRIVER_NAME: '../../../../../tmp/hostile-va',
LIBVA_DRIVERS_PATH: '/tmp/hostile-va-path',
VDPAU_DRIVER_PATH: '/tmp/hostile-vdpau',
VK_DRIVER_FILES: '/tmp/hostile-vulkan-driver.json',
VK_ICD_FILENAMES: '/tmp/hostile-vulkan-icd.json',
VK_ADD_DRIVER_FILES: '/tmp/hostile-vulkan-add-driver.json',
VK_ADD_LAYER_PATH: '/tmp/hostile-vulkan-layers',
VK_IMPLICIT_LAYER_PATH: '/tmp/hostile-vulkan-implicit-layers',
VK_ADD_IMPLICIT_LAYER_PATH:
'/tmp/hostile-vulkan-add-implicit-layers',
VK_LAYER_PATH: '/tmp/hostile-vulkan-layer-path',
},
});
expect(probeRuntime(fixture.helperPath)).toEqual({
usable: true,
profile: 'system',
runtimeMode: 'system',
libmpv: '2.3',
renderApi: 'egl',
});
expect(probeRuntime(fixture.helperPath).usable).toBe(true);
expect(context.spawnRuntimeProbe).toHaveBeenCalledTimes(1);
expect(context.spawnRuntimeProbe).toHaveBeenCalledWith(
fixture.helperPath,
['--runtime-probe'],
{
encoding: 'utf8',
timeout: 3000,
killSignal: 'SIGKILL',
windowsHide: true,
maxBuffer: 16 * 1024 * 1024,
env: {
PATH: '/usr/bin',
LIBGL_ALWAYS_SOFTWARE: '1',
GALLIUM_DRIVER: 'llvmpipe',
},
}
);
expect(context.fileSystem.readFileSync).toHaveBeenCalled();
});
it('invalidates a cached result when helper or manifest bytes change under identical stats', () => {
const fixture = createFixture(context.rootDir);
const fixedStats = new Map([
[fixture.helperPath, lstatSync(fixture.helperPath)],
[fixture.manifestPath, lstatSync(fixture.manifestPath)],
]);
context.fileSystem = {
...context.fileSystem,
lstatSync: jest.fn(
(filePath: string) =>
fixedStats.get(filePath) ?? lstatSync(filePath)
),
};
const probeRuntime = context.createProbe();
expect(probeRuntime(fixture.helperPath).usable).toBe(true);
const changedHelper = readFileSync(fixture.helperPath);
changedHelper[0] ^= 0xff;
writeFileSync(fixture.helperPath, changedHelper);
expect(probeRuntime(fixture.helperPath).usable).toBe(true);
fixture.manifest.generatedAt = '2026-07-18T00:00:00.000Z';
writeManifest(fixture.manifestPath, fixture.manifest);
expect(probeRuntime(fixture.helperPath).usable).toBe(true);
expect(context.spawnRuntimeProbe).toHaveBeenCalledTimes(3);
});
it.each(['portable', 'flatpak'] as const)(
'validates the exact %s bundled closure and uses only its private library directory',
(profile) => {
const fixture = createFixture(context.rootDir, profile);
const probeRuntime = context.createProbe();
expect(probeRuntime(fixture.helperPath)).toEqual(
expect.objectContaining({
usable: true,
profile,
runtimeMode: 'bundled',
})
);
expect(context.spawnRuntimeProbe).toHaveBeenCalledWith(
fixture.helperPath,
['--runtime-probe'],
expect.objectContaining({
env: {
PATH: '/usr/bin',
LD_LIBRARY_PATH: path.join(fixture.nativeDir, 'lib'),
},
})
);
}
);
it('runs a packaged Snap probe through the connected graphics provider wrapper', () => {
const actualSnapRoot = path.join(context.rootDir, 'snap-root');
const actualFixtureRoot = path.join(actualSnapRoot, 'fixture');
const fixture = createFixture(actualFixtureRoot, 'portable');
const actualGraphicsRoot = path.join(actualSnapRoot, 'graphics');
const actualProviderWrapper = path.join(
actualGraphicsRoot,
'bin',
'graphics-core22-provider-wrapper'
);
mkdirSync(path.dirname(actualProviderWrapper), { recursive: true });
writeFileSync(actualProviderWrapper, '#!/bin/sh\nexec "$@"\n', {
mode: 0o755,
});
const virtualSnapRoot = '/snap/iptvnator/42';
const linuxTriplet = 'x86_64-linux-gnu';
const snapLibraries = (...relativePaths: string[]): string[] =>
relativePaths.map((relativePath) =>
path.join(virtualSnapRoot, relativePath)
);
const virtualNativeDir = path.join(
virtualSnapRoot,
'resources',
'app.asar.unpacked',
'electron-backend',
'native'
);
const virtualHelperPath = path.join(
virtualNativeDir,
'iptvnator_mpv_helper'
);
const virtualGraphicsRoot = path.join(virtualSnapRoot, 'graphics');
const virtualProviderWrapper = path.join(
virtualGraphicsRoot,
'bin',
'graphics-core22-provider-wrapper'
);
const translatePath = (candidatePath: string): string => {
if (
candidatePath === virtualNativeDir ||
candidatePath.startsWith(`${virtualNativeDir}${path.sep}`)
) {
return path.join(
fixture.nativeDir,
path.relative(virtualNativeDir, candidatePath)
);
}
if (
candidatePath === virtualGraphicsRoot ||
candidatePath.startsWith(`${virtualGraphicsRoot}${path.sep}`)
) {
return path.join(
actualGraphicsRoot,
path.relative(virtualGraphicsRoot, candidatePath)
);
}
return candidatePath;
};
const virtualFileSystem = {
accessSync: jest.fn((candidatePath: string, mode: number) =>
accessSync(translatePath(candidatePath), mode)
),
lstatSync: jest.fn((candidatePath: string) =>
lstatSync(translatePath(candidatePath))
),
readFileSync: jest.fn((candidatePath: string) =>
readFileSync(translatePath(candidatePath))
),
readdirSync: jest.fn((candidatePath: string) =>
readdirSync(translatePath(candidatePath))
),
};
const probeRuntime = context.createProbe({
env: {
PATH: '/snap/bin:/usr/bin',
SNAP: virtualSnapRoot,
SNAP_DESKTOP_RUNTIME: path.join(
virtualSnapRoot,
'gnome-platform'
),
SNAP_LIBRARY_PATH:
'/var/lib/snapd/lib/gl:/var/lib/snapd/lib/gl/nvidia',
},
fileSystem: virtualFileSystem,
});
expect(probeRuntime(virtualHelperPath)).toEqual(
expect.objectContaining({
usable: true,
profile: 'portable',
runtimeMode: 'bundled',
})
);
expect(context.spawnRuntimeProbe).toHaveBeenCalledWith(
virtualProviderWrapper,
[virtualHelperPath, '--runtime-probe'],
expect.objectContaining({
env: expect.objectContaining({
SNAP: virtualSnapRoot,
LD_LIBRARY_PATH: [
path.join(virtualNativeDir, 'lib'),
'/var/lib/snapd/lib/gl',
'/var/lib/snapd/lib/gl/nvidia',
...snapLibraries(
`graphics/usr/lib/${linuxTriplet}`,
`graphics/usr/lib/${linuxTriplet}/vdpau`
),
'/usr/lib/x86_64-linux-gnu',
...snapLibraries(
`gnome-platform/lib/${linuxTriplet}`,
`gnome-platform/usr/lib/${linuxTriplet}`,
`gnome-platform/usr/lib/${linuxTriplet}/mesa`,
`gnome-platform/usr/lib/${linuxTriplet}/mesa-egl`,
`gnome-platform/usr/lib/${linuxTriplet}/dri`,
`gnome-platform/usr/lib/${linuxTriplet}/pulseaudio`,
'lib',
'usr/lib',
`lib/${linuxTriplet}`,
`usr/lib/${linuxTriplet}`
),
].join(':'),
}),
})
);
});
it('reports a stable unavailable reason when the Snap graphics provider is disconnected', () => {
const actualSnapRoot = path.join(context.rootDir, 'snap-root');
const fixture = createFixture(
path.join(actualSnapRoot, 'fixture'),
'portable'
);
const actualGraphicsRoot = path.join(actualSnapRoot, 'graphics');
mkdirSync(actualGraphicsRoot, { recursive: true });
const virtualSnapRoot = '/snap/iptvnator/42';
const virtualNativeDir = path.join(
virtualSnapRoot,
'resources',
'app.asar.unpacked',
'electron-backend',
'native'
);
const translatePath = (candidatePath: string): string => {
if (
candidatePath === virtualNativeDir ||
candidatePath.startsWith(`${virtualNativeDir}${path.sep}`)
) {
return path.join(
fixture.nativeDir,
path.relative(virtualNativeDir, candidatePath)
);
}
const virtualGraphicsRoot = path.join(virtualSnapRoot, 'graphics');
if (
candidatePath === virtualGraphicsRoot ||
candidatePath.startsWith(`${virtualGraphicsRoot}${path.sep}`)
) {
return path.join(
actualGraphicsRoot,
path.relative(virtualGraphicsRoot, candidatePath)
);
}
return candidatePath;
};
const probeRuntime = context.createProbe({
env: { SNAP: virtualSnapRoot },
fileSystem: {
accessSync: (candidatePath, mode) =>
accessSync(translatePath(candidatePath), mode),
lstatSync: (candidatePath) =>
lstatSync(translatePath(candidatePath)),
readFileSync: (candidatePath) =>
readFileSync(translatePath(candidatePath)),
readdirSync: (candidatePath) =>
readdirSync(translatePath(candidatePath)),
},
});
expect(
probeRuntime(path.join(virtualNativeDir, 'iptvnator_mpv_helper'))
).toEqual({
usable: false,
reason: 'snap-graphics-provider-unavailable',
});
expect(context.spawnRuntimeProbe).not.toHaveBeenCalled();
});
it('reprobes when the helper identity changes', () => {
const fixture = createFixture(context.rootDir);
const probeRuntime = context.createProbe();
expect(probeRuntime(fixture.helperPath).usable).toBe(true);
writeFileSync(fixture.helperPath, '#!/bin/sh\n# changed\n');
chmodSync(fixture.helperPath, 0o755);
expect(probeRuntime(fixture.helperPath).usable).toBe(true);
expect(context.spawnRuntimeProbe).toHaveBeenCalledTimes(2);
});
it('reprobes when the manifest identity changes', () => {
const fixture = createFixture(context.rootDir);
const probeRuntime = context.createProbe();
expect(probeRuntime(fixture.helperPath).usable).toBe(true);
const manifest = cloneManifest(fixture.manifest);
manifest.generatedAt = '2026-07-17T00:01:00.000Z';
writeManifest(fixture.manifestPath, manifest);
expect(probeRuntime(fixture.helperPath).usable).toBe(true);
expect(context.spawnRuntimeProbe).toHaveBeenCalledTimes(2);
});
it.each([
['linux', 'arm64', 'unsupported-architecture'],
['linux', 'arm', 'unsupported-architecture'],
['darwin', 'x64', 'unsupported-platform'],
] as const)(
'does not probe unsupported %s/%s runtimes',
(platform, arch, reason) => {
const fixture = createFixture(context.rootDir);
expect(
context.createProbe({ platform, arch })(fixture.helperPath)
).toEqual({
usable: false,
reason,
});
expect(context.spawnRuntimeProbe).not.toHaveBeenCalled();
}
);
});
@@ -0,0 +1,334 @@
import { spawnSync as nodeSpawnSync } from 'child_process';
import * as nodeFileSystem from 'fs';
import path from 'path';
import {
RUNTIME_MANIFEST_NAME,
RUNTIME_PROBE_MAX_BUFFER_BYTES,
RUNTIME_PROBE_PROTOCOL,
RUNTIME_PROBE_TIMEOUT_MS,
} from './contracts';
import { createLinuxFrameCopyHelperLaunch } from './helper-launch';
import { validatePackage } from './package-validator';
import { EMBEDDED_MPV_HELPER_RUNTIME_PROBE_FAILURE_REASONS } from './types';
import type {
EmbeddedMpvFrameCopyManifestContract,
EmbeddedMpvFrameCopyRuntimeDependencies,
EmbeddedMpvFrameCopyRuntimeFileSystem,
EmbeddedMpvFrameCopyRuntimeResult,
EmbeddedMpvHelperRuntimeProbeFailureReason,
ValidManifest,
ValidatedPackage,
} from './types';
import {
failure,
fileIdentity,
hasExactFields,
isMissingFileError,
isObject,
isValidationFailure,
} from './validation-primitives';
const HELPER_RUNTIME_PROBE_FAILURE_REASON_ALLOWLIST = new Set<string>(
Object.values(EMBEDDED_MPV_HELPER_RUNTIME_PROBE_FAILURE_REASONS)
);
const HELPER_RUNTIME_PROBE_STDERR_LIMIT = 16_384;
const HELPER_RUNTIME_PROBE_STDERR_EVENT =
'embedded-mpv-helper-runtime-probe-stderr';
interface ParsedFailedProbe {
helperReason: EmbeddedMpvHelperRuntimeProbeFailureReason;
helperDetail?: string;
}
function isSafeHelperDetail(value: unknown): value is string {
return (
typeof value === 'string' &&
value.length >= 1 &&
value.length <= 1024 &&
!/[^\x20-\x7e]/.test(value)
);
}
function parseFailedProbe(stdout: unknown): ParsedFailedProbe | null {
if (typeof stdout !== 'string' || !/^[^\r\n]+\n$/.test(stdout)) {
return null;
}
let parsed: unknown;
try {
parsed = JSON.parse(stdout.slice(0, -1));
} catch {
return null;
}
if (!isObject(parsed)) {
return null;
}
const hasDetail = Object.prototype.hasOwnProperty.call(parsed, 'detail');
const fields = hasDetail
? ['detail', 'protocol', 'reason', 'usable']
: ['protocol', 'reason', 'usable'];
if (
!hasExactFields(parsed, fields) ||
parsed.protocol !== RUNTIME_PROBE_PROTOCOL ||
parsed.usable !== false ||
typeof parsed.reason !== 'string' ||
!HELPER_RUNTIME_PROBE_FAILURE_REASON_ALLOWLIST.has(parsed.reason) ||
(hasDetail && !isSafeHelperDetail(parsed.detail))
) {
return null;
}
return {
helperReason:
parsed.reason as EmbeddedMpvHelperRuntimeProbeFailureReason,
...(hasDetail ? { helperDetail: parsed.detail as string } : {}),
};
}
function parseSuccessfulProbe(
stdout: unknown,
manifest: ValidManifest
): EmbeddedMpvFrameCopyRuntimeResult {
if (typeof stdout !== 'string' || !/^[^\r\n]+\n$/.test(stdout)) {
return failure('helper-probe-invalid-output');
}
let parsed: unknown;
try {
parsed = JSON.parse(stdout.slice(0, -1));
} catch {
return failure('helper-probe-invalid-output');
}
if (!isObject(parsed)) {
return failure('helper-probe-invalid-output');
}
if (parsed.protocol !== RUNTIME_PROBE_PROTOCOL) {
return failure('helper-probe-protocol-mismatch');
}
if (parsed.usable !== true) {
return failure(
parsed.usable === false
? 'helper-probe-unusable'
: 'helper-probe-invalid-output'
);
}
if (
!hasExactFields(parsed, [
'libmpv',
'protocol',
'renderApi',
'usable',
]) ||
typeof parsed.libmpv !== 'string' ||
parsed.libmpv.trim() === '' ||
parsed.renderApi !== 'egl'
) {
return failure('helper-probe-invalid-output');
}
return {
usable: true,
profile: manifest.profile,
runtimeMode: manifest.runtimeMode,
libmpv: parsed.libmpv,
renderApi: parsed.renderApi,
};
}
function traceHelperProbeStderr(
stderr: unknown,
dependencies: EmbeddedMpvFrameCopyRuntimeDependencies
): void {
if (
dependencies.env.IPTVNATOR_TRACE_PLAYER !== '1' ||
typeof stderr !== 'string' ||
stderr.length === 0
) {
return;
}
const boundedStderr = stderr.slice(0, HELPER_RUNTIME_PROBE_STDERR_LIMIT);
const output = `${JSON.stringify({
event: HELPER_RUNTIME_PROBE_STDERR_EVENT,
stderr: boundedStderr,
truncated: stderr.length > boundedStderr.length,
})}\n`;
try {
dependencies.writeStderr(output);
} catch {
// Opt-in diagnostics must never change the fail-closed probe result.
}
}
function runHelperProbe(
runtimePackage: ValidatedPackage,
dependencies: EmbeddedMpvFrameCopyRuntimeDependencies
): EmbeddedMpvFrameCopyRuntimeResult {
const launch = createLinuxFrameCopyHelperLaunch({
environment: dependencies.env,
helperPath: runtimePackage.helperPath,
helperArgs: ['--runtime-probe'],
runtimeMode: runtimePackage.manifest.runtimeMode,
fileSystem: dependencies.fileSystem,
});
if (launch.usable === false) {
return failure(launch.reason);
}
let result: ReturnType<typeof nodeSpawnSync>;
try {
result = dependencies.spawnSync(launch.command, launch.args, {
encoding: 'utf8',
timeout: RUNTIME_PROBE_TIMEOUT_MS,
killSignal: 'SIGKILL',
windowsHide: true,
maxBuffer: RUNTIME_PROBE_MAX_BUFFER_BYTES,
env: launch.env,
});
} catch {
return failure('helper-probe-spawn-error');
}
traceHelperProbeStderr(result.stderr, dependencies);
if (
result.error &&
'code' in result.error &&
result.error.code === 'ETIMEDOUT'
) {
return failure('helper-probe-timeout');
}
if (result.error) {
return failure('helper-probe-spawn-error');
}
if (result.signal) {
return failure('helper-probe-signaled');
}
if (result.status !== 0) {
const helperFailure = parseFailedProbe(result.stdout);
return helperFailure
? {
usable: false,
reason: 'helper-probe-failed',
...helperFailure,
}
: failure('helper-probe-failed');
}
return parseSuccessfulProbe(result.stdout, runtimePackage.manifest);
}
/**
* Creates an isolated probe/cache. Production uses the singleton below;
* tests inject filesystem and spawn collaborators through this factory.
*/
export function createEmbeddedMpvFrameCopyRuntimeProbe(
overrides: Partial<EmbeddedMpvFrameCopyRuntimeDependencies> = {}
): (
helperPath: string,
manifestContract?: EmbeddedMpvFrameCopyManifestContract
) => EmbeddedMpvFrameCopyRuntimeResult {
const defaultFileSystem: EmbeddedMpvFrameCopyRuntimeFileSystem = {
accessSync: (filePath, mode) =>
nodeFileSystem.accessSync(filePath, mode),
lstatSync: (filePath) => nodeFileSystem.lstatSync(filePath),
readFileSync: (filePath) => nodeFileSystem.readFileSync(filePath),
readdirSync: (filePath) => nodeFileSystem.readdirSync(filePath),
};
const dependencies: EmbeddedMpvFrameCopyRuntimeDependencies = {
platform: process.platform,
arch: process.arch,
env: process.env,
fileSystem: defaultFileSystem,
spawnSync: nodeSpawnSync,
writeStderr: (output) => {
nodeFileSystem.writeSync(process.stderr.fd, output);
},
...overrides,
};
const resultCache = new Map<string, EmbeddedMpvFrameCopyRuntimeResult>();
return (
helperPath: string,
manifestContract: EmbeddedMpvFrameCopyManifestContract = 'packaged'
): EmbeddedMpvFrameCopyRuntimeResult => {
if (dependencies.platform !== 'linux') {
return failure('unsupported-platform');
}
if (dependencies.arch !== 'x64') {
return failure('unsupported-architecture');
}
const manifestPath = path.join(
path.dirname(helperPath),
RUNTIME_MANIFEST_NAME
);
let helperStat: nodeFileSystem.Stats;
let manifestStat: nodeFileSystem.Stats;
try {
helperStat = dependencies.fileSystem.lstatSync(helperPath);
} catch {
return failure('runtime-artifact-missing');
}
try {
manifestStat = dependencies.fileSystem.lstatSync(manifestPath);
} catch (error) {
return failure(
isMissingFileError(error)
? 'runtime-manifest-missing'
: 'runtime-manifest-invalid'
);
}
let helperContents: Buffer;
let manifestContents: Buffer;
try {
helperContents = dependencies.fileSystem.readFileSync(helperPath);
} catch {
return failure('runtime-artifact-invalid');
}
try {
manifestContents =
dependencies.fileSystem.readFileSync(manifestPath);
} catch {
return failure('runtime-manifest-invalid');
}
const cacheKey = `${manifestContract}\0${fileIdentity(
helperPath,
helperStat,
helperContents
)}\0${fileIdentity(manifestPath, manifestStat, manifestContents)}`;
const cached = resultCache.get(cacheKey);
if (cached) {
return cached;
}
let result: EmbeddedMpvFrameCopyRuntimeResult;
try {
const runtimePackage = validatePackage(
helperPath,
manifestPath,
dependencies.fileSystem,
manifestContract
);
result = isValidationFailure(runtimePackage)
? failure(runtimePackage.reason)
: runHelperProbe(runtimePackage.value, dependencies);
} catch {
result = failure('runtime-probe-internal-error');
}
resultCache.set(cacheKey, result);
return result;
};
}
const processRuntimeProbe = createEmbeddedMpvFrameCopyRuntimeProbe();
/**
* Fail-closed, process-lifetime Linux runtime decision shared by startup and
* the service gate. The helper and manifest identities scope cached results.
*/
export function probeEmbeddedMpvFrameCopyRuntime(
helperPath: string,
manifestContract: EmbeddedMpvFrameCopyManifestContract
): EmbeddedMpvFrameCopyRuntimeResult {
return processRuntimeProbe(helperPath, manifestContract);
}
@@ -0,0 +1,95 @@
import { isDeepStrictEqual } from 'util';
import {
ALLOWED_EXTERNAL_LIBRARY_NAMES,
EXPECTED_EXTERNAL_SYSTEM_LIBRARIES,
SAFE_RUNTIME_NAME_PATTERN,
SHARED_LIBRARY_PATTERN,
} from './contracts';
import type { RuntimeFile } from './types';
import {
hasExactFields,
isObject,
isSafeRuntimeName,
} from './validation-primitives';
export function validateRuntimeClosure(
value: unknown,
runtimeFiles: RuntimeFile[],
libmpvSoname: string,
externalSystemLibraries: unknown
): boolean {
if (
!isDeepStrictEqual(
externalSystemLibraries,
EXPECTED_EXTERNAL_SYSTEM_LIBRARIES
) ||
!isObject(value) ||
!hasExactFields(value, ['entries', 'externalDependencies']) ||
!Array.isArray(value.entries) ||
!Array.isArray(value.externalDependencies) ||
value.externalDependencies.some(
(dependency) =>
typeof dependency !== 'string' ||
!SAFE_RUNTIME_NAME_PATTERN.test(dependency) ||
!SHARED_LIBRARY_PATTERN.test(dependency)
)
) {
return false;
}
const runtimeNames = runtimeFiles.map(({ name }) => name);
const runtimeNameSet = new Set(runtimeNames);
const closureNames: string[] = [];
const computedExternalDependencies = new Set<string>();
for (const entry of value.entries) {
if (
!isObject(entry) ||
!hasExactFields(entry, [
'name',
'needed',
'rpath',
'runpath',
'soname',
]) ||
!isSafeRuntimeName(entry.name) ||
(entry.soname !== null && !isSafeRuntimeName(entry.soname)) ||
!Array.isArray(entry.needed) ||
entry.needed.some(
(dependency) =>
typeof dependency !== 'string' ||
!SAFE_RUNTIME_NAME_PATTERN.test(dependency) ||
!SHARED_LIBRARY_PATTERN.test(dependency)
) ||
!isDeepStrictEqual(entry.rpath, []) ||
!isDeepStrictEqual(entry.runpath, ['$ORIGIN']) ||
new Set(entry.needed).size !== entry.needed.length ||
!isDeepStrictEqual([...entry.needed].sort(), entry.needed)
) {
return false;
}
closureNames.push(entry.name);
for (const dependency of entry.needed) {
if (runtimeNameSet.has(dependency)) {
continue;
}
if (!ALLOWED_EXTERNAL_LIBRARY_NAMES.has(dependency)) {
return false;
}
computedExternalDependencies.add(dependency);
}
}
const linkerAlias = value.entries.find(
(entry) => isObject(entry) && entry.name === 'libmpv.so'
);
return (
isDeepStrictEqual(closureNames, runtimeNames) &&
new Set(closureNames).size === closureNames.length &&
isDeepStrictEqual(
value.externalDependencies,
[...computedExternalDependencies].sort()
) &&
isObject(linkerAlias) &&
linkerAlias.soname === libmpvSoname
);
}
@@ -0,0 +1,306 @@
import { createHash } from 'crypto';
import { chmodSync, mkdirSync, writeFileSync } from 'fs';
import path from 'path';
import type { createEmbeddedMpvFrameCopyRuntimeProbe } from '../embedded-mpv-frame-copy-runtime';
import {
EXTERNAL_SYSTEM_LIBRARIES,
PINNED_SOURCE_PACKAGE_IDENTITIES,
type RuntimeFile,
type RuntimeFixture,
} from './runtime.spec-data';
function sha256(contents: Buffer): string {
return createHash('sha256').update(contents).digest('hex');
}
function createRuntimeFiles(
runtimeContents: Record<string, Buffer>
): RuntimeFile[] {
return Object.entries(runtimeContents)
.map(([name, contents]) => ({
name,
size: contents.length,
sha256: sha256(contents),
}))
.sort((left, right) => left.name.localeCompare(right.name));
}
function createSourceRuntime(
runtimeFiles: RuntimeFile[],
runtimeDependencyClosure: Record<string, unknown>
): Record<string, unknown> {
const packages = cloneManifest(PINNED_SOURCE_PACKAGE_IDENTITIES);
return {
schemaVersion: 1,
origin: 'vendored-lgpl-source-build',
platform: 'linux',
arch: 'x64',
packages,
ffmpeg: {
...(packages.ffmpeg as Record<string, unknown>),
configureFlags: ['--disable-gpl', '--disable-nonfree'],
},
mpv: {
...(packages.mpv as Record<string, unknown>),
mesonFlags: ['-Dgpl=false', '-Dlibmpv=true'],
},
sourceDistribution:
'Publish the exact hwdata archive and pnp.ids with the libdisplay-info source.',
runtimeFiles,
runtimeTotalBytes: runtimeFiles.reduce(
(total, runtimeFile) => total + runtimeFile.size,
0
),
runtimeAbi: {
baseline: {
distribution: 'Ubuntu 22.04',
glibcMaximum: '2.35',
glibcxxMaximum: '3.4.30',
},
files: runtimeFiles.map(({ name }) => ({
name,
requiredGlibc: '2.34',
requiredGlibcxx: null,
})),
},
runtimeDependencyClosure,
externalSystemLibraries: cloneManifest(EXTERNAL_SYSTEM_LIBRARIES),
};
}
export function createFixture(
rootDir: string,
profile: 'system' | 'portable' | 'flatpak' = 'system'
): RuntimeFixture {
const nativeDir = path.join(rootDir, profile, 'native');
const helperPath = path.join(nativeDir, 'iptvnator_mpv_helper');
const manifestPath = path.join(nativeDir, 'embedded-mpv-runtime.json');
mkdirSync(nativeDir, { recursive: true });
writeFileSync(path.join(nativeDir, 'embedded_mpv.node'), 'addon', {
mode: 0o644,
});
writeFileSync(
path.join(nativeDir, 'embedded_mpv_frame_reader.node'),
'reader',
{ mode: 0o644 }
);
writeFileSync(helperPath, '#!/bin/sh\n', { mode: 0o755 });
const bundled = profile !== 'system';
const runtimeContents = bundled
? {
'libmpv.so': Buffer.from('libmpv-linker-alias'),
'libmpv.so.2': Buffer.from('libmpv-soname'),
}
: {};
const runtimeFiles = createRuntimeFiles(runtimeContents);
const runtimeDependencyClosure = {
entries: runtimeFiles.map(({ name }) => ({
name,
soname: name === 'libmpv.so' ? 'libmpv.so.2' : name,
needed: [],
rpath: [],
runpath: ['$ORIGIN'],
})),
externalDependencies: [],
};
const externalSystemLibraries = cloneManifest(EXTERNAL_SYSTEM_LIBRARIES);
const sourceRuntime = createSourceRuntime(
runtimeFiles,
runtimeDependencyClosure
);
const sourceArchive = {
schemaVersion: 1,
name: 'linux-frame-copy-runtime-sources.tar.xz',
sha256: '7'.repeat(64),
repositoryRevision: '8'.repeat(40),
};
const manifest: Record<string, unknown> = {
schemaVersion: 1,
origin: bundled
? 'bundled-lgpl-frame-copy'
: 'system-libmpv-frame-copy',
generatedAt: '2026-07-17T00:00:00.000Z',
platform: 'linux',
arch: 'x64',
profile,
runtimeMode: bundled ? 'bundled' : 'system',
targets:
profile === 'system'
? ['deb', 'pacman', 'rpm']
: profile === 'portable'
? ['appimage', 'snap']
: ['flatpak'],
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: bundled
? {}
: {
deb: ['libmpv2', 'libegl1', 'libgl1', 'libgbm1'],
rpm: [
'mpv-libs',
'libglvnd-egl',
'libglvnd-glx',
'mesa-libgbm',
],
pacman: ['mpv', 'libglvnd', 'mesa'],
},
runtimeFiles,
runtimeTotalBytes: runtimeFiles.reduce(
(total, runtimeFile) => total + runtimeFile.size,
0
),
...(bundled
? {
runtimeDependencyClosure,
externalSystemLibraries,
sourceArchive,
sourceRuntime,
}
: {}),
};
writeManifest(manifestPath, manifest);
if (bundled) {
const libDir = path.join(nativeDir, 'lib');
mkdirSync(libDir);
for (const [name, contents] of Object.entries(runtimeContents)) {
writeFileSync(path.join(libDir, name), contents, {
mode: 0o644,
});
}
}
return {
nativeDir,
helperPath,
manifestPath,
manifest,
runtimeContents,
};
}
export function createDevelopmentFixture(
rootDir: string,
buildInputMode: 'system-dev' | 'system-build-inputs' | 'bundled-runtime'
): RuntimeFixture {
const bundled = buildInputMode === 'bundled-runtime';
const fixture = createFixture(rootDir, bundled ? 'portable' : 'system');
const packagedSourceRuntime = fixture.manifest.sourceRuntime;
const sourceRuntime =
buildInputMode === 'system-dev'
? {
linuxBackend: 'process-isolated mpv --wid',
warning:
'Development-only unmanaged system libmpv toolchain.',
}
: buildInputMode === 'system-build-inputs'
? {
linuxBackend: 'process-isolated mpv --wid',
buildInputs: {
libmpvDevPackage: 'libmpv-dev',
mpvPackage: 'mpv',
},
sourceDistribution:
'Linux development inputs are supplied by the host package manager.',
}
: packagedSourceRuntime;
const manifest: Record<string, unknown> = {
schemaVersion: 1,
origin: 'linux-frame-copy-build',
generatedAt: '2026-07-17T00:00:00.000Z',
platform: 'linux',
arch: 'x64',
buildInputMode,
sourceRuntimeValidated: bundled,
allowedPackageRuntimeModes: ['system', 'bundled'],
packageRuntimeAvailability: {
system: bundled,
bundled,
},
artifacts: {
addon: 'embedded_mpv.node',
frameReader: 'embedded_mpv_frame_reader.node',
helper: 'iptvnator_mpv_helper',
},
processIsolation: {
addonLoadsLibmpv: false,
helperLinksLibmpv: true,
helperRunpath: ['$ORIGIN/lib'],
},
nativeViewFallback: 'process-isolated mpv --wid',
libmpvSoname: bundled ? 'libmpv.so.2' : null,
runtimeFiles: fixture.manifest.runtimeFiles,
runtimeTotalBytes: fixture.manifest.runtimeTotalBytes,
sourceArchive: bundled
? cloneManifest(fixture.manifest.sourceArchive)
: null,
sourceRuntime,
};
fixture.manifest = manifest;
writeManifest(fixture.manifestPath, manifest);
return fixture;
}
export function probeDevelopmentRuntime(
probeRuntime: ReturnType<typeof createEmbeddedMpvFrameCopyRuntimeProbe>,
helperPath: string
) {
return (
probeRuntime as unknown as (
path: string,
contract: 'development'
) => ReturnType<typeof probeRuntime>
)(helperPath, 'development');
}
export function mirrorBundledManifestFields(
manifest: Record<string, unknown>
): void {
const sourceRuntime = manifest.sourceRuntime as Record<string, unknown>;
sourceRuntime.runtimeFiles = cloneManifest(manifest.runtimeFiles);
sourceRuntime.runtimeTotalBytes = manifest.runtimeTotalBytes;
sourceRuntime.runtimeDependencyClosure = cloneManifest(
manifest.runtimeDependencyClosure
);
sourceRuntime.externalSystemLibraries = cloneManifest(
manifest.externalSystemLibraries
);
}
export function writeManifest(
manifestPath: string,
manifest: Record<string, unknown>
): void {
writeFileSync(manifestPath, `${JSON.stringify(manifest)}\n`, {
mode: 0o644,
});
chmodSync(manifestPath, 0o644);
}
export function cloneManifest<T>(manifest: T): T {
return JSON.parse(JSON.stringify(manifest)) as T;
}
@@ -0,0 +1,74 @@
import { spawnSync } from 'child_process';
import {
accessSync,
lstatSync,
mkdtempSync,
readFileSync,
readdirSync,
rmSync,
} from 'fs';
import { tmpdir } from 'os';
import path from 'path';
import {
createEmbeddedMpvFrameCopyRuntimeProbe,
type EmbeddedMpvFrameCopyRuntimeDependencies,
} from '../embedded-mpv-frame-copy-runtime';
import { SUCCESS_OUTPUT } from './runtime.spec-data';
export interface RuntimeTestContext {
rootDir: string;
spawnRuntimeProbe: jest.Mock;
writeRuntimeProbeStderr: jest.Mock<void, [string]>;
fileSystem: EmbeddedMpvFrameCopyRuntimeDependencies['fileSystem'];
createProbe(
overrides?: Partial<EmbeddedMpvFrameCopyRuntimeDependencies>
): ReturnType<typeof createEmbeddedMpvFrameCopyRuntimeProbe>;
dispose(): void;
}
export function createRuntimeTestContext(): RuntimeTestContext {
const rootDir = mkdtempSync(path.join(tmpdir(), 'iptvnator-fc-runtime-'));
const spawnRuntimeProbe = jest.fn(() => ({
status: 0,
signal: null,
stdout: SUCCESS_OUTPUT,
stderr: '',
}));
const writeRuntimeProbeStderr = jest.fn<void, [string]>();
const fileSystem: EmbeddedMpvFrameCopyRuntimeDependencies['fileSystem'] = {
accessSync: jest.fn((filePath: string, mode: number) =>
accessSync(filePath, mode)
),
lstatSync: jest.fn((filePath: string) => lstatSync(filePath)),
readFileSync: jest.fn((filePath: string) => readFileSync(filePath)),
readdirSync: jest.fn((filePath: string) => readdirSync(filePath)),
};
const context: RuntimeTestContext = {
rootDir,
spawnRuntimeProbe,
writeRuntimeProbeStderr,
fileSystem,
createProbe(
overrides: Partial<EmbeddedMpvFrameCopyRuntimeDependencies> = {}
) {
return createEmbeddedMpvFrameCopyRuntimeProbe({
platform: 'linux',
arch: 'x64',
env: {
PATH: '/usr/bin',
LD_AUDIT: '/tmp/audit.so',
LD_LIBRARY_PATH: '/ambient/libs',
LD_PRELOAD: '/tmp/inject.so',
},
fileSystem: context.fileSystem,
spawnSync: context.spawnRuntimeProbe as typeof spawnSync,
writeStderr: context.writeRuntimeProbeStderr,
...overrides,
});
},
dispose() {
rmSync(context.rootDir, { recursive: true, force: true });
},
};
return context;
}
@@ -0,0 +1,179 @@
export const SUCCESS_OUTPUT =
'{"protocol":1,"usable":true,"libmpv":"2.3","renderApi":"egl"}\n';
export const EXTERNAL_SYSTEM_LIBRARIES = [
{
name: 'libEGL.so.1',
interface: 'EGL',
reason: 'System graphics-driver interface used by the frame-copy helper.',
},
{
name: 'libGL.so.1',
interface: 'OpenGL',
reason: 'System OpenGL compatibility interface supplied by the graphics stack.',
},
{
name: 'libGLX.so.0',
interface: 'OpenGL',
reason: 'GLVND OpenGL dispatch interface supplied by the graphics stack.',
},
{
name: 'libOpenGL.so.0',
interface: 'OpenGL',
reason: 'GLVND OpenGL interface supplied by the graphics stack.',
},
{
name: 'libasound.so.2',
interface: 'ALSA',
reason: 'Linux system audio interface intentionally used by libmpv.',
},
{
name: 'libdrm.so.2',
interface: 'DRM',
reason: 'Kernel graphics interface used by system GBM and VA-API drivers.',
},
{
name: 'libgbm.so.1',
interface: 'GBM',
reason: 'System graphics-buffer interface used by headless EGL rendering.',
},
{
name: 'libpulse.so.0',
interface: 'PulseAudio',
reason: 'Linux desktop audio interface intentionally used by libmpv.',
},
{
name: 'libva-drm.so.2',
interface: 'VA-API DRM',
reason: 'System VA-API DRM interface used for hardware decoding.',
},
{
name: 'libva.so.2',
interface: 'VA-API',
reason: 'System video-acceleration interface used for hardware decoding.',
},
];
export const PINNED_SOURCE_PACKAGE_IDENTITIES = {
freetype: {
version: '2.13.3',
sourceUrl:
'https://download.savannah.gnu.org/releases/freetype/freetype-2.13.3.tar.xz',
sourceSha256:
'0550350666d427c74daeb85d5ac7bb353acba5f76956395995311a9c6f063289',
license: 'FreeType License (FTL)',
},
fribidi: {
version: '1.0.16',
sourceUrl:
'https://github.com/fribidi/fribidi/releases/download/v1.0.16/fribidi-1.0.16.tar.xz',
sourceSha256:
'1b1cde5b235d40479e91be2f0e88a309e3214c8ab470ec8a2744d82a5a9ea05c',
license: 'LGPL-2.1-or-later',
},
harfbuzz: {
version: '8.5.0',
sourceUrl:
'https://github.com/harfbuzz/harfbuzz/releases/download/8.5.0/harfbuzz-8.5.0.tar.xz',
sourceSha256:
'77e4f7f98f3d86bf8788b53e6832fb96279956e1c3961988ea3d4b7ca41ddc27',
license: 'MIT',
},
expat: {
version: '2.8.2',
sourceUrl:
'https://github.com/libexpat/libexpat/releases/download/R_2_8_2/expat-2.8.2.tar.xz',
sourceSha256:
'3ad89b8588e6644bd4e49981480d48b21289eebbcd4f0a1a4afb1c29f99b6ab4',
license: 'MIT',
},
fontconfig: {
version: '2.16.0',
sourceUrl:
'https://www.freedesktop.org/software/fontconfig/release/fontconfig-2.16.0.tar.xz',
sourceSha256:
'6a33dc555cc9ba8b10caf7695878ef134eeb36d0af366041f639b1da9b6ed220',
license: 'MIT',
},
libass: {
version: '0.17.3',
sourceUrl:
'https://github.com/libass/libass/releases/download/0.17.3/libass-0.17.3.tar.xz',
sourceSha256:
'eae425da50f0015c21f7b3a9c7262a910f0218af469e22e2931462fed3c50959',
license: 'ISC',
},
openssl: {
version: '3.5.7',
sourceUrl:
'https://github.com/openssl/openssl/releases/download/openssl-3.5.7/openssl-3.5.7.tar.gz',
sourceSha256:
'a8c0d28a529ca480f9f36cf5792e2cd21984552a3c8e4aa11a24aa31aeac98e8',
license: 'Apache-2.0',
},
ffmpeg: {
version: '8.1',
sourceUrl: 'https://ffmpeg.org/releases/ffmpeg-8.1.tar.xz',
sourceSha256:
'b072aed6871998cce9b36e7774033105ca29e33632be5b6347f3206898e0756a',
license: 'LGPL-2.1-or-later',
},
libplacebo: {
version: '7.360.1',
sourceUrl: 'https://github.com/haasn/libplacebo.git',
sourceTag: 'v7.360.1',
sourceGitCommit: 'cee9b076f2c63104ccfd497fa79c39a867293ec4',
sourceSubmodules: [
'450bd2232225d6c7728a4108055ac2e37cef6475 3rdparty/Vulkan-Headers',
'97b54ca9e75f5303507699d27c6b4f4efe4641a1 3rdparty/fast_float',
'73db193f853e2ee079bf3ca8a64aa2eaf6459043 3rdparty/glad',
'15206881c006c79667fe5154fe80c01c65410679 3rdparty/jinja',
'297fc8e356e6836a62087949245d09a28e9f1b13 3rdparty/markupsafe',
'242f35efa067a46c595645eeda7b1771ea1f83b1 demos/3rdparty/nuklear',
],
license: 'LGPL-2.1-or-later',
},
hwdata: {
version: '0.409',
sourceUrl:
'https://github.com/vcrhonek/hwdata/archive/refs/tags/v0.409.tar.gz',
sourceSha256:
'23006accc0f931dd5187d0307a57d0744e2b8feb85e73c37bc0f5229fb31eadd',
buildInput: {
consumer: 'libdisplay-info',
relativePath: 'pnp.ids',
purpose: 'PNP vendor lookup table compiled into libdisplay-info.',
},
license: 'GPL-2.0-or-later OR XFree86-1.0',
},
'libdisplay-info': {
version: '0.1.1',
sourceUrl:
'https://gitlab.freedesktop.org/emersion/libdisplay-info/-/releases/0.1.1/downloads/libdisplay-info-0.1.1.tar.xz',
sourceSha256:
'0d8731588e9f82a9cac96324a3d7c82e2ba5b1b5e006143fefe692c74069fb60',
license: 'MIT',
},
mpv: {
version: '0.41.0',
sourceUrl:
'https://github.com/mpv-player/mpv/archive/refs/tags/v0.41.0.tar.gz',
sourceSha256:
'ee21092a5ee427353392360929dc64645c54479aefdb5babc5cfbb5fad626209',
license: 'LGPL-2.1-or-later with -Dgpl=false',
},
};
export interface RuntimeFile {
name: string;
size: number;
sha256: string;
}
export interface RuntimeFixture {
nativeDir: string;
helperPath: string;
manifestPath: string;
manifest: Record<string, unknown>;
runtimeContents: Record<string, Buffer>;
}
@@ -0,0 +1,150 @@
import {
cloneManifest,
createFixture,
writeManifest,
} from './runtime-fixtures.test-helpers';
import {
createRuntimeTestContext,
type RuntimeTestContext,
} from './runtime-harness.test-helpers';
describe('embedded-mpv frame-copy source runtime policy', () => {
let context: RuntimeTestContext;
beforeEach(() => {
context = createRuntimeTestContext();
});
afterEach(() => {
context.dispose();
});
it.each([
{
label: 'pinned source identity',
mutate(sourceRuntime: Record<string, unknown>) {
const packages = sourceRuntime.packages as Record<
string,
Record<string, unknown>
>;
packages.mpv.sourceSha256 = '0'.repeat(64);
},
},
{
label: 'pinned source URL',
mutate(sourceRuntime: Record<string, unknown>) {
const packages = sourceRuntime.packages as Record<
string,
Record<string, unknown>
>;
packages.freetype.sourceUrl =
'https://example.invalid/freetype.tar.xz';
},
},
{
label: 'pinned git tag',
mutate(sourceRuntime: Record<string, unknown>) {
const packages = sourceRuntime.packages as Record<
string,
Record<string, unknown>
>;
packages.libplacebo.sourceTag = 'main';
},
},
{
label: 'pinned hwdata build input',
mutate(sourceRuntime: Record<string, unknown>) {
const packages = sourceRuntime.packages as Record<
string,
Record<string, unknown>
>;
packages.hwdata.buildInput = {
consumer: 'libdisplay-info',
relativePath: '../pnp.ids',
purpose:
'PNP vendor lookup table compiled into libdisplay-info.',
};
},
},
{
label: 'git submodule record',
mutate(sourceRuntime: Record<string, unknown>) {
const packages = sourceRuntime.packages as Record<
string,
Record<string, unknown>
>;
packages.libplacebo.sourceSubmodules = [
`${'a'.repeat(40)} ../outside`,
];
},
},
{
label: 'duplicate git submodule records',
mutate(sourceRuntime: Record<string, unknown>) {
const packages = sourceRuntime.packages as Record<
string,
Record<string, unknown>
>;
const record = `${'a'.repeat(40)} 3rdparty/example`;
packages.libplacebo.sourceSubmodules = [record, record];
},
},
{
label: 'unpinned git submodule commit',
mutate(sourceRuntime: Record<string, unknown>) {
const packages = sourceRuntime.packages as Record<
string,
Record<string, unknown>
>;
packages.libplacebo.sourceSubmodules = [
`${'f'.repeat(40)} 3rdparty/Vulkan-Headers`,
`${'e'.repeat(40)} 3rdparty/fast_float`,
];
},
},
{
label: 'portable ABI baseline',
mutate(sourceRuntime: Record<string, unknown>) {
const runtimeAbi = sourceRuntime.runtimeAbi as {
baseline: Record<string, unknown>;
};
runtimeAbi.baseline.glibcMaximum = '9.99';
},
},
{
label: 'source-distribution obligation',
mutate(sourceRuntime: Record<string, unknown>) {
sourceRuntime.sourceDistribution = 'Sources available.';
},
},
{
label: 'FFmpeg LGPL flags',
mutate(sourceRuntime: Record<string, unknown>) {
const ffmpeg = sourceRuntime.ffmpeg as {
configureFlags: string[];
};
ffmpeg.configureFlags = ['--disable-nonfree', '--enable-gpl'];
},
},
{
label: 'mpv LGPL flags',
mutate(sourceRuntime: Record<string, unknown>) {
const mpv = sourceRuntime.mpv as {
mesonFlags: string[];
};
mpv.mesonFlags = ['-Dgpl=true', '-Dlibmpv=true'];
},
},
])('rejects an invalid $label', ({ mutate }) => {
const fixture = createFixture(context.rootDir, 'portable');
const manifest = cloneManifest(fixture.manifest);
mutate(manifest.sourceRuntime as Record<string, unknown>);
writeManifest(fixture.manifestPath, manifest);
expect(context.createProbe()(fixture.helperPath)).toEqual({
usable: false,
reason: 'runtime-manifest-invalid',
});
expect(context.spawnRuntimeProbe).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,247 @@
import path from 'path';
import { isDeepStrictEqual } from 'util';
import {
GIT_COMMIT_PATTERN,
PINNED_SOURCE_PACKAGE_IDENTITIES,
PORTABLE_ABI_BASELINE,
SUBMODULE_RECORD_PATTERN,
VERSION_PATTERN,
} from './contracts';
import type { RuntimeFile } from './types';
import {
hasExactFields,
isObject,
isSafeRuntimeName,
} from './validation-primitives';
function compareDottedVersions(left: string, right: string): number {
const leftParts = left.split('.').map(Number);
const rightParts = right.split('.').map(Number);
const length = Math.max(leftParts.length, rightParts.length);
for (let index = 0; index < length; index += 1) {
const difference = (leftParts[index] ?? 0) - (rightParts[index] ?? 0);
if (difference !== 0) {
return difference;
}
}
return 0;
}
function validatesPinnedSourceIdentity(
candidate: unknown,
expected: (typeof PINNED_SOURCE_PACKAGE_IDENTITIES)[keyof typeof PINNED_SOURCE_PACKAGE_IDENTITIES]
): boolean {
if (
!isObject(candidate) ||
candidate.version !== expected.version ||
candidate.sourceUrl !== expected.sourceUrl ||
candidate.license !== expected.license
) {
return false;
}
if (
('sourceTag' in expected
? candidate.sourceTag !== expected.sourceTag
: candidate.sourceTag !== undefined) ||
('buildInput' in expected
? !isDeepStrictEqual(candidate.buildInput, expected.buildInput)
: candidate.buildInput !== undefined)
) {
return false;
}
if ('sourceSha256' in expected) {
return (
candidate.sourceSha256 === expected.sourceSha256 &&
candidate.sourceGitCommit === undefined &&
candidate.sourceSubmodules === undefined
);
}
return (
'sourceSubmodules' in expected &&
candidate.sourceGitCommit === expected.sourceGitCommit &&
GIT_COMMIT_PATTERN.test(candidate.sourceGitCommit) &&
candidate.sourceSha256 === undefined &&
validatesGitSubmoduleRecords(candidate.sourceSubmodules) &&
isDeepStrictEqual(candidate.sourceSubmodules, expected.sourceSubmodules)
);
}
function validatesGitSubmoduleRecords(value: unknown): boolean {
if (
!Array.isArray(value) ||
value.length === 0 ||
new Set(value).size !== value.length
) {
return false;
}
return value.every((record) => {
if (typeof record !== 'string') {
return false;
}
const match = record.match(SUBMODULE_RECORD_PATTERN);
if (!match) {
return false;
}
const submodulePath = match[1];
return (
!path.posix.isAbsolute(submodulePath) &&
!submodulePath
.split('/')
.some((segment) => segment === '.' || segment === '..')
);
});
}
function validateStringFlags(value: unknown): value is string[] {
return (
Array.isArray(value) &&
value.every(
(flag) =>
typeof flag === 'string' &&
flag.length > 0 &&
flag.trim() === flag
) &&
new Set(value).size === value.length
);
}
function validateRuntimeAbi(
value: unknown,
runtimeFiles: RuntimeFile[]
): boolean {
if (
!isObject(value) ||
!hasExactFields(value, ['baseline', 'files']) ||
!isDeepStrictEqual(value.baseline, PORTABLE_ABI_BASELINE) ||
!Array.isArray(value.files)
) {
return false;
}
const abiFileNames: string[] = [];
for (const record of value.files) {
if (
!isObject(record) ||
!hasExactFields(record, [
'name',
'requiredGlibc',
'requiredGlibcxx',
]) ||
!isSafeRuntimeName(record.name)
) {
return false;
}
for (const [field, maximum] of [
['requiredGlibc', PORTABLE_ABI_BASELINE.glibcMaximum],
['requiredGlibcxx', PORTABLE_ABI_BASELINE.glibcxxMaximum],
] as const) {
const version = record[field];
if (
version !== null &&
(typeof version !== 'string' ||
!VERSION_PATTERN.test(version) ||
compareDottedVersions(version, maximum) > 0)
) {
return false;
}
}
abiFileNames.push(record.name);
}
return isDeepStrictEqual(
abiFileNames,
runtimeFiles.map(({ name }) => name)
);
}
/**
* The source builder and package verifier own exhaustive build-host, recipe,
* tool-version, URL and external-configuration validation. Startup repeats
* only the immutable policy boundary needed before sandbox relaxation:
* pinned source identities/licenses, LGPL flags, portable ABI, display-data
* distribution, and the exact runtime closure mirrored by the package.
*/
export function validateSourceRuntimePolicy(
value: unknown,
runtimeFiles: RuntimeFile[],
runtimeDependencyClosure: unknown,
externalSystemLibraries: unknown
): boolean {
if (
!isObject(value) ||
value.schemaVersion !== 1 ||
value.origin !== 'vendored-lgpl-source-build' ||
value.platform !== 'linux' ||
value.arch !== 'x64' ||
!isObject(value.packages) ||
!isObject(value.ffmpeg) ||
!isObject(value.mpv) ||
!isDeepStrictEqual(
Object.keys(value.packages).sort(),
Object.keys(PINNED_SOURCE_PACKAGE_IDENTITIES).sort()
)
) {
return false;
}
for (const [packageName, expectedIdentity] of Object.entries(
PINNED_SOURCE_PACKAGE_IDENTITIES
)) {
if (
!validatesPinnedSourceIdentity(
value.packages[packageName],
expectedIdentity
)
) {
return false;
}
}
if (
!validatesPinnedSourceIdentity(
value.ffmpeg,
PINNED_SOURCE_PACKAGE_IDENTITIES.ffmpeg
) ||
!validateStringFlags(value.ffmpeg.configureFlags) ||
!value.ffmpeg.configureFlags.includes('--disable-gpl') ||
!value.ffmpeg.configureFlags.includes('--disable-nonfree') ||
value.ffmpeg.configureFlags.includes('--enable-gpl') ||
value.ffmpeg.configureFlags.includes('--enable-nonfree') ||
!validatesPinnedSourceIdentity(
value.mpv,
PINNED_SOURCE_PACKAGE_IDENTITIES.mpv
) ||
!validateStringFlags(value.mpv.mesonFlags) ||
!value.mpv.mesonFlags.includes('-Dgpl=false') ||
!value.mpv.mesonFlags.includes('-Dlibmpv=true') ||
value.mpv.mesonFlags.includes('-Dgpl=true')
) {
return false;
}
if (
typeof value.sourceDistribution !== 'string' ||
!/\bhwdata\b/i.test(value.sourceDistribution) ||
!/\bpnp\.ids\b/i.test(value.sourceDistribution) ||
!/\blibdisplay-info\b/i.test(value.sourceDistribution) ||
value.runtimeTotalBytes !==
runtimeFiles.reduce(
(total, runtimeFile) => total + runtimeFile.size,
0
) ||
!isDeepStrictEqual(value.runtimeFiles, runtimeFiles) ||
!isDeepStrictEqual(
value.runtimeDependencyClosure,
runtimeDependencyClosure
) ||
!isDeepStrictEqual(
value.externalSystemLibraries,
externalSystemLibraries
) ||
!validateRuntimeAbi(value.runtimeAbi, runtimeFiles)
) {
return false;
}
return true;
}
@@ -0,0 +1,51 @@
import path from 'path';
const TRUSTED_SNAP_MOUNT_ROOTS = ['/snap', '/var/lib/snapd/snap'] as const;
function isPathInside(
parentPath: string,
candidatePath: string,
allowEqual: boolean
): boolean {
const relativePath = path.relative(parentPath, candidatePath);
if (relativePath === '') {
return allowEqual;
}
return (
relativePath !== '..' &&
!relativePath.startsWith(`..${path.sep}`) &&
!path.isAbsolute(relativePath)
);
}
export function resolveTrustedSnapRoot(
environment: NodeJS.ProcessEnv,
nativeDir: string
): string | null {
const declaredSnapRoot = environment.SNAP;
if (
!declaredSnapRoot ||
!path.isAbsolute(declaredSnapRoot) ||
!path.isAbsolute(nativeDir)
) {
return null;
}
const normalizedSnapRoot = path.resolve(declaredSnapRoot);
const resemblesReadOnlySnapMount = TRUSTED_SNAP_MOUNT_ROOTS.some(
(mountRoot) => {
const relativePath = path.relative(mountRoot, normalizedSnapRoot);
return (
isPathInside(mountRoot, normalizedSnapRoot, false) &&
relativePath.split(path.sep).filter(Boolean).length >= 2
);
}
);
if (
!resemblesReadOnlySnapMount ||
!isPathInside(normalizedSnapRoot, path.resolve(nativeDir), false)
) {
return null;
}
return normalizedSnapRoot;
}
@@ -0,0 +1,103 @@
import type { spawnSync as nodeSpawnSync } from 'child_process';
import type * as nodeFileSystem from 'fs';
export const EMBEDDED_MPV_HELPER_RUNTIME_PROBE_FAILURE_REASONS = {
MPV_CREATE_FAILED: 'mpv-create-failed',
MPV_INITIALIZE_FAILED: 'mpv-initialize-failed',
GL_CONTEXT_CREATE_FAILED: 'gl-context-create-failed',
GL_CONTEXT_BIND_FAILED: 'gl-context-bind-failed',
MPV_RENDER_CONTEXT_FAILED: 'mpv-render-context-failed',
SHARED_MEMORY_CREATE_FAILED: 'shared-memory-create-failed',
SHARED_MEMORY_INITIALIZE_FAILED: 'shared-memory-initialize-failed',
} as const;
export type EmbeddedMpvHelperRuntimeProbeFailureReason =
(typeof EMBEDDED_MPV_HELPER_RUNTIME_PROBE_FAILURE_REASONS)[keyof typeof EMBEDDED_MPV_HELPER_RUNTIME_PROBE_FAILURE_REASONS];
export type EmbeddedMpvFrameCopyRuntimeFailureReason =
| 'unsupported-platform'
| 'unsupported-architecture'
| 'runtime-manifest-missing'
| 'runtime-manifest-invalid'
| 'runtime-artifact-missing'
| 'runtime-artifact-invalid'
| 'runtime-library-directory-invalid'
| 'runtime-library-missing'
| 'runtime-library-undeclared'
| 'runtime-library-invalid'
| 'runtime-library-size-mismatch'
| 'runtime-library-hash-mismatch'
| 'snap-graphics-provider-unavailable'
| 'helper-probe-timeout'
| 'helper-probe-spawn-error'
| 'helper-probe-signaled'
| 'helper-probe-failed'
| 'helper-probe-invalid-output'
| 'helper-probe-protocol-mismatch'
| 'helper-probe-unusable'
| 'runtime-probe-internal-error';
export type EmbeddedMpvFrameCopyManifestContract = 'packaged' | 'development';
export type EmbeddedMpvFrameCopyRuntimeMode = 'system' | 'bundled';
export type RuntimeProfile = 'system' | 'portable' | 'flatpak';
export type RuntimeProbeProfile = RuntimeProfile | 'development';
export type EmbeddedMpvFrameCopyRuntimeResult =
| {
usable: true;
profile: RuntimeProbeProfile;
runtimeMode: EmbeddedMpvFrameCopyRuntimeMode;
libmpv: string;
renderApi: 'egl';
}
| {
usable: false;
reason: EmbeddedMpvFrameCopyRuntimeFailureReason;
helperReason?: EmbeddedMpvHelperRuntimeProbeFailureReason;
helperDetail?: string;
};
export interface EmbeddedMpvFrameCopyRuntimeFileSystem {
accessSync(filePath: string, mode: number): void;
lstatSync(filePath: string): nodeFileSystem.Stats;
readFileSync(filePath: string): Buffer;
readdirSync(filePath: string): string[];
}
export interface EmbeddedMpvFrameCopyRuntimeDependencies {
platform: NodeJS.Platform;
arch: string;
env: NodeJS.ProcessEnv;
fileSystem: EmbeddedMpvFrameCopyRuntimeFileSystem;
spawnSync: typeof nodeSpawnSync;
writeStderr(output: string): void;
}
export interface RuntimeFile {
name: string;
size: number;
sha256: string;
}
export interface ValidManifest {
profile: RuntimeProbeProfile;
runtimeMode: EmbeddedMpvFrameCopyRuntimeMode;
runtimeFiles: RuntimeFile[];
}
export interface ValidatedPackage {
manifest: ValidManifest;
helperPath: string;
nativeDir: string;
}
export interface ValidationSuccess<T> {
value: T;
}
export interface ValidationFailure {
reason: EmbeddedMpvFrameCopyRuntimeFailureReason;
}
export type ValidationResult<T> = ValidationSuccess<T> | ValidationFailure;
@@ -0,0 +1,162 @@
import { createHash } from 'crypto';
import type * as nodeFileSystem from 'fs';
import path from 'path';
import { isDeepStrictEqual } from 'util';
import {
SAFE_RUNTIME_NAME_PATTERN,
SHA256_PATTERN,
SHARED_LIBRARY_PATTERN,
} from './contracts';
import type {
EmbeddedMpvFrameCopyRuntimeFailureReason,
EmbeddedMpvFrameCopyRuntimeFileSystem,
EmbeddedMpvFrameCopyRuntimeResult,
RuntimeFile,
ValidationFailure,
ValidationResult,
} from './types';
export function failure(
reason: EmbeddedMpvFrameCopyRuntimeFailureReason
): EmbeddedMpvFrameCopyRuntimeResult {
return { usable: false, reason };
}
export function validationFailure(
reason: EmbeddedMpvFrameCopyRuntimeFailureReason
): ValidationFailure {
return { reason };
}
export function isValidationFailure<T>(
result: ValidationResult<T>
): result is ValidationFailure {
return 'reason' in result;
}
export function isObject(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === 'object' && !Array.isArray(value);
}
export function hasExactFields(
value: Record<string, unknown>,
fields: readonly string[]
): boolean {
return isDeepStrictEqual(Object.keys(value).sort(), [...fields].sort());
}
export function isSafeRuntimeName(value: unknown): value is string {
return (
typeof value === 'string' &&
value.length > 0 &&
value !== '.' &&
value !== '..' &&
path.basename(value) === value &&
!value.includes('/') &&
!value.includes('\\') &&
SAFE_RUNTIME_NAME_PATTERN.test(value) &&
SHARED_LIBRARY_PATTERN.test(value)
);
}
export function isMissingFileError(error: unknown): boolean {
return (
isObject(error) &&
typeof error.code === 'string' &&
(error.code === 'ENOENT' || error.code === 'ENOTDIR')
);
}
export function fileIdentity(
filePath: string,
stat: nodeFileSystem.Stats,
contents: Buffer
): string {
return [
path.resolve(filePath),
stat.dev,
stat.ino,
stat.mode,
stat.size,
stat.mtimeMs,
stat.ctimeMs,
createHash('sha256').update(contents).digest('hex'),
].join(':');
}
export function readManifest(
manifestPath: string,
fileSystem: EmbeddedMpvFrameCopyRuntimeFileSystem
): ValidationResult<Record<string, unknown>> {
let contents: Buffer;
try {
contents = fileSystem.readFileSync(manifestPath);
} catch {
return validationFailure('runtime-manifest-invalid');
}
try {
const parsed: unknown = JSON.parse(contents.toString('utf8'));
return isObject(parsed)
? { value: parsed }
: validationFailure('runtime-manifest-invalid');
} catch {
return validationFailure('runtime-manifest-invalid');
}
}
export function validateTargets(
targets: unknown,
allowedTargets: ReadonlySet<string>
): boolean {
const expectedTargets = [...allowedTargets].sort();
if (
!Array.isArray(targets) ||
targets.length !== expectedTargets.length ||
targets.some(
(target) =>
typeof target !== 'string' ||
target.trim() !== target ||
target.toLowerCase() !== target ||
!allowedTargets.has(target)
)
) {
return false;
}
return isDeepStrictEqual(targets, expectedTargets);
}
export function validateRuntimeFiles(value: unknown): RuntimeFile[] | null {
if (!Array.isArray(value) || value.length === 0) {
return null;
}
const runtimeFiles: RuntimeFile[] = [];
const names = new Set<string>();
for (const candidate of value) {
if (
!isObject(candidate) ||
!hasExactFields(candidate, ['name', 'sha256', 'size']) ||
!isSafeRuntimeName(candidate.name) ||
!Number.isSafeInteger(candidate.size) ||
(candidate.size as number) <= 0 ||
typeof candidate.sha256 !== 'string' ||
!SHA256_PATTERN.test(candidate.sha256) ||
names.has(candidate.name)
) {
return null;
}
names.add(candidate.name);
runtimeFiles.push({
name: candidate.name,
size: candidate.size as number,
sha256: candidate.sha256,
});
}
return isDeepStrictEqual(
runtimeFiles.map(({ name }) => name).sort(),
runtimeFiles.map(({ name }) => name)
)
? runtimeFiles
: null;
}
@@ -1,4 +1,5 @@
import { EventEmitter } from 'events';
import type { Stats } from 'fs';
import path from 'path';
const spawnMock = jest.fn();
@@ -7,6 +8,55 @@ jest.mock('child_process', () => ({
}));
import { EmbeddedMpvFrameCopyAdapter } from './embedded-mpv-frame-copy.adapter';
import type { EmbeddedMpvFrameCopyRuntimeMode } from './embedded-mpv-frame-copy-runtime';
const HOSTILE_LOADER_ENVIRONMENT = {
BASH_ENV: '/tmp/hostile-bash-env',
ENV: '/tmp/hostile-shell-env',
BASHOPTS: 'extdebug',
SHELLOPTS: 'xtrace',
PS4: '$(/tmp/hostile-trace-hook)',
BASH_XTRACEFD: '9',
CDPATH: '/tmp/hostile-cdpath',
'BASH_FUNC_dirname%%': '() { printf /tmp/hostile-provider-root; exit 0; }',
LD_AUDIT: '/tmp/audit.so',
LD_LIBRARY_PATH: '/tmp/hostile-libs',
LD_ORIGIN_PATH: '/tmp/hostile-origin',
LD_PRELOAD: '/tmp/inject.so',
__EGL_VENDOR_LIBRARY_FILENAMES: '/tmp/hostile-egl-vendor.json',
__EGL_VENDOR_LIBRARY_DIRS: '/tmp/hostile-egl-vendor-dir',
__EGL_EXTERNAL_PLATFORM_CONFIG_DIRS: '/tmp/hostile-egl-platform',
__EGL_EXTERNAL_PLATFORM_CONFIG_FILENAMES: '/tmp/hostile-egl-platform.json',
GBM_BACKEND: '../../../../../tmp/hostile-gbm',
GBM_BACKENDS_PATH: '/tmp/hostile-gbm-path',
LIBGL_DRIVERS_PATH: '/tmp/hostile-dri-path',
MESA_LOADER_DRIVER_OVERRIDE: '../../../../../tmp/hostile-dri',
LIBVA_DRIVER_NAME: '../../../../../tmp/hostile-va',
LIBVA_DRIVERS_PATH: '/tmp/hostile-va-path',
VDPAU_DRIVER_PATH: '/tmp/hostile-vdpau',
VK_DRIVER_FILES: '/tmp/hostile-vulkan-driver.json',
VK_ICD_FILENAMES: '/tmp/hostile-vulkan-icd.json',
VK_ADD_DRIVER_FILES: '/tmp/hostile-vulkan-add-driver.json',
VK_ADD_LAYER_PATH: '/tmp/hostile-vulkan-layers',
VK_IMPLICIT_LAYER_PATH: '/tmp/hostile-vulkan-implicit-layers',
VK_ADD_IMPLICIT_LAYER_PATH: '/tmp/hostile-vulkan-add-implicit-layers',
VK_LAYER_PATH: '/tmp/hostile-vulkan-layer-path',
} as const;
const GRAPHICS_SELECTOR_ENVIRONMENT = {
LIBGL_ALWAYS_SOFTWARE: '1',
GALLIUM_DRIVER: 'llvmpipe',
} as const;
function fakeStat(
kind: 'directory' | 'file'
): Pick<Stats, 'isDirectory' | 'isFile' | 'isSymbolicLink'> {
return {
isDirectory: () => kind === 'directory',
isFile: () => kind === 'file',
isSymbolicLink: () => false,
};
}
class FakeHelperProcess extends EventEmitter {
exitCode: number | null = null;
@@ -36,14 +86,34 @@ describe('EmbeddedMpvFrameCopyAdapter', () => {
let frameSourceChanges: Array<{ sessionId: string; shmName: string }>;
let adapter: EmbeddedMpvFrameCopyAdapter;
const createAdapter = (helperPath: string | null = '/native/helper') => {
const createAdapter = (
helperPath: string | null = '/native/helper',
{
runtimeMode = 'system',
environment,
helperLaunchFileSystem,
}: {
runtimeMode?: EmbeddedMpvFrameCopyRuntimeMode | null;
environment?: NodeJS.ProcessEnv;
helperLaunchFileSystem?: {
lstatSync(filePath: string): Stats;
accessSync(filePath: string, mode: number): void;
};
} = {}
) => {
frameSourceChanges = [];
return new EmbeddedMpvFrameCopyAdapter({
resolveHelperPath: () => helperPath,
resolveRuntimeMode: () => runtimeMode,
environment,
helperLaunchFileSystem,
getScaleFactor: () => 2,
onFrameSourceChanged: (sessionId, source) =>
frameSourceChanges.push({ sessionId, shmName: source.shmName }),
});
frameSourceChanges.push({
sessionId,
shmName: source.shmName,
}),
} as ConstructorParameters<typeof EmbeddedMpvFrameCopyAdapter>[0]);
};
beforeEach(() => {
@@ -83,6 +153,269 @@ describe('EmbeddedMpvFrameCopyAdapter', () => {
]);
});
describe('Linux loader environment', () => {
const originalPlatform = process.platform;
const originalArch = process.arch;
beforeEach(() => {
Object.defineProperty(process, 'platform', { value: 'linux' });
Object.defineProperty(process, 'arch', { value: 'x64' });
});
afterEach(() => {
Object.defineProperty(process, 'platform', {
value: originalPlatform,
});
Object.defineProperty(process, 'arch', { value: originalArch });
});
it('uses a sanitized system environment for the real helper session', () => {
adapter = createAdapter('/opt/iptvnator/native/helper', {
runtimeMode: 'system',
environment: {
PATH: '/usr/bin',
HOME: '/home/user',
...HOSTILE_LOADER_ENVIRONMENT,
...GRAPHICS_SELECTOR_ENVIRONMENT,
},
});
createSession();
expect(spawnMock.mock.calls[0][2]).toEqual({
stdio: ['pipe', 'pipe', 'pipe'],
env: {
PATH: '/usr/bin',
HOME: '/home/user',
...GRAPHICS_SELECTOR_ENVIRONMENT,
},
});
});
it('keeps trusted Snap GL roots ahead of generic Snap libraries for playback', () => {
const snapRoot = '/snap/iptvnator/42';
const nativeDir = path.join(
snapRoot,
'resources',
'app.asar.unpacked',
'electron-backend',
'native'
);
adapter = createAdapter(path.join(nativeDir, 'helper'), {
runtimeMode: 'bundled',
helperLaunchFileSystem: {
lstatSync: (candidatePath) =>
fakeStat(
candidatePath.endsWith('/graphics')
? 'directory'
: 'file'
) as Stats,
accessSync: () => undefined,
},
environment: {
PATH: '/snap/bin:/usr/bin',
SNAP: snapRoot,
SNAP_LIBRARY_PATH: '/var/lib/snapd/lib/gl:/tmp/hostile-gl',
SNAP_DESKTOP_ARCH_TRIPLET: 'hostile-linux-gnu',
SNAP_DESKTOP_RUNTIME: path.join(snapRoot, 'gnome-platform'),
GBM_BACKENDS_PATH: '/tmp/hostile-gbm',
LIBGL_DRIVERS_PATH: '/tmp/hostile-dri',
LIBVA_DRIVERS_PATH: '/tmp/hostile-va',
__EGL_EXTERNAL_PLATFORM_CONFIG_DIRS:
'/tmp/hostile-egl-platform',
__EGL_VENDOR_LIBRARY_DIRS: '/tmp/hostile-egl-vendor',
VK_LAYER_PATH: '/tmp/hostile-vulkan',
XDG_CONFIG_HOME: '/tmp/hostile-xdg-config-home',
XDG_CONFIG_DIRS: '/tmp/hostile-xdg-config-dirs',
XDG_DATA_HOME: '/tmp/hostile-xdg-data-home',
XDG_DATA_DIRS: '/tmp/hostile-xdg-data-dirs',
...HOSTILE_LOADER_ENVIRONMENT,
...GRAPHICS_SELECTOR_ENVIRONMENT,
},
});
createSession();
expect(spawnMock.mock.calls[0][0]).toBe(
path.join(
snapRoot,
'graphics',
'bin',
'graphics-core22-provider-wrapper'
)
);
expect(spawnMock.mock.calls[0][1][0]).toBe(
path.join(nativeDir, 'helper')
);
expect(spawnMock.mock.calls[0][2]).toEqual({
stdio: ['pipe', 'pipe', 'pipe'],
env: {
PATH: '/usr/sbin:/usr/bin:/sbin:/bin',
SNAP: snapRoot,
SNAP_LIBRARY_PATH: '/var/lib/snapd/lib/gl',
SNAP_ARCH: 'amd64',
SNAP_DESKTOP_ARCH_TRIPLET: 'x86_64-linux-gnu',
SNAP_DESKTOP_RUNTIME: path.join(snapRoot, 'gnome-platform'),
...GRAPHICS_SELECTOR_ENVIRONMENT,
GBM_BACKENDS_PATH: [
path.join(
snapRoot,
'graphics',
'usr',
'lib',
'x86_64-linux-gnu',
'gbm'
),
'/var/lib/snapd/lib/gl/gbm',
].join(':'),
LIBGL_DRIVERS_PATH: path.join(
snapRoot,
'graphics',
'usr',
'lib',
'x86_64-linux-gnu',
'dri'
),
LIBVA_DRIVERS_PATH: path.join(
snapRoot,
'graphics',
'usr',
'lib',
'x86_64-linux-gnu',
'dri'
),
__EGL_EXTERNAL_PLATFORM_CONFIG_DIRS: path.join(
snapRoot,
'graphics',
'usr',
'share',
'egl',
'egl_external_platform.d'
),
__EGL_VENDOR_LIBRARY_DIRS: [
'/var/lib/snapd/lib/glvnd/egl_vendor.d',
path.join(
snapRoot,
'graphics',
'usr',
'share',
'glvnd',
'egl_vendor.d'
),
].join(':'),
VK_LAYER_PATH: [
path.join(
snapRoot,
'graphics',
'usr',
'share',
'vulkan',
'implicit_layer.d'
),
path.join(
snapRoot,
'graphics',
'usr',
'share',
'vulkan',
'explicit_layer.d'
),
].join(':'),
XDG_CONFIG_HOME: path.join(snapRoot, 'etc', 'xdg'),
XDG_CONFIG_DIRS: [
path.join(snapRoot, 'etc', 'xdg'),
'/etc/xdg',
].join(':'),
XDG_DATA_HOME: path.join(snapRoot, 'usr', 'share'),
XDG_DATA_DIRS: [
path.join(snapRoot, 'graphics', 'usr', 'share'),
path.join(snapRoot, 'gnome-platform', 'usr', 'share'),
path.join(snapRoot, 'usr', 'share'),
'/usr/share',
].join(':'),
LD_LIBRARY_PATH: [
path.join(nativeDir, 'lib'),
'/var/lib/snapd/lib/gl',
path.join(
snapRoot,
'graphics',
'usr',
'lib',
'x86_64-linux-gnu'
),
path.join(
snapRoot,
'graphics',
'usr',
'lib',
'x86_64-linux-gnu',
'vdpau'
),
'/usr/lib/x86_64-linux-gnu',
path.join(
snapRoot,
'gnome-platform',
'lib',
'x86_64-linux-gnu'
),
path.join(
snapRoot,
'gnome-platform',
'usr',
'lib',
'x86_64-linux-gnu'
),
path.join(
snapRoot,
'gnome-platform',
'usr',
'lib',
'x86_64-linux-gnu',
'mesa'
),
path.join(
snapRoot,
'gnome-platform',
'usr',
'lib',
'x86_64-linux-gnu',
'mesa-egl'
),
path.join(
snapRoot,
'gnome-platform',
'usr',
'lib',
'x86_64-linux-gnu',
'dri'
),
path.join(
snapRoot,
'gnome-platform',
'usr',
'lib',
'x86_64-linux-gnu',
'pulseaudio'
),
path.join(snapRoot, 'lib'),
path.join(snapRoot, 'usr', 'lib'),
path.join(snapRoot, 'lib', 'x86_64-linux-gnu'),
path.join(snapRoot, 'usr', 'lib', 'x86_64-linux-gnu'),
].join(':'),
},
});
});
it('refuses a Linux session without a validated runtime mode', () => {
adapter = createAdapter('/native/helper', { runtimeMode: null });
expect(() => createSession()).toThrow(
'validated Linux frame-copy runtime'
);
expect(spawnMock).not.toHaveBeenCalled();
});
});
it('caches helper snapshot events for getSessionSnapshot', () => {
const sessionId = createSession();
child.emitStdout({
@@ -149,7 +482,12 @@ describe('EmbeddedMpvFrameCopyAdapter', () => {
'size\twidth=1600\theight=900\n'
);
const writesBefore = child.stdin.written.length;
adapter.setBounds(sessionId, { x: -10000, y: -10000, width: 1, height: 1 });
adapter.setBounds(sessionId, {
x: -10000,
y: -10000,
width: 1,
height: 1,
});
expect(child.stdin.written.length).toBe(writesBefore);
});
@@ -192,7 +530,7 @@ describe('EmbeddedMpvFrameCopyAdapter', () => {
['darwin', 'arm64', true],
['darwin', 'x64', false],
['linux', 'x64', true],
['linux', 'arm64', true],
['linux', 'arm64', false],
['win32', 'x64', true],
['freebsd', 'x64', false],
])(
@@ -7,6 +7,11 @@ import {
ResolvedPortalPlayback,
} from '@iptvnator/shared/interfaces';
import { isFrameCopyPlatformSupported } from './embedded-mpv-frame-copy-platform.util';
import { createLinuxFrameCopyHelperLaunch } from './embedded-mpv-frame-copy-runtime';
import type {
EmbeddedMpvFrameCopyRuntimeMode,
LinuxFrameCopyHelperLaunchFileSystem,
} from './embedded-mpv-frame-copy-runtime';
import type {
NativeEmbeddedMpvAddon,
NativeEmbeddedMpvSessionSnapshot,
@@ -28,6 +33,9 @@ import type {
export interface EmbeddedMpvFrameCopyAdapterOptions {
resolveHelperPath: () => string | null;
resolveRuntimeMode: () => EmbeddedMpvFrameCopyRuntimeMode | null;
environment?: NodeJS.ProcessEnv;
helperLaunchFileSystem?: LinuxFrameCopyHelperLaunchFileSystem;
getScaleFactor: () => number;
onFrameSourceChanged: (
sessionId: string,
@@ -76,14 +84,18 @@ function createInitialSnapshot(): NativeEmbeddedMpvSessionSnapshot {
export class EmbeddedMpvFrameCopyAdapter implements NativeEmbeddedMpvAddon {
private readonly sessions = new Map<string, FrameCopyRuntimeSession>();
constructor(
private readonly options: EmbeddedMpvFrameCopyAdapterOptions
) {}
constructor(private readonly options: EmbeddedMpvFrameCopyAdapterOptions) {}
isSupported(): boolean {
if (
!isFrameCopyPlatformSupported() ||
this.options.resolveHelperPath() === null
) {
return false;
}
return (
isFrameCopyPlatformSupported() &&
this.options.resolveHelperPath() !== null
process.platform !== 'linux' ||
this.options.resolveRuntimeMode() !== null
);
}
@@ -104,30 +116,56 @@ export class EmbeddedMpvFrameCopyAdapter implements NativeEmbeddedMpvAddon {
const scale = this.options.getScaleFactor();
const width = Math.max(16, Math.round(bounds.width * scale));
const height = Math.max(16, Math.round(bounds.height * scale));
const helperArgs = [
'--shm-base',
`/${sessionId}`,
'--width',
String(width),
'--height',
String(height),
'--volume',
String(Math.min(Math.max(initialVolume ?? 1, 0), 1)),
// Lip-sync compensation for the video path's added latency
// (~10 ms measured on M1 Pro); tunable until calibration
// lands, see the architecture doc.
...(process.env.IPTVNATOR_EMBEDDED_MPV_AUDIO_DELAY
? [
'--audio-delay',
process.env.IPTVNATOR_EMBEDDED_MPV_AUDIO_DELAY,
]
: []),
];
let helperCommand = helperPath;
let resolvedHelperArgs = helperArgs;
let helperEnvironment: NodeJS.ProcessEnv | undefined;
if (process.platform === 'linux') {
const runtimeMode = this.options.resolveRuntimeMode();
if (!runtimeMode) {
throw new Error(
'A validated Linux frame-copy runtime is not available.'
);
}
const launch = createLinuxFrameCopyHelperLaunch({
environment: this.options.environment ?? process.env,
helperPath,
helperArgs,
runtimeMode,
fileSystem: this.options.helperLaunchFileSystem,
});
if (!launch.usable) {
throw new Error(
'The connected Snap graphics provider is not available.'
);
}
helperCommand = launch.command;
resolvedHelperArgs = launch.args;
helperEnvironment = launch.env;
}
const child = spawn(
helperPath,
[
'--shm-base',
`/${sessionId}`,
'--width',
String(width),
'--height',
String(height),
'--volume',
String(Math.min(Math.max(initialVolume ?? 1, 0), 1)),
// Lip-sync compensation for the video path's added latency
// (~10 ms measured on M1 Pro); tunable until calibration
// lands, see the architecture doc.
...(process.env.IPTVNATOR_EMBEDDED_MPV_AUDIO_DELAY
? [
'--audio-delay',
process.env.IPTVNATOR_EMBEDDED_MPV_AUDIO_DELAY,
]
: []),
],
{ stdio: ['pipe', 'pipe', 'pipe'] }
);
const child = spawn(helperCommand, resolvedHelperArgs, {
stdio: ['pipe', 'pipe', 'pipe'],
...(helperEnvironment ? { env: helperEnvironment } : {}),
});
console.log(
`[embedded-mpv-fc][${sessionId}] spawn ${width}x${height} (pid pending)`
@@ -177,7 +215,9 @@ export class EmbeddedMpvFrameCopyAdapter implements NativeEmbeddedMpvAddon {
}
loadPlayback(sessionId: string, playback: ResolvedPortalPlayback): void {
const fields: string[] = [`url=${encodeProtocolValue(playback.streamUrl)}`];
const fields: string[] = [
`url=${encodeProtocolValue(playback.streamUrl)}`,
];
if (playback.title) {
fields.push(
`opt.force-media-title=${encodeProtocolValue(playback.title)}`
@@ -0,0 +1,539 @@
import {
existsSync,
mkdtempSync,
mkdirSync,
rmSync,
symlinkSync,
unlinkSync,
writeFileSync,
} from 'fs';
import { createHash } from 'crypto';
import { createRequire } from 'module';
import { tmpdir } from 'os';
import path from 'path';
const linkageModulePath = path.resolve(
__dirname,
'../../../embedded-mpv-linux-linkage.cjs'
);
const requireBuildHelper = createRequire(__filename);
interface RuntimeFileRecord {
name: string;
size: number;
sha256: string;
}
interface SonameFixture {
exactPath: string;
outputLibDir: string;
runtimeDependencyClosure: {
entries: Array<{
name: string;
needed: string[];
rpath: string[];
runpath: string[];
soname: string | null;
}>;
};
runtimeFiles: RuntimeFileRecord[];
}
function loadLinkageModule(): {
parseReadelfDynamic: (output: string) => {
needed: string[];
rpath: string[];
runpath: string[];
soname: string[];
};
resolveVerifiedLinuxLibMpvSoname: (options: {
outputLibDir: string;
readDynamicSection: (filePath: string) => string;
runtimeDependencyClosure: SonameFixture['runtimeDependencyClosure'];
runtimeFiles: RuntimeFileRecord[];
}) => string;
resolveLinuxFrameCopyLinkageInputs: (options: {
buildInputMode: string;
outputLibDir: string;
packagedLibmpvSoname: string | null;
readDynamicSection: (filePath: string) => string;
runtimeLibDir: string;
}) => {
expectedLibmpvSoname: string | null;
linkerLibraryDir: string;
};
runWithCleanup: <T>(operation: () => T, cleanup: () => void) => T;
validateLinuxFrameCopyLinkage: (options: {
expectedLibmpvSoname: string;
outputDir: string;
readDynamicSection: (filePath: string) => string;
}) => void;
} {
expect(existsSync(linkageModulePath)).toBe(true);
return requireBuildHelper(linkageModulePath);
}
function sha256(contents: Buffer): string {
return createHash('sha256').update(contents).digest('hex');
}
function runtimeFile(name: string, contents: Buffer): RuntimeFileRecord {
return {
name,
size: contents.byteLength,
sha256: sha256(contents),
};
}
function readelfDynamic(
entries: Array<['NEEDED' | 'RPATH' | 'RUNPATH' | 'SONAME', string]>
): string {
return entries
.map(
([tag, value], index) =>
` 0x${index
.toString(16)
.padStart(16, '0')} (${tag}) Library value: [${value}]`
)
.join('\n');
}
describe('Linux Embedded MPV linkage verification', () => {
const temporaryDirectories: string[] = [];
afterEach(() => {
for (const directory of temporaryDirectories.splice(0)) {
rmSync(directory, { recursive: true, force: true });
}
});
function temporaryDirectory(): string {
const directory = mkdtempSync(
path.join(tmpdir(), 'iptvnator-mpv-linkage-')
);
temporaryDirectories.push(directory);
return directory;
}
function createSonameFixture(soname = 'libmpv.so.2'): SonameFixture {
const outputLibDir = path.join(temporaryDirectory(), 'lib');
mkdirSync(outputLibDir, { recursive: true });
const contents = Buffer.from('verified libmpv ELF contents');
const aliasPath = path.join(outputLibDir, 'libmpv.so');
const exactPath = path.join(outputLibDir, soname);
writeFileSync(aliasPath, contents);
writeFileSync(exactPath, contents);
return {
exactPath,
outputLibDir,
runtimeFiles: [
runtimeFile('libmpv.so', contents),
runtimeFile(soname, contents),
],
runtimeDependencyClosure: {
entries: [
{
name: 'libmpv.so',
needed: [],
rpath: [],
runpath: ['$ORIGIN'],
soname,
},
{
name: soname,
needed: [],
rpath: [],
runpath: ['$ORIGIN'],
soname,
},
],
},
};
}
it('parses every dynamic tag without hiding duplicate SONAME entries', () => {
const { parseReadelfDynamic } = loadLinkageModule();
expect(
parseReadelfDynamic(
readelfDynamic([
['NEEDED', 'libmpv.so.2'],
['RPATH', '/forbidden'],
['RUNPATH', '$ORIGIN/lib'],
['SONAME', 'libmpv.so.2'],
['SONAME', 'libmpv.so.3'],
])
)
).toEqual({
needed: ['libmpv.so.2'],
rpath: ['/forbidden'],
runpath: ['$ORIGIN/lib'],
soname: ['libmpv.so.2', 'libmpv.so.3'],
});
});
it('resolves the exact libmpv SONAME from closure metadata and verified copied files', () => {
const { resolveVerifiedLinuxLibMpvSoname } = loadLinkageModule();
const fixture = createSonameFixture();
expect(
resolveVerifiedLinuxLibMpvSoname({
...fixture,
readDynamicSection: () =>
readelfDynamic([['SONAME', 'libmpv.so.2']]),
})
).toBe('libmpv.so.2');
});
it('rejects missing and ambiguous closure SONAME metadata', () => {
const { resolveVerifiedLinuxLibMpvSoname } = loadLinkageModule();
const missingFixture = createSonameFixture();
for (const entry of missingFixture.runtimeDependencyClosure.entries) {
entry.soname = null;
}
expect(() =>
resolveVerifiedLinuxLibMpvSoname({
...missingFixture,
readDynamicSection: () =>
readelfDynamic([['SONAME', 'libmpv.so.2']]),
})
).toThrow(/exactly one versioned libmpv SONAME/i);
const ambiguousFixture = createSonameFixture();
ambiguousFixture.runtimeDependencyClosure.entries.push({
name: 'libmpv.so.3',
needed: [],
rpath: [],
runpath: ['$ORIGIN'],
soname: 'libmpv.so.3',
});
expect(() =>
resolveVerifiedLinuxLibMpvSoname({
...ambiguousFixture,
readDynamicSection: () =>
readelfDynamic([['SONAME', 'libmpv.so.2']]),
})
).toThrow(/exactly one versioned libmpv SONAME/i);
});
it('rejects missing, ambiguous, and mismatched DT_SONAME values', () => {
const { resolveVerifiedLinuxLibMpvSoname } = loadLinkageModule();
const fixture = createSonameFixture();
expect(() =>
resolveVerifiedLinuxLibMpvSoname({
...fixture,
readDynamicSection: () => readelfDynamic([]),
})
).toThrow(/exactly one DT_SONAME/i);
expect(() =>
resolveVerifiedLinuxLibMpvSoname({
...fixture,
readDynamicSection: () =>
readelfDynamic([
['SONAME', 'libmpv.so.2'],
['SONAME', 'libmpv.so.3'],
]),
})
).toThrow(/exactly one DT_SONAME/i);
expect(() =>
resolveVerifiedLinuxLibMpvSoname({
...fixture,
readDynamicSection: () =>
readelfDynamic([['SONAME', 'libmpv.so.3']]),
})
).toThrow(/does not match validated closure SONAME/i);
});
(process.platform === 'win32' ? it.skip : it)(
'rejects a symlinked copied linker input',
() => {
const { resolveVerifiedLinuxLibMpvSoname } = loadLinkageModule();
const fixture = createSonameFixture();
const aliasPath = path.join(fixture.outputLibDir, 'libmpv.so');
unlinkSync(aliasPath);
symlinkSync(path.basename(fixture.exactPath), aliasPath);
expect(() =>
resolveVerifiedLinuxLibMpvSoname({
...fixture,
readDynamicSection: () =>
readelfDynamic([['SONAME', 'libmpv.so.2']]),
})
).toThrow(/must be a regular non-symbolic-link file/i);
}
);
it('rejects a missing exact runtime record and a mismatched exact file hash', () => {
const { resolveVerifiedLinuxLibMpvSoname } = loadLinkageModule();
const missingRecordFixture = createSonameFixture();
expect(() =>
resolveVerifiedLinuxLibMpvSoname({
...missingRecordFixture,
runtimeFiles: missingRecordFixture.runtimeFiles.filter(
({ name }) => name !== 'libmpv.so.2'
),
readDynamicSection: () =>
readelfDynamic([['SONAME', 'libmpv.so.2']]),
})
).toThrow(/exact runtimeFiles record/i);
const mismatchedFileFixture = createSonameFixture();
writeFileSync(
mismatchedFileFixture.exactPath,
Buffer.from('tampered exact SONAME file')
);
expect(() =>
resolveVerifiedLinuxLibMpvSoname({
...mismatchedFileFixture,
readDynamicSection: () =>
readelfDynamic([['SONAME', 'libmpv.so.2']]),
})
).toThrow(/size|SHA-256/i);
});
it('uses and identifies the unmanaged system libmpv only for system development', () => {
const { resolveLinuxFrameCopyLinkageInputs } = loadLinkageModule();
const readDynamicSection = jest.fn((filePath: string) => {
expect(filePath).toBe('/opt/libmpv/lib/libmpv.so');
return readelfDynamic([['SONAME', 'libmpv.so.2']]);
});
expect(
resolveLinuxFrameCopyLinkageInputs({
buildInputMode: 'system-dev',
outputLibDir: '/native/build/Release/lib',
packagedLibmpvSoname: null,
readDynamicSection,
runtimeLibDir: '/opt/libmpv/lib',
})
).toEqual({
expectedLibmpvSoname: 'libmpv.so.2',
linkerLibraryDir: '/opt/libmpv/lib',
});
expect(readDynamicSection).toHaveBeenCalledTimes(1);
});
it('keeps bundled and untrusted build modes on the copied runtime directory', () => {
const { resolveLinuxFrameCopyLinkageInputs } = loadLinkageModule();
const readDynamicSection = jest.fn(() => {
throw new Error('must not inspect the ambient system runtime');
});
for (const buildInputMode of [
'bundled-runtime',
'system-build-inputs',
'unexpected-mode',
]) {
expect(
resolveLinuxFrameCopyLinkageInputs({
buildInputMode,
outputLibDir: '/native/build/Release/lib',
packagedLibmpvSoname:
buildInputMode === 'bundled-runtime'
? 'libmpv.so.2'
: null,
readDynamicSection,
runtimeLibDir: '/usr/lib/x86_64-linux-gnu',
})
).toEqual({
expectedLibmpvSoname:
buildInputMode === 'bundled-runtime' ? 'libmpv.so.2' : null,
linkerLibraryDir: '/native/build/Release/lib',
});
}
expect(readDynamicSection).not.toHaveBeenCalled();
});
it('rejects ambiguous or unversioned system-development libmpv identities', () => {
const { resolveLinuxFrameCopyLinkageInputs } = loadLinkageModule();
const options = {
buildInputMode: 'system-dev',
outputLibDir: '/native/build/Release/lib',
packagedLibmpvSoname: null,
runtimeLibDir: '/usr/lib/x86_64-linux-gnu',
};
expect(() =>
resolveLinuxFrameCopyLinkageInputs({
...options,
readDynamicSection: () =>
readelfDynamic([['SONAME', 'libmpv.so']]),
})
).toThrow(/system-development.*exactly one versioned libmpv SONAME/i);
expect(() =>
resolveLinuxFrameCopyLinkageInputs({
...options,
readDynamicSection: () => readelfDynamic([]),
})
).toThrow(/system-development.*exactly one versioned libmpv SONAME/i);
expect(() =>
resolveLinuxFrameCopyLinkageInputs({
...options,
readDynamicSection: () =>
readelfDynamic([
['SONAME', 'libmpv.so.1'],
['SONAME', 'libmpv.so.2'],
]),
})
).toThrow(/system-development.*exactly one versioned libmpv SONAME/i);
});
function createArtifactFixture(): {
outputDir: string;
readDynamicSection: (filePath: string) => string;
outputs: Record<string, string>;
} {
const outputDir = temporaryDirectory();
const outputs: Record<string, string> = {
'embedded_mpv.node': readelfDynamic([['NEEDED', 'libX11.so.6']]),
'embedded_mpv_frame_reader.node': readelfDynamic([]),
iptvnator_mpv_helper: readelfDynamic([
['NEEDED', 'libmpv.so.2'],
['NEEDED', 'libEGL.so.1'],
['RUNPATH', '$ORIGIN/lib'],
]),
};
for (const artifact of Object.keys(outputs)) {
writeFileSync(path.join(outputDir, artifact), 'ELF');
}
return {
outputDir,
outputs,
readDynamicSection: (filePath: string) =>
outputs[path.basename(filePath)],
};
}
it('accepts only process-isolated Linux frame-copy linkage', () => {
const { validateLinuxFrameCopyLinkage } = loadLinkageModule();
const fixture = createArtifactFixture();
expect(() =>
validateLinuxFrameCopyLinkage({
expectedLibmpvSoname: 'libmpv.so.2',
outputDir: fixture.outputDir,
readDynamicSection: fixture.readDynamicSection,
})
).not.toThrow();
});
it('rejects a helper linked to the wrong libmpv SONAME', () => {
const { validateLinuxFrameCopyLinkage } = loadLinkageModule();
const fixture = createArtifactFixture();
fixture.outputs.iptvnator_mpv_helper = readelfDynamic([
['NEEDED', 'libmpv.so.3'],
['RUNPATH', '$ORIGIN/lib'],
]);
expect(() =>
validateLinuxFrameCopyLinkage({
expectedLibmpvSoname: 'libmpv.so.2',
outputDir: fixture.outputDir,
readDynamicSection: fixture.readDynamicSection,
})
).toThrow(/helper.*DT_NEEDED must contain exactly libmpv\.so\.2/i);
});
it('rejects helper RPATH and any RUNPATH other than $ORIGIN/lib', () => {
const { validateLinuxFrameCopyLinkage } = loadLinkageModule();
const rpathFixture = createArtifactFixture();
rpathFixture.outputs.iptvnator_mpv_helper = readelfDynamic([
['NEEDED', 'libmpv.so.2'],
['RPATH', '/host/lib'],
['RUNPATH', '$ORIGIN/lib'],
]);
expect(() =>
validateLinuxFrameCopyLinkage({
expectedLibmpvSoname: 'libmpv.so.2',
outputDir: rpathFixture.outputDir,
readDynamicSection: rpathFixture.readDynamicSection,
})
).toThrow(/helper must not contain RPATH/i);
const runpathFixture = createArtifactFixture();
runpathFixture.outputs.iptvnator_mpv_helper = readelfDynamic([
['NEEDED', 'libmpv.so.2'],
['RUNPATH', '$ORIGIN'],
]);
expect(() =>
validateLinuxFrameCopyLinkage({
expectedLibmpvSoname: 'libmpv.so.2',
outputDir: runpathFixture.outputDir,
readDynamicSection: runpathFixture.readDynamicSection,
})
).toThrow(/helper RUNPATH must be exactly \$ORIGIN\/lib/i);
});
it.each([
['embedded_mpv.node', 'addon'],
['embedded_mpv_frame_reader.node', 'frame reader'],
])('rejects Electron-side libmpv linkage from %s', (fileName, label) => {
const { validateLinuxFrameCopyLinkage } = loadLinkageModule();
const fixture = createArtifactFixture();
fixture.outputs[fileName] = readelfDynamic([['NEEDED', 'libmpv.so.2']]);
expect(() =>
validateLinuxFrameCopyLinkage({
expectedLibmpvSoname: 'libmpv.so.2',
outputDir: fixture.outputDir,
readDynamicSection: fixture.readDynamicSection,
})
).toThrow(new RegExp(`${label} must not have a direct libmpv`, 'i'));
});
it('rejects missing artifacts and readelf failures', () => {
const { validateLinuxFrameCopyLinkage } = loadLinkageModule();
const missingArtifactFixture = createArtifactFixture();
unlinkSync(
path.join(
missingArtifactFixture.outputDir,
'embedded_mpv_frame_reader.node'
)
);
expect(() =>
validateLinuxFrameCopyLinkage({
expectedLibmpvSoname: 'libmpv.so.2',
outputDir: missingArtifactFixture.outputDir,
readDynamicSection: missingArtifactFixture.readDynamicSection,
})
).toThrow(/missing.*frame reader/i);
const readelfFailureFixture = createArtifactFixture();
expect(() =>
validateLinuxFrameCopyLinkage({
expectedLibmpvSoname: 'libmpv.so.2',
outputDir: readelfFailureFixture.outputDir,
readDynamicSection: () => {
throw new Error('readelf is unavailable');
},
})
).toThrow(/readelf is unavailable/);
});
it('runs cleanup before rethrowing the original transaction failure', () => {
const { runWithCleanup } = loadLinkageModule();
const calls: string[] = [];
const failure = new Error('post-link validation failed');
expect(() =>
runWithCleanup(
() => {
calls.push('operation');
throw failure;
},
() => calls.push('cleanup')
)
).toThrow(failure);
expect(calls).toEqual(['operation', 'cleanup']);
});
});
@@ -32,6 +32,16 @@ describe('Embedded MPV native source recording invariants', () => {
),
'utf8'
);
const electronBuilderConfig = JSON.parse(
readFileSync(
path.resolve(__dirname, '../../../../../electron-builder.json'),
'utf8'
)
) as {
snap?: {
plugs?: unknown;
};
};
const stageRuntimeSource = readFileSync(
path.resolve(
__dirname,
@@ -43,6 +53,10 @@ describe('Embedded MPV native source recording invariants', () => {
path.resolve(__dirname, '../../../native/helper/frame_helper_render.h'),
'utf8'
);
const frameHelperSource = readFileSync(
path.resolve(__dirname, '../../../native/helper/mpv_frame_helper.cpp'),
'utf8'
);
const frameHelperGlSource = readFileSync(
path.resolve(__dirname, '../../../native/helper/frame_helper_gl.h'),
'utf8'
@@ -570,11 +584,120 @@ describe('Embedded MPV native source recording invariants', () => {
expect(buildScriptSource).toContain('cleanNativeBuildIntermediates();');
});
it('does not stage Linux libmpv runtime libraries', () => {
expect(stageRuntimeSource).toContain(
"if (platform !== 'linux') {\n" +
' copyDirectory(sourceLibDir, destinationLibDir, runtimeFileFilter);\n' +
' }'
it('validates and copies only the staged Linux shared-library closure', () => {
expect(buildScriptSource).toContain(
"require('../../tools/embedded-mpv/linux-runtime-manifest.cjs')"
);
expect(buildScriptSource).toContain(
'validateLinuxRuntimeManifest(sourceRuntimeManifest)'
);
expect(buildScriptSource).toContain(
'validateLinuxSystemBuildInputManifest(sourceRuntimeManifest)'
);
expect(buildScriptSource).toContain(
'copyLinuxRuntimeClosureToNativeBuild(runtime)'
);
expect(buildScriptSource).toContain(
'runtime.sourceRuntimeManifest.runtimeFiles'
);
expect(buildScriptSource).toContain(
'SHA-256 mismatch for staged Linux runtime file'
);
expect(buildScriptSource).toContain(
'resolveLinuxFrameCopyLinkageInputs({'
);
expect(buildScriptSource).toContain(
'LINUX_VERIFIED_RUNTIME_LIBRARY_DIR:\n' +
' linuxLinkageInputs.linkerLibraryDir'
);
expect(buildScriptSource).toContain(
'expectedLibmpvSoname: linuxLinkageInputs.expectedLibmpvSoname'
);
expect(buildScriptSource).not.toContain(
'process.env.LINUX_NATIVE_LIBRARY_DIR || runtime.libDir'
);
expect(buildScriptSource).not.toContain(
'LINUX_NATIVE_LIBRARY_DIR: runtime.libDir'
);
});
it('writes a profile-neutral Linux frame-copy build manifest', () => {
expect(buildScriptSource).toContain(
"const LINUX_PACKAGE_RUNTIME_MODES = Object.freeze(['system', 'bundled']);"
);
expect(buildScriptSource).toContain("origin: 'linux-frame-copy-build'");
expect(buildScriptSource).toContain(
'allowedPackageRuntimeModes: [...LINUX_PACKAGE_RUNTIME_MODES]'
);
expect(buildScriptSource).toContain(
'buildInputMode: runtime.buildInputMode'
);
expect(buildScriptSource).toContain(
'sourceRuntime: runtime.sourceRuntimeManifest'
);
expect(buildScriptSource).toContain('runtimeFiles: copiedRuntimeFiles');
expect(buildScriptSource).not.toContain(
'writeLinuxProcessRuntimeManifest'
);
});
it('requires a validated bundled source runtime for required Linux builds', () => {
expect(buildScriptSource).toContain(
"sourceRuntimeValidated: buildInputMode === 'bundled-runtime'"
);
expect(buildScriptSource).toContain(
'assertRequiredLinuxFrameCopyRuntime(runtime);'
);
expect(buildScriptSource).toContain(
"runtime.buildInputMode !== 'bundled-runtime' ||"
);
expect(buildScriptSource).toContain(
'runtime.sourceRuntimeValidated !== true'
);
expect(buildScriptSource).toContain("runtimeFile.name === 'libmpv.so'");
expect(buildScriptSource).toContain(
'Required Linux builds must use the validated bundled source runtime containing staged libmpv.'
);
});
it('derives packaged Linux libmpv identity from validated closure SONAME metadata', () => {
expect(buildScriptSource).toContain('resolveVerifiedLinuxLibMpvSoname');
expect(buildScriptSource).toContain(
'runtime.sourceRuntimeManifest.runtimeDependencyClosure'
);
expect(buildScriptSource).toContain('libmpvSoname,');
expect(buildScriptSource).not.toContain(
'VERSIONED_LINUX_LIBMPV_PATTERN'
);
expect(buildScriptSource).not.toContain("libmpvSoname: 'libmpv.so.2'");
});
it('marks both package modes available only for a validated bundled build', () => {
expect(buildScriptSource).toContain(
'runtime.sourceRuntimeValidated === true &&\n' +
" runtime.buildInputMode === 'bundled-runtime' &&\n" +
' copiedRuntimeFiles.length > 0 &&\n' +
' libmpvSoname !== null'
);
expect(buildScriptSource).toContain('system: packageRuntimeAvailable');
expect(buildScriptSource).toContain('bundled: packageRuntimeAvailable');
});
it('validates Linux post-link isolation before marking the build available', () => {
const postLinkValidation = buildScriptSource.indexOf(
'validateLinuxFrameCopyLinkage({'
);
const availabilityMarkerRemoval = buildScriptSource.lastIndexOf(
'fs.rmSync(unavailableMarkerFile'
);
expect(buildScriptSource).toContain(
"spawnSync('readelf', ['-d', filePath]"
);
expect(postLinkValidation).toBeGreaterThanOrEqual(0);
expect(availabilityMarkerRemoval).toBeGreaterThan(postLinkValidation);
expect(buildScriptSource).toContain(
'runWithCleanup(buildNativeArtifacts, cleanOutput)'
);
});
@@ -597,17 +720,33 @@ describe('Embedded MPV native source recording invariants', () => {
);
});
it('requires Linux embedded MPV build inputs and validates process isolation in CI', () => {
it('requires the pinned Linux source runtime artifact and validates process isolation in CI', () => {
expect(buildAndMakeWorkflowSource).toContain(
'libmpv-dev mpv pkg-config libegl-dev libgl-dev libopengl-dev libgbm-dev'
'Build and stage pinned LGPL Linux runtime'
);
expect(buildAndMakeWorkflowSource).toContain(
'node tools/embedded-mpv/build-linux-runtime.mjs "${RUNTIME_PREFIX}"'
);
expect(buildAndMakeWorkflowSource).toContain(
'node tools/embedded-mpv/stage-runtime.mjs linux x64 "${RUNTIME_PREFIX}"'
);
expect(buildAndMakeWorkflowSource).toContain(
'name: linux-embedded-mpv-runtime'
);
expect(buildAndMakeWorkflowSource).toContain(
'path: vendor/embedded-mpv/linux-x64'
);
expect(buildAndMakeWorkflowSource).toContain(
'Download pinned Linux Embedded MPV runtime'
);
expect(buildAndMakeWorkflowSource).not.toContain('libopengl-dev');
expect(buildAndMakeWorkflowSource).not.toContain(
'Stage Linux embedded MPV build inputs'
);
expect(buildAndMakeWorkflowSource).toContain(
"linuxBackend: 'process-isolated mpv --wid'"
);
expect(buildAndMakeWorkflowSource).toContain("matrix.os == 'linux'");
expect(buildAndMakeWorkflowSource).toContain(
"manifest.origin !== 'linux-frame-copy-build' || manifest.sourceRuntimeValidated !== true"
);
expect(buildAndMakeWorkflowSource).toContain(
'Linux embedded MPV addon must not link directly to libmpv'
);
@@ -615,14 +754,11 @@ describe('Embedded MPV native source recording invariants', () => {
'test -f dist/apps/electron-backend/native/iptvnator_mpv_helper'
);
expect(buildAndMakeWorkflowSource).toContain(
'Linux frame-copy helper must link libmpv'
'Linux frame-copy helper must need libmpv.so.2'
);
expect(buildScriptSource).toContain("origin: 'external-mpv-process'");
expect(buildScriptSource).toContain('writeLinuxProcessRuntimeManifest');
expect(buildScriptSource).toContain('runtimeFiles: []');
});
it('supports Linux system-development inputs without leaving stale frame-copy artifacts', () => {
it('keeps optional Linux system development separate from required staged inputs', () => {
expect(buildScriptSource).toContain(
"const systemIncludeDir =\n process.env.LIBMPV_INCLUDE_DIR || '/usr/include';"
);
@@ -635,6 +771,9 @@ describe('Embedded MPV native source recording invariants', () => {
expect(buildScriptSource).toContain(
"if (!embeddedMpvRequired && runtime.origin === 'system-dev')"
);
expect(buildScriptSource).toContain(
'Required Linux builds must use the validated bundled source runtime containing staged libmpv.'
);
expect(buildScriptSource).toContain(
'removeStaleFrameCopyArtifacts(outputDir);'
);
@@ -654,6 +793,101 @@ describe('Embedded MPV native source recording invariants', () => {
);
});
it('keeps Electron Builder defaults and exact Snap runtime plugs', () => {
expect(electronBuilderConfig.snap?.plugs).toEqual([
'default',
{
'graphics-core22': {
interface: 'content',
target: '$SNAP/graphics',
'default-provider': 'mesa-core22',
},
},
{
'shared-memory': {
interface: 'shared-memory',
private: true,
},
},
]);
});
it('runs the helper runtime probe through shared memory without media or command loops', () => {
const runtimeProbe = sourceFunctionBody(
frameHelperSource,
'int runRuntimeProbe(',
'runRuntimeProbe'
);
const runtimeProbeShmName = sourceFunctionBody(
frameHelperSource,
'std::string runtimeProbeShmName(',
'runtimeProbeShmName'
);
const main = sourceFunctionBody(frameHelperSource, 'int main(', 'main');
const runtimeProbeFailure = sourceFunctionBody(
frameHelperSource,
'int runtimeProbeFailure(',
'runtimeProbeFailure'
);
expect(main).toContain('if (args.runtimeProbe) {');
expect(main).toContain('return runRuntimeProbe();');
expect(runtimeProbe).toContain('mpv_create()');
expect(runtimeProbe).toContain('"idle", "yes"');
expect(runtimeProbe).toContain('"vo", "libmpv"');
expect(runtimeProbe).toContain('mpv_initialize(mpv)');
expect(runtimeProbe).toContain('GlContext gl;');
expect(runtimeProbe).toContain('gl.create(error)');
expect(runtimeProbe).toContain('gl.makeCurrent(error)');
expect(runtimeProbe).toContain('mpv_render_context_create(');
expect(runtimeProbe).toContain('mpv_render_context_free(');
expect(runtimeProbe).toContain('gl.destroy()');
expect(runtimeProbe).toContain('mpv_terminate_destroy(mpv)');
expect(runtimeProbe).toContain(
'frame_helper::ShmRing runtimeProbeRing;'
);
expect(runtimeProbe).toContain(
'const std::string shmName = runtimeProbeShmName();'
);
expect(runtimeProbe).toContain(
'runtimeProbeRing.create(shmName, 16, 16, 1)'
);
expect(runtimeProbe).toContain(
'runtimeProbeRing.header->magic == FRAME_SHM_MAGIC'
);
expect(runtimeProbe).toContain('runtimeProbeRing.destroy();');
expect(runtimeProbe).toContain('"shared-memory-create-failed"');
expect(runtimeProbe).toContain('"shared-memory-initialize-failed"');
expect(runtimeProbeShmName).toContain('"/impv-fc-runtime-probe-"');
expect(runtimeProbeShmName).toContain('getpid()');
expect(runtimeProbeShmName).toContain('GetCurrentProcessId()');
expect(runtimeProbeShmName).toContain('std::to_string(processId)');
expect(runtimeProbe).toContain('.num("protocol", 1)');
expect(runtimeProbe).toContain('.boolean("usable", true)');
expect(runtimeProbe).toContain('.str("libmpv",');
expect(runtimeProbe).toContain('.str("renderApi", gl.renderApiName())');
expect(runtimeProbe).not.toContain('pipeline');
expect(runtimeProbe).not.toContain('runStdinLoop');
expect(runtimeProbe).not.toContain('runMpvEventLoop');
expect(runtimeProbe).not.toContain('loadfile');
expect(runtimeProbe.match(/emitLine\(/g)).toHaveLength(1);
expect(runtimeProbeFailure).toContain('.num("protocol", 1)');
expect(runtimeProbeFailure).toContain('.boolean("usable", false)');
expect(runtimeProbeFailure).toContain('.str("reason", reason)');
expect(runtimeProbeFailure.match(/emitLine\(/g)).toHaveLength(1);
expect(runtimeProbeFailure).toContain('return 1;');
});
it('identifies the Linux helper runtime probe render API as EGL', () => {
const renderApiName = sourceFunctionBody(
linuxFrameHelperGlSource,
'const char* renderApiName() const',
'GlContext::renderApiName'
);
expect(renderApiName).toContain('return "egl";');
});
it('validates complete EGL candidates and keeps software rendering as the final fallback', () => {
const tryCandidate = sourceFunctionBody(
linuxFrameHelperGlSource,
@@ -817,14 +1051,33 @@ describe('Embedded MPV native build configuration', () => {
)?.[1];
expect(linuxAddonConfig?.libraries).not.toContain('-lmpv');
expect(JSON.stringify(linuxAddonConfig)).not.toContain('-lmpv');
expect(linuxHelperConfig?.libraries).toEqual(
expect.arrayContaining([
'-lmpv',
'-lEGL',
'-lOpenGL',
'-lgbm',
'-ldl',
])
expect.arrayContaining(['-lEGL', '-lGL', '-lgbm', '-ldl'])
);
expect(linuxHelperConfig?.libraries).not.toContain('-lOpenGL');
expect(linuxHelperConfig?.libraries).not.toContain('-lmpv');
expect(
linuxHelperConfig?.libraries.find((library: string) =>
library.includes("path.join(dir, 'libmpv.so')")
)
).toContain('LINUX_VERIFIED_RUNTIME_LIBRARY_DIR');
expect(JSON.stringify(linuxHelperConfig)).not.toContain(
"LINUX_VERIFIED_RUNTIME_LIBRARY_DIR || '/usr/lib'"
);
expect(JSON.stringify(linuxHelperConfig)).toContain(
'Missing LINUX_VERIFIED_RUNTIME_LIBRARY_DIR'
);
const runtimeLinkerFlags = linuxHelperConfig?.ldflags.filter(
(flag: string) => flag.startsWith('-Wl,')
);
expect(runtimeLinkerFlags).toEqual([
'-Wl,--enable-new-dtags',
"-Wl,-rpath,'$$ORIGIN/lib'",
]);
expect(JSON.stringify(runtimeLinkerFlags)).not.toContain(
'LINUX_NATIVE_LIBRARY_DIR'
);
});
});
@@ -3,24 +3,30 @@ import type {
EmbeddedMpvSessionStatus,
ResolvedPortalPlayback,
} from '@iptvnator/shared/interfaces';
import {
chmodSync,
existsSync,
mkdirSync,
mkdtempSync,
rmSync,
writeFileSync,
} from 'fs';
import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'fs';
import { tmpdir } from 'os';
import path from 'path';
import type { EmbeddedMpvNativeService as EmbeddedMpvNativeServiceType } from './embedded-mpv-native.service';
const mockSpawnSync = jest.fn();
const mockIsFrameCopyRuntimeUsable = jest.fn<boolean, []>();
const mockGetFrameCopyRuntimeAvailability = jest.fn();
jest.mock('child_process', () => ({
spawnSync: mockSpawnSync,
}));
jest.mock('./embedded-mpv-frame-copy-platform.util', () => {
const actual = jest.requireActual(
'./embedded-mpv-frame-copy-platform.util'
);
return {
...actual,
getFrameCopyRuntimeAvailability: mockGetFrameCopyRuntimeAvailability,
isFrameCopyRuntimeUsable: mockIsFrameCopyRuntimeUsable,
};
});
const powerSaveBlockerMock = {
start: jest.fn<number, [string]>(),
stop: jest.fn<void, [number]>(),
@@ -138,6 +144,13 @@ describe('EmbeddedMpvNativeService power blocker', () => {
mockSpawnSync.mockReturnValue({
status: 0,
});
mockIsFrameCopyRuntimeUsable.mockReset();
mockIsFrameCopyRuntimeUsable.mockReturnValue(false);
mockGetFrameCopyRuntimeAvailability.mockReset();
mockGetFrameCopyRuntimeAvailability.mockReturnValue({
usable: false,
reason: 'helper-probe-failed',
});
mainWindowGetNativeWindowHandleMock.mockReset();
mainWindowGetNativeWindowHandleMock.mockReturnValue(Buffer.alloc(8));
mainWindowSendMock.mockReset();
@@ -230,14 +243,9 @@ describe('EmbeddedMpvNativeService power blocker', () => {
// no helper on disk => the engine env flag is ignored, native keeps
// working, and support does not advertise frame-copy.
process.env.IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY = '1';
jest.spyOn(
service as unknown as {
resolveFrameCopyHelperPath: () => string | null;
},
'resolveFrameCopyHelperPath'
).mockReturnValue(null);
try {
expect(service.getActiveEngine()).toBe('native');
expect(mockIsFrameCopyRuntimeUsable).toHaveBeenCalledWith();
const support = service.getSupport();
expect(support.engine).not.toBe('frame-copy');
startSession('s-fallback', snapshot('loading'));
@@ -262,37 +270,23 @@ describe('EmbeddedMpvNativeService power blocker', () => {
});
(process.platform === 'win32' ? it.skip : it)(
'falls back to the native engine when the frame-copy helper is not executable',
'falls back to the native engine when the frame-copy runtime probe fails',
() => {
const tempDir = createTempDir();
const releaseDir = path.join(
tempDir,
'apps',
'electron-backend',
'native',
'build',
'Release'
);
const helperPath = path.join(
releaseDir,
'iptvnator_mpv_helper'
);
mkdirSync(releaseDir, { recursive: true });
writeFileSync(helperPath, '#!/bin/sh\n');
chmodSync(helperPath, 0o644);
writeFileSync(
path.join(releaseDir, 'embedded_mpv_frame_reader.node'),
'reader'
);
const cwdSpy = jest.spyOn(process, 'cwd').mockReturnValue(tempDir);
process.env.IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY = '1';
try {
expect(service.getActiveEngine()).toBe('native');
expect(service.isFrameCopyAvailable()).toBe(false);
expect(mockIsFrameCopyRuntimeUsable).toHaveBeenCalledWith();
expect(service.getSupport()).toEqual(
expect.objectContaining({
engine: 'native',
frameCopyAvailable: false,
frameCopyUnavailableReason: 'helper-probe-failed',
})
);
} finally {
delete process.env.IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY;
cwdSpy.mockRestore();
}
}
);
@@ -300,13 +294,11 @@ describe('EmbeddedMpvNativeService power blocker', () => {
describe('frame-copy platform gate', () => {
const originalArch = process.arch;
function mockHelperPresent(): void {
jest.spyOn(
service as unknown as {
resolveFrameCopyHelperPath: () => string | null;
},
'resolveFrameCopyHelperPath'
).mockReturnValue('/native/iptvnator_mpv_helper');
function mockRuntimeUsable(): void {
mockIsFrameCopyRuntimeUsable.mockReturnValue(true);
mockGetFrameCopyRuntimeAvailability.mockReturnValue({
usable: true,
});
}
afterEach(() => {
@@ -322,7 +314,7 @@ describe('EmbeddedMpvNativeService power blocker', () => {
process.env.DISPLAY = ':0';
process.env.WAYLAND_DISPLAY = 'wayland-0';
process.env.IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY = '1';
mockHelperPresent();
mockRuntimeUsable();
expect(service.getActiveEngine()).toBe('frame-copy');
expect(service.isFrameCopyAvailable()).toBe(true);
@@ -342,7 +334,7 @@ describe('EmbeddedMpvNativeService power blocker', () => {
Object.defineProperty(process, 'platform', { value: 'linux' });
process.env.DISPLAY = ':0';
process.env.WAYLAND_DISPLAY = 'wayland-0';
mockHelperPresent();
mockRuntimeUsable();
const support = service.getSupport();
expect(support.supported).toBe(false);
@@ -355,7 +347,7 @@ describe('EmbeddedMpvNativeService power blocker', () => {
process.env.DISPLAY = ':0';
delete process.env.WAYLAND_DISPLAY;
mockSpawnSync.mockReturnValue({ status: 1 });
mockHelperPresent();
mockRuntimeUsable();
const support = service.getSupport();
expect(support.supported).toBe(false);
@@ -370,7 +362,7 @@ describe('EmbeddedMpvNativeService power blocker', () => {
delete process.env.WAYLAND_DISPLAY;
process.env.IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY = '1';
mockSpawnSync.mockReturnValue({ status: 1 });
mockHelperPresent();
mockRuntimeUsable();
expect(service.getSupport()).toEqual(
expect.objectContaining({
@@ -383,16 +375,35 @@ describe('EmbeddedMpvNativeService power blocker', () => {
it('activates the frame-copy engine on macOS arm64', () => {
Object.defineProperty(process, 'arch', { value: 'arm64' });
process.env.IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY = '1';
mockHelperPresent();
mockRuntimeUsable();
expect(service.getActiveEngine()).toBe('frame-copy');
expect(service.isFrameCopyAvailable()).toBe(true);
});
it('supplies the adapter with the runtime mode from the validated Linux capability', () => {
Object.defineProperty(process, 'platform', { value: 'linux' });
mockGetFrameCopyRuntimeAvailability.mockReturnValue({
usable: true,
profile: 'portable',
runtimeMode: 'bundled',
libmpv: '2.3',
renderApi: 'egl',
});
expect(
(
service as unknown as {
resolveFrameCopyRuntimeMode(): string | null;
}
).resolveFrameCopyRuntimeMode()
).toBe('bundled');
});
it('keeps the frame-copy engine Apple-Silicon-only on macOS', () => {
Object.defineProperty(process, 'arch', { value: 'x64' });
process.env.IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY = '1';
mockHelperPresent();
mockIsFrameCopyRuntimeUsable.mockReturnValue(false);
expect(service.getActiveEngine()).toBe('native');
expect(service.isFrameCopyAvailable()).toBe(false);
@@ -401,7 +412,7 @@ describe('EmbeddedMpvNativeService power blocker', () => {
it('skips the native window handle when creating a frame-copy session', () => {
Object.defineProperty(process, 'platform', { value: 'linux' });
process.env.IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY = '1';
mockHelperPresent();
mockRuntimeUsable();
const frameCopyAddon = createMockAddon();
frameCopyAddon.createSession.mockReturnValueOnce('s-fc');
frameCopyAddon.getSessionSnapshot.mockReturnValueOnce(
@@ -425,9 +436,7 @@ describe('EmbeddedMpvNativeService power blocker', () => {
// dispatches to the adapter that owns the session, not the
// native addon the outer afterEach shutdown would pick.
service.disposeSession('s-fc');
expect(frameCopyAddon.disposeSession).toHaveBeenCalledWith(
's-fc'
);
expect(frameCopyAddon.disposeSession).toHaveBeenCalledWith('s-fc');
});
});
@@ -455,9 +464,7 @@ describe('EmbeddedMpvNativeService power blocker', () => {
expect.arrayContaining(['render-process-gone', 'did-navigate'])
);
const consoleWarnSpy = jest
.spyOn(console, 'warn')
.mockImplementation();
const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation();
handlers.get('did-navigate')?.();
expect(addon.disposeSession).toHaveBeenCalledWith('s1');
@@ -29,10 +29,12 @@ import {
} from '@iptvnator/shared/interfaces';
import { EmbeddedMpvFrameCopyAdapter } from './embedded-mpv-frame-copy.adapter';
import {
getFrameCopyRuntimeAvailability,
getEmbeddedMpvAddonCandidatePaths,
isFrameCopyRuntimeUsable,
resolveFrameCopyHelperPath,
} from './embedded-mpv-frame-copy-platform.util';
import type { EmbeddedMpvFrameCopyRuntimeMode } from './embedded-mpv-frame-copy-runtime';
import {
EMBEDDED_MPV_EXPERIMENT_ENV,
isEmbeddedMpvFeatureEnabled,
@@ -112,8 +114,9 @@ export class EmbeddedMpvNativeService {
/**
* Frame-copy engine: helper process + shm ring + renderer canvas.
* Experimental, macOS Apple Silicon (owner decision 2026-07-10), Linux
* and Windows, opted into with IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY=1
* on top of the regular embedded MPV experiment flag.
* x64 and Windows, opted into with
* IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY=1 on top of the regular
* embedded MPV experiment flag.
*/
private isFrameCopyEngineRequested(): boolean {
return ['1', 'true', 'yes', 'on'].includes(
@@ -127,10 +130,7 @@ export class EmbeddedMpvNativeService {
// Requires the helper binary too: a stale opt-in (cleaned native
// build, bad install) must fall back to the native engine instead
// of leaving embedded MPV unsupported with no way to recover.
return (
this.isFrameCopyEngineRequested() &&
isFrameCopyRuntimeUsable(() => this.resolveFrameCopyHelperPath())
);
return this.isFrameCopyEngineRequested() && isFrameCopyRuntimeUsable();
}
getActiveEngine(): EmbeddedMpvEngine {
@@ -138,9 +138,31 @@ export class EmbeddedMpvNativeService {
}
isFrameCopyAvailable(): boolean {
return isFrameCopyRuntimeUsable(() =>
this.resolveFrameCopyHelperPath()
);
return isFrameCopyRuntimeUsable();
}
private getFrameCopySupportDetails(): Pick<
EmbeddedMpvSupport,
'frameCopyAvailable' | 'frameCopyUnavailableReason'
> {
const availability = getFrameCopyRuntimeAvailability();
if (!('reason' in availability)) {
return { frameCopyAvailable: true };
}
return {
frameCopyAvailable: false,
frameCopyUnavailableReason: availability.reason,
};
}
private resolveFrameCopyRuntimeMode(): EmbeddedMpvFrameCopyRuntimeMode | null {
if (process.platform !== 'linux') {
return null;
}
const availability = getFrameCopyRuntimeAvailability();
return availability.usable && 'runtimeMode' in availability
? availability.runtimeMode
: null;
}
getFrameSource(sessionId: string): EmbeddedMpvFrameSource | null {
@@ -150,7 +172,8 @@ export class EmbeddedMpvNativeService {
private getFrameCopyAdapter(): EmbeddedMpvFrameCopyAdapter {
if (!this.frameCopyAdapter) {
this.frameCopyAdapter = new EmbeddedMpvFrameCopyAdapter({
resolveHelperPath: () => this.resolveFrameCopyHelperPath(),
resolveHelperPath: resolveFrameCopyHelperPath,
resolveRuntimeMode: () => this.resolveFrameCopyRuntimeMode(),
getScaleFactor: () => this.getMainWindowScaleFactor(),
onFrameSourceChanged: (sessionId, source) => {
if (!App.mainWindow || App.mainWindow.isDestroyed()) {
@@ -166,12 +189,6 @@ export class EmbeddedMpvNativeService {
return this.frameCopyAdapter;
}
private resolveFrameCopyHelperPath(): string | null {
// Shared with the startup sandbox gate; kept as an instance method so
// service tests can stub helper discovery per scenario.
return resolveFrameCopyHelperPath();
}
private getMainWindowScaleFactor(): number {
try {
if (!App.mainWindow || App.mainWindow.isDestroyed()) {
@@ -229,7 +246,7 @@ export class EmbeddedMpvNativeService {
supported: false,
platform: process.platform,
reason: 'Embedded MPV on Linux currently requires X11 or Xwayland. Native Wayland embedding is not supported yet.',
frameCopyAvailable: this.isFrameCopyAvailable(),
...this.getFrameCopySupportDetails(),
};
}
@@ -249,7 +266,7 @@ export class EmbeddedMpvNativeService {
supported: true,
platform: process.platform,
engine: 'frame-copy',
frameCopyAvailable: true,
...this.getFrameCopySupportDetails(),
capabilities: this.detectCapabilities(),
};
}
@@ -261,7 +278,7 @@ export class EmbeddedMpvNativeService {
supported: false,
platform: process.platform,
reason: missingLinuxMpvExecutableReason,
frameCopyAvailable: this.isFrameCopyAvailable(),
...this.getFrameCopySupportDetails(),
};
}
@@ -279,7 +296,7 @@ export class EmbeddedMpvNativeService {
supported: true,
platform: process.platform,
engine: this.getActiveEngine(),
frameCopyAvailable: this.isFrameCopyAvailable(),
...this.getFrameCopySupportDetails(),
capabilities: this.detectCapabilities(),
};
} catch (error) {
@@ -345,7 +362,7 @@ export class EmbeddedMpvNativeService {
supported: true,
platform: process.platform,
engine: this.getActiveEngine(),
frameCopyAvailable: this.isFrameCopyAvailable(),
...this.getFrameCopySupportDetails(),
capabilities: this.detectCapabilities(),
};
} catch (error) {
@@ -377,7 +394,7 @@ export class EmbeddedMpvNativeService {
supported: true,
platform: process.platform,
engine: this.getActiveEngine(),
frameCopyAvailable: this.isFrameCopyAvailable(),
...this.getFrameCopySupportDetails(),
capabilities: this.detectCapabilities(),
};
} catch (error) {
@@ -709,8 +726,9 @@ export class EmbeddedMpvNativeService {
});
};
App.mainWindow.webContents.on('render-process-gone', (_event, details) =>
disposeAll(`process gone (${details.reason})`)
App.mainWindow.webContents.on(
'render-process-gone',
(_event, details) => disposeAll(`process gone (${details.reason})`)
);
// Full navigations/reloads only — in-app Angular routing emits
// did-navigate-in-page and must not kill the active session.
@@ -0,0 +1,112 @@
const mockElectronApp = {
isPackaged: true,
};
jest.mock('electron', () => ({ app: mockElectronApp }));
import {
EMBEDDED_MPV_RUNTIME_PROBE_SWITCH,
runEmbeddedMpvRuntimeDiagnosticOrContinue,
} from './embedded-mpv-runtime-diagnostic';
import type { FrameCopyRuntimeAvailability } from './embedded-mpv-frame-copy-platform.util';
interface DiagnosticHarness {
continueStartup: jest.Mock<void, []>;
exit: jest.Mock<void, [number]>;
getRuntimeAvailability: jest.Mock<FrameCopyRuntimeAvailability, []>;
writeStdout: jest.Mock<void, [string]>;
}
function createHarness(
availability: FrameCopyRuntimeAvailability = {
usable: false,
reason: 'runtime-artifact-missing',
}
): DiagnosticHarness {
return {
continueStartup: jest.fn(),
exit: jest.fn(),
getRuntimeAvailability: jest.fn(() => availability),
writeStdout: jest.fn(),
};
}
function runDiagnostic(
argv: readonly string[],
harness: DiagnosticHarness
): void {
runEmbeddedMpvRuntimeDiagnosticOrContinue(argv, harness.continueStartup, {
exit: harness.exit,
getRuntimeAvailability: harness.getRuntimeAvailability,
writeStdout: harness.writeStdout,
});
}
describe('embedded MPV runtime diagnostic', () => {
it.each([
[['electron', 'main.js']],
[['electron', 'main.js', `${EMBEDDED_MPV_RUNTIME_PROBE_SWITCH}=1`]],
[['electron', 'main.js', 'embedded-mpv-runtime-probe']],
])('continues normal startup for argv %j', (argv) => {
const harness = createHarness();
runDiagnostic(argv, harness);
expect(harness.continueStartup).toHaveBeenCalledTimes(1);
expect(harness.getRuntimeAvailability).not.toHaveBeenCalled();
expect(harness.writeStdout).not.toHaveBeenCalled();
expect(harness.exit).not.toHaveBeenCalled();
});
it('prints the usable availability as one JSON line, exits zero, and skips startup', () => {
const availability: FrameCopyRuntimeAvailability = {
usable: true,
profile: 'portable',
runtimeMode: 'bundled',
libmpv: '2.3',
renderApi: 'egl',
};
const harness = createHarness(availability);
runDiagnostic(
['electron', 'main.js', EMBEDDED_MPV_RUNTIME_PROBE_SWITCH],
harness
);
expect(harness.getRuntimeAvailability).toHaveBeenCalledTimes(1);
expect(harness.writeStdout).toHaveBeenCalledWith(
`${JSON.stringify(availability)}\n`
);
expect(harness.writeStdout).toHaveBeenCalledTimes(1);
expect(harness.exit).toHaveBeenCalledWith(0);
expect(harness.exit).toHaveBeenCalledTimes(1);
expect(harness.writeStdout.mock.invocationCallOrder[0]).toBeLessThan(
harness.exit.mock.invocationCallOrder[0]
);
expect(harness.continueStartup).not.toHaveBeenCalled();
});
it('prints the unavailable helper reason as one JSON line, exits nonzero, and skips startup', () => {
const availability: FrameCopyRuntimeAvailability = {
usable: false,
reason: 'helper-probe-failed',
helperReason: 'gl-context-create-failed',
helperDetail: 'EGL initialization failed: display unavailable',
};
const harness = createHarness(availability);
runDiagnostic(
[EMBEDDED_MPV_RUNTIME_PROBE_SWITCH, 'electron', 'main.js'],
harness
);
expect(harness.getRuntimeAvailability).toHaveBeenCalledTimes(1);
expect(harness.writeStdout).toHaveBeenCalledWith(
'{"usable":false,"reason":"helper-probe-failed","helperReason":"gl-context-create-failed","helperDetail":"EGL initialization failed: display unavailable"}\n'
);
expect(harness.writeStdout).toHaveBeenCalledTimes(1);
expect(harness.exit).toHaveBeenCalledWith(1);
expect(harness.exit).toHaveBeenCalledTimes(1);
expect(harness.continueStartup).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,36 @@
import { writeSync } from 'fs';
import {
getFrameCopyRuntimeAvailability,
type FrameCopyRuntimeAvailability,
} from './embedded-mpv-frame-copy-platform.util';
export const EMBEDDED_MPV_RUNTIME_PROBE_SWITCH = '--embedded-mpv-runtime-probe';
interface EmbeddedMpvRuntimeDiagnosticDependencies {
exit(code: number): void;
getRuntimeAvailability(): FrameCopyRuntimeAvailability;
writeStdout(output: string): void;
}
const defaultDependencies: EmbeddedMpvRuntimeDiagnosticDependencies = {
exit: (code) => process.exit(code),
getRuntimeAvailability: getFrameCopyRuntimeAvailability,
writeStdout: (output) => {
writeSync(process.stdout.fd, output);
},
};
export function runEmbeddedMpvRuntimeDiagnosticOrContinue(
argv: readonly string[],
continueStartup: () => void,
dependencies: EmbeddedMpvRuntimeDiagnosticDependencies = defaultDependencies
): void {
if (!argv.includes(EMBEDDED_MPV_RUNTIME_PROBE_SWITCH)) {
continueStartup();
return;
}
const availability = dependencies.getRuntimeAvailability();
dependencies.writeStdout(`${JSON.stringify(availability)}\n`);
dependencies.exit(availability.usable ? 0 : 1);
}
@@ -9,10 +9,10 @@ export const VLC_PLAYER_ARGUMENTS = 'VLC_PLAYER_ARGUMENTS';
export const MPV_REUSE_INSTANCE = 'MPV_REUSE_INSTANCE';
export const VLC_REUSE_INSTANCE = 'VLC_REUSE_INSTANCE';
/**
* Embedded MPV frame-copy engine opt-in (macOS arm64, Linux). Lives in the main
* process config file because it must be readable synchronously before the
* BrowserWindow is created — the engine relaxes the window sandbox for its
* preload frame pump, which cannot change after window creation.
* Embedded MPV frame-copy engine opt-in (macOS arm64, Linux x64). Lives in the
* main process config file because it must be readable synchronously before
* the BrowserWindow is created — the engine relaxes the window sandbox for
* its preload frame pump, which cannot change after window creation.
*/
export const EMBEDDED_MPV_FRAME_COPY = 'EMBEDDED_MPV_FRAME_COPY';
+22 -22
View File
@@ -36,10 +36,8 @@ import {
shouldPromotePersistedFrameCopyOptIn,
} from './app/services/embedded-mpv-frame-copy-platform.util';
import { isEmbeddedMpvFeatureEnabled } from './app/services/embedded-mpv-runtime-policy.util';
import {
EMBEDDED_MPV_FRAME_COPY,
store,
} from './app/services/store.service';
import { runEmbeddedMpvRuntimeDiagnosticOrContinue } from './app/services/embedded-mpv-runtime-diagnostic';
import { EMBEDDED_MPV_FRAME_COPY, store } from './app/services/store.service';
app.setName('iptvnator');
@@ -74,7 +72,7 @@ if (
shouldPromotePersistedFrameCopyOptIn(
store.get(EMBEDDED_MPV_FRAME_COPY, false),
process.env.IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY,
isFrameCopyRuntimeUsable()
isFrameCopyRuntimeUsable
)
) {
process.env.IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY = '1';
@@ -192,23 +190,25 @@ export default class Main {
}
}
// handle setup events as quickly as possible
Main.initialize();
runEmbeddedMpvRuntimeDiagnosticOrContinue(process.argv, () => {
// handle setup events as quickly as possible
Main.initialize();
// bootstrap app
Main.bootstrapApp();
// bootstrap app
Main.bootstrapApp();
// Bootstrap app events after Electron app is ready
app.whenReady().then(async () => {
if (isStartupTraceEnabled()) {
trace('startup', 'app.whenReady');
}
await Main.bootstrapAppEvents();
});
app.on('before-quit', () => {
shutdownEmbeddedMpv();
shutdownMpvSession();
shutdownVlcSession();
void databaseWorkerClient.shutdown();
// Bootstrap app events after Electron app is ready
app.whenReady().then(async () => {
if (isStartupTraceEnabled()) {
trace('startup', 'app.whenReady');
}
await Main.bootstrapAppEvents();
});
app.on('before-quit', () => {
shutdownEmbeddedMpv();
shutdownMpvSession();
shutdownVlcSession();
void databaseWorkerClient.shutdown();
});
});
+15 -14
View File
@@ -1,16 +1,17 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../dist/out-tsc",
"esModuleInterop": true,
"module": "commonjs",
"moduleResolution": "node10",
"types": ["jest", "node"]
},
"include": [
"jest.config.ts",
"src/**/*.test.ts",
"src/**/*.spec.ts",
"src/**/*.d.ts"
]
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../dist/out-tsc",
"esModuleInterop": true,
"allowJs": true,
"module": "commonjs",
"moduleResolution": "node10",
"types": ["jest", "node"]
},
"include": [
"jest.config.ts",
"src/**/*.test.ts",
"src/**/*.spec.ts",
"src/**/*.d.ts"
]
}
+1
View File
@@ -39,6 +39,7 @@ export default {
tslib: 'tslib/tslib.es6.js',
'^iptv-playlist-parser$':
'<rootDir>/src/test-stubs/iptv-playlist-parser.mjs',
'^video.js$': '<rootDir>/src/test-stubs/video-js.js',
'^rxjs': '<rootDir>/../../node_modules/rxjs/dist/bundles/rxjs.umd.js',
'^uuid$': '<rootDir>/../../node_modules/uuid/wrapper.mjs',
},
@@ -0,0 +1,140 @@
import { Component, input, output, signal } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import {
SeriesResumeTarget,
UnifiedCollectionItem,
} from '@iptvnator/portal/shared/util';
import { GlobalCollectionDetailHostComponent } from './global-collection-route.component';
@Component({
selector: 'app-xtream-collection-detail',
template: '',
})
class MockXtreamCollectionDetailComponent {
readonly item = input<UnifiedCollectionItem | null>(null);
readonly seriesResume = input<SeriesResumeTarget | null>(null);
readonly closeRequested = output<void>();
}
jest.unstable_mockModule('@iptvnator/portal/xtream/feature', () => ({
XtreamCollectionDetailComponent: MockXtreamCollectionDetailComponent,
}));
@Component({
imports: [GlobalCollectionDetailHostComponent],
template: `
<app-global-collection-detail-host
[item]="item()"
[seriesResume]="seriesResume()"
(closeRequested)="closeCount = closeCount + 1"
/>
`,
})
class WrapperComponent {
readonly item = signal<UnifiedCollectionItem | null>(null);
readonly seriesResume = signal<SeriesResumeTarget | null>(null);
closeCount = 0;
}
const xtreamSeriesItem: UnifiedCollectionItem = {
uid: 'xtream::xtream-1::series:103',
name: 'Resume Series',
contentType: 'series',
sourceType: 'xtream',
playlistId: 'xtream-1',
playlistName: 'Xtream One',
xtreamId: 103,
categoryId: 3,
};
describe('GlobalCollectionDetailHostComponent', () => {
let fixture: ComponentFixture<WrapperComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [WrapperComponent],
}).compileComponents();
fixture = TestBed.createComponent(WrapperComponent);
});
afterEach(() => {
fixture?.destroy();
});
function queryDetail() {
return fixture.debugElement.query(
By.directive(MockXtreamCollectionDetailComponent)
);
}
async function stabilize(): Promise<void> {
fixture.detectChanges();
await fixture.whenStable();
await new Promise((resolve) => setTimeout(resolve, 0));
fixture.detectChanges();
await fixture.whenStable();
fixture.detectChanges();
}
it('mocks the xtream feature barrel', async () => {
const mod = await import('@iptvnator/portal/xtream/feature');
expect(mod.XtreamCollectionDetailComponent).toBe(
MockXtreamCollectionDetailComponent as never
);
});
it('renders nothing until an item arrives', async () => {
await stabilize();
expect(queryDetail()).toBeNull();
});
it('ignores sources without a collection detail component', async () => {
fixture.componentInstance.item.set({
...xtreamSeriesItem,
uid: 'm3u::pl-1::vod:1',
sourceType: 'm3u' as UnifiedCollectionItem['sourceType'],
playlistId: 'pl-1',
});
await stabilize();
expect(queryDetail()).toBeNull();
});
it('creates the Xtream detail with the series resume target attached', async () => {
const seriesResume = {
seriesXtreamId: 103,
contentXtreamId: 2001,
seasonNumber: 2,
episodeNumber: 1,
};
fixture.componentInstance.seriesResume.set(seriesResume);
fixture.componentInstance.item.set(xtreamSeriesItem);
await stabilize();
const detail = queryDetail();
expect(detail).not.toBeNull();
const instance =
detail?.componentInstance as MockXtreamCollectionDetailComponent;
expect(instance.item()?.xtreamId).toBe(103);
expect(instance.seriesResume()).toEqual(seriesResume);
});
it('re-emits close requests and clears the detail with the item', async () => {
fixture.componentInstance.item.set(xtreamSeriesItem);
await stabilize();
const instance = queryDetail()
?.componentInstance as MockXtreamCollectionDetailComponent;
instance.closeRequested.emit();
expect(fixture.componentInstance.closeCount).toBe(1);
fixture.componentInstance.item.set(null);
await stabilize();
expect(queryDetail()).toBeNull();
});
});
@@ -24,6 +24,7 @@ import {
import {
CollectionScope,
PortalProvider,
SeriesResumeTarget,
UnifiedCollectionItem,
} from '@iptvnator/portal/shared/util';
import { WORKSPACE_SHELL_ACTIONS } from '@iptvnator/workspace/shell/util';
@@ -68,6 +69,7 @@ function providerToPortalType(provider: PortalProvider): UnifiedPortalType {
})
export class GlobalCollectionDetailHostComponent implements OnDestroy {
readonly item = input<UnifiedCollectionItem | null>(null);
readonly seriesResume = input<SeriesResumeTarget | null>(null);
readonly closeRequested = output<void>();
private readonly viewContainer = inject(ViewContainerRef);
@@ -78,9 +80,10 @@ export class GlobalCollectionDetailHostComponent implements OnDestroy {
constructor() {
effect(() => {
const item = this.item();
const seriesResume = this.seriesResume();
untracked(() => {
void this.renderDetail(item);
void this.renderDetail(item, seriesResume);
});
});
}
@@ -91,7 +94,8 @@ export class GlobalCollectionDetailHostComponent implements OnDestroy {
}
private async renderDetail(
item: UnifiedCollectionItem | null
item: UnifiedCollectionItem | null,
seriesResume: SeriesResumeTarget | null
): Promise<void> {
const requestId = ++this.renderRequestId;
this.clearDetail();
@@ -109,6 +113,9 @@ export class GlobalCollectionDetailHostComponent implements OnDestroy {
environmentInjector: this.environmentInjector,
});
componentRef.setInput('item', item);
if (item.sourceType === 'xtream') {
componentRef.setInput('seriesResume', seriesResume);
}
this.subscribeToClose(componentRef.instance);
}
@@ -175,9 +182,17 @@ export class GlobalCollectionDetailHostComponent implements OnDestroy {
[portalType]="activePortalType() ?? undefined"
[defaultScope]="effectiveDefaultScope()"
>
<ng-template unifiedCollectionDetail let-item let-close="close">
<ng-template
unifiedCollectionDetail
let-item
let-seriesResume="seriesResume"
let-close="close"
>
@if (item.sourceType === 'xtream') {
<app-global-collection-detail-host [item]="item" />
<app-global-collection-detail-host
[item]="item"
[seriesResume]="seriesResume"
/>
} @else if (item.sourceType === 'stalker') {
<app-global-collection-detail-host
[item]="item"
@@ -0,0 +1,36 @@
import { FormBuilder } from '@angular/forms';
import { Settings } from '@iptvnator/shared/interfaces';
import {
createSettingsForm,
createSettingsFromFormValue,
} from './settings-form.utils';
describe('settings form utils — strip country prefix', () => {
const formBuilder = new FormBuilder();
it('defaults the form control to false', () => {
const form = createSettingsForm(formBuilder, true);
expect(form.getRawValue().stripCountryPrefix).toBe(false);
});
it('carries an enabled toggle into the settings object', () => {
const form = createSettingsForm(formBuilder, true);
form.patchValue({ stripCountryPrefix: true });
const settings = createSettingsFromFormValue(form, {} as Settings);
expect(settings.stripCountryPrefix).toBe(true);
});
it('falls back to false when the form value is missing', () => {
const form = createSettingsForm(formBuilder, true);
form.patchValue({
stripCountryPrefix: null as unknown as boolean,
});
const settings = createSettingsFromFormValue(form, {} as Settings);
expect(settings.stripCountryPrefix).toBe(false);
});
});
@@ -55,6 +55,7 @@ export function createSettingsForm(
}),
startupBehavior: StartupBehavior.FirstView,
showExternalPlaybackBar: true,
stripCountryPrefix: false,
theme: Theme.SystemTheme,
mpvPlayerPath: '',
mpvPlayerArguments: '',
@@ -124,6 +125,7 @@ export function createSettingsFromFormValue(
dashboardRails: normalizeDashboardRailsSettings(value.dashboardRails),
startupBehavior: value.startupBehavior ?? StartupBehavior.FirstView,
showExternalPlaybackBar: value.showExternalPlaybackBar ?? true,
stripCountryPrefix: value.stripCountryPrefix ?? false,
theme: value.theme ?? Theme.SystemTheme,
mpvPlayerPath: normalizeExternalPlayerPath(value.mpvPlayerPath),
mpvPlayerArguments: normalizeExternalPlayerArguments(
@@ -156,4 +156,16 @@
<mat-checkbox formControlName="showCaptions"></mat-checkbox>
</div>
</div>
<div class="setting-item">
<div class="setting-item__meta">
<h4>{{ 'SETTINGS.STRIP_COUNTRY_PREFIX' | translate }}</h4>
<p>
{{ 'SETTINGS.STRIP_COUNTRY_PREFIX_DESCRIPTION' | translate }}
</p>
</div>
<div class="setting-item__control setting-item__toggle">
<mat-checkbox formControlName="stripCountryPrefix"></mat-checkbox>
</div>
</div>
</section>
@@ -92,6 +92,7 @@ const DEFAULT_SETTINGS = {
showDashboard: true,
startupBehavior: StartupBehavior.FirstView,
showExternalPlaybackBar: true,
stripCountryPrefix: false,
theme: Theme.SystemTheme,
mpvPlayerPath: '',
mpvPlayerArguments: '',
+3
View File
@@ -317,6 +317,8 @@
"SHOW_CAPTIONS_DESCRIPTION": "عرض أو إخفاء الترجمات افتراضيًا",
"SHOW_EXTERNAL_PLAYBACK_BAR": "عرض شريط التشغيل الحالي",
"SHOW_EXTERNAL_PLAYBACK_BAR_DESCRIPTION": "إبقاء شريط حالة المشغل الخارجي مرئيًا في أسفل مساحة العمل على سطح المكتب.",
"STRIP_COUNTRY_PREFIX": "إزالة بادئات البلد من أسماء القنوات",
"STRIP_COUNTRY_PREFIX_DESCRIPTION": "يزيل بادئات البلد/المجموعة مثل \"US | \" أو \"UK - \" من أسماء قنوات البث المباشر في جميع أنحاء التطبيق.",
"VERSION_DESCRIPTION": "الإصدار الحالي للتطبيق",
"ABOUT_SUBTITLE": "الإصدار وروابط المشروع وطرق دعم IPTVnator.",
"EMBEDDED_MPV_NOTICE_TITLE": "بيئة تشغيل MPV المضمّنة",
@@ -787,6 +789,7 @@
"RESTART": "البدء من جديد",
"CONTINUE_WATCHING": "متابعة المشاهدة",
"PLAY_FIRST_EPISODE": "تشغيل الحلقة الأولى",
"PLAY_EPISODE": "تشغيل الحلقة {{episode}}",
"RESUME_EPISODE": "استئناف الحلقة",
"PLAY_NEXT_EPISODE": "تشغيل الحلقة التالية",
"SERIES_WATCHED": "تمت مشاهدة المسلسل",
+3
View File
@@ -317,6 +317,8 @@
"SHOW_CAPTIONS_DESCRIPTION": "وري ولا خفي الترجمة افتراضياً",
"SHOW_EXTERNAL_PLAYBACK_BAR": "وري شريط التشغيل الحالي",
"SHOW_EXTERNAL_PLAYBACK_BAR_DESCRIPTION": "خلي شريط حالة المشغل الخارجي ظاهر في الأسفل ديال مساحة العمل على سطح المكتب.",
"STRIP_COUNTRY_PREFIX": "حيد بادئات البلد من أسماء القنوات",
"STRIP_COUNTRY_PREFIX_DESCRIPTION": "كيحيد بادئات البلد/المجموعة بحال \"US | \" ولا \"UK - \" من أسماء قنوات البث المباشر فكامل التطبيق.",
"VERSION_DESCRIPTION": "الإصدار الحالي ديال التطبيق",
"ABOUT_SUBTITLE": "الإصدار، روابط المشروع، وطرق دعم IPTVnator.",
"EMBEDDED_MPV_NOTICE_TITLE": "وقت تشغيل MPV المدمج",
@@ -787,6 +789,7 @@
"RESTART": "بدا من الأول",
"CONTINUE_WATCHING": "كمل المشاهدة",
"PLAY_FIRST_EPISODE": "شغل الحلقة الأولى",
"PLAY_EPISODE": "شغل الحلقة {{episode}}",
"RESUME_EPISODE": "كمل الحلقة",
"PLAY_NEXT_EPISODE": "شغل الحلقة الجاية",
"SERIES_WATCHED": "السلسلة متشافة",
+3
View File
@@ -317,6 +317,8 @@
"SHOW_CAPTIONS_DESCRIPTION": "Паказаць або схаваць субтытры па змаўчанні.",
"SHOW_EXTERNAL_PLAYBACK_BAR": "Паказаць ніжнюю панэль прайгравання",
"SHOW_EXTERNAL_PLAYBACK_BAR_DESCRIPTION": "Пакідаць панэль стану знешняга прайгравальніка бачнай унізе працоўнай прасторы на дэсктопе.",
"STRIP_COUNTRY_PREFIX": "Выдаляць прэфіксы краін з назваў каналаў",
"STRIP_COUNTRY_PREFIX_DESCRIPTION": "Выдаляе прэфіксы краін/груп, такія як \"US | \" або \"UK - \", з назваў каналаў прамых трансляцый ва ўсёй праграме.",
"VERSION_DESCRIPTION": "Бягучая версія праграмы",
"ABOUT_SUBTITLE": "Версія, спасылкі праекта і спосабы падтрымаць IPTVnator.",
"EMBEDDED_MPV_NOTICE_TITLE": "Асяроддзе ўбудаванага MPV",
@@ -787,6 +789,7 @@
"RESTART": "Спачатку",
"CONTINUE_WATCHING": "Працягнуць прагляд",
"PLAY_FIRST_EPISODE": "Прайграць першы эпізод",
"PLAY_EPISODE": "Прайграць эпізод {{episode}}",
"RESUME_EPISODE": "Працягнуць эпізод",
"PLAY_NEXT_EPISODE": "Прайграць наступны эпізод",
"SERIES_WATCHED": "Серыял прагледжаны",
+3
View File
@@ -317,6 +317,8 @@
"SHOW_CAPTIONS_DESCRIPTION": "Untertitel standardmäßig ein- oder ausblenden",
"SHOW_EXTERNAL_PLAYBACK_BAR": "Now-Playing-Fußleiste anzeigen",
"SHOW_EXTERNAL_PLAYBACK_BAR_DESCRIPTION": "Zeigt die Statusleiste für externe Player unten im Arbeitsbereich auf dem Desktop an.",
"STRIP_COUNTRY_PREFIX": "Länderpräfixe aus Sendernamen entfernen",
"STRIP_COUNTRY_PREFIX_DESCRIPTION": "Entfernt Länder-/Gruppenpräfixe wie \"US | \" oder \"UK - \" aus den Namen von Live-Sendern in der gesamten App.",
"VERSION_DESCRIPTION": "Aktuelle Version der Anwendung",
"ABOUT_SUBTITLE": "Version, Projekt-Links und Möglichkeiten, IPTVnator zu unterstützen.",
"EMBEDDED_MPV_NOTICE_TITLE": "Eingebettete MPV-Laufzeit",
@@ -787,6 +789,7 @@
"RESTART": "Neu starten",
"CONTINUE_WATCHING": "Weiter schauen",
"PLAY_FIRST_EPISODE": "Erste Folge abspielen",
"PLAY_EPISODE": "Folge {{episode}} abspielen",
"RESUME_EPISODE": "Folge fortsetzen",
"PLAY_NEXT_EPISODE": "Nächste Folge abspielen",
"SERIES_WATCHED": "Serie angesehen",
+3
View File
@@ -317,6 +317,8 @@
"SHOW_CAPTIONS_DESCRIPTION": "Εμφάνιση ή απόκρυψη υποτίτλων από προεπιλογή",
"SHOW_EXTERNAL_PLAYBACK_BAR": "Εμφάνιση υποσέλιδου αναπαραγωγής",
"SHOW_EXTERNAL_PLAYBACK_BAR_DESCRIPTION": "Διατηρήστε ορατή τη γραμμή κατάστασης του εξωτερικού προγράμματος αναπαραγωγής στο κάτω μέρος του χώρου εργασίας στον υπολογιστή.",
"STRIP_COUNTRY_PREFIX": "Αφαίρεση προθεμάτων χώρας από τα ονόματα καναλιών",
"STRIP_COUNTRY_PREFIX_DESCRIPTION": "Αφαιρεί προθέματα χώρας/ομάδας όπως \"US | \" ή \"UK - \" από τα ονόματα καναλιών ζωντανής ροής σε όλη την εφαρμογή.",
"VERSION_DESCRIPTION": "Τρέχουσα έκδοση της εφαρμογής",
"ABOUT_SUBTITLE": "Έκδοση, σύνδεσμοι έργου και τρόποι υποστήριξης του IPTVnator.",
"EMBEDDED_MPV_NOTICE_TITLE": "Ενσωματωμένος χρόνος εκτέλεσης MPV",
@@ -787,6 +789,7 @@
"RESTART": "Από την αρχή",
"CONTINUE_WATCHING": "Συνέχιση παρακολούθησης",
"PLAY_FIRST_EPISODE": "Αναπαραγωγή πρώτου επεισοδίου",
"PLAY_EPISODE": "Αναπαραγωγή επεισοδίου {{episode}}",
"RESUME_EPISODE": "Συνέχιση επεισοδίου",
"PLAY_NEXT_EPISODE": "Αναπαραγωγή επόμενου επεισοδίου",
"SERIES_WATCHED": "Η σειρά προβλήθηκε",
+3
View File
@@ -317,6 +317,8 @@
"SHOW_CAPTIONS_DESCRIPTION": "Show or hide subtitles by default",
"SHOW_EXTERNAL_PLAYBACK_BAR": "Show now playing footer",
"SHOW_EXTERNAL_PLAYBACK_BAR_DESCRIPTION": "Keep the external-player status bar visible at the bottom of the workspace on desktop.",
"STRIP_COUNTRY_PREFIX": "Strip country prefixes from channel names",
"STRIP_COUNTRY_PREFIX_DESCRIPTION": "Removes country/group prefixes like \"US | \" or \"UK - \" from live channel names throughout the app.",
"VERSION_DESCRIPTION": "Current version of the application",
"ABOUT_SUBTITLE": "Version, project links and ways to support IPTVnator.",
"EMBEDDED_MPV_NOTICE_TITLE": "Embedded MPV runtime",
@@ -787,6 +789,7 @@
"RESTART": "Start Over",
"CONTINUE_WATCHING": "Continue Watching",
"PLAY_FIRST_EPISODE": "Play first episode",
"PLAY_EPISODE": "Play episode {{episode}}",
"RESUME_EPISODE": "Resume episode",
"PLAY_NEXT_EPISODE": "Play next episode",
"SERIES_WATCHED": "Series watched",
+3
View File
@@ -317,6 +317,8 @@
"SHOW_CAPTIONS_DESCRIPTION": "Mostrar u ocultar subtítulos por defecto",
"SHOW_EXTERNAL_PLAYBACK_BAR": "Mostrar pie de reproducción actual",
"SHOW_EXTERNAL_PLAYBACK_BAR_DESCRIPTION": "Mantén visible la barra de estado del reproductor externo en la parte inferior del espacio de trabajo en escritorio.",
"STRIP_COUNTRY_PREFIX": "Quitar prefijos de país de los nombres de los canales",
"STRIP_COUNTRY_PREFIX_DESCRIPTION": "Elimina los prefijos de país o grupo, como \"US | \" o \"UK - \", de los nombres de los canales en vivo en toda la app.",
"VERSION_DESCRIPTION": "Versión actual de la aplicación.",
"ABOUT_SUBTITLE": "Versión, enlaces del proyecto y formas de apoyar a IPTVnator.",
"EMBEDDED_MPV_NOTICE_TITLE": "Runtime de MPV integrado",
@@ -787,6 +789,7 @@
"RESTART": "Reiniciar",
"CONTINUE_WATCHING": "Continuar viendo",
"PLAY_FIRST_EPISODE": "Reproducir primer episodio",
"PLAY_EPISODE": "Reproducir episodio {{episode}}",
"RESUME_EPISODE": "Continuar episodio",
"PLAY_NEXT_EPISODE": "Reproducir siguiente episodio",
"SERIES_WATCHED": "Serie vista",
+3
View File
@@ -317,6 +317,8 @@
"SHOW_CAPTIONS_DESCRIPTION": "Afficher ou masquer les sous-titres par défaut",
"SHOW_EXTERNAL_PLAYBACK_BAR": "Afficher le pied de page de lecture en cours",
"SHOW_EXTERNAL_PLAYBACK_BAR_DESCRIPTION": "Garde la barre d'état du lecteur externe visible en bas de l'espace de travail sur ordinateur.",
"STRIP_COUNTRY_PREFIX": "Retirer les préfixes de pays des noms de chaînes",
"STRIP_COUNTRY_PREFIX_DESCRIPTION": "Supprime les préfixes de pays ou de groupe comme \"US | \" ou \"UK - \" des noms des chaînes en direct dans toute l'application.",
"VERSION_DESCRIPTION": "Version actuelle de l'application",
"ABOUT_SUBTITLE": "Version, liens du projet et façons de soutenir IPTVnator.",
"EMBEDDED_MPV_NOTICE_TITLE": "Runtime MPV intégré",
@@ -787,6 +789,7 @@
"RESTART": "Recommencer",
"CONTINUE_WATCHING": "Reprendre la lecture",
"PLAY_FIRST_EPISODE": "Lire le premier épisode",
"PLAY_EPISODE": "Lire l'épisode {{episode}}",
"RESUME_EPISODE": "Reprendre l'épisode",
"PLAY_NEXT_EPISODE": "Lire l'épisode suivant",
"SERIES_WATCHED": "Série vue",
+3
View File
@@ -317,6 +317,8 @@
"SHOW_CAPTIONS_DESCRIPTION": "Visualizza/nascondi i sottotitoli per impostazione predefinita",
"SHOW_EXTERNAL_PLAYBACK_BAR": "Mostra footer In riproduzione",
"SHOW_EXTERNAL_PLAYBACK_BAR_DESCRIPTION": "Mantieni visibile la barra di stato del lettore esterno in fondo all'area di lavoro su desktop.",
"STRIP_COUNTRY_PREFIX": "Rimuovi i prefissi del paese dai nomi dei canali",
"STRIP_COUNTRY_PREFIX_DESCRIPTION": "Rimuove i prefissi di paese/gruppo come \"US | \" o \"UK - \" dai nomi dei canali live in tutta l'app.",
"VERSION_DESCRIPTION": "Versione attuale dell'applicazione",
"ABOUT_SUBTITLE": "Versione, link al progetto e modi per supportare IPTVnator.",
"EMBEDDED_MPV_NOTICE_TITLE": "Runtime MPV integrato",
@@ -787,6 +789,7 @@
"RESTART": "Ricomincia",
"CONTINUE_WATCHING": "Continua a guardare",
"PLAY_FIRST_EPISODE": "Riproduci primo episodio",
"PLAY_EPISODE": "Riproduci episodio {{episode}}",
"RESUME_EPISODE": "Riprendi episodio",
"PLAY_NEXT_EPISODE": "Riproduci episodio successivo",
"SERIES_WATCHED": "Serie vista",
+3
View File
@@ -317,6 +317,8 @@
"SHOW_CAPTIONS_DESCRIPTION": "デフォルトで字幕を表示/非表示",
"SHOW_EXTERNAL_PLAYBACK_BAR": "再生中フッターを表示",
"SHOW_EXTERNAL_PLAYBACK_BAR_DESCRIPTION": "デスクトップでワークスペース下部に外部プレーヤーのステータスバーを表示し続けます。",
"STRIP_COUNTRY_PREFIX": "チャンネル名から国名プレフィックスを削除",
"STRIP_COUNTRY_PREFIX_DESCRIPTION": "アプリ全体で、ライブチャンネル名から \"US | \" や \"UK - \" のような国/グループのプレフィックスを削除します。",
"VERSION_DESCRIPTION": "アプリケーションの現在のバージョン",
"ABOUT_SUBTITLE": "バージョン、プロジェクトのリンク、IPTVnatorをサポートする方法。",
"EMBEDDED_MPV_NOTICE_TITLE": "組み込みMPVランタイム",
@@ -787,6 +789,7 @@
"RESTART": "最初から再生",
"CONTINUE_WATCHING": "視聴を再開",
"PLAY_FIRST_EPISODE": "第1話を再生",
"PLAY_EPISODE": "第{{episode}}話を再生",
"RESUME_EPISODE": "エピソードを再開",
"PLAY_NEXT_EPISODE": "次のエピソードを再生",
"SERIES_WATCHED": "シリーズ視聴済み",
+3
View File
@@ -317,6 +317,8 @@
"SHOW_CAPTIONS_DESCRIPTION": "기본적으로 자막 표시 또는 숨기기",
"SHOW_EXTERNAL_PLAYBACK_BAR": "지금 재생 중 표시줄 표시",
"SHOW_EXTERNAL_PLAYBACK_BAR_DESCRIPTION": "데스크톱의 작업 공간 하단에 외부 플레이어 상태 표시줄을 계속 표시합니다.",
"STRIP_COUNTRY_PREFIX": "채널 이름에서 국가 접두사 제거",
"STRIP_COUNTRY_PREFIX_DESCRIPTION": "라이브 채널 이름에서 \"US | \" 또는 \"UK - \"와 같은 국가/그룹 접두사를 앱 전체에서 제거합니다.",
"VERSION_DESCRIPTION": "애플리케이션의 현재 버전",
"ABOUT_SUBTITLE": "버전, 프로젝트 링크 및 IPTVnator를 후원하는 방법.",
"EMBEDDED_MPV_NOTICE_TITLE": "내장 MPV 런타임",
@@ -787,6 +789,7 @@
"RESTART": "처음부터 보기",
"CONTINUE_WATCHING": "이어서 시청",
"PLAY_FIRST_EPISODE": "첫 에피소드 재생",
"PLAY_EPISODE": "에피소드 {{episode}} 재생",
"RESUME_EPISODE": "에피소드 이어 보기",
"PLAY_NEXT_EPISODE": "다음 에피소드 재생",
"SERIES_WATCHED": "시리즈 시청 완료",
+3
View File
@@ -317,6 +317,8 @@
"SHOW_CAPTIONS_DESCRIPTION": "Standaard ondertiteling weergeven of verbergen",
"SHOW_EXTERNAL_PLAYBACK_BAR": "Voettekst 'nu spelend' tonen",
"SHOW_EXTERNAL_PLAYBACK_BAR_DESCRIPTION": "Houd de statusbalk van de externe speler zichtbaar onderaan de werkruimte op desktop.",
"STRIP_COUNTRY_PREFIX": "Landprefixen uit kanaalnamen verwijderen",
"STRIP_COUNTRY_PREFIX_DESCRIPTION": "Verwijdert land-/groepsprefixen zoals \"US | \" of \"UK - \" uit de namen van live-kanalen, overal in de app.",
"VERSION_DESCRIPTION": "Huidige versie van de applicatie",
"ABOUT_SUBTITLE": "Versie, projectlinks en manieren om IPTVnator te ondersteunen.",
"EMBEDDED_MPV_NOTICE_TITLE": "Ingebedde MPV-runtime",
@@ -787,6 +789,7 @@
"RESTART": "Opnieuw beginnen",
"CONTINUE_WATCHING": "Verder kijken",
"PLAY_FIRST_EPISODE": "Eerste aflevering afspelen",
"PLAY_EPISODE": "Aflevering {{episode}} afspelen",
"RESUME_EPISODE": "Aflevering hervatten",
"PLAY_NEXT_EPISODE": "Volgende aflevering afspelen",
"SERIES_WATCHED": "Serie bekeken",
+3
View File
@@ -317,6 +317,8 @@
"SHOW_CAPTIONS_DESCRIPTION": "Domyślnie pokaż lub ukryj napisy",
"SHOW_EXTERNAL_PLAYBACK_BAR": "Pokaż stopkę „teraz odtwarzane”",
"SHOW_EXTERNAL_PLAYBACK_BAR_DESCRIPTION": "Pozostaw pasek statusu zewnętrznego odtwarzacza widoczny u dołu obszaru roboczego na komputerze.",
"STRIP_COUNTRY_PREFIX": "Usuń prefiksy krajów z nazw kanałów",
"STRIP_COUNTRY_PREFIX_DESCRIPTION": "Usuwa prefiksy kraju/grupy, takie jak \"US | \" lub \"UK - \", z nazw kanałów na żywo w całej aplikacji.",
"VERSION_DESCRIPTION": "Aktualna wersja aplikacji",
"ABOUT_SUBTITLE": "Wersja, linki do projektu i sposoby wsparcia IPTVnator.",
"EMBEDDED_MPV_NOTICE_TITLE": "Środowisko wbudowanego MPV",
@@ -787,6 +789,7 @@
"RESTART": "Zacznij od nowa",
"CONTINUE_WATCHING": "Kontynuuj oglądanie",
"PLAY_FIRST_EPISODE": "Odtwórz pierwszy odcinek",
"PLAY_EPISODE": "Odtwórz odcinek {{episode}}",
"RESUME_EPISODE": "Wznów odcinek",
"PLAY_NEXT_EPISODE": "Odtwórz następny odcinek",
"SERIES_WATCHED": "Serial obejrzany",
+3
View File
@@ -317,6 +317,8 @@
"SHOW_CAPTIONS_DESCRIPTION": "Mostrar ou ocultar legendas por padrão",
"SHOW_EXTERNAL_PLAYBACK_BAR": "Mostrar rodapé de reprodução atual",
"SHOW_EXTERNAL_PLAYBACK_BAR_DESCRIPTION": "Mantém a barra de status do reprodutor externo visível na parte inferior do espaço de trabalho no desktop.",
"STRIP_COUNTRY_PREFIX": "Remover prefixos de país dos nomes dos canais",
"STRIP_COUNTRY_PREFIX_DESCRIPTION": "Remove prefixos de país/grupo como \"US | \" ou \"UK - \" dos nomes dos canais ao vivo em todo o aplicativo.",
"VERSION_DESCRIPTION": "Versão atual do aplicativo",
"ABOUT_SUBTITLE": "Versão, links do projeto e formas de apoiar o IPTVnator.",
"EMBEDDED_MPV_NOTICE_TITLE": "Runtime MPV integrado",
@@ -787,6 +789,7 @@
"RESTART": "Recomeçar",
"CONTINUE_WATCHING": "Continuar assistindo",
"PLAY_FIRST_EPISODE": "Reproduzir primeiro episódio",
"PLAY_EPISODE": "Reproduzir episódio {{episode}}",
"RESUME_EPISODE": "Continuar episódio",
"PLAY_NEXT_EPISODE": "Reproduzir próximo episódio",
"SERIES_WATCHED": "Série assistida",
+3
View File
@@ -317,6 +317,8 @@
"SHOW_CAPTIONS_DESCRIPTION": "Показать или скрыть субтитры по умолчанию.",
"SHOW_EXTERNAL_PLAYBACK_BAR": "Показывать панель «Сейчас воспроизводится»",
"SHOW_EXTERNAL_PLAYBACK_BAR_DESCRIPTION": "Оставлять панель состояния внешнего проигрывателя видимой внизу рабочей области в настольном приложении.",
"STRIP_COUNTRY_PREFIX": "Удалять префиксы стран из названий каналов",
"STRIP_COUNTRY_PREFIX_DESCRIPTION": "Удаляет префиксы стран/групп, такие как \"US | \" или \"UK - \", из названий каналов прямого эфира во всём приложении.",
"VERSION_DESCRIPTION": "Текущая версия приложения",
"ABOUT_SUBTITLE": "Версия, ссылки на проект и способы поддержать IPTVnator.",
"EMBEDDED_MPV_NOTICE_TITLE": "Среда выполнения встроенного MPV",
@@ -787,6 +789,7 @@
"RESTART": "С начала",
"CONTINUE_WATCHING": "Продолжить просмотр",
"PLAY_FIRST_EPISODE": "Воспроизвести первую серию",
"PLAY_EPISODE": "Воспроизвести серию {{episode}}",
"RESUME_EPISODE": "Продолжить серию",
"PLAY_NEXT_EPISODE": "Следующая серия",
"SERIES_WATCHED": "Сериал просмотрен",
+3
View File
@@ -317,6 +317,8 @@
"SHOW_CAPTIONS_DESCRIPTION": "Varsayılan olarak altyazıları göster veya gizle",
"SHOW_EXTERNAL_PLAYBACK_BAR": "Şu an oynatılıyor altbilgisini göster",
"SHOW_EXTERNAL_PLAYBACK_BAR_DESCRIPTION": "Masaüstünde harici oynatıcı durum çubuğunu çalışma alanının altında görünür tutun.",
"STRIP_COUNTRY_PREFIX": "Kanal adlarından ülke ön eklerini kaldır",
"STRIP_COUNTRY_PREFIX_DESCRIPTION": "Uygulama genelinde canlı kanal adlarından \"US | \" veya \"UK - \" gibi ülke/grup ön eklerini kaldırır.",
"VERSION_DESCRIPTION": "Uygulamanın geçerli sürümü",
"ABOUT_SUBTITLE": "Sürüm, proje bağlantıları ve IPTVnator'u desteklemenin yolları.",
"EMBEDDED_MPV_NOTICE_TITLE": "Gömülü MPV çalışma zamanı",
@@ -787,6 +789,7 @@
"RESTART": "Baştan Başla",
"CONTINUE_WATCHING": "İzlemeye Devam Et",
"PLAY_FIRST_EPISODE": "İlk bölümü oynat",
"PLAY_EPISODE": "{{episode}}. bölümü oynat",
"RESUME_EPISODE": "Bölüme devam et",
"PLAY_NEXT_EPISODE": "Sonraki bölümü oynat",
"SERIES_WATCHED": "Dizi izlendi",
+3
View File
@@ -317,6 +317,8 @@
"SHOW_CAPTIONS_DESCRIPTION": "默认显示或隐藏字幕",
"SHOW_EXTERNAL_PLAYBACK_BAR": "显示「正在播放」底栏",
"SHOW_EXTERNAL_PLAYBACK_BAR_DESCRIPTION": "在桌面端工作区底部保持外部播放器状态栏可见。",
"STRIP_COUNTRY_PREFIX": "移除频道名称中的国家前缀",
"STRIP_COUNTRY_PREFIX_DESCRIPTION": "从整个应用中的直播频道名称里移除国家/分组前缀,例如 \"US | \" 或 \"UK - \"。",
"VERSION_DESCRIPTION": "应用程序的当前版本",
"ABOUT_SUBTITLE": "版本、项目链接以及支持 IPTVnator 的方式。",
"EMBEDDED_MPV_NOTICE_TITLE": "嵌入式 MPV 运行时",
@@ -787,6 +789,7 @@
"RESTART": "从头开始",
"CONTINUE_WATCHING": "继续观看",
"PLAY_FIRST_EPISODE": "播放第一集",
"PLAY_EPISODE": "播放第 {{episode}} 集",
"RESUME_EPISODE": "继续播放本集",
"PLAY_NEXT_EPISODE": "播放下一集",
"SERIES_WATCHED": "剧集已看完",
+3
View File
@@ -317,6 +317,8 @@
"SHOW_CAPTIONS_DESCRIPTION": "預設顯示或隱藏字幕",
"SHOW_EXTERNAL_PLAYBACK_BAR": "顯示「正在播放」列",
"SHOW_EXTERNAL_PLAYBACK_BAR_DESCRIPTION": "在桌面工作區底部保留外部播放器狀態列可見。",
"STRIP_COUNTRY_PREFIX": "從頻道名稱中移除國家前綴",
"STRIP_COUNTRY_PREFIX_DESCRIPTION": "移除應用程式中直播頻道名稱裡的國家/群組前綴,例如「US | 」或「UK - 」。",
"VERSION_DESCRIPTION": "應用程式目前版本",
"ABOUT_SUBTITLE": "版本、專案連結,以及支持 IPTVnator 的方式。",
"EMBEDDED_MPV_NOTICE_TITLE": "內嵌 MPV 執行環境",
@@ -787,6 +789,7 @@
"RESTART": "從頭開始",
"CONTINUE_WATCHING": "繼續觀看",
"PLAY_FIRST_EPISODE": "播放第一集",
"PLAY_EPISODE": "播放第 {{episode}} 集",
"RESUME_EPISODE": "繼續播放本集",
"PLAY_NEXT_EPISODE": "播放下一集",
"SERIES_WATCHED": "劇集已看完",
+43
View File
@@ -0,0 +1,43 @@
// The video.js CJS bundle fails to evaluate under the web project's ESM jest
// preset, so specs that transitively import the vjs player get this stub. It
// stays CommonJS because videojs plugin packages require() it at module load.
class StubComponent {
createEl() {
return {};
}
}
class StubPlugin {}
class StubEventTarget {
on() {
return undefined;
}
off() {
return undefined;
}
one() {
return undefined;
}
trigger() {
return undefined;
}
}
const videoJs = () => ({
dispose: () => undefined,
on: () => undefined,
off: () => undefined,
src: () => undefined,
});
videoJs.getComponent = () => StubComponent;
videoJs.registerComponent = () => undefined;
videoJs.getPlugin = () => StubPlugin;
videoJs.registerPlugin = () => undefined;
videoJs.EventTarget = StubEventTarget;
videoJs.dom = { createEl: () => ({}) };
videoJs.browser = {};
module.exports = videoJs;
module.exports.default = videoJs;
@@ -176,6 +176,13 @@ When a detail view starts playback:
The detail or collection/search host owns inline state. `PlayerService` is not
an owner of embedded UI playback state.
After a successful external episode launch, the detail host immediately
persists that episode as the latest playback-position entry, preserving an
existing resume offset or using zero for a newly opened episode. MPV/VLC
position telemetry overwrites this launch marker when available. This keeps the
last-watched season and episode correct even when an external player's progress
interface is unavailable; exact external timestamps remain best-effort.
## Series Quick Start CTA
Xtream and Stalker series detail views share the quick-start decision helper in
@@ -189,6 +196,9 @@ Current contract:
`S01E02 · Episode title`
- if an episode is in progress, resume the latest updated in-progress episode
with its saved offset
- if the newest episode entry is a successful external-player launch marker
with no meaningful progress yet, target it with `Play episode N` instead of
falling back to the first episode
- if no episode is in progress, play the first unwatched episode in season order
- if watched episodes end at a season boundary, play the first episode of the
next loaded season
+436 -59
View File
@@ -18,7 +18,7 @@ Source files for the embedded MPV integration:
- `libs/shared/interfaces/src/lib/embedded-mpv-session.interface.ts` defines the shared session and audio-track contract.
- `libs/ui/playback/src/lib/embedded-mpv-player/` owns the Angular UI and controls.
Frame-copy engine sources (experimental, macOS Apple Silicon, Linux and
Frame-copy engine sources (experimental, macOS Apple Silicon, Linux x64, and
Windows — see the "Frame-Copy Engine" section below):
- `apps/electron-backend/native/helper/` — `iptvnator_mpv_helper` process (`mpv_frame_helper.cpp`, `frame_helper_render.h`, `frame_helper_gl.h`, `frame_helper_io.h`, `frame_shm.h`).
@@ -60,20 +60,76 @@ Linux native Wayland embedding is not implemented. When Electron is started on X
## Linux Support Matrix
Embedded MPV on Linux is supported only for x64 desktop builds where Electron runs under X11 or Xwayland and an `mpv` executable is available on `PATH`. Native Wayland embedding is not supported in this implementation.
Official Linux frame-copy packaging is x64-only. The native-view and frame-copy
engines have different runtime requirements:
The experimental frame-copy engine (below) is the exception to both requirements: it renders offscreen through headless EGL into a renderer canvas — no window embedding — and the helper links libmpv itself, so neither the X11/Xwayland constraint nor the system-`mpv`-on-`PATH` probe applies while it is active. It is currently a dev-build-only engine on Linux (the helper links the build host's system `libmpv` and is stripped from packaged apps until the bundled-runtime staging lands). Packaged Linux launchers pass `--ozone-platform=x11` so Wayland desktops use Xwayland when it is available, and `main.ts` appends the same switch on Linux when it is absent so direct binary/AppImage launches from a terminal behave like launcher starts. Explicit user intent is never overridden: both a user-provided `--ozone-platform` switch and the `ELECTRON_OZONE_PLATFORM_HINT` environment variable suppress the fallback.
| Engine | Display path | MPV runtime |
| ----------- | ----------------------------- | -------------------------------------------------------------------------- |
| native-view | X11 or Xwayland | An `mpv` executable on `PATH`; playback is an isolated `mpv --wid` process |
| frame-copy | Headless EGL; no window embed | A separately linked and capability-probed `iptvnator_mpv_helper` |
When the `mpv` executable probe fails inside a Flatpak or Snap sandbox (`FLATPAK_ID`/`SNAP` env present), the support reason explains that sandboxed packages cannot access a system mpv instead of asking the user to install it.
Native Wayland embedding is not implemented for native-view. Frame-copy itself
does not embed a window and can render through EGL on a native Wayland desktop,
although packaged launchers still default Electron to X11/Xwayland unless the
user explicitly supplies an Ozone choice. A user-provided `--ozone-platform`
or `ELECTRON_OZONE_PLATFORM_HINT` is never overridden.
Current release-announcement wording should stay close to this:
Linux packages are built in separate passes because Electron Builder reuses
one unpacked application layout per pass:
- Supported display path: X11 or Xwayland.
- Not supported: native Wayland embedding.
- Validated locally: Ubuntu 24.04 GNOME Wayland session with Electron forced to X11/Xwayland and system `mpv`.
- Validated in CI: Ubuntu 22.04 standard Linux package build and Ubuntu 24.04 Flatpak package build.
- Expected standard packages: `.deb` on Ubuntu/Debian, `pacman` on Arch/Manjaro, `.rpm` on RPM-based distributions, and AppImage on x64 glibc systems, all with system `mpv` installed.
- Sandbox caveat: Flatpak and Snap packages build and continue to support the normal inline/external-player flows, but embedded MPV is not announced as supported there yet because the Linux backend launches `mpv --wid` and those sandboxed formats do not expose the host `mpv` executable to the app by default.
| Profile | Formats | Frame-copy runtime strategy |
| ---------- | ---------------- | -------------------------------------------------------------------------------------------------------- |
| `system` | DEB, RPM, Pacman | System `libmpv.so.2` plus the helper's direct EGL/GL/GBM interfaces; exact dependencies are listed below |
| `portable` | AppImage, Snap | Bundled pinned LGPL-compatible runtime under `native/lib` |
| `flatpak` | Flatpak | The same bundled pinned LGPL-compatible runtime under `native/lib` |
System package dependencies are fail-closed and format-specific:
- DEB: `libmpv2`, `libegl1`, `libgl1`, `libgbm1`
- RPM: `mpv-libs`, `libglvnd-egl`, `libglvnd-glx`, `mesa-libgbm`
- Pacman: `mpv`, `libglvnd`, `mesa`
The DEB contract deliberately names `libmpv2`, not a loose `libmpv`
alternative, and explicitly names the GLVND `libGL.so.1` interface because
`libmpv2` does not pull it in for the helper. The helper uses `-lGL`, not
`-lOpenGL`; this matches the graphics interface supplied by distributions and
Snap's `mesa-core22`. Release CI verifies that contract on Ubuntu 24.04
(Noble). Ubuntu 22.04 (Jammy) provides `libmpv1`, so its DEB cannot enable this
system-runtime frame-copy path; use the x64 AppImage there instead.
Every x64 layout contains the addon, frame reader, helper, and a normalized
`embedded-mpv-runtime.json`. The Electron executable, Electron libraries,
`embedded_mpv.node`, and the frame reader must not link libmpv; only the helper
may do so. AppImage, Snap, and Flatpak retain dynamically linked, replaceable
runtime libraries and ship the corresponding source/build metadata. Their
native directory also contains hash-validated `embedded-mpv-notices.json`,
`THIRD_PARTY_NOTICES.txt`, and `licenses/<package>/**`. DEB, RPM, Pacman, and
marker-only packages intentionally contain neither a private `native/lib`
directory nor the bundled-runtime legal payload.
The build-time `electron-backend/native` tree is excluded from `app.asar`.
`afterPack` is the only owner of
`resources/app.asar.unpacked/electron-backend/native`, so each profile receives
exactly its normalized payload and ARM packages cannot retain a hidden x64
helper, runtime, manifest, or notice copy in the archive. Both unpacked-layout
and final Linux artifact verification enumerate `app.asar` and fail if any
entry remains below `/electron-backend/native/`.
The pristine `afterPack` and unpacked-layout checks recursively inspect
Electron-owned shared libraries. An extracted Snap has already overlaid its
package-manager `lib/**` and `usr/lib/**` runtime trees onto that same payload
root, so the post-target verifier excludes exactly those two target-provided
trees while continuing to scan every other directory recursively. Electron
libraries are still required to be regular files and free of libmpv linkage;
Snap runtime symlinks are outside that ownership boundary.
ARM Linux packages remain marker-only. They never borrow x64 native artifacts,
even when build environment variables claim a matching staged architecture.
Consequently frame-copy is not advertised there, and the normal inline/external
players remain available. On x64, any missing or unusable frame-copy dependency
falls back to native-view without crashing; if native-view also lacks X11 or a
system `mpv` executable, Embedded MPV is reported unavailable with a stable
diagnostic reason.
The flow is:
@@ -117,7 +173,16 @@ The renderer never gets direct native-module access. It can only call the preloa
- dispose session
- subscribe to session updates
Settings uses the preload support API as an availability and capability check. Unsupported paths return before loading the addon when platform, experiment gating, addon presence, bundled runtime presence, or the Linux `mpv` executable check fails. Supported paths load `embedded_mpv.node` so the renderer can receive capability flags from the actual addon binary. Avoid calling this support API from global workspace startup paths; use an explicit user action or idle preparation path when a renderer surface only needs to reveal optional Embedded MPV UI.
Settings uses the preload support API as an availability and capability check.
Unsupported paths return before loading the addon when platform, experiment
gating, native artifacts, the packaged runtime manifest, the Linux helper
probe, or the native-view `mpv` executable check fails. Support diagnostics
include a stable `frameCopyUnavailableReason`; it is tracing/support data, not
user-facing copy. Supported paths load `embedded_mpv.node` so the renderer can
receive capability flags from the actual addon binary. Avoid calling this
support API from global workspace startup paths; use an explicit user action
or idle preparation path when a renderer surface only needs to reveal optional
Embedded MPV UI.
When `embedded-mpv` is the saved player, the settings store schedules an idle `prepareEmbeddedMpv()` call. This intentionally moves the first native addon load away from the click-to-play path. It can still block the Electron main process briefly because Node native addon loading is synchronous, but doing it during idle is less visible than doing it when the user clicks a video. Actual MPV session creation still happens on playback because it needs the current Electron window handle and viewport bounds.
@@ -194,19 +259,126 @@ service and the adapter):
Enabling it: the `Settings > Playback > Embedded MPV: frame-copy engine`
checkbox (shown only when support reports `frameCopyAvailable`) persists to
the main-process config store (`electron-conf`), which `main.ts` reads
before creating the window and translates into the env flag; an explicitly
set env var (including `0`) wins over the stored preference, but cannot bypass
the platform/runtime safety gate. Frame-copy can relax the window sandbox only
the main-process config store (`electron-conf`), which `main.ts` reads before
creating the window and translates into the env flag; an explicitly set env
var (including `0`) wins over the stored preference, but cannot bypass the
platform/runtime safety gate. Frame-copy can relax the window sandbox only
when embedded MPV itself is enabled for the current run (packaged app or the
regular development experiment flag) and discovery finds both an executable
(`X_OK`) helper and a readable regular frame-reader addon in the same native
directory. Packaged discovery is limited to packaged resource locations and
never falls through to writable cwd/dist development paths. A disabled base
experiment keeps the renderer sandbox enabled and embedded MPV unavailable.
When the base feature is enabled, a missing, mode-stripped, or incomplete
frame-copy runtime keeps the sandbox enabled and falls back to the native
engine.
regular development experiment flag) and one process-wide capability decision
has succeeded. On Linux x64 that decision validates the profile manifest,
regular-file/access modes, the complete declared bundled closure and hashes,
then runs `iptvnator_mpv_helper --runtime-probe` with a three-second timeout.
The probe loads dependencies through the normal ELF loader, initializes an
idle libmpv client, creates EGL/OpenGL plus mpv render contexts, then
creates, maps, validates, and destroys a minimal `16x16` shared-memory ring
named `/impv-fc-runtime-probe-<pid>`. It never opens media or enters the media
or command loops. Shared-memory creation/mapping and header-initialization
failures emit the stable helper reasons `shared-memory-create-failed` and
`shared-memory-initialize-failed`. The probe must emit exactly one protocol-v1
JSON line and return zero. When the helper exits nonzero with an otherwise
exact failure line, the application availability diagnostic keeps the
fail-closed top-level reason `helper-probe-failed` and may add only the
allowlisted helper reason as `helperReason`. An optional `helperDetail` is
copied only from the same exact line when it contains 1–1024 printable ASCII
characters; an invalid detail rejects both helper fields. Malformed,
multi-line, wrong-protocol, or unknown failure output never reaches either
field. Every probe uses the same explicit 16 MiB aggregate captured-output
ceiling, independent of tracing, so verbose diagnostics do not fall back to
Node's smaller implicit buffer. When `IPTVNATOR_TRACE_PLAYER=1`, the probe also
emits non-empty captured helper stderr separately as one JSON line. JSON
escaping keeps embedded newlines on that single line, the `stderr` field is
limited to the first 16,384 characters, and the `truncated` boolean is always
present. A missing flag, empty capture, or trace-writer failure produces no
trace and never changes the cached availability result or the application
diagnostic's stdout protocol.
The startup probe and every playback helper session use the same sanitized
loader environment selected by the validated manifest's cached `runtimeMode`.
Both remove ambient ELF audit/preload/origin/library overrides, direct
EGL/GBM/GL/VA/Vulkan driver and layer paths, shell startup/options, tracing
hooks, and exported Bash functions. The extracted-artifact verifier applies
the same deny-set before its direct helper smoke, while preserving
feature/debug selectors such as `LIBGL_ALWAYS_SOFTWARE`. The system profile
then uses the default system loader without a private path. Bundled profiles
put the validated packaged `native/lib` first. AppImage and Flatpak resolve the
declared external graphics/audio interfaces through their normal host or
sandbox loader.
For the exact `com.fourgray.iptvnator` Flatpak payload under `/app`, the helper
reconstructs Freedesktop Platform 24.08's immutable
`__EGL_EXTERNAL_PLATFORM_CONFIG_DIRS` value:
`/etc/egl/egl_external_platform.d:/usr/lib/x86_64-linux-gnu/GL/egl/egl_external_platform.d:/usr/share/egl/egl_external_platform.d`.
All ambient EGL/GBM/GL/VA/Vulkan path overrides remain removed. Freedesktop's
GL extension `add-ld-path` is supplied through the sandbox loader cache, so no
ambient `LD_LIBRARY_PATH` is needed. Flatpak CI runs the application-level
`--embedded-mpv-runtime-probe`; direct helper execution is only a package
layout check and cannot substitute for the real gate.
The installed-Snap smoke enables `IPTVNATOR_TRACE_PLAYER=1`,
`EGL_LOG_LEVEL=debug`, and `LIBGL_DEBUG=verbose`, so GLVND/Mesa loader failures
remain observable through the bounded stderr record while the same hostile
ambient-path assertions and fail-closed application gate stay active.
Inside a genuine Snap mount, filtered absolute `SNAP_LIBRARY_PATH` entries below
`/var/lib/snapd/lib/gl` follow `native/lib`. The exact
`$SNAP/graphics/usr/lib/x86_64-linux-gnu` content-provider roots come next,
followed by the core22 base `/usr/lib/x86_64-linux-gnu`, then the GNOME
platform's fixed x64 library, Mesa, DRI, and PulseAudio roots only when
`SNAP_DESKTOP_RUNTIME` resolves exactly to `$SNAP/gnome-platform`; generic
`$SNAP` roots remain last. Core22 must precede that older desktop content
runtime so its compatible `libedit.so.2` wins instead of the GNOME copy that
requires unavailable `libtinfo.so.5`. The helper also rebuilds the GBM, GL/VA
driver, EGL vendor/platform, and Vulkan layer variables from those trusted
roots. Caller-provided triplets, graphics-driver paths, and out-of-root loader
entries are ignored.
Both the bounded probe and playback execute the helper through
`$SNAP/graphics/bin/graphics-core22-provider-wrapper`. Before either launch,
the app requires the mounted graphics root to be a real directory and the
wrapper to be a regular, non-symlinked, readable executable. A missing or
disconnected provider therefore reports the stable
`snap-graphics-provider-unavailable` reason instead of attempting a partial
loader setup. Because that provider wrapper is a non-interactive Bash script,
the child environment also removes shell startup/options, exported
`BASH_FUNC_*` functions, and tracing hooks, and fixes `PATH` to core22 system
directories. This prevents ambient shell configuration from replacing the
probe or its `dirname` lookup before the helper executes.
The Snap is `base: core22` with strict confinement. It keeps Electron Builder's
default plugs and adds an auto-connected private `shared-memory` plug plus the
`graphics-core22` content plug targeting a real empty mode-0755
`$SNAP/graphics`, with `mesa-core22` as default provider. `mesa-core22`
supplies the shared
EGL/GL/GLX/GBM/DRM/VA userspace; the existing GNOME content runtime supplies
ALSA/PulseAudio. These providers are external shared snaps, so their binaries,
source, notices, and installed bytes are not part of the IPTVnator Snap or its
compliance archive. CI installs and explicitly connects both providers for a
locally installed `--dangerous` artifact, then runs the application-level
probe under strict confinement.
The package carries the empty content target itself because core22 does not
create `$SNAP` content targets while packing. Metadata verification rejects a
missing, non-directory, symlinked, non-empty, or incorrectly permissioned
target. It also requires exactly the canonical provider-data layouts:
`/usr/share/libdrm` binds from `$SNAP/graphics/libdrm`, and
`/usr/share/drirc.d` symlinks to `$SNAP/graphics/drirc.d`. No additional or
duplicate layout entry is accepted.
Private shared memory gives the app a confined, snap-specific POSIX shm
namespace rather than global cross-snap access. The packaging-only
`--embedded-mpv-runtime-probe` application switch invokes the same complete
manifest, mode, hash, linkage, environment, and bounded helper probe used at
startup before any BrowserWindow is created. It writes exactly one availability
JSON line and exits zero only when frame-copy is usable. Consequently the
installed-Snap smoke validates the actual confinement and shared-memory
lifecycle required by playback, not only direct helper execution. The smoke
first disconnects `graphics-core22` and requires the application diagnostic to
emit `usable:false` with reason `snap-graphics-provider-unavailable` and
controlled exit code `1`; it then reconnects the provider and requires the
same diagnostic to succeed.
Packaged addon, frame-reader, and helper discovery is limited to package-owned
`app.asar.unpacked` resource locations and never falls through to writable
cwd/dist development paths. Those fallbacks are development-only. A disabled
base experiment or any failed capability check keeps the renderer sandbox
enabled and falls back to the native engine. The result is cached by
helper/manifest identity for the process lifetime, so the startup and service
gates cannot disagree.
Changing the toggle requires an app restart because web preferences are fixed
at window creation.
@@ -232,14 +404,12 @@ the executable resolves it from its own directory. The after-pack hook
restores the POSIX helper's executable mode after the asset copy, and
optional/skipped native rebuilds remove stale helper/reader artifacts before
reporting frame-copy availability. This cleanup prevents known leftover build
output; it is not a compatibility check for a complete but version-mismatched
runtime pair. Linux packages deliberately do NOT ship the helper yet: it links
the build host's system `libmpv`, which packaged apps cannot assume is
installed, so centralized after-pack preparation strips both possible helper
basenames and package validation rejects either one if it survives. The
support probe therefore reports frame-copy unavailable in Linux packages.
The engine is dev-build-only on Linux until bundled-libmpv runtime staging
lands (PORTING.md milestone 4).
output; the manifest and runtime probe are the compatibility check for a
complete Linux runtime. Linux x64 packages retain the helper and frame reader:
system packages resolve the declared `libmpv.so.2` through their package
manager, while portable and sandboxed profiles resolve the source-built closure
through `$ORIGIN/lib`. Foreign-architecture packages remove all native
artifacts and contain only the unavailable marker.
Trade-offs and constraints:
@@ -251,12 +421,12 @@ Trade-offs and constraints:
MessagePort (costs one extra copy + GC churn since Electron ports clone
ArrayBuffers) or a WebCodecs-based path.
- Scope: on macOS Apple Silicon only by owner decision (2026-07-10);
Intel Macs keep the native-view engine. Linux (any arch) is ported —
Intel Macs keep the native-view engine. Official Linux frame-copy is x64 —
headless EGL, works under native Wayland since nothing embeds into a
window; dev builds need `libmpv-dev`, `libegl-dev`, `libgl-dev`,
`libopengl-dev` and `libgbm-dev` (the helper links system libmpv, which
is legal out-of-process — the in-process libmpv ban still binds the
addon). The helper logs the chosen EGL display tier and the GL renderer
window; local system builds need `libmpv-dev`, `libegl-dev`, `libgl-dev`,
and `libgbm-dev`. The helper links libmpv, which is legal
out-of-process; the in-process-libmpv ban still binds the addon and frame
reader. The helper logs the chosen EGL display tier and the GL renderer
string to stderr. If an early tier selects Mesa software rendering (for
example, while a proprietary NVIDIA driver is reachable through the default
display or GBM), it probes the remaining tiers and uses software only when
@@ -491,39 +661,82 @@ The Electron main process holds an `electron.powerSaveBlocker` of type `prevent-
Current development behavior:
- The addon build supports `darwin`, `win32`, and `linux`; Windows and Linux builds require running on that target OS.
- The build script first looks for staged inputs at `vendor/embedded-mpv/<platform>-<arch>/`. On Linux, local development can fall back to distribution `libmpv-dev` headers and libraries; `LIBMPV_INCLUDE_DIR` and `LINUX_NATIVE_LIBRARY_DIR` override the default system paths.
- When the staged-input path is used, it must contain `include/mpv/client.h` and `runtime-manifest.json`. macOS and Windows staging also contains the platform runtime files that are bundled into the app.
- The build script first looks for staged inputs at `vendor/embedded-mpv/<platform>-<arch>/`. On Linux, local development can fall back to distribution `libmpv-dev` headers and libraries. `LIBMPV_INCLUDE_DIR` overrides the header root. `LINUX_NATIVE_LIBRARY_DIR` is a link-time override and must name a directory already visible to the system dynamic loader; it is never inherited as helper `LD_LIBRARY_PATH`.
- When the staged-input path is used, it must contain `include/mpv/client.h`,
`runtime-manifest.json`, and the platform runtime/build files. The Linux
source builder also stages the complete declared `.so` closure.
- The compiled `.node` addon is copied into `dist/apps/electron-backend/native/embedded_mpv.node`.
- Bundled runtime files are copied into `dist/apps/electron-backend/native/lib/` for macOS and Windows. macOS copies `.dylib` and non-`.dylib` Mach-O dependencies; Windows copies the staged `mpv-2.dll`/`libmpv-2.dll`/`mpv.dll`/`libmpv.dll` runtime name plus import libraries. Linux writes an `external-mpv-process` manifest and intentionally leaves `libmpv.so` out of the package.
- Linux does not bundle or load `libmpv` in the Electron process. The addon can compile against staged or system-development MPV headers. Its native engine still depends on an X11/Xwayland window handle plus an `mpv` executable on `PATH`; the dev-only frame-copy helper is a separate process linked to system `libmpv` and renders through headless EGL, so it bypasses those native-engine prerequisites.
- Bundled runtime files are copied into
`dist/apps/electron-backend/native/lib/`. macOS copies `.dylib` and
non-`.dylib` Mach-O dependencies; Windows copies the staged
`mpv-2.dll`/`libmpv-2.dll`/`mpv.dll`/`libmpv.dll` runtime name plus import
libraries. Linux source-runtime builds copy only the manifest-declared
closure.
- Linux never bundles or loads libmpv in the Electron process. The native-view
addon remains X11/process-only; the inverse rule applies to frame-copy:
`iptvnator_mpv_helper` must link exactly the declared `libmpv.so.2`, while
the addon and frame reader must not.
- `afterPack` copies `dist/apps/electron-backend/native/` into `app.asar.unpacked/electron-backend/native/` on macOS, Windows, and Linux so the addon, manifest, and runtime libraries are filesystem-addressable.
- Electron Builder excludes `electron-backend/native{,/**/*}` from `app.asar`;
package verification rejects any archived native entry so `afterPack`
remains the single profile-aware owner.
Current release caveat:
Linux release profiles:
- Release packaging requires a `vendored-lgpl` runtime manifest on macOS and Windows, and an `external-mpv-process` manifest on Linux.
- The Linux addon is built once per CI host architecture (x64). Linux packages for other architectures (arm64, armv7l) must not ship that foreign addon: `afterPack` replaces the native directory with an `embedded-mpv-unavailable.txt` marker explaining that embedded MPV is not bundled for that architecture, and package-layout verification rejects a foreign-architecture `embedded_mpv.node` while requiring the marker.
- `IPTVNATOR_LINUX_FRAME_COPY_PROFILE=system` builds DEB, RPM, and Pacman.
`afterPack` removes the private `lib` directory, writes a
`system-libmpv-frame-copy` manifest, and package metadata requires the exact
libmpv plus EGL/GL/GBM package set listed above. The DEB path is verified
on Ubuntu 24.04+; Ubuntu 22.04 users need the x64 AppImage because Jammy only
provides `libmpv1`.
- `IPTVNATOR_LINUX_FRAME_COPY_PROFILE=portable` builds AppImage and Snap with
the pinned source-built closure and a `bundled-lgpl-frame-copy` manifest.
- `IPTVNATOR_LINUX_FRAME_COPY_PROFILE=flatpak` builds Flatpak with the same
source-built closure and manifest origin. Its app-level probe reconstructs
only the exact Freedesktop 24.08 EGL external-platform search path inside the
trusted `/app` payload.
- The three profiles are separate packaging passes; mixing target sets fails
closed. Linux packages for other architectures (arm64, armv7l) must not ship
x64 native artifacts. `afterPack` replaces the native directory with
`embedded-mpv-unavailable.txt`, and package verification requires that
marker.
- Every packaged manifest names its exact artifacts, profile/targets, libmpv
SONAME, loader closure, byte sizes, SHA-256 hashes, package dependencies, and
native-view fallback. Artifact modes and ELF dependency isolation are
verified after packaging.
- macOS release packaging rejects embedded MPV binaries linked to `/opt/homebrew` or `/usr/local`.
- Windows release packaging verifies that the platform runtime file is present when Embedded MPV is required. Linux release packaging verifies that the addon and manifest are present, no bundled `libmpv.so` files slipped into the package, and no development-only frame-copy helper survived `afterPack`.
- Windows release packaging verifies that the platform runtime file is present
when Embedded MPV is required.
- Local development can opt into Homebrew `libmpv` only by setting `IPTVNATOR_EMBEDDED_MPV_ALLOW_HOMEBREW=1`; packaged release validation rejects that runtime origin.
Before public release, packaging must:
Release packaging must:
- stage an LGPL-compatible `libmpv` runtime for each macOS/Windows release platform/architecture, and stage Linux MPV headers/build metadata for Linux
- stage an LGPL-compatible libmpv runtime for each bundled release
platform/architecture, including the pinned Linux x64 source runtime
- collect indirect macOS dependencies expressed as absolute paths, `@loader_path`, or `@rpath`
- rewrite macOS install names and dependency paths to app-relative paths such as `@loader_path`
- code-sign and notarize the full macOS dependency set
- ensure Windows runtime staging includes both the DLL and the import library used by `node-gyp`
- ensure Linux native builds do not gain a direct `libmpv` dependency; the runtime playback path is `mpv --wid` in a separate process
- publish the corresponding FFmpeg/libmpv source and build metadata for bundled macOS/Windows runtimes; Linux should document the distribution package versions used as build inputs
- ensure Linux Electron/addon/reader binaries do not gain a direct libmpv
dependency and the helper does
- execute the helper capability probe in each intended x64 package environment
- publish corresponding source archives, git/submodule records, checksums,
exact flags, local patches, and build scripts for every bundled runtime
Users on macOS and Windows do not need the MPV GUI application for this architecture. Linux currently requires an `mpv` executable because the supported backend is process-isolated. If the native addon/runtime prerequisites or Linux `mpv` executable are missing, embedded MPV is hidden/unsupported and the existing inline/external players remain available.
Users on macOS and Windows do not need the MPV GUI application for this
architecture. Linux native-view still requires an `mpv` executable. Linux
frame-copy system packages need their declared libmpv and EGL/GL/GBM
packages, while AppImage, Snap, and Flatpak carry their own runtime closure.
If frame-copy prerequisites are missing, x64 falls back to native-view; if all
Embedded MPV prerequisites are unavailable, the existing inline/external
players remain available.
## Runtime Staging
Runtime staging tooling lives in:
- `/Users/4gray/Code/iptvnator/tools/embedded-mpv/`
- `/Users/4gray/Code/iptvnator/vendor/embedded-mpv/`
- `tools/embedded-mpv/`
- `vendor/embedded-mpv/`
Release runtime policy:
@@ -547,11 +760,83 @@ pnpm embedded-mpv:build-runtime -- arm64 /tmp/embedded-mpv-prefix
pnpm embedded-mpv:stage-runtime -- darwin arm64 /tmp/embedded-mpv-prefix
```
During temporary PR and `master` artifact testing, CI can restore an exact-keyed GitHub Actions cache for the staged `vendor/embedded-mpv/<platform>-<arch>/` runtime and skip the expensive source build or archive staging path where one exists. The cache only contains `include/`, `lib/`, and `runtime-manifest.json`; it never contains the compiled `embedded_mpv.node` addon because that target depends on Electron headers, ABI, architecture, and build environment. Runtime cache entries are saved only from trusted repository refs, and tagged public macOS release builds continue to rebuild from pinned sources until a dedicated signed and attested runtime artifact flow exists. Windows CI uses a checksum-pinned `win32-x64` runtime archive configured through `IPTVNATOR_WINDOWS_EMBEDDED_MPV_RUNTIME_URL` and `IPTVNATOR_WINDOWS_EMBEDDED_MPV_RUNTIME_SHA256` repository variables or secrets on cache miss. Non-tag artifact builds have a pinned `zhongfly/mpv-winbuild` `mpv-dev-lgpl-x86_64` fallback so PR builds can produce a Windows embedded MPV artifact before repository variables are configured; tagged releases still require explicit repository configuration. The Windows archive helper accepts normal `lib/` + `bin/` prefixes and common `mpv-dev-lgpl` flat archives, including `libmpv-2.dll` names, and preserves the DLL basename expected by the import library; when the archive does not include `runtime-manifest.json`, it generates a minimal manifest from the archive URL/path and checksum. Linux stages Ubuntu package build inputs only; adding pinned source builders for Windows and Linux remains a separate release-hardening task.
Linux x64 builds the release runtime from pinned source inputs and stages it
before compiling the helper:
The CI builder pins FFmpeg `8.1`, mpv `0.41.0`, libplacebo `7.360.1`, libass `0.17.3`, FreeType `2.13.3`, FriBidi `1.0.16`, and HarfBuzz `8.5.0`. FFmpeg disables autodetected external libraries so Homebrew libraries cannot silently enter the runtime. Libplacebo is checked out from git with the submodules required by its Meson build because the generated GitHub archive does not include submodule contents. Even with Vulkan disabled, libplacebo still compiles Vulkan stubs and needs `3rdparty/Vulkan-Headers`. The generated manifest records source URLs, archive SHA-256 values where applicable, libplacebo git commit/submodule metadata, FFmpeg configure flags, and mpv Meson flags. The staging step normalizes macOS/Windows manifests to `origin: vendored-lgpl`, which release package validation requires on those platforms.
```bash
pnpm embedded-mpv:build-runtime:linux -- /tmp/embedded-mpv-linux-prefix
pnpm embedded-mpv:stage-runtime -- linux x64 /tmp/embedded-mpv-linux-prefix
```
The Electron backend build consumes the staged runtime/build inputs and copies macOS/Windows runtime files into the native build output. Linux consumes staged MPV headers when available or distribution development headers for local builds, writes an `external-mpv-process` manifest, and does not copy `libmpv.so` into the package. macOS additionally rewrites Mach-O paths so `embedded_mpv.node` loads `@loader_path/lib/libmpv.2.dylib` instead of a machine-local Homebrew path. After `install_name_tool` rewrites any addon or runtime binary, the build re-signs that binary with an ad-hoc signature for local development. Release packaging still performs the normal app signing and notarization later.
The Linux builder is intentionally host-restricted to Linux x64. It checks
minimum build-tool versions, uses an owned staging directory plus atomic
publish, rejects host pkg-config/runtime leakage, rewrites every bundled
library to an `$ORIGIN` RUNPATH, and enforces the portable ABI ceilings
`GLIBC_2.35` and `GLIBCXX_3.4.30`. It also verifies the exact libmpv SONAME,
complete dependency closure, and absence of build-prefix paths.
CI may restore exact-keyed caches for staged
`vendor/embedded-mpv/<platform>-<arch>/` runtimes. The Linux cache contains
only generated headers, libraries, the runtime manifest, and immutable source
inputs: exact archives, a clean recursive libplacebo checkout, and collected
license inputs. It never contains `embedded_mpv.node`, generated notices, or
the finished source-compliance archive. Runtime cache entries are saved only
from trusted repository refs. The Linux cache key covers the builder/stager,
notice generator, pinned sources, and toolchain. On every run, including a
cache hit, CI validates the cached hashes and clean checkout, regenerates the
notices for the current runtime manifest, converts libplacebo into a
VCS-metadata-free working-tree snapshot while retaining the validated
commit/submodule record, and creates
`linux-frame-copy-runtime-sources.tar.xz` for the current repository revision
and binary diff with normalized tar metadata.
The workflow keeps the macOS/Windows package matrix independent from the Linux
runtime prerequisite. Only the three Linux profile jobs depend on the runtime
builder; both matrices reuse one YAML-anchored step list to prevent packaging
logic drift. Draft release assembly still requires both matrices, so a public
release cannot silently omit a promised platform.
Windows CI uses a checksum-pinned `win32-x64` runtime archive configured
through `IPTVNATOR_WINDOWS_EMBEDDED_MPV_RUNTIME_URL` and
`IPTVNATOR_WINDOWS_EMBEDDED_MPV_RUNTIME_SHA256`. Non-tag artifact builds have
a pinned `zhongfly/mpv-winbuild` `mpv-dev-lgpl-x86_64` fallback; tagged
releases require explicit repository configuration. Upstream retains only its
latest 30 daily builds, so the fallback and any repository-variable copy must
be refreshed as one URL/checksum pair before expiry. A long-lived mirror must
publish the matching source/build records and license notices with the binary.
The archive helper accepts normal `lib/` + `bin/` prefixes and common flat
archives, preserves the DLL basename encoded by the import library, and
generates minimal build metadata only when the archive lacks it.
The Linux builder pins FFmpeg `8.1`, mpv `0.41.0`, libplacebo `7.360.1`,
libass `0.17.3`, FreeType `2.13.3`, FriBidi `1.0.16`, HarfBuzz `8.5.0`,
Expat `2.8.2`, Fontconfig `2.16.0`, OpenSSL `3.5.7`, hwdata `0.409`, and
libdisplay-info `0.1.1`. FFmpeg disables autodetected external libraries.
Libplacebo is checked out at an exact git commit with all required submodules.
The hwdata archive and its `pnp.ids` build input are pinned so
libdisplay-info cannot silently consume `/usr/share/hwdata` from the builder.
The generated manifest records source URLs/checksums or git commits,
submodules, licenses, exact flags, build-host/toolchain data, runtime hashes,
and the dynamic closure. FFmpeg/mpv remain LGPL-compatible and dynamically
linked; codecs outside that build configuration are not implied.
`generate-linux-runtime-notices.cjs` collects the exact upstream license files
for every pinned package and all recursive libplacebo submodules. Generation
is fail-closed for a missing, undeclared, symlinked, size-mismatched, or
hash-mismatched file. Portable and Flatpak package hooks copy only the
validated notice manifest, aggregate notice, and per-package license tree;
package-layout verification revalidates that legal payload against the
embedded source-runtime manifest.
The Electron backend build consumes the staged runtime/build inputs. On Linux
it links the helper against the verified staged `libmpv.so.2`, never against a
generic host `-lmpv`, then verifies the helper's `DT_NEEDED` and
`$ORIGIN/lib` RUNPATH with `readelf`. The addon and frame reader are checked
for the opposite invariant. The profile-aware packaging hook later retains or
removes the private closure. Local Linux builds may still use distribution
headers/libraries, but a required package build must use the staged manifest.
macOS additionally rewrites Mach-O paths and re-signs modified local binaries;
release signing/notarization still happens later.
For local development before the vendored runtime exists, Homebrew can be used explicitly:
@@ -593,7 +878,88 @@ For tagged macOS builds, CI must:
For Windows builds, CI must restore the `win32-x64` staged runtime cache or stage the checksum-pinned runtime archive before `pnpm run build:backend`. The Windows job must set `IPTVNATOR_EMBEDDED_MPV_PLATFORM=win32`, `IPTVNATOR_EMBEDDED_MPV_ARCH=x64`, and `IPTVNATOR_REQUIRE_EMBEDDED_MPV=1` for backend build, package make, and package-layout verification. CI narrows `electron-builder.json` to x64 Windows targets while only a `win32-x64` runtime is available. The Windows job is pinned to `windows-2022` until the Electron `node-gyp` toolchain can identify Visual Studio 18 from `windows-latest`.
For Linux builds, CI must set `IPTVNATOR_EMBEDDED_MPV_PLATFORM=linux`, `IPTVNATOR_EMBEDDED_MPV_ARCH=x64`, and `IPTVNATOR_REQUIRE_EMBEDDED_MPV=1` after staging the Ubuntu package build inputs. Linux package verification checks the `external-mpv-process` manifest and confirms that no bundled `libmpv.so` files are present.
For Linux builds, CI first builds or restores the pinned x64 source runtime and
stages it under `vendor/embedded-mpv/linux-x64`. It then runs three isolated
packaging passes with `IPTVNATOR_EMBEDDED_MPV_PLATFORM=linux`,
`IPTVNATOR_EMBEDDED_MPV_ARCH=x64`,
`IPTVNATOR_REQUIRE_EMBEDDED_MPV=1`, and one exact
`IPTVNATOR_LINUX_FRAME_COPY_PROFILE`. Each produced artifact is extracted and
verified, and the x64 helper probe runs in the intended runtime environment.
The packaged x64 Playwright smoke first runs its fixture-contract target and
passes Chromium `--ignore-gpu-blocklist` so Mesa llvmpipe can expose WebGL2 in
CI. That launch-only flag does not bypass any manifest, hash, loader, or helper
capability check; `--no-sandbox` is added only when the runner is root.
Bundled package layouts must include the generated notices and exact license
tree. The separately uploaded
`linux-frame-copy-runtime-sources.tar.xz` contains the exact archive set,
the VCS-metadata-free libplacebo working tree plus the exact pinned commit and
six recursive submodule records, notice/license inputs, runtime metadata,
current revision/diff, and build tooling. Each submodule record is canonical
`full-commit safe/path`; clone-depth-dependent `git describe` annotations are
discarded. The source index also records a
globally sorted exact inventory
of every libplacebo directory, regular file, and symlink. File hashes, sizes,
normalized executable bits, link targets, aggregate counts/bytes, and the
canonical inventory digest must match the trusted pinned v7.360.1 checkout;
an arbitrary or incomplete self-declared tree is rejected. Its tar metadata is
normalized, its member/type layout is exact, and
`metadata/archive-sha256.txt` must describe the actual source archive bytes.
Tar listing continues past every end marker, so concatenated xz/tar streams
cannot hide undeclared members. ARM artifacts are independently verified as
marker-only and never run the x64 helper.
After constructing the final
`linux-frame-copy-runtime-sources.tar.xz`, CI hashes its exact bytes and stages
`source-archive-binding.json` beside the x64 runtime. AppImage, Snap, and
Flatpak manifests copy that binding unchanged as `sourceArchive`, including the
SHA-256 and repository revision. System-package manifests and marker-only
non-x64 packages must not carry it, so a portable package cannot advertise
source correspondence inherited from another profile or architecture.
The build workflow creates a draft GitHub release but never publishes Snap in
parallel with that draft. The separate Snap workflow runs only for a public
`release.published` event whose tag starts with `v`; before any Store upload it
requires at least one exact `.snap` asset and exactly one non-empty
`linux-frame-copy-runtime-sources.tar.xz` in that public release. It hashes and
safely inspects the bounded downloaded archive, requires regular metadata,
archive, legal, and tooling member/type set, validates link targets and the
archive checksum metadata, and requires the clean checkout and source index to
match the released tag. It verifies the actual pinned source-member hashes,
the six recursive libplacebo submodule records, license-input and notice
hashes, the exact VCS-free libplacebo tree inventory/digest, and byte-identical
tooling from the released tag. Checkout and both artifact-transfer actions use
full pinned commits, and checkout sets `persist-credentials: false`.
The bounded SquashFS preflight and extraction then require the canonical
`/usr/lib/iptvnator` layout and reuse the static package validator for every
selected Snap. The public-release verifier separately reapplies the exact
strict `meta/snap.yaml` graphics/shared-memory/layout contract and enumerates
the extracted `resources/app.asar`; any archived
`electron-backend/native/**` entry fails before Store publication. The bounded
ASAR header reader uses only Node built-ins plus released local tooling, keeping
this check runnable from the clean tag checkout without `node_modules`. Exactly
one x64 Snap must contain a bundled portable manifest
whose exact `sourceArchive` and `sourceRuntime` match the archive; non-x64
Snaps must remain marker-only. Repository credentials are scoped to the two
GitHub asset steps. The secretless verification job copies downloaded files
through no-follow descriptors into a private snapshot, hashes them before and
after inspection, writes an exact receipt, root-seals the snapshot, and reruns
the complete source/package verifier against those bytes. It then transfers
only the sealed data through the pinned artifact service, publishes the exact
receipt digest separately as a job output, and terminates.
The dependent publish job runs on a bounded GitHub-hosted `ubuntu-latest`
runner with no checkout or release-tag code. It requires the separately
transmitted receipt digest, validates the exact receipt schema and every asset
size/hash, accepts only the expected regular `.snap`, source archive, and
receipt layout, rejects links and extra entries, and root-seals the transferred
files again before installing the official stable Snapcraft snap.
Only its final fixed shell step receives the Store credential. That step uses a
bounded Bash glob, resolves no PATH command, executes no released code, and
passes the credential only to each exact
`/snap/bin/snapcraft upload --release=edge` process. Any verification or
transfer mismatch aborts before Store credentials are available.
Candidate/stable promotion is manual after installed-Snap frame-copy and
missing-runtime fallback smoke; GitHub Actions never promotes automatically.
During temporary artifact tests, CI may also set `IPTVNATOR_REQUIRE_EMBEDDED_MPV=1` for PR and `master` push jobs where a runtime is known to exist. After the artifacts are manually validated, remove temporary conditions so ordinary development builds leave `IPTVNATOR_REQUIRE_EMBEDDED_MPV` unset or `0`. This keeps the native feature in-tree without making every non-release build depend on runtime artifacts.
@@ -605,7 +971,8 @@ The feature is still experimental. The largest risks are native-process risks, n
- packaging can fail if `libmpv` or one of its platform runtime dependencies is missing, unsigned where signing applies, or linked to the wrong runtime path
- macOS graphics behavior can vary across Intel, Apple Silicon, external displays, fullscreen transitions, and hardware decoding paths
- Windows `HWND` and Linux X11/Xwayland embedding need packaged-app smoke coverage for focus, resize, and fullscreen behavior
- Linux native Wayland is unsupported until a dedicated Wayland embedding path exists
- Linux native-view remains unsupported on native Wayland; frame-copy has no
window-embedding dependency but still requires a working EGL probe
- Homebrew `libmpv` builds can target a newer macOS version than IPTVnator's declared deployment target
It is reasonable to ship the code in-tree behind the current experiment flag. It is not yet safe to make it the default player. It can be exposed as desktop experimental if support detection is strict, the UI clearly labels it experimental, and fallback to Video.js or external MPV/VLC stays available.
@@ -616,8 +983,18 @@ If an embedded session fails to initialize, the app should keep the user in cont
Do not expose embedded MPV broadly until these pass on every supported target:
- macOS/Windows packaged app starts without system `mpv` installed; Linux reports Embedded MPV unsupported with a clear message when system `mpv` is missing
- bundled `libmpv` and dependent runtime files pass macOS/Windows package validation; Linux package validation confirms the external-process manifest and absence of bundled `libmpv.so`
- macOS/Windows packaged apps start without system `mpv`; Linux x64
frame-copy starts in each declared package profile, and a missing frame-copy
dependency falls back without crashing
- bundled libmpv and dependent runtime files pass package validation;
DEB/RPM/Pacman contain no private closure and declare the exact system
dependency
- Electron, its shipped libraries, `embedded_mpv.node`, and the frame reader
have no direct libmpv `DT_NEEDED`; the helper resolves the exact declared
libmpv runtime
- AppImage, DEB, RPM, Pacman, Snap, and Flatpak payloads pass extraction,
manifest/mode/ELF checks and the applicable helper probe; ARM payloads are
marker-only
- macOS bundled `libmpv` and dependent dylibs pass code signing and notarization
- VOD resume starts near the saved offset
- series EOF emits `ended` and embedded MPV auto-continues only inside the current season
@@ -23,6 +23,12 @@ Related:
- Dashboard `Global Favorites` and `Recently Watched` widgets hand off Xtream and
Stalker movies/series into the matching global collection route with detail
pre-opened.
- Dashboard Continue Watching handoffs for Xtream series may additionally carry
a one-shot season/episode resume target. The collection-owned Xtream detail
consumes it after its episode positions load and starts that exact episode;
opening the series from the collection grid itself remains detail-only. If the
positions load fails, the target stays unconsumed and the handoff degrades to
detail-only rather than starting the episode at offset zero.
- Do not force both portals into the same browse/detail behavior unless the full
portal detail architecture is being changed.
@@ -73,6 +79,14 @@ Dashboard behavior to preserve:
Xtream movie/series items into `/workspace/global-favorites` or
`/workspace/global-recent` with collection detail pre-opened from navigation
state.
- When an Xtream series recent has a saved episode position, the dashboard hero
and Continue Watching card should include that exact series/episode target in
the navigation state. It is a one-shot playback request and must not leak into
normal favorites, search, category, or collection-grid navigation. Only
position rows that name their parent `seriesXtreamId` produce a target:
episode-keyed recents make `item.xtream_id` an episode id, so legacy rows
without the pointer stay detail-only instead of promoting the episode id to a
series id.
- Back from the collection detail should return to the dashboard handoff state,
not switch the active playlist.
+10 -2
View File
@@ -97,6 +97,11 @@ Render rules:
hero and cards can show progress, remaining time, and series season/
episode badges. Series lookup uses keyed maps for both direct episode ids
and series ids; card renders must not scan the full playback-position map.
Dashboard-originated Xtream series clicks also carry that exact episode
target through the global-recent inline-detail handoff. Once the series
metadata and playback positions load, the detail player consumes the
target once and resumes the saved episode. Opening the same item normally
from the global recent grid remains a detail-only action.
3. `liveOnFavoritesCardsEnriched` — maps favorited live channels first,
falling back to recently watched live channels when no live favorites
exist. M3U cards carry an `epg_lookup_key` using the app-wide XMLTV
@@ -174,9 +179,12 @@ The welcome state is rendered via the existing
rails have data.
5. Navigation from a rail card must deep-link into the appropriate workspace
route without switching the active playlist in the header switcher.
6. `Recently Used Sources` reflects recent source usage across all provider
6. Xtream series hero/Continue Watching clicks with a saved episode position
must resume that exact episode while preserving the collection-owned detail
and Back behavior. Do not apply autoplay to ordinary collection-grid clicks.
7. `Recently Used Sources` reflects recent source usage across all provider
types, not just recent imports.
7. The live rail title key must match the rendered source: favorites use
8. The live rail title key must match the rendered source: favorites use
`WORKSPACE.DASHBOARD.LIVE_FAVORITES`; recently watched fallback uses
`WORKSPACE.DASHBOARD.LIVE_RECENT`.
@@ -0,0 +1,658 @@
# Linux Embedded MPV Frame-Copy Packaging Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Ship a verified Linux x64 frame-copy runtime in AppImage, DEB, RPM, Pacman, Snap, and Flatpak while preserving out-of-process libmpv isolation and honest native-view fallback.
**Architecture:** Split official Linux packaging into system-runtime and bundled-runtime passes because Electron Builder reuses one unpacked layout per pass. A versioned manifest plus a real helper `--runtime-probe` gates frame-copy before BrowserWindow creation; only the helper may link libmpv, and foreign-architecture packages retain the unavailable marker.
**Tech Stack:** Electron 41, Node/TypeScript, C++17/N-API, libmpv render API, EGL/OpenGL/GBM, ELF/RPATH tooling, electron-builder 26, Nx/Jest/node:test, Playwright, GitHub Actions, AppImage/DEB/RPM/Pacman/Snap/Flatpak.
---
## File Map
### Runtime contracts and staging
- Create `tools/embedded-mpv/linux-runtime-manifest.cjs`
- Parse, normalize, and validate Linux frame-copy runtime manifests.
- Create `tools/embedded-mpv/build-linux-runtime.mjs`
- Build the pinned LGPL-compatible FFmpeg/libass/libplacebo/libmpv prefix.
- Modify `tools/embedded-mpv/stage-runtime.mjs`
- Stage Linux shared libraries and reject incomplete release manifests.
- Modify `apps/electron-backend/build-embedded-mpv.js`
- Build against staged Linux libmpv, copy the bundled closure, and emit the
profile-neutral build manifest.
- Modify `apps/electron-backend/native/binding.gyp`
- Keep helper RPATH relative and remove build-host RPATH.
- Modify `package.json`
- Expose the Linux runtime build command.
### Packaging profiles and validation
- Create `tools/packaging/linux-frame-copy-profile.cjs`
- Own profile names, target sets, manifest origins, and package dependencies.
- Create `tools/packaging/linux-frame-copy-profile.test.mjs`
- Verify profile/target/dependency mapping and invalid combinations.
- Modify `electron-builder.json`
- Declare DEB/RPM/Pacman libmpv dependencies.
- Modify `tools/packaging/embedded-mpv-frame-copy-files.cjs`
- Package Linux helper/reader and select/remove private runtime by profile.
- Modify `tools/packaging/embedded-mpv-packaging.cjs`
- Validate Linux ELF linkage, manifest, files, modes, RPATH, and isolation.
- Modify `tools/packaging/embedded-mpv-arch.test.mjs`
- Cover system, bundled, malformed, and foreign-architecture layouts.
- Modify `tools/packaging/electron-after-pack.cjs`
- Pass the required Linux profile into preparation and validation.
- Modify `tools/packaging/verify-electron-package-layout.mjs`
- Verify the expected profile for every unpacked layout.
- Modify `tools/packaging/project.json`
- Add new tests and source inputs.
### Runtime capability probe
- Modify `apps/electron-backend/native/helper/frame_helper_gl.h`
- Provide a context-only probe that does not create a playback session.
- Modify `apps/electron-backend/native/helper/mpv_frame_helper.cpp`
- Implement the versioned `--runtime-probe` JSON protocol.
- Create `apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime.ts`
- Validate manifest/files and run/cache the bounded helper probe.
- Create `apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime.spec.ts`
- Cover all fail-closed paths and success caching.
- Modify `apps/electron-backend/src/app/services/embedded-mpv-frame-copy-platform.util.ts`
- Resolve an artifact set and delegate usability to the runtime probe.
- Modify `apps/electron-backend/src/app/services/embedded-mpv-frame-copy-platform.util.spec.ts`
- Keep path/security coverage and add manifest/probe integration cases.
- Modify `apps/electron-backend/src/app/services/embedded-mpv-native.service.ts`
- Surface stable fallback diagnostics without changing native-view safety.
### Linux CI, package smoke, and documentation
- Create `tools/packaging/verify-linux-frame-copy-runtime.mjs`
- Inspect real package payload ELF/modes/manifest and invoke the helper probe.
- Create `tools/packaging/verify-linux-frame-copy-runtime.test.mjs`
- Unit-test verifier parsing and failure reporting with fixtures.
- Create `apps/electron-backend-e2e/src/embedded-mpv-frame-copy-packaged.e2e.ts`
- Exercise packaged capability, a deterministic media frame, and fallback.
- Modify `.github/workflows/build-and-make.yaml`
- Build/cache runtime, split profiles, inspect every format, and run sandbox
and container smoke coverage.
- Modify `docs/architecture/embedded-mpv-native.md`
- Modify `tools/embedded-mpv/README.md`
- Modify `vendor/embedded-mpv/README.md`
- Modify `AGENTS.md`
- Modify `CLAUDE.md`
- Document the final contract and verification matrix.
## Task 1: Define Linux Packaging Profiles
**Files:**
- Create: `tools/packaging/linux-frame-copy-profile.cjs`
- Create: `tools/packaging/linux-frame-copy-profile.test.mjs`
- Modify: `electron-builder.json`
- Modify: `tools/packaging/project.json`
- [ ] **Step 1: Write failing profile tests**
Cover the exact public API:
```js
assert.deepEqual(resolveLinuxFrameCopyProfile('system'), {
name: 'system',
runtimeMode: 'system',
targets: ['deb', 'rpm', 'pacman'],
manifestOrigin: 'system-libmpv-frame-copy',
});
assert.deepEqual(resolveLinuxFrameCopyProfile('portable').targets, [
'appimage',
'snap',
]);
assert.deepEqual(resolveLinuxFrameCopyProfile('flatpak').targets, ['flatpak']);
assert.throws(() => resolveLinuxFrameCopyProfile('standard'), /Unsupported/);
assert.deepEqual(LINUX_SYSTEM_PACKAGE_DEPENDENCIES, {
deb: 'libmpv2',
rpm: 'mpv-libs',
pacman: 'mpv',
});
assert.deepEqual(validateLinuxProfileTargets('system', ['deb', 'AppImage']), [
'Linux frame-copy profile "system" cannot build target "appimage".',
]);
```
- [ ] **Step 2: Run RED**
```bash
node --test tools/packaging/linux-frame-copy-profile.test.mjs
```
Expected: FAIL because the profile module does not exist.
- [ ] **Step 3: Implement the profile module and package dependencies**
Export immutable `LINUX_FRAME_COPY_PROFILES`,
`LINUX_SYSTEM_PACKAGE_DEPENDENCIES`, `resolveLinuxFrameCopyProfile()`, and
`validateLinuxProfileTargets()`. Add `deb.depends += libmpv2`,
`rpm.depends += mpv-libs`, and `pacman.depends += mpv` without replacing
Electron Builder's existing defaults.
- [ ] **Step 4: Register and run GREEN**
Add the new test to `packaging:test`, then run:
```bash
pnpm nx test packaging --skip-nx-cache
```
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
git add electron-builder.json tools/packaging
git commit -m "feat(packaging): define Linux frame-copy profiles"
```
## Task 2: Stage A Pinned LGPL Linux Runtime
**Files:**
- Create: `tools/embedded-mpv/linux-runtime-manifest.cjs`
- Create: `tools/embedded-mpv/linux-runtime-manifest.test.mjs`
- Create: `tools/embedded-mpv/build-linux-runtime.mjs`
- Modify: `tools/embedded-mpv/stage-runtime.mjs`
- Modify: `package.json`
- Modify: `tools/packaging/project.json`
- [ ] **Step 1: Write failing manifest/staging tests**
Use temporary prefixes to prove:
```js
assert.equal(validateLinuxRuntimeManifest(validManifest).length, 0);
assert.match(
validateLinuxRuntimeManifest({
...validManifest,
ffmpeg: { configureFlags: ['--enable-gpl'] },
})[0],
/--enable-gpl/
);
assert.match(
validateLinuxRuntimeManifest({
...validManifest,
mpv: { mesonFlags: ['-Dgpl=true'] },
})[0],
/-Dgpl=false/
);
```
Exercise `stage-runtime.mjs linux x64 <prefix>` and assert it copies
`libmpv.so.2` plus all manifest-declared `.so` files and records byte sizes.
- [ ] **Step 2: Run RED**
```bash
node --test tools/embedded-mpv/linux-runtime-manifest.test.mjs
```
Expected: FAIL because the validator/build/staging contract is absent.
- [ ] **Step 3: Implement the source builder**
Reuse the pinned package versions already used by the macOS builder. Linux
FFmpeg flags must include:
```text
--enable-shared --disable-static --disable-programs --disable-doc
--disable-debug --disable-autodetect --disable-gpl --disable-nonfree
--enable-pic --enable-pthreads
```
Linux mpv flags must include:
```text
-Dgpl=false -Dlibmpv=true -Dcplayer=false -Dtests=false
-Dlua=disabled -Djavascript=disabled -Dcplugins=disabled
-Dlibarchive=disabled -Dlibbluray=disabled -Ddvdnav=disabled
-Dcdda=disabled -Ddvbin=disabled -Dvulkan=disabled
-Dplain-gl=enabled -Degl=enabled -Dgbm=enabled
```
Record downloaded SHA-256 values, git commits/submodules, exact flags, runtime
file names/sizes, and source-distribution obligations. Pin the hwdata v0.409
archive and record its `pnp.ids` as a build input to libdisplay-info 0.1.1.
Stage private `hwdata.pc` metadata and run libdisplay-info's Meson setup with a
prefix-only pkg-config environment so the upstream
`/usr/share/hwdata/pnp.ids` fallback is unreachable. Include the exact hwdata
archive and its `GPL-2.0-or-later OR XFree86-1.0` notice in the release source
bundle.
- [ ] **Step 4: Implement Linux staging**
Require `include/mpv/client.h`, a versioned `libmpv.so.*`, and a valid manifest.
Copy only manifest-declared shared libraries and preserve SONAME symlinks as
materialized regular files so Electron Builder cannot lose them.
- [ ] **Step 5: Run GREEN and static policy checks**
```bash
pnpm nx test packaging --skip-nx-cache
node --check tools/embedded-mpv/build-linux-runtime.mjs
node --check tools/embedded-mpv/stage-runtime.mjs
```
Expected: PASS and no GPL/nonfree-enabling flag in generated policy fixtures.
- [ ] **Step 6: Commit**
```bash
git add package.json tools/embedded-mpv tools/packaging/project.json
git commit -m "feat(embedded-mpv): stage LGPL Linux runtime"
```
## Task 3: Build A Relocatable Isolated Helper
**Files:**
- Modify: `apps/electron-backend/native/binding.gyp`
- Modify: `apps/electron-backend/build-embedded-mpv.js`
- Test: `apps/electron-backend/src/app/services/embedded-mpv-native-source.spec.ts`
- [ ] **Step 1: Extend source-policy tests**
Assert the Linux helper has `$ORIGIN/lib` and no absolute build-host RPATH,
the addon does not use `-lmpv`, the staged closure is copied, and the build
manifest identifies both allowed package modes.
- [ ] **Step 2: Run RED**
```bash
pnpm nx test electron-backend --skip-nx-cache --runInBand \
--testPathPatterns=embedded-mpv-native-source.spec
```
Expected: FAIL on the current absolute `LINUX_NATIVE_LIBRARY_DIR` RPATH and
`external-mpv-process`-only manifest.
- [ ] **Step 3: Update native build integration**
Link the helper against staged `libmpv.so`, retain only:
```text
-Wl,--enable-new-dtags
-Wl,-rpath,$ORIGIN/lib
```
Copy the staged shared-library closure to build output for downstream bundled
profiles, but keep `embedded_mpv.node` dynamically independent of libmpv.
Write a build manifest that carries the runtime metadata without prematurely
choosing `system` versus `portable`.
- [ ] **Step 4: Run GREEN**
```bash
pnpm nx test electron-backend --skip-nx-cache --runInBand \
--testPathPatterns=embedded-mpv-native-source.spec
```
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
git add apps/electron-backend/native/binding.gyp \
apps/electron-backend/build-embedded-mpv.js \
apps/electron-backend/src/app/services/embedded-mpv-native-source.spec.ts
git commit -m "feat(embedded-mpv): build relocatable Linux helper"
```
## Task 4: Package And Validate Each Runtime Mode
**Files:**
- Modify: `tools/packaging/embedded-mpv-frame-copy-files.cjs`
- Modify: `tools/packaging/embedded-mpv-packaging.cjs`
- Modify: `tools/packaging/embedded-mpv-arch.test.mjs`
- Modify: `tools/packaging/electron-after-pack.cjs`
- Modify: `tools/packaging/verify-electron-package-layout.mjs`
- [ ] **Step 1: Write failing package-layout tests**
Create realistic temp layouts and prove:
- system mode requires executable helper, reader, system manifest, no
`native/lib/libmpv*`;
- bundled mode requires all manifest files and rejects missing/runtime-prefix
links;
- helper mode `0644` is rejected;
- reader symlinks/directories are rejected;
- Linux addon or Electron `DT_NEEDED libmpv` is rejected;
- helper without `DT_NEEDED libmpv.so.2` is rejected;
- foreign-arch layout contains only the unavailable marker.
- [ ] **Step 2: Run RED**
```bash
pnpm nx test packaging --skip-nx-cache
```
Expected: FAIL because Linux helpers are deleted and validation forbids them.
- [ ] **Step 3: Implement profile-aware preparation**
For x64:
- restore helper mode `0755`;
- always retain the frame reader;
- `system`: remove private runtime and write normalized system manifest;
- `portable`/`flatpak`: retain only manifest-declared closure and write bundled
manifest;
- reject missing `IPTVNATOR_LINUX_FRAME_COPY_PROFILE` when Embedded MPV is
required.
For foreign architectures, keep the current unavailable marker behavior and
remove every native artifact.
- [ ] **Step 4: Implement ELF/package validation**
Use `readelf -d` for `NEEDED` and RPATH/RUNPATH inspection. Resolve bundled
closure recursively from the private directory and permit only a documented
glibc/driver/system allowlist outside it. Keep validation host-aware: pure
manifest/mode checks run everywhere; ELF inspection is required on Linux CI.
- [ ] **Step 5: Run GREEN**
```bash
pnpm nx test packaging --skip-nx-cache
pnpm nx test electron-backend --skip-nx-cache --runInBand \
--testPathPatterns=embedded-mpv-native-source.spec
```
Expected: PASS.
- [ ] **Step 6: Commit**
```bash
git add tools/packaging
git commit -m "feat(packaging): ship Linux frame-copy artifacts"
```
## Task 5: Add The Real Runtime Capability Probe
**Files:**
- Modify: `apps/electron-backend/native/helper/frame_helper_gl.h`
- Modify: `apps/electron-backend/native/helper/mpv_frame_helper.cpp`
- Create: `apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime.ts`
- Create: `apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime.spec.ts`
- Modify: `apps/electron-backend/src/app/services/embedded-mpv-frame-copy-platform.util.ts`
- Modify: `apps/electron-backend/src/app/services/embedded-mpv-frame-copy-platform.util.spec.ts`
- Modify: `apps/electron-backend/src/app/services/embedded-mpv-native.service.ts`
- [ ] **Step 1: Write failing TypeScript probe tests**
Inject filesystem and `spawnSync` collaborators. Cover:
```ts
expect(probeRuntime(validArtifacts, successSpawn).usable).toBe(true);
expect(probeRuntime(validArtifacts, timeoutSpawn).reason).toBe(
'helper-probe-timeout'
);
expect(probeRuntime(validArtifacts, nonzeroSpawn).reason).toBe(
'helper-probe-failed'
);
expect(probeRuntime(validArtifacts, invalidJsonSpawn).reason).toBe(
'helper-probe-invalid-output'
);
expect(probeRuntime(missingManifest, successSpawn).reason).toBe(
'runtime-manifest-missing'
);
expect(successSpawn).toHaveBeenCalledTimes(1);
```
The last assertion calls the public probe twice and proves process-lifetime
caching.
- [ ] **Step 2: Run RED**
```bash
pnpm nx test electron-backend --skip-nx-cache --runInBand \
--testPathPatterns=embedded-mpv-frame-copy-runtime.spec
```
Expected: FAIL because the runtime probe module does not exist.
- [ ] **Step 3: Implement helper `--runtime-probe`**
Emit exactly one line:
```json
{ "protocol": 1, "usable": true, "libmpv": "2.x", "renderApi": "egl" }
```
Exit nonzero with a JSON `reason` when `mpv_create`, `mpv_initialize`, or the
EGL/OpenGL context probe fails. Do not create shared memory, open a URL, or
enter the normal command loop.
- [ ] **Step 4: Implement fail-closed main-process probing**
Validate manifest/artifacts first, then run:
```ts
spawnSync(helperPath, ['--runtime-probe'], {
encoding: 'utf8',
timeout: 3000,
windowsHide: true,
env: probeEnvironment,
});
```
Cache by helper/manifest identity. Never throw across startup; return a stable
reason and make `isFrameCopyRuntimeUsable()` depend on `.usable`.
- [ ] **Step 5: Run GREEN and related regression tests**
```bash
pnpm nx test electron-backend --skip-nx-cache --runInBand \
--testPathPatterns='embedded-mpv-frame-copy-(runtime|platform).util.spec|app.spec|embedded-mpv-native.service.spec'
```
Expected: PASS; unavailable runtime keeps sandbox enabled and selects native.
- [ ] **Step 6: Commit**
```bash
git add apps/electron-backend/native/helper \
apps/electron-backend/src/app/services
git commit -m "feat(embedded-mpv): probe Linux frame-copy runtime"
```
## Task 6: Split Linux CI And Inspect Every Artifact
**Files:**
- Create: `tools/packaging/verify-linux-frame-copy-runtime.mjs`
- Create: `tools/packaging/verify-linux-frame-copy-runtime.test.mjs`
- Modify: `.github/workflows/build-and-make.yaml`
- Modify: `tools/packaging/project.json`
- [ ] **Step 1: Write failing verifier tests**
Test payload discovery for `.AppImage`, `.deb`, `.rpm`, Pacman archive,
`.snap`, and `.flatpak`, plus clear errors for a missing helper, wrong mode,
wrong profile, direct addon libmpv linkage, and unresolved helper dependency.
- [ ] **Step 2: Run RED**
```bash
node --test tools/packaging/verify-linux-frame-copy-runtime.test.mjs
```
Expected: FAIL because the verifier does not exist.
- [ ] **Step 3: Implement artifact verification**
Provide `--artifact <path> --profile <name>`. Extract/mount into a temp
directory, find the native layout, run manifest/mode/ELF checks, and execute
`iptvnator_mpv_helper --runtime-probe` in the package's intended environment.
Always clean temporary mounts/directories.
- [ ] **Step 4: Split and harden CI**
Change Linux matrix entries to:
```yaml
- os: linux
linux_profile: system
- os: linux
linux_profile: portable
- os: linux
linux_profile: flatpak
```
Filter targets exactly per profile and set
`IPTVNATOR_LINUX_FRAME_COPY_PROFILE`. Build/cache the pinned runtime once per
source/tool hash. Add format-specific extraction/installation tools and invoke
the verifier for every produced artifact.
Run system formats in matching containers with `libmpv2`, `mpv-libs`, or
`mpv`; run AppImage directly with extraction fallback; install and probe Snap
and Flatpak inside their sandboxes.
- [ ] **Step 5: Run GREEN and workflow source regressions**
```bash
pnpm nx test packaging --skip-nx-cache
pnpm nx test electron-backend --skip-nx-cache --runInBand \
--testPathPatterns=embedded-mpv-native-source.spec
```
Expected: PASS and source tests prove all three profiles and six formats.
- [ ] **Step 6: Commit**
```bash
git add .github/workflows/build-and-make.yaml tools/packaging
git commit -m "ci: verify Linux frame-copy packages"
```
## Task 7: Add Packaged Playback And Fallback Smoke Coverage
**Files:**
- Create: `apps/electron-backend-e2e/src/embedded-mpv-frame-copy-packaged.e2e.ts`
- Modify: `.github/workflows/build-and-make.yaml`
- [ ] **Step 1: Write the packaged E2E**
Use the existing Electron test fixtures and a generated two-second local media
fixture. Assert the support response reports `frameCopyAvailable: true`,
activate the engine, load the fixture, observe a nonzero frame generation and
playing/paused snapshot, then relaunch with the runtime hidden and assert
native engine selection without a main-process crash.
- [ ] **Step 2: Run the closest local parse/list check**
```bash
pnpm nx lint electron-backend-e2e
pnpm nx show project electron-backend-e2e
```
Expected: PASS on macOS; the actual test is Linux-packaged-only.
- [ ] **Step 3: Wire the Linux packaged smoke**
Run the spec against the unpacked x64 bundled layout under software EGL
(`LIBGL_ALWAYS_SOFTWARE=1`) and keep a hardware-enabled smoke as a separate
non-blocking diagnostic when the CI runner exposes DRI.
- [ ] **Step 4: Commit**
```bash
git add apps/electron-backend-e2e/src/embedded-mpv-frame-copy-packaged.e2e.ts \
.github/workflows/build-and-make.yaml
git commit -m "test(embedded-mpv): smoke packaged Linux frame-copy"
```
## Task 8: Update Canonical Documentation
**Files:**
- Modify: `docs/architecture/embedded-mpv-native.md`
- Modify: `tools/embedded-mpv/README.md`
- Modify: `vendor/embedded-mpv/README.md`
- Modify: `AGENTS.md`
- Modify: `CLAUDE.md`
- [ ] **Step 1: Replace the dev-only Linux contract**
Document x64 support across all six formats, the three profiles, dependency
names, runtime manifest/probe, codec baseline, source obligations, fallback,
and ARM unavailable behavior. Keep `AGENTS.md` and `CLAUDE.md` synchronized.
- [ ] **Step 2: Verify documentation consistency**
```bash
rg -n "dev-build-only|stripped from packages|must not bundle libmpv" \
AGENTS.md CLAUDE.md docs/architecture/embedded-mpv-native.md \
tools/embedded-mpv/README.md vendor/embedded-mpv/README.md
git diff --check
```
Expected: no stale Linux frame-copy shipping claim; any remaining
“must not bundle” text applies specifically to Electron/addon or system
profiles.
- [ ] **Step 3: Commit**
```bash
git add AGENTS.md CLAUDE.md docs/architecture/embedded-mpv-native.md \
tools/embedded-mpv/README.md vendor/embedded-mpv/README.md
git commit -m "docs: document Linux frame-copy packages"
```
## Task 9: Final Verification Matrix
**Files:** No production edits unless verification exposes a defect.
- [ ] **Step 1: Run local project discovery and affected checks**
```bash
pnpm nx show projects
pnpm nx test packaging --skip-nx-cache
pnpm nx test electron-backend --skip-nx-cache --runInBand
pnpm nx lint packaging --skip-nx-cache
pnpm nx lint electron-backend --skip-nx-cache
pnpm nx lint electron-backend-e2e --skip-nx-cache
pnpm nx build electron-backend --configuration=production --skip-nx-cache
```
Expected: PASS or an explicitly recorded platform-only native-build skip on
macOS without a vendored runtime.
- [ ] **Step 2: Verify isolation source and local artifacts**
```bash
rg -n -- '-lmpv|libmpv' apps/electron-backend/native/binding.gyp \
tools/packaging apps/electron-backend/build-embedded-mpv.js
git diff --check origin/master...HEAD
git status --short
```
Expected: only the helper target links libmpv; no unstaged/unexplained files.
- [ ] **Step 3: Record the exact evidence matrix**
Report separately:
- verified locally on macOS: Node/Jest/lint/build/static/package-policy tests;
- structurally verified but not executable locally: Linux runtime builder and
artifact extraction code;
- requires Linux CI: ELF resolution, actual six-format packages, package
managers, Snap/Flatpak confinement, EGL/GBM, frame production, and fallback
with libmpv removed.
- [ ] **Step 4: Stop before publication**
Do not push, open a PR, publish artifacts, or merge. Leave the fully checked
local branch ready for explicit user confirmation in a new task.
@@ -0,0 +1,263 @@
# Linux Embedded MPV Frame-Copy Packaging Design
**Date:** 2026-07-17
**Status:** Approved
## Goal
Ship a genuinely usable Embedded MPV frame-copy runtime in every official
Linux x64 package format produced by IPTVnator: AppImage, DEB, RPM, Pacman,
Snap, and Flatpak. Keep libmpv outside the Electron process, retain the
existing native-view engine as a safe fallback, and never advertise
frame-copy from artifact presence alone.
Linux arm64 and armv7l remain out of scope for native Embedded MPV artifacts.
The current CI cross-packages those architectures from an x64 host and cannot
produce or exercise matching native addons. Those packages must continue to
carry an explicit unavailable marker and must not contain x64 native binaries.
## Evidence From The Existing Implementation
- `apps/electron-backend/native/binding.gyp` builds three distinct artifacts:
the native-view addon, the N-API shared-memory frame reader, and the
`iptvnator_mpv_helper` process. On Linux only the helper links `-lmpv`; the
addon uses X11/Xext/dlopen and must remain free of libmpv linkage.
- `tools/packaging/embedded-mpv-frame-copy-files.cjs` deliberately deletes the
helper from every Linux package.
- `tools/packaging/embedded-mpv-packaging.cjs` rejects Linux packages that
contain either the helper or libmpv, and accepts only the
`external-mpv-process` manifest origin.
- `resolveFrameCopyHelperPath()` currently treats an executable helper plus a
readable reader addon as a usable runtime. It does not prove that the ELF
loader can resolve libmpv or that libmpv/EGL initialization works.
- `.github/workflows/build-and-make.yaml` builds and verifies the Linux helper
against Ubuntu's system libmpv, then relies on the after-pack hook to remove
it. The same x64 build output is used for foreign-architecture Linux
packages, which receive the unavailable marker.
- Electron Builder creates one unpacked application layout before producing
multiple distributable targets. A system-runtime layout and a bundled
portable-runtime layout therefore cannot safely share one packaging pass.
## Selected Distribution Strategy
Linux packaging is split into explicit profiles:
| Profile | Formats | libmpv strategy |
| ---------- | ---------------- | -------------------------------------------------------------------------- |
| `system` | DEB, RPM, Pacman | Depend on the distribution package and resolve `libmpv.so.2` from the host |
| `portable` | AppImage, Snap | Bundle the pinned LGPL-compatible runtime closure under `native/lib` |
| `flatpak` | Flatpak | Bundle the same pinned LGPL-compatible runtime closure under `native/lib` |
The official CI matrix must run these profiles independently. The packaging
hook receives the profile through a required environment value and validates
that the selected target set matches the runtime mode. It must fail closed if
an official x64 package is requested with an absent, incomplete, or ambiguous
profile.
System package dependencies are:
- DEB: `libmpv2`, `libegl1`, `libgl1`, `libgbm1`
- RPM: `mpv-libs`, `libglvnd-egl`, `libglvnd-glx`, `mesa-libgbm`
- Pacman: `mpv`, `libglvnd`, `mesa`
These names match the current Debian, Fedora, and Arch package databases and
cover every direct helper interface: libmpv, EGL, GL, and GBM. The helper
links `libGL.so.1` rather than `libOpenGL.so.0`, matching both the distro
contracts and Snap's graphics provider. System
packages do not copy libmpv into IPTVnator. The helper keeps an `$ORIGIN/lib`
RUNPATH first for a consistent binary, but naturally resolves the system SONAME
when the private directory is absent.
Portable and sandboxed packages use a source-built runtime rather than copying
the Ubuntu runner's mpv package. The runtime build is checksum/version pinned,
uses FFmpeg without GPL/nonfree switches and mpv with `-Dgpl=false`, records
sources and exact flags in the manifest, and keeps shared libraries replaceable
under the LGPL. The minimal codec baseline is FFmpeg's built-in LGPL decoders,
demuxers, protocols, and software scaling/resampling plus libass text
subtitles. Hardware decoding remains opportunistic through host Mesa/driver
interfaces and must fall back to software decoding.
The source build also pins the `hwdata` v0.409 archive and its SHA-256 because
libdisplay-info 0.1.1 compiles `pnp.ids` into its generated vendor lookup
table. The builder stages that file with private `hwdata.pc` metadata and
restricts libdisplay-info's native pkg-config search to the staged prefix, so
Meson's `/usr/share/hwdata/pnp.ids` fallback cannot make the runtime depend on
unrecorded host data. The runtime manifest records this build-input
relationship. Release source bundles must include the exact hwdata archive and
its dual-license notice (`GPL-2.0-or-later OR XFree86-1.0`) alongside the
MIT-licensed libdisplay-info source.
The strict Snap uses `base: core22`, a private `shared-memory` plug, and an
exact `graphics-core22` content plug targeting a real empty mode-0755
`$SNAP/graphics` with external `mesa-core22` as default provider. The graphics provider supplies
EGL/GL/GLX/GBM/DRM/VA; Electron Builder's GNOME content runtime supplies
ALSA/PulseAudio. Those shared providers are not copied into IPTVnator's Snap or
source/notices archive. Because core22 does not synthesize `$SNAP` content
targets, the package hook creates the empty directory and extracted-artifact
validation checks its type and emptiness. The metadata also declares exactly
the canonical graphics layouts: `/usr/share/libdrm` binds from
`$SNAP/graphics/libdrm`, and `/usr/share/drirc.d` symlinks to
`$SNAP/graphics/drirc.d`. Locally installed `--dangerous` artifacts explicitly
install and connect both providers in CI, disconnect `graphics-core22` to
require an unavailable application diagnostic with exit code `1`, then
reconnect it and require success.
## Runtime Layout And Linkage
The x64 packaged native directory is:
```text
resources/app.asar.unpacked/electron-backend/native/
embedded_mpv.node
embedded_mpv_frame_reader.node
iptvnator_mpv_helper
embedded-mpv-runtime.json
lib/
libmpv.so.2
libavcodec.so.*
libavformat.so.*
libavutil.so.*
libavfilter.so.*
libswresample.so.*
libswscale.so.*
libass.so.*
...other non-system runtime dependencies
```
For `system`, `lib/` is absent and the manifest declares the required SONAME
and package-family dependency. For `portable` and `flatpak`, `lib/` contains
the complete non-system dependency closure. ELF dependencies inside that
closure and the helper use only SONAMEs plus `$ORIGIN`-relative RPATH/RUNPATH;
they may not retain build-prefix paths.
`embedded_mpv.node`, the Electron executable (`iptvnator.bin`), and Electron's
shipped libraries must not have a direct `DT_NEEDED` entry for libmpv.
`iptvnator_mpv_helper` must have one. Process isolation is an invariant, not a
profile-specific choice. The source `electron-backend/native{,/**/*}` tree is
excluded from `app.asar`; `afterPack` is the sole owner of the normalized
unpacked native directory, and package checks reject every archived native
entry. The pristine Electron tree is scanned recursively
before target packaging. Because Snap later overlays package-manager
`lib/**`/`usr/lib/**` trees into the payload root, its extracted-target scan
excludes exactly those two target-provided trees while remaining recursive
everywhere else. Electron-library symlinks outside those roots fail closed.
The manifest records:
- schema version, platform, architecture, profile, and runtime origin;
- required helper/reader names and executable/readable expectations;
- libmpv SONAME and either system package requirements or bundled files;
- source package versions, URLs/checksums, license identifiers, and exact
FFmpeg/mpv build flags for bundled profiles;
- the pinned hwdata `pnp.ids` build input consumed by libdisplay-info;
- runtime closure and total byte size;
- the native-view backend contract and the fact that only the helper links
libmpv.
## Honest Capability Detection
The helper gains a side-effect-free `--runtime-probe` mode. It must:
1. load through the normal ELF loader and therefore prove that all `DT_NEEDED`
dependencies resolve;
2. create and initialize an idle libmpv handle with `vo=libmpv`;
3. create the platform render pipeline far enough to prove EGL/OpenGL/GBM
availability without opening media;
4. create, map, validate, and destroy the minimal shared-memory ring required
by playback;
5. emit one versioned JSON result and exit promptly with status zero only on
success.
The main process invokes this probe synchronously with a bounded timeout before
BrowserWindow creation. Probe success is cached for the process lifetime.
The probe environment prepends the packaged `native/lib` directory only when
the manifest declares a bundled runtime. The system profile does not inject a
private loader path. Snap additionally rebuilds its loader and graphics-driver
variables from validated host GL, `$SNAP/graphics`, exact GNOME-platform, and
generic core22 roots; ambient preload/audit/library/driver overrides and
caller-provided architecture triplets are not inherited. The direct provider
wrapper launch also removes shell startup/options, tracing hooks, and exported
functions and fixes `PATH` to immutable core22 system directories.
Packaging CI invokes the full main-process gate with the exact
`--embedded-mpv-runtime-probe` application switch. It executes before
BrowserWindow startup, writes one availability JSON line, and exits zero only
for a usable runtime. CI does not treat a direct helper invocation or an
environment opt-in as proof of packaged capability.
Frame-copy is usable only when all of the following are true:
- platform and architecture are supported;
- helper and reader are regular files with correct access modes;
- the runtime manifest is present, parses, matches Linux/x64, names the actual
artifacts, and uses an allowed profile/origin;
- every manifest-declared bundled file exists as a readable regular file;
- `--runtime-probe` succeeds and returns the expected protocol version.
Any failure returns `false`, keeps the renderer sandbox enabled, and makes the
native service choose native-view. The capability result includes a stable
reason code for tracing and diagnostics but does not crash startup.
If dependencies disappear after startup, helper spawn/early-exit remains a
session error and follows the existing renderer fallback path. The helper is
never loaded into Electron as a library.
## Packaging And CI Validation
Unit and packaging tests cover:
- profile-to-target mapping and rejection of mixed system/bundled passes;
- Linux runtime staging, manifest normalization, closure collection, RPATH,
file modes, and stale-artifact cleanup;
- package validation for system, portable, Flatpak, foreign architecture, and
malformed/incomplete manifests;
- capability probe timeout, nonzero exit, invalid JSON, manifest mismatch,
missing dependency, and successful result caching;
- exclusion of all native payloads from `app.asar`, including marker-only ARM
and system-package stale x64 artifacts;
- helper probe protocol and failure behavior;
- package metadata dependencies for DEB/RPM/Pacman.
Linux CI must:
1. build or restore the pinned x64 LGPL runtime;
2. build the addon, reader, and helper once against that staged runtime;
3. prove with `readelf`/`ldd` that Electron and `embedded_mpv.node` do not link
libmpv and the helper does;
4. package the three profiles independently;
5. unpack or mount each produced format and validate its real payload, modes,
manifest, RPATH, dependency closure, and profile;
6. install/run the application-level packaged gate inside the actual Snap and
Flatpak (with the exact Freedesktop 24.08 EGL external-platform path
reconstructed inside `/app`), and probe the AppImage payload;
7. install system packages in matching disposable distro containers and run
the helper probe after the declared libmpv dependency is installed;
8. run a packaged Electron smoke test that confirms frame-copy capability,
creates a helper session against a deterministic local media fixture, sees
at least one frame/snapshot, and then repeats with libmpv hidden or removed
to prove non-crashing native-view fallback.
Checks that require Linux kernel/package tooling or GPU/EGL are CI-only.
macOS development can run all pure Node/Jest tests and static source checks,
but cannot establish Linux ELF, package-manager, sandbox, or rendering
behavior.
## Documentation And Release Compliance
Update `docs/architecture/embedded-mpv-native.md`,
`tools/embedded-mpv/README.md`, `vendor/embedded-mpv/README.md`,
`AGENTS.md`, and `CLAUDE.md`. The docs must describe the x64 format matrix,
profile selection, manifest/probe contract, process-isolation invariant,
fallback behavior, source-distribution obligations, codec baseline, and ARM
status.
Release artifacts must publish the generated runtime manifest and exact source
archives/metadata required by the recorded LGPL source-distribution statement.
The libplacebo payload is a VCS-metadata-free working-tree snapshot with exact
commit/submodule records, so clone-local `.git` state cannot perturb the
compliance tar. Automated Snap publication must wait for a public `v*` release
that already contains both the Snap assets and the exact source archive. Snap
Store publication remains outside this implementation and requires its
separate release workflow; repository integration follows explicit maintainer
authorization.
+25
View File
@@ -20,6 +20,7 @@
"filter": ["**/*"]
},
"electron-backend/**/*",
"!electron-backend/native{,/**/*}",
"web/**/*",
"!**/*.map"
],
@@ -130,8 +131,32 @@
"grade": "stable",
"summary": "IPTV application for M3U playlists, Xtream Codes API, and Stalker portals",
"executableArgs": ["--ozone-platform=x11"],
"plugs": [
"default",
{
"graphics-core22": {
"interface": "content",
"target": "$SNAP/graphics",
"default-provider": "mesa-core22"
}
},
{
"shared-memory": {
"interface": "shared-memory",
"private": true
}
}
],
"environment": {
"DISABLE_WAYLAND": "1"
},
"layout": {
"/usr/share/libdrm": {
"bind": "$SNAP/graphics/libdrm"
},
"/usr/share/drirc.d": {
"symlink": "$SNAP/graphics/drirc.d"
}
}
},
"win": {
@@ -67,16 +67,14 @@
<app-audio-player
[url]="activeChannel.url"
[icon]="activeChannel?.tvg?.logo!"
[channelName]="
activeChannel.name || activeChannel?.tvg?.name || ''
"
[channelName]="displayChannelName()"
[volume]="volume()"
(volumeChange)="onInlineVolumeChange($event)"
/>
} @else {
<app-web-player-view
[streamUrl]="embeddedPlayback()?.streamUrl ?? ''"
[title]="embeddedPlayback()?.title ?? ''"
[title]="inlinePlayerTitle()"
[playback]="embeddedPlayback()"
[playerOverride]="playerSettings.player ?? null"
[volume]="volume()"
@@ -191,6 +191,7 @@ describe('VideoPlayerComponent', () => {
const player = signal<VideoPlayer>(VideoPlayer.VideoJs);
const showCaptions = signal(false);
const stripCountryPrefix = signal(false);
const epgViewMode = signal<'timeline' | 'list'>('timeline');
const originalElectron = window.electron;
@@ -314,6 +315,7 @@ describe('VideoPlayerComponent', () => {
localStorage.removeItem(LIVE_EPG_PANEL_STATE_STORAGE_KEY);
player.set(VideoPlayer.VideoJs);
showCaptions.set(false);
stripCountryPrefix.set(false);
activePlaybackUrl.set(null);
channelsLoading.set(false);
currentEpgProgram.set(null);
@@ -389,6 +391,7 @@ describe('VideoPlayerComponent', () => {
useValue: {
player,
showCaptions,
stripCountryPrefix,
resolvedEpgViewMode: epgViewMode,
},
},
@@ -456,6 +459,26 @@ describe('VideoPlayerComponent', () => {
expect(headerContext.action()).toBeNull();
});
it('strips country prefixes from the timeline and player titles when enabled', () => {
stripCountryPrefix.set(true);
syncStoreState({ ...sampleChannel, name: 'US | CNN' } as Channel);
fixture.detectChanges();
expect(component.timelineChannelName()).toBe('CNN');
expect(component.displayChannelName()).toBe('CNN');
expect(component.inlinePlayerTitle()).toBe('CNN');
});
it('keeps raw channel names while prefix stripping is disabled', () => {
syncStoreState({ ...sampleChannel, name: 'US | CNN' } as Channel);
fixture.detectChanges();
expect(component.timelineChannelName()).toBe('US | CNN');
expect(component.displayChannelName()).toBe('US | CNN');
});
it('renders the inline player with the embedded EPG panel', () => {
syncStoreState(sampleChannel);
player.set(VideoPlayer.VideoJs);
@@ -22,6 +22,7 @@ import { StorageMap } from '@ngx-pwa/local-storage';
import { TranslatePipe } from '@ngx-translate/core';
import { ResizableDirective } from '@iptvnator/ui/components';
import {
applyChannelNameStrip,
getM3uArchiveDays,
isM3uCatchupPlaybackSupported,
} from '@iptvnator/shared/m3u-utils';
@@ -182,8 +183,26 @@ export class VideoPlayerComponent implements OnInit, OnDestroy {
readonly epgArchiveDays = computed(() =>
getM3uArchiveDays(this.activeChannel())
);
readonly timelineChannelName = computed(
() => this.activeChannel()?.name ?? ''
readonly timelineChannelName = computed(() =>
applyChannelNameStrip(
this.activeChannel()?.name,
this.settingsStore.stripCountryPrefix?.()
)
);
/** Channel name for the radio player header. */
readonly displayChannelName = computed(() => {
const channel = this.activeChannel();
return applyChannelNameStrip(
channel?.name || channel?.tvg?.name,
this.settingsStore.stripCountryPrefix?.()
);
});
/** Display title for the inline web player header. */
readonly inlinePlayerTitle = computed(() =>
applyChannelNameStrip(
this.embeddedPlayback()?.title,
this.settingsStore.stripCountryPrefix?.()
)
);
/** Channel logo from the EPG feed (M3U playlists often lack tvg-logo). */
private readonly epgChannelLogo = toSignal(
@@ -211,6 +211,7 @@
[pageSizeOptions]="pageSizeOptions"
[showPaginator]="false"
[searchTerm]="searchTerm()"
[type]="contentType() ?? ''"
(pageChange)="onPageChange($event)"
/>
</div>
@@ -31,6 +31,7 @@ class MockGridListComponent {
readonly pageSizeOptions = input<number[]>();
readonly showPaginator = input(true);
readonly searchTerm = input<string>('');
readonly type = input<string>('');
readonly itemClicked = output<unknown>();
readonly pageChange = output<unknown>();
}
@@ -1,7 +1,9 @@
import { signal } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { TranslatePipe } from '@ngx-translate/core';
import { MockPipe } from 'ng-mocks';
import { SettingsStore } from '@iptvnator/services';
import {
formatGridRating,
GridListComponent,
@@ -159,4 +161,78 @@ describe('GridListComponent', () => {
'live_tv'
);
});
it('renders raw titles while prefix stripping is disabled', () => {
fixture.componentRef.setInput('items', [{ name: 'US | CNN' }]);
fixture.componentRef.setInput('type', 'live');
fixture.detectChanges();
const title = fixture.debugElement.query(By.css('.title'));
expect(title.nativeElement.textContent.trim()).toBe('US | CNN');
});
it('falls back to a placeholder title for items without a name', () => {
fixture.componentRef.setInput('items', [{}]);
fixture.detectChanges();
const title = fixture.debugElement.query(By.css('.title'));
expect(title.nativeElement.textContent.trim()).toBe('No name');
});
});
describe('GridListComponent with strip country prefix enabled', () => {
let fixture: ComponentFixture<GridListComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [GridListComponent],
providers: [
{
provide: SettingsStore,
useValue: { stripCountryPrefix: signal(true) },
},
],
})
.overrideComponent(GridListComponent, {
remove: { imports: [TranslatePipe] },
add: {
imports: [
MockPipe(
TranslatePipe,
(value: string | null | undefined) => value ?? ''
),
],
},
})
.compileComponents();
fixture = TestBed.createComponent(GridListComponent);
});
const renderedTitle = () =>
fixture.debugElement
.query(By.css('.title'))
.nativeElement.textContent.trim();
it('strips prefixes from live grid titles', () => {
fixture.componentRef.setInput('items', [{ name: 'US | CNN' }]);
fixture.componentRef.setInput('type', 'live');
fixture.detectChanges();
expect(renderedTitle()).toBe('CNN');
});
it('keeps VOD titles untouched', () => {
fixture.componentRef.setInput('items', [
{ title: 'US | Some Movie' },
]);
fixture.componentRef.setInput('type', 'vod');
fixture.detectChanges();
expect(renderedTitle()).toBe('US | Some Movie');
});
});
@@ -2,6 +2,7 @@ import {
ChangeDetectionStrategy,
Component,
computed,
inject,
input,
output,
signal,
@@ -11,6 +12,8 @@ import { MatIcon } from '@angular/material/icon';
import { MatPaginatorModule, PageEvent } from '@angular/material/paginator';
import { MatTooltip } from '@angular/material/tooltip';
import { TranslatePipe } from '@ngx-translate/core';
import { applyChannelNameStrip } from '@iptvnator/shared/m3u-utils';
import { SettingsStore } from '@iptvnator/services';
import {
ProgressCapsuleComponent,
WatchedBadgeComponent,
@@ -164,10 +167,9 @@ function normalizeArtworkUrl(value: string | undefined): string | undefined {
<mat-icon>star</mat-icon>{{ rating }}
</div>
}
@let title = i.title ?? i.o_name ?? i.name;
<mat-card-actions>
<div class="title">
{{ title || 'No name' }}
{{ channelTitle(i) }}
</div>
</mat-card-actions>
</mat-card>
@@ -229,6 +231,7 @@ function normalizeArtworkUrl(value: string | undefined): string | undefined {
})
export class GridListComponent {
private readonly failedArtworkUrls = signal<ReadonlySet<string>>(new Set());
private readonly settingsStore = inject(SettingsStore);
readonly items = input<GridListItem[]>([]);
readonly isLoading = input<boolean>(false);
@@ -253,6 +256,18 @@ export class GridListComponent {
protected readonly hasActiveSearch = computed(
() => (this.searchTerm() ?? '').trim().length > 0
);
/** Prefix stripping applies to channel grids only, never VOD/series. */
private readonly isLiveGrid = computed(() =>
['live', 'itv', 'radio'].includes(this.type())
);
protected readonly channelTitle = (item: GridListItem): string => {
const raw = item.title ?? item.o_name ?? item.name ?? '';
const stripped = applyChannelNameStrip(
raw,
this.isLiveGrid() && this.settingsStore.stripCountryPrefix?.()
);
return stripped || 'No name';
};
readonly skeletonRows = computed(() => {
const preferredCount = this.limit() ?? 12;
@@ -1,9 +1,13 @@
import { Directive, inject, TemplateRef } from '@angular/core';
import { UnifiedCollectionItem } from '@iptvnator/portal/shared/util';
import {
SeriesResumeTarget,
UnifiedCollectionItem,
} from '@iptvnator/portal/shared/util';
export interface UnifiedCollectionDetailContext {
$implicit: UnifiedCollectionItem;
item: UnifiedCollectionItem;
seriesResume: SeriesResumeTarget | null;
close: () => void;
}
@@ -76,8 +76,15 @@ class StubUnifiedGridTabComponent {
[portalType]="portalType"
[defaultScope]="defaultScope"
>
<ng-template unifiedCollectionDetail let-item>
<ng-template
unifiedCollectionDetail
let-item
let-seriesResume="seriesResume"
>
<div class="detail-probe">{{ item.name }}</div>
<div class="resume-probe">
{{ seriesResume?.contentXtreamId }}
</div>
</ng-template>
</app-unified-collection-page>
`,
@@ -295,10 +302,10 @@ describe('UnifiedCollectionPageComponent', () => {
})
.compileComponents();
window.history.replaceState({}, document.title);
fixture = TestBed.createComponent(UnifiedCollectionPageComponent);
fixture.componentRef.setInput('mode', 'favorites');
fixture.componentRef.setInput('defaultScope', 'all');
window.history.replaceState({}, document.title);
});
it('reloads favorites after playlist hydration completes', async () => {
@@ -712,9 +719,103 @@ describe('UnifiedCollectionPageComponent', () => {
).toBe('movie');
});
it('exposes a dashboard series resume target to the inline detail host', async () => {
const item: UnifiedCollectionItem = {
uid: 'xtream::xtream-1::series:103',
name: 'Resume Series',
contentType: 'series',
sourceType: 'xtream',
playlistId: 'xtream-1',
playlistName: 'Xtream One',
xtreamId: 103,
categoryId: 3,
};
window.history.replaceState(
{
[OPEN_COLLECTION_DETAIL_STATE_KEY]: {
item,
seriesResume: {
seriesXtreamId: 103,
contentXtreamId: 2001,
seasonNumber: 2,
episodeNumber: 1,
},
},
},
document.title
);
const hostFixture = TestBed.createComponent(
HostUnifiedCollectionPageComponent
);
hostFixture.detectChanges();
await hostFixture.whenStable();
hostFixture.detectChanges();
expect(
hostFixture.nativeElement.querySelector('.resume-probe')
?.textContent
).toContain('2001');
expect(
hostFixture.componentInstance.pageComponent?.selectedDetailSeriesResume()
?.episodeNumber
).toBe(1);
hostFixture.destroy();
});
it('redirects cross-provider detail selections with the resume target intact', async () => {
const originalUrl = router.url;
router.url = '/workspace/stalker/stalker-7/favorites';
try {
fixture.detectChanges();
await fixture.whenStable();
router.navigate.mockClear();
const seriesResume = {
seriesXtreamId: 103,
contentXtreamId: 2001,
seasonNumber: 2,
episodeNumber: 1,
};
fixture.componentInstance.selectedDetailSeriesResume.set(
seriesResume
);
fixture.componentInstance.selectedDetailItem.set({
uid: 'xtream::xtream-1::series:103',
name: 'Resume Series',
contentType: 'series',
sourceType: 'xtream',
playlistId: 'xtream-1',
playlistName: 'Xtream One',
xtreamId: 103,
categoryId: 3,
} satisfies UnifiedCollectionItem);
fixture.detectChanges();
await fixture.whenStable();
expect(router.navigate).toHaveBeenCalledWith(
['/workspace', 'global-favorites'],
{
state: {
[OPEN_COLLECTION_DETAIL_STATE_KEY]: {
item: expect.objectContaining({ xtreamId: 103 }),
seriesResume,
},
},
}
);
expect(
fixture.componentInstance.selectedDetailItem()
).toBeNull();
} finally {
router.url = originalUrl;
}
});
it('restores collection scope and selected content type from history state', async () => {
setRouteParams({ id: 'playlist-1' });
playlistsLoaded.set(true);
fixture.destroy();
window.history.replaceState(
{
[COLLECTION_VIEW_STATE_KEY]: {
Loaded 100 of 176 files, more files were not shown because too many files have changed in this diff. Show more