diff --git a/.changes/parental-lock-external-players.md b/.changes/parental-lock-external-players.md new file mode 100644 index 0000000000..18d9aa515a --- /dev/null +++ b/.changes/parental-lock-external-players.md @@ -0,0 +1,6 @@ +--- +type: fix +area: parental-lock +--- + +Locking the app again now closes an MPV or VLC window opened while it was unlocked from a source with locked categories, also after the window reloads. Films in MPV, VLC and Embedded MPV no longer lock half way. A stream that stops playing no longer keeps the screen awake and the app unlocked. diff --git a/apps/electron-backend-e2e/src/parental-lock-external-player.e2e.ts b/apps/electron-backend-e2e/src/parental-lock-external-player.e2e.ts new file mode 100644 index 0000000000..69c3dc26a2 --- /dev/null +++ b/apps/electron-backend-e2e/src/parental-lock-external-player.e2e.ts @@ -0,0 +1,202 @@ +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import type { Page } from '@playwright/test'; +import { + addXtreamPortal, + closeElectronApp, + expect, + launchElectronApp, + openSettings, + openSettingsSection, + openWorkspaceSection, + test, + waitForXtreamWorkspaceReady, +} from './electron-test-fixtures'; +import { reloadFromMainProcess } from './renderer-reload.support'; + +const PIN = '2468'; +const PID_KEY = '__parentalLockPlayerPids'; + +function stopPlayer(pid: number): void { + try { + process.kill(pid); + } catch { + // Already gone. + } +} + +function playerIsAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + +async function launchPlayer( + app: Awaited>, + playerPath: string, + title: string +): Promise { + const before = await app.electronApp.evaluate( + (_, key) => (Reflect.get(globalThis, key) as number[]).length, + PID_KEY + ); + await app.mainWindow.evaluate( + async ({ playerPath, title }) => { + await window.electron.setMpvPlayerPath(playerPath); + await window.electron.openInMpv( + 'https://streams.example.test/synthetic.ts', + title, + '', + '' + ); + }, + { playerPath, title } + ); + await expect + .poll(() => + app.electronApp.evaluate( + (_, key) => (Reflect.get(globalThis, key) as number[]).length, + PID_KEY + ) + ) + .toBe(before + 1); + return app.electronApp.evaluate( + (_, key) => (Reflect.get(globalThis, key) as number[]).at(-1) ?? 0, + PID_KEY + ); +} + +async function enterPin(page: Page): Promise { + await page.getByTestId('parental-lock-pin').fill(PIN); + await page.getByTestId('parental-lock-pin-submit').click(); +} + +/** + * A relock closes an MPV session opened while unlocked from a source with + * a locked category, and leaves one opened while locked; a renderer reload + * closes one the unlocked page left running. The native process boundary is + * replaced by a quiet, long-lived child; CI installs no MPV. + */ +test('@electron @parental @playback relock and reload close an MPV session opened while unlocked', async ({ + dataDir, +}) => { + test.setTimeout(120_000); + const directory = mkdtempSync(join(tmpdir(), 'iptvnator-lock-player-')); + const playerPath = join(directory, 'synthetic-mpv'); + const app = await launchElectronApp(dataDir); + const pids: number[] = []; + try { + const page = app.mainWindow; + await app.electronApp.evaluate( + (_, { playerPath, nodePath, key }) => { + const childProcess = process.getBuiltinModule('child_process'); + const spawn = childProcess.spawn; + Reflect.set(globalThis, key, []); + childProcess.spawn = ((command, ...args) => { + if (command !== playerPath) + return Reflect.apply(spawn, childProcess, [ + command, + ...args, + ]); + const child = spawn( + nodePath, + ['-e', 'setInterval(() => {}, 1000)'], + { stdio: ['ignore', 'pipe', 'pipe'], detached: false } + ); + (Reflect.get(globalThis, key) as number[]).push( + child.pid as number + ); + return child; + }) as typeof spawn; + }, + { playerPath, nodePath: process.execPath, key: PID_KEY } + ); + await page.evaluate(() => + window.electron.updateSettings({ mpvReuseInstance: true }) + ); + + // Enabling the lock leaves this session unlocked. + await openSettings(page); + await openSettingsSection(page, 'parental'); + const toggle = page.locator( + '[data-test-id="parental-lock-enabled"] button[role="switch"]' + ); + await toggle.click(); + await page.getByTestId('parental-lock-pin').fill(PIN); + await page.getByTestId('parental-lock-pin-confirm').fill(PIN); + await page.getByTestId('parental-lock-pin-submit').click(); + await expect(toggle).toHaveAttribute('aria-checked', 'true'); + + await addXtreamPortal(page, { name: 'Synthetic Lock Portal' }); + await waitForXtreamWorkspaceReady(page); + await openWorkspaceSection(page, 'Live TV'); + await page.getByTestId('context-manage-categories').click(); + const dialog = page.locator('app-category-management-dialog'); + await dialog + .locator('[data-test-id^="category-lock-"]') + .first() + .click(); + await dialog.getByRole('button', { name: 'Save', exact: true }).click(); + await expect(dialog).toBeHidden(); + await expect(page).toHaveURL(/\/workspace\/xtreams\/[^/]+\/live/); + + // Opened while unlocked, from a source with a locked category. + const unlockedPid = await launchPlayer( + app, + playerPath, + 'Synthetic unlocked launch' + ); + pids.push(unlockedPid); + expect(playerIsAlive(unlockedPid)).toBe(true); + + const lockButton = page.getByTestId('header-parental-lock'); + await lockButton.click(); + await expect(lockButton).toHaveClass(/is-active/); + await expect.poll(() => playerIsAlive(unlockedPid)).toBe(false); + + // Opened while locked: it came from the filtered catalog and must + // survive an unlock and the next relock. + const lockedPid = await launchPlayer( + app, + playerPath, + 'Synthetic locked launch' + ); + pids.push(lockedPid); + await lockButton.click(); + await enterPin(page); + await expect(lockButton).not.toHaveClass(/is-active/); + await lockButton.click(); + await expect(lockButton).toHaveClass(/is-active/); + // Nothing to wait for when the session is rightly left alone: give + // a wrong close (sent at once on relock) time to land. + // eslint-disable-next-line playwright/no-wait-for-timeout + await page.waitForTimeout(3_000); + expect(playerIsAlive(lockedPid)).toBe(true); + stopPlayer(lockedPid); + + // A reload forgets what the page saw; the new page starts locked and + // closes a player still running from the unlocked page. + await lockButton.click(); + await enterPin(page); + await expect(lockButton).not.toHaveClass(/is-active/); + const reloadedPid = await launchPlayer( + app, + playerPath, + 'Synthetic launch before a reload' + ); + pids.push(reloadedPid); + await reloadFromMainProcess(app); + await expect(page.getByTestId('header-parental-lock')).toHaveClass( + /is-active/ + ); + await expect.poll(() => playerIsAlive(reloadedPid)).toBe(false); + } finally { + pids.forEach(stopPlayer); + await closeElectronApp(app); + rmSync(directory, { recursive: true, force: true }); + } +}); diff --git a/apps/electron-backend/src/app/api/main.preload.ts b/apps/electron-backend/src/app/api/main.preload.ts index 4e1a177caa..b4b120a6be 100644 --- a/apps/electron-backend/src/app/api/main.preload.ts +++ b/apps/electron-backend/src/app/api/main.preload.ts @@ -631,6 +631,8 @@ const electronApi: ElectronBridgeApi = { sessionId, ...(exact ? [true] : []) ), + listExternalPlayerSessions: (): Promise => + ipcRenderer.invoke('LIST_EXTERNAL_PLAYER_SESSIONS'), getEmbeddedMpvSupport: (): Promise => ipcRenderer.invoke('EMBEDDED_MPV_SUPPORT'), prepareEmbeddedMpv: (): Promise => diff --git a/apps/electron-backend/src/app/events/external-player-session-registry.spec.ts b/apps/electron-backend/src/app/events/external-player-session-registry.spec.ts index d0dcccde09..6c70baaa25 100644 --- a/apps/electron-backend/src/app/events/external-player-session-registry.spec.ts +++ b/apps/electron-backend/src/app/events/external-player-session-registry.spec.ts @@ -55,6 +55,27 @@ describe('ExternalPlayerSessionRegistry', () => { ); }); + it('lists the sessions whose player may still run', () => { + const begin = (title: string) => + registry.beginSession({ + player: 'mpv', + title, + streamUrl: 'https://streams.example.test/one.m3u8', + }); + const launching = begin('Launching'); + const opened = begin('Opened'); + const closed = begin('Closed'); + const failed = begin('Failed'); + registry.markOpened(opened.id); + registry.markClosed(closed.id); + registry.markError(failed.id, 'synthetic failure'); + + expect(registry.listOpenSessions().map(({ id }) => id)).toEqual([ + launching.id, + opened.id, + ]); + }); + it('keeps close capability and closes the session explicitly', async () => { const session = registry.beginSession({ player: 'vlc', diff --git a/apps/electron-backend/src/app/events/external-player-session-registry.ts b/apps/electron-backend/src/app/events/external-player-session-registry.ts index b143d9457c..0a5cf3d6e6 100644 --- a/apps/electron-backend/src/app/events/external-player-session-registry.ts +++ b/apps/electron-backend/src/app/events/external-player-session-registry.ts @@ -96,6 +96,13 @@ export class ExternalPlayerSessionRegistry { return this.sessions.get(id)?.snapshot ?? null; } + /** Every session whose player may still be running. */ + listOpenSessions(): ExternalPlayerSession[] { + return [...this.sessions.values()] + .map(({ snapshot }) => snapshot) + .filter(isRestorableSession); + } + isNewerLaunch(id: string | null, session: ExternalPlayerSession): boolean { const order = id ? this.getSession(id)?.launchOrder : undefined; return (order ?? 0) > (session.launchOrder ?? 0); diff --git a/apps/electron-backend/src/app/events/player.events.ts b/apps/electron-backend/src/app/events/player.events.ts index bb52f8734c..fbfb2b606e 100644 --- a/apps/electron-backend/src/app/events/player.events.ts +++ b/apps/electron-backend/src/app/events/player.events.ts @@ -1,6 +1,7 @@ import { ipcMain } from 'electron'; import { CLOSE_EXTERNAL_PLAYER_SESSION, + LIST_EXTERNAL_PLAYER_SESSIONS, type ExternalPlayerName, PLAYBACK_SET_KEEP_AWAKE, PlayerContentInfo, @@ -188,6 +189,10 @@ ipcMain.handle( } ); +ipcMain.handle(LIST_EXTERNAL_PLAYER_SESSIONS, () => + externalPlayerSessions.listOpenSessions() +); + ipcMain.handle(PLAYBACK_SET_KEEP_AWAKE, (event, active: boolean) => { setPlaybackKeepAwake(event.sender, active === true); }); diff --git a/apps/web/src/app/services/parental-lock-enforcement.service.spec.ts b/apps/web/src/app/services/parental-lock-enforcement.service.spec.ts index 8549df0fee..1eec1c784e 100644 --- a/apps/web/src/app/services/parental-lock-enforcement.service.spec.ts +++ b/apps/web/src/app/services/parental-lock-enforcement.service.spec.ts @@ -9,6 +9,7 @@ import { } from '@iptvnator/portal/xtream/data-access'; import { ParentalLockService } from '@iptvnator/services'; import { ParentalLockEnforcementService } from './parental-lock-enforcement.service'; +import { ParentalLockExternalSessions } from './parental-lock-external-sessions'; import { PlaybackKeepAwakeService } from './playback-keep-awake.service'; interface Applier { @@ -546,18 +547,40 @@ describe('ParentalLockEnforcementService apply serialization', () => { }); describe('ParentalLockEnforcementService busy probe', () => { - it('counts playing audio (the radio player) as activity', () => { - let probe: (() => boolean) | undefined; + let probe: (() => boolean) | undefined; + let version: ReturnType>; + let active: ReturnType>; + let externalSessions: { + start: jest.Mock; + isBusy: jest.Mock; + onLockChange: jest.Mock; + }; + + beforeEach(() => { + probe = undefined; + version = signal(0); + active = signal(false); + externalSessions = { + start: jest.fn(), + isBusy: jest.fn(() => false), + onLockChange: jest.fn(), + }; TestBed.resetTestingModule(); TestBed.configureTestingModule({ providers: [ { provide: ParentalLockService, useValue: { - version: signal(0), + version, + active, + isM3uGroupLocked: () => false, registerBusyProbe: (fn: () => boolean) => (probe = fn), }, }, + { + provide: ParentalLockExternalSessions, + useValue: externalSessions, + }, { provide: XtreamStore, useValue: {} }, { provide: XTREAM_DATA_SOURCE, useValue: {} }, { provide: StalkerStore, useValue: {} }, @@ -574,6 +597,26 @@ describe('ParentalLockEnforcementService busy probe', () => { }); const service = TestBed.inject(ParentalLockEnforcementService); TestBed.runInInjectionContext(() => service.start()); + TestBed.flushEffects(); + }); + + it('counts players outside the page (Embedded MPV, MPV/VLC films)', () => { + expect(externalSessions.start).toHaveBeenCalled(); + expect(probe?.()).toBe(false); + + externalSessions.isBusy.mockReturnValue(true); + expect(probe?.()).toBe(true); + }); + + it('hands a relock to the external session closer', () => { + active.set(true); + version.set(1); + TestBed.flushEffects(); + + expect(externalSessions.onLockChange).toHaveBeenCalledTimes(1); + }); + + it('counts playing audio (the radio player) as activity', () => { expect(probe?.()).toBe(false); const audio = document.createElement('audio'); diff --git a/apps/web/src/app/services/parental-lock-enforcement.service.ts b/apps/web/src/app/services/parental-lock-enforcement.service.ts index 16a0927559..1b78d8ebd6 100644 --- a/apps/web/src/app/services/parental-lock-enforcement.service.ts +++ b/apps/web/src/app/services/parental-lock-enforcement.service.ts @@ -14,6 +14,7 @@ import { } from '@iptvnator/portal/xtream/data-access'; import { ParentalLockService } from '@iptvnator/services'; import { toParentalLockXtreamCategoryType } from '@iptvnator/shared/interfaces'; +import { ParentalLockExternalSessions } from './parental-lock-external-sessions'; import { PlaybackKeepAwakeService } from './playback-keep-awake.service'; const XTREAM_ROUTE = @@ -37,6 +38,7 @@ export class ParentalLockEnforcementService { private readonly router = inject(Router); private readonly store = inject(Store); private readonly keepAwake = inject(PlaybackKeepAwakeService); + private readonly externalSessions = inject(ParentalLockExternalSessions); private readonly activeChannel = this.store.selectSignal(selectActive); private started = false; private lastVersion = -1; @@ -50,10 +52,15 @@ export class ParentalLockEnforcementService { } this.started = true; // Playback counts as activity: video through the keep-awake - // tracker, and audio (the radio player) read directly, since the - // keep-awake service deliberately ignores