build: configure Flatpak finish arguments for external player launching

Entire-Checkpoint: 21b9b9f90f23
This commit is contained in:
4gray committed 2026-04-12 18:38:07 +02:00
1 parent c06ed66c41
commit 35141bec31
5 files changed
+401 -59

No files matched your search

@@ -0,0 +1,128 @@
jest.mock('electron', () => ({
ipcMain: {
handle: jest.fn(),
},
}));
jest.mock('../app', () => ({
__esModule: true,
default: {
mainWindow: null,
},
}));
jest.mock('../services/store.service', () => ({
MPV_PLAYER_PATH: 'MPV_PLAYER_PATH',
MPV_REUSE_INSTANCE: 'MPV_REUSE_INSTANCE',
VLC_PLAYER_PATH: 'VLC_PLAYER_PATH',
store: {
get: jest.fn(),
set: jest.fn(),
},
}));
jest.mock('../services/stalker-playback-context.service', () => ({
getStalkerPlaybackContextHeaders: jest.fn(() => undefined),
}));
import {
buildExternalPlayerSpawnSpec,
isRunningInFlatpak,
resolveExternalPlayerLaunchContext,
shouldReuseMpvInstance,
shouldUseMpvSocketBridge,
} from './player.events';
function createPathExists(existingPaths: string[]) {
return (candidatePath: string) => existingPaths.includes(candidatePath);
}
describe('player.events Flatpak launch helpers', () => {
it('detects Flatpak only on Linux when /.flatpak-info exists', () => {
expect(isRunningInFlatpak(createPathExists(['/.flatpak-info']), 'linux')).toBe(
true
);
expect(isRunningInFlatpak(createPathExists(['/.flatpak-info']), 'darwin')).toBe(
false
);
expect(isRunningInFlatpak(createPathExists([]), 'linux')).toBe(false);
});
it('keeps direct Linux player launching outside Flatpak', () => {
const launchContext = resolveExternalPlayerLaunchContext('mpv', undefined, {
platform: 'linux',
isFlatpak: false,
pathExists: createPathExists(['/usr/local/bin/mpv']),
});
const spawnSpec = buildExternalPlayerSpawnSpec(launchContext, [
'--ytdl=no',
'https://example.com/stream.m3u8',
]);
expect(launchContext).toEqual({
mode: 'direct',
playerPath: '/usr/local/bin/mpv',
command: '/usr/local/bin/mpv',
argsPrefix: [],
});
expect(spawnSpec).toEqual({
mode: 'direct',
playerPath: '/usr/local/bin/mpv',
command: '/usr/local/bin/mpv',
args: ['--ytdl=no', 'https://example.com/stream.m3u8'],
});
});
it('builds Flatpak host launches with bare player names by default', () => {
const launchContext = resolveExternalPlayerLaunchContext('vlc', undefined, {
platform: 'linux',
isFlatpak: true,
pathExists: createPathExists(['/usr/bin/vlc']),
});
const spawnSpec = buildExternalPlayerSpawnSpec(launchContext, [
'--extraintf=rc',
'https://example.com/stream.m3u8',
]);
expect(launchContext).toEqual({
mode: 'flatpak-host',
playerPath: 'vlc',
command: 'flatpak-spawn',
argsPrefix: ['--host', '--watch-bus', 'vlc'],
});
expect(spawnSpec.args).toEqual([
'--host',
'--watch-bus',
'vlc',
'--extraintf=rc',
'https://example.com/stream.m3u8',
]);
});
it('passes custom host player paths through Flatpak launching unchanged', () => {
const launchContext = resolveExternalPlayerLaunchContext(
'mpv',
'/opt/mpv/bin/mpv',
{
platform: 'linux',
isFlatpak: true,
}
);
expect(launchContext).toEqual({
mode: 'flatpak-host',
playerPath: '/opt/mpv/bin/mpv',
command: 'flatpak-spawn',
argsPrefix: ['--host', '--watch-bus', '/opt/mpv/bin/mpv'],
});
});
it('disables MPV reuse and socket bridging only in Flatpak', () => {
expect(shouldReuseMpvInstance(true, true)).toBe(false);
expect(shouldUseMpvSocketBridge(true)).toBe(false);
expect(shouldReuseMpvInstance(true, false)).toBe(true);
expect(shouldReuseMpvInstance(false, false)).toBe(false);
expect(shouldUseMpvSocketBridge(false)).toBe(true);
});
});
@@ -2,6 +2,7 @@ import { ipcMain } from 'electron';
import {
CLOSE_EXTERNAL_PLAYER_SESSION,
EXTERNAL_PLAYER_SESSION_UPDATE,
ExternalPlayerName,
ExternalPlayerSession,
} from 'shared-interfaces';
import App from '../app';
@@ -40,6 +41,138 @@ const externalPlayerSessions = new ExternalPlayerSessionRegistry(
sendExternalPlayerSessionUpdate
);
type PathExists = (path: string) => boolean;
type ExternalPlayerLaunchMode = 'direct' | 'flatpak-host';
interface PlayerPathOptions {
platform?: NodeJS.Platform;
isFlatpak?: boolean;
pathExists?: PathExists;
}
interface ExternalPlayerLaunchContext {
mode: ExternalPlayerLaunchMode;
playerPath: string;
command: string;
argsPrefix: string[];
}
interface ExternalPlayerSpawnSpec {
mode: ExternalPlayerLaunchMode;
playerPath: string;
command: string;
args: string[];
}
export function isRunningInFlatpak(
pathExists: PathExists = existsSync,
platform: NodeJS.Platform = process.platform
): boolean {
return platform === 'linux' && pathExists('/.flatpak-info');
}
function normalizeCustomPlayerPath(value: string | undefined): string | null {
const trimmedValue = value?.trim();
return trimmedValue ? trimmedValue : null;
}
function getDefaultPlayerPath(
player: ExternalPlayerName,
options: PlayerPathOptions = {}
): string {
const {
platform = process.platform,
isFlatpak = isRunningInFlatpak(),
pathExists = existsSync,
} = options;
if (platform === 'linux' && isFlatpak) {
return player;
}
if (player === 'mpv') {
return getDefaultMpvPath({ platform, isFlatpak, pathExists });
}
return getDefaultVlcPath({ platform, isFlatpak, pathExists });
}
export function resolveExternalPlayerLaunchContext(
player: ExternalPlayerName,
customPlayerPath?: string,
options: PlayerPathOptions = {}
): ExternalPlayerLaunchContext {
const {
platform = process.platform,
isFlatpak = isRunningInFlatpak(),
pathExists = existsSync,
} = options;
const playerPath =
normalizeCustomPlayerPath(customPlayerPath) ??
getDefaultPlayerPath(player, {
platform,
isFlatpak,
pathExists,
});
if (platform === 'linux' && isFlatpak) {
return {
mode: 'flatpak-host',
playerPath,
command: 'flatpak-spawn',
argsPrefix: ['--host', '--watch-bus', playerPath],
};
}
return {
mode: 'direct',
playerPath,
command: playerPath,
argsPrefix: [],
};
}
export function buildExternalPlayerSpawnSpec(
launchContext: ExternalPlayerLaunchContext,
playerArgs: string[]
): ExternalPlayerSpawnSpec {
return {
mode: launchContext.mode,
playerPath: launchContext.playerPath,
command: launchContext.command,
args: [...launchContext.argsPrefix, ...playerArgs],
};
}
export function shouldReuseMpvInstance(
requestedReuseInstance: boolean,
isFlatpak: boolean = isRunningInFlatpak()
): boolean {
return !isFlatpak && requestedReuseInstance;
}
export function shouldUseMpvSocketBridge(
isFlatpak: boolean = isRunningInFlatpak()
): boolean {
return !isFlatpak;
}
function buildPlayerStartError(
player: 'MPV' | 'VLC',
error: Error,
launchContext: ExternalPlayerLaunchContext
): Error {
const guidance =
launchContext.mode === 'flatpak-host'
? `Make sure ${player} is installed on the host system and reachable via Flatpak host spawning at '${launchContext.playerPath}'.`
: `Make sure ${player} is installed and the path '${launchContext.playerPath}' is correct.`;
return new Error(
`Failed to start ${player} player: ${error.message}. ${guidance}`
);
}
// Helper function to send error notifications to the renderer
function sendPlayerErrorNotification(player: 'MPV' | 'VLC', error: string) {
if (App.mainWindow && !App.mainWindow.isDestroyed()) {
@@ -383,8 +516,18 @@ ipcMain.handle(
});
try {
const mpvPath = getMpvPath();
const reuseInstance = store.get(MPV_REUSE_INSTANCE, false);
const isFlatpak = isRunningInFlatpak();
const mpvLaunchContext = resolveExternalPlayerLaunchContext(
'mpv',
getMpvPath({ isFlatpak }),
{ isFlatpak }
);
const requestedReuseInstance = store.get(MPV_REUSE_INSTANCE, false);
const reuseInstance = shouldReuseMpvInstance(
requestedReuseInstance,
isFlatpak
);
const useMpvSocketBridge = shouldUseMpvSocketBridge(isFlatpak);
const fallbackHeaders = getStalkerPlaybackContextHeaders(url) ?? {};
const mergedHeaders = isStalkerDirectStreamProfile(fallbackHeaders)
? fallbackHeaders
@@ -413,7 +556,9 @@ ipcMain.handle(
);
console.log('[MPV] Opening player', {
path: mpvPath,
path: mpvLaunchContext.playerPath,
launchMode: mpvLaunchContext.mode,
requestedReuseInstance,
reuseInstance,
stream: maskUrlForLogs(url),
hasUserAgent: Boolean(effectiveUserAgent),
@@ -520,19 +665,20 @@ ipcMain.handle(
// Create new MPV process
console.log('Creating new MPV instance');
// Generate unique socket path
const socketPath =
process.platform === 'win32'
? `\\\\.\\pipe\\mpv-${Date.now()}`
: `/tmp/mpvsocket-${Date.now()}`;
let socketPath: string | null = null;
const args: string[] = [];
const args = [
`--input-ipc-server=${socketPath}`,
'--idle=yes',
// IPTV URLs often look like generic web pages to ytdl_hook and can fail with 403.
// Force MPV to open them directly instead of probing via yt-dlp/youtube-dl.
'--ytdl=no',
];
if (useMpvSocketBridge) {
socketPath =
process.platform === 'win32'
? `\\\\.\\pipe\\mpv-${Date.now()}`
: `/tmp/mpvsocket-${Date.now()}`;
args.push(`--input-ipc-server=${socketPath}`, '--idle=yes');
}
// IPTV URLs often look like generic web pages to ytdl_hook and can fail with 403.
// Force MPV to open them directly instead of probing via yt-dlp/youtube-dl.
args.push('--ytdl=no');
// Add user agent if provided
if (effectiveUserAgent) {
@@ -563,7 +709,11 @@ ipcMain.handle(
// Wrap spawn in a promise to catch startup errors
await new Promise<void>((resolve, reject) => {
const proc = spawn(mpvPath, args, {
const spawnSpec = buildExternalPlayerSpawnSpec(
mpvLaunchContext,
args
);
const proc = spawn(spawnSpec.command, spawnSpec.args, {
shell: false,
detached: !reuseInstance,
// Only pipe stdio when reusing instance; use 'ignore' for detached to allow clean shutdown
@@ -644,11 +794,7 @@ ipcMain.handle(
session.id,
`Failed to start MPV player: ${err.message}`
);
reject(
new Error(
`Failed to start MPV player: ${err.message}. Make sure MPV is installed and the path '${mpvPath}' is correct.`
)
);
reject(buildPlayerStartError('MPV', err, mpvLaunchContext));
});
proc.on('exit', (code) => {
@@ -677,7 +823,7 @@ ipcMain.handle(
});
// Store the process reference if reuse is enabled
if (reuseInstance) {
if (reuseInstance && socketPath) {
mpvProcess = proc;
mpvSocketPath = socketPath;
console.log(
@@ -696,7 +842,7 @@ ipcMain.handle(
});
// Start polling if content info is provided
if (contentInfo) {
if (useMpvSocketBridge && contentInfo && socketPath) {
startPositionPolling(socketPath, contentInfo, session.id);
}
@@ -765,7 +911,12 @@ ipcMain.handle(
});
try {
const vlcPath = getVlcPath();
const isFlatpak = isRunningInFlatpak();
const vlcLaunchContext = resolveExternalPlayerLaunchContext(
'vlc',
getVlcPath({ isFlatpak }),
{ isFlatpak }
);
const fallbackHeaders = getStalkerPlaybackContextHeaders(url) ?? {};
const mergedHeaders = isStalkerDirectStreamProfile(fallbackHeaders)
? fallbackHeaders
@@ -788,7 +939,8 @@ ipcMain.handle(
mergedHeaders['User-Agent'] ??
mergedHeaders['user-agent'] ??
undefined;
console.log('Opening VLC player with path:', vlcPath);
console.log('Opening VLC player with path:', vlcLaunchContext.playerPath);
console.log('VLC launch mode:', vlcLaunchContext.mode);
console.log('URL:', url);
// console.log('User-Agent:', userAgent);
// console.log('Referer:', referer);
@@ -854,8 +1006,12 @@ ipcMain.handle(
// Wrap spawn in a promise to catch startup errors
await new Promise<void>((resolve, reject) => {
const spawnVlc = (spawnArgs: string[], isRetry = false) => {
const proc = spawn(vlcPath, spawnArgs, {
const spawnVlc = (playerArgs: string[], isRetry = false) => {
const spawnSpec = buildExternalPlayerSpawnSpec(
vlcLaunchContext,
playerArgs
);
const proc = spawn(spawnSpec.command, spawnSpec.args, {
shell: false,
detached: true,
stdio: 'ignore', // Use 'ignore' for detached to allow clean shutdown
@@ -897,7 +1053,7 @@ ipcMain.handle(
if (!isRetry && rcPort > 0) {
console.log('Retrying VLC without RC interface...');
// Retry without RC args
const retryArgs = spawnArgs.filter(
const retryArgs = playerArgs.filter(
(arg) =>
!arg.includes('--extraintf') &&
!arg.includes('--rc-host') &&
@@ -910,8 +1066,10 @@ ipcMain.handle(
`Failed to start VLC player: ${err.message}`
);
reject(
new Error(
`Failed to start VLC player: ${err.message}. Make sure VLC is installed and the path '${vlcPath}' is correct.`
buildPlayerStartError(
'VLC',
err,
vlcLaunchContext
)
);
}
@@ -925,7 +1083,7 @@ ipcMain.handle(
'VLC exited with error, retrying without RC interface...'
);
stopVlcPositionPolling();
const retryArgs = spawnArgs.filter(
const retryArgs = playerArgs.filter(
(arg) =>
!arg.includes('--extraintf') &&
!arg.includes('--rc-host') &&
@@ -985,26 +1143,32 @@ ipcMain.handle(
}
);
function getMpvPath() {
const customMpvPath = store.get(MPV_PLAYER_PATH);
if (customMpvPath) {
return customMpvPath;
} else {
return getDefaultMpvPath();
}
function getMpvPath(options: PlayerPathOptions = {}) {
return (
normalizeCustomPlayerPath(store.get(MPV_PLAYER_PATH)) ??
getDefaultMpvPath(options)
);
}
function getVlcPath() {
const customVlcPath = store.get(VLC_PLAYER_PATH);
if (customVlcPath) {
return customVlcPath;
} else {
return getDefaultVlcPath();
}
function getVlcPath(options: PlayerPathOptions = {}) {
return (
normalizeCustomPlayerPath(store.get(VLC_PLAYER_PATH)) ??
getDefaultVlcPath(options)
);
}
function getDefaultMpvPath() {
if (process.platform === 'win32') {
function getDefaultMpvPath(options: PlayerPathOptions = {}) {
const {
platform = process.platform,
isFlatpak = isRunningInFlatpak(),
pathExists = existsSync,
} = options;
if (platform === 'linux' && isFlatpak) {
return 'mpv';
}
if (platform === 'win32') {
// Check multiple common Windows paths
const windowsPaths = [
path.join('C:', 'Program Files', 'mpv', 'mpv.exe'),
@@ -1013,13 +1177,13 @@ function getDefaultMpvPath() {
// Check if any of the paths exist
for (const mpvPath of windowsPaths) {
if (existsSync(mpvPath)) {
if (pathExists(mpvPath)) {
return mpvPath;
}
}
// Default to just 'mpv' if it's in PATH
return 'mpv';
} else if (process.platform === 'linux') {
} else if (platform === 'linux') {
// Check multiple common Linux paths
const linuxPaths = [
'/usr/bin/mpv',
@@ -1028,12 +1192,12 @@ function getDefaultMpvPath() {
];
for (const mpvPath of linuxPaths) {
if (existsSync(mpvPath)) {
if (pathExists(mpvPath)) {
return mpvPath;
}
}
return 'mpv';
} else if (process.platform === 'darwin') {
} else if (platform === 'darwin') {
// Check multiple common macOS paths
const macosPaths = [
'/Applications/mpv.app/Contents/MacOS/mpv',
@@ -1042,7 +1206,7 @@ function getDefaultMpvPath() {
];
for (const mpvPath of macosPaths) {
if (existsSync(mpvPath)) {
if (pathExists(mpvPath)) {
return mpvPath;
}
}
@@ -1053,8 +1217,18 @@ function getDefaultMpvPath() {
return 'mpv';
}
function getDefaultVlcPath() {
if (process.platform === 'win32') {
function getDefaultVlcPath(options: PlayerPathOptions = {}) {
const {
platform = process.platform,
isFlatpak = isRunningInFlatpak(),
pathExists = existsSync,
} = options;
if (platform === 'linux' && isFlatpak) {
return 'vlc';
}
if (platform === 'win32') {
// Check multiple common Windows paths (64-bit and 32-bit)
const windowsPaths = [
path.join('C:', 'Program Files', 'VideoLAN', 'VLC', 'vlc.exe'),
@@ -1068,12 +1242,12 @@ function getDefaultVlcPath() {
];
for (const vlcPath of windowsPaths) {
if (existsSync(vlcPath)) {
if (pathExists(vlcPath)) {
return vlcPath;
}
}
return 'vlc';
} else if (process.platform === 'linux') {
} else if (platform === 'linux') {
// Check multiple common Linux paths
const linuxPaths = [
'/usr/bin/vlc',
@@ -1082,12 +1256,12 @@ function getDefaultVlcPath() {
];
for (const vlcPath of linuxPaths) {
if (existsSync(vlcPath)) {
if (pathExists(vlcPath)) {
return vlcPath;
}
}
return 'vlc';
} else if (process.platform === 'darwin') {
} else if (platform === 'darwin') {
// Check multiple common macOS paths
const macosPaths = [
'/Applications/VLC.app/Contents/MacOS/VLC',
@@ -1095,7 +1269,7 @@ function getDefaultVlcPath() {
];
for (const vlcPath of macosPaths) {
if (existsSync(vlcPath)) {
if (pathExists(vlcPath)) {
return vlcPath;
}
}
@@ -6,6 +6,7 @@ This document records the current contract for embedded playback in portal detai
- Embedded web players are `videojs`, `html5`, and `artplayer`.
- External players are `mpv` and `vlc`.
- Flatpak launches external players on the host via `flatpak-spawn --host`.
- Live playback stays inline in dedicated live layouts.
- VOD and series detail playback now also stays inline on canonical detail surfaces.
- Material dialog playback remains only as a fallback for older non-detail callers.
@@ -56,6 +57,19 @@ When a detail view starts playback:
The detail host owns inline state. `PlayerService` is no longer the primary owner of UI playback state for canonical VOD/series detail screens.
## Flatpak External Players
Flatpak cannot execute host-installed `mpv` or `vlc` binaries directly from the sandbox.
Current contract:
- Flatpak launches external players through `flatpak-spawn --host`.
- AppImage, deb/rpm, snap, macOS, and Windows keep the existing direct process spawn flow.
- VLC keeps the current external-session flow in Flatpak, including the RC port used for progress polling.
- MPV is intentionally reduced in Flatpak: the app does not reuse an existing MPV instance there and does not open the Unix socket bridge used for non-Flatpak progress polling.
This keeps non-Flatpak behavior unchanged while allowing Flatpak builds to open host-installed external players.
## Typed Playback Payload
Shared playback payloads live in:
+11
View File
@@ -77,6 +77,17 @@
"sdk": "org.freedesktop.Sdk",
"base": "org.electronjs.Electron2.BaseApp",
"baseVersion": "24.08",
"finishArgs": [
"--socket=wayland",
"--socket=x11",
"--share=ipc",
"--device=dri",
"--socket=pulseaudio",
"--filesystem=home",
"--share=network",
"--talk-name=org.freedesktop.Notifications",
"--talk-name=org.freedesktop.Flatpak"
],
"files": [
[
"apps/electron-backend/linux/com.fourgray.iptvnator.metainfo.xml",
@@ -27,6 +27,7 @@ const packageMetadata = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
const electronBuilderConfig = JSON.parse(
fs.readFileSync(electronBuilderConfigPath, 'utf8')
);
const flatpakFinishArgs = electronBuilderConfig.flatpak?.finishArgs ?? [];
const workerRelativeDir = path.join(
'dist',
'apps',
@@ -172,6 +173,19 @@ function verifyLinuxLauncher(resourceDir, errors) {
}
}
function verifyFlatpakPermissions(errors) {
if (!Array.isArray(flatpakFinishArgs)) {
errors.push('Flatpak finishArgs must be configured as an array.');
return;
}
if (!flatpakFinishArgs.includes('--talk-name=org.freedesktop.Flatpak')) {
errors.push(
'Flatpak finishArgs must include --talk-name=org.freedesktop.Flatpak for host player launching.'
);
}
}
function verifyResourceDir(resourceDir) {
const missingWorkers = workerFiles.filter(
(workerFile) =>
@@ -207,6 +221,7 @@ function verifyResourceDir(resourceDir) {
if (!fileExists(flatpakMetainfoPath)) {
errors.push(`Missing Flatpak metainfo file: ${flatpakMetainfoPath}`);
}
verifyFlatpakPermissions(errors);
verifyLinuxLauncher(resourceDir, errors);
}