Skip to content
Open
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
5 changes: 5 additions & 0 deletions src/main/handlers/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { EVENTS } from '../../shared/events';

import { Paths } from '../config';
import { handleMainEvent, onMainEvent } from '../events';
import { setKeepRunningInTray } from '../lifecycle/window';

/**
* Register IPC handlers for general application queries and window/app control.
Expand All @@ -20,6 +21,10 @@ export function registerAppHandlers(mb: Menubar): void {

onMainEvent(EVENTS.QUIT, () => mb.app.quit());

onMainEvent(EVENTS.UPDATE_KEEP_RUNNING_IN_TRAY, (_, value: boolean) => {
setKeepRunningInTray(value);
});

// Path handlers for renderer queries about resource locations
handleMainEvent(EVENTS.NOTIFICATION_SOUND_PATH, () => {
return Paths.notificationSound;
Expand Down
177 changes: 176 additions & 1 deletion src/main/lifecycle/window.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,20 @@
import type { Menubar } from 'menubar';

import { configureWindowEvents } from './window';
import {
__resetWindowLifecycleForTests,
configureWindowEvents,
setKeepRunningInTray,
} from './window';

const appOnMock = vi.fn();
const appQuitMock = vi.fn();

vi.mock('electron', () => ({
app: {
on: (...args: unknown[]) => appOnMock(...args),
quit: (...args: unknown[]) => appQuitMock(...args),
},
}));

vi.mock('../config', () => ({
WindowConfig: {
Expand All @@ -9,10 +23,43 @@ vi.mock('../config', () => ({
},
}));

const ORIGINAL_PLATFORM = process.platform;

const setPlatform = (platform: NodeJS.Platform) => {
Object.defineProperty(process, 'platform', {
value: platform,
configurable: true,
});
};

const findAppHandler = (eventName: string): (() => void) | undefined => {
const call = appOnMock.mock.calls.find(([name]) => name === eventName);
return call?.[1] as (() => void) | undefined;
};

const findWindowHandler = (
menubar: Menubar,
eventName: string,
): ((event: { preventDefault: () => void }) => void) | undefined => {
const onMock = menubar.window?.on as ReturnType<typeof vi.fn>;
const call = onMock.mock.calls.find(([name]) => name === eventName);
return call?.[1] as
| ((event: { preventDefault: () => void }) => void)
| undefined;
};

const flushDeferred = () => new Promise((resolve) => setImmediate(resolve));

describe('main/lifecycle/window.ts', () => {
let menubar: Menubar;

beforeEach(() => {
appOnMock.mockClear();
appQuitMock.mockClear();
__resetWindowLifecycleForTests();
setKeepRunningInTray(false);
setPlatform('linux');

menubar = {
hideWindow: vi.fn(),
tray: {
Expand All @@ -24,6 +71,9 @@ describe('main/lifecycle/window.ts', () => {
setSize: vi.fn(),
center: vi.fn(),
setAlwaysOnTop: vi.fn(),
hide: vi.fn(),
isDestroyed: vi.fn().mockReturnValue(false),
on: vi.fn(),
webContents: {
on: vi.fn(),
},
Expand All @@ -34,6 +84,10 @@ describe('main/lifecycle/window.ts', () => {
} as unknown as Menubar;
});

afterEach(() => {
setPlatform(ORIGINAL_PLATFORM);
});

it('configureWindowEvents returns early if no window', () => {
const mbNoWindow = { ...menubar, window: null };

Expand All @@ -58,4 +112,125 @@ describe('main/lifecycle/window.ts', () => {
expect.any(Function),
);
});

it('configureWindowEvents registers window close, before-quit and window-all-closed listeners', () => {
configureWindowEvents(menubar);

expect(menubar.window?.on).toHaveBeenCalledWith(
'close',
expect.any(Function),
);
expect(appOnMock).toHaveBeenCalledWith('before-quit', expect.any(Function));
expect(appOnMock).toHaveBeenCalledWith(
'window-all-closed',
expect.any(Function),
);
});

describe('window close handler', () => {
it('hides the window and restores menubar reference when keepRunningInTray is enabled', async () => {
configureWindowEvents(menubar);
setKeepRunningInTray(true);

const closeHandler = findWindowHandler(menubar, 'close');
const event = { preventDefault: vi.fn() };
closeHandler?.(event);

expect(event.preventDefault).toHaveBeenCalled();
expect(menubar.window?.hide).not.toHaveBeenCalled();

// Simulate menubar's windowClear nulling its reference.
const captured = menubar.window;
(menubar as unknown as { window: undefined }).window = undefined;

await flushDeferred();

expect(captured?.hide).toHaveBeenCalled();
expect(
(menubar as unknown as { _browserWindow: unknown })._browserWindow,
).toBe(captured);
});

it('skips the deferred hide when the captured window is destroyed', async () => {
configureWindowEvents(menubar);
setKeepRunningInTray(true);

const captured = menubar.window;
const closeHandler = findWindowHandler(menubar, 'close');
closeHandler?.({ preventDefault: vi.fn() });

(captured?.isDestroyed as ReturnType<typeof vi.fn>).mockReturnValue(true);

await flushDeferred();

expect(captured?.hide).not.toHaveBeenCalled();
});

it('lets the window close normally when keepRunningInTray is disabled', async () => {
configureWindowEvents(menubar);

const closeHandler = findWindowHandler(menubar, 'close');
const event = { preventDefault: vi.fn() };
closeHandler?.(event);

await flushDeferred();

expect(event.preventDefault).not.toHaveBeenCalled();
expect(menubar.window?.hide).not.toHaveBeenCalled();
});

it('lets the window close during quit even when keepRunningInTray is enabled', async () => {
configureWindowEvents(menubar);
setKeepRunningInTray(true);

findAppHandler('before-quit')?.();

const closeHandler = findWindowHandler(menubar, 'close');
const event = { preventDefault: vi.fn() };
closeHandler?.(event);

await flushDeferred();

expect(event.preventDefault).not.toHaveBeenCalled();
expect(menubar.window?.hide).not.toHaveBeenCalled();
});
});

describe('window-all-closed handler', () => {
it('keeps the app alive when keepRunningInTray is enabled', () => {
configureWindowEvents(menubar);
setKeepRunningInTray(true);

findAppHandler('window-all-closed')?.();

expect(appQuitMock).not.toHaveBeenCalled();
});

it('quits when keepRunningInTray is disabled (linux)', () => {
configureWindowEvents(menubar);

findAppHandler('window-all-closed')?.();

expect(appQuitMock).toHaveBeenCalled();
});

it('quits when the user is quitting even if keepRunningInTray is enabled', () => {
configureWindowEvents(menubar);
setKeepRunningInTray(true);

findAppHandler('before-quit')?.();
findAppHandler('window-all-closed')?.();

expect(appQuitMock).toHaveBeenCalled();
});

it('does not quit on macOS when keepRunningInTray is disabled', () => {
setPlatform('darwin');
configureWindowEvents(menubar);

findAppHandler('window-all-closed')?.();

expect(appQuitMock).not.toHaveBeenCalled();
});
});
});
114 changes: 113 additions & 1 deletion src/main/lifecycle/window.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,129 @@
import { app, type BrowserWindow } from 'electron';
import type { Menubar } from 'menubar';

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

import { WindowConfig } from '../config';

let keepRunningInTray = false;
let isQuitting = false;

/**
* Update the "keep running in tray" preference. When `true`, an OS / window
* manager close request hides the window instead of quitting the app.
*
* @param value - `true` to hide on window close, `false` to quit.
*/
export function setKeepRunningInTray(value: boolean): void {
keepRunningInTray = value;
}

/**
* Reset module-level lifecycle flags. Module-level state is unavoidable
* because `app.on(...)` listeners are registered once at startup; this
* helper lets tests start each case from a clean slate.
*
* @internal
*/
export function __resetWindowLifecycleForTests(): void {
keepRunningInTray = false;
isQuitting = false;
}

/**
* Restore menubar's `_browserWindow` field after we hide the window so
* the next tray click reuses the same window instance. Wrapped in a
* try/catch so a future menubar refactor that renames the field
* degrades gracefully (next show creates a fresh window, losing renderer
* state but not crashing).
*/
function restoreMenubarWindowReference(mb: Menubar, win: BrowserWindow): void {
if (mb.window) {
return;
}
try {
(mb as unknown as { _browserWindow: BrowserWindow })._browserWindow = win;
} catch (error) {
logWarn(
'main:window',
`failed to restore menubar window reference: ${toError(error).message}`,
);
}
}

/**
* Attach window-level event listeners for keyboard input and DevTools.
*
* @param mb - The menubar instance whose window events are configured.
*/
export function configureWindowEvents(mb: Menubar): void {
if (!mb.window) {
const win = mb.window;
if (!win) {
return;
}

/**
* Track explicit quit requests so the close handlers can distinguish
* between an app quit and a WM-initiated window close.
*/
app.on('before-quit', () => {
isQuitting = true;
});

/**
* Intercept window close so a WM close request hides the window
* instead of destroying it. Hiding (rather than destroying + recreating)
* preserves the renderer state — keyboard listeners, notification
* cache, scroll position, etc.
*
* Implementation notes:
*
* 1. `menubar` registers its own `close` listener (`windowClear`)
* that nulls `mb.window` regardless of `preventDefault`. Listeners
* run in registration order, so by the time our handler executes,
* `mb.window` is already `undefined`. We capture the
* `BrowserWindow` reference at config time (above) and use that
* captured reference inside the handler, then restore menubar's
* internal field so the next tray click reuses this same window.
*
* 2. On Wayland, calling `hide()` synchronously after `preventDefault`
* on a frameless surface can leave it in a half-closed state where
* the window stays mapped but loses keyboard input routing.
* Deferring with `setImmediate` lets the close cancellation unwind
* first.
*/
win.on('close', (event) => {
if (!keepRunningInTray || isQuitting) {
return;
}

event.preventDefault();

setImmediate(() => {
if (win.isDestroyed()) {
return;
}

win.hide();
restoreMenubarWindowReference(mb, win);
});
});

/**
* Safety net: if the WM tears down the window despite `preventDefault`
* (a known Wayland edge case), suppress the default Electron quit so
* the tray icon stays put and `menubar` can recreate the window on
* the next tray click.
*/
app.on('window-all-closed', () => {
if (keepRunningInTray && !isQuitting) {
return;
}
if (process.platform !== 'darwin') {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's use the isMacOS util from ./shared/platform

app.quit();
}
});

/**
* Listen for 'before-input-event' to detect Escape key presses and hide the window.
*/
Expand Down
9 changes: 9 additions & 0 deletions src/preload/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,15 @@ export const api = {
openAsHidden: value,
}),

/**
* Enable or disable hiding the application window to the tray when the
* window receives a close request (e.g. from the OS / window manager).
*
* @param value - `true` to hide on close, `false` to quit on close.
*/
setKeepRunningInTray: (value: boolean) =>
sendMainEvent(EVENTS.UPDATE_KEEP_RUNNING_IN_TRAY, value),

/**
* Apply the global keyboard shortcut for toggling the app window visibility.
*
Expand Down
1 change: 1 addition & 0 deletions src/renderer/__helpers__/vitest.setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ window.gitify = {
onResetApp: vi.fn(),
onSystemThemeUpdate: vi.fn(),
setAutoLaunch: vi.fn(),
setKeepRunningInTray: vi.fn(),
applyKeyboardShortcut: vi.fn().mockResolvedValue({ success: true }),
raiseNativeNotification: vi.fn(),
};
Expand Down
1 change: 1 addition & 0 deletions src/renderer/__mocks__/state-mocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ const mockSystemSettings: SystemSettingsState = {
playSound: true,
notificationVolume: 20 as Percentage,
openAtStartup: false,
keepRunningInTray: false,
};

export const mockSettings: SettingsState = {
Expand Down
Loading
Loading