diff --git a/src/main/index.ts b/src/main/index.ts index 8d33b329..21bea4a2 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -135,6 +135,7 @@ let servicesReady = false; let startupRunning = false; let shutdownRunning = false; let shutdownComplete = false; +let observeMainWindowState: ((window: BrowserWindow | null) => void) | null = null; // Set the instant the shell window's close is requested — before the window is // destroyed — and cleared when a new one is created. Electron aborts the // navigations that race that close (ERR_ABORTED / ERR_FAILED / "Object has been @@ -169,6 +170,7 @@ async function createWindow(): Promise { } }); mainWindow = window; + observeMainWindowState?.(window); // A fresh window is not closing; the previous one's flag must not leak in. mainWindowClosing = false; @@ -210,7 +212,10 @@ async function createWindow(): Promise { }); window.on("closed", () => { mainWindowClosing = true; - if (mainWindow === window) mainWindow = null; + if (mainWindow === window) { + mainWindow = null; + observeMainWindowState?.(null); + } }); try { @@ -497,7 +502,7 @@ async function initializeServices(): Promise { await pluginSecretsService.load(); protocol.handle("canvastty-plugin", (request) => pluginManager!.protocolResponse(request.url)); protocol.handle("canvastty-media", (request) => pluginMediaService!.protocolResponse(request)); - registerIpc({ + observeMainWindowState = registerIpc({ settings, providerClis, recheckProviderClis: async () => { diff --git a/src/main/ipc/registerIpc.ts b/src/main/ipc/registerIpc.ts index 55860798..932b3528 100644 --- a/src/main/ipc/registerIpc.ts +++ b/src/main/ipc/registerIpc.ts @@ -15,7 +15,7 @@ import type { } from "../../shared/contracts"; import { IPC } from "../../shared/contracts"; import { isCanvasNavigationMouseButton } from "../../shared/canvasNavigation"; -import { observeWindowState, readWindowState } from "../windowState"; +import { createWindowStateObserver, readWindowState } from "../windowState"; import type { SettingsStore } from "../services/SettingsStore"; import { providerCliAvailability, type ProviderCliRegistry } from "../services/providerCliRegistry"; import type { TerminalManager } from "../services/TerminalManager"; @@ -92,7 +92,7 @@ export function registerIpc({ requestPluginCanvas, broadcastPluginStorageChange, updater -}: Dependencies): void { +}: Dependencies): (window: BrowserWindow | null) => void { const pluginBrowserOpenBroker = new PluginBrowserOpenBroker(getMainWindow); const requestPluginBrowserOpen = async (pluginId: string, value: unknown): Promise => { plugins.assertPermission(pluginId, "browser:open"); @@ -621,12 +621,10 @@ export function registerIpc({ terminals.setVisible(id, visible); }); - const publishWindowState = (window: BrowserWindow): void => { - if (!window.isDestroyed()) window.webContents.send(IPC.windowState, readWindowState(window)); - }; - - const mainWindow = getMainWindow(); - if (mainWindow) observeWindowState(mainWindow, () => publishWindowState(mainWindow)); + const observeMainWindow = createWindowStateObserver((window, state) => { + if (!window.isDestroyed()) window.webContents.send(IPC.windowState, state); + }); + observeMainWindow(getMainWindow()); ipcMain.on(IPC.windowMinimize, (event) => BrowserWindow.fromWebContents(event.sender)?.minimize()); ipcMain.handle(IPC.windowToggleMaximize, (event) => { @@ -646,6 +644,7 @@ export function registerIpc({ assertMainRenderer(event, getMainWindow); updater.install(); }); + return observeMainWindow; } function isCanvasNavigationPointerBindingInput( diff --git a/src/main/windowState.ts b/src/main/windowState.ts index 5b100d03..8c16145b 100644 --- a/src/main/windowState.ts +++ b/src/main/windowState.ts @@ -9,6 +9,7 @@ export type WindowStateEvent = "maximize" | "unmaximize" | "enter-full-screen" | export interface WindowStateObservable extends WindowStateSource { on(event: WindowStateEvent, listener: () => void): void; + off(event: WindowStateEvent, listener: () => void): void; } export function readWindowState( @@ -26,10 +27,27 @@ export function observeWindowState( window: WindowStateObservable, publish: (state: WindowState) => void, platform: NodeJS.Platform = process.platform -): void { +): () => void { const notify = (): void => publish(readWindowState(window, platform)); - window.on("maximize", notify); - window.on("unmaximize", notify); - window.on("enter-full-screen", notify); - window.on("leave-full-screen", notify); + const events: WindowStateEvent[] = ["maximize", "unmaximize", "enter-full-screen", "leave-full-screen"]; + for (const event of events) window.on(event, notify); + return () => { + for (const event of events) window.off(event, notify); + }; +} + +export function createWindowStateObserver( + publish: (window: TWindow, state: WindowState) => void, + platform: NodeJS.Platform = process.platform +): (window: TWindow | null) => void { + let observedWindow: TWindow | null = null; + let stopObserving: (() => void) | null = null; + + return (window) => { + if (observedWindow === window) return; + stopObserving?.(); + stopObserving = null; + observedWindow = window; + if (window) stopObserving = observeWindowState(window, (state) => publish(window, state), platform); + }; } diff --git a/tests/window-ipc-contract.test.mjs b/tests/window-ipc-contract.test.mjs index f4b0378c..c5207031 100644 --- a/tests/window-ipc-contract.test.mjs +++ b/tests/window-ipc-contract.test.mjs @@ -5,12 +5,14 @@ import test from "node:test"; const contractsPath = new URL("../src/shared/contracts.ts", import.meta.url); const preloadPath = new URL("../src/preload/index.ts", import.meta.url); const ipcPath = new URL("../src/main/ipc/registerIpc.ts", import.meta.url); +const mainPath = new URL("../src/main/index.ts", import.meta.url); test("window bridge exposes native fullscreen state updates without a renderer toggle", async () => { - const [contracts, preload, ipc] = await Promise.all([ + const [contracts, preload, ipc, main] = await Promise.all([ readFile(contractsPath, "utf8"), readFile(preloadPath, "utf8"), - readFile(ipcPath, "utf8") + readFile(ipcPath, "utf8"), + readFile(mainPath, "utf8") ]); assert.match(contracts, /fullscreen: boolean/); @@ -18,7 +20,11 @@ test("window bridge exposes native fullscreen state updates without a renderer t assert.match(contracts, /windowState: "window:state"/); assert.match(preload, /isMacOS: process\.platform === "darwin"/); assert.match(preload, /onState: \(listener\) => subscribe\(IPC\.windowState, listener\)/); - assert.match(ipc, /observeWindowState\(mainWindow, \(\) => publishWindowState\(mainWindow\)\)/); + assert.match(ipc, /createWindowStateObserver/); + assert.match(ipc, /return observeMainWindow/); + assert.match(main, /observeMainWindowState = registerIpc\(/); + assert.match(main, /observeMainWindowState\?\.\(window\)/); + assert.match(main, /observeMainWindowState\?\.\(null\)/); assert.doesNotMatch(contracts, /toggleFullScreen/); assert.doesNotMatch(preload, /toggleFullScreen/); assert.doesNotMatch(ipc, /windowToggleFullScreen/); diff --git a/tests/window-state.test.mjs b/tests/window-state.test.mjs index c60a75ea..ad3ea54b 100644 --- a/tests/window-state.test.mjs +++ b/tests/window-state.test.mjs @@ -1,6 +1,6 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { observeWindowState, readWindowState } from "../src/main/windowState.ts"; +import { createWindowStateObserver, observeWindowState, readWindowState } from "../src/main/windowState.ts"; function windowStub({ maximized = false, fullScreen = false } = {}) { return { @@ -9,8 +9,14 @@ function windowStub({ maximized = false, fullScreen = false } = {}) { listeners: new Map(), isMaximized() { return this.maximized; }, isFullScreen() { return this.fullScreen; }, - on(event, listener) { this.listeners.set(event, listener); }, - emit(event) { this.listeners.get(event)?.(); } + on(event, listener) { + const listeners = this.listeners.get(event) ?? new Set(); + listeners.add(listener); + this.listeners.set(event, listeners); + }, + off(event, listener) { this.listeners.get(event)?.delete(listener); }, + listenerCount() { return [...this.listeners.values()].reduce((count, listeners) => count + listeners.size, 0); }, + emit(event) { for (const listener of this.listeners.get(event) ?? []) listener(); } }; } @@ -44,3 +50,32 @@ test("publishes native fullscreen changes", () => { { isMacOS: true, maximized: false, fullscreen: false } ]); }); + +test("rebinds window state after close and reopen without leaking listeners", () => { + const deliveries = []; + const observe = createWindowStateObserver((window, state) => window.webContents.send("window:state", state), "darwin"); + const oldWindow = windowStub(); + oldWindow.webContents = { send() { assert.fail("closed window received a state update"); } }; + const newWindow = windowStub(); + newWindow.webContents = { sent: [], send(channel, state) { this.sent.push({ channel, state }); } }; + + observe(oldWindow); + assert.equal(oldWindow.listenerCount(), 4); + observe(null); + assert.equal(oldWindow.listenerCount(), 0); + observe(newWindow); + observe(newWindow); + assert.equal(newWindow.listenerCount(), 4); + + newWindow.maximized = true; + newWindow.emit("maximize"); + newWindow.fullScreen = true; + newWindow.emit("enter-full-screen"); + deliveries.push(...newWindow.webContents.sent); + + assert.deepEqual(deliveries, [ + { channel: "window:state", state: { isMacOS: true, maximized: true, fullscreen: false } }, + { channel: "window:state", state: { isMacOS: true, maximized: true, fullscreen: true } } + ]); + assert.equal(oldWindow.listenerCount(), 0); +});