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:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user