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

+10 -5
View File
@@ -112,13 +112,15 @@ same way: `ParentalLockStorageService.readLocks()` reports a failed read as
`null` (distinct from an absent store, `{}`; Electron reads through
`DatabaseService.readAppState`, which keeps a rejected IPC apart from a
missing key, and a stored payload that does not parse or does not have the
shape `writeLocks` produces — `isWellFormedParentalLockStore`, down to the
nested entries — is a failed read too — corruption never becomes an empty
shape `writeLocks` produces — `isWellFormedParentalLockStore`, all three
lists present, down to the nested entries — is a failed read too — corruption never becomes an empty
store), and while the lock is active with the store unreadable
`ParentalLockService.withholdsEverything` is true — every `is*Locked`
predicate answers true and the set-based filters (PWA Xtream, Stalker
content and search, the M3U channel list) receive
`ALL_CATEGORIES_WITHHELD` — under which a row WITHOUT a genre is withheld
`ALL_CATEGORIES_WITHHELD`, and `ElectronXtreamDataSource` serves no
categories, content or search hits either, since its SQLite index may still
carry a stale stamp — under which a row WITHOUT a genre is withheld
too (`isStalkerItemWithheld`, `isStalkerCategoryLocked`), since "no genre"
must not be the one row a withheld catalog still shows — so the whole
catalog is withheld until the PIN
@@ -130,7 +132,9 @@ The window before the initial read settles is treated the same way
the feature as on before the locks are known. The feature switch itself is
persisted through one guarded path (`persistEnabled`): `updateSettings`
patches memory before it writes, so a failed write is undone in memory and
`setupPin`/`disable` report false; the Electron mirror write is awaited
`setupPin`/`disable` report false (whether to persist is decided from the
settings switch BEFORE the PIN is stored, since `enabled` follows `hasPin`
while the switch is unknown); the Electron mirror write is awaited
next, and a mirror that cannot be written undoes the settings write the
same way — the toggle never shows a state the next launch will not have,
on either side.
@@ -278,7 +282,8 @@ on either side.
write that removes a playlist's LAST lock clears the index first and
drops the key afterwards (the reconcile finds playlists only through
their key; an interruption then leaves store-with-lock and index-without,
which the next reconcile repairs toward locked) — awaited inside
which the next reconcile repairs toward locked; a store recovered by a
later successful read marks its playlists stale the same way) — awaited inside
the store's `load()`, so `readable` (and with it every catalog read the
renderer gates) stays false until the index agrees with the store; a
re-stamp that keeps failing keeps the session fail-closed.
@@ -378,6 +378,13 @@ export class StalkerSearchComponent {
contentType,
withheldCategoryIds
);
// Before the withheld-id bookkeeping: a stale page must not
// pre-record ids into a set a newer relock request cleared,
// or that request's page counts no new withheld rows and
// stops paging short of later visible matches.
if (!isCurrent()) {
return items;
}
let newWithheldCount = 0;
if (items.length < rawItems.length) {
const kept = new Set(items);
@@ -393,10 +400,6 @@ export class StalkerSearchComponent {
}
}
if (!isCurrent()) {
return items;
}
const merged = this.applySearchPageSuccess(
params.page,
items,
@@ -1,3 +1,5 @@
import { TestBed } from '@angular/core/testing';
import { ParentalLockService } from '@iptvnator/services';
import {
credentials,
ElectronXtreamDataSourceHarness,
@@ -34,6 +36,42 @@ describe('ElectronXtreamDataSource (DB-first strategy)', () => {
harness = setupElectronXtreamDataSource();
});
describe('withholding everything', () => {
it('serves no categories, content or search hits while the lock store withholds everything', async () => {
const parentalLock = TestBed.inject(ParentalLockService);
Object.defineProperty(parentalLock, 'withholdsEverything', {
configurable: true,
value: () => true,
});
harness.dbService.getXtreamImportStatus.mockResolvedValue(
'completed'
);
harness.dbService.getXtreamCategories.mockResolvedValue([
dbCategory,
]);
harness.dbService.getXtreamContent.mockResolvedValue([
dbContentItem,
]);
await expect(
harness.dataSource.getCategories(
playlistId,
credentials,
'live'
)
).resolves.toEqual([]);
await expect(
harness.dataSource.getContent(playlistId, credentials, 'live')
).resolves.toEqual([]);
await expect(
harness.dataSource.searchContent(playlistId, 'news', ['live'])
).resolves.toEqual([]);
expect(
harness.dbService.getXtreamCategories
).not.toHaveBeenCalled();
});
});
describe('getCategories', () => {
it('returns cached categories without calling the API when import is completed', async () => {
harness.dbService.getXtreamImportStatus.mockResolvedValue(
@@ -149,6 +149,12 @@ export class ElectronXtreamDataSource implements IXtreamDataSource {
type: CategoryType,
options?: XtreamOperationOptions
): Promise<XtreamCategoryFromDb[]> {
// Fail closed with the renderer: while the lock store is loading,
// unreadable or its SQLite index not yet reconciled, the index may
// still carry a stale `locked = false` stamp, so nothing is served.
if (this.parentalLock.withholdsEverything?.()) {
return [];
}
const dbType = mapCategoryTypeToDbType(type);
// The lock version keys the share: a read issued under an older lock
// state (still unlocked, or locks since edited) answers with rows the
@@ -307,6 +313,12 @@ export class ElectronXtreamDataSource implements IXtreamDataSource {
onTotal?: (total: number) => void,
options?: XtreamOperationOptions
): Promise<XtreamContentItem[]> {
// Fail closed with the renderer: while the lock store is loading,
// unreadable or its SQLite index not yet reconciled, the index may
// still carry a stale `locked = false` stamp, so nothing is served.
if (this.parentalLock.withholdsEverything?.()) {
return [];
}
const requestKey = `${playlistId}:${type}:${this.parentalLock.version()}`;
const inFlightRequest = this.contentRequests.get(requestKey);
@@ -424,6 +436,12 @@ export class ElectronXtreamDataSource implements IXtreamDataSource {
types: string[],
excludeHidden?: boolean
): Promise<XtreamContentItem[]> {
// Fail closed with the renderer: while the lock store is loading,
// unreadable or its SQLite index not yet reconciled, the index may
// still carry a stale `locked = false` stamp, so nothing is served.
if (this.parentalLock.withholdsEverything?.()) {
return [];
}
return this.dbService.searchXtreamContent(
playlistId,
searchTerm,
@@ -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;
}
@@ -113,7 +113,7 @@ describe('parental-lock.util', () => {
});
describe('isWellFormedParentalLockStore', () => {
it('accepts what writeLocks produces, lists omitted or empty', () => {
it('accepts what writeLocks produces, lists present even when empty', () => {
expect(isWellFormedParentalLockStore({})).toBe(true);
expect(
isWellFormedParentalLockStore({
@@ -122,7 +122,7 @@ describe('isWellFormedParentalLockStore', () => {
stalker: [{ categoryType: 'itv', categoryId: '9' }],
m3u: ['Adult'],
},
q: { m3u: [] },
q: { xtream: [], stalker: [], m3u: [] },
})
).toBe(true);
});
@@ -130,19 +130,45 @@ describe('isWellFormedParentalLockStore', () => {
it.each([
['a non-object root', []],
['a non-object playlist entry', { p: 'corrupt' }],
['a non-list lock field', { p: { xtream: 'corrupt' } }],
['a non-string group title', { p: { m3u: [1] } }],
['a playlist entry missing a list', { p: { xtream: [], m3u: [] } }],
['an empty playlist entry', { p: {} }],
[
'a non-list lock field',
{ p: { xtream: 'corrupt', stalker: [], m3u: [] } },
],
[
'a non-string group title',
{ p: { xtream: [], stalker: [], m3u: [1] } },
],
[
'an unknown category type',
{ p: { xtream: [{ categoryType: 'x', xtreamId: 1 }] } },
{
p: {
xtream: [{ categoryType: 'x', xtreamId: 1 }],
stalker: [],
m3u: [],
},
},
],
[
'a non-numeric xtream id',
{ p: { xtream: [{ categoryType: 'live', xtreamId: 'nope' }] } },
{
p: {
xtream: [{ categoryType: 'live', xtreamId: 'nope' }],
stalker: [],
m3u: [],
},
},
],
[
'the Stalker "all" pseudo id',
{ p: { stalker: [{ categoryType: 'itv', categoryId: '*' }] } },
{
p: {
xtream: [],
stalker: [{ categoryType: 'itv', categoryId: '*' }],
m3u: [],
},
},
],
])('rejects %s', (_, value) => {
expect(isWellFormedParentalLockStore(value)).toBe(false);
@@ -156,16 +156,14 @@ function isWellFormedList(
value: unknown,
isWellFormedEntry: (entry: unknown) => boolean
): boolean {
return (
value === undefined ||
(Array.isArray(value) && value.every(isWellFormedEntry))
);
return Array.isArray(value) && value.every(isWellFormedEntry);
}
/**
* Whether a persisted store has the shape `writeLocks` produces: every
* playlist entry an object whose optional `xtream`/`stalker`/`m3u` lists
* hold only entries the normalizers accept. The normalizers DROP what they
* playlist entry an object whose `xtream`/`stalker`/`m3u` lists are all
* present (a missing list is corruption, not "empty") and hold only
* entries the normalizers accept. The normalizers DROP what they
* do not understand, which is right for user-supplied backups but turns a
* corrupted persisted store into "nothing is locked"; a store that fails
* this check is treated as unreadable instead.