diff --git a/src/main/handlers/app.ts b/src/main/handlers/app.ts index 7d7770564..953aaeb41 100644 --- a/src/main/handlers/app.ts +++ b/src/main/handlers/app.ts @@ -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. @@ -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; diff --git a/src/main/lifecycle/window.test.ts b/src/main/lifecycle/window.test.ts index 7c688d19d..280c7e230 100644 --- a/src/main/lifecycle/window.test.ts +++ b/src/main/lifecycle/window.test.ts @@ -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: { @@ -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; + 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: { @@ -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(), }, @@ -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 }; @@ -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).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(); + }); + }); }); diff --git a/src/main/lifecycle/window.ts b/src/main/lifecycle/window.ts index 81d219868..fbe72148c 100644 --- a/src/main/lifecycle/window.ts +++ b/src/main/lifecycle/window.ts @@ -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') { + app.quit(); + } + }); + /** * Listen for 'before-input-event' to detect Escape key presses and hide the window. */ diff --git a/src/preload/index.ts b/src/preload/index.ts index 055fc13d1..0ff8fc127 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -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. * diff --git a/src/renderer/__helpers__/vitest.setup.ts b/src/renderer/__helpers__/vitest.setup.ts index 2c5914c89..559c86fb6 100644 --- a/src/renderer/__helpers__/vitest.setup.ts +++ b/src/renderer/__helpers__/vitest.setup.ts @@ -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(), }; diff --git a/src/renderer/__mocks__/state-mocks.ts b/src/renderer/__mocks__/state-mocks.ts index 4e7f58be4..f9b8e8b21 100644 --- a/src/renderer/__mocks__/state-mocks.ts +++ b/src/renderer/__mocks__/state-mocks.ts @@ -65,6 +65,7 @@ const mockSystemSettings: SystemSettingsState = { playSound: true, notificationVolume: 20 as Percentage, openAtStartup: false, + keepRunningInTray: false, }; export const mockSettings: SettingsState = { diff --git a/src/renderer/components/settings/SystemSettings.tsx b/src/renderer/components/settings/SystemSettings.tsx index b04e40674..607c56212 100644 --- a/src/renderer/components/settings/SystemSettings.tsx +++ b/src/renderer/components/settings/SystemSettings.tsx @@ -372,6 +372,28 @@ export const SystemSettings: FC = () => { } visible={!window.gitify.platform.isLinux()} /> + + + updateSetting('keepRunningInTray', !settings.keepRunningInTray) + } + tooltip={ + + + Hide {APPLICATION.NAME} to the system tray when the window is + closed by the operating system or window manager, instead of + quitting the application. + + + Use the tray menu or the Quit shortcut to fully exit{' '} + {APPLICATION.NAME}. + + + } + /> ); diff --git a/src/renderer/context/App.tsx b/src/renderer/context/App.tsx index dd3630a42..8be57c5f5 100644 --- a/src/renderer/context/App.tsx +++ b/src/renderer/context/App.tsx @@ -57,6 +57,7 @@ import { decryptValue, encryptValue, setAutoLaunch, + setKeepRunningInTray, setUseAlternateIdleIcon, setUseUnreadActiveIcon, } from '../utils/system/comms'; @@ -368,6 +369,10 @@ export const AppProvider = ({ children }: { children: ReactNode }) => { setAutoLaunch(settings.openAtStartup); }, [settings.openAtStartup]); + useEffect(() => { + setKeepRunningInTray(settings.keepRunningInTray); + }, [settings.keepRunningInTray]); + useEffect(() => { window.gitify.onResetApp(() => { clearState(); diff --git a/src/renderer/context/defaults.ts b/src/renderer/context/defaults.ts index ae22026cc..02150c4d4 100644 --- a/src/renderer/context/defaults.ts +++ b/src/renderer/context/defaults.ts @@ -56,6 +56,7 @@ const defaultSystemSettings: SystemSettingsState = { playSound: true, notificationVolume: 20 as Percentage, openAtStartup: false, + keepRunningInTray: false, }; export const defaultSettings: SettingsState = { diff --git a/src/renderer/routes/__snapshots__/Settings.test.tsx.snap b/src/renderer/routes/__snapshots__/Settings.test.tsx.snap index bf0640eec..4246d0d95 100644 --- a/src/renderer/routes/__snapshots__/Settings.test.tsx.snap +++ b/src/renderer/routes/__snapshots__/Settings.test.tsx.snap @@ -2138,6 +2138,55 @@ exports[`renderer/routes/Settings.tsx > should render itself & its children 1`] +
+ + + +
should render itself & its children 1`] data-wrap="nowrap" >