Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -169,6 +170,7 @@ async function createWindow(): Promise<BrowserWindow> {
}
});
mainWindow = window;
observeMainWindowState?.(window);
// A fresh window is not closing; the previous one's flag must not leak in.
mainWindowClosing = false;

Expand Down Expand Up @@ -210,7 +212,10 @@ async function createWindow(): Promise<BrowserWindow> {
});
window.on("closed", () => {
mainWindowClosing = true;
if (mainWindow === window) mainWindow = null;
if (mainWindow === window) {
mainWindow = null;
observeMainWindowState?.(null);
}
});

try {
Expand Down Expand Up @@ -497,7 +502,7 @@ async function initializeServices(): Promise<void> {
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 () => {
Expand Down
15 changes: 7 additions & 8 deletions src/main/ipc/registerIpc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<void> => {
plugins.assertPermission(pluginId, "browser:open");
Expand Down Expand Up @@ -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<BrowserWindow>((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) => {
Expand All @@ -646,6 +644,7 @@ export function registerIpc({
assertMainRenderer(event, getMainWindow);
updater.install();
});
return observeMainWindow;
}

function isCanvasNavigationPointerBindingInput(
Expand Down
28 changes: 23 additions & 5 deletions src/main/windowState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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<TWindow extends WindowStateObservable>(
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);
};
}
12 changes: 9 additions & 3 deletions tests/window-ipc-contract.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -5,20 +5,26 @@ 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/);
assert.match(contracts, /isMacOS: boolean/);
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<BrowserWindow>/);
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/);
Expand Down
41 changes: 38 additions & 3 deletions tests/window-state.test.mjs
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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(); }
};
}

Expand Down Expand Up @@ -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);
});
Loading