diff --git a/apps/electron-backend/src/app/api/main.preload.spec.ts b/apps/electron-backend/src/app/api/main.preload.spec.ts index 27b3e9268..d83fc3cc8 100644 --- a/apps/electron-backend/src/app/api/main.preload.spec.ts +++ b/apps/electron-backend/src/app/api/main.preload.spec.ts @@ -84,6 +84,23 @@ describe('main preload DB IPC contract', () => { } ); + it('forwards scoped user-agent override arguments', async () => { + const api = getExposedApi(); + + await api.setUserAgent( + 'ChannelAgent/1.0', + 'https://portal.example/referrer', + 'https://stream.example/live.m3u8' + ); + + expect(mockIpcRenderer.invoke).toHaveBeenLastCalledWith( + 'set-user-agent', + 'ChannelAgent/1.0', + 'https://portal.example/referrer', + 'https://stream.example/live.m3u8' + ); + }); + it('forwards request-scoped DB operation events and unregisters the listener', () => { const api = getExposedApi(); const callback = jest.fn(); diff --git a/apps/electron-backend/src/app/api/main.preload.ts b/apps/electron-backend/src/app/api/main.preload.ts index 917a5a822..f8efdaf56 100644 --- a/apps/electron-backend/src/app/api/main.preload.ts +++ b/apps/electron-backend/src/app/api/main.preload.ts @@ -299,8 +299,11 @@ const electronApi = { ) => ipcRenderer.invoke('save-file-dialog', defaultPath, filters), writeFile: (filePath: string, content: string) => ipcRenderer.invoke('write-file', filePath, content), - setUserAgent: (userAgent: string, referer?: string) => - ipcRenderer.invoke('set-user-agent', userAgent, referer), + setUserAgent: ( + userAgent?: string | null, + referer?: string | null, + scopeUrl?: string | null + ) => ipcRenderer.invoke('set-user-agent', userAgent, referer, scopeUrl), openInMpv: ( url: string, title: string, diff --git a/apps/electron-backend/src/app/app.spec.ts b/apps/electron-backend/src/app/app.spec.ts new file mode 100644 index 000000000..a620f5a4f --- /dev/null +++ b/apps/electron-backend/src/app/app.spec.ts @@ -0,0 +1,79 @@ +jest.mock('electron', () => ({ + app: { + getPath: jest.fn(() => '/tmp'), + isPackaged: false, + }, + BrowserWindow: jest.fn(), + Menu: { + buildFromTemplate: jest.fn(), + }, + screen: { + getPrimaryDisplay: jest.fn(), + }, + shell: { + openExternal: jest.fn(), + }, +})); + +jest.mock('./services/store.service', () => ({ + store: { + get: jest.fn(), + set: jest.fn(), + }, + WINDOW_BOUNDS: 'windowBounds', +})); + +import { + getMainWindowWebPreferences, + isExternalBrowserUrl, + isTrustedRendererNavigationUrl, +} from './app'; + +describe('Electron app security helpers', () => { + it('creates an explicitly hardened BrowserWindow webPreferences object', () => { + expect(getMainWindowWebPreferences()).toEqual( + expect.objectContaining({ + contextIsolation: true, + nodeIntegration: false, + sandbox: true, + webSecurity: true, + backgroundThrottling: false, + }) + ); + }); + + it('treats only http and https URLs as external browser URLs', () => { + expect(isExternalBrowserUrl('https://example.com')).toBe(true); + expect(isExternalBrowserUrl('http://example.com')).toBe(true); + expect(isExternalBrowserUrl('file:///tmp/index.html')).toBe(false); + expect(isExternalBrowserUrl('javascript:alert(1)')).toBe(false); + expect(isExternalBrowserUrl('not a url')).toBe(false); + }); + + it('allows only the dev server origin in development navigation', () => { + expect( + isTrustedRendererNavigationUrl('http://localhost:4200/home', true) + ).toBe(true); + expect( + isTrustedRendererNavigationUrl('http://127.0.0.1:4200/home', true) + ).toBe(true); + expect( + isTrustedRendererNavigationUrl('http://localhost:4300/home', true) + ).toBe(false); + expect( + isTrustedRendererNavigationUrl('https://example.com', true) + ).toBe(false); + expect( + isTrustedRendererNavigationUrl('file:///tmp/index.html', true) + ).toBe(false); + }); + + it('allows packaged file navigation but rejects external web navigation', () => { + expect( + isTrustedRendererNavigationUrl('file:///tmp/index.html', false) + ).toBe(true); + expect( + isTrustedRendererNavigationUrl('https://example.com', false) + ).toBe(false); + }); +}); diff --git a/apps/electron-backend/src/app/app.ts b/apps/electron-backend/src/app/app.ts index c58fbf0aa..2612daddc 100644 --- a/apps/electron-backend/src/app/app.ts +++ b/apps/electron-backend/src/app/app.ts @@ -8,6 +8,59 @@ import { } from './services/debug-trace'; import { store, WINDOW_BOUNDS } from './services/store.service'; +const externalBrowserProtocols = new Set(['http:', 'https:']); + +function parseUrl(url: string): URL | null { + try { + return new URL(url); + } catch { + return null; + } +} + +export function isExternalBrowserUrl(url: string): boolean { + const parsedUrl = parseUrl(url); + return Boolean( + parsedUrl && externalBrowserProtocols.has(parsedUrl.protocol) + ); +} + +export function isTrustedRendererNavigationUrl( + url: string, + isDevelopmentMode: boolean +): boolean { + const parsedUrl = parseUrl(url); + + if (!parsedUrl) { + return false; + } + + if (parsedUrl.protocol === 'file:') { + return !isDevelopmentMode; + } + + if (!isDevelopmentMode) { + return false; + } + + return ( + parsedUrl.protocol === 'http:' && + ['localhost', '127.0.0.1', '[::1]'].includes(parsedUrl.hostname) && + parsedUrl.port === String(rendererAppPort) + ); +} + +export function getMainWindowWebPreferences(): Electron.BrowserWindowConstructorOptions['webPreferences'] { + return { + contextIsolation: true, + nodeIntegration: false, + sandbox: true, + webSecurity: true, + backgroundThrottling: false, + preload: join(__dirname, 'main.preload.js'), + }; +} + function attachWindowTrace(mainWindow: Electron.BrowserWindow): void { if (!isWindowTraceEnabled()) { return; @@ -125,14 +178,6 @@ export default class App { App.mainWindow = null; } - private static onRedirect(event: any, url: string) { - if (url !== App.mainWindow.webContents.getURL()) { - // this is a normal external redirect, open it in a new browser window - event.preventDefault(); - shell.openExternal(url); - } - } - private static onReady() { // This method will be called when Electron has finished // initialization and is ready to create browser windows. @@ -164,11 +209,7 @@ export default class App { width: width, height: height, show: false, - webPreferences: { - contextIsolation: true, - backgroundThrottling: false, - preload: join(__dirname, 'main.preload.js'), - }, + webPreferences: getMainWindowWebPreferences(), ...savedWindowBounds, minHeight: 600, minWidth: 900, @@ -193,12 +234,24 @@ export default class App { // Route target="_blank" / window.open() to the OS default browser App.mainWindow.webContents.setWindowOpenHandler(({ url }) => { - if (/^https?:\/\//.test(url)) { + if (isExternalBrowserUrl(url)) { shell.openExternal(url); } return { action: 'deny' }; }); + App.mainWindow.webContents.on('will-navigate', (event, url) => { + if (isTrustedRendererNavigationUrl(url, App.isDevelopmentMode())) { + return; + } + + event.preventDefault(); + + if (isExternalBrowserUrl(url)) { + shell.openExternal(url); + } + }); + // Emitted when the window is closed. App.mainWindow.on('closed', () => { // Dereference the window object, usually you would store windows diff --git a/apps/electron-backend/src/app/events/shared.events.ts b/apps/electron-backend/src/app/events/shared.events.ts index faf62e1b4..dcd4a4360 100644 --- a/apps/electron-backend/src/app/events/shared.events.ts +++ b/apps/electron-backend/src/app/events/shared.events.ts @@ -1,4 +1,8 @@ -import { ipcMain, session } from 'electron'; +import { ipcMain } from 'electron'; +import { + clearRequestHeaderOverride, + configureRequestHeaderOverride, +} from '../services/request-header-overrides.service'; export default class SharedEvents { static bootstrapSharedEvents(): Electron.IpcMain { @@ -6,34 +10,26 @@ export default class SharedEvents { } } -ipcMain.handle('set-user-agent', (event, userAgent, referer) => { - setUserAgent(userAgent, referer); // TODO: test if defaults needed +ipcMain.handle('set-user-agent', (_event, userAgent, referer, scopeUrl) => { + setUserAgent(userAgent, referer, scopeUrl); return true; }); /** - * Sets the user agent header for all http requests + * Sets scoped request headers for the currently selected stream. * @param userAgent user agent to use * @param referer referer to use + * @param scopeUrl stream URL used to limit the override to the active origin */ -export function setUserAgent(userAgent: string, referer?: string): void { - if (userAgent === undefined || userAgent === null || userAgent === '') { - userAgent = this.defaultUserAgent; +export function setUserAgent( + userAgent?: string | null, + referer?: string | null, + scopeUrl?: string | null +): void { + if (!userAgent?.trim() && !referer?.trim()) { + clearRequestHeaderOverride(); + return; } - // Remove trailing slash from referer if it exists - let originURL: string; - if (referer?.endsWith('/')) { - originURL = referer.slice(0, -1); - } - - session.defaultSession.webRequest.onBeforeSendHeaders( - (details, callback) => { - details.requestHeaders['User-Agent'] = userAgent; - details.requestHeaders['Referer'] = referer as string; - details.requestHeaders['Origin'] = originURL as string; - callback({ requestHeaders: details.requestHeaders }); - } - ); - console.log(`Success: Set "${userAgent}" as user agent header`); + configureRequestHeaderOverride(userAgent, referer, scopeUrl); } diff --git a/apps/electron-backend/src/app/services/request-header-overrides.service.spec.ts b/apps/electron-backend/src/app/services/request-header-overrides.service.spec.ts new file mode 100644 index 000000000..15696e55c --- /dev/null +++ b/apps/electron-backend/src/app/services/request-header-overrides.service.spec.ts @@ -0,0 +1,173 @@ +type HeaderListener = ( + details: Electron.OnBeforeSendHeadersListenerDetails, + callback: (beforeSendResponse: Electron.BeforeSendResponse) => void +) => void; + +const mockOnBeforeSendHeaders = jest.fn(); + +jest.mock('electron', () => ({ + session: { + defaultSession: { + webRequest: { + onBeforeSendHeaders: mockOnBeforeSendHeaders, + }, + }, + }, +})); + +function createRequestDetails( + url: string, + requestHeaders: Record = {} +): Electron.OnBeforeSendHeadersListenerDetails { + return { + id: 1, + method: 'GET', + referrer: '', + requestHeaders, + resourceType: 'media', + timestamp: Date.now(), + uploadData: [], + url, + webContentsId: 1, + webContents: undefined, + } as Electron.OnBeforeSendHeadersListenerDetails; +} + +function runHeaderListener( + listener: HeaderListener, + url: string, + requestHeaders: Record = {} +): Record { + let response: Electron.BeforeSendResponse | undefined; + + listener(createRequestDetails(url, requestHeaders), (nextResponse) => { + response = nextResponse; + }); + + if (!response?.requestHeaders) { + throw new Error('Expected request headers response'); + } + + return response.requestHeaders as Record; +} + +describe('request header overrides', () => { + beforeEach(() => { + jest.resetModules(); + mockOnBeforeSendHeaders.mockClear(); + }); + + it('registers one listener and updates the active scoped headers', async () => { + const { configureRequestHeaderOverride } = + await import('./request-header-overrides.service'); + + configureRequestHeaderOverride( + 'FirstAgent/1.0', + 'https://portal.example/', + 'https://stream.example/live.m3u8' + ); + configureRequestHeaderOverride( + 'SecondAgent/2.0', + 'https://portal.example/referrer', + 'https://stream.example/live.m3u8' + ); + + expect(mockOnBeforeSendHeaders).toHaveBeenCalledTimes(1); + expect(mockOnBeforeSendHeaders).toHaveBeenCalledWith( + { urls: ['http://*/*', 'https://*/*'] }, + expect.any(Function) + ); + + const listener = mockOnBeforeSendHeaders.mock.calls[0][1]; + const headers = runHeaderListener( + listener, + 'https://stream.example/segment.ts' + ); + + expect(headers).toEqual({ + Origin: 'https://portal.example', + Referer: 'https://portal.example/referrer', + 'User-Agent': 'SecondAgent/2.0', + }); + }); + + it('does not apply scoped headers to unrelated origins', async () => { + const { configureRequestHeaderOverride } = + await import('./request-header-overrides.service'); + + configureRequestHeaderOverride( + 'ScopedAgent/1.0', + 'https://portal.example/referrer', + 'https://stream.example/live.m3u8' + ); + + const listener = mockOnBeforeSendHeaders.mock.calls[0][1]; + const headers = runHeaderListener( + listener, + 'https://unrelated.example/segment.ts', + { Accept: '*/*' } + ); + + expect(headers).toEqual({ Accept: '*/*' }); + }); + + it('clears active header overrides without registering another listener', async () => { + const { clearRequestHeaderOverride, configureRequestHeaderOverride } = + await import('./request-header-overrides.service'); + + configureRequestHeaderOverride( + 'ScopedAgent/1.0', + 'https://portal.example/referrer', + 'https://stream.example/live.m3u8' + ); + clearRequestHeaderOverride(); + + expect(mockOnBeforeSendHeaders).toHaveBeenCalledTimes(1); + + const listener = mockOnBeforeSendHeaders.mock.calls[0][1]; + const headers = runHeaderListener( + listener, + 'https://stream.example/segment.ts', + { Accept: '*/*' } + ); + + expect(headers).toEqual({ Accept: '*/*' }); + }); + + it('does not register a listener when cleared before any override exists', async () => { + const { clearRequestHeaderOverride } = + await import('./request-header-overrides.service'); + + clearRequestHeaderOverride(); + + expect(mockOnBeforeSendHeaders).not.toHaveBeenCalled(); + }); + + it('replaces existing header names case-insensitively', async () => { + const { configureRequestHeaderOverride } = + await import('./request-header-overrides.service'); + + configureRequestHeaderOverride( + 'ScopedAgent/1.0', + 'https://portal.example/referrer', + 'https://stream.example/live.m3u8' + ); + + const listener = mockOnBeforeSendHeaders.mock.calls[0][1]; + const headers = runHeaderListener( + listener, + 'https://stream.example/segment.ts', + { + origin: 'https://old.example', + referer: 'https://old.example/ref', + 'user-agent': 'OldAgent/0.1', + } + ); + + expect(headers).toEqual({ + Origin: 'https://portal.example', + Referer: 'https://portal.example/referrer', + 'User-Agent': 'ScopedAgent/1.0', + }); + }); +}); diff --git a/apps/electron-backend/src/app/services/request-header-overrides.service.ts b/apps/electron-backend/src/app/services/request-header-overrides.service.ts new file mode 100644 index 000000000..07e86949c --- /dev/null +++ b/apps/electron-backend/src/app/services/request-header-overrides.service.ts @@ -0,0 +1,133 @@ +import { session } from 'electron'; + +type HeaderOverride = { + origin?: string; + referer?: string; + scopeOrigins: Set; + userAgent?: string; +}; + +const headerOverrideUrlFilter = { + urls: ['http://*/*', 'https://*/*'], +}; + +let activeHeaderOverride: HeaderOverride | null = null; +let listenerRegistered = false; + +function normalizeHeaderValue(value?: string | null): string | undefined { + const trimmed = value?.trim(); + return trimmed ? trimmed : undefined; +} + +function getOrigin(value?: string | null): string | undefined { + const normalizedValue = normalizeHeaderValue(value); + + if (!normalizedValue) { + return undefined; + } + + try { + return new URL(normalizedValue).origin; + } catch { + return undefined; + } +} + +function shouldApplyOverride(url: string, override: HeaderOverride): boolean { + if (override.scopeOrigins.size === 0) { + return true; + } + + const requestOrigin = getOrigin(url); + return Boolean(requestOrigin && override.scopeOrigins.has(requestOrigin)); +} + +function setRequestHeader( + requestHeaders: Record, + headerName: string, + headerValue: string +): void { + const normalizedHeaderName = headerName.toLowerCase(); + const existingHeaderName = Object.keys(requestHeaders).find( + (name) => name.toLowerCase() === normalizedHeaderName + ); + + if (existingHeaderName) { + delete requestHeaders[existingHeaderName]; + } + + requestHeaders[headerName] = headerValue; +} + +function handleBeforeSendHeaders( + details: Electron.OnBeforeSendHeadersListenerDetails, + callback: (beforeSendResponse: Electron.BeforeSendResponse) => void +): void { + const requestHeaders = { ...details.requestHeaders }; + const override = activeHeaderOverride; + + if (!override || !shouldApplyOverride(details.url, override)) { + callback({ requestHeaders }); + return; + } + + if (override.userAgent) { + setRequestHeader(requestHeaders, 'User-Agent', override.userAgent); + } + + if (override.referer) { + setRequestHeader(requestHeaders, 'Referer', override.referer); + } + + if (override.origin) { + setRequestHeader(requestHeaders, 'Origin', override.origin); + } + + callback({ requestHeaders }); +} + +function ensureHeaderOverrideListener(): void { + if (listenerRegistered) { + return; + } + + session.defaultSession.webRequest.onBeforeSendHeaders( + headerOverrideUrlFilter, + handleBeforeSendHeaders + ); + listenerRegistered = true; +} + +export function configureRequestHeaderOverride( + userAgent?: string | null, + referer?: string | null, + scopeUrl?: string | null +): void { + const normalizedUserAgent = normalizeHeaderValue(userAgent); + const normalizedReferer = normalizeHeaderValue(referer); + + if (!normalizedUserAgent && !normalizedReferer) { + clearRequestHeaderOverride(); + return; + } + + const refererOrigin = getOrigin(normalizedReferer); + const scopeOrigin = getOrigin(scopeUrl); + const scopeOrigins = new Set( + [scopeOrigin, refererOrigin].filter((origin): origin is string => + Boolean(origin) + ) + ); + + activeHeaderOverride = { + origin: refererOrigin, + referer: normalizedReferer, + scopeOrigins, + userAgent: normalizedUserAgent, + }; + ensureHeaderOverrideListener(); +} + +export function clearRequestHeaderOverride(): void { + activeHeaderOverride = null; +} diff --git a/apps/web/src/index.html b/apps/web/src/index.html index 967aa47ed..c8e2b90e2 100644 --- a/apps/web/src/index.html +++ b/apps/web/src/index.html @@ -6,6 +6,10 @@ + diff --git a/apps/web/src/typings.d.ts b/apps/web/src/typings.d.ts index aa9469371..4d5fc21b8 100644 --- a/apps/web/src/typings.d.ts +++ b/apps/web/src/typings.d.ts @@ -67,7 +67,11 @@ declare global { filePath: string, content: string ) => Promise<{ success: boolean }>; - setUserAgent: (userAgent: string, referer?: string) => void; + setUserAgent: ( + userAgent?: string | null, + referer?: string | null, + scopeUrl?: string | null + ) => Promise; openInMpv: ( url: string, title: string, diff --git a/docs/architecture/electron-security.md b/docs/architecture/electron-security.md new file mode 100644 index 000000000..e70bd06a1 --- /dev/null +++ b/docs/architecture/electron-security.md @@ -0,0 +1,77 @@ +# Electron Security Contract + +This document records the Electron runtime security contract for the desktop app. + +## BrowserWindow Defaults + +The main window is created in `apps/electron-backend/src/app/app.ts` with an +explicit hardened `webPreferences` object: + +- `contextIsolation: true` +- `nodeIntegration: false` +- `sandbox: true` +- `webSecurity: true` +- `preload: apps/electron-backend/src/app/api/main.preload.ts` + +Renderer code must use the preload bridge exposed as `window.electron`. +Do not re-enable direct Node.js access from Angular code. New desktop-only APIs +should be added to the preload bridge and backed by an `ipcMain.handle(...)` +owner in the Electron backend. + +## Navigation And External URLs + +The main window owns two navigation gates: + +- `setWindowOpenHandler` denies every new window. `http:` and `https:` targets + are opened in the operating system browser through `shell.openExternal`. +- `will-navigate` allows only the trusted renderer URL. Development mode allows + `http://localhost:4200`, `http://127.0.0.1:4200`, and `http://[::1]:4200`. + Packaged mode allows the app's file-backed renderer. External web + navigations are denied in the app window and opened in the operating system + browser. + +Do not add broad protocol allow-lists for renderer navigation. If a new +desktop-only flow needs to open a URL outside IPTVnator, route it through the +default browser unless the app window is deliberately meant to host that URL. + +## Content Security Policy + +The Angular shell defines a baseline CSP in `apps/web/src/index.html`. + +The policy keeps the application self-hosted for scripts, blocks object and +frame embedding, limits forms to the app origin, and allows IPTV playback +sources through `media-src` and `connect-src` for `http:`, `https:`, `blob:`, +and `data:`. The policy keeps `script-src` self-hosted and currently keeps +`unsafe-inline` for existing inline styles. + +Before tightening either value, validate both Electron development startup and +the PWA/electron build configurations. Playback-heavy changes should also check +that HLS, MPEG-TS, thumbnails, and local file playback are still allowed by the +policy. + +## Scoped Request Header Overrides + +Inline playback can request temporary `User-Agent`, `Referer`, and `Origin` +header overrides through `window.electron.setUserAgent(userAgent, referer, +scopeUrl)`. + +The Electron backend handles that IPC in `apps/electron-backend/src/app/events/shared.events.ts` +and delegates to `apps/electron-backend/src/app/services/request-header-overrides.service.ts`. +The service registers one `session.defaultSession.webRequest.onBeforeSendHeaders` +listener and updates the active override in memory instead of stacking a new +listener for every channel change. + +Rules: + +- empty `userAgent` and empty `referer` clear the active override +- channel playback should pass the stream URL as `scopeUrl` +- scoped overrides apply only to the active stream origin and referer origin +- playlist-level user agents and referrers may call the bridge without a + `scopeUrl`; that is intentionally broader because playlist settings apply to + the whole M3U playlist +- header names are replaced case-insensitively before canonical `User-Agent`, + `Referer`, and `Origin` names are written + +When changing this flow, keep stale header cleanup covered. Switching from a +channel or playlist with custom headers to one without custom headers must clear +the previous override. diff --git a/global.d.ts b/global.d.ts index d23dc5942..5be1ab229 100644 --- a/global.d.ts +++ b/global.d.ts @@ -63,7 +63,11 @@ declare global { filePath: string, content: string ) => Promise<{ success: boolean }>; - setUserAgent: (userAgent: string, referer?: string) => void; + setUserAgent: ( + userAgent?: string | null, + referer?: string | null, + scopeUrl?: string | null + ) => Promise; openInMpv: ( url: string, title: string, diff --git a/libs/m3u-state/src/lib/effects.ts b/libs/m3u-state/src/lib/effects.ts index d911d6358..8f8df6d15 100644 --- a/libs/m3u-state/src/lib/effects.ts +++ b/libs/m3u-state/src/lib/effects.ts @@ -160,13 +160,11 @@ export class PlaylistEffects { this.epgService.getChannelPrograms(channelId); } - // Set user agent if specified on channel - if (channel.http['user-agent']) { - window.electron?.setUserAgent( - channel.http['user-agent'], - channel.http.referrer - ); - } + window.electron?.setUserAgent( + channel.http['user-agent'], + channel.http.referrer, + channel.url + ); firstValueFrom(this.storage.get(STORE_KEY.Settings)).then( (settings: any) => { diff --git a/libs/playlist/m3u/feature-player/src/lib/m3u-workspace-route-session.service.spec.ts b/libs/playlist/m3u/feature-player/src/lib/m3u-workspace-route-session.service.spec.ts index 9618b5a7a..cee66647b 100644 --- a/libs/playlist/m3u/feature-player/src/lib/m3u-workspace-route-session.service.spec.ts +++ b/libs/playlist/m3u/feature-player/src/lib/m3u-workspace-route-session.service.spec.ts @@ -1,6 +1,6 @@ import { TestBed } from '@angular/core/testing'; import { NavigationEnd, Router } from '@angular/router'; -import { Subject } from 'rxjs'; +import { of, Subject } from 'rxjs'; import { ChannelActions, FavoritesActions } from '@iptvnator/m3u-state'; import { PlaylistContextFacade } from '@iptvnator/playlist/shared/util'; import { PlaylistsService } from '@iptvnator/services'; @@ -79,6 +79,9 @@ describe('M3uWorkspaceRouteSession', () => { const store = { dispatch: jest.fn(), }; + const electronApi = { + setUserAgent: jest.fn().mockResolvedValue(true), + }; const router = { url: `/workspace/playlists/${PLAYLIST_ID}/all`, events: routerEvents.asObservable(), @@ -89,6 +92,11 @@ describe('M3uWorkspaceRouteSession', () => { playlistContext.syncFromUrl.mockImplementation((url: string) => getM3uRouteContext(url) ); + Object.defineProperty(window, 'electron', { + configurable: true, + value: electronApi, + }); + electronApi.setUserAgent.mockClear(); playlistsService.getPlaylist.mockReset(); store.dispatch.mockClear(); @@ -115,6 +123,10 @@ describe('M3uWorkspaceRouteSession', () => { }); }); + afterEach(() => { + delete (window as unknown as { electron?: unknown }).electron; + }); + it('does not start channel loading for collection routes like favorites', async () => { router.url = `/workspace/playlists/${PLAYLIST_ID}/favorites`; @@ -130,15 +142,55 @@ describe('M3uWorkspaceRouteSession', () => { expect(playlistsService.getPlaylist).not.toHaveBeenCalled(); }); + it('configures playlist-level user agent overrides when loading channels', async () => { + playlistsService.getPlaylist.mockReturnValue( + of({ + playlist: { + items: [PRIMARY_CHANNEL], + }, + referrer: 'https://portal.example/referrer', + userAgent: 'PlaylistAgent/1.0', + } as Playlist) + ); + + TestBed.inject(M3uWorkspaceRouteSession); + await flushEffects(); + + expect(electronApi.setUserAgent).toHaveBeenCalledWith( + 'PlaylistAgent/1.0', + 'https://portal.example/referrer' + ); + }); + + it('clears stale playlist-level user agent overrides when the playlist has none', async () => { + playlistsService.getPlaylist.mockReturnValue( + of({ + playlist: { + items: [PRIMARY_CHANNEL], + }, + } as Playlist) + ); + + TestBed.inject(M3uWorkspaceRouteSession); + await flushEffects(); + + expect(electronApi.setUserAgent).toHaveBeenCalledWith( + undefined, + undefined + ); + }); + it('ignores stale playlist responses after a newer route request wins', async () => { const firstResponse = new Subject(); const secondResponse = new Subject(); - playlistsService.getPlaylist.mockImplementation((playlistId: string) => { - return playlistId === PLAYLIST_ID - ? firstResponse.asObservable() - : secondResponse.asObservable(); - }); + playlistsService.getPlaylist.mockImplementation( + (playlistId: string) => { + return playlistId === PLAYLIST_ID + ? firstResponse.asObservable() + : secondResponse.asObservable(); + } + ); TestBed.inject(M3uWorkspaceRouteSession); await flushEffects(); diff --git a/libs/playlist/m3u/feature-player/src/lib/m3u-workspace-route-session.service.ts b/libs/playlist/m3u/feature-player/src/lib/m3u-workspace-route-session.service.ts index 7ae06bb14..1a7a284f7 100644 --- a/libs/playlist/m3u/feature-player/src/lib/m3u-workspace-route-session.service.ts +++ b/libs/playlist/m3u/feature-player/src/lib/m3u-workspace-route-session.service.ts @@ -85,7 +85,9 @@ export class M3uWorkspaceRouteSession { } const requestId = ++this.loadRequestId; - this.store.dispatch(ChannelActions.setChannelsLoading({ loading: true })); + this.store.dispatch( + ChannelActions.setChannelsLoading({ loading: true }) + ); try { const playlist = await firstValueFrom( @@ -96,9 +98,10 @@ export class M3uWorkspaceRouteSession { return; } - if (playlist.userAgent) { - window.electron?.setUserAgent(playlist.userAgent, 'localhost'); - } + window.electron?.setUserAgent( + playlist.userAgent, + playlist.referrer + ); this.store.dispatch( ChannelActions.setChannels({ @@ -126,7 +129,9 @@ export class M3uWorkspaceRouteSession { } } - private isLoadedSection(section: string | null): section is M3uLoadedSection { + private isLoadedSection( + section: string | null + ): section is M3uLoadedSection { return section === 'all' || section === 'groups'; } diff --git a/libs/ui/playback/src/lib/html-video-player/html-video-player.component.spec.ts b/libs/ui/playback/src/lib/html-video-player/html-video-player.component.spec.ts index 827089d59..a4d2f5f78 100644 --- a/libs/ui/playback/src/lib/html-video-player/html-video-player.component.spec.ts +++ b/libs/ui/playback/src/lib/html-video-player/html-video-player.component.spec.ts @@ -2,15 +2,19 @@ import { SimpleChange } from '@angular/core'; import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; import { TranslateModule } from '@ngx-translate/core'; import { DataService } from '@iptvnator/services'; +import { Channel } from '@iptvnator/shared/interfaces'; import { HtmlVideoPlayerComponent } from './html-video-player.component'; describe('HtmlVideoPlayerComponent', () => { let component: HtmlVideoPlayerComponent; let fixture: ComponentFixture; + const electronApi = { + setUserAgent: jest.fn().mockResolvedValue(true), + }; // eslint-disable-next-line @typescript-eslint/no-unused-vars let dataService: DataService; - const TEST_CHANNEL = { + const TEST_CHANNEL: Channel = { id: '1234', url: 'http://test.ts', name: 'Test channel', @@ -18,8 +22,18 @@ describe('HtmlVideoPlayerComponent', () => { title: 'News group', }, http: { + origin: '', + referrer: '', 'user-agent': 'localhost', }, + radio: 'false', + tvg: { + id: '', + logo: '', + name: '', + rec: '', + url: '', + }, }; beforeEach(waitForAsync(() => { @@ -34,12 +48,21 @@ describe('HtmlVideoPlayerComponent', () => { })); beforeEach(() => { + Object.defineProperty(window, 'electron', { + configurable: true, + value: electronApi, + }); + electronApi.setUserAgent.mockClear(); fixture = TestBed.createComponent(HtmlVideoPlayerComponent); component = fixture.componentInstance; dataService = TestBed.inject(DataService); fixture.detectChanges(); }); + afterEach(() => { + delete (window as unknown as { electron?: unknown }).electron; + }); + it('should create', () => { expect(component).toBeTruthy(); }); @@ -57,9 +80,57 @@ describe('HtmlVideoPlayerComponent', () => { expect(component.playChannel).toHaveBeenCalledWith(TEST_CHANNEL); }); + it('passes channel headers and stream URL to Electron header overrides', () => { + jest.spyOn( + component.videoPlayer.nativeElement, + 'play' + ).mockResolvedValue(undefined); + + component.playChannel({ + ...TEST_CHANNEL, + http: { + 'user-agent': 'ChannelAgent/1.0', + origin: '', + referrer: 'https://portal.example/referrer', + }, + radio: 'false', + url: 'https://stream.example/video.mp4', + }); + + expect(electronApi.setUserAgent).toHaveBeenCalledWith( + 'ChannelAgent/1.0', + 'https://portal.example/referrer', + 'https://stream.example/video.mp4' + ); + }); + + it('clears Electron header overrides for channels without custom headers', () => { + jest.spyOn( + component.videoPlayer.nativeElement, + 'play' + ).mockResolvedValue(undefined); + + component.playChannel({ + ...TEST_CHANNEL, + http: { + 'user-agent': '', + origin: '', + referrer: '', + }, + radio: 'false', + url: 'https://stream.example/video.mp4', + }); + + expect(electronApi.setUserAgent).toHaveBeenCalledWith( + '', + '', + 'https://stream.example/video.mp4' + ); + }); + it('emits a playback issue when the native video element reports an unsupported source', () => { const issues: unknown[] = []; - component.channel = TEST_CHANNEL as never; + component.channel = TEST_CHANNEL; component.playbackIssue.subscribe((issue) => issues.push(issue)); Object.defineProperty(component.videoPlayer.nativeElement, 'error', { diff --git a/libs/ui/playback/src/lib/html-video-player/html-video-player.component.ts b/libs/ui/playback/src/lib/html-video-player/html-video-player.component.ts index e32b84b9d..08558605f 100644 --- a/libs/ui/playback/src/lib/html-video-player/html-video-player.component.ts +++ b/libs/ui/playback/src/lib/html-video-player/html-video-player.component.ts @@ -150,13 +150,11 @@ export class HtmlVideoPlayerComponent implements OnInit, OnChanges, OnDestroy { const url = channel.url + (channel.epgParams ?? ''); const extension = getPlaybackMediaExtensionFromUrl(channel.url); - // Set user agent if specified on channel - if (channel.http?.['user-agent']) { - window.electron?.setUserAgent( - channel.http['user-agent'], - channel.http.referrer - ); - } + window.electron?.setUserAgent( + channel.http?.['user-agent'], + channel.http?.referrer, + channel.url + ); if ((extension === 'ts' || !extension) && mpegts.isSupported()) { debugHtmlPlayer(