diff --git a/CLAUDE.md b/CLAUDE.md index d66d22c8d..a07c612d5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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=` 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 ``, 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 ``, 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)**: diff --git a/apps/electron-backend/native/helper/frame_helper_gl.h b/apps/electron-backend/native/helper/frame_helper_gl.h index 742859a46..ef4ec7a06 100644 --- a/apps/electron-backend/native/helper/frame_helper_gl.h +++ b/apps/electron-backend/native/helper/frame_helper_gl.h @@ -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( - 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 diff --git a/apps/electron-backend/native/helper/frame_shm.h b/apps/electron-backend/native/helper/frame_shm.h index 40ad94a6e..aeb9dd925 100644 --- a/apps/electron-backend/native/helper/frame_shm.h +++ b/apps/electron-backend/native/helper/frame_shm.h @@ -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 + diff --git a/apps/electron-backend/src/app/services/embedded-mpv-native-source.spec.ts b/apps/electron-backend/src/app/services/embedded-mpv-native-source.spec.ts index 0e3fea6d4..09039e268 100644 --- a/apps/electron-backend/src/app/services/embedded-mpv-native-source.spec.ts +++ b/apps/electron-backend/src/app/services/embedded-mpv-native-source.spec.ts @@ -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)' diff --git a/apps/web/src/app/settings/settings-playback-section.component.html b/apps/web/src/app/settings/settings-playback-section.component.html index c038443ce..defb115c5 100644 --- a/apps/web/src/app/settings/settings-playback-section.component.html +++ b/apps/web/src/app/settings/settings-playback-section.component.html @@ -114,10 +114,7 @@ - @if ( - isDesktop() && - (frameCopyAvailable() || form().value.embeddedMpvFrameCopy) - ) { + @if (frameCopyAvailable() || form().value.embeddedMpvFrameCopy) {
} - @if (supportsExternalPlayerPathSettings() && form().value.player === 'mpv') { + @if ( + supportsExternalPlayerPathSettings() && form().value.player === 'mpv' + ) {

{{ 'SETTINGS.MPV_PLAYER_PATH_LABEL' | translate }}

@@ -259,7 +258,9 @@
} - @if (supportsExternalPlayerPathSettings() && form().value.player === 'vlc') { + @if ( + supportsExternalPlayerPathSettings() && form().value.player === 'vlc' + ) {

{{ 'SETTINGS.VLC_PLAYER_PATH_LABEL' | translate }}

diff --git a/apps/web/src/app/settings/settings-playback-section.component.spec.ts b/apps/web/src/app/settings/settings-playback-section.component.spec.ts index c38c68daa..f983212d3 100644 --- a/apps/web/src/app/settings/settings-playback-section.component.spec.ts +++ b/apps/web/src/app/settings/settings-playback-section.component.spec.ts @@ -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); diff --git a/docs/architecture/embedded-mpv-native.md b/docs/architecture/embedded-mpv-native.md index c571de5a3..c38499392 100644 --- a/docs/architecture/embedded-mpv-native.md +++ b/docs/architecture/embedded-mpv-native.md @@ -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= --input-ipc-server=`, 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`. diff --git a/spikes/mpv-frame-copy/PORTING.md b/spikes/mpv-frame-copy/PORTING.md index ef1f8b484..0e0389738 100644 --- a/spikes/mpv-frame-copy/PORTING.md +++ b/spikes/mpv-frame-copy/PORTING.md @@ -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, diff --git a/tools/embedded-mpv/README.md b/tools/embedded-mpv/README.md index 08b3dd9b6..a4807823b 100644 --- a/tools/embedded-mpv/README.md +++ b/tools/embedded-mpv/README.md @@ -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. diff --git a/tools/packaging/electron-package-identity.test.mjs b/tools/packaging/electron-package-identity.test.mjs index 809ff4231..a1b2a7a13 100644 --- a/tools/packaging/electron-package-identity.test.mjs +++ b/tools/packaging/electron-package-identity.test.mjs @@ -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); diff --git a/tools/packaging/embedded-mpv-packaging.cjs b/tools/packaging/embedded-mpv-packaging.cjs index afb376c8e..a17234156 100644 --- a/tools/packaging/embedded-mpv-packaging.cjs +++ b/tools/packaging/embedded-mpv-packaging.cjs @@ -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)) {