diff --git a/apps/electron-backend/src/app/database/operations/category.operations.spec.ts b/apps/electron-backend/src/app/database/operations/category.operations.spec.ts index e0177338b..582efb537 100644 --- a/apps/electron-backend/src/app/database/operations/category.operations.spec.ts +++ b/apps/electron-backend/src/app/database/operations/category.operations.spec.ts @@ -212,14 +212,28 @@ describe('category.operations parental lock', () => { ]); }); - it('re-stamps one playlist/type: clears everything, then locks the listed ids', async () => { - const where = jest.fn().mockResolvedValue(undefined); + function lockIndexDb() { + const run = jest.fn(); + const where = jest.fn().mockReturnValue({ run }); const set = jest.fn().mockReturnValue({ where }); const update = jest.fn().mockReturnValue({ set }); - const db = { update } as unknown as AppDatabase; + const transaction = jest.fn((callback: () => void) => callback()); + return { + db: { update, transaction } as unknown as AppDatabase, + run, + set, + transaction, + where, + }; + } + + it('re-stamps one playlist/type in one transaction: clears everything, then locks the listed ids', async () => { + const { db, run, set, transaction, where } = lockIndexDb(); await setCategoryLocks(db, 'playlist-1', 'live', [5, 5, 7, 1.5]); + expect(transaction).toHaveBeenCalledTimes(1); + expect(run).toHaveBeenCalledTimes(2); expect(set).toHaveBeenNthCalledWith(1, { locked: false }); expect(set).toHaveBeenNthCalledWith(2, { locked: true }); const lockScope = new SQLiteSyncDialect().sqlToQuery( @@ -230,10 +244,7 @@ describe('category.operations parental lock', () => { }); it('only clears when no id is locked', async () => { - const where = jest.fn().mockResolvedValue(undefined); - const set = jest.fn().mockReturnValue({ where }); - const update = jest.fn().mockReturnValue({ set }); - const db = { update } as unknown as AppDatabase; + const { db, set } = lockIndexDb(); await setCategoryLocks(db, 'playlist-1', 'series', []); @@ -241,3 +252,23 @@ describe('category.operations parental lock', () => { expect(set).toHaveBeenCalledWith({ locked: false }); }); }); + +describe('setCategoryLocks atomicity', () => { + it('runs both statements inside the transaction callback', async () => { + const order: string[] = []; + const run = jest.fn(() => order.push('run')); + const where = jest.fn().mockReturnValue({ run }); + const set = jest.fn().mockReturnValue({ where }); + const update = jest.fn().mockReturnValue({ set }); + const transaction = jest.fn((callback: () => void) => { + order.push('begin'); + callback(); + order.push('commit'); + }); + const db = { update, transaction } as unknown as AppDatabase; + + await setCategoryLocks(db, 'playlist-1', 'live', [5]); + + expect(order).toEqual(['begin', 'run', 'run', 'commit']); + }); +}); diff --git a/apps/electron-backend/src/app/database/operations/category.operations.ts b/apps/electron-backend/src/app/database/operations/category.operations.ts index 465c9d287..f1d3e2415 100644 --- a/apps/electron-backend/src/app/database/operations/category.operations.ts +++ b/apps/electron-backend/src/app/database/operations/category.operations.ts @@ -250,14 +250,22 @@ export async function setCategoryLocks( ...new Set(lockedXtreamIds.filter((id) => Number.isInteger(id))), ]; - await db.update(schema.categories).set({ locked: false }).where(scope); + // One transaction: a re-stamp that fails after the clear would otherwise + // leave every category of this playlist/type unlocked while the lock + // store still lists the intended locks. `.run()` (synchronous), not + // `.execute()`: see playback-position.operations.ts. + await db.transaction(() => { + db.update(schema.categories).set({ locked: false }).where(scope).run(); - if (lockedIds.length > 0) { - await db - .update(schema.categories) - .set({ locked: true }) - .where(and(scope, inArray(schema.categories.xtreamId, lockedIds))); - } + if (lockedIds.length > 0) { + db.update(schema.categories) + .set({ locked: true }) + .where( + and(scope, inArray(schema.categories.xtreamId, lockedIds)) + ) + .run(); + } + }); return { success: true }; } diff --git a/docs/architecture/parental-lock.md b/docs/architecture/parental-lock.md index 008a00c5d..4a562c4f4 100644 --- a/docs/architecture/parental-lock.md +++ b/docs/architecture/parental-lock.md @@ -102,7 +102,18 @@ then unknown rather than off — a stored PIN stands in for it (`enabled` follows `hasPin`, so the session starts locked and one PIN entry unlocks it), and without a PIN the renderer does not report at all, leaving the main process on its mirrored default instead of announcing "unlocked" on -the strength of default settings. +the strength of default settings. The lock store itself fails closed the +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 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`, so the whole 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). ### In-memory catalogs @@ -211,7 +222,12 @@ the strength of default settings. its candidates through the capability-selected data source (`IXtreamDataSource.getAllCategories`, which the PWA source answers from its session cache or the API), so PWA users can set locks too; the - hide/show checkboxes remain Electron-only. + hide/show checkboxes remain Electron-only. The M3U dialog hands its lock + list back to `ChannelListContainerComponent`, which awaits the write and + reports a failed save in a snackbar (the dialog has closed by then). On + Electron the `categories.locked` re-stamp (`setCategoryLocks`) clears and + re-locks one playlist/type inside ONE transaction, so a failed restamp + keeps the previous index instead of leaving every category unlocked. - Header lock/unlock button and the `parental-lock-now` / `parental-unlock` palette commands. @@ -240,7 +256,9 @@ predicate is `ParentalLockService.is*Locked`. - Contracts: `libs/shared/interfaces/src/lib/parental-lock.util.ts`, `parental-lock-pin.util.ts`, `Settings.parentalLock*`, `PARENTAL_LOCK_SET_STATE`. -- Renderer: `libs/services/src/lib/parental-lock/`, +- Renderer: `libs/services/src/lib/parental-lock/` (`ParentalLockService` + owns the session state and PIN flows; `ParentalLockLockStore` owns the + persisted lock set, its unreadable state and the SQLite re-stamp), `apps/web/src/app/services/parental-lock-prompt.service.ts`, `parental-lock-enforcement.service.ts`, `apps/web/src/app/settings/settings-parental-lock*`, diff --git a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-content.feature.ts b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-content.feature.ts index 5d922e77a..a8abe6e55 100644 --- a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-content.feature.ts +++ b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-content.feature.ts @@ -15,6 +15,7 @@ import { resetHostConnectivityGuard, } from '@iptvnator/services'; import type { PlaylistMeta } from '@iptvnator/shared/interfaces'; +import { ALL_CATEGORIES_WITHHELD } from '@iptvnator/shared/interfaces'; import { StalkerCategoryItem, StalkerContentItem, @@ -47,11 +48,14 @@ function withheldStalkerCategoryIds( parentalLock: ParentalLockService, playlist: PlaylistMeta | undefined, contentType: StalkerContentType -): Set { +): ReadonlySet { const playlistId = playlist?._id; if (!playlistId || !parentalLock.active()) { return new Set(); } + if (parentalLock.withholdsEverything?.()) { + return ALL_CATEGORIES_WITHHELD; + } return new Set(parentalLock.lockedStalkerIds(playlistId, contentType)); } 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 576762fd7..a1bcccfb6 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 @@ -30,6 +30,7 @@ import { resetHostConnectivityGuard, } from '@iptvnator/services'; import { + ALL_CATEGORIES_WITHHELD, PlaybackPositionData, ResolvedPortalPlayback, StalkerPortalActions, @@ -278,14 +279,17 @@ export class StalkerSearchComponent { // The dedicated search route has no category guard, so it filters // the portal's rows itself: a locked genre's title must not reach // the grid, its detail or playback through search. - const withheldCategoryIds = this.parentalLock.active() - ? new Set( - this.parentalLock.lockedStalkerIds( - playlist._id, - contentType - ) - ) - : new Set(); + const withheldCategoryIds: ReadonlySet = + !this.parentalLock.active() + ? new Set() + : this.parentalLock.withholdsEverything?.() + ? ALL_CATEGORIES_WITHHELD + : new Set( + this.parentalLock.lockedStalkerIds( + playlist._id, + contentType + ) + ); const lockVersionChanged = this.searchResultsLockVersion !== null && this.searchResultsLockVersion !== params.parentalLockVersion; diff --git a/libs/portal/xtream/data-access/src/lib/data-sources/pwa-xtream-data-source.spec.ts b/libs/portal/xtream/data-access/src/lib/data-sources/pwa-xtream-data-source.spec.ts index 71bef9d59..caecc7746 100644 --- a/libs/portal/xtream/data-access/src/lib/data-sources/pwa-xtream-data-source.spec.ts +++ b/libs/portal/xtream/data-access/src/lib/data-sources/pwa-xtream-data-source.spec.ts @@ -20,6 +20,7 @@ describe('PwaXtreamDataSource', () => { let parentalLock: { active: jest.Mock; lockedXtreamIds: jest.Mock; + withholdsEverything: jest.Mock; }; const credentials: XtreamCredentials = { @@ -41,6 +42,7 @@ describe('PwaXtreamDataSource', () => { parentalLock = { active: jest.fn(() => false), lockedXtreamIds: jest.fn(() => []), + withholdsEverything: jest.fn(() => false), }; TestBed.configureTestingModule({ @@ -96,6 +98,27 @@ describe('PwaXtreamDataSource', () => { expect(unlocked).toHaveLength(2); }); + it('withholds everything while the lock store cannot be read', async () => { + apiService.getStreams.mockResolvedValue([ + { stream_id: 1, name: 'Family film', category_id: '5' }, + ]); + parentalLock.active.mockReturnValue(true); + parentalLock.withholdsEverything = jest.fn(() => true); + + const content = await dataSource.getContent( + 'playlist-1', + credentials, + 'movie' + ); + const found = await dataSource.searchContent('playlist-1', 'family', [ + 'movie', + ]); + + expect(content).toEqual([]); + expect(found).toEqual([]); + expect(parentalLock.lockedXtreamIds).not.toHaveBeenCalled(); + }); + it('reports remote loading phases for API fetches but stays silent on cache hits', async () => { apiService.getStreams.mockResolvedValue([ { stream_id: 1, name: 'News' }, diff --git a/libs/portal/xtream/data-access/src/lib/data-sources/pwa-xtream-data-source.ts b/libs/portal/xtream/data-access/src/lib/data-sources/pwa-xtream-data-source.ts index eee9cbcc7..147a63bc0 100644 --- a/libs/portal/xtream/data-access/src/lib/data-sources/pwa-xtream-data-source.ts +++ b/libs/portal/xtream/data-access/src/lib/data-sources/pwa-xtream-data-source.ts @@ -1,5 +1,6 @@ import { inject, Injectable, Injector } from '@angular/core'; import { + ALL_CATEGORIES_WITHHELD, foldSearchText, ContentMetadataPatch, Playlist, @@ -361,10 +362,13 @@ export class PwaXtreamDataSource implements IXtreamDataSource { private withheldCategoryIds( playlistId: string, type: CategoryType | StreamType - ): Set { + ): ReadonlySet { if (!this.parentalLock.active()) { return new Set(); } + if (this.parentalLock.withholdsEverything?.()) { + return ALL_CATEGORIES_WITHHELD; + } const dbType = type === 'vod' || type === 'movie' ? 'movies' diff --git a/libs/services/src/lib/database-electron.service.ts b/libs/services/src/lib/database-electron.service.ts index 84c1c3639..5ae6a77fa 100644 --- a/libs/services/src/lib/database-electron.service.ts +++ b/libs/services/src/lib/database-electron.service.ts @@ -568,12 +568,20 @@ export class DatabaseService { } async getAppState(key: string): Promise { + return (await this.readAppState(key))?.value ?? null; + } + + /** + * Like {@link getAppState}, but a failed read (no bridge, rejected IPC) + * comes back as `null` instead of looking like an absent key. + */ + async readAppState(key: string): Promise<{ value: string | null } | null> { if (typeof window.electron?.dbGetAppState !== 'function') { return null; } try { - return await window.electron.dbGetAppState(key); + return { value: await window.electron.dbGetAppState(key) }; } catch (error) { console.error('Error getting app state:', error); return null; diff --git a/libs/services/src/lib/parental-lock/index.ts b/libs/services/src/lib/parental-lock/index.ts index 626b7e45f..00efa3c9f 100644 --- a/libs/services/src/lib/parental-lock/index.ts +++ b/libs/services/src/lib/parental-lock/index.ts @@ -1,4 +1,5 @@ export * from './parental-lock-idle-timer'; export * from './parental-lock-prompt.token'; +export * from './parental-lock-lock-store.service'; export * from './parental-lock-storage'; export * from './parental-lock.service'; 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 new file mode 100644 index 000000000..5f34ec066 --- /dev/null +++ b/libs/services/src/lib/parental-lock/parental-lock-lock-store.service.ts @@ -0,0 +1,207 @@ +import { computed, inject, Injectable, signal } from '@angular/core'; +import { + createEmptyParentalLockPlaylistLocks, + isParentalLockPlaylistLocksEmpty, + lockedStalkerCategoryIds, + lockedXtreamCategoryIds, + normalizeParentalLockPlaylistLocks, + ParentalLockPlaylistLocks, + ParentalLockStalkerCategoryType, + ParentalLockStore, + ParentalLockXtreamCategoryType, +} from '@iptvnator/shared/interfaces'; +import { DatabaseService } from '../database-electron.service'; +import { RuntimeCapabilitiesService } from '../runtime-capabilities.service'; +import { ParentalLockStorageService } from './parental-lock-storage'; +import { + withM3uLocks, + withStalkerLocks, + withXtreamLocks, +} from './parental-lock-store.util'; + +const XTREAM_CATEGORY_TYPES: readonly ParentalLockXtreamCategoryType[] = [ + 'live', + 'movies', + 'series', +]; + +/** + * The lock store: WHICH categories are locked, per playlist, persisted + * through `ParentalLockStorageService`. Knows nothing about the PIN or the + * session state — `ParentalLockService` composes it and adds `active`. + * + * A store that could not be READ is a distinct state (`unreadable`): it is + * not "nothing is locked", and no write may be built on the empty in-memory + * store while it lasts, or the persisted locks would be wiped. + */ +@Injectable({ providedIn: 'root' }) +export class ParentalLockLockStore { + private readonly storage = inject(ParentalLockStorageService); + private readonly runtime = inject(RuntimeCapabilitiesService); + private readonly databaseService = inject(DatabaseService); + + private readonly locks = signal({}); + private readonly revisionState = signal(0); + private loading: Promise | null = null; + + /** The persisted store could not be read; see `ensureReadable()`. */ + readonly unreadable = signal(false); + /** Bumps whenever the lock set changes; consumers re-query. */ + readonly revision = this.revisionState.asReadonly(); + readonly isEmpty = computed(() => Object.keys(this.locks()).length === 0); + + /** Reads the persisted store once. */ + load(): Promise { + if (!this.loading) { + this.loading = this.storage.readLocks().then((locks) => { + if (locks === null) { + console.error('The parental lock store could not be read.'); + this.unreadable.set(true); + return; + } + this.locks.set(locks); + }); + } + return this.loading; + } + + /** + * Re-reads a store that failed to read earlier. False while it still + * cannot be read. + */ + async ensureReadable(): Promise { + await this.load(); + if (!this.unreadable()) { + return true; + } + const locks = await this.storage.readLocks(); + if (locks === null) { + return false; + } + this.locks.set(locks); + this.unreadable.set(false); + this.revisionState.update((value) => value + 1); + return true; + } + + locksFor(playlistId: string): ParentalLockPlaylistLocks { + return ( + this.locks()[playlistId] ?? createEmptyParentalLockPlaylistLocks() + ); + } + + lockedXtreamIds( + playlistId: string, + categoryType: ParentalLockXtreamCategoryType + ): number[] { + return lockedXtreamCategoryIds(this.locks()[playlistId], categoryType); + } + + lockedStalkerIds( + playlistId: string, + categoryType: ParentalLockStalkerCategoryType + ): string[] { + return lockedStalkerCategoryIds(this.locks()[playlistId], categoryType); + } + + lockedGroupTitles(playlistId: string): string[] { + return this.locks()[playlistId]?.m3u ?? []; + } + + async setXtreamLocks( + playlistId: string, + categoryType: ParentalLockXtreamCategoryType, + xtreamIds: number[] + ): Promise { + const next = withXtreamLocks( + this.locksFor(playlistId), + categoryType, + xtreamIds + ); + if (!(await this.persistPlaylistLocks(playlistId, next))) { + return false; + } + return this.stampXtreamLocks(playlistId, [categoryType]); + } + + async setStalkerLocks( + playlistId: string, + categoryType: ParentalLockStalkerCategoryType, + categoryIds: string[] + ): Promise { + return this.persistPlaylistLocks( + playlistId, + withStalkerLocks( + this.locksFor(playlistId), + categoryType, + categoryIds + ) + ); + } + + async setM3uLocks( + playlistId: string, + groupTitles: string[] + ): Promise { + return this.persistPlaylistLocks( + playlistId, + withM3uLocks(this.locksFor(playlistId), groupTitles) + ); + } + + /** Backup restore: replaces every lock of one playlist. */ + async replacePlaylistLocks( + playlistId: string, + locks: ParentalLockPlaylistLocks + ): Promise { + if (!(await this.persistPlaylistLocks(playlistId, locks))) { + return false; + } + return this.stampXtreamLocks(playlistId, XTREAM_CATEGORY_TYPES); + } + + /** + * Re-stamps the Electron `categories.locked` index for a playlist from + * the store, e.g. after a refresh recreated the rows. + */ + async stampXtreamLocks( + playlistId: string, + categoryTypes: readonly ParentalLockXtreamCategoryType[] = XTREAM_CATEGORY_TYPES + ): Promise { + if (!this.runtime.supportsXtreamSqliteDataSource) { + return true; + } + let success = true; + for (const categoryType of categoryTypes) { + success = + (await this.databaseService.setCategoryLocks( + playlistId, + categoryType, + this.lockedXtreamIds(playlistId, categoryType) + )) && success; + } + return success; + } + + private async persistPlaylistLocks( + playlistId: string, + locks: ParentalLockPlaylistLocks + ): Promise { + if (!(await this.ensureReadable())) { + return false; + } + const normalized = normalizeParentalLockPlaylistLocks(locks); + const next: ParentalLockStore = { ...this.locks() }; + if (isParentalLockPlaylistLocksEmpty(normalized)) { + delete next[playlistId]; + } else { + next[playlistId] = normalized; + } + if (!(await this.storage.writeLocks(next))) { + return false; + } + this.locks.set(next); + this.revisionState.update((value) => value + 1); + return true; + } +} diff --git a/libs/services/src/lib/parental-lock/parental-lock-storage.ts b/libs/services/src/lib/parental-lock/parental-lock-storage.ts index ef2034544..3e0dcea7d 100644 --- a/libs/services/src/lib/parental-lock/parental-lock-storage.ts +++ b/libs/services/src/lib/parental-lock/parental-lock-storage.ts @@ -26,7 +26,7 @@ export class ParentalLockStorageService { } async readPinHash(): Promise { - const value = await this.read(PARENTAL_LOCK_PIN_KEY); + const value = (await this.read(PARENTAL_LOCK_PIN_KEY))?.value; return value && value.trim() !== '' ? value : null; } @@ -34,13 +34,21 @@ export class ParentalLockStorageService { return this.write(PARENTAL_LOCK_PIN_KEY, hash); } - async readLocks(): Promise { - const raw = await this.read(PARENTAL_LOCK_STORE_KEY); - if (!raw) { + /** + * The lock store, or `null` when it could not be READ — distinct from an + * absent store, which is `{}`. A failed read must not look like "nothing + * is locked": the service withholds everything until it can read again. + */ + async readLocks(): Promise { + const result = await this.read(PARENTAL_LOCK_STORE_KEY); + if (result === null) { + return null; + } + if (!result.value) { return {}; } try { - return normalizeParentalLockStore(JSON.parse(raw)); + return normalizeParentalLockStore(JSON.parse(result.value)); } catch { return {}; } @@ -53,12 +61,13 @@ export class ParentalLockStorageService { ); } - private async read(key: string): Promise { + /** `null` when the read itself failed; an absent key is `{ value: null }`. */ + private async read(key: string): Promise<{ value: string | null } | null> { if (this.usesAppState) { - return this.databaseService.getAppState(key); + return this.databaseService.readAppState(key); } try { - return localStorage.getItem(key); + return { value: localStorage.getItem(key) }; } catch { return null; } 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 4e53ad91d..3a93ceedd 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 @@ -153,6 +153,36 @@ describe('ParentalLockService', () => { expect(setParentalLockState).not.toHaveBeenCalled(); }); + it('withholds every category while the lock store cannot be read', async () => { + storage.pinHash = await hashParentalLockPin('1234'); + parentalLockEnabled.set(true); + storage.readLocks.mockResolvedValue(null); + + const service = await createService(); + + expect(service.active()).toBe(true); + expect(service.withholdsEverything()).toBe(true); + expect(service.isXtreamCategoryLocked('p', 'live', 1)).toBe(true); + expect(service.isStalkerCategoryLocked('p', 'itv', '1')).toBe(true); + expect(service.isM3uGroupLocked('p', 'News')).toBe(true); + // Nothing is known about the persisted locks: a write built on the + // empty in-memory store would wipe them. + await expect(service.setM3uLocks('p', ['Adult'])).resolves.toBe(false); + expect(storage.writeLocks).not.toHaveBeenCalled(); + + // The store reads again: the real locks apply and writes resume. + storage.readLocks.mockResolvedValue({ + p: { xtream: [], stalker: [], m3u: ['Adult'] }, + }); + await expect(service.setM3uLocks('p', ['Adult', 'XXX'])).resolves.toBe( + true + ); + TestBed.flushEffects(); + expect(service.withholdsEverything()).toBe(false); + expect(service.isM3uGroupLocked('p', 'News')).toBe(false); + expect(service.isM3uGroupLocked('p', 'XXX')).toBe(true); + }); + it('starts locked with the feature on and unlocks through the prompt', 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 f85a66c84..9b7042ab5 100644 --- a/libs/services/src/lib/parental-lock/parental-lock.service.ts +++ b/libs/services/src/lib/parental-lock/parental-lock.service.ts @@ -7,23 +7,16 @@ import { untracked, } from '@angular/core'; import { - createEmptyParentalLockPlaylistLocks, hashParentalLockPin, - isParentalLockPlaylistLocksEmpty, - lockedStalkerCategoryIds, - lockedXtreamCategoryIds, - normalizeParentalLockPlaylistLocks, normalizeParentalLockRelockMinutes, ParentalLockPlaylistLocks, ParentalLockStalkerCategoryType, - ParentalLockStore, ParentalLockXtreamCategoryType, verifyParentalLockPin, } from '@iptvnator/shared/interfaces'; -import { DatabaseService } from '../database-electron.service'; -import { RuntimeCapabilitiesService } from '../runtime-capabilities.service'; import { SettingsStore } from '../settings-store.service'; import { ParentalLockIdleTimer } from './parental-lock-idle-timer'; +import { ParentalLockLockStore } from './parental-lock-lock-store.service'; import { PARENTAL_LOCK_PROMPT, ParentalLockPromptRequest, @@ -33,17 +26,6 @@ import { syncParentalLockStateToMainProcess, } from './parental-lock-bridge'; import { ParentalLockStorageService } from './parental-lock-storage'; -import { - withM3uLocks, - withStalkerLocks, - withXtreamLocks, -} from './parental-lock-store.util'; - -const XTREAM_CATEGORY_TYPES: readonly ParentalLockXtreamCategoryType[] = [ - 'live', - 'movies', - 'series', -]; /** * Parental lock: hides locked categories everywhere until the PIN is @@ -64,13 +46,11 @@ const XTREAM_CATEGORY_TYPES: readonly ParentalLockXtreamCategoryType[] = [ export class ParentalLockService { private readonly settingsStore = inject(SettingsStore); private readonly storage = inject(ParentalLockStorageService); - private readonly runtime = inject(RuntimeCapabilitiesService); - private readonly databaseService = inject(DatabaseService); + private readonly locks = inject(ParentalLockLockStore); private readonly prompt = inject(PARENTAL_LOCK_PROMPT, { optional: true }); private readonly unlockedState = signal(false); private readonly pinHash = signal(null); - private readonly lockStore = signal({}); private readonly versionState = signal(0); // The main process starts LOCKED whenever the feature is on (mirrored // setting). Reporting our state before settings have loaded would send a @@ -109,8 +89,20 @@ export class ParentalLockService { readonly active = computed(() => this.enabled() && !this.unlockedState()); /** A PIN exists; enabling is only possible once this is true. */ readonly hasPin = computed(() => this.pinHash() !== null); + /** + * Locked, and the lock store could not be read: which categories are + * locked is unknown, so EVERY category is withheld — the renderer-side + * filters treat every id as locked — until the PIN is entered or the + * store reads again. Failing toward "nothing is locked" would expose the + * protected content precisely during a storage failure. + */ + readonly withholdsEverything = computed( + () => this.active() && this.locks.unreadable() + ); /** Bumps whenever `active` or the lock store changes; consumers re-query. */ - readonly version = this.versionState.asReadonly(); + readonly version = computed( + () => this.versionState() + this.locks.revision() + ); readonly relockMinutes = computed(() => normalizeParentalLockRelockMinutes( this.settingsStore.parentalLockRelockMinutes?.() @@ -153,13 +145,12 @@ export class ParentalLockService { initialize(): Promise { if (!this.initialization) { this.initialization = (async () => { - const [pinHash, locks] = await Promise.all([ + const [pinHash] = await Promise.all([ this.storage.readPinHash(), - this.storage.readLocks(), + this.locks.load(), this.settingsStore.loadSettings(), ]); this.pinHash.set(pinHash); - this.lockStore.set(locks); this.versionState.update((value) => value + 1); this.settingsReady.set(true); })().catch((error) => { @@ -196,6 +187,9 @@ export class ParentalLockService { if (!this.active()) { return true; } + // A store that failed to read at startup may read now; do not send + // the user through the PIN for categories that are not locked. + await this.locks.ensureReadable(); if (this.pendingUnlock) { return this.pendingUnlock; } @@ -316,37 +310,28 @@ export class ParentalLockService { }); } - // -- Lock store ------------------------------------------------------- + // -- Lock store (ParentalLockLockStore; predicates add `active`) ------- locksFor(playlistId: string): ParentalLockPlaylistLocks { - return ( - this.lockStore()[playlistId] ?? - createEmptyParentalLockPlaylistLocks() - ); + return this.locks.locksFor(playlistId); } lockedXtreamIds( playlistId: string, categoryType: ParentalLockXtreamCategoryType ): number[] { - return lockedXtreamCategoryIds( - this.lockStore()[playlistId], - categoryType - ); + return this.locks.lockedXtreamIds(playlistId, categoryType); } lockedStalkerIds( playlistId: string, categoryType: ParentalLockStalkerCategoryType ): string[] { - return lockedStalkerCategoryIds( - this.lockStore()[playlistId], - categoryType - ); + return this.locks.lockedStalkerIds(playlistId, categoryType); } lockedGroupTitles(playlistId: string): string[] { - return this.lockStore()[playlistId]?.m3u ?? []; + return this.locks.lockedGroupTitles(playlistId); } /** Whether the category is locked AND currently withheld. */ @@ -357,7 +342,10 @@ export class ParentalLockService { ): boolean { return ( this.active() && - this.lockedXtreamIds(playlistId, categoryType).includes(xtreamId) + (this.locks.unreadable() || + this.lockedXtreamIds(playlistId, categoryType).includes( + xtreamId + )) ); } @@ -371,16 +359,18 @@ export class ParentalLockService { } return ( this.active() && - this.lockedStalkerIds(playlistId, categoryType).includes( - String(categoryId) - ) + (this.locks.unreadable() || + this.lockedStalkerIds(playlistId, categoryType).includes( + String(categoryId) + )) ); } isM3uGroupLocked(playlistId: string, groupTitle: string): boolean { return ( this.active() && - this.lockedGroupTitles(playlistId).includes(groupTitle) + (this.locks.unreadable() || + this.lockedGroupTitles(playlistId).includes(groupTitle)) ); } @@ -389,15 +379,8 @@ export class ParentalLockService { categoryType: ParentalLockXtreamCategoryType, xtreamIds: number[] ): Promise { - const next = withXtreamLocks( - this.locksFor(playlistId), - categoryType, - xtreamIds - ); - if (!(await this.persistPlaylistLocks(playlistId, next))) { - return false; - } - return this.stampXtreamLocks(playlistId, [categoryType]); + await this.initialize(); + return this.locks.setXtreamLocks(playlistId, categoryType, xtreamIds); } async setStalkerLocks( @@ -405,13 +388,11 @@ export class ParentalLockService { categoryType: ParentalLockStalkerCategoryType, categoryIds: string[] ): Promise { - return this.persistPlaylistLocks( + await this.initialize(); + return this.locks.setStalkerLocks( playlistId, - withStalkerLocks( - this.locksFor(playlistId), - categoryType, - categoryIds - ) + categoryType, + categoryIds ); } @@ -419,10 +400,8 @@ export class ParentalLockService { playlistId: string, groupTitles: string[] ): Promise { - return this.persistPlaylistLocks( - playlistId, - withM3uLocks(this.locksFor(playlistId), groupTitles) - ); + await this.initialize(); + return this.locks.setM3uLocks(playlistId, groupTitles); } /** Backup restore: replaces every lock of one playlist. */ @@ -430,53 +409,19 @@ export class ParentalLockService { playlistId: string, locks: ParentalLockPlaylistLocks ): Promise { - if (!(await this.persistPlaylistLocks(playlistId, locks))) { - return false; - } - return this.stampXtreamLocks(playlistId, XTREAM_CATEGORY_TYPES); + await this.initialize(); + return this.locks.replacePlaylistLocks(playlistId, locks); } /** * Re-stamps the Electron `categories.locked` index for a playlist from * the store, e.g. after a refresh recreated the rows. */ - async stampXtreamLocks( + stampXtreamLocks( playlistId: string, - categoryTypes: readonly ParentalLockXtreamCategoryType[] = XTREAM_CATEGORY_TYPES + categoryTypes?: readonly ParentalLockXtreamCategoryType[] ): Promise { - if (!this.runtime.supportsXtreamSqliteDataSource) { - return true; - } - let success = true; - for (const categoryType of categoryTypes) { - success = - (await this.databaseService.setCategoryLocks( - playlistId, - categoryType, - this.lockedXtreamIds(playlistId, categoryType) - )) && success; - } - return success; - } - - private async persistPlaylistLocks( - playlistId: string, - locks: ParentalLockPlaylistLocks - ): Promise { - await this.initialize(); - const normalized = normalizeParentalLockPlaylistLocks(locks); - const next: ParentalLockStore = { ...this.lockStore() }; - if (isParentalLockPlaylistLocksEmpty(normalized)) { - delete next[playlistId]; - } else { - next[playlistId] = normalized; - } - if (!(await this.storage.writeLocks(next))) { - return false; - } - this.lockStore.set(next); - this.versionState.update((value) => value + 1); - return true; + return this.locks.stampXtreamLocks(playlistId, categoryTypes); } private async storePin(pin: string): Promise { diff --git a/libs/shared/interfaces/src/lib/parental-lock.util.ts b/libs/shared/interfaces/src/lib/parental-lock.util.ts index de049de32..9f220d05b 100644 --- a/libs/shared/interfaces/src/lib/parental-lock.util.ts +++ b/libs/shared/interfaces/src/lib/parental-lock.util.ts @@ -48,6 +48,22 @@ export const PARENTAL_LOCK_XTREAM_CATEGORY_TYPES: readonly ParentalLockXtreamCat export const PARENTAL_LOCK_STALKER_CATEGORY_TYPES: readonly ParentalLockStalkerCategoryType[] = ['itv', 'vod', 'series', 'radio']; +/** + * A "withheld categories" set that withholds EVERY category. Used while the + * lock is active but the lock store could not be read: consumers keep their + * `size`/`has` checks and simply find nothing they may show. + */ +export const ALL_CATEGORIES_WITHHELD: ReadonlySet = Object.freeze( + new (class extends Set { + override has(): boolean { + return true; + } + override get size(): number { + return 1; + } + })() +); + export function createEmptyParentalLockPlaylistLocks(): ParentalLockPlaylistLocks { return { xtream: [], stalker: [], m3u: [] }; } diff --git a/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.spec.ts b/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.spec.ts index cd97ea603..09775f0dd 100644 --- a/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.spec.ts +++ b/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.spec.ts @@ -6,6 +6,8 @@ import { NavigationEnd, Router, } from '@angular/router'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { TranslateService } from '@ngx-translate/core'; import { Store } from '@ngrx/store'; import { StorageMap } from '@ngx-pwa/local-storage'; import { BehaviorSubject, firstValueFrom, of, Subject } from 'rxjs'; @@ -126,6 +128,11 @@ describe('ChannelListContainerComponent', () => { await TestBed.configureTestingModule({ imports: [ChannelListContainerComponent], providers: [ + { provide: MatSnackBar, useValue: { open: jest.fn() } }, + { + provide: TranslateService, + useValue: { instant: (key: string) => key }, + }, { provide: EpgService, useValue: epgService, @@ -297,7 +304,9 @@ describe('ChannelListContainerComponent', () => { }); epgService.getCurrentProgramsForChannels .mockReturnValueOnce(first.asObservable()) - .mockReturnValueOnce(of(new Map([['guide-news', program('Fresh')]]))); + .mockReturnValueOnce( + of(new Map([['guide-news', program('Fresh')]])) + ); fixture.detectChanges(); const channels = [ diff --git a/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.ts b/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.ts index b02b0d202..a0ed936d3 100644 --- a/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.ts +++ b/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.ts @@ -19,7 +19,8 @@ import { MatIconModule } from '@angular/material/icon'; import { ActivatedRoute, NavigationEnd, Router } from '@angular/router'; import { Store } from '@ngrx/store'; import { StorageMap } from '@ngx-pwa/local-storage'; -import { TranslatePipe } from '@ngx-translate/core'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { TranslatePipe, TranslateService } from '@ngx-translate/core'; import { EpgService } from '@iptvnator/epg/data-access'; import { PlaylistContextFacade } from '@iptvnator/playlist/shared/util'; import { @@ -51,6 +52,7 @@ import { SettingsStore, } from '@iptvnator/services'; import { + ALL_CATEGORIES_WITHHELD, foldSearchText, Channel, EpgProgram, @@ -119,6 +121,8 @@ export class ChannelListContainerComponent implements OnInit, OnDestroy { private readonly epgService = inject(EpgService); private readonly playlistsService = inject(PlaylistsService); private readonly parentalLock = inject(ParentalLockService); + private readonly snackBar = inject(MatSnackBar); + private readonly translate = inject(TranslateService); private readonly storage = inject(StorageMap); private readonly store = inject(Store); private readonly router = inject(Router); @@ -343,6 +347,9 @@ export class ChannelListContainerComponent implements OnInit, OnDestroy { if (!playlistId || !this.parentalLock.active()) { return new Set(); } + if (this.parentalLock.withholdsEverything?.()) { + return ALL_CATEGORIES_WITHHELD; + } return new Set(this.parentalLock.lockedGroupTitles(playlistId)); }); /** @@ -585,12 +592,27 @@ export class ChannelListContainerComponent implements OnInit, OnDestroy { ); } - onLockedGroupTitlesChanged(lockedGroupTitles: string[]): void { + async onLockedGroupTitlesChanged( + lockedGroupTitles: string[] + ): Promise { const playlistId = this.lockPlaylistId(); if (!playlistId) { return; } - void this.parentalLock.setM3uLocks(playlistId, lockedGroupTitles); + // The dialog has closed by now, so a failed write (storage full or + // unavailable, app-state IPC failure) must be reported here, or the + // user relocks believing the chosen groups are protected. + const saved = await this.parentalLock.setM3uLocks( + playlistId, + lockedGroupTitles + ); + if (!saved) { + this.snackBar.open( + this.translate.instant('PARENTAL_LOCK.SAVE_FAILED'), + this.translate.instant('CLOSE'), + { duration: 5000 } + ); + } } onHiddenGroupTitlesChanged(hiddenGroupTitles: string[]): void {