From ea58fc78a1a7cf4c4743d5bb48f60dce04861e33 Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Thu, 15 Oct 2026 18:09:19 +0200 Subject: [PATCH] fix(editor): let the editor un-maximize on Wayland and stop its bounds drifting On GNOME Wayland the editor opened maximized could not be restored: its title-bar restore button did nothing. Electron 41's Chromium latches its "restore to maximized" flag when maximize() is asked for while a configure without the maximized state is still on its way, which is always the case for the maximize issued right after construction (electron/electron#53063, fixed in Electron 42+, not backported to 41). Under Wayland the editor now maximizes once the compositor has mapped and focused it, after a short settle; the bench keeps its immediate maximize. editor-window.json also grew on every maximized close (+30x+41 px, origin -15/-10): under Wayland getNormalBounds() read while maximized subtracts the maximized frame insets from the restored, shadow-carrying widget rect. The normal bounds are now taken from getBounds() while the window is in its normal state. What is saved is clamped to the work area, which under Wayland is the whole output, so a tenth of each dimension is left to panels; and a saved position, which a Wayland client can neither set nor read, is no longer handed back to the window there. Windows, macOS and X11 (including --ozone-platform=x11) keep the immediate maximize and getNormalBounds(); the hand-edited zero-size or non-boolean state still falls back to the default window. --- electron/editorWindowState.test.ts | 78 ++++++ electron/editorWindowState.ts | 70 ++++- electron/wayland.test.ts | 24 +- electron/wayland.ts | 17 ++ electron/windows.editorWindow.test.ts | 365 ++++++++++++++++++++++++++ electron/windows.ts | 54 +++- 6 files changed, 593 insertions(+), 15 deletions(-) create mode 100644 electron/windows.editorWindow.test.ts diff --git a/electron/editorWindowState.test.ts b/electron/editorWindowState.test.ts index a9dbcdcadb..198b916e68 100644 --- a/electron/editorWindowState.test.ts +++ b/electron/editorWindowState.test.ts @@ -4,6 +4,7 @@ import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import { clampRectToWorkArea, + fitEditorRect, loadEditorWindowState, resolveEditorCreation, saveEditorWindowState, @@ -42,6 +43,7 @@ describe("editor window state", () => { it("still maximizes when nothing is saved, and never loads or saves the bench", () => { const missing = resolveEditorCreation({ isBench: false, saved: null }); expect(missing.maximize).toBe(true); + expect(missing.deferMaximize).toBe(false); expect(missing.persist).toBe(true); expect(missing.bounds).toEqual({ width: 1200, height: 800 }); @@ -49,6 +51,10 @@ describe("editor window state", () => { const bench = resolveEditorCreation({ isBench: true, saved: null }); expect(bench.persist).toBe(false); expect(bench.maximize).toBe(true); + // Not even under Wayland: a maximize landing mid-run would resize what it measures. + expect(resolveEditorCreation({ isBench: true, saved: null, wayland: true }).deferMaximize).toBe( + false, + ); const dir = tmp(); saveEditorWindowState(dir, { x: 10, y: 20, width: 1280, height: 720, maximized: false }); @@ -106,3 +112,75 @@ describe("editor window state", () => { } }); }); + +describe("editor window state under Wayland", () => { + // GNOME 46 on a 1920x1080 output: Chromium reports the whole output as the work area, + // although the top bar and the dock take part of it. + const OUTPUT = { x: 0, y: 0, width: 1920, height: 1080 }; + // What the v2.1.0-rc.1 pass found on disk after a maximized session (it grew each time). + const DRIFTED = { x: 256, y: -10, width: 1440, height: 1121, maximized: true }; + + it("keeps a tenth of each dimension free for panels it cannot see", () => { + expect(fitEditorRect(DRIFTED, OUTPUT, true)).toEqual({ + x: 256, + y: 54, + width: 1440, + height: 972, + }); + // Elsewhere the reported work area is the real one, and is used whole. + expect(fitEditorRect(DRIFTED, OUTPUT, false)).toEqual({ + x: 256, + y: 0, + width: 1440, + height: 1080, + }); + expect(fitEditorRect(DRIFTED, OUTPUT, false)).toEqual(clampRectToWorkArea(DRIFTED, OUTPUT)); + }); + + it("leaves a rect that already fits as it is", () => { + const rect = { x: 300, y: 120, width: 1200, height: 800 }; + expect(fitEditorRect(rect, OUTPUT, true)).toEqual(rect); + }); + + it("never fits below the editor's minimum size", () => { + const fitted = fitEditorRect( + { x: 0, y: 0, width: 1024, height: 700 }, + { x: 0, y: 0, width: 800, height: 600 }, + true, + ); + expect(fitted.width).toBe(800); + expect(fitted.height).toBe(600); + }); + + it("leaves placement to the compositor and maximizes only once mapped", () => { + const saved = { ...fitEditorRect(DRIFTED, OUTPUT, true), maximized: true }; + const creation = resolveEditorCreation({ isBench: false, saved, wayland: true }); + expect(creation.bounds).toEqual({ width: 1440, height: 972 }); + expect(creation.maximize).toBe(true); + expect(creation.deferMaximize).toBe(true); + + const fresh = resolveEditorCreation({ isBench: false, saved: null, wayland: true }); + expect(fresh.maximize).toBe(true); + expect(fresh.deferMaximize).toBe(true); + }); + + it("keeps the saved position and the immediate maximize elsewhere", () => { + const saved = { x: 10, y: 20, width: 1280, height: 720, maximized: true }; + const creation = resolveEditorCreation({ isBench: false, saved, wayland: false }); + expect(creation.bounds).toEqual({ x: 10, y: 20, width: 1280, height: 720 }); + expect(creation.deferMaximize).toBe(false); + }); + + it("still opens the default window for a hand-edited zero size or non-boolean maximized", () => { + const dir = tmp(); + writeFileSync( + path.join(dir, "editor-window.json"), + JSON.stringify({ x: 10, y: 20, width: 0, height: 0, maximized: "yes" }), + ); + const saved = loadEditorWindowState(dir); + expect(saved).toBeNull(); + const creation = resolveEditorCreation({ isBench: false, saved, wayland: true }); + expect(creation.bounds).toEqual({ width: 1200, height: 800 }); + expect(creation.maximize).toBe(true); + }); +}); diff --git a/electron/editorWindowState.ts b/electron/editorWindowState.ts index 6597e37361..01bec33153 100644 --- a/electron/editorWindowState.ts +++ b/electron/editorWindowState.ts @@ -95,6 +95,47 @@ export function clampRectToWorkArea( }; } +/** + * Share of each dimension of the reported work area left to panels under Wayland. + * + * Wayland tells a client nothing about panels, so Chromium reports the whole output as the + * work area — GNOME's 32 px top bar and a docked Ubuntu dock included. A normal window as tall + * as the output then runs under them, with its bottom edge off screen, and un-maximizing + * returns to exactly that. A tenth of each dimension (108 px of a 1080 px output) covers a top + * bar and a dock; the compositor still places the window, so only its size is at stake. + */ +export const WAYLAND_PANEL_RESERVE = 0.1; + +/** Clamps a normal rect to the work area — under Wayland, to the share panels leave free. */ +export function fitEditorRect( + rect: EditorWindowRect, + workArea: DisplayWorkArea, + wayland: boolean, +): EditorWindowRect { + if (!wayland) return clampRectToWorkArea(rect, workArea); + // Centred: the panels may sit on any edge. + const width = Math.floor(workArea.width * (1 - WAYLAND_PANEL_RESERVE)); + const height = Math.floor(workArea.height * (1 - WAYLAND_PANEL_RESERVE)); + return clampRectToWorkArea(rect, { + x: workArea.x + Math.floor((workArea.width - width) / 2), + y: workArea.y + Math.floor((workArea.height - height) / 2), + width, + height, + }); +} + +/** + * How long a Wayland editor waits, once the compositor has focused it, before maximizing. + * + * Electron 41's Chromium leaves a window stuck maximized — its restore button and + * `unmaximize()` do nothing — when it asks for maximize while the compositor still has a + * configure without the maximized state on its way: the configure latches Chromium's + * "restore to maximized" flag (electron/electron#53063, fixed in Electron 42+ only). Focus + * means the window is mapped, and the configures that come with mapping (activated, suspended) + * arrive within a frame or two of it; this waits them out with room to spare. + */ +export const WAYLAND_MAXIMIZE_SETTLE_MS = 200; + export function shouldTrackEditorWindow(query: Record): boolean { return query.windowType !== "bench"; } @@ -102,25 +143,38 @@ export function shouldTrackEditorWindow(query: Record): boolean export function resolveEditorCreation(input: { isBench: boolean; saved: EditorWindowState | null; + /** Chromium's Wayland backend: positions are fiction, and maximize must wait for the map. */ + wayland?: boolean; }): { bounds: { x?: number; y?: number; width: number; height: number }; maximize: boolean; + /** Maximize only once the compositor has mapped the window (see WAYLAND_MAXIMIZE_SETTLE_MS). */ + deferMaximize: boolean; persist: boolean; } { if (input.isBench) { - return { bounds: { ...DEFAULT_EDITOR_SIZE }, maximize: true, persist: false }; + // The bench never un-maximizes, and a maximize that lands mid-run would resize what it + // measures: it keeps the immediate one everywhere. + return { + bounds: { ...DEFAULT_EDITOR_SIZE }, + maximize: true, + deferMaximize: false, + persist: false, + }; } + const deferMaximize = input.wayland === true; if (!input.saved) { - return { bounds: { ...DEFAULT_EDITOR_SIZE }, maximize: true, persist: true }; + return { bounds: { ...DEFAULT_EDITOR_SIZE }, maximize: true, deferMaximize, persist: true }; } + const { width, height } = input.saved; return { - bounds: { - x: input.saved.x, - y: input.saved.y, - width: input.saved.width, - height: input.saved.height, - }, + // A Wayland client cannot place its window and never learns where the compositor put + // it, so a saved position is whatever Chromium assumed: leave placement to the compositor. + bounds: input.wayland + ? { width, height } + : { x: input.saved.x, y: input.saved.y, width, height }, maximize: input.saved.maximized, + deferMaximize, persist: true, }; } diff --git a/electron/wayland.test.ts b/electron/wayland.test.ts index 62ea6b484c..01624872d2 100644 --- a/electron/wayland.test.ts +++ b/electron/wayland.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from "vitest"; -import { configureWaylandSupport } from "./wayland"; +import { configureWaylandSupport, usesWaylandBackend } from "./wayland"; describe("Wayland startup configuration", () => { it.each([ @@ -36,3 +36,25 @@ describe("Wayland startup configuration", () => { expect(commandLine.appendSwitch).not.toHaveBeenCalled(); }); }); + +describe("Wayland backend detection", () => { + const ozone = (value: string) => ({ getSwitchValue: () => value }); + const session = { XDG_SESSION_TYPE: "wayland" }; + + it.each(["wayland", "auto", ""])("is on in a Linux Wayland session (ozone-platform=%j)", (v) => { + expect(usesWaylandBackend("linux", ozone(v), session)).toBe(true); + expect(usesWaylandBackend("linux", ozone(v), { WAYLAND_DISPLAY: "wayland-0" })).toBe(true); + }); + + it("is off under Xwayland, when ozone-platform=x11 was passed explicitly", () => { + expect(usesWaylandBackend("linux", ozone("x11"), session)).toBe(false); + }); + + it("is off in an X11 session", () => { + expect(usesWaylandBackend("linux", ozone(""), { XDG_SESSION_TYPE: "x11" })).toBe(false); + }); + + it.each(["win32", "darwin"] as const)("is off on %s, whatever the environment", (platform) => { + expect(usesWaylandBackend(platform, ozone("wayland"), session)).toBe(false); + }); +}); diff --git a/electron/wayland.ts b/electron/wayland.ts index 23497074cc..c89e70da71 100644 --- a/electron/wayland.ts +++ b/electron/wayland.ts @@ -5,6 +5,23 @@ export function isWaylandSession(env: Env): boolean { return env.XDG_SESSION_TYPE === "wayland" || env.WAYLAND_DISPLAY !== undefined; } +/** + * Whether Chromium draws this process's windows through its Wayland backend: a Linux Wayland + * session, unless an explicit `--ozone-platform=x11` sent it to Xwayland, which + * configureWaylandSupport leaves alone. + */ +export function usesWaylandBackend( + platform: NodeJS.Platform, + commandLine: Pick, + env: Env, +): boolean { + return ( + platform === "linux" && + isWaylandSession(env) && + commandLine.getSwitchValue("ozone-platform") !== "x11" + ); +} + /** Prefer Wayland in a Wayland session, while respecting an explicit backend. */ export function configureWaylandSupport(commandLine: CommandLine, env: Env): void { if (!isWaylandSession(env)) return; diff --git a/electron/windows.editorWindow.test.ts b/electron/windows.editorWindow.test.ts new file mode 100644 index 0000000000..4da1026db5 --- /dev/null +++ b/electron/windows.editorWindow.test.ts @@ -0,0 +1,365 @@ +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { WAYLAND_MAXIMIZE_SETTLE_MS } from "./editorWindowState"; + +const electron = vi.hoisted(() => { + type Rect = { x: number; y: number; width: number; height: number }; + type Insets = { top: number; left: number; bottom: number; right: number }; + const NONE: Insets = { top: 0, left: 0, bottom: 0, right: 0 }; + const outset = (r: Rect, i: Insets): Rect => ({ + x: r.x - i.left, + y: r.y - i.top, + width: r.width + i.left + i.right, + height: r.height + i.top + i.bottom, + }); + const inset = (r: Rect, i: Insets): Rect => + outset(r, { + top: -i.top, + left: -i.left, + bottom: -i.bottom, + right: -i.right, + }); + + const state = { + // The output as Chromium reports it under Wayland: the whole 1920x1080, panels included. + workArea: { x: 0, y: 0, width: 1920, height: 1080 }, + userData: "", + ozonePlatform: "", + // Electron 41's client-side frame under Wayland (OpaqueFrameView, LinuxCSDCustomFrameLayout): + // shadow and resize band while restored, a 1 px edge while maximized. NONE elsewhere. + restoredInsets: NONE, + maximizedInsets: NONE, + windows: [] as FakeWindow[], + }; + + /** + * A BrowserWindow reduced to the bounds arithmetic of shell/browser/native_window_views.cc: + * the widget rect carries the frame insets, getBounds() takes the current ones back off, and + * getNormalBounds() takes the *current* ones off the restored widget rect — which is the + * Electron 41 bug under Wayland, since the restored rect carries the restored insets. + */ + class FakeWindow { + options: Partial; + widget: Rect; + restoredWidget: Rect | null = null; + maximized = false; + destroyed = false; + maximize = vi.fn(() => { + if (this.maximized) return; + this.restoredWidget = this.widget; + this.maximized = true; + this.widget = { ...state.workArea }; + this.emit("maximize"); + this.emit("resize"); + }); + webContents = { on: vi.fn(), send: vi.fn(), insertCSS: vi.fn(async () => undefined) }; + setAutoHideMenuBar = vi.fn(); + loadURL = vi.fn(); + loadFile = vi.fn(); + private listeners = new Map void; once: boolean }[]>(); + + constructor(options: Partial & { width: number; height: number }) { + this.options = options; + const { width, height } = options; + // widget()->CenterWindow() when no position is given. + const x = options.x ?? state.workArea.x + Math.floor((state.workArea.width - width) / 2); + const y = options.y ?? state.workArea.y + Math.floor((state.workArea.height - height) / 2); + this.widget = outset({ x, y, width, height }, state.restoredInsets); + state.windows.push(this); + } + on(event: string, fn: () => void) { + this.listeners.set(event, [...(this.listeners.get(event) ?? []), { fn, once: false }]); + return this; + } + once(event: string, fn: () => void) { + this.listeners.set(event, [...(this.listeners.get(event) ?? []), { fn, once: true }]); + return this; + } + emit(event: string) { + const list = this.listeners.get(event) ?? []; + this.listeners.set( + event, + list.filter((l) => !l.once), + ); + for (const l of list) l.fn(); + } + private insets() { + return this.maximized ? state.maximizedInsets : state.restoredInsets; + } + getBounds() { + return this.maximized ? { ...this.widget } : inset(this.widget, this.insets()); + } + getNormalBounds() { + return inset(this.restoredWidget ?? this.widget, this.insets()); + } + unmaximize() { + if (!this.maximized || !this.restoredWidget) return; + this.widget = this.restoredWidget; + this.restoredWidget = null; + this.maximized = false; + this.emit("unmaximize"); + this.emit("resize"); + } + /** A resize by the user, in logical (shadow-free) size. */ + resizeTo(width: number, height: number) { + const { x, y } = this.getBounds(); + this.widget = outset({ x, y, width, height }, this.insets()); + this.emit("resize"); + } + close() { + this.emit("close"); + this.destroyed = true; + } + isMaximized() { + return this.maximized; + } + isMinimized() { + return false; + } + isFullScreen() { + return false; + } + isDestroyed() { + return this.destroyed; + } + } + + return { state, FakeWindow, NONE }; +}); + +vi.mock("electron", () => ({ + app: { + getPath: () => electron.state.userData, + commandLine: { getSwitchValue: () => electron.state.ozonePlatform }, + }, + BrowserWindow: electron.FakeWindow, + ipcMain: { on: vi.fn() }, + screen: { + getDisplayMatching: () => ({ workArea: electron.state.workArea }), + getPrimaryDisplay: () => ({ workArea: electron.state.workArea }), + on: vi.fn(), + }, +})); + +const REAL_PLATFORM = process.platform; + +/** windows.ts reads the platform and the OS version (an Electron-only API) at import. */ +async function load(platform: NodeJS.Platform) { + Object.defineProperty(process, "platform", { value: platform, configurable: true }); + Object.defineProperty(process, "getSystemVersion", { + value: () => "10.0.26100", + configurable: true, + }); + vi.resetModules(); + return import("./windows"); +} + +function stateFile() { + return path.join(electron.state.userData, "editor-window.json"); +} + +function saved() { + return JSON.parse(readFileSync(stateFile(), "utf8")); +} + +function openEditor(createEditorWindow: () => unknown) { + createEditorWindow(); + const win = electron.state.windows.at(-1); + if (!win) throw new Error("no window was created"); + return win; +} + +beforeEach(() => { + vi.useFakeTimers(); + electron.state.userData = mkdtempSync(path.join(os.tmpdir(), "os-editor-window-")); + electron.state.windows = []; + electron.state.ozonePlatform = ""; +}); + +afterEach(() => { + rmSync(electron.state.userData, { recursive: true, force: true }); + vi.useRealTimers(); + vi.unstubAllEnvs(); +}); + +afterAll(() => { + Object.defineProperty(process, "platform", { value: REAL_PLATFORM, configurable: true }); + Reflect.deleteProperty(process, "getSystemVersion"); +}); + +describe("editor window under Wayland (GNOME 46, Electron 41)", () => { + beforeEach(() => { + vi.stubEnv("XDG_SESSION_TYPE", "wayland"); + electron.state.restoredInsets = { top: 10, left: 16, bottom: 32, right: 16 }; + electron.state.maximizedInsets = { top: 0, left: 1, bottom: 1, right: 1 }; + }); + + it("models Electron 41 as measured: read back while maximized, the normal rect grows", () => { + // rc.1 clamped the saved {271,24,1410,1087} to {271,0,1410,1080}, opened it maximized and + // wrote {256,-10,1440,1121} on close. + const win = new electron.FakeWindow({ x: 271, y: 0, width: 1410, height: 1080 }); + win.maximize(); + expect(win.getNormalBounds()).toEqual({ x: 256, y: -10, width: 1440, height: 1121 }); + }); + + it("maximizes only once the compositor has focused the window and settled", async () => { + const { createEditorWindow } = await load("linux"); + const win = openEditor(createEditorWindow); + expect(win.maximize).not.toHaveBeenCalled(); + + win.emit("focus"); + vi.advanceTimersByTime(WAYLAND_MAXIMIZE_SETTLE_MS - 1); + expect(win.maximize).not.toHaveBeenCalled(); + vi.advanceTimersByTime(1); + expect(win.maximize).toHaveBeenCalledTimes(1); + }); + + it("saves the normal bounds it opened with after a maximized session, every session", async () => { + const { createEditorWindow } = await load("linux"); + // The file the rc.1 pass started from. + writeFileSync( + stateFile(), + JSON.stringify({ x: 271, y: 24, width: 1410, height: 1087, maximized: true }), + ); + + const sessions = []; + for (let i = 0; i < 3; i++) { + const win = openEditor(createEditorWindow); + win.emit("focus"); + vi.advanceTimersByTime(WAYLAND_MAXIMIZE_SETTLE_MS); + expect(win.isMaximized()).toBe(true); + win.close(); + sessions.push(saved()); + } + + // Placement is the compositor's: no position is handed to the window. + const first = electron.state.windows[0]; + expect(first?.options.x).toBeUndefined(); + expect(first?.options.y).toBeUndefined(); + // Fits the share of the output the panels leave free, and no longer grows. + expect(sessions[0]).toEqual({ x: 255, y: 54, width: 1410, height: 972, maximized: true }); + expect(sessions[1]).toEqual(sessions[0]); + expect(sessions[2]).toEqual(sessions[0]); + }); + + it("saves the size the user restored to, not the shadowed one Electron reports", async () => { + const { createEditorWindow } = await load("linux"); + const win = openEditor(createEditorWindow); + win.emit("focus"); + vi.advanceTimersByTime(WAYLAND_MAXIMIZE_SETTLE_MS); + + win.unmaximize(); + win.resizeTo(1300, 900); + win.maximize(); + win.close(); + + expect(saved()).toMatchObject({ width: 1300, height: 900, maximized: true }); + }); + + it("records an un-maximized close as such", async () => { + const { createEditorWindow } = await load("linux"); + const win = openEditor(createEditorWindow); + win.emit("focus"); + vi.advanceTimersByTime(WAYLAND_MAXIMIZE_SETTLE_MS); + + win.unmaximize(); + win.close(); + + expect(saved()).toEqual({ x: 360, y: 140, width: 1200, height: 800, maximized: false }); + }); + + it("still records maximized when closed before the deferred maximize", async () => { + const { createEditorWindow } = await load("linux"); + const win = openEditor(createEditorWindow); + win.close(); + vi.runAllTimers(); + + expect(win.maximize).not.toHaveBeenCalled(); + expect(saved().maximized).toBe(true); + }); + + it("does not fight a maximize the user made first", async () => { + const { createEditorWindow } = await load("linux"); + const win = openEditor(createEditorWindow); + win.emit("focus"); + win.maximize(); + win.unmaximize(); + vi.advanceTimersByTime(WAYLAND_MAXIMIZE_SETTLE_MS); + + expect(win.maximize).toHaveBeenCalledTimes(1); + expect(win.isMaximized()).toBe(false); + }); + + it("opens the default window for a hand-edited zero size and non-boolean maximized", async () => { + const { createEditorWindow } = await load("linux"); + writeFileSync( + stateFile(), + JSON.stringify({ x: 10, y: 20, width: 0, height: 0, maximized: "true" }), + ); + const win = openEditor(createEditorWindow); + + expect(win.options).toMatchObject({ width: 1200, height: 800 }); + win.emit("focus"); + vi.advanceTimersByTime(WAYLAND_MAXIMIZE_SETTLE_MS); + expect(win.isMaximized()).toBe(true); + }); + + it("keeps the immediate maximize for the export bench", async () => { + const { createEditorWindow } = await load("linux"); + createEditorWindow({ windowType: "bench" }); + expect(electron.state.windows.at(-1)?.maximize).toHaveBeenCalledTimes(1); + }); + + it("treats Xwayland (--ozone-platform=x11) as X11", async () => { + electron.state.ozonePlatform = "x11"; + electron.state.restoredInsets = electron.NONE; + electron.state.maximizedInsets = electron.NONE; + const { createEditorWindow } = await load("linux"); + writeFileSync( + stateFile(), + JSON.stringify({ x: 40, y: 30, width: 1280, height: 720, maximized: true }), + ); + const win = openEditor(createEditorWindow); + + expect(win.options).toMatchObject({ x: 40, y: 30, width: 1280, height: 720 }); + expect(win.maximize).toHaveBeenCalledTimes(1); + }); +}); + +describe("editor window on Windows", () => { + beforeEach(() => { + electron.state.restoredInsets = electron.NONE; + electron.state.maximizedInsets = electron.NONE; + electron.state.workArea = { x: 0, y: 0, width: 1920, height: 1040 }; + }); + + afterEach(() => { + electron.state.workArea = { x: 0, y: 0, width: 1920, height: 1080 }; + }); + + it("keeps the saved position, maximizes at once and saves getNormalBounds()", async () => { + const { createEditorWindow } = await load("win32"); + writeFileSync( + stateFile(), + JSON.stringify({ x: 100, y: 50, width: 1280, height: 720, maximized: true }), + ); + const win = openEditor(createEditorWindow); + + expect(win.options).toMatchObject({ x: 100, y: 50, width: 1280, height: 720 }); + expect(win.maximize).toHaveBeenCalledTimes(1); + win.close(); + expect(saved()).toEqual({ x: 100, y: 50, width: 1280, height: 720, maximized: true }); + }); + + it("clamps what it saves to the work area", async () => { + const { createEditorWindow } = await load("win32"); + const win = openEditor(createEditorWindow); + win.unmaximize(); + win.resizeTo(1920, 1080); + win.close(); + + expect(saved()).toMatchObject({ y: 0, width: 1920, height: 1040, maximized: false }); + }); +}); diff --git a/electron/windows.ts b/electron/windows.ts index e972caf2ad..7b5cb67192 100644 --- a/electron/windows.ts +++ b/electron/windows.ts @@ -2,11 +2,12 @@ import path from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; import { app, BrowserWindow, ipcMain, screen } from "electron"; import { - clampRectToWorkArea, + fitEditorRect, loadEditorWindowState, resolveEditorCreation, saveEditorWindowState, shouldTrackEditorWindow, + WAYLAND_MAXIMIZE_SETTLE_MS, } from "./editorWindowState"; import { clampHudBoundsToWorkArea, @@ -22,6 +23,7 @@ import { import { keepAlwaysOnTop, reassertAlwaysOnTop } from "./keepOnTop"; import { followAcrossSpaces } from "./macSpaces"; import { markSheetless } from "./messageBox"; +import { usesWaylandBackend } from "./wayland"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); @@ -553,15 +555,20 @@ export function createHudOverlayWindow(): BrowserWindow { */ export function createEditorWindow(query: Record = {}): BrowserWindow { const isMac = process.platform === "darwin"; + const wayland = usesWaylandBackend(process.platform, app.commandLine, process.env); const persist = shouldTrackEditorWindow(query); const loaded = persist ? loadEditorWindowState(app.getPath("userData")) : null; const saved = loaded ? { - ...clampRectToWorkArea(loaded, screen.getDisplayMatching(loaded).workArea), + ...fitEditorRect(loaded, screen.getDisplayMatching(loaded).workArea, wayland), maximized: loaded.maximized, } : null; - const creation = resolveEditorCreation({ isBench: query.windowType === "bench", saved }); + const creation = resolveEditorCreation({ + isBench: query.windowType === "bench", + saved, + wayland, + }); const win = new BrowserWindow({ ...creation.bounds, @@ -602,17 +609,52 @@ export function createEditorWindow(query: Record = {}): BrowserW }, }); - if (creation.maximize) win.maximize(); + const inNormalState = () => !win.isMaximized() && !win.isMinimized() && !win.isFullScreen(); + // Under Wayland, Electron 41's getNormalBounds() is wrong while the window is maximized: it + // takes the restored widget bounds, which carry the client-side shadow, and subtracts the + // *maximized* frame insets (none) instead of the restored ones. Each close while maximized + // so wrote the normal rect back 30×41 px larger and 15×10 px up-left — on GNOME 46, + // {271,0,1410,1080} came back as {256,-10,1440,1121}. getBounds() read in the normal state + // is exact, so the last such reading is what gets saved there. The window is still in its + // normal state here, before any maximize. + let lastNormalBounds = win.getBounds(); + const trackNormalBounds = () => { + if (!win.isDestroyed() && inNormalState()) lastNormalBounds = win.getBounds(); + }; + + // Under Wayland the maximize waits until the compositor has mapped and focused the window, + // or it sticks (WAYLAND_MAXIMIZE_SETTLE_MS). Until then the window still counts as + // maximized for the saved state, so closing it early does not forget the choice. + let maximizePending = creation.maximize && creation.deferMaximize; + if (maximizePending) { + win.once("maximize", () => { + maximizePending = false; + }); + win.once("focus", () => { + setTimeout(() => { + if (!maximizePending || win.isDestroyed()) return; + maximizePending = false; + if (inNormalState()) win.maximize(); + }, WAYLAND_MAXIMIZE_SETTLE_MS); + }); + } else if (creation.maximize) { + win.maximize(); + } + if (creation.persist) { + // `resized` and `moved` never fire on Linux; `resize` does, mid-drag included. + if (wayland) win.on("resize", trackNormalBounds); const persistState = () => { if (win.isDestroyed()) return; - const bounds = win.getNormalBounds(); + if (wayland) trackNormalBounds(); + const rect = wayland ? lastNormalBounds : win.getNormalBounds(); + const bounds = fitEditorRect(rect, screen.getDisplayMatching(rect).workArea, wayland); saveEditorWindowState(app.getPath("userData"), { x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height, - maximized: win.isMaximized(), + maximized: win.isMaximized() || maximizePending, }); }; win.on("moved", persistState);