feat(embedded-mpv): probe Linux frame-copy runtime

This commit is contained in:
4gray committed 2026-07-17 22:42:36 +02:00
1 parent 9bc8d74f35
commit 456ba1574b
11 files changed
+1963 -98

No files matched your search

@@ -188,6 +188,7 @@ public:
GlGetProcAddressFn procLoader() const { return glDlsymGetProcAddress; }
void* procLoaderCtx() const { return glDylib_; }
const char* renderApiName() const { return "cgl"; }
private:
CGLContextObj cgl_ = nullptr;
@@ -337,6 +338,7 @@ public:
GlGetProcAddressFn procLoader() const { return eglWrapGetProcAddress; }
void* procLoaderCtx() const { return nullptr; }
const char* renderApiName() const { return "egl"; }
private:
enum class DisplayTier { SurfacelessMesa, Default, Gbm };
@@ -780,6 +782,7 @@ public:
GlGetProcAddressFn procLoader() const { return wglLoaderGetProcAddress; }
void* procLoaderCtx() const { return opengl32_; }
const char* renderApiName() const { return "wgl"; }
private:
HWND window_ = nullptr;
@@ -41,6 +41,7 @@ namespace {
using frame_helper::Command;
using frame_helper::emitLine;
using frame_helper::GlContext;
using frame_helper::JsonWriter;
using frame_helper::RenderPipeline;
@@ -612,6 +613,7 @@ struct HelperArgs {
int width = 1280;
int height = 720;
double volume = 1;
bool runtimeProbe = false;
};
HelperArgs parseArgs(int argc, char** argv) {
@@ -627,12 +629,101 @@ HelperArgs parseArgs(int argc, char** argv) {
else if (arg == "--volume") args.volume = std::atof(next().c_str());
else if (arg == "--hwdec") args.hwdec = next();
else if (arg == "--audio-delay") args.audioDelay = next();
else if (arg == "--runtime-probe") args.runtimeProbe = true;
}
args.width = std::max(16, args.width);
args.height = std::max(16, args.height);
return args;
}
int runtimeProbeFailure(const std::string& reason,
const std::string& detail = "") {
JsonWriter writer;
writer.num("protocol", 1)
.boolean("usable", false)
.str("reason", reason);
if (!detail.empty()) writer.str("detail", detail);
emitLine(writer.finish());
return 1;
}
std::string libmpvClientApiVersion() {
const unsigned long version = mpv_client_api_version();
return std::to_string(version >> 16) + "." +
std::to_string(version & 0xffff);
}
/*
* Bounded startup capability probe: initialize an idle libmpv client and
* create the platform GL + mpv OpenGL render contexts. It deliberately does
* not create an FBO/shm ring, open media, or enter either command loop.
*/
int runRuntimeProbe() {
mpv_handle* mpv = mpv_create();
if (!mpv) {
return runtimeProbeFailure("mpv-create-failed");
}
mpv_set_option_string(mpv, "vo", "libmpv");
mpv_set_option_string(mpv, "idle", "yes");
mpv_set_option_string(mpv, "input-default-bindings", "no");
mpv_set_option_string(mpv, "osc", "no");
const int initializeResult = mpv_initialize(mpv);
if (initializeResult < 0) {
const std::string error = mpv_error_string(initializeResult);
mpv_destroy(mpv);
return runtimeProbeFailure("mpv-initialize-failed", error);
}
GlContext gl;
std::string error;
if (!gl.create(error)) {
gl.destroy();
mpv_terminate_destroy(mpv);
return runtimeProbeFailure("gl-context-create-failed", error);
}
if (!gl.makeCurrent(error)) {
gl.destroy();
mpv_terminate_destroy(mpv);
return runtimeProbeFailure("gl-context-bind-failed", error);
}
mpv_opengl_init_params glInit = {
gl.procLoader(),
gl.procLoaderCtx(),
};
mpv_render_param renderParams[] = {
{MPV_RENDER_PARAM_API_TYPE,
const_cast<char*>(MPV_RENDER_API_TYPE_OPENGL)},
{MPV_RENDER_PARAM_OPENGL_INIT_PARAMS, &glInit},
{MPV_RENDER_PARAM_INVALID, nullptr},
};
mpv_render_context* renderContext = nullptr;
const int renderResult =
mpv_render_context_create(&renderContext, mpv, renderParams);
if (renderResult < 0 || !renderContext) {
const std::string renderError =
renderResult < 0 ? mpv_error_string(renderResult)
: "render context unavailable";
if (renderContext) mpv_render_context_free(renderContext);
gl.destroy();
mpv_terminate_destroy(mpv);
return runtimeProbeFailure("mpv-render-context-failed", renderError);
}
const std::string libmpvVersion = libmpvClientApiVersion();
mpv_render_context_free(renderContext);
gl.destroy();
mpv_terminate_destroy(mpv);
emitLine(JsonWriter()
.num("protocol", 1)
.boolean("usable", true)
.str("libmpv", libmpvVersion)
.str("renderApi", gl.renderApiName())
.finish());
return 0;
}
} // namespace
int main(int argc, char** argv) {
@@ -641,6 +732,9 @@ int main(int argc, char** argv) {
signal(SIGPIPE, SIG_IGN);
#endif
const HelperArgs args = parseArgs(argc, argv);
if (args.runtimeProbe) {
return runRuntimeProbe();
}
g_state.mpv = mpv_create();
if (!g_state.mpv) {
@@ -10,10 +10,13 @@ const mockElectronApp = {
jest.mock('electron', () => ({ app: mockElectronApp }));
import {
getFrameCopyRuntimeAvailability,
isFrameCopyPlatformSupported,
isFrameCopyRuntimeUsable,
resolveFrameCopyHelperPath,
} from './embedded-mpv-frame-copy-platform.util';
import * as frameCopyPlatform from './embedded-mpv-frame-copy-platform.util';
import type { EmbeddedMpvFrameCopyRuntimeResult } from './embedded-mpv-frame-copy-runtime';
describe('embedded-mpv-frame-copy-platform.util', () => {
describe('isFrameCopyPlatformSupported', () => {
@@ -31,7 +34,8 @@ describe('embedded-mpv-frame-copy-platform.util', () => {
['darwin', 'arm64', true],
['darwin', 'x64', false],
['linux', 'x64', true],
['linux', 'arm64', true],
['linux', 'arm64', false],
['linux', 'arm', false],
['win32', 'x64', true],
['freebsd', 'x64', false],
])('%s/%s -> %s', (platform, arch, expected) => {
@@ -61,8 +65,7 @@ describe('embedded-mpv-frame-copy-platform.util', () => {
process.platform === 'win32'
? 'iptvnator_mpv_helper.exe'
: 'iptvnator_mpv_helper';
const helperPath = () =>
path.join(releaseDir(), helperFileName());
const helperPath = () => path.join(releaseDir(), helperFileName());
const readerPath = () =>
path.join(releaseDir(), 'embedded_mpv_frame_reader.node');
@@ -154,6 +157,87 @@ describe('embedded-mpv-frame-copy-platform.util', () => {
});
});
describe('isFrameCopyRuntimeUsable', () => {
const originalPlatform = process.platform;
const originalArch = process.arch;
afterEach(() => {
Object.defineProperty(process, 'platform', {
value: originalPlatform,
});
Object.defineProperty(process, 'arch', { value: originalArch });
});
it('requires a successful Linux x64 runtime probe', () => {
Object.defineProperty(process, 'platform', { value: 'linux' });
Object.defineProperty(process, 'arch', { value: 'x64' });
const resolveHelper = jest.fn(() => '/native/iptvnator_mpv_helper');
const probeRuntime = jest.fn<
EmbeddedMpvFrameCopyRuntimeResult,
[string]
>(() => ({
usable: true,
profile: 'system',
runtimeMode: 'system',
libmpv: '2.3',
renderApi: 'egl',
}));
expect(isFrameCopyRuntimeUsable(resolveHelper, probeRuntime)).toBe(
true
);
expect(probeRuntime).toHaveBeenCalledWith(
'/native/iptvnator_mpv_helper'
);
probeRuntime.mockReturnValueOnce({
usable: false,
reason: 'helper-probe-failed',
});
expect(
getFrameCopyRuntimeAvailability(resolveHelper, probeRuntime)
).toEqual({
usable: false,
reason: 'helper-probe-failed',
});
});
it.each<[NodeJS.Platform, string]>([
['darwin', 'arm64'],
['win32', 'x64'],
])(
'keeps the existing helper-presence gate on %s',
(platform, arch) => {
Object.defineProperty(process, 'platform', {
value: platform,
});
Object.defineProperty(process, 'arch', { value: arch });
const resolveHelper = jest.fn(
() => '/native/iptvnator_mpv_helper'
);
const probeRuntime = jest.fn();
expect(
isFrameCopyRuntimeUsable(resolveHelper, probeRuntime)
).toBe(true);
expect(probeRuntime).not.toHaveBeenCalled();
}
);
it('rejects Linux ARM before helper discovery or probing', () => {
Object.defineProperty(process, 'platform', { value: 'linux' });
Object.defineProperty(process, 'arch', { value: 'arm64' });
const resolveHelper = jest.fn(() => '/native/iptvnator_mpv_helper');
const probeRuntime = jest.fn();
expect(isFrameCopyRuntimeUsable(resolveHelper, probeRuntime)).toBe(
false
);
expect(resolveHelper).not.toHaveBeenCalled();
expect(probeRuntime).not.toHaveBeenCalled();
});
});
it('promotes a stored opt-in only without an explicit env override and with a usable runtime', () => {
const shouldPromote = (
frameCopyPlatform as typeof frameCopyPlatform & {
@@ -1,6 +1,8 @@
import { app } from 'electron';
import { accessSync, constants as fsConstants, statSync } from 'fs';
import path from 'path';
import { probeEmbeddedMpvFrameCopyRuntime } from './embedded-mpv-frame-copy-runtime';
import type { EmbeddedMpvFrameCopyRuntimeResult } from './embedded-mpv-frame-copy-runtime';
/**
* Platform gate + helper discovery for the embedded MPV frame-copy engine,
@@ -9,15 +11,15 @@ import path from 'path';
* callable before app.whenReady().
*
* macOS: Apple Silicon only (owner decision 2026-07-10) — Intel Macs keep
* the docked native engine. Linux: any arch — the helper renders offscreen
* through headless EGL and links libmpv out of process, so neither window
* embedding nor the in-process-libmpv ban constrains it. Windows: any arch
* with a helper binary (WGL offscreen render; in practice x64, the only
* vendored runtime) — the helper-presence check below is the real gate.
* the docked native engine. Linux: x64 only — official packages validate a
* profile manifest and run the helper's bounded EGL/libmpv capability probe;
* ARM packages remain honestly unavailable. Windows: any arch with a helper
* binary (WGL offscreen render; in practice x64, the only vendored runtime)
* — the helper-presence check below is the real gate.
*/
export function isFrameCopyPlatformSupported(): boolean {
return (
process.platform === 'linux' ||
(process.platform === 'linux' && process.arch === 'x64') ||
process.platform === 'win32' ||
(process.platform === 'darwin' && process.arch === 'arm64')
);
@@ -104,10 +106,7 @@ export function resolveFrameCopyHelperPath(): string | null {
.map((candidatePath) => path.dirname(candidatePath))
.map((nativeDir) => ({
helper: path.join(nativeDir, helperFileName),
reader: path.join(
nativeDir,
'embedded_mpv_frame_reader.node'
),
reader: path.join(nativeDir, 'embedded_mpv_frame_reader.node'),
}))
.find(({ helper, reader }) => {
try {
@@ -127,10 +126,67 @@ export function resolveFrameCopyHelperPath(): string | null {
);
}
type FrameCopyRuntimeProbe = (
helperPath: string
) => EmbeddedMpvFrameCopyRuntimeResult;
export type FrameCopyRuntimeAvailability =
| EmbeddedMpvFrameCopyRuntimeResult
| { usable: true };
let cachedDefaultRuntimeAvailability: FrameCopyRuntimeAvailability | undefined;
export function getFrameCopyRuntimeAvailability(
resolveHelper: () => string | null = resolveFrameCopyHelperPath,
probeRuntime: FrameCopyRuntimeProbe = probeEmbeddedMpvFrameCopyRuntime
): FrameCopyRuntimeAvailability {
const usesProcessDecision =
resolveHelper === resolveFrameCopyHelperPath &&
probeRuntime === probeEmbeddedMpvFrameCopyRuntime;
if (usesProcessDecision && cachedDefaultRuntimeAvailability !== undefined) {
return cachedDefaultRuntimeAvailability;
}
let availability: FrameCopyRuntimeAvailability;
if (!isFrameCopyPlatformSupported()) {
availability = {
usable: false,
reason:
process.platform === 'linux'
? 'unsupported-architecture'
: 'unsupported-platform',
};
} else {
const helperPath = resolveHelper();
if (!helperPath) {
availability = {
usable: false,
reason: 'runtime-artifact-missing',
};
} else if (process.platform !== 'linux') {
availability = { usable: true };
} else {
try {
availability = probeRuntime(helperPath);
} catch {
availability = {
usable: false,
reason: 'runtime-probe-internal-error',
};
}
}
}
if (usesProcessDecision) {
cachedDefaultRuntimeAvailability = availability;
}
return availability;
}
export function isFrameCopyRuntimeUsable(
resolveHelper: () => string | null = resolveFrameCopyHelperPath
resolveHelper: () => string | null = resolveFrameCopyHelperPath,
probeRuntime: FrameCopyRuntimeProbe = probeEmbeddedMpvFrameCopyRuntime
): boolean {
return isFrameCopyPlatformSupported() && resolveHelper() !== null;
return getFrameCopyRuntimeAvailability(resolveHelper, probeRuntime).usable;
}
export function shouldPromotePersistedFrameCopyOptIn(
@@ -0,0 +1,682 @@
import { spawnSync } from 'child_process';
import { createHash } from 'crypto';
import {
accessSync,
chmodSync,
constants as fsConstants,
lstatSync,
mkdirSync,
mkdtempSync,
readFileSync,
readdirSync,
rmSync,
symlinkSync,
unlinkSync,
writeFileSync,
} from 'fs';
import { tmpdir } from 'os';
import path from 'path';
import {
createEmbeddedMpvFrameCopyRuntimeProbe,
EmbeddedMpvFrameCopyRuntimeDependencies,
} from './embedded-mpv-frame-copy-runtime';
const SUCCESS_OUTPUT =
'{"protocol":1,"usable":true,"libmpv":"2.3","renderApi":"egl"}\n';
interface RuntimeFile {
name: string;
size: number;
sha256: string;
}
interface RuntimeFixture {
nativeDir: string;
helperPath: string;
manifestPath: string;
manifest: Record<string, unknown>;
runtimeContents: Record<string, Buffer>;
}
function sha256(contents: Buffer): string {
return createHash('sha256').update(contents).digest('hex');
}
function createRuntimeFiles(
runtimeContents: Record<string, Buffer>
): RuntimeFile[] {
return Object.entries(runtimeContents)
.map(([name, contents]) => ({
name,
size: contents.length,
sha256: sha256(contents),
}))
.sort((left, right) => left.name.localeCompare(right.name));
}
function createFixture(
rootDir: string,
profile: 'system' | 'portable' | 'flatpak' = 'system'
): RuntimeFixture {
const nativeDir = path.join(rootDir, profile, 'native');
const helperPath = path.join(nativeDir, 'iptvnator_mpv_helper');
const manifestPath = path.join(nativeDir, 'embedded-mpv-runtime.json');
mkdirSync(nativeDir, { recursive: true });
writeFileSync(path.join(nativeDir, 'embedded_mpv.node'), 'addon', {
mode: 0o644,
});
writeFileSync(
path.join(nativeDir, 'embedded_mpv_frame_reader.node'),
'reader',
{ mode: 0o644 }
);
writeFileSync(helperPath, '#!/bin/sh\n', { mode: 0o755 });
const bundled = profile !== 'system';
const runtimeContents = bundled
? {
'libmpv.so': Buffer.from('libmpv-linker-alias'),
'libmpv.so.2': Buffer.from('libmpv-soname'),
}
: {};
const runtimeFiles = createRuntimeFiles(runtimeContents);
const runtimeDependencyClosure = {
entries: runtimeFiles.map(({ name }) => ({
name,
soname: name === 'libmpv.so' ? 'libmpv.so.2' : name,
needed: [],
rpath: [],
runpath: ['$ORIGIN'],
})),
externalDependencies: [],
};
const externalSystemLibraries: unknown[] = [];
const manifest: Record<string, unknown> = {
schemaVersion: 1,
origin: bundled
? 'bundled-lgpl-frame-copy'
: 'system-libmpv-frame-copy',
generatedAt: '2026-07-17T00:00:00.000Z',
platform: 'linux',
arch: 'x64',
profile,
runtimeMode: bundled ? 'bundled' : 'system',
targets:
profile === 'system'
? ['deb', 'pacman', 'rpm']
: profile === 'portable'
? ['appimage', 'snap']
: ['flatpak'],
artifacts: {
addon: {
name: 'embedded_mpv.node',
regularFile: true,
readable: true,
},
frameReader: {
name: 'embedded_mpv_frame_reader.node',
regularFile: true,
readable: true,
},
helper: {
name: 'iptvnator_mpv_helper',
regularFile: true,
readable: true,
executable: true,
},
},
processIsolation: {
addonLoadsLibmpv: false,
readerLoadsLibmpv: false,
electronLoadsLibmpv: false,
helperLinksLibmpv: true,
helperRunpath: ['$ORIGIN/lib'],
},
nativeViewFallback: 'process-isolated mpv --wid',
libmpvSoname: 'libmpv.so.2',
packageDependencies: bundled
? {}
: {
deb: 'libmpv2',
rpm: 'mpv-libs',
pacman: 'mpv',
},
runtimeFiles,
runtimeTotalBytes: runtimeFiles.reduce(
(total, runtimeFile) => total + runtimeFile.size,
0
),
...(bundled
? {
runtimeDependencyClosure,
externalSystemLibraries,
sourceRuntime: {
schemaVersion: 1,
origin: 'vendored-lgpl-source-build',
platform: 'linux',
arch: 'x64',
runtimeFiles,
runtimeTotalBytes: runtimeFiles.reduce(
(total, runtimeFile) => total + runtimeFile.size,
0
),
runtimeDependencyClosure,
externalSystemLibraries,
},
}
: {}),
};
writeManifest(manifestPath, manifest);
if (bundled) {
const libDir = path.join(nativeDir, 'lib');
mkdirSync(libDir);
for (const [name, contents] of Object.entries(runtimeContents)) {
writeFileSync(path.join(libDir, name), contents, {
mode: 0o644,
});
}
}
return {
nativeDir,
helperPath,
manifestPath,
manifest,
runtimeContents,
};
}
function writeManifest(
manifestPath: string,
manifest: Record<string, unknown>
): void {
writeFileSync(manifestPath, `${JSON.stringify(manifest)}\n`, {
mode: 0o644,
});
chmodSync(manifestPath, 0o644);
}
function cloneManifest(
manifest: Record<string, unknown>
): Record<string, unknown> {
return JSON.parse(JSON.stringify(manifest)) as Record<string, unknown>;
}
describe('embedded-mpv-frame-copy-runtime', () => {
let rootDir: string;
let spawnRuntimeProbe: jest.Mock;
let fileSystem: EmbeddedMpvFrameCopyRuntimeDependencies['fileSystem'];
beforeEach(() => {
rootDir = mkdtempSync(path.join(tmpdir(), 'iptvnator-fc-runtime-'));
spawnRuntimeProbe = jest.fn(() => ({
status: 0,
signal: null,
stdout: SUCCESS_OUTPUT,
stderr: '',
}));
fileSystem = {
accessSync: jest.fn((filePath: string, mode: number) =>
accessSync(filePath, mode)
),
lstatSync: jest.fn((filePath: string) => lstatSync(filePath)),
readFileSync: jest.fn((filePath: string) => readFileSync(filePath)),
readdirSync: jest.fn((filePath: string) => readdirSync(filePath)),
};
});
afterEach(() => {
rmSync(rootDir, { recursive: true, force: true });
});
function createProbe(
overrides: Partial<EmbeddedMpvFrameCopyRuntimeDependencies> = {}
) {
return createEmbeddedMpvFrameCopyRuntimeProbe({
platform: 'linux',
arch: 'x64',
env: { PATH: '/usr/bin', LD_LIBRARY_PATH: '/system/libs' },
fileSystem,
spawnSync: spawnRuntimeProbe as typeof spawnSync,
...overrides,
});
}
it('validates a system package, preserves its environment, and caches by helper/manifest identity', () => {
const fixture = createFixture(rootDir);
const probeRuntime = createProbe();
expect(probeRuntime(fixture.helperPath)).toEqual({
usable: true,
profile: 'system',
runtimeMode: 'system',
libmpv: '2.3',
renderApi: 'egl',
});
expect(probeRuntime(fixture.helperPath).usable).toBe(true);
expect(spawnRuntimeProbe).toHaveBeenCalledTimes(1);
expect(spawnRuntimeProbe).toHaveBeenCalledWith(
fixture.helperPath,
['--runtime-probe'],
{
encoding: 'utf8',
timeout: 3000,
windowsHide: true,
env: {
PATH: '/usr/bin',
LD_LIBRARY_PATH: '/system/libs',
},
}
);
expect(fileSystem?.readFileSync).toHaveBeenCalled();
});
it.each(['portable', 'flatpak'] as const)(
'validates the exact %s bundled closure and prepends only its private library directory',
(profile) => {
const fixture = createFixture(rootDir, profile);
const probeRuntime = createProbe();
expect(probeRuntime(fixture.helperPath)).toEqual(
expect.objectContaining({
usable: true,
profile,
runtimeMode: 'bundled',
})
);
expect(spawnRuntimeProbe).toHaveBeenCalledWith(
fixture.helperPath,
['--runtime-probe'],
expect.objectContaining({
env: {
PATH: '/usr/bin',
LD_LIBRARY_PATH: `${path.join(
fixture.nativeDir,
'lib'
)}:/system/libs`,
},
})
);
}
);
it('reprobes when the helper identity changes', () => {
const fixture = createFixture(rootDir);
const probeRuntime = createProbe();
expect(probeRuntime(fixture.helperPath).usable).toBe(true);
writeFileSync(fixture.helperPath, '#!/bin/sh\n# changed\n');
chmodSync(fixture.helperPath, 0o755);
expect(probeRuntime(fixture.helperPath).usable).toBe(true);
expect(spawnRuntimeProbe).toHaveBeenCalledTimes(2);
});
it('reprobes when the manifest identity changes', () => {
const fixture = createFixture(rootDir);
const probeRuntime = createProbe();
expect(probeRuntime(fixture.helperPath).usable).toBe(true);
const manifest = cloneManifest(fixture.manifest);
manifest.generatedAt = '2026-07-17T00:01:00.000Z';
writeManifest(fixture.manifestPath, manifest);
expect(probeRuntime(fixture.helperPath).usable).toBe(true);
expect(spawnRuntimeProbe).toHaveBeenCalledTimes(2);
});
it('converts a thrown spawn failure into a stable result', () => {
const fixture = createFixture(rootDir);
spawnRuntimeProbe.mockImplementation(() => {
throw new Error('spawn exploded');
});
expect(createProbe()(fixture.helperPath)).toEqual({
usable: false,
reason: 'helper-probe-spawn-error',
});
});
it.each([
{
label: 'timeout',
spawnResult: {
status: null,
signal: 'SIGTERM',
stdout: '',
stderr: '',
error: Object.assign(new Error('timed out'), {
code: 'ETIMEDOUT',
}),
},
reason: 'helper-probe-timeout',
},
{
label: 'spawn error',
spawnResult: {
status: null,
signal: null,
stdout: '',
stderr: '',
error: Object.assign(new Error('spawn failed'), {
code: 'EACCES',
}),
},
reason: 'helper-probe-spawn-error',
},
{
label: 'nonzero exit',
spawnResult: {
status: 1,
signal: null,
stdout: '{"protocol":1,"usable":false,"reason":"egl-unavailable"}\n',
stderr: '',
},
reason: 'helper-probe-failed',
},
{
label: 'signal',
spawnResult: {
status: null,
signal: 'SIGKILL',
stdout: '',
stderr: '',
},
reason: 'helper-probe-signaled',
},
{
label: 'invalid JSON',
spawnResult: {
status: 0,
signal: null,
stdout: 'not-json\n',
stderr: '',
},
reason: 'helper-probe-invalid-output',
},
{
label: 'multiple lines',
spawnResult: {
status: 0,
signal: null,
stdout: `${SUCCESS_OUTPUT}${SUCCESS_OUTPUT}`,
stderr: '',
},
reason: 'helper-probe-invalid-output',
},
{
label: 'wrong protocol',
spawnResult: {
status: 0,
signal: null,
stdout: '{"protocol":2,"usable":true,"libmpv":"2.3","renderApi":"egl"}\n',
stderr: '',
},
reason: 'helper-probe-protocol-mismatch',
},
{
label: 'unusable success',
spawnResult: {
status: 0,
signal: null,
stdout: '{"protocol":1,"usable":false,"reason":"egl-unavailable"}\n',
stderr: '',
},
reason: 'helper-probe-unusable',
},
])('fails closed on helper $label', ({ spawnResult, reason }) => {
const fixture = createFixture(rootDir);
spawnRuntimeProbe.mockReturnValue(spawnResult);
expect(createProbe()(fixture.helperPath)).toEqual(
expect.objectContaining({ usable: false, reason })
);
});
it.each([
['linux', 'arm64', 'unsupported-architecture'],
['linux', 'arm', 'unsupported-architecture'],
['darwin', 'x64', 'unsupported-platform'],
] as const)(
'does not probe unsupported %s/%s runtimes',
(platform, arch, reason) => {
const fixture = createFixture(rootDir);
expect(createProbe({ platform, arch })(fixture.helperPath)).toEqual(
{
usable: false,
reason,
}
);
expect(spawnRuntimeProbe).not.toHaveBeenCalled();
}
);
it('rejects a missing or malformed manifest without throwing', () => {
const missing = createFixture(rootDir);
unlinkSync(missing.manifestPath);
expect(createProbe()(missing.helperPath)).toEqual({
usable: false,
reason: 'runtime-manifest-missing',
});
const malformed = createFixture(rootDir, 'portable');
writeFileSync(malformed.manifestPath, '{broken\n', { mode: 0o644 });
expect(createProbe()(malformed.helperPath)).toEqual({
usable: false,
reason: 'runtime-manifest-invalid',
});
expect(spawnRuntimeProbe).not.toHaveBeenCalled();
});
it.each([
['origin', 'system-libmpv-frame-copy'],
['arch', 'arm64'],
['runtimeMode', 'system'],
['targets', ['deb']],
['unexpectedField', true],
])('rejects a bundled profile mismatch in %s', (field, value) => {
const fixture = createFixture(rootDir, 'portable');
const manifest = cloneManifest(fixture.manifest);
manifest[field] = value;
writeManifest(fixture.manifestPath, manifest);
expect(createProbe()(fixture.helperPath)).toEqual({
usable: false,
reason: 'runtime-manifest-invalid',
});
expect(spawnRuntimeProbe).not.toHaveBeenCalled();
});
it('rejects system manifests with a private library directory', () => {
const fixture = createFixture(rootDir);
mkdirSync(path.join(fixture.nativeDir, 'lib'));
expect(createProbe()(fixture.helperPath)).toEqual({
usable: false,
reason: 'runtime-library-directory-invalid',
});
});
it.each([
{
label: 'missing addon',
mutate(fixture: RuntimeFixture) {
unlinkSync(path.join(fixture.nativeDir, 'embedded_mpv.node'));
},
reason: 'runtime-artifact-missing',
},
{
label: 'non-executable helper',
mutate(fixture: RuntimeFixture) {
chmodSync(fixture.helperPath, 0o644);
},
reason: 'runtime-artifact-invalid',
},
{
label: 'wrong reader mode',
mutate(fixture: RuntimeFixture) {
chmodSync(
path.join(
fixture.nativeDir,
'embedded_mpv_frame_reader.node'
),
0o600
);
},
reason: 'runtime-artifact-invalid',
},
{
label: 'symlinked helper',
mutate(fixture: RuntimeFixture) {
const target = `${fixture.helperPath}.real`;
writeFileSync(target, '#!/bin/sh\n', { mode: 0o755 });
unlinkSync(fixture.helperPath);
symlinkSync(target, fixture.helperPath);
},
reason: 'runtime-artifact-invalid',
},
{
label: 'reader directory',
mutate(fixture: RuntimeFixture) {
const readerPath = path.join(
fixture.nativeDir,
'embedded_mpv_frame_reader.node'
);
unlinkSync(readerPath);
mkdirSync(readerPath);
},
reason: 'runtime-artifact-invalid',
},
])('rejects a $label', ({ mutate, reason }) => {
const fixture = createFixture(rootDir);
mutate(fixture);
expect(createProbe()(fixture.helperPath)).toEqual({
usable: false,
reason,
});
expect(spawnRuntimeProbe).not.toHaveBeenCalled();
});
it.each([
{
label: 'missing declared library',
mutate(fixture: RuntimeFixture) {
unlinkSync(path.join(fixture.nativeDir, 'lib', 'libmpv.so.2'));
},
reason: 'runtime-library-missing',
},
{
label: 'undeclared library',
mutate(fixture: RuntimeFixture) {
writeFileSync(
path.join(fixture.nativeDir, 'lib', 'libextra.so'),
'extra'
);
},
reason: 'runtime-library-undeclared',
},
{
label: 'library size mismatch',
mutate(fixture: RuntimeFixture) {
writeFileSync(
path.join(fixture.nativeDir, 'lib', 'libmpv.so.2'),
'different-length'
);
},
reason: 'runtime-library-size-mismatch',
},
{
label: 'library hash mismatch',
mutate(fixture: RuntimeFixture) {
const runtimePath = path.join(
fixture.nativeDir,
'lib',
'libmpv.so.2'
);
const original = readFileSync(runtimePath);
writeFileSync(
runtimePath,
Buffer.from(original.map((value) => value ^ 0xff))
);
},
reason: 'runtime-library-hash-mismatch',
},
{
label: 'symlinked library',
mutate(fixture: RuntimeFixture) {
const runtimePath = path.join(
fixture.nativeDir,
'lib',
'libmpv.so.2'
);
const targetPath = path.join(
fixture.nativeDir,
'libmpv-real.so.2'
);
writeFileSync(
targetPath,
fixture.runtimeContents['libmpv.so.2']
);
unlinkSync(runtimePath);
symlinkSync(targetPath, runtimePath);
},
reason: 'runtime-library-invalid',
},
{
label: 'library directory',
mutate(fixture: RuntimeFixture) {
const runtimePath = path.join(
fixture.nativeDir,
'lib',
'libmpv.so.2'
);
unlinkSync(runtimePath);
mkdirSync(runtimePath);
},
reason: 'runtime-library-invalid',
},
])('rejects a $label', ({ mutate, reason }) => {
const fixture = createFixture(rootDir, 'portable');
mutate(fixture);
expect(createProbe()(fixture.helperPath)).toEqual({
usable: false,
reason,
});
expect(spawnRuntimeProbe).not.toHaveBeenCalled();
});
it.each(['../libmpv.so.2', '/tmp/libmpv.so.2', 'sub/libmpv.so.2'])(
'rejects unsafe runtime path %s',
(unsafeName) => {
const fixture = createFixture(rootDir, 'portable');
const manifest = cloneManifest(fixture.manifest);
const runtimeFiles = manifest.runtimeFiles as RuntimeFile[];
runtimeFiles[0].name = unsafeName;
writeManifest(fixture.manifestPath, manifest);
expect(createProbe()(fixture.helperPath)).toEqual({
usable: false,
reason: 'runtime-manifest-invalid',
});
expect(spawnRuntimeProbe).not.toHaveBeenCalled();
}
);
it('uses read and execute access checks for declared artifacts', () => {
const fixture = createFixture(rootDir);
const probeRuntime = createProbe();
expect(probeRuntime(fixture.helperPath).usable).toBe(true);
const accessMock = fileSystem?.accessSync as jest.Mock;
expect(accessMock).toHaveBeenCalledWith(
fixture.helperPath,
fsConstants.R_OK | fsConstants.X_OK
);
expect(accessMock).toHaveBeenCalledWith(
path.join(fixture.nativeDir, 'embedded_mpv_frame_reader.node'),
fsConstants.R_OK
);
});
});
@@ -0,0 +1,886 @@
import { spawnSync as nodeSpawnSync } from 'child_process';
import { createHash } from 'crypto';
import * as nodeFileSystem from 'fs';
import { isDeepStrictEqual } from 'util';
import path from 'path';
const RUNTIME_MANIFEST_NAME = 'embedded-mpv-runtime.json';
const FRAME_COPY_ADDON_NAME = 'embedded_mpv.node';
const FRAME_COPY_READER_NAME = 'embedded_mpv_frame_reader.node';
const FRAME_COPY_HELPER_NAME = 'iptvnator_mpv_helper';
const RUNTIME_PROBE_PROTOCOL = 1;
const RUNTIME_PROBE_TIMEOUT_MS = 3000;
const LINUX_LIBRARY_PATH_DELIMITER = ':';
const VERSIONED_LIBMPV_PATTERN = /^libmpv\.so\.\d+(?:\.\d+)*$/;
const SAFE_RUNTIME_NAME_PATTERN = /^[A-Za-z0-9_+.-]+$/;
const SHARED_LIBRARY_PATTERN = /\.so(?:\.\d+)*$/;
const SHA256_PATTERN = /^[a-f0-9]{64}$/;
const EXPECTED_ARTIFACTS = {
addon: {
name: FRAME_COPY_ADDON_NAME,
regularFile: true,
readable: true,
},
frameReader: {
name: FRAME_COPY_READER_NAME,
regularFile: true,
readable: true,
},
helper: {
name: FRAME_COPY_HELPER_NAME,
regularFile: true,
readable: true,
executable: true,
},
};
const EXPECTED_PROCESS_ISOLATION = {
addonLoadsLibmpv: false,
readerLoadsLibmpv: false,
electronLoadsLibmpv: false,
helperLinksLibmpv: true,
helperRunpath: ['$ORIGIN/lib'],
};
const SYSTEM_PACKAGE_DEPENDENCIES = {
deb: 'libmpv2',
rpm: 'mpv-libs',
pacman: 'mpv',
};
const PROFILE_CONTRACTS = {
system: {
origin: 'system-libmpv-frame-copy',
runtimeMode: 'system',
targets: new Set(['deb', 'rpm', 'pacman']),
},
portable: {
origin: 'bundled-lgpl-frame-copy',
runtimeMode: 'bundled',
targets: new Set(['appimage', 'snap']),
},
flatpak: {
origin: 'bundled-lgpl-frame-copy',
runtimeMode: 'bundled',
targets: new Set(['flatpak']),
},
} as const;
const BASE_MANIFEST_FIELDS = [
'arch',
'artifacts',
'generatedAt',
'libmpvSoname',
'nativeViewFallback',
'origin',
'packageDependencies',
'platform',
'processIsolation',
'profile',
'runtimeFiles',
'runtimeMode',
'runtimeTotalBytes',
'schemaVersion',
'targets',
] as const;
const BUNDLED_MANIFEST_FIELDS = [
...BASE_MANIFEST_FIELDS,
'externalSystemLibraries',
'runtimeDependencyClosure',
'sourceRuntime',
] as const;
export type EmbeddedMpvFrameCopyRuntimeFailureReason =
| 'unsupported-platform'
| 'unsupported-architecture'
| 'runtime-manifest-missing'
| 'runtime-manifest-invalid'
| 'runtime-artifact-missing'
| 'runtime-artifact-invalid'
| 'runtime-library-directory-invalid'
| 'runtime-library-missing'
| 'runtime-library-undeclared'
| 'runtime-library-invalid'
| 'runtime-library-size-mismatch'
| 'runtime-library-hash-mismatch'
| 'helper-probe-timeout'
| 'helper-probe-spawn-error'
| 'helper-probe-signaled'
| 'helper-probe-failed'
| 'helper-probe-invalid-output'
| 'helper-probe-protocol-mismatch'
| 'helper-probe-unusable'
| 'runtime-probe-internal-error';
export type EmbeddedMpvFrameCopyRuntimeResult =
| {
usable: true;
profile: keyof typeof PROFILE_CONTRACTS;
runtimeMode: 'system' | 'bundled';
libmpv: string;
renderApi: 'egl';
}
| {
usable: false;
reason: EmbeddedMpvFrameCopyRuntimeFailureReason;
};
export interface EmbeddedMpvFrameCopyRuntimeFileSystem {
accessSync(filePath: string, mode: number): void;
lstatSync(filePath: string): nodeFileSystem.Stats;
readFileSync(filePath: string): Buffer;
readdirSync(filePath: string): string[];
}
export interface EmbeddedMpvFrameCopyRuntimeDependencies {
platform: NodeJS.Platform;
arch: string;
env: NodeJS.ProcessEnv;
fileSystem: EmbeddedMpvFrameCopyRuntimeFileSystem;
spawnSync: typeof nodeSpawnSync;
}
type RuntimeProfile = keyof typeof PROFILE_CONTRACTS;
interface RuntimeFile {
name: string;
size: number;
sha256: string;
}
interface ValidManifest {
profile: RuntimeProfile;
runtimeMode: 'system' | 'bundled';
runtimeFiles: RuntimeFile[];
}
interface ValidatedPackage {
manifest: ValidManifest;
helperPath: string;
nativeDir: string;
}
interface ValidationSuccess<T> {
value: T;
}
interface ValidationFailure {
reason: EmbeddedMpvFrameCopyRuntimeFailureReason;
}
type ValidationResult<T> = ValidationSuccess<T> | ValidationFailure;
function failure(
reason: EmbeddedMpvFrameCopyRuntimeFailureReason
): EmbeddedMpvFrameCopyRuntimeResult {
return { usable: false, reason };
}
function validationFailure(
reason: EmbeddedMpvFrameCopyRuntimeFailureReason
): ValidationFailure {
return { reason };
}
function isValidationFailure<T>(
result: ValidationResult<T>
): result is ValidationFailure {
return 'reason' in result;
}
function isObject(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === 'object' && !Array.isArray(value);
}
function hasExactFields(
value: Record<string, unknown>,
fields: readonly string[]
): boolean {
return isDeepStrictEqual(Object.keys(value).sort(), [...fields].sort());
}
function isSafeRuntimeName(value: unknown): value is string {
return (
typeof value === 'string' &&
value.length > 0 &&
value !== '.' &&
value !== '..' &&
path.basename(value) === value &&
!value.includes('/') &&
!value.includes('\\') &&
SAFE_RUNTIME_NAME_PATTERN.test(value) &&
SHARED_LIBRARY_PATTERN.test(value)
);
}
function isMissingFileError(error: unknown): boolean {
return (
isObject(error) &&
typeof error.code === 'string' &&
(error.code === 'ENOENT' || error.code === 'ENOTDIR')
);
}
function fileIdentity(filePath: string, stat: nodeFileSystem.Stats): string {
return [
path.resolve(filePath),
stat.dev,
stat.ino,
stat.mode,
stat.size,
stat.mtimeMs,
stat.ctimeMs,
].join(':');
}
function readManifest(
manifestPath: string,
fileSystem: EmbeddedMpvFrameCopyRuntimeFileSystem
): ValidationResult<Record<string, unknown>> {
let contents: Buffer;
try {
contents = fileSystem.readFileSync(manifestPath);
} catch {
return validationFailure('runtime-manifest-invalid');
}
try {
const parsed: unknown = JSON.parse(contents.toString('utf8'));
return isObject(parsed)
? { value: parsed }
: validationFailure('runtime-manifest-invalid');
} catch {
return validationFailure('runtime-manifest-invalid');
}
}
function validateTargets(
targets: unknown,
allowedTargets: ReadonlySet<string>
): boolean {
if (
!Array.isArray(targets) ||
targets.length === 0 ||
targets.some(
(target) =>
typeof target !== 'string' ||
target.trim() !== target ||
target.toLowerCase() !== target ||
!allowedTargets.has(target)
)
) {
return false;
}
return (
new Set(targets).size === targets.length &&
isDeepStrictEqual([...targets].sort(), targets)
);
}
function validateRuntimeFiles(value: unknown): RuntimeFile[] | null {
if (!Array.isArray(value) || value.length === 0) {
return null;
}
const runtimeFiles: RuntimeFile[] = [];
const names = new Set<string>();
for (const candidate of value) {
if (
!isObject(candidate) ||
!hasExactFields(candidate, ['name', 'sha256', 'size']) ||
!isSafeRuntimeName(candidate.name) ||
!Number.isSafeInteger(candidate.size) ||
(candidate.size as number) <= 0 ||
typeof candidate.sha256 !== 'string' ||
!SHA256_PATTERN.test(candidate.sha256) ||
names.has(candidate.name)
) {
return null;
}
names.add(candidate.name);
runtimeFiles.push({
name: candidate.name,
size: candidate.size as number,
sha256: candidate.sha256,
});
}
return isDeepStrictEqual(
runtimeFiles.map(({ name }) => name).sort(),
runtimeFiles.map(({ name }) => name)
)
? runtimeFiles
: null;
}
function validateRuntimeClosure(
value: unknown,
runtimeFiles: RuntimeFile[],
libmpvSoname: string
): boolean {
if (
!isObject(value) ||
!hasExactFields(value, ['entries', 'externalDependencies']) ||
!Array.isArray(value.entries) ||
!Array.isArray(value.externalDependencies) ||
value.externalDependencies.some(
(dependency) =>
typeof dependency !== 'string' ||
!SAFE_RUNTIME_NAME_PATTERN.test(dependency)
)
) {
return false;
}
const runtimeNames = runtimeFiles.map(({ name }) => name);
const closureNames: string[] = [];
for (const entry of value.entries) {
if (
!isObject(entry) ||
!hasExactFields(entry, [
'name',
'needed',
'rpath',
'runpath',
'soname',
]) ||
!isSafeRuntimeName(entry.name) ||
(entry.soname !== null && !isSafeRuntimeName(entry.soname)) ||
!Array.isArray(entry.needed) ||
entry.needed.some(
(dependency) =>
typeof dependency !== 'string' ||
!SAFE_RUNTIME_NAME_PATTERN.test(dependency)
) ||
!isDeepStrictEqual(entry.rpath, []) ||
!isDeepStrictEqual(entry.runpath, ['$ORIGIN'])
) {
return false;
}
closureNames.push(entry.name);
}
const linkerAlias = value.entries.find(
(entry) => isObject(entry) && entry.name === 'libmpv.so'
);
return (
isDeepStrictEqual([...closureNames].sort(), [...runtimeNames].sort()) &&
new Set(closureNames).size === closureNames.length &&
isObject(linkerAlias) &&
linkerAlias.soname === libmpvSoname
);
}
function validateManifest(
manifest: Record<string, unknown>
): ValidationResult<ValidManifest> {
if (
manifest.schemaVersion !== 1 ||
manifest.platform !== 'linux' ||
manifest.arch !== 'x64' ||
typeof manifest.profile !== 'string' ||
!Object.prototype.hasOwnProperty.call(
PROFILE_CONTRACTS,
manifest.profile
)
) {
return validationFailure('runtime-manifest-invalid');
}
const profile = manifest.profile as RuntimeProfile;
const contract = PROFILE_CONTRACTS[profile];
if (
manifest.origin !== contract.origin ||
manifest.runtimeMode !== contract.runtimeMode ||
!hasExactFields(
manifest,
contract.runtimeMode === 'bundled'
? BUNDLED_MANIFEST_FIELDS
: BASE_MANIFEST_FIELDS
) ||
typeof manifest.generatedAt !== 'string' ||
manifest.generatedAt.trim() === '' ||
Number.isNaN(Date.parse(manifest.generatedAt)) ||
!validateTargets(manifest.targets, contract.targets) ||
!isDeepStrictEqual(manifest.artifacts, EXPECTED_ARTIFACTS) ||
!isDeepStrictEqual(
manifest.processIsolation,
EXPECTED_PROCESS_ISOLATION
) ||
manifest.nativeViewFallback !== 'process-isolated mpv --wid' ||
typeof manifest.libmpvSoname !== 'string' ||
!VERSIONED_LIBMPV_PATTERN.test(manifest.libmpvSoname)
) {
return validationFailure('runtime-manifest-invalid');
}
if (contract.runtimeMode === 'system') {
if (
!isDeepStrictEqual(
manifest.packageDependencies,
SYSTEM_PACKAGE_DEPENDENCIES
) ||
!isDeepStrictEqual(manifest.runtimeFiles, []) ||
manifest.runtimeTotalBytes !== 0
) {
return validationFailure('runtime-manifest-invalid');
}
return {
value: {
profile,
runtimeMode: 'system',
runtimeFiles: [],
},
};
}
const runtimeFiles = validateRuntimeFiles(manifest.runtimeFiles);
if (
!runtimeFiles ||
!isDeepStrictEqual(manifest.packageDependencies, {}) ||
!runtimeFiles.some(({ name }) => name === 'libmpv.so') ||
!runtimeFiles.some(({ name }) => name === manifest.libmpvSoname) ||
manifest.runtimeTotalBytes !==
runtimeFiles.reduce(
(total, runtimeFile) => total + runtimeFile.size,
0
) ||
!validateRuntimeClosure(
manifest.runtimeDependencyClosure,
runtimeFiles,
manifest.libmpvSoname
) ||
!Array.isArray(manifest.externalSystemLibraries) ||
!isObject(manifest.sourceRuntime)
) {
return validationFailure('runtime-manifest-invalid');
}
const sourceRuntime = manifest.sourceRuntime;
if (
sourceRuntime.schemaVersion !== 1 ||
sourceRuntime.origin !== 'vendored-lgpl-source-build' ||
sourceRuntime.platform !== 'linux' ||
sourceRuntime.arch !== 'x64' ||
sourceRuntime.runtimeTotalBytes !== manifest.runtimeTotalBytes ||
!isDeepStrictEqual(sourceRuntime.runtimeFiles, manifest.runtimeFiles) ||
!isDeepStrictEqual(
sourceRuntime.runtimeDependencyClosure,
manifest.runtimeDependencyClosure
) ||
!isDeepStrictEqual(
sourceRuntime.externalSystemLibraries,
manifest.externalSystemLibraries
)
) {
return validationFailure('runtime-manifest-invalid');
}
return {
value: {
profile,
runtimeMode: 'bundled',
runtimeFiles,
},
};
}
function validateRegularArtifact(
filePath: string,
accessMode: number,
expectedMode: number | null,
fileSystem: EmbeddedMpvFrameCopyRuntimeFileSystem
): ValidationResult<nodeFileSystem.Stats> {
let stat: nodeFileSystem.Stats;
try {
stat = fileSystem.lstatSync(filePath);
} catch (error) {
return validationFailure(
isMissingFileError(error)
? 'runtime-artifact-missing'
: 'runtime-artifact-invalid'
);
}
if (
stat.isSymbolicLink() ||
!stat.isFile() ||
(expectedMode !== null && (stat.mode & 0o777) !== expectedMode)
) {
return validationFailure('runtime-artifact-invalid');
}
try {
fileSystem.accessSync(filePath, accessMode);
} catch {
return validationFailure('runtime-artifact-invalid');
}
return { value: stat };
}
function pathExistsByLstat(
filePath: string,
fileSystem: EmbeddedMpvFrameCopyRuntimeFileSystem
): boolean {
try {
fileSystem.lstatSync(filePath);
return true;
} catch (error) {
if (isMissingFileError(error)) {
return false;
}
throw error;
}
}
function validateBundledRuntimeFiles(
nativeDir: string,
runtimeFiles: RuntimeFile[],
fileSystem: EmbeddedMpvFrameCopyRuntimeFileSystem
): ValidationResult<true> {
const libDir = path.join(nativeDir, 'lib');
let libStat: nodeFileSystem.Stats;
try {
libStat = fileSystem.lstatSync(libDir);
} catch {
return validationFailure('runtime-library-directory-invalid');
}
if (libStat.isSymbolicLink() || !libStat.isDirectory()) {
return validationFailure('runtime-library-directory-invalid');
}
let packagedNames: string[];
try {
packagedNames = fileSystem.readdirSync(libDir) as string[];
} catch {
return validationFailure('runtime-library-directory-invalid');
}
const declaredNames = new Set(runtimeFiles.map(({ name }) => name));
if (packagedNames.some((name) => !declaredNames.has(name))) {
return validationFailure('runtime-library-undeclared');
}
for (const runtimeFile of runtimeFiles) {
const runtimePath = path.join(libDir, runtimeFile.name);
let stat: nodeFileSystem.Stats;
try {
stat = fileSystem.lstatSync(runtimePath);
} catch (error) {
return validationFailure(
isMissingFileError(error)
? 'runtime-library-missing'
: 'runtime-library-invalid'
);
}
if (stat.isSymbolicLink() || !stat.isFile()) {
return validationFailure('runtime-library-invalid');
}
try {
fileSystem.accessSync(runtimePath, nodeFileSystem.constants.R_OK);
} catch {
return validationFailure('runtime-library-invalid');
}
if (stat.size !== runtimeFile.size) {
return validationFailure('runtime-library-size-mismatch');
}
let contents: Buffer;
try {
contents = fileSystem.readFileSync(runtimePath);
} catch {
return validationFailure('runtime-library-invalid');
}
if (contents.length !== runtimeFile.size) {
return validationFailure('runtime-library-size-mismatch');
}
const actualSha256 = createHash('sha256')
.update(contents)
.digest('hex');
if (actualSha256 !== runtimeFile.sha256) {
return validationFailure('runtime-library-hash-mismatch');
}
}
return { value: true };
}
function validatePackage(
helperPath: string,
manifestPath: string,
fileSystem: EmbeddedMpvFrameCopyRuntimeFileSystem
): ValidationResult<ValidatedPackage> {
const nativeDir = path.dirname(helperPath);
if (
path.basename(helperPath) !== FRAME_COPY_HELPER_NAME ||
path.dirname(manifestPath) !== nativeDir
) {
return validationFailure('runtime-artifact-invalid');
}
const manifestArtifact = validateRegularArtifact(
manifestPath,
nodeFileSystem.constants.R_OK,
0o644,
fileSystem
);
if (isValidationFailure(manifestArtifact)) {
return validationFailure(
manifestArtifact.reason === 'runtime-artifact-missing'
? 'runtime-manifest-missing'
: 'runtime-manifest-invalid'
);
}
const manifestResult = readManifest(manifestPath, fileSystem);
if (isValidationFailure(manifestResult)) {
return manifestResult;
}
const validManifest = validateManifest(manifestResult.value);
if (isValidationFailure(validManifest)) {
return validManifest;
}
for (const [artifactPath, accessMode, expectedMode] of [
[
path.join(nativeDir, FRAME_COPY_ADDON_NAME),
nodeFileSystem.constants.R_OK,
null,
],
[
path.join(nativeDir, FRAME_COPY_READER_NAME),
nodeFileSystem.constants.R_OK,
0o644,
],
[
helperPath,
nodeFileSystem.constants.R_OK | nodeFileSystem.constants.X_OK,
0o755,
],
] as const) {
const artifact = validateRegularArtifact(
artifactPath,
accessMode,
expectedMode,
fileSystem
);
if (isValidationFailure(artifact)) {
return artifact;
}
}
const libDir = path.join(nativeDir, 'lib');
if (validManifest.value.runtimeMode === 'system') {
try {
if (pathExistsByLstat(libDir, fileSystem)) {
return validationFailure('runtime-library-directory-invalid');
}
} catch {
return validationFailure('runtime-library-directory-invalid');
}
} else {
const bundledRuntime = validateBundledRuntimeFiles(
nativeDir,
validManifest.value.runtimeFiles,
fileSystem
);
if (isValidationFailure(bundledRuntime)) {
return bundledRuntime;
}
}
return {
value: {
manifest: validManifest.value,
helperPath,
nativeDir,
},
};
}
function parseSuccessfulProbe(
stdout: unknown,
manifest: ValidManifest
): EmbeddedMpvFrameCopyRuntimeResult {
if (typeof stdout !== 'string' || !/^[^\r\n]+\n$/.test(stdout)) {
return failure('helper-probe-invalid-output');
}
let parsed: unknown;
try {
parsed = JSON.parse(stdout.slice(0, -1));
} catch {
return failure('helper-probe-invalid-output');
}
if (!isObject(parsed)) {
return failure('helper-probe-invalid-output');
}
if (parsed.protocol !== RUNTIME_PROBE_PROTOCOL) {
return failure('helper-probe-protocol-mismatch');
}
if (parsed.usable !== true) {
return failure(
parsed.usable === false
? 'helper-probe-unusable'
: 'helper-probe-invalid-output'
);
}
if (
!hasExactFields(parsed, [
'libmpv',
'protocol',
'renderApi',
'usable',
]) ||
typeof parsed.libmpv !== 'string' ||
parsed.libmpv.trim() === '' ||
parsed.renderApi !== 'egl'
) {
return failure('helper-probe-invalid-output');
}
return {
usable: true,
profile: manifest.profile,
runtimeMode: manifest.runtimeMode,
libmpv: parsed.libmpv,
renderApi: parsed.renderApi,
};
}
function runHelperProbe(
runtimePackage: ValidatedPackage,
dependencies: EmbeddedMpvFrameCopyRuntimeDependencies
): EmbeddedMpvFrameCopyRuntimeResult {
const probeEnvironment = { ...dependencies.env };
if (runtimePackage.manifest.runtimeMode === 'bundled') {
const bundledLibDir = path.join(runtimePackage.nativeDir, 'lib');
probeEnvironment.LD_LIBRARY_PATH =
bundledLibDir +
(probeEnvironment.LD_LIBRARY_PATH
? `${LINUX_LIBRARY_PATH_DELIMITER}${probeEnvironment.LD_LIBRARY_PATH}`
: '');
}
let result: ReturnType<typeof nodeSpawnSync>;
try {
result = dependencies.spawnSync(
runtimePackage.helperPath,
['--runtime-probe'],
{
encoding: 'utf8',
timeout: RUNTIME_PROBE_TIMEOUT_MS,
windowsHide: true,
env: probeEnvironment,
}
);
} catch {
return failure('helper-probe-spawn-error');
}
if (
result.error &&
'code' in result.error &&
result.error.code === 'ETIMEDOUT'
) {
return failure('helper-probe-timeout');
}
if (result.error) {
return failure('helper-probe-spawn-error');
}
if (result.signal) {
return failure('helper-probe-signaled');
}
if (result.status !== 0) {
return failure('helper-probe-failed');
}
return parseSuccessfulProbe(result.stdout, runtimePackage.manifest);
}
/**
* Creates an isolated probe/cache. Production uses the singleton below;
* tests inject filesystem and spawn collaborators through this factory.
*/
export function createEmbeddedMpvFrameCopyRuntimeProbe(
overrides: Partial<EmbeddedMpvFrameCopyRuntimeDependencies> = {}
): (helperPath: string) => EmbeddedMpvFrameCopyRuntimeResult {
const defaultFileSystem: EmbeddedMpvFrameCopyRuntimeFileSystem = {
accessSync: (filePath, mode) =>
nodeFileSystem.accessSync(filePath, mode),
lstatSync: (filePath) => nodeFileSystem.lstatSync(filePath),
readFileSync: (filePath) => nodeFileSystem.readFileSync(filePath),
readdirSync: (filePath) => nodeFileSystem.readdirSync(filePath),
};
const dependencies: EmbeddedMpvFrameCopyRuntimeDependencies = {
platform: process.platform,
arch: process.arch,
env: process.env,
fileSystem: defaultFileSystem,
spawnSync: nodeSpawnSync,
...overrides,
};
const resultCache = new Map<string, EmbeddedMpvFrameCopyRuntimeResult>();
return (helperPath: string): EmbeddedMpvFrameCopyRuntimeResult => {
if (dependencies.platform !== 'linux') {
return failure('unsupported-platform');
}
if (dependencies.arch !== 'x64') {
return failure('unsupported-architecture');
}
const manifestPath = path.join(
path.dirname(helperPath),
RUNTIME_MANIFEST_NAME
);
let helperStat: nodeFileSystem.Stats;
let manifestStat: nodeFileSystem.Stats;
try {
helperStat = dependencies.fileSystem.lstatSync(helperPath);
} catch {
return failure('runtime-artifact-missing');
}
try {
manifestStat = dependencies.fileSystem.lstatSync(manifestPath);
} catch (error) {
return failure(
isMissingFileError(error)
? 'runtime-manifest-missing'
: 'runtime-manifest-invalid'
);
}
const cacheKey = `${fileIdentity(
helperPath,
helperStat
)}\0${fileIdentity(manifestPath, manifestStat)}`;
const cached = resultCache.get(cacheKey);
if (cached) {
return cached;
}
let result: EmbeddedMpvFrameCopyRuntimeResult;
try {
const runtimePackage = validatePackage(
helperPath,
manifestPath,
dependencies.fileSystem
);
result = isValidationFailure(runtimePackage)
? failure(runtimePackage.reason)
: runHelperProbe(runtimePackage.value, dependencies);
} catch {
result = failure('runtime-probe-internal-error');
}
resultCache.set(cacheKey, result);
return result;
};
}
const processRuntimeProbe = createEmbeddedMpvFrameCopyRuntimeProbe();
/**
* Fail-closed, process-lifetime Linux runtime decision shared by startup and
* the service gate. The helper and manifest identities scope cached results.
*/
export function probeEmbeddedMpvFrameCopyRuntime(
helperPath: string
): EmbeddedMpvFrameCopyRuntimeResult {
return processRuntimeProbe(helperPath);
}
@@ -149,7 +149,12 @@ describe('EmbeddedMpvFrameCopyAdapter', () => {
'size\twidth=1600\theight=900\n'
);
const writesBefore = child.stdin.written.length;
adapter.setBounds(sessionId, { x: -10000, y: -10000, width: 1, height: 1 });
adapter.setBounds(sessionId, {
x: -10000,
y: -10000,
width: 1,
height: 1,
});
expect(child.stdin.written.length).toBe(writesBefore);
});
@@ -192,7 +197,7 @@ describe('EmbeddedMpvFrameCopyAdapter', () => {
['darwin', 'arm64', true],
['darwin', 'x64', false],
['linux', 'x64', true],
['linux', 'arm64', true],
['linux', 'arm64', false],
['win32', 'x64', true],
['freebsd', 'x64', false],
])(
@@ -43,6 +43,10 @@ describe('Embedded MPV native source recording invariants', () => {
path.resolve(__dirname, '../../../native/helper/frame_helper_render.h'),
'utf8'
);
const frameHelperSource = readFileSync(
path.resolve(__dirname, '../../../native/helper/mpv_frame_helper.cpp'),
'utf8'
);
const frameHelperGlSource = readFileSync(
path.resolve(__dirname, '../../../native/helper/frame_helper_gl.h'),
'utf8'
@@ -756,6 +760,59 @@ describe('Embedded MPV native source recording invariants', () => {
);
});
it('runs the helper runtime probe without shared memory, media, or command loops', () => {
const runtimeProbe = sourceFunctionBody(
frameHelperSource,
'int runRuntimeProbe(',
'runRuntimeProbe'
);
const main = sourceFunctionBody(frameHelperSource, 'int main(', 'main');
const runtimeProbeFailure = sourceFunctionBody(
frameHelperSource,
'int runtimeProbeFailure(',
'runtimeProbeFailure'
);
expect(main).toContain('if (args.runtimeProbe) {');
expect(main).toContain('return runRuntimeProbe();');
expect(runtimeProbe).toContain('mpv_create()');
expect(runtimeProbe).toContain('"idle", "yes"');
expect(runtimeProbe).toContain('"vo", "libmpv"');
expect(runtimeProbe).toContain('mpv_initialize(mpv)');
expect(runtimeProbe).toContain('GlContext gl;');
expect(runtimeProbe).toContain('gl.create(error)');
expect(runtimeProbe).toContain('gl.makeCurrent(error)');
expect(runtimeProbe).toContain('mpv_render_context_create(');
expect(runtimeProbe).toContain('mpv_render_context_free(');
expect(runtimeProbe).toContain('gl.destroy()');
expect(runtimeProbe).toContain('mpv_terminate_destroy(mpv)');
expect(runtimeProbe).toContain('.num("protocol", 1)');
expect(runtimeProbe).toContain('.boolean("usable", true)');
expect(runtimeProbe).toContain('.str("libmpv",');
expect(runtimeProbe).toContain('.str("renderApi", gl.renderApiName())');
expect(runtimeProbe).not.toContain('pipeline');
expect(runtimeProbe).not.toContain('shm');
expect(runtimeProbe).not.toContain('runStdinLoop');
expect(runtimeProbe).not.toContain('runMpvEventLoop');
expect(runtimeProbe).not.toContain('loadfile');
expect(runtimeProbe.match(/emitLine\(/g)).toHaveLength(1);
expect(runtimeProbeFailure).toContain('.num("protocol", 1)');
expect(runtimeProbeFailure).toContain('.boolean("usable", false)');
expect(runtimeProbeFailure).toContain('.str("reason", reason)');
expect(runtimeProbeFailure.match(/emitLine\(/g)).toHaveLength(1);
expect(runtimeProbeFailure).toContain('return 1;');
});
it('identifies the Linux helper runtime probe render API as EGL', () => {
const renderApiName = sourceFunctionBody(
linuxFrameHelperGlSource,
'const char* renderApiName() const',
'GlContext::renderApiName'
);
expect(renderApiName).toContain('return "egl";');
});
it('validates complete EGL candidates and keeps software rendering as the final fallback', () => {
const tryCandidate = sourceFunctionBody(
linuxFrameHelperGlSource,
@@ -3,24 +3,30 @@ import type {
EmbeddedMpvSessionStatus,
ResolvedPortalPlayback,
} from '@iptvnator/shared/interfaces';
import {
chmodSync,
existsSync,
mkdirSync,
mkdtempSync,
rmSync,
writeFileSync,
} from 'fs';
import { 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';
const mockSpawnSync = jest.fn();
const mockIsFrameCopyRuntimeUsable = jest.fn<boolean, []>();
const mockGetFrameCopyRuntimeAvailability = jest.fn();
jest.mock('child_process', () => ({
spawnSync: mockSpawnSync,
}));
jest.mock('./embedded-mpv-frame-copy-platform.util', () => {
const actual = jest.requireActual(
'./embedded-mpv-frame-copy-platform.util'
);
return {
...actual,
getFrameCopyRuntimeAvailability: mockGetFrameCopyRuntimeAvailability,
isFrameCopyRuntimeUsable: mockIsFrameCopyRuntimeUsable,
};
});
const powerSaveBlockerMock = {
start: jest.fn<number, [string]>(),
stop: jest.fn<void, [number]>(),
@@ -138,6 +144,13 @@ describe('EmbeddedMpvNativeService power blocker', () => {
mockSpawnSync.mockReturnValue({
status: 0,
});
mockIsFrameCopyRuntimeUsable.mockReset();
mockIsFrameCopyRuntimeUsable.mockReturnValue(false);
mockGetFrameCopyRuntimeAvailability.mockReset();
mockGetFrameCopyRuntimeAvailability.mockReturnValue({
usable: false,
reason: 'helper-probe-failed',
});
mainWindowGetNativeWindowHandleMock.mockReset();
mainWindowGetNativeWindowHandleMock.mockReturnValue(Buffer.alloc(8));
mainWindowSendMock.mockReset();
@@ -230,14 +243,9 @@ describe('EmbeddedMpvNativeService power blocker', () => {
// no helper on disk => the engine env flag is ignored, native keeps
// working, and support does not advertise frame-copy.
process.env.IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY = '1';
jest.spyOn(
service as unknown as {
resolveFrameCopyHelperPath: () => string | null;
},
'resolveFrameCopyHelperPath'
).mockReturnValue(null);
try {
expect(service.getActiveEngine()).toBe('native');
expect(mockIsFrameCopyRuntimeUsable).toHaveBeenCalledWith();
const support = service.getSupport();
expect(support.engine).not.toBe('frame-copy');
startSession('s-fallback', snapshot('loading'));
@@ -262,37 +270,23 @@ describe('EmbeddedMpvNativeService power blocker', () => {
});
(process.platform === 'win32' ? it.skip : it)(
'falls back to the native engine when the frame-copy helper is not executable',
'falls back to the native engine when the frame-copy runtime probe fails',
() => {
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);
expect(mockIsFrameCopyRuntimeUsable).toHaveBeenCalledWith();
expect(service.getSupport()).toEqual(
expect.objectContaining({
engine: 'native',
frameCopyAvailable: false,
frameCopyUnavailableReason: 'helper-probe-failed',
})
);
} finally {
delete process.env.IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY;
cwdSpy.mockRestore();
}
}
);
@@ -300,13 +294,11 @@ describe('EmbeddedMpvNativeService power blocker', () => {
describe('frame-copy platform gate', () => {
const originalArch = process.arch;
function mockHelperPresent(): void {
jest.spyOn(
service as unknown as {
resolveFrameCopyHelperPath: () => string | null;
},
'resolveFrameCopyHelperPath'
).mockReturnValue('/native/iptvnator_mpv_helper');
function mockRuntimeUsable(): void {
mockIsFrameCopyRuntimeUsable.mockReturnValue(true);
mockGetFrameCopyRuntimeAvailability.mockReturnValue({
usable: true,
});
}
afterEach(() => {
@@ -322,7 +314,7 @@ describe('EmbeddedMpvNativeService power blocker', () => {
process.env.DISPLAY = ':0';
process.env.WAYLAND_DISPLAY = 'wayland-0';
process.env.IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY = '1';
mockHelperPresent();
mockRuntimeUsable();
expect(service.getActiveEngine()).toBe('frame-copy');
expect(service.isFrameCopyAvailable()).toBe(true);
@@ -342,7 +334,7 @@ describe('EmbeddedMpvNativeService power blocker', () => {
Object.defineProperty(process, 'platform', { value: 'linux' });
process.env.DISPLAY = ':0';
process.env.WAYLAND_DISPLAY = 'wayland-0';
mockHelperPresent();
mockRuntimeUsable();
const support = service.getSupport();
expect(support.supported).toBe(false);
@@ -355,7 +347,7 @@ describe('EmbeddedMpvNativeService power blocker', () => {
process.env.DISPLAY = ':0';
delete process.env.WAYLAND_DISPLAY;
mockSpawnSync.mockReturnValue({ status: 1 });
mockHelperPresent();
mockRuntimeUsable();
const support = service.getSupport();
expect(support.supported).toBe(false);
@@ -370,7 +362,7 @@ describe('EmbeddedMpvNativeService power blocker', () => {
delete process.env.WAYLAND_DISPLAY;
process.env.IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY = '1';
mockSpawnSync.mockReturnValue({ status: 1 });
mockHelperPresent();
mockRuntimeUsable();
expect(service.getSupport()).toEqual(
expect.objectContaining({
@@ -383,7 +375,7 @@ describe('EmbeddedMpvNativeService power blocker', () => {
it('activates the frame-copy engine on macOS arm64', () => {
Object.defineProperty(process, 'arch', { value: 'arm64' });
process.env.IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY = '1';
mockHelperPresent();
mockRuntimeUsable();
expect(service.getActiveEngine()).toBe('frame-copy');
expect(service.isFrameCopyAvailable()).toBe(true);
@@ -392,7 +384,7 @@ describe('EmbeddedMpvNativeService power blocker', () => {
it('keeps the frame-copy engine Apple-Silicon-only on macOS', () => {
Object.defineProperty(process, 'arch', { value: 'x64' });
process.env.IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY = '1';
mockHelperPresent();
mockIsFrameCopyRuntimeUsable.mockReturnValue(false);
expect(service.getActiveEngine()).toBe('native');
expect(service.isFrameCopyAvailable()).toBe(false);
@@ -401,7 +393,7 @@ describe('EmbeddedMpvNativeService power blocker', () => {
it('skips the native window handle when creating a frame-copy session', () => {
Object.defineProperty(process, 'platform', { value: 'linux' });
process.env.IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY = '1';
mockHelperPresent();
mockRuntimeUsable();
const frameCopyAddon = createMockAddon();
frameCopyAddon.createSession.mockReturnValueOnce('s-fc');
frameCopyAddon.getSessionSnapshot.mockReturnValueOnce(
@@ -425,9 +417,7 @@ describe('EmbeddedMpvNativeService power blocker', () => {
// dispatches to the adapter that owns the session, not the
// native addon the outer afterEach shutdown would pick.
service.disposeSession('s-fc');
expect(frameCopyAddon.disposeSession).toHaveBeenCalledWith(
's-fc'
);
expect(frameCopyAddon.disposeSession).toHaveBeenCalledWith('s-fc');
});
});
@@ -455,9 +445,7 @@ describe('EmbeddedMpvNativeService power blocker', () => {
expect.arrayContaining(['render-process-gone', 'did-navigate'])
);
const consoleWarnSpy = jest
.spyOn(console, 'warn')
.mockImplementation();
const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation();
handlers.get('did-navigate')?.();
expect(addon.disposeSession).toHaveBeenCalledWith('s1');
@@ -29,6 +29,7 @@ import {
} from '@iptvnator/shared/interfaces';
import { EmbeddedMpvFrameCopyAdapter } from './embedded-mpv-frame-copy.adapter';
import {
getFrameCopyRuntimeAvailability,
getEmbeddedMpvAddonCandidatePaths,
isFrameCopyRuntimeUsable,
resolveFrameCopyHelperPath,
@@ -127,10 +128,7 @@ export class EmbeddedMpvNativeService {
// Requires the helper binary too: a stale opt-in (cleaned native
// build, bad install) must fall back to the native engine instead
// of leaving embedded MPV unsupported with no way to recover.
return (
this.isFrameCopyEngineRequested() &&
isFrameCopyRuntimeUsable(() => this.resolveFrameCopyHelperPath())
);
return this.isFrameCopyEngineRequested() && isFrameCopyRuntimeUsable();
}
getActiveEngine(): EmbeddedMpvEngine {
@@ -138,9 +136,21 @@ export class EmbeddedMpvNativeService {
}
isFrameCopyAvailable(): boolean {
return isFrameCopyRuntimeUsable(() =>
this.resolveFrameCopyHelperPath()
);
return isFrameCopyRuntimeUsable();
}
private getFrameCopySupportDetails(): Pick<
EmbeddedMpvSupport,
'frameCopyAvailable' | 'frameCopyUnavailableReason'
> {
const availability = getFrameCopyRuntimeAvailability();
if (!('reason' in availability)) {
return { frameCopyAvailable: true };
}
return {
frameCopyAvailable: false,
frameCopyUnavailableReason: availability.reason,
};
}
getFrameSource(sessionId: string): EmbeddedMpvFrameSource | null {
@@ -150,7 +160,7 @@ export class EmbeddedMpvNativeService {
private getFrameCopyAdapter(): EmbeddedMpvFrameCopyAdapter {
if (!this.frameCopyAdapter) {
this.frameCopyAdapter = new EmbeddedMpvFrameCopyAdapter({
resolveHelperPath: () => this.resolveFrameCopyHelperPath(),
resolveHelperPath: resolveFrameCopyHelperPath,
getScaleFactor: () => this.getMainWindowScaleFactor(),
onFrameSourceChanged: (sessionId, source) => {
if (!App.mainWindow || App.mainWindow.isDestroyed()) {
@@ -166,12 +176,6 @@ export class EmbeddedMpvNativeService {
return this.frameCopyAdapter;
}
private resolveFrameCopyHelperPath(): string | 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 {
try {
if (!App.mainWindow || App.mainWindow.isDestroyed()) {
@@ -229,7 +233,7 @@ export class EmbeddedMpvNativeService {
supported: false,
platform: process.platform,
reason: 'Embedded MPV on Linux currently requires X11 or Xwayland. Native Wayland embedding is not supported yet.',
frameCopyAvailable: this.isFrameCopyAvailable(),
...this.getFrameCopySupportDetails(),
};
}
@@ -249,7 +253,7 @@ export class EmbeddedMpvNativeService {
supported: true,
platform: process.platform,
engine: 'frame-copy',
frameCopyAvailable: true,
...this.getFrameCopySupportDetails(),
capabilities: this.detectCapabilities(),
};
}
@@ -261,7 +265,7 @@ export class EmbeddedMpvNativeService {
supported: false,
platform: process.platform,
reason: missingLinuxMpvExecutableReason,
frameCopyAvailable: this.isFrameCopyAvailable(),
...this.getFrameCopySupportDetails(),
};
}
@@ -279,7 +283,7 @@ export class EmbeddedMpvNativeService {
supported: true,
platform: process.platform,
engine: this.getActiveEngine(),
frameCopyAvailable: this.isFrameCopyAvailable(),
...this.getFrameCopySupportDetails(),
capabilities: this.detectCapabilities(),
};
} catch (error) {
@@ -345,7 +349,7 @@ export class EmbeddedMpvNativeService {
supported: true,
platform: process.platform,
engine: this.getActiveEngine(),
frameCopyAvailable: this.isFrameCopyAvailable(),
...this.getFrameCopySupportDetails(),
capabilities: this.detectCapabilities(),
};
} catch (error) {
@@ -377,7 +381,7 @@ export class EmbeddedMpvNativeService {
supported: true,
platform: process.platform,
engine: this.getActiveEngine(),
frameCopyAvailable: this.isFrameCopyAvailable(),
...this.getFrameCopySupportDetails(),
capabilities: this.detectCapabilities(),
};
} catch (error) {
@@ -709,8 +713,9 @@ export class EmbeddedMpvNativeService {
});
};
App.mainWindow.webContents.on('render-process-gone', (_event, details) =>
disposeAll(`process gone (${details.reason})`)
App.mainWindow.webContents.on(
'render-process-gone',
(_event, details) => disposeAll(`process gone (${details.reason})`)
);
// Full navigations/reloads only — in-app Angular routing emits
// did-navigate-in-page and must not kill the active session.
@@ -37,11 +37,16 @@ export interface EmbeddedMpvSupport {
engine?: EmbeddedMpvEngine;
/**
* True when this machine could run the frame-copy engine (macOS arm64
* or Linux, with the helper binary present), regardless of whether it
* is active.
* or Linux x64, after its helper/runtime capability gate), regardless of
* whether it is active.
* Drives the Settings toggle; switching engines requires an app restart.
*/
frameCopyAvailable?: boolean;
/**
* Stable fail-closed capability reason when frameCopyAvailable is false.
* Intended for startup tracing and support diagnostics, not user copy.
*/
frameCopyUnavailableReason?: string;
}
/**