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:
7
.changeset/fn-7472-windows-desktop-close-quits.md
Normal file
7
.changeset/fn-7472-windows-desktop-close-quits.md
Normal 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.
|
||||
@@ -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`)
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -156,6 +156,10 @@ export function setupTray(mainWindow: BrowserWindow, tray: Tray): Tray {
|
||||
return;
|
||||
}
|
||||
|
||||
if (process.platform === "win32") {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
mainWindow.hide();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user