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

This commit is contained in:
4gray committed 2026-07-15 20:19:07 +02:00
1 parent d065875a53
commit 340bbf25c5
9 files changed
+563 -145

No files matched your search

+2 -1
View File
@@ -149,7 +149,8 @@
"-lmpv",
"-lEGL",
"-lOpenGL",
"-lgbm"
"-lgbm",
"-ldl"
]
}
]
@@ -5,12 +5,15 @@
* macOS: accelerated CGL 3.2 core context; GL entry points for mpv come
* from the OpenGL framework via dlsym.
*
* Linux: EGL display picked in order — Mesa's surfaceless platform (no
* display server needed), the session's default display, then a GBM render
* node — with a desktop-GL 3.2 core context bound surfaceless (1x1 pbuffer
* Linux: EGL display candidates are fully probed in order — Mesa's
* surfaceless platform (no display server needed), the session's default
* display, then a GBM render node. The first hardware renderer wins; an
* 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
* its entry points via eglGetProcAddress.
* core symbols from that linked library and falls back to eglGetProcAddress
* for extensions.
*
* Threading contract: create() runs on the main thread and must leave the
* context unbound; the render thread calls makeCurrent() once and owns the
@@ -44,7 +47,10 @@
#include <gbm.h>
#include <unistd.h>
#include <algorithm>
#include <cctype>
#include <cstdio>
#include <dlfcn.h>
#ifndef EGL_PLATFORM_SURFACELESS_MESA
#define EGL_PLATFORM_SURFACELESS_MESA 0x31DD
@@ -104,7 +110,13 @@ public:
return true;
}
void makeCurrent() { CGLSetCurrentContext(cgl_); }
bool makeCurrent(std::string& errorOut) {
if (CGLSetCurrentContext(cgl_) != kCGLNoError) {
errorOut = "failed to bind the CGL render context";
return false;
}
return true;
}
void destroy() {
CGLSetCurrentContext(nullptr);
@@ -125,69 +137,125 @@ private:
#elif defined(__linux__)
inline void* eglWrapGetProcAddress(void* /*ctx*/, const char* name) {
if (void* linkedSymbol = dlsym(RTLD_DEFAULT, name)) return linkedSymbol;
return reinterpret_cast<void*>(eglGetProcAddress(name));
}
class GlContext {
public:
bool create(std::string& errorOut) {
if (!acquireDisplay()) {
errorOut = "no usable EGL display (surfaceless/default/GBM)";
return false;
}
if (eglBindAPI(EGL_OPENGL_API) != EGL_TRUE) {
errorOut = "EGL implementation lacks desktop OpenGL support";
return false;
}
EGLConfig config = nullptr;
if (!chooseConfig(EGL_PBUFFER_BIT, &config) &&
!chooseConfig(0, &config)) {
errorOut = "no usable EGLConfig for desktop OpenGL";
return false;
}
const EGLint contextAttrs[] = {
EGL_CONTEXT_MAJOR_VERSION, 3,
EGL_CONTEXT_MINOR_VERSION, 2,
EGL_CONTEXT_OPENGL_PROFILE_MASK,
EGL_CONTEXT_OPENGL_CORE_PROFILE_BIT,
EGL_NONE,
const DisplayTier tiers[] = {
DisplayTier::SurfacelessMesa,
DisplayTier::Default,
DisplayTier::Gbm,
};
context_ =
eglCreateContext(display_, config, EGL_NO_CONTEXT, contextAttrs);
if (context_ == EGL_NO_CONTEXT) {
errorOut = "failed to create a 3.2 core EGL context";
return false;
}
/* Probe surfaceless binding here (main thread) so the render thread
* can just makeCurrent(); fall back to a 1x1 pbuffer. Unbind before
* returning — see the threading contract above. */
if (eglMakeCurrent(display_, EGL_NO_SURFACE, EGL_NO_SURFACE,
context_) != EGL_TRUE) {
const EGLint pbufferAttrs[] = {EGL_WIDTH, 1, EGL_HEIGHT, 1,
EGL_NONE};
surface_ = eglCreatePbufferSurface(display_, config, pbufferAttrs);
if (surface_ == EGL_NO_SURFACE ||
eglMakeCurrent(display_, surface_, surface_, context_) !=
EGL_TRUE) {
errorOut =
"eglMakeCurrent failed (no surfaceless context or "
"pbuffer)";
bool hasSoftwareFallback = false;
DisplayTier softwareFallback = DisplayTier::SurfacelessMesa;
std::string failures;
for (DisplayTier tier : tiers) {
Candidate candidate;
std::string candidateError;
if (!tryCandidate(tier, candidate, candidateError)) {
if (candidateOwnsResources(candidate)) {
errorOut = candidateError +
"; failed to safely tear down rejected EGL "
"candidate";
return false;
}
appendFailure(failures, candidateError);
continue;
}
if (!candidate.softwareRenderer) {
adoptCandidate(candidate);
std::fprintf(stderr, "egl display: %s\n", tierName(tier));
return true;
}
std::fprintf(stderr,
"egl candidate %s uses software renderer %s; "
"trying the next tier\n",
tierName(tier), candidate.renderer.c_str());
if (!hasSoftwareFallback) {
softwareFallback = tier;
hasSoftwareFallback = true;
}
destroyCandidate(candidate);
if (candidateOwnsResources(candidate)) {
errorOut = std::string(tierName(tier)) +
": failed to safely release software EGL "
"candidate";
return false;
}
}
eglMakeCurrent(display_, EGL_NO_SURFACE, EGL_NO_SURFACE,
EGL_NO_CONTEXT);
return true;
/* Recreate rather than retain the software context while probing the
* remaining tiers: EGL implementations may alias platform displays,
* and terminating one alias can invalidate the retained context. */
if (hasSoftwareFallback) {
Candidate candidate;
std::string candidateError;
if (tryCandidate(softwareFallback, candidate, candidateError)) {
adoptCandidate(candidate);
std::fprintf(stderr,
"egl display: %s (software fallback: %s)\n",
tierName(softwareFallback), renderer_.c_str());
return true;
}
if (candidateOwnsResources(candidate)) {
errorOut = candidateError +
"; failed to safely tear down software fallback";
return false;
}
appendFailure(failures, candidateError);
}
errorOut = "no usable EGL display (surfaceless/default/GBM)";
if (!failures.empty()) errorOut += ": " + failures;
return false;
}
void makeCurrent() {
eglMakeCurrent(display_, surface_, surface_, context_);
bool makeCurrent(std::string& errorOut) {
/* eglBindAPI is thread-local. create() runs on the main thread, so the
* render thread must select desktop GL for both bind and teardown. */
if (eglBindAPI(EGL_OPENGL_API) != EGL_TRUE) {
errorOut = "failed to bind the desktop OpenGL EGL API";
return false;
}
if (eglMakeCurrent(display_, surface_, surface_, context_) != EGL_TRUE) {
errorOut = "failed to bind the EGL render context";
eglReleaseThread();
return false;
}
return true;
}
void destroy() {
if (display_ != EGL_NO_DISPLAY) {
eglMakeCurrent(display_, EGL_NO_SURFACE, EGL_NO_SURFACE,
EGL_NO_CONTEXT);
bool released = true;
if (eglBindAPI(EGL_OPENGL_API) != EGL_TRUE) {
std::fprintf(stderr,
"egl teardown: failed to bind desktop OpenGL\n");
released = false;
} else if (eglMakeCurrent(display_, EGL_NO_SURFACE, EGL_NO_SURFACE,
EGL_NO_CONTEXT) != EGL_TRUE) {
std::fprintf(stderr,
"egl teardown: failed to release current "
"context\n");
released = false;
}
if (eglReleaseThread() != EGL_TRUE) {
std::fprintf(stderr,
"egl teardown: eglReleaseThread failed\n");
released = false;
}
/* Never tear down the display/GBM device under a context that EGL
* may still consider current. The process is already exiting, so
* leaking on this exceptional path is safer than use-after-free. */
if (!released) return;
if (surface_ != EGL_NO_SURFACE)
eglDestroySurface(display_, surface_);
if (context_ != EGL_NO_CONTEXT)
@@ -211,43 +279,61 @@ public:
void* procLoaderCtx() const { return nullptr; }
private:
bool acquireDisplay() {
/* Tier log goes to stderr (the adapter mirrors it) — stdout is
* reserved for the JSON event protocol. */
display_ = eglGetPlatformDisplay(EGL_PLATFORM_SURFACELESS_MESA,
nullptr, nullptr);
if (initDisplay(display_)) {
std::fprintf(stderr, "egl display: surfaceless-mesa\n");
return true;
}
enum class DisplayTier { SurfacelessMesa, Default, Gbm };
display_ = eglGetDisplay(EGL_DEFAULT_DISPLAY);
if (initDisplay(display_)) {
std::fprintf(stderr, "egl display: default\n");
return true;
}
struct Candidate {
EGLDisplay display = EGL_NO_DISPLAY;
EGLContext context = EGL_NO_CONTEXT;
EGLSurface surface = EGL_NO_SURFACE;
struct gbm_device* gbmDevice = nullptr;
int gbmFd = -1;
bool initialized = false;
bool current = false;
bool threadReleased = false;
bool softwareRenderer = false;
std::string renderer;
};
display_ = EGL_NO_DISPLAY;
if (openGbmDevice()) {
display_ = eglGetPlatformDisplay(EGL_PLATFORM_GBM_KHR, gbmDevice_,
nullptr);
if (initDisplay(display_)) {
std::fprintf(stderr, "egl display: gbm render node\n");
return true;
}
static const char* tierName(DisplayTier tier) {
switch (tier) {
case DisplayTier::SurfacelessMesa:
return "surfaceless-mesa";
case DisplayTier::Default:
return "default";
case DisplayTier::Gbm:
return "gbm render node";
}
return "unknown";
}
static void appendFailure(std::string& failures,
const std::string& failure) {
if (failure.empty()) return;
if (!failures.empty()) failures += "; ";
failures += failure;
}
static bool candidateOwnsResources(const Candidate& candidate) {
return candidate.display != EGL_NO_DISPLAY ||
candidate.context != EGL_NO_CONTEXT ||
candidate.surface != EGL_NO_SURFACE || candidate.gbmDevice ||
candidate.gbmFd >= 0 || candidate.current;
}
static bool isSoftwareRenderer(const std::string& renderer) {
std::string normalized = renderer;
std::transform(normalized.begin(), normalized.end(), normalized.begin(),
[](unsigned char value) {
return static_cast<char>(std::tolower(value));
});
for (const char* marker : {"llvmpipe", "softpipe", "swrast",
"software rasterizer", "lavapipe"}) {
if (normalized.find(marker) != std::string::npos) return true;
}
display_ = EGL_NO_DISPLAY;
return false;
}
static bool initDisplay(EGLDisplay display) {
if (display == EGL_NO_DISPLAY) return false;
EGLint major = 0;
EGLint minor = 0;
return eglInitialize(display, &major, &minor) == EGL_TRUE;
}
bool openGbmDevice() {
static bool openGbmDevice(Candidate& candidate) {
for (int node = 128; node <= 131; node++) {
char devicePath[32];
std::snprintf(devicePath, sizeof(devicePath),
@@ -259,14 +345,15 @@ private:
close(fd);
continue;
}
gbmFd_ = fd;
gbmDevice_ = device;
candidate.gbmFd = fd;
candidate.gbmDevice = device;
return true;
}
return false;
}
bool chooseConfig(EGLint surfaceType, EGLConfig* out) {
static bool chooseConfig(EGLDisplay display, EGLint surfaceType,
EGLConfig* out) {
const EGLint attrs[] = {
EGL_SURFACE_TYPE, surfaceType,
EGL_RENDERABLE_TYPE, EGL_OPENGL_BIT,
@@ -277,16 +364,152 @@ private:
EGL_NONE,
};
EGLint matched = 0;
return eglChooseConfig(display_, attrs, out, 1, &matched) ==
EGL_TRUE &&
return eglChooseConfig(display, attrs, out, 1, &matched) == EGL_TRUE &&
matched > 0;
}
bool tryCandidate(DisplayTier tier, Candidate& candidate,
std::string& errorOut) {
const auto fail = [&](const std::string& detail) {
errorOut = std::string(tierName(tier)) + ": " + detail;
destroyCandidate(candidate);
return false;
};
switch (tier) {
case DisplayTier::SurfacelessMesa:
candidate.display = eglGetPlatformDisplay(
EGL_PLATFORM_SURFACELESS_MESA, nullptr, nullptr);
break;
case DisplayTier::Default:
candidate.display = eglGetDisplay(EGL_DEFAULT_DISPLAY);
break;
case DisplayTier::Gbm:
if (!openGbmDevice(candidate)) {
return fail("no accessible DRM render node");
}
candidate.display = eglGetPlatformDisplay(
EGL_PLATFORM_GBM_KHR, candidate.gbmDevice, nullptr);
break;
}
if (candidate.display == EGL_NO_DISPLAY) {
return fail("eglGetDisplay returned EGL_NO_DISPLAY");
}
EGLint major = 0;
EGLint minor = 0;
if (eglInitialize(candidate.display, &major, &minor) != EGL_TRUE) {
return fail("eglInitialize failed");
}
candidate.initialized = true;
if (eglBindAPI(EGL_OPENGL_API) != EGL_TRUE) {
return fail("desktop OpenGL API unavailable");
}
EGLConfig config = nullptr;
if (!chooseConfig(candidate.display, EGL_PBUFFER_BIT, &config) &&
!chooseConfig(candidate.display, 0, &config)) {
return fail("no desktop-OpenGL EGLConfig");
}
const EGLint contextAttrs[] = {
EGL_CONTEXT_MAJOR_VERSION,
3,
EGL_CONTEXT_MINOR_VERSION,
2,
EGL_CONTEXT_OPENGL_PROFILE_MASK,
EGL_CONTEXT_OPENGL_CORE_PROFILE_BIT,
EGL_NONE,
};
candidate.context = eglCreateContext(candidate.display, config,
EGL_NO_CONTEXT, contextAttrs);
if (candidate.context == EGL_NO_CONTEXT) {
return fail("failed to create a 3.2 core context");
}
if (eglMakeCurrent(candidate.display, EGL_NO_SURFACE, EGL_NO_SURFACE,
candidate.context) != EGL_TRUE) {
const EGLint pbufferAttrs[] = {
EGL_WIDTH, 1, EGL_HEIGHT, 1, EGL_NONE,
};
candidate.surface = eglCreatePbufferSurface(
candidate.display, config, pbufferAttrs);
if (candidate.surface == EGL_NO_SURFACE ||
eglMakeCurrent(candidate.display, candidate.surface,
candidate.surface, candidate.context) !=
EGL_TRUE) {
return fail(
"eglMakeCurrent failed (surfaceless and pbuffer)");
}
}
candidate.current = true;
const GLubyte* renderer = glGetString(GL_RENDERER);
if (!renderer) return fail("GL_RENDERER unavailable");
candidate.renderer = reinterpret_cast<const char*>(renderer);
candidate.softwareRenderer = isSoftwareRenderer(candidate.renderer);
if (eglMakeCurrent(candidate.display, EGL_NO_SURFACE, EGL_NO_SURFACE,
EGL_NO_CONTEXT) != EGL_TRUE) {
return fail("failed to unbind the probe context");
}
candidate.current = false;
if (eglReleaseThread() != EGL_TRUE) {
return fail("eglReleaseThread failed after probing");
}
candidate.threadReleased = true;
return true;
}
static bool releaseCandidateThread(Candidate& candidate) {
if (candidate.threadReleased) return true;
if (candidate.current) {
if (eglBindAPI(EGL_OPENGL_API) != EGL_TRUE ||
eglMakeCurrent(candidate.display, EGL_NO_SURFACE,
EGL_NO_SURFACE, EGL_NO_CONTEXT) != EGL_TRUE) {
return false;
}
candidate.current = false;
}
if (eglReleaseThread() != EGL_TRUE) return false;
candidate.threadReleased = true;
return true;
}
static void destroyCandidate(Candidate& candidate) {
if (!releaseCandidateThread(candidate)) {
std::fprintf(stderr,
"egl candidate teardown skipped: context release "
"failed\n");
return;
}
if (candidate.display != EGL_NO_DISPLAY) {
if (candidate.surface != EGL_NO_SURFACE)
eglDestroySurface(candidate.display, candidate.surface);
if (candidate.context != EGL_NO_CONTEXT)
eglDestroyContext(candidate.display, candidate.context);
if (candidate.initialized) eglTerminate(candidate.display);
}
if (candidate.gbmDevice) gbm_device_destroy(candidate.gbmDevice);
if (candidate.gbmFd >= 0) close(candidate.gbmFd);
candidate = Candidate{};
}
void adoptCandidate(Candidate& candidate) {
display_ = candidate.display;
context_ = candidate.context;
surface_ = candidate.surface;
gbmDevice_ = candidate.gbmDevice;
gbmFd_ = candidate.gbmFd;
renderer_ = candidate.renderer;
candidate = Candidate{};
}
EGLDisplay display_ = EGL_NO_DISPLAY;
EGLContext context_ = EGL_NO_CONTEXT;
EGLSurface surface_ = EGL_NO_SURFACE;
struct gbm_device* gbmDevice_ = nullptr;
int gbmFd_ = -1;
std::string renderer_;
};
#else
@@ -162,11 +162,10 @@ inline bool RenderPipeline::start(mpv_handle* mpv,
}
inline bool RenderPipeline::setupGl(std::string& errorOut) {
gl_.makeCurrent();
if (!gl_.makeCurrent(errorOut)) return false;
/* Diagnosable renderer choice: e.g. Mesa's surfaceless platform can
* silently fall back to llvmpipe when the hardware driver is only
* reachable via another EGL display tier. */
/* Keep the accepted renderer diagnosable. Linux already rejected earlier
* software tiers when a later hardware-backed EGL candidate was usable. */
const GLubyte* renderer = glGetString(GL_RENDERER);
if (renderer) {
std::fprintf(stderr, "gl renderer: %s\n",
@@ -316,6 +315,8 @@ inline void RenderPipeline::runLoop() {
.str("error", "render init failed: " + glError)
.finish());
initState_.store(-1);
gl_.destroy();
ring_.destroy();
return;
}
initState_.store(1);
@@ -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',
])
);
});
});
+17 -13
View File
@@ -113,8 +113,10 @@ service and the adapter):
one-process-per-session libmpv host. It decodes (hwdec), renders
offscreen at viewport size (async PBO readback ring over a headless GL
context — `frame_helper_gl.h`: CGL on macOS; on Linux EGL, acquiring a
display in order surfaceless-Mesa → default display → GBM render node,
logging the chosen tier to stderr), publishes BGRA frames into a POSIX
display in order surfaceless-Mesa → default display → GBM render node;
every tier must complete config/context/bind validation, hardware rendering
is preferred, and a software tier is retained only as the final fallback),
publishes BGRA frames into a POSIX
shm seqlock ring
(`frame_shm.h`, 3 slots, resize creates a new `-g<N>` generation), and
plays audio directly. Control protocol: tab-separated commands on stdin,
@@ -186,7 +188,9 @@ known leftover build output; it is not a compatibility check for a complete
but version-mismatched runtime pair. Linux packages deliberately do NOT ship
the helper yet: it links the build host's system `libmpv`, which packaged
apps cannot assume is installed, so the centralized after-pack artifact
preparation strips it and the support probe reports frame-copy unavailable.
preparation strips both possible helper basenames and package validation
rejects either one if it survives. The support probe therefore reports
frame-copy unavailable in Linux packages.
The engine is dev-build-only on Linux until bundled-libmpv runtime staging
lands (PORTING.md milestone 4).
@@ -206,11 +210,11 @@ Trade-offs and constraints:
`libopengl-dev` and `libgbm-dev` (the helper links system libmpv, which
is legal out-of-process — the in-process libmpv ban still binds the
addon). The helper logs the chosen EGL display tier and the GL renderer
string to stderr; on systems whose hardware driver is only reachable
via the default display (e.g. NVIDIA proprietary), the surfaceless tier
can select Mesa's software renderer — check that log line when
diagnosing performance. The Windows port of the helper (WGL) is future
work — the shm protocol and adapter are platform-agnostic.
string to stderr. If an early tier selects Mesa software rendering (for
example, while a proprietary NVIDIA driver is reachable through the default
display or GBM), it probes the remaining tiers and uses software only when
no hardware-backed context works. The Windows port of the helper (WGL) is
future work — the shm protocol and adapter are platform-agnostic.
- Measured baseline (M1 Pro, spikes/mpv-frame-copy/RESULTS.md): 4K60 HEVC
sustained end to end, ~1.2 ms shm copy + ~3.5 ms texture upload, ~10 ms
produce-to-upload latency, zero torn frames over a 10-minute run.
@@ -327,11 +331,11 @@ The Electron main process holds an `electron.powerSaveBlocker` of type `prevent-
Current development behavior:
- The addon build supports `darwin`, `win32`, and `linux`; Windows and Linux builds require running on that target OS.
- The build script first looks for a staged runtime at `vendor/embedded-mpv/<platform>-<arch>/`.
- The staged runtime/build inputs must contain `include/mpv/client.h` and `runtime-manifest.json`. macOS and Windows staging also contains the platform runtime files that are bundled into the app.
- The build script first looks for staged inputs at `vendor/embedded-mpv/<platform>-<arch>/`. On Linux, local development can fall back to distribution `libmpv-dev` headers and libraries; `LIBMPV_INCLUDE_DIR` and `LINUX_NATIVE_LIBRARY_DIR` override the default system paths.
- When the staged-input path is used, it must contain `include/mpv/client.h` and `runtime-manifest.json`. macOS and Windows staging also contains the platform runtime files that are bundled into the app.
- The compiled `.node` addon is copied into `dist/apps/electron-backend/native/embedded_mpv.node`.
- Bundled runtime files are copied into `dist/apps/electron-backend/native/lib/` for macOS and Windows. macOS copies `.dylib` and non-`.dylib` Mach-O dependencies; Windows copies the staged `mpv-2.dll`/`libmpv-2.dll`/`mpv.dll`/`libmpv.dll` runtime name plus import libraries. Linux writes an `external-mpv-process` manifest and intentionally leaves `libmpv.so` out of the package.
- Linux does not bundle or load `libmpv` in the Electron process. Its native addon still requires staged MPV headers, but runtime support depends on the X11/Xwayland window handle plus an `mpv` executable on `PATH`.
- Linux does not bundle or load `libmpv` in the Electron process. The addon can compile against staged or system-development MPV headers. Its native engine still depends on an X11/Xwayland window handle plus an `mpv` executable on `PATH`; the dev-only frame-copy helper is a separate process linked to system `libmpv` and renders through headless EGL, so it bypasses those native-engine prerequisites.
- `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.
Current release caveat:
@@ -339,7 +343,7 @@ Current release caveat:
- Release packaging requires a `vendored-lgpl` runtime manifest on macOS and Windows, and an `external-mpv-process` manifest on Linux.
- The Linux addon is built once per CI host architecture (x64). Linux packages for other architectures (arm64, armv7l) must not ship that foreign addon: `afterPack` replaces the native directory with an `embedded-mpv-unavailable.txt` marker explaining that embedded MPV is not bundled for that architecture, and package-layout verification rejects a foreign-architecture `embedded_mpv.node` while requiring the marker.
- macOS release packaging rejects embedded MPV binaries linked to `/opt/homebrew` or `/usr/local`.
- Windows release packaging verifies that the platform runtime file is present when Embedded MPV is required. Linux release packaging verifies that the addon and manifest are present and that no bundled `libmpv.so` files slipped into the package.
- Windows release packaging verifies that the platform runtime file is present when Embedded MPV is required. Linux release packaging verifies that the addon and manifest are present, no bundled `libmpv.so` files slipped into the package, and no development-only frame-copy helper survived `afterPack`.
- Local development can opt into Homebrew `libmpv` only by setting `IPTVNATOR_EMBEDDED_MPV_ALLOW_HOMEBREW=1`; packaged release validation rejects that runtime origin.
Before public release, packaging must:
@@ -387,7 +391,7 @@ During temporary PR and `master` artifact testing, CI can restore an exact-keyed
The CI builder pins FFmpeg `8.1`, mpv `0.41.0`, libplacebo `7.360.1`, libass `0.17.3`, FreeType `2.13.3`, FriBidi `1.0.16`, and HarfBuzz `8.5.0`. FFmpeg disables autodetected external libraries so Homebrew libraries cannot silently enter the runtime. Libplacebo is checked out from git with the submodules required by its Meson build because the generated GitHub archive does not include submodule contents. Even with Vulkan disabled, libplacebo still compiles Vulkan stubs and needs `3rdparty/Vulkan-Headers`. The generated manifest records source URLs, archive SHA-256 values where applicable, libplacebo git commit/submodule metadata, FFmpeg configure flags, and mpv Meson flags. The staging step normalizes macOS/Windows manifests to `origin: vendored-lgpl`, which release package validation requires on those platforms.
The Electron backend build consumes the staged runtime/build inputs and copies macOS/Windows runtime files into the native build output. Linux consumes the staged MPV headers, writes an `external-mpv-process` manifest, and does not copy `libmpv.so` into the package. macOS additionally rewrites Mach-O paths so `embedded_mpv.node` loads `@loader_path/lib/libmpv.2.dylib` instead of a machine-local Homebrew path. After `install_name_tool` rewrites any addon or runtime binary, the build re-signs that binary with an ad-hoc signature for local development. Release packaging still performs the normal app signing and notarization later.
The Electron backend build consumes the staged runtime/build inputs and copies macOS/Windows runtime files into the native build output. Linux consumes staged MPV headers when available or distribution development headers for local builds, writes an `external-mpv-process` manifest, and does not copy `libmpv.so` into the package. macOS additionally rewrites Mach-O paths so `embedded_mpv.node` loads `@loader_path/lib/libmpv.2.dylib` instead of a machine-local Homebrew path. After `install_name_tool` rewrites any addon or runtime binary, the build re-signs that binary with an ad-hoc signature for local development. Release packaging still performs the normal app signing and notarization later.
For local development before the vendored runtime exists, Homebrew can be used explicitly:
+26 -31
View File
@@ -5,28 +5,26 @@
> is the transfer). Written 2026-07-11 by the macOS session that built the
> engine; fold into DESIGN.md once both ports land.
## State as of this handoff
## State as of 2026-07-15
- Branch: `claude/embedded-mpv-frame-copy-6ddc36`, PR:
https://github.com/4gray/iptvnator/pull/1169 (CI fully green).
- The macOS base shipped through PR #1169 and is now merged into `master`.
- The engine works end-to-end on macOS Apple Silicon: Settings toggle →
restart → helper process renders mpv offscreen → shm ring → preload pump
→ WebGL canvas. Verified live with real IPTV + Stalker VOD.
- Scope decision: macOS = arm64 only (Intel Macs keep the native engine).
- **UPDATE 2026-07-11: the Linux port below is DONE** (branch
`claude/linux-frame-copy-port-0a871b`, stacked PR on #1169) — headless
- **The Linux port below is DONE in PR #1171** (rebased directly onto the
merged #1169 result) — headless
EGL helper, portable clock, reader on `__linux__`, TS gates, i18n,
measurements in RESULTS.md. Verified end-to-end in-app on Ubuntu 25.04
(Wayland session) with the xtream mock portal. Dev-build-only on Linux:
the helper links system libmpv and `electron-after-pack.cjs` strips it
from packages until milestone 4 (Linux bundled-libmpv runtime) — remove
that strip when milestone 4 lands. Windows is NOT ported yet — this branch on
Windows behaves exactly like master (helper doesn't build there, engine
can't activate, env flag falls back to native).
- Coordination: PR #1169 credits larsemig's idea (#1154 comment 4932807350)
and proposes the series merge plan: merge the shared-controls subset
(#1148/#1149/#1152–54) rebased, supersede immersive (#1150/#1151) with
this engine. **Do not touch the controls layer** until that lands.
that strip when milestone 4 lands. The Windows implementation lives in the
follow-up PR #1175; until that PR lands, `master` still has no WGL/named-shm
helper and the frame-copy flag falls back to the native engine on Windows.
- PR #1169 credits larsemig's idea (#1154 comment 4932807350). Shared-player
controls are reviewed as a separate integration after this platform stack;
do not conflate that UI layer with the frame-copy transport ports.
## What "porting" means
@@ -52,24 +50,18 @@ Porting Windows = give `frame_helper_gl.h` a WGL GlContext twin, give the
shm create/open (+ `frame_shm_now_ns`) Windows twins, flip the TS gate in
`embedded-mpv-frame-copy-platform.util.ts`, extend packaging.
## Branching & merge strategy (do this, not "commit to the current branch")
## Branching & merge strategy
- **Never commit port work into `claude/embedded-mpv-frame-copy-6ddc36`** —
that branch backs PR #1169, which is a frozen review/testing target
(review fixes only).
- Create a port branch off it: `claude/frame-copy-linux-port` (Windows
later: branch off the Linux port branch if it reuses its portable
clock/shm refactors — likely yes — otherwise off the frame-copy branch).
- Open the port PR with **base = the frame-copy branch**, so the diff shows
only the port. Merge order: #1169 → Linux PR → Windows PR. When #1169
merges and its branch is deleted, GitHub retargets the stacked PR to
master automatically.
- Keep the stack at most one unmerged level deep; if #1169 gains review
commits, rebase the port branch onto it early and often.
- Merge order is #1169 → #1171 → #1175. #1169 is already merged; #1171 is
based directly on that `master`, while #1175 remains stacked on the Linux
port until #1171 lands.
- Rewrite only the platform-specific commit range when moving a stacked PR;
do not replay the old parent history after its squash merge. Retarget the PR
explicitly and keep the parent branch until its child has been rewritten.
- Keep the stack at most one unmerged level deep. New follow-up work branches
from the latest landed platform base on `master`.
- Commit incrementally within the port branch; land each platform's
measurement rows in `RESULTS.md` in the same PR as its port.
- If #1169 has already merged by the time you read this: branch off
`master` instead and ignore the retargeting notes.
## Per-platform task lists
@@ -77,8 +69,11 @@ shm create/open (+ `frame_shm_now_ns`) Windows twins, flip the TS gate in
1. **Render backend**: headless EGL (`EGL_PLATFORM_SURFACELESS_MESA` /
`eglGetPlatformDisplay(EGL_PLATFORM_SURFACELESS_MESA)` with fallback to
GBM) + the same FBO/PBO/readback code. GL entry points via
`eglGetProcAddress` in mpv's `get_proc_address`.
default-display and GBM candidates) + the same FBO/PBO/readback code.
Each candidate is validated through context bind and `GL_RENDERER`; a
hardware renderer wins over an earlier software tier. mpv resolves linked
core GL symbols through `dlsym(RTLD_DEFAULT)` and falls back to
`eglGetProcAddress` for extensions.
2. **shm**: POSIX `shm_open` works as-is. The ONLY blocker in shared code:
`clock_gettime_nsec_np(CLOCK_MONOTONIC_RAW)` is **macOS-only** — replace
with a portable `now_ns()` (`clock_gettime(CLOCK_MONOTONIC, ...)`) in
@@ -213,8 +208,8 @@ shm create/open (+ `frame_shm_now_ns`) Windows twins, flip the TS gate in
## Suggested milestone order
1. Linux helper bring-up (EGL + portable clock) → lavfi smoke → in-app
behind flag → measure.
1. **Completed in #1171:** Linux helper bring-up (EGL + portable clock) →
lavfi smoke → in-app behind flag → measure.
2. Windows helper bring-up (WGL, named shm, reader twin) → same ladder →
**iGPU laptop numbers = the decisive open gate**.
3. Packaging: per-platform artifact validation + runtime staging.
@@ -497,6 +497,11 @@ test('frame-copy packaging file operations enforce modes and remove stale artifa
false,
'Linux packages must omit the unsupported frame-copy helper'
);
assert.equal(
fs.existsSync(windowsHelperPath),
false,
'Linux packages must omit stale Windows frame-copy helpers too'
);
fs.writeFileSync(helperPath, '#!/bin/sh\n');
removeStaleFrameCopyArtifacts(tempDir);
@@ -618,3 +623,36 @@ test('embedded MPV package validation rejects bundled Linux libmpv', () => {
fs.rmSync(tempDir, { recursive: true, force: true });
}
});
test('embedded MPV package validation rejects frame-copy helpers in Linux packages', () => {
const tempDir = fs.mkdtempSync(join(os.tmpdir(), 'iptvnator-mpv-package-'));
try {
const nativeDir = join(
tempDir,
'app.asar.unpacked',
'electron-backend',
'native'
);
fs.mkdirSync(nativeDir, { recursive: true });
fs.writeFileSync(join(nativeDir, 'embedded_mpv.node'), '');
fs.writeFileSync(
join(nativeDir, 'embedded-mpv-runtime.json'),
JSON.stringify({ origin: 'external-mpv-process' })
);
fs.writeFileSync(join(nativeDir, 'iptvnator_mpv_helper'), '');
fs.writeFileSync(join(nativeDir, 'iptvnator_mpv_helper.exe'), '');
const errors = validatePackagedEmbeddedMpv(tempDir, {
platform: 'linux',
required: true,
});
const message = errors.join('\n');
assert.match(message, /must not ship frame-copy helpers/);
assert.match(message, /iptvnator_mpv_helper\n/);
assert.match(message, /iptvnator_mpv_helper\.exe/);
} finally {
fs.rmSync(tempDir, { recursive: true, force: true });
}
});
@@ -6,6 +6,19 @@ const WINDOWS_FRAME_COPY_HELPER = 'iptvnator_mpv_helper.exe';
const FRAME_COPY_READER = 'embedded_mpv_frame_reader.node';
function preparePackagedFrameCopyArtifacts(nativeDir, platform) {
if (platform === 'linux') {
// Until Linux ships a bundled libmpv runtime, do not package a helper
// linked against the build host's system library. Remove both names
// so a stale cross-platform build artifact cannot leak into a package.
for (const fileName of [
FRAME_COPY_HELPER,
WINDOWS_FRAME_COPY_HELPER,
]) {
fs.rmSync(path.join(nativeDir, fileName), { force: true });
}
return;
}
const helperPath = path.join(
nativeDir,
platform === 'win32'
@@ -13,13 +26,6 @@ function preparePackagedFrameCopyArtifacts(nativeDir, platform) {
: FRAME_COPY_HELPER
);
if (platform === 'linux') {
// Until Linux ships a bundled libmpv runtime, do not package a helper
// linked against the build host's system library.
fs.rmSync(helperPath, { force: true });
return;
}
if (platform !== 'win32' && fs.existsSync(helperPath)) {
// Asset copying drops POSIX modes; restore spawn permission.
fs.chmodSync(helperPath, 0o755);
@@ -572,6 +572,22 @@ function validatePackagedEmbeddedMpv(resourceDir, options = {}) {
}
if (platform === 'linux') {
const packagedFrameCopyHelpers = [
path.join(unpackedNativeDir, 'iptvnator_mpv_helper'),
path.join(unpackedNativeDir, 'iptvnator_mpv_helper.exe'),
].filter((candidate) => fs.existsSync(candidate));
if (packagedFrameCopyHelpers.length > 0) {
errors.push(
[
'Linux packages must not ship frame-copy helpers linked against the build host system libmpv.',
'Remove:',
...packagedFrameCopyHelpers.map(
(candidate) => `- ${candidate}`
),
].join('\n')
);
}
const bundledLinuxRuntime = [
path.join(libDir, 'libmpv.so.2'),
path.join(libDir, 'libmpv.so.1'),