diff --git a/electron/main-window.ts b/electron/main-window.ts index e967e20204..71aa46cffb 100644 --- a/electron/main-window.ts +++ b/electron/main-window.ts @@ -455,7 +455,7 @@ const appCloseHandler = (app: App): void => { mainWin.on('close', (event) => { // NOTE: this might not work if we run a second instance of the app - log('close, isQuiting:', getIsQuiting()); + log('close event: isQuiting=', getIsQuiting(), 'pendingBeforeCloseIds=', ids); if (!getIsQuiting()) { event.preventDefault(); if (getIsMinimizeToTray()) { diff --git a/electron/start-app.ts b/electron/start-app.ts index d95c77f1c5..774541e97a 100644 --- a/electron/start-app.ts +++ b/electron/start-app.ts @@ -21,14 +21,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 } from './main-window'; +import { createWindow, getWin } 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 } from './shared-state'; +import { getIsQuiting, setIsQuiting, setIsLocked } from './shared-state'; const ICONS_FOLDER = __dirname + '/assets/icons/'; const IS_MAC = process.platform === 'darwin'; @@ -299,18 +299,32 @@ export const startApp = (): void => { appIN.on('will-quit', () => { // un-register all shortcuts. globalShortcut.unregisterAll(); + // Safe to remove IPC listeners here: all windows are closed and before-close + // IPC flows (sync, finish-day) are guaranteed to have completed. + ipcMain.removeAllListeners(); }); - appIN.on('before-quit', () => { - log('App before-quit: cleaning up resources'); - - // Clean up task widget before quitting + appIN.on('before-quit', (event) => { + log('App before-quit: isQuiting=', getIsQuiting()); + if (!getIsQuiting()) { + // Native quit path (Cmd+Q, Dock > Quit on macOS): app.quit() was called + // by the OS without going through quitApp(), so isQuiting was never set. + // Prevent the immediate quit and delegate to the window close handler, + // 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()) { + win.close(); + } else { + // No window to close — set flag and re-trigger quit directly. + setIsQuiting(true); + app.quit(); + } + return; + } + // isQuiting=true: all before-close IPC work is complete — safe to clean up. destroyTaskWidget(); - - // Remove all IPC listeners to prevent memory leaks - ipcMain.removeAllListeners(); - - // Clear any pending timeouts/intervals if (global.gc) { global.gc(); }