feat(stalker): secure native playback contexts

This commit is contained in:
4gray committed 2026-07-27 10:56:33 +02:00
1 parent 57e108b8cb
commit 91761f5abe
15 files changed
+552 -61

No files matched your search

@@ -150,6 +150,34 @@ export const stalkerSessionPreloadCases: PreloadInvokeCase[] = [
},
];
const externalPlaybackArgs = [
'https://stream.example/live.m3u8',
'Managed stream',
'https://images.example/channel.png',
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
'opaque-playback-context',
];
export const playbackContextPreloadCases: PreloadInvokeCase[] = [
{
method: 'openInMpv',
args: externalPlaybackArgs,
channel: 'OPEN_MPV_PLAYER',
forwardedArgs: externalPlaybackArgs,
},
{
method: 'openInVlc',
args: externalPlaybackArgs,
channel: 'OPEN_VLC_PLAYER',
forwardedArgs: externalPlaybackArgs,
},
];
export const dbPreloadCases: PreloadInvokeCase[] = [
{
method: 'dbCreatePlaylist',
@@ -15,6 +15,7 @@ import {
dbPreloadCases,
epgPreloadCases,
operationId,
playbackContextPreloadCases,
stalkerSessionPreloadCases,
} from './main.preload.spec-data';
@@ -136,6 +137,20 @@ describe('main preload DB IPC contract', () => {
}
);
it.each(playbackContextPreloadCases)(
'$method forwards opaque playback context through $channel',
async ({ method, args, channel, forwardedArgs }) => {
const api = getExposedApi();
await callExposedApiMethod(api, method, args);
expect(mockIpcRenderer.invoke).toHaveBeenLastCalledWith(
channel,
...forwardedArgs
);
}
);
it('keeps Stalker session IPC narrow while preserving the legacy request bridge', async () => {
const api = getExposedApi();
const legacyPayload = {
@@ -46,6 +46,7 @@ import type {
PortalDebugEvent,
ResolvedPortalPlayback,
Settings,
StalkerPlaybackContextRef,
TmdbCacheEntry,
TmdbCacheMediaType,
XtreamCategory,
@@ -372,10 +373,10 @@ const electronApi: ElectronBridgeApi = {
origin?: string,
contentInfo?: PlayerContentInfo,
startTime?: number,
headers?: Record<string, string>
): Promise<ExternalPlayerSession> =>
ipcRenderer.invoke(
'OPEN_MPV_PLAYER',
headers?: Record<string, string>,
playbackContextRef?: StalkerPlaybackContextRef
): Promise<ExternalPlayerSession> => {
const args = [
url,
title,
thumbnail,
@@ -384,8 +385,16 @@ const electronApi: ElectronBridgeApi = {
origin,
contentInfo,
startTime,
headers
),
headers,
] as const;
return playbackContextRef === undefined
? ipcRenderer.invoke('OPEN_MPV_PLAYER', ...args)
: ipcRenderer.invoke(
'OPEN_MPV_PLAYER',
...args,
playbackContextRef
);
},
openInVlc: (
url: string,
title: string,
@@ -395,10 +404,10 @@ const electronApi: ElectronBridgeApi = {
origin?: string,
contentInfo?: PlayerContentInfo,
startTime?: number,
headers?: Record<string, string>
): Promise<ExternalPlayerSession> =>
ipcRenderer.invoke(
'OPEN_VLC_PLAYER',
headers?: Record<string, string>,
playbackContextRef?: StalkerPlaybackContextRef
): Promise<ExternalPlayerSession> => {
const args = [
url,
title,
thumbnail,
@@ -407,8 +416,16 @@ const electronApi: ElectronBridgeApi = {
origin,
contentInfo,
startTime,
headers
),
headers,
] as const;
return playbackContextRef === undefined
? ipcRenderer.invoke('OPEN_VLC_PLAYER', ...args)
: ipcRenderer.invoke(
'OPEN_VLC_PLAYER',
...args,
playbackContextRef
);
},
closeExternalPlayerSession: (sessionId: string) =>
ipcRenderer.invoke('CLOSE_EXTERNAL_PLAYER_SESSION', sessionId),
getEmbeddedMpvSupport: (): Promise<EmbeddedMpvSupport> =>
@@ -6,16 +6,26 @@ jest.mock('electron', () => ({
const mockEmbeddedMpvService = {
getSupport: jest.fn(),
loadPlayback: jest.fn(),
setPaused: jest.fn(),
};
const mockConsumeStalkerPlaybackContext = jest.fn();
jest.mock('../services/embedded-mpv-native.service', () => ({
EmbeddedMpvNativeService: class {},
embeddedMpvNativeService: mockEmbeddedMpvService,
}));
jest.mock('../services/stalker-playback-context.service', () => ({
stalkerPlaybackContextService: {
consume: mockConsumeStalkerPlaybackContext,
},
}));
import { ipcMain } from 'electron';
import {
EMBEDDED_MPV_LOAD_PLAYBACK,
EMBEDDED_MPV_SET_PAUSED,
EMBEDDED_MPV_SUPPORT,
} from '@iptvnator/shared/interfaces';
@@ -42,7 +52,9 @@ function getIpcMainHandler(
describe('EmbeddedMpvEvents IPC handlers', () => {
beforeEach(() => {
mockEmbeddedMpvService.getSupport.mockReset();
mockEmbeddedMpvService.loadPlayback.mockReset();
mockEmbeddedMpvService.setPaused.mockReset();
mockConsumeStalkerPlaybackContext.mockReset();
});
it('forwards arguments to the native service and returns its result', async () => {
@@ -79,4 +91,94 @@ describe('EmbeddedMpvEvents IPC handlers', () => {
consoleErrorSpy.mockRestore();
}
});
it('consumes a sender-bound context and strips renderer secrets and the ref before native playback', async () => {
mockConsumeStalkerPlaybackContext.mockReturnValue({
Authorization: 'Bearer main-token',
Cookie: 'session=main-cookie',
Origin: 'https://main.example',
Referer: 'https://main.example/player/',
'User-Agent': 'MainAgent/1.0',
});
const handler = getIpcMainHandler(EMBEDDED_MPV_LOAD_PLAYBACK);
await handler({ sender: { id: 44 } }, 'embedded-session', {
headers: {
Authorization: 'Bearer renderer-token',
Cookie: 'session=renderer-cookie',
},
origin: 'https://renderer.example',
playbackContextRef: 'opaque-playback-context',
referer: 'https://renderer.example/referrer',
streamUrl: 'https://stream.example/live.m3u8',
title: 'Managed stream',
userAgent: 'RendererAgent/1.0',
});
expect(mockConsumeStalkerPlaybackContext).toHaveBeenCalledWith({
contextRef: 'opaque-playback-context',
senderId: 44,
streamUrl: 'https://stream.example/live.m3u8',
});
expect(mockEmbeddedMpvService.loadPlayback).toHaveBeenCalledWith(
'embedded-session',
{
headers: {
Authorization: 'Bearer main-token',
Cookie: 'session=main-cookie',
Origin: 'https://main.example',
Referer: 'https://main.example/player/',
'User-Agent': 'MainAgent/1.0',
},
origin: 'https://main.example',
referer: 'https://main.example/player/',
streamUrl: 'https://stream.example/live.m3u8',
title: 'Managed stream',
userAgent: 'MainAgent/1.0',
}
);
const nativePayload =
mockEmbeddedMpvService.loadPlayback.mock.calls[0][1];
expect(JSON.stringify(nativePayload)).not.toMatch(
/renderer-token|renderer-cookie|opaque-playback-context/
);
});
it('fails invalid contexts closed and preserves legacy no-ref playback', async () => {
const consoleErrorSpy = jest
.spyOn(console, 'error')
.mockImplementation();
const handler = getIpcMainHandler(EMBEDDED_MPV_LOAD_PLAYBACK);
const legacyPlayback = {
headers: { 'User-Agent': 'LegacyAgent/1.0' },
streamUrl: 'https://stream.example/legacy.m3u8',
title: 'Legacy stream',
};
try {
mockConsumeStalkerPlaybackContext.mockReturnValue(null);
await expect(
handler({ sender: { id: 12 } }, 'embedded-session', {
...legacyPlayback,
playbackContextRef: 'foreign-or-expired-context',
})
).rejects.toThrow('invalid-playback-context');
expect(mockEmbeddedMpvService.loadPlayback).not.toHaveBeenCalled();
await expect(
handler(
{ sender: { id: 12 } },
'embedded-session',
legacyPlayback
)
).resolves.toBeUndefined();
expect(mockConsumeStalkerPlaybackContext).toHaveBeenCalledTimes(1);
expect(mockEmbeddedMpvService.loadPlayback).toHaveBeenCalledWith(
'embedded-session',
legacyPlayback
);
} finally {
consoleErrorSpy.mockRestore();
}
});
});
@@ -26,6 +26,8 @@ import {
EmbeddedMpvNativeService,
embeddedMpvNativeService,
} from '../services/embedded-mpv-native.service';
import { stalkerPlaybackContextService } from '../services/stalker-playback-context.service';
import { resolveEffectiveExternalPlaybackRequest } from './external-player-playback-request';
export default class EmbeddedMpvEvents {
static bootstrapEmbeddedMpvEvents(): Electron.IpcMain {
@@ -47,14 +49,20 @@ function handleEmbeddedMpv<Args extends unknown[]>(
channel: string,
handler: (...args: Args) => unknown
): void {
ipcMain.handle(channel, async (_event, ...args: unknown[]) => {
handleEmbeddedMpvEvent(channel, (_event, ...args: Args) =>
handler(...args)
);
}
function handleEmbeddedMpvEvent<Args extends unknown[]>(
channel: string,
handler: (event: Electron.IpcMainInvokeEvent, ...args: Args) => unknown
): void {
ipcMain.handle(channel, async (event, ...args: unknown[]) => {
try {
return await handler(...(args as Args));
return await handler(event, ...(args as Args));
} catch (error) {
console.error(
`[Embedded MPV] ${channel} handler failed:`,
error
);
console.error(`[Embedded MPV] ${channel} handler failed:`, error);
throw error;
}
});
@@ -70,10 +78,49 @@ handleEmbeddedMpv(
getService().createSession(bounds, title, initialVolume)
);
handleEmbeddedMpv(
handleEmbeddedMpvEvent(
EMBEDDED_MPV_LOAD_PLAYBACK,
(sessionId: string, playback: ResolvedPortalPlayback) =>
getService().loadPlayback(sessionId, playback)
(event, sessionId: string, playback: ResolvedPortalPlayback) => {
if (playback.playbackContextRef === undefined) {
return getService().loadPlayback(sessionId, playback);
}
const mainOwnedHeaders = stalkerPlaybackContextService.consume({
contextRef: playback.playbackContextRef,
senderId: event.sender.id,
streamUrl: playback.streamUrl,
});
if (!mainOwnedHeaders) {
throw new Error('invalid-playback-context');
}
const {
playbackContextRef: _playbackContextRef,
headers: _headers,
userAgent: _userAgent,
referer: _referer,
origin: _origin,
...safePlayback
} = playback;
const {
effectiveOrigin,
effectiveReferer,
effectiveUserAgent,
mergedHeaders,
} = resolveEffectiveExternalPlaybackRequest({
url: playback.streamUrl,
mainOwnedHeaders,
});
const managedPlayback: ResolvedPortalPlayback = {
...safePlayback,
headers: mergedHeaders,
...(effectiveOrigin ? { origin: effectiveOrigin } : {}),
...(effectiveReferer ? { referer: effectiveReferer } : {}),
...(effectiveUserAgent ? { userAgent: effectiveUserAgent } : {}),
};
return getService().loadPlayback(sessionId, managedPlayback);
}
);
handleEmbeddedMpv(
@@ -51,26 +51,32 @@ export function resolveEffectiveExternalPlaybackRequest(options: {
referer?: string;
origin?: string;
headers?: Record<string, string>;
mainOwnedHeaders?: Record<string, string>;
}): EffectiveExternalPlaybackRequest {
const fallbackHeaders = getStalkerPlaybackContextHeaders(options.url) ?? {};
const mergedHeaders = isStalkerDirectStreamProfile(fallbackHeaders)
? fallbackHeaders
: {
...fallbackHeaders,
...(options.headers ?? {}),
};
const usesMainOwnedHeaders = options.mainOwnedHeaders !== undefined;
const fallbackHeaders = usesMainOwnedHeaders
? {}
: (getStalkerPlaybackContextHeaders(options.url) ?? {});
const mergedHeaders = usesMainOwnedHeaders
? { ...options.mainOwnedHeaders }
: isStalkerDirectStreamProfile(fallbackHeaders)
? fallbackHeaders
: {
...fallbackHeaders,
...(options.headers ?? {}),
};
const effectiveOrigin =
options.origin ??
(usesMainOwnedHeaders ? undefined : options.origin) ??
mergedHeaders['Origin'] ??
mergedHeaders['origin'] ??
undefined;
const effectiveReferer =
options.referer ??
(usesMainOwnedHeaders ? undefined : options.referer) ??
mergedHeaders['Referer'] ??
mergedHeaders['referer'] ??
undefined;
const effectiveUserAgent =
options.userAgent ??
(usesMainOwnedHeaders ? undefined : options.userAgent) ??
mergedHeaders['User-Agent'] ??
mergedHeaders['user-agent'] ??
undefined;
@@ -39,6 +39,7 @@ export interface OpenExternalPlayerRequest {
contentInfo?: PlayerContentInfo;
startTime?: number;
headers?: Record<string, string>;
mainOwnedHeaders?: Record<string, string>;
}
let mpvProcess: ChildProcess | null = null;
@@ -220,6 +221,7 @@ export async function openMpvPlayer({
contentInfo,
startTime,
headers,
mainOwnedHeaders,
}: OpenExternalPlayerRequest) {
const session = externalPlayerSessions.beginSession({
player: 'mpv',
@@ -254,6 +256,7 @@ export async function openMpvPlayer({
referer,
origin,
headers,
mainOwnedHeaders,
});
traceExternalPlayer('open mpv player', {
@@ -26,8 +26,14 @@ jest.mock('../services/store.service', () => ({
},
}));
const mockConsumeStalkerPlaybackContext = jest.fn();
const mockGetStalkerPlaybackContextHeaders = jest.fn(() => undefined);
jest.mock('../services/stalker-playback-context.service', () => ({
getStalkerPlaybackContextHeaders: jest.fn(() => undefined),
getStalkerPlaybackContextHeaders: mockGetStalkerPlaybackContextHeaders,
stalkerPlaybackContextService: {
consume: mockConsumeStalkerPlaybackContext,
},
}));
import { ipcMain } from 'electron';
@@ -52,6 +58,7 @@ import {
shouldReuseVlcInstance,
shouldUseMpvSocketBridge,
} from './player.events';
import { resolveEffectiveExternalPlaybackRequest } from './external-player-playback-request';
import { openVlcPlayer } from './vlc-session.service';
function createPathExists(existingPaths: string[]) {
@@ -388,6 +395,143 @@ describe('player.events external player path settings', () => {
});
});
describe('player.events Stalker playback contexts', () => {
beforeEach(() => {
mockConsumeStalkerPlaybackContext.mockReset();
mockGetStalkerPlaybackContextHeaders.mockReset();
mockGetStalkerPlaybackContextHeaders.mockReturnValue(undefined);
(spawn as unknown as jest.Mock).mockReset();
(store.get as unknown as jest.Mock).mockImplementation(
(_key: string, fallback?: unknown) => fallback
);
});
it('uses only main-owned context headers for a sender-bound MPV launch and consumes the ref once', async () => {
jest.useFakeTimers();
try {
const proc = createMockChildProcess();
(spawn as unknown as jest.Mock).mockReturnValue(proc);
mockConsumeStalkerPlaybackContext
.mockReturnValueOnce({
Authorization: 'Bearer main-token',
Cookie: 'session=main-cookie',
Origin: 'https://main.example',
Referer: 'https://main.example/player/',
'User-Agent': 'MainAgent/1.0',
})
.mockReturnValueOnce(null);
const handler = getIpcMainHandler('OPEN_MPV_PLAYER');
const args = [
'https://stream.example/live.m3u8',
'Managed stream',
'',
'RendererAgent/1.0',
'https://renderer.example/referrer',
'https://renderer.example',
undefined,
undefined,
{
Authorization: 'Bearer renderer-token',
Cookie: 'session=renderer-cookie',
},
'opaque-playback-context',
] as const;
const firstLaunch = handler({ sender: { id: 71 } }, ...args);
jest.advanceTimersByTime(100);
await expect(firstLaunch).resolves.toMatchObject({
player: 'mpv',
status: 'opened',
});
expect(mockConsumeStalkerPlaybackContext).toHaveBeenCalledWith({
contextRef: 'opaque-playback-context',
senderId: 71,
streamUrl: 'https://stream.example/live.m3u8',
});
expect(mockGetStalkerPlaybackContextHeaders).not.toHaveBeenCalled();
const spawnArgs = (spawn as unknown as jest.Mock).mock.calls[0][1];
const serializedArgs = JSON.stringify(spawnArgs);
expect(serializedArgs).toContain('Bearer main-token');
expect(serializedArgs).toContain('session=main-cookie');
expect(serializedArgs).toContain('MainAgent/1.0');
expect(serializedArgs).not.toContain('renderer-token');
expect(serializedArgs).not.toContain('renderer-cookie');
expect(serializedArgs).not.toContain('RendererAgent/1.0');
expect(serializedArgs).not.toContain('opaque-playback-context');
await expect(
handler({ sender: { id: 71 } }, ...args)
).rejects.toThrow('invalid-playback-context');
expect(spawn).toHaveBeenCalledTimes(1);
expect(mockConsumeStalkerPlaybackContext).toHaveBeenCalledTimes(2);
} finally {
jest.useRealTimers();
}
});
it('fails a foreign or expired VLC context before spawning the player', async () => {
mockConsumeStalkerPlaybackContext.mockReturnValue(null);
const handler = getIpcMainHandler('OPEN_VLC_PLAYER');
await expect(
handler(
{ sender: { id: 99 } },
'https://stream.example/live.m3u8',
'Managed stream',
'',
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
'foreign-or-expired-context'
)
).rejects.toThrow('invalid-playback-context');
expect(mockConsumeStalkerPlaybackContext).toHaveBeenCalledWith({
contextRef: 'foreign-or-expired-context',
senderId: 99,
streamUrl: 'https://stream.example/live.m3u8',
});
expect(spawn).not.toHaveBeenCalled();
});
it('keeps the legacy no-ref fallback while main-owned headers bypass renderer overrides', () => {
mockGetStalkerPlaybackContextHeaders.mockReturnValue({
'User-Agent': 'LegacyAgent/1.0',
});
expect(
resolveEffectiveExternalPlaybackRequest({
headers: { 'X-Renderer': 'allowed-legacy-header' },
url: 'https://stream.example/legacy.m3u8',
}).mergedHeaders
).toEqual({
'User-Agent': 'LegacyAgent/1.0',
'X-Renderer': 'allowed-legacy-header',
});
const managedOptions = {
headers: {
Authorization: 'Bearer renderer-token',
'User-Agent': 'RendererAgent/1.0',
},
mainOwnedHeaders: {
Authorization: 'Bearer main-token',
'User-Agent': 'MainAgent/1.0',
},
url: 'https://stream.example/managed.m3u8',
userAgent: 'RendererAgent/1.0',
};
const managed = resolveEffectiveExternalPlaybackRequest(managedOptions);
expect(managed.mergedHeaders).toEqual(managedOptions.mainOwnedHeaders);
expect(managed.effectiveUserAgent).toBe('MainAgent/1.0');
});
});
describe('openVlcPlayer', () => {
beforeEach(() => {
(spawn as unknown as jest.Mock).mockReset();
@@ -2,6 +2,7 @@ import { ipcMain } from 'electron';
import {
CLOSE_EXTERNAL_PLAYER_SESSION,
PlayerContentInfo,
StalkerPlaybackContextRef,
} from '@iptvnator/shared/interfaces';
import {
MPV_PLAYER_PATH,
@@ -9,6 +10,7 @@ import {
VLC_PLAYER_PATH,
} from '../services/store.service';
import { normalizePlayerPathForStore } from './external-player-launch-context';
import { stalkerPlaybackContextService } from '../services/stalker-playback-context.service';
import {
externalPlayerSessions,
traceExternalPlayer,
@@ -41,7 +43,7 @@ export default class PlayerEvents {
ipcMain.handle(
'OPEN_MPV_PLAYER',
async (
_event,
event,
url: string,
title: string,
thumbnail?: string,
@@ -50,9 +52,29 @@ ipcMain.handle(
origin?: string,
contentInfo?: PlayerContentInfo,
startTime?: number,
headers?: Record<string, string>
) =>
openMpvPlayer({
headers?: Record<string, string>,
playbackContextRef?: StalkerPlaybackContextRef
) => {
if (playbackContextRef !== undefined) {
const mainOwnedHeaders = stalkerPlaybackContextService.consume({
contextRef: playbackContextRef,
senderId: event.sender.id,
streamUrl: url,
});
if (!mainOwnedHeaders) {
throw new Error('invalid-playback-context');
}
return openMpvPlayer({
url,
title,
thumbnail,
contentInfo,
startTime,
mainOwnedHeaders,
});
}
return openMpvPlayer({
url,
title,
thumbnail,
@@ -62,7 +84,8 @@ ipcMain.handle(
contentInfo,
startTime,
headers,
})
});
}
);
ipcMain.handle(
@@ -83,7 +106,7 @@ ipcMain.handle('SET_MPV_REUSE_INSTANCE', (_event, reuseInstance: boolean) => {
ipcMain.handle(
'OPEN_VLC_PLAYER',
async (
_event,
event,
url: string,
title: string,
thumbnail?: string,
@@ -92,9 +115,29 @@ ipcMain.handle(
origin?: string,
contentInfo?: PlayerContentInfo,
startTime?: number,
headers?: Record<string, string>
) =>
openVlcPlayer({
headers?: Record<string, string>,
playbackContextRef?: StalkerPlaybackContextRef
) => {
if (playbackContextRef !== undefined) {
const mainOwnedHeaders = stalkerPlaybackContextService.consume({
contextRef: playbackContextRef,
senderId: event.sender.id,
streamUrl: url,
});
if (!mainOwnedHeaders) {
throw new Error('invalid-playback-context');
}
return openVlcPlayer({
url,
title,
thumbnail,
contentInfo,
startTime,
mainOwnedHeaders,
});
}
return openVlcPlayer({
url,
title,
thumbnail,
@@ -104,7 +147,8 @@ ipcMain.handle(
contentInfo,
startTime,
headers,
})
});
}
);
ipcMain.handle(
@@ -39,6 +39,7 @@ export interface OpenVlcPlayerRequest {
contentInfo?: PlayerContentInfo;
startTime?: number;
headers?: Record<string, string>;
mainOwnedHeaders?: Record<string, string>;
}
let vlcProcess: ChildProcess | null = null;
@@ -309,6 +310,7 @@ export async function openVlcPlayer({
contentInfo,
startTime,
headers,
mainOwnedHeaders,
}: OpenVlcPlayerRequest) {
const session = externalPlayerSessions.beginSession({
player: 'vlc',
@@ -342,6 +344,7 @@ export async function openVlcPlayer({
referer,
origin,
headers,
mainOwnedHeaders,
});
traceExternalPlayer('open vlc player', {
path: vlcLaunchContext.playerPath,
@@ -198,6 +198,34 @@ describe('ElectronService', () => {
);
});
it.each([
['OPEN_MPV_PLAYER', 'openInMpv'],
['OPEN_VLC_PLAYER', 'openInVlc'],
])(
'forwards opaque playback contexts through %s',
async (event, method) => {
await service.sendIpcEvent(event, {
playbackContextRef: 'opaque-playback-context',
url: 'https://stream.example/live.m3u8',
});
expect(
electronBridge[method as 'openInMpv' | 'openInVlc']
).toHaveBeenCalledWith(
'https://stream.example/live.m3u8',
'',
'',
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
'opaque-playback-context'
);
}
);
describe('startup playlist auto-refresh feedback', () => {
function createPlaylist(id: string, title: string): Playlist {
return {
+20 -6
View File
@@ -16,6 +16,7 @@ import {
Playlist,
PLAYLIST_PARSE_BY_URL,
PLAYLIST_UPDATE,
StalkerPlaybackContextRef,
XTREAM_REQUEST,
XTREAM_RESPONSE,
XtreamCodeActions,
@@ -38,6 +39,7 @@ interface PlayerLaunchPayload {
readonly url: string;
readonly ['user-agent']?: string;
readonly contentInfo?: PlayerContentInfo;
readonly playbackContextRef?: StalkerPlaybackContextRef;
}
interface ErrorStatus {
@@ -165,7 +167,7 @@ export class ElectronService extends DataService {
if (type === 'OPEN_MPV_PLAYER') {
const data = payload as PlayerLaunchPayload;
try {
return (await window.electron.openInMpv(
const args = [
data.url,
data.title ?? '',
data.thumbnail ?? '',
@@ -174,8 +176,14 @@ export class ElectronService extends DataService {
data.origin ?? undefined,
data.contentInfo,
data.startTime,
data.headers ?? undefined
)) as T;
data.headers ?? undefined,
] as const;
return (await (data.playbackContextRef === undefined
? window.electron.openInMpv(...args)
: window.electron.openInMpv(
...args,
data.playbackContextRef
))) as T;
} catch (error: unknown) {
const errorMessage =
this.getErrorDetails(error)?.message ?? String(error);
@@ -194,7 +202,7 @@ export class ElectronService extends DataService {
if (type === 'OPEN_VLC_PLAYER') {
const data = payload as PlayerLaunchPayload;
try {
return (await window.electron.openInVlc(
const args = [
data.url,
data.title ?? '',
data.thumbnail ?? '',
@@ -203,8 +211,14 @@ export class ElectronService extends DataService {
data.origin ?? undefined,
data.contentInfo,
data.startTime,
data.headers ?? undefined
)) as T;
data.headers ?? undefined,
] as const;
return (await (data.playbackContextRef === undefined
? window.electron.openInVlc(...args)
: window.electron.openInVlc(
...args,
data.playbackContextRef
))) as T;
} catch (error: unknown) {
const errorMessage =
this.getErrorDetails(error)?.message ?? String(error);
@@ -130,4 +130,31 @@ describe('PlayerService', () => {
expect(dialog.open).not.toHaveBeenCalled();
expect(result).toEqual(session);
});
it('forwards only the opaque context instead of renderer-controlled headers for managed playback', async () => {
settingsStore.player.mockReturnValue(VideoPlayer.MPV);
await service.openResolvedPlayback({
headers: {
Authorization: 'Bearer renderer-token',
Cookie: 'session=renderer-cookie',
},
origin: 'https://renderer.example',
playbackContextRef: 'opaque-playback-context',
referer: 'https://renderer.example/referrer',
streamUrl: 'https://stream.example/live.m3u8',
title: 'Managed stream',
userAgent: 'RendererAgent/1.0',
});
const payload = dataService.sendIpcEvent.mock.calls[0][1];
expect(payload).toMatchObject({
playbackContextRef: 'opaque-playback-context',
url: 'https://stream.example/live.m3u8',
});
expect(payload).not.toHaveProperty('headers');
expect(payload).not.toHaveProperty('user-agent');
expect(payload).not.toHaveProperty('referer');
expect(payload).not.toHaveProperty('origin');
});
});
+21 -11
View File
@@ -89,20 +89,30 @@ export class PlayerService {
): Promise<ExternalPlayerSession | void> {
const ipcEvent =
player === 'mpv' ? OPEN_MPV_PLAYER : OPEN_VLC_PLAYER;
const presentation = {
url: playback.streamUrl,
title: playback.title,
thumbnail: playback.thumbnail,
contentInfo: playback.contentInfo,
startTime: playback.startTime,
};
const payload =
playback.playbackContextRef !== undefined
? {
...presentation,
playbackContextRef: playback.playbackContextRef,
}
: {
...presentation,
'user-agent': playback.userAgent,
referer: playback.referer,
origin: playback.origin,
headers: playback.headers,
};
return await this.dataService.sendIpcEvent<ExternalPlayerSession>(
ipcEvent,
{
url: playback.streamUrl,
title: playback.title,
thumbnail: playback.thumbnail,
'user-agent': playback.userAgent,
referer: playback.referer,
origin: playback.origin,
headers: playback.headers,
contentInfo: playback.contentInfo,
startTime: playback.startTime,
}
payload
);
}
}
@@ -40,6 +40,7 @@ import {
StalkerSessionControlOutcome,
StalkerSessionControlRequest,
StalkerSessionOpenRequest,
StalkerPlaybackContextRef,
StalkerSessionRequest,
StalkerSessionRequestOutcome,
} from './stalker-session.interface';
@@ -594,7 +595,8 @@ export interface ElectronBridgeApi {
origin?: string,
contentInfo?: PlayerContentInfo,
startTime?: number,
headers?: Record<string, string>
headers?: Record<string, string>,
playbackContextRef?: StalkerPlaybackContextRef
) => Promise<ExternalPlayerSession>;
openInVlc: (
url: string,
@@ -605,7 +607,8 @@ export interface ElectronBridgeApi {
origin?: string,
contentInfo?: PlayerContentInfo,
startTime?: number,
headers?: Record<string, string>
headers?: Record<string, string>,
playbackContextRef?: StalkerPlaybackContextRef
) => Promise<ExternalPlayerSession>;
autoUpdatePlaylists: (
playlists: Playlist[],