fix(settings): fail closed when the Electron bridge lacks the parental lock worker filter

A new runtime capability requires the lock-state and index-stamping IPC.
When Electron reads Xtream through the SQLite worker without it (a
partial or older preload), the locked session withholds every category
instead of trusting a worker that never learned the lock state.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5.5 committed 2026-09-27 13:31:52 +02:00
1 parent e9afb56017
commit 9735ccf69a
5 files changed
+80 -5

No files matched your search

+6
View File
@@ -135,6 +135,12 @@ catalog is withheld until the PIN
is entered or the store reads again (`requestUnlock` and every lock write
retry the read first, and a write is refused while it still fails, since it
would be built on an empty in-memory store and wipe the persisted locks).
The same fail-closed mode applies while locked when Electron reads Xtream
through the SQLite worker (`supportsXtreamSqliteDataSource`) but the bridge
lacks the worker filter (`supportsParentalLockSqliteFilter`:
`setParentalLockState` and `dbSetCategoryLocks`, e.g. a partial or older
preload): the worker would never learn the lock state, so it cannot
withhold locked rows itself.
The window before the initial read settles is treated the same way
(`ParentalLockLockStore.readable` is false until then): settings can report
the feature as on before the locks are known — and the workspace route's
@@ -30,6 +30,10 @@ describe('ParentalLockService', () => {
let setCategoryLocks: jest.Mock;
let setParentalLockState: jest.Mock;
let updateBridgeSettings: jest.Mock;
let runtime: {
supportsXtreamSqliteDataSource: boolean;
supportsParentalLockSqliteFilter: boolean;
};
beforeAll(() => {
if (!globalThis.crypto?.subtle) {
@@ -69,6 +73,10 @@ describe('ParentalLockService', () => {
}),
};
prompt = { requestPin: jest.fn() };
runtime = {
supportsXtreamSqliteDataSource: true,
supportsParentalLockSqliteFilter: true,
};
setCategoryLocks = jest.fn(async () => true);
setParentalLockState = jest.fn(async () => undefined);
updateBridgeSettings = jest.fn(async () => undefined);
@@ -98,7 +106,7 @@ describe('ParentalLockService', () => {
{ provide: PARENTAL_LOCK_PROMPT, useValue: prompt },
{
provide: RuntimeCapabilitiesService,
useValue: { supportsXtreamSqliteDataSource: true },
useValue: runtime,
},
{ provide: DatabaseService, useValue: { setCategoryLocks } },
],
@@ -186,6 +194,24 @@ describe('ParentalLockService', () => {
expect(service.isM3uGroupLocked('p', 'XXX')).toBe(true);
});
it('withholds every category while locked when the worker filter bridge is missing', async () => {
jest.spyOn(console, 'error').mockImplementation(() => undefined);
runtime.supportsParentalLockSqliteFilter = false;
storage.pinHash = await hashParentalLockPin('1234');
parentalLockEnabled.set(true);
const service = await createService();
expect(service.active()).toBe(true);
expect(service.locksReadable()).toBe(true);
expect(service.withholdsEverything()).toBe(true);
expect(service.isXtreamCategoryLocked('p', 'live', 1)).toBe(true);
prompt.requestPin.mockResolvedValue('1234');
await expect(service.requestUnlock()).resolves.toBe(true);
expect(service.withholdsEverything()).toBe(false);
});
it('withholds everything while the initial lock store read is in flight', async () => {
storage.pinHash = await hashParentalLockPin('1234');
parentalLockEnabled.set(true);
@@ -15,6 +15,7 @@ import {
ParentalLockXtreamCategoryType,
verifyParentalLockPin,
} from '@iptvnator/shared/interfaces';
import { RuntimeCapabilitiesService } from '../runtime-capabilities.service';
import { SettingsStore } from '../settings-store.service';
import { ParentalLockIdleTimer } from './parental-lock-idle-timer';
import {
@@ -54,6 +55,23 @@ export class ParentalLockService {
private readonly storage = inject(ParentalLockStorageService);
private readonly locks = inject(ParentalLockLockStore);
private readonly prompt = inject(PARENTAL_LOCK_PROMPT, { optional: true });
/**
* Electron reads Xtream through the SQLite worker, but the bridge lacks
* the lock-state or index IPC (a partial or older preload): the worker
* cannot withhold locked rows, so the locked session withholds all.
*/
private readonly workerFilterMissing = (() => {
const runtime = inject(RuntimeCapabilitiesService);
const missing =
runtime.supportsXtreamSqliteDataSource &&
!runtime.supportsParentalLockSqliteFilter;
if (missing) {
console.error(
'The parental lock bridge is incomplete; locked sessions withhold every category.'
);
}
return missing;
})();
private readonly unlockedState = signal(false);
/** Shared by every unlock prompt: a reopened dialog keeps the cooldown. */
@@ -109,7 +127,9 @@ export class ParentalLockService {
* protected content precisely during a storage failure.
*/
readonly withholdsEverything = computed(
() => this.active() && !this.locks.readable()
() =>
this.active() &&
(this.workerFilterMissing || !this.locks.readable())
);
/** Bumps whenever `active` or the lock store changes; consumers re-query. */
readonly version = computed(
@@ -379,7 +399,7 @@ export class ParentalLockService {
): boolean {
return (
this.active() &&
(!this.locks.readable() ||
(this.withholdsEverything() ||
this.lockedXtreamIds(playlistId, categoryType).includes(
xtreamId
))
@@ -396,7 +416,7 @@ export class ParentalLockService {
}
// Fail-closed mode withholds rows without a genre too: "unknown
// genre" is not "no locked genre" while the locks are unknown.
if (!this.locks.readable()) {
if (this.withholdsEverything()) {
return true;
}
if (categoryId === null || categoryId === undefined) {
@@ -410,7 +430,7 @@ export class ParentalLockService {
isM3uGroupLocked(playlistId: string, groupTitle: string): boolean {
return (
this.active() &&
(!this.locks.readable() ||
(this.withholdsEverything() ||
this.lockedGroupTitles(playlistId).includes(groupTitle))
);
}
@@ -20,6 +20,16 @@ describe('RuntimeCapabilitiesService', () => {
expect(service.supportsPortalConnectivityGuard).toBe(false);
});
it('offers the parental lock worker filter only with both bridge methods', () => {
const service = new RuntimeCapabilitiesService();
testWindow.electron = { setParentalLockState: jest.fn() };
expect(service.supportsParentalLockSqliteFilter).toBe(false);
testWindow.electron['dbSetCategoryLocks'] = jest.fn();
expect(service.supportsParentalLockSqliteFilter).toBe(true);
testWindow.electron = undefined;
expect(service.supportsParentalLockSqliteFilter).toBe(false);
});
it('reports browser PWA capabilities when the Electron bridge is absent', () => {
testWindow.electron = undefined;
@@ -180,6 +180,19 @@ export class RuntimeCapabilitiesService {
].every((methodName) => this.hasElectronMethod(methodName));
}
/**
* The worker-side parental lock filter: the lock state reaches the
* SQLite worker and the `categories.locked` index can be stamped. When
* the Xtream SQLite data source is used without it, the worker cannot
* withhold locked rows and the lock service fails closed instead.
*/
get supportsParentalLockSqliteFilter(): boolean {
return (
this.hasElectronMethod('setParentalLockState') &&
this.hasElectronMethod('dbSetCategoryLocks')
);
}
get supportsXtreamSqliteDataSource(): boolean {
return [
'dbGetPlaylist',