feat(desktop): Windows close-to-tray option in the close dialog

Windows close previously always meant full shutdown (macOS/Linux hid to
the existing tray icon). The close dialog now defaults to Minimize to
tray — Fusion and the embedded PostgreSQL keep running in the
background, restorable from the tray — alongside the exit choices
(stop or keep PostgreSQL). OS session end still skips dialogs and
performs the full stop.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-07-18 00:47:06 -07:00
parent 68ce5c31da
commit c4a00d7c37
3 changed files with 72 additions and 6 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": minor
---
summary: The Windows desktop close dialog now offers Minimize to tray, keeping Fusion and the embedded PostgreSQL running in the background.
category: feature
dev: "win32 close dialog gains a Minimize to tray default alongside Exit-and-stop-PostgreSQL / Exit-leave-it-running (or a two-button variant when no embedded runtime is active); tray click restores the window. OS session end still skips dialogs and performs the full stop."

View File

@@ -121,6 +121,7 @@ const mocks = vi.hoisted(() => {
Menu, Menu,
nativeImage, nativeImage,
shell, shell,
dialog: { showMessageBoxSync: vi.fn(() => 1) },
screen, screen,
browserWindowInstance, browserWindowInstance,
browserWindowHandlers, browserWindowHandlers,
@@ -133,6 +134,7 @@ const mocks = vi.hoisted(() => {
vi.mock("electron", () => ({ vi.mock("electron", () => ({
app: mocks.app, app: mocks.app,
dialog: mocks.dialog,
BrowserWindow: mocks.BrowserWindow, BrowserWindow: mocks.BrowserWindow,
ipcMain: mocks.ipcMain, ipcMain: mocks.ipcMain,
Tray: mocks.Tray, Tray: mocks.Tray,
@@ -496,8 +498,14 @@ describe("main process", () => {
expect(mocks.browserWindowInstance.on).toHaveBeenCalledWith("closed", expect.any(Function)); expect(mocks.browserWindowInstance.on).toHaveBeenCalledWith("closed", expect.any(Function));
}); });
it("windows window close saves state and allows quit cleanup instead of hiding", async () => { /*
FNXC:DesktopClosePolicy 2026-07-18-06:40:
Windows close now asks: Minimize to tray keeps the app (and embedded
PostgreSQL) running in the background; Exit performs the full shutdown.
*/
it("windows window close with Exit chosen saves state and allows quit cleanup instead of hiding", async () => {
mockPlatform("win32"); mockPlatform("win32");
mocks.dialog.showMessageBoxSync.mockReturnValue(1);
const { initializeApp, run } = await importMainModule(); const { initializeApp, run } = await importMainModule();
await initializeApp(); await initializeApp();
@@ -518,6 +526,25 @@ describe("main process", () => {
expect(mainDeps.stopLocal).toHaveBeenCalledTimes(1); expect(mainDeps.stopLocal).toHaveBeenCalledTimes(1);
}); });
it("windows window close with Minimize to tray hides instead of quitting", async () => {
mockPlatform("win32");
mocks.dialog.showMessageBoxSync.mockReturnValue(0);
const { initializeApp, run } = await importMainModule();
await initializeApp();
run();
const closeHandler = mocks.browserWindowHandlers.get("close") as
| ((event: { preventDefault: () => void }) => void)
| undefined;
const event = { preventDefault: vi.fn() };
closeHandler?.(event);
expect(event.preventDefault).toHaveBeenCalledTimes(1);
expect(mocks.browserWindowInstance.hide).toHaveBeenCalledTimes(1);
expect(mocks.app.quit).not.toHaveBeenCalled();
});
it("macOS window close hides to tray without quitting", async () => { it("macOS window close hides to tray without quitting", async () => {
mockPlatform("darwin"); mockPlatform("darwin");
const { initializeApp, run } = await importMainModule(); const { initializeApp, run } = await importMainModule();

View File

@@ -271,19 +271,51 @@ export function createMainWindow(state?: WindowState, launchTargetUrl?: string):
force-killed, skipping all teardown. Electron's app "session-end" event force-killed, skipping all teardown. Electron's app "session-end" event
flags that case; the default (full stop) then applies. flags that case; the default (full stop) then applies.
*/ */
/*
FNXC:DesktopClosePolicy 2026-07-18-06:40:
Operator request: Windows needs a close-to-tray background option like
macOS/Linux. The close dialog now offers it alongside the exit choices;
"Minimize to tray" keeps Fusion (and the embedded PostgreSQL) running in
the background, restorable from the tray icon. OS session end still skips
every dialog and performs the full stop.
*/
if (osSessionEnding) {
return;
}
const runtimeStatus = localRuntimeManager?.getStatus(); const runtimeStatus = localRuntimeManager?.getStatus();
if (!osSessionEnding && runtimeStatus?.state === "running" && runtimeStatus.source === "embedded-local") { if (runtimeStatus?.state === "running" && runtimeStatus.source === "embedded-local") {
const choice = dialog.showMessageBoxSync(window, { const choice = dialog.showMessageBoxSync(window, {
type: "question", type: "question",
title: "Fusion", title: "Fusion",
message: "Also shut down the embedded PostgreSQL server?", message: "Close Fusion?",
detail: "Other Fusion processes (like the fn CLI) can keep using it if you leave it running. It will be reused on the next start either way.", detail: "Minimize to tray keeps Fusion and the embedded PostgreSQL server running in the background. If you exit, other Fusion processes (like the fn CLI) can keep using PostgreSQL only if you leave it running.",
buttons: ["Shut down PostgreSQL", "Leave it running"], buttons: ["Minimize to tray", "Exit and stop PostgreSQL", "Exit, leave PostgreSQL running"],
defaultId: 0, defaultId: 0,
cancelId: 0, cancelId: 0,
noLink: true, noLink: true,
}); });
keepEmbeddedPostgresOnQuit = choice === 1; if (choice === 0) {
event.preventDefault();
window.hide();
return;
}
keepEmbeddedPostgresOnQuit = choice === 2;
return;
}
const plainChoice = dialog.showMessageBoxSync(window, {
type: "question",
title: "Fusion",
message: "Close Fusion?",
detail: "Minimize to tray keeps Fusion running in the background; restore it from the tray icon.",
buttons: ["Minimize to tray", "Exit"],
defaultId: 0,
cancelId: 0,
noLink: true,
});
if (plainChoice === 0) {
event.preventDefault();
window.hide();
return;
} }
return; return;
} }