feat(embedded-mpv): add Windows frame-copy support (#1175)

Port the embedded mpv frame-copy pipeline to Windows with WGL rendering and named shared memory. Includes packaging validation, platform gates, tests, and architecture documentation.
This commit is contained in:
4gray authored and GitHub committed 2026-07-15 21:27:56 +02:00
1 parent 7d75d989e8
commit 59e08fd2d6
41 files changed
+1284 -157

No files matched your search

@@ -102,6 +102,37 @@ const embeddedMpvWin32Source = fs.readFileSync(
);
const { validatePackagedEmbeddedMpv } = require('./embedded-mpv-packaging.cjs');
function writeWindowsHelperFixture(helperPath, importedDllName) {
const peOffset = 0x80;
const optionalHeaderOffset = peOffset + 24;
const optionalHeaderSize = 0xf0;
const sectionTableOffset = optionalHeaderOffset + optionalHeaderSize;
const importRva = 0x1000;
const importRawOffset = 0x200;
const importNameOffset = 0x30;
const image = Buffer.alloc(0x400);
image.write('MZ', 0, 'ascii');
image.writeUInt32LE(peOffset, 0x3c);
image.write('PE\0\0', peOffset, 'ascii');
image.writeUInt16LE(0x8664, peOffset + 4);
image.writeUInt16LE(1, peOffset + 6);
image.writeUInt16LE(optionalHeaderSize, peOffset + 20);
image.writeUInt16LE(0x20b, optionalHeaderOffset);
image.writeUInt32LE(0x200, optionalHeaderOffset + 60);
image.writeUInt32LE(16, optionalHeaderOffset + 108);
image.writeUInt32LE(importRva, optionalHeaderOffset + 120);
image.writeUInt32LE(40, optionalHeaderOffset + 124);
image.write('.idata\0\0', sectionTableOffset, 'ascii');
image.writeUInt32LE(0x200, sectionTableOffset + 8);
image.writeUInt32LE(importRva, sectionTableOffset + 12);
image.writeUInt32LE(0x200, sectionTableOffset + 16);
image.writeUInt32LE(importRawOffset, sectionTableOffset + 20);
image.writeUInt32LE(importRva + importNameOffset, importRawOffset + 12);
image.write(`${importedDllName}\0`, importRawOffset + importNameOffset);
fs.writeFileSync(helperPath, image);
}
test('Linux package identity does not expose the internal Electron backend project name', () => {
assert.equal(electronBuilderConfig.productName, 'IPTVnator');
assert.equal(electronBuilderConfig.extraMetadata?.name, 'iptvnator');
@@ -119,8 +150,10 @@ test('Linux package identity does not expose the internal Electron backend proje
});
test('GitHub Releases auto-update metadata is generated and uploaded', () => {
// \r?\n keeps this host-agnostic: Windows checkouts with autocrlf see
// CRLF in the workflow file.
const releaseFiles = buildAndMakeWorkflow.match(
/files: \|\n([\s\S]*?)\n\s+env:/
/files: \|\r?\n([\s\S]*?)\r?\n\s+env:/
)?.[1];
assert.ok(releaseFiles, 'release upload files block must exist');
@@ -304,10 +337,16 @@ test('embedded MPV package validation accepts Windows runtime files and Linux pr
for (const [platform, runtimeFile] of [
['windows', 'mpv-2.dll'],
['windows', 'libmpv-2.dll'],
['windows', join('lib', 'mpv.dll')],
['windows', join('lib', 'libmpv.dll')],
['windows', 'mpv.dll'],
['windows', 'libmpv.dll'],
]) {
const resourceDir = join(tempDir, platform);
// One fixture dir per runtime-file scenario: the frame-copy
// artifacts written below must not leak into the next
// iteration's missing-artifact assertions.
const resourceDir = join(
tempDir,
`${platform}-${runtimeFile.replace(/[\\/]/g, '_')}`
);
const nativeDir = join(
resourceDir,
'app.asar.unpacked',
@@ -322,6 +361,32 @@ test('embedded MPV package validation accepts Windows runtime files and Linux pr
);
fs.writeFileSync(join(nativeDir, runtimeFile), '');
// Windows packages that ship the addon must also ship the
// frame-copy engine artifacts built by the same binding.gyp run.
const missingWindowsFrameCopyErrors = validatePackagedEmbeddedMpv(
resourceDir,
{ platform, required: true }
);
assert.ok(
missingWindowsFrameCopyErrors.some((error) =>
error.includes('iptvnator_mpv_helper.exe')
)
);
assert.ok(
missingWindowsFrameCopyErrors.some((error) =>
error.includes('embedded_mpv_frame_reader.node')
)
);
writeWindowsHelperFixture(
join(nativeDir, 'iptvnator_mpv_helper.exe'),
runtimeFile
);
fs.writeFileSync(
join(nativeDir, 'embedded_mpv_frame_reader.node'),
''
);
assert.deepEqual(
validatePackagedEmbeddedMpv(resourceDir, {
platform,
@@ -371,10 +436,10 @@ test('embedded MPV package validation accepts Windows runtime files and Linux pr
// Host-agnostic assertion: on non-macOS hosts the validator also
// reports that link validation needs a macOS host, so only the
// frame-copy artifact requirement is asserted here.
const remainingErrors = validatePackagedEmbeddedMpv(
darwinResourceDir,
{ platform: 'darwin', required: true }
);
const remainingErrors = validatePackagedEmbeddedMpv(darwinResourceDir, {
platform: 'darwin',
required: true,
});
assert.ok(
!remainingErrors.some((error) =>
error.includes('frame-copy artifact')
@@ -408,6 +473,138 @@ test('embedded MPV package validation accepts Windows runtime files and Linux pr
}
});
test('Windows frame-copy packages reject an mpv DLL that exists only under native/lib', () => {
const tempDir = fs.mkdtempSync(join(os.tmpdir(), 'iptvnator-mpv-package-'));
try {
for (const runtimeFile of [
'mpv-2.dll',
'libmpv-2.dll',
'mpv.dll',
'libmpv.dll',
]) {
const resourceDir = join(tempDir, runtimeFile);
const nativeDir = join(
resourceDir,
'app.asar.unpacked',
'electron-backend',
'native'
);
fs.mkdirSync(join(nativeDir, 'lib'), { recursive: true });
fs.writeFileSync(join(nativeDir, 'embedded_mpv.node'), '');
fs.writeFileSync(
join(nativeDir, 'embedded-mpv-runtime.json'),
JSON.stringify({ origin: 'vendored-lgpl' })
);
writeWindowsHelperFixture(
join(nativeDir, 'iptvnator_mpv_helper.exe'),
runtimeFile
);
fs.writeFileSync(
join(nativeDir, 'embedded_mpv_frame_reader.node'),
''
);
fs.writeFileSync(join(nativeDir, 'lib', runtimeFile), '');
const errors = validatePackagedEmbeddedMpv(resourceDir, {
platform: 'windows',
required: true,
});
assert.ok(
errors.some(
(error) =>
error.includes(
'beside the Windows frame-copy helper'
) && error.includes(nativeDir)
),
`${runtimeFile} under native/lib must not satisfy the helper DLL requirement: ${errors.join('; ')}`
);
}
} finally {
fs.rmSync(tempDir, { recursive: true, force: true });
}
});
test('Windows frame-copy packages require the DLL imported by the helper', () => {
const tempDir = fs.mkdtempSync(join(os.tmpdir(), 'iptvnator-mpv-package-'));
try {
const nativeDir = join(
tempDir,
'app.asar.unpacked',
'electron-backend',
'native'
);
fs.mkdirSync(nativeDir, { recursive: true });
fs.writeFileSync(join(nativeDir, 'embedded_mpv.node'), '');
fs.writeFileSync(
join(nativeDir, 'embedded-mpv-runtime.json'),
JSON.stringify({ origin: 'vendored-lgpl' })
);
writeWindowsHelperFixture(
join(nativeDir, 'iptvnator_mpv_helper.exe'),
'mpv-2.dll'
);
fs.writeFileSync(join(nativeDir, 'embedded_mpv_frame_reader.node'), '');
fs.writeFileSync(join(nativeDir, 'libmpv.dll'), '');
const errors = validatePackagedEmbeddedMpv(tempDir, {
platform: 'windows',
required: true,
});
assert.ok(
errors.some(
(error) =>
error.includes('imports mpv-2.dll') &&
error.includes(join(nativeDir, 'mpv-2.dll'))
),
`a different accepted DLL must not satisfy the helper import: ${errors.join('; ')}`
);
} finally {
fs.rmSync(tempDir, { recursive: true, force: true });
}
});
test('Windows frame-copy package validation fails closed for a malformed helper', () => {
const tempDir = fs.mkdtempSync(join(os.tmpdir(), 'iptvnator-mpv-package-'));
try {
const nativeDir = join(
tempDir,
'app.asar.unpacked',
'electron-backend',
'native'
);
fs.mkdirSync(nativeDir, { recursive: true });
fs.writeFileSync(join(nativeDir, 'embedded_mpv.node'), '');
fs.writeFileSync(
join(nativeDir, 'embedded-mpv-runtime.json'),
JSON.stringify({ origin: 'vendored-lgpl' })
);
fs.writeFileSync(join(nativeDir, 'iptvnator_mpv_helper.exe'), 'MZ');
fs.writeFileSync(join(nativeDir, 'embedded_mpv_frame_reader.node'), '');
fs.writeFileSync(join(nativeDir, 'mpv-2.dll'), '');
const errors = validatePackagedEmbeddedMpv(tempDir, {
platform: 'windows',
required: true,
});
assert.ok(
errors.some((error) =>
error.includes(
'Unable to inspect Windows frame-copy helper imports'
)
),
`malformed helper must fail package validation: ${errors.join('; ')}`
);
} finally {
fs.rmSync(tempDir, { recursive: true, force: true });
}
});
test('Windows embedded MPV staging preserves import-library DLL basenames', () => {
assert.match(
embeddedMpvStageRuntimeSource,
@@ -472,10 +669,7 @@ test('frame-copy packaging file operations enforce modes and remove stale artifa
try {
const helperPath = join(tempDir, 'iptvnator_mpv_helper');
const windowsHelperPath = join(
tempDir,
'iptvnator_mpv_helper.exe'
);
const windowsHelperPath = join(tempDir, 'iptvnator_mpv_helper.exe');
const readerPath = join(tempDir, 'embedded_mpv_frame_reader.node');
fs.writeFileSync(helperPath, '#!/bin/sh\n');
fs.chmodSync(helperPath, 0o644);
+267 -15
View File
@@ -4,6 +4,12 @@ const { spawnSync } = require('child_process');
const forbiddenRuntimePathPrefixes = ['/opt/homebrew/', '/usr/local/'];
const systemRuntimePathPrefixes = ['/System/Library/', '/usr/lib/'];
const windowsMpvRuntimeNames = [
'mpv-2.dll',
'libmpv-2.dll',
'mpv.dll',
'libmpv.dll',
];
function run(command, args, options = {}) {
const result = spawnSync(command, args, {
@@ -67,6 +73,177 @@ function listRuntimeFiles(directoryPath) {
.sort();
}
function assertPeRange(image, offset, size, label) {
if (
!Number.isSafeInteger(offset) ||
!Number.isSafeInteger(size) ||
offset < 0 ||
size < 0 ||
offset + size > image.length
) {
throw new Error(`Invalid PE image: ${label} is out of range.`);
}
}
function readPeImportedDllNames(binaryPath) {
const image = fs.readFileSync(binaryPath);
assertPeRange(image, 0, 0x40, 'DOS header');
if (image.toString('ascii', 0, 2) !== 'MZ') {
throw new Error('Invalid PE image: missing DOS signature.');
}
const peOffset = image.readUInt32LE(0x3c);
assertPeRange(image, peOffset, 24, 'PE header');
if (image.toString('ascii', peOffset, peOffset + 4) !== 'PE\0\0') {
throw new Error('Invalid PE image: missing PE signature.');
}
const sectionCount = image.readUInt16LE(peOffset + 6);
const optionalHeaderSize = image.readUInt16LE(peOffset + 20);
const optionalHeaderOffset = peOffset + 24;
assertPeRange(
image,
optionalHeaderOffset,
optionalHeaderSize,
'optional header'
);
const optionalMagic = image.readUInt16LE(optionalHeaderOffset);
const dataDirectoryOffset =
optionalMagic === 0x20b
? optionalHeaderOffset + 112
: optionalMagic === 0x10b
? optionalHeaderOffset + 96
: null;
const directoryCountOffset =
optionalMagic === 0x20b
? optionalHeaderOffset + 108
: optionalMagic === 0x10b
? optionalHeaderOffset + 92
: null;
if (dataDirectoryOffset === null || directoryCountOffset === null) {
throw new Error(
`Invalid PE image: unsupported optional-header magic 0x${optionalMagic.toString(16)}.`
);
}
const optionalHeaderEnd = optionalHeaderOffset + optionalHeaderSize;
if (
directoryCountOffset + 4 > optionalHeaderEnd ||
dataDirectoryOffset + 16 > optionalHeaderEnd
) {
throw new Error(
'Invalid PE image: data directories exceed the optional header.'
);
}
assertPeRange(image, directoryCountOffset, 4, 'data-directory count');
if (image.readUInt32LE(directoryCountOffset) < 2) {
return [];
}
assertPeRange(image, dataDirectoryOffset + 8, 8, 'import directory');
const importRva = image.readUInt32LE(dataDirectoryOffset + 8);
const importSize = image.readUInt32LE(dataDirectoryOffset + 12);
if (importRva === 0 && importSize === 0) {
return [];
}
if (importRva === 0 || importSize < 20) {
throw new Error('Invalid PE image: malformed import directory.');
}
const sectionTableOffset = optionalHeaderOffset + optionalHeaderSize;
assertPeRange(
image,
sectionTableOffset,
sectionCount * 40,
'section table'
);
const sections = Array.from({ length: sectionCount }, (_, index) => {
const offset = sectionTableOffset + index * 40;
return {
virtualSize: image.readUInt32LE(offset + 8),
virtualAddress: image.readUInt32LE(offset + 12),
rawSize: image.readUInt32LE(offset + 16),
rawOffset: image.readUInt32LE(offset + 20),
};
});
const rvaToLocation = (rva, label) => {
const section = sections.find(
(candidate) =>
rva >= candidate.virtualAddress &&
rva <
candidate.virtualAddress +
Math.max(candidate.virtualSize, candidate.rawSize)
);
if (!section) {
throw new Error(
`Invalid PE image: ${label} RVA 0x${rva.toString(16)} is not mapped.`
);
}
const sectionOffset = rva - section.virtualAddress;
if (sectionOffset >= section.rawSize) {
throw new Error(
`Invalid PE image: ${label} is outside section file data.`
);
}
const offset = section.rawOffset + sectionOffset;
assertPeRange(image, offset, 1, label);
return {
offset,
sectionEnd: section.rawOffset + section.rawSize,
};
};
const importedDllNames = [];
const descriptorCount = Math.floor(importSize / 20);
let foundTerminator = false;
for (let index = 0; index < descriptorCount; index += 1) {
const descriptorLocation = rvaToLocation(
importRva + index * 20,
'import descriptor'
);
const descriptorOffset = descriptorLocation.offset;
if (descriptorOffset + 20 > descriptorLocation.sectionEnd) {
throw new Error(
'Invalid PE image: import descriptor crosses section file data.'
);
}
assertPeRange(image, descriptorOffset, 20, 'import descriptor');
const descriptor = image.subarray(
descriptorOffset,
descriptorOffset + 20
);
if (descriptor.every((value) => value === 0)) {
foundTerminator = true;
break;
}
const nameRva = image.readUInt32LE(descriptorOffset + 12);
const nameLocation = rvaToLocation(nameRva, 'imported DLL name');
const nameOffset = nameLocation.offset;
const nameEnd = image.indexOf(0, nameOffset);
if (nameEnd < 0 || nameEnd >= nameLocation.sectionEnd) {
throw new Error(
'Invalid PE image: imported DLL name is not terminated.'
);
}
const name = image.toString('ascii', nameOffset, nameEnd);
if (!name) {
throw new Error('Invalid PE image: imported DLL name is empty.');
}
importedDllNames.push(name);
}
if (!foundTerminator) {
throw new Error(
'Invalid PE image: import descriptor table has no terminator.'
);
}
return [...new Set(importedDllNames)];
}
function parseOtoolDependencies(binaryPath) {
const output = run('otool', ['-L', binaryPath]);
return output
@@ -426,16 +603,12 @@ function getPackagedRuntimeCandidates(libDir, platform, nativeDir) {
path.join(libDir, 'libmpv.dylib'),
];
case 'win32':
return [
nativeDir ? path.join(nativeDir, 'mpv-2.dll') : null,
nativeDir ? path.join(nativeDir, 'libmpv-2.dll') : null,
nativeDir ? path.join(nativeDir, 'mpv.dll') : null,
nativeDir ? path.join(nativeDir, 'libmpv.dll') : null,
path.join(libDir, 'mpv-2.dll'),
path.join(libDir, 'libmpv-2.dll'),
path.join(libDir, 'mpv.dll'),
path.join(libDir, 'libmpv.dll'),
].filter(Boolean);
return windowsMpvRuntimeNames.flatMap((name) =>
[
nativeDir ? path.join(nativeDir, name) : null,
path.join(libDir, name),
].filter(Boolean)
);
case 'linux':
return [];
default:
@@ -480,7 +653,11 @@ function getEmbeddedMpvAddonArch(env = process.env) {
* dist output. Those packages must not ship a foreign-architecture
* `embedded_mpv.node` — it can never load and produces a cryptic error.
*/
function isForeignLinuxEmbeddedMpvArch(platform, targetArch, env = process.env) {
function isForeignLinuxEmbeddedMpvArch(
platform,
targetArch,
env = process.env
) {
if (normalizeEmbeddedMpvPlatform(platform) !== 'linux') {
return false;
}
@@ -540,12 +717,16 @@ function validatePackagedEmbeddedMpv(resourceDir, options = {}) {
return errors;
}
if (platform === 'darwin') {
if (platform === 'darwin' || platform === 'win32') {
// The frame-copy engine artifacts are built by the same binding.gyp
// run as the addon; a macOS package that ships the addon without
// them would silently lose the engine (support probe hides it).
// run as the addon; a macOS/Windows package that ships the addon
// without them would silently lose the engine (support probe hides
// it). Linux packages intentionally strip the helper until the
// bundled-libmpv runtime lands (see electron-after-pack.cjs).
const missingFrameCopyArtifacts = [
'iptvnator_mpv_helper',
platform === 'win32'
? 'iptvnator_mpv_helper.exe'
: 'iptvnator_mpv_helper',
'embedded_mpv_frame_reader.node',
]
.map((name) => path.join(unpackedNativeDir, name))
@@ -556,6 +737,77 @@ function validatePackagedEmbeddedMpv(resourceDir, options = {}) {
`Missing embedded MPV frame-copy artifact: ${artifactPath}`
)
);
if (platform === 'win32') {
// The helper is a separate executable. Windows resolves its
// imported libmpv DLL from the executable directory, so a copy
// under native/lib may satisfy addon bookkeeping but cannot
// start iptvnator_mpv_helper.exe.
const helperPath = path.join(
unpackedNativeDir,
'iptvnator_mpv_helper.exe'
);
const helperRuntimeCandidates = windowsMpvRuntimeNames.map((name) =>
path.join(unpackedNativeDir, name)
);
if (
!helperRuntimeCandidates.some((candidate) =>
fs.existsSync(candidate)
)
) {
errors.push(
[
`Missing bundled MPV DLL beside the Windows frame-copy helper in ${unpackedNativeDir}.`,
'Expected one of:',
...helperRuntimeCandidates.map(
(candidate) => `- ${candidate}`
),
].join('\n')
);
}
if (fs.existsSync(helperPath)) {
try {
const acceptedRuntimeNames = new Set(
windowsMpvRuntimeNames.map((name) => name.toLowerCase())
);
const importedRuntimeNames = readPeImportedDllNames(
helperPath
).filter((name) =>
acceptedRuntimeNames.has(name.toLowerCase())
);
if (importedRuntimeNames.length === 0) {
errors.push(
`Windows frame-copy helper does not import a supported MPV DLL: ${helperPath}`
);
}
const packagedRuntimeNames = new Set(
fs
.readdirSync(unpackedNativeDir, {
withFileTypes: true,
})
.filter((entry) => entry.isFile())
.map((entry) => entry.name.toLowerCase())
);
for (const importedRuntimeName of importedRuntimeNames) {
if (
!packagedRuntimeNames.has(
importedRuntimeName.toLowerCase()
)
) {
errors.push(
`Windows frame-copy helper imports ${importedRuntimeName}, but the matching DLL is missing beside it: ${path.join(unpackedNativeDir, importedRuntimeName)}`
);
}
}
} catch (error) {
errors.push(
`Unable to inspect Windows frame-copy helper imports at ${helperPath}: ${error instanceof Error ? error.message : String(error)}`
);
}
}
}
}
if (!fs.existsSync(manifestPath)) {