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

@@ -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());
}
});
});