mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-14 05:06:16 -08:00
fix(settings): fail closed when the Electron bridge lacks the parental lock worker filter
A new runtime capability requires the lock-state and index-stamping IPC. When Electron reads Xtream through the SQLite worker without it (a partial or older preload), the locked session withholds every category instead of trusting a worker that never learned the lock state. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
e9afb56017
commit
9735ccf69a
5 files changed
+80
-5
No files matched your search
@@ -135,6 +135,12 @@ catalog is withheld until the PIN
|
||||
is entered or the store reads again (`requestUnlock` and every lock write
|
||||
retry the read first, and a write is refused while it still fails, since it
|
||||
would be built on an empty in-memory store and wipe the persisted locks).
|
||||
The same fail-closed mode applies while locked when Electron reads Xtream
|
||||
through the SQLite worker (`supportsXtreamSqliteDataSource`) but the bridge
|
||||
lacks the worker filter (`supportsParentalLockSqliteFilter`:
|
||||
`setParentalLockState` and `dbSetCategoryLocks`, e.g. a partial or older
|
||||
preload): the worker would never learn the lock state, so it cannot
|
||||
withhold locked rows itself.
|
||||
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 — and the workspace route's
|
||||
|
||||
@@ -30,6 +30,10 @@ describe('ParentalLockService', () => {
|
||||
let setCategoryLocks: jest.Mock;
|
||||
let setParentalLockState: jest.Mock;
|
||||
let updateBridgeSettings: jest.Mock;
|
||||
let runtime: {
|
||||
supportsXtreamSqliteDataSource: boolean;
|
||||
supportsParentalLockSqliteFilter: boolean;
|
||||
};
|
||||
|
||||
beforeAll(() => {
|
||||
if (!globalThis.crypto?.subtle) {
|
||||
@@ -69,6 +73,10 @@ describe('ParentalLockService', () => {
|
||||
}),
|
||||
};
|
||||
prompt = { requestPin: jest.fn() };
|
||||
runtime = {
|
||||
supportsXtreamSqliteDataSource: true,
|
||||
supportsParentalLockSqliteFilter: true,
|
||||
};
|
||||
setCategoryLocks = jest.fn(async () => true);
|
||||
setParentalLockState = jest.fn(async () => undefined);
|
||||
updateBridgeSettings = jest.fn(async () => undefined);
|
||||
@@ -98,7 +106,7 @@ describe('ParentalLockService', () => {
|
||||
{ provide: PARENTAL_LOCK_PROMPT, useValue: prompt },
|
||||
{
|
||||
provide: RuntimeCapabilitiesService,
|
||||
useValue: { supportsXtreamSqliteDataSource: true },
|
||||
useValue: runtime,
|
||||
},
|
||||
{ provide: DatabaseService, useValue: { setCategoryLocks } },
|
||||
],
|
||||
@@ -186,6 +194,24 @@ describe('ParentalLockService', () => {
|
||||
expect(service.isM3uGroupLocked('p', 'XXX')).toBe(true);
|
||||
});
|
||||
|
||||
it('withholds every category while locked when the worker filter bridge is missing', async () => {
|
||||
jest.spyOn(console, 'error').mockImplementation(() => undefined);
|
||||
runtime.supportsParentalLockSqliteFilter = false;
|
||||
storage.pinHash = await hashParentalLockPin('1234');
|
||||
parentalLockEnabled.set(true);
|
||||
|
||||
const service = await createService();
|
||||
|
||||
expect(service.active()).toBe(true);
|
||||
expect(service.locksReadable()).toBe(true);
|
||||
expect(service.withholdsEverything()).toBe(true);
|
||||
expect(service.isXtreamCategoryLocked('p', 'live', 1)).toBe(true);
|
||||
|
||||
prompt.requestPin.mockResolvedValue('1234');
|
||||
await expect(service.requestUnlock()).resolves.toBe(true);
|
||||
expect(service.withholdsEverything()).toBe(false);
|
||||
});
|
||||
|
||||
it('withholds everything while the initial lock store read is in flight', async () => {
|
||||
storage.pinHash = await hashParentalLockPin('1234');
|
||||
parentalLockEnabled.set(true);
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
ParentalLockXtreamCategoryType,
|
||||
verifyParentalLockPin,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { RuntimeCapabilitiesService } from '../runtime-capabilities.service';
|
||||
import { SettingsStore } from '../settings-store.service';
|
||||
import { ParentalLockIdleTimer } from './parental-lock-idle-timer';
|
||||
import {
|
||||
@@ -54,6 +55,23 @@ export class ParentalLockService {
|
||||
private readonly storage = inject(ParentalLockStorageService);
|
||||
private readonly locks = inject(ParentalLockLockStore);
|
||||
private readonly prompt = inject(PARENTAL_LOCK_PROMPT, { optional: true });
|
||||
/**
|
||||
* Electron reads Xtream through the SQLite worker, but the bridge lacks
|
||||
* the lock-state or index IPC (a partial or older preload): the worker
|
||||
* cannot withhold locked rows, so the locked session withholds all.
|
||||
*/
|
||||
private readonly workerFilterMissing = (() => {
|
||||
const runtime = inject(RuntimeCapabilitiesService);
|
||||
const missing =
|
||||
runtime.supportsXtreamSqliteDataSource &&
|
||||
!runtime.supportsParentalLockSqliteFilter;
|
||||
if (missing) {
|
||||
console.error(
|
||||
'The parental lock bridge is incomplete; locked sessions withhold every category.'
|
||||
);
|
||||
}
|
||||
return missing;
|
||||
})();
|
||||
|
||||
private readonly unlockedState = signal(false);
|
||||
/** Shared by every unlock prompt: a reopened dialog keeps the cooldown. */
|
||||
@@ -109,7 +127,9 @@ export class ParentalLockService {
|
||||
* protected content precisely during a storage failure.
|
||||
*/
|
||||
readonly withholdsEverything = computed(
|
||||
() => this.active() && !this.locks.readable()
|
||||
() =>
|
||||
this.active() &&
|
||||
(this.workerFilterMissing || !this.locks.readable())
|
||||
);
|
||||
/** Bumps whenever `active` or the lock store changes; consumers re-query. */
|
||||
readonly version = computed(
|
||||
@@ -379,7 +399,7 @@ export class ParentalLockService {
|
||||
): boolean {
|
||||
return (
|
||||
this.active() &&
|
||||
(!this.locks.readable() ||
|
||||
(this.withholdsEverything() ||
|
||||
this.lockedXtreamIds(playlistId, categoryType).includes(
|
||||
xtreamId
|
||||
))
|
||||
@@ -396,7 +416,7 @@ export class ParentalLockService {
|
||||
}
|
||||
// Fail-closed mode withholds rows without a genre too: "unknown
|
||||
// genre" is not "no locked genre" while the locks are unknown.
|
||||
if (!this.locks.readable()) {
|
||||
if (this.withholdsEverything()) {
|
||||
return true;
|
||||
}
|
||||
if (categoryId === null || categoryId === undefined) {
|
||||
@@ -410,7 +430,7 @@ export class ParentalLockService {
|
||||
isM3uGroupLocked(playlistId: string, groupTitle: string): boolean {
|
||||
return (
|
||||
this.active() &&
|
||||
(!this.locks.readable() ||
|
||||
(this.withholdsEverything() ||
|
||||
this.lockedGroupTitles(playlistId).includes(groupTitle))
|
||||
);
|
||||
}
|
||||
|
||||
@@ -20,6 +20,16 @@ describe('RuntimeCapabilitiesService', () => {
|
||||
expect(service.supportsPortalConnectivityGuard).toBe(false);
|
||||
});
|
||||
|
||||
it('offers the parental lock worker filter only with both bridge methods', () => {
|
||||
const service = new RuntimeCapabilitiesService();
|
||||
testWindow.electron = { setParentalLockState: jest.fn() };
|
||||
expect(service.supportsParentalLockSqliteFilter).toBe(false);
|
||||
testWindow.electron['dbSetCategoryLocks'] = jest.fn();
|
||||
expect(service.supportsParentalLockSqliteFilter).toBe(true);
|
||||
testWindow.electron = undefined;
|
||||
expect(service.supportsParentalLockSqliteFilter).toBe(false);
|
||||
});
|
||||
|
||||
it('reports browser PWA capabilities when the Electron bridge is absent', () => {
|
||||
testWindow.electron = undefined;
|
||||
|
||||
|
||||
@@ -180,6 +180,19 @@ export class RuntimeCapabilitiesService {
|
||||
].every((methodName) => this.hasElectronMethod(methodName));
|
||||
}
|
||||
|
||||
/**
|
||||
* The worker-side parental lock filter: the lock state reaches the
|
||||
* SQLite worker and the `categories.locked` index can be stamped. When
|
||||
* the Xtream SQLite data source is used without it, the worker cannot
|
||||
* withhold locked rows and the lock service fails closed instead.
|
||||
*/
|
||||
get supportsParentalLockSqliteFilter(): boolean {
|
||||
return (
|
||||
this.hasElectronMethod('setParentalLockState') &&
|
||||
this.hasElectronMethod('dbSetCategoryLocks')
|
||||
);
|
||||
}
|
||||
|
||||
get supportsXtreamSqliteDataSource(): boolean {
|
||||
return [
|
||||
'dbGetPlaylist',
|
||||
|
||||
Reference in new issue
Block a user