From bfdfe18a9c880efd3d36c40c8d7637b62533a537 Mon Sep 17 00:00:00 2001 From: 4gray Date: Sat, 26 Sep 2026 07:36:23 +0200 Subject: [PATCH] fix(settings): withhold Electron Xtream reads while locks are unknown, persist the switch when settings are unreadable, re-stamp after a recovered read - ElectronXtreamDataSource serves no categories, content or search hits while the lock store withholds everything; its SQLite index may still carry a stale stamp. - setupPin decides whether to persist the switch from the settings value before the PIN is stored, since enabled follows hasPin while the switch is unknown. - A lock store recovered by a later read marks its playlists stale so the index is re-derived, a persisted entry must carry all three lists, and a stale Stalker search page is dropped before touching the withheld-id bookkeeping. Co-Authored-By: Claude Fable 5.1 --- docs/architecture/parental-lock.md | 15 ++++--- .../stalker-search.component.ts | 11 +++-- .../electron-xtream-data-source.spec.ts | 38 ++++++++++++++++++ .../electron-xtream-data-source.ts | 18 +++++++++ .../parental-lock-lock-store.service.spec.ts | 12 ++++++ .../parental-lock-lock-store.service.ts | 6 +++ .../parental-lock-storage.spec.ts | 17 ++++++-- .../parental-lock.service.spec.ts | 15 +++++++ .../parental-lock/parental-lock.service.ts | 8 +++- .../src/lib/parental-lock.util.spec.ts | 40 +++++++++++++++---- .../interfaces/src/lib/parental-lock.util.ts | 10 ++--- 11 files changed, 163 insertions(+), 27 deletions(-) diff --git a/docs/architecture/parental-lock.md b/docs/architecture/parental-lock.md index 3fd1a3e69..4aba07f10 100644 --- a/docs/architecture/parental-lock.md +++ b/docs/architecture/parental-lock.md @@ -112,13 +112,15 @@ same way: `ParentalLockStorageService.readLocks()` reports a failed read as `null` (distinct from an absent store, `{}`; Electron reads through `DatabaseService.readAppState`, which keeps a rejected IPC apart from a missing key, and a stored payload that does not parse or does not have the -shape `writeLocks` produces — `isWellFormedParentalLockStore`, down to the -nested entries — is a failed read too — corruption never becomes an empty +shape `writeLocks` produces — `isWellFormedParentalLockStore`, all three +lists present, down to the nested entries — is a failed read too — corruption never becomes an empty store), and while the lock is active with the store unreadable `ParentalLockService.withholdsEverything` is true — every `is*Locked` predicate answers true and the set-based filters (PWA Xtream, Stalker content and search, the M3U channel list) receive -`ALL_CATEGORIES_WITHHELD` — under which a row WITHOUT a genre is withheld +`ALL_CATEGORIES_WITHHELD`, and `ElectronXtreamDataSource` serves no +categories, content or search hits either, since its SQLite index may still +carry a stale stamp — under which a row WITHOUT a genre is withheld too (`isStalkerItemWithheld`, `isStalkerCategoryLocked`), since "no genre" must not be the one row a withheld catalog still shows — so the whole catalog is withheld until the PIN @@ -130,7 +132,9 @@ The window before the initial read settles is treated the same way the feature as on before the locks are known. The feature switch itself is persisted through one guarded path (`persistEnabled`): `updateSettings` patches memory before it writes, so a failed write is undone in memory and -`setupPin`/`disable` report false; the Electron mirror write is awaited +`setupPin`/`disable` report false (whether to persist is decided from the +settings switch BEFORE the PIN is stored, since `enabled` follows `hasPin` +while the switch is unknown); the Electron mirror write is awaited next, and a mirror that cannot be written undoes the settings write the same way — the toggle never shows a state the next launch will not have, on either side. @@ -278,7 +282,8 @@ on either side. write that removes a playlist's LAST lock clears the index first and drops the key afterwards (the reconcile finds playlists only through their key; an interruption then leaves store-with-lock and index-without, - which the next reconcile repairs toward locked) — awaited inside + which the next reconcile repairs toward locked; a store recovered by a + later successful read marks its playlists stale the same way) — awaited inside the store's `load()`, so `readable` (and with it every catalog read the renderer gates) stays false until the index agrees with the store; a re-stamp that keeps failing keeps the session fail-closed. diff --git a/libs/portal/stalker/feature/src/lib/stalker-search/stalker-search.component.ts b/libs/portal/stalker/feature/src/lib/stalker-search/stalker-search.component.ts index daec451be..83dcc9e9d 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-search/stalker-search.component.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-search/stalker-search.component.ts @@ -378,6 +378,13 @@ export class StalkerSearchComponent { contentType, withheldCategoryIds ); + // Before the withheld-id bookkeeping: a stale page must not + // pre-record ids into a set a newer relock request cleared, + // or that request's page counts no new withheld rows and + // stops paging short of later visible matches. + if (!isCurrent()) { + return items; + } let newWithheldCount = 0; if (items.length < rawItems.length) { const kept = new Set(items); @@ -393,10 +400,6 @@ export class StalkerSearchComponent { } } - if (!isCurrent()) { - return items; - } - const merged = this.applySearchPageSuccess( params.page, items, diff --git a/libs/portal/xtream/data-access/src/lib/data-sources/electron-xtream-data-source.spec.ts b/libs/portal/xtream/data-access/src/lib/data-sources/electron-xtream-data-source.spec.ts index 67386d7ba..3dde236be 100644 --- a/libs/portal/xtream/data-access/src/lib/data-sources/electron-xtream-data-source.spec.ts +++ b/libs/portal/xtream/data-access/src/lib/data-sources/electron-xtream-data-source.spec.ts @@ -1,3 +1,5 @@ +import { TestBed } from '@angular/core/testing'; +import { ParentalLockService } from '@iptvnator/services'; import { credentials, ElectronXtreamDataSourceHarness, @@ -34,6 +36,42 @@ describe('ElectronXtreamDataSource (DB-first strategy)', () => { harness = setupElectronXtreamDataSource(); }); + describe('withholding everything', () => { + it('serves no categories, content or search hits while the lock store withholds everything', async () => { + const parentalLock = TestBed.inject(ParentalLockService); + Object.defineProperty(parentalLock, 'withholdsEverything', { + configurable: true, + value: () => true, + }); + harness.dbService.getXtreamImportStatus.mockResolvedValue( + 'completed' + ); + harness.dbService.getXtreamCategories.mockResolvedValue([ + dbCategory, + ]); + harness.dbService.getXtreamContent.mockResolvedValue([ + dbContentItem, + ]); + + await expect( + harness.dataSource.getCategories( + playlistId, + credentials, + 'live' + ) + ).resolves.toEqual([]); + await expect( + harness.dataSource.getContent(playlistId, credentials, 'live') + ).resolves.toEqual([]); + await expect( + harness.dataSource.searchContent(playlistId, 'news', ['live']) + ).resolves.toEqual([]); + expect( + harness.dbService.getXtreamCategories + ).not.toHaveBeenCalled(); + }); + }); + describe('getCategories', () => { it('returns cached categories without calling the API when import is completed', async () => { harness.dbService.getXtreamImportStatus.mockResolvedValue( diff --git a/libs/portal/xtream/data-access/src/lib/data-sources/electron-xtream-data-source.ts b/libs/portal/xtream/data-access/src/lib/data-sources/electron-xtream-data-source.ts index 80ccac605..65fe37919 100644 --- a/libs/portal/xtream/data-access/src/lib/data-sources/electron-xtream-data-source.ts +++ b/libs/portal/xtream/data-access/src/lib/data-sources/electron-xtream-data-source.ts @@ -149,6 +149,12 @@ export class ElectronXtreamDataSource implements IXtreamDataSource { type: CategoryType, options?: XtreamOperationOptions ): Promise { + // Fail closed with the renderer: while the lock store is loading, + // unreadable or its SQLite index not yet reconciled, the index may + // still carry a stale `locked = false` stamp, so nothing is served. + if (this.parentalLock.withholdsEverything?.()) { + return []; + } const dbType = mapCategoryTypeToDbType(type); // The lock version keys the share: a read issued under an older lock // state (still unlocked, or locks since edited) answers with rows the @@ -307,6 +313,12 @@ export class ElectronXtreamDataSource implements IXtreamDataSource { onTotal?: (total: number) => void, options?: XtreamOperationOptions ): Promise { + // Fail closed with the renderer: while the lock store is loading, + // unreadable or its SQLite index not yet reconciled, the index may + // still carry a stale `locked = false` stamp, so nothing is served. + if (this.parentalLock.withholdsEverything?.()) { + return []; + } const requestKey = `${playlistId}:${type}:${this.parentalLock.version()}`; const inFlightRequest = this.contentRequests.get(requestKey); @@ -424,6 +436,12 @@ export class ElectronXtreamDataSource implements IXtreamDataSource { types: string[], excludeHidden?: boolean ): Promise { + // Fail closed with the renderer: while the lock store is loading, + // unreadable or its SQLite index not yet reconciled, the index may + // still carry a stale `locked = false` stamp, so nothing is served. + if (this.parentalLock.withholdsEverything?.()) { + return []; + } return this.dbService.searchXtreamContent( playlistId, searchTerm, diff --git a/libs/services/src/lib/parental-lock/parental-lock-lock-store.service.spec.ts b/libs/services/src/lib/parental-lock/parental-lock-lock-store.service.spec.ts index a2096cd79..e146c7d58 100644 --- a/libs/services/src/lib/parental-lock/parental-lock-lock-store.service.spec.ts +++ b/libs/services/src/lib/parental-lock/parental-lock-lock-store.service.spec.ts @@ -80,6 +80,18 @@ describe('ParentalLockLockStore', () => { expect(store.readable()).toBe(true); }); + it('re-derives the index from a store recovered after a failed read', async () => { + storage.readLocks.mockResolvedValueOnce(null); + await store.load(); + expect(store.readable()).toBe(false); + expect(setCategoryLocks).not.toHaveBeenCalled(); + + await expect(store.ensureReadable()).resolves.toBe(true); + + expect(setCategoryLocks).toHaveBeenCalledWith('pl-1', 'live', [7]); + expect(store.readable()).toBe(true); + }); + it('re-derives the SQLite index from the store on load', async () => { await store.load(); // ensureReadable awaits the reconcile that load() started. diff --git a/libs/services/src/lib/parental-lock/parental-lock-lock-store.service.ts b/libs/services/src/lib/parental-lock/parental-lock-lock-store.service.ts index a5712d361..c10cd0e8f 100644 --- a/libs/services/src/lib/parental-lock/parental-lock-lock-store.service.ts +++ b/libs/services/src/lib/parental-lock/parental-lock-lock-store.service.ts @@ -116,6 +116,12 @@ export class ParentalLockLockStore { } this.locks.set(locks); this.unreadable.set(false); + // The index may carry stamps from a write that failed before + // the read did; re-derive it from the recovered store as load() + // does. + for (const playlistId of Object.keys(locks)) { + this.markIndexStale(playlistId); + } this.revisionState.update((value) => value + 1); } await this.reconcileXtreamIndex(); diff --git a/libs/services/src/lib/parental-lock/parental-lock-storage.spec.ts b/libs/services/src/lib/parental-lock/parental-lock-storage.spec.ts index 798c5b01b..a7e2c7bcf 100644 --- a/libs/services/src/lib/parental-lock/parental-lock-storage.spec.ts +++ b/libs/services/src/lib/parental-lock/parental-lock-storage.spec.ts @@ -35,7 +35,9 @@ describe('ParentalLockStorageService.readLocks', () => { it('normalizes a stored store', async () => { localStorage.setItem( PARENTAL_LOCK_STORE_KEY, - JSON.stringify({ p: { m3u: ['Adult'], junk: 1 } }) + JSON.stringify({ + p: { xtream: [], stalker: [], m3u: ['Adult'], junk: 1 }, + }) ); await expect(service.readLocks()).resolves.toEqual({ p: { xtream: [], stalker: [], m3u: ['Adult'] }, @@ -46,11 +48,18 @@ describe('ParentalLockStorageService.readLocks', () => { ['truncated JSON', '{"p":{"m3u":["Adu'], ['an array payload', '[]'], ['a scalar payload', '"locks"'], - ['a corrupt nested list', '{"p":{"xtream":"corrupt"}}'], - ['a corrupt nested entry', '{"p":{"m3u":[1]}}'], + [ + 'a corrupt nested list', + '{"p":{"xtream":"corrupt","stalker":[],"m3u":[]}}', + ], + [ + 'a corrupt nested entry', + '{"p":{"xtream":[],"stalker":[],"m3u":[1]}}', + ], + ['a playlist entry missing a list', '{"p":{}}'], [ 'an entry the normalizer would drop', - '{"p":{"xtream":[{"categoryType":"live","xtreamId":"nope"}]}}', + '{"p":{"xtream":[{"categoryType":"live","xtreamId":"nope"}],"stalker":[],"m3u":[]}}', ], ])('treats %s as a failed read, not an empty store', async (_, raw) => { localStorage.setItem(PARENTAL_LOCK_STORE_KEY, raw); 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 bdb497843..383eb4818 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 @@ -226,6 +226,21 @@ describe('ParentalLockService', () => { expect(updateBridgeSettings).not.toHaveBeenCalled(); }); + it('still persists the switch when settings could not be read', async () => { + storageFailure.set('load'); + prompt.requestPin.mockResolvedValue('1234'); + const service = await createService(); + + await expect(service.setupPin()).resolves.toBe(true); + + expect(updateSettings).toHaveBeenCalledWith({ + parentalLockEnabled: true, + }); + expect(updateBridgeSettings).toHaveBeenCalledWith({ + parentalLockEnabled: true, + }); + }); + it('keeps the session locked on a failed PIN read and retries before unlocking', 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 ab82a7bf5..f33c0dd0e 100644 --- a/libs/services/src/lib/parental-lock/parental-lock.service.ts +++ b/libs/services/src/lib/parental-lock/parental-lock.service.ts @@ -238,6 +238,12 @@ export class ParentalLockService { if (!this.prompt) { return false; } + // Decided BEFORE the PIN is stored: with the settings switch + // unknown, `enabled` follows `hasPin`, and would read as on the + // moment the PIN lands — skipping the persistence the next launch + // depends on. + const needsPersist = + this.settingsStore.parentalLockEnabled?.() !== true; const pin = await this.prompt.requestPin({ mode: 'set' }); if (pin === null) { return false; @@ -246,7 +252,7 @@ export class ParentalLockService { return false; } this.unlockedState.set(true); - if (!this.enabled() && !(await this.persistEnabled(true))) { + if (needsPersist && !(await this.persistEnabled(true))) { this.unlockedState.set(false); return false; } diff --git a/libs/shared/interfaces/src/lib/parental-lock.util.spec.ts b/libs/shared/interfaces/src/lib/parental-lock.util.spec.ts index 5fbcec576..14276fbdb 100644 --- a/libs/shared/interfaces/src/lib/parental-lock.util.spec.ts +++ b/libs/shared/interfaces/src/lib/parental-lock.util.spec.ts @@ -113,7 +113,7 @@ describe('parental-lock.util', () => { }); describe('isWellFormedParentalLockStore', () => { - it('accepts what writeLocks produces, lists omitted or empty', () => { + it('accepts what writeLocks produces, lists present even when empty', () => { expect(isWellFormedParentalLockStore({})).toBe(true); expect( isWellFormedParentalLockStore({ @@ -122,7 +122,7 @@ describe('isWellFormedParentalLockStore', () => { stalker: [{ categoryType: 'itv', categoryId: '9' }], m3u: ['Adult'], }, - q: { m3u: [] }, + q: { xtream: [], stalker: [], m3u: [] }, }) ).toBe(true); }); @@ -130,19 +130,45 @@ describe('isWellFormedParentalLockStore', () => { it.each([ ['a non-object root', []], ['a non-object playlist entry', { p: 'corrupt' }], - ['a non-list lock field', { p: { xtream: 'corrupt' } }], - ['a non-string group title', { p: { m3u: [1] } }], + ['a playlist entry missing a list', { p: { xtream: [], m3u: [] } }], + ['an empty playlist entry', { p: {} }], + [ + 'a non-list lock field', + { p: { xtream: 'corrupt', stalker: [], m3u: [] } }, + ], + [ + 'a non-string group title', + { p: { xtream: [], stalker: [], m3u: [1] } }, + ], [ 'an unknown category type', - { p: { xtream: [{ categoryType: 'x', xtreamId: 1 }] } }, + { + p: { + xtream: [{ categoryType: 'x', xtreamId: 1 }], + stalker: [], + m3u: [], + }, + }, ], [ 'a non-numeric xtream id', - { p: { xtream: [{ categoryType: 'live', xtreamId: 'nope' }] } }, + { + p: { + xtream: [{ categoryType: 'live', xtreamId: 'nope' }], + stalker: [], + m3u: [], + }, + }, ], [ 'the Stalker "all" pseudo id', - { p: { stalker: [{ categoryType: 'itv', categoryId: '*' }] } }, + { + p: { + xtream: [], + stalker: [{ categoryType: 'itv', categoryId: '*' }], + m3u: [], + }, + }, ], ])('rejects %s', (_, value) => { expect(isWellFormedParentalLockStore(value)).toBe(false); diff --git a/libs/shared/interfaces/src/lib/parental-lock.util.ts b/libs/shared/interfaces/src/lib/parental-lock.util.ts index 29595340b..9f166b056 100644 --- a/libs/shared/interfaces/src/lib/parental-lock.util.ts +++ b/libs/shared/interfaces/src/lib/parental-lock.util.ts @@ -156,16 +156,14 @@ function isWellFormedList( value: unknown, isWellFormedEntry: (entry: unknown) => boolean ): boolean { - return ( - value === undefined || - (Array.isArray(value) && value.every(isWellFormedEntry)) - ); + return Array.isArray(value) && value.every(isWellFormedEntry); } /** * Whether a persisted store has the shape `writeLocks` produces: every - * playlist entry an object whose optional `xtream`/`stalker`/`m3u` lists - * hold only entries the normalizers accept. The normalizers DROP what they + * playlist entry an object whose `xtream`/`stalker`/`m3u` lists are all + * present (a missing list is corruption, not "empty") and hold only + * entries the normalizers accept. The normalizers DROP what they * do not understand, which is right for user-supplied backups but turns a * corrupted persisted store into "nothing is locked"; a store that fails * this check is treated as unreadable instead.