fix(settings): fail closed on a rejected worker lock sync, tear down Stalker synchronously and capture the Xtream dialog context before the PIN

- A rejected lock-state sync to the SQLite worker makes the locked
  session withhold everything until a later sync succeeds.
- The Stalker enforcement chunk is preloaded when a Stalker route
  opens; a relock runs it synchronously, or leaves the route at once
  while it is not loaded, instead of awaiting the chunk.
- Xtream "Manage categories" captures playlist, provider and section
  before the PIN prompt and re-checks them after it and after the
  dialog import.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5.5 committed 2026-09-27 15:11:18 +02:00
1 parent 4aa1b5a8e6
commit 067f920833
8 files changed
+267 -62

No files matched your search

@@ -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<unknown>;
}
).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(
@@ -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<void> = Promise.resolve();
private stalkerModule: StalkerEnforcementModule | null = null;
private stalkerModuleLoad: Promise<StalkerEnforcementModule> | 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<StalkerEnforcementModule> =>
import('./parental-lock-stalker-enforcement');
/** Loads the Stalker step once; a failed load may be retried. */
private loadStalker(): Promise<StalkerEnforcementModule> {
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<ReturnType<typeof this.loadStalkerEnforcement>>;
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
+7 -3
View File
@@ -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`.
@@ -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<boolean> {
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<void> {
const failed = !(await syncParentalLockStateToMainProcess(active));
if (current() !== active || this.failed() === failed) {
return;
}
this.failed.set(failed);
this.changes.update((value) => value + 1);
}
}
@@ -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);
@@ -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());
}
});
});
@@ -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);
@@ -572,51 +572,59 @@ export class WorkspaceContextPanelComponent {
(module) => module.StalkerCategoryLockDialogComponent
);
/** The dynamic import; a field so specs can substitute it. */
loadXtreamCategoryDialog = (): Promise<Type<unknown>> =>
import('@iptvnator/portal/xtream/feature').then(
(module) => module.CategoryManagementDialogComponent
);
async openManageCategories(): Promise<void> {
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 {