diff --git a/apps/electron-backend/src/app/events/player.events.spec.ts b/apps/electron-backend/src/app/events/player.events.spec.ts new file mode 100644 index 000000000..80dbdbb94 --- /dev/null +++ b/apps/electron-backend/src/app/events/player.events.spec.ts @@ -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); + }); +}); diff --git a/apps/electron-backend/src/app/events/player.events.ts b/apps/electron-backend/src/app/events/player.events.ts index 1daf2f9d0..6719758f4 100644 --- a/apps/electron-backend/src/app/events/player.events.ts +++ b/apps/electron-backend/src/app/events/player.events.ts @@ -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((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((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; } } diff --git a/docs/architecture/embedded-inline-playback.md b/docs/architecture/embedded-inline-playback.md index 92c0cdc09..6495a5eba 100644 --- a/docs/architecture/embedded-inline-playback.md +++ b/docs/architecture/embedded-inline-playback.md @@ -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: diff --git a/electron-builder.json b/electron-builder.json index 21d14bb26..18eecec31 100644 --- a/electron-builder.json +++ b/electron-builder.json @@ -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", diff --git a/tools/packaging/verify-electron-package-layout.mjs b/tools/packaging/verify-electron-package-layout.mjs index 830417751..01193fcca 100644 --- a/tools/packaging/verify-electron-package-layout.mjs +++ b/tools/packaging/verify-electron-package-layout.mjs @@ -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); }