mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-11 11:06:16 -08:00
fix(embedded-mpv): harden frame-copy helper startup
This commit is contained in:
1 parent
a3276b5a3c
commit
5b6b5d8272
19 files changed
+750
-114
No files matched your search
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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 +
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
Reference in new issue
Block a user