fix(settings): harden the parental lock after review

- The M3U group dialog opens only after the PIN, like the Xtream and Stalker
  dialogs: it lists locked group names and can rewrite the locks.
- Change PIN and Disable always verify the stored hash, even while the
  session is unlocked, so an app left unlocked cannot lose its lock.
- Stalker paging judges progress on the raw portal page: withheld ids the
  list has not seen count as progress, a page made only of locked rows
  requests the next one itself, and the VOD total is reduced by withheld
  ids so the grid stops asking once every visible row is in.
- Parental lock contract linked from the agent context map after the
  guidance reorganization; bridge helpers split out to stay under the
  file-size cap.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5.1 committed 2026-09-25 22:17:50 +02:00
1 parent ae5d3f49de
commit 299a162416
32 files changed
+414 -89

No files matched your search

+3 -1
View File
@@ -1911,7 +1911,9 @@
"COOLDOWN": "Too many attempts. Wait 30 seconds and try again.",
"NO_RECOVERY": "There is no way to recover a forgotten PIN — only resetting the app data removes it.",
"SAVE": "Save PIN",
"UNLOCK": "Unlock"
"UNLOCK": "Unlock",
"CONFIRM_TITLE": "Confirm parental PIN",
"CONFIRM_DESCRIPTION": "Enter the current PIN to change this setting."
},
"LOCKED_COUNT": "Locked",
"LOCK_CATEGORY": "Lock category",
+3 -1
View File
@@ -1911,7 +1911,9 @@
"COOLDOWN": "Too many attempts. Wait 30 seconds and try again.",
"NO_RECOVERY": "There is no way to recover a forgotten PIN — only resetting the app data removes it.",
"SAVE": "Save PIN",
"UNLOCK": "Unlock"
"UNLOCK": "Unlock",
"CONFIRM_TITLE": "Confirm parental PIN",
"CONFIRM_DESCRIPTION": "Enter the current PIN to change this setting."
},
"LOCKED_COUNT": "Locked",
"LOCK_CATEGORY": "Lock category",
+3 -1
View File
@@ -1911,7 +1911,9 @@
"COOLDOWN": "Too many attempts. Wait 30 seconds and try again.",
"NO_RECOVERY": "There is no way to recover a forgotten PIN — only resetting the app data removes it.",
"SAVE": "Save PIN",
"UNLOCK": "Unlock"
"UNLOCK": "Unlock",
"CONFIRM_TITLE": "Confirm parental PIN",
"CONFIRM_DESCRIPTION": "Enter the current PIN to change this setting."
},
"LOCKED_COUNT": "Locked",
"LOCK_CATEGORY": "Lock category",
+3 -1
View File
@@ -1911,7 +1911,9 @@
"COOLDOWN": "Zu viele Versuche. Warte 30 Sekunden.",
"NO_RECOVERY": "Eine vergessene PIN kann nicht wiederhergestellt werden – nur das Zurücksetzen der App-Daten entfernt sie.",
"SAVE": "PIN speichern",
"UNLOCK": "Entsperren"
"UNLOCK": "Entsperren",
"CONFIRM_TITLE": "Kindersicherungs-PIN bestätigen",
"CONFIRM_DESCRIPTION": "Gib die aktuelle PIN ein, um diese Einstellung zu ändern."
},
"LOCKED_COUNT": "Gesperrt",
"LOCK_CATEGORY": "Kategorie sperren",
+3 -1
View File
@@ -1911,7 +1911,9 @@
"COOLDOWN": "Too many attempts. Wait 30 seconds and try again.",
"NO_RECOVERY": "There is no way to recover a forgotten PIN — only resetting the app data removes it.",
"SAVE": "Save PIN",
"UNLOCK": "Unlock"
"UNLOCK": "Unlock",
"CONFIRM_TITLE": "Confirm parental PIN",
"CONFIRM_DESCRIPTION": "Enter the current PIN to change this setting."
},
"LOCKED_COUNT": "Locked",
"LOCK_CATEGORY": "Lock category",
+3 -1
View File
@@ -1911,7 +1911,9 @@
"COOLDOWN": "Too many attempts. Wait 30 seconds and try again.",
"NO_RECOVERY": "There is no way to recover a forgotten PIN — only resetting the app data removes it.",
"SAVE": "Save PIN",
"UNLOCK": "Unlock"
"UNLOCK": "Unlock",
"CONFIRM_TITLE": "Confirm parental PIN",
"CONFIRM_DESCRIPTION": "Enter the current PIN to change this setting."
},
"LOCKED_COUNT": "Locked",
"LOCK_CATEGORY": "Lock category",
+3 -1
View File
@@ -1911,7 +1911,9 @@
"COOLDOWN": "Too many attempts. Wait 30 seconds and try again.",
"NO_RECOVERY": "There is no way to recover a forgotten PIN — only resetting the app data removes it.",
"SAVE": "Save PIN",
"UNLOCK": "Unlock"
"UNLOCK": "Unlock",
"CONFIRM_TITLE": "Confirm parental PIN",
"CONFIRM_DESCRIPTION": "Enter the current PIN to change this setting."
},
"LOCKED_COUNT": "Locked",
"LOCK_CATEGORY": "Lock category",
+3 -1
View File
@@ -1911,7 +1911,9 @@
"COOLDOWN": "Too many attempts. Wait 30 seconds and try again.",
"NO_RECOVERY": "There is no way to recover a forgotten PIN — only resetting the app data removes it.",
"SAVE": "Save PIN",
"UNLOCK": "Unlock"
"UNLOCK": "Unlock",
"CONFIRM_TITLE": "Confirm parental PIN",
"CONFIRM_DESCRIPTION": "Enter the current PIN to change this setting."
},
"LOCKED_COUNT": "Locked",
"LOCK_CATEGORY": "Lock category",
+3 -1
View File
@@ -1911,7 +1911,9 @@
"COOLDOWN": "Too many attempts. Wait 30 seconds and try again.",
"NO_RECOVERY": "There is no way to recover a forgotten PIN — only resetting the app data removes it.",
"SAVE": "Save PIN",
"UNLOCK": "Unlock"
"UNLOCK": "Unlock",
"CONFIRM_TITLE": "Confirm parental PIN",
"CONFIRM_DESCRIPTION": "Enter the current PIN to change this setting."
},
"LOCKED_COUNT": "Locked",
"LOCK_CATEGORY": "Lock category",
+3 -1
View File
@@ -1911,7 +1911,9 @@
"COOLDOWN": "Too many attempts. Wait 30 seconds and try again.",
"NO_RECOVERY": "There is no way to recover a forgotten PIN — only resetting the app data removes it.",
"SAVE": "Save PIN",
"UNLOCK": "Unlock"
"UNLOCK": "Unlock",
"CONFIRM_TITLE": "Confirm parental PIN",
"CONFIRM_DESCRIPTION": "Enter the current PIN to change this setting."
},
"LOCKED_COUNT": "Locked",
"LOCK_CATEGORY": "Lock category",
+3 -1
View File
@@ -1911,7 +1911,9 @@
"COOLDOWN": "Too many attempts. Wait 30 seconds and try again.",
"NO_RECOVERY": "There is no way to recover a forgotten PIN — only resetting the app data removes it.",
"SAVE": "Save PIN",
"UNLOCK": "Unlock"
"UNLOCK": "Unlock",
"CONFIRM_TITLE": "Confirm parental PIN",
"CONFIRM_DESCRIPTION": "Enter the current PIN to change this setting."
},
"LOCKED_COUNT": "Locked",
"LOCK_CATEGORY": "Lock category",
+3 -1
View File
@@ -1911,7 +1911,9 @@
"COOLDOWN": "Too many attempts. Wait 30 seconds and try again.",
"NO_RECOVERY": "There is no way to recover a forgotten PIN — only resetting the app data removes it.",
"SAVE": "Save PIN",
"UNLOCK": "Unlock"
"UNLOCK": "Unlock",
"CONFIRM_TITLE": "Confirm parental PIN",
"CONFIRM_DESCRIPTION": "Enter the current PIN to change this setting."
},
"LOCKED_COUNT": "Locked",
"LOCK_CATEGORY": "Lock category",
+3 -1
View File
@@ -1911,7 +1911,9 @@
"COOLDOWN": "Too many attempts. Wait 30 seconds and try again.",
"NO_RECOVERY": "There is no way to recover a forgotten PIN — only resetting the app data removes it.",
"SAVE": "Save PIN",
"UNLOCK": "Unlock"
"UNLOCK": "Unlock",
"CONFIRM_TITLE": "Confirm parental PIN",
"CONFIRM_DESCRIPTION": "Enter the current PIN to change this setting."
},
"LOCKED_COUNT": "Locked",
"LOCK_CATEGORY": "Lock category",
+3 -1
View File
@@ -1911,7 +1911,9 @@
"COOLDOWN": "Too many attempts. Wait 30 seconds and try again.",
"NO_RECOVERY": "There is no way to recover a forgotten PIN — only resetting the app data removes it.",
"SAVE": "Save PIN",
"UNLOCK": "Unlock"
"UNLOCK": "Unlock",
"CONFIRM_TITLE": "Confirm parental PIN",
"CONFIRM_DESCRIPTION": "Enter the current PIN to change this setting."
},
"LOCKED_COUNT": "Locked",
"LOCK_CATEGORY": "Lock category",
+3 -1
View File
@@ -1911,7 +1911,9 @@
"COOLDOWN": "Too many attempts. Wait 30 seconds and try again.",
"NO_RECOVERY": "There is no way to recover a forgotten PIN — only resetting the app data removes it.",
"SAVE": "Save PIN",
"UNLOCK": "Unlock"
"UNLOCK": "Unlock",
"CONFIRM_TITLE": "Confirm parental PIN",
"CONFIRM_DESCRIPTION": "Enter the current PIN to change this setting."
},
"LOCKED_COUNT": "Locked",
"LOCK_CATEGORY": "Lock category",
+3 -1
View File
@@ -1911,7 +1911,9 @@
"COOLDOWN": "Слишком много попыток. Подождите 30 секунд.",
"NO_RECOVERY": "Забытый PIN восстановить нельзя — его удаляет только сброс данных приложения.",
"SAVE": "Сохранить PIN",
"UNLOCK": "Разблокировать"
"UNLOCK": "Разблокировать",
"CONFIRM_TITLE": "Подтвердите родительский PIN",
"CONFIRM_DESCRIPTION": "Введите текущий PIN, чтобы изменить эту настройку."
},
"LOCKED_COUNT": "Заблокировано",
"LOCK_CATEGORY": "Заблокировать категорию",
+3 -1
View File
@@ -1911,7 +1911,9 @@
"COOLDOWN": "Too many attempts. Wait 30 seconds and try again.",
"NO_RECOVERY": "There is no way to recover a forgotten PIN — only resetting the app data removes it.",
"SAVE": "Save PIN",
"UNLOCK": "Unlock"
"UNLOCK": "Unlock",
"CONFIRM_TITLE": "Confirm parental PIN",
"CONFIRM_DESCRIPTION": "Enter the current PIN to change this setting."
},
"LOCKED_COUNT": "Locked",
"LOCK_CATEGORY": "Lock category",
+3 -1
View File
@@ -1911,7 +1911,9 @@
"COOLDOWN": "Too many attempts. Wait 30 seconds and try again.",
"NO_RECOVERY": "There is no way to recover a forgotten PIN — only resetting the app data removes it.",
"SAVE": "Save PIN",
"UNLOCK": "Unlock"
"UNLOCK": "Unlock",
"CONFIRM_TITLE": "Confirm parental PIN",
"CONFIRM_DESCRIPTION": "Enter the current PIN to change this setting."
},
"LOCKED_COUNT": "Locked",
"LOCK_CATEGORY": "Lock category",
+3 -1
View File
@@ -1911,7 +1911,9 @@
"COOLDOWN": "Too many attempts. Wait 30 seconds and try again.",
"NO_RECOVERY": "There is no way to recover a forgotten PIN — only resetting the app data removes it.",
"SAVE": "Save PIN",
"UNLOCK": "Unlock"
"UNLOCK": "Unlock",
"CONFIRM_TITLE": "Confirm parental PIN",
"CONFIRM_DESCRIPTION": "Enter the current PIN to change this setting."
},
"LOCKED_COUNT": "Locked",
"LOCK_CATEGORY": "Lock category",
+9
View File
@@ -144,6 +144,15 @@ Otherwise the app may still be using an older
`dist/apps/electron-backend/workers/database.worker.js` bundle even though the
TypeScript source has already been updated.
### Parental Lock Toggles
While the parental lock is enabled (`Settings → Parental lock`), the same
dialog renders a lock toggle per row and the whole dialog opens only after the
PIN. Locks are a separate concept from `hidden`: they live in the renderer's
lock store keyed by provider category id and are mirrored into
`categories.locked` through `DB_SET_CATEGORY_LOCKS`, so a refresh keeps them
the same way it keeps `hidden`. Contract: [parental lock](parental-lock.md).
## Files Changed
```
+14 -5
View File
@@ -11,8 +11,11 @@ follow in a second PR.
## Product rules
- Off by default. Enabling asks for a new PIN (4–8 digits); disabling asks
for the current PIN and keeps the locks for a later re-enable.
- Off by default. Enabling asks for a new PIN (4–8 digits); disabling and
changing the PIN always verify the current PIN against the stored hash,
even while the session is unlocked (`verifyCurrentPin()`, never the
`requestUnlock()` short-cut) — a parent leaving the app unlocked must not
leave the lock removable. Disabling keeps the locks for a later re-enable.
- The unit of locking is the category. Nothing is blurred or greyed: a
withheld category and its rows are absent. The only trace is one
"N locked · Enter PIN to show" row at the bottom of a portal's category
@@ -102,8 +105,13 @@ locked default.
dialog. `itvFullChannelList` and the content loader drop rows whose
`tv_genre_id` / `category_id` is withheld, the content resource's params
carry `parentalLockVersion` so lock/unlock re-fires it, and a stale
response is discarded when the version moved. A selected withheld genre is
cleared and the section root is navigated to.
response is discarded when the version moved. Paging is judged on the RAW
page: withheld ids the list has not seen before count as progress (so a
portal page made only of locked rows does not end the list), a page that
is entirely withheld requests the next page by itself, and the VOD/series
`totalCount` is reduced by the withheld ids seen so the grid stops asking
once every visible row is in. A selected withheld genre is cleared and the
section root is navigated to.
- **M3U:** `ChannelListContainerComponent` derives one `visibleChannelList`
(all views, favorites, recents, the fullscreen panel and numeric zapping
read from it); locked groups are absent from the groups rail. A playing
@@ -120,7 +128,8 @@ locked default.
dialog (rendered only while the feature is on), plus a new Stalker
`StalkerCategoryLockDialogComponent` reached from a lock button above the
categories rail; it offers "Lock adult (18+)" for genres the portal flags
`censored`. Opening any of these while locked asks for the PIN first.
`censored`. All three list the locked names and can rewrite the locks, so
each opens only after `requestUnlock()` succeeds.
- Header lock/unlock button and the `parental-lock-now` /
`parental-unlock` palette commands.
+4 -1
View File
@@ -52,7 +52,10 @@ Current workspace routes:
8. `/workspace/search`
9. `/workspace/downloads`
10. `/workspace/settings/:section` (`/workspace/settings` redirects to
`general`; the settings context panel links each section page)
`general`; the settings context panel links each section page; sections:
`general`, `playback`, `epg`, `dashboard`, `remote-control`, `tmdb`,
`parental` — see [parental lock](parental-lock.md) — `backup`, `reset`,
`about`)
11. `/workspace/xtreams/:id/...`
12. `/workspace/stalker/:id/...`
+1
View File
@@ -38,6 +38,7 @@ are not prerequisites for reading repository contracts.
| Live panels, keyboard focus, grid/layout conventions; shared UI and portal views | [UI guidelines](../architecture/iptvnator-ui-guidelines.md), [detail navigation](../architecture/portal-detail-navigation.md) | [UI design](../../.codex/skills/iptvnator-ui-design/SKILL.md), [theme/style](../../.codex/skills/iptvnator-theme-style/SKILL.md) |
| Workspace routes, title bar, switcher, collections and dashboard; `libs/workspace` | [Workspace shell](../architecture/workspace-shell.md), [dashboard](../architecture/workspace-dashboard.md), [collection/detail navigation](../architecture/portal-detail-navigation.md) | UI/theme skills for visible changes |
| Remote control, playback queue, channel return and shortcuts; `libs/ui/remote-control`, `apps/remote-control-web` | [Remote control](../architecture/remote-control.md) | Provider skill when queue ownership changes |
| Parental lock: PIN, per-category locks, worker-side filtering; `libs/services/src/lib/parental-lock`, category/group dialogs, `apps/electron-backend/src/app/database/parental-lock-state.ts` | [Parental lock](../architecture/parental-lock.md), affected provider contract | Read the affected provider skill |
| Downloads, offline details, catch-up and file availability; `libs/portal/downloads` | [Download manager](../architecture/download-manager.md), provider contract for URL resolution | Read the affected provider skill |
| VOD source discovery, factual metadata and failover; `libs/portal/shared/data-access` | [VOD multi-source](../architecture/vod-multi-source.md) | [Xtream](../../.codex/skills/xtream-electron/SKILL.md) |
| TMDB enrichment, artwork, actors and recommendations; `libs/services/src/lib/tmdb` | [TMDB contracts](../architecture/tmdb-metadata-enrichment.md), [dashboard](../architecture/workspace-dashboard.md) | UI skill for rendering changes |
@@ -2,7 +2,7 @@ import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { patchState, signalStore, withMethods, withState } from '@ngrx/signals';
import { TranslateService } from '@ngx-translate/core';
import { DataService } from '@iptvnator/services';
import { DataService, ParentalLockService } from '@iptvnator/services';
import {
CONNECTIVITY_GUARD_RESET,
PlaylistMeta,
@@ -138,16 +138,27 @@ describe('withStalkerContent failure states', () => {
let dataService: {
sendIpcEvent: jest.Mock<Promise<unknown>, unknown[]>;
};
let parentalLock: {
active: jest.Mock<boolean, []>;
version: ReturnType<typeof signal<number>>;
lockedStalkerIds: jest.Mock<string[], [string, string]>;
};
beforeEach(() => {
dataService = {
sendIpcEvent: jest.fn(),
};
parentalLock = {
active: jest.fn(() => false),
version: signal(0),
lockedStalkerIds: jest.fn(() => []),
};
TestBed.configureTestingModule({
providers: [
TestContentStore,
{ provide: DataService, useValue: dataService },
{ provide: ParentalLockService, useValue: parentalLock },
{
provide: StalkerItvCacheService,
useValue: createItvCacheMock(),
@@ -590,6 +601,62 @@ describe('withStalkerContent failure states', () => {
expect(store.totalCount()).toBe(2);
});
it('pages past a VOD page made only of parental-locked rows', async () => {
parentalLock.active.mockReturnValue(true);
parentalLock.lockedStalkerIds.mockReturnValue(['9']);
dataService.sendIpcEvent.mockImplementation(
(_event: unknown, payload: { params?: { p?: number } }) => {
const page = Number(payload.params?.p ?? 1);
// Page 1: one visible film. Page 2: locked rows only. Page 3:
// the visible film paging must still reach.
const data =
page === 1
? [{ id: 'movie-1', name: 'One', category_id: '5' }]
: page === 2
? [
{ id: 'adult-1', name: 'A', category_id: '9' },
{ id: 'adult-2', name: 'B', category_id: '9' },
]
: [
{
id: 'movie-3',
name: 'Three',
category_id: '5',
},
];
return Promise.resolve({ js: { data, total_items: 4 } });
}
);
store.setSelectedContentType('vod');
store.setCategories('vod', [
{ category_id: '5', category_name: 'Action' },
{ category_id: '9', category_name: 'Adult' },
]);
store.setSelectedCategory('*');
store.setCurrentPlaylist(PLAYLIST);
void store.isPaginatedContentLoading();
await waitForCondition(() => store.getPaginatedContent().length === 1);
expect(store.hasMoreContent()).toBe(true);
// The append lands on the fully withheld page; the loader must ask
// for the next one by itself instead of ending the list.
store.setPage(1);
await waitForCondition(
() => store.getPaginatedContent().length === 2,
60
);
expect(store.getPaginatedContent().map((item) => item.id)).toEqual([
'movie-1',
'movie-3',
]);
// Both withheld ids are subtracted from the portal's total.
expect(store.totalCount()).toBe(2);
expect(store.hasMoreContent()).toBe(false);
});
it('keeps accumulated pages when an append fails and retries the same page', async () => {
let failPageTwo = true;
dataService.sendIpcEvent.mockImplementation(
@@ -284,6 +284,12 @@ export function withStalkerContent() {
};
let lastLivePageKey = '';
// Withheld (parental-locked) row ids seen while accumulating
// the current list. A page that adds only withheld ids still
// counts as progress, so paging continues past it; a page
// adding nothing new — withheld or not — is a stalled portal.
let withheldSeenKey = '';
const withheldSeenIds = new Set<string>();
return {
categoryResource: resource({
params: () => ({
@@ -648,16 +654,70 @@ export function withStalkerContent() {
return [];
}
const rawItems = response.js.data.map((item) =>
toStalkerContentItem(
item,
playlist.portalUrl ?? ''
)
);
const newItems = withoutWithheldStalkerItems(
response.js.data.map((item) =>
toStalkerContentItem(
item,
playlist.portalUrl ?? ''
)
),
rawItems,
params.contentType,
withheldCategoryIds
);
const listKey = JSON.stringify([
paramsPlaylistKey,
params.contentType,
params.category,
params.search,
]);
if (
params.pageIndex === 1 ||
withheldSeenKey !== listKey
) {
withheldSeenKey = listKey;
withheldSeenIds.clear();
}
let newWithheldCount = 0;
if (newItems.length < rawItems.length) {
const kept = new Set(newItems);
for (const item of rawItems) {
if (kept.has(item)) {
continue;
}
const id = String(item.id ?? '');
if (!withheldSeenIds.has(id)) {
withheldSeenIds.add(id);
newWithheldCount += 1;
}
}
}
// A page made only of withheld rows would
// leave the list unchanged; request the next
// one so unlocked rows further on still load.
const skipWithheldPage = (hasMore: boolean) => {
if (
!hasMore ||
newItems.length > 0 ||
newWithheldCount === 0
) {
return;
}
queueMicrotask(() => {
if (isCurrentRequest()) {
// `page` belongs to the selection
// feature; the facade composes
// its setter ahead of this one.
(
store as unknown as {
setPage?: (
page: number
) => void;
}
).setPage?.(params.pageIndex);
}
});
};
if (
params.contentType === 'itv' ||
@@ -688,6 +748,16 @@ export function withStalkerContent() {
const replay =
livePageKey === lastLivePageKey;
lastLivePageKey = livePageKey;
const hasMoreChannels =
rawItems.length > 0 &&
(params.pageIndex === 1 ||
replay ||
newWithheldCount > 0 ||
nextChannels.length >
existingChannels.length) &&
nextChannels.length +
withheldSeenIds.size <
(response.js.total_items ?? 0);
patchState(store, {
totalCount:
response.js.total_items ?? 0,
@@ -705,15 +775,9 @@ export function withStalkerContent() {
},
}
: { radioChannels: nextChannels }),
hasMoreChannels:
channels.length > 0 &&
(params.pageIndex === 1 ||
replay ||
nextChannels.length >
existingChannels.length) &&
nextChannels.length <
(response.js.total_items ?? 0),
hasMoreChannels,
});
skipWithheldPage(hasMoreChannels);
} else {
// VOD/series pages accumulate into one
// continuous list for the infinite-scroll
@@ -737,18 +801,30 @@ export function withStalkerContent() {
// end on every scroll crossing.
const appendStalled =
params.pageIndex > 1 &&
newWithheldCount === 0 &&
nextContent.length <=
previousContent.length;
// Withheld rows count against the portal's
// total, or the grid would keep asking for
// pages the lock will never let it show.
const totalCount = appendStalled
? nextContent.length
: Math.max(
0,
(response.js.total_items ?? 0) -
withheldSeenIds.size
);
patchState(store, {
totalCount: appendStalled
? nextContent.length
: (response.js.total_items ?? 0),
totalCount,
paginatedContent: nextContent,
contentError: null,
appendError: null,
hasMoreChannels: false,
});
skipWithheldPage(
nextContent.length < totalCount
);
return nextContent;
}
@@ -0,0 +1,29 @@
/**
* 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 {
const bridge = window.electron;
if (typeof bridge?.setParentalLockState !== 'function') {
return;
}
void bridge.setParentalLockState(active).catch((error) => {
console.error('Failed to sync the parental lock state.', error);
});
}
/**
* Mirrors the feature switch into electron-conf so a reloaded renderer and a
* restarted worker start locked while the feature is on.
*/
export function mirrorParentalLockEnabledSetting(enabled: boolean): void {
const bridge = window.electron;
if (typeof bridge?.updateSettings !== 'function') {
return;
}
void bridge
.updateSettings({ parentalLockEnabled: enabled })
.catch(() => undefined);
}
@@ -33,7 +33,10 @@ function createScheduler(): FakeScheduler {
return Object.assign(scheduler, {
setTimeout: (callback: () => void, delay: number) => {
const handle = nextHandle++;
scheduler.timers.set(handle, { at: scheduler.now + delay, callback });
scheduler.timers.set(handle, {
at: scheduler.now + delay,
callback,
});
return handle;
},
clearTimeout: (handle: number) => {
@@ -43,11 +46,17 @@ function createScheduler(): FakeScheduler {
}
function createTarget() {
const listeners = new Map<string, Set<EventListenerOrEventListenerObject>>();
const listeners = new Map<
string,
Set<EventListenerOrEventListenerObject>
>();
return {
listeners,
addEventListener: jest.fn((type: string, listener) => {
listeners.set(type, (listeners.get(type) ?? new Set()).add(listener));
listeners.set(
type,
(listeners.get(type) ?? new Set()).add(listener)
);
}),
removeEventListener: jest.fn((type: string, listener) => {
listeners.get(type)?.delete(listener);
@@ -104,7 +113,9 @@ describe('ParentalLockIdleTimer', () => {
it('defers expiry while playback is busy and fires once it stops', () => {
let busy = true;
const { timer, scheduler, onExpire } = createTimer({ busy: () => busy });
const { timer, scheduler, onExpire } = createTimer({
busy: () => busy,
});
timer.arm(5);
scheduler.advance(5 * 60_000);
@@ -35,7 +35,10 @@ export class ParentalLockIdleTimer {
private readonly onExpire: () => void;
private readonly isBusy: () => boolean;
private readonly now: () => number;
private readonly schedule: (callback: () => void, delayMs: number) => number;
private readonly schedule: (
callback: () => void,
delayMs: number
) => number;
private readonly cancel: (handle: number) => void;
private readonly target: Pick<
Document,
@@ -94,10 +97,13 @@ export class ParentalLockIdleTimer {
}
private scheduleCheck(delayMs: number): void {
this.handle = this.schedule(() => {
this.handle = null;
this.check();
}, Math.max(delayMs, 1_000));
this.handle = this.schedule(
() => {
this.handle = null;
this.check();
},
Math.max(delayMs, 1_000)
);
}
private check(): void {
@@ -5,7 +5,10 @@ import { hashParentalLockPin } from '@iptvnator/shared/interfaces';
import { DatabaseService } from '../database-electron.service';
import { RuntimeCapabilitiesService } from '../runtime-capabilities.service';
import { SettingsStore } from '../settings-store.service';
import { PARENTAL_LOCK_PROMPT, ParentalLockPromptRequest } from './parental-lock-prompt.token';
import {
PARENTAL_LOCK_PROMPT,
ParentalLockPromptRequest,
} from './parental-lock-prompt.token';
import { ParentalLockStorageService } from './parental-lock-storage';
import { ParentalLockService } from './parental-lock.service';
@@ -172,7 +175,9 @@ describe('ParentalLockService', () => {
expect(storage.writePinHash).toHaveBeenCalledTimes(1);
expect(storage.pinHash).not.toContain('9876');
expect(updateSettings).toHaveBeenCalledWith({ parentalLockEnabled: true });
expect(updateSettings).toHaveBeenCalledWith({
parentalLockEnabled: true,
});
expect(updateBridgeSettings).toHaveBeenCalledWith({
parentalLockEnabled: true,
});
@@ -197,6 +202,44 @@ describe('ParentalLockService', () => {
expect(service.lockedGroupTitles('p-1')).toEqual(['Adult']);
});
it('changePin and disable verify the stored PIN even while the session is unlocked', async () => {
storage.pinHash = await hashParentalLockPin('1234');
parentalLockEnabled.set(true);
prompt.requestPin.mockImplementation(
async (request: ParentalLockPromptRequest) =>
request.mode === 'set'
? '5678'
: (await request.verify?.('1234'))
? '1234'
: null
);
const service = await createService();
await service.requestUnlock();
TestBed.flushEffects();
expect(service.unlocked()).toBe(true);
prompt.requestPin.mockClear();
// A dismissed verification prompt aborts the change.
prompt.requestPin.mockResolvedValueOnce(null);
await expect(service.changePin()).resolves.toBe(false);
expect(prompt.requestPin).toHaveBeenCalledTimes(1);
expect(prompt.requestPin.mock.calls[0][0].mode).toBe('unlock');
expect(storage.writePinHash).not.toHaveBeenCalled();
prompt.requestPin.mockClear();
await expect(service.changePin()).resolves.toBe(true);
expect(
prompt.requestPin.mock.calls.map((call) => call[0].mode)
).toEqual(['unlock', 'set']);
expect(storage.writePinHash).toHaveBeenCalledTimes(1);
prompt.requestPin.mockClear();
prompt.requestPin.mockResolvedValueOnce(null);
await expect(service.disable()).resolves.toBe(false);
expect(service.enabled()).toBe(true);
expect(prompt.requestPin.mock.calls[0][0].mode).toBe('unlock');
});
it('persists locks per portal, stamps the Xtream column and bumps the version', async () => {
const service = await createService();
const versionBefore = service.version();
@@ -28,6 +28,10 @@ import {
PARENTAL_LOCK_PROMPT,
ParentalLockPromptRequest,
} from './parental-lock-prompt.token';
import {
mirrorParentalLockEnabledSetting,
syncParentalLockStateToMainProcess,
} from './parental-lock-bridge';
import { ParentalLockStorageService } from './parental-lock-storage';
const XTREAM_CATEGORY_TYPES: readonly ParentalLockXtreamCategoryType[] = [
@@ -80,9 +84,7 @@ export class ParentalLockService {
() => this.settingsStore.parentalLockEnabled?.() === true
);
/** Feature on and the PIN has been entered this session. */
readonly unlocked = computed(
() => this.enabled() && this.unlockedState()
);
readonly unlocked = computed(() => this.enabled() && this.unlockedState());
/** Locked categories must currently be withheld. */
readonly active = computed(() => this.enabled() && !this.unlockedState());
/** A PIN exists; enabling is only possible once this is true. */
@@ -103,9 +105,11 @@ export class ParentalLockService {
}
untracked(() => {
this.versionState.update((value) => value + 1);
this.syncMainProcess(active);
syncParentalLockStateToMainProcess(active);
if (!active) {
this.idleTimer.arm(this.enabled() ? this.relockMinutes() : 0);
this.idleTimer.arm(
this.enabled() ? this.relockMinutes() : 0
);
} else {
this.idleTimer.disarm();
}
@@ -157,7 +161,10 @@ export class ParentalLockService {
* accepted). Concurrent callers share one prompt.
*/
requestUnlock(
options: Pick<ParentalLockPromptRequest, 'titleKey' | 'descriptionKey'> = {}
options: Pick<
ParentalLockPromptRequest,
'titleKey' | 'descriptionKey'
> = {}
): Promise<boolean> {
if (!this.active()) {
return Promise.resolve(true);
@@ -218,7 +225,7 @@ export class ParentalLockService {
await this.settingsStore.updateSettings({
parentalLockEnabled: true,
});
this.mirrorEnabledSetting(true);
mirrorParentalLockEnabledSetting(true);
}
return true;
}
@@ -229,7 +236,7 @@ export class ParentalLockService {
if (!this.prompt || !this.hasPin()) {
return false;
}
if (this.enabled() && !(await this.requestUnlock())) {
if (!(await this.verifyCurrentPin())) {
return false;
}
const pin = await this.prompt.requestPin({ mode: 'set' });
@@ -244,17 +251,38 @@ export class ParentalLockService {
if (!this.enabled()) {
return true;
}
if (!(await this.requestUnlock())) {
if (!(await this.verifyCurrentPin())) {
return false;
}
await this.settingsStore.updateSettings({
parentalLockEnabled: false,
});
this.mirrorEnabledSetting(false);
mirrorParentalLockEnabledSetting(false);
this.unlockedState.set(false);
return true;
}
/**
* Always asks for the PIN, unlocked session or not: changing the PIN or
* switching the feature off must not be possible just because a parent
* left the app unlocked. Unlike `requestUnlock()` this never short-cuts
* on `active`.
*/
private async verifyCurrentPin(): Promise<boolean> {
await this.initialize();
const hash = this.pinHash();
if (!this.prompt || !hash) {
return false;
}
const pin = await this.prompt.requestPin({
mode: 'unlock',
verify: (candidate) => verifyParentalLockPin(candidate, hash),
titleKey: 'PARENTAL_LOCK.PIN_DIALOG.CONFIRM_TITLE',
descriptionKey: 'PARENTAL_LOCK.PIN_DIALOG.CONFIRM_DESCRIPTION',
});
return pin !== null;
}
async setRelockMinutes(minutes: number): Promise<void> {
await this.settingsStore.updateSettings({
parentalLockRelockMinutes:
@@ -325,7 +353,8 @@ export class ParentalLockService {
isM3uGroupLocked(playlistId: string, groupTitle: string): boolean {
return (
this.active() && this.lockedGroupTitles(playlistId).includes(groupTitle)
this.active() &&
this.lockedGroupTitles(playlistId).includes(groupTitle)
);
}
@@ -450,24 +479,4 @@ export class ParentalLockService {
return false;
}
}
private syncMainProcess(active: boolean): void {
const bridge = window.electron;
if (typeof bridge?.setParentalLockState !== 'function') {
return;
}
void bridge.setParentalLockState(active).catch((error) => {
console.error('Failed to sync the parental lock state.', error);
});
}
private mirrorEnabledSetting(enabled: boolean): void {
const bridge = window.electron;
if (typeof bridge?.updateSettings !== 'function') {
return;
}
void bridge
.updateSettings({ parentalLockEnabled: enabled })
.catch(() => undefined);
}
}
@@ -5,6 +5,7 @@ import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { MatDialog } from '@angular/material/dialog';
import { TranslateModule } from '@ngx-translate/core';
import { of } from 'rxjs';
import { ParentalLockService } from '@iptvnator/services';
import { Channel } from '@iptvnator/shared/interfaces';
import { ChannelDetailsDialogComponent } from '../channel-details-dialog/channel-details-dialog.component';
import { GroupManagementDialogComponent } from './group-management-dialog/group-management-dialog.component';
@@ -46,6 +47,7 @@ describe('GroupsViewComponent', () => {
let fixture: ComponentFixture<GroupsViewComponent>;
let component: GroupsViewComponent;
let dialog: { open: jest.Mock };
let parentalLock: { requestUnlock: jest.Mock };
const sportsCenter = createChannel(
'sports-1',
@@ -102,6 +104,8 @@ describe('GroupsViewComponent', () => {
beforeEach(async () => {
localStorage.removeItem(GROUP_CHANNEL_SORT_STORAGE_KEY);
parentalLock = { requestUnlock: jest.fn().mockResolvedValue(true) };
dialog = {
open: jest.fn(),
};
@@ -117,6 +121,10 @@ describe('GroupsViewComponent', () => {
provide: MatDialog,
useValue: dialog,
},
{
provide: ParentalLockService,
useValue: parentalLock,
},
],
}).compileComponents();
@@ -498,14 +506,14 @@ describe('GroupsViewComponent', () => {
).toEqual(['Movie Classic']);
});
it('opens the manage-groups dialog with all groups and emits updated hidden titles on save', () => {
it('opens the manage-groups dialog with all groups and emits updated hidden titles on save', async () => {
const hiddenGroupTitlesChanged = jest.fn();
component.hiddenGroupTitlesChanged.subscribe(hiddenGroupTitlesChanged);
dialog.open.mockReturnValue({
afterClosed: () => of({ hiddenGroupTitles: ['News', 'Sports'] }),
});
component.openGroupManagement();
await component.openGroupManagement();
expect(dialog.open).toHaveBeenCalledWith(
GroupManagementDialogComponent,
@@ -529,6 +537,15 @@ describe('GroupsViewComponent', () => {
]);
});
it('keeps the manage-groups dialog closed when the parental PIN is refused', async () => {
// The dialog names every locked group and can rewrite the locks.
parentalLock.requestUnlock.mockResolvedValueOnce(false);
await component.openGroupManagement();
expect(dialog.open).not.toHaveBeenCalled();
});
it('drops the selected-group header in compact mode but keeps the groups rail header', () => {
fixture.componentRef.setInput('showHeader', false);
fixture.detectChanges();
@@ -22,7 +22,7 @@ import { MatTooltipModule } from '@angular/material/tooltip';
import { TranslatePipe } from '@ngx-translate/core';
import { EpgRuntimeBridgeService } from '@iptvnator/epg/data-access';
import { resolveChannelEpgLookupKey } from '@iptvnator/m3u-state';
import { SettingsStore } from '@iptvnator/services';
import { ParentalLockService, SettingsStore } from '@iptvnator/services';
import {
foldSearchText,
Channel,
@@ -84,6 +84,7 @@ interface FilteredGroupView {
})
export class GroupsViewComponent {
private readonly dialog = inject(MatDialog);
private readonly parentalLock = inject(ParentalLockService);
private readonly epgBridge = inject(EpgRuntimeBridgeService);
private readonly settingsStore = inject(SettingsStore);
readonly supportsEpgMapping = this.epgBridge.supportsEpgMapping;
@@ -488,7 +489,13 @@ export class GroupsViewComponent {
this.localGroupSearchTerm.set(value);
}
openGroupManagement(): void {
async openGroupManagement(): Promise<void> {
// The dialog lists every group by name, locked ones included, and can
// rewrite the locks — so it sits behind the PIN like the portal
// dialogs do.
if (!(await this.parentalLock.requestUnlock())) {
return;
}
const groups =
this.managementGroups() ??
this.allGroups().map<GroupManagementDialogGroup>(