mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-11 02:46:16 -08:00
feat(stalker): secure native playback contexts
This commit is contained in:
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 {
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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[],
|
||||
|
||||
Reference in new issue
Block a user