From 44756e022da9162e6434f5b6771246a7754b7f2d Mon Sep 17 00:00:00 2001 From: Eivind Jonassen Date: Tue, 11 Aug 2026 16:14:26 +0200 Subject: [PATCH] fix(linux): sync Hyprland cursor telemetry --- electron/electron-env.d.ts | 6 +- electron/gpuSwitches.ts | 36 +---- electron/ipc/cursor/hyprland.test.ts | 190 ++++++++++++++++++++++++ electron/ipc/cursor/hyprland.ts | 191 +++++++++++++++++++++++++ electron/ipc/cursor/interaction.ts | 10 +- electron/ipc/cursor/telemetry.ts | 3 +- electron/ipc/register/recording.ts | 34 ++++- electron/ipc/register/sourceMapping.ts | 14 +- electron/ipc/state.ts | 11 +- electron/linuxWindowSystem.test.ts | 32 +++++ electron/linuxWindowSystem.ts | 36 +++++ electron/preload.ts | 10 +- src/hooks/useScreenRecorder.test.ts | 56 ++++++++ src/hooks/useScreenRecorder.ts | 63 +++++++- 14 files changed, 631 insertions(+), 61 deletions(-) create mode 100644 electron/ipc/cursor/hyprland.test.ts create mode 100644 electron/ipc/cursor/hyprland.ts create mode 100644 electron/linuxWindowSystem.test.ts create mode 100644 electron/linuxWindowSystem.ts diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index 8a4eedd63..ad9e97306 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -566,7 +566,10 @@ interface Window { startDelayMsByPath?: Record; error?: string; }>; - setRecordingState: (recording: boolean) => Promise; + setRecordingState: ( + recording: boolean, + options?: { mediaTimelineStartedAtEpochMs?: number }, + ) => Promise<{ cursorOverlayAvailable: boolean }>; getCursorTelemetry: (videoPath?: string) => Promise<{ success: boolean; samples: CursorTelemetryPoint[]; @@ -839,7 +842,6 @@ interface Window { onMenuSaveProject: (callback: () => void) => () => void; onMenuSaveProjectAs: (callback: () => void) => () => void; getPlatform: () => Promise; - getLinuxWindowSystem: () => Promise<"wayland" | "x11" | null>; revealInFolder: ( filePath: string, ) => Promise<{ success: boolean; error?: string; message?: string }>; diff --git a/electron/gpuSwitches.ts b/electron/gpuSwitches.ts index 7b7c81ee8..570815d3f 100644 --- a/electron/gpuSwitches.ts +++ b/electron/gpuSwitches.ts @@ -1,43 +1,13 @@ +import { resolveLinuxWindowSystem } from "./linuxWindowSystem"; + export interface GpuSwitches { useAngle?: string; useGl?: string; disableFeatures?: string[]; } -function normalizeLinuxWindowSystem(value: string | undefined): "wayland" | "x11" | null { - const normalized = value?.trim().toLowerCase(); - if (normalized === "wayland" || normalized === "x11") { - return normalized; - } - - return null; -} - -function getForcedLinuxWindowSystem(env: NodeJS.ProcessEnv): "wayland" | "x11" | null { - return ( - normalizeLinuxWindowSystem(env.OZONE_PLATFORM) ?? - normalizeLinuxWindowSystem(env.ELECTRON_OZONE_PLATFORM_HINT) - ); -} - export function shouldForceLinuxEgl(env: NodeJS.ProcessEnv): boolean { - const forcedWindowSystem = getForcedLinuxWindowSystem(env); - if (forcedWindowSystem === "wayland") { - return false; - } - if (forcedWindowSystem === "x11") { - return true; - } - - const sessionType = env.XDG_SESSION_TYPE?.toLowerCase(); - if (sessionType === "wayland") { - return false; - } - if (sessionType === "x11") { - return true; - } - - return !env.WAYLAND_DISPLAY; + return resolveLinuxWindowSystem("linux", env) !== "wayland"; } export function getGpuSwitches( diff --git a/electron/ipc/cursor/hyprland.test.ts b/electron/ipc/cursor/hyprland.test.ts new file mode 100644 index 000000000..da56cba1e --- /dev/null +++ b/electron/ipc/cursor/hyprland.test.ts @@ -0,0 +1,190 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("electron", () => ({ + app: { + getPath: vi.fn(() => "/tmp"), + }, +})); + +import { activeCursorSamples, linuxCursorScreenPoint, setActiveCursorSamples } from "../state"; +import { + getHyprlandRequestSocketPath, + isHyprlandCursorProviderActive, + parseHyprlandCursorPosition, + resolveHyprlandCursorCaptureEpochMs, + startHyprlandCursorProvider, + stopHyprlandCursorProvider, +} from "./hyprland"; + +const waylandEnv = { + XDG_RUNTIME_DIR: "/run/user/1000", + XDG_SESSION_TYPE: "wayland", + WAYLAND_DISPLAY: "wayland-1", + HYPRLAND_INSTANCE_SIGNATURE: "abc123_456", +}; + +describe("Hyprland cursor provider", () => { + beforeEach(() => { + stopHyprlandCursorProvider(); + vi.useRealTimers(); + }); + + afterEach(() => { + stopHyprlandCursorProvider(); + vi.useRealTimers(); + }); + + it("resolves the Hyprland request socket on native Wayland", async () => { + expect(getHyprlandRequestSocketPath(waylandEnv, "linux")).toBe( + "/run/user/1000/hypr/abc123_456/.socket.sock", + ); + }); + + it("does not start until the cursor socket returns an initial point", async () => { + await expect( + startHyprlandCursorProvider({ + env: waylandEnv, + platform: "linux", + query: vi.fn().mockResolvedValue(null), + }), + ).resolves.toBe(false); + expect(isHyprlandCursorProviderActive()).toBe(false); + }); + + it("does not activate for X11 or unsafe instance signatures", () => { + expect( + getHyprlandRequestSocketPath({ ...waylandEnv, OZONE_PLATFORM: "x11" }, "linux"), + ).toBeNull(); + expect( + getHyprlandRequestSocketPath( + { ...waylandEnv, OZONE_PLATFORM: "auto", ELECTRON_OZONE_PLATFORM_HINT: "x11" }, + "linux", + ), + ).toBeNull(); + expect( + getHyprlandRequestSocketPath( + { ...waylandEnv, HYPRLAND_INSTANCE_SIGNATURE: "../../other" }, + "linux", + ), + ).toBeNull(); + expect(getHyprlandRequestSocketPath(waylandEnv, "darwin")).toBeNull(); + }); + + it("parses finite logical cursor coordinates", () => { + expect(parseHyprlandCursorPosition('{"x":-120.5,"y":480}')).toEqual({ + x: -120.5, + y: 480, + }); + expect(parseHyprlandCursorPosition('{"x":"12","y":4}')).toBeNull(); + expect(parseHyprlandCursorPosition("not json")).toBeNull(); + }); + + it("applies the measured Hyprland media timeline correction", () => { + expect(resolveHyprlandCursorCaptureEpochMs(10_000)).toBe(9_700); + }); + + it("polls serially and stops without publishing a late response", async () => { + vi.useFakeTimers(); + let resolveQuery!: (point: { x: number; y: number }) => void; + const query = vi.fn( + () => + new Promise<{ x: number; y: number }>((resolve) => { + resolveQuery = resolve; + }), + ); + const onPoint = vi.fn(); + + const started = startHyprlandCursorProvider({ + env: waylandEnv, + platform: "linux", + query, + onPoint, + pollIntervalMs: 10, + }); + expect(query).toHaveBeenCalledOnce(); + expect(isHyprlandCursorProviderActive()).toBe(false); + + stopHyprlandCursorProvider(); + expect(isHyprlandCursorProviderActive()).toBe(false); + resolveQuery({ x: 10, y: 20 }); + await vi.runAllTimersAsync(); + + await expect(started).resolves.toBe(false); + expect(onPoint).not.toHaveBeenCalled(); + expect(query).toHaveBeenCalledOnce(); + }); + + it("publishes the initial compositor response before reporting success", async () => { + const onPoint = vi.fn(); + + await expect( + startHyprlandCursorProvider({ + env: waylandEnv, + platform: "linux", + query: vi.fn().mockResolvedValue({ x: 12, y: 34 }), + onPoint, + pollIntervalMs: 60_000, + }), + ).resolves.toBe(true); + + expect(onPoint).toHaveBeenCalledWith({ x: 12, y: 34 }); + expect(isHyprlandCursorProviderActive()).toBe(true); + }); + + it("only refreshes provider state and clears it when polling fails", async () => { + vi.useFakeTimers(); + setActiveCursorSamples([]); + const query = vi.fn().mockResolvedValueOnce({ x: 12, y: 34 }).mockResolvedValueOnce(null); + + await startHyprlandCursorProvider({ + env: waylandEnv, + platform: "linux", + query, + pollIntervalMs: 10, + }); + + expect(linuxCursorScreenPoint).toMatchObject({ + x: 12, + y: 34, + coordinateSpace: "logical", + source: "hyprland", + }); + expect(activeCursorSamples).toEqual([]); + + await vi.advanceTimersByTimeAsync(10); + expect(linuxCursorScreenPoint).toBeNull(); + expect(isHyprlandCursorProviderActive()).toBe(false); + }); + + it("keeps a successful provider healthy while the next query is pending", async () => { + vi.useFakeTimers(); + vi.setSystemTime(1_000); + let resolvePendingQuery!: (point: { x: number; y: number } | null) => void; + const query = vi + .fn() + .mockResolvedValueOnce({ x: 12, y: 34 }) + .mockImplementationOnce( + () => + new Promise<{ x: number; y: number } | null>((resolve) => { + resolvePendingQuery = resolve; + }), + ); + + await startHyprlandCursorProvider({ + env: waylandEnv, + platform: "linux", + query, + pollIntervalMs: 33, + }); + expect(isHyprlandCursorProviderActive()).toBe(true); + + await vi.advanceTimersByTimeAsync(33); + expect(query).toHaveBeenCalledTimes(2); + await vi.advanceTimersByTimeAsync(167); + expect(isHyprlandCursorProviderActive()).toBe(true); + + resolvePendingQuery(null); + await vi.advanceTimersByTimeAsync(0); + expect(isHyprlandCursorProviderActive()).toBe(false); + }); +}); diff --git a/electron/ipc/cursor/hyprland.ts b/electron/ipc/cursor/hyprland.ts new file mode 100644 index 000000000..d56eb629d --- /dev/null +++ b/electron/ipc/cursor/hyprland.ts @@ -0,0 +1,191 @@ +import net from "node:net"; +import path from "node:path"; +import { resolveLinuxWindowSystem } from "../../linuxWindowSystem"; +import { CURSOR_SAMPLE_INTERVAL_MS } from "../constants"; +import { linuxCursorScreenPoint, setLinuxCursorScreenPoint } from "../state"; + +const MAX_RESPONSE_BYTES = 4096; +const REQUEST_TIMEOUT_MS = 250; +const PROVIDER_FRESHNESS_INTERVALS = 3; +// Calibration against Hyprland portal recordings showed cursor telemetry 300 ms early. +export const HYPRLAND_CURSOR_MEDIA_OFFSET_MS = 300; + +type CursorPoint = { x: number; y: number }; +type QueryCursorPoint = (socketPath: string) => Promise; + +let pollTimer: NodeJS.Timeout | null = null; +let pollGeneration = 0; +let providerHealthyUntilMs = 0; + +export function resolveHyprlandCursorCaptureEpochMs(mediaTimelineStartedAtEpochMs: number) { + return Math.max(0, mediaTimelineStartedAtEpochMs - HYPRLAND_CURSOR_MEDIA_OFFSET_MS); +} + +export function getHyprlandRequestSocketPath( + env: NodeJS.ProcessEnv, + platform: NodeJS.Platform | string = process.platform, +) { + if (resolveLinuxWindowSystem(platform, env) !== "wayland") { + return null; + } + + const runtimeDir = env.XDG_RUNTIME_DIR?.trim(); + const instanceSignature = env.HYPRLAND_INSTANCE_SIGNATURE?.trim(); + if ( + !runtimeDir || + !path.isAbsolute(runtimeDir) || + !instanceSignature || + !/^[A-Za-z0-9_.-]+$/.test(instanceSignature) + ) { + return null; + } + + return path.join(runtimeDir, "hypr", instanceSignature, ".socket.sock"); +} + +export function parseHyprlandCursorPosition(response: string): CursorPoint | null { + try { + const parsed = JSON.parse(response) as { x?: unknown; y?: unknown }; + if ( + typeof parsed.x !== "number" || + !Number.isFinite(parsed.x) || + typeof parsed.y !== "number" || + !Number.isFinite(parsed.y) + ) { + return null; + } + + return { x: parsed.x, y: parsed.y }; + } catch { + return null; + } +} + +export function queryHyprlandCursorPosition(socketPath: string): Promise { + return new Promise((resolve) => { + let output = ""; + let settled = false; + const socket = net.createConnection(socketPath); + + const finish = (point: CursorPoint | null) => { + if (settled) return; + settled = true; + socket.destroy(); + resolve(point); + }; + + socket.setEncoding("utf8"); + socket.setTimeout(REQUEST_TIMEOUT_MS, () => finish(null)); + socket.once("connect", () => socket.end("j/cursorpos")); + socket.on("data", (chunk: string) => { + output += chunk; + if (Buffer.byteLength(output) > MAX_RESPONSE_BYTES) { + finish(null); + } + }); + socket.once("end", () => finish(parseHyprlandCursorPosition(output))); + socket.once("error", () => finish(null)); + socket.once("close", () => finish(null)); + }); +} + +function clearHyprlandCursorPoint() { + if (linuxCursorScreenPoint?.source === "hyprland") { + setLinuxCursorScreenPoint(null); + } +} + +export function stopHyprlandCursorProvider() { + pollGeneration += 1; + providerHealthyUntilMs = 0; + if (pollTimer) { + clearTimeout(pollTimer); + pollTimer = null; + } + clearHyprlandCursorPoint(); +} + +export async function startHyprlandCursorProvider(options?: { + env?: NodeJS.ProcessEnv; + platform?: NodeJS.Platform | string; + pollIntervalMs?: number; + query?: QueryCursorPoint; + onPoint?: (point: CursorPoint) => void; +}) { + stopHyprlandCursorProvider(); + + const socketPath = getHyprlandRequestSocketPath( + options?.env ?? process.env, + options?.platform ?? process.platform, + ); + if (!socketPath) { + return false; + } + + const generation = pollGeneration; + const query = options?.query ?? queryHyprlandCursorPosition; + const pollIntervalMs = options?.pollIntervalMs ?? CURSOR_SAMPLE_INTERVAL_MS; + const onPoint = + options?.onPoint ?? + ((point: CursorPoint) => { + setLinuxCursorScreenPoint({ + ...point, + updatedAt: Date.now(), + coordinateSpace: "logical", + source: "hyprland", + }); + }); + const markHealthy = () => { + providerHealthyUntilMs = + Date.now() + + Math.max( + REQUEST_TIMEOUT_MS + pollIntervalMs, + pollIntervalMs * PROVIDER_FRESHNESS_INTERVALS, + ); + }; + const queryPoint = async () => { + try { + return await query(socketPath); + } catch { + return null; + } + }; + + const initialPoint = await queryPoint(); + if (generation !== pollGeneration || !initialPoint) { + return false; + } + markHealthy(); + onPoint(initialPoint); + + let nextPollAtMs = performance.now() + pollIntervalMs; + const poll = async () => { + const pollStartedAtMs = performance.now(); + const point = await queryPoint(); + if (generation !== pollGeneration) { + return; + } + + if (point) { + markHealthy(); + onPoint(point); + } else { + providerHealthyUntilMs = 0; + clearHyprlandCursorPoint(); + } + + nextPollAtMs += pollIntervalMs; + const nowMs = performance.now(); + if (nextPollAtMs <= pollStartedAtMs || nextPollAtMs < nowMs - pollIntervalMs) { + nextPollAtMs = nowMs + pollIntervalMs; + } + pollTimer = setTimeout(poll, Math.max(1, nextPollAtMs - nowMs)); + }; + + pollTimer = setTimeout(poll, pollIntervalMs); + return true; +} + +export function isHyprlandCursorProviderActive() { + return providerHealthyUntilMs > Date.now(); +} diff --git a/electron/ipc/cursor/interaction.ts b/electron/ipc/cursor/interaction.ts index 9b6f3ac92..79e708e9f 100644 --- a/electron/ipc/cursor/interaction.ts +++ b/electron/ipc/cursor/interaction.ts @@ -12,6 +12,7 @@ import { setLastLeftClick, setLinuxCursorScreenPoint, } from "../state"; +import { isHyprlandCursorProviderActive } from "./hyprland"; import { getNormalizedCursorPoint, getCursorCaptureElapsedMs, @@ -257,6 +258,7 @@ export async function startInteractionCapture() { const onMouseMove = (event: HookMouseEvent) => { if ( process.platform !== "linux" || + isHyprlandCursorProviderActive() || !isCursorCaptureActive || isCursorCapturePaused() ) { @@ -268,7 +270,13 @@ export async function startInteractionCapture() { return; } - setLinuxCursorScreenPoint({ x: point.x, y: point.y, updatedAt: Date.now() }); + setLinuxCursorScreenPoint({ + x: point.x, + y: point.y, + updatedAt: Date.now(), + coordinateSpace: "physical", + source: "uiohook", + }); }; hook.on("mousedown", onMouseDown); diff --git a/electron/ipc/cursor/telemetry.ts b/electron/ipc/cursor/telemetry.ts index ebedfe72a..d66bebe50 100644 --- a/electron/ipc/cursor/telemetry.ts +++ b/electron/ipc/cursor/telemetry.ts @@ -178,8 +178,9 @@ export function getNormalizedCursorPoint() { const primarySf = process.platform !== "darwin" ? getScreen().getPrimaryDisplay().scaleFactor || 1 : 1; + const linuxCursorScale = linuxCursorCache?.coordinateSpace === "logical" ? 1 : primarySf; const cursor = isLinuxCacheFresh - ? { x: linuxCursorCache.x / primarySf, y: linuxCursorCache.y / primarySf } + ? { x: linuxCursorCache.x / linuxCursorScale, y: linuxCursorCache.y / linuxCursorScale } : fallbackCursor; const windowBounds = selectedSource?.id?.startsWith("window:") ? selectedWindowBounds : null; diff --git a/electron/ipc/register/recording.ts b/electron/ipc/register/recording.ts index 06a33f84f..efbcfe88e 100644 --- a/electron/ipc/register/recording.ts +++ b/electron/ipc/register/recording.ts @@ -16,6 +16,11 @@ import { showCursor } from "../../cursorHider"; import { getMonitorHandles } from "../monitorResolver"; import { ALLOW_RECORDLY_WINDOW_CAPTURE } from "../constants"; import { startWindowBoundsCapture, stopWindowBoundsCapture } from "../cursor/bounds"; +import { + resolveHyprlandCursorCaptureEpochMs, + startHyprlandCursorProvider, + stopHyprlandCursorProvider, +} from "../cursor/hyprland"; import { startInteractionCapture, stopInteractionCapture } from "../cursor/interaction"; import { startNativeCursorMonitor, stopNativeCursorMonitor } from "../cursor/monitor"; import { @@ -394,6 +399,8 @@ async function resolveExistingPath(...candidates: Array void, ) { + let cursorCaptureGeneration = 0; + ipcMain.handle( "start-native-screen-recording", async (_, source: SelectedSource, options?: NativeMacRecordingOptions) => { @@ -1811,7 +1818,9 @@ export function registerRecordingHandlers( } }); - ipcMain.handle("set-recording-state", (_, recording: boolean) => { + ipcMain.handle("set-recording-state", async (_, recording: boolean, options?: unknown) => { + const captureGeneration = ++cursorCaptureGeneration; + let cursorOverlayAvailable = false; if (recording) { stopCursorCapture(); stopInteractionCapture(); @@ -1820,10 +1829,28 @@ export function registerRecordingHandlers( setIsCursorCaptureActive(true); setActiveCursorSamples([]); setPendingCursorSamples([]); - setCursorCaptureStartTimeMs(Date.now()); resetCursorCaptureClock(); setLinuxCursorScreenPoint(null); setLastLeftClick(null); + const hyprlandCursorProviderStarted = await startHyprlandCursorProvider(); + if (captureGeneration !== cursorCaptureGeneration) { + return { cursorOverlayAvailable: false }; + } + + cursorOverlayAvailable = hyprlandCursorProviderStarted; + const mediaTimelineStartedAtEpochMs = isRecord(options) + ? options.mediaTimelineStartedAtEpochMs + : undefined; + const captureStartedAtMs = normalizeRendererTimestampMs( + mediaTimelineStartedAtEpochMs, + ); + setCursorCaptureStartTimeMs( + hyprlandCursorProviderStarted && + typeof mediaTimelineStartedAtEpochMs === "number" && + Number.isFinite(mediaTimelineStartedAtEpochMs) + ? resolveHyprlandCursorCaptureEpochMs(captureStartedAtMs) + : captureStartedAtMs, + ); sampleCursorPoint(); startCursorSampling(); void startInteractionCapture(); @@ -1831,6 +1858,7 @@ export function registerRecordingHandlers( setIsCursorCaptureActive(false); stopCursorCapture(); stopInteractionCapture(); + stopHyprlandCursorProvider(); stopWindowBoundsCapture(); stopNativeCursorMonitor(); showCursor(); @@ -1853,6 +1881,8 @@ export function registerRecordingHandlers( if (onRecordingStateChange) { onRecordingStateChange(recording, source.name); } + + return { cursorOverlayAvailable }; }); ipcMain.handle("pause-cursor-capture", (_, pausedAtMs?: unknown) => { diff --git a/electron/ipc/register/sourceMapping.ts b/electron/ipc/register/sourceMapping.ts index a61b4cf72..c6a649ff9 100644 --- a/electron/ipc/register/sourceMapping.ts +++ b/electron/ipc/register/sourceMapping.ts @@ -1,15 +1,9 @@ +import { resolveLinuxWindowSystem } from "../../linuxWindowSystem"; + export const LINUX_PORTAL_SCREEN_SOURCE_ID = "screen:linux-portal"; export function isLikelyLinuxWaylandSession(env: NodeJS.ProcessEnv) { - const sessionType = env.XDG_SESSION_TYPE?.trim().toLowerCase(); - if (sessionType === "wayland") { - return true; - } - if (sessionType === "x11") { - return false; - } - - return Boolean(env.WAYLAND_DISPLAY); + return resolveLinuxWindowSystem("linux", env) === "wayland"; } export function getScreenSourceIdForDisplay({ @@ -32,4 +26,4 @@ export function getScreenSourceIdForDisplay({ } return `screen:fallback:${displayId}`; -} \ No newline at end of file +} diff --git a/electron/ipc/state.ts b/electron/ipc/state.ts index a0a41744e..2cffce248 100644 --- a/electron/ipc/state.ts +++ b/electron/ipc/state.ts @@ -84,7 +84,14 @@ export let isCursorCaptureActive = false; export let interactionCaptureCleanup: (() => void) | null = null; export let hasLoggedInteractionHookFailure = false; export let lastLeftClick: { timeMs: number; cx: number; cy: number } | null = null; -export let linuxCursorScreenPoint: { x: number; y: number; updatedAt: number } | null = null; +export interface LinuxCursorScreenPoint { + x: number; + y: number; + updatedAt: number; + coordinateSpace: "logical" | "physical"; + source: "hyprland" | "uiohook"; +} +export let linuxCursorScreenPoint: LinuxCursorScreenPoint | null = null; export let selectedWindowBounds: WindowBounds | null = null; export let windowBoundsCaptureInterval: NodeJS.Timeout | null = null; @@ -263,7 +270,7 @@ export function setHasLoggedInteractionHookFailure(v: boolean) { export function setLastLeftClick(v: { timeMs: number; cx: number; cy: number } | null) { lastLeftClick = v; } -export function setLinuxCursorScreenPoint(v: { x: number; y: number; updatedAt: number } | null) { +export function setLinuxCursorScreenPoint(v: LinuxCursorScreenPoint | null) { linuxCursorScreenPoint = v; } export function setSelectedWindowBounds(v: WindowBounds | null) { diff --git a/electron/linuxWindowSystem.test.ts b/electron/linuxWindowSystem.test.ts new file mode 100644 index 000000000..5e5854a9c --- /dev/null +++ b/electron/linuxWindowSystem.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "vitest"; +import { resolveLinuxWindowSystem } from "./linuxWindowSystem"; + +describe("resolveLinuxWindowSystem", () => { + it("uses validated Ozone settings before session environment fallbacks", () => { + expect( + resolveLinuxWindowSystem("linux", { + OZONE_PLATFORM: "auto", + ELECTRON_OZONE_PLATFORM_HINT: "x11", + XDG_SESSION_TYPE: "wayland", + }), + ).toBe("x11"); + }); + + it("uses the explicit session type before display variables", () => { + expect( + resolveLinuxWindowSystem("linux", { + XDG_SESSION_TYPE: "x11", + WAYLAND_DISPLAY: "wayland-0", + }), + ).toBe("x11"); + }); + + it("falls back to the available display variable", () => { + expect(resolveLinuxWindowSystem("linux", { WAYLAND_DISPLAY: "wayland-0" })).toBe("wayland"); + expect(resolveLinuxWindowSystem("linux", { DISPLAY: ":0" })).toBe("x11"); + }); + + it("returns null outside Linux", () => { + expect(resolveLinuxWindowSystem("darwin", { XDG_SESSION_TYPE: "wayland" })).toBeNull(); + }); +}); diff --git a/electron/linuxWindowSystem.ts b/electron/linuxWindowSystem.ts new file mode 100644 index 000000000..68f4126a1 --- /dev/null +++ b/electron/linuxWindowSystem.ts @@ -0,0 +1,36 @@ +export type LinuxWindowSystem = "wayland" | "x11" | null; + +function normalizeLinuxWindowSystem(value: string | undefined): LinuxWindowSystem { + const normalized = value?.trim().toLowerCase(); + return normalized === "wayland" || normalized === "x11" ? normalized : null; +} + +export function resolveLinuxWindowSystem( + platform: NodeJS.Platform | string, + env: NodeJS.ProcessEnv = process.env, +): LinuxWindowSystem { + if (platform !== "linux") { + return null; + } + + const configuredWindowSystem = + normalizeLinuxWindowSystem(env.OZONE_PLATFORM) ?? + normalizeLinuxWindowSystem(env.ELECTRON_OZONE_PLATFORM_HINT); + if (configuredWindowSystem) { + return configuredWindowSystem; + } + + const sessionWindowSystem = normalizeLinuxWindowSystem(env.XDG_SESSION_TYPE); + if (sessionWindowSystem) { + return sessionWindowSystem; + } + + if (env.WAYLAND_DISPLAY) { + return "wayland"; + } + if (env.DISPLAY) { + return "x11"; + } + + return null; +} diff --git a/electron/preload.ts b/electron/preload.ts index e55d42cbd..afcff9a80 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -575,8 +575,11 @@ contextBridge.exposeInMainWorld("electronAPI", { getRecordedVideoPath: () => { return ipcRenderer.invoke("get-recorded-video-path"); }, - setRecordingState: (recording: boolean) => { - return ipcRenderer.invoke("set-recording-state", recording); + setRecordingState: ( + recording: boolean, + options?: { mediaTimelineStartedAtEpochMs?: number }, + ) => { + return ipcRenderer.invoke("set-recording-state", recording, options); }, setCursorScale: (scale: number) => { return ipcRenderer.invoke("set-cursor-scale", scale); @@ -910,9 +913,6 @@ contextBridge.exposeInMainWorld("electronAPI", { getPlatform: () => { return ipcRenderer.invoke("get-platform"); }, - getLinuxWindowSystem: () => { - return ipcRenderer.invoke("get-linux-window-system"); - }, revealInFolder: (filePath: string) => { return ipcRenderer.invoke("reveal-in-folder", filePath); }, diff --git a/src/hooks/useScreenRecorder.test.ts b/src/hooks/useScreenRecorder.test.ts index d74e884ce..24512097a 100644 --- a/src/hooks/useScreenRecorder.test.ts +++ b/src/hooks/useScreenRecorder.test.ts @@ -6,6 +6,7 @@ import { normalizeBrowserMicrophoneProfile, resolveBrowserCaptureCursorPolicy, shouldUseNativeWindowsCaptureForSource, + startMediaRecorderAtTimelineBoundary, } from "./useScreenRecorder"; type RecordingState = "inactive" | "recording" | "paused"; @@ -159,6 +160,61 @@ describe("resolveBrowserCaptureCursorPolicy", () => { }); }); +describe("startMediaRecorderAtTimelineBoundary", () => { + it("uses the recorder start event as the media timeline origin", async () => { + vi.useFakeTimers(); + vi.setSystemTime(1_000); + const recorder = Object.assign(new EventTarget(), { + start: vi.fn(), + }) as unknown as MediaRecorder; + const startedAt = startMediaRecorderAtTimelineBoundary(recorder, 250); + + vi.setSystemTime(2_400); + recorder.dispatchEvent(new Event("start")); + + await expect(startedAt).resolves.toBe(2_400); + expect(recorder.start).toHaveBeenCalledWith(250); + vi.useRealTimers(); + }); + + it("rejects if no media timeline starts before the timeout", async () => { + vi.useFakeTimers(); + const recorder = Object.assign(new EventTarget(), { + start: vi.fn(), + }) as unknown as MediaRecorder; + const startedAt = startMediaRecorderAtTimelineBoundary(recorder, 250, 100); + const expectation = expect(startedAt).rejects.toThrow( + "did not start within the expected time", + ); + + await vi.advanceTimersByTimeAsync(100); + await expectation; + vi.useRealTimers(); + }); + + it("rejects if the recorder fails before its media timeline starts", async () => { + const recorder = Object.assign(new EventTarget(), { + start: vi.fn(), + }) as unknown as MediaRecorder; + const startedAt = startMediaRecorderAtTimelineBoundary(recorder, 250); + + recorder.dispatchEvent(new Event("error")); + + await expect(startedAt).rejects.toThrow("failed before its media timeline started"); + }); + + it("rejects and cleans up when MediaRecorder.start throws", async () => { + const startError = new Error("unsupported recording configuration"); + const recorder = Object.assign(new EventTarget(), { + start: vi.fn(() => { + throw startError; + }), + }) as unknown as MediaRecorder; + + await expect(startMediaRecorderAtTimelineBoundary(recorder, 250)).rejects.toBe(startError); + }); +}); + describe("shouldUseNativeWindowsCaptureForSource", () => { it("keeps native Windows capture on screen sources", () => { expect(shouldUseNativeWindowsCaptureForSource({ id: "screen:101:0" })).toBe(true); diff --git a/src/hooks/useScreenRecorder.ts b/src/hooks/useScreenRecorder.ts index c5cd70056..37d691e4a 100644 --- a/src/hooks/useScreenRecorder.ts +++ b/src/hooks/useScreenRecorder.ts @@ -202,7 +202,6 @@ export function resolveBrowserCaptureCursorPolicy({ hideEditorOverlayCursorByDefault: true, }; } - return { streamCursor: "never", hideOsCursorBeforeRecording: true, @@ -210,6 +209,54 @@ export function resolveBrowserCaptureCursorPolicy({ }; } +export function startMediaRecorderAtTimelineBoundary( + recorder: Pick, + timesliceMs: number, + timeoutMs = 5_000, +) { + return new Promise((resolve, reject) => { + let settled = false; + const cleanup = () => { + clearTimeout(timeoutId); + recorder.removeEventListener("start", handleStart); + recorder.removeEventListener("error", handleFailure); + recorder.removeEventListener("stop", handleFailure); + }; + const finish = () => { + if (settled) return; + settled = true; + cleanup(); + resolve(Date.now()); + }; + const fail = (error: unknown) => { + if (settled) return; + settled = true; + cleanup(); + reject( + error instanceof Error + ? error + : new Error("MediaRecorder failed before its media timeline started."), + ); + }; + const handleStart = () => finish(); + const handleFailure = () => + fail(new Error("MediaRecorder failed before its media timeline started.")); + const timeoutId = setTimeout( + () => fail(new Error("MediaRecorder did not start within the expected time.")), + timeoutMs, + ); + recorder.addEventListener("start", handleStart, { once: true }); + recorder.addEventListener("error", handleFailure, { once: true }); + recorder.addEventListener("stop", handleFailure, { once: true }); + + try { + recorder.start(timesliceMs); + } catch (error) { + fail(error); + } + }); +} + export function shouldUseNativeWindowsCaptureForSource( source: Pick | null | undefined, ): boolean { @@ -1768,7 +1815,6 @@ export function useScreenRecorder(): UseScreenRecorderReturn { if (!stream.current || !videoTrack) { throw new Error("Media stream is not available."); } - try { await videoTrack.applyConstraints({ frameRate: { ideal: TARGET_FRAME_RATE, max: TARGET_FRAME_RATE }, @@ -1901,15 +1947,22 @@ export function useScreenRecorder(): UseScreenRecorderReturn { recorder.onerror = () => { setRecording(false); }; - const mainStartedAt = Date.now(); + const mainStartedAt = await startMediaRecorderAtTimelineBoundary( + recorder, + RECORDER_TIMESLICE_MS, + ); beginWebcamCapture(); resetRecordingClock(mainStartedAt); webcamTimeOffsetMs.current = webcamStartTime.current === null ? 0 : webcamStartTime.current - mainStartedAt; - recorder.start(RECORDER_TIMESLICE_MS); setRecording(true); try { - await window.electronAPI?.setRecordingState(true); + const cursorCaptureState = await window.electronAPI?.setRecordingState(true, { + mediaTimelineStartedAtEpochMs: mainStartedAt, + }); + if (cursorCaptureState?.cursorOverlayAvailable) { + hideEditorOverlayCursorByDefault.current = false; + } } catch (stateError) { console.warn("Failed to notify main process that recording started:", stateError); }