diff --git a/docs/architecture/parental-lock.md b/docs/architecture/parental-lock.md index add7d4ad9..86a3b365a 100644 --- a/docs/architecture/parental-lock.md +++ b/docs/architecture/parental-lock.md @@ -137,7 +137,12 @@ lock store are known (in the PWA the settings read can be the slower one). Every lock write re-reads a failed store BEFORE building its edit, so a recovered store is edited, never overwritten by an edit built on the empty fail-closed one. The feature switch itself is -persisted through one guarded path (`persistEnabled`): `updateSettings` +persisted through one guarded path (`persistEnabled`), and every +parental-lock settings write first retries a failed startup settings read +(`ensureSettingsReadable`) and is refused while settings stay unreadable — +`updateSettings` writes the whole settings object, which after a failed +read is the defaults and would replace the user's persisted preferences. +Within that path `updateSettings` patches memory before it writes, so a failed write is undone in memory and `setupPin`/`disable` report false (whether to persist is decided from the settings switch BEFORE the PIN is stored, since `enabled` follows `hasPin` diff --git a/libs/services/src/lib/parental-lock/parental-lock-settings-writer.ts b/libs/services/src/lib/parental-lock/parental-lock-settings-writer.ts new file mode 100644 index 000000000..53fa16202 --- /dev/null +++ b/libs/services/src/lib/parental-lock/parental-lock-settings-writer.ts @@ -0,0 +1,88 @@ +import { normalizeParentalLockRelockMinutes } from '@iptvnator/shared/interfaces'; +import { SettingsStore } from '../settings-store.service'; +import { mirrorParentalLockEnabledSetting } from './parental-lock-bridge'; + +type SettingsWriter = Pick< + InstanceType, + 'updateSettings' | 'loadSettings' +> & { storageFailure?: () => 'load' | 'save' | null }; + +/** + * `updateSettings` writes the WHOLE settings object. After a failed startup + * read that object is the defaults, so a parental-lock write would replace + * the user's persisted player, portal and UI preferences. The read is + * retried first (a transient failure recovers here), and the write is + * refused while settings stay unreadable. + */ +export async function ensureParentalLockSettingsReadable( + settings: SettingsWriter +): Promise { + if (settings.storageFailure?.() !== 'load') { + return true; + } + await settings.loadSettings(); + return settings.storageFailure?.() !== 'load'; +} + +/** + * Persists the feature switch. `updateSettings` patches the in-memory value + * before the write; on a failed write that patch is undone (the second write + * fails the same way and is ignored), so the toggle cannot show a state the + * next launch will not have. The Electron mirror is written after the + * settings and undoes them the same way when it fails: a reloaded renderer + * and a restarted worker start from the mirror. + */ +export async function persistParentalLockEnabled( + settings: SettingsWriter, + enabled: boolean +): Promise { + if (!(await ensureParentalLockSettingsReadable(settings))) { + return false; + } + const undo = () => + settings + .updateSettings({ parentalLockEnabled: !enabled }) + .catch(() => undefined); + try { + await settings.updateSettings({ parentalLockEnabled: enabled }); + } catch (error) { + console.error('Failed to persist the parental lock switch.', error); + await undo(); + return false; + } + if (!(await mirrorParentalLockEnabledSetting(enabled))) { + await undo(); + return false; + } + return true; +} + +/** + * False when the value could not be persisted; the in-memory patch is undone + * so the timer on screen never differs from the one the next launch uses. + */ +export async function persistParentalLockRelockMinutes( + settings: SettingsWriter, + minutes: number, + previous: number +): Promise { + if (!(await ensureParentalLockSettingsReadable(settings))) { + return false; + } + try { + await settings.updateSettings({ + parentalLockRelockMinutes: + normalizeParentalLockRelockMinutes(minutes), + }); + return true; + } catch (error) { + console.error('Failed to persist the relock timeout.', error); + await settings + .updateSettings({ + parentalLockRelockMinutes: + normalizeParentalLockRelockMinutes(previous), + }) + .catch(() => undefined); + return false; + } +} diff --git a/libs/services/src/lib/parental-lock/parental-lock.service.spec.ts b/libs/services/src/lib/parental-lock/parental-lock.service.spec.ts index 383eb4818..a58ec462e 100644 --- a/libs/services/src/lib/parental-lock/parental-lock.service.spec.ts +++ b/libs/services/src/lib/parental-lock/parental-lock.service.spec.ts @@ -226,12 +226,19 @@ describe('ParentalLockService', () => { expect(updateBridgeSettings).not.toHaveBeenCalled(); }); - it('still persists the switch when settings could not be read', async () => { + it('retries a failed settings read before persisting the switch', async () => { storageFailure.set('load'); prompt.requestPin.mockResolvedValue('1234'); const service = await createService(); + // Storage recovers: the retry reads the persisted settings back. + const loadSettings = TestBed.inject(SettingsStore) + .loadSettings as jest.Mock; + loadSettings.mockImplementationOnce(async () => { + storageFailure.set(null); + }); await expect(service.setupPin()).resolves.toBe(true); + expect(loadSettings).toHaveBeenCalledTimes(2); expect(updateSettings).toHaveBeenCalledWith({ parentalLockEnabled: true, @@ -241,6 +248,18 @@ describe('ParentalLockService', () => { }); }); + it('refuses parental-lock settings writes while settings stay unreadable', async () => { + storageFailure.set('load'); + prompt.requestPin.mockResolvedValue('1234'); + const service = await createService(); + + // Writing now would replace the persisted settings with defaults. + await expect(service.setupPin()).resolves.toBe(false); + await expect(service.setRelockMinutes(30)).resolves.toBe(false); + expect(updateSettings).not.toHaveBeenCalled(); + expect(prompt.requestPin).not.toHaveBeenCalled(); + }); + it('keeps the session locked on a failed PIN read and retries before unlocking', async () => { storage.pinHash = await hashParentalLockPin('1234'); parentalLockEnabled.set(true); diff --git a/libs/services/src/lib/parental-lock/parental-lock.service.ts b/libs/services/src/lib/parental-lock/parental-lock.service.ts index 0c62904f4..f78069876 100644 --- a/libs/services/src/lib/parental-lock/parental-lock.service.ts +++ b/libs/services/src/lib/parental-lock/parental-lock.service.ts @@ -24,10 +24,12 @@ import { PARENTAL_LOCK_PROMPT, ParentalLockPromptRequest, } from './parental-lock-prompt.token'; +import { syncParentalLockStateToMainProcess } from './parental-lock-bridge'; import { - mirrorParentalLockEnabledSetting, - syncParentalLockStateToMainProcess, -} from './parental-lock-bridge'; + ensureParentalLockSettingsReadable, + persistParentalLockEnabled, + persistParentalLockRelockMinutes, +} from './parental-lock-settings-writer'; import { ParentalLockStorageService } from './parental-lock-storage'; /** @@ -238,7 +240,7 @@ export class ParentalLockService { */ async setupPin(): Promise { await this.initialize(); - if (!this.prompt) { + if (!this.prompt || !(await this.ensureSettingsReadable())) { return false; } // Decided BEFORE the PIN is stored: with the settings switch @@ -262,34 +264,12 @@ export class ParentalLockService { return true; } - /** - * Persists the feature switch. `updateSettings` patches the in-memory - * value before the write; on a failed write that patch is undone (the - * second write fails the same way and is ignored), so the toggle - * cannot show a state the next launch will not have, and the Electron - * mirror is only updated for a persisted switch. - */ - private async persistEnabled(enabled: boolean): Promise { - try { - await this.settingsStore.updateSettings({ - parentalLockEnabled: enabled, - }); - } catch (error) { - console.error('Failed to persist the parental lock switch.', error); - await this.settingsStore - .updateSettings({ parentalLockEnabled: !enabled }) - .catch(() => undefined); - return false; - } - // 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; + private persistEnabled(enabled: boolean): Promise { + return persistParentalLockEnabled(this.settingsStore, enabled); + } + + private ensureSettingsReadable(): Promise { + return ensureParentalLockSettingsReadable(this.settingsStore); } /** Verifies the current PIN, then replaces it. */ @@ -352,20 +332,11 @@ export class ParentalLockService { * timer on screen never differs from the one the next launch uses. */ async setRelockMinutes(minutes: number): Promise { - const previous = this.relockMinutes(); - try { - await this.settingsStore.updateSettings({ - parentalLockRelockMinutes: - normalizeParentalLockRelockMinutes(minutes), - }); - return true; - } catch (error) { - console.error('Failed to persist the relock timeout.', error); - await this.settingsStore - .updateSettings({ parentalLockRelockMinutes: previous }) - .catch(() => undefined); - return false; - } + return persistParentalLockRelockMinutes( + this.settingsStore, + minutes, + this.relockMinutes() + ); } // -- Lock store (ParentalLockLockStore; predicates add `active`) -------