diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx index 15f96bb94..4bd30f0a5 100644 --- a/packages/app/src/app/_layout.tsx +++ b/packages/app/src/app/_layout.tsx @@ -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({ /> + ); diff --git a/packages/app/src/components/quitting-overlay.tsx b/packages/app/src/components/quitting-overlay.tsx new file mode 100644 index 000000000..8151391cb --- /dev/null +++ b/packages/app/src/components/quitting-overlay.tsx @@ -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 ( + + + Quitting Paseo… + Stopping the local daemon. + + ); +} + +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, + }, +})); diff --git a/packages/desktop/src/daemon/quit-lifecycle.test.ts b/packages/desktop/src/daemon/quit-lifecycle.test.ts index a0d856aa3..d9af82b65 100644 --- a/packages/desktop/src/daemon/quit-lifecycle.test.ts +++ b/packages/desktop/src/daemon/quit-lifecycle.test.ts @@ -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); }); }); diff --git a/packages/desktop/src/daemon/quit-lifecycle.ts b/packages/desktop/src/daemon/quit-lifecycle.ts index f2abdbfc8..247ca2b3f 100644 --- a/packages/desktop/src/daemon/quit-lifecycle.ts +++ b/packages/desktop/src/daemon/quit-lifecycle.ts @@ -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; 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); }); }; } diff --git a/packages/desktop/src/main.ts b/packages/desktop/src/main.ts index c586e6211..8b641829c 100644 --- a/packages/desktop/src/main.ts +++ b/packages/desktop/src/main.ts @@ -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 { 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(