mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-11 19:46:16 -08:00
feat(embedded-mpv): probe Linux frame-copy runtime
This commit is contained in:
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) {
|
||||
|
||||
+87
-3
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in new issue
Block a user