diff --git a/electron/main.cjs b/electron/main.cjs index c5d2f8a59..6a2be3110 100644 --- a/electron/main.cjs +++ b/electron/main.cjs @@ -1546,6 +1546,12 @@ function createWindow() { console.log("Frontend loaded successfully"); }); + mainWindow.webContents.on("will-prevent-unload", (event) => { + // Explicit Quit must not be vetoed by the renderer's connection guard. + // In this Electron event, preventDefault allows the unload to proceed. + if (isQuitting) event.preventDefault(); + }); + mainWindow.on("close", (event) => { if (!isQuitting && tray && !tray.isDestroyed()) { event.preventDefault(); diff --git a/src/backend/tests/electron/app-quit.test.ts b/src/backend/tests/electron/app-quit.test.ts new file mode 100644 index 000000000..af93fafc8 --- /dev/null +++ b/src/backend/tests/electron/app-quit.test.ts @@ -0,0 +1,97 @@ +import { EventEmitter } from "node:events"; +import fs from "node:fs"; +import vm from "node:vm"; +import { describe, expect, it, vi } from "vitest"; + +const main = fs.readFileSync( + new URL("../../../../electron/main.cjs", import.meta.url), + "utf8", +); +const shell = fs.readFileSync( + new URL("../../../ui/AppShell.tsx", import.meta.url), + "utf8", +); + +function harness(active = true, hasTray = true) { + const app = new EventEmitter(); + const window = Object.assign(new EventEmitter(), { + webContents: new EventEmitter(), + hide: vi.fn(), + }); + const context = vm.createContext({ + isQuitting: false, + app, + mainWindow: window, + console, + tray: hasTray ? { isDestroyed: () => false } : null, + hasActiveConnection: () => active, + }); + // Run the production registrations together, sharing the actual quit flag. + vm.runInContext( + main.slice( + main.indexOf(' mainWindow.webContents.on("did-finish-load"'), + main.indexOf(" mainWindow.webContents.setWindowOpenHandler"), + ), + context, + ); + vm.runInContext( + main.slice( + main.indexOf('app.on("before-quit"'), + main.indexOf('app.on("will-quit"'), + ), + context, + ); + const unload = shell.slice( + shell.indexOf(" const handleBeforeUnload ="), + shell.indexOf(' window.addEventListener("beforeunload"'), + ); + vm.runInContext( + unload.replace("(event: BeforeUnloadEvent)", "(event)") + + "\nglobalThis.unload = handleBeforeUnload;", + context, + ); + const rendererUnload = () => { + const event = { preventDefault: vi.fn(), returnValue: undefined }; + context.unload(event); + return event.preventDefault.mock.calls.length > 0; + }; + function attemptQuit() { + app.emit("before-quit"); + const close = { preventDefault: vi.fn() }; + window.emit("close", close); + if (close.preventDefault.mock.calls.length) return false; + if (!rendererUnload()) return true; + const prevented = { preventDefault: vi.fn() }; + window.webContents.emit("will-prevent-unload", prevented); + return prevented.preventDefault.mock.calls.length > 0; + } + return { window, rendererUnload, attemptQuit }; +} + +describe("desktop quit with active connections", () => { + it.each([true, false])( + "allows explicit quit when the renderer vetoes unloading (tray: %s)", + (hasTray) => { + const app = harness(true, hasTray); + expect(app.attemptQuit()).toBe(true); + expect(app.window.hide).not.toHaveBeenCalled(); + }, + ); + it("keeps the unload guard during ordinary navigation or reload", () => { + const app = harness(); + expect(app.rendererUnload()).toBe(true); + const event = { preventDefault: vi.fn() }; + app.window.webContents.emit("will-prevent-unload", event); + expect(event.preventDefault).not.toHaveBeenCalled(); + }); + it("continues hiding ordinary window closes in the tray", () => { + const app = harness(); + const event = { preventDefault: vi.fn() }; + app.window.emit("close", event); + expect(event.preventDefault).toHaveBeenCalledOnce(); + expect(app.window.hide).toHaveBeenCalledOnce(); + }); + it("quits normally when no connection blocks unloading", () => { + expect(harness(false).attemptQuit()).toBe(true); + }); +});