mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-16 22:26:16 -08:00
fix(settings): fail closed on a rejected worker lock sync, tear down Stalker synchronously and capture the Xtream dialog context before the PIN
- A rejected lock-state sync to the SQLite worker makes the locked session withhold everything until a later sync succeeds. - The Stalker enforcement chunk is preloaded when a Stalker route opens; a relock runs it synchronously, or leaves the route at once while it is not loaded, instead of awaiting the chunk. - Xtream "Manage categories" captures playlist, provider and section before the PIN prompt and re-checks them after it and after the dialog import. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
4aa1b5a8e6
commit
067f920833
8 files changed
+267
-62
No files matched your search
@@ -1,17 +1,29 @@
|
||||
import { signal } from '@angular/core';
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
/**
|
||||
* Tells the SQLite worker (through main) whether locked rows are withheld.
|
||||
* Resolves false when the IPC rejected: the worker keeps its previous
|
||||
* state, so the caller must not trust worker reads while locked.
|
||||
*/
|
||||
export async function syncParentalLockStateToMainProcess(
|
||||
active: boolean
|
||||
): Promise<boolean> {
|
||||
const bridge = window.electron;
|
||||
if (typeof bridge?.setParentalLockState !== 'function') {
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
void bridge.setParentalLockState(active).catch((error) => {
|
||||
try {
|
||||
await bridge.setParentalLockState(active);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Failed to sync the parental lock state.', error);
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -36,3 +48,47 @@ export async function mirrorParentalLockEnabledSetting(
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 a locked session withholds everything.
|
||||
*/
|
||||
export function isParentalLockWorkerFilterMissing(runtime: {
|
||||
supportsXtreamSqliteDataSource: boolean;
|
||||
supportsParentalLockSqliteFilter: boolean;
|
||||
}): boolean {
|
||||
const missing =
|
||||
runtime.supportsXtreamSqliteDataSource &&
|
||||
!runtime.supportsParentalLockSqliteFilter;
|
||||
if (missing) {
|
||||
console.error(
|
||||
'The parental lock bridge is incomplete; locked sessions withhold every category.'
|
||||
);
|
||||
}
|
||||
return missing;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the SQLite worker learned the current lock state. A rejected
|
||||
* sync leaves the worker in its previous state, so a locked session must
|
||||
* not trust worker reads until a later sync succeeds.
|
||||
*/
|
||||
export class ParentalLockWorkerSync {
|
||||
readonly failed = signal(false);
|
||||
/** Bumps whenever `failed` changes; part of the lock version. */
|
||||
readonly changes = signal(0);
|
||||
|
||||
/**
|
||||
* Syncs `active`. A sync superseded by a newer transition (`current()`
|
||||
* no longer equals `active`) changes nothing.
|
||||
*/
|
||||
async sync(active: boolean, current: () => boolean): Promise<void> {
|
||||
const failed = !(await syncParentalLockStateToMainProcess(active));
|
||||
if (current() !== active || this.failed() === failed) {
|
||||
return;
|
||||
}
|
||||
this.failed.set(failed);
|
||||
this.changes.update((value) => value + 1);
|
||||
}
|
||||
}
|
||||
@@ -212,6 +212,35 @@ describe('ParentalLockService', () => {
|
||||
expect(service.withholdsEverything()).toBe(false);
|
||||
});
|
||||
|
||||
it('withholds everything while locked when the worker lock-state sync rejects', async () => {
|
||||
jest.spyOn(console, 'error').mockImplementation(() => undefined);
|
||||
setParentalLockState.mockRejectedValue(new Error('no handler'));
|
||||
storage.pinHash = await hashParentalLockPin('1234');
|
||||
parentalLockEnabled.set(true);
|
||||
|
||||
const service = await createService();
|
||||
const version = service.version();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(service.active()).toBe(true);
|
||||
expect(service.withholdsEverything()).toBe(true);
|
||||
expect(service.version()).toBeGreaterThan(version);
|
||||
|
||||
// The sync of the next transition succeeds: normal filtering again.
|
||||
setParentalLockState.mockResolvedValue(undefined);
|
||||
prompt.requestPin.mockResolvedValue('1234');
|
||||
await service.requestUnlock();
|
||||
TestBed.flushEffects();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
service.lock();
|
||||
TestBed.flushEffects();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
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);
|
||||
|
||||
@@ -26,7 +26,10 @@ import {
|
||||
PARENTAL_LOCK_PROMPT,
|
||||
ParentalLockPromptRequest,
|
||||
} from './parental-lock-prompt.token';
|
||||
import { syncParentalLockStateToMainProcess } from './parental-lock-bridge';
|
||||
import {
|
||||
isParentalLockWorkerFilterMissing,
|
||||
ParentalLockWorkerSync,
|
||||
} from './parental-lock-bridge';
|
||||
import {
|
||||
ensureParentalLockSettingsReadable,
|
||||
persistParentalLockEnabled,
|
||||
@@ -60,18 +63,9 @@ export class ParentalLockService {
|
||||
* 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 workerFilterMissing = isParentalLockWorkerFilterMissing(
|
||||
inject(RuntimeCapabilitiesService)
|
||||
);
|
||||
|
||||
private readonly unlockedState = signal(false);
|
||||
/** Shared by every unlock prompt: a reopened dialog keeps the cooldown. */
|
||||
@@ -80,6 +74,7 @@ export class ParentalLockService {
|
||||
/** The PIN hash could not be read; retried before PIN-protected steps. */
|
||||
private readonly pinUnreadable = signal(false);
|
||||
private readonly versionState = signal(0);
|
||||
private readonly workerSync = new ParentalLockWorkerSync();
|
||||
// The main process starts LOCKED whenever the feature is on (mirrored
|
||||
// setting). Reporting our state before settings have loaded would send a
|
||||
// spurious "unlocked" and open a window of unfiltered reads.
|
||||
@@ -129,11 +124,16 @@ export class ParentalLockService {
|
||||
readonly withholdsEverything = computed(
|
||||
() =>
|
||||
this.active() &&
|
||||
(this.workerFilterMissing || !this.locks.readable())
|
||||
(this.workerFilterMissing ||
|
||||
this.workerSync.failed() ||
|
||||
!this.locks.readable())
|
||||
);
|
||||
/** Bumps whenever `active` or the lock store changes; consumers re-query. */
|
||||
readonly version = computed(
|
||||
() => this.versionState() + this.locks.revision()
|
||||
() =>
|
||||
this.versionState() +
|
||||
this.locks.revision() +
|
||||
this.workerSync.changes()
|
||||
);
|
||||
readonly relockMinutes = computed(() =>
|
||||
normalizeParentalLockRelockMinutes(
|
||||
@@ -156,7 +156,7 @@ export class ParentalLockService {
|
||||
// process keeps its mirrored (locked) default rather than
|
||||
// being told "unlocked" on the strength of default settings.
|
||||
if (!this.switchUnknown() || this.hasPin()) {
|
||||
syncParentalLockStateToMainProcess(active);
|
||||
void this.workerSync.sync(active, () => this.active());
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user