fix(settings): withhold Electron Xtream reads while locks are unknown, persist the switch when settings are unreadable, re-stamp after a recovered read

- ElectronXtreamDataSource serves no categories, content or search hits
  while the lock store withholds everything; its SQLite index may still
  carry a stale stamp.
- setupPin decides whether to persist the switch from the settings value
  before the PIN is stored, since enabled follows hasPin while the switch
  is unknown.
- A lock store recovered by a later read marks its playlists stale so the
  index is re-derived, a persisted entry must carry all three lists, and a
  stale Stalker search page is dropped before touching the withheld-id
  bookkeeping.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5.1 committed 2026-09-26 07:36:23 +02:00
1 parent 4f931030e1
commit bfdfe18a9c
11 files changed
+163 -27

No files matched your search

@@ -80,6 +80,18 @@ describe('ParentalLockLockStore', () => {
expect(store.readable()).toBe(true);
});
it('re-derives the index from a store recovered after a failed read', async () => {
storage.readLocks.mockResolvedValueOnce(null);
await store.load();
expect(store.readable()).toBe(false);
expect(setCategoryLocks).not.toHaveBeenCalled();
await expect(store.ensureReadable()).resolves.toBe(true);
expect(setCategoryLocks).toHaveBeenCalledWith('pl-1', 'live', [7]);
expect(store.readable()).toBe(true);
});
it('re-derives the SQLite index from the store on load', async () => {
await store.load();
// ensureReadable awaits the reconcile that load() started.
@@ -116,6 +116,12 @@ export class ParentalLockLockStore {
}
this.locks.set(locks);
this.unreadable.set(false);
// The index may carry stamps from a write that failed before
// the read did; re-derive it from the recovered store as load()
// does.
for (const playlistId of Object.keys(locks)) {
this.markIndexStale(playlistId);
}
this.revisionState.update((value) => value + 1);
}
await this.reconcileXtreamIndex();
@@ -35,7 +35,9 @@ describe('ParentalLockStorageService.readLocks', () => {
it('normalizes a stored store', async () => {
localStorage.setItem(
PARENTAL_LOCK_STORE_KEY,
JSON.stringify({ p: { m3u: ['Adult'], junk: 1 } })
JSON.stringify({
p: { xtream: [], stalker: [], m3u: ['Adult'], junk: 1 },
})
);
await expect(service.readLocks()).resolves.toEqual({
p: { xtream: [], stalker: [], m3u: ['Adult'] },
@@ -46,11 +48,18 @@ describe('ParentalLockStorageService.readLocks', () => {
['truncated JSON', '{"p":{"m3u":["Adu'],
['an array payload', '[]'],
['a scalar payload', '"locks"'],
['a corrupt nested list', '{"p":{"xtream":"corrupt"}}'],
['a corrupt nested entry', '{"p":{"m3u":[1]}}'],
[
'a corrupt nested list',
'{"p":{"xtream":"corrupt","stalker":[],"m3u":[]}}',
],
[
'a corrupt nested entry',
'{"p":{"xtream":[],"stalker":[],"m3u":[1]}}',
],
['a playlist entry missing a list', '{"p":{}}'],
[
'an entry the normalizer would drop',
'{"p":{"xtream":[{"categoryType":"live","xtreamId":"nope"}]}}',
'{"p":{"xtream":[{"categoryType":"live","xtreamId":"nope"}],"stalker":[],"m3u":[]}}',
],
])('treats %s as a failed read, not an empty store', async (_, raw) => {
localStorage.setItem(PARENTAL_LOCK_STORE_KEY, raw);
@@ -226,6 +226,21 @@ describe('ParentalLockService', () => {
expect(updateBridgeSettings).not.toHaveBeenCalled();
});
it('still persists the switch when settings could not be read', async () => {
storageFailure.set('load');
prompt.requestPin.mockResolvedValue('1234');
const service = await createService();
await expect(service.setupPin()).resolves.toBe(true);
expect(updateSettings).toHaveBeenCalledWith({
parentalLockEnabled: true,
});
expect(updateBridgeSettings).toHaveBeenCalledWith({
parentalLockEnabled: true,
});
});
it('keeps the session locked on a failed PIN read and retries before unlocking', async () => {
storage.pinHash = await hashParentalLockPin('1234');
parentalLockEnabled.set(true);
@@ -238,6 +238,12 @@ export class ParentalLockService {
if (!this.prompt) {
return false;
}
// Decided BEFORE the PIN is stored: with the settings switch
// unknown, `enabled` follows `hasPin`, and would read as on the
// moment the PIN lands — skipping the persistence the next launch
// depends on.
const needsPersist =
this.settingsStore.parentalLockEnabled?.() !== true;
const pin = await this.prompt.requestPin({ mode: 'set' });
if (pin === null) {
return false;
@@ -246,7 +252,7 @@ export class ParentalLockService {
return false;
}
this.unlockedState.set(true);
if (!this.enabled() && !(await this.persistEnabled(true))) {
if (needsPersist && !(await this.persistEnabled(true))) {
this.unlockedState.set(false);
return false;
}