mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
perf(electron): look up the login shell PATH without blocking the main thread (#1784)
* perf(electron): look up the login shell PATH without blocking the main thread fix-path ran $SHELL -ilc env synchronously right after the first load. With a typical zsh profile that held the main thread for 1-2 s, while the database worker's ready message and the renderer's first IPC calls waited, so the launch journey's first card came that much later. Use shell-path's async shellPath() with fix-path's fallback, so the resulting PATH is the same and the main thread stays free. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(perf): name the PR that made the login shell PATH lookup async Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(electron): let bare-name player spawns wait for the login shell PATH With the lookup now asynchronous, an external player launched (or the Linux embedded MPV support check, which runs and caches a bare `mpv --version`) within the first seconds could see the inherited PATH. The OPEN_MPV_PLAYER / OPEN_VLC_PLAYER handlers and every embedded MPV handler now await waitForLoginShellPath() (settled lookup, at most 10 s, immediate on Windows), restoring the guarantee the blocking lookup gave. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(electron): wait for the login shell PATH only for bare-name spawns External players wait only when they resolve to a bare name (no configured path, no well-known install found); a path to an executable starts at once. Embedded MPV waits only on Linux and only for support and prepare, which run the cached bare-name `mpv --version` check; sessions and controls never wait. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(electron): wait for the login shell PATH only before the mpv probe Embedded MPV support and prepare waited on every Linux call, although getSupport() returns before the bare-name `mpv --version` probe for the frame-copy engine, native Wayland, a disabled feature or a cached result. willProbeLinuxMpvExecutable() now gates the wait on the probe actually running. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(electron): stop waiting for a login shell that already timed out once After the first wait for a hung `$SHELL -ilc env` runs out, later bare-name player launches and Linux mpv probes proceed at once instead of each waiting the full limit again. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(electron): bound login shell PATH waits by the lookup's own budget Replace the latch on the first expired wait with a deadline set when the lookup starts (10 s). Every wait ends when the lookup settles or the deadline passes: a launch retried while the shell is still within its budget waits for the PATH again, and once the budget is spent no launch waits, so a hung shell still delays at most the first seconds. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(electron): re-probe a missing mpv once a late login shell answers When the PATH lookup runs out of budget, the Linux embedded MPV support check probes `mpv --version` with the inherited PATH and caches a missing result for the rest of the session. waitForLoginShellPath() now reports whether the lookup settled; after a timed-out wait the handler forgets a cached "missing" once the lookup finishes, so the next support check probes again with the login shell PATH. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(electron): share one deadline among waits before the PATH lookup starts A bare-name launch that waited before the lookup was scheduled started its own 10 s limit, so while startup was stuck every retry paid the full delay again. The first early wait now sets the shared deadline; the lookup still replaces it with its own budget when it starts. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(electron): re-probe mpv after a late login shell PATH either way A Linux mpv probe that ran on the inherited PATH can be wrong in both directions: the login shell PATH may add mpv or drop the directory the inherited one found it in. forgetLinuxMpvExecutableProbe() now clears a found result as well as a missing one. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(electron): skip the login shell PATH wait for Flatpak host launches In Flatpak, players start through `flatpak-spawn --host`, which resolves the name with the host's PATH; the sandbox's login shell lookup cannot change it. The launch handlers now decide from the same launch context the player uses: no wait in flatpak-host mode, otherwise only for a bare player name. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: 4gray <fourgray@proton.me> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
14 files changed
+702
-74
No files matched your search
@@ -0,0 +1,9 @@
|
||||
---
|
||||
type: perf
|
||||
area: electron
|
||||
---
|
||||
|
||||
On macOS and Linux the app shows your sources sooner after launch: looking up
|
||||
the PATH from your login shell (used to find MPV and VLC) no longer freezes the
|
||||
app while the shell starts, which took one to two seconds with a typical zsh
|
||||
setup.
|
||||
@@ -6,7 +6,10 @@ jest.mock('electron', () => ({
|
||||
|
||||
const mockEmbeddedMpvService = {
|
||||
createSession: jest.fn(),
|
||||
prepareAddon: jest.fn(),
|
||||
getSupport: jest.fn(),
|
||||
willProbeLinuxMpvExecutable: jest.fn(() => false),
|
||||
forgetLinuxMpvExecutableProbe: jest.fn(),
|
||||
setPaused: jest.fn(),
|
||||
};
|
||||
const mockSessionOptions = {
|
||||
@@ -21,10 +24,20 @@ jest.mock('../services/embedded-mpv-native.service', () => ({
|
||||
jest.mock('../services/embedded-mpv-session-options', () => ({
|
||||
readEmbeddedMpvSessionOptions: () => mockSessionOptions,
|
||||
}));
|
||||
const mockWaitForLoginShellPath = jest.fn(() => Promise.resolve(true));
|
||||
let settleLookup: () => void = () => undefined;
|
||||
const mockLookupSettled = new Promise<void>((resolve) => {
|
||||
settleLookup = resolve;
|
||||
});
|
||||
jest.mock('../startup/login-shell-path', () => ({
|
||||
waitForLoginShellPath: () => mockWaitForLoginShellPath(),
|
||||
whenLoginShellPathSettled: () => mockLookupSettled,
|
||||
}));
|
||||
|
||||
import { ipcMain } from 'electron';
|
||||
import {
|
||||
EMBEDDED_MPV_CREATE_SESSION,
|
||||
EMBEDDED_MPV_PREPARE,
|
||||
EMBEDDED_MPV_SET_PAUSED,
|
||||
EMBEDDED_MPV_SUPPORT,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
@@ -55,6 +68,91 @@ describe('EmbeddedMpvEvents IPC handlers', () => {
|
||||
mockEmbeddedMpvService.setPaused.mockReset();
|
||||
});
|
||||
|
||||
describe('support checks and the login shell PATH', () => {
|
||||
afterEach(() => {
|
||||
mockWaitForLoginShellPath.mockClear();
|
||||
mockEmbeddedMpvService.willProbeLinuxMpvExecutable.mockReset();
|
||||
mockEmbeddedMpvService.willProbeLinuxMpvExecutable.mockReturnValue(
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
it.each([EMBEDDED_MPV_SUPPORT, EMBEDDED_MPV_PREPARE])(
|
||||
'%s waits for the lookup before the bare-name mpv probe',
|
||||
async (channel) => {
|
||||
mockEmbeddedMpvService.willProbeLinuxMpvExecutable.mockReturnValue(
|
||||
true
|
||||
);
|
||||
let settle: (settled: boolean) => void = () => undefined;
|
||||
mockWaitForLoginShellPath.mockReturnValueOnce(
|
||||
new Promise<boolean>((resolve) => {
|
||||
settle = resolve;
|
||||
})
|
||||
);
|
||||
mockEmbeddedMpvService.getSupport.mockReturnValue({
|
||||
supported: true,
|
||||
});
|
||||
mockEmbeddedMpvService.prepareAddon.mockReturnValue({
|
||||
supported: true,
|
||||
});
|
||||
|
||||
const support = getIpcMainHandler(channel)({});
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
expect(
|
||||
mockEmbeddedMpvService.getSupport
|
||||
).not.toHaveBeenCalled();
|
||||
expect(
|
||||
mockEmbeddedMpvService.prepareAddon
|
||||
).not.toHaveBeenCalled();
|
||||
|
||||
settle(true);
|
||||
await expect(support).resolves.toEqual({ supported: true });
|
||||
expect(
|
||||
mockEmbeddedMpvService.forgetLinuxMpvExecutableProbe
|
||||
).not.toHaveBeenCalled();
|
||||
}
|
||||
);
|
||||
|
||||
it('re-probes mpv once a lookup that ran out finally answers', async () => {
|
||||
mockEmbeddedMpvService.willProbeLinuxMpvExecutable.mockReturnValue(
|
||||
true
|
||||
);
|
||||
mockWaitForLoginShellPath.mockResolvedValueOnce(false);
|
||||
mockEmbeddedMpvService.getSupport.mockReturnValue({
|
||||
supported: false,
|
||||
});
|
||||
|
||||
await expect(
|
||||
getIpcMainHandler(EMBEDDED_MPV_SUPPORT)({})
|
||||
).resolves.toEqual({ supported: false });
|
||||
expect(
|
||||
mockEmbeddedMpvService.forgetLinuxMpvExecutableProbe
|
||||
).not.toHaveBeenCalled();
|
||||
|
||||
settleLookup();
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
expect(
|
||||
mockEmbeddedMpvService.forgetLinuxMpvExecutableProbe
|
||||
).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not wait when no probe runs, nor for session calls', async () => {
|
||||
mockEmbeddedMpvService.getSupport.mockReturnValue({
|
||||
supported: true,
|
||||
engine: 'frame-copy',
|
||||
});
|
||||
mockEmbeddedMpvService.createSession.mockReturnValue({ id: 's' });
|
||||
|
||||
await getIpcMainHandler(EMBEDDED_MPV_SUPPORT)({});
|
||||
await getIpcMainHandler(EMBEDDED_MPV_CREATE_SESSION)(
|
||||
{},
|
||||
{ x: 0, y: 0, width: 1, height: 1 }
|
||||
);
|
||||
|
||||
expect(mockWaitForLoginShellPath).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('creates a session with the options read from the settings mirror', async () => {
|
||||
const session = { id: 'session-1', status: 'idle' };
|
||||
mockEmbeddedMpvService.createSession.mockReturnValue(session);
|
||||
|
||||
@@ -33,6 +33,10 @@ import {
|
||||
embeddedMpvNativeService,
|
||||
} from '../services/embedded-mpv-native.service';
|
||||
import { readEmbeddedMpvSessionOptions } from '../services/embedded-mpv-session-options';
|
||||
import {
|
||||
waitForLoginShellPath,
|
||||
whenLoginShellPathSettled,
|
||||
} from '../startup/login-shell-path';
|
||||
|
||||
export default class EmbeddedMpvEvents {
|
||||
static bootstrapEmbeddedMpvEvents(): Electron.IpcMain {
|
||||
@@ -64,9 +68,34 @@ function handleEmbeddedMpv<Args extends unknown[]>(
|
||||
});
|
||||
}
|
||||
|
||||
handleEmbeddedMpv(EMBEDDED_MPV_SUPPORT, () => getService().getSupport());
|
||||
/**
|
||||
* On Linux, support (and prepare, which checks support first) may run
|
||||
* `mpv --version` by bare name and cache the result, so it must see the
|
||||
* login shell PATH. Only that probe waits; nothing else here spawns by name.
|
||||
*/
|
||||
async function afterLoginShellPathIfProbing<T>(check: () => T): Promise<T> {
|
||||
if (
|
||||
!getService().willProbeLinuxMpvExecutable() ||
|
||||
(await waitForLoginShellPath())
|
||||
) {
|
||||
return check();
|
||||
}
|
||||
// The lookup ran out of budget, so this probe sees the inherited PATH.
|
||||
// Once the shell does answer, the result is probed again.
|
||||
const result = check();
|
||||
void whenLoginShellPathSettled().then(() =>
|
||||
getService().forgetLinuxMpvExecutableProbe()
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
||||
handleEmbeddedMpv(EMBEDDED_MPV_PREPARE, () => getService().prepareAddon());
|
||||
handleEmbeddedMpv(EMBEDDED_MPV_SUPPORT, () =>
|
||||
afterLoginShellPathIfProbing(() => getService().getSupport())
|
||||
);
|
||||
|
||||
handleEmbeddedMpv(EMBEDDED_MPV_PREPARE, () =>
|
||||
afterLoginShellPathIfProbing(() => getService().prepareAddon())
|
||||
);
|
||||
|
||||
handleEmbeddedMpv(
|
||||
EMBEDDED_MPV_CREATE_SESSION,
|
||||
|
||||
@@ -4,6 +4,11 @@ jest.mock('electron', () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('fs', () => {
|
||||
const actual = jest.requireActual<typeof import('fs')>('fs');
|
||||
return { ...actual, existsSync: jest.fn(actual.existsSync) };
|
||||
});
|
||||
|
||||
jest.mock('child_process', () => ({
|
||||
spawn: jest.fn(),
|
||||
}));
|
||||
@@ -26,6 +31,10 @@ jest.mock('../services/store.service', () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('../startup/login-shell-path', () => ({
|
||||
waitForLoginShellPath: jest.fn(() => Promise.resolve()),
|
||||
}));
|
||||
|
||||
jest.mock('../services/stalker-playback-context.service', () => ({
|
||||
getStalkerPlaybackContextHeaders: jest.fn(() => undefined),
|
||||
}));
|
||||
@@ -33,6 +42,7 @@ jest.mock('../services/stalker-playback-context.service', () => ({
|
||||
import { ipcMain } from 'electron';
|
||||
import { spawn, type ChildProcess } from 'child_process';
|
||||
import { EventEmitter } from 'events';
|
||||
import * as fs from 'fs';
|
||||
import {
|
||||
MPV_PLAYER_PATH,
|
||||
store,
|
||||
@@ -53,6 +63,7 @@ import {
|
||||
shouldUseMpvSocketBridge,
|
||||
} from './player.events';
|
||||
import { openVlcPlayer } from './vlc-session.service';
|
||||
import { waitForLoginShellPath } from '../startup/login-shell-path';
|
||||
|
||||
function createPathExists(existingPaths: string[]) {
|
||||
return (candidatePath: string) => existingPaths.includes(candidatePath);
|
||||
@@ -461,6 +472,117 @@ describe('openVlcPlayer', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('external player launch handlers', () => {
|
||||
const pathKeys: Record<string, string> = {
|
||||
OPEN_MPV_PLAYER: MPV_PLAYER_PATH,
|
||||
OPEN_VLC_PLAYER: VLC_PLAYER_PATH,
|
||||
};
|
||||
|
||||
function configurePlayerPath(channel: string, playerPath: string): void {
|
||||
(store.get as unknown as jest.Mock).mockImplementation(
|
||||
(key: string, fallback?: unknown) =>
|
||||
key === pathKeys[channel] ? playerPath : fallback
|
||||
);
|
||||
}
|
||||
|
||||
async function launchUntilSpawn(
|
||||
channel: string,
|
||||
wait: Promise<void>,
|
||||
afterLaunch: () => Promise<void>
|
||||
): Promise<void> {
|
||||
(waitForLoginShellPath as jest.Mock).mockReturnValueOnce(wait);
|
||||
const proc = createMockChildProcess();
|
||||
(spawn as unknown as jest.Mock).mockReturnValue(proc);
|
||||
const consoleErrorSpy = jest
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => undefined);
|
||||
try {
|
||||
const launch = Promise.resolve(
|
||||
getIpcMainHandler(channel)(
|
||||
{},
|
||||
'https://example.com/live.m3u8',
|
||||
'Live'
|
||||
)
|
||||
).catch(() => undefined);
|
||||
await afterLaunch();
|
||||
proc.emit('error', new Error('spawn ENOENT'));
|
||||
await launch;
|
||||
} finally {
|
||||
consoleErrorSpy.mockRestore();
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
(spawn as unknown as jest.Mock).mockReset();
|
||||
(waitForLoginShellPath as jest.Mock).mockClear();
|
||||
});
|
||||
|
||||
it.each(['OPEN_MPV_PLAYER', 'OPEN_VLC_PLAYER'])(
|
||||
'%s spawns a bare player name only after the login shell PATH lookup settled',
|
||||
async (channel) => {
|
||||
configurePlayerPath(channel, 'player-on-shell-path');
|
||||
let settle: () => void = () => undefined;
|
||||
const wait = new Promise<void>((resolve) => {
|
||||
settle = resolve;
|
||||
});
|
||||
await launchUntilSpawn(channel, wait, async () => {
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
expect(spawn).not.toHaveBeenCalled();
|
||||
settle();
|
||||
await waitForSpawnCallCount(1);
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
it.each(['OPEN_MPV_PLAYER', 'OPEN_VLC_PLAYER'])(
|
||||
'%s does not wait for a Flatpak host launch of a bare name',
|
||||
async (channel) => {
|
||||
const originalPlatform = process.platform;
|
||||
const existsSync = fs.existsSync as unknown as jest.Mock;
|
||||
existsSync.mockImplementation(
|
||||
(candidate: unknown) => String(candidate) === '/.flatpak-info'
|
||||
);
|
||||
Object.defineProperty(process, 'platform', { value: 'linux' });
|
||||
configurePlayerPath(channel, 'player-on-host-path');
|
||||
try {
|
||||
await launchUntilSpawn(
|
||||
channel,
|
||||
new Promise<void>(() => undefined),
|
||||
async () => {
|
||||
await waitForSpawnCallCount(1);
|
||||
expect(waitForLoginShellPath).not.toHaveBeenCalled();
|
||||
expect(
|
||||
(spawn as unknown as jest.Mock).mock.calls[0][0]
|
||||
).toBe('flatpak-spawn');
|
||||
}
|
||||
);
|
||||
} finally {
|
||||
existsSync.mockImplementation(
|
||||
jest.requireActual<typeof import('fs')>('fs').existsSync
|
||||
);
|
||||
Object.defineProperty(process, 'platform', {
|
||||
value: originalPlatform,
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
it.each(['OPEN_MPV_PLAYER', 'OPEN_VLC_PLAYER'])(
|
||||
'%s starts a configured executable path without waiting',
|
||||
async (channel) => {
|
||||
configurePlayerPath(channel, '/opt/players/bin/player');
|
||||
await launchUntilSpawn(
|
||||
channel,
|
||||
new Promise<void>(() => undefined),
|
||||
async () => {
|
||||
await waitForSpawnCallCount(1);
|
||||
expect(waitForLoginShellPath).not.toHaveBeenCalled();
|
||||
}
|
||||
);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
describe('buildVlcEnqueueCommands', () => {
|
||||
it('clears the playlist and adds the URL with no extra options', () => {
|
||||
expect(
|
||||
|
||||
@@ -1,16 +1,21 @@
|
||||
import { ipcMain } from 'electron';
|
||||
import {
|
||||
CLOSE_EXTERNAL_PLAYER_SESSION,
|
||||
type ExternalPlayerName,
|
||||
PLAYBACK_SET_KEEP_AWAKE,
|
||||
PlayerContentInfo,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { setPlaybackKeepAwake } from '../services/playback-keep-awake.service';
|
||||
import { waitForLoginShellPath } from '../startup/login-shell-path';
|
||||
import {
|
||||
MPV_PLAYER_PATH,
|
||||
store,
|
||||
VLC_PLAYER_PATH,
|
||||
} from '../services/store.service';
|
||||
import { normalizePlayerPathForStore } from './external-player-launch-context';
|
||||
import {
|
||||
normalizePlayerPathForStore,
|
||||
resolveExternalPlayerLaunchContext as resolveLaunchContext,
|
||||
} from './external-player-launch-context';
|
||||
import {
|
||||
externalPlayerSessions,
|
||||
traceExternalPlayer,
|
||||
@@ -34,6 +39,23 @@ export {
|
||||
parseVlcRcPlaybackState,
|
||||
} from './vlc-session.service';
|
||||
|
||||
/**
|
||||
* A player resolved to a bare name (no configured path, no well-known
|
||||
* install found) is looked up through PATH, so it waits for the login shell
|
||||
* PATH; a path to an executable starts right away. So does a Flatpak host
|
||||
* launch: `flatpak-spawn --host` resolves the name with the host's PATH,
|
||||
* which the sandbox's login shell lookup cannot change.
|
||||
*/
|
||||
async function waitForPathIfBareName(
|
||||
player: ExternalPlayerName,
|
||||
configuredPath: string | undefined
|
||||
): Promise<void> {
|
||||
const context = resolveLaunchContext(player, configuredPath);
|
||||
if (context.mode !== 'flatpak-host' && !/[\\/]/.test(context.playerPath)) {
|
||||
await waitForLoginShellPath();
|
||||
}
|
||||
}
|
||||
|
||||
export default class PlayerEvents {
|
||||
static bootstrapPlayerEvents(): Electron.IpcMain {
|
||||
return ipcMain;
|
||||
@@ -53,8 +75,9 @@ ipcMain.handle(
|
||||
contentInfo?: PlayerContentInfo,
|
||||
startTime?: number,
|
||||
headers?: Record<string, string>
|
||||
) =>
|
||||
openMpvPlayer({
|
||||
) => {
|
||||
await waitForPathIfBareName('mpv', store.get(MPV_PLAYER_PATH));
|
||||
return openMpvPlayer({
|
||||
url,
|
||||
title,
|
||||
thumbnail,
|
||||
@@ -64,7 +87,8 @@ ipcMain.handle(
|
||||
contentInfo,
|
||||
startTime,
|
||||
headers,
|
||||
})
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
ipcMain.handle(
|
||||
@@ -95,8 +119,9 @@ ipcMain.handle(
|
||||
contentInfo?: PlayerContentInfo,
|
||||
startTime?: number,
|
||||
headers?: Record<string, string>
|
||||
) =>
|
||||
openVlcPlayer({
|
||||
) => {
|
||||
await waitForPathIfBareName('vlc', store.get(VLC_PLAYER_PATH));
|
||||
return openVlcPlayer({
|
||||
url,
|
||||
title,
|
||||
thumbnail,
|
||||
@@ -106,7 +131,8 @@ ipcMain.handle(
|
||||
contentInfo,
|
||||
startTime,
|
||||
headers,
|
||||
})
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
ipcMain.handle(
|
||||
|
||||
@@ -375,6 +375,60 @@ describe('EmbeddedMpvNativeService power blocker', () => {
|
||||
expect(support.frameCopyAvailable).toBe(true);
|
||||
});
|
||||
|
||||
it('predicts the bare-name mpv probe exactly when getSupport runs it', () => {
|
||||
Object.defineProperty(process, 'platform', { value: 'linux' });
|
||||
process.env.DISPLAY = ':0';
|
||||
delete process.env.WAYLAND_DISPLAY;
|
||||
mockSpawnSync.mockClear();
|
||||
mockSpawnSync.mockReturnValue({ status: 0 });
|
||||
mockRuntimeUsable();
|
||||
|
||||
expect(service.willProbeLinuxMpvExecutable()).toBe(true);
|
||||
service.getSupport();
|
||||
expect(mockSpawnSync).toHaveBeenCalledWith(
|
||||
'mpv',
|
||||
['--version'],
|
||||
expect.anything()
|
||||
);
|
||||
// Cached afterwards: no further probe, so no further wait.
|
||||
expect(service.willProbeLinuxMpvExecutable()).toBe(false);
|
||||
});
|
||||
|
||||
it('probes mpv again after a missing or a found result is forgotten', () => {
|
||||
Object.defineProperty(process, 'platform', { value: 'linux' });
|
||||
process.env.DISPLAY = ':0';
|
||||
delete process.env.WAYLAND_DISPLAY;
|
||||
mockSpawnSync.mockClear();
|
||||
mockSpawnSync.mockReturnValue({ status: 1 });
|
||||
mockRuntimeUsable();
|
||||
|
||||
expect(service.getSupport().supported).toBe(false);
|
||||
service.forgetLinuxMpvExecutableProbe();
|
||||
expect(service.willProbeLinuxMpvExecutable()).toBe(true);
|
||||
|
||||
mockSpawnSync.mockReturnValue({ status: 0 });
|
||||
service.getSupport();
|
||||
expect(mockSpawnSync).toHaveBeenCalledTimes(2);
|
||||
expect(service.willProbeLinuxMpvExecutable()).toBe(false);
|
||||
service.forgetLinuxMpvExecutableProbe();
|
||||
expect(service.willProbeLinuxMpvExecutable()).toBe(true);
|
||||
});
|
||||
|
||||
it('predicts no probe for the frame-copy engine or native Wayland', () => {
|
||||
Object.defineProperty(process, 'platform', { value: 'linux' });
|
||||
process.env.DISPLAY = ':0';
|
||||
process.env.WAYLAND_DISPLAY = 'wayland-0';
|
||||
mockSpawnSync.mockClear();
|
||||
mockRuntimeUsable();
|
||||
expect(service.willProbeLinuxMpvExecutable()).toBe(false);
|
||||
|
||||
delete process.env.WAYLAND_DISPLAY;
|
||||
process.env.IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY = '1';
|
||||
expect(service.willProbeLinuxMpvExecutable()).toBe(false);
|
||||
service.getSupport();
|
||||
expect(mockSpawnSync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps frame-copy supported on Linux without a system mpv executable', () => {
|
||||
// The helper links libmpv itself; the mpv-on-PATH probe only
|
||||
// binds the native --wid engine.
|
||||
|
||||
@@ -377,6 +377,32 @@ export class EmbeddedMpvNativeService {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether `getSupport()` would now run the uncached Linux bare-name
|
||||
* `mpv --version` probe, i.e. reach the native-engine checks: no
|
||||
* frame-copy engine, a usable display server and the feature enabled.
|
||||
* The IPC layer waits for the login shell PATH only then.
|
||||
*/
|
||||
willProbeLinuxMpvExecutable(): boolean {
|
||||
return (
|
||||
process.platform === 'linux' &&
|
||||
this.cachedLinuxMpvExecutableReason === undefined &&
|
||||
!this.isUnsupportedLinuxDisplayServer() &&
|
||||
isEmbeddedMpvFeatureEnabled() &&
|
||||
!this.isFrameCopyEngineActive()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Drops the cached `mpv --version` result, found or missing, so the next
|
||||
* support check probes again. For a probe that ran before the login
|
||||
* shell PATH arrived: that PATH may add mpv or leave out the directory
|
||||
* the inherited one found it in.
|
||||
*/
|
||||
forgetLinuxMpvExecutableProbe(): void {
|
||||
this.cachedLinuxMpvExecutableReason = undefined;
|
||||
}
|
||||
|
||||
getSupport(): EmbeddedMpvSupport {
|
||||
if (!SUPPORTED_EMBEDDED_MPV_PLATFORMS.has(process.platform)) {
|
||||
return {
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
* main.ts loads this module through a dynamic import inside the main
|
||||
* window's `did-start-loading` listener (see deferred-bootstrap.ts), so the
|
||||
* heavy dependencies it pulls in (axios, drizzle-orm, better-sqlite3,
|
||||
* electron-updater, fix-path) are evaluated while the renderer parses and
|
||||
* electron-updater) are evaluated while the renderer parses and
|
||||
* runs its own bundle instead of before the window can load at all.
|
||||
*
|
||||
* Keep `bootstrapDeferredEvents()` synchronous: the guarantee that no
|
||||
@@ -51,6 +51,9 @@ import {
|
||||
} from '../services/app-update-channel';
|
||||
import { databaseWorkerClient } from '../services/database-worker-client';
|
||||
import type { bootstrapWindowCloseGuard } from '../services/window-close-guard.service';
|
||||
import { scheduleDeferredFixPath } from './login-shell-path';
|
||||
|
||||
export { scheduleDeferredFixPath };
|
||||
|
||||
export interface DeferredEventsContext {
|
||||
readonly appVersion: string;
|
||||
@@ -126,35 +129,6 @@ export async function finishStartupAfterFirstLoad(): Promise<void> {
|
||||
traceStartupPhase('reconcile-stale-recordings:done');
|
||||
}
|
||||
|
||||
let fixPathScheduled = false;
|
||||
|
||||
/**
|
||||
* Update process.env.PATH from the user's interactive login shell so that
|
||||
* spawned external players (MPV/VLC) can be resolved by binary name.
|
||||
*
|
||||
* Runs after window creation + IPC handler registration so the 50-300 ms
|
||||
* shell-spawn cost (bash/zsh -ilc env) doesn't block startup. Idempotent:
|
||||
* subsequent calls are no-ops. fix-path itself is imported here, on demand,
|
||||
* so its module evaluation stays off the launch path as well.
|
||||
*/
|
||||
export function scheduleDeferredFixPath(): void {
|
||||
if (fixPathScheduled || process.platform === 'win32') {
|
||||
return;
|
||||
}
|
||||
|
||||
fixPathScheduled = true;
|
||||
setImmediate(() => {
|
||||
import('fix-path')
|
||||
.then(({ default: fixPath }) => {
|
||||
fixPath();
|
||||
traceStartupPhase('fix-path:done');
|
||||
})
|
||||
.catch((error) => {
|
||||
console.warn('fix-path failed:', error);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** Tears down sessions and the DB worker; safe when nothing was started. */
|
||||
export function shutdownDeferredServices(): void {
|
||||
shutdownEmbeddedMpv();
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
const shellPath = jest.fn<Promise<string | undefined>, []>();
|
||||
const shellPathSync = jest.fn<string | undefined, []>();
|
||||
|
||||
jest.mock('shell-path', () => ({ shellPath, shellPathSync }));
|
||||
jest.mock('../services/debug-trace', () => ({
|
||||
traceStartupPhase: jest.fn(),
|
||||
}));
|
||||
|
||||
type LoginShellPathModule = typeof import('./login-shell-path');
|
||||
|
||||
function loadModule(): LoginShellPathModule {
|
||||
let loaded: LoginShellPathModule | undefined;
|
||||
jest.isolateModules(() => {
|
||||
loaded = jest.requireActual('./login-shell-path');
|
||||
});
|
||||
return loaded as LoginShellPathModule;
|
||||
}
|
||||
|
||||
async function flushScheduled(): Promise<void> {
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
}
|
||||
|
||||
describe('login shell PATH', () => {
|
||||
const originalPath = process.env.PATH;
|
||||
const originalPlatform = process.platform;
|
||||
|
||||
beforeEach(() => {
|
||||
shellPath.mockReset();
|
||||
shellPathSync.mockReset();
|
||||
process.env.PATH = '/usr/bin:/bin';
|
||||
Object.defineProperty(process, 'platform', { value: 'darwin' });
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
process.env.PATH = originalPath;
|
||||
Object.defineProperty(process, 'platform', {
|
||||
value: originalPlatform,
|
||||
});
|
||||
});
|
||||
|
||||
it('reads the login shell asynchronously, never through the blocking API', async () => {
|
||||
let resolveShell: (path: string) => void = () => undefined;
|
||||
shellPath.mockReturnValue(
|
||||
new Promise((resolve) => {
|
||||
resolveShell = resolve;
|
||||
})
|
||||
);
|
||||
|
||||
loadModule().scheduleDeferredFixPath();
|
||||
await flushScheduled();
|
||||
|
||||
// The shell is still starting, and the main thread is free.
|
||||
expect(shellPath).toHaveBeenCalledTimes(1);
|
||||
expect(shellPathSync).not.toHaveBeenCalled();
|
||||
expect(process.env.PATH).toBe('/usr/bin:/bin');
|
||||
|
||||
resolveShell('/opt/homebrew/bin:/usr/bin:/bin');
|
||||
await flushScheduled();
|
||||
expect(process.env.PATH).toBe('/opt/homebrew/bin:/usr/bin:/bin');
|
||||
});
|
||||
|
||||
it('lets spawns wait until the lookup settled, at most the limit', async () => {
|
||||
let resolveShell: (path: string) => void = () => undefined;
|
||||
const module = loadModule();
|
||||
module.scheduleDeferredFixPath(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
resolveShell = resolve;
|
||||
})
|
||||
);
|
||||
let waited = false;
|
||||
const wait = module.waitForLoginShellPath().then(() => {
|
||||
waited = true;
|
||||
});
|
||||
await flushScheduled();
|
||||
expect(waited).toBe(false);
|
||||
|
||||
resolveShell('/opt/homebrew/bin');
|
||||
await wait;
|
||||
expect(process.env.PATH).toBe('/opt/homebrew/bin');
|
||||
});
|
||||
|
||||
it('ends every wait at the lookup budget, so a hung shell delays no later launch', async () => {
|
||||
const stuck = loadModule();
|
||||
stuck.scheduleDeferredFixPath(() => new Promise(() => undefined), 20);
|
||||
await expect(stuck.waitForLoginShellPath()).resolves.toBe(false);
|
||||
// Past the budget a later launch goes at once (the test would time
|
||||
// out on this one otherwise).
|
||||
await expect(stuck.waitForLoginShellPath(60_000)).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it('lets waits before the lookup is scheduled share one deadline', async () => {
|
||||
jest.useFakeTimers();
|
||||
try {
|
||||
const module = loadModule();
|
||||
const first = module.waitForLoginShellPath();
|
||||
await jest.advanceTimersByTimeAsync(6_000);
|
||||
const retry = module.waitForLoginShellPath();
|
||||
await jest.advanceTimersByTimeAsync(4_000);
|
||||
// Both end at the first wait's deadline, not 10 s after the retry.
|
||||
await expect(first).resolves.toBe(false);
|
||||
await expect(retry).resolves.toBe(false);
|
||||
await expect(module.waitForLoginShellPath()).resolves.toBe(false);
|
||||
} finally {
|
||||
jest.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('lets a launch retried within the budget wait for the PATH again', async () => {
|
||||
let resolveShell: (path: string) => void = () => undefined;
|
||||
const module = loadModule();
|
||||
module.scheduleDeferredFixPath(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
resolveShell = resolve;
|
||||
}),
|
||||
60_000
|
||||
);
|
||||
await flushScheduled();
|
||||
// The first launch gives up on its own, shorter limit...
|
||||
await module.waitForLoginShellPath(5);
|
||||
expect(process.env.PATH).toBe('/usr/bin:/bin');
|
||||
|
||||
// ...a retry still waits, and sees the PATH once the shell answers.
|
||||
const retry = module.waitForLoginShellPath();
|
||||
resolveShell('/opt/homebrew/bin');
|
||||
await expect(retry).resolves.toBe(true);
|
||||
expect(process.env.PATH).toBe('/opt/homebrew/bin');
|
||||
});
|
||||
|
||||
it('lets spawns go immediately on Windows', async () => {
|
||||
Object.defineProperty(process, 'platform', { value: 'win32' });
|
||||
await expect(loadModule().waitForLoginShellPath(60_000)).resolves.toBe(
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to the paths fix-path used when the shell reports none', async () => {
|
||||
await loadModule().hydratePathFromLoginShell(async () => undefined);
|
||||
|
||||
expect(process.env.PATH).toBe(
|
||||
'./node_modules/.bin:/.nodebrew/current/bin:/usr/local/bin:/usr/bin:/bin'
|
||||
);
|
||||
});
|
||||
|
||||
it('runs once, and not at all on Windows', async () => {
|
||||
const read = jest.fn(async () => '/custom/bin');
|
||||
const module = loadModule();
|
||||
module.scheduleDeferredFixPath(read);
|
||||
module.scheduleDeferredFixPath(read);
|
||||
await flushScheduled();
|
||||
expect(read).toHaveBeenCalledTimes(1);
|
||||
|
||||
Object.defineProperty(process, 'platform', { value: 'win32' });
|
||||
const windowsRead = jest.fn(async () => '/custom/bin');
|
||||
loadModule().scheduleDeferredFixPath(windowsRead);
|
||||
await flushScheduled();
|
||||
expect(windowsRead).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps the current PATH when the lookup fails', async () => {
|
||||
const warn = jest.spyOn(console, 'warn').mockImplementation(() => {
|
||||
// Expected failure.
|
||||
});
|
||||
loadModule().scheduleDeferredFixPath(async () => {
|
||||
throw new Error('shell exited');
|
||||
});
|
||||
await flushScheduled();
|
||||
|
||||
expect(process.env.PATH).toBe('/usr/bin:/bin');
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
'Login shell PATH lookup failed:',
|
||||
expect.any(Error)
|
||||
);
|
||||
warn.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,124 @@
|
||||
import { traceStartupPhase } from '../services/debug-trace';
|
||||
|
||||
/**
|
||||
* Update process.env.PATH from the user's interactive login shell so that
|
||||
* spawned external players (MPV/VLC) can be resolved by binary name when the
|
||||
* app was started from Finder or a desktop launcher.
|
||||
*
|
||||
* The shell (`$SHELL -ilc env`) starts asynchronously. Its start-up cost
|
||||
* depends on the user's shell profile and was about 1-2 s with a typical
|
||||
* zsh setup; the synchronous `fix-path` used before blocked the main thread
|
||||
* for all of it, right when the database worker's `ready` message and the
|
||||
* renderer's first IPC calls were waiting, which delayed the first card of
|
||||
* the launch journey by the same amount. The resulting PATH, and the
|
||||
* fallback when the shell reports none, are the ones `fix-path` 5 produced.
|
||||
*
|
||||
* Runs after window creation and IPC handler registration. Idempotent:
|
||||
* subsequent calls are no-ops. shell-path is imported here, on demand, so
|
||||
* its module evaluation stays off the launch path as well.
|
||||
*
|
||||
* Handlers that spawn a binary by bare name (external players, the Linux
|
||||
* `mpv --version` check of embedded MPV) await `waitForLoginShellPath()`
|
||||
* first, so they see the same PATH the blocking lookup guaranteed.
|
||||
*/
|
||||
let loginShellPathScheduled = false;
|
||||
let settleLoginShellPath: () => void = () => undefined;
|
||||
const loginShellPathSettled: Promise<void> =
|
||||
process.platform === 'win32'
|
||||
? Promise.resolve()
|
||||
: new Promise((resolve) => {
|
||||
settleLoginShellPath = resolve;
|
||||
});
|
||||
|
||||
/**
|
||||
* Time budget of the lookup, counted from its start. A shell that has not
|
||||
* answered by then is treated as hung: waits end at this shared deadline,
|
||||
* so a broken profile cannot delay every launch by the full budget, while a
|
||||
* launch retried within the budget still waits for the PATH.
|
||||
*/
|
||||
export const LOGIN_SHELL_PATH_WAIT_LIMIT_MS = 10_000;
|
||||
let loginShellPathDeadline: number | null = null;
|
||||
|
||||
let loginShellPathHasSettled = process.platform === 'win32';
|
||||
const settledOutcome = loginShellPathSettled.then(() => {
|
||||
loginShellPathHasSettled = true;
|
||||
return true;
|
||||
});
|
||||
|
||||
/**
|
||||
* Resolves once the login shell PATH lookup has finished (successfully or
|
||||
* not), or when its budget is spent; immediately on Windows. Before the
|
||||
* lookup is scheduled, waits end at a deadline shared from the first of
|
||||
* them. `limitMs` caps a single wait. Resolves to whether
|
||||
* the lookup had finished: false means the caller runs on the inherited
|
||||
* PATH and should not keep a negative result.
|
||||
*/
|
||||
export function waitForLoginShellPath(
|
||||
limitMs = LOGIN_SHELL_PATH_WAIT_LIMIT_MS
|
||||
): Promise<boolean> {
|
||||
if (loginShellPathHasSettled) {
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
// Waits before the lookup is scheduled share one deadline too, set by
|
||||
// the first of them; scheduling the lookup replaces it with its budget.
|
||||
loginShellPathDeadline ??= Date.now() + LOGIN_SHELL_PATH_WAIT_LIMIT_MS;
|
||||
const remainingMs = Math.min(limitMs, loginShellPathDeadline - Date.now());
|
||||
if (remainingMs <= 0) {
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
let timer: NodeJS.Timeout | undefined;
|
||||
const limit = new Promise<boolean>((resolve) => {
|
||||
timer = setTimeout(() => resolve(false), remainingMs);
|
||||
});
|
||||
return Promise.race([settledOutcome, limit]).finally(() =>
|
||||
clearTimeout(timer)
|
||||
);
|
||||
}
|
||||
|
||||
/** Resolves when the lookup finishes, however long it takes. */
|
||||
export function whenLoginShellPathSettled(): Promise<void> {
|
||||
return loginShellPathSettled;
|
||||
}
|
||||
|
||||
export type ReadLoginShellPath = () => Promise<string | undefined>;
|
||||
|
||||
const readLoginShellPath: ReadLoginShellPath = async () => {
|
||||
const { shellPath } = await import('shell-path');
|
||||
return shellPath();
|
||||
};
|
||||
|
||||
export async function hydratePathFromLoginShell(
|
||||
readPath: ReadLoginShellPath = readLoginShellPath
|
||||
): Promise<void> {
|
||||
const shellPath = await readPath();
|
||||
process.env.PATH =
|
||||
shellPath ||
|
||||
[
|
||||
'./node_modules/.bin',
|
||||
'/.nodebrew/current/bin',
|
||||
'/usr/local/bin',
|
||||
process.env.PATH,
|
||||
].join(':');
|
||||
}
|
||||
|
||||
export function scheduleDeferredFixPath(
|
||||
readPath: ReadLoginShellPath = readLoginShellPath,
|
||||
budgetMs = LOGIN_SHELL_PATH_WAIT_LIMIT_MS
|
||||
): void {
|
||||
if (loginShellPathScheduled || process.platform === 'win32') {
|
||||
return;
|
||||
}
|
||||
|
||||
loginShellPathScheduled = true;
|
||||
loginShellPathDeadline = Date.now() + budgetMs;
|
||||
setImmediate(() => {
|
||||
hydratePathFromLoginShell(readPath)
|
||||
.then(() => {
|
||||
traceStartupPhase('fix-path:done');
|
||||
})
|
||||
.catch((error) => {
|
||||
console.warn('Login shell PATH lookup failed:', error);
|
||||
})
|
||||
.finally(() => settleLoginShellPath());
|
||||
});
|
||||
}
|
||||
@@ -184,12 +184,13 @@ export default class Main {
|
||||
traceStartupPhase('bootstrap-events:done');
|
||||
|
||||
// Hydrate process.env.PATH from the user's login shell now — after
|
||||
// the window has loaded and IPC handlers are live. Fire-and-forget
|
||||
// (setImmediate) so it doesn't gate any user-visible work. Worst
|
||||
// case: the user clicks an external player within the ~100 ms it
|
||||
// takes to complete; the spawn would still find MPV/VLC at any of
|
||||
// the well-known paths checked by getDefault*Path before falling
|
||||
// back to bare-name PATH lookup.
|
||||
// the window has loaded and IPC handlers are live. The shell runs
|
||||
// asynchronously, so the main thread keeps serving the renderer
|
||||
// while it starts (about 1-2 s with a typical zsh profile). Worst
|
||||
// case: the user clicks an external player before it completes;
|
||||
// the spawn would still find MPV/VLC at any of the well-known paths
|
||||
// checked by getDefault*Path before falling back to bare-name PATH
|
||||
// lookup.
|
||||
module.scheduleDeferredFixPath();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -421,6 +421,17 @@ process before the first card.
|
||||
completion bumps `EpgSourceSettingsService.revision()`, the fence that
|
||||
keeps XMLTV lookups from returning data of a removed source.
|
||||
|
||||
- The main thread must stay free between the load and the first card. The
|
||||
renderer's first database read creates the database worker, and every
|
||||
request waits until the main process has handled the worker's `ready`
|
||||
message. Until #1784 the login-shell PATH lookup (`fix-path`) ran right
|
||||
after `bootstrap-events:done` and spawned `$SHELL -ilc env` synchronously:
|
||||
on a Mac with a typical zsh profile it held the main thread for about
|
||||
1-2 s, the first `dbGetAppState` resolved about 2.3 s after spawn, and
|
||||
everything after it (about 20 ms of IPC) waited. It now runs the shell
|
||||
asynchronously (`startup/login-shell-path.ts`). On Linux runners bash
|
||||
starts in tens of milliseconds, so the effect there is small.
|
||||
|
||||
Validation (#1716, Principle 3): deferring the download list, update status
|
||||
and dashboard recent/favorites reads until after the first render took the
|
||||
counter from 12 to 7 on a Mac, but moved neither `spawnToFirstCardMs` nor
|
||||
|
||||
+1
-1
@@ -133,7 +133,6 @@
|
||||
"electron-conf": "1.3.0",
|
||||
"electron-updater": "6.8.9",
|
||||
"epg-parser": "^0.5.0",
|
||||
"fix-path": "5.0.0",
|
||||
"hls.js": "1.7.1",
|
||||
"iptv-playlist-parser": "github:4gray/iptv-playlist-parser#v0.15.2-iptvnator.2",
|
||||
"marked": "18.0.11",
|
||||
@@ -145,6 +144,7 @@
|
||||
"rxjs": "7.8.2",
|
||||
"saxes": "6.0.0",
|
||||
"shaka-player": "5.2.4",
|
||||
"shell-path": "3.1.0",
|
||||
"video.js": "8.24.0",
|
||||
"videojs-contrib-quality-levels": "4.1.0",
|
||||
"videojs-quality-selector-hls": "1.1.1",
|
||||
|
||||
Generated
+4
-28
@@ -145,9 +145,6 @@ importers:
|
||||
epg-parser:
|
||||
specifier: ^0.5.0
|
||||
version: 0.5.0
|
||||
fix-path:
|
||||
specifier: 5.0.0
|
||||
version: 5.0.0
|
||||
hls.js:
|
||||
specifier: 1.7.1
|
||||
version: 1.7.1
|
||||
@@ -181,6 +178,9 @@ importers:
|
||||
shaka-player:
|
||||
specifier: 5.2.4
|
||||
version: 5.2.4
|
||||
shell-path:
|
||||
specifier: 3.1.0
|
||||
version: 3.1.0
|
||||
video.js:
|
||||
specifier: 8.24.0
|
||||
version: 8.24.0
|
||||
@@ -4774,10 +4774,6 @@ packages:
|
||||
resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
ansi-regex@6.2.2:
|
||||
resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
ansi-regex@6.3.0:
|
||||
resolution: {integrity: sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ==}
|
||||
engines: {node: '>=12'}
|
||||
@@ -6210,10 +6206,6 @@ packages:
|
||||
resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
fix-path@5.0.0:
|
||||
resolution: {integrity: sha512-erEWGGCN7RIu1bXTCfNVpVBdm0f5mwcbeja+4QXiEZzIQukP401sbpu8gd3Ny1vS34YNeswyMO0TdT2tP5OlHA==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
flat-cache@4.0.1:
|
||||
resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==}
|
||||
engines: {node: '>=16'}
|
||||
@@ -8864,7 +8856,6 @@ packages:
|
||||
|
||||
shaka-player@5.2.4:
|
||||
resolution: {integrity: sha512-vf81av2EIcb03jRpeeZBhrPT3PMyggXnZA9k4sxGBxpr/H6v0iEL6b51T2Kwz5YNrQG/yDYoadgBW+oW40LeoA==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
shallow-clone@3.0.1:
|
||||
resolution: {integrity: sha512-/6KqX+GVUdqPuPPd2LxDDxzX6CAbjJehAAOKlNpqqUpAqPM6HeL8f+o3a+JsyGjn2lv0WY8UsTgUJjU9Ok55NA==}
|
||||
@@ -9050,10 +9041,6 @@ packages:
|
||||
resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
strip-ansi@7.1.2:
|
||||
resolution: {integrity: sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
strip-ansi@7.2.0:
|
||||
resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==}
|
||||
engines: {node: '>=12'}
|
||||
@@ -14260,8 +14247,6 @@ snapshots:
|
||||
|
||||
ansi-regex@5.0.1: {}
|
||||
|
||||
ansi-regex@6.2.2: {}
|
||||
|
||||
ansi-regex@6.3.0: {}
|
||||
|
||||
ansi-styles@4.3.0:
|
||||
@@ -15972,11 +15957,6 @@ snapshots:
|
||||
locate-path: 6.0.0
|
||||
path-exists: 4.0.0
|
||||
|
||||
fix-path@5.0.0:
|
||||
dependencies:
|
||||
shell-path: 3.1.0
|
||||
strip-ansi: 7.1.2
|
||||
|
||||
flat-cache@4.0.1:
|
||||
dependencies:
|
||||
flatted: 3.4.2
|
||||
@@ -19620,7 +19600,7 @@ snapshots:
|
||||
dependencies:
|
||||
default-shell: 2.2.0
|
||||
execa: 5.1.1
|
||||
strip-ansi: 7.1.2
|
||||
strip-ansi: 7.2.0
|
||||
|
||||
shell-path@3.1.0:
|
||||
dependencies:
|
||||
@@ -19812,10 +19792,6 @@ snapshots:
|
||||
dependencies:
|
||||
ansi-regex: 5.0.1
|
||||
|
||||
strip-ansi@7.1.2:
|
||||
dependencies:
|
||||
ansi-regex: 6.2.2
|
||||
|
||||
strip-ansi@7.2.0:
|
||||
dependencies:
|
||||
ansi-regex: 6.3.0
|
||||
|
||||
Reference in new issue
Block a user