From ba70293380ed709bcccfd725c121c33b0e38c184 Mon Sep 17 00:00:00 2001 From: BlueManCZ Date: Wed, 6 May 2026 22:03:06 +0200 Subject: [PATCH] feat(settings): add "Close to tray" option MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an opt-in System setting (default off) that hides the menubar window on a WM-initiated close request instead of quitting the app. Useful on Linux tiling WMs where a global "close window" shortcut otherwise terminates Gitify. The implementation works around two quirks documented inline in src/main/lifecycle/window.ts: 1. menubar's own `close` listener nulls `mb.window` regardless of preventDefault, and listeners run in registration order, so we capture the BrowserWindow at config time and restore the reference after hiding so the next tray click reuses the same window (preserving renderer state). 2. On Wayland, `hide()` is deferred via setImmediate to let the close cancellation unwind — a synchronous hide can leave the surface mapped but without keyboard input routing. A `window-all-closed` handler acts as a safety net: if the WM tears down the window despite preventDefault (a known Wayland edge case, electron/electron#34788, #35657), it suppresses the default quit so the tray icon stays put and menubar can recreate the window. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/main/handlers/app.ts | 5 + src/main/lifecycle/window.test.ts | 177 +++++++++++++++++- src/main/lifecycle/window.ts | 114 ++++++++++- src/preload/index.ts | 9 + src/renderer/__helpers__/vitest.setup.ts | 1 + src/renderer/__mocks__/state-mocks.ts | 1 + .../components/settings/SystemSettings.tsx | 22 +++ src/renderer/context/App.tsx | 5 + src/renderer/context/defaults.ts | 1 + .../__snapshots__/Settings.test.tsx.snap | 61 +++++- src/renderer/types.ts | 1 + src/renderer/utils/system/comms.ts | 10 + src/shared/events.ts | 1 + 13 files changed, 400 insertions(+), 8 deletions(-) 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" >