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 { CommandCenter } from "@/components/command-center";
import { WorktreeSetupCalloutSource } from "@/components/worktree-setup-callout-source"; import { WorktreeSetupCalloutSource } from "@/components/worktree-setup-callout-source";
import { DownloadToast } from "@/components/download-toast"; import { DownloadToast } from "@/components/download-toast";
import { QuittingOverlay } from "@/components/quitting-overlay";
import { KeyboardShortcutsDialog } from "@/components/keyboard-shortcuts-dialog"; import { KeyboardShortcutsDialog } from "@/components/keyboard-shortcuts-dialog";
import { LeftSidebar } from "@/components/left-sidebar"; import { LeftSidebar } from "@/components/left-sidebar";
import { ProjectPickerModal } from "@/components/project-picker-modal"; import { ProjectPickerModal } from "@/components/project-picker-modal";
@@ -479,6 +480,7 @@ function AppContainer({
/> />
<WorkspaceSetupDialog /> <WorkspaceSetupDialog />
<KeyboardShortcutsDialog /> <KeyboardShortcutsDialog />
<QuittingOverlay />
</View> </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; let resolveStopDecision: (() => void) | null = null;
const app = { quit: vi.fn() }; const app = { exit: vi.fn() };
const closeTransportSessions = vi.fn(); const closeTransportSessions = vi.fn();
const onStopError = vi.fn(); const onStopError = vi.fn();
const preventDefault = vi.fn(); const preventDefault = vi.fn();
@@ -99,20 +99,20 @@ describe("quit-lifecycle", () => {
expect(preventDefault).toHaveBeenCalledTimes(1); expect(preventDefault).toHaveBeenCalledTimes(1);
expect(closeTransportSessions).toHaveBeenCalledTimes(1); expect(closeTransportSessions).toHaveBeenCalledTimes(1);
expect(app.quit).not.toHaveBeenCalled(); expect(app.exit).not.toHaveBeenCalled();
expect(resolveStopDecision).not.toBeNull(); expect(resolveStopDecision).not.toBeNull();
resolveStopDecision?.(); resolveStopDecision?.();
await Promise.resolve(); await Promise.resolve();
await Promise.resolve(); await Promise.resolve();
expect(app.quit).toHaveBeenCalledTimes(1); expect(app.exit).toHaveBeenCalledWith(0);
expect(onStopError).not.toHaveBeenCalled(); expect(onStopError).not.toHaveBeenCalled();
handleBeforeQuit({ preventDefault: secondPreventDefault }); handleBeforeQuit({ preventDefault: secondPreventDefault });
expect(secondPreventDefault).not.toHaveBeenCalled(); expect(secondPreventDefault).not.toHaveBeenCalled();
expect(closeTransportSessions).toHaveBeenCalledTimes(2); 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 { interface BeforeQuitApp {
quit(): void; exit(code: number): void;
} }
export interface StopOnQuitDeps { export interface StopOnQuitDeps {
@@ -53,30 +53,25 @@ export function createBeforeQuitHandler({
stopDesktopManagedDaemonIfNeeded: () => Promise<boolean>; stopDesktopManagedDaemonIfNeeded: () => Promise<boolean>;
onStopError: (error: unknown) => void; onStopError: (error: unknown) => void;
}): (event: BeforeQuitEvent) => void { }): (event: BeforeQuitEvent) => void {
let allowingQuitToContinue = false; // We always preventDefault on first quit so we can run the async stop
let quittingInProgress = false; // 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) => { return (event) => {
closeTransportSessions(); closeTransportSessions();
if (quitting) return;
if (allowingQuitToContinue) { quitting = true;
return;
}
event.preventDefault(); event.preventDefault();
if (quittingInProgress) {
return;
}
quittingInProgress = true;
void stopDesktopManagedDaemonIfNeeded() void stopDesktopManagedDaemonIfNeeded()
.catch((error) => { .catch((error) => {
onStopError(error); onStopError(error);
}) })
.finally(() => { .finally(() => {
allowingQuitToContinue = true; app.exit(0);
quittingInProgress = false;
app.quit();
}); });
}; };
} }
+4 -9
View File
@@ -9,7 +9,7 @@ import path from "node:path";
import { pathToFileURL } from "node:url"; import { pathToFileURL } from "node:url";
import { existsSync } from "node:fs"; import { existsSync } from "node:fs";
import { execFileSync } from "node:child_process"; 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 { createDaemonCommandHandlers, registerDaemonManager } from "./daemon/daemon-manager.js";
import { import {
parseCliPassthroughArgsFromArgv, parseCliPassthroughArgsFromArgv,
@@ -218,7 +218,6 @@ async function createMainWindow(): Promise<void> {
const { loadReactDevTools } = await import("./features/react-devtools.js"); const { loadReactDevTools } = await import("./features/react-devtools.js");
await loadReactDevTools(); await loadReactDevTools();
await mainWindow.loadURL(DEV_SERVER_URL); await mainWindow.loadURL(DEV_SERVER_URL);
mainWindow.webContents.openDevTools({ mode: "detach" });
return; return;
} }
@@ -402,13 +401,9 @@ void bootstrap().catch((error) => {
}); });
function showDaemonShutdownDialog(): void { function showDaemonShutdownDialog(): void {
void dialog.showMessageBox({ for (const win of BrowserWindow.getAllWindows()) {
type: "info", win.webContents.send("paseo:event:quitting", {});
message: "Shutting down Paseo daemon…", }
detail: "Paseo will quit once the local daemon has stopped.",
buttons: [],
noLink: true,
});
} }
app.on( app.on(