mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
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:
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
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
+32
@@ -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);
|
||||
|
||||
+41
-33
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user