FN-7472: quit Windows desktop runtime on window close

Ensure Windows Desktop treats window close as application shutdown.

- Allow Windows BrowserWindow close events to proceed so Electron emits quit lifecycle hooks.
- Keep non-Windows close-to-tray behavior while documenting platform-specific close semantics.
- Cover native, renderer, tray, and runtime cleanup paths with desktop tests.

Files changed:
 .changeset/fn-7472-windows-desktop-close-quits.md |   7 ++
 packages/desktop/README.md                        |  10 +-
 packages/desktop/src/__tests__/ipc.test.ts        |   9 ++
 packages/desktop/src/__tests__/main.test.ts       |  76 ++++++++++++++-
 packages/desktop/src/__tests__/tray.test.ts       | 110 +++++++++++++++++++++-
 packages/desktop/src/main.ts                      |   8 ++
 packages/desktop/src/tray.ts                      |   4 +
 7 files changed, 214 insertions(+), 10 deletions(-)

Fusion-Task-Id: FN-7472
Fusion-Task-Lineage: c007e20f-37b9-435e-861d-1665e2691bc9
Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-03 15:51:02 -07:00
parent b800f7d0df
commit 52dbc0e65e
7 changed files with 214 additions and 10 deletions

View File

@@ -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.

View File

@@ -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`)

View File

@@ -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));

View File

@@ -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<string, (...args: unknown[]) => 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();

View File

@@ -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<string, (...args: unknown[]) => 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<string, (...args: unknown[]) => 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);

View File

@@ -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();
});

View File

@@ -156,6 +156,10 @@ export function setupTray(mainWindow: BrowserWindow, tray: Tray): Tray {
return;
}
if (process.platform === "win32") {
return;
}
event.preventDefault();
mainWindow.hide();
});