diff --git a/electron/hudOverlayBounds.test.ts b/electron/hudOverlayBounds.test.ts index db21e92bd..8f36f91a5 100644 --- a/electron/hudOverlayBounds.test.ts +++ b/electron/hudOverlayBounds.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { + getHudOverlayStaticBounds, getHudOverlayWindowBounds, resizeHudOverlayFallbackBounds, shouldExpandHudOverlayFallback, @@ -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, diff --git a/electron/hudOverlayBounds.ts b/electron/hudOverlayBounds.ts index 8c51b88c7..c64116f3c 100644 --- a/electron/hudOverlayBounds.ts +++ b/electron/hudOverlayBounds.ts @@ -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, diff --git a/electron/hudOverlaySession.test.ts b/electron/hudOverlaySession.test.ts new file mode 100644 index 000000000..8d750af1f --- /dev/null +++ b/electron/hudOverlaySession.test.ts @@ -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, + ); + }); +}); diff --git a/electron/hudOverlaySession.ts b/electron/hudOverlaySession.ts new file mode 100644 index 000000000..5a6dbd134 --- /dev/null +++ b/electron/hudOverlaySession.ts @@ -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; +} diff --git a/electron/hudOverlayWindowActions.test.ts b/electron/hudOverlayWindowActions.test.ts new file mode 100644 index 000000000..c5dd197f6 --- /dev/null +++ b/electron/hudOverlayWindowActions.test.ts @@ -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"); + } + }); +}); diff --git a/electron/hudOverlayWindowActions.ts b/electron/hudOverlayWindowActions.ts new file mode 100644 index 000000000..01419ddeb --- /dev/null +++ b/electron/hudOverlayWindowActions.ts @@ -0,0 +1,62 @@ +import type { BrowserWindow } from "electron"; + +import { isWaylandSession } from "./hudOverlaySession"; + +export type HudOverlayHideTarget = Pick; + +export function hideHudOverlayWindow( + hud: HudOverlayHideTarget, + platform: NodeJS.Platform = process.platform, + waylandSession: boolean = isWaylandSession(), +): void { + // Wayland compositors ignore minimize() and there is no taskbar entry + // to restore from, so the "−" control must hide the window instead. + // Gated to Wayland sessions: on X11 minimize() works and the taskbar + // restores the HUD — main's behavior. The tray "Show HUD" action + // (showHudOverlayFromTray) restores a hidden window with show(), which + // works on every platform. + if (platform === "linux" && waylandSession) { + hud.hide(); + return; + } + + hud.minimize(); +} + +export type HudOverlayRestoreStrategy = "show-existing" | "recreate"; + +export type HudOverlayRestoreInput = { + platform: NodeJS.Platform; + isFocused: boolean; + isVisible: boolean; + isMinimized: boolean; + isEditor: boolean; + recordingActive: boolean; +}; + +export function decideHudOverlayRestoreStrategy( + input: HudOverlayRestoreInput, +): HudOverlayRestoreStrategy { + // On Linux, tray activation can't focus an existing window (compositors + // ignore focus()), so main destroys and recreates the HUD to regain + // focus via the creation path. That workaround must never fire for a + // hidden or minimized window — and never during an active recording, + // when the HUD stays visible but unfocused: destroy kills the renderer + // — and with it the in-flight MediaRecorder, which lives in the HUD + // renderer — while the fresh renderer starts with idle state and main + // keeps recording=true in the tray. Hidden/minimized/recording windows + // restore through the show() path instead, exactly like the tray menu + // items (showHudOverlayFromTray) already do. + if ( + input.platform === "linux" && + !input.isEditor && + input.isVisible && + !input.isMinimized && + !input.isFocused && + !input.recordingActive + ) { + return "recreate"; + } + + return "show-existing"; +} diff --git a/electron/main.ts b/electron/main.ts index 890726670..7ee55de6c 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -53,11 +53,13 @@ import { getUpdateToastWindow, hideUpdateToastWindow, isHudOverlayMousePassthroughSupported, + isHudOverlayRecordingActive, reassertHudOverlayCaptureProtection, reassertHudOverlayMousePassthrough as reassertHudOverlayMouseState, setHudOverlayRecordingActive, showUpdateToastWindow, } from "./windows"; +import { decideHudOverlayRestoreStrategy } from "./hudOverlayWindowActions"; const electronMainDir = path.dirname(fileURLToPath(import.meta.url)); const IS_SMOKE_EXPORT = process.env.RECORDLY_SMOKE_EXPORT === "1"; @@ -373,10 +375,18 @@ function focusOrCreateMainWindow() { // work because they receive an XDG activation token via StatusNotifierItem.ProvideXdgActivationToken; // Electron's tray doesn't handle that yet. Workaround: destroy and recreate the HUD so the new // window gets focus (creation path works). Only for HUD, not editor. + // A hidden, minimized, or recording HUD must never take this path: destroy kills + // the renderer and any in-flight recording with it — the show() + // path below restores it (same as showHudOverlayFromTray). if ( - process.platform === "linux" && - !mainWindow.isFocused() && - !isEditorWindow(mainWindow) + decideHudOverlayRestoreStrategy({ + platform: process.platform, + isFocused: mainWindow.isFocused(), + isVisible: mainWindow.isVisible(), + isMinimized: mainWindow.isMinimized(), + isEditor: isEditorWindow(mainWindow), + recordingActive: isHudOverlayRecordingActive(), + }) === "recreate" ) { const win = mainWindow; mainWindow = null; diff --git a/electron/windows.ts b/electron/windows.ts index 23e874fa1..e3d61fcb9 100644 --- a/electron/windows.ts +++ b/electron/windows.ts @@ -6,11 +6,14 @@ import { app, BrowserWindow, ipcMain } from "electron"; import { supportsHudCaptureProtection } from "../src/lib/hudCaptureProtection"; import { USER_DATA_PATH } from "./appPaths"; import { + getHudOverlayStaticBounds, getHudOverlayWindowBounds, resizeHudOverlayFallbackBounds, shouldExpandHudOverlayFallback, } from "./hudOverlayBounds"; import { getHudOverlayTaskbarOptions } from "./hudOverlayWindowOptions"; +import { isWaylandSession } from "./hudOverlaySession"; +import { hideHudOverlayWindow } from "./hudOverlayWindowActions"; import { getPackagedRendererBaseUrl } from "./rendererServer"; const electronWindowsDir = path.dirname(fileURLToPath(import.meta.url)); @@ -119,6 +122,18 @@ export function isHudOverlayMousePassthroughSupported(): boolean { return process.platform !== "linux"; } +let hudOverlayWaylandSession: boolean | null = null; + +function isHudOverlayWaylandSession(): boolean { + // Session env is stable for the lifetime of the app process, so probe it + // once (lazily) and cache — every Wayland-only HUD behavior reads this + // single flag instead of touching process.env at call sites. + if (hudOverlayWaylandSession === null) { + hudOverlayWaylandSession = isWaylandSession(); + } + return hudOverlayWaylandSession; +} + function loadHudOverlayCaptureProtectionSetting(): boolean { if (hudOverlayCaptureProtectionLoaded) { return hudOverlayHiddenFromCapture; @@ -202,16 +217,23 @@ function getHudOverlayDisplay() { function getHudOverlayBounds() { const { workArea } = getHudOverlayDisplay(); + const mousePassthroughSupported = isHudOverlayMousePassthroughSupported(); + const waylandSession = isHudOverlayWaylandSession(); + if (!mousePassthroughSupported && waylandSession) { + // Wayland-only fallback HUD: created expanded and never resized at + // runtime (Wayland ignores programmatic placement; runtime growth was + // the instability behind the reverted e2802bf). Static bounds keep + // every recompute — creation, recording/webcam transitions, display + // changes — at the expanded height so nothing shrinks the window. + // X11 falls through to the dynamic fallback below, matching main. + return getHudOverlayStaticBounds(workArea, mousePassthroughSupported, waylandSession); + } const fallbackExpanded = shouldExpandHudOverlayFallback({ fallbackExpanded: hudOverlayFallbackExpanded, recordingActive: hudOverlayRecordingActive, webcamPreviewVisible: hudOverlayWebcamPreviewVisible, }); - return getHudOverlayWindowBounds( - workArea, - isHudOverlayMousePassthroughSupported(), - fallbackExpanded, - ); + return getHudOverlayWindowBounds(workArea, mousePassthroughSupported, fallbackExpanded); } function applyHudOverlayBounds() { @@ -401,7 +423,7 @@ ipcMain.on("hud-overlay-drag", (_event, phase: string, screenX: number, screenY: ipcMain.on("hud-overlay-hide", () => { if (hudOverlayWindow && !hudOverlayWindow.isDestroyed()) { - hudOverlayWindow.minimize(); + hideHudOverlayWindow(hudOverlayWindow, process.platform, isHudOverlayWaylandSession()); } }); @@ -691,6 +713,10 @@ export function setHudOverlayRecordingActive(recording: boolean): void { setHudOverlayMousePassthrough(true); } +export function isHudOverlayRecordingActive(): boolean { + return hudOverlayRecordingActive; +} + export function createUpdateToastWindow(): BrowserWindow { const initialBounds = getUpdateToastBounds(); diff --git a/package.json b/package.json index 6ef75b014..c2b592dff 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,7 @@ "benchmark:export-queues": "node scripts/benchmark-export-queues.mjs", "normalize:electron-main-cjs": "node scripts/normalize-electron-main-cjs.mjs", "smoke:electron-main-cjs": "node scripts/smoke-electron-main-cjs.mjs", + "smoke:hud-x11": "node scripts/smoke-hud-x11.mjs", "smoke:packaged-binaries": "node scripts/smoke-packaged-binaries.mjs", "verify:macos-distribution": "node scripts/verify-macos-distribution.mjs", "checksums:release": "node scripts/write-release-checksums.mjs", diff --git a/scripts/smoke-hud-x11.mjs b/scripts/smoke-hud-x11.mjs new file mode 100755 index 000000000..f6858aec7 --- /dev/null +++ b/scripts/smoke-hud-x11.mjs @@ -0,0 +1,210 @@ +#!/usr/bin/env node +// Proves the Wayland-only HUD gates leave X11 behavior identical to main: +// the HUD is created at the compact 160 DIP fallback height and the "−" +// control still decides minimize() (not hide()). +// +// Runs the real Electron binary under Xvfb with an X11 session env and +// exercises the real modules (hudOverlaySession, hudOverlayBounds, +// hudOverlayWindowActions) plus a real frameless BrowserWindow. +// +// Usage: +// node scripts/smoke-hud-x11.mjs (or: npm run smoke:hud-x11) +// +// Requires xvfb-run on PATH (Debian/Ubuntu: sudo apt install xvfb). +// Nothing is installed globally by this script. +// +// The same file plays two roles: as the node launcher it bundles itself +// (CJS, electron external) and spawns Electron under Xvfb; as the Electron +// entry it runs the assertions below. Kept CJS-safe on purpose: no +// import.meta and no top-level await. + +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const XDG_SESSION_TYPE = "x11"; +const XVFB_SERVER_ARGS = "-screen 0 1920x1080x24"; + +function log(message) { + process.stdout.write(`SMOKE hud-x11: ${message}\n`); +} + +function fail(message) { + process.stderr.write(`ERROR hud-x11: ${message}\n`); + process.exit(1); +} + +if (process.versions.electron) { + // Inside the Electron main process (bundled by esbuild): run assertions + // against the real modules and a real BrowserWindow under X11. + const { app, BrowserWindow, screen } = require("electron"); + const { isWaylandSession } = require("../electron/hudOverlaySession"); + const { + getHudOverlayStaticBounds, + getHudOverlayWindowBounds, + shouldExpandHudOverlayFallback, + } = require("../electron/hudOverlayBounds"); + const { hideHudOverlayWindow } = require("../electron/hudOverlayWindowActions"); + + function assert(condition, message) { + if (!condition) { + throw new Error(`assertion failed: ${message}`); + } + log(`ok — ${message}`); + } + + async function run() { + await app.whenReady(); + + assert( + isWaylandSession() === false, + "session env is X11 (XDG_SESSION_TYPE=x11, no WAYLAND_DISPLAY)", + ); + assert( + isWaylandSession({ WAYLAND_DISPLAY: "wayland-0" }) === true, + "detector still flags Wayland when WAYLAND_DISPLAY is set (gate is live, not hardcoded false)", + ); + + const { workArea } = screen.getPrimaryDisplay(); + assert( + workArea.width >= 860 && workArea.height >= 540, + `Xvfb work area is large enough for the HUD fallback (${workArea.width}x${workArea.height})`, + ); + + // Creation bounds X11 actually takes: the static entry with the live + // session flag must equal main's compact creation fallback. + const staticBounds = getHudOverlayStaticBounds(workArea, false, isWaylandSession()); + const dynamicCreation = getHudOverlayWindowBounds( + workArea, + false, + shouldExpandHudOverlayFallback({ + fallbackExpanded: false, + recordingActive: false, + webcamPreviewVisible: false, + }), + ); + assert( + staticBounds.height === 160, + `static bounds stay compact on X11 (height ${staticBounds.height})`, + ); + assert( + JSON.stringify(staticBounds) === JSON.stringify(dynamicCreation), + "static bounds on X11 equal main's dynamic creation bounds", + ); + + // Real window: X11 honors programmatic bounds, so the compact height + // must survive a real setBounds/getBounds round-trip. + const win = new BrowserWindow({ + x: staticBounds.x, + y: staticBounds.y, + width: staticBounds.width, + height: staticBounds.height, + frame: false, + show: false, + }); + try { + const applied = win.getBounds(); + assert( + applied.height === 160, + `real BrowserWindow keeps the compact height (got ${applied.height})`, + ); + assert( + applied.width === 860, + `real BrowserWindow keeps the fallback width (got ${applied.width})`, + ); + + // Hide decision: minimize (main behavior), never hide, on X11. + // Spied via duck-typing — hideHudOverlayWindow only needs the + // hide/minimize pair, so the spy sees the real decision without + // depending on Xvfb having a window manager for iconic state. + const calls = { hide: 0, minimize: 0 }; + const target = { + hide: () => { + calls.hide += 1; + }, + minimize: () => { + calls.minimize += 1; + }, + }; + hideHudOverlayWindow(target, process.platform, isWaylandSession()); + assert( + calls.minimize === 1 && calls.hide === 0, + `hide control decides minimize() on X11 (hide=${calls.hide}, minimize=${calls.minimize})`, + ); + } finally { + win.destroy(); + } + + log("X11 HUD smoke passed"); + app.exit(0); + } + + run().catch((error) => { + process.stderr.write(`ERROR hud-x11: ${error?.stack ?? error}\n`); + app.exit(1); + }); +} else { + main().catch((error) => fail(error?.stack ?? String(error))); +} + +// Launcher (plain node): bundle this script with the real TS modules and +// spawn it under Xvfb with an X11 session environment. +async function main() { + const scriptPath = path.resolve(process.argv[1]); + const repoRoot = path.resolve(scriptPath, "..", ".."); + + const xvfbCheck = spawnSync("xvfb-run", ["--version"], { encoding: "utf8" }); + if (xvfbCheck.error || xvfbCheck.status !== 0) { + fail( + "xvfb-run not found on PATH — install it first (Debian/Ubuntu: sudo apt install xvfb)", + ); + } + + const esbuild = await import("esbuild"); + const outDir = fs.mkdtempSync(path.join(os.tmpdir(), "recordly-hud-x11-")); + const outFile = path.join(outDir, "smoke-hud-x11.cjs"); + await esbuild.build({ + entryPoints: [scriptPath], + outfile: outFile, + bundle: true, + platform: "node", + format: "cjs", + target: "node20", + external: ["electron", "esbuild"], + logLevel: "silent", + }); + + const electronBin = path.join(repoRoot, "node_modules", ".bin", "electron"); + if (!fs.existsSync(electronBin)) { + fail(`electron binary not found at ${electronBin}`); + } + + const childEnv = { ...process.env }; + childEnv.XDG_SESSION_TYPE = XDG_SESSION_TYPE; + delete childEnv.WAYLAND_DISPLAY; + childEnv.ELECTRON_DISABLE_SECURITY_WARNINGS = "1"; + + const result = spawnSync("xvfb-run", ["-a", "-s", XVFB_SERVER_ARGS, electronBin, outFile], { + env: childEnv, + encoding: "utf8", + timeout: 120_000, + }); + fs.rmSync(outDir, { recursive: true, force: true }); + + const lines = `${result.stdout ?? ""}${result.stderr ?? ""}`.split("\n"); + for (const line of lines) { + if (/^(SMOKE|ERROR) hud-x11:/.test(line)) { + process.stdout.write(`${line}\n`); + } + } + + if (result.status === 0) { + return; + } + const tail = lines + .filter((line) => line.trim().length > 0) + .slice(-10) + .join("\n"); + fail(`smoke exited with status ${result.status ?? `signal ${result.signal}`}\n${tail}`); +}