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

This commit is contained in:
4gray committed 2026-07-15 21:04:24 +02:00
1 parent 1a0e03cb93
commit 972c4cc3b7
11 files changed
+609 -110

No files matched your search

+1 -1
View File
@@ -617,7 +617,7 @@ This project uses modern Angular signal-based APIs and patterns. **ALWAYS** use
- Built-in HTML5 player with HLS.js or Video.js
- External players: MPV, VLC (via IPC to Electron backend)
- Embedded MPV (experimental, macOS/Windows/Linux): renders mpv video inside the Electron window through a native addon. macOS uses the libmpv render API in an `NSOpenGLView`; Windows uses in-process libmpv with `--wid` against an app-owned child `HWND`; Linux spawns an out-of-process `mpv --wid=<x11-window>` controlled over a JSON IPC socket (X11/XWayland only, requires system `mpv` on PATH; subtitles/speed/aspect/recording are not exported there). mpv's own screensaver inhibition does not apply to any of these paths, so `EmbeddedMpvNativeService` holds an Electron `powerSaveBlocker` (`prevent-display-sleep`) whenever any session's status is `playing`, and releases it on pause, dispose, or shutdown. Service: `apps/electron-backend/src/app/services/embedded-mpv-native.service.ts`; full architecture: `docs/architecture/embedded-mpv-native.md`.
- Embedded MPV frame-copy engine (experimental, macOS Apple Silicon + Linux + Windows; enabled via `Settings > Playback > Embedded MPV: frame-copy engine` (restart required) or `IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY=1` on top of the embedded MPV experiment flag): a per-session helper renders mpv offscreen at viewport size (headless CGL on macOS, headless EGL on Linux, WGL against a hidden window on Windows) and publishes BGRA frames into a shm ring (POSIX shm; a `Local\` named file mapping on Windows); the preload frame pump uploads them onto a renderer `<canvas data-embedded-mpv-frame>`, so controls/dialogs are ordinary DOM above the video. Stored and explicit opt-ins relax the sandbox only while the base embedded-MPV feature is enabled and a platform-supported packaged runtime contains both the regular-file helper (`iptvnator_mpv_helper` / `.exe`) and readable regular frame-reader addon; packaged discovery is restricted to packaged resources. A disabled base experiment keeps embedded MPV unavailable with the sandbox intact, while a missing, mode-stripped, or incomplete frame-copy runtime falls back to the native engine without relaxing the sandbox. On Linux the engine is dev-build-only for now: the helper links system libmpv (build deps: `libmpv-dev`, `libegl-dev`, `libgl-dev`, `libopengl-dev`, `libgbm-dev`) and is stripped from packages until bundled-runtime staging lands. On Windows the helper links vendored libmpv and packages must place an accepted mpv DLL beside the helper executable. Adapter: `apps/electron-backend/src/app/services/embedded-mpv-frame-copy.adapter.ts`; helper: `apps/electron-backend/native/helper/`; details in `docs/architecture/embedded-mpv-native.md` ("Frame-Copy Engine").
- Embedded MPV frame-copy engine (experimental, macOS Apple Silicon + Linux + Windows; enabled via `Settings > Playback > Embedded MPV: frame-copy engine` (restart required) or `IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY=1` on top of the embedded MPV experiment flag): a per-session helper renders mpv offscreen at viewport size (headless CGL on macOS, headless EGL on Linux, WGL against a hidden window on Windows) and publishes BGRA frames into a shm ring (POSIX shm; a `Local\` named file mapping on Windows); the preload frame pump uploads them onto a renderer `<canvas data-embedded-mpv-frame>`, so controls/dialogs are ordinary DOM above the video. Stored and explicit opt-ins relax the sandbox only while the base embedded-MPV feature is enabled and a platform-supported packaged runtime contains both the regular-file helper (`iptvnator_mpv_helper` / `.exe`) and readable regular frame-reader addon; packaged discovery is restricted to packaged resources. A disabled base experiment keeps embedded MPV unavailable with the sandbox intact, while a missing, mode-stripped, or incomplete frame-copy runtime falls back to the native engine without relaxing the sandbox. On Linux the engine is dev-build-only for now: the helper links system libmpv (build deps: `libmpv-dev`, `libegl-dev`, `libgl-dev`, `libopengl-dev`, `libgbm-dev`) and is stripped from packages until bundled-runtime staging lands. On Windows the helper links vendored libmpv and package validation requires the exact MPV DLL named in the helper's PE import table beside the executable. Adapter: `apps/electron-backend/src/app/services/embedded-mpv-frame-copy.adapter.ts`; helper: `apps/electron-backend/native/helper/`; details in `docs/architecture/embedded-mpv-native.md` ("Frame-Copy Engine").
**VOD/Series Detail Pages (two-state layout)**:
@@ -666,14 +666,19 @@ public:
return false;
}
if (wglMakeCurrent(dc_, legacy) != TRUE) {
wglDeleteContext(legacy);
if (wglDeleteContext(legacy) != TRUE) {
std::fprintf(stderr,
"wgl create: failed to delete unbound legacy "
"context\n");
}
errorOut = "failed to bind the legacy WGL context";
return false;
}
const auto createContextAttribs =
reinterpret_cast<FrameHelperWglCreateContextAttribsFn>(
wglGetProcAddress("wglCreateContextAttribsARB"));
wglLoaderGetProcAddress(opengl32_,
"wglCreateContextAttribsARB"));
if (createContextAttribs) {
const int attribs[] = {
WGL_CONTEXT_MAJOR_VERSION_ARB, 3,
@@ -683,9 +688,29 @@ public:
};
HGLRC core = createContextAttribs(dc_, nullptr, attribs);
if (core) {
wglMakeCurrent(dc_, core);
wglDeleteContext(legacy);
context_ = core;
if (wglMakeCurrent(dc_, core) != TRUE) {
if (wglDeleteContext(core) != TRUE) {
std::fprintf(
stderr,
"wgl create: failed to delete unbound core "
"context\n");
}
if (wglGetCurrentContext() != legacy &&
wglMakeCurrent(dc_, legacy) != TRUE) {
errorOut =
"failed to restore the legacy WGL context";
return false;
}
context_ = legacy;
} else {
context_ = core;
if (wglDeleteContext(legacy) != TRUE) {
std::fprintf(
stderr,
"wgl create: failed to delete superseded legacy "
"context\n");
}
}
} else {
/* Pre-3.2 driver: keep the legacy (compatibility) context;
* the entry-point loading below decides adequacy. */
@@ -708,20 +733,43 @@ public:
#undef FRAME_HELPER_WGL_LOAD
if (!loaded) {
errorOut = std::string("missing GL entry point: ") + missing;
wglMakeCurrent(nullptr, nullptr);
if (wglMakeCurrent(nullptr, nullptr) != TRUE) {
errorOut += "; failed to release the WGL context";
}
return false;
}
/* Unbind before returning — see the threading contract above. */
wglMakeCurrent(nullptr, nullptr);
if (wglMakeCurrent(nullptr, nullptr) != TRUE) {
errorOut =
"failed to release the WGL context for render-thread handoff";
return false;
}
return true;
}
void makeCurrent() { wglMakeCurrent(dc_, context_); }
bool makeCurrent(std::string& errorOut) {
if (!dc_ || !context_) {
errorOut = "WGL render context is unavailable";
return false;
}
if (wglMakeCurrent(dc_, context_) != TRUE) {
errorOut = "failed to bind the WGL render context";
return false;
}
return true;
}
void destroy() {
wglMakeCurrent(nullptr, nullptr);
if (context_) wglDeleteContext(context_);
if (context_ && wglMakeCurrent(nullptr, nullptr) != TRUE) {
std::fprintf(stderr,
"wgl teardown: failed to release current context\n");
return;
}
if (context_ && wglDeleteContext(context_) != TRUE) {
std::fprintf(stderr, "wgl teardown: failed to delete context\n");
return;
}
context_ = nullptr;
/* The hidden window/class belong to the main thread (DestroyWindow
* is thread-affine and this runs on the render thread); the process
@@ -44,14 +44,17 @@ typedef _Atomic uint64_t frame_shm_atomic_u64;
* without overflow by splitting whole seconds from the remainder. */
static inline uint64_t frame_shm_now_ns(void) {
#if defined(_WIN32)
/* QPF is fixed after boot, so the benign init race writes one value. */
static uint64_t frequency;
/* C++11 function-local initialization is synchronized, so the helper's
* render/event threads cannot race while caching the boot-stable QPF. */
static const uint64_t frequency = []() -> uint64_t {
LARGE_INTEGER value;
if (QueryPerformanceFrequency(&value) != TRUE ||
value.QuadPart <= 0) {
return 1;
}
return (uint64_t)value.QuadPart;
}();
LARGE_INTEGER counter;
if (frequency == 0) {
LARGE_INTEGER f;
QueryPerformanceFrequency(&f);
frequency = (uint64_t)f.QuadPart;
}
QueryPerformanceCounter(&counter);
const uint64_t ticks = (uint64_t)counter.QuadPart;
return (ticks / frequency) * 1000000000ull +
@@ -40,19 +40,23 @@ describe('Embedded MPV native source recording invariants', () => {
'utf8'
);
const frameHelperRenderSource = readFileSync(
path.resolve(
__dirname,
'../../../native/helper/frame_helper_render.h'
),
path.resolve(__dirname, '../../../native/helper/frame_helper_render.h'),
'utf8'
);
const frameHelperGlSource = readFileSync(
path.resolve(__dirname, '../../../native/helper/frame_helper_gl.h'),
'utf8'
);
const frameShmSource = readFileSync(
path.resolve(__dirname, '../../../native/helper/frame_shm.h'),
'utf8'
);
const linuxFrameHelperGlSource = frameHelperGlSource.slice(
frameHelperGlSource.indexOf('inline void* eglWrapGetProcAddress')
);
const windowsFrameHelperGlSource = frameHelperGlSource.slice(
frameHelperGlSource.indexOf('#define FRAME_HELPER_WGL_ENTRY_POINTS')
);
function functionBody(name: string): string {
return sourceFunctionBody(nativeSource, `Napi::Value ${name}(`, name);
@@ -304,9 +308,7 @@ describe('Embedded MPV native source recording invariants', () => {
});
it('copies Windows runtime DLLs next to the addon for Windows loader lookup', () => {
expect(buildScriptSource).toContain(
"'libmpv-2.dll',"
);
expect(buildScriptSource).toContain("'libmpv-2.dll',");
expect(buildScriptSource).toContain(
'path.join(outputDir, windowsDllName)'
);
@@ -719,6 +721,57 @@ describe('Embedded MPV native source recording invariants', () => {
);
});
it('checks WGL context handoff and never deletes a context after failed unbind', () => {
const create = sourceFunctionBody(
windowsFrameHelperGlSource,
'bool create(std::string& errorOut)',
'WGL GlContext::create'
);
const makeCurrent = sourceFunctionBody(
windowsFrameHelperGlSource,
'bool makeCurrent(std::string& errorOut)',
'WGL GlContext::makeCurrent'
);
const destroy = sourceFunctionBody(
windowsFrameHelperGlSource,
'void destroy()',
'WGL GlContext::destroy'
);
expect(create).toContain('wglMakeCurrent(dc_, core) != TRUE');
expect(create).toContain('wglMakeCurrent(nullptr, nullptr) != TRUE');
expect(create).toContain('wglLoaderGetProcAddress(opengl32_,');
expect(create).toContain('"wglCreateContextAttribsARB")');
expect(create).not.toContain(
'wglGetProcAddress("wglCreateContextAttribsARB")'
);
expect(makeCurrent).toContain('wglMakeCurrent(dc_, context_) != TRUE');
expect(makeCurrent).toContain('return false;');
const unbindIndex = destroy.indexOf(
'wglMakeCurrent(nullptr, nullptr) != TRUE'
);
const deleteIndex = destroy.indexOf(
'wglDeleteContext(context_) != TRUE'
);
expect(unbindIndex).toBeGreaterThanOrEqual(0);
expect(deleteIndex).toBeGreaterThan(unbindIndex);
expect(destroy.slice(0, deleteIndex)).toContain('return;');
});
it('initializes the Windows QPC frequency without a cross-thread data race', () => {
const nowNs = sourceFunctionBody(
frameShmSource,
'static inline uint64_t frame_shm_now_ns(void)',
'frame_shm_now_ns'
);
expect(nowNs).toContain(
'static const uint64_t frequency = []() -> uint64_t'
);
expect(nowNs).not.toContain('static uint64_t frequency;');
});
it('resolves linked core GL symbols before falling back to EGL extension lookup', () => {
const dlsymIndex = linuxFrameHelperGlSource.indexOf(
'dlsym(RTLD_DEFAULT, name)'
@@ -114,10 +114,7 @@
</div>
</div>
@if (
isDesktop() &&
(frameCopyAvailable() || form().value.embeddedMpvFrameCopy)
) {
@if (frameCopyAvailable() || form().value.embeddedMpvFrameCopy) {
<div
class="setting-item"
data-test-id="embedded-mpv-frame-copy-setting"
@@ -187,7 +184,9 @@
</div>
}
@if (supportsExternalPlayerPathSettings() && form().value.player === 'mpv') {
@if (
supportsExternalPlayerPathSettings() && form().value.player === 'mpv'
) {
<div class="setting-item">
<div class="setting-item__meta">
<h4>{{ 'SETTINGS.MPV_PLAYER_PATH_LABEL' | translate }}</h4>
@@ -259,7 +258,9 @@
</div>
}
@if (supportsExternalPlayerPathSettings() && form().value.player === 'vlc') {
@if (
supportsExternalPlayerPathSettings() && form().value.player === 'vlc'
) {
<div class="setting-item">
<div class="setting-item__meta">
<h4>{{ 'SETTINGS.VLC_PLAYER_PATH_LABEL' | translate }}</h4>
@@ -171,47 +171,29 @@ describe('SettingsPlaybackSectionComponent', () => {
).toBeNull();
});
it('shows the frame-copy toggle when the engine is available on desktop', () => {
fixture.componentRef.setInput('isDesktop', true);
fixture.componentRef.setInput('frameCopyAvailable', true);
fixture.detectChanges();
it.each<[string, boolean, boolean, boolean, boolean]>([
['shows the available desktop engine', true, true, false, true],
['keeps a stale desktop opt-in clearable', true, false, true, true],
['hides a stale opt-in in the PWA', false, false, true, false],
])(
'%s',
(_description, isDesktop, frameCopyAvailable, stored, expected) => {
const form = createForm();
form.controls['embeddedMpvFrameCopy'].setValue(stored);
fixture.componentRef.setInput('form', form);
fixture.componentRef.setInput('isDesktop', isDesktop);
fixture.componentRef.setInput(
'frameCopyAvailable',
frameCopyAvailable
);
fixture.detectChanges();
expect(
fixture.nativeElement.querySelector(
const setting = fixture.nativeElement.querySelector(
'[data-test-id="embedded-mpv-frame-copy-setting"]'
)
).not.toBeNull();
});
it('keeps a stale frame-copy opt-in clearable on desktop without the helper', () => {
const form = createForm();
form.controls['embeddedMpvFrameCopy'].setValue(true);
fixture.componentRef.setInput('form', form);
fixture.componentRef.setInput('isDesktop', true);
fixture.componentRef.setInput('frameCopyAvailable', false);
fixture.detectChanges();
expect(
fixture.nativeElement.querySelector(
'[data-test-id="embedded-mpv-frame-copy-setting"]'
)
).not.toBeNull();
});
it('never shows the frame-copy toggle in the PWA, even with a stored opt-in', () => {
const form = createForm();
form.controls['embeddedMpvFrameCopy'].setValue(true);
fixture.componentRef.setInput('form', form);
fixture.componentRef.setInput('isDesktop', false);
fixture.componentRef.setInput('frameCopyAvailable', false);
fixture.detectChanges();
expect(
fixture.nativeElement.querySelector(
'[data-test-id="embedded-mpv-frame-copy-setting"]'
)
).toBeNull();
});
);
expect(Boolean(setting)).toBe(expected);
}
);
it('shows the recording folder setting only in desktop builds', () => {
fixture.componentRef.setInput('isDesktop', true);
+5 -1
View File
@@ -41,7 +41,11 @@ Windows packaged runtimes must preserve the MPV DLL basename referenced by the
import library used at native-addon link time. For example, an archive that
ships `libmpv.dll.a` and `libmpv-2.dll` must package `libmpv-2.dll`; renaming it
to `mpv-2.dll` leaves `embedded_mpv.node` with an unresolved DLL dependency at
startup, so the Settings support probe hides the Embedded MPV option.
startup, so the Settings support probe hides the Embedded MPV option. The
frame-copy helper is a separate executable and resolves that same import from
its own directory. Package validation therefore reads the helper's PE import
table and requires the exact referenced MPV DLL beside
`iptvnator_mpv_helper.exe`, not only under `native/lib/`.
On Linux, `embedded_mpv.node` must not link directly to `libmpv` or load libmpv in-process. Electron loads its own `libffmpeg` and Chromium graphics stack; in-process libmpv can resolve FFmpeg/GL symbols against incompatible Electron symbols, while isolated dynamic-loader namespaces introduce thread/runtime ownership problems. The Linux addon therefore owns only the X11 child-window embedding, process lifecycle, and a private MPV JSON IPC socket. It starts `mpv --wid=<window> --input-ipc-server=<socket>`, polls `time-pos`, `duration`, `volume`, and `pause`, and forwards pause/seek/volume/audio-track commands through that socket. The Linux MPV JSON IPC polling runs on an addon-owned background thread; `getSessionSnapshot()` returns the last cached snapshot and must not perform socket round trips on Electron's main thread. Linux MPV process teardown sends `SIGTERM` on the caller path, then waits and escalates to `SIGKILL` on a detached cleanup thread. A healthy Linux build lists X11/Xext as addon dependencies, but `ldd apps/electron-backend/native/build/Release/embedded_mpv.node` must not list `libmpv`. Runtime support also requires an `mpv` executable on `PATH`.
+10 -16
View File
@@ -1,9 +1,8 @@
# Frame-copy engine — Windows/Linux porting handoff
> Handoff for future Claude/dev sessions on Windows and Linux machines
> (they won't have the originating Mac's local session memory — this file
> is the transfer). Written 2026-07-11 by the macOS session that built the
> engine; fold into DESIGN.md once both ports land.
> Historical handoff and maintenance notes for the completed Linux and
> Windows ports. The canonical runtime contract now lives in
> `docs/architecture/embedded-mpv-native.md`.
## State as of 2026-07-15
@@ -70,16 +69,11 @@ twins, the TS gate in `embedded-mpv-frame-copy-platform.util.ts`, packaging.
## Branching & merge strategy
- 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.
- The platform stack landed in order #1169 → #1171 → #1175.
- Each child was rewritten onto the latest squash-merged `master` using only
its platform-specific commit range; the old parent history was not replayed.
- New frame-copy work branches directly from `master`. Keep future stacks at
most one unmerged level deep and land platform measurements with their port.
## Per-platform task lists
@@ -217,10 +211,10 @@ display-capable DC.
`node -e "const r=require('.../embedded_mpv_frame_reader.node'); const i=r.open('/impv-t-g2'); ..."`
→ `latestSeq()` advancing + pixel min/max spread.
- **In-app**: `IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY=1 pnpm run
serve:backend:embedded-mpv` or the Settings toggle (+restart). Second
serve:backend:embedded-mpv` or the Settings toggle (+restart). Second
parallel instance for CDP testing: build, then run
`electron dist/apps/electron-backend/main.js --remote-debugging-port=9223
--user-data-dir=/tmp/x` with `ELECTRON_IS_DEV=0` for the file:// renderer
--user-data-dir=/tmp/x` with `ELECTRON_IS_DEV=0` for the file:// renderer
(dist package.json has no `main` field — point at main.js explicitly;
a separate user-data-dir avoids the Chromium profile singleton).
- **Perf gate**: follow `RESULTS.md` methodology (STATS/LONGRUN lines,
+4 -1
View File
@@ -110,7 +110,10 @@ libmpv import library, and `mpv-2.dll`/`mpv.dll` or
(`lib/` and `bin/`) or the common `mpv-dev-lgpl` flat layout with the import
library and DLL in the archive root. The staged runtime preserves the DLL
basename from the archive because Windows import libraries encode the DLL name
that `embedded_mpv.node` must load at runtime. If
that native binaries must load at runtime. Package validation reads the
frame-copy helper's PE imports and requires that exact DLL basename beside
`iptvnator_mpv_helper.exe`; a different accepted MPV DLL name or a copy only
under `native/lib/` is not sufficient. If
`runtime-manifest.json` is missing, CI generates a minimal manifest from the
archive URL/path and checksum; release-ready runtime archives should still
provide full source/build metadata.
@@ -102,6 +102,37 @@ const embeddedMpvWin32Source = fs.readFileSync(
);
const { validatePackagedEmbeddedMpv } = require('./embedded-mpv-packaging.cjs');
function writeWindowsHelperFixture(helperPath, importedDllName) {
const peOffset = 0x80;
const optionalHeaderOffset = peOffset + 24;
const optionalHeaderSize = 0xf0;
const sectionTableOffset = optionalHeaderOffset + optionalHeaderSize;
const importRva = 0x1000;
const importRawOffset = 0x200;
const importNameOffset = 0x30;
const image = Buffer.alloc(0x400);
image.write('MZ', 0, 'ascii');
image.writeUInt32LE(peOffset, 0x3c);
image.write('PE\0\0', peOffset, 'ascii');
image.writeUInt16LE(0x8664, peOffset + 4);
image.writeUInt16LE(1, peOffset + 6);
image.writeUInt16LE(optionalHeaderSize, peOffset + 20);
image.writeUInt16LE(0x20b, optionalHeaderOffset);
image.writeUInt32LE(0x200, optionalHeaderOffset + 60);
image.writeUInt32LE(16, optionalHeaderOffset + 108);
image.writeUInt32LE(importRva, optionalHeaderOffset + 120);
image.writeUInt32LE(40, optionalHeaderOffset + 124);
image.write('.idata\0\0', sectionTableOffset, 'ascii');
image.writeUInt32LE(0x200, sectionTableOffset + 8);
image.writeUInt32LE(importRva, sectionTableOffset + 12);
image.writeUInt32LE(0x200, sectionTableOffset + 16);
image.writeUInt32LE(importRawOffset, sectionTableOffset + 20);
image.writeUInt32LE(importRva + importNameOffset, importRawOffset + 12);
image.write(`${importedDllName}\0`, importRawOffset + importNameOffset);
fs.writeFileSync(helperPath, image);
}
test('Linux package identity does not expose the internal Electron backend project name', () => {
assert.equal(electronBuilderConfig.productName, 'IPTVnator');
assert.equal(electronBuilderConfig.extraMetadata?.name, 'iptvnator');
@@ -306,8 +337,8 @@ test('embedded MPV package validation accepts Windows runtime files and Linux pr
for (const [platform, runtimeFile] of [
['windows', 'mpv-2.dll'],
['windows', 'libmpv-2.dll'],
['windows', join('lib', 'mpv.dll')],
['windows', join('lib', 'libmpv.dll')],
['windows', 'mpv.dll'],
['windows', 'libmpv.dll'],
]) {
// One fixture dir per runtime-file scenario: the frame-copy
// artifacts written below must not leak into the next
@@ -347,7 +378,10 @@ test('embedded MPV package validation accepts Windows runtime files and Linux pr
)
);
fs.writeFileSync(join(nativeDir, 'iptvnator_mpv_helper.exe'), '');
writeWindowsHelperFixture(
join(nativeDir, 'iptvnator_mpv_helper.exe'),
runtimeFile
);
fs.writeFileSync(
join(nativeDir, 'embedded_mpv_frame_reader.node'),
''
@@ -402,10 +436,10 @@ test('embedded MPV package validation accepts Windows runtime files and Linux pr
// Host-agnostic assertion: on non-macOS hosts the validator also
// reports that link validation needs a macOS host, so only the
// frame-copy artifact requirement is asserted here.
const remainingErrors = validatePackagedEmbeddedMpv(
darwinResourceDir,
{ platform: 'darwin', required: true }
);
const remainingErrors = validatePackagedEmbeddedMpv(darwinResourceDir, {
platform: 'darwin',
required: true,
});
assert.ok(
!remainingErrors.some((error) =>
error.includes('frame-copy artifact')
@@ -439,6 +473,138 @@ test('embedded MPV package validation accepts Windows runtime files and Linux pr
}
});
test('Windows frame-copy packages reject an mpv DLL that exists only under native/lib', () => {
const tempDir = fs.mkdtempSync(join(os.tmpdir(), 'iptvnator-mpv-package-'));
try {
for (const runtimeFile of [
'mpv-2.dll',
'libmpv-2.dll',
'mpv.dll',
'libmpv.dll',
]) {
const resourceDir = join(tempDir, runtimeFile);
const nativeDir = join(
resourceDir,
'app.asar.unpacked',
'electron-backend',
'native'
);
fs.mkdirSync(join(nativeDir, 'lib'), { recursive: true });
fs.writeFileSync(join(nativeDir, 'embedded_mpv.node'), '');
fs.writeFileSync(
join(nativeDir, 'embedded-mpv-runtime.json'),
JSON.stringify({ origin: 'vendored-lgpl' })
);
writeWindowsHelperFixture(
join(nativeDir, 'iptvnator_mpv_helper.exe'),
runtimeFile
);
fs.writeFileSync(
join(nativeDir, 'embedded_mpv_frame_reader.node'),
''
);
fs.writeFileSync(join(nativeDir, 'lib', runtimeFile), '');
const errors = validatePackagedEmbeddedMpv(resourceDir, {
platform: 'windows',
required: true,
});
assert.ok(
errors.some(
(error) =>
error.includes(
'beside the Windows frame-copy helper'
) && error.includes(nativeDir)
),
`${runtimeFile} under native/lib must not satisfy the helper DLL requirement: ${errors.join('; ')}`
);
}
} finally {
fs.rmSync(tempDir, { recursive: true, force: true });
}
});
test('Windows frame-copy packages require the DLL imported by the helper', () => {
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: 'vendored-lgpl' })
);
writeWindowsHelperFixture(
join(nativeDir, 'iptvnator_mpv_helper.exe'),
'mpv-2.dll'
);
fs.writeFileSync(join(nativeDir, 'embedded_mpv_frame_reader.node'), '');
fs.writeFileSync(join(nativeDir, 'libmpv.dll'), '');
const errors = validatePackagedEmbeddedMpv(tempDir, {
platform: 'windows',
required: true,
});
assert.ok(
errors.some(
(error) =>
error.includes('imports mpv-2.dll') &&
error.includes(join(nativeDir, 'mpv-2.dll'))
),
`a different accepted DLL must not satisfy the helper import: ${errors.join('; ')}`
);
} finally {
fs.rmSync(tempDir, { recursive: true, force: true });
}
});
test('Windows frame-copy package validation fails closed for a malformed helper', () => {
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: 'vendored-lgpl' })
);
fs.writeFileSync(join(nativeDir, 'iptvnator_mpv_helper.exe'), 'MZ');
fs.writeFileSync(join(nativeDir, 'embedded_mpv_frame_reader.node'), '');
fs.writeFileSync(join(nativeDir, 'mpv-2.dll'), '');
const errors = validatePackagedEmbeddedMpv(tempDir, {
platform: 'windows',
required: true,
});
assert.ok(
errors.some((error) =>
error.includes(
'Unable to inspect Windows frame-copy helper imports'
)
),
`malformed helper must fail package validation: ${errors.join('; ')}`
);
} finally {
fs.rmSync(tempDir, { recursive: true, force: true });
}
});
test('Windows embedded MPV staging preserves import-library DLL basenames', () => {
assert.match(
embeddedMpvStageRuntimeSource,
@@ -503,10 +669,7 @@ test('frame-copy packaging file operations enforce modes and remove stale artifa
try {
const helperPath = join(tempDir, 'iptvnator_mpv_helper');
const windowsHelperPath = join(
tempDir,
'iptvnator_mpv_helper.exe'
);
const windowsHelperPath = join(tempDir, 'iptvnator_mpv_helper.exe');
const readerPath = join(tempDir, 'embedded_mpv_frame_reader.node');
fs.writeFileSync(helperPath, '#!/bin/sh\n');
fs.chmodSync(helperPath, 0o644);
+259 -11
View File
@@ -4,6 +4,12 @@ const { spawnSync } = require('child_process');
const forbiddenRuntimePathPrefixes = ['/opt/homebrew/', '/usr/local/'];
const systemRuntimePathPrefixes = ['/System/Library/', '/usr/lib/'];
const windowsMpvRuntimeNames = [
'mpv-2.dll',
'libmpv-2.dll',
'mpv.dll',
'libmpv.dll',
];
function run(command, args, options = {}) {
const result = spawnSync(command, args, {
@@ -67,6 +73,177 @@ function listRuntimeFiles(directoryPath) {
.sort();
}
function assertPeRange(image, offset, size, label) {
if (
!Number.isSafeInteger(offset) ||
!Number.isSafeInteger(size) ||
offset < 0 ||
size < 0 ||
offset + size > image.length
) {
throw new Error(`Invalid PE image: ${label} is out of range.`);
}
}
function readPeImportedDllNames(binaryPath) {
const image = fs.readFileSync(binaryPath);
assertPeRange(image, 0, 0x40, 'DOS header');
if (image.toString('ascii', 0, 2) !== 'MZ') {
throw new Error('Invalid PE image: missing DOS signature.');
}
const peOffset = image.readUInt32LE(0x3c);
assertPeRange(image, peOffset, 24, 'PE header');
if (image.toString('ascii', peOffset, peOffset + 4) !== 'PE\0\0') {
throw new Error('Invalid PE image: missing PE signature.');
}
const sectionCount = image.readUInt16LE(peOffset + 6);
const optionalHeaderSize = image.readUInt16LE(peOffset + 20);
const optionalHeaderOffset = peOffset + 24;
assertPeRange(
image,
optionalHeaderOffset,
optionalHeaderSize,
'optional header'
);
const optionalMagic = image.readUInt16LE(optionalHeaderOffset);
const dataDirectoryOffset =
optionalMagic === 0x20b
? optionalHeaderOffset + 112
: optionalMagic === 0x10b
? optionalHeaderOffset + 96
: null;
const directoryCountOffset =
optionalMagic === 0x20b
? optionalHeaderOffset + 108
: optionalMagic === 0x10b
? optionalHeaderOffset + 92
: null;
if (dataDirectoryOffset === null || directoryCountOffset === null) {
throw new Error(
`Invalid PE image: unsupported optional-header magic 0x${optionalMagic.toString(16)}.`
);
}
const optionalHeaderEnd = optionalHeaderOffset + optionalHeaderSize;
if (
directoryCountOffset + 4 > optionalHeaderEnd ||
dataDirectoryOffset + 16 > optionalHeaderEnd
) {
throw new Error(
'Invalid PE image: data directories exceed the optional header.'
);
}
assertPeRange(image, directoryCountOffset, 4, 'data-directory count');
if (image.readUInt32LE(directoryCountOffset) < 2) {
return [];
}
assertPeRange(image, dataDirectoryOffset + 8, 8, 'import directory');
const importRva = image.readUInt32LE(dataDirectoryOffset + 8);
const importSize = image.readUInt32LE(dataDirectoryOffset + 12);
if (importRva === 0 && importSize === 0) {
return [];
}
if (importRva === 0 || importSize < 20) {
throw new Error('Invalid PE image: malformed import directory.');
}
const sectionTableOffset = optionalHeaderOffset + optionalHeaderSize;
assertPeRange(
image,
sectionTableOffset,
sectionCount * 40,
'section table'
);
const sections = Array.from({ length: sectionCount }, (_, index) => {
const offset = sectionTableOffset + index * 40;
return {
virtualSize: image.readUInt32LE(offset + 8),
virtualAddress: image.readUInt32LE(offset + 12),
rawSize: image.readUInt32LE(offset + 16),
rawOffset: image.readUInt32LE(offset + 20),
};
});
const rvaToLocation = (rva, label) => {
const section = sections.find(
(candidate) =>
rva >= candidate.virtualAddress &&
rva <
candidate.virtualAddress +
Math.max(candidate.virtualSize, candidate.rawSize)
);
if (!section) {
throw new Error(
`Invalid PE image: ${label} RVA 0x${rva.toString(16)} is not mapped.`
);
}
const sectionOffset = rva - section.virtualAddress;
if (sectionOffset >= section.rawSize) {
throw new Error(
`Invalid PE image: ${label} is outside section file data.`
);
}
const offset = section.rawOffset + sectionOffset;
assertPeRange(image, offset, 1, label);
return {
offset,
sectionEnd: section.rawOffset + section.rawSize,
};
};
const importedDllNames = [];
const descriptorCount = Math.floor(importSize / 20);
let foundTerminator = false;
for (let index = 0; index < descriptorCount; index += 1) {
const descriptorLocation = rvaToLocation(
importRva + index * 20,
'import descriptor'
);
const descriptorOffset = descriptorLocation.offset;
if (descriptorOffset + 20 > descriptorLocation.sectionEnd) {
throw new Error(
'Invalid PE image: import descriptor crosses section file data.'
);
}
assertPeRange(image, descriptorOffset, 20, 'import descriptor');
const descriptor = image.subarray(
descriptorOffset,
descriptorOffset + 20
);
if (descriptor.every((value) => value === 0)) {
foundTerminator = true;
break;
}
const nameRva = image.readUInt32LE(descriptorOffset + 12);
const nameLocation = rvaToLocation(nameRva, 'imported DLL name');
const nameOffset = nameLocation.offset;
const nameEnd = image.indexOf(0, nameOffset);
if (nameEnd < 0 || nameEnd >= nameLocation.sectionEnd) {
throw new Error(
'Invalid PE image: imported DLL name is not terminated.'
);
}
const name = image.toString('ascii', nameOffset, nameEnd);
if (!name) {
throw new Error('Invalid PE image: imported DLL name is empty.');
}
importedDllNames.push(name);
}
if (!foundTerminator) {
throw new Error(
'Invalid PE image: import descriptor table has no terminator.'
);
}
return [...new Set(importedDllNames)];
}
function parseOtoolDependencies(binaryPath) {
const output = run('otool', ['-L', binaryPath]);
return output
@@ -426,16 +603,12 @@ function getPackagedRuntimeCandidates(libDir, platform, nativeDir) {
path.join(libDir, 'libmpv.dylib'),
];
case 'win32':
return [
nativeDir ? path.join(nativeDir, 'mpv-2.dll') : null,
nativeDir ? path.join(nativeDir, 'libmpv-2.dll') : null,
nativeDir ? path.join(nativeDir, 'mpv.dll') : null,
nativeDir ? path.join(nativeDir, 'libmpv.dll') : null,
path.join(libDir, 'mpv-2.dll'),
path.join(libDir, 'libmpv-2.dll'),
path.join(libDir, 'mpv.dll'),
path.join(libDir, 'libmpv.dll'),
].filter(Boolean);
return windowsMpvRuntimeNames.flatMap((name) =>
[
nativeDir ? path.join(nativeDir, name) : null,
path.join(libDir, name),
].filter(Boolean)
);
case 'linux':
return [];
default:
@@ -480,7 +653,11 @@ function getEmbeddedMpvAddonArch(env = process.env) {
* dist output. Those packages must not ship a foreign-architecture
* `embedded_mpv.node` — it can never load and produces a cryptic error.
*/
function isForeignLinuxEmbeddedMpvArch(platform, targetArch, env = process.env) {
function isForeignLinuxEmbeddedMpvArch(
platform,
targetArch,
env = process.env
) {
if (normalizeEmbeddedMpvPlatform(platform) !== 'linux') {
return false;
}
@@ -560,6 +737,77 @@ function validatePackagedEmbeddedMpv(resourceDir, options = {}) {
`Missing embedded MPV frame-copy artifact: ${artifactPath}`
)
);
if (platform === 'win32') {
// The helper is a separate executable. Windows resolves its
// imported libmpv DLL from the executable directory, so a copy
// under native/lib may satisfy addon bookkeeping but cannot
// start iptvnator_mpv_helper.exe.
const helperPath = path.join(
unpackedNativeDir,
'iptvnator_mpv_helper.exe'
);
const helperRuntimeCandidates = windowsMpvRuntimeNames.map((name) =>
path.join(unpackedNativeDir, name)
);
if (
!helperRuntimeCandidates.some((candidate) =>
fs.existsSync(candidate)
)
) {
errors.push(
[
`Missing bundled MPV DLL beside the Windows frame-copy helper in ${unpackedNativeDir}.`,
'Expected one of:',
...helperRuntimeCandidates.map(
(candidate) => `- ${candidate}`
),
].join('\n')
);
}
if (fs.existsSync(helperPath)) {
try {
const acceptedRuntimeNames = new Set(
windowsMpvRuntimeNames.map((name) => name.toLowerCase())
);
const importedRuntimeNames = readPeImportedDllNames(
helperPath
).filter((name) =>
acceptedRuntimeNames.has(name.toLowerCase())
);
if (importedRuntimeNames.length === 0) {
errors.push(
`Windows frame-copy helper does not import a supported MPV DLL: ${helperPath}`
);
}
const packagedRuntimeNames = new Set(
fs
.readdirSync(unpackedNativeDir, {
withFileTypes: true,
})
.filter((entry) => entry.isFile())
.map((entry) => entry.name.toLowerCase())
);
for (const importedRuntimeName of importedRuntimeNames) {
if (
!packagedRuntimeNames.has(
importedRuntimeName.toLowerCase()
)
) {
errors.push(
`Windows frame-copy helper imports ${importedRuntimeName}, but the matching DLL is missing beside it: ${path.join(unpackedNativeDir, importedRuntimeName)}`
);
}
}
} catch (error) {
errors.push(
`Unable to inspect Windows frame-copy helper imports at ${helperPath}: ${error instanceof Error ? error.message : String(error)}`
);
}
}
}
}
if (!fs.existsSync(manifestPath)) {