feat(embedded-mpv): Linux port of the frame-copy rendering engine (headless EGL) (#1171)

* feat(embedded-mpv): Linux frame-copy helper via headless EGL

Port the frame-copy engine's native layer to Linux (PORTING.md items 1-4):

- frame_helper_gl.h: platform GlContext abstraction. macOS keeps the CGL
  path (moved verbatim); Linux acquires an EGL display in order
  surfaceless-Mesa -> default display -> GBM render node, binds a 3.2 core
  desktop-GL context surfaceless (1x1 pbuffer fallback), and hands mpv
  eglGetProcAddress. The helper's own GL calls link against glvnd
  libOpenGL, so no display server is required.
- frame_shm.h: portable frame_shm_now_ns() (CLOCK_MONOTONIC) shared by the
  helper and the reader addon, replacing the macOS-only
  clock_gettime_nsec_np(CLOCK_MONOTONIC_RAW); producer and consumer stay on
  the same clock.
- embedded_mpv_frame_reader.c: real implementation now also on __linux__
  (the code was already POSIX apart from the clock call).
- binding.gyp: OS==linux executable branch for iptvnator_mpv_helper linking
  system libmpv (-lmpv) + EGL/OpenGL/gbm, with rpaths for $ORIGIN/lib and
  the build-time library dir. The in-process addon still does not link
  libmpv - the ban only binds in-process, the helper is out of process.
- build-embedded-mpv.js: system-dev fallback on Linux (LIBMPV_INCLUDE_DIR
  or /usr/include) so a distro libmpv-dev install builds without staging a
  vendored runtime; a pre-set LINUX_NATIVE_LIBRARY_DIR now wins over the
  vendored lib dir.

Verified on Ubuntu 25.04 / i7-1165G7 (Iris Xe): lavfi smoke per PORTING.md
(idle->loading->playing snapshots at 4 Hz, aspect-fit generation bump
g1 1280x720 -> g2 960x720 for a 4:3 source), reader probe 60 fps at
1080p60 with 0 torn reads, clean quit with no leaked processes or shm.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(embedded-mpv): enable the frame-copy engine gates on Linux

Flip the TypeScript side of the Linux port (PORTING.md item 5). A shared
dependency-free predicate, isFrameCopyPlatformSupported() (linux any-arch,
darwin arm64-only), now backs all four gates so they cannot drift:

- main.ts: the persisted Settings toggle promotes to the env flag on Linux
  too (this runs before window creation and controls the sandbox relax).
- EmbeddedMpvNativeService.isFrameCopyEngineActive/isFrameCopyAvailable.
- EmbeddedMpvFrameCopyAdapter.isSupported.

getSupport() ordering: the frame-copy branch moves above the Linux-only
native-engine prerequisites - the X11/Xwayland display-server check and
the system-mpv-on-PATH probe only bind the --wid native engine, while the
frame-copy helper renders offscreen (headless EGL) and links libmpv
itself. createSession() also skips resolving the native window handle for
frame-copy sessions, which the adapter ignores anyway, so native-Wayland
sessions no longer trip the window-handle assertion.

Settings copy: the i18n frame-copy description now says macOS (Apple
Silicon) and Linux in all 18 languages; stale macOS-only doc comments in
the settings/support interfaces updated alongside.

Tests: platform-gate matrix for the adapter (darwin arm64/x64, linux
x64/arm64, win32) and service specs covering Linux activation under
native Wayland, macOS arm64 staying active, macOS x64 staying native, and
the skipped window handle for frame-copy sessions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore(packaging): CI + package guards for the Linux frame-copy helper

- build-and-make.yaml: install libegl-dev/libgl-dev/libgbm-dev on the
  Linux runner (the helper's EGL backend needs them now that the helper
  target builds on Linux), and verify the built helper exists and DOES
  link libmpv - the inverse of the addon's no-libmpv rule, which still
  holds and stays validated.
- electron-after-pack.cjs: strip iptvnator_mpv_helper from packaged Linux
  apps. It links the build host's system libmpv, which end-user systems
  cannot be assumed to have; the support probe treats the missing helper
  as frame-copy-unavailable (dev-build-only engine until the
  bundled-runtime staging milestone).
- frame_helper_gl.h: log the chosen EGL display tier to stderr (the
  adapter mirrors helper stderr), so bring-up problems on exotic setups
  are diagnosable.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(embedded-mpv): document the Linux frame-copy port

- architecture doc: frame-copy section covers Linux (EGL display tiers,
  build deps, package strip), Linux support matrix notes the frame-copy
  exception to the X11 + system-mpv requirements, Linux measured baseline.
- RESULTS.md: Ubuntu 25.04 / i7-1165G7 (Iris Xe) measurement rows via the
  production helper + reader probe; viewport-size claim reproduced.
- PORTING.md: Linux marked done with pointers to what changed; Windows
  remains the open port and its perf gate the open decision.
- CLAUDE.md + tools/embedded-mpv/README.md: platform scope, Linux dev
  build requirements, system-headers fallback, helper strip.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(embedded-mpv): commit the Linux frame-copy measurement probe

linux-frame-probe.mjs reproduces the RESULTS.md Linux rows: spawns the
production helper, attaches the frame-reader addon to the announced shm
generation, and reports new-frame fps, copy wall time, produce->copy age,
torn reads and pixel spread. Usage documented in RESULTS.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(embedded-mpv): address multi-agent review findings on the Linux port

Confirmed findings (each verified by 3 adversarial reviewers):

- CI would fail to link the helper: -lOpenGL needs the unversioned glvnd
  libOpenGL.so, shipped only by libopengl-dev, which neither the runner
  images nor the previous apt line provide. Added to the workflow and to
  every documented Linux build-dep list.
- The new 'test -x' dist guard could never pass: webpack's dist asset
  copy drops file modes (helper arrives as 0644). The guard is now
  'test -f'; electron-after-pack.cjs restores the execute bit on packaged
  helpers (also fixes packaged-macOS spawns); the support probe now
  requires X_OK, so a mode-stripped helper reads as frame-copy-unavailable
  and falls back to native instead of failing spawn with EACCES.
- The Settings frame-copy toggle was unreachable in exactly the Linux
  states the port targets: the native-Wayland and missing-system-mpv
  unsupported payloads omitted frameCopyAvailable, and toggle visibility
  derives solely from it. Both returns now advertise availability.

Also from review:

- build-embedded-mpv.js keeps the old graceful-skip contract when the new
  system-dev fallback finds libmpv-dev but the GL/EGL/gbm dev stack is
  missing (previously such machines skipped; a hard electron-build
  failure was a regression).
- createSession derives the window-handle skip from the dispatched addon
  instead of re-evaluating the engine gate, so the two cannot disagree.
- The render thread logs the GL renderer string (surfaceless Mesa can
  silently pick llvmpipe on non-Mesa-primary systems; now diagnosable —
  verified 'Mesa Intel Iris Xe' on this machine).
- Specs pin the new semantics: frameCopyAvailable advertised while native
  is unsupported (Wayland / no mpv), frame-copy supported without a
  system mpv, and the handle-skip test disposes its session through the
  owning adapter.
- Docs: PORTING.md file map reflects the frame_helper_gl.h seam for the
  Windows porter; helper-strip removal correctly gated on milestone 4
  (bundled libmpv), not milestone 3; RESULTS.md preamble notes the
  RAW->MONOTONIC clock change; stale '(macOS)' scope comments updated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(embedded-mpv): address Greptile/Codex review comments

- Sandbox gate requires a usable helper (Greptile P1, security): the
  main.ts env promotion now also probes for an executable
  iptvnator_mpv_helper before relaxing the window sandbox — a stale
  opt-in on packaged Linux (helper deliberately stripped) or after a
  cleaned native build no longer costs a sandboxless launch for an
  engine that cannot activate. Helper discovery (addon candidate paths +
  X_OK probe) moved into embedded-mpv-frame-copy-platform.util.ts,
  shared by main.ts and the service; the service keeps thin instance
  wrappers so tests can stub per scenario. New util spec pins the
  platform matrix, candidate resolution, and the execute-bit semantics.
- Stale frame-copy artifacts on skipped builds (Codex P2): cleanOutput()
  now also removes iptvnator_mpv_helper and
  embedded_mpv_frame_reader.node, so a failed/skipped rebuild cannot
  leave a previous helper advertising frame-copy support against a
  runtime the build just declared unavailable.
- Multiarch default lib dir (Greptile P1, partially refuted): -l
  resolution never depended on our -L (the compiler's built-in search
  paths include the Debian/Ubuntu multiarch dir — proven by the green CI
  run linking with a nonexistent -L dir), but the system-dev fallback
  now defaults to /usr/lib/<multiarch-triple> when present so the -L
  flag and the helper's baked rpath point somewhere real.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(embedded-mpv): harden Linux frame-copy port

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5 authored and GitHub committed 2026-07-15 20:42:50 +02:00
1 parent 26271fc076
commit 7d75d989e8
45 files changed
+1387 -177

No files matched your search

@@ -30,7 +30,8 @@ describe('embedded-mpv-frame-copy-platform.util', () => {
it.each<[NodeJS.Platform, string, boolean]>([
['darwin', 'arm64', true],
['darwin', 'x64', false],
['linux', 'x64', false],
['linux', 'x64', true],
['linux', 'arm64', true],
['win32', 'x64', false],
])('%s/%s -> %s', (platform, arch, expected) => {
Object.defineProperty(process, 'platform', { value: platform });
@@ -4,11 +4,18 @@ import path from 'path';
/**
* Platform gate + helper discovery for the embedded MPV frame-copy engine,
* shared by startup and the runtime service so sandbox and engine decisions
* cannot drift. These helpers must remain callable before app.whenReady().
* shared by startup, the runtime service, and the frame-copy adapter so
* sandbox and engine decisions cannot drift. These helpers must remain
* callable before app.whenReady().
*
* macOS stays Apple-Silicon-only. Linux supports every architecture because
* its helper renders through headless EGL and links libmpv out of process.
*/
export function isFrameCopyPlatformSupported(): boolean {
return process.platform === 'darwin' && process.arch === 'arm64';
return (
process.platform === 'linux' ||
(process.platform === 'darwin' && process.arch === 'arm64')
);
}
function dedupeDefinedPaths(paths: Array<string | undefined>): string[] {
@@ -173,4 +173,33 @@ describe('EmbeddedMpvFrameCopyAdapter', () => {
const withoutHelper = createAdapter(null);
expect(withoutHelper.isSupported()).toBe(false);
});
describe('isSupported platform gate', () => {
const originalPlatform = process.platform;
const originalArch = process.arch;
afterEach(() => {
Object.defineProperty(process, 'platform', {
value: originalPlatform,
});
Object.defineProperty(process, 'arch', { value: originalArch });
});
it.each<[NodeJS.Platform, string, boolean]>([
['darwin', 'arm64', true],
['darwin', 'x64', false],
['linux', 'x64', true],
['linux', 'arm64', true],
['win32', 'x64', false],
])(
'on %s/%s with a helper binary present -> %s',
(platform, arch, expected) => {
Object.defineProperty(process, 'platform', {
value: platform,
});
Object.defineProperty(process, 'arch', { value: arch });
expect(adapter.isSupported()).toBe(expected);
}
);
});
});
@@ -6,6 +6,7 @@ import {
EmbeddedMpvFrameSource,
ResolvedPortalPlayback,
} from '@iptvnator/shared/interfaces';
import { isFrameCopyPlatformSupported } from './embedded-mpv-frame-copy-platform.util';
import type {
NativeEmbeddedMpvAddon,
NativeEmbeddedMpvSessionSnapshot,
@@ -81,8 +82,7 @@ export class EmbeddedMpvFrameCopyAdapter implements NativeEmbeddedMpvAddon {
isSupported(): boolean {
return (
process.platform === 'darwin' &&
process.arch === 'arm64' &&
isFrameCopyPlatformSupported() &&
this.options.resolveHelperPath() !== null
);
}
@@ -46,6 +46,13 @@ describe('Embedded MPV native source recording invariants', () => {
),
'utf8'
);
const frameHelperGlSource = readFileSync(
path.resolve(__dirname, '../../../native/helper/frame_helper_gl.h'),
'utf8'
);
const linuxFrameHelperGlSource = frameHelperGlSource.slice(
frameHelperGlSource.indexOf('inline void* eglWrapGetProcAddress')
);
function functionBody(name: string): string {
return sourceFunctionBody(nativeSource, `Napi::Value ${name}(`, name);
@@ -590,7 +597,7 @@ describe('Embedded MPV native source recording invariants', () => {
it('requires Linux embedded MPV build inputs and validates process isolation in CI', () => {
expect(buildAndMakeWorkflowSource).toContain(
'libmpv-dev mpv pkg-config'
'libmpv-dev mpv pkg-config libegl-dev libgl-dev libopengl-dev libgbm-dev'
);
expect(buildAndMakeWorkflowSource).toContain(
'Stage Linux embedded MPV build inputs'
@@ -602,11 +609,35 @@ describe('Embedded MPV native source recording invariants', () => {
expect(buildAndMakeWorkflowSource).toContain(
'Linux embedded MPV addon must not link directly to libmpv'
);
expect(buildAndMakeWorkflowSource).toContain(
'test -f dist/apps/electron-backend/native/iptvnator_mpv_helper'
);
expect(buildAndMakeWorkflowSource).toContain(
'Linux frame-copy helper must link libmpv'
);
expect(buildScriptSource).toContain("origin: 'external-mpv-process'");
expect(buildScriptSource).toContain('writeLinuxProcessRuntimeManifest');
expect(buildScriptSource).toContain('runtimeFiles: []');
});
it('supports Linux system-development inputs without leaving stale frame-copy artifacts', () => {
expect(buildScriptSource).toContain(
"const systemIncludeDir =\n process.env.LIBMPV_INCLUDE_DIR || '/usr/include';"
);
expect(buildScriptSource).toContain(
'process.env.LINUX_NATIVE_LIBRARY_DIR ||\n defaultLinuxSystemLibDir()'
);
expect(buildScriptSource).toContain("arm: 'arm-linux-gnueabihf'");
expect(buildScriptSource).toContain("arm64: 'aarch64-linux-gnu'");
expect(buildScriptSource).toContain("x64: 'x86_64-linux-gnu'");
expect(buildScriptSource).toContain(
"if (!embeddedMpvRequired && runtime.origin === 'system-dev')"
);
expect(buildScriptSource).toContain(
'removeStaleFrameCopyArtifacts(outputDir);'
);
});
it('forces the current frame into a rebuilt shm generation after a paused resize', () => {
const runLoop = sourceFunctionBody(
frameHelperRenderSource,
@@ -620,6 +651,85 @@ describe('Embedded MPV native source recording invariants', () => {
'if (targetsRebuilt || (flags & MPV_RENDER_UPDATE_FRAME))'
);
});
it('validates complete EGL candidates and keeps software rendering as the final fallback', () => {
const tryCandidate = sourceFunctionBody(
linuxFrameHelperGlSource,
'bool tryCandidate(',
'GlContext::tryCandidate'
);
const create = sourceFunctionBody(
linuxFrameHelperGlSource,
'bool create(std::string& errorOut)',
'GlContext::create'
);
for (const requiredStep of [
'eglInitialize(',
'eglBindAPI(EGL_OPENGL_API)',
'chooseConfig(',
'eglCreateContext(',
'eglMakeCurrent(',
'glGetString(GL_RENDERER)',
]) {
expect(tryCandidate).toContain(requiredStep);
}
expect(create).toContain('softwareFallback');
expect(create).toContain('candidate.softwareRenderer');
expect(create.indexOf('DisplayTier::SurfacelessMesa')).toBeLessThan(
create.indexOf('DisplayTier::Default')
);
expect(create.indexOf('DisplayTier::Default')).toBeLessThan(
create.indexOf('DisplayTier::Gbm')
);
});
it('binds the desktop GL API on the render thread and releases it before EGL teardown', () => {
const makeCurrent = sourceFunctionBody(
linuxFrameHelperGlSource,
'bool makeCurrent(std::string& errorOut)',
'GlContext::makeCurrent'
);
const destroy = sourceFunctionBody(
linuxFrameHelperGlSource,
'void destroy()',
'GlContext::destroy'
);
expect(makeCurrent).toContain('eglBindAPI(EGL_OPENGL_API)');
expect(makeCurrent).toContain('eglMakeCurrent(');
expect(makeCurrent).toContain('return false;');
const bindIndex = destroy.indexOf('eglBindAPI(EGL_OPENGL_API)');
const unbindIndex = destroy.indexOf('eglMakeCurrent(');
const releaseIndex = destroy.indexOf('eglReleaseThread()');
const destroySurfaceIndex = destroy.indexOf('eglDestroySurface(');
expect(bindIndex).toBeGreaterThanOrEqual(0);
expect(unbindIndex).toBeGreaterThan(bindIndex);
expect(releaseIndex).toBeGreaterThan(unbindIndex);
expect(destroySurfaceIndex).toBeGreaterThan(releaseIndex);
const setupGl = sourceFunctionBody(
frameHelperRenderSource,
'inline bool RenderPipeline::setupGl(std::string& errorOut)',
'RenderPipeline::setupGl'
);
expect(setupGl).toContain(
'if (!gl_.makeCurrent(errorOut)) return false;'
);
});
it('resolves linked core GL symbols before falling back to EGL extension lookup', () => {
const dlsymIndex = linuxFrameHelperGlSource.indexOf(
'dlsym(RTLD_DEFAULT, name)'
);
const eglIndex = linuxFrameHelperGlSource.indexOf(
'eglGetProcAddress(name)'
);
expect(dlsymIndex).toBeGreaterThanOrEqual(0);
expect(eglIndex).toBeGreaterThan(dlsymIndex);
});
});
describe('Embedded MPV native build configuration', () => {
@@ -633,6 +743,10 @@ describe('Embedded MPV native build configuration', () => {
(candidate: { target_name?: string }) =>
candidate.target_name === 'embedded_mpv'
);
const helperTarget = bindingGyp.targets.find(
(candidate: { target_name?: string }) =>
candidate.target_name === 'iptvnator_mpv_helper'
);
it('declares platform-specific native sources for macOS, Windows, and Linux', () => {
expect(target).toBeDefined();
@@ -640,4 +754,24 @@ describe('Embedded MPV native build configuration', () => {
expect(JSON.stringify(target)).toContain('src/embedded_mpv_win32.cc');
expect(JSON.stringify(target)).toContain('src/embedded_mpv_linux.cc');
});
it('links libmpv and headless GL only into the Linux helper process', () => {
const linuxAddonConfig = target.conditions.find(
([condition]: [string]) => condition === 'OS=="linux"'
)?.[1];
const linuxHelperConfig = helperTarget.conditions.find(
([condition]: [string]) => condition === 'OS=="linux"'
)?.[1];
expect(linuxAddonConfig?.libraries).not.toContain('-lmpv');
expect(linuxHelperConfig?.libraries).toEqual(
expect.arrayContaining([
'-lmpv',
'-lEGL',
'-lOpenGL',
'-lgbm',
'-ldl',
])
);
});
});
@@ -297,6 +297,140 @@ describe('EmbeddedMpvNativeService power blocker', () => {
}
);
describe('frame-copy platform gate', () => {
const originalArch = process.arch;
function mockHelperPresent(): void {
jest.spyOn(
service as unknown as {
resolveFrameCopyHelperPath: () => string | null;
},
'resolveFrameCopyHelperPath'
).mockReturnValue('/native/iptvnator_mpv_helper');
}
afterEach(() => {
Object.defineProperty(process, 'arch', { value: originalArch });
delete process.env.IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY;
});
it('activates the frame-copy engine on Linux, even under native Wayland', () => {
Object.defineProperty(process, 'platform', { value: 'linux' });
// Native Wayland session (no X11 ozone): blocks the --wid native
// engine, but the frame-copy helper renders offscreen into shm
// and must stay available.
process.env.DISPLAY = ':0';
process.env.WAYLAND_DISPLAY = 'wayland-0';
process.env.IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY = '1';
mockHelperPresent();
expect(service.getActiveEngine()).toBe('frame-copy');
expect(service.isFrameCopyAvailable()).toBe(true);
expect(service.getSupport()).toEqual(
expect.objectContaining({
supported: true,
engine: 'frame-copy',
frameCopyAvailable: true,
})
);
});
it('advertises frame-copy availability while native Wayland blocks the native engine', () => {
// Pre-opt-in discoverability: without frameCopyAvailable on the
// unsupported payload the Settings toggle never appears in
// exactly the states the frame-copy engine exists to fix.
Object.defineProperty(process, 'platform', { value: 'linux' });
process.env.DISPLAY = ':0';
process.env.WAYLAND_DISPLAY = 'wayland-0';
mockHelperPresent();
const support = service.getSupport();
expect(support.supported).toBe(false);
expect(support.reason).toContain('Native Wayland embedding');
expect(support.frameCopyAvailable).toBe(true);
});
it('advertises frame-copy availability when the system mpv executable is missing', () => {
Object.defineProperty(process, 'platform', { value: 'linux' });
process.env.DISPLAY = ':0';
delete process.env.WAYLAND_DISPLAY;
mockSpawnSync.mockReturnValue({ status: 1 });
mockHelperPresent();
const support = service.getSupport();
expect(support.supported).toBe(false);
expect(support.frameCopyAvailable).toBe(true);
});
it('keeps frame-copy supported on Linux without a system mpv executable', () => {
// The helper links libmpv itself; the mpv-on-PATH probe only
// binds the native --wid engine.
Object.defineProperty(process, 'platform', { value: 'linux' });
process.env.DISPLAY = ':0';
delete process.env.WAYLAND_DISPLAY;
process.env.IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY = '1';
mockSpawnSync.mockReturnValue({ status: 1 });
mockHelperPresent();
expect(service.getSupport()).toEqual(
expect.objectContaining({
supported: true,
engine: 'frame-copy',
})
);
});
it('activates the frame-copy engine on macOS arm64', () => {
Object.defineProperty(process, 'arch', { value: 'arm64' });
process.env.IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY = '1';
mockHelperPresent();
expect(service.getActiveEngine()).toBe('frame-copy');
expect(service.isFrameCopyAvailable()).toBe(true);
});
it('keeps the frame-copy engine Apple-Silicon-only on macOS', () => {
Object.defineProperty(process, 'arch', { value: 'x64' });
process.env.IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY = '1';
mockHelperPresent();
expect(service.getActiveEngine()).toBe('native');
expect(service.isFrameCopyAvailable()).toBe(false);
});
it('skips the native window handle when creating a frame-copy session', () => {
Object.defineProperty(process, 'platform', { value: 'linux' });
process.env.IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY = '1';
mockHelperPresent();
const frameCopyAddon = createMockAddon();
frameCopyAddon.createSession.mockReturnValueOnce('s-fc');
frameCopyAddon.getSessionSnapshot.mockReturnValueOnce(
snapshot('loading')
);
(
service as unknown as { frameCopyAdapter: MockAddon }
).frameCopyAdapter = frameCopyAddon;
service.createSession(BOUNDS, '', 1);
expect(mainWindowGetNativeWindowHandleMock).not.toHaveBeenCalled();
expect(frameCopyAddon.createSession).toHaveBeenCalledWith(
Buffer.alloc(0),
BOUNDS,
'',
1
);
// Dispose while the frame-copy env is still set so teardown
// dispatches to the adapter that owns the session, not the
// native addon the outer afterEach shutdown would pick.
service.disposeSession('s-fc');
expect(frameCopyAddon.disposeSession).toHaveBeenCalledWith(
's-fc'
);
});
});
it('does not acquire a blocker for a loading session', () => {
startSession('s1', snapshot('loading'));
expect(powerSaveBlockerMock.start).not.toHaveBeenCalled();
@@ -111,9 +111,9 @@ export class EmbeddedMpvNativeService {
/**
* Frame-copy engine: helper process + shm ring + renderer canvas.
* Experimental, macOS Apple Silicon only (owner decision 2026-07-10),
* opted into with IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY=1 on top of
* the regular embedded MPV experiment flag.
* Experimental, macOS Apple Silicon (owner decision 2026-07-10) and
* Linux, opted into with IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY=1 on
* top of the regular embedded MPV experiment flag.
*/
private isFrameCopyEngineRequested(): boolean {
return ['1', 'true', 'yes', 'on'].includes(
@@ -215,11 +215,21 @@ export class EmbeddedMpvNativeService {
};
}
if (this.isUnsupportedLinuxDisplayServer()) {
// The frame-copy engine renders offscreen (headless EGL on Linux)
// into a renderer canvas: the Linux X11/Xwayland and system-mpv
// requirements below only bind the native --wid engine. Both
// native-engine failure returns still advertise frameCopyAvailable
// so the Settings toggle stays reachable — otherwise the states the
// frame-copy engine exists to fix would hide the way to enable it.
if (
this.isUnsupportedLinuxDisplayServer() &&
!this.isFrameCopyEngineActive()
) {
return {
supported: false,
platform: process.platform,
reason: 'Embedded MPV on Linux currently requires X11 or Xwayland. Native Wayland embedding is not supported yet.',
frameCopyAvailable: this.isFrameCopyAvailable(),
};
}
@@ -231,16 +241,6 @@ export class EmbeddedMpvNativeService {
};
}
const missingLinuxMpvExecutableReason =
this.getMissingLinuxMpvExecutableReason();
if (missingLinuxMpvExecutableReason) {
return {
supported: false,
platform: process.platform,
reason: missingLinuxMpvExecutableReason,
};
}
// A requested-but-unavailable frame-copy engine (wrong platform or
// missing helper) intentionally falls through to the native path so
// embedded MPV keeps working and Settings can clear the opt-in.
@@ -254,6 +254,17 @@ export class EmbeddedMpvNativeService {
};
}
const missingLinuxMpvExecutableReason =
this.getMissingLinuxMpvExecutableReason();
if (missingLinuxMpvExecutableReason) {
return {
supported: false,
platform: process.platform,
reason: missingLinuxMpvExecutableReason,
frameCopyAvailable: this.isFrameCopyAvailable(),
};
}
if (this.addon) {
try {
if (!this.addon.isSupported()) {
@@ -385,7 +396,16 @@ export class EmbeddedMpvNativeService {
): EmbeddedMpvSession {
this.assertEmbeddedMpvEnabled();
const addon = this.getAddon();
const windowHandle = this.getMainWindowHandle();
// The frame-copy adapter ignores the native window handle (frames go
// through shm to a DOM canvas), so skip resolving it — under native
// Wayland the handle assertion would reject an engine that does not
// embed into the window at all. Derive the skip from the dispatched
// addon rather than re-evaluating the engine gate, so the two
// decisions cannot disagree.
const windowHandle =
this.frameCopyAdapter && addon === this.frameCopyAdapter
? Buffer.alloc(0)
: this.getMainWindowHandle();
const startedAt = new Date().toISOString();
const sessionId = addon.createSession(
windowHandle,
@@ -9,7 +9,7 @@ export const VLC_PLAYER_ARGUMENTS = 'VLC_PLAYER_ARGUMENTS';
export const MPV_REUSE_INSTANCE = 'MPV_REUSE_INSTANCE';
export const VLC_REUSE_INSTANCE = 'VLC_REUSE_INSTANCE';
/**
* Embedded MPV frame-copy engine opt-in (macOS arm64). Lives in the main
* Embedded MPV frame-copy engine opt-in (macOS arm64, Linux). Lives in the main
* process config file because it must be readable synchronously before the
* BrowserWindow is created — the engine relaxes the window sandbox for its
* preload frame pump, which cannot change after window creation.