diff --git a/apps/electron-backend/build-embedded-mpv.js b/apps/electron-backend/build-embedded-mpv.js index 432f46486..444bf6452 100644 --- a/apps/electron-backend/build-embedded-mpv.js +++ b/apps/electron-backend/build-embedded-mpv.js @@ -1,3 +1,4 @@ +const crypto = require('crypto'); const fs = require('fs'); const path = require('path'); const { spawnSync } = require('child_process'); @@ -10,6 +11,15 @@ const { const { removeStaleFrameCopyArtifacts, } = require('../../tools/packaging/embedded-mpv-frame-copy-files.cjs'); +const { + isLinuxSystemBuildInputManifest, + validateLinuxRuntimeManifest, + validateLinuxSystemBuildInputManifest, +} = require('../../tools/embedded-mpv/linux-runtime-manifest.cjs'); + +const LINUX_PACKAGE_RUNTIME_MODES = Object.freeze(['system', 'bundled']); +const LINUX_STAGED_RUNTIME_ORIGIN = 'vendored-lgpl'; +const LINUX_SOURCE_RUNTIME_ORIGIN = 'vendored-lgpl-source-build'; const workspaceRoot = process.cwd(); const addonRoot = path.join( @@ -109,6 +119,124 @@ function readRuntimeManifest(runtimeRoot) { return JSON.parse(fs.readFileSync(manifestPath, 'utf8')); } +function validatedLinuxSourceRuntime(runtimeRoot) { + const stagedManifest = readRuntimeManifest(runtimeRoot); + if ( + stagedManifest === null || + typeof stagedManifest !== 'object' || + Array.isArray(stagedManifest) + ) { + throw new Error( + `Staged Linux runtime manifest must be an object: ${path.join( + runtimeRoot, + 'runtime-manifest.json' + )}` + ); + } + + const envelopeErrors = []; + if (stagedManifest.origin !== LINUX_STAGED_RUNTIME_ORIGIN) { + envelopeErrors.push(`origin must be "${LINUX_STAGED_RUNTIME_ORIGIN}"`); + } + if (stagedManifest.platform !== 'linux') { + envelopeErrors.push('platform must be "linux"'); + } + if (stagedManifest.arch !== targetArch) { + envelopeErrors.push(`arch must be "${targetArch}"`); + } + if ( + typeof stagedManifest.stagedAt !== 'string' || + stagedManifest.stagedAt.trim().length === 0 + ) { + envelopeErrors.push('stagedAt must be a non-empty string'); + } + if (!Array.isArray(stagedManifest.runtimeFiles)) { + envelopeErrors.push('runtimeFiles must be an array'); + } + + const systemBuildInputs = isLinuxSystemBuildInputManifest(stagedManifest); + let buildInputMode; + let sourceRuntimeManifest; + if (systemBuildInputs) { + buildInputMode = 'system-build-inputs'; + sourceRuntimeManifest = { + linuxBackend: stagedManifest.linuxBackend, + buildInputs: stagedManifest.buildInputs, + sourceDistribution: stagedManifest.sourceDistribution, + }; + if ( + Array.isArray(stagedManifest.runtimeFiles) && + stagedManifest.runtimeFiles.length !== 0 + ) { + envelopeErrors.push( + 'system build inputs must not declare staged runtime files' + ); + } + if (stagedManifest.sourceBuildOrigin !== undefined) { + envelopeErrors.push( + 'system build inputs must not declare sourceBuildOrigin' + ); + } + } else { + buildInputMode = 'bundled-runtime'; + const sourceBuildOrigin = stagedManifest.sourceBuildOrigin; + const sourceMetadata = { ...stagedManifest }; + delete sourceMetadata.sourceBuildOrigin; + delete sourceMetadata.stagedAt; + sourceRuntimeManifest = { + ...sourceMetadata, + origin: sourceBuildOrigin, + }; + if (sourceBuildOrigin !== LINUX_SOURCE_RUNTIME_ORIGIN) { + envelopeErrors.push( + `sourceBuildOrigin must be "${LINUX_SOURCE_RUNTIME_ORIGIN}"` + ); + } + } + + const sourceManifestErrors = + buildInputMode === 'system-build-inputs' + ? validateLinuxSystemBuildInputManifest(sourceRuntimeManifest) + : validateLinuxRuntimeManifest(sourceRuntimeManifest); + const declaredRuntimeFileNames = Array.isArray( + sourceRuntimeManifest.runtimeFiles + ) + ? sourceRuntimeManifest.runtimeFiles + .map((runtimeFile) => runtimeFile.name) + .sort() + : []; + const stagedRuntimeFileNames = listRuntimeFiles( + path.join(runtimeRoot, 'lib'), + runtimeFilePredicate + ) + .map((runtimeFile) => path.basename(runtimeFile)) + .sort(); + if ( + JSON.stringify(stagedRuntimeFileNames) !== + JSON.stringify(declaredRuntimeFileNames) + ) { + envelopeErrors.push( + 'staged lib directory must exactly match manifest runtimeFiles' + ); + } + + const errors = [...envelopeErrors, ...sourceManifestErrors]; + if (errors.length > 0) { + throw new Error( + [ + `Invalid staged Linux runtime at ${runtimeRoot}:`, + ...errors.map((error) => `- ${error}`), + ].join('\n') + ); + } + + return { + buildInputMode, + sourceRuntimeManifest, + sourceRuntimeValidated: true, + }; +} + function fileExists(filePath) { return fs.existsSync(filePath) && fs.statSync(filePath).isFile(); } @@ -211,15 +339,19 @@ function findLinuxLibMpv(libDir) { } function resolveRuntime() { + const vendoredHeader = path.join(vendoredIncludeDir, 'mpv', 'client.h'); + const stagedLinuxRuntime = + targetPlatform === 'linux' && fs.existsSync(vendoredHeader) + ? validatedLinuxSourceRuntime(vendoredRuntimeRoot) + : null; const vendoredLibMpv = targetPlatform === 'darwin' ? findLibMpv(vendoredLibDir) : targetPlatform === 'win32' ? findWindowsLibMpv(vendoredRuntimeRoot) : targetPlatform === 'linux' - ? true + ? stagedLinuxRuntime : null; - const vendoredHeader = path.join(vendoredIncludeDir, 'mpv', 'client.h'); if (vendoredLibMpv && fs.existsSync(vendoredHeader)) { const windowsImportLib = @@ -237,6 +369,7 @@ function resolveRuntime() { binDir: vendoredBinDir, manifest: readRuntimeManifest(vendoredRuntimeRoot), windowsImportLib, + ...(stagedLinuxRuntime ?? {}), }; } @@ -248,6 +381,16 @@ function resolveRuntime() { const systemIncludeDir = process.env.LIBMPV_INCLUDE_DIR || '/usr/include'; if (fs.existsSync(path.join(systemIncludeDir, 'mpv', 'client.h'))) { + if (embeddedMpvRequired) { + throw new Error( + 'Required Linux builds must use an explicit staged runtime manifest.' + ); + } + + const sourceRuntimeManifest = { + linuxBackend: 'process-isolated mpv --wid', + warning: 'Development-only unmanaged system libmpv toolchain.', + }; return { origin: 'system-dev', includeDir: systemIncludeDir, @@ -255,10 +398,10 @@ function resolveRuntime() { process.env.LINUX_NATIVE_LIBRARY_DIR || defaultLinuxSystemLibDir(), binDir: undefined, - manifest: { - warning: - 'Development-only system libmpv toolchain. Release packaging keeps the external-mpv-process contract.', - }, + manifest: sourceRuntimeManifest, + buildInputMode: 'system-dev', + sourceRuntimeManifest, + sourceRuntimeValidated: false, windowsImportLib: null, }; } @@ -288,19 +431,96 @@ function resolveRuntime() { return null; } -function writeLinuxProcessRuntimeManifest(runtime) { +function copyLinuxRuntimeClosureToNativeBuild(runtime) { fs.rmSync(outputLibDir, { recursive: true, force: true }); + const declaredRuntimeFiles = + runtime.buildInputMode === 'bundled-runtime' + ? runtime.sourceRuntimeManifest.runtimeFiles + : []; + if (declaredRuntimeFiles.length === 0) { + return []; + } + + fs.mkdirSync(outputLibDir, { recursive: true }); + const copiedRuntimeFiles = []; + for (const runtimeFile of declaredRuntimeFiles) { + const sourcePath = path.join(runtime.libDir, runtimeFile.name); + let descriptor; + try { + descriptor = fs.openSync( + sourcePath, + fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW + ); + const stat = fs.fstatSync(descriptor); + if (!stat.isFile()) { + throw new Error( + `Staged Linux runtime path is not a regular file: ${sourcePath}` + ); + } + + const contents = fs.readFileSync(descriptor); + if (contents.byteLength !== runtimeFile.size) { + throw new Error( + `Size mismatch for staged Linux runtime file ${runtimeFile.name}: expected ${runtimeFile.size}, received ${contents.byteLength}.` + ); + } + const actualSha256 = crypto + .createHash('sha256') + .update(contents) + .digest('hex'); + if (actualSha256 !== runtimeFile.sha256) { + throw new Error( + `SHA-256 mismatch for staged Linux runtime file ${runtimeFile.name}: expected ${runtimeFile.sha256}, received ${actualSha256}.` + ); + } + + const destinationPath = path.join(outputLibDir, runtimeFile.name); + fs.writeFileSync(destinationPath, contents, { mode: 0o755 }); + copiedRuntimeFiles.push({ ...runtimeFile }); + } finally { + if (descriptor !== undefined) { + fs.closeSync(descriptor); + } + } + } + + return copiedRuntimeFiles; +} + +function writeLinuxFrameCopyBuildManifest(runtime) { + const copiedRuntimeFiles = copyLinuxRuntimeClosureToNativeBuild(runtime); const manifest = { - ...runtime.manifest, - origin: 'external-mpv-process', + schemaVersion: 1, + origin: 'linux-frame-copy-build', generatedAt: new Date().toISOString(), - runtimeFiles: [], - linuxBackend: - runtime.manifest.linuxBackend ?? 'process-isolated mpv --wid', - mpvExecutable: 'mpv', platform: targetPlatform, - targetArch, + arch: targetArch, + buildInputMode: runtime.buildInputMode, + sourceRuntimeValidated: runtime.sourceRuntimeValidated, + allowedPackageRuntimeModes: [...LINUX_PACKAGE_RUNTIME_MODES], + packageRuntimeAvailability: { + system: true, + bundled: copiedRuntimeFiles.length > 0, + }, + artifacts: { + addon: 'embedded_mpv.node', + frameReader: 'embedded_mpv_frame_reader.node', + helper: 'iptvnator_mpv_helper', + }, + processIsolation: { + addonLoadsLibmpv: false, + helperLinksLibmpv: true, + helperRunpath: ['$ORIGIN/lib'], + }, + nativeViewFallback: 'process-isolated mpv --wid', + libmpvSoname: 'libmpv.so.2', + runtimeFiles: copiedRuntimeFiles, + runtimeTotalBytes: copiedRuntimeFiles.reduce( + (total, runtimeFile) => total + runtimeFile.size, + 0 + ), + sourceRuntime: runtime.sourceRuntimeManifest, }; fs.writeFileSync( @@ -477,7 +697,7 @@ function main() { }, }) : targetPlatform === 'linux' - ? writeLinuxProcessRuntimeManifest(runtime) + ? writeLinuxFrameCopyBuildManifest(runtime) : copyGenericRuntimeToNativeBuild(runtime); fs.rmSync(unavailableMarkerFile, { force: true }); @@ -496,8 +716,7 @@ function main() { LIBMPV_INCLUDE_DIR: runtime.includeDir, ...(targetPlatform === 'linux' ? { - LINUX_NATIVE_LIBRARY_DIR: - process.env.LINUX_NATIVE_LIBRARY_DIR || runtime.libDir, + LINUX_NATIVE_LIBRARY_DIR: runtime.libDir, } : { LIBMPV_LIBRARY_DIR: outputLibDir }), ...(runtime.windowsImportLib diff --git a/apps/electron-backend/native/binding.gyp b/apps/electron-backend/native/binding.gyp index 4ab060049..33edd65f0 100644 --- a/apps/electron-backend/native/binding.gyp +++ b/apps/electron-backend/native/binding.gyp @@ -158,8 +158,8 @@ ], "ldflags": [ "-pthread", - "-Wl,-rpath,'$$ORIGIN/lib'", - "-Wl,-rpath, { expect(buildScriptSource).toContain('cleanNativeBuildIntermediates();'); }); - it('does not stage Linux libmpv runtime libraries', () => { - expect(stageRuntimeSource).toContain( - "if (platform !== 'linux') {\n" + - ' copyDirectory(sourceLibDir, destinationLibDir, runtimeFileFilter);\n' + - ' }' + it('validates and copies only the staged Linux shared-library closure', () => { + expect(buildScriptSource).toContain( + "require('../../tools/embedded-mpv/linux-runtime-manifest.cjs')" + ); + expect(buildScriptSource).toContain( + 'validateLinuxRuntimeManifest(sourceRuntimeManifest)' + ); + expect(buildScriptSource).toContain( + 'validateLinuxSystemBuildInputManifest(sourceRuntimeManifest)' + ); + expect(buildScriptSource).toContain( + 'copyLinuxRuntimeClosureToNativeBuild(runtime)' + ); + expect(buildScriptSource).toContain( + 'runtime.sourceRuntimeManifest.runtimeFiles' + ); + expect(buildScriptSource).toContain( + 'SHA-256 mismatch for staged Linux runtime file' + ); + expect(buildScriptSource).toContain( + 'LINUX_NATIVE_LIBRARY_DIR: runtime.libDir' + ); + expect(buildScriptSource).not.toContain( + 'process.env.LINUX_NATIVE_LIBRARY_DIR || runtime.libDir' + ); + }); + + it('writes a profile-neutral Linux frame-copy build manifest', () => { + expect(buildScriptSource).toContain( + "const LINUX_PACKAGE_RUNTIME_MODES = Object.freeze(['system', 'bundled']);" + ); + expect(buildScriptSource).toContain("origin: 'linux-frame-copy-build'"); + expect(buildScriptSource).toContain( + 'allowedPackageRuntimeModes: [...LINUX_PACKAGE_RUNTIME_MODES]' + ); + expect(buildScriptSource).toContain( + 'buildInputMode: runtime.buildInputMode' + ); + expect(buildScriptSource).toContain( + 'sourceRuntime: runtime.sourceRuntimeManifest' + ); + expect(buildScriptSource).toContain('runtimeFiles: copiedRuntimeFiles'); + expect(buildScriptSource).not.toContain( + 'writeLinuxProcessRuntimeManifest' ); }); @@ -617,12 +656,9 @@ describe('Embedded MPV native source recording invariants', () => { expect(buildAndMakeWorkflowSource).toContain( 'Linux frame-copy helper must link libmpv' ); - expect(buildScriptSource).toContain("origin: 'external-mpv-process'"); - expect(buildScriptSource).toContain('writeLinuxProcessRuntimeManifest'); - expect(buildScriptSource).toContain('runtimeFiles: []'); }); - it('supports Linux system-development inputs without leaving stale frame-copy artifacts', () => { + it('keeps optional Linux system development separate from required staged inputs', () => { expect(buildScriptSource).toContain( "const systemIncludeDir =\n process.env.LIBMPV_INCLUDE_DIR || '/usr/include';" ); @@ -635,6 +671,9 @@ describe('Embedded MPV native source recording invariants', () => { expect(buildScriptSource).toContain( "if (!embeddedMpvRequired && runtime.origin === 'system-dev')" ); + expect(buildScriptSource).toContain( + 'Required Linux builds must use an explicit staged runtime manifest.' + ); expect(buildScriptSource).toContain( 'removeStaleFrameCopyArtifacts(outputDir);' ); @@ -817,6 +856,7 @@ describe('Embedded MPV native build configuration', () => { )?.[1]; expect(linuxAddonConfig?.libraries).not.toContain('-lmpv'); + expect(JSON.stringify(linuxAddonConfig)).not.toContain('-lmpv'); expect(linuxHelperConfig?.libraries).toEqual( expect.arrayContaining([ '-lmpv', @@ -826,5 +866,16 @@ describe('Embedded MPV native build configuration', () => { '-ldl', ]) ); + + const runtimeLinkerFlags = linuxHelperConfig?.ldflags.filter( + (flag: string) => flag.startsWith('-Wl,') + ); + expect(runtimeLinkerFlags).toEqual([ + '-Wl,--enable-new-dtags', + "-Wl,-rpath,'$$ORIGIN/lib'", + ]); + expect(JSON.stringify(runtimeLinkerFlags)).not.toContain( + 'LINUX_NATIVE_LIBRARY_DIR' + ); }); });