fix(settings): arm the relock timer on enable and harden Stalker search relock

- The idle timer follows the unlocked transition instead of `active`, so the
  session that just enabled the lock still locks itself later.
- Stalker search closes an open detail whose genre became withheld on
  relock and advances by itself past pages made only of locked rows (only
  while they add ids the list has not seen).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5.1 committed 2026-09-25 23:50:39 +02:00
1 parent 7bd945e4fb
commit 45661e311e
5 files changed
+222 -31

No files matched your search

+8 -3
View File
@@ -23,7 +23,9 @@ follow in a second PR.
- The unlock lives in memory only. The app locks again on every restart, on
"Lock now" (header button, command palette, settings), and after
`Settings.parentalLockRelockMinutes` minutes without user interaction
(default 15; `0` = only on restart). Active playback of a built-in web
(default 15; `0` = only on restart). The idle timer follows the UNLOCKED
transition: armed the moment a session is unlocked — including the session
that just enabled the feature — and disarmed on lock. Active playback of a built-in web
player counts as interaction, so a film never locks half way.
- This is a child lock, not a security boundary: the PIN hash and the lock
store sit in user-readable app data. The UI says so. There is no PIN
@@ -128,8 +130,11 @@ locked default.
in the URL): `StalkerSearchComponent` filters each portal page through the
same withheld-genre predicate, carries `parentalLockVersion` in its
resource params, judges paging progress on the raw page (a page made only
of locked rows is not the end of the results) and, on a lock flip past
page 1, drops the withheld rows and restarts from page 1.
of locked rows is not the end of the results), advances past a run of
fully withheld pages by itself (the infinite scroll stops auto-filling
after a few no-growth loads), closes an open detail whose genre is newly
withheld and, on a lock flip past page 1, drops the withheld rows and
restarts from page 1.
- **Stalker:** genres are stored unfiltered; `getCategoryResource` filters
them, `getAllCategoriesForSelectedType` is the raw list for the lock
dialog. `itvFullChannelList` and the content loader drop rows whose
@@ -24,7 +24,11 @@ import {
StalkerStore,
} from '@iptvnator/portal/stalker/data-access';
import { createPlaybackSessionKey } from '@iptvnator/playback/util';
import { DataService, PlaylistsService } from '@iptvnator/services';
import {
DataService,
ParentalLockService,
PlaylistsService,
} from '@iptvnator/services';
import { CONNECTIVITY_GUARD_RESET } from '@iptvnator/shared/interfaces';
import type { ResolvedPortalPlayback } from '@iptvnator/shared/interfaces';
import { StalkerSearchComponent } from './stalker-search.component';
@@ -289,6 +293,37 @@ describe('StalkerSearchComponent playback session key', () => {
describe('StalkerSearchComponent result paging', () => {
let component: StalkerSearchComponent;
let dataService: { sendIpcEvent: jest.Mock };
let parentalLock: {
active: jest.Mock<boolean, []>;
version: ReturnType<typeof signal<number>>;
lockedStalkerIds: jest.Mock<string[], [string, string]>;
};
let stalkerStoreMock: {
selectedItem: ReturnType<typeof signal<unknown>>;
setSelectedContentType: jest.Mock;
setSelectedItem: jest.Mock;
addToFavorites: jest.Mock;
removeFromFavorites: jest.Mock;
resolveVodPlayback: jest.Mock;
};
async function flush(): Promise<void> {
TestBed.flushEffects();
await Promise.resolve();
await Promise.resolve();
TestBed.flushEffects();
await Promise.resolve();
}
async function waitFor(predicate: () => boolean, attempts = 40) {
for (let index = 0; index < attempts; index += 1) {
if (predicate()) {
return;
}
await flush();
}
throw new Error('Timed out waiting for the search resource');
}
const activePlaylist = signal({
_id: 'playlist|one',
title: 'Search portal',
@@ -307,6 +342,19 @@ describe('StalkerSearchComponent result paging', () => {
dataService = {
sendIpcEvent: jest.fn().mockResolvedValue({ success: true }),
};
parentalLock = {
active: jest.fn(() => false),
version: signal(0),
lockedStalkerIds: jest.fn(() => []),
};
stalkerStoreMock = {
selectedItem: signal(null),
setSelectedContentType: jest.fn(),
setSelectedItem: jest.fn(),
addToFavorites: jest.fn(),
removeFromFavorites: jest.fn(),
resolveVodPlayback: jest.fn(),
};
activePlaylist.set({
_id: 'playlist|one',
title: 'Search portal',
@@ -336,17 +384,8 @@ describe('StalkerSearchComponent result paging', () => {
provide: PlaylistsService,
useValue: { getPortalFavorites: () => of([]) },
},
{
provide: StalkerStore,
useValue: {
selectedItem: signal(null),
setSelectedContentType: jest.fn(),
setSelectedItem: jest.fn(),
addToFavorites: jest.fn(),
removeFromFavorites: jest.fn(),
resolveVodPlayback: jest.fn(),
},
},
{ provide: StalkerStore, useValue: stalkerStoreMock },
{ provide: ParentalLockService, useValue: parentalLock },
{ provide: StalkerSessionService, useValue: {} },
{ provide: StalkerPortalRepairService, useValue: {} },
{
@@ -403,6 +442,38 @@ describe('StalkerSearchComponent result paging', () => {
expect(component.searchHasMore()).toBe(false);
});
it('advances past a page made only of parental-locked rows, but not past a repeated one', async () => {
component.applySearchPageSuccess(1, searchItems('page1', 3), 10);
const pageBefore = component.searchPage();
// Locked rows the list had not seen: schedule the next page.
component.advancePastWithheldPage(pageBefore, 0, 2, () => true);
await flushMicrotasks();
expect(component.searchPage()).toBe(pageBefore + 1);
// A page with visible rows, or one adding no new withheld ids, or a
// request that is no longer current: stay put.
component.advancePastWithheldPage(pageBefore + 1, 1, 2, () => true);
component.advancePastWithheldPage(pageBefore + 1, 0, 0, () => true);
component.advancePastWithheldPage(pageBefore + 1, 0, 2, () => false);
await flushMicrotasks();
expect(component.searchPage()).toBe(pageBefore + 1);
});
it('closes an open detail whose genre became withheld on relock', () => {
component.selectItem({ id: 'adult-9', name: 'A', category_id: '9' });
expect(component.itemDetails()).not.toBeNull();
// Another genre locked: the detail stays.
component.closeWithheldDetail(new Set(['5']));
expect(component.itemDetails()).not.toBeNull();
component.closeWithheldDetail(new Set(['9']));
expect(component.itemDetails()).toBeNull();
expect(component.vodDetailsItem()).toBeNull();
expect(stalkerStoreMock.setSelectedItem).toHaveBeenLastCalledWith(null);
});
it('keeps paging past a page whose rows were all withheld by the parental lock', () => {
component.applySearchPageSuccess(1, searchItems('page1', 3), 10);
expect(component.searchHasMore()).toBe(true);
@@ -125,6 +125,13 @@ export class StalkerSearchComponent {
private readonly parentalLock = inject(ParentalLockService);
/** Lock version the accumulated results were built under. */
private searchResultsLockVersion: number | null = null;
/**
* Withheld row ids seen for the current search identity. A page adding
* only new withheld ids is still progress and is skipped automatically;
* a page adding nothing new is the end of the results.
*/
private searchWithheldKey = '';
private readonly searchWithheldIds = new Set<string>();
private readonly playlistContext = inject(PlaylistContextFacade);
private readonly playlistService = inject(PlaylistsService);
readonly externalPlayback = inject(PORTAL_EXTERNAL_PLAYBACK);
@@ -279,15 +286,19 @@ export class StalkerSearchComponent {
)
)
: new Set<string>();
if (
const lockVersionChanged =
this.searchResultsLockVersion !== null &&
this.searchResultsLockVersion !== params.parentalLockVersion &&
params.page > 1
) {
this.searchResultsLockVersion !== params.parentalLockVersion;
this.searchResultsLockVersion = params.parentalLockVersion;
if (lockVersionChanged) {
// A relock must also close an open detail of a genre that is
// withheld now; the list alone hiding it is not enough.
this.closeWithheldDetail(withheldCategoryIds);
}
if (lockVersionChanged && params.page > 1) {
// A lock flip past page 1: drop the withheld rows on screen
// and rebuild from page 1 rather than appending to pages
// accumulated under the old lock state.
this.searchResultsLockVersion = params.parentalLockVersion;
const retained = withoutWithheldStalkerItems(
this.accumulatedSearchResults(),
contentType,
@@ -297,7 +308,16 @@ export class StalkerSearchComponent {
this.searchPage.set(1);
return retained;
}
this.searchResultsLockVersion = params.parentalLockVersion;
const withheldKey = JSON.stringify([
params.playlistId,
contentType,
params.search,
params.parentalLockVersion,
]);
if (params.page === 1 || this.searchWithheldKey !== withheldKey) {
this.searchWithheldKey = withheldKey;
this.searchWithheldIds.clear();
}
// Mirror the catalog request shape: many Ministra portals
// return an empty list for get_ordered_list without the
@@ -348,19 +368,41 @@ export class StalkerSearchComponent {
contentType,
withheldCategoryIds
);
let newWithheldCount = 0;
if (items.length < rawItems.length) {
const kept = new Set(items);
for (const item of rawItems) {
const id = String(item.id ?? '');
if (
!kept.has(item) &&
!this.searchWithheldIds.has(id)
) {
this.searchWithheldIds.add(id);
newWithheldCount += 1;
}
}
}
if (!isCurrent()) {
return items;
}
return this.applySearchPageSuccess(
const merged = this.applySearchPageSuccess(
params.page,
items,
response.js?.total_items,
// A page made only of withheld rows still is a page the
// portal served; judge progress on what it sent.
rawItems.length > 0
rawItems.length > 0 &&
(items.length > 0 || newWithheldCount > 0)
);
this.advancePastWithheldPage(
params.page,
items.length,
newWithheldCount,
isCurrent
);
return merged;
} catch (error) {
this.logger.warn('Stalker search page failed', {
page: params.page,
@@ -655,6 +697,58 @@ export class StalkerSearchComponent {
});
}
/**
* The infinite scroll gives up after a few loads that add no height, so
* a run of pages made only of parental-locked rows must advance by
* itself until a visible row (or the real end) is reached. Only a page
* that added withheld ids not seen before counts — a stalled portal
* repeating the same locked rows must still end the loop.
*/
advancePastWithheldPage(
page: number,
visibleCount: number,
newWithheldCount: number,
isCurrent: () => boolean
): void {
if (
visibleCount > 0 ||
newWithheldCount === 0 ||
!this.searchHasMore()
) {
return;
}
queueMicrotask(() => {
if (isCurrent()) {
this.searchPage.set(page + 1);
}
});
}
/**
* Closes the open detail when its genre is withheld by the parental
* lock (Lock now, idle relock): the title, its playback actions and the
* store's selected item must not outlive the list row.
*/
closeWithheldDetail(withheldCategoryIds: ReadonlySet<string>): void {
const details = this.itemDetails();
const categoryId = details?.category_id;
if (
!details ||
categoryId === undefined ||
categoryId === null ||
!withheldCategoryIds.has(String(categoryId))
) {
return;
}
const cleared = clearStalkerDetailViewState();
this.itemDetails.set(cleared.itemDetails);
this.vodDetailsItem.set(cleared.vodDetailsItem);
this.isSelectedVodFavorite.set(false);
this.selectedVodPosition.set(null);
this.closeInlinePlayer();
this.stalkerStore.setSelectedItem(null);
}
/** Leave the search page (e.g. back to the actor page that opened it) */
goBack(): void {
this.location.back();
@@ -229,6 +229,28 @@ describe('ParentalLockService', () => {
expect(service.lockedGroupTitles('p-1')).toEqual(['Adult']);
});
it('arms the idle timer for the session that just enabled the feature', async () => {
jest.useFakeTimers();
try {
prompt.requestPin.mockResolvedValue('9876');
parentalLockRelockMinutes.set(5);
const service = await createService();
await expect(service.setupPin()).resolves.toBe(true);
TestBed.flushEffects();
expect(service.unlocked()).toBe(true);
// `active` never changed (unlocked before and after enabling), so
// the timer must be armed by the unlocked transition itself.
jest.advanceTimersByTime(5 * 60_000 + 1_500);
TestBed.flushEffects();
expect(service.unlocked()).toBe(false);
expect(service.active()).toBe(true);
} finally {
jest.useRealTimers();
}
});
it('changePin and disable verify the stored PIN even while the session is unlocked', async () => {
storage.pinHash = await hashParentalLockPin('1234');
parentalLockEnabled.set(true);
@@ -106,20 +106,19 @@ export class ParentalLockService {
untracked(() => {
this.versionState.update((value) => value + 1);
syncParentalLockStateToMainProcess(active);
if (!active) {
this.idleTimer.arm(
this.enabled() ? this.relockMinutes() : 0
);
} else {
this.idleTimer.disarm();
}
});
});
// The idle timer follows the UNLOCKED transition, not `active`:
// enabling the feature from an unlocked session (setupPin) never
// changes `active`, yet that session must still lock itself later.
effect(() => {
const unlocked = this.unlocked();
const minutes = this.relockMinutes();
untracked(() => {
if (this.unlocked()) {
if (unlocked) {
this.idleTimer.arm(minutes);
} else {
this.idleTimer.disarm();
}
});
});