diff --git a/electron/main-window.ts b/electron/main-window.ts index 34655f0e80..1a2b95c053 100644 --- a/electron/main-window.ts +++ b/electron/main-window.ts @@ -62,6 +62,42 @@ export const getWin = (): BrowserWindow => { return mainWinModule.win; }; +// How long the "quit requested" intent survives before auto-clearing. +// Long enough to cover normal before-close IPC (sync, finish-day prompt); +// short enough that if the user cancels finish-day and then clicks the +// window close button, they get their normal minimize-to-tray behavior +// rather than being re-prompted indefinitely. +const QUIT_REQUEST_TIMEOUT_MS = 5_000; + +let isQuitRequested = false; +let quitRequestResetTimer: NodeJS.Timeout | undefined; + +const getIsQuitRequested = (): boolean => isQuitRequested; + +const setIsQuitRequested = (flag: boolean): void => { + if (quitRequestResetTimer) clearTimeout(quitRequestResetTimer); + isQuitRequested = flag; + quitRequestResetTimer = flag + ? setTimeout(() => { + isQuitRequested = false; + quitRequestResetTimer = undefined; + }, QUIT_REQUEST_TIMEOUT_MS) + : undefined; +}; + +export const closeWinAndQuit = (quitApp: () => void): void => { + if (mainWin && !mainWin.isDestroyed()) { + // Ensure the close handler takes the real close path (not the minimize-to-tray + // hide branch) so the before-close IPC flow (sync, finish-day) completes. + setIsQuitRequested(true); + mainWin.close(); + } else { + // No window to drive the IPC flow through — quit directly. No flag + // needed: the close handler that reads it cannot run without a window. + quitApp(); + } +}; + export const getIsAppReady = (): boolean => { return mainWinModule.isAppReady; }; @@ -466,7 +502,7 @@ function createMenu(quitApp: () => void): void { { label: 'Quit', accelerator: 'CmdOrCtrl+Q', - click: quitApp, + click: () => closeWinAndQuit(quitApp), }, ], }, @@ -519,7 +555,7 @@ const appCloseHandler = (app: App): void => { // NOTE: this might not work if we run a second instance of the app log('close event: isQuiting=', getIsQuiting(), 'pendingBeforeCloseIds=', ids); if (!getIsQuiting()) { - if (getIsMinimizeToTray()) { + if (getIsMinimizeToTray() && !getIsQuitRequested()) { const indicator = ensureIndicator(); if (indicator) { event.preventDefault(); @@ -542,6 +578,10 @@ const appCloseHandler = (app: App): void => { }); mainWin.on('closed', () => { + // Clear any pending reset timer so it doesn't keep the event loop alive + // after the window is gone. + setIsQuitRequested(false); + // Dereference the window object mainWin = null; mainWinModule.win = null; diff --git a/electron/start-app.ts b/electron/start-app.ts index 1716bd29db..812b2f3177 100644 --- a/electron/start-app.ts +++ b/electron/start-app.ts @@ -13,14 +13,14 @@ import { CONFIG } from './CONFIG'; import { lazySetInterval } from './shared-with-frontend/lazy-set-interval'; import { initIndicator } from './indicator'; import { quitApp, showOrFocus } from './various-shared'; -import { createWindow, getWin } from './main-window'; +import { closeWinAndQuit, createWindow } from './main-window'; import { IdleTimeHandler } from './idle-time-handler'; import { destroyTaskWidget } from './task-widget/task-widget'; import { initializeProtocolHandling, processPendingProtocolUrls, } from './protocol-handler'; -import { getIsQuiting, setIsLocked, setIsMinimizeToTray } from './shared-state'; +import { getIsQuiting, setIsLocked } from './shared-state'; import { clearStaleLevelDbLocks } from './clear-stale-idb-locks'; import { evaluateGpuStartupGuard } from './gpu-startup-guard'; import * as fs from 'fs'; @@ -430,16 +430,7 @@ export const startApp = (): void => { // which manages the before-close callback flow (sync, finish-day, etc.) // and sets isQuiting=true before re-quitting. event.preventDefault(); - const win = getWin(); - if (win && !win.isDestroyed()) { - // Ensure the close handler takes the real close path (not the minimize-to-tray - // hide branch) so the before-close IPC flow (sync, finish-day) completes. - setIsMinimizeToTray(false); - win.close(); - } else { - // No window to close — set flag and re-trigger quit directly. - quitApp(); - } + closeWinAndQuit(quitApp); return; } // isQuiting=true: all before-close IPC work is complete — safe to clean up.