fix(electron): preserve playlist header defaults

Keep playlist-level request header overrides active when channel-scoped headers are cleared, and cover layered override behavior.
This commit is contained in:
4gray committed 2026-05-23 15:57:29 +03:00
1 parent fc449ca73e
commit 3c5f7ab92e
7 files changed
+146 -35

No files matched your search

@@ -57,6 +57,9 @@ describe('Electron app security helpers', () => {
expect(
isTrustedRendererNavigationUrl('http://127.0.0.1:4200/home', true)
).toBe(true);
expect(
isTrustedRendererNavigationUrl('http://[::1]:4200/home', true)
).toBe(true);
expect(
isTrustedRendererNavigationUrl('http://localhost:4300/home', true)
).toBe(false);
+7 -1
View File
@@ -9,6 +9,12 @@ import {
import { store, WINDOW_BOUNDS } from './services/store.service';
const externalBrowserProtocols = new Set(['http:', 'https:']);
const trustedDevRendererHosts = new Set([
'localhost',
'127.0.0.1',
'[::1]',
'::1',
]);
function parseUrl(url: string): URL | null {
try {
@@ -45,7 +51,7 @@ export function isTrustedRendererNavigationUrl(
return (
parsedUrl.protocol === 'http:' &&
['localhost', '127.0.0.1', '[::1]'].includes(parsedUrl.hostname) &&
trustedDevRendererHosts.has(parsedUrl.hostname) &&
parsedUrl.port === String(rendererAppPort)
);
}
@@ -1,8 +1,5 @@
import { ipcMain } from 'electron';
import {
clearRequestHeaderOverride,
configureRequestHeaderOverride,
} from '../services/request-header-overrides.service';
import { configureRequestHeaderOverride } from '../services/request-header-overrides.service';
export default class SharedEvents {
static bootstrapSharedEvents(): Electron.IpcMain {
@@ -26,10 +23,5 @@ export function setUserAgent(
referer?: string | null,
scopeUrl?: string | null
): void {
if (!userAgent?.trim() && !referer?.trim()) {
clearRequestHeaderOverride();
return;
}
configureRequestHeaderOverride(userAgent, referer, scopeUrl);
}
@@ -111,10 +111,97 @@ describe('request header overrides', () => {
expect(headers).toEqual({ Accept: '*/*' });
});
it('applies playlist-level headers broadly when no stream scope is provided', async () => {
const { configureRequestHeaderOverride } =
await import('./request-header-overrides.service');
configureRequestHeaderOverride(
'PlaylistAgent/1.0',
'https://portal.example/referrer'
);
const listener = mockOnBeforeSendHeaders.mock.calls[0][1];
const headers = runHeaderListener(
listener,
'https://cdn.example/segment.ts'
);
expect(headers).toEqual({
Origin: 'https://portal.example',
Referer: 'https://portal.example/referrer',
'User-Agent': 'PlaylistAgent/1.0',
});
});
it('keeps playlist headers when a channel without headers clears scoped overrides', async () => {
const { configureRequestHeaderOverride } =
await import('./request-header-overrides.service');
configureRequestHeaderOverride(
'PlaylistAgent/1.0',
'https://portal.example/referrer'
);
configureRequestHeaderOverride(
'ChannelAgent/2.0',
'https://channel.example/referrer',
'https://stream.example/live.m3u8'
);
configureRequestHeaderOverride(
null,
null,
'https://stream.example/next.m3u8'
);
expect(mockOnBeforeSendHeaders).toHaveBeenCalledTimes(1);
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': 'PlaylistAgent/1.0',
});
});
it('layers scoped channel headers over playlist defaults', async () => {
const { configureRequestHeaderOverride } =
await import('./request-header-overrides.service');
configureRequestHeaderOverride(
'PlaylistAgent/1.0',
'https://portal.example/referrer'
);
configureRequestHeaderOverride(
'ChannelAgent/2.0',
null,
'https://stream.example/live.m3u8'
);
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': 'ChannelAgent/2.0',
});
});
it('clears active header overrides without registering another listener', async () => {
const { clearRequestHeaderOverride, configureRequestHeaderOverride } =
await import('./request-header-overrides.service');
configureRequestHeaderOverride(
'PlaylistAgent/1.0',
'https://portal.example/referrer'
);
configureRequestHeaderOverride(
'ScopedAgent/1.0',
'https://portal.example/referrer',
@@ -3,7 +3,7 @@ import { session } from 'electron';
type HeaderOverride = {
origin?: string;
referer?: string;
scopeOrigins: Set<string>;
scopeOrigins?: Set<string>;
userAgent?: string;
};
@@ -12,6 +12,7 @@ const headerOverrideUrlFilter = {
};
let activeHeaderOverride: HeaderOverride | null = null;
let activeScopedHeaderOverride: HeaderOverride | null = null;
let listenerRegistered = false;
function normalizeHeaderValue(value?: string | null): string | undefined {
@@ -34,7 +35,7 @@ function getOrigin(value?: string | null): string | undefined {
}
function shouldApplyOverride(url: string, override: HeaderOverride): boolean {
if (override.scopeOrigins.size === 0) {
if (!override.scopeOrigins) {
return true;
}
@@ -64,23 +65,28 @@ function handleBeforeSendHeaders(
callback: (beforeSendResponse: Electron.BeforeSendResponse) => void
): void {
const requestHeaders = { ...details.requestHeaders };
const override = activeHeaderOverride;
const overrides = [activeHeaderOverride, activeScopedHeaderOverride].filter(
(override): override is HeaderOverride =>
Boolean(override && shouldApplyOverride(details.url, override))
);
if (!override || !shouldApplyOverride(details.url, override)) {
if (overrides.length === 0) {
callback({ requestHeaders });
return;
}
if (override.userAgent) {
setRequestHeader(requestHeaders, 'User-Agent', override.userAgent);
}
for (const override of overrides) {
if (override.userAgent) {
setRequestHeader(requestHeaders, 'User-Agent', override.userAgent);
}
if (override.referer) {
setRequestHeader(requestHeaders, 'Referer', override.referer);
}
if (override.referer) {
setRequestHeader(requestHeaders, 'Referer', override.referer);
}
if (override.origin) {
setRequestHeader(requestHeaders, 'Origin', override.origin);
if (override.origin) {
setRequestHeader(requestHeaders, 'Origin', override.origin);
}
}
callback({ requestHeaders });
@@ -105,29 +111,44 @@ export function configureRequestHeaderOverride(
): void {
const normalizedUserAgent = normalizeHeaderValue(userAgent);
const normalizedReferer = normalizeHeaderValue(referer);
const isScopedOverride = scopeUrl !== undefined && scopeUrl !== null;
if (!normalizedUserAgent && !normalizedReferer) {
clearRequestHeaderOverride();
if (isScopedOverride) {
clearScopedRequestHeaderOverride();
} else {
clearRequestHeaderOverride();
}
return;
}
const refererOrigin = getOrigin(normalizedReferer);
const scopeOrigin = getOrigin(scopeUrl);
const scopeOrigins = new Set(
[scopeOrigin, refererOrigin].filter((origin): origin is string =>
Boolean(origin)
)
);
activeHeaderOverride = {
const override: HeaderOverride = {
origin: refererOrigin,
referer: normalizedReferer,
scopeOrigins,
userAgent: normalizedUserAgent,
};
if (isScopedOverride) {
override.scopeOrigins = new Set(
[scopeOrigin, refererOrigin].filter((origin): origin is string =>
Boolean(origin)
)
);
activeScopedHeaderOverride = override;
} else {
activeHeaderOverride = override;
}
ensureHeaderOverrideListener();
}
export function clearRequestHeaderOverride(): void {
activeHeaderOverride = null;
activeScopedHeaderOverride = null;
}
function clearScopedRequestHeaderOverride(): void {
activeScopedHeaderOverride = null;
}
+4 -2
View File
@@ -58,12 +58,14 @@ 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 and updates layered in-memory overrides instead of stacking a new
listener for every channel change.
Rules:
- empty `userAgent` and empty `referer` clear the active override
- empty playlist-level `userAgent` and `referer` clear all active overrides
- empty channel-level `userAgent` and `referer` with a `scopeUrl` clear only
the scoped channel override, preserving playlist-level defaults
- 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
+2 -2
View File
@@ -161,8 +161,8 @@ export class PlaylistEffects {
}
window.electron?.setUserAgent(
channel.http['user-agent'],
channel.http.referrer,
channel.http?.['user-agent'],
channel.http?.referrer,
channel.url
);