Fix Cmd+Q on macOS leaving the desktop app alive

The before-quit handler preventDefaulted on every quit and re-fired
app.quit() from .finally. That re-entered Electron through the
window-all-closed gate, which on macOS is a no-op listener that
vetoes the quit — so the window closed but the process stayed up.

Use app.exit(0) to bypass the close → window-all-closed → will-quit
chain entirely. Also drop the daemon-stop NSAlert: with no parent
window it ran app-modal and stalled the child-process pipes for
'paseo daemon stop', so daemon shutdown waited on the user dismissing
the dialog. Replace it with an in-app overlay rendered in the renderer
via a paseo:event:quitting IPC event. Stop opening DevTools on dev boot.
This commit is contained in:
Mohamed Boudra
2026-04-27 15:22:16 +07:00
parent 2ba523ce05
commit 88f382cad3
5 changed files with 88 additions and 29 deletions
@@ -75,9 +75,9 @@ describe("quit-lifecycle", () => {
);
});
it("gates quit until the stop decision completes, then lets the next quit pass through", async () => {
it("preventDefaults the first quit, runs the async stop decision, then exits hard", async () => {
let resolveStopDecision: (() => void) | null = null;
const app = { quit: vi.fn() };
const app = { exit: vi.fn() };
const closeTransportSessions = vi.fn();
const onStopError = vi.fn();
const preventDefault = vi.fn();
@@ -99,20 +99,20 @@ describe("quit-lifecycle", () => {
expect(preventDefault).toHaveBeenCalledTimes(1);
expect(closeTransportSessions).toHaveBeenCalledTimes(1);
expect(app.quit).not.toHaveBeenCalled();
expect(app.exit).not.toHaveBeenCalled();
expect(resolveStopDecision).not.toBeNull();
resolveStopDecision?.();
await Promise.resolve();
await Promise.resolve();
expect(app.quit).toHaveBeenCalledTimes(1);
expect(app.exit).toHaveBeenCalledWith(0);
expect(onStopError).not.toHaveBeenCalled();
handleBeforeQuit({ preventDefault: secondPreventDefault });
expect(secondPreventDefault).not.toHaveBeenCalled();
expect(closeTransportSessions).toHaveBeenCalledTimes(2);
expect(app.quit).toHaveBeenCalledTimes(1);
expect(app.exit).toHaveBeenCalledTimes(1);
});
});
+10 -15
View File
@@ -11,7 +11,7 @@ interface BeforeQuitEvent {
}
interface BeforeQuitApp {
quit(): void;
exit(code: number): void;
}
export interface StopOnQuitDeps {
@@ -53,30 +53,25 @@ export function createBeforeQuitHandler({
stopDesktopManagedDaemonIfNeeded: () => Promise<boolean>;
onStopError: (error: unknown) => void;
}): (event: BeforeQuitEvent) => void {
let allowingQuitToContinue = false;
let quittingInProgress = false;
// We always preventDefault on first quit so we can run the async stop
// decision, then call app.exit(0) — which bypasses Electron's
// close → window-all-closed → will-quit chain. The window-all-closed
// listener is a darwin no-op (macOS convention) and would otherwise
// veto a re-fired app.quit().
let quitting = false;
return (event) => {
closeTransportSessions();
if (allowingQuitToContinue) {
return;
}
if (quitting) return;
quitting = true;
event.preventDefault();
if (quittingInProgress) {
return;
}
quittingInProgress = true;
void stopDesktopManagedDaemonIfNeeded()
.catch((error) => {
onStopError(error);
})
.finally(() => {
allowingQuitToContinue = true;
quittingInProgress = false;
app.quit();
app.exit(0);
});
};
}
+4 -9
View File
@@ -9,7 +9,7 @@ import path from "node:path";
import { pathToFileURL } from "node:url";
import { existsSync } from "node:fs";
import { execFileSync } from "node:child_process";
import { app, BrowserWindow, dialog, ipcMain, nativeImage, net, protocol } from "electron";
import { app, BrowserWindow, ipcMain, nativeImage, net, protocol } from "electron";
import { createDaemonCommandHandlers, registerDaemonManager } from "./daemon/daemon-manager.js";
import {
parseCliPassthroughArgsFromArgv,
@@ -218,7 +218,6 @@ async function createMainWindow(): Promise<void> {
const { loadReactDevTools } = await import("./features/react-devtools.js");
await loadReactDevTools();
await mainWindow.loadURL(DEV_SERVER_URL);
mainWindow.webContents.openDevTools({ mode: "detach" });
return;
}
@@ -402,13 +401,9 @@ void bootstrap().catch((error) => {
});
function showDaemonShutdownDialog(): void {
void dialog.showMessageBox({
type: "info",
message: "Shutting down Paseo daemon…",
detail: "Paseo will quit once the local daemon has stopped.",
buttons: [],
noLink: true,
});
for (const win of BrowserWindow.getAllWindows()) {
win.webContents.send("paseo:event:quitting", {});
}
}
app.on(