From 5b6b5d827240cf352d6f120da43232c5c9758d28 Mon Sep 17 00:00:00 2001 From: 4gray Date: Wed, 15 Jul 2026 18:26:36 +0200 Subject: [PATCH] fix(embedded-mpv): harden frame-copy helper startup --- CLAUDE.md | 2 +- apps/electron-backend/build-embedded-mpv.js | 6 + .../native/helper/frame_helper_render.h | 6 +- .../app/api/embedded-mpv-frame-pump.spec.ts | 40 +++++ .../src/app/api/embedded-mpv-frame-pump.ts | 60 ++++--- apps/electron-backend/src/app/app.spec.ts | 35 ++++ apps/electron-backend/src/app/app.ts | 14 +- ...edded-mpv-frame-copy-platform.util.spec.ts | 167 ++++++++++++++++++ .../embedded-mpv-frame-copy-platform.util.ts | 126 +++++++++++++ .../embedded-mpv-native-source.spec.ts | 21 +++ .../embedded-mpv-native.service.spec.ts | 71 +++++++- .../services/embedded-mpv-native.service.ts | 92 ++-------- .../embedded-mpv-runtime-policy.util.spec.ts | 53 ++++++ .../embedded-mpv-runtime-policy.util.ts | 19 ++ apps/electron-backend/src/main.ts | 15 +- docs/architecture/embedded-mpv-native.md | 32 +++- tools/packaging/electron-after-pack.cjs | 10 +- .../electron-package-identity.test.mjs | 53 ++++++ .../embedded-mpv-frame-copy-files.cjs | 42 +++++ 19 files changed, 750 insertions(+), 114 deletions(-) create mode 100644 apps/electron-backend/src/app/api/embedded-mpv-frame-pump.spec.ts create mode 100644 apps/electron-backend/src/app/services/embedded-mpv-frame-copy-platform.util.spec.ts create mode 100644 apps/electron-backend/src/app/services/embedded-mpv-frame-copy-platform.util.ts create mode 100644 apps/electron-backend/src/app/services/embedded-mpv-runtime-policy.util.spec.ts create mode 100644 apps/electron-backend/src/app/services/embedded-mpv-runtime-policy.util.ts create mode 100644 tools/packaging/embedded-mpv-frame-copy-files.cjs diff --git a/CLAUDE.md b/CLAUDE.md index c8af9c203..e850b571d 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 only; 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 `iptvnator_mpv_helper` process renders mpv offscreen at viewport size and publishes BGRA frames into a shm ring; the preload frame pump uploads them onto a renderer ``, so controls/dialogs are ordinary DOM above the video (no native-surface compositing workarounds; the flag relaxes the window sandbox for the preload's native reader addon). 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 only; 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 `iptvnator_mpv_helper` process renders mpv offscreen at viewport size and publishes BGRA frames into a shm ring; the preload frame pump uploads them onto a renderer ``, so controls/dialogs are ordinary DOM above the video (no native-surface compositing workarounds; the flag relaxes the window sandbox for the preload's native reader addon). Stored and explicit opt-ins relax the sandbox only while the base embedded-MPV feature is enabled and a platform-supported runtime contains both an executable helper 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. 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/build-embedded-mpv.js b/apps/electron-backend/build-embedded-mpv.js index 868a48a5a..ceeac4a70 100644 --- a/apps/electron-backend/build-embedded-mpv.js +++ b/apps/electron-backend/build-embedded-mpv.js @@ -7,6 +7,9 @@ const { patchAddonForBundledRuntime, validateNoForbiddenRuntimeLinks, } = require('../../tools/packaging/embedded-mpv-packaging.cjs'); +const { + removeStaleFrameCopyArtifacts, +} = require('../../tools/packaging/embedded-mpv-frame-copy-files.cjs'); const workspaceRoot = process.cwd(); const addonRoot = path.join( @@ -59,6 +62,9 @@ function log(message) { function cleanOutput() { fs.rmSync(outputFile, { force: true }); fs.rmSync(outputLibDir, { recursive: true, force: true }); + // Do not let an optional/skipped rebuild leave frame-copy artifacts that + // could make startup treat an incomplete runtime as available. + removeStaleFrameCopyArtifacts(outputDir); for (const windowsDllName of [ 'mpv-2.dll', 'libmpv-2.dll', diff --git a/apps/electron-backend/native/helper/frame_helper_render.h b/apps/electron-backend/native/helper/frame_helper_render.h index ecfe9162b..56a93c47b 100644 --- a/apps/electron-backend/native/helper/frame_helper_render.h +++ b/apps/electron-backend/native/helper/frame_helper_render.h @@ -352,6 +352,7 @@ inline void RenderPipeline::runLoop() { while (true) { int resizeWidth = 0; int resizeHeight = 0; + bool targetsRebuilt = false; { std::unique_lock lock(mutex_); cv_.wait_for(lock, std::chrono::milliseconds(100), [&] { @@ -374,10 +375,13 @@ inline void RenderPipeline::runLoop() { .finish()); break; } + targetsRebuilt = true; } const uint64_t flags = mpv_render_context_update(renderContext_); - if (flags & MPV_RENDER_UPDATE_FRAME) { + /* A resize installs an empty shm ring. Redraw the current frame even + * while paused; the async PBO is published on the next loop. */ + if (targetsRebuilt || (flags & MPV_RENDER_UPDATE_FRAME)) { renderFrame(); } else { /* Flush a stranded readback so pause keeps the last frame. */ diff --git a/apps/electron-backend/src/app/api/embedded-mpv-frame-pump.spec.ts b/apps/electron-backend/src/app/api/embedded-mpv-frame-pump.spec.ts new file mode 100644 index 000000000..1aa09fad1 --- /dev/null +++ b/apps/electron-backend/src/app/api/embedded-mpv-frame-pump.spec.ts @@ -0,0 +1,40 @@ +jest.mock('electron', () => ({ + ipcRenderer: { + invoke: jest.fn(), + on: jest.fn(), + }, +})); + +import { copyStableLatestFrame } from './embedded-mpv-frame-pump'; + +describe('embedded MPV frame pump', () => { + const frame = new ArrayBuffer(16); + + it('drops a torn copy without consuming its sequence so the next tick retries', () => { + const reader = { + latestSeq: jest.fn(() => 7), + copyLatest: jest + .fn() + .mockReturnValueOnce({ seq: 7, ageMs: 1, torn: true }) + .mockReturnValueOnce({ seq: 7, ageMs: 1, torn: false }), + }; + + expect(copyStableLatestFrame(reader, frame, 6)).toBeNull(); + expect(copyStableLatestFrame(reader, frame, 6)).toEqual({ + seq: 7, + ageMs: 1, + torn: false, + }); + expect(reader.copyLatest).toHaveBeenCalledTimes(2); + }); + + it('does not copy when the producer has no newer sequence', () => { + const reader = { + latestSeq: jest.fn(() => 6), + copyLatest: jest.fn(), + }; + + expect(copyStableLatestFrame(reader, frame, 6)).toBeNull(); + expect(reader.copyLatest).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/electron-backend/src/app/api/embedded-mpv-frame-pump.ts b/apps/electron-backend/src/app/api/embedded-mpv-frame-pump.ts index 3c0167e9a..a0b66f8fc 100644 --- a/apps/electron-backend/src/app/api/embedded-mpv-frame-pump.ts +++ b/apps/electron-backend/src/app/api/embedded-mpv-frame-pump.ts @@ -24,12 +24,16 @@ interface FrameReaderInfo { generation: number; } +interface FrameCopyResult { + seq: number; + ageMs: number; + torn: boolean; +} + interface FrameReaderAddon { open(shmName: string): FrameReaderInfo; latestSeq(): number; - copyLatest( - buffer: ArrayBuffer - ): { seq: number; ageMs: number; torn: boolean } | null; + copyLatest(buffer: ArrayBuffer): FrameCopyResult | null; producerAliveMs(): number; close(): void; } @@ -90,6 +94,20 @@ let sourceListenerRegistered = false; */ let attachEpoch = 0; +export function copyStableLatestFrame( + reader: Pick, + buffer: ArrayBuffer, + lastSeq: number +): FrameCopyResult | null { + if (reader.latestSeq() <= lastSeq) return null; + + const result = reader.copyLatest(buffer); + // A torn memcpy raced the helper overwriting this ring slot. Keep the + // previous sequence so the next animation tick retries instead of + // uploading partially copied pixels or marking the frame consumed. + return result && !result.torn ? result : null; +} + function delay(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } @@ -196,23 +214,25 @@ function pumpTick(): void { pump.rafHandle = requestAnimationFrame(pumpTick); const { gl, reader, frame } = pump; - if (reader.latestSeq() > pump.lastSeq) { - const result = reader.copyLatest(frame.buffer as ArrayBuffer); - if (result) { - gl.texSubImage2D( - gl.TEXTURE_2D, - 0, - 0, - 0, - pump.width, - pump.height, - gl.RGBA, - gl.UNSIGNED_BYTE, - frame - ); - pump.lastSeq = result.seq; - gl.drawArrays(gl.TRIANGLES, 0, 3); - } + const result = copyStableLatestFrame( + reader, + frame.buffer as ArrayBuffer, + pump.lastSeq + ); + if (result) { + gl.texSubImage2D( + gl.TEXTURE_2D, + 0, + 0, + 0, + pump.width, + pump.height, + gl.RGBA, + gl.UNSIGNED_BYTE, + frame + ); + pump.lastSeq = result.seq; + gl.drawArrays(gl.TRIANGLES, 0, 3); } } diff --git a/apps/electron-backend/src/app/app.spec.ts b/apps/electron-backend/src/app/app.spec.ts index 41484888b..9658f5607 100644 --- a/apps/electron-backend/src/app/app.spec.ts +++ b/apps/electron-backend/src/app/app.spec.ts @@ -1,4 +1,6 @@ const mockClearStorageData = jest.fn(); +const mockIsFrameCopyRuntimeUsable = jest.fn(); +const mockIsEmbeddedMpvFeatureEnabled = jest.fn(); jest.mock('electron', () => ({ app: { @@ -32,6 +34,14 @@ jest.mock('./services/store.service', () => ({ WINDOW_BOUNDS: 'windowBounds', })); +jest.mock('./services/embedded-mpv-frame-copy-platform.util', () => ({ + isFrameCopyRuntimeUsable: mockIsFrameCopyRuntimeUsable, +})); + +jest.mock('./services/embedded-mpv-runtime-policy.util', () => ({ + isEmbeddedMpvFeatureEnabled: mockIsEmbeddedMpvFeatureEnabled, +})); + import { clearElectronServiceWorkerStorage, getMainWindowWebPreferences, @@ -93,6 +103,10 @@ describe('Electron app security helpers', () => { beforeEach(() => { jest.clearAllMocks(); delete process.env.ELECTRON_IS_DEV; + delete process.env.IPTVNATOR_ENABLE_EMBEDDED_MPV_EXPERIMENT; + delete process.env.IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY; + mockIsEmbeddedMpvFeatureEnabled.mockReturnValue(false); + mockIsFrameCopyRuntimeUsable.mockReturnValue(false); const appInternals = getAppInternals(); appInternals.loadedMainWindow = null; appInternals.mainWindow = null; @@ -117,6 +131,27 @@ describe('Electron app security helpers', () => { ); }); + it('keeps the renderer sandboxed when frame-copy is requested without a usable runtime', () => { + process.env.IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY = '1'; + + expect(getMainWindowWebPreferences()?.sandbox).toBe(true); + }); + + it('keeps the renderer sandboxed when frame-copy is requested but embedded MPV is disabled', () => { + process.env.IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY = '1'; + mockIsFrameCopyRuntimeUsable.mockReturnValue(true); + + expect(getMainWindowWebPreferences()?.sandbox).toBe(true); + }); + + it('relaxes the renderer sandbox only when embedded MPV and a usable frame-copy runtime are enabled', () => { + process.env.IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY = '1'; + mockIsEmbeddedMpvFeatureEnabled.mockReturnValue(true); + mockIsFrameCopyRuntimeUsable.mockReturnValue(true); + + expect(getMainWindowWebPreferences()?.sandbox).toBe(false); + }); + it('treats only http and https URLs as external browser URLs', () => { expect(isExternalBrowserUrl('https://example.com')).toBe(true); expect(isExternalBrowserUrl('http://example.com')).toBe(true); diff --git a/apps/electron-backend/src/app/app.ts b/apps/electron-backend/src/app/app.ts index aee1581f9..00d7d940b 100644 --- a/apps/electron-backend/src/app/app.ts +++ b/apps/electron-backend/src/app/app.ts @@ -10,6 +10,8 @@ import { trace, } from './services/debug-trace'; import { store, WINDOW_BOUNDS } from './services/store.service'; +import { isFrameCopyRuntimeUsable } from './services/embedded-mpv-frame-copy-platform.util'; +import { isEmbeddedMpvFeatureEnabled } from './services/embedded-mpv-runtime-policy.util'; const externalBrowserProtocols = new Set(['http:', 'https:']); const trustedDevRendererHosts = new Set([ @@ -84,11 +86,13 @@ export function getMainWindowWebPreferences(): Electron.BrowserWindowConstructor // forbids. Only that opt-in flag relaxes the sandbox; context isolation // and nodeIntegration:false stay on either way, so page code never // gains Node access. Revisit before the engine can become a default. - const frameCopyExperiment = ['1', 'true', 'yes', 'on'].includes( - (process.env.IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY ?? '') - .trim() - .toLowerCase() - ); + const frameCopyExperiment = + isEmbeddedMpvFeatureEnabled() && + ['1', 'true', 'yes', 'on'].includes( + (process.env.IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY ?? '') + .trim() + .toLowerCase() + ) && isFrameCopyRuntimeUsable(); return { contextIsolation: true, nodeIntegration: false, diff --git a/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-platform.util.spec.ts b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-platform.util.spec.ts new file mode 100644 index 000000000..7638d9424 --- /dev/null +++ b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-platform.util.spec.ts @@ -0,0 +1,167 @@ +import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'fs'; +import { tmpdir } from 'os'; +import path from 'path'; + +const mockElectronApp = { + isPackaged: false, + getAppPath: jest.fn(() => ''), +}; + +jest.mock('electron', () => ({ app: mockElectronApp })); + +import { + isFrameCopyPlatformSupported, + resolveFrameCopyHelperPath, +} from './embedded-mpv-frame-copy-platform.util'; +import * as frameCopyPlatform from './embedded-mpv-frame-copy-platform.util'; + +describe('embedded-mpv-frame-copy-platform.util', () => { + describe('isFrameCopyPlatformSupported', () => { + const originalPlatform = process.platform; + const originalArch = process.arch; + + afterEach(() => { + Object.defineProperty(process, 'platform', { + value: originalPlatform, + }); + Object.defineProperty(process, 'arch', { value: originalArch }); + }); + + it.each<[NodeJS.Platform, string, boolean]>([ + ['darwin', 'arm64', true], + ['darwin', 'x64', false], + ['linux', 'x64', false], + ['win32', 'x64', false], + ])('%s/%s -> %s', (platform, arch, expected) => { + Object.defineProperty(process, 'platform', { value: platform }); + Object.defineProperty(process, 'arch', { value: arch }); + expect(isFrameCopyPlatformSupported()).toBe(expected); + }); + }); + + describe('resolveFrameCopyHelperPath', () => { + let tempDir: string; + let cwdSpy: jest.SpyInstance; + let originalResourcesPath: string | undefined; + + const releaseDir = () => + path.join( + tempDir, + 'apps', + 'electron-backend', + 'native', + 'build', + 'Release' + ); + const helperPath = () => + path.join(releaseDir(), 'iptvnator_mpv_helper'); + const readerPath = () => + path.join(releaseDir(), 'embedded_mpv_frame_reader.node'); + + beforeEach(() => { + tempDir = mkdtempSync(path.join(tmpdir(), 'impv-fc-util-')); + mkdirSync(releaseDir(), { recursive: true }); + cwdSpy = jest.spyOn(process, 'cwd').mockReturnValue(tempDir); + mockElectronApp.isPackaged = false; + mockElectronApp.getAppPath.mockReturnValue(''); + originalResourcesPath = ( + process as NodeJS.Process & { resourcesPath?: string } + ).resourcesPath; + }); + + afterEach(() => { + cwdSpy.mockRestore(); + Object.defineProperty(process, 'resourcesPath', { + configurable: true, + value: originalResourcesPath, + }); + rmSync(tempDir, { recursive: true, force: true }); + }); + + it('returns null when no helper exists', () => { + expect(resolveFrameCopyHelperPath()).toBeNull(); + }); + + it('returns an executable helper next to the local-build addon path', () => { + writeFileSync(helperPath(), '#!/bin/sh\n'); + chmodSync(helperPath(), 0o755); + writeFileSync(readerPath(), 'reader'); + + expect(resolveFrameCopyHelperPath()).toBe(helperPath()); + }); + + it('ignores an executable helper without its frame reader addon', () => { + writeFileSync(helperPath(), '#!/bin/sh\n'); + chmodSync(helperPath(), 0o755); + + expect(resolveFrameCopyHelperPath()).toBeNull(); + }); + + (process.platform === 'win32' ? it.skip : it)( + 'ignores a helper without the execute bit', + () => { + writeFileSync(helperPath(), '#!/bin/sh\n'); + chmodSync(helperPath(), 0o644); + writeFileSync(readerPath(), 'reader'); + + expect(resolveFrameCopyHelperPath()).toBeNull(); + } + ); + + it('does not fall back to cwd build artifacts in a packaged app', () => { + writeFileSync(helperPath(), '#!/bin/sh\n'); + chmodSync(helperPath(), 0o755); + writeFileSync(readerPath(), 'reader'); + mockElectronApp.isPackaged = true; + const resourcesPath = path.join(tempDir, 'IPTVnator', 'Resources'); + Object.defineProperty(process, 'resourcesPath', { + configurable: true, + value: resourcesPath, + }); + mockElectronApp.getAppPath.mockReturnValue( + path.join(resourcesPath, 'app.asar') + ); + + expect(resolveFrameCopyHelperPath()).toBeNull(); + + const packagedNativeDir = path.join( + resourcesPath, + 'app.asar.unpacked', + 'electron-backend', + 'native' + ); + mkdirSync(packagedNativeDir, { recursive: true }); + const packagedHelper = path.join( + packagedNativeDir, + 'iptvnator_mpv_helper' + ); + writeFileSync(packagedHelper, '#!/bin/sh\n'); + chmodSync(packagedHelper, 0o755); + writeFileSync( + path.join(packagedNativeDir, 'embedded_mpv_frame_reader.node'), + 'reader' + ); + + expect(resolveFrameCopyHelperPath()).toBe(packagedHelper); + }); + }); + + it('promotes a stored opt-in only without an explicit env override and with a usable runtime', () => { + const shouldPromote = ( + frameCopyPlatform as typeof frameCopyPlatform & { + shouldPromotePersistedFrameCopyOptIn?: ( + storedEnabled: boolean, + explicitEnv: string | undefined, + runtimeUsable: boolean + ) => boolean; + } + ).shouldPromotePersistedFrameCopyOptIn; + + expect(shouldPromote).toBeDefined(); + expect(shouldPromote?.(true, undefined, true)).toBe(true); + expect(shouldPromote?.(true, undefined, false)).toBe(false); + expect(shouldPromote?.(true, '0', true)).toBe(false); + expect(shouldPromote?.(true, '1', true)).toBe(false); + expect(shouldPromote?.(false, undefined, true)).toBe(false); + }); +}); diff --git a/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-platform.util.ts b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-platform.util.ts new file mode 100644 index 000000000..037f3c4d4 --- /dev/null +++ b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-platform.util.ts @@ -0,0 +1,126 @@ +import { app } from 'electron'; +import { accessSync, constants as fsConstants, statSync } from 'fs'; +import path from 'path'; + +/** + * Platform gate + helper discovery for the embedded MPV frame-copy engine, + * shared by startup and the runtime service so sandbox and engine decisions + * cannot drift. These helpers must remain callable before app.whenReady(). + */ +export function isFrameCopyPlatformSupported(): boolean { + return process.platform === 'darwin' && process.arch === 'arm64'; +} + +function dedupeDefinedPaths(paths: Array): string[] { + return [ + ...new Set(paths.filter((value): value is string => Boolean(value))), + ]; +} + +function getLocalBuildAddonPath(): string { + return path.resolve( + process.cwd(), + 'apps/electron-backend/native/build/Release/embedded_mpv.node' + ); +} + +function getDistAddonPaths(): string[] { + return [ + path.resolve(__dirname, 'native/embedded_mpv.node'), + path.resolve(__dirname, '../../native/embedded_mpv.node'), + ]; +} + +function getPackagedAddonPaths(): string[] { + const resourcesPath = ( + process as NodeJS.Process & { resourcesPath?: string } + ).resourcesPath; + return dedupeDefinedPaths([ + resourcesPath + ? path.resolve( + resourcesPath, + 'app.asar.unpacked', + 'electron-backend', + 'native', + 'embedded_mpv.node' + ) + : undefined, + app.getAppPath() + ? path.join( + path.dirname(app.getAppPath()), + 'app.asar.unpacked', + 'electron-backend', + 'native', + 'embedded_mpv.node' + ) + : undefined, + ]); +} + +/** Candidate locations of the embedded MPV addon, most likely first. */ +export function getEmbeddedMpvAddonCandidatePaths(): string[] { + const localBuildAddonPath = getLocalBuildAddonPath(); + const distAddonPaths = getDistAddonPaths(); + const packagedAddonPaths = getPackagedAddonPaths(); + + return dedupeDefinedPaths( + app.isPackaged + ? [...packagedAddonPaths, ...distAddonPaths, localBuildAddonPath] + : [localBuildAddonPath, ...distAddonPaths, ...packagedAddonPaths] + ); +} + +/** + * Resolve the first executable frame-copy helper with a readable regular-file + * frame reader beside it. Both artifacts are required before the engine may + * relax the renderer sandbox. + */ +export function resolveFrameCopyHelperPath(): string | null { + // Packaged startup must never fall through to cwd/dist development + // artifacts: doing so would let a writable launch directory supply the + // executable that causes BrowserWindow sandbox relaxation. + const addonCandidates = app.isPackaged + ? getPackagedAddonPaths() + : [getLocalBuildAddonPath(), ...getDistAddonPaths()]; + + return ( + addonCandidates + .map((candidatePath) => path.dirname(candidatePath)) + .map((nativeDir) => ({ + helper: path.join(nativeDir, 'iptvnator_mpv_helper'), + reader: path.join( + nativeDir, + 'embedded_mpv_frame_reader.node' + ), + })) + .find(({ helper, reader }) => { + try { + accessSync(helper, fsConstants.X_OK); + accessSync(reader, fsConstants.R_OK); + if ( + !statSync(helper).isFile() || + !statSync(reader).isFile() + ) { + return false; + } + return true; + } catch { + return false; + } + })?.helper ?? null + ); +} + +export function isFrameCopyRuntimeUsable( + resolveHelper: () => string | null = resolveFrameCopyHelperPath +): boolean { + return isFrameCopyPlatformSupported() && resolveHelper() !== null; +} + +export function shouldPromotePersistedFrameCopyOptIn( + storedEnabled: boolean, + explicitEnv: string | undefined, + runtimeUsable = isFrameCopyRuntimeUsable() +): boolean { + return explicitEnv === undefined && storedEnabled && runtimeUsable; +} 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 a2bea976f..064c9ff07 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 @@ -39,6 +39,13 @@ describe('Embedded MPV native source recording invariants', () => { ), 'utf8' ); + const frameHelperRenderSource = readFileSync( + path.resolve( + __dirname, + '../../../native/helper/frame_helper_render.h' + ), + 'utf8' + ); function functionBody(name: string): string { return sourceFunctionBody(nativeSource, `Napi::Value ${name}(`, name); @@ -599,6 +606,20 @@ describe('Embedded MPV native source recording invariants', () => { expect(buildScriptSource).toContain('writeLinuxProcessRuntimeManifest'); expect(buildScriptSource).toContain('runtimeFiles: []'); }); + + it('forces the current frame into a rebuilt shm generation after a paused resize', () => { + const runLoop = sourceFunctionBody( + frameHelperRenderSource, + 'inline void RenderPipeline::runLoop()', + 'RenderPipeline::runLoop' + ); + + expect(runLoop).toContain('bool targetsRebuilt = false;'); + expect(runLoop).toContain('targetsRebuilt = true;'); + expect(runLoop).toContain( + 'if (targetsRebuilt || (flags & MPV_RENDER_UPDATE_FRAME))' + ); + }); }); describe('Embedded MPV native build configuration', () => { diff --git a/apps/electron-backend/src/app/services/embedded-mpv-native.service.spec.ts b/apps/electron-backend/src/app/services/embedded-mpv-native.service.spec.ts index 9e5fcf582..5549d175c 100644 --- a/apps/electron-backend/src/app/services/embedded-mpv-native.service.spec.ts +++ b/apps/electron-backend/src/app/services/embedded-mpv-native.service.spec.ts @@ -3,7 +3,14 @@ import type { EmbeddedMpvSessionStatus, ResolvedPortalPlayback, } from '@iptvnator/shared/interfaces'; -import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'fs'; +import { + chmodSync, + existsSync, + mkdirSync, + mkdtempSync, + rmSync, + writeFileSync, +} from 'fs'; import { tmpdir } from 'os'; import path from 'path'; import type { EmbeddedMpvNativeService as EmbeddedMpvNativeServiceType } from './embedded-mpv-native.service'; @@ -113,7 +120,9 @@ describe('EmbeddedMpvNativeService power blocker', () => { let addon: MockAddon; let nextBlockerId: number; let originalPlatform: NodeJS.Platform; + let originalArch: string; let originalDisplay: string | undefined; + let originalExperiment: string | undefined; let originalOzonePlatformHint: string | undefined; let originalWaylandDisplay: string | undefined; let tempDirs: string[]; @@ -133,16 +142,21 @@ describe('EmbeddedMpvNativeService power blocker', () => { mainWindowGetNativeWindowHandleMock.mockReturnValue(Buffer.alloc(8)); mainWindowSendMock.mockReset(); mainWindowWebContentsOnMock.mockReset(); + appMock.isPackaged = true; tempDirs = []; nextBlockerId = 1; powerSaveBlockerMock.start.mockImplementation(() => nextBlockerId++); powerSaveBlockerMock.isStarted.mockReturnValue(true); originalPlatform = process.platform; + originalArch = process.arch; Object.defineProperty(process, 'platform', { value: 'darwin', }); + Object.defineProperty(process, 'arch', { value: 'arm64' }); originalDisplay = process.env.DISPLAY; + originalExperiment = + process.env.IPTVNATOR_ENABLE_EMBEDDED_MPV_EXPERIMENT; originalOzonePlatformHint = process.env.ELECTRON_OZONE_PLATFORM_HINT; originalWaylandDisplay = process.env.WAYLAND_DISPLAY; @@ -164,7 +178,12 @@ describe('EmbeddedMpvNativeService power blocker', () => { Object.defineProperty(process, 'platform', { value: originalPlatform, }); + Object.defineProperty(process, 'arch', { value: originalArch }); restoreEnv('DISPLAY', originalDisplay); + restoreEnv( + 'IPTVNATOR_ENABLE_EMBEDDED_MPV_EXPERIMENT', + originalExperiment + ); restoreEnv('ELECTRON_OZONE_PLATFORM_HINT', originalOzonePlatformHint); restoreEnv('WAYLAND_DISPLAY', originalWaylandDisplay); }); @@ -228,6 +247,56 @@ describe('EmbeddedMpvNativeService power blocker', () => { } }); + it('requires the base embedded-MPV opt-in for unpackaged runs', () => { + appMock.isPackaged = false; + delete process.env.IPTVNATOR_ENABLE_EMBEDDED_MPV_EXPERIMENT; + + expect(service.getSupport()).toEqual( + expect.objectContaining({ supported: false }) + ); + + process.env.IPTVNATOR_ENABLE_EMBEDDED_MPV_EXPERIMENT = '1'; + expect(service.getSupport()).toEqual( + expect.objectContaining({ supported: true }) + ); + }); + + (process.platform === 'win32' ? it.skip : it)( + 'falls back to the native engine when the frame-copy helper is not executable', + () => { + const tempDir = createTempDir(); + const releaseDir = path.join( + tempDir, + 'apps', + 'electron-backend', + 'native', + 'build', + 'Release' + ); + const helperPath = path.join( + releaseDir, + 'iptvnator_mpv_helper' + ); + mkdirSync(releaseDir, { recursive: true }); + writeFileSync(helperPath, '#!/bin/sh\n'); + chmodSync(helperPath, 0o644); + writeFileSync( + path.join(releaseDir, 'embedded_mpv_frame_reader.node'), + 'reader' + ); + const cwdSpy = jest.spyOn(process, 'cwd').mockReturnValue(tempDir); + process.env.IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY = '1'; + + try { + expect(service.getActiveEngine()).toBe('native'); + expect(service.isFrameCopyAvailable()).toBe(false); + } finally { + delete process.env.IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY; + cwdSpy.mockRestore(); + } + } + ); + it('does not acquire a blocker for a loading session', () => { startSession('s1', snapshot('loading')); expect(powerSaveBlockerMock.start).not.toHaveBeenCalled(); diff --git a/apps/electron-backend/src/app/services/embedded-mpv-native.service.ts b/apps/electron-backend/src/app/services/embedded-mpv-native.service.ts index 9b57f1352..1ee864e21 100644 --- a/apps/electron-backend/src/app/services/embedded-mpv-native.service.ts +++ b/apps/electron-backend/src/app/services/embedded-mpv-native.service.ts @@ -28,6 +28,15 @@ import { ResolvedPortalPlayback, } from '@iptvnator/shared/interfaces'; import { EmbeddedMpvFrameCopyAdapter } from './embedded-mpv-frame-copy.adapter'; +import { + getEmbeddedMpvAddonCandidatePaths, + isFrameCopyRuntimeUsable, + resolveFrameCopyHelperPath, +} from './embedded-mpv-frame-copy-platform.util'; +import { + EMBEDDED_MPV_EXPERIMENT_ENV, + isEmbeddedMpvFeatureEnabled, +} from './embedded-mpv-runtime-policy.util'; export interface NativeEmbeddedMpvSessionSnapshot { status: EmbeddedMpvSessionStatus; @@ -82,7 +91,6 @@ interface EmbeddedMpvRuntimeSession { lastStatus: EmbeddedMpvSessionStatus | null; } -const EMBEDDED_MPV_EXPERIMENT_ENV = 'IPTVNATOR_ENABLE_EMBEDDED_MPV_EXPERIMENT'; const EMBEDDED_MPV_FRAME_COPY_ENV = 'IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY'; const SUPPORTED_EMBEDDED_MPV_PLATFORMS = new Set([ 'darwin', @@ -90,12 +98,6 @@ const SUPPORTED_EMBEDDED_MPV_PLATFORMS = new Set([ 'linux', ]); -function dedupePaths(paths: Array): string[] { - return [ - ...new Set(paths.filter((value): value is string => Boolean(value))), - ]; -} - export class EmbeddedMpvNativeService { private addon: NativeEmbeddedMpvAddon | null = null; private addonLoadError: Error | null = null; @@ -127,9 +129,7 @@ export class EmbeddedMpvNativeService { // of leaving embedded MPV unsupported with no way to recover. return ( this.isFrameCopyEngineRequested() && - process.platform === 'darwin' && - process.arch === 'arm64' && - this.resolveFrameCopyHelperPath() !== null + isFrameCopyRuntimeUsable(() => this.resolveFrameCopyHelperPath()) ); } @@ -138,10 +138,8 @@ export class EmbeddedMpvNativeService { } isFrameCopyAvailable(): boolean { - return ( - process.platform === 'darwin' && - process.arch === 'arm64' && - this.resolveFrameCopyHelperPath() !== null + return isFrameCopyRuntimeUsable(() => + this.resolveFrameCopyHelperPath() ); } @@ -169,11 +167,9 @@ export class EmbeddedMpvNativeService { } private resolveFrameCopyHelperPath(): string | null { - const candidates = this.getAddonCandidatePaths().map( - (candidatePath) => - path.join(path.dirname(candidatePath), 'iptvnator_mpv_helper') - ); - return candidates.find((candidate) => existsSync(candidate)) ?? null; + // Shared with the startup sandbox gate; kept as an instance method so + // service tests can stub helper discovery per scenario. + return resolveFrameCopyHelperPath(); } private getMainWindowScaleFactor(): number { @@ -227,7 +223,7 @@ export class EmbeddedMpvNativeService { }; } - if (!this.isEmbeddedMpvEnabled()) { + if (!isEmbeddedMpvFeatureEnabled()) { return { supported: false, platform: process.platform, @@ -926,20 +922,8 @@ export class EmbeddedMpvNativeService { return `${parts[0]}${parts[1]}${parts[2]}-${parts[3]}${parts[4]}${parts[5]}`; } - private isExperimentEnabled(): boolean { - return ['1', 'true', 'yes', 'on'].includes( - (process.env[EMBEDDED_MPV_EXPERIMENT_ENV] ?? '') - .trim() - .toLowerCase() - ); - } - - private isEmbeddedMpvEnabled(): boolean { - return app.isPackaged || this.isExperimentEnabled(); - } - private assertEmbeddedMpvEnabled(): void { - if (!this.isEmbeddedMpvEnabled()) { + if (!isEmbeddedMpvFeatureEnabled()) { throw new Error( `Embedded MPV is disabled. Set ${EMBEDDED_MPV_EXPERIMENT_ENV}=1 to enable the local desktop harness, or use a packaged build with the bundled runtime.` ); @@ -1066,47 +1050,7 @@ export class EmbeddedMpvNativeService { } private getAddonCandidatePaths(): string[] { - const localBuildAddonPath = path.resolve( - process.cwd(), - 'apps/electron-backend/native/build/Release/embedded_mpv.node' - ); - const distAddonPaths = [ - path.resolve(__dirname, 'native/embedded_mpv.node'), - path.resolve(__dirname, '../../native/embedded_mpv.node'), - ]; - const packagedAddonPaths = [ - path.resolve( - (process as NodeJS.Process & { resourcesPath?: string }) - .resourcesPath ?? '', - 'app.asar.unpacked', - 'electron-backend', - 'native', - 'embedded_mpv.node' - ), - app.getAppPath() - ? path.join( - path.dirname(app.getAppPath()), - 'app.asar.unpacked', - 'electron-backend', - 'native', - 'embedded_mpv.node' - ) - : undefined, - ]; - - return dedupePaths( - app.isPackaged - ? [ - ...packagedAddonPaths, - ...distAddonPaths, - localBuildAddonPath, - ] - : [ - localBuildAddonPath, - ...distAddonPaths, - ...packagedAddonPaths, - ] - ); + return getEmbeddedMpvAddonCandidatePaths(); } private readUnavailableReason(candidatePaths: string[]): string | null { diff --git a/apps/electron-backend/src/app/services/embedded-mpv-runtime-policy.util.spec.ts b/apps/electron-backend/src/app/services/embedded-mpv-runtime-policy.util.spec.ts new file mode 100644 index 000000000..4b672dbe3 --- /dev/null +++ b/apps/electron-backend/src/app/services/embedded-mpv-runtime-policy.util.spec.ts @@ -0,0 +1,53 @@ +const mockElectronApp = { + isPackaged: false, +}; + +jest.mock('electron', () => ({ app: mockElectronApp })); + +import { isEmbeddedMpvFeatureEnabled } from './embedded-mpv-runtime-policy.util'; + +describe('embedded-mpv-runtime-policy.util', () => { + const originalExperiment = + process.env.IPTVNATOR_ENABLE_EMBEDDED_MPV_EXPERIMENT; + + afterEach(() => { + mockElectronApp.isPackaged = false; + if (originalExperiment === undefined) { + delete process.env.IPTVNATOR_ENABLE_EMBEDDED_MPV_EXPERIMENT; + } else { + process.env.IPTVNATOR_ENABLE_EMBEDDED_MPV_EXPERIMENT = + originalExperiment; + } + }); + + it('enables embedded MPV for packaged apps', () => { + mockElectronApp.isPackaged = true; + delete process.env.IPTVNATOR_ENABLE_EMBEDDED_MPV_EXPERIMENT; + + expect(isEmbeddedMpvFeatureEnabled()).toBe(true); + }); + + it.each(['1', 'true', 'yes', 'on'])( + 'enables embedded MPV for a truthy development opt-in: %s', + (value) => { + mockElectronApp.isPackaged = false; + process.env.IPTVNATOR_ENABLE_EMBEDDED_MPV_EXPERIMENT = value; + + expect(isEmbeddedMpvFeatureEnabled()).toBe(true); + } + ); + + it.each([undefined, '0', 'false', 'off'])( + 'keeps embedded MPV disabled for an unpackaged run with opt-in %s', + (value) => { + mockElectronApp.isPackaged = false; + if (value === undefined) { + delete process.env.IPTVNATOR_ENABLE_EMBEDDED_MPV_EXPERIMENT; + } else { + process.env.IPTVNATOR_ENABLE_EMBEDDED_MPV_EXPERIMENT = value; + } + + expect(isEmbeddedMpvFeatureEnabled()).toBe(false); + } + ); +}); diff --git a/apps/electron-backend/src/app/services/embedded-mpv-runtime-policy.util.ts b/apps/electron-backend/src/app/services/embedded-mpv-runtime-policy.util.ts new file mode 100644 index 000000000..d7753f71d --- /dev/null +++ b/apps/electron-backend/src/app/services/embedded-mpv-runtime-policy.util.ts @@ -0,0 +1,19 @@ +import { app } from 'electron'; + +export const EMBEDDED_MPV_EXPERIMENT_ENV = + 'IPTVNATOR_ENABLE_EMBEDDED_MPV_EXPERIMENT'; + +function isTruthy(value: string | undefined): boolean { + return ['1', 'true', 'yes', 'on'].includes( + (value ?? '').trim().toLowerCase() + ); +} + +/** + * Packaged desktop builds enable embedded MPV by default. Unpackaged runs + * require the regular experiment opt-in before any embedded-MPV engine may + * start or relax BrowserWindow security settings. + */ +export function isEmbeddedMpvFeatureEnabled(): boolean { + return app.isPackaged || isTruthy(process.env[EMBEDDED_MPV_EXPERIMENT_ENV]); +} diff --git a/apps/electron-backend/src/main.ts b/apps/electron-backend/src/main.ts index 1d1787805..0e99bd6ee 100644 --- a/apps/electron-backend/src/main.ts +++ b/apps/electron-backend/src/main.ts @@ -31,6 +31,11 @@ import { databaseWorkerClient } from './app/services/database-worker-client'; import WindowEvents from './app/events/window.events'; import XtreamEvents from './app/events/xtream.events'; import { environment } from './environments/environment'; +import { + isFrameCopyRuntimeUsable, + shouldPromotePersistedFrameCopyOptIn, +} from './app/services/embedded-mpv-frame-copy-platform.util'; +import { isEmbeddedMpvFeatureEnabled } from './app/services/embedded-mpv-runtime-policy.util'; import { EMBEDDED_MPV_FRAME_COPY, store, @@ -65,10 +70,12 @@ if (electronUserDataPath) { // to the main-process config store; an explicitly set env var (including // '0') always wins so dev/CI behavior stays scriptable. if ( - process.platform === 'darwin' && - process.arch === 'arm64' && - process.env.IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY === undefined && - store.get(EMBEDDED_MPV_FRAME_COPY, false) + isEmbeddedMpvFeatureEnabled() && + shouldPromotePersistedFrameCopyOptIn( + store.get(EMBEDDED_MPV_FRAME_COPY, false), + process.env.IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY, + isFrameCopyRuntimeUsable() + ) ) { process.env.IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY = '1'; } diff --git a/docs/architecture/embedded-mpv-native.md b/docs/architecture/embedded-mpv-native.md index ee8ab73d7..1fa818410 100644 --- a/docs/architecture/embedded-mpv-native.md +++ b/docs/architecture/embedded-mpv-native.md @@ -124,21 +124,36 @@ engine that replaces the native-view compositing entirely: the newest complete frame into a reused ArrayBuffer once per rAF and upload it to a WebGL2 texture on the renderer's `` (BGRA swizzle in the shader). Frame - data never crosses the contextBridge; the bridge only exposes + copies whose post-copy seqlock check reports a writer race are discarded + without advancing the consumed sequence, so the next rAF retries instead + of uploading partial pixels. Frame data never crosses the contextBridge; + the bridge only exposes `attachEmbeddedMpvFrameView`/`detachEmbeddedMpvFrameView`. - Renderer: `EmbeddedMpvPlayerComponent` renders the canvas when `support.engine === 'frame-copy'` and skips the compositor workarounds — no `HIDDEN_BOUNDS` when dialogs open, no popover bottom cutout; dialogs and controls stack above the canvas as ordinary DOM. Bounds sync still runs: the helper re-renders at the new viewport size (device pixels via - the display scale factor). + the display scale factor), including a forced current-frame render when a + paused resize creates a fresh shared-memory generation. Enabling it: the `Settings > Playback > Embedded MPV: frame-copy engine` checkbox (shown only when support reports `frameCopyAvailable`) persists to the main-process config store (`electron-conf`), which `main.ts` reads before creating the window and translates into the env flag; an explicitly -set env var (including `0`) always wins. Changing the toggle requires an -app restart because the sandbox relaxation is fixed at window creation. +set env var (including `0`) wins over the stored preference, but cannot bypass +the platform/runtime safety gate. Frame-copy can relax the window sandbox only +when embedded MPV itself is enabled for the current run (packaged app or the +regular development experiment flag) and discovery finds both an executable +(`X_OK`) helper and a readable regular frame-reader addon in the same native +directory. Packaged discovery is limited to packaged resource locations and +never falls through to writable cwd/dist development paths. A disabled base +experiment keeps the renderer sandbox enabled and embedded MPV unavailable. +When the base feature is enabled, a missing, mode-stripped, or incomplete +frame-copy runtime keeps the sandbox enabled and falls back to the native +engine. +Changing the toggle requires an app restart because web preferences are fixed +at window creation. Rendering size: the helper renders at the **aspect-fit** size of the video (observed `dwidth`/`dheight`) inside the requested viewport and bumps a shm @@ -156,11 +171,16 @@ without the watch helper processes (or native mpv handles) would leak until app shutdown. Unexpected helper exits surface as a session `error`. macOS package validation requires `iptvnator_mpv_helper` and `embedded_mpv_frame_reader.node` next to the addon whenever the addon -ships. +ships. The after-pack hook restores the helper's executable mode after the +asset copy, and optional/skipped native rebuilds remove stale helper/reader +artifacts before reporting frame-copy availability. This cleanup prevents +known leftover build output; it is not a compatibility check for a complete +but version-mismatched runtime pair. Trade-offs and constraints: -- The experiment flag relaxes the BrowserWindow sandbox (preload must +- The frame-copy experiment flag can relax the BrowserWindow sandbox only + while the base embedded-MPV feature is enabled (preload must `require` the reader addon); `contextIsolation` and `nodeIntegration:false` stay on. The sandbox story must be revisited before this engine can become a default — candidates: utilityProcess + diff --git a/tools/packaging/electron-after-pack.cjs b/tools/packaging/electron-after-pack.cjs index 0fe49457e..e86f23667 100644 --- a/tools/packaging/electron-after-pack.cjs +++ b/tools/packaging/electron-after-pack.cjs @@ -6,6 +6,9 @@ const { } = require('./embedded-mpv-packaging.cjs'); const fs = require('fs'); const path = require('path'); +const { + preparePackagedFrameCopyArtifacts, +} = require('./embedded-mpv-frame-copy-files.cjs'); function log(message) { console.log(` - ${message}`); @@ -19,7 +22,7 @@ function isTruthy(value) { ); } -function copyEmbeddedMpvNativeOutput(resourceDir, projectDir) { +function copyEmbeddedMpvNativeOutput(resourceDir, projectDir, platform) { const sourceDir = path.join( projectDir, 'dist', @@ -41,6 +44,8 @@ function copyEmbeddedMpvNativeOutput(resourceDir, projectDir) { fs.rmSync(destinationDir, { recursive: true, force: true }); fs.cpSync(sourceDir, destinationDir, { recursive: true }); + + preparePackagedFrameCopyArtifacts(destinationDir, platform); } function writeEmbeddedMpvUnavailableMarker(resourceDir, targetArch) { @@ -85,7 +90,8 @@ async function afterPackHook(params) { } else { copyEmbeddedMpvNativeOutput( resourceDir, - params.packager.projectDir ?? process.cwd() + params.packager.projectDir ?? process.cwd(), + params.electronPlatformName ); } diff --git a/tools/packaging/electron-package-identity.test.mjs b/tools/packaging/electron-package-identity.test.mjs index fc77f6b15..2ffda5fa5 100644 --- a/tools/packaging/electron-package-identity.test.mjs +++ b/tools/packaging/electron-package-identity.test.mjs @@ -60,6 +60,10 @@ const electronAfterPackSource = fs.readFileSync( join(currentDir, 'electron-after-pack.cjs'), 'utf8' ); +const frameCopyFilesModulePath = join( + currentDir, + 'embedded-mpv-frame-copy-files.cjs' +); const embeddedMpvPackagingSource = fs.readFileSync( join(currentDir, 'embedded-mpv-packaging.cjs'), 'utf8' @@ -455,6 +459,55 @@ test('embedded MPV packaging helpers use a cross-platform module name', () => { ); }); +test('frame-copy packaging file operations enforce modes and remove stale artifacts', () => { + assert.ok( + fs.existsSync(frameCopyFilesModulePath), + 'shared frame-copy packaging file helper must exist' + ); + const { + preparePackagedFrameCopyArtifacts, + removeStaleFrameCopyArtifacts, + } = require(frameCopyFilesModulePath); + const tempDir = fs.mkdtempSync(join(os.tmpdir(), 'impv-fc-files-')); + + try { + const helperPath = join(tempDir, 'iptvnator_mpv_helper'); + 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); + fs.writeFileSync(windowsHelperPath, 'exe'); + fs.writeFileSync(readerPath, 'reader'); + + if (process.platform !== 'win32') { + preparePackagedFrameCopyArtifacts(tempDir, 'darwin'); + assert.notEqual( + fs.statSync(helperPath).mode & 0o111, + 0, + 'macOS helper must be executable after packaging' + ); + } + + preparePackagedFrameCopyArtifacts(tempDir, 'linux'); + assert.equal( + fs.existsSync(helperPath), + false, + 'Linux packages must omit the unsupported frame-copy helper' + ); + + fs.writeFileSync(helperPath, '#!/bin/sh\n'); + removeStaleFrameCopyArtifacts(tempDir); + assert.equal(fs.existsSync(helperPath), false); + assert.equal(fs.existsSync(windowsHelperPath), false); + assert.equal(fs.existsSync(readerPath), false); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +}); + test('Windows CI packages embedded MPV from a staged x64 runtime', () => { const requireEmbeddedMpvLines = buildAndMakeWorkflow .split(/\r?\n/) diff --git a/tools/packaging/embedded-mpv-frame-copy-files.cjs b/tools/packaging/embedded-mpv-frame-copy-files.cjs new file mode 100644 index 000000000..0e7d6ed7d --- /dev/null +++ b/tools/packaging/embedded-mpv-frame-copy-files.cjs @@ -0,0 +1,42 @@ +const fs = require('fs'); +const path = require('path'); + +const FRAME_COPY_HELPER = 'iptvnator_mpv_helper'; +const WINDOWS_FRAME_COPY_HELPER = 'iptvnator_mpv_helper.exe'; +const FRAME_COPY_READER = 'embedded_mpv_frame_reader.node'; + +function preparePackagedFrameCopyArtifacts(nativeDir, platform) { + const helperPath = path.join( + nativeDir, + platform === 'win32' + ? WINDOWS_FRAME_COPY_HELPER + : 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); + } +} + +function removeStaleFrameCopyArtifacts(nativeDir) { + for (const fileName of [ + FRAME_COPY_HELPER, + WINDOWS_FRAME_COPY_HELPER, + FRAME_COPY_READER, + ]) { + fs.rmSync(path.join(nativeDir, fileName), { force: true }); + } +} + +module.exports = { + preparePackagedFrameCopyArtifacts, + removeStaleFrameCopyArtifacts, +};