From 573ba3cae837a6324b6b4aa0cb7fa0e9c28059f3 Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Thu, 15 Oct 2026 18:05:07 +0200 Subject: [PATCH 1/2] fix(hud): hide the bar instead of minimizing it on Linux On GNOME Wayland, "Hide recording bar" minimized the HUD and the tray's Open could not bring it back: GNOME only showed an "openscreen is ready" notification. xdg-shell has set_minimized and nothing to undo it, Chromium's Wayland Show() returns early on a toplevel that still exists, and the activation focus() then requests carries no fresh token, which mutter turns into that notification. hide() destroys the toplevel and show() maps a new one, so the tray's Open, a second launch and the global shortcut all bring the bar back. Linux only: Windows and macOS keep minimize(). --- electron/windows.hudHide.test.ts | 70 ++++++++++++++++++++++++++++++++ electron/windows.ts | 17 +++++++- 2 files changed, 86 insertions(+), 1 deletion(-) create mode 100644 electron/windows.hudHide.test.ts diff --git a/electron/windows.hudHide.test.ts b/electron/windows.hudHide.test.ts new file mode 100644 index 0000000000..3671c247d2 --- /dev/null +++ b/electron/windows.hudHide.test.ts @@ -0,0 +1,70 @@ +import { afterAll, describe, expect, it, vi } from "vitest"; + +const electron = vi.hoisted(() => { + class FakeWindow { + webContents = { on: vi.fn(), send: vi.fn() }; + setContentProtection = vi.fn(); + setVisibleOnAllWorkspaces = vi.fn(); + setAlwaysOnTop = vi.fn(); + isAlwaysOnTop = () => true; + hide = vi.fn(); + minimize = vi.fn(); + once = vi.fn(); + on = vi.fn(); + loadURL = vi.fn(); + loadFile = vi.fn(); + isDestroyed() { + return false; + } + } + const handlers = new Map void>(); + return { FakeWindow, handlers }; +}); + +vi.mock("electron", () => ({ + app: {}, + BrowserWindow: electron.FakeWindow, + ipcMain: { + on: (channel: string, handler: () => void) => electron.handlers.set(channel, handler), + }, + screen: { + getPrimaryDisplay: () => ({ workArea: { x: 0, y: 0, width: 1920, height: 1080 } }), + on: vi.fn(), + }, +})); + +const REAL_PLATFORM = process.platform; + +afterAll(() => { + Object.defineProperty(process, "platform", { value: REAL_PLATFORM, configurable: true }); + Reflect.deleteProperty(process, "getSystemVersion"); +}); + +/** Opens a HUD on `platform` (windows.ts decides at import) and presses "Hide recording bar". */ +async function hideHudOn(platform: NodeJS.Platform) { + Object.defineProperty(process, "platform", { value: platform, configurable: true }); + Object.defineProperty(process, "getSystemVersion", { + value: () => (platform === "darwin" ? "15.5.0" : "10.0.26100"), + configurable: true, + }); + vi.resetModules(); + electron.handlers.clear(); + const { createHudOverlayWindow } = await import("./windows"); + const win = createHudOverlayWindow() as unknown as InstanceType; + electron.handlers.get("hud-overlay-hide")?.(); + return win; +} + +describe("Hide recording bar", () => { + it("hides the HUD on Linux, where a Wayland client cannot un-minimize itself", async () => { + const win = await hideHudOn("linux"); + expect(win.hide).toHaveBeenCalledOnce(); + expect(win.minimize).not.toHaveBeenCalled(); + }); + + it.each(["win32", "darwin"] as const)("still minimizes the HUD on %s", async (platform) => { + const win = await hideHudOn(platform); + expect(win.minimize).toHaveBeenCalledOnce(); + expect(win.hide).not.toHaveBeenCalled(); + }); +}); diff --git a/electron/windows.ts b/electron/windows.ts index e972caf2ad..013a1f32d5 100644 --- a/electron/windows.ts +++ b/electron/windows.ts @@ -234,9 +234,24 @@ function watchHudWorkAreaChanges() { screen.on("display-removed", reclampHud); } +// Linux hides the bar instead of minimizing it, because a Wayland client cannot bring a +// minimized window back. xdg-shell has set_minimized and no request to undo it, and +// Chromium's Wayland Show() returns early on a toplevel that still exists, so the tray's +// restore() and show() did nothing; focus() then asked for activation without a fresh token, +// which GNOME turns into an "is ready" notification while the bar stays minimized. hide() +// destroys the toplevel and show() maps a new one, which every compositor displays. The cost +// is the position: the compositor places the new toplevel itself. X11 takes the same path: +// there `skipTaskbar` already left a minimized HUD no taskbar entry, so the tray was its only +// way back anyway. +const HUD_HIDES_INSTEAD_OF_MINIMIZING = process.platform === "linux"; + ipcMain.on("hud-overlay-hide", () => { if (hudOverlayWindow && !hudOverlayWindow.isDestroyed()) { - hudOverlayWindow.minimize(); + if (HUD_HIDES_INSTEAD_OF_MINIMIZING) { + hudOverlayWindow.hide(); + } else { + hudOverlayWindow.minimize(); + } } }); From 3e262901e9cab7fc23baa57f28de896349f13220 Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Thu, 15 Oct 2026 18:09:36 +0200 Subject: [PATCH 2/2] fix(hud): budget the HUD below the panels Wayland does not report On GNOME Wayland, switching the HUD to the vertical bar left its bottom controls off screen: the window came out 648x1080 on a 1080p display and the compositor kept its top-left where the horizontal one had been, so it ran from y 186 to 1266, the record button under the dock and Open Studio, language, hide and quit below the screen edge. Wayland has no protocol for a client to learn where the shell's panels are, so Chromium's screen.availHeight is the display's full height there. The vertical tray asks for more than it can have, so it was granted all 1080px: a window that cannot fit below GNOME's 32px top bar, whose bottom controls stayed out of reach wherever it was dragged. And mutter only moves a client-resized window back into the work area when it fits there. On Linux, when the platform reported the whole height as free, the HUD now budgets 128px less (GNOME's top bar plus a bottom dock at Ubuntu's largest icon size). The vertical window then fits: it can be dragged fully on screen, and mutter's fully-onscreen constraint puts a flipped tray back on screen when the window was inside the work area before. X11, Windows and macOS report their panels and are unchanged, and so is the horizontal tray. --- src/components/launch/LaunchWindow.test.tsx | 27 ++++++++++++ src/components/launch/LaunchWindow.tsx | 15 ++++--- src/components/launch/hudGeometry.test.ts | 48 +++++++++++++++++++++ src/components/launch/hudGeometry.ts | 32 ++++++++++++++ 4 files changed, 116 insertions(+), 6 deletions(-) diff --git a/src/components/launch/LaunchWindow.test.tsx b/src/components/launch/LaunchWindow.test.tsx index f1b9d26295..8cd92de957 100644 --- a/src/components/launch/LaunchWindow.test.tsx +++ b/src/components/launch/LaunchWindow.test.tsx @@ -14,6 +14,7 @@ import { HUD_POPOVER_MAX_HEIGHT, HUD_STACK_GAP, HUD_TOAST_WIDTH, + HUD_UNREPORTED_PANELS_ALLOWANCE, } from "./hudGeometry"; import { LaunchWindow } from "./LaunchWindow"; @@ -1393,6 +1394,32 @@ describe("LaunchWindow overlay sizing", () => { }); }); + it("keeps a tall tray below a Linux display whose whole height is reported free (Wayland)", async () => { + // What Chromium reports on GNOME Wayland: no panels at all, top bar and dock included. + platformState.value = "linux"; + Object.defineProperty(window.screen, "height", { value: 1080, configurable: true }); + Object.defineProperty(window.screen, "availHeight", { value: 1080, configurable: true }); + try { + renderLaunchWindow(); + + const bar = (await screen.findByTestId("hud-drag-handle")).closest( + "[data-tray-layout]", + ) as HTMLElement; + // The vertical tray from the GNOME pass, which used to be granted all 1080px. + stubBox(bar, 54, 499); + await flushResizeObservers(); + + await waitFor(() => { + expect(window.electronAPI.setHudOverlaySize).toHaveBeenCalled(); + }); + const [, height] = lastRequestedHudSize(); + expect(height).toBeLessThanOrEqual(1080 - HUD_UNREPORTED_PANELS_ALLOWANCE); + } finally { + Reflect.deleteProperty(window.screen, "height"); + Reflect.deleteProperty(window.screen, "availHeight"); + } + }); + it("grows the HUD overlay tall enough to fit the system language prompt", async () => { i18nState.value.systemLocaleSuggestion = "zh-CN"; diff --git a/src/components/launch/LaunchWindow.tsx b/src/components/launch/LaunchWindow.tsx index 0aa3127b52..d4b402b841 100644 --- a/src/components/launch/LaunchWindow.tsx +++ b/src/components/launch/LaunchWindow.tsx @@ -5,7 +5,7 @@ import { offersEditableCursor } from "@/lib/editableCursorAvailability"; import { canTurnMicrophoneOn } from "@/lib/microphoneAvailability"; import { loadUserPreferences, saveUserPreferences } from "@/lib/userPreferences"; import { nativeBridgeClient } from "@/native"; -import { canRecordMicrophone } from "@/utils/platformUtils"; +import { canRecordMicrophone, getPlatform } from "@/utils/platformUtils"; import { type CameraDevice } from "../../hooks/useCameraDevices"; import { useCameraHudSync } from "../../hooks/useCameraHudSync"; import { @@ -42,6 +42,7 @@ import { import { HudDeviceSettings, type HudDeviceSettingsLabels } from "./HudDeviceSettings"; import { addHudToastsToStack, + computeHudAvailableHeight, computeHudBarMaxHeight, computeHudModalMaxHeight, computeHudPopoverMaxHeight, @@ -64,13 +65,15 @@ const FALLBACK_SCREEN_HEIGHT = 1080; /** * Work-area height of the display, which is what the HUD's vertical budget is - * really bounded by. Deliberately NOT `window.innerHeight`: the overlay window's - * own height is the value this measurement feeds back into, and reading it here - * is exactly what used to close the resize feedback loop. + * really bounded by (less what Wayland never reports: see computeHudAvailableHeight). + * Deliberately NOT `window.innerHeight`: the overlay window's own height is the + * value this measurement feeds back into, and reading it here is exactly what used + * to close the resize feedback loop. */ function getAvailableScreenHeight(): number { - const available = typeof window === "undefined" ? 0 : window.screen?.availHeight; - return available && available > 0 ? available : FALLBACK_SCREEN_HEIGHT; + const screen = typeof window === "undefined" ? undefined : window.screen; + const available = screen ? computeHudAvailableHeight(screen, getPlatform()) : 0; + return available > 0 ? available : FALLBACK_SCREEN_HEIGHT; } function hudBarMaxHeightCss(): string { diff --git a/src/components/launch/hudGeometry.test.ts b/src/components/launch/hudGeometry.test.ts index beca204da3..a5044b6c5a 100644 --- a/src/components/launch/hudGeometry.test.ts +++ b/src/components/launch/hudGeometry.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { addHudToastsToStack, + computeHudAvailableHeight, computeHudBarMaxHeight, computeHudModalMaxHeight, computeHudPopoverMaxHeight, @@ -19,6 +20,7 @@ import { HUD_TOAST_LIFT, HUD_TOAST_VISIBLE, HUD_TOAST_WIDTH, + HUD_UNREPORTED_PANELS_ALLOWANCE, } from "./hudGeometry"; const SCREEN = 1080; @@ -87,6 +89,52 @@ describe("computeHudWindowSize", () => { }); }); +describe("computeHudAvailableHeight", () => { + // GNOME 46 Wayland, 1920x1080 at 100 %: the vertical tray's window came out 1080 tall, the + // budget's ceiling, so Chromium's availHeight was the whole display, top bar and dock included. + const WAYLAND_1080P = { height: 1080, availHeight: 1080 }; + const GNOME_TOP_BAR = 32; + // Ubuntu's dock at its largest icon size (64px), along the bottom edge. + const LARGEST_UBUNTU_DOCK = 80; + + it("leaves room for the panels a Wayland session never reports", () => { + expect(computeHudAvailableHeight(WAYLAND_1080P, "linux")).toBe( + 1080 - HUD_UNREPORTED_PANELS_ALLOWANCE, + ); + }); + + it("trusts a work area the platform did report", () => { + // X11 publishes the panels (_NET_WORKAREA), so availHeight already leaves them out. + expect(computeHudAvailableHeight({ height: 1080, availHeight: 1048 }, "linux")).toBe(1048); + // A full-height work area on Windows is an auto-hidden taskbar, and macOS reports the + // menu bar and the Dock: neither is touched. + expect(computeHudAvailableHeight({ height: 1080, availHeight: 1080 }, "win32")).toBe(1080); + expect(computeHudAvailableHeight({ height: 1080, availHeight: 1055 }, "darwin")).toBe(1055); + }); + + it("budgets a vertical tray on Wayland that fits between GNOME's top bar and its dock", () => { + // The vertical bar from the GNOME pass, 54x499. Budgeted on the reported 1080 it was + // granted the full 1080: taller than the room under the top bar, so its bottom + // controls stayed off screen wherever it was dragged. + expect(size(54, 499, 0, WAYLAND_1080P.availHeight).granted.height).toBe(1080); + + const budget = computeHudAvailableHeight(WAYLAND_1080P, "linux"); + const { required, granted } = size(54, 499, 0, budget); + expect(granted.height).toBeLessThanOrEqual(1080 - GNOME_TOP_BAR - LARGEST_UBUNTU_DOCK); + expect(required.height).toBeLessThanOrEqual(granted.height); + // Still room for the bar itself and a usable popover above it, and no internal scroll. + expect(granted.height).toBeGreaterThanOrEqual( + HUD_BAR_BOTTOM + 499 + HUD_POPOVER_GAP + HUD_POPOVER_MAX_HEIGHT + HUD_EDGE_SLACK, + ); + expect(computeHudBarMaxHeight(budget)).toBeGreaterThanOrEqual(499); + }); + + it("leaves the horizontal tray's window as it was", () => { + const budget = computeHudAvailableHeight(WAYLAND_1080P, "linux"); + expect(size(528, 54, 0, budget)).toEqual(size(528, 54, 0, WAYLAND_1080P.availHeight)); + }); +}); + describe("computeHudPopoverMaxHeight", () => { it("caps at the design height when there is plenty of room", () => { expect(computeHudPopoverMaxHeight(48, SCREEN)).toBe(HUD_POPOVER_MAX_HEIGHT); diff --git a/src/components/launch/hudGeometry.ts b/src/components/launch/hudGeometry.ts index da93d456b9..5887343f6e 100644 --- a/src/components/launch/hudGeometry.ts +++ b/src/components/launch/hudGeometry.ts @@ -62,6 +62,38 @@ export const HUD_MODAL_MIN_HEIGHT = 200; export const HUD_GROWTH_RESERVE = 120; /** Floor for the window itself, in case the bar measures as ~nothing. */ export const HUD_MIN_WINDOW_WIDTH = 220; +/** + * Room left for desktop panels the platform never reported: GNOME's 32px top bar plus a + * bottom dock at Ubuntu's largest icon size, with some to spare. + */ +export const HUD_UNREPORTED_PANELS_ALLOWANCE = 128; + +/** + * The height the HUD budgets its window against: the work area, as the platform reports it. + * + * Wayland reports none. No protocol tells a client where the shell's panels are, so + * Chromium's `screen.availHeight` there is the display's full height, even on a GNOME + * desktop whose top bar and dock take part of it. The vertical tray asks for more than it + * can have, so on a 1080p display it was granted all 1080px: a window that cannot fit below + * GNOME's top bar, so the bottom of the bar (Open Studio, Hide, Quit) stayed off screen + * wherever it was dragged. The compositor also resizes a Wayland window from its top-left + * corner, so every flip to vertical pushed the bar down under the dock, and mutter moves a + * resized window back into the work area only when it fits there. Budgeted below the + * display's height, the window fits: it can be dragged fully on screen, and mutter puts a + * flipped tray back on screen by itself when the window was inside the work area before. + * + * Linux only, and only when nothing was reported: on X11 the panels are in `availHeight` + * already, and on Windows a full-height work area is an auto-hidden taskbar. + */ +export function computeHudAvailableHeight( + screen: { availHeight: number; height: number }, + platform: string, +): number { + if (platform === "linux" && screen.availHeight >= screen.height) { + return Math.max(0, screen.availHeight - HUD_UNREPORTED_PANELS_ALLOWANCE); + } + return screen.availHeight; +} export interface HudSize { width: number;