fix(embedded-mpv): harden frame-copy helper startup

This commit is contained in:
4gray committed 2026-07-15 19:21:09 +02:00
1 parent a3276b5a3c
commit 5b6b5d8272
19 files changed
+750 -114

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 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 `<canvas data-embedded-mpv-frame>`, 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 `<canvas data-embedded-mpv-frame>`, 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)**:
@@ -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',
@@ -352,6 +352,7 @@ inline void RenderPipeline::runLoop() {
while (true) {
int resizeWidth = 0;
int resizeHeight = 0;
bool targetsRebuilt = false;
{
std::unique_lock<std::mutex> 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. */
@@ -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();
});
});
@@ -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<FrameReaderAddon, 'latestSeq' | 'copyLatest'>,
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<void> {
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);
}
}
+35
View File
@@ -1,4 +1,6 @@
const mockClearStorageData = jest.fn();
const mockIsFrameCopyRuntimeUsable = jest.fn<boolean, []>();
const mockIsEmbeddedMpvFeatureEnabled = jest.fn<boolean, []>();
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);
+9 -5
View File
@@ -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,
@@ -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<string, []>;
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);
});
});
@@ -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 | undefined>): 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;
}
@@ -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', () => {
@@ -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();
@@ -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<NodeJS.Platform>([
'darwin',
@@ -90,12 +98,6 @@ const SUPPORTED_EMBEDDED_MPV_PLATFORMS = new Set<NodeJS.Platform>([
'linux',
]);
function dedupePaths(paths: Array<string | undefined>): 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 {
@@ -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);
}
);
});
@@ -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]);
}
+11 -4
View File
@@ -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';
}
+26 -6
View File
@@ -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
`<canvas data-embedded-mpv-frame>` (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 +
+8 -2
View File
@@ -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
);
}
@@ -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/)
@@ -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,
};