mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-11 11:06:16 -08:00
Harden Linux embedded MPV packaging (#1043)
This commit is contained in:
1 parent
bc7d0fd1b0
commit
4094a36fef
22 files changed
+2220
-288
No files matched your search
@@ -68,7 +68,7 @@ jobs:
|
||||
if: matrix.os == 'linux'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install --no-install-recommends -y rpm libarchive-tools flatpak flatpak-builder appstream libx11-dev libxext-dev
|
||||
sudo apt-get install --no-install-recommends -y rpm libarchive-tools flatpak flatpak-builder appstream libx11-dev libxext-dev libmpv-dev mpv pkg-config
|
||||
|
||||
# Configure Flatpak
|
||||
# 1. Add the Flathub repository (source of runtimes)
|
||||
@@ -204,17 +204,54 @@ jobs:
|
||||
pnpm embedded-mpv:build-runtime -- "${{ matrix.embedded_mpv_arch }}" "${RUNTIME_PREFIX}"
|
||||
pnpm embedded-mpv:stage-runtime -- "${{ matrix.embedded_mpv_platform }}" "${{ matrix.embedded_mpv_arch }}" "${RUNTIME_PREFIX}"
|
||||
|
||||
- name: Stage Linux embedded MPV build inputs
|
||||
if: matrix.os == 'linux'
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
RUNTIME_PREFIX="${RUNNER_TEMP}/embedded-mpv-runtime/linux-x64/prefix"
|
||||
rm -rf "${RUNTIME_PREFIX}"
|
||||
mkdir -p "${RUNTIME_PREFIX}/include"
|
||||
|
||||
cp -a /usr/include/mpv "${RUNTIME_PREFIX}/include/"
|
||||
|
||||
LIBMPV_DEV_VERSION="$(dpkg-query -W -f='${Version}' libmpv-dev)"
|
||||
MPV_VERSION="$(dpkg-query -W -f='${Version}' mpv)"
|
||||
export RUNTIME_PREFIX LIBMPV_DEV_VERSION MPV_VERSION
|
||||
node <<'NODE'
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const manifest = {
|
||||
linuxBackend: 'process-isolated mpv --wid',
|
||||
buildInputs: {
|
||||
libmpvDevPackage: process.env.LIBMPV_DEV_VERSION,
|
||||
mpvPackage: process.env.MPV_VERSION,
|
||||
},
|
||||
sourceDistribution:
|
||||
'Linux CI build inputs come from Ubuntu runner packages. Runtime playback uses the system mpv executable; IPTVnator does not bundle or load libmpv in the Electron process on Linux.',
|
||||
};
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(process.env.RUNTIME_PREFIX, 'runtime-manifest.json'),
|
||||
`${JSON.stringify(manifest, null, 2)}\n`
|
||||
);
|
||||
NODE
|
||||
|
||||
pnpm embedded-mpv:stage-runtime -- linux x64 "${RUNTIME_PREFIX}"
|
||||
|
||||
- name: Build backend
|
||||
env:
|
||||
IPTVNATOR_EMBEDDED_MPV_PLATFORM: ${{ matrix.embedded_mpv_platform || '' }}
|
||||
IPTVNATOR_EMBEDDED_MPV_ARCH: ${{ matrix.embedded_mpv_arch || matrix.arch || '' }}
|
||||
IPTVNATOR_REQUIRE_EMBEDDED_MPV: ${{ ((matrix.os == 'macos' && (startsWith(github.ref, 'refs/tags/v') || github.event_name == 'pull_request' || github.ref == 'refs/heads/master')) || (steps.embedded-mpv-runtime-cache.outputs.cache-hit == 'true')) && '1' || '0' }}
|
||||
IPTVNATOR_REQUIRE_EMBEDDED_MPV: ${{ (matrix.os == 'linux' || (matrix.os == 'macos' && (startsWith(github.ref, 'refs/tags/v') || github.event_name == 'pull_request' || github.ref == 'refs/heads/master')) || (steps.embedded-mpv-runtime-cache.outputs.cache-hit == 'true')) && '1' || '0' }}
|
||||
run: pnpm run build:backend
|
||||
|
||||
- name: Verify embedded MPV build output
|
||||
# TEMPORARY ARTIFACT TEST: remove `|| github.event_name == 'pull_request' || github.ref == 'refs/heads/master'`
|
||||
# after the macOS Embedded MPV artifacts are built and manually tested.
|
||||
if: (matrix.os == 'macos' && (startsWith(github.ref, 'refs/tags/v') || github.event_name == 'pull_request' || github.ref == 'refs/heads/master')) || steps.embedded-mpv-runtime-cache.outputs.cache-hit == 'true'
|
||||
if: matrix.os == 'linux' || (matrix.os == 'macos' && (startsWith(github.ref, 'refs/tags/v') || github.event_name == 'pull_request' || github.ref == 'refs/heads/master')) || steps.embedded-mpv-runtime-cache.outputs.cache-hit == 'true'
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
@@ -238,7 +275,17 @@ jobs:
|
||||
test -f dist/apps/electron-backend/native/lib/mpv-2.dll || test -f dist/apps/electron-backend/native/lib/mpv.dll
|
||||
;;
|
||||
linux)
|
||||
test -f dist/apps/electron-backend/native/lib/libmpv.so.2 || test -f dist/apps/electron-backend/native/lib/libmpv.so.1 || test -f dist/apps/electron-backend/native/lib/libmpv.so
|
||||
node -e "const manifest = require('./dist/apps/electron-backend/native/embedded-mpv-runtime.json'); if (manifest.origin !== 'external-mpv-process') { throw new Error('Linux embedded MPV manifest must use external-mpv-process origin.'); }"
|
||||
if find dist/apps/electron-backend/native/lib -name 'libmpv.so*' -print -quit 2>/dev/null | grep -q .; then
|
||||
echo "::error::Linux embedded MPV packages must not bundle libmpv"
|
||||
find dist/apps/electron-backend/native/lib -name 'libmpv.so*' -print
|
||||
exit 1
|
||||
fi
|
||||
if ldd dist/apps/electron-backend/native/embedded_mpv.node | grep -q 'libmpv'; then
|
||||
echo "::error::Linux embedded MPV addon must not link directly to libmpv"
|
||||
ldd dist/apps/electron-backend/native/embedded_mpv.node
|
||||
exit 1
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
|
||||
@@ -424,7 +471,7 @@ jobs:
|
||||
IPTVNATOR_EMBEDDED_MPV_ARCH: ${{ matrix.embedded_mpv_arch || matrix.arch || '' }}
|
||||
# TEMPORARY PR TEST: change this back to '0' after manually
|
||||
# testing the macOS PR artifact with Embedded MPV included.
|
||||
IPTVNATOR_REQUIRE_EMBEDDED_MPV: ${{ ((matrix.os == 'macos' && github.event_name == 'pull_request') || (steps.embedded-mpv-runtime-cache.outputs.cache-hit == 'true')) && '1' || '0' }}
|
||||
IPTVNATOR_REQUIRE_EMBEDDED_MPV: ${{ (matrix.os == 'linux' || (matrix.os == 'macos' && github.event_name == 'pull_request') || (steps.embedded-mpv-runtime-cache.outputs.cache-hit == 'true')) && '1' || '0' }}
|
||||
run: pnpm run make:app
|
||||
|
||||
- name: Verify packaged worker layout
|
||||
@@ -434,7 +481,7 @@ jobs:
|
||||
PACKAGE_ARCH: ${{ matrix.arch || '' }}
|
||||
# TEMPORARY ARTIFACT TEST: remove `|| github.event_name == 'pull_request' || github.ref == 'refs/heads/master'`
|
||||
# after the macOS Embedded MPV artifacts are built and manually tested.
|
||||
IPTVNATOR_REQUIRE_EMBEDDED_MPV: ${{ ((matrix.os == 'macos' && (startsWith(github.ref, 'refs/tags/v') || github.event_name == 'pull_request' || github.ref == 'refs/heads/master')) || (steps.embedded-mpv-runtime-cache.outputs.cache-hit == 'true')) && '1' || '0' }}
|
||||
IPTVNATOR_REQUIRE_EMBEDDED_MPV: ${{ (matrix.os == 'linux' || (matrix.os == 'macos' && (startsWith(github.ref, 'refs/tags/v') || github.event_name == 'pull_request' || github.ref == 'refs/heads/master')) || (steps.embedded-mpv-runtime-cache.outputs.cache-hit == 'true')) && '1' || '0' }}
|
||||
run: pnpm run verify:package-layout -- "$PACKAGE_OS" "$PACKAGE_ARCH"
|
||||
|
||||
- name: Save embedded MPV runtime cache
|
||||
|
||||
+4
-3
@@ -81,6 +81,7 @@ Thumbs.db
|
||||
# Native Electron addons
|
||||
apps/electron-backend/native/build/
|
||||
apps/electron-backend/src/app/options/electron-builder.metadata.generated.json
|
||||
vendor/embedded-mpv/darwin-*/include/
|
||||
vendor/embedded-mpv/darwin-*/lib/
|
||||
vendor/embedded-mpv/darwin-*/runtime-manifest.json
|
||||
vendor/embedded-mpv/*/bin/
|
||||
vendor/embedded-mpv/*/include/
|
||||
vendor/embedded-mpv/*/lib/
|
||||
vendor/embedded-mpv/*/runtime-manifest.json
|
||||
@@ -61,42 +61,42 @@ The application is a cross-platform, open-source project built with Electron and
|
||||
|
||||
Press `?` or `Shift+/` in the workspace to open the in-app shortcuts list.
|
||||
|
||||
| Area | Shortcut | Action |
|
||||
| --- | --- | --- |
|
||||
| Global | `Ctrl/Cmd+K` | Open command palette |
|
||||
| Global | `Ctrl/Cmd+F` | Open global search in the desktop app |
|
||||
| Global | `Ctrl/Cmd+R` | Open recently viewed in the desktop app |
|
||||
| Global | `Enter` in workspace search | Submit the current search |
|
||||
| Navigation | `Ctrl/Cmd+B` | Toggle the live sidebar |
|
||||
| Navigation | `0-9` | Select an M3U channel by number |
|
||||
| Playback | `Space` / `K` | Play or pause embedded MPV playback in the desktop app |
|
||||
| Playback | `F` | Toggle embedded MPV fullscreen in the desktop app |
|
||||
| Playback | `ArrowLeft` / `ArrowRight` | Seek embedded MPV playback by 5 seconds in the desktop app |
|
||||
| Playback | `ArrowUp` / `ArrowDown` | Adjust volume by 5% |
|
||||
| Playback | `M` | Mute audio |
|
||||
| Dialogs and lists | `ArrowUp` / `ArrowDown` | Move command palette selection |
|
||||
| Dialogs and lists | `Enter` | Run the selected command or open a focused item |
|
||||
| Dialogs and lists | `Escape` | Close dialogs and dismiss overlays |
|
||||
| Area | Shortcut | Action |
|
||||
| ----------------- | --------------------------- | ---------------------------------------------------------- |
|
||||
| Global | `Ctrl/Cmd+K` | Open command palette |
|
||||
| Global | `Ctrl/Cmd+F` | Open global search in the desktop app |
|
||||
| Global | `Ctrl/Cmd+R` | Open recently viewed in the desktop app |
|
||||
| Global | `Enter` in workspace search | Submit the current search |
|
||||
| Navigation | `Ctrl/Cmd+B` | Toggle the live sidebar |
|
||||
| Navigation | `0-9` | Select an M3U channel by number |
|
||||
| Playback | `Space` / `K` | Play or pause embedded MPV playback in the desktop app |
|
||||
| Playback | `F` | Toggle embedded MPV fullscreen in the desktop app |
|
||||
| Playback | `ArrowLeft` / `ArrowRight` | Seek embedded MPV playback by 5 seconds in the desktop app |
|
||||
| Playback | `ArrowUp` / `ArrowDown` | Adjust volume by 5% |
|
||||
| Playback | `M` | Mute audio |
|
||||
| Dialogs and lists | `ArrowUp` / `ArrowDown` | Move command palette selection |
|
||||
| Dialogs and lists | `Enter` | Run the selected command or open a focused item |
|
||||
| Dialogs and lists | `Escape` | Close dialogs and dismiss overlays |
|
||||
|
||||
## Screenshots:
|
||||
|
||||
| Dashboard with recently watched content | Live channels with inline player and EPG |
|
||||
| :-------------------------------------: | :--------------------------------------: |
|
||||
|  |  |
|
||||
| Add playlist dialog for M3U, Xtream, and Stalker | Live category channel list |
|
||||
|  |  |
|
||||
| Global search across live TV, movies, and series | Manage visible live categories |
|
||||
|  |  |
|
||||
| Movie category grid with sorting and pagination | Recently added movies and series |
|
||||
|  |  |
|
||||
| VOD details with playback and download actions | Download manager |
|
||||
|  |  |
|
||||
| Multi-channel EPG grid | External MPV player support |
|
||||
|  |  |
|
||||
| Radio playback with dedicated audio player | Light theme |
|
||||
|  |  |
|
||||
| Application settings | |
|
||||
|  | |
|
||||
| Dashboard with recently watched content | Live channels with inline player and EPG |
|
||||
| :-------------------------------------------------------------------------------------------------------------: | :---------------------------------------------------------------------------------------------------: |
|
||||
|  |  |
|
||||
| Add playlist dialog for M3U, Xtream, and Stalker | Live category channel list |
|
||||
|  |  |
|
||||
| Global search across live TV, movies, and series | Manage visible live categories |
|
||||
|  |  |
|
||||
| Movie category grid with sorting and pagination | Recently added movies and series |
|
||||
|  |  |
|
||||
| VOD details with playback and download actions | Download manager |
|
||||
|  |  |
|
||||
| Multi-channel EPG grid | External MPV player support |
|
||||
|  |  |
|
||||
| Radio playback with dedicated audio player | Light theme |
|
||||
|  |  |
|
||||
| Application settings | |
|
||||
|  | |
|
||||
|
||||
_Note: First version of the application which was developed as a PWA is available in an extra git branch._
|
||||
|
||||
@@ -158,6 +158,22 @@ sudo emerge --sync gentoo-zh
|
||||
sudo emerge iptvnator-bin
|
||||
```
|
||||
|
||||
### Linux Embedded MPV Support
|
||||
|
||||
Embedded MPV on Linux is experimental and currently supports x64 desktop
|
||||
sessions where IPTVnator runs under X11 or Xwayland. Native Wayland embedding
|
||||
is not supported yet. Linux package launchers request X11 with
|
||||
`--ozone-platform=x11`, so Wayland desktops still need Xwayland available.
|
||||
|
||||
The Linux backend starts a system `mpv` executable with `--wid`, so `mpv` must
|
||||
be installed and available on `PATH`. CI validates the Linux native addon and
|
||||
standard packages on Ubuntu 22.04, with Flatpak packaging built on Ubuntu 24.04.
|
||||
Expected user targets are Ubuntu/Debian `.deb`, Arch/Manjaro `pacman`, RPM
|
||||
distributions, and AppImage on x64 systems with X11/Xwayland plus `mpv`
|
||||
installed. Flatpak and Snap builds remain available, but embedded MPV is not
|
||||
announced as supported there yet because those sandboxed formats do not expose
|
||||
the host `mpv` executable to the embedded backend by default.
|
||||
|
||||
[](https://snapcraft.io/iptvnator)
|
||||
|
||||
<a href="https://github.com/sponsors/4gray" target="_blank"><img src="https://cdn.buymeacoffee.com/buttons/default-green.png" alt="Buy Me A Coffee" width="185"></a>
|
||||
|
||||
@@ -9,7 +9,12 @@ const {
|
||||
} = require('../../tools/packaging/embedded-mpv-packaging.cjs');
|
||||
|
||||
const workspaceRoot = process.cwd();
|
||||
const addonRoot = path.join(workspaceRoot, 'apps', 'electron-backend', 'native');
|
||||
const addonRoot = path.join(
|
||||
workspaceRoot,
|
||||
'apps',
|
||||
'electron-backend',
|
||||
'native'
|
||||
);
|
||||
const outputDir = path.join(addonRoot, 'build', 'Release');
|
||||
const outputFile = path.join(outputDir, 'embedded_mpv.node');
|
||||
const outputLibDir = path.join(outputDir, 'lib');
|
||||
@@ -20,7 +25,10 @@ const distNativeDir = path.join(
|
||||
'electron-backend',
|
||||
'native'
|
||||
);
|
||||
const unavailableMarkerFile = path.join(outputDir, 'embedded-mpv-unavailable.txt');
|
||||
const unavailableMarkerFile = path.join(
|
||||
outputDir,
|
||||
'embedded-mpv-unavailable.txt'
|
||||
);
|
||||
const homebrewIncludeDir = '/opt/homebrew/include';
|
||||
const homebrewLibDir = '/opt/homebrew/lib';
|
||||
const targetPlatform =
|
||||
@@ -68,6 +76,15 @@ function cleanOutput() {
|
||||
);
|
||||
}
|
||||
|
||||
function cleanNativeBuildIntermediates() {
|
||||
fs.rmSync(outputFile, { force: true });
|
||||
fs.rmSync(path.join(outputDir, '.deps'), { recursive: true, force: true });
|
||||
fs.rmSync(path.join(outputDir, 'obj.target'), {
|
||||
recursive: true,
|
||||
force: true,
|
||||
});
|
||||
}
|
||||
|
||||
function cleanDistNativeOutput() {
|
||||
fs.rmSync(distNativeDir, { recursive: true, force: true });
|
||||
}
|
||||
@@ -164,7 +181,7 @@ function resolveRuntime() {
|
||||
: targetPlatform === 'win32'
|
||||
? findWindowsLibMpv(vendoredRuntimeRoot)
|
||||
: targetPlatform === 'linux'
|
||||
? findLinuxLibMpv(vendoredLibDir)
|
||||
? true
|
||||
: null;
|
||||
const vendoredHeader = path.join(vendoredIncludeDir, 'mpv', 'client.h');
|
||||
|
||||
@@ -211,6 +228,29 @@ function resolveRuntime() {
|
||||
return null;
|
||||
}
|
||||
|
||||
function writeLinuxProcessRuntimeManifest(runtime) {
|
||||
fs.rmSync(outputLibDir, { recursive: true, force: true });
|
||||
|
||||
const manifest = {
|
||||
...runtime.manifest,
|
||||
origin: 'external-mpv-process',
|
||||
generatedAt: new Date().toISOString(),
|
||||
runtimeFiles: [],
|
||||
linuxBackend:
|
||||
runtime.manifest.linuxBackend ?? 'process-isolated mpv --wid',
|
||||
mpvExecutable: 'mpv',
|
||||
platform: targetPlatform,
|
||||
targetArch,
|
||||
};
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(outputDir, 'embedded-mpv-runtime.json'),
|
||||
`${JSON.stringify(manifest, null, 2)}\n`
|
||||
);
|
||||
|
||||
return manifest;
|
||||
}
|
||||
|
||||
function copyFile(sourcePath, destinationPath) {
|
||||
fs.mkdirSync(path.dirname(destinationPath), { recursive: true });
|
||||
fs.copyFileSync(sourcePath, destinationPath);
|
||||
@@ -322,7 +362,9 @@ function runNodeGyp(command, env) {
|
||||
);
|
||||
|
||||
if (result.status !== 0) {
|
||||
throw new Error(`node-gyp ${command} failed with status ${result.status ?? 1}.`);
|
||||
throw new Error(
|
||||
`node-gyp ${command} failed with status ${result.status ?? 1}.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -349,9 +391,11 @@ function main() {
|
||||
const message = [
|
||||
`Skipping build because no embedded MPV runtime was found for ${targetPlatform}-${targetArch}.`,
|
||||
`Expected vendored runtime at ${vendoredRuntimeRoot}.`,
|
||||
targetPlatform === 'darwin'
|
||||
? 'For local development only, set IPTVNATOR_EMBEDDED_MPV_ALLOW_HOMEBREW=1 to use Homebrew libmpv.'
|
||||
: 'Stage a vendored LGPL-compatible runtime before requiring Embedded MPV on this platform.',
|
||||
targetPlatform === 'linux'
|
||||
? 'Stage Linux MPV build inputs before requiring Embedded MPV on this platform.'
|
||||
: targetPlatform === 'darwin'
|
||||
? 'For local development only, set IPTVNATOR_EMBEDDED_MPV_ALLOW_HOMEBREW=1 to use Homebrew libmpv.'
|
||||
: 'Stage a vendored LGPL-compatible runtime before requiring Embedded MPV on this platform.',
|
||||
].join('\n');
|
||||
if (embeddedMpvRequired) {
|
||||
throw new Error(message);
|
||||
@@ -372,15 +416,14 @@ function main() {
|
||||
...runtime.manifest,
|
||||
},
|
||||
})
|
||||
: copyGenericRuntimeToNativeBuild(runtime);
|
||||
: targetPlatform === 'linux'
|
||||
? writeLinuxProcessRuntimeManifest(runtime)
|
||||
: copyGenericRuntimeToNativeBuild(runtime);
|
||||
fs.rmSync(unavailableMarkerFile, { force: true });
|
||||
|
||||
const electronPackageJson = require(path.join(
|
||||
workspaceRoot,
|
||||
'node_modules',
|
||||
'electron',
|
||||
'package.json'
|
||||
));
|
||||
const electronPackageJson = require(
|
||||
path.join(workspaceRoot, 'node_modules', 'electron', 'package.json')
|
||||
);
|
||||
const electronVersion = electronPackageJson.version;
|
||||
const env = {
|
||||
...process.env,
|
||||
@@ -391,15 +434,23 @@ function main() {
|
||||
npm_config_build_from_source: 'true',
|
||||
npm_config_update_binary: 'false',
|
||||
LIBMPV_INCLUDE_DIR: runtime.includeDir,
|
||||
LIBMPV_LIBRARY_DIR: outputLibDir,
|
||||
...(targetPlatform === 'linux'
|
||||
? { LINUX_NATIVE_LIBRARY_DIR: runtime.libDir }
|
||||
: { LIBMPV_LIBRARY_DIR: outputLibDir }),
|
||||
...(runtime.windowsImportLib
|
||||
? { LIBMPV_IMPORT_LIB: path.join(outputLibDir, path.basename(runtime.windowsImportLib)) }
|
||||
? {
|
||||
LIBMPV_IMPORT_LIB: path.join(
|
||||
outputLibDir,
|
||||
path.basename(runtime.windowsImportLib)
|
||||
),
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
|
||||
log(
|
||||
`Building native addon against Electron ${electronVersion} using ${runtime.origin} runtime for ${targetPlatform}-${targetArch}...`
|
||||
);
|
||||
cleanNativeBuildIntermediates();
|
||||
runNodeGyp('configure', env);
|
||||
runNodeGyp('build', env);
|
||||
|
||||
@@ -415,7 +466,10 @@ function main() {
|
||||
path.join(outputLibDir, dylib)
|
||||
),
|
||||
]);
|
||||
if (runtime.origin === 'vendored-lgpl' && forbiddenLinkErrors.length > 0) {
|
||||
if (
|
||||
runtime.origin === 'vendored-lgpl' &&
|
||||
forbiddenLinkErrors.length > 0
|
||||
) {
|
||||
throw new Error(forbiddenLinkErrors.join('\n'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,14 +63,14 @@
|
||||
"sources": [
|
||||
"src/embedded_mpv_linux.cc"
|
||||
],
|
||||
"libraries": [
|
||||
"-L<!(node -p \"process.env.LIBMPV_LIBRARY_DIR || '/usr/lib'\")",
|
||||
"-lmpv",
|
||||
"-lX11",
|
||||
"-lXext"
|
||||
"defines": [
|
||||
"IPTVNATOR_DYNAMIC_LIBMPV"
|
||||
],
|
||||
"ldflags": [
|
||||
"-Wl,-rpath,\\$$ORIGIN/lib"
|
||||
"libraries": [
|
||||
"-L<!(node -p \"process.env.LINUX_NATIVE_LIBRARY_DIR || '/usr/lib'\")",
|
||||
"-lX11",
|
||||
"-lXext",
|
||||
"-ldl"
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
#ifndef _GNU_SOURCE
|
||||
#define _GNU_SOURCE
|
||||
#endif
|
||||
|
||||
#include <X11/Xlib.h>
|
||||
#include <X11/extensions/shape.h>
|
||||
|
||||
@@ -7,11 +11,73 @@
|
||||
#include <cmath>
|
||||
#include <cstdlib>
|
||||
#include <cstdint>
|
||||
#include <iostream>
|
||||
#include <mutex>
|
||||
#include <sstream>
|
||||
#include <string>
|
||||
|
||||
namespace {
|
||||
|
||||
std::mutex gX11ErrorMutex;
|
||||
bool gX11ErrorTrapped = false;
|
||||
XErrorEvent gLastX11Error{};
|
||||
|
||||
int trapX11Error(Display*, XErrorEvent* event)
|
||||
{
|
||||
gX11ErrorTrapped = true;
|
||||
gLastX11Error = *event;
|
||||
return 0;
|
||||
}
|
||||
|
||||
bool isTraceEnabled()
|
||||
{
|
||||
return std::getenv("IPTVNATOR_TRACE_EMBEDDED_MPV") != nullptr;
|
||||
}
|
||||
|
||||
void trace(const std::string& message)
|
||||
{
|
||||
if (!isTraceEnabled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
std::cerr << "[Embedded MPV Linux] " << message << std::endl;
|
||||
}
|
||||
|
||||
class ScopedX11ErrorTrap {
|
||||
public:
|
||||
explicit ScopedX11ErrorTrap(Display* display)
|
||||
: lock_(gX11ErrorMutex)
|
||||
, display_(display)
|
||||
, previousHandler_(XSetErrorHandler(trapX11Error))
|
||||
{
|
||||
gX11ErrorTrapped = false;
|
||||
gLastX11Error = {};
|
||||
}
|
||||
|
||||
~ScopedX11ErrorTrap()
|
||||
{
|
||||
if (display_) {
|
||||
XSync(display_, False);
|
||||
}
|
||||
XSetErrorHandler(previousHandler_);
|
||||
}
|
||||
|
||||
bool failed() const
|
||||
{
|
||||
return gX11ErrorTrapped;
|
||||
}
|
||||
|
||||
int errorCode() const
|
||||
{
|
||||
return gLastX11Error.error_code;
|
||||
}
|
||||
|
||||
private:
|
||||
std::unique_lock<std::mutex> lock_;
|
||||
Display* display_ = nullptr;
|
||||
XErrorHandler previousHandler_ = nullptr;
|
||||
};
|
||||
|
||||
class NativeVideoHost {
|
||||
public:
|
||||
static bool isAvailable()
|
||||
@@ -32,6 +98,13 @@ public:
|
||||
return false;
|
||||
}
|
||||
|
||||
if (parentHandle <= 1) {
|
||||
lastError_ =
|
||||
"Electron did not provide a valid X11 parent window; native Wayland embedding is not supported yet.";
|
||||
return false;
|
||||
}
|
||||
|
||||
trace("opening X11 display");
|
||||
display_ = XOpenDisplay(nullptr);
|
||||
if (!display_) {
|
||||
lastError_ = "Unable to open the X11 display for embedded MPV.";
|
||||
@@ -39,6 +112,7 @@ public:
|
||||
}
|
||||
|
||||
parentWindow_ = static_cast<Window>(parentHandle);
|
||||
trace("parent window " + std::to_string(static_cast<unsigned long>(parentWindow_)));
|
||||
if (!parentWindow_) {
|
||||
lastError_ = "Unable to resolve Electron X11 window handle.";
|
||||
destroy();
|
||||
@@ -49,29 +123,58 @@ public:
|
||||
attributes.background_pixel = BlackPixel(display_, DefaultScreen(display_));
|
||||
attributes.event_mask = ExposureMask | StructureNotifyMask;
|
||||
|
||||
window_ = XCreateWindow(
|
||||
display_,
|
||||
parentWindow_,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
CopyFromParent,
|
||||
InputOutput,
|
||||
CopyFromParent,
|
||||
CWBackPixel | CWEventMask,
|
||||
&attributes
|
||||
);
|
||||
bool createWindowFailed = false;
|
||||
{
|
||||
ScopedX11ErrorTrap x11Errors(display_);
|
||||
trace("creating child window");
|
||||
window_ = XCreateWindow(
|
||||
display_,
|
||||
parentWindow_,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
CopyFromParent,
|
||||
InputOutput,
|
||||
CopyFromParent,
|
||||
CWBackPixel | CWEventMask,
|
||||
&attributes
|
||||
);
|
||||
XSync(display_, False);
|
||||
if (!window_ || x11Errors.failed()) {
|
||||
std::ostringstream message;
|
||||
message
|
||||
<< "Failed to create embedded MPV X11 child window.";
|
||||
if (x11Errors.failed()) {
|
||||
message << " X11 error code: " << x11Errors.errorCode()
|
||||
<< ".";
|
||||
}
|
||||
message
|
||||
<< " Electron did not provide a valid X11 parent window; "
|
||||
"native Wayland embedding is not supported yet.";
|
||||
lastError_ = message.str();
|
||||
window_ = 0;
|
||||
createWindowFailed = true;
|
||||
}
|
||||
}
|
||||
if (createWindowFailed) {
|
||||
destroy();
|
||||
return false;
|
||||
}
|
||||
if (!window_) {
|
||||
lastError_ = "Failed to create embedded MPV X11 child window.";
|
||||
destroy();
|
||||
return false;
|
||||
}
|
||||
|
||||
trace("clearing input shape");
|
||||
clearInputShape();
|
||||
trace("mapping child window");
|
||||
XMapWindow(display_, window_);
|
||||
trace("setting child bounds");
|
||||
setBounds(bounds);
|
||||
trace("flushing X11 display");
|
||||
XFlush(display_);
|
||||
return true;
|
||||
}
|
||||
@@ -99,9 +202,7 @@ public:
|
||||
|
||||
std::string wid() const
|
||||
{
|
||||
std::ostringstream stream;
|
||||
stream << static_cast<unsigned long>(window_);
|
||||
return stream.str();
|
||||
return std::to_string(static_cast<unsigned long>(window_));
|
||||
}
|
||||
|
||||
void destroy()
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -26,27 +26,45 @@ describe('Embedded MPV native source recording invariants', () => {
|
||||
'utf8'
|
||||
);
|
||||
const buildAndMakeWorkflowSource = readFileSync(
|
||||
path.resolve(__dirname, '../../../../../.github/workflows/build-and-make.yaml'),
|
||||
path.resolve(
|
||||
__dirname,
|
||||
'../../../../../.github/workflows/build-and-make.yaml'
|
||||
),
|
||||
'utf8'
|
||||
);
|
||||
const stageRuntimeSource = readFileSync(
|
||||
path.resolve(
|
||||
__dirname,
|
||||
'../../../../../tools/embedded-mpv/stage-runtime.mjs'
|
||||
),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
function functionBody(name: string): string {
|
||||
const start = nativeSource.indexOf(`Napi::Value ${name}(`);
|
||||
return sourceFunctionBody(nativeSource, `Napi::Value ${name}(`, name);
|
||||
}
|
||||
|
||||
function sourceFunctionBody(
|
||||
source: string,
|
||||
signature: string,
|
||||
name: string
|
||||
): string {
|
||||
const start = source.indexOf(signature);
|
||||
expect(start).toBeGreaterThanOrEqual(0);
|
||||
|
||||
const bodyStart = nativeSource.indexOf('{', start);
|
||||
const bodyStart = source.indexOf('{', start);
|
||||
expect(bodyStart).toBeGreaterThanOrEqual(0);
|
||||
|
||||
let depth = 0;
|
||||
for (let index = bodyStart; index < nativeSource.length; index += 1) {
|
||||
if (nativeSource[index] === '{') {
|
||||
for (let index = bodyStart; index < source.length; index += 1) {
|
||||
if (source[index] === '{') {
|
||||
depth += 1;
|
||||
}
|
||||
if (nativeSource[index] === '}') {
|
||||
if (source[index] === '}') {
|
||||
depth -= 1;
|
||||
}
|
||||
if (depth === 0) {
|
||||
return nativeSource.slice(bodyStart, index + 1);
|
||||
return source.slice(bodyStart, index + 1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -141,7 +159,9 @@ describe('Embedded MPV native source recording invariants', () => {
|
||||
idleFallbackStart
|
||||
);
|
||||
expect(redirectBranch).toContain('SessionStatus::Loading');
|
||||
expect(redirectBranch).toContain('session->snapshot.error.clear();');
|
||||
expect(redirectBranch).toContain(
|
||||
'session->snapshot.error.clear();'
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -245,7 +265,9 @@ describe('Embedded MPV native source recording invariants', () => {
|
||||
});
|
||||
|
||||
it('checks Win32 window class registration failures explicitly', () => {
|
||||
expect(win32Source).toContain('const ATOM classAtom = RegisterClassExW');
|
||||
expect(win32Source).toContain(
|
||||
'const ATOM classAtom = RegisterClassExW'
|
||||
);
|
||||
expect(win32Source).toContain('ERROR_CLASS_ALREADY_EXISTS');
|
||||
expect(win32Source).toContain(
|
||||
'Failed to register embedded MPV child window class.'
|
||||
@@ -259,6 +281,248 @@ describe('Embedded MPV native source recording invariants', () => {
|
||||
expect(linuxSource).toContain('drainEvents();');
|
||||
});
|
||||
|
||||
it('defers Linux X11 create failure cleanup until after the error trap scope closes', () => {
|
||||
const trapStart = linuxSource.indexOf(
|
||||
'ScopedX11ErrorTrap x11Errors(display_);'
|
||||
);
|
||||
expect(trapStart).toBeGreaterThanOrEqual(0);
|
||||
const deferredCleanupStart = linuxSource.indexOf(
|
||||
'if (createWindowFailed) {',
|
||||
trapStart
|
||||
);
|
||||
expect(deferredCleanupStart).toBeGreaterThan(trapStart);
|
||||
|
||||
const trappedCreateBlock = linuxSource.slice(
|
||||
trapStart,
|
||||
deferredCleanupStart
|
||||
);
|
||||
const nextWindowGuardStart = linuxSource.indexOf(
|
||||
'if (!window_) {',
|
||||
deferredCleanupStart
|
||||
);
|
||||
expect(nextWindowGuardStart).toBeGreaterThan(deferredCleanupStart);
|
||||
const deferredCleanupBlock = linuxSource.slice(
|
||||
deferredCleanupStart,
|
||||
nextWindowGuardStart
|
||||
);
|
||||
|
||||
expect(trappedCreateBlock).toContain('createWindowFailed = true;');
|
||||
expect(trappedCreateBlock).not.toContain('destroy();');
|
||||
expect(deferredCleanupBlock).toContain('destroy();');
|
||||
});
|
||||
|
||||
it('keeps Linux mpv child processes isolated from Wayland and inherited Electron descriptors', () => {
|
||||
expect(widCommonSource).toContain(
|
||||
'if (hasEnvPrefix(entry, "WAYLAND_DISPLAY="))'
|
||||
);
|
||||
expect(widCommonSource).toContain(
|
||||
'environment.push_back("XDG_SESSION_TYPE=x11");'
|
||||
);
|
||||
expect(widCommonSource).toContain(
|
||||
'arguments.push_back("--vo=gpu,x11");'
|
||||
);
|
||||
expect(widCommonSource).toContain(
|
||||
'arguments.push_back("--gpu-context=x11egl");'
|
||||
);
|
||||
expect(widCommonSource).toContain(
|
||||
'void closeInheritedFileDescriptors()'
|
||||
);
|
||||
expect(widCommonSource).toContain('opendir("/proc/self/fd")');
|
||||
expect(widCommonSource).toContain('readdir(directory)');
|
||||
expect(widCommonSource).toContain(
|
||||
'fcntl(descriptor, F_SETFD, flags | FD_CLOEXEC)'
|
||||
);
|
||||
expect(widCommonSource).toContain('closeInheritedFileDescriptors();');
|
||||
expect(widCommonSource).not.toContain('inheritedFileDescriptorLimit()');
|
||||
expect(widCommonSource).not.toContain(
|
||||
'closeInheritedFileDescriptors(fileDescriptorLimit);'
|
||||
);
|
||||
});
|
||||
|
||||
it('drives Linux out-of-process MPV state and controls over JSON IPC', () => {
|
||||
expect(widCommonSource).toContain('mpvIpcSocketPath');
|
||||
expect(widCommonSource).toContain(
|
||||
'arguments.push_back("--input-ipc-server=" + ipcSocketPath);'
|
||||
);
|
||||
expect(widCommonSource).toContain('refreshLinuxMpvSnapshot(session);');
|
||||
expect(widCommonSource).toContain(
|
||||
'queryLinuxMpvNumber(socketPath, "time-pos")'
|
||||
);
|
||||
expect(widCommonSource).toContain(
|
||||
'queryLinuxMpvNumber(socketPath, "duration")'
|
||||
);
|
||||
expect(widCommonSource).toContain(
|
||||
'std::string("{\\"command\\":[\\"set_property\\",\\"pause\\",")'
|
||||
);
|
||||
expect(widCommonSource).toContain(
|
||||
'"{\\"command\\":[\\"seek\\"," + seconds + ",\\"absolute\\"]}\\n"'
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps Linux MPV IPC volume readback in mpv percent units', () => {
|
||||
const refreshBody = sourceFunctionBody(
|
||||
widCommonSource,
|
||||
'void refreshLinuxMpvSnapshot(',
|
||||
'refreshLinuxMpvSnapshot'
|
||||
);
|
||||
expect(refreshBody).toContain(
|
||||
'queryLinuxMpvNumber(socketPath, "volume")'
|
||||
);
|
||||
expect(refreshBody).toContain(
|
||||
'session->snapshot.volumePercent =\n' +
|
||||
' std::max(0.0, std::min(100.0, *volume));'
|
||||
);
|
||||
expect(refreshBody).not.toContain('clampVolumePercent(*volume)');
|
||||
});
|
||||
|
||||
it('only marks open file descriptors close-on-exec before Linux MPV exec', () => {
|
||||
const closeDescriptorsBody = sourceFunctionBody(
|
||||
widCommonSource,
|
||||
'void closeInheritedFileDescriptors(',
|
||||
'closeInheritedFileDescriptors'
|
||||
);
|
||||
expect(closeDescriptorsBody).toContain('opendir("/proc/self/fd")');
|
||||
expect(closeDescriptorsBody).toContain('readdir(directory)');
|
||||
expect(closeDescriptorsBody).toContain('fcntl(descriptor, F_GETFD)');
|
||||
expect(closeDescriptorsBody).toContain(
|
||||
'fcntl(descriptor, F_SETFD, flags | FD_CLOEXEC)'
|
||||
);
|
||||
expect(widCommonSource).not.toContain('sysconf(_SC_OPEN_MAX)');
|
||||
expect(widCommonSource).not.toContain(
|
||||
'fileDescriptor < fileDescriptorLimit'
|
||||
);
|
||||
|
||||
const spawnBody = sourceFunctionBody(
|
||||
widCommonSource,
|
||||
'pid_t spawnLinuxMpvProcess(',
|
||||
'spawnLinuxMpvProcess'
|
||||
);
|
||||
expect(spawnBody).toContain('closeInheritedFileDescriptors();');
|
||||
expect(spawnBody).not.toContain('inheritedFileDescriptorLimit()');
|
||||
});
|
||||
|
||||
it('formats MPV floating-point values independently from the user locale', () => {
|
||||
const formatterBody = sourceFunctionBody(
|
||||
widCommonSource,
|
||||
'std::string formatInvariantDouble(',
|
||||
'formatInvariantDouble'
|
||||
);
|
||||
expect(formatterBody).toContain('std::to_chars(');
|
||||
expect(formatterBody).toContain('std::chars_format::general');
|
||||
expect(formatterBody).not.toContain('std::locale::classic()');
|
||||
expect(formatterBody).not.toContain('std::ostringstream');
|
||||
|
||||
const linuxArgumentsBody = sourceFunctionBody(
|
||||
widCommonSource,
|
||||
'std::vector<std::string> buildLinuxMpvArguments(',
|
||||
'buildLinuxMpvArguments'
|
||||
);
|
||||
expect(linuxArgumentsBody).toContain(
|
||||
'"--volume=" +\n' +
|
||||
' formatInvariantDouble(session->snapshot.volumePercent)'
|
||||
);
|
||||
expect(linuxArgumentsBody).toContain(
|
||||
'appendLinuxMpvOption(\n' +
|
||||
' arguments,\n' +
|
||||
' "start",\n' +
|
||||
' formatInvariantDouble(startTime)\n' +
|
||||
' );'
|
||||
);
|
||||
|
||||
const seekBody = sourceFunctionBody(
|
||||
widCommonSource,
|
||||
'Napi::Value Seek(',
|
||||
'Seek'
|
||||
);
|
||||
expect(seekBody).toContain('formatInvariantDouble(');
|
||||
expect(seekBody).not.toContain('std::to_string(info[1]');
|
||||
|
||||
const setVolumeBody = sourceFunctionBody(
|
||||
widCommonSource,
|
||||
'Napi::Value SetVolume(',
|
||||
'SetVolume'
|
||||
);
|
||||
expect(setVolumeBody).toContain('formatInvariantDouble(volume)');
|
||||
expect(setVolumeBody).not.toContain('std::to_string(volume)');
|
||||
});
|
||||
|
||||
it('keeps Linux MPV snapshot IPC off the NAPI snapshot read path', () => {
|
||||
const getSnapshotBody = sourceFunctionBody(
|
||||
widCommonSource,
|
||||
'Napi::Value GetSessionSnapshot(',
|
||||
'GetSessionSnapshot'
|
||||
);
|
||||
expect(getSnapshotBody).not.toContain('refreshLinuxMpvSnapshot');
|
||||
expect(widCommonSource).toContain('void runLinuxProcessPollLoop');
|
||||
expect(widCommonSource).toContain('refreshLinuxMpvSnapshot(session);');
|
||||
expect(widCommonSource).toContain('startLinuxProcessPolling(session)');
|
||||
expect(widCommonSource).toContain(
|
||||
'session->eventThread = std::thread(runLinuxProcessPollLoop, session);'
|
||||
);
|
||||
});
|
||||
|
||||
it('terminates Linux MPV processes away from the NAPI teardown path', () => {
|
||||
const destroyBody = sourceFunctionBody(
|
||||
widCommonSource,
|
||||
'void destroySession(',
|
||||
'destroySession'
|
||||
);
|
||||
expect(destroyBody).toContain(
|
||||
'terminateLinuxMpvProcessAsync(session);'
|
||||
);
|
||||
expect(destroyBody).toContain('session->eventThread.detach();');
|
||||
expect(destroyBody).not.toContain('std::this_thread::sleep_for');
|
||||
expect(destroyBody).not.toContain('SIGKILL');
|
||||
|
||||
const asyncTerminationBody = sourceFunctionBody(
|
||||
widCommonSource,
|
||||
'void terminateLinuxMpvProcessAsync(',
|
||||
'terminateLinuxMpvProcessAsync'
|
||||
);
|
||||
expect(asyncTerminationBody).toContain(
|
||||
'kill(process.processId, SIGTERM);'
|
||||
);
|
||||
expect(asyncTerminationBody).toContain('std::thread(');
|
||||
expect(asyncTerminationBody).toContain('waitForLinuxMpvProcessExit');
|
||||
});
|
||||
|
||||
it('uses generation-unique Linux MPV IPC socket paths', () => {
|
||||
expect(widCommonSource).toContain('gNextLinuxIpcSocketId');
|
||||
expect(widCommonSource).toContain(
|
||||
'std::to_string(gNextLinuxIpcSocketId.fetch_add(1))'
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps dynamic libmpv symbol declarations compatible with distro headers', () => {
|
||||
expect(widCommonSource).not.toContain('MPV_CPLUGIN_DYNAMIC_SYM');
|
||||
expect(widCommonSource).toContain('#ifdef IPTVNATOR_DYNAMIC_LIBMPV');
|
||||
expect(widCommonSource).toContain('#ifdef MPV_SELECTANY');
|
||||
expect(widCommonSource).toContain(
|
||||
'#define IPTVNATOR_MPV_SELECTANY MPV_SELECTANY'
|
||||
);
|
||||
expect(widCommonSource).toContain(
|
||||
'IPTVNATOR_MPV_SELECTANY decltype(&name) pfn_##name = nullptr;'
|
||||
);
|
||||
expect(widCommonSource).toContain(
|
||||
'IPTVNATOR_DECLARE_MPV_DYNAMIC_SYMBOL(mpv_command_async)'
|
||||
);
|
||||
expect(widCommonSource).toContain(
|
||||
'#define mpv_command_async pfn_mpv_command_async'
|
||||
);
|
||||
expect(widCommonSource).not.toContain('mpvLibraryCandidates');
|
||||
expect(widCommonSource).not.toContain('ensureMpvApiLoaded');
|
||||
expect(widCommonSource).not.toContain('"libmpv.so"');
|
||||
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('uses platform-specific embedded MPV runtime cache key inputs in CI', () => {
|
||||
expect(buildAndMakeWorkflowSource).toContain(
|
||||
"const targetPlatform = '${{ matrix.embedded_mpv_platform }}';"
|
||||
@@ -277,6 +541,25 @@ describe('Embedded MPV native source recording invariants', () => {
|
||||
' `xcode${xcodeHash}`,'
|
||||
);
|
||||
});
|
||||
|
||||
it('requires Linux embedded MPV build inputs and validates process isolation in CI', () => {
|
||||
expect(buildAndMakeWorkflowSource).toContain(
|
||||
'libmpv-dev mpv pkg-config'
|
||||
);
|
||||
expect(buildAndMakeWorkflowSource).toContain(
|
||||
'Stage Linux embedded MPV build inputs'
|
||||
);
|
||||
expect(buildAndMakeWorkflowSource).toContain(
|
||||
"linuxBackend: 'process-isolated mpv --wid'"
|
||||
);
|
||||
expect(buildAndMakeWorkflowSource).toContain("matrix.os == 'linux'");
|
||||
expect(buildAndMakeWorkflowSource).toContain(
|
||||
'Linux embedded MPV addon must not link directly to libmpv'
|
||||
);
|
||||
expect(buildScriptSource).toContain("origin: 'external-mpv-process'");
|
||||
expect(buildScriptSource).toContain('writeLinuxProcessRuntimeManifest');
|
||||
expect(buildScriptSource).toContain('runtimeFiles: []');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Embedded MPV native build configuration', () => {
|
||||
|
||||
@@ -8,24 +8,38 @@ import { tmpdir } from 'os';
|
||||
import path from 'path';
|
||||
import type { EmbeddedMpvNativeService as EmbeddedMpvNativeServiceType } from './embedded-mpv-native.service';
|
||||
|
||||
const mockSpawnSync = jest.fn();
|
||||
|
||||
jest.mock('child_process', () => ({
|
||||
spawnSync: mockSpawnSync,
|
||||
}));
|
||||
|
||||
const powerSaveBlockerMock = {
|
||||
start: jest.fn<number, [string]>(),
|
||||
stop: jest.fn<void, [number]>(),
|
||||
isStarted: jest.fn<boolean, [number]>(),
|
||||
};
|
||||
const commandLineMock = {
|
||||
getSwitchValue: jest.fn<string, [string]>(),
|
||||
};
|
||||
const appMock = {
|
||||
isPackaged: true,
|
||||
getAppPath: () => '/mock/app.asar',
|
||||
commandLine: commandLineMock,
|
||||
};
|
||||
|
||||
jest.mock('electron', () => ({
|
||||
app: {
|
||||
isPackaged: true,
|
||||
getAppPath: () => '/mock/app.asar',
|
||||
},
|
||||
app: appMock,
|
||||
powerSaveBlocker: powerSaveBlockerMock,
|
||||
}));
|
||||
|
||||
const mainWindowSendMock = jest.fn();
|
||||
const mainWindowGetNativeWindowHandleMock = jest.fn<Buffer, []>(() =>
|
||||
Buffer.alloc(8)
|
||||
);
|
||||
const mainWindowMock = {
|
||||
isDestroyed: () => false,
|
||||
getNativeWindowHandle: () => Buffer.alloc(8),
|
||||
getNativeWindowHandle: mainWindowGetNativeWindowHandleMock,
|
||||
webContents: { send: mainWindowSendMock },
|
||||
};
|
||||
|
||||
@@ -98,6 +112,9 @@ describe('EmbeddedMpvNativeService power blocker', () => {
|
||||
let addon: MockAddon;
|
||||
let nextBlockerId: number;
|
||||
let originalPlatform: NodeJS.Platform;
|
||||
let originalDisplay: string | undefined;
|
||||
let originalOzonePlatformHint: string | undefined;
|
||||
let originalWaylandDisplay: string | undefined;
|
||||
let tempDirs: string[];
|
||||
|
||||
beforeEach(async () => {
|
||||
@@ -105,6 +122,14 @@ describe('EmbeddedMpvNativeService power blocker', () => {
|
||||
powerSaveBlockerMock.start.mockReset();
|
||||
powerSaveBlockerMock.stop.mockReset();
|
||||
powerSaveBlockerMock.isStarted.mockReset();
|
||||
commandLineMock.getSwitchValue.mockReset();
|
||||
commandLineMock.getSwitchValue.mockReturnValue('');
|
||||
mockSpawnSync.mockReset();
|
||||
mockSpawnSync.mockReturnValue({
|
||||
status: 0,
|
||||
});
|
||||
mainWindowGetNativeWindowHandleMock.mockReset();
|
||||
mainWindowGetNativeWindowHandleMock.mockReturnValue(Buffer.alloc(8));
|
||||
mainWindowSendMock.mockReset();
|
||||
|
||||
tempDirs = [];
|
||||
@@ -115,6 +140,9 @@ describe('EmbeddedMpvNativeService power blocker', () => {
|
||||
Object.defineProperty(process, 'platform', {
|
||||
value: 'darwin',
|
||||
});
|
||||
originalDisplay = process.env.DISPLAY;
|
||||
originalOzonePlatformHint = process.env.ELECTRON_OZONE_PLATFORM_HINT;
|
||||
originalWaylandDisplay = process.env.WAYLAND_DISPLAY;
|
||||
|
||||
({ EmbeddedMpvNativeService } =
|
||||
await import('./embedded-mpv-native.service'));
|
||||
@@ -134,8 +162,20 @@ describe('EmbeddedMpvNativeService power blocker', () => {
|
||||
Object.defineProperty(process, 'platform', {
|
||||
value: originalPlatform,
|
||||
});
|
||||
restoreEnv('DISPLAY', originalDisplay);
|
||||
restoreEnv('ELECTRON_OZONE_PLATFORM_HINT', originalOzonePlatformHint);
|
||||
restoreEnv('WAYLAND_DISPLAY', originalWaylandDisplay);
|
||||
});
|
||||
|
||||
function restoreEnv(key: string, value: string | undefined): void {
|
||||
if (value === undefined) {
|
||||
delete process.env[key];
|
||||
return;
|
||||
}
|
||||
|
||||
process.env[key] = value;
|
||||
}
|
||||
|
||||
function createTempDir(): string {
|
||||
const tempDir = mkdtempSync(
|
||||
path.join(tmpdir(), 'iptvnator-recording-')
|
||||
@@ -181,14 +221,12 @@ describe('EmbeddedMpvNativeService power blocker', () => {
|
||||
|
||||
// s1 keeps failing while s2 stays healthy: the healthy session
|
||||
// must not reset the log suppression for the failing one.
|
||||
addon.getSessionSnapshot.mockImplementation(
|
||||
(sessionId: string) => {
|
||||
if (sessionId === 's1') {
|
||||
throw new Error('addon crashed');
|
||||
}
|
||||
return snapshot('playing');
|
||||
addon.getSessionSnapshot.mockImplementation((sessionId: string) => {
|
||||
if (sessionId === 's1') {
|
||||
throw new Error('addon crashed');
|
||||
}
|
||||
);
|
||||
return snapshot('playing');
|
||||
});
|
||||
|
||||
// Three poll ticks: nothing may escape the interval callback,
|
||||
// and the failure is logged once instead of at poll rate.
|
||||
@@ -308,6 +346,7 @@ describe('EmbeddedMpvNativeService power blocker', () => {
|
||||
it.each<NodeJS.Platform>(['darwin', 'win32', 'linux'])(
|
||||
'reports embedded MPV support on %s when the addon is already loaded',
|
||||
(platform) => {
|
||||
delete process.env.WAYLAND_DISPLAY;
|
||||
Object.defineProperty(process, 'platform', {
|
||||
value: platform,
|
||||
});
|
||||
@@ -321,6 +360,114 @@ describe('EmbeddedMpvNativeService power blocker', () => {
|
||||
}
|
||||
);
|
||||
|
||||
it('reports Linux Wayland as unsupported unless Electron is using X11/Xwayland', () => {
|
||||
Object.defineProperty(process, 'platform', {
|
||||
value: 'linux',
|
||||
});
|
||||
process.env.DISPLAY = ':0';
|
||||
process.env.WAYLAND_DISPLAY = 'wayland-0';
|
||||
|
||||
const support = service.getSupport();
|
||||
|
||||
expect(support.supported).toBe(false);
|
||||
expect(support.reason).toContain('Native Wayland embedding');
|
||||
});
|
||||
|
||||
it('does not treat the ozone platform hint env as proof that Electron is using X11', () => {
|
||||
Object.defineProperty(process, 'platform', {
|
||||
value: 'linux',
|
||||
});
|
||||
process.env.DISPLAY = ':0';
|
||||
process.env.WAYLAND_DISPLAY = 'wayland-0';
|
||||
process.env.ELECTRON_OZONE_PLATFORM_HINT = 'x11';
|
||||
|
||||
expect(service.getSupport().supported).toBe(false);
|
||||
});
|
||||
|
||||
it('reports Linux Wayland as supported when X11 ozone is requested', () => {
|
||||
Object.defineProperty(process, 'platform', {
|
||||
value: 'linux',
|
||||
});
|
||||
process.env.DISPLAY = ':0';
|
||||
process.env.WAYLAND_DISPLAY = 'wayland-0';
|
||||
commandLineMock.getSwitchValue.mockReturnValue('x11');
|
||||
|
||||
expect(service.getSupport()).toEqual(
|
||||
expect.objectContaining({
|
||||
supported: true,
|
||||
platform: 'linux',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('reports Linux as unsupported when the mpv executable is missing', () => {
|
||||
Object.defineProperty(process, 'platform', {
|
||||
value: 'linux',
|
||||
});
|
||||
delete process.env.WAYLAND_DISPLAY;
|
||||
mockSpawnSync.mockReturnValueOnce({
|
||||
status: null,
|
||||
error: Object.assign(new Error('not found'), { code: 'ENOENT' }),
|
||||
});
|
||||
|
||||
const support = service.getSupport();
|
||||
|
||||
expect(support.supported).toBe(false);
|
||||
expect(support.reason).toContain('requires the mpv executable on PATH');
|
||||
expect(mockSpawnSync).toHaveBeenCalledWith('mpv', ['--version'], {
|
||||
stdio: 'ignore',
|
||||
timeout: 3000,
|
||||
});
|
||||
});
|
||||
|
||||
it('caches the Linux mpv executable probe result', () => {
|
||||
Object.defineProperty(process, 'platform', {
|
||||
value: 'linux',
|
||||
});
|
||||
delete process.env.WAYLAND_DISPLAY;
|
||||
|
||||
service.getSupport();
|
||||
service.getSupport();
|
||||
|
||||
expect(mockSpawnSync).toHaveBeenCalledTimes(1);
|
||||
expect(mockSpawnSync).toHaveBeenCalledWith('mpv', ['--version'], {
|
||||
stdio: 'ignore',
|
||||
timeout: 3000,
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects Electron native Wayland placeholder handles before calling the addon', () => {
|
||||
Object.defineProperty(process, 'platform', {
|
||||
value: 'linux',
|
||||
});
|
||||
process.env.DISPLAY = ':0';
|
||||
process.env.WAYLAND_DISPLAY = 'wayland-0';
|
||||
mainWindowGetNativeWindowHandleMock.mockReturnValueOnce(
|
||||
Buffer.from([1, 0, 0, 0])
|
||||
);
|
||||
|
||||
expect(() => service.createSession(BOUNDS, '', 1)).toThrow(
|
||||
'Embedded MPV on Linux requires Electron to run under X11 or Xwayland.'
|
||||
);
|
||||
expect(addon.createSession).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects 64-bit Electron native Wayland placeholder handles before calling the addon', () => {
|
||||
Object.defineProperty(process, 'platform', {
|
||||
value: 'linux',
|
||||
});
|
||||
process.env.DISPLAY = ':0';
|
||||
process.env.WAYLAND_DISPLAY = 'wayland-0';
|
||||
mainWindowGetNativeWindowHandleMock.mockReturnValueOnce(
|
||||
Buffer.alloc(8)
|
||||
);
|
||||
|
||||
expect(() => service.createSession(BOUNDS, '', 1)).toThrow(
|
||||
'Embedded MPV on Linux requires Electron to run under X11 or Xwayland.'
|
||||
);
|
||||
expect(addon.createSession).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
platform: 'darwin' as NodeJS.Platform,
|
||||
@@ -330,13 +477,10 @@ describe('EmbeddedMpvNativeService power blocker', () => {
|
||||
platform: 'win32' as NodeJS.Platform,
|
||||
runtimeFile: path.join('lib', 'mpv-2.dll'),
|
||||
},
|
||||
{
|
||||
platform: 'linux' as NodeJS.Platform,
|
||||
runtimeFile: path.join('lib', 'libmpv.so.2'),
|
||||
},
|
||||
])(
|
||||
'loads the addon after validating the $platform runtime file exists',
|
||||
({ platform, runtimeFile }) => {
|
||||
delete process.env.WAYLAND_DISPLAY;
|
||||
Object.defineProperty(process, 'platform', {
|
||||
value: platform,
|
||||
});
|
||||
@@ -365,6 +509,36 @@ describe('EmbeddedMpvNativeService power blocker', () => {
|
||||
}
|
||||
);
|
||||
|
||||
it('loads the Linux addon without bundled libmpv runtime files', () => {
|
||||
delete process.env.WAYLAND_DISPLAY;
|
||||
Object.defineProperty(process, 'platform', {
|
||||
value: 'linux',
|
||||
});
|
||||
const nativeDir = createTempDir();
|
||||
const addonPath = path.join(nativeDir, 'embedded_mpv.node');
|
||||
writeFileSync(addonPath, '');
|
||||
const loadAddonModule = jest.fn().mockReturnValue(addon);
|
||||
|
||||
Object.assign(service as unknown as Record<string, unknown>, {
|
||||
addon: null,
|
||||
addonLoadError: null,
|
||||
loadAddonModule,
|
||||
getAddonCandidatePaths: () => [addonPath],
|
||||
});
|
||||
|
||||
expect(service.getSupport()).toEqual(
|
||||
expect.objectContaining({
|
||||
supported: true,
|
||||
platform: 'linux',
|
||||
})
|
||||
);
|
||||
expect(loadAddonModule).toHaveBeenCalledWith(addonPath);
|
||||
expect(mockSpawnSync).toHaveBeenCalledWith('mpv', ['--version'], {
|
||||
stdio: 'ignore',
|
||||
timeout: 3000,
|
||||
});
|
||||
});
|
||||
|
||||
it('loads the addon before reporting support capabilities', () => {
|
||||
const loadAddonModule = jest.fn().mockReturnValue(addon);
|
||||
Object.assign(service as unknown as Record<string, unknown>, {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { app, dialog, powerSaveBlocker } from 'electron';
|
||||
import { spawnSync } from 'child_process';
|
||||
import {
|
||||
closeSync,
|
||||
existsSync,
|
||||
@@ -96,6 +97,7 @@ export class EmbeddedMpvNativeService {
|
||||
private readonly pollFailuresLogged = new Set<string>();
|
||||
private powerBlockerId: number | null = null;
|
||||
private readonly loadAddonModule = createRequire(__filename);
|
||||
private cachedLinuxMpvExecutableReason: string | null | undefined;
|
||||
|
||||
private detectCapabilities(): EmbeddedMpvCapabilities {
|
||||
const addon = this.addon;
|
||||
@@ -115,8 +117,7 @@ export class EmbeddedMpvNativeService {
|
||||
return {
|
||||
supported: false,
|
||||
platform: process.platform,
|
||||
reason:
|
||||
'Embedded MPV is currently available on macOS, Windows, and Linux only.',
|
||||
reason: 'Embedded MPV is currently available on macOS, Windows, and Linux only.',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -124,8 +125,7 @@ export class EmbeddedMpvNativeService {
|
||||
return {
|
||||
supported: false,
|
||||
platform: process.platform,
|
||||
reason:
|
||||
'Embedded MPV on Linux currently requires X11 or Xwayland. Native Wayland embedding is not supported yet.',
|
||||
reason: 'Embedded MPV on Linux currently requires X11 or Xwayland. Native Wayland embedding is not supported yet.',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -137,6 +137,16 @@ export class EmbeddedMpvNativeService {
|
||||
};
|
||||
}
|
||||
|
||||
const missingLinuxMpvExecutableReason =
|
||||
this.getMissingLinuxMpvExecutableReason();
|
||||
if (missingLinuxMpvExecutableReason) {
|
||||
return {
|
||||
supported: false,
|
||||
platform: process.platform,
|
||||
reason: missingLinuxMpvExecutableReason,
|
||||
};
|
||||
}
|
||||
|
||||
if (this.addon) {
|
||||
try {
|
||||
if (!this.addon.isSupported()) {
|
||||
@@ -664,7 +674,14 @@ export class EmbeddedMpvNativeService {
|
||||
throw new Error('The Electron main window is not available.');
|
||||
}
|
||||
|
||||
return App.mainWindow.getNativeWindowHandle();
|
||||
const windowHandle = App.mainWindow.getNativeWindowHandle();
|
||||
if (this.isInvalidLinuxWaylandWindowHandle(windowHandle)) {
|
||||
throw new Error(
|
||||
'Embedded MPV on Linux requires Electron to run under X11 or Xwayland. Native Wayland embedding is not supported yet. Start IPTVnator with --ozone-platform=x11.'
|
||||
);
|
||||
}
|
||||
|
||||
return windowHandle;
|
||||
}
|
||||
|
||||
private reserveRecordingTargetPath(
|
||||
@@ -758,13 +775,60 @@ export class EmbeddedMpvNativeService {
|
||||
}
|
||||
|
||||
private isUnsupportedLinuxDisplayServer(): boolean {
|
||||
if (process.platform !== 'linux' || !process.env.WAYLAND_DISPLAY) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return !process.env.DISPLAY || !this.isLinuxX11OzoneRequested();
|
||||
}
|
||||
|
||||
private isLinuxX11OzoneRequested(): boolean {
|
||||
return (
|
||||
process.platform === 'linux' &&
|
||||
Boolean(process.env.WAYLAND_DISPLAY) &&
|
||||
!process.env.DISPLAY
|
||||
app.commandLine.getSwitchValue('ozone-platform').toLowerCase() ===
|
||||
'x11'
|
||||
);
|
||||
}
|
||||
|
||||
private getMissingLinuxMpvExecutableReason(): string | null {
|
||||
if (process.platform !== 'linux') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (this.cachedLinuxMpvExecutableReason !== undefined) {
|
||||
return this.cachedLinuxMpvExecutableReason;
|
||||
}
|
||||
|
||||
const result = spawnSync('mpv', ['--version'], {
|
||||
stdio: 'ignore',
|
||||
timeout: 3000,
|
||||
});
|
||||
this.cachedLinuxMpvExecutableReason =
|
||||
result.status === 0
|
||||
? null
|
||||
: 'Embedded MPV on Linux requires the mpv executable on PATH. Install the mpv package for your distribution and restart IPTVnator.';
|
||||
return this.cachedLinuxMpvExecutableReason;
|
||||
}
|
||||
|
||||
private isInvalidLinuxWaylandWindowHandle(windowHandle: Buffer): boolean {
|
||||
if (
|
||||
process.platform !== 'linux' ||
|
||||
!process.env.WAYLAND_DISPLAY ||
|
||||
!process.env.DISPLAY
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (windowHandle.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const windowHandleValue =
|
||||
windowHandle.length >= 4
|
||||
? windowHandle.readUInt32LE(0)
|
||||
: windowHandle.readUIntLE(0, windowHandle.length);
|
||||
return windowHandleValue <= 1;
|
||||
}
|
||||
|
||||
private getAddon(): NativeEmbeddedMpvAddon {
|
||||
if (this.addon) {
|
||||
return this.addon;
|
||||
@@ -880,6 +944,10 @@ export class EmbeddedMpvNativeService {
|
||||
const nativeDir = path.dirname(addonPath);
|
||||
const runtimeCandidates = this.getRuntimeLibraryCandidates(nativeDir);
|
||||
|
||||
if (runtimeCandidates.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!runtimeCandidates.some((candidate) => existsSync(candidate))) {
|
||||
return [
|
||||
'Embedded MPV runtime is incomplete. Missing one of:',
|
||||
@@ -905,11 +973,7 @@ export class EmbeddedMpvNativeService {
|
||||
path.join(nativeDir, 'mpv.dll'),
|
||||
];
|
||||
case 'linux':
|
||||
return [
|
||||
path.join(nativeDir, 'lib', 'libmpv.so.2'),
|
||||
path.join(nativeDir, 'lib', 'libmpv.so.1'),
|
||||
path.join(nativeDir, 'lib', 'libmpv.so'),
|
||||
];
|
||||
return [];
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ This document records the current contract for embedded playback in portal detai
|
||||
## Summary
|
||||
|
||||
- Embedded web players are `videojs`, `html5`, and `artplayer`.
|
||||
- `embedded-mpv` exists as a hidden desktop experimental harness backed by a native `libmpv` addon.
|
||||
- `embedded-mpv` exists as a hidden desktop experimental harness backed by a native MPV addon. macOS and Windows use in-process `libmpv`; Linux uses an X11/Xwayland child window with an out-of-process `mpv --wid` backend.
|
||||
- Controlled external players are `mpv` and `vlc`.
|
||||
- macOS `.app` bundle paths are resolved only for real MPV/VLC apps. IINA may
|
||||
launch through the MPV path field when the user supplies an executable path
|
||||
@@ -53,26 +53,30 @@ Current contract:
|
||||
- desktop only: macOS, Windows x64, and Linux x64 under X11/Xwayland
|
||||
- experimental opt-in
|
||||
- enabled in local development only when `IPTVNATOR_ENABLE_EMBEDDED_MPV_EXPERIMENT=1`
|
||||
- enabled in packaged desktop builds only when the bundled native addon and `vendored-lgpl` libmpv runtime load successfully
|
||||
- Linux Wayland sessions must start Electron through Xwayland, for example with `pnpm nx run electron-backend:serve-electron --args=--ozone-platform=x11` during local development or `iptvnator --ozone-platform=x11` for a packaged app
|
||||
- enabled in packaged desktop builds only when the bundled native addon/runtime prerequisites are present; Linux additionally requires an `mpv` executable on `PATH`
|
||||
- uses IPTVnator-owned controls and `ResolvedPortalPlayback` payloads
|
||||
- uses the libmpv render API on macOS and renders through an IPTVnator-owned native `NSView`
|
||||
- uses mpv `wid` embedding on Windows and Linux through IPTVnator-owned native child windows
|
||||
- defaults to libmpv's OpenGL render backend with `hwdec=auto-safe`
|
||||
- Linux starts `mpv --wid=<x11-window>` out of process so MPV does not share Electron's FFmpeg or graphics symbols
|
||||
- Linux controls that out-of-process MPV instance through a private JSON IPC socket so duration, position, pause, volume, and seek state come from MPV instead of renderer guesses
|
||||
- Linux starts that MPV process with `WAYLAND_DISPLAY` removed, `XDG_SESSION_TYPE=x11`, and X11 video output options; otherwise MPV can pick Wayland inside a Wayland desktop session, ignore `--wid`, and open a separate window instead of embedding
|
||||
- macOS/Windows default to libmpv's OpenGL render backend with `hwdec=auto-safe`
|
||||
- keeps the previous software renderer as a debug fallback via `IPTVNATOR_EMBEDDED_MPV_RENDERER=sw`
|
||||
- emits lightweight render diagnostics when `IPTVNATOR_TRACE_EMBEDDED_MPV=1` is set
|
||||
- emits lightweight native diagnostics when `IPTVNATOR_TRACE_EMBEDDED_MPV=1` is set; Linux also writes MPV's own trace log to `/tmp/iptvnator-embedded-mpv.log`
|
||||
- exposes an IPTVnator-owned fullscreen button that uses the renderer fullscreen API and resyncs the native MPV view bounds after fullscreen transitions
|
||||
- auto-hides IPTVnator-owned controls while playback is active and restores them on pointer/focus interaction
|
||||
- exposes audio-track metadata from MPV and switches tracks through the `aid` property without reloading the stream
|
||||
- passes VOD/episode resume offsets to MPV through the `loadfile` options map; live catchup URLs are treated as already-positioned streams
|
||||
- applies the initial volume during session creation and uses async libmpv control calls after startup
|
||||
- applies the initial volume during session creation and uses async libmpv control calls after startup where the in-process libmpv backend is active
|
||||
- VLC remains external-only
|
||||
|
||||
Current limitation:
|
||||
|
||||
- the current feasibility harness is still experimental and platform-specific
|
||||
- the original macOS `wid` embedding path produced audio with a black video surface inside Electron, so the harness now avoids foreign-window embedding on macOS
|
||||
- Windows and Linux use the mpv `wid` path, require staged LGPL runtime files, and still need OS-native smoke coverage before public exposure
|
||||
- Linux native Wayland is not supported in this implementation; the Electron process must have `DISPLAY` through X11 or Xwayland
|
||||
- Windows and Linux use the mpv `wid` path and still need OS-native packaged smoke coverage before public exposure
|
||||
- Linux native Wayland is not supported in this implementation; the Electron process must have `DISPLAY` through X11/Xwayland and a real X11 window handle
|
||||
- the OpenGL render path avoids the old per-frame `CGImage` copy path, but it still needs broader interaction, resize, and packaging coverage
|
||||
- startup deadlocks seen during early macOS playback bring-up are mitigated, but the feature is still kept behind the explicit experiment flag until more interaction and packaging coverage is proven
|
||||
- because of that, the setting is auto-sanitized back to the default inline player unless support detection reports that the experimental runtime is available
|
||||
@@ -195,7 +199,7 @@ The diagnostic surface covers the inline player viewport when playback fails, wi
|
||||
|
||||
URL extension metadata is filtered before diagnostics and player selection use it. Web script extensions such as `.php` are not shown as stream containers; explicit media query metadata such as `extension=ts` or `format=m3u8` is preferred when present.
|
||||
|
||||
Portal VOD and episode payloads with `contentInfo` are treated as non-live by the Video.js MPEG-TS path unless `isLive` is explicitly set. If Chromium leaves the underlying MediaSource duration at `Infinity` for a finite TS VOD, the Video.js wrapper normalizes its UI duration from the finite `seekable` or `buffered` range. This removes the misleading `LIVE` control state without changing stream decoding, diagnostics, or external fallback behavior.
|
||||
Portal VOD and episode payloads with `contentInfo` are treated as non-live by the inline players unless `isLive` is explicitly set. If Chromium leaves the underlying MediaSource duration at `Infinity` for a finite TS VOD, the Video.js wrapper normalizes its UI duration from the finite `seekable` or `buffered` range. Embedded MPV uses the same live decision rule and shows an unknown duration placeholder for VOD/episode snapshots until MPV reports a finite duration. This removes the misleading `LIVE` control state without changing stream decoding, diagnostics, or external fallback behavior.
|
||||
|
||||
When a diagnostic is actionable in Electron, the diagnostic surface may offer `Open in MPV`, `Open in VLC`, `Copy URL`, technical details, and `Retry`. Web builds only expose copy/help text and retry. MPV/VLC fallback requests carry the original `ResolvedPortalPlayback` payload so headers, referer, origin, user-agent, content metadata, and resume offset stay intact. Retry clears the current diagnostic and rebuilds the active inline player inputs; it does not change the saved player setting.
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ Source files for the embedded MPV integration:
|
||||
- `apps/electron-backend/native/src/embedded_mpv.mm` owns the macOS `libmpv` render integration.
|
||||
- `apps/electron-backend/native/src/embedded_mpv_win32.cc` owns the Windows `HWND` + mpv `wid` backend.
|
||||
- `apps/electron-backend/native/src/embedded_mpv_linux.cc` owns the Linux X11/Xwayland `Window` + mpv `wid` backend.
|
||||
- `apps/electron-backend/native/src/embedded_mpv_wid_common.h` owns the shared Windows/Linux libmpv session surface.
|
||||
- `apps/electron-backend/native/src/embedded_mpv_wid_common.h` owns the shared Windows/Linux session surface, including Linux `mpv --wid` process control and JSON IPC.
|
||||
- `apps/electron-backend/src/app/services/embedded-mpv-native.service.ts` owns Electron main-process session lifecycle and support detection.
|
||||
- `apps/electron-backend/src/app/events/embedded-mpv.events.ts` registers the IPC contract.
|
||||
- `apps/electron-backend/src/app/api/main.preload.ts` exposes the preload bridge to the renderer.
|
||||
@@ -26,7 +26,24 @@ The build directory contains files such as `Makefile`, `binding.Makefile`, `conf
|
||||
|
||||
## How It Is Embedded
|
||||
|
||||
The embedded player does not spawn the normal `mpv` application. IPTVnator loads `libmpv` through a native Node addon and renders MPV frames into an app-owned native video surface. macOS uses the libmpv render API in an `NSOpenGLView` because the mpv `wid` path produced a black video surface inside Electron. Windows and Linux use mpv's `wid` option against IPTVnator-owned child windows (`HWND` on Windows, X11 `Window` on Linux/Xwayland).
|
||||
The embedded player renders MPV frames into an app-owned native video surface. macOS uses the libmpv render API in an `NSOpenGLView` because the mpv `wid` path produced a black video surface inside Electron. Windows loads `libmpv` through the native Node addon and uses mpv's `wid` option against an IPTVnator-owned child `HWND`. Linux creates an IPTVnator-owned X11/Xwayland child `Window` and starts an out-of-process `mpv --wid=<window>` instance for that child window.
|
||||
|
||||
On Linux, `embedded_mpv.node` must not link directly to `libmpv` or load libmpv in-process. Electron loads its own `libffmpeg` and Chromium graphics stack; in-process libmpv can resolve FFmpeg/GL symbols against incompatible Electron symbols, while isolated dynamic-loader namespaces introduce thread/runtime ownership problems. The Linux addon therefore owns only the X11 child-window embedding, process lifecycle, and a private MPV JSON IPC socket. It starts `mpv --wid=<window> --input-ipc-server=<socket>`, polls `time-pos`, `duration`, `volume`, and `pause`, and forwards pause/seek/volume/audio-track commands through that socket. The Linux MPV JSON IPC polling runs on an addon-owned background thread; `getSessionSnapshot()` returns the last cached snapshot and must not perform socket round trips on Electron's main thread. Linux MPV process teardown sends `SIGTERM` on the caller path, then waits and escalates to `SIGKILL` on a detached cleanup thread. A healthy Linux build lists X11/Xext as addon dependencies, but `ldd apps/electron-backend/native/build/Release/embedded_mpv.node` must not list `libmpv`. Runtime support also requires an `mpv` executable on `PATH`.
|
||||
|
||||
Linux native Wayland embedding is not implemented. When Electron is started on Xwayland, the Linux backend also starts the child MPV process with `WAYLAND_DISPLAY` removed, `XDG_SESSION_TYPE=x11`, `--vo=gpu,x11`, and `--gpu-context=x11egl`. This prevents MPV from choosing a Wayland VO in a Wayland desktop session, which would ignore the X11 `--wid` target and open a separate top-level MPV window.
|
||||
|
||||
## Linux Support Matrix
|
||||
|
||||
Embedded MPV on Linux is supported only for x64 desktop builds where Electron runs under X11 or Xwayland and an `mpv` executable is available on `PATH`. Native Wayland embedding is not supported in this implementation. Packaged Linux launchers pass `--ozone-platform=x11` so Wayland desktops use Xwayland when it is available.
|
||||
|
||||
Current release-announcement wording should stay close to this:
|
||||
|
||||
- Supported display path: X11 or Xwayland.
|
||||
- Not supported: native Wayland embedding.
|
||||
- Validated locally: Ubuntu 24.04 GNOME Wayland session with Electron forced to X11/Xwayland and system `mpv`.
|
||||
- Validated in CI: Ubuntu 22.04 standard Linux package build and Ubuntu 24.04 Flatpak package build.
|
||||
- Expected standard packages: `.deb` on Ubuntu/Debian, `pacman` on Arch/Manjaro, `.rpm` on RPM-based distributions, and AppImage on x64 glibc systems, all with system `mpv` installed.
|
||||
- Sandbox caveat: Flatpak and Snap packages build and continue to support the normal inline/external-player flows, but embedded MPV is not announced as supported there yet because the Linux backend launches `mpv --wid` and those sandboxed formats do not expose the host `mpv` executable to the app by default.
|
||||
|
||||
The flow is:
|
||||
|
||||
@@ -36,8 +53,8 @@ The flow is:
|
||||
4. The component asks the preload API to create an embedded MPV session with the current viewport bounds and initial volume.
|
||||
5. The Electron preload forwards calls through IPC to the main process.
|
||||
6. `EmbeddedMpvNativeService` owns sessions, polls snapshots, and emits session updates to the renderer.
|
||||
7. The native addon creates an `mpv_handle`, disables MPV's own OSC/input handling, and creates an app-owned platform video host inside the Electron window.
|
||||
8. On macOS the addon configures `vo=libmpv`, creates a `mpv_render_context`, and draws into the OpenGL surface. On Windows/Linux it passes the child-window id to mpv through `wid` and uses `vo=gpu`.
|
||||
7. The native addon creates an app-owned platform video host inside the Electron window.
|
||||
8. On macOS the addon configures `vo=libmpv`, creates a `mpv_render_context`, and draws into the OpenGL surface. On Windows it creates an `mpv_handle`, disables MPV's own OSC/input handling, and passes the child-window id through `wid`. On Linux it starts `mpv --wid=<x11-window>` in a separate process with a private JSON IPC socket and tracks that process until playback replacement or dispose.
|
||||
9. Resize, scroll, and fullscreen changes are measured in Angular and sent back to the addon as native bounds so the platform video host stays aligned with the Angular layout.
|
||||
10. Playback controls remain IPTVnator-owned Angular UI. MPV receives commands only through the controlled IPC surface.
|
||||
|
||||
@@ -68,13 +85,15 @@ The dock has a stable reserved height while embedded controls are enabled. Contr
|
||||
|
||||
`ResolvedPortalPlayback.startTime` is treated as a media offset in seconds for VOD and episodes. The native addon passes it as the `start` option in one MPV `loadfile` options map together with title, user agent, referrer, and HTTP headers.
|
||||
|
||||
VOD and episode payloads carry `contentInfo` and are treated as non-live unless `isLive` is explicitly set. The embedded MPV UI must not infer "live" from a missing duration alone: on Linux the first snapshot can arrive before the out-of-process MPV IPC socket has reported `duration`, so the UI shows an unknown duration placeholder until MPV reports a finite duration. Live playback is classified from `ResolvedPortalPlayback.isLive` when present, otherwise from the absence of `contentInfo`.
|
||||
|
||||
Live catchup is different: the catchup URL already encodes the archive window, so live catchup playback must not pass an absolute Unix timestamp as `startTime`.
|
||||
|
||||
Audio tracks are discovered from MPV's `track-list` property. The selected track is controlled through MPV's `aid` property. Switching tracks must not reload the stream.
|
||||
|
||||
Subtitle tracks mirror the audio-track contract: same `track-list` source, same parsing pipeline, but selected through MPV's `sid` property. A `trackId` of `-1` from the renderer is interpreted as "disable subtitles" and translated to `sid=no` at the addon boundary. Playback speed is observed and set through MPV's `speed` property, clamped at the addon to `[0.25, 4.0]`. Aspect override uses MPV's `video-aspect-override` property as a passthrough string ("no", "16:9", "4:3", "21:9", "2.35:1"). All four properties (`sid`, `speed`, `video-aspect-override`, plus `aid`) are observed at session init so renderer state stays in sync with the native side without needing extra round-trips.
|
||||
|
||||
The renderer learns which features the loaded addon binary supports through the `EmbeddedMpvSupport.capabilities` field returned from `getEmbeddedMpvSupport()`. The service probes `typeof addon.<method> === 'function'` for each optional native export. Older addon binaries with the original audio-only surface return `capabilities: { subtitles: false, playbackSpeed: false, aspectOverride: false, screenshot: false, recording: false }`, and the renderer hides the corresponding controls instead of throwing at runtime. After a native rebuild, the new buttons light up automatically without renderer changes.
|
||||
The renderer learns which features the loaded addon binary supports through the `EmbeddedMpvSupport.capabilities` field returned from `getEmbeddedMpvSupport()`. The service probes `typeof addon.<method> === 'function'` for each optional native export. Older addon binaries with the original audio-only surface return `capabilities: { subtitles: false, playbackSpeed: false, aspectOverride: false, screenshot: false, recording: false }`, and the renderer hides the corresponding controls instead of throwing at runtime. Linux intentionally does not export libmpv-only optional controls while it uses the process-isolated `mpv --wid` backend.
|
||||
|
||||
## Session End And Series Navigation
|
||||
|
||||
@@ -90,13 +109,13 @@ Renderer autoplay must use `ended` only. It must not treat `closed`, `idle`, or
|
||||
|
||||
The native addon also observes mpv's `eof-reached` property and maps a true value to `ended`. This is required because embedded sessions run with `keep-open=yes`; MPV can pause at EOF while keeping the file loaded, so relying only on `MPV_EVENT_END_FILE` can leave the renderer in a paused-at-end state and block series autoplay.
|
||||
|
||||
Series episode navigation is owned by the portal feature components and passed through the shared inline player to `EmbeddedMpvPlayerComponent`. The embedded MPV controls show `skip_previous` and `skip_next` buttons only as part of the embedded player control surface. The shared navigation payload contains `canPrevious`, `canNext`, and `autoplayEnabled`; the component disables previous/next at the current-season boundaries and guards the output handlers as well as the button disabled state.
|
||||
Series episode navigation is owned by the portal feature components and passed through the shared inline player to `EmbeddedMpvPlayerComponent`. The embedded MPV controls show `skip_previous` and `skip_next` buttons only for non-live series playback. The shared navigation payload contains `canPrevious`, `canNext`, and `autoplayEnabled`; the component disables previous/next at the current-season boundaries and guards the output handlers as well as the button disabled state.
|
||||
|
||||
Autoplay is enabled by default for series playback in embedded MPV. On `ended`, Xtream and Stalker series detail views start the next episode only when the current episode has a next item in the same season. Playback stops on the last episode of the current season. Previous always switches to the previous episode in the current season; it does not implement a restart-threshold behavior.
|
||||
|
||||
## Live Stream Recording
|
||||
|
||||
Embedded MPV can record live streams through mpv's `stream-record` option. IPTVnator exposes this only for `ResolvedPortalPlayback.isLive === true`; VOD, episodes, catchup playback, radio audio playback, and non-embedded players do not show the recording control.
|
||||
Embedded MPV can record live streams through mpv's `stream-record` option. IPTVnator exposes this only for playback classified as live (`ResolvedPortalPlayback.isLive` when present, otherwise no `contentInfo`); VOD, episodes, catchup playback, radio audio playback, and non-embedded players do not show the recording control.
|
||||
|
||||
Recording is session-scoped:
|
||||
|
||||
@@ -164,29 +183,30 @@ Current development behavior:
|
||||
|
||||
- The addon build supports `darwin`, `win32`, and `linux`; Windows and Linux builds require running on that target OS.
|
||||
- The build script first looks for a staged runtime at `vendor/embedded-mpv/<platform>-<arch>/`.
|
||||
- The staged runtime must contain `include/mpv/client.h`, platform runtime files, and `runtime-manifest.json`.
|
||||
- The staged runtime/build inputs must contain `include/mpv/client.h` and `runtime-manifest.json`. macOS and Windows staging also contains the platform runtime files that are bundled into the app.
|
||||
- The compiled `.node` addon is copied into `dist/apps/electron-backend/native/embedded_mpv.node`.
|
||||
- Bundled runtime files are copied into `dist/apps/electron-backend/native/lib/`. macOS copies `.dylib` and non-`.dylib` Mach-O dependencies, Windows copies `mpv-2.dll`/`mpv.dll` and import libraries, and Linux copies `libmpv.so*`.
|
||||
- Bundled runtime files are copied into `dist/apps/electron-backend/native/lib/` for macOS and Windows. macOS copies `.dylib` and non-`.dylib` Mach-O dependencies; Windows copies `mpv-2.dll`/`mpv.dll` and import libraries. Linux writes an `external-mpv-process` manifest and intentionally leaves `libmpv.so` out of the package.
|
||||
- Linux does not bundle or load `libmpv` in the Electron process. Its native addon still requires staged MPV headers, but runtime support depends on the X11/Xwayland window handle plus an `mpv` executable on `PATH`.
|
||||
- `afterPack` copies `dist/apps/electron-backend/native/` into `app.asar.unpacked/electron-backend/native/` on macOS, Windows, and Linux so the addon, manifest, and runtime libraries are filesystem-addressable.
|
||||
|
||||
Current release caveat:
|
||||
|
||||
- Release packaging requires a `vendored-lgpl` runtime manifest.
|
||||
- Release packaging requires a `vendored-lgpl` runtime manifest on macOS and Windows, and an `external-mpv-process` manifest on Linux.
|
||||
- macOS release packaging rejects embedded MPV binaries linked to `/opt/homebrew` or `/usr/local`.
|
||||
- Windows and Linux release packaging verifies that the platform runtime file is present when Embedded MPV is required.
|
||||
- Windows release packaging verifies that the platform runtime file is present when Embedded MPV is required. Linux release packaging verifies that the addon and manifest are present and that no bundled `libmpv.so` files slipped into the package.
|
||||
- Local development can opt into Homebrew `libmpv` only by setting `IPTVNATOR_EMBEDDED_MPV_ALLOW_HOMEBREW=1`; packaged release validation rejects that runtime origin.
|
||||
|
||||
Before public release, packaging must:
|
||||
|
||||
- stage an LGPL-compatible `libmpv` runtime for each release platform/architecture
|
||||
- stage an LGPL-compatible `libmpv` runtime for each macOS/Windows release platform/architecture, and stage Linux MPV headers/build metadata for Linux
|
||||
- collect indirect macOS dependencies expressed as absolute paths, `@loader_path`, or `@rpath`
|
||||
- rewrite macOS install names and dependency paths to app-relative paths such as `@loader_path`
|
||||
- code-sign and notarize the full macOS dependency set
|
||||
- ensure Windows runtime staging includes both the DLL and the import library used by `node-gyp`
|
||||
- ensure Linux runtime staging includes a runtime SONAME (`libmpv.so.2`, `libmpv.so.1`, or `libmpv.so`) and an unversioned `libmpv.so` linker name for local native builds
|
||||
- publish the corresponding FFmpeg/libmpv source and build metadata
|
||||
- ensure Linux native builds do not gain a direct `libmpv` dependency; the runtime playback path is `mpv --wid` in a separate process
|
||||
- publish the corresponding FFmpeg/libmpv source and build metadata for bundled macOS/Windows runtimes; Linux should document the distribution package versions used as build inputs
|
||||
|
||||
Users do not need the MPV GUI application for this architecture. IPTVnator bundles `libmpv` for release builds. If the bundled runtime is missing or fails to load, embedded MPV is hidden/unsupported and the existing inline/external players remain available.
|
||||
Users on macOS and Windows do not need the MPV GUI application for this architecture. Linux currently requires an `mpv` executable because the supported backend is process-isolated. If the native addon/runtime prerequisites or Linux `mpv` executable are missing, embedded MPV is hidden/unsupported and the existing inline/external players remain available.
|
||||
|
||||
## Runtime Staging
|
||||
|
||||
@@ -219,9 +239,9 @@ pnpm embedded-mpv:stage-runtime -- darwin arm64 /tmp/embedded-mpv-prefix
|
||||
|
||||
During temporary PR and `master` artifact testing, CI can restore an exact-keyed GitHub Actions cache for the staged `vendor/embedded-mpv/<platform>-<arch>/` runtime and skip the expensive source build where a source builder exists. The cache only contains `include/`, `lib/`, and `runtime-manifest.json`; it never contains the compiled `embedded_mpv.node` addon because that target depends on Electron headers, ABI, architecture, and build environment. Runtime cache entries are saved only from trusted repository refs, and tagged public macOS release builds continue to rebuild from pinned sources until a dedicated signed and attested runtime artifact flow exists. Windows and Linux currently use staged runtime cache inputs only; adding pinned source builders for those platforms is a separate release-hardening task.
|
||||
|
||||
The CI builder pins FFmpeg `8.1`, mpv `0.41.0`, libplacebo `7.360.1`, libass `0.17.3`, FreeType `2.13.3`, FriBidi `1.0.16`, and HarfBuzz `8.5.0`. FFmpeg disables autodetected external libraries so Homebrew libraries cannot silently enter the runtime. Libplacebo is checked out from git with the submodules required by its Meson build because the generated GitHub archive does not include submodule contents. Even with Vulkan disabled, libplacebo still compiles Vulkan stubs and needs `3rdparty/Vulkan-Headers`. The generated manifest records source URLs, archive SHA-256 values where applicable, libplacebo git commit/submodule metadata, FFmpeg configure flags, and mpv Meson flags. The staging step normalizes that manifest to `origin: vendored-lgpl`, which release package validation requires.
|
||||
The CI builder pins FFmpeg `8.1`, mpv `0.41.0`, libplacebo `7.360.1`, libass `0.17.3`, FreeType `2.13.3`, FriBidi `1.0.16`, and HarfBuzz `8.5.0`. FFmpeg disables autodetected external libraries so Homebrew libraries cannot silently enter the runtime. Libplacebo is checked out from git with the submodules required by its Meson build because the generated GitHub archive does not include submodule contents. Even with Vulkan disabled, libplacebo still compiles Vulkan stubs and needs `3rdparty/Vulkan-Headers`. The generated manifest records source URLs, archive SHA-256 values where applicable, libplacebo git commit/submodule metadata, FFmpeg configure flags, and mpv Meson flags. The staging step normalizes macOS/Windows manifests to `origin: vendored-lgpl`, which release package validation requires on those platforms.
|
||||
|
||||
The Electron backend build consumes the staged runtime and copies platform runtime files into the native build output. macOS additionally rewrites Mach-O paths so `embedded_mpv.node` loads `@loader_path/lib/libmpv.2.dylib` instead of a machine-local Homebrew path. After `install_name_tool` rewrites any addon or runtime binary, the build re-signs that binary with an ad-hoc signature for local development. Release packaging still performs the normal app signing and notarization later.
|
||||
The Electron backend build consumes the staged runtime/build inputs and copies macOS/Windows runtime files into the native build output. Linux consumes the staged MPV headers, writes an `external-mpv-process` manifest, and does not copy `libmpv.so` into the package. macOS additionally rewrites Mach-O paths so `embedded_mpv.node` loads `@loader_path/lib/libmpv.2.dylib` instead of a machine-local Homebrew path. After `install_name_tool` rewrites any addon or runtime binary, the build re-signs that binary with an ad-hoc signature for local development. Release packaging still performs the normal app signing and notarization later.
|
||||
|
||||
For local development before the vendored runtime exists, Homebrew can be used explicitly:
|
||||
|
||||
@@ -284,8 +304,8 @@ If an embedded session fails to initialize, the app should keep the user in cont
|
||||
|
||||
Do not expose embedded MPV broadly until these pass on every supported target:
|
||||
|
||||
- packaged app starts without system `mpv` installed
|
||||
- bundled `libmpv` and dependent runtime files pass platform package validation
|
||||
- macOS/Windows packaged app starts without system `mpv` installed; Linux reports Embedded MPV unsupported with a clear message when system `mpv` is missing
|
||||
- bundled `libmpv` and dependent runtime files pass macOS/Windows package validation; Linux package validation confirms the external-process manifest and absence of bundled `libmpv.so`
|
||||
- macOS bundled `libmpv` and dependent dylibs pass code signing and notarization
|
||||
- VOD resume starts near the saved offset
|
||||
- series EOF emits `ended` and embedded MPV auto-continues only inside the current season
|
||||
|
||||
@@ -50,6 +50,7 @@
|
||||
"linux": {
|
||||
"category": "Video",
|
||||
"executableName": "iptvnator",
|
||||
"executableArgs": ["--ozone-platform=x11"],
|
||||
"desktop": {
|
||||
"entry": {
|
||||
"StartupWMClass": "iptvnator"
|
||||
|
||||
@@ -100,33 +100,35 @@
|
||||
}}</mat-icon>
|
||||
</button>
|
||||
|
||||
<button
|
||||
mat-icon-button
|
||||
type="button"
|
||||
class="embedded-mpv-player__control-button"
|
||||
data-test-id="embedded-mpv-previous-episode"
|
||||
[disabled]="isLoading() || !canPreviousEpisode()"
|
||||
(click)="requestPreviousEpisode()"
|
||||
aria-label="Previous episode"
|
||||
matTooltip="Previous episode"
|
||||
matTooltipPosition="above"
|
||||
>
|
||||
<mat-icon>skip_previous</mat-icon>
|
||||
</button>
|
||||
@if (showSeriesNavigation()) {
|
||||
<button
|
||||
mat-icon-button
|
||||
type="button"
|
||||
class="embedded-mpv-player__control-button"
|
||||
data-test-id="embedded-mpv-previous-episode"
|
||||
[disabled]="isLoading() || !canPreviousEpisode()"
|
||||
(click)="requestPreviousEpisode()"
|
||||
aria-label="Previous episode"
|
||||
matTooltip="Previous episode"
|
||||
matTooltipPosition="above"
|
||||
>
|
||||
<mat-icon>skip_previous</mat-icon>
|
||||
</button>
|
||||
|
||||
<button
|
||||
mat-icon-button
|
||||
type="button"
|
||||
class="embedded-mpv-player__control-button"
|
||||
data-test-id="embedded-mpv-next-episode"
|
||||
[disabled]="isLoading() || !canNextEpisode()"
|
||||
(click)="requestNextEpisode()"
|
||||
aria-label="Next episode"
|
||||
matTooltip="Next episode"
|
||||
matTooltipPosition="above"
|
||||
>
|
||||
<mat-icon>skip_next</mat-icon>
|
||||
</button>
|
||||
<button
|
||||
mat-icon-button
|
||||
type="button"
|
||||
class="embedded-mpv-player__control-button"
|
||||
data-test-id="embedded-mpv-next-episode"
|
||||
[disabled]="isLoading() || !canNextEpisode()"
|
||||
(click)="requestNextEpisode()"
|
||||
aria-label="Next episode"
|
||||
matTooltip="Next episode"
|
||||
matTooltipPosition="above"
|
||||
>
|
||||
<mat-icon>skip_next</mat-icon>
|
||||
</button>
|
||||
}
|
||||
|
||||
<button
|
||||
mat-icon-button
|
||||
@@ -179,7 +181,7 @@
|
||||
<span>{{
|
||||
formatTime(session()?.durationSeconds)
|
||||
}}</span>
|
||||
} @else {
|
||||
} @else if (isLivePlayback()) {
|
||||
<span
|
||||
class="embedded-mpv-player__live-badge"
|
||||
aria-label="Live stream"
|
||||
@@ -190,6 +192,8 @@
|
||||
></span>
|
||||
LIVE
|
||||
</span>
|
||||
} @else {
|
||||
<span>--:--</span>
|
||||
}
|
||||
</div>
|
||||
@if (recordingStatusText(); as recordingStatus) {
|
||||
|
||||
+108
-25
@@ -1,11 +1,13 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { Component, signal } from '@angular/core';
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { By } from '@angular/platform-browser';
|
||||
import { ResolvedPortalPlayback } from '@iptvnator/shared/interfaces';
|
||||
import {
|
||||
EmbeddedMpvSession,
|
||||
ResolvedPortalPlayback,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { EmbeddedMpvOverlayVisibilityService } from './embedded-mpv-overlay-visibility.service';
|
||||
import { EmbeddedMpvPlayerComponent } from './embedded-mpv-player.component';
|
||||
import { EmbeddedMpvSessionController } from './embedded-mpv-session-controller';
|
||||
import { signal } from '@angular/core';
|
||||
|
||||
@Component({
|
||||
imports: [EmbeddedMpvPlayerComponent],
|
||||
@@ -20,9 +22,14 @@ import { signal } from '@angular/core';
|
||||
`,
|
||||
})
|
||||
class EmbeddedMpvPlayerHostComponent {
|
||||
readonly playback: ResolvedPortalPlayback = {
|
||||
playback: ResolvedPortalPlayback = {
|
||||
streamUrl: 'https://example.test/series/1002.mp4',
|
||||
title: 'Episode 2',
|
||||
contentInfo: {
|
||||
playlistId: 'playlist-1',
|
||||
contentXtreamId: 1002,
|
||||
contentType: 'episode',
|
||||
},
|
||||
};
|
||||
|
||||
seriesNavigation = {
|
||||
@@ -41,27 +48,9 @@ describe('EmbeddedMpvPlayerComponent series navigation', () => {
|
||||
let player: EmbeddedMpvPlayerComponent;
|
||||
let controller: EmbeddedMpvSessionController;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [EmbeddedMpvPlayerHostComponent],
|
||||
providers: [
|
||||
{
|
||||
provide: EmbeddedMpvOverlayVisibilityService,
|
||||
useValue: { overlayActive: signal(false) },
|
||||
},
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(EmbeddedMpvPlayerHostComponent);
|
||||
fixture.detectChanges();
|
||||
|
||||
const playerDebugElement = fixture.debugElement.query(
|
||||
By.directive(EmbeddedMpvPlayerComponent)
|
||||
);
|
||||
player = playerDebugElement.componentInstance;
|
||||
controller = playerDebugElement.injector.get(
|
||||
EmbeddedMpvSessionController
|
||||
);
|
||||
const configureReadyController = (
|
||||
sessionOverrides: Partial<EmbeddedMpvSession> = {}
|
||||
) => {
|
||||
controller.support.set({
|
||||
supported: true,
|
||||
platform: 'darwin',
|
||||
@@ -90,8 +79,36 @@ describe('EmbeddedMpvPlayerComponent series navigation', () => {
|
||||
recording: { active: false },
|
||||
startedAt: '2026-06-06T12:00:00Z',
|
||||
updatedAt: '2026-06-06T12:00:00Z',
|
||||
...sessionOverrides,
|
||||
});
|
||||
fixture.detectChanges();
|
||||
};
|
||||
|
||||
const bindPlayer = () => {
|
||||
const playerDebugElement = fixture.debugElement.query(
|
||||
By.directive(EmbeddedMpvPlayerComponent)
|
||||
);
|
||||
player = playerDebugElement.componentInstance;
|
||||
controller = playerDebugElement.injector.get(
|
||||
EmbeddedMpvSessionController
|
||||
);
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [EmbeddedMpvPlayerHostComponent],
|
||||
providers: [
|
||||
{
|
||||
provide: EmbeddedMpvOverlayVisibilityService,
|
||||
useValue: { overlayActive: signal(false) },
|
||||
},
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(EmbeddedMpvPlayerHostComponent);
|
||||
fixture.detectChanges();
|
||||
bindPlayer();
|
||||
configureReadyController();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -143,4 +160,70 @@ describe('EmbeddedMpvPlayerComponent series navigation', () => {
|
||||
expect(fixture.componentInstance.endedCount).toBe(1);
|
||||
expect(player.isPlaying()).toBe(false);
|
||||
});
|
||||
|
||||
it('hides episode navigation for live playback', () => {
|
||||
fixture.destroy();
|
||||
fixture = TestBed.createComponent(EmbeddedMpvPlayerHostComponent);
|
||||
fixture.componentInstance.playback = {
|
||||
streamUrl: 'https://example.test/live/zdf-hd.ts',
|
||||
title: 'ZDF HD',
|
||||
isLive: true,
|
||||
};
|
||||
fixture.detectChanges();
|
||||
bindPlayer();
|
||||
configureReadyController({
|
||||
streamUrl: 'https://example.test/live/zdf-hd.ts',
|
||||
title: 'ZDF HD',
|
||||
durationSeconds: 120,
|
||||
});
|
||||
|
||||
expect(
|
||||
fixture.debugElement.query(
|
||||
By.css('[data-test-id="embedded-mpv-previous-episode"]')
|
||||
)
|
||||
).toBeNull();
|
||||
expect(
|
||||
fixture.debugElement.query(
|
||||
By.css('[data-test-id="embedded-mpv-next-episode"]')
|
||||
)
|
||||
).toBeNull();
|
||||
expect(
|
||||
fixture.debugElement.query(
|
||||
By.css('.embedded-mpv-player__live-badge')
|
||||
)
|
||||
).not.toBeNull();
|
||||
expect(
|
||||
fixture.debugElement.query(
|
||||
By.css('button[aria-label="Back 10 seconds"]')
|
||||
).nativeElement.disabled
|
||||
).toBe(true);
|
||||
expect(
|
||||
fixture.debugElement.query(
|
||||
By.css('button[aria-label="Forward 10 seconds"]')
|
||||
).nativeElement.disabled
|
||||
).toBe(true);
|
||||
expect(
|
||||
fixture.debugElement.query(By.css('.embedded-mpv-player__slider'))
|
||||
.nativeElement.disabled
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('does not label VOD or episode playback as live while duration is loading', () => {
|
||||
controller.session.update((session) =>
|
||||
session
|
||||
? {
|
||||
...session,
|
||||
durationSeconds: null,
|
||||
}
|
||||
: session
|
||||
);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(
|
||||
fixture.debugElement.query(
|
||||
By.css('.embedded-mpv-player__live-badge')
|
||||
)
|
||||
).toBeNull();
|
||||
expect(fixture.nativeElement.textContent).toContain('--:--');
|
||||
});
|
||||
});
|
||||
@@ -128,8 +128,17 @@ export class EmbeddedMpvPlayerComponent implements OnDestroy {
|
||||
);
|
||||
readonly isPlaying = computed(() => this.session()?.status === 'playing');
|
||||
readonly isErrored = computed(() => this.session()?.status === 'error');
|
||||
readonly isLivePlayback = computed(() => {
|
||||
const playback = this.playback();
|
||||
if (typeof playback.isLive === 'boolean') {
|
||||
return playback.isLive;
|
||||
}
|
||||
|
||||
return !playback.contentInfo;
|
||||
});
|
||||
readonly canSeek = computed(
|
||||
() => (this.session()?.durationSeconds ?? 0) > 0
|
||||
() =>
|
||||
!this.isLivePlayback() && (this.session()?.durationSeconds ?? 0) > 0
|
||||
);
|
||||
readonly canFullscreen = computed(
|
||||
() =>
|
||||
@@ -198,18 +207,25 @@ export class EmbeddedMpvPlayerComponent implements OnDestroy {
|
||||
readonly canRecord = computed(
|
||||
() =>
|
||||
this.capabilities().recording &&
|
||||
this.playback().isLive === true &&
|
||||
this.isLivePlayback() &&
|
||||
this.isSupported() &&
|
||||
!this.isErrored()
|
||||
);
|
||||
readonly isRecording = computed(
|
||||
() => this.session()?.recording?.active === true
|
||||
);
|
||||
readonly showSeriesNavigation = computed(
|
||||
() => !this.isLivePlayback() && this.seriesNavigation() !== null
|
||||
);
|
||||
readonly canPreviousEpisode = computed(
|
||||
() => this.seriesNavigation()?.canPrevious === true
|
||||
() =>
|
||||
this.showSeriesNavigation() &&
|
||||
this.seriesNavigation()?.canPrevious === true
|
||||
);
|
||||
readonly canNextEpisode = computed(
|
||||
() => this.seriesNavigation()?.canNext === true
|
||||
() =>
|
||||
this.showSeriesNavigation() &&
|
||||
this.seriesNavigation()?.canNext === true
|
||||
);
|
||||
readonly recordingElapsed = computed(() => {
|
||||
const startedAt = this.session()?.recording?.startedAt;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Embedded MPV Runtime
|
||||
|
||||
This folder contains tooling for preparing the `libmpv` runtime that is bundled with IPTVnator's experimental embedded MPV player.
|
||||
This folder contains tooling for preparing MPV runtime/build inputs for IPTVnator's experimental embedded MPV player. macOS and Windows bundle `libmpv`; Linux uses staged MPV headers for compilation and launches the system `mpv` executable at runtime.
|
||||
|
||||
## Runtime Policy
|
||||
|
||||
@@ -34,8 +34,6 @@ vendor/embedded-mpv/
|
||||
runtime-manifest.json
|
||||
linux-x64/
|
||||
include/mpv/client.h
|
||||
lib/libmpv.so.2
|
||||
lib/libmpv.so
|
||||
runtime-manifest.json
|
||||
```
|
||||
|
||||
@@ -59,11 +57,11 @@ pnpm embedded-mpv:stage-runtime:macos -- arm64 /path/to/lgpl-prefix
|
||||
pnpm embedded-mpv:stage-runtime:macos -- x64 /path/to/lgpl-prefix
|
||||
```
|
||||
|
||||
The prefix must contain `include/mpv/client.h` and the platform runtime files:
|
||||
The prefix must contain `include/mpv/client.h` and the platform runtime/build files:
|
||||
|
||||
- macOS: `lib/libmpv.2.dylib` or `lib/libmpv.dylib` plus all non-system dylib dependencies
|
||||
- Windows: `lib/mpv.lib` or `lib/mpv-2.lib`, and `bin/mpv-2.dll` or `lib/mpv-2.dll`
|
||||
- Linux: `lib/libmpv.so.2`, `lib/libmpv.so.1`, or `lib/libmpv.so`; include a `libmpv.so` linker name when building locally
|
||||
- Linux: `include/mpv/client.h`; CI also records the `libmpv-dev` and `mpv` package versions used as build inputs. Linux runtime playback uses the system `mpv` executable and does not bundle `libmpv.so`.
|
||||
|
||||
If the prefix contains `runtime-manifest.json`, the staging script copies its build metadata into the vendored manifest. At minimum, record:
|
||||
|
||||
@@ -80,6 +78,13 @@ pnpm embedded-mpv:build-runtime -- arm64 /tmp/embedded-mpv-prefix
|
||||
pnpm embedded-mpv:stage-runtime -- darwin arm64 /tmp/embedded-mpv-prefix
|
||||
```
|
||||
|
||||
Linux CI does not build libmpv from source. It installs Ubuntu runner packages
|
||||
(`libmpv-dev` and `mpv`), stages their headers and build metadata under
|
||||
`vendor/embedded-mpv/linux-x64/`, and requires the native addon/package layout
|
||||
to be present. Linux playback does not load or bundle `libmpv` in the Electron
|
||||
process; the addon creates an X11 child window and starts a system `mpv --wid`
|
||||
process at runtime.
|
||||
|
||||
During temporary PR and `master` artifact testing, CI restores an exact-keyed GitHub Actions cache for the staged `vendor/embedded-mpv/<platform>-<arch>/` runtime before falling back to the macOS source build where available. The cache key includes the target platform, architecture, macOS deployment target, Xcode version when available, and hashes of the runtime build/staging scripts. Cache entries are saved only from trusted repository refs and are treated strictly as a speed optimization; tagged macOS release builds continue to rebuild from pinned sources unless a future signed and attested runtime artifact flow is introduced.
|
||||
|
||||
The builder currently pins:
|
||||
@@ -89,11 +94,11 @@ The builder currently pins:
|
||||
- libplacebo `7.360.1`, checked out from git with the `glad`, Python template, `fast_float`, and `Vulkan-Headers` submodules required by its Meson build
|
||||
- libass `0.17.3` plus FreeType, FriBidi, and HarfBuzz
|
||||
|
||||
The build manifest records source URLs, downloaded archive SHA-256 values where applicable, libplacebo git commit/submodule metadata, and the exact FFmpeg/mpv flags. The staged manifest is normalized to `origin: vendored-lgpl`, which is the only embedded MPV runtime origin allowed in required macOS release packaging.
|
||||
The build manifest records source URLs, downloaded archive SHA-256 values where applicable, libplacebo git commit/submodule metadata, and the exact FFmpeg/mpv flags. The staged macOS/Windows manifest is normalized to `origin: vendored-lgpl`, which is the only embedded MPV runtime origin allowed in required macOS/Windows release packaging.
|
||||
|
||||
## Build Integration
|
||||
|
||||
`apps/electron-backend/build-embedded-mpv.js` links the native addon against the staged runtime, copies runtime libraries into `apps/electron-backend/native/build/Release/lib/`, rewrites macOS Mach-O paths to `@loader_path`, and writes `embedded-mpv-runtime.json`.
|
||||
`apps/electron-backend/build-embedded-mpv.js` builds the native addon against the staged runtime/build inputs, copies macOS/Windows runtime libraries into `apps/electron-backend/native/build/Release/lib/`, rewrites macOS Mach-O paths to `@loader_path`, and writes `embedded-mpv-runtime.json`. Linux builds use the staged MPV headers and system X11 development libraries, write an `external-mpv-process` manifest, and must not copy or link directly to `libmpv`; CI validates this with package checks and `ldd`.
|
||||
|
||||
For local macOS development with Homebrew `mpv`, use:
|
||||
|
||||
@@ -103,14 +108,14 @@ pnpm run serve:backend:embedded-mpv
|
||||
|
||||
The script rebuilds the native addon with `IPTVNATOR_EMBEDDED_MPV_ALLOW_HOMEBREW=1` before starting Electron with the experimental player enabled. Use this only for local testing; release packaging rejects the resulting `homebrew-dev` runtime manifest.
|
||||
|
||||
The `afterPack` hook copies `dist/apps/electron-backend/native/` into `app.asar.unpacked/electron-backend/native/` so the addon, runtime manifest, and runtime libraries are available as real files on macOS, Windows, and Linux.
|
||||
The `afterPack` hook copies `dist/apps/electron-backend/native/` into `app.asar.unpacked/electron-backend/native/` so the addon, runtime manifest, and runtime libraries are available as real files where needed. Linux packages include the addon and manifest, but no bundled `libmpv.so`.
|
||||
|
||||
During release packaging, `tools/packaging/electron-after-pack.cjs` verifies that the packaged app uses a `vendored-lgpl` runtime. macOS artifacts additionally verify that Mach-O dependencies have no `/opt/homebrew` or `/usr/local` dynamic links for embedded MPV.
|
||||
During release packaging, `tools/packaging/electron-after-pack.cjs` verifies that macOS/Windows packages use a `vendored-lgpl` runtime/build input set. macOS artifacts additionally verify that Mach-O dependencies have no `/opt/homebrew` or `/usr/local` dynamic links for embedded MPV. Linux artifacts verify that the addon and `external-mpv-process` manifest are present, that no bundled `libmpv.so` files are present, and the runtime support check verifies that `mpv` is available on `PATH`.
|
||||
|
||||
Set `IPTVNATOR_REQUIRE_EMBEDDED_MPV=1` when packaging a release artifact that must include Embedded MPV. The same variable is temporarily enabled for macOS PR and `master` push artifacts while the bundled runtime is being tested. Windows and Linux CI packaging requires Embedded MPV when an exact-keyed staged runtime cache is restored; otherwise those jobs build without the native addon and Settings keeps Embedded MPV hidden.
|
||||
Set `IPTVNATOR_REQUIRE_EMBEDDED_MPV=1` when packaging a release artifact that must include Embedded MPV. The same variable is temporarily enabled for macOS PR and `master` push artifacts while the bundled runtime is being tested. Linux CI packaging requires Embedded MPV after staging the Ubuntu package build inputs. Windows CI packaging requires Embedded MPV when an exact-keyed staged runtime cache is restored; otherwise the Windows job builds without the native addon and Settings keeps Embedded MPV hidden.
|
||||
|
||||
## Platform Notes
|
||||
|
||||
- macOS keeps the existing libmpv render-context backend because mpv `wid` stays black inside Electron on macOS.
|
||||
- Windows uses an embedded child `HWND` and passes it to mpv through `wid`.
|
||||
- Linux uses an X11 child window and passes it to mpv through `wid`. Native Wayland is not supported in v1; run under X11/Xwayland so `DISPLAY` is set.
|
||||
- Linux uses an X11 child window and starts a system `mpv --wid` process for that window. Native Wayland is not supported in v1; run under X11/Xwayland so `DISPLAY` is set and `mpv` can honor the X11 window id.
|
||||
@@ -23,7 +23,7 @@ if (!validTargets.has(`${platform}-${arch}`) || !sourcePrefix) {
|
||||
'- win32 x64',
|
||||
'- linux x64',
|
||||
'',
|
||||
'The prefix must contain include/mpv/client.h and dynamic libmpv runtime files.',
|
||||
'The prefix must contain include/mpv/client.h. macOS and Windows prefixes must also contain dynamic libmpv runtime files.',
|
||||
].join('\n')
|
||||
);
|
||||
process.exit(1);
|
||||
@@ -162,11 +162,15 @@ try {
|
||||
path.join(sourceIncludeDir, 'mpv', 'client.h'),
|
||||
'Missing libmpv header'
|
||||
);
|
||||
if (!findRuntimeFile(sourceLibDir)) {
|
||||
throw new Error(`Missing libmpv runtime for ${platform} in ${sourceLibDir}`);
|
||||
if (platform !== 'linux' && !findRuntimeFile(sourceLibDir)) {
|
||||
throw new Error(
|
||||
`Missing libmpv runtime for ${platform} in ${sourceLibDir}`
|
||||
);
|
||||
}
|
||||
if (platform === 'win32' && !hasWindowsImportLibrary(sourceLibDir)) {
|
||||
throw new Error(`Missing Windows libmpv import library in ${sourceLibDir}`);
|
||||
throw new Error(
|
||||
`Missing Windows libmpv import library in ${sourceLibDir}`
|
||||
);
|
||||
}
|
||||
|
||||
fs.rmSync(destinationIncludeDir, { recursive: true, force: true });
|
||||
@@ -177,14 +181,17 @@ try {
|
||||
path.join(sourceIncludeDir, 'mpv'),
|
||||
path.join(destinationIncludeDir, 'mpv')
|
||||
);
|
||||
copyDirectory(sourceLibDir, destinationLibDir, runtimeFileFilter);
|
||||
if (platform !== 'linux') {
|
||||
copyDirectory(sourceLibDir, destinationLibDir, runtimeFileFilter);
|
||||
}
|
||||
if (platform === 'win32') {
|
||||
copyDirectory(sourceBinDir, destinationLibDir, runtimeFileFilter);
|
||||
}
|
||||
|
||||
const externalManifest =
|
||||
readJsonIfExists(path.join(normalizedPrefix, 'runtime-manifest.json')) ??
|
||||
{};
|
||||
readJsonIfExists(
|
||||
path.join(normalizedPrefix, 'runtime-manifest.json')
|
||||
) ?? {};
|
||||
const manifest = {
|
||||
...externalManifest,
|
||||
origin: 'vendored-lgpl',
|
||||
@@ -193,14 +200,16 @@ try {
|
||||
stagedAt: new Date().toISOString(),
|
||||
runtimeFiles: listRuntimeFiles(destinationLibDir),
|
||||
ffmpeg: {
|
||||
licensePolicy: 'LGPL, built without --enable-gpl and --enable-nonfree',
|
||||
licensePolicy:
|
||||
'LGPL, built without --enable-gpl and --enable-nonfree',
|
||||
...externalManifest.ffmpeg,
|
||||
configureFlags:
|
||||
externalManifest.ffmpeg?.configureFlags ??
|
||||
'Record the exact FFmpeg configure flags used to build this runtime.',
|
||||
},
|
||||
mpv: {
|
||||
licensePolicy: 'LGPL-compatible libmpv, built with -Dlibmpv=true -Dgpl=false',
|
||||
licensePolicy:
|
||||
'LGPL-compatible libmpv, built with -Dlibmpv=true -Dgpl=false',
|
||||
...externalManifest.mpv,
|
||||
mesonFlags:
|
||||
externalManifest.mpv?.mesonFlags ??
|
||||
|
||||
@@ -19,7 +19,14 @@ const electronBuilderConfig = JSON.parse(
|
||||
);
|
||||
const electronProjectConfig = JSON.parse(
|
||||
fs.readFileSync(
|
||||
join(currentDir, '..', '..', 'apps', 'electron-backend', 'project.json'),
|
||||
join(
|
||||
currentDir,
|
||||
'..',
|
||||
'..',
|
||||
'apps',
|
||||
'electron-backend',
|
||||
'project.json'
|
||||
),
|
||||
'utf8'
|
||||
)
|
||||
);
|
||||
@@ -49,9 +56,7 @@ const electronAfterPackSource = fs.readFileSync(
|
||||
join(currentDir, 'electron-after-pack.cjs'),
|
||||
'utf8'
|
||||
);
|
||||
const {
|
||||
validatePackagedEmbeddedMpv,
|
||||
} = require('./embedded-mpv-packaging.cjs');
|
||||
const { validatePackagedEmbeddedMpv } = require('./embedded-mpv-packaging.cjs');
|
||||
|
||||
test('Linux package identity does not expose the internal Electron backend project name', () => {
|
||||
assert.equal(electronBuilderConfig.productName, 'IPTVnator');
|
||||
@@ -62,15 +67,16 @@ test('Linux package identity does not expose the internal Electron backend proje
|
||||
electronBuilderConfig.linux?.desktop?.entry?.StartupWMClass,
|
||||
'iptvnator'
|
||||
);
|
||||
assert.ok(
|
||||
electronBuilderConfig.linux?.executableArgs?.includes(
|
||||
'--ozone-platform=x11'
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
test('generated Electron package metadata mirrors the root package identity', async () => {
|
||||
const {
|
||||
buildElectronBuilderMetadata,
|
||||
buildElectronPackageMetadata,
|
||||
} = await import(
|
||||
'./generate-electron-builder-metadata.mjs'
|
||||
);
|
||||
const { buildElectronBuilderMetadata, buildElectronPackageMetadata } =
|
||||
await import('./generate-electron-builder-metadata.mjs');
|
||||
const generatedElectronPackage = buildElectronPackageMetadata(
|
||||
packageMetadata,
|
||||
electronBuilderConfig,
|
||||
@@ -156,13 +162,9 @@ test('package layout verifier uses canonical helpers and direct dependencies', (
|
||||
});
|
||||
|
||||
test('nx-electron packaging does not copy duplicate root package metadata', () => {
|
||||
const nxElectronExecutorPath = require.resolve(
|
||||
'nx-electron/src/executors/package/executor.js'
|
||||
);
|
||||
const nxElectronExecutor = fs.readFileSync(
|
||||
nxElectronExecutorPath,
|
||||
'utf8'
|
||||
);
|
||||
const nxElectronExecutorPath =
|
||||
require.resolve('nx-electron/src/executors/package/executor.js');
|
||||
const nxElectronExecutor = fs.readFileSync(nxElectronExecutorPath, 'utf8');
|
||||
|
||||
assert.doesNotMatch(nxElectronExecutor, /['"]\.\/package\.json['"]/);
|
||||
assert.match(
|
||||
@@ -189,14 +191,13 @@ test('embedded MPV runtime binaries are unpacked on every supported desktop plat
|
||||
}
|
||||
});
|
||||
|
||||
test('embedded MPV package validation accepts Windows and Linux runtime files', () => {
|
||||
test('embedded MPV package validation accepts Windows runtime files and Linux process isolation', () => {
|
||||
const tempDir = fs.mkdtempSync(join(os.tmpdir(), 'iptvnator-mpv-package-'));
|
||||
|
||||
try {
|
||||
for (const [platform, runtimeFile] of [
|
||||
['windows', 'mpv-2.dll'],
|
||||
['windows', join('lib', 'mpv.dll')],
|
||||
['linux', join('lib', 'libmpv.so.2')],
|
||||
]) {
|
||||
const resourceDir = join(tempDir, platform);
|
||||
const nativeDir = join(
|
||||
@@ -221,6 +222,28 @@ test('embedded MPV package validation accepts Windows and Linux runtime files',
|
||||
[]
|
||||
);
|
||||
}
|
||||
|
||||
const linuxResourceDir = join(tempDir, 'linux');
|
||||
const linuxNativeDir = join(
|
||||
linuxResourceDir,
|
||||
'app.asar.unpacked',
|
||||
'electron-backend',
|
||||
'native'
|
||||
);
|
||||
fs.mkdirSync(linuxNativeDir, { recursive: true });
|
||||
fs.writeFileSync(join(linuxNativeDir, 'embedded_mpv.node'), '');
|
||||
fs.writeFileSync(
|
||||
join(linuxNativeDir, 'embedded-mpv-runtime.json'),
|
||||
JSON.stringify({ origin: 'external-mpv-process' })
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
validatePackagedEmbeddedMpv(linuxResourceDir, {
|
||||
platform: 'linux',
|
||||
required: true,
|
||||
}),
|
||||
[]
|
||||
);
|
||||
} finally {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
@@ -264,3 +287,32 @@ test('embedded MPV package validation rejects missing required Windows runtime',
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('embedded MPV package validation rejects bundled Linux libmpv', () => {
|
||||
const tempDir = fs.mkdtempSync(join(os.tmpdir(), 'iptvnator-mpv-package-'));
|
||||
|
||||
try {
|
||||
const nativeDir = join(
|
||||
tempDir,
|
||||
'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: 'external-mpv-process' })
|
||||
);
|
||||
fs.writeFileSync(join(nativeDir, 'lib', 'libmpv.so'), '');
|
||||
|
||||
const errors = validatePackagedEmbeddedMpv(tempDir, {
|
||||
platform: 'linux',
|
||||
required: true,
|
||||
});
|
||||
|
||||
assert.match(errors.join('\n'), /must not bundle libmpv/);
|
||||
} finally {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -193,7 +193,11 @@ function collectExternalDylibs(entryPaths) {
|
||||
|
||||
while (queue.length > 0) {
|
||||
const currentPath = queue.shift();
|
||||
if (!currentPath || visited.has(currentPath) || !fs.existsSync(currentPath)) {
|
||||
if (
|
||||
!currentPath ||
|
||||
visited.has(currentPath) ||
|
||||
!fs.existsSync(currentPath)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -203,7 +207,10 @@ function collectExternalDylibs(entryPaths) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const resolvedPath = resolveDependencyPath(dependencyPath, currentPath);
|
||||
const resolvedPath = resolveDependencyPath(
|
||||
dependencyPath,
|
||||
currentPath
|
||||
);
|
||||
if (!resolvedPath || !fs.existsSync(resolvedPath)) {
|
||||
continue;
|
||||
}
|
||||
@@ -244,7 +251,11 @@ function patchDylibIds(libDir) {
|
||||
}
|
||||
}
|
||||
|
||||
function patchBinaryDependencies(binaryPath, dependencyBaseDir, replacementPrefix) {
|
||||
function patchBinaryDependencies(
|
||||
binaryPath,
|
||||
dependencyBaseDir,
|
||||
replacementPrefix
|
||||
) {
|
||||
const availableRuntimeFiles = new Set(
|
||||
listRuntimeFiles(dependencyBaseDir).map((runtimePath) =>
|
||||
path.basename(runtimePath)
|
||||
@@ -299,7 +310,9 @@ function copyRuntimeToNativeBuild({
|
||||
removeDir(outputLibDir);
|
||||
ensureDir(outputLibDir);
|
||||
|
||||
const runtimeFilesByName = new Map([[path.basename(libMpvPath), libMpvPath]]);
|
||||
const runtimeFilesByName = new Map([
|
||||
[path.basename(libMpvPath), libMpvPath],
|
||||
]);
|
||||
for (const dylibPath of collectExternalDylibs([libMpvPath])) {
|
||||
runtimeFilesByName.set(path.basename(dylibPath), dylibPath);
|
||||
}
|
||||
@@ -315,9 +328,15 @@ function copyRuntimeToNativeBuild({
|
||||
}
|
||||
|
||||
if (!fs.existsSync(path.join(outputLibDir, 'libmpv.2.dylib'))) {
|
||||
const copiedLibMpvPath = path.join(outputLibDir, path.basename(libMpvPath));
|
||||
const copiedLibMpvPath = path.join(
|
||||
outputLibDir,
|
||||
path.basename(libMpvPath)
|
||||
);
|
||||
if (path.basename(copiedLibMpvPath) !== 'libmpv.2.dylib') {
|
||||
copyFile(copiedLibMpvPath, path.join(outputLibDir, 'libmpv.2.dylib'));
|
||||
copyFile(
|
||||
copiedLibMpvPath,
|
||||
path.join(outputLibDir, 'libmpv.2.dylib')
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -414,11 +433,7 @@ function getPackagedRuntimeCandidates(libDir, platform, nativeDir) {
|
||||
path.join(libDir, 'mpv.dll'),
|
||||
].filter(Boolean);
|
||||
case 'linux':
|
||||
return [
|
||||
path.join(libDir, 'libmpv.so.2'),
|
||||
path.join(libDir, 'libmpv.so.1'),
|
||||
path.join(libDir, 'libmpv.so'),
|
||||
];
|
||||
return [];
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
@@ -461,9 +476,29 @@ function validatePackagedEmbeddedMpv(resourceDir, options = {}) {
|
||||
errors.push(`Missing embedded MPV runtime manifest: ${manifestPath}`);
|
||||
} else {
|
||||
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
|
||||
if (manifest.origin !== 'vendored-lgpl') {
|
||||
const expectedOrigin =
|
||||
platform === 'linux' ? 'external-mpv-process' : 'vendored-lgpl';
|
||||
if (manifest.origin !== expectedOrigin) {
|
||||
errors.push(
|
||||
`Embedded MPV packaged runtime must be vendored-lgpl, received: ${manifest.origin}`
|
||||
`Embedded MPV packaged runtime must be ${expectedOrigin}, received: ${manifest.origin}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (platform === 'linux') {
|
||||
const bundledLinuxRuntime = [
|
||||
path.join(libDir, 'libmpv.so.2'),
|
||||
path.join(libDir, 'libmpv.so.1'),
|
||||
path.join(libDir, 'libmpv.so'),
|
||||
].filter((candidate) => fs.existsSync(candidate));
|
||||
|
||||
if (bundledLinuxRuntime.length > 0) {
|
||||
errors.push(
|
||||
[
|
||||
'Linux embedded MPV must use the external mpv process backend and must not bundle libmpv.',
|
||||
'Remove:',
|
||||
...bundledLinuxRuntime.map((candidate) => `- ${candidate}`),
|
||||
].join('\n')
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,8 +43,11 @@ const electronBuilderConfig = JSON.parse(
|
||||
fs.readFileSync(electronBuilderConfigPath, 'utf8')
|
||||
);
|
||||
const flatpakFinishArgs = electronBuilderConfig.flatpak?.finishArgs ?? [];
|
||||
const linuxExecutableArgs = electronBuilderConfig.linux?.executableArgs ?? [];
|
||||
const snapConfigInspection = loadSnapConfigInspection();
|
||||
const embeddedMpvRequired = isTruthy(process.env.IPTVNATOR_REQUIRE_EMBEDDED_MPV);
|
||||
const embeddedMpvRequired = isTruthy(
|
||||
process.env.IPTVNATOR_REQUIRE_EMBEDDED_MPV
|
||||
);
|
||||
const workerRelativeDir = path.join(
|
||||
'dist',
|
||||
'apps',
|
||||
@@ -118,20 +121,18 @@ function getMacResourceDirs() {
|
||||
}
|
||||
|
||||
function getUnpackedResourceDirs(prefix) {
|
||||
return packageOutputRoots
|
||||
.filter(directoryExists)
|
||||
.flatMap((outputRoot) =>
|
||||
fs
|
||||
.readdirSync(outputRoot, { withFileTypes: true })
|
||||
.filter(
|
||||
(entry) =>
|
||||
entry.isDirectory() &&
|
||||
entry.name.startsWith(prefix) &&
|
||||
entry.name.endsWith('-unpacked')
|
||||
)
|
||||
.map((entry) => path.join(outputRoot, entry.name, 'resources'))
|
||||
.filter(directoryExists)
|
||||
);
|
||||
return packageOutputRoots.filter(directoryExists).flatMap((outputRoot) =>
|
||||
fs
|
||||
.readdirSync(outputRoot, { withFileTypes: true })
|
||||
.filter(
|
||||
(entry) =>
|
||||
entry.isDirectory() &&
|
||||
entry.name.startsWith(prefix) &&
|
||||
entry.name.endsWith('-unpacked')
|
||||
)
|
||||
.map((entry) => path.join(outputRoot, entry.name, 'resources'))
|
||||
.filter(directoryExists)
|
||||
);
|
||||
}
|
||||
|
||||
function getResourceDirs() {
|
||||
@@ -149,7 +150,17 @@ function getResourceDirs() {
|
||||
}
|
||||
|
||||
function sanitizeExecutableName(value) {
|
||||
const invalidCharacters = new Set(['<', '>', ':', '"', '/', '\\', '|', '?', '*']);
|
||||
const invalidCharacters = new Set([
|
||||
'<',
|
||||
'>',
|
||||
':',
|
||||
'"',
|
||||
'/',
|
||||
'\\',
|
||||
'|',
|
||||
'?',
|
||||
'*',
|
||||
]);
|
||||
|
||||
return [...value]
|
||||
.filter(
|
||||
@@ -362,7 +373,8 @@ function parseEffectiveSnapConfig(yamlContent) {
|
||||
}
|
||||
|
||||
function loadSnapConfigInspection() {
|
||||
const builderEffectiveConfigPath = builderEffectiveConfigPaths.find(fileExists);
|
||||
const builderEffectiveConfigPath =
|
||||
builderEffectiveConfigPaths.find(fileExists);
|
||||
|
||||
if (builderEffectiveConfigPath) {
|
||||
const effectiveConfigContent = fs.readFileSync(
|
||||
@@ -496,6 +508,19 @@ function verifyFlatpakPermissions(errors) {
|
||||
}
|
||||
}
|
||||
|
||||
function verifyLinuxExecutableArgs(errors) {
|
||||
if (!Array.isArray(linuxExecutableArgs)) {
|
||||
errors.push('linux.executableArgs must be configured as an array.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!linuxExecutableArgs.includes('--ozone-platform=x11')) {
|
||||
errors.push(
|
||||
'linux.executableArgs must include --ozone-platform=x11 while embedded MPV requires X11/Xwayland on Linux.'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function verifySnapPackagingConfig(errors) {
|
||||
if (snapConfigInspection.config?.base !== 'core22') {
|
||||
errors.push(
|
||||
@@ -561,6 +586,7 @@ function verifyResourceDir(resourceDir) {
|
||||
`Missing Flatpak metainfo file: ${flatpakMetainfoPath}`
|
||||
);
|
||||
}
|
||||
verifyLinuxExecutableArgs(errors);
|
||||
verifyFlatpakPermissions(errors);
|
||||
verifySnapPackagingConfig(errors);
|
||||
verifyLinuxLauncher(resourceDir, errors);
|
||||
|
||||
Vendored
+12
-2
@@ -1,10 +1,20 @@
|
||||
# Embedded MPV Runtime Artifacts
|
||||
|
||||
This directory is the staging location for macOS embedded MPV runtime artifacts.
|
||||
This directory is the staging location for generated embedded MPV runtime/build
|
||||
artifacts.
|
||||
|
||||
Generated architecture folders are expected at:
|
||||
|
||||
- `vendor/embedded-mpv/darwin-arm64/`
|
||||
- `vendor/embedded-mpv/darwin-x64/`
|
||||
- `vendor/embedded-mpv/win32-x64/`
|
||||
- `vendor/embedded-mpv/linux-x64/`
|
||||
|
||||
Each generated folder must contain `include/mpv/client.h`, `lib/*.dylib`, and `runtime-manifest.json`. The binary runtime directories are ignored by git by default; generate or restore them in release packaging jobs before building the Electron backend.
|
||||
Each generated folder must contain `include/mpv/client.h` and
|
||||
`runtime-manifest.json`. macOS and Windows folders also contain platform
|
||||
runtime/build inputs under `lib/` or `bin/`. The binary runtime directories are
|
||||
ignored by git by default; generate, stage, or restore them in release packaging
|
||||
jobs before building the Electron backend.
|
||||
|
||||
Linux uses this directory for MPV headers and build metadata only. Linux
|
||||
packages launch the system `mpv` executable and must not bundle `libmpv.so`.
|
||||
Reference in new issue
Block a user