fix(settings): await the enablement mirror, restore partial lock stamps and validate nested lock-store entries

- The Electron mirror of the feature switch is awaited; a mirror that
  cannot be written undoes the settings write, so a reload never starts
  from a mirror that disagrees with the persisted switch.
- A failed multi-type re-stamp rolls the store back AND re-stamps every
  touched type from it, since earlier types may already carry the new
  locks; a failed rollback keeps the playlist stale (fail-closed).
- A persisted lock store whose nested entries are not what writeLocks
  produces is a failed read, not an empty store.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5.1 committed 2026-09-26 05:40:21 +02:00
1 parent b64defb576
commit 559ec5508c
10 files changed
+193 -23

No files matched your search

+14 -8
View File
@@ -106,8 +106,10 @@ the strength of default settings. The lock store itself fails closed the
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 is not an object
is a failed read too — corruption never becomes an empty store), and while the lock is active with the store unreadable
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
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
@@ -122,9 +124,11 @@ The window before the initial read settles is treated the same way
(`ParentalLockLockStore.readable` is false until then): settings can report
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, the
Electron mirror is left untouched and `setupPin`/`disable` report false —
the toggle never shows a state the next launch will not have.
patches memory before it writes, so a failed write is undone in memory and
`setupPin`/`disable` report false; 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.
### In-memory catalogs
@@ -249,9 +253,11 @@ the toggle never shows a state the next launch will not have.
re-locks one playlist/type inside ONE transaction, so a failed restamp
keeps the previous index instead of leaving every category unlocked. The
store commits BEFORE that re-stamp, so a failed re-stamp rolls the store
back to what the index reflects (a category must not be recorded and
shown as locked while Electron reads, which filter by the index alone,
still serve it); if the rollback write fails too, the playlist is
back to the previous locks AND re-stamps every touched type from them (a
backup restore stamps three types, and the ones before the failing type
already carry the new locks; a category must not be recorded and shown
as locked while Electron reads, which filter by the index alone, still
serve it); if the rollback write or its re-stamp fails too, the playlist is
re-stamped on the next store access, and every launch re-derives the
index from the store for each playlist that has locks — awaited inside
the store's `load()`, so `readable` (and with it every catalog read the
@@ -16,14 +16,23 @@ export function syncParentalLockStateToMainProcess(active: boolean): void {
/**
* Mirrors the feature switch into electron-conf so a reloaded renderer and a
* restarted worker start locked while the feature is on.
* restarted worker start locked while the feature is on. Resolves false
* when the main process could not persist it — the caller must then not
* report the switch as changed, or a reload would start from the old
* mirror. True in the PWA, which has no mirror.
*/
export function mirrorParentalLockEnabledSetting(enabled: boolean): void {
export async function mirrorParentalLockEnabledSetting(
enabled: boolean
): Promise<boolean> {
const bridge = window.electron;
if (typeof bridge?.updateSettings !== 'function') {
return;
return true;
}
try {
await bridge.updateSettings({ parentalLockEnabled: enabled });
return true;
} catch (error) {
console.error('Failed to mirror the parental lock switch.', error);
return false;
}
void bridge
.updateSettings({ parentalLockEnabled: enabled })
.catch(() => undefined);
}
@@ -108,6 +108,36 @@ describe('ParentalLockLockStore', () => {
);
});
it('re-stamps every touched type from the restored store after a partial multi-type failure', async () => {
await store.load();
await store.ensureReadable();
setCategoryLocks.mockClear();
// live commits, movies fails: the index now carries the NEW live
// locks while the store is rolled back to the old ones.
setCategoryLocks
.mockResolvedValueOnce(true)
.mockResolvedValueOnce(false)
.mockResolvedValueOnce(true)
.mockResolvedValue(true);
await expect(
store.replacePlaylistLocks('pl-1', {
xtream: [{ categoryType: 'movies', xtreamId: 3 }],
stalker: [],
m3u: [],
})
).resolves.toBe(false);
expect(store.lockedXtreamIds('pl-1', 'live')).toEqual([7]);
// Rollback re-stamps all three types from the restored store.
expect(setCategoryLocks.mock.calls.slice(3)).toEqual([
['pl-1', 'live', [7]],
['pl-1', 'movies', []],
['pl-1', 'series', []],
]);
expect(store.readable()).toBe(true);
});
it('re-stamps on the next access when even the rollback write failed', async () => {
await store.load();
await store.ensureReadable();
@@ -189,7 +189,7 @@ export class ParentalLockLockStore {
if (await this.stampXtreamLocks(playlistId, [categoryType])) {
return true;
}
await this.rollBack(playlistId, previous);
await this.rollBack(playlistId, previous, [categoryType]);
return false;
}
@@ -230,7 +230,7 @@ export class ParentalLockLockStore {
if (await this.stampXtreamLocks(playlistId, XTREAM_CATEGORY_TYPES)) {
return true;
}
await this.rollBack(playlistId, previous);
await this.rollBack(playlistId, previous, XTREAM_CATEGORY_TYPES);
return false;
}
@@ -238,15 +238,23 @@ export class ParentalLockLockStore {
* The store commits before the SQLite index is re-stamped; a failed
* re-stamp would otherwise leave a category recorded (and shown) as
* locked while Electron reads, which filter by the index alone, still
* serve it. The store goes back to what the index reflects; if even
* that write fails, the playlist is re-stamped on the next access.
* serve it. The store goes back to the previous locks and the touched
* types are re-stamped from it — a multi-type re-stamp may have
* committed some types before the failing one. If either step fails,
* the playlist is marked stale, which keeps the session fail-closed and
* re-stamps it on the next access.
*/
private async rollBack(
playlistId: string,
previous: ParentalLockPlaylistLocks
previous: ParentalLockPlaylistLocks,
categoryTypes: readonly ParentalLockXtreamCategoryType[]
): Promise<void> {
if (!(await this.persistPlaylistLocks(playlistId, previous))) {
console.error('Failed to roll back the parental lock store.');
const restored = await this.persistPlaylistLocks(playlistId, previous);
const restamped =
restored &&
(await this.stampXtreamLocks(playlistId, categoryTypes));
if (!restored || !restamped) {
console.error('Failed to roll back the parental lock index.');
this.markIndexStale(playlistId);
this.revisionState.update((value) => value + 1);
}
@@ -46,6 +46,12 @@ 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]}}'],
[
'an entry the normalizer would drop',
'{"p":{"xtream":[{"categoryType":"live","xtreamId":"nope"}]}}',
],
])('treats %s as a failed read, not an empty store', async (_, raw) => {
localStorage.setItem(PARENTAL_LOCK_STORE_KEY, raw);
await expect(service.readLocks()).resolves.toBeNull();
@@ -1,5 +1,6 @@
import { inject, Injectable } from '@angular/core';
import {
isWellFormedParentalLockStore,
normalizeParentalLockStore,
PARENTAL_LOCK_PIN_KEY,
PARENTAL_LOCK_STORE_KEY,
@@ -55,7 +56,7 @@ export class ParentalLockStorageService {
} catch {
return null;
}
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
if (!isWellFormedParentalLockStore(parsed)) {
return null;
}
return normalizeParentalLockStore(parsed);
@@ -226,6 +226,20 @@ describe('ParentalLockService', () => {
expect(updateBridgeSettings).not.toHaveBeenCalled();
});
it('rolls the switch back when the Electron mirror cannot be written', async () => {
prompt.requestPin.mockResolvedValue('1234');
updateBridgeSettings.mockRejectedValueOnce(new Error('ipc'));
const service = await createService();
await expect(service.setupPin()).resolves.toBe(false);
expect(service.enabled()).toBe(false);
expect(service.unlocked()).toBe(false);
expect(updateSettings).toHaveBeenLastCalledWith({
parentalLockEnabled: false,
});
});
it('starts locked with the feature on and unlocks through the prompt', async () => {
storage.pinHash = await hashParentalLockPin('1234');
parentalLockEnabled.set(true);
@@ -267,7 +267,14 @@ export class ParentalLockService {
.catch(() => undefined);
return false;
}
mirrorParentalLockEnabledSetting(enabled);
// The mirror is what a reloaded renderer and a restarted worker
// start from; a switch persisted on one side only is undone.
if (!(await mirrorParentalLockEnabledSetting(enabled))) {
await this.settingsStore
.updateSettings({ parentalLockEnabled: !enabled })
.catch(() => undefined);
return false;
}
return true;
}
@@ -1,5 +1,6 @@
import {
isParentalLockPlaylistLocksEmpty,
isWellFormedParentalLockStore,
lockedStalkerCategoryIds,
lockedXtreamCategoryIds,
normalizeParentalLockPlaylistLocks,
@@ -109,3 +110,40 @@ describe('parental-lock.util', () => {
});
});
});
describe('isWellFormedParentalLockStore', () => {
it('accepts what writeLocks produces, lists omitted or empty', () => {
expect(isWellFormedParentalLockStore({})).toBe(true);
expect(
isWellFormedParentalLockStore({
p: {
xtream: [{ categoryType: 'live', xtreamId: 7 }],
stalker: [{ categoryType: 'itv', categoryId: '9' }],
m3u: ['Adult'],
},
q: { m3u: [] },
})
).toBe(true);
});
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] } }],
[
'an unknown category type',
{ p: { xtream: [{ categoryType: 'x', xtreamId: 1 }] } },
],
[
'a non-numeric xtream id',
{ p: { xtream: [{ categoryType: 'live', xtreamId: 'nope' }] } },
],
[
'the Stalker "all" pseudo id',
{ p: { stalker: [{ categoryType: 'itv', categoryId: '*' }] } },
],
])('rejects %s', (_, value) => {
expect(isWellFormedParentalLockStore(value)).toBe(false);
});
});
@@ -152,6 +152,57 @@ export function normalizeParentalLockStalkerCategories(
return result;
}
function isWellFormedList(
value: unknown,
isWellFormedEntry: (entry: unknown) => boolean
): boolean {
return (
value === undefined ||
(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
* 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.
*/
export function isWellFormedParentalLockStore(value: unknown): boolean {
if (!isRecord(value)) {
return false;
}
return Object.values(value).every(
(locks) =>
isRecord(locks) &&
isWellFormedList(
locks['xtream'],
(entry) =>
isRecord(entry) &&
PARENTAL_LOCK_XTREAM_CATEGORY_TYPES.includes(
entry['categoryType'] as ParentalLockXtreamCategoryType
) &&
normalizeNumericId(entry['xtreamId']) !== null
) &&
isWellFormedList(
locks['stalker'],
(entry) =>
isRecord(entry) &&
PARENTAL_LOCK_STALKER_CATEGORY_TYPES.includes(
entry['categoryType'] as ParentalLockStalkerCategoryType
) &&
((typeof entry['categoryId'] === 'string' &&
entry['categoryId'].trim() !== '' &&
entry['categoryId'].trim() !== '*') ||
(typeof entry['categoryId'] === 'number' &&
Number.isFinite(entry['categoryId'])))
) &&
isWellFormedList(locks['m3u'], (entry) => typeof entry === 'string')
);
}
export function normalizeParentalLockGroupTitles(value: unknown): string[] {
const seen = new Set<string>();
for (const item of toArray(value)) {