feat(packaging): ship Linux frame-copy runtimes

This commit is contained in:
4gray committed 2026-07-18 12:16:31 +02:00
1 parent 351f9609d4
commit 33573ceee4
41 files changed
+3213 -317

No files matched your search

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