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
+2
View File
@@ -29,6 +29,7 @@ import { UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import { CommandCenter } from "@/components/command-center";
import { WorktreeSetupCalloutSource } from "@/components/worktree-setup-callout-source";
import { DownloadToast } from "@/components/download-toast";
import { QuittingOverlay } from "@/components/quitting-overlay";
import { KeyboardShortcutsDialog } from "@/components/keyboard-shortcuts-dialog";
import { LeftSidebar } from "@/components/left-sidebar";
import { ProjectPickerModal } from "@/components/project-picker-modal";
@@ -479,6 +480,7 @@ function AppContainer({
/>
<WorkspaceSetupDialog />
<KeyboardShortcutsDialog />
<QuittingOverlay />
</View>
);
@@ -0,0 +1,67 @@
import { useEffect, useState } from "react";
import { Text, View } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { getIsElectronRuntime } from "@/constants/layout";
import { listenToDesktopEvent } from "@/desktop/electron/events";
export function QuittingOverlay() {
const { theme } = useUnistyles();
const [quitting, setQuitting] = useState(false);
useEffect(() => {
if (!getIsElectronRuntime()) return;
let cancelled = false;
let unlisten: (() => void) | null = null;
void listenToDesktopEvent("quitting", () => {
if (!cancelled) setQuitting(true);
}).then((fn) => {
if (cancelled) {
fn();
} else {
unlisten = fn;
}
return undefined;
});
return () => {
cancelled = true;
unlisten?.();
};
}, []);
if (!quitting) return null;
return (
<View style={styles.overlay}>
<LoadingSpinner size="large" color={theme.colors.foregroundMuted} />
<Text style={styles.title}>Quitting Paseo…</Text>
<Text style={styles.detail}>Stopping the local daemon.</Text>
</View>
);
}
const styles = StyleSheet.create((theme) => ({
overlay: {
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: theme.colors.surface1,
alignItems: "center",
justifyContent: "center",
gap: theme.spacing[3],
zIndex: 9999,
},
title: {
color: theme.colors.foreground,
fontSize: theme.fontSize.base,
},
detail: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
},
}));
@@ -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(