diff --git a/electron/hudOverlayBounds.test.ts b/electron/hudOverlayBounds.test.ts index db21e92bd..b99617390 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,46 @@ describe("getHudOverlayWindowBounds", () => { }); }); +describe("getHudOverlayStaticBounds", () => { + const workArea = { + x: 120, + y: 40, + width: 1920, + height: 1040, + }; + + it("pre-sizes the Wayland fallback so HUD popovers are not clipped", () => { + const bounds = getHudOverlayStaticBounds(workArea, false, true); + expect(bounds).toEqual({ + x: 650, + y: 540, + width: 860, + height: 540, + }); + // Maximum menu card (400) + popover offset and HUD bar clearance. + expect(bounds.height).toBeGreaterThanOrEqual(400 + 16 + 96); + }); + + it("preserves compact bounds for a Linux X11 session", () => { + expect(getHudOverlayStaticBounds(workArea, false, false)).toEqual({ + x: 650, + y: 920, + width: 860, + height: 160, + }); + }); + + it("preserves full-work-area bounds on passthrough platforms", () => { + expect(getHudOverlayStaticBounds(workArea, true, true)).toEqual(workArea); + expect(getHudOverlayStaticBounds(workArea, true, false)).toEqual(workArea); + }); + + it("clamps the Wayland fallback to a small display work area", () => { + const smallWorkArea = { x: -100, y: 20, width: 640, height: 420 }; + expect(getHudOverlayStaticBounds(smallWorkArea, false, true)).toEqual(smallWorkArea); + }); +}); + describe("resizeHudOverlayFallbackBounds", () => { const workArea = { x: 0, diff --git a/electron/hudOverlayBounds.ts b/electron/hudOverlayBounds.ts index 8c51b88c7..4e4ad5aaf 100644 --- a/electron/hudOverlayBounds.ts +++ b/electron/hudOverlayBounds.ts @@ -38,6 +38,27 @@ export function getHudOverlayWindowBounds( }; } +/** + * Return stable creation/recompute bounds for the HUD. Wayland cannot honor + * the x/y half of a client-side resize reliably, so its non-passthrough HUD is + * created at the existing expanded height and kept there. That gives in-window + * popovers enough native surface to render without a resize/jump cycle. + * + * The gate is intentionally session-specific: Windows, macOS, and Linux/X11 + * retain the existing bounds and runtime behavior. + */ +export function getHudOverlayStaticBounds( + workArea: HudOverlayWorkArea, + mousePassthroughSupported: boolean, + waylandSession: boolean, +): HudOverlayWorkArea { + return getHudOverlayWindowBounds( + workArea, + mousePassthroughSupported, + !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..aee3b7457 --- /dev/null +++ b/electron/hudOverlaySession.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "vitest"; + +import { isWaylandSession } from "./hudOverlaySession"; + +describe("isWaylandSession", () => { + it("detects Wayland from XDG_SESSION_TYPE", () => { + expect(isWaylandSession({ XDG_SESSION_TYPE: "wayland" })).toBe(true); + expect(isWaylandSession({ XDG_SESSION_TYPE: " Wayland " })).toBe(true); + }); + + it("detects Wayland from WAYLAND_DISPLAY", () => { + expect(isWaylandSession({ WAYLAND_DISPLAY: "wayland-0" })).toBe(true); + }); + + it("does not treat X11 or empty variables as Wayland", () => { + expect(isWaylandSession({ XDG_SESSION_TYPE: "x11" })).toBe(false); + expect(isWaylandSession({ XDG_SESSION_TYPE: "", WAYLAND_DISPLAY: " " })).toBe(false); + expect(isWaylandSession({})).toBe(false); + }); + + it("prefers an exposed Wayland socket when both session hints exist", () => { + 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..72a7f05ba --- /dev/null +++ b/electron/hudOverlaySession.ts @@ -0,0 +1,15 @@ +export interface HudOverlaySessionEnv { + XDG_SESSION_TYPE?: string | undefined; + WAYLAND_DISPLAY?: string | undefined; + [key: string]: string | undefined; +} + +/** + * Detect whether the desktop session uses Wayland. The environment is stable + * for the Electron process lifetime and is injectable so platform gating can + * be covered without depending on the host running the tests. + */ +export function isWaylandSession(env: HudOverlaySessionEnv = process.env): boolean { + const sessionType = env.XDG_SESSION_TYPE?.trim().toLowerCase(); + return sessionType === "wayland" || Boolean(env.WAYLAND_DISPLAY?.trim()); +} diff --git a/electron/windows.ts b/electron/windows.ts index 23e874fa1..103d49153 100644 --- a/electron/windows.ts +++ b/electron/windows.ts @@ -6,10 +6,12 @@ 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 { isWaylandSession } from "./hudOverlaySession"; import { getHudOverlayTaskbarOptions } from "./hudOverlayWindowOptions"; import { getPackagedRendererBaseUrl } from "./rendererServer"; @@ -202,16 +204,20 @@ function getHudOverlayDisplay() { function getHudOverlayBounds() { const { workArea } = getHudOverlayDisplay(); + const mousePassthroughSupported = isHudOverlayMousePassthroughSupported(); + if (!mousePassthroughSupported && isWaylandSession()) { + // Popovers live inside this BrowserWindow and cannot paint outside its + // native surface. Keep the Wayland fallback at its expanded height from + // creation onward; runtime growth cannot preserve x/y on Wayland and can + // make the bottom-anchored bar jump or oscillate. + return getHudOverlayStaticBounds(workArea, mousePassthroughSupported, true); + } const fallbackExpanded = shouldExpandHudOverlayFallback({ fallbackExpanded: hudOverlayFallbackExpanded, recordingActive: hudOverlayRecordingActive, webcamPreviewVisible: hudOverlayWebcamPreviewVisible, }); - return getHudOverlayWindowBounds( - workArea, - isHudOverlayMousePassthroughSupported(), - fallbackExpanded, - ); + return getHudOverlayWindowBounds(workArea, mousePassthroughSupported, fallbackExpanded); } function applyHudOverlayBounds() { diff --git a/src/lib/exporter/modernVideoExporter.fallback.test.ts b/src/lib/exporter/modernVideoExporter.fallback.test.ts index f4d291ab7..5493dc7b5 100644 --- a/src/lib/exporter/modernVideoExporter.fallback.test.ts +++ b/src/lib/exporter/modernVideoExporter.fallback.test.ts @@ -22,6 +22,7 @@ const mocks = vi.hoisted(() => { streamingDecoderGetDemuxer: vi.fn(() => null), streamingDecoderGetEffectiveDuration: vi.fn(() => 0), streamingDecoderLoadMetadata: vi.fn(async () => videoInfo), + frameRendererConfigs: [] as Array<{ preferredRenderBackend?: "webgl" | "webgpu" }>, frameRendererDestroy: vi.fn(), frameRendererGetBackend: vi.fn(() => "webgl"), frameRendererInitialize: vi.fn(async () => {}), @@ -48,7 +49,8 @@ vi.mock("./streamingDecoder", () => ({ })); vi.mock("./modernFrameRenderer", () => ({ - FrameRenderer: vi.fn().mockImplementation(function () { + FrameRenderer: vi.fn().mockImplementation(function (config) { + mocks.frameRendererConfigs.push(config); return { destroy: mocks.frameRendererDestroy, getRendererBackend: mocks.frameRendererGetBackend, @@ -76,10 +78,49 @@ describe("ModernVideoExporter native fallback routing", () => { afterEach(() => { vi.clearAllMocks(); + mocks.frameRendererConfigs.length = 0; if (vi.isMockFunction(console.error)) console.error.mockRestore(); vi.unstubAllGlobals(); }); + it.each([ + { configured: undefined, expected: "webgl" }, + { configured: "webgpu" as const, expected: "webgpu" }, + ])("uses $expected as the Lightning render backend when configured backend is $configured", async ({ + configured, + expected, + }) => { + const exporter = new ModernVideoExporter({ + videoUrl: "file:///recording.mp4", + width: 1920, + height: 1080, + frameRate: 30, + bitrate: 8_000_000, + wallpaper: "#101010", + padding: 0, + borderRadius: 0, + backgroundBlur: 0, + shadowIntensity: 0, + showShadow: false, + cropRegion: { x: 0, y: 0, width: 1, height: 1 }, + backendPreference: "webcodecs", + preferredRenderBackend: configured, + } as never) as unknown as { + export: () => Promise<{ success: boolean }>; + initializeEncoder: () => Promise; + }; + vi.spyOn(exporter, "initializeEncoder").mockResolvedValue({ + codec: "avc1.640034", + hardwareAcceleration: "prefer-hardware", + }); + + const result = await exporter.export(); + + expect(result.success).toBe(true); + expect(mocks.frameRendererConfigs).toHaveLength(1); + expect(mocks.frameRendererConfigs[0]?.preferredRenderBackend).toBe(expected); + }, 15_000); + it("removes failed native writes without creating an unhandled rejection", async () => { const exporter = new ModernVideoExporter({} as never) as unknown as { trackNativeWritePromise: (write: Promise) => void; diff --git a/src/lib/exporter/modernVideoExporter.ts b/src/lib/exporter/modernVideoExporter.ts index 81137b267..9b209a1ed 100644 --- a/src/lib/exporter/modernVideoExporter.ts +++ b/src/lib/exporter/modernVideoExporter.ts @@ -49,6 +49,7 @@ import { } from "@/lib/wallpapers"; import { AudioProcessor, isAacAudioEncodingSupported } from "./audioEncoder"; import { + getDefaultLightningRenderBackend, normalizeLightningRuntimePlatform, shouldPreferNativeAutoBackend, shouldPreferNativeStaticLayoutBeforeBreeze, @@ -604,7 +605,8 @@ export class ModernVideoExporter { timelineEffects: this.config.clipRegions !== undefined, width: this.config.width, height: this.config.height, - preferredRenderBackend: undefined, + preferredRenderBackend: + this.config.preferredRenderBackend ?? getDefaultLightningRenderBackend(), wallpaper: this.config.wallpaper, zoomRegions: this.config.zoomRegions, showShadow: this.config.showShadow,