Skip to content
Open
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
65 changes: 65 additions & 0 deletions electron/hudOverlayBounds.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";

import {
getHudOverlayStaticBounds,
getHudOverlayWindowBounds,
resizeHudOverlayFallbackBounds,
shouldExpandHudOverlayFallback,
Expand Down Expand Up @@ -76,6 +77,70 @@ describe("getHudOverlayWindowBounds", () => {
});
});

describe("getHudOverlayStaticBounds", () => {
const workArea = {
x: 120,
y: 40,
width: 1920,
height: 1040,
};

it("creates the Wayland HUD at the expanded height so menus fit", () => {
expect(getHudOverlayStaticBounds(workArea, false, true)).toEqual({
x: 650,
y: 540,
width: 860,
height: 540,
});
});

it("keeps X11 sessions on the compact creation bounds of main", () => {
expect(getHudOverlayStaticBounds(workArea, false, false)).toEqual({
x: 650,
y: 920,
width: 860,
height: 160,
});
});

it("keeps passthrough platforms (win/mac) on the full work area", () => {
expect(getHudOverlayStaticBounds(workArea, true, false)).toEqual(workArea);
});

it("never shrinks the Wayland HUD when recording without a webcam preview", () => {
// The dynamic fallback compacts to 160 in this state, which would
// shrink a window that was created expanded and re-clip the menus.
expect(
shouldExpandHudOverlayFallback({
fallbackExpanded: false,
recordingActive: true,
webcamPreviewVisible: false,
}),
).toBe(false);
expect(getHudOverlayStaticBounds(workArea, false, true).height).toBe(540);
});

it("fits the static expanded Wayland fallback inside small displays", () => {
expect(
getHudOverlayStaticBounds(
{
x: -100,
y: 20,
width: 640,
height: 420,
},
false,
true,
),
).toEqual({
x: -100,
y: 20,
width: 640,
height: 420,
});
});
});

describe("resizeHudOverlayFallbackBounds", () => {
const workArea = {
x: 0,
Expand Down
19 changes: 19 additions & 0 deletions electron/hudOverlayBounds.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,25 @@ export function getHudOverlayWindowBounds(
};
}

export function getHudOverlayStaticBounds(
workArea: HudOverlayWorkArea,
mousePassthroughSupported: boolean,
waylandSession: boolean,
): HudOverlayWorkArea {
// Linux/Wayland runs the non-passthrough fallback HUD. Compositors there
// ignore programmatic x/y placement, so runtime resizes re-anchor the
// window and destabilize the bottom-anchored toolbar (the instability
// behind the reverted e2802bf / PR #656). The window is therefore created
// directly at the expanded fallback height and every later bounds
// recompute keeps it there: menus fit and no recording/webcam state can
// shrink the window after creation.
//
// The expansion is gated to Wayland sessions: X11 honors programmatic
// placement and keeps the dynamic compact/expanded fallback of main, so
// a non-Wayland session gets the compact creation bounds (160 DIP).
return getHudOverlayWindowBounds(workArea, mousePassthroughSupported, waylandSession);
}

export function shouldExpandHudOverlayFallback({
fallbackExpanded,
recordingActive,
Expand Down
37 changes: 37 additions & 0 deletions electron/hudOverlaySession.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { describe, expect, it } from "vitest";

import { isWaylandSession } from "./hudOverlaySession";

describe("isWaylandSession", () => {
it("detects Wayland via XDG_SESSION_TYPE", () => {
expect(isWaylandSession({ XDG_SESSION_TYPE: "wayland" })).toBe(true);
});

it("detects Wayland via WAYLAND_DISPLAY", () => {
expect(isWaylandSession({ WAYLAND_DISPLAY: "wayland-0" })).toBe(true);
});

it("detects Wayland when both variables are set", () => {
expect(
isWaylandSession({ XDG_SESSION_TYPE: "wayland", WAYLAND_DISPLAY: "wayland-1" }),
).toBe(true);
});

it("reports X11 sessions as non-Wayland", () => {
expect(isWaylandSession({ XDG_SESSION_TYPE: "x11" })).toBe(false);
});

it("reports undefined session variables as non-Wayland", () => {
expect(isWaylandSession({})).toBe(false);
});

it("ignores empty environment values", () => {
expect(isWaylandSession({ XDG_SESSION_TYPE: "", WAYLAND_DISPLAY: "" })).toBe(false);
});

it("treats a set WAYLAND_DISPLAY as Wayland even when XDG_SESSION_TYPE says x11", () => {
expect(isWaylandSession({ XDG_SESSION_TYPE: "x11", WAYLAND_DISPLAY: "wayland-0" })).toBe(
true,
);
});
});
23 changes: 23 additions & 0 deletions electron/hudOverlaySession.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
export interface HudOverlaySessionEnv {
XDG_SESSION_TYPE?: string | undefined;
WAYLAND_DISPLAY?: string | undefined;
// Index signature mirrors Node's ProcessEnv shape so `process.env`
// satisfies the interface without casts.
[key: string]: string | undefined;
}

/**
* Wayland detection for HUD platform gating. Pure and injectable so callers
* (and tests) never read process.env directly.
*
* A session counts as Wayland when XDG_SESSION_TYPE says so OR a Wayland
* socket is exposed via WAYLAND_DISPLAY. Empty strings are treated as
* unset (a stray `FOO=` must not flip the gate).
*/
export function isWaylandSession(env: HudOverlaySessionEnv = process.env): boolean {
return env.XDG_SESSION_TYPE === "wayland" || isSet(env.WAYLAND_DISPLAY);
}

function isSet(value: string | undefined): boolean {
return typeof value === "string" && value.length > 0;
}
178 changes: 178 additions & 0 deletions electron/hudOverlayWindowActions.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
import { describe, expect, it, vi } from "vitest";

import { isWaylandSession } from "./hudOverlaySession";
import {
decideHudOverlayRestoreStrategy,
hideHudOverlayWindow,
} from "./hudOverlayWindowActions";

function createHudStub() {
return {
hide: vi.fn(),
minimize: vi.fn(),
};
}

describe("hideHudOverlayWindow", () => {
it("hides instead of minimizing on Linux Wayland (compositors ignore minimize and there is no taskbar)", () => {
const hud = createHudStub();

hideHudOverlayWindow(hud, "linux", true);

expect(hud.hide).toHaveBeenCalledOnce();
expect(hud.minimize).not.toHaveBeenCalled();
});

it("minimizes on Linux X11 so the taskbar entry restores the HUD, matching main", () => {
const hud = createHudStub();

hideHudOverlayWindow(hud, "linux", false);

expect(hud.minimize).toHaveBeenCalledOnce();
expect(hud.hide).not.toHaveBeenCalled();
});

it("minimizes on Windows so the taskbar entry restores the HUD", () => {
const hud = createHudStub();

hideHudOverlayWindow(hud, "win32", false);

expect(hud.minimize).toHaveBeenCalledOnce();
expect(hud.hide).not.toHaveBeenCalled();
});

it("minimizes on macOS so the Dock restores the HUD", () => {
const hud = createHudStub();

hideHudOverlayWindow(hud, "darwin", false);

expect(hud.minimize).toHaveBeenCalledOnce();
expect(hud.hide).not.toHaveBeenCalled();
});

it("ignores the Wayland flag outside Linux (win32 sessions are never Wayland)", () => {
const hud = createHudStub();

hideHudOverlayWindow(hud, "win32", true);

expect(hud.minimize).toHaveBeenCalledOnce();
expect(hud.hide).not.toHaveBeenCalled();
});

it("defaults to the current process platform and session", () => {
const hud = createHudStub();

hideHudOverlayWindow(hud);

if (process.platform === "linux" && isWaylandSession()) {
expect(hud.hide).toHaveBeenCalledOnce();
expect(hud.minimize).not.toHaveBeenCalled();
} else {
expect(hud.minimize).toHaveBeenCalledOnce();
expect(hud.hide).not.toHaveBeenCalled();
}
});
});

describe("decideHudOverlayRestoreStrategy", () => {
it("shows a hidden HUD instead of recreating it (recording survives tray restore on Wayland)", () => {
// Regression: hidden window is never focused, so the old condition
// destroyed+recreated it, killing the renderer and its in-flight
// MediaRecorder while main kept recording=true in the tray.
expect(
decideHudOverlayRestoreStrategy({
platform: "linux",
isFocused: false,
isVisible: false,
isMinimized: false,
isEditor: false,
recordingActive: true,
}),
).toBe("show-existing");
});

it("shows a minimized HUD instead of recreating it (X11 minimize path)", () => {
expect(
decideHudOverlayRestoreStrategy({
platform: "linux",
isFocused: false,
isVisible: true,
isMinimized: true,
isEditor: false,
recordingActive: true,
}),
).toBe("show-existing");
});

it("shows a visible but unfocused HUD during active recording instead of recreating it", () => {
// Regression (P1): during recording the HUD stays visible while
// unfocused (windows.ts keeps it shown while recording), so the
// recreate workaround destroyed the window and silently killed the
// MediaRecorder living in the HUD renderer — main kept
// recording=true in the tray with no window left to stop it.
expect(
decideHudOverlayRestoreStrategy({
platform: "linux",
isFocused: false,
isVisible: true,
isMinimized: false,
isEditor: false,
recordingActive: true,
}),
).toBe("show-existing");
});

it("keeps the recreate workaround for a visible but unfocused HUD on Linux while not recording", () => {
expect(
decideHudOverlayRestoreStrategy({
platform: "linux",
isFocused: false,
isVisible: true,
isMinimized: false,
isEditor: false,
recordingActive: false,
}),
).toBe("recreate");
});

it("never recreates an already focused HUD on Linux", () => {
expect(
decideHudOverlayRestoreStrategy({
platform: "linux",
isFocused: true,
isVisible: true,
isMinimized: false,
isEditor: false,
recordingActive: false,
}),
).toBe("show-existing");
});

it("never recreates editor windows on Linux", () => {
expect(
decideHudOverlayRestoreStrategy({
platform: "linux",
isFocused: false,
isVisible: true,
isMinimized: false,
isEditor: true,
recordingActive: false,
}),
).toBe("show-existing");
});

it("shows the existing window on Windows and macOS regardless of focus", () => {
for (const platform of ["win32", "darwin"] as const) {
expect(
decideHudOverlayRestoreStrategy({
platform,
isFocused: false,
isVisible: false,
isMinimized: false,
isEditor: false,
recordingActive: false,
}),
).toBe("show-existing");
}
});
});
Loading