Files
iptvnator/apps/electron-backend/src/app/services/single-instance.spec.ts
T
4grayandClaude Opus 5 f80eb4d1b9 fix(playlist): open playlists handed over by the OS (#1299)
Opening an .m3u/.m3u8 file from the command line or a file association did
nothing. The renderer parsed `process.argv` and sent an `OPEN_FILE` IPC event
that had no `ipcMain` handler and no preload channel, so `sendIpcEvent` logged
it as an unknown type and dropped it.

The path now belongs to the main process, which is where the OS actually
delivers it:

- argv is parsed on first launch (skipping the executable and Chromium
  switches) and normalized to an absolute path;
- macOS gets an `open-file` listener registered before `whenReady`, since
  Launch Services never puts the path in argv;
- the single-instance guard forwards a second launch's argv and working
  directory instead of discarding them, so opening a playlist against a
  running app works too.

Requests are queued in the main process until the renderer subscribes to the
`OPEN_FILE` push and drains the queue, which closes the startup race. The
import itself reuses the existing file path, so persistence, playlist-scoped
EPG and the navigation to the new playlist behave exactly like a dialog
import; a failed open now surfaces a snackbar instead of silence.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 20:28:29 +02:00

243 lines
7.7 KiB
TypeScript

import {
ALLOW_MULTIPLE_INSTANCES_ENV,
acquireSingleInstanceLock,
allowsMultipleInstances,
focusExistingWindow,
type SingleInstanceApp,
type SingleInstanceWindow,
} from './single-instance';
/**
* Electron's real `second-instance` listener signature. The guard has to keep
* working when Electron hands over an empty command line, so the argv and
* working directory are modelled as optional here.
*/
type SecondInstanceHandler = (
event: unknown,
argv: string[] | undefined,
workingDirectory: string | undefined
) => void;
function createApp(hasLock: boolean): jest.Mocked<SingleInstanceApp> {
return {
quit: jest.fn(),
on: jest.fn(),
requestSingleInstanceLock: jest.fn().mockReturnValue(hasLock),
};
}
function createWindow(
overrides: Partial<SingleInstanceWindow> = {}
): jest.Mocked<SingleInstanceWindow> {
return {
focus: jest.fn(),
isDestroyed: jest.fn().mockReturnValue(false),
isMinimized: jest.fn().mockReturnValue(false),
isVisible: jest.fn().mockReturnValue(true),
restore: jest.fn(),
show: jest.fn(),
...overrides,
} as jest.Mocked<SingleInstanceWindow>;
}
describe('allowsMultipleInstances', () => {
it.each(['1', 'true', 'YES', ' on '])(
'treats %p as an opt-in',
(value) => {
expect(
allowsMultipleInstances({
[ALLOW_MULTIPLE_INSTANCES_ENV]: value,
})
).toBe(true);
}
);
it.each([undefined, '', '0', 'false', 'nope'])(
'treats %p as opted out',
(value) => {
expect(
allowsMultipleInstances(
value === undefined
? {}
: { [ALLOW_MULTIPLE_INSTANCES_ENV]: value }
)
).toBe(false);
}
);
});
describe('acquireSingleInstanceLock', () => {
it('continues startup and registers the focus handler when the lock is free', () => {
const app = createApp(true);
expect(
acquireSingleInstanceLock(app, () => null, jest.fn(), { env: {} })
).toBe(true);
expect(app.quit).not.toHaveBeenCalled();
expect(app.on).toHaveBeenCalledWith(
'second-instance',
expect.any(Function)
);
});
it('quits and stops startup when another instance owns the profile', () => {
const app = createApp(false);
expect(
acquireSingleInstanceLock(app, () => null, jest.fn(), { env: {} })
).toBe(false);
expect(app.quit).toHaveBeenCalledTimes(1);
expect(app.on).not.toHaveBeenCalled();
});
it('focuses the existing window when a second instance is launched', () => {
const app = createApp(true);
const window = createWindow({
isMinimized: jest.fn().mockReturnValue(true),
isVisible: jest.fn().mockReturnValue(false),
});
const createMainWindow = jest.fn();
acquireSingleInstanceLock(app, () => window, createMainWindow, {
env: {},
});
const [, handler] = app.on.mock.calls[0];
(handler as () => void)();
expect(window.restore).toHaveBeenCalledTimes(1);
expect(window.show).toHaveBeenCalledTimes(1);
expect(window.focus).toHaveBeenCalledTimes(1);
expect(createMainWindow).not.toHaveBeenCalled();
});
// macOS keeps the process alive after the last window closes, so a second
// launch must rebuild a window instead of quitting into nothing.
it.each([
['no window exists', null],
['the window was destroyed', 'destroyed'],
])('re-creates the main window when %s', (_label, windowState) => {
const app = createApp(true);
const window =
windowState === 'destroyed'
? createWindow({
isDestroyed: jest.fn().mockReturnValue(true),
})
: null;
const createMainWindow = jest.fn();
acquireSingleInstanceLock(app, () => window, createMainWindow, {
env: {},
});
const [, handler] = app.on.mock.calls[0];
(handler as () => void)();
expect(createMainWindow).toHaveBeenCalledTimes(1);
if (window) {
expect(window.focus).not.toHaveBeenCalled();
}
});
it('skips the lock entirely when multiple instances are allowed', () => {
const app = createApp(false);
expect(
acquireSingleInstanceLock(app, () => null, jest.fn(), {
env: { [ALLOW_MULTIPLE_INSTANCES_ENV]: '1' },
})
).toBe(true);
expect(app.requestSingleInstanceLock).not.toHaveBeenCalled();
expect(app.quit).not.toHaveBeenCalled();
});
it('forwards a second launch command line before focusing the window', () => {
const app = createApp(true);
const window = createWindow();
const onSecondInstance = jest.fn();
acquireSingleInstanceLock(app, () => window, jest.fn(), {
env: {},
onSecondInstance,
});
const [, handler] = app.on.mock.calls[0];
(handler as SecondInstanceHandler)(
{},
['/opt/iptvnator/iptvnator', '/home/user/list.m3u'],
'/home/user'
);
expect(onSecondInstance).toHaveBeenCalledWith(
['/opt/iptvnator/iptvnator', '/home/user/list.m3u'],
'/home/user'
);
expect(onSecondInstance.mock.invocationCallOrder[0]).toBeLessThan(
window.focus.mock.invocationCallOrder[0]
);
});
it('forwards a second launch that carries no arguments', () => {
const app = createApp(true);
const window = createWindow();
const onSecondInstance = jest.fn();
acquireSingleInstanceLock(app, () => window, jest.fn(), {
env: {},
onSecondInstance,
});
const [, handler] = app.on.mock.calls[0];
(handler as SecondInstanceHandler)({}, undefined, undefined);
expect(onSecondInstance).toHaveBeenCalledWith([], '');
expect(window.focus).toHaveBeenCalledTimes(1);
});
it('still forwards when the lock owner has to rebuild its window', () => {
const app = createApp(true);
const createMainWindow = jest.fn();
const onSecondInstance = jest.fn();
acquireSingleInstanceLock(app, () => null, createMainWindow, {
env: {},
onSecondInstance,
});
const [, handler] = app.on.mock.calls[0];
(handler as SecondInstanceHandler)(
{},
['/opt/iptvnator/iptvnator', '/home/user/list.m3u8'],
'/home/user'
);
expect(onSecondInstance).toHaveBeenCalledWith(
['/opt/iptvnator/iptvnator', '/home/user/list.m3u8'],
'/home/user'
);
expect(createMainWindow).toHaveBeenCalledTimes(1);
});
});
describe('focusExistingWindow', () => {
it('does nothing without a window', () => {
expect(() => focusExistingWindow(null)).not.toThrow();
});
it('does not touch a destroyed window', () => {
const window = createWindow({
isDestroyed: jest.fn().mockReturnValue(true),
});
focusExistingWindow(window);
expect(window.focus).not.toHaveBeenCalled();
expect(window.restore).not.toHaveBeenCalled();
});
it('only focuses an already visible window', () => {
const window = createWindow();
focusExistingWindow(window);
expect(window.restore).not.toHaveBeenCalled();
expect(window.show).not.toHaveBeenCalled();
expect(window.focus).toHaveBeenCalledTimes(1);
});
});