diff --git a/apps/electron-backend/native/helper/frame_helper_gl.h b/apps/electron-backend/native/helper/frame_helper_gl.h index ef4ec7a06..927bfd5e3 100644 --- a/apps/electron-backend/native/helper/frame_helper_gl.h +++ b/apps/electron-backend/native/helper/frame_helper_gl.h @@ -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; diff --git a/apps/electron-backend/native/helper/mpv_frame_helper.cpp b/apps/electron-backend/native/helper/mpv_frame_helper.cpp index 9cb577fc1..60b484cc9 100644 --- a/apps/electron-backend/native/helper/mpv_frame_helper.cpp +++ b/apps/electron-backend/native/helper/mpv_frame_helper.cpp @@ -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(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) { diff --git a/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-platform.util.spec.ts b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-platform.util.spec.ts index 77986aeb0..27abce4c7 100644 --- a/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-platform.util.spec.ts +++ b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-platform.util.spec.ts @@ -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 & { diff --git a/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-platform.util.ts b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-platform.util.ts index b412c5c98..55f00431f 100644 --- a/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-platform.util.ts +++ b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-platform.util.ts @@ -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( diff --git a/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime.spec.ts b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime.spec.ts new file mode 100644 index 000000000..f7a506ee4 --- /dev/null +++ b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime.spec.ts @@ -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; + runtimeContents: Record; +} + +function sha256(contents: Buffer): string { + return createHash('sha256').update(contents).digest('hex'); +} + +function createRuntimeFiles( + runtimeContents: Record +): 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 = { + 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 +): void { + writeFileSync(manifestPath, `${JSON.stringify(manifest)}\n`, { + mode: 0o644, + }); + chmodSync(manifestPath, 0o644); +} + +function cloneManifest( + manifest: Record +): Record { + return JSON.parse(JSON.stringify(manifest)) as Record; +} + +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 = {} + ) { + 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 + ); + }); +}); diff --git a/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime.ts b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime.ts new file mode 100644 index 000000000..1073f24f1 --- /dev/null +++ b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime.ts @@ -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 { + value: T; +} + +interface ValidationFailure { + reason: EmbeddedMpvFrameCopyRuntimeFailureReason; +} + +type ValidationResult = ValidationSuccess | ValidationFailure; + +function failure( + reason: EmbeddedMpvFrameCopyRuntimeFailureReason +): EmbeddedMpvFrameCopyRuntimeResult { + return { usable: false, reason }; +} + +function validationFailure( + reason: EmbeddedMpvFrameCopyRuntimeFailureReason +): ValidationFailure { + return { reason }; +} + +function isValidationFailure( + result: ValidationResult +): result is ValidationFailure { + return 'reason' in result; +} + +function isObject(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +function hasExactFields( + value: Record, + 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> { + 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 +): 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(); + 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 +): ValidationResult { + 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 { + 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 { + 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 { + 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; + 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 = {} +): (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(); + + 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); +} diff --git a/apps/electron-backend/src/app/services/embedded-mpv-frame-copy.adapter.spec.ts b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy.adapter.spec.ts index 26fa33b55..e0dc4ba82 100644 --- a/apps/electron-backend/src/app/services/embedded-mpv-frame-copy.adapter.spec.ts +++ b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy.adapter.spec.ts @@ -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], ])( diff --git a/apps/electron-backend/src/app/services/embedded-mpv-native-source.spec.ts b/apps/electron-backend/src/app/services/embedded-mpv-native-source.spec.ts index 857d3cafd..57e01df3f 100644 --- a/apps/electron-backend/src/app/services/embedded-mpv-native-source.spec.ts +++ b/apps/electron-backend/src/app/services/embedded-mpv-native-source.spec.ts @@ -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, diff --git a/apps/electron-backend/src/app/services/embedded-mpv-native.service.spec.ts b/apps/electron-backend/src/app/services/embedded-mpv-native.service.spec.ts index 3df3c7160..69bfd282e 100644 --- a/apps/electron-backend/src/app/services/embedded-mpv-native.service.spec.ts +++ b/apps/electron-backend/src/app/services/embedded-mpv-native.service.spec.ts @@ -3,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(); +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(), stop: jest.fn(), @@ -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'); diff --git a/apps/electron-backend/src/app/services/embedded-mpv-native.service.ts b/apps/electron-backend/src/app/services/embedded-mpv-native.service.ts index 4f967a36a..4cba7f713 100644 --- a/apps/electron-backend/src/app/services/embedded-mpv-native.service.ts +++ b/apps/electron-backend/src/app/services/embedded-mpv-native.service.ts @@ -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. diff --git a/libs/shared/interfaces/src/lib/embedded-mpv-session.interface.ts b/libs/shared/interfaces/src/lib/embedded-mpv-session.interface.ts index 7c26a1253..74fd73112 100644 --- a/libs/shared/interfaces/src/lib/embedded-mpv-session.interface.ts +++ b/libs/shared/interfaces/src/lib/embedded-mpv-session.interface.ts @@ -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; } /**