diff --git a/.changeset/fn-7472-windows-desktop-close-quits.md b/.changeset/fn-7472-windows-desktop-close-quits.md new file mode 100644 index 0000000000..21f08bc954 --- /dev/null +++ b/.changeset/fn-7472-windows-desktop-close-quits.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Quit Fusion Desktop on Windows when the window is closed. +category: fix +dev: Updates Electron close lifecycle so Windows shutdown reaches embedded runtime cleanup. diff --git a/packages/desktop/README.md b/packages/desktop/README.md index aec579be69..8e25c99388 100644 --- a/packages/desktop/README.md +++ b/packages/desktop/README.md @@ -159,21 +159,25 @@ Desktop local mode uses an in-process runtime manager (`src/local-runtime.ts`) t 11. `startUpdateCheckInterval(mainWindow)` (4-hour periodic background checks) 12. `mainWindow.maximize()` when restored state was maximized -### Window state and close-to-tray behavior +### Window state and platform close behavior - Startup restores width/height from persisted state (fallback: `DEFAULT_WINDOW_STATE`). - Restored position (`x`, `y`) is validated against `screen.getAllDisplays()` work areas. If the restored window rectangle has less than `64px × 64px` overlap with every display, `x`/`y` are dropped and the OS picks a visible default location while preserving width/height. - After `loadURL`/`loadFile`, the window is explicitly `show()` + `focus()` on `ready-to-show`, with a 2-second fallback timer that also `show()`/`focus()`es if `ready-to-show` never fires. - On window close: - state is saved via `saveWindowState(mainWindow)` - - if app is **not quitting**, close is prevented and the window hides to tray - - if app **is quitting**, close proceeds normally + - on **Windows**, close proceeds normally so `window-all-closed` can quit the Electron app and `before-quit` can stop the embedded local Fusion runtime + - on **macOS** and other non-Windows desktop platforms, if app is **not quitting**, close is prevented and the window hides to tray/dock for later restore + - if app **is quitting**, close proceeds normally on every platform +- Renderer `window:close` uses `mainWindow.close()`, so the custom titlebar close button inherits the same platform policy as the native window close button. +- Tray **Show/Hide Window** remains the explicit way to hide or restore the window on all platforms. ### Quit cleanup - `before-quit` sets `app.isQuitting = true` - Periodic updater interval is disposed - Tray instance is destroyed (`tray.destroy()`) +- Embedded local runtime cleanup runs via `localRuntimeManager.stopLocal()`; external CLI runtime mode remains a no-op through the runtime manager - `mainWindow` is nulled on `closed` for clean re-creation on macOS `activate` ## Preload APIs (`window.electronAPI` and `window.fusionShell`) diff --git a/packages/desktop/src/__tests__/ipc.test.ts b/packages/desktop/src/__tests__/ipc.test.ts index 9adc06ee9c..eed1c8f662 100644 --- a/packages/desktop/src/__tests__/ipc.test.ts +++ b/packages/desktop/src/__tests__/ipc.test.ts @@ -129,6 +129,15 @@ describe("ipc handlers", () => { expect(mocks.setupAutoUpdater).not.toHaveBeenCalled(); }); + it("window:close delegates to BrowserWindow.close so platform lifecycle policy is inherited", async () => { + const { window } = await registerHandlers(); + const handler = mocks.ipcHandlers.get("window:close"); + + handler?.({}); + + expect(window.close).toHaveBeenCalledTimes(1); + }); + it("registers shell channels", async () => { await registerHandlers(); const channels = new Set(mocks.ipcMain.handle.mock.calls.map(([channel]) => channel)); diff --git a/packages/desktop/src/__tests__/main.test.ts b/packages/desktop/src/__tests__/main.test.ts index 51723e7dd5..9bd54c3b6f 100644 --- a/packages/desktop/src/__tests__/main.test.ts +++ b/packages/desktop/src/__tests__/main.test.ts @@ -1,4 +1,13 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const platformDescriptor = Object.getOwnPropertyDescriptor(process, "platform"); + +function mockPlatform(platform: NodeJS.Platform): void { + Object.defineProperty(process, "platform", { + configurable: true, + value: platform, + }); +} // Mock renderer module - must be hoisted before importing main const rendererMocks = vi.hoisted(() => { @@ -31,6 +40,7 @@ const mocks = vi.hoisted(() => { show: vi.fn(), focus: vi.fn(), hide: vi.fn(), + close: vi.fn(), maximize: vi.fn(), webContents: { reload: vi.fn() }, }; @@ -43,12 +53,17 @@ const mocks = vi.hoisted(() => { }; BrowserWindow.getAllWindows = vi.fn(() => []); + const appHandlers = new Map void>(); const app = { + isQuitting: false, whenReady: vi.fn(() => Promise.resolve()), getVersion: vi.fn(() => "0.1.0"), getPath: vi.fn(() => "/mock/home"), quit: vi.fn(), - on: vi.fn(), + on: vi.fn((event: string, handler: (...args: unknown[]) => void) => { + appHandlers.set(event, handler); + return app; + }), }; const ipcMain = { @@ -61,6 +76,7 @@ const mocks = vi.hoisted(() => { setToolTip: vi.fn(), setContextMenu: vi.fn(), on: vi.fn(), + destroy: vi.fn(), }; const Tray = vi.fn(function () { @@ -92,6 +108,7 @@ const mocks = vi.hoisted(() => { return { app, + appHandlers, BrowserWindow, ipcMain, trayInstance, @@ -142,6 +159,7 @@ const mainDeps = vi.hoisted(() => { return { startLocal, stopLocal, getStatus, getServerPort }; }), startLocal, + stopLocal, }; }); @@ -208,6 +226,11 @@ describe("main process", () => { beforeEach(() => { vi.clearAllMocks(); vi.resetModules(); + mocks.appHandlers.clear(); + mocks.app.isQuitting = false; + if (platformDescriptor) { + Object.defineProperty(process, "platform", platformDescriptor); + } delete process.env.FUSION_DESKTOP_MODE; delete process.env.FUSION_HOME; if (originalDashboardUrl === undefined) { @@ -228,6 +251,13 @@ describe("main process", () => { mocks.screen.getAllDisplays.mockReturnValue([{ workArea: { x: 0, y: 0, width: 1920, height: 1080 } }]); }); + afterEach(() => { + if (platformDescriptor) { + Object.defineProperty(process, "platform", platformDescriptor); + } + vi.useRealTimers(); + }); + it("DASHBOARD_URL defaults to local file URL in production mode", async () => { delete process.env.FUSION_DASHBOARD_URL; @@ -414,6 +444,48 @@ describe("main process", () => { expect(mocks.browserWindowInstance.on).toHaveBeenCalledWith("closed", expect.any(Function)); }); + it("windows window close saves state and allows quit cleanup instead of hiding", async () => { + mockPlatform("win32"); + 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); + mocks.appHandlers.get("window-all-closed")?.(); + mocks.appHandlers.get("before-quit")?.(); + + expect(mainDeps.saveWindowState).toHaveBeenCalledWith(mocks.browserWindowInstance); + expect(event.preventDefault).not.toHaveBeenCalled(); + expect(mocks.browserWindowInstance.hide).not.toHaveBeenCalled(); + expect(mocks.app.quit).toHaveBeenCalledTimes(1); + expect(mainDeps.stopLocal).toHaveBeenCalledTimes(1); + }); + + it("macOS window close hides to tray without quitting", async () => { + mockPlatform("darwin"); + 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); + mocks.appHandlers.get("window-all-closed")?.(); + + expect(mainDeps.saveWindowState).toHaveBeenCalledWith(mocks.browserWindowInstance); + expect(event.preventDefault).toHaveBeenCalledTimes(1); + expect(mocks.browserWindowInstance.hide).toHaveBeenCalledTimes(1); + expect(mocks.app.quit).not.toHaveBeenCalled(); + }); + it("createMainWindow shows and focuses on ready-to-show", async () => { const { createMainWindow } = await importMainModule(); diff --git a/packages/desktop/src/__tests__/tray.test.ts b/packages/desktop/src/__tests__/tray.test.ts index 2f3adb32bc..265d094c05 100644 --- a/packages/desktop/src/__tests__/tray.test.ts +++ b/packages/desktop/src/__tests__/tray.test.ts @@ -1,8 +1,21 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const platformDescriptor = Object.getOwnPropertyDescriptor(process, "platform"); + +function mockPlatform(platform: NodeJS.Platform): void { + Object.defineProperty(process, "platform", { + configurable: true, + value: platform, + }); +} const mocks = vi.hoisted(() => { + const appHandlers = new Map void>(); const app = { - on: vi.fn(), + on: vi.fn((event: string, handler: (...args: unknown[]) => void) => { + appHandlers.set(event, handler); + return app; + }), quit: vi.fn(), }; @@ -18,6 +31,7 @@ const mocks = vi.hoisted(() => { return { app, + appHandlers, menu, nativeImage, }; @@ -33,12 +47,17 @@ vi.mock("electron", () => ({ function createMainWindowMock(isVisible = true) { const listeners = new Map void>(); + let visible = isVisible; return { - isVisible: vi.fn(() => isVisible), - show: vi.fn(), + isVisible: vi.fn(() => visible), + show: vi.fn(() => { + visible = true; + }), focus: vi.fn(), - hide: vi.fn(), + hide: vi.fn(() => { + visible = false; + }), on: vi.fn((event: string, handler: (...args: unknown[]) => void) => { listeners.set(event, handler); return undefined; @@ -69,6 +88,17 @@ function createTrayMock() { describe("tray module", () => { beforeEach(() => { vi.clearAllMocks(); + vi.resetModules(); + mocks.appHandlers.clear(); + if (platformDescriptor) { + Object.defineProperty(process, "platform", platformDescriptor); + } + }); + + afterEach(() => { + if (platformDescriptor) { + Object.defineProperty(process, "platform", platformDescriptor); + } }); it("getTrayTooltip returns running label", async () => { @@ -146,6 +176,76 @@ describe("tray module", () => { expect(tray.setContextMenu).toHaveBeenCalledTimes(1); }); + it("tray Show/Hide Window explicitly toggles visibility on every platform", async () => { + mockPlatform("win32"); + const { setupTray } = await import("../tray.ts"); + const mainWindow = createMainWindowMock(true); + const tray = createTrayMock(); + + setupTray(mainWindow as never, tray as never); + const visibleTemplate = mocks.menu.buildFromTemplate.mock.calls.at(-1)?.[0] as Array<{ label?: string; click?: () => void }>; + visibleTemplate[0]?.click?.(); + mainWindow.getListener("hide")?.(); + expect(mainWindow.hide).toHaveBeenCalledTimes(1); + + const hiddenTemplate = mocks.menu.buildFromTemplate.mock.calls.at(-1)?.[0] as Array<{ label?: string; click?: () => void }>; + expect(hiddenTemplate[0]).toMatchObject({ label: "Show Window" }); + hiddenTemplate[0]?.click?.(); + expect(mainWindow.show).toHaveBeenCalledTimes(1); + expect(mainWindow.focus).toHaveBeenCalledTimes(1); + }); + + it("tray Quit Fusion sets quitting state and allows later close events", async () => { + mockPlatform("darwin"); + const { setupTray } = await import("../tray.ts"); + const mainWindow = createMainWindowMock(true); + const tray = createTrayMock(); + + setupTray(mainWindow as never, tray as never); + const template = mocks.menu.buildFromTemplate.mock.calls.at(-1)?.[0] as Array<{ label?: string; click?: () => void }>; + template.find((item) => item.label === "Quit Fusion")?.click?.(); + + const closeHandler = mainWindow.getListener("close") as ((event: { preventDefault: () => void }) => void) | undefined; + const event = { preventDefault: vi.fn() }; + closeHandler?.(event); + + expect(mocks.app.quit).toHaveBeenCalledTimes(1); + expect(event.preventDefault).not.toHaveBeenCalled(); + expect(mainWindow.hide).not.toHaveBeenCalled(); + }); + + it("windows close events are not converted into tray hides by setupTray", async () => { + mockPlatform("win32"); + const { setupTray } = await import("../tray.ts"); + const mainWindow = createMainWindowMock(true); + const tray = createTrayMock(); + + setupTray(mainWindow as never, tray as never); + const closeHandler = mainWindow.getListener("close") as ((event: { preventDefault: () => void }) => void) | undefined; + const event = { preventDefault: vi.fn() }; + + closeHandler?.(event); + + expect(event.preventDefault).not.toHaveBeenCalled(); + expect(mainWindow.hide).not.toHaveBeenCalled(); + }); + + it("macOS close events still hide to tray when quit is not in progress", async () => { + mockPlatform("darwin"); + const { setupTray } = await import("../tray.ts"); + const mainWindow = createMainWindowMock(true); + const tray = createTrayMock(); + + setupTray(mainWindow as never, tray as never); + const closeHandler = mainWindow.getListener("close") as ((event: { preventDefault: () => void }) => void) | undefined; + const event = { preventDefault: vi.fn() }; + + closeHandler?.(event); + + expect(event.preventDefault).toHaveBeenCalledTimes(1); + expect(mainWindow.hide).toHaveBeenCalledTimes(1); + }); + it("updateTrayStatus updates tooltip and menu", async () => { const { setupTray, updateTrayStatus } = await import("../tray.ts"); const mainWindow = createMainWindowMock(true); diff --git a/packages/desktop/src/main.ts b/packages/desktop/src/main.ts index f4ef93e6a8..26b31067d8 100644 --- a/packages/desktop/src/main.ts +++ b/packages/desktop/src/main.ts @@ -174,6 +174,14 @@ export function createMainWindow(state?: WindowState, launchTargetUrl?: string): return; } + /* + FNXC:DesktopClosePolicy 2026-07-03-15:30: + Windows Desktop close is a shutdown request, not a tray-minimize request. Let the BrowserWindow close so Electron emits window-all-closed and before-quit, which stops the embedded local Fusion runtime; keep macOS/non-Windows close-to-tray semantics for dock/tray restoration. + */ + if (process.platform === "win32") { + return; + } + event.preventDefault(); window.hide(); }); diff --git a/packages/desktop/src/tray.ts b/packages/desktop/src/tray.ts index 2bb81fe1bd..9ed01ccbcc 100644 --- a/packages/desktop/src/tray.ts +++ b/packages/desktop/src/tray.ts @@ -156,6 +156,10 @@ export function setupTray(mainWindow: BrowserWindow, tray: Tray): Tray { return; } + if (process.platform === "win32") { + return; + } + event.preventDefault(); mainWindow.hide(); });