fix(macOS): gracefully quit app (#7733)

* fix(macOS): gracefully quit app

When macOS App is closed via menu item "Quit", make sure that IPC flow completes first.

- close main window instead of directly invoking `quitApp()`

* fix(macOS): use quit-intent flag

- do not touch "minimizeToTray" setting
- instead set "quitRequested" flag to indicate quit intent
- set timeout to cancel quit request, if app fails to respond within 5 seconds

* refactor(macOS): split quit-intent state and add timeout rationale

- Separate the dual-purpose timer handle into an explicit boolean +
  named timer to make the semantics obvious.
- Extract the 5s window as a named constant with a comment on why this
  particular trade-off (long enough for sync/finish-day, short enough
  to not block a subsequent tray-hide click after cancel).
- Fix the misleading "set flag" comment in the no-window branch
  (no flag is set there; none is needed).
- Note that the `closed` reset clears the pending timer so it doesn't
  keep the event loop alive.

Pure cleanup, no behavior change.

---------

Co-authored-by: Johannes Millan <johannes.millan@gmail.com>
This commit is contained in:
Stefan Hynst 2026-05-27 17:13:43 +02:00 committed by GitHub
parent 9a7a86c8ec
commit 18472e86b9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 45 additions and 14 deletions

View file

@ -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;

View file

@ -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.