feat(portals): make connection cooldown configurable in desktop settings (#1536)

This commit is contained in:
4gray authored and GitHub committed 2026-09-05 11:18:05 +02:00
1 parent de81e3b238
commit 0245d73d78
48 files changed
+787 -27

No files matched your search

@@ -28,6 +28,7 @@ const STORE_KEYS = {
MPV_PLAYER_ARGUMENTS: 'MPV_PLAYER_ARGUMENTS',
MPV_REUSE_INSTANCE: 'MPV_REUSE_INSTANCE',
STARTUP_WINDOW_MODE: 'STARTUP_WINDOW_MODE',
PORTAL_CONNECTIVITY_GUARD: 'PORTAL_CONNECTIVITY_GUARD',
VLC_PLAYER_ARGUMENTS: 'VLC_PLAYER_ARGUMENTS',
VLC_REUSE_INSTANCE: 'VLC_REUSE_INSTANCE',
} as const;
@@ -55,6 +56,7 @@ jest.mock('../services/store.service', () => ({
MPV_PLAYER_ARGUMENTS: STORE_KEYS.MPV_PLAYER_ARGUMENTS,
MPV_REUSE_INSTANCE: STORE_KEYS.MPV_REUSE_INSTANCE,
STARTUP_WINDOW_MODE: STORE_KEYS.STARTUP_WINDOW_MODE,
PORTAL_CONNECTIVITY_GUARD: STORE_KEYS.PORTAL_CONNECTIVITY_GUARD,
VLC_PLAYER_ARGUMENTS: STORE_KEYS.VLC_PLAYER_ARGUMENTS,
VLC_REUSE_INSTANCE: STORE_KEYS.VLC_REUSE_INSTANCE,
store: {
@@ -101,6 +103,37 @@ describe('SETTINGS_UPDATE', () => {
consoleLogSpy.mockRestore();
});
it('restores the persisted preference before the renderer starts', async () => {
mockStoreGet.mockReturnValue(false);
const settingsEvents = (await import('./settings.events')).default;
settingsEvents.bootstrapSettingsEvents();
const guard = await import('../util/host-connectivity-guard');
expect(
guard.beginGuardedHostRequest('https://portal.example')
).toBeNull();
settingsUpdateHandler({}, { portalConnectivityGuard: true });
expect(
guard.beginGuardedHostRequest('https://portal.example')
).not.toBeNull();
expect(mockStoreSet).toHaveBeenCalledWith(
STORE_KEYS.PORTAL_CONNECTIVITY_GUARD,
true
);
});
it('applies an opt-out immediately and preserves it through unrelated saves', async () => {
const guard = await import('../util/host-connectivity-guard');
settingsUpdateHandler({}, { portalConnectivityGuard: false });
settingsUpdateHandler({}, { showCaptions: true });
expect(
guard.beginGuardedHostRequest('https://portal.example')
).toBeNull();
expect(mockStoreSet).toHaveBeenCalledWith(
STORE_KEYS.PORTAL_CONNECTIVITY_GUARD,
false
);
});
it('normalizes external-player arguments and preserves explicit false reuse settings', () => {
settingsUpdateHandler(
{},
@@ -12,14 +12,19 @@ import {
MPV_PLAYER_ARGUMENTS,
MPV_REUSE_INSTANCE,
STARTUP_WINDOW_MODE,
PORTAL_CONNECTIVITY_GUARD,
store,
VLC_PLAYER_ARGUMENTS,
VLC_REUSE_INSTANCE,
} from '../services/store.service';
import { httpServer } from '../server/http-server';
import { setHostConnectivityGuardEnabled } from '../util/host-connectivity-guard';
export default class SettingsEvents {
static bootstrapSettingsEvents(): Electron.IpcMain {
setHostConnectivityGuardEnabled(
store.get(PORTAL_CONNECTIVITY_GUARD, true) !== false
);
return ipcMain;
}
}
@@ -30,6 +35,12 @@ ipcMain.handle('SETTINGS_UPDATE', (_event, arg) => {
redactSensitiveData(arg)
);
if (arg.portalConnectivityGuard !== undefined) {
const enabled = arg.portalConnectivityGuard !== false;
store.set(PORTAL_CONNECTIVITY_GUARD, enabled);
setHostConnectivityGuardEnabled(enabled);
}
if (arg.mpvPlayerArguments !== undefined) {
store.set(
MPV_PLAYER_ARGUMENTS,
@@ -25,6 +25,9 @@ export const EMBEDDED_MPV_FRAME_COPY = 'EMBEDDED_MPV_FRAME_COPY';
*/
export const STARTUP_WINDOW_MODE = 'STARTUP_WINDOW_MODE';
/** Desktop portal request cooldown; absent means enabled. */
export const PORTAL_CONNECTIVITY_GUARD = 'PORTAL_CONNECTIVITY_GUARD';
/**
* Extra libmpv options for embedded sessions, one "key=value" per line, as
* typed in Settings > Playback. Mirrored here by the SETTINGS_UPDATE handler
@@ -52,6 +55,7 @@ export type StoreType = {
[EMBEDDED_MPV_EXTRA_OPTIONS]: string;
[EMBEDDED_MPV_AUTO_RECONNECT]: boolean;
[STARTUP_WINDOW_MODE]: StartupWindowMode;
[PORTAL_CONNECTIVITY_GUARD]: boolean;
};
// Export singleton store instance
@@ -9,6 +9,8 @@ import {
beginGuardedHostRequest,
reportGuardedHostFailure,
resetHostConnectivityGuardForTests,
setHostConnectivityGuardEnabled,
reportGuardedHostSuccess,
} from './host-connectivity-guard';
describe('reportGuardedHostFailure', () => {
@@ -49,6 +51,62 @@ describe('reportGuardedHostFailure', () => {
resetHostConnectivityGuardForTests();
});
it('bypasses an open guard when disabled and starts fresh when re-enabled', () => {
attempt(ownFailure());
attempt(ownFailure());
expect(() => beginGuardedHostRequest(URL_ON_ENDPOINT)).toThrow(
HostConnectivityGuardError
);
setHostConnectivityGuardEnabled(false);
expect(beginGuardedHostRequest(URL_ON_ENDPOINT)).toBeNull();
setHostConnectivityGuardEnabled(true);
expect(() => beginGuardedHostRequest(URL_ON_ENDPOINT)).not.toThrow();
attempt(ownFailure());
expect(() => beginGuardedHostRequest(URL_ON_ENDPOINT)).not.toThrow();
});
it('does not reset the guard when saving an unchanged preference', () => {
attempt(ownFailure());
attempt(ownFailure());
setHostConnectivityGuardEnabled(true);
expect(() => beginGuardedHostRequest(URL_ON_ENDPOINT)).toThrow(
HostConnectivityGuardError
);
});
it('ignores old failures and successes after a preference transition', () => {
const oldFailure = beginGuardedHostRequest(URL_ON_ENDPOINT);
const oldSuccess = beginGuardedHostRequest(URL_ON_ENDPOINT);
setHostConnectivityGuardEnabled(false);
setHostConnectivityGuardEnabled(true);
reportGuardedHostFailure(oldFailure, ownFailure());
reportGuardedHostFailure(oldFailure, ownFailure());
expect(() => beginGuardedHostRequest(URL_ON_ENDPOINT)).not.toThrow();
attempt(ownFailure());
attempt(ownFailure());
reportGuardedHostSuccess(oldSuccess);
expect(() => beginGuardedHostRequest(URL_ON_ENDPOINT)).toThrow(
HostConnectivityGuardError
);
});
it('keeps the environment disable switch authoritative', () => {
const original = process.env.IPTVNATOR_DISABLE_CONNECTIVITY_GUARD;
process.env.IPTVNATOR_DISABLE_CONNECTIVITY_GUARD = '1';
try {
setHostConnectivityGuardEnabled(true);
attempt(ownFailure());
attempt(ownFailure());
expect(() =>
beginGuardedHostRequest(URL_ON_ENDPOINT)
).not.toThrow();
} finally {
if (original === undefined)
delete process.env.IPTVNATOR_DISABLE_CONNECTIVITY_GUARD;
else process.env.IPTVNATOR_DISABLE_CONNECTIVITY_GUARD = original;
}
});
it('does not charge a same-origin redirect hop to the guarded endpoint', () => {
// `/player_api.php` -> 302 -> `/slow/player_api.php` on the same origin:
// the origin answered, so its record must clear even though the failing
@@ -22,6 +22,16 @@ export { HostConnectivityGuardError };
export type { HostRequestToken };
let sharedGuard: HostConnectivityGuard | null = null;
let enabled = true;
let currentTokens = new WeakSet<HostRequestToken>();
/** Apply a saved desktop preference without letting old requests affect a new guard. */
export function setHostConnectivityGuardEnabled(value: boolean): void {
if (enabled === value) return;
enabled = value;
sharedGuard = null;
currentTokens = new WeakSet<HostRequestToken>();
}
/** The guard both portal IPC handlers share. */
export function getHostConnectivityGuard(): HostConnectivityGuard {
@@ -40,6 +50,8 @@ export function getHostConnectivityGuard(): HostConnectivityGuard {
/** Test seam: forgets the shared guard so each spec starts clean. */
export function resetHostConnectivityGuardForTests(): void {
enabled = true;
currentTokens = new WeakSet<HostRequestToken>();
sharedGuard = null;
}
@@ -53,7 +65,7 @@ export function resetHostConnectivityGuardForTests(): void {
*/
export function beginGuardedHostRequest(url: string): HostRequestToken | null {
const endpoint = portalEndpointKeyOf(url);
if (!endpoint) {
if (!endpoint || !enabled) {
return null;
}
@@ -62,6 +74,7 @@ export function beginGuardedHostRequest(url: string): HostRequestToken | null {
throw new HostConnectivityGuardError(endpoint);
}
currentTokens.add(check.token);
return check.token;
}
@@ -70,11 +83,14 @@ export function observeGuardedHostRequest(
url: string
): HostRequestToken | null {
const endpoint = portalEndpointKeyOf(url);
return endpoint ? getHostConnectivityGuard().observe(endpoint) : null;
if (!endpoint || !enabled) return null;
const token = getHostConnectivityGuard().observe(endpoint);
currentTokens.add(token);
return token;
}
export function reportGuardedHostSuccess(token: HostRequestToken | null): void {
if (token) {
if (token && currentTokens.has(token)) {
getHostConnectivityGuard().reportSuccess(token);
}
}
@@ -91,7 +107,7 @@ export function reportGuardedHostFailure(
error: unknown,
options: { countFailures?: boolean; requestUrl?: string } = {}
): void {
if (!token) {
if (!token || !currentTokens.has(token)) {
return;
}