diff --git a/docs/architecture/parental-lock.md b/docs/architecture/parental-lock.md index d1621a26a..594f30dea 100644 --- a/docs/architecture/parental-lock.md +++ b/docs/architecture/parental-lock.md @@ -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 diff --git a/libs/services/src/lib/parental-lock/parental-lock.service.spec.ts b/libs/services/src/lib/parental-lock/parental-lock.service.spec.ts index 019c35da1..a3406ed5f 100644 --- a/libs/services/src/lib/parental-lock/parental-lock.service.spec.ts +++ b/libs/services/src/lib/parental-lock/parental-lock.service.spec.ts @@ -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); diff --git a/libs/services/src/lib/parental-lock/parental-lock.service.ts b/libs/services/src/lib/parental-lock/parental-lock.service.ts index 91db0aa0c..e57bf55b5 100644 --- a/libs/services/src/lib/parental-lock/parental-lock.service.ts +++ b/libs/services/src/lib/parental-lock/parental-lock.service.ts @@ -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)) ); } diff --git a/libs/services/src/lib/runtime-capabilities.service.spec.ts b/libs/services/src/lib/runtime-capabilities.service.spec.ts index 27dcea69e..168221aea 100644 --- a/libs/services/src/lib/runtime-capabilities.service.spec.ts +++ b/libs/services/src/lib/runtime-capabilities.service.spec.ts @@ -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; diff --git a/libs/services/src/lib/runtime-capabilities.service.ts b/libs/services/src/lib/runtime-capabilities.service.ts index 1acd82405..6efda14e1 100644 --- a/libs/services/src/lib/runtime-capabilities.service.ts +++ b/libs/services/src/lib/runtime-capabilities.service.ts @@ -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',