Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions src/main/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ vi.mock('./lifecycle', () => ({
configureUserDataPath: vi.fn(() => calls.push('userData')),
configureWindowEvents: vi.fn(),
handleProtocolURL: vi.fn(),
initializeAppLifecycle: vi.fn(),
initializeAppLifecycle: vi.fn(() => calls.push('lifecycle')),
onFirstRunMaybe: vi.fn(),
}));

Expand Down Expand Up @@ -93,8 +93,8 @@ describe('main/index.ts', () => {
]);
});

it('configures user data and the Ozone backend before Electron becomes ready', () => {
expect(calls).toEqual(['userData', 'ozone', 'whenReady']);
it('configures user data, Ozone, and lifecycle handlers before Electron becomes ready', () => {
expect(calls).toEqual(['userData', 'ozone', 'lifecycle', 'whenReady']);
});

it('logs an error when Electron fails to become ready', async () => {
Expand Down
5 changes: 3 additions & 2 deletions src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,13 +62,14 @@ const appUpdater = new AppUpdater(mb, menuBuilder);
// queued forever.
registerNativeNotificationHandlers(mb);

// Capture relaunches before ready; lifecycle waits for the preloaded window.
initializeAppLifecycle(mb, contextMenu);

app
.whenReady()
.then(async () => {
await onFirstRunMaybe();

initializeAppLifecycle(mb, contextMenu);

// Configure window event handlers (Escape key, DevTools resize)
configureWindowEvents(mb, menuBuilder);

Expand Down
138 changes: 94 additions & 44 deletions src/main/lifecycle/startup.test.ts
Original file line number Diff line number Diff line change
@@ -1,67 +1,117 @@
import { EventEmitter } from 'node:events';

import { app } from 'electron';
import type { Menubar } from 'electron-menubar';

import { initializeAppLifecycle } from './startup';
import { APPLICATION } from '../../shared/constants';
import { logError, logWarn } from '../../shared/logger';

const requestSingleInstanceLockMock = vi.fn(() => true);
const appQuitMock = vi.fn();
import { initializeAppLifecycle } from './startup';

vi.mock('electron', () => ({
app: {
requestSingleInstanceLock: () => requestSingleInstanceLockMock(),
quit: () => appQuitMock(),
} satisfies Pick<Electron.App, 'requestSingleInstanceLock' | 'quit'>,
}));
vi.mock('electron', async () => {
const { EventEmitter } = await import('node:events');
return {
app: Object.assign(new EventEmitter(), {
requestSingleInstanceLock: vi.fn(() => true),
quit: vi.fn(),
}),
};
});

const logWarnMock = vi.fn();
vi.mock('../../shared/logger', () => ({
logWarn: (...a: unknown[]) => logWarnMock(...a),
vi.mock('../../shared/logger', async (importOriginal) => ({
...(await importOriginal<typeof import('../../shared/logger')>()),
logWarn: vi.fn(),
logError: vi.fn(),
}));

function createMb() {
return {
on: vi.fn(),
showWindow: vi.fn(),
return Object.assign(new EventEmitter(), {
showWindow: vi.fn().mockResolvedValue(undefined),
setContextMenu: vi.fn(),
app: { setAppUserModelId: vi.fn(), quit: vi.fn() },
tray: {
setToolTip: vi.fn(),
},
};
app: { setAppUserModelId: vi.fn() },
tray: { setToolTip: vi.fn() },
});
}

describe('main/lifecycle/startup.ts', () => {
describe('initializeAppLifecycle', () => {
it('registers menubar ready handler', () => {
const mb = createMb();
const contextMenu = {} as Electron.Menu;
beforeEach(() => {
app.removeAllListeners();
});

initializeAppLifecycle(mb as unknown as Menubar, contextMenu);
it('initializes the tray on ready without showing the window on a normal launch', () => {
const mb = createMb();
const contextMenu = {} as Electron.Menu;

expect(mb.on).toHaveBeenCalledWith('ready', expect.any(Function));
});
initializeAppLifecycle(mb as unknown as Menubar, contextMenu);
mb.emit('ready');

it('quits and warns when second instance detected', () => {
requestSingleInstanceLockMock.mockReturnValueOnce(false);
const mb = createMb();
const contextMenu = {} as Electron.Menu;
expect(mb.app.setAppUserModelId).toHaveBeenCalledWith(APPLICATION.ID);
expect(mb.tray.setToolTip).toHaveBeenCalledWith(APPLICATION.NAME);
expect(mb.setContextMenu).toHaveBeenCalledWith(contextMenu);
expect(mb.showWindow).not.toHaveBeenCalled();
expect(app.quit).not.toHaveBeenCalled();
});

it('quits the second process without registering a relaunch listener', () => {
vi.mocked(app.requestSingleInstanceLock).mockReturnValueOnce(false);
const mb = createMb();

initializeAppLifecycle(mb as unknown as Menubar, {} as Electron.Menu);
app.emit('second-instance');
mb.emit('ready');

initializeAppLifecycle(mb as unknown as Menubar, contextMenu);
expect(app.quit).toHaveBeenCalledOnce();
expect(logWarn).toHaveBeenCalledWith('main:gotTheLock', 'Second instance detected, quitting');
expect(app.listenerCount('second-instance')).toBe(0);
expect(mb.showWindow).not.toHaveBeenCalled();
});

it('shows the existing window on every relaunch instead of toggling it', () => {
const mb = createMb();
initializeAppLifecycle(mb as unknown as Menubar, {} as Electron.Menu);
mb.emit('ready');

expect(appQuitMock).toHaveBeenCalled();
expect(logWarnMock).toHaveBeenCalled();
});
app.emit('second-instance');
app.emit('second-instance');

it('delegates context-menu wiring to mb.setContextMenu', () => {
const mb = createMb();
const contextMenu = {} as Electron.Menu;
expect(mb.showWindow).toHaveBeenCalledTimes(2);
expect(app.quit).not.toHaveBeenCalled();
// macOS activation is already owned by electron-menubar.
expect(app.listenerCount('activate')).toBe(0);
});

it('coalesces early relaunches until the menubar window is ready', () => {
const mb = createMb();
initializeAppLifecycle(mb as unknown as Menubar, {} as Electron.Menu);

app.emit('second-instance');
app.emit('second-instance');
expect(mb.showWindow).not.toHaveBeenCalled();

mb.emit('ready');
expect(mb.showWindow).toHaveBeenCalledOnce();

app.emit('second-instance');
expect(mb.showWindow).toHaveBeenCalledTimes(2);
});

initializeAppLifecycle(mb as unknown as Menubar, contextMenu);
it('logs a failed show and allows the next relaunch to retry', async () => {
const mb = createMb();
const error = new Error('Window could not be shown');
mb.showWindow.mockRejectedValueOnce(error);
initializeAppLifecycle(mb as unknown as Menubar, {} as Electron.Menu);
mb.emit('ready');

const readyHandler = (mb.on as unknown as ReturnType<typeof vi.fn>).mock.calls[0]?.[1];
expect(readyHandler).toBeDefined();
readyHandler?.();
app.emit('second-instance');
await vi.waitFor(() =>
expect(logError).toHaveBeenCalledWith(
'main:second-instance',
'Failed to show the existing window',
error,
),
);

expect(mb.setContextMenu).toHaveBeenCalledWith(contextMenu);
});
app.emit('second-instance');
expect(mb.showWindow).toHaveBeenCalledTimes(2);
});
});
49 changes: 33 additions & 16 deletions src/main/lifecycle/startup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,13 @@ import { app } from 'electron';
import type { Menubar } from 'electron-menubar';

import { APPLICATION } from '../../shared/constants';
import { logWarn } from '../../shared/logger';
import { logError, logWarn, toError } from '../../shared/logger';

/**
* Set up core application lifecycle events: tray ready setup and
* single-instance enforcement.
* single-instance enforcement, showing the existing window on a second launch.
* Register before Electron becomes ready so early launches can wait for the
* menubar's preloaded window.
*
* The tray's context-menu wiring (Linux `setContextMenu` vs macOS/Windows
* right-click popup) and the macOS `setIgnoreDoubleClickEvents` default
Expand All @@ -17,24 +19,39 @@ import { logWarn } from '../../shared/logger';
* @param contextMenu - The tray context menu to attach via `mb.setContextMenu`.
*/
export function initializeAppLifecycle(mb: Menubar, contextMenu: Electron.Menu): void {
if (!app.requestSingleInstanceLock()) {
logWarn('main:gotTheLock', 'Second instance detected, quitting');
app.quit();
return;
}

let isMenubarReady = false;
let showRequested = false;

const showWindow = () => {
void mb.showWindow().catch((error) => {
logError('main:second-instance', 'Failed to show the existing window', toError(error));
});
};

mb.on('ready', () => {
mb.app.setAppUserModelId(APPLICATION.ID);
mb.tray.setToolTip(APPLICATION.NAME);
mb.setContextMenu(contextMenu);
});

preventSecondInstance();
}
isMenubarReady = true;

/**
* Enforce a single application instance. A second launch quits immediately so
* the already-running tray instance remains the only one.
*/
function preventSecondInstance(): void {
const gotTheLock = app.requestSingleInstanceLock();
if (showRequested) {
showRequested = false;
showWindow();
}
});

if (!gotTheLock) {
logWarn('main:gotTheLock', 'Second instance detected, quitting');
app.quit();
}
app.on('second-instance', () => {
if (isMenubarReady) {
showWindow();
} else {
// Coalesce launches while the initial window is still loading.
showRequested = true;
}
});
}
Loading