diff --git a/packages/desktop-app/README.md b/packages/desktop-app/README.md index 20a16c36b9..a1e22a5455 100644 --- a/packages/desktop-app/README.md +++ b/packages/desktop-app/README.md @@ -91,10 +91,8 @@ Available in all renderer code via the preload context bridge: ```ts // Window chrome window.electronAPI.windowControls.minimize() -window.electronAPI.windowControls.maximize() +window.electronAPI.windowControls.toggleWindowMode() window.electronAPI.windowControls.close() -window.electronAPI.windowControls.isMaximized() // Promise -window.electronAPI.windowControls.onMaximizedChange(cb) // returns unsubscribe fn // Inter-app messaging window.electronAPI.interApp.send(targetAppId, event, data) diff --git a/packages/desktop-app/shared/ipc-channels.ts b/packages/desktop-app/shared/ipc-channels.ts index 2497cd0a11..78fed628cd 100644 --- a/packages/desktop-app/shared/ipc-channels.ts +++ b/packages/desktop-app/shared/ipc-channels.ts @@ -14,16 +14,10 @@ import type { export const IPC = { /** Window control channels (renderer → main) */ WINDOW_MINIMIZE: "window:minimize", - WINDOW_MAXIMIZE: "window:maximize", + WINDOW_TOGGLE_WINDOW_MODE: "window:toggle-window-mode", WINDOW_CLOSE: "window:close", WINDOW_NATIVE_BUTTONS_VISIBILITY: "window:native-buttons-visibility", - /** Window state query (renderer ↔ main) */ - WINDOW_IS_MAXIMIZED: "window:is-maximized", - - /** Window state broadcast (main → renderer) */ - WINDOW_MAXIMIZED_CHANGED: "window:maximized-changed", - /** Inter-app message relay (renderer → main → renderer) */ INTER_APP_SEND: "inter-app:send", INTER_APP_MESSAGE: "inter-app:message", diff --git a/packages/desktop-app/src/main/index.ts b/packages/desktop-app/src/main/index.ts index 13d023c44f..8019458848 100644 --- a/packages/desktop-app/src/main/index.ts +++ b/packages/desktop-app/src/main/index.ts @@ -14040,15 +14040,6 @@ app.whenReady().then(async () => { } }); - // Broadcast window maximized state changes to the renderer - const broadcastMaximized = (isMaximized: boolean) => - win.webContents.send(IPC.WINDOW_MAXIMIZED_CHANGED, isMaximized); - - win.on("maximize", () => broadcastMaximized(true)); - win.on("unmaximize", () => broadcastMaximized(false)); - win.on("enter-full-screen", () => broadcastMaximized(true)); - win.on("leave-full-screen", () => broadcastMaximized(false)); - // macOS: restore/focus the window when dock icon is clicked app.on("activate", () => { if (isQuickPromptActive()) return; diff --git a/packages/desktop-app/src/main/ipc/window.spec.ts b/packages/desktop-app/src/main/ipc/window.spec.ts new file mode 100644 index 0000000000..77c7cf95cb --- /dev/null +++ b/packages/desktop-app/src/main/ipc/window.spec.ts @@ -0,0 +1,96 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const electronState = vi.hoisted(() => { + const handlers = new Map void>(); + return { + browserWindow: { + fromWebContents: vi.fn(), + }, + ipcMain: { + on: vi.fn( + (channel: string, handler: (event: { sender: unknown }) => void) => { + handlers.set(channel, handler); + }, + ), + }, + handlers, + }; +}); + +vi.mock("electron", () => ({ + BrowserWindow: electronState.browserWindow, + ipcMain: electronState.ipcMain, +})); + +import { IPC } from "@shared/ipc-channels"; + +import { registerWindowIpc, toggleWindowMode } from "./window.js"; + +describe("desktop window controls", () => { + beforeEach(() => { + vi.clearAllMocks(); + electronState.handlers.clear(); + }); + + function createWindow({ + fullscreen = false, + maximized = false, + }: { + fullscreen?: boolean; + maximized?: boolean; + } = {}) { + return { + isFullScreen: vi.fn(() => fullscreen), + setFullScreen: vi.fn(), + isMaximized: vi.fn(() => maximized), + maximize: vi.fn(), + restore: vi.fn(), + }; + } + + it("toggles fullscreen mode on macOS", () => { + const window = createWindow(); + + toggleWindowMode(window, "darwin"); + + expect(window.setFullScreen).toHaveBeenCalledWith(true); + expect(window.maximize).not.toHaveBeenCalled(); + }); + + it("exits fullscreen mode on macOS", () => { + const window = createWindow({ fullscreen: true }); + + toggleWindowMode(window, "darwin"); + + expect(window.setFullScreen).toHaveBeenCalledWith(false); + }); + + it("maximizes the window on Windows and Linux", () => { + for (const platform of ["win32", "linux"] as const) { + const window = createWindow(); + + toggleWindowMode(window, platform); + + expect(window.maximize).toHaveBeenCalledOnce(); + expect(window.setFullScreen).not.toHaveBeenCalled(); + } + }); + + it("restores an already-maximized window on Windows and Linux", () => { + for (const platform of ["win32", "linux"] as const) { + const window = createWindow({ maximized: true }); + + toggleWindowMode(window, platform); + + expect(window.restore).toHaveBeenCalledOnce(); + } + }); + + it("registers the platform-aware window mode command", () => { + registerWindowIpc(); + + expect(electronState.handlers.has(IPC.WINDOW_TOGGLE_WINDOW_MODE)).toBe( + true, + ); + }); +}); diff --git a/packages/desktop-app/src/main/ipc/window.ts b/packages/desktop-app/src/main/ipc/window.ts index 1fece5a88f..eff3868830 100644 --- a/packages/desktop-app/src/main/ipc/window.ts +++ b/packages/desktop-app/src/main/ipc/window.ts @@ -1,21 +1,33 @@ import { IPC } from "@shared/ipc-channels"; -import { +import { BrowserWindow, ipcMain, type IpcMainEvent } from "electron"; + +type WindowModeTarget = Pick< BrowserWindow, - ipcMain, - type IpcMainEvent, - type IpcMainInvokeEvent, -} from "electron"; + "isFullScreen" | "setFullScreen" | "isMaximized" | "maximize" | "restore" +>; + +export function toggleWindowMode( + window: WindowModeTarget, + platform = process.platform, +): void { + if (platform === "darwin") { + window.setFullScreen(!window.isFullScreen()); + return; + } + + window.isMaximized() ? window.restore() : window.maximize(); +} -/** Registers the basic frameless-window control IPC handlers (minimize/maximize/close/is-maximized). */ +/** Registers the basic frameless-window control IPC handlers. */ export function registerWindowIpc(): void { ipcMain.on(IPC.WINDOW_MINIMIZE, (event: IpcMainEvent) => { BrowserWindow.fromWebContents(event.sender)?.minimize(); }); - ipcMain.on(IPC.WINDOW_MAXIMIZE, (event: IpcMainEvent) => { + ipcMain.on(IPC.WINDOW_TOGGLE_WINDOW_MODE, (event: IpcMainEvent) => { const win = BrowserWindow.fromWebContents(event.sender); if (!win) return; - win.isMaximized() ? win.restore() : win.maximize(); + toggleWindowMode(win); }); ipcMain.on(IPC.WINDOW_CLOSE, (event: IpcMainEvent) => { @@ -33,13 +45,4 @@ export function registerWindowIpc(): void { ); }, ); - - ipcMain.handle( - IPC.WINDOW_IS_MAXIMIZED, - (event: IpcMainInvokeEvent): boolean => { - return ( - BrowserWindow.fromWebContents(event.sender)?.isMaximized() ?? false - ); - }, - ); } diff --git a/packages/desktop-app/src/preload/index.ts b/packages/desktop-app/src/preload/index.ts index 69e1740d9d..e347e242e2 100644 --- a/packages/desktop-app/src/preload/index.ts +++ b/packages/desktop-app/src/preload/index.ts @@ -143,21 +143,10 @@ const electronAPI = { /** Window chrome controls */ windowControls: { minimize: () => ipcRenderer.send(IPC.WINDOW_MINIMIZE), - maximize: () => ipcRenderer.send(IPC.WINDOW_MAXIMIZE), + toggleWindowMode: () => ipcRenderer.send(IPC.WINDOW_TOGGLE_WINDOW_MODE), close: () => ipcRenderer.send(IPC.WINDOW_CLOSE), setNativeTrafficLightsVisible: (visible: boolean): void => ipcRenderer.send(IPC.WINDOW_NATIVE_BUTTONS_VISIBILITY, visible), - isMaximized: (): Promise => - ipcRenderer.invoke(IPC.WINDOW_IS_MAXIMIZED), - - /** Subscribe to maximize/restore state changes. Returns an unsubscribe fn. */ - onMaximizedChange: (cb: (isMaximized: boolean) => void): (() => void) => { - const handler = (_: Electron.IpcRendererEvent, value: boolean) => - cb(value); - ipcRenderer.on(IPC.WINDOW_MAXIMIZED_CHANGED, handler); - return () => - ipcRenderer.removeListener(IPC.WINDOW_MAXIMIZED_CHANGED, handler); - }, }, /** Shortcuts forwarded from the main process */ diff --git a/packages/desktop-app/src/renderer/components/WindowControls.tsx b/packages/desktop-app/src/renderer/components/WindowControls.tsx index 5019e5cb4a..232f685dc7 100644 --- a/packages/desktop-app/src/renderer/components/WindowControls.tsx +++ b/packages/desktop-app/src/renderer/components/WindowControls.tsx @@ -3,6 +3,9 @@ export default function WindowControls({ }: { className?: string; }) { + const windowModeLabel = + window.electronAPI?.platform === "darwin" ? "Full screen" : "Maximize"; + return (
); @@ -59,9 +62,9 @@ export function CollapsedMacWindowControls({