mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-11 11:06:16 -08:00
feat(packaging): ship Linux frame-copy runtimes
This commit is contained in:
1 parent
351f9609d4
commit
33573ceee4
41 files changed
+3213
-317
No files matched your search
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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']),
|
||||
|
||||
+148
@@ -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'),
|
||||
},
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
+282
-9
@@ -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(
|
||||
|
||||
+215
-43
@@ -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;
|
||||
|
||||
+203
@@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
+103
@@ -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,
|
||||
};
|
||||
}
|
||||
+215
-2
@@ -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');
|
||||
}
|
||||
|
||||
+2
-2
@@ -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'],
|
||||
|
||||
+51
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
+49
-11
@@ -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.
|
||||
@@ -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": {
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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-'));
|
||||
|
||||
|
||||
@@ -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(() =>
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
);
|
||||
|
||||
Vendored
+7
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user