diff --git a/apps/web/src/app/services/parental-lock-enforcement.service.spec.ts b/apps/web/src/app/services/parental-lock-enforcement.service.spec.ts index 013cfe1d5..65c1bcbe5 100644 --- a/apps/web/src/app/services/parental-lock-enforcement.service.spec.ts +++ b/apps/web/src/app/services/parental-lock-enforcement.service.spec.ts @@ -151,6 +151,34 @@ describe('ParentalLockEnforcementService', () => { ]); }); + it('leaves the Stalker route synchronously on relock while the step is not loaded', () => { + router.url = '/workspace/stalker/stalker-1/itv'; + ( + service as unknown as { + loadStalkerEnforcement: () => Promise; + } + ).loadStalkerEnforcement = () => new Promise(() => undefined); + + service.failClosedNow(); + + expect(router.navigate).toHaveBeenCalledWith([ + '/workspace', + 'sources', + ]); + }); + + it('runs a preloaded Stalker step synchronously on relock', async () => { + router.url = '/workspace/stalker/stalker-1/vod/42'; + await service.applyStalker(); // loads the step + lockedStalkerIds.add('9'); + stalkerStore.selectedItem.set({ category_id: '9' }); + stalkerStore.clearSelectedItem.mockClear(); + + service.failClosedNow(); + + expect(stalkerStore.clearSelectedItem).toHaveBeenCalled(); + }); + it('does not load the Stalker step outside a Stalker route', async () => { router.url = '/workspace/xtreams/xtream-1/live'; const load = jest.spyOn( diff --git a/apps/web/src/app/services/parental-lock-enforcement.service.ts b/apps/web/src/app/services/parental-lock-enforcement.service.ts index 5d005e2dd..64108ca69 100644 --- a/apps/web/src/app/services/parental-lock-enforcement.service.ts +++ b/apps/web/src/app/services/parental-lock-enforcement.service.ts @@ -5,7 +5,7 @@ import { Injectable, untracked, } from '@angular/core'; -import { Router } from '@angular/router'; +import { NavigationEnd, Router } from '@angular/router'; import { Store } from '@ngrx/store'; import { ChannelActions, selectActive } from '@iptvnator/m3u-state'; import { @@ -41,6 +41,8 @@ export class ParentalLockEnforcementService { private started = false; private lastVersion = -1; private applyChain: Promise = Promise.resolve(); + private stalkerModule: StalkerEnforcementModule | null = null; + private stalkerModuleLoad: Promise | null = null; start(): void { if (this.started) { @@ -53,6 +55,16 @@ export class ParentalLockEnforcementService { this.parentalLock.registerBusyProbe( () => this.keepAwake.hasPlayingVideo() || hasPlayingAudio() ); + // The Stalker step is loaded as soon as a Stalker route opens, so a + // relock there can run it synchronously instead of awaiting a chunk. + this.router.events?.subscribe((event) => { + if ( + event instanceof NavigationEnd && + STALKER_ROUTE.test(event.urlAfterRedirects) + ) { + void this.loadStalker().catch(() => undefined); + } + }); effect(() => { const version = this.parentalLock.version(); untracked(() => { @@ -103,7 +115,7 @@ export class ParentalLockEnforcementService { */ private failClosedNow(): void { this.applyM3u(); - void this.applyStalker(); + this.failClosedStalkerNow(); const playlistId = this.xtreamStore.playlistId?.(); if (!playlistId) { return; @@ -273,9 +285,42 @@ export class ParentalLockEnforcementService { } /** The dynamic import; a field so specs can substitute it. */ - loadStalkerEnforcement = () => + loadStalkerEnforcement = (): Promise => import('./parental-lock-stalker-enforcement'); + /** Loads the Stalker step once; a failed load may be retried. */ + private loadStalker(): Promise { + this.stalkerModuleLoad ??= this.loadStalkerEnforcement().then( + (module) => (this.stalkerModule = module), + (error: unknown) => { + this.stalkerModuleLoad = null; + throw error; + } + ); + return this.stalkerModuleLoad; + } + + /** + * The relock's Stalker step, synchronously: run it when the chunk is + * there, otherwise leave the Stalker route at once (its route session + * clears the selection and the live layout stops playback) rather than + * wait for a chunk that may be slow or never arrive. + */ + private failClosedStalkerNow(): void { + if (!STALKER_ROUTE.test(this.router.url)) { + return; + } + if (!this.stalkerModule) { + void this.router.navigate(['/workspace', 'sources']); + return; + } + this.stalkerModule.applyParentalLockToStalker( + this.injector, + this.parentalLock, + this.router + ); + } + /** * The Stalker data layer stays off the initial path: the step is loaded * only while a Stalker route is open. Outside one there is nothing to @@ -286,9 +331,9 @@ export class ParentalLockEnforcementService { if (!STALKER_ROUTE.test(this.router.url)) { return; } - let module: Awaited>; + let module: StalkerEnforcementModule; try { - module = await this.loadStalkerEnforcement(); + module = await this.loadStalker(); } catch (error) { // Fail closed (e.g. a stale PWA page whose chunk is gone): leave // the Stalker route. Its route session clears the selection and @@ -328,6 +373,9 @@ export class ParentalLockEnforcementService { } } +type StalkerEnforcementModule = + typeof import('./parental-lock-stalker-enforcement'); + function hasPlayingAudio(): boolean { return Array.from(document.querySelectorAll('audio')).some( (audio) => !audio.paused && !audio.ended diff --git a/docs/architecture/parental-lock.md b/docs/architecture/parental-lock.md index e9b047d7c..475482408 100644 --- a/docs/architecture/parental-lock.md +++ b/docs/architecture/parental-lock.md @@ -142,7 +142,9 @@ 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 direct worker consumers +withhold locked rows itself. The same holds while locked after the +lock-state sync itself was rejected (`ParentalLockWorkerSync`), until a +later sync succeeds. The direct worker consumers (`CatalogTitleMatchService`, `VodSourceDiscoveryService`) ask the worker nothing while `withholdsEverything` is true, in either case. The VOD multi-source host keys its discovery session to the lock version: a lock @@ -389,8 +391,10 @@ on them). Everything else stays lazy: the PIN dialog loads through reads as a cancelled prompt), and the enforcement's Stalker step loads through `parental-lock-stalker-enforcement.ts` only while a Stalker route is open — a static import of the Stalker store would put the whole Stalker -data layer back into `main.js`. If that chunk cannot load (a stale PWA page -after a deployment) the step fails closed by navigating to +data layer back into `main.js`. The chunk is preloaded when a Stalker route +opens, so a relock there runs the step synchronously; if it is not loaded +yet (still fetching, or it cannot load — a stale PWA page after a +deployment) the step fails closed at once by navigating to `/workspace/sources`: leaving the Stalker route clears its selection and stops its playback, and the Xtream step still runs. The feature costs about 30 KB of `renderer.initialBytes`. diff --git a/libs/services/src/lib/parental-lock/parental-lock-bridge.ts b/libs/services/src/lib/parental-lock/parental-lock-bridge.ts index 9b81f199f..a2f0cbe96 100644 --- a/libs/services/src/lib/parental-lock/parental-lock-bridge.ts +++ b/libs/services/src/lib/parental-lock/parental-lock-bridge.ts @@ -1,17 +1,29 @@ +import { signal } from '@angular/core'; + /** * Electron bridge calls of the parental lock. Both are no-ops in the PWA, * where there is no main process to inform. */ -/** Tells the SQLite worker (through main) whether locked rows are withheld. */ -export function syncParentalLockStateToMainProcess(active: boolean): void { +/** + * Tells the SQLite worker (through main) whether locked rows are withheld. + * Resolves false when the IPC rejected: the worker keeps its previous + * state, so the caller must not trust worker reads while locked. + */ +export async function syncParentalLockStateToMainProcess( + active: boolean +): Promise { const bridge = window.electron; if (typeof bridge?.setParentalLockState !== 'function') { - return; + return true; } - void bridge.setParentalLockState(active).catch((error) => { + try { + await bridge.setParentalLockState(active); + return true; + } catch (error) { console.error('Failed to sync the parental lock state.', error); - }); + return false; + } } /** @@ -36,3 +48,47 @@ export async function mirrorParentalLockEnabledSetting( return false; } } + +/** + * 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 a locked session withholds everything. + */ +export function isParentalLockWorkerFilterMissing(runtime: { + supportsXtreamSqliteDataSource: boolean; + supportsParentalLockSqliteFilter: boolean; +}): boolean { + const missing = + runtime.supportsXtreamSqliteDataSource && + !runtime.supportsParentalLockSqliteFilter; + if (missing) { + console.error( + 'The parental lock bridge is incomplete; locked sessions withhold every category.' + ); + } + return missing; +} + +/** + * Whether the SQLite worker learned the current lock state. A rejected + * sync leaves the worker in its previous state, so a locked session must + * not trust worker reads until a later sync succeeds. + */ +export class ParentalLockWorkerSync { + readonly failed = signal(false); + /** Bumps whenever `failed` changes; part of the lock version. */ + readonly changes = signal(0); + + /** + * Syncs `active`. A sync superseded by a newer transition (`current()` + * no longer equals `active`) changes nothing. + */ + async sync(active: boolean, current: () => boolean): Promise { + const failed = !(await syncParentalLockStateToMainProcess(active)); + if (current() !== active || this.failed() === failed) { + return; + } + this.failed.set(failed); + this.changes.update((value) => value + 1); + } +} 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 a3406ed5f..9bae03fbe 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 @@ -212,6 +212,35 @@ describe('ParentalLockService', () => { expect(service.withholdsEverything()).toBe(false); }); + it('withholds everything while locked when the worker lock-state sync rejects', async () => { + jest.spyOn(console, 'error').mockImplementation(() => undefined); + setParentalLockState.mockRejectedValue(new Error('no handler')); + storage.pinHash = await hashParentalLockPin('1234'); + parentalLockEnabled.set(true); + + const service = await createService(); + const version = service.version(); + await Promise.resolve(); + await Promise.resolve(); + + expect(service.active()).toBe(true); + expect(service.withholdsEverything()).toBe(true); + expect(service.version()).toBeGreaterThan(version); + + // The sync of the next transition succeeds: normal filtering again. + setParentalLockState.mockResolvedValue(undefined); + prompt.requestPin.mockResolvedValue('1234'); + await service.requestUnlock(); + TestBed.flushEffects(); + await Promise.resolve(); + await Promise.resolve(); + service.lock(); + TestBed.flushEffects(); + await Promise.resolve(); + await Promise.resolve(); + 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 e57bf55b5..7099c2865 100644 --- a/libs/services/src/lib/parental-lock/parental-lock.service.ts +++ b/libs/services/src/lib/parental-lock/parental-lock.service.ts @@ -26,7 +26,10 @@ import { PARENTAL_LOCK_PROMPT, ParentalLockPromptRequest, } from './parental-lock-prompt.token'; -import { syncParentalLockStateToMainProcess } from './parental-lock-bridge'; +import { + isParentalLockWorkerFilterMissing, + ParentalLockWorkerSync, +} from './parental-lock-bridge'; import { ensureParentalLockSettingsReadable, persistParentalLockEnabled, @@ -60,18 +63,9 @@ export class ParentalLockService { * 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 workerFilterMissing = isParentalLockWorkerFilterMissing( + inject(RuntimeCapabilitiesService) + ); private readonly unlockedState = signal(false); /** Shared by every unlock prompt: a reopened dialog keeps the cooldown. */ @@ -80,6 +74,7 @@ export class ParentalLockService { /** The PIN hash could not be read; retried before PIN-protected steps. */ private readonly pinUnreadable = signal(false); private readonly versionState = signal(0); + private readonly workerSync = new ParentalLockWorkerSync(); // The main process starts LOCKED whenever the feature is on (mirrored // setting). Reporting our state before settings have loaded would send a // spurious "unlocked" and open a window of unfiltered reads. @@ -129,11 +124,16 @@ export class ParentalLockService { readonly withholdsEverything = computed( () => this.active() && - (this.workerFilterMissing || !this.locks.readable()) + (this.workerFilterMissing || + this.workerSync.failed() || + !this.locks.readable()) ); /** Bumps whenever `active` or the lock store changes; consumers re-query. */ readonly version = computed( - () => this.versionState() + this.locks.revision() + () => + this.versionState() + + this.locks.revision() + + this.workerSync.changes() ); readonly relockMinutes = computed(() => normalizeParentalLockRelockMinutes( @@ -156,7 +156,7 @@ export class ParentalLockService { // process keeps its mirrored (locked) default rather than // being told "unlocked" on the strength of default settings. if (!this.switchUnknown() || this.hasPin()) { - syncParentalLockStateToMainProcess(active); + void this.workerSync.sync(active, () => this.active()); } }); }); diff --git a/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.spec.ts b/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.spec.ts index 8ab581370..ad75e6ce9 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.spec.ts +++ b/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.spec.ts @@ -927,6 +927,38 @@ describe('WorkspaceContextPanelComponent', () => { ).not.toBeNull(); }); + it('opens no Xtream category dialog when the route changed while the PIN was asked', async () => { + fixture.componentRef.setInput('context', { + provider: 'xtreams', + playlistId: 'xtream-1', + }); + fixture.componentRef.setInput('section', 'vod'); + fixture.detectChanges(); + const component = fixture.componentInstance; + Object.defineProperty(component, 'canManageXtreamCategories', { + configurable: true, + value: () => true, + }); + const parentalLock = TestBed.inject(ParentalLockService); + jest.spyOn(parentalLock, 'requestUnlock').mockImplementation( + async () => { + fixture.componentRef.setInput('context', { + provider: 'xtreams', + playlistId: 'xtream-2', + }); + return true; + } + ); + component.loadXtreamCategoryDialog = jest.fn( + async () => class DialogStub {} + ); + + await component.openManageCategories(); + + expect(component.loadXtreamCategoryDialog).not.toHaveBeenCalled(); + expect(dialog.open).not.toHaveBeenCalled(); + }); + describe('Stalker lock dialog', () => { function enableLock(): void { const parentalLock = TestBed.inject(ParentalLockService); diff --git a/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.ts b/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.ts index 70476b794..ced48da18 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.ts +++ b/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.ts @@ -572,51 +572,59 @@ export class WorkspaceContextPanelComponent { (module) => module.StalkerCategoryLockDialogComponent ); + /** The dynamic import; a field so specs can substitute it. */ + loadXtreamCategoryDialog = (): Promise> => + import('@iptvnator/portal/xtream/feature').then( + (module) => module.CategoryManagementDialogComponent + ); + async openManageCategories(): Promise { if (!this.canManageXtreamCategories()) { return; } - // Hidden AND locked categories are listed there by name, so the - // dialog itself sits behind the PIN while the lock is on. - if (!(await this.parentalLock.requestUnlock())) { - return; - } - + // Captured before any await (the PIN prompt and the dialog load + // lazily) and re-checked after each: the dialog saves visibility and + // locks for the playlist it was opened for, which must still be the + // one on screen. const context = this.context(); const section = this.section(); + const unchanged = (): boolean => + this.context().playlistId === context.playlistId && + this.context().provider === context.provider && + this.section() === section && + this.canManageXtreamCategories(); const contentType = section === 'series' ? 'series' : section === 'live' ? 'live' : 'vod'; - - void import('@iptvnator/portal/xtream/feature').then( - ({ CategoryManagementDialogComponent }) => { - const dialogRef = this.dialog.open( - CategoryManagementDialogComponent, - { - data: { - playlistId: context.playlistId, - contentType, - itemCounts: - this.xtreamStore.getCategoryItemCounts(), - }, - width: '500px', - maxHeight: '90vh', - } - ); - - dialogRef - .afterClosed() - .pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe((result) => { - if (result) { - this.xtreamStore.reloadCategories(); - } - }); - } - ); + // Hidden AND locked categories are listed there by name, so the + // dialog itself sits behind the PIN while the lock is on. + if (!(await this.parentalLock.requestUnlock()) || !unchanged()) { + return; + } + const dialogComponent = await this.loadXtreamCategoryDialog(); + if (!unchanged()) { + return; + } + const dialogRef = this.dialog.open(dialogComponent, { + data: { + playlistId: context.playlistId, + contentType, + itemCounts: this.xtreamStore.getCategoryItemCounts(), + }, + width: '500px', + maxHeight: '90vh', + }); + dialogRef + .afterClosed() + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe((result) => { + if (result) { + this.xtreamStore.reloadCategories(); + } + }); } hideCategories(): void {