diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index c00ef3226..c4d1c8e64 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -560,7 +560,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..0c1031879 --- /dev/null +++ b/electron/ipc/cursor/hyprland.ts @@ -0,0 +1,340 @@ +import fs from "node:fs"; +import net from "node:net"; +import path from "node:path"; +import { readdirSync, readFileSync } from "node:fs"; +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; +// EXPERIMENTO: offset zerado para medir o desalinhamento real entre a +// telemetria do cursor e o início do vídeo (hipótese: a telemetria inicia +// antes da captura, pois o seletor do portal bloqueia o getUserMedia após +// a contagem). Original do upstream: 300. +export const HYPRLAND_CURSOR_MEDIA_OFFSET_MS = 0; +let lastDebugPosLogAt = 0; + +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) => { + const nowMs = Date.now(); + if (nowMs - lastDebugPosLogAt > 500) { + lastDebugPosLogAt = nowMs; + console.log(`[REC-DEBUG] POS ${point.x},${point.y} at ${nowMs}`); + } + setLinuxCursorScreenPoint({ + ...point, + updatedAt: nowMs, + 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(); +} + +// ===== Cursor button events via evdev (our addition on top of #808) ===== +// Position comes from the Hyprland polling above; buttons need raw input +// device access (user must be in the "input" group). +// Non-blocking reads: a blocking read() on an evdev char device parks a +// libuv threadpool thread (only 4 by default) until the mouse moves — with +// several devices open that starves the pool and hangs the recording save. +// O_NONBLOCK makes read() return immediately (EAGAIN) when there is no data. +const EV_KEY = 1; +const BTN_LEFT = 0x110; +const BTN_RIGHT = 0x111; +const BTN_MIDDLE = 0x112; +const INPUT_EVENT_SIZE = 24; +const EVDEV_POLL_INTERVAL_MS = 20; + +export function hasMouseButtonCapability(keyCapabilities: string): boolean { + const words = keyCapabilities.trim().split(/\s+/); + const word = words[words.length - 1 - Math.floor(BTN_LEFT / 64)]; + if (!word) { + return false; + } + return ((Number.parseInt(word, 16) >>> (BTN_LEFT % 64)) & 1) === 1; +} + +export type EvdevButtonEvent = { button: 1 | 2 | 3; pressed: boolean }; + +export function parseEvdevButtonEvents(buffer: Buffer): EvdevButtonEvent[] { + const events: EvdevButtonEvent[] = []; + for (let offset = 0; offset + INPUT_EVENT_SIZE <= buffer.length; offset += INPUT_EVENT_SIZE) { + const type = buffer.readUInt16LE(offset + 16); + const code = buffer.readUInt16LE(offset + 18); + const value = buffer.readInt32LE(offset + 20); + if (type !== EV_KEY || value > 1) { + continue; + } + const button = + code === BTN_LEFT ? 1 : code === BTN_RIGHT ? 2 : code === BTN_MIDDLE ? 3 : null; + if (button) { + events.push({ button, pressed: value === 1 }); + } + } + return events; +} + +function listMouseEventDevices(): string[] { + try { + return readdirSync("/sys/class/input") + .filter((name) => name.startsWith("event")) + .filter((name) => { + try { + const capabilities = readFileSync( + `/sys/class/input/${name}/device/capabilities/key`, + "utf-8", + ); + return hasMouseButtonCapability(capabilities); + } catch { + return false; + } + }) + .map((name) => `/dev/input/${name}`); + } catch { + return []; + } +} + +export function startEvdevButtonCapture(handlers: { + onMouseDown: (button: 1 | 2 | 3) => void; + onMouseUp: () => void; +}): () => void { + // Only Hyprland/Wayland sessions need raw evdev buttons: on X11 the uiohook + // already captures clicks, and double-counting them corrupts the telemetry. + if (process.platform !== "linux" || !getHyprlandRequestSocketPath(process.env)) { + return () => { + console.log("[REC-DEBUG] evdev capture skipped (no Hyprland session)"); + }; + } + const stoppers = listMouseEventDevices().map((devicePath) => { + let fd: number | null = null; + let timer: NodeJS.Timeout | null = null; + let stopped = false; + const buffer = Buffer.alloc(256); + const stop = () => { + stopped = true; + if (timer) { + clearInterval(timer); + timer = null; + } + if (fd !== null) { + const fdToClose = fd; + fd = null; + fs.close(fdToClose, () => { + console.log(`[REC-DEBUG] evdev closed: ${devicePath}`); + }); + } + }; + fs.open(devicePath, fs.constants.O_RDONLY | fs.constants.O_NONBLOCK, (openError, openedFd) => { + if (openError || openedFd === undefined) { + console.log("[REC-DEBUG] evdev open FAILED:", devicePath, openError?.message); + stop(); + return; + } + if (stopped) { + // stop() ran while fs.open was in flight — close the descriptor + // immediately instead of leaking it. + fs.closeSync(openedFd); + console.log("[REC-DEBUG] evdev closed (stop before open):", devicePath); + return; + } + fd = openedFd; + console.log("[REC-DEBUG] evdev fd opened:", devicePath); + timer = setInterval(() => { + if (stopped || fd === null) { + clearInterval(timer ?? undefined); + return; + } + fs.read(fd, buffer, 0, buffer.length, null, (readError, bytesRead) => { + if (stopped || readError || bytesRead <= 0) { + return; + } + for (const event of parseEvdevButtonEvents(buffer.subarray(0, bytesRead))) { + if (event.pressed) { + handlers.onMouseDown(event.button); + } else { + handlers.onMouseUp(); + } + } + }); + }, EVDEV_POLL_INTERVAL_MS); + }); + return stop; + }); + + return () => { + for (const stop of stoppers) { + stop(); + } + }; +} diff --git a/electron/ipc/cursor/interaction.ts b/electron/ipc/cursor/interaction.ts index 47c42437f..c3aac3fa7 100644 --- a/electron/ipc/cursor/interaction.ts +++ b/electron/ipc/cursor/interaction.ts @@ -17,6 +17,10 @@ import type { UiohookLike, UiohookModuleNamespace, } from "../types"; +import { + isHyprlandCursorProviderActive, + startEvdevButtonCapture, +} from "./hyprland"; import { getCursorCaptureElapsedMs, getHookCursorScreenPoint, @@ -249,6 +253,24 @@ export async function startInteractionCapture() { stopInteractionCapture(); + const onMouseDown = (event: HookMouseEvent) => { + recordCursorMouseDown(getHookMouseButton(event)); + }; + + const onMouseUp = () => { + recordCursorMouseUp(); + }; + + // Raw evdev clicks (Wayland: the uiohook never sees them) — must start + // independently of the uiohook, which can fail to load on Wayland. + const stopEvdevCapture = startEvdevButtonCapture({ + onMouseDown: (button) => onMouseDown({ button } as unknown as HookMouseEvent), + onMouseUp: () => onMouseUp(), + }); + setInteractionCaptureCleanup(() => { + stopEvdevCapture(); + }); + try { const hook = loadUiohookModule(); console.log( @@ -260,24 +282,23 @@ export async function startInteractionCapture() { typeof hook?.start, ); if (!isCursorCaptureActive) { + stopEvdevCapture(); return; } if (!hook || typeof hook.on !== "function" || typeof hook.start !== "function") { console.log("[CursorTelemetry] hook unusable — aborting interaction capture"); + stopEvdevCapture(); return; } - const onMouseDown = (event: HookMouseEvent) => { - recordCursorMouseDown(getHookMouseButton(event)); - }; - - const onMouseUp = () => { - recordCursorMouseUp(); - }; - const onMouseMove = (event: HookMouseEvent) => { - if (process.platform !== "linux" || !isCursorCaptureActive || isCursorCapturePaused()) { + if ( + process.platform !== "linux" || + isHyprlandCursorProviderActive() || + !isCursorCaptureActive || + isCursorCapturePaused() + ) { return; } @@ -286,7 +307,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); @@ -296,6 +323,7 @@ export async function startInteractionCapture() { } setInteractionCaptureCleanup(() => { + stopEvdevCapture(); try { if (typeof hook.off === "function") { hook.off("mousedown", onMouseDown); @@ -325,6 +353,7 @@ export async function startInteractionCapture() { hook.start(); } catch (error) { + stopEvdevCapture(); if (!hasLoggedInteractionHookFailure) { setHasLoggedInteractionHookFailure(true); console.warn("[CursorTelemetry] Global interaction capture unavailable:", error); diff --git a/electron/ipc/cursor/telemetry.ts b/electron/ipc/cursor/telemetry.ts index 73f62714e..493cf0d96 100644 --- a/electron/ipc/cursor/telemetry.ts +++ b/electron/ipc/cursor/telemetry.ts @@ -172,8 +172,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 438b6eeee..3e2b750fc 100644 --- a/electron/ipc/register/recording.ts +++ b/electron/ipc/register/recording.ts @@ -20,6 +20,11 @@ import { } from "../../windows"; 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 { @@ -401,6 +406,8 @@ async function resolveExistingPath(...candidates: Array void, ) { + let cursorCaptureGeneration = 0; + ipcMain.handle( "start-native-screen-recording", async (_, source: SelectedSource, options?: NativeMacRecordingOptions) => { @@ -1860,7 +1867,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(); @@ -1869,10 +1878,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(); @@ -1880,6 +1907,7 @@ export function registerRecordingHandlers( setIsCursorCaptureActive(false); stopCursorCapture(); stopInteractionCapture(); + stopHyprlandCursorProvider(); stopWindowBoundsCapture(); stopNativeCursorMonitor(); showCursor(); @@ -1902,6 +1930,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 8b13501a2..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({ 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 a30372314..0cddd3129 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); @@ -916,9 +919,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 1ddceb4e1..6ce6996a8 100644 --- a/src/hooks/useScreenRecorder.test.ts +++ b/src/hooks/useScreenRecorder.test.ts @@ -6,6 +6,7 @@ import { normalizeBrowserMicrophoneProfile, resolveBrowserCaptureCursorPolicy, shouldUseNativeWindowsCaptureForSource, + startMediaRecorderAtTimelineBoundary, stopAndDiscardNativeCapture, } from "./useScreenRecorder"; @@ -160,6 +161,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 459652415..ea5c8d871 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 { @@ -1692,7 +1739,11 @@ export function useScreenRecorder(): UseScreenRecorderReturn { preparedStart; const useNativeCapture = useNativeMacScreenCapture || useNativeWindowsCapture; const shouldWarmStartNativeCapture = useNativeCapture && countdownDelay > 0; - if (countdownDelay > 0 && !shouldWarmStartNativeCapture) { + if ( + countdownDelay > 0 && + !shouldWarmStartNativeCapture && + selectedSource.id !== "screen:linux-portal" + ) { setCountdownActive(true); try { const result = await window.electronAPI.startCountdown(countdownDelay); @@ -2108,7 +2159,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 }, @@ -2140,6 +2190,25 @@ export function useScreenRecorder(): UseScreenRecorderReturn { )} Mbps`, ); + // Linux portal: the screen picker (and its permission token) runs + // BEFORE the countdown, so the recording starts immediately after + // it — no frozen lead-in frames and no telemetry/video drift. + if (countdownDelay > 0) { + setCountdownActive(true); + try { + const result = await window.electronAPI.startCountdown(countdownDelay); + if (!result.success || result.cancelled || startWasCancelled()) { + cleanupCapturedMedia(); + await stopWebcamRecorder(); + return; + } + } finally { + setCountdownActive(false); + } + recordingSessionTimestamp.current = Date.now(); + resetRecordingClock(recordingSessionTimestamp.current); + } + chunks.current = []; const hasAudio = stream.current.getAudioTracks().length > 0; const audioBitsPerSecond = hasAudio @@ -2241,15 +2310,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); }