diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index c98cad95a..955f70ee6 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -608,7 +608,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[]; @@ -952,7 +955,6 @@ interface Window { getPlatform: () => Promise; isWindowFullscreen: () => Promise; onWindowFullscreenChanged: (callback: (isFullscreen: boolean) => void) => () => void; - getLinuxWindowSystem: () => Promise<"wayland" | "x11" | null>; ackAuthCallbackUrl: (url: string) => Promise; getPendingAuthCallbackUrl: () => Promise; onAuthCallbackUrl: (callback: (url: string) => void) => () => void; 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..17cfda590 --- /dev/null +++ b/electron/ipc/cursor/hyprland.test.ts @@ -0,0 +1,310 @@ +import type fs from "node:fs"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("electron", () => ({ + app: { + getPath: vi.fn(() => "/tmp"), + }, +})); + +import { activeCursorSamples, linuxCursorScreenPoint, setActiveCursorSamples } from "../state"; +import { + decodeEvdevButtonChunk, + getHyprlandRequestSocketPath, + hasMouseButtonCapability, + isHyprlandCursorProviderActive, + parseHyprlandCursorPosition, + resolveHyprlandCursorCaptureEpochMs, + startEvdevButtonCapture, + 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("starts cursor telemetry at the media timeline boundary", () => { + expect(resolveHyprlandCursorCaptureEpochMs(10_000)).toBe(10_000); + }); + + 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); + }); +}); + +describe("Hyprland mouse buttons", () => { + it("recognizes a left button bit in a 64-bit capability word", () => { + expect(hasMouseButtonCapability("10000 0 0 0 0")).toBe(true); + expect(hasMouseButtonCapability("ffffffffffffffff 0 0 0 0")).toBe(true); + expect(hasMouseButtonCapability("0 0 0 0 0")).toBe(false); + }); + + it("keeps partial input events until the next device read", () => { + const press = Buffer.alloc(24); + press.writeUInt16LE(1, 16); + press.writeUInt16LE(0x110, 18); + press.writeInt32LE(1, 20); + const release = Buffer.from(press); + release.writeInt32LE(0, 20); + + const first = decodeEvdevButtonChunk(Buffer.alloc(0), press.subarray(0, 11)); + expect(first.events).toEqual([]); + const second = decodeEvdevButtonChunk( + first.pending, + Buffer.concat([press.subarray(11), release]), + ); + expect(second.events).toEqual([ + { button: 1, pressed: true }, + { button: 1, pressed: false }, + ]); + expect(second.pending).toHaveLength(0); + }); + + it("does not overlap reads when a device callback is delayed", async () => { + vi.useFakeTimers(); + const onMouseDown = vi.fn(); + const onMouseUp = vi.fn(); + const read = vi.fn(); + const close = vi.fn((_fd, callback) => callback(null)); + const fsApi = { + open: vi.fn((_path, _flags, callback) => callback(null, 42)), + read, + close, + } as unknown as typeof fs; + const stop = startEvdevButtonCapture( + { onMouseDown, onMouseUp }, + { + devicePaths: ["/dev/input/event-test"], + fsApi, + platform: "linux", + env: waylandEnv, + pollIntervalMs: 10, + }, + ); + try { + await vi.advanceTimersByTimeAsync(35); + expect(read).toHaveBeenCalledTimes(1); + const buffer = read.mock.calls[0][1] as Buffer; + buffer.writeUInt16LE(1, 16); + buffer.writeUInt16LE(0x110, 18); + buffer.writeInt32LE(1, 20); + read.mock.calls[0][5](null, 24, buffer); + expect(onMouseDown).toHaveBeenCalledWith(1); + await vi.advanceTimersByTimeAsync(10); + expect(read).toHaveBeenCalledTimes(2); + } finally { + stop(); + vi.useRealTimers(); + } + expect(close).toHaveBeenCalledWith(42, expect.any(Function)); + }); + + it("drains a full evdev buffer before the next polling interval", async () => { + vi.useFakeTimers(); + const onMouseDown = vi.fn(); + const onMouseUp = vi.fn(); + const onDeviceOpened = vi.fn(); + const read = vi.fn((_fd, buffer: Buffer, _offset, length, _position, callback) => { + if (read.mock.calls.length === 1) { + expect(length).toBeGreaterThan(256); + buffer.fill(0); + buffer.writeUInt16LE(1, 16); + buffer.writeUInt16LE(0x110, 18); + buffer.writeInt32LE(1, 20); + callback(null, length, buffer); + } else { + buffer.fill(0); + buffer.writeUInt16LE(1, 16); + buffer.writeUInt16LE(0x110, 18); + callback(null, 24, buffer); + } + }); + const fsApi = { + open: vi.fn((_path, _flags, callback) => callback(null, 42)), + read, + close: vi.fn((_fd, callback) => callback(null)), + } as unknown as typeof fs; + const stop = startEvdevButtonCapture( + { onMouseDown, onMouseUp }, + { + devicePaths: ["/dev/input/event-test"], + fsApi, + platform: "linux", + env: waylandEnv, + pollIntervalMs: 10, + onDeviceOpened, + }, + ); + try { + await vi.advanceTimersByTimeAsync(10); + expect(onDeviceOpened).toHaveBeenCalledOnce(); + expect(read).toHaveBeenCalledTimes(2); + expect(onMouseDown).toHaveBeenCalledOnce(); + expect(onMouseUp).toHaveBeenCalledOnce(); + } finally { + stop(); + vi.useRealTimers(); + } + }); +}); diff --git a/electron/ipc/cursor/hyprland.ts b/electron/ipc/cursor/hyprland.ts new file mode 100644 index 000000000..a933989cd --- /dev/null +++ b/electron/ipc/cursor/hyprland.ts @@ -0,0 +1,383 @@ +import fs, { readdirSync, readFileSync } from "node:fs"; +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; +// The portal selection finishes before recording starts, so there is no +// additional cursor-to-video offset to apply. +export const HYPRLAND_CURSOR_MEDIA_OFFSET_MS = 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(); + 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 (for example through a udev uaccess rule). +// 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; + } + try { + return ((BigInt(`0x${word}`) >> BigInt(BTN_LEFT % 64)) & 1n) === 1n; + } catch { + return false; + } +} + +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; +} + +export function decodeEvdevButtonChunk(pending: Buffer, chunk: Buffer) { + const data = Buffer.concat([pending, chunk]); + const completeLength = Math.floor(data.length / INPUT_EVENT_SIZE) * INPUT_EVENT_SIZE; + return { + events: parseEvdevButtonEvents(data.subarray(0, completeLength)), + pending: Buffer.from(data.subarray(completeLength)), + }; +} + +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; + }, + options?: { + devicePaths?: string[]; + fsApi?: typeof fs; + platform?: NodeJS.Platform; + env?: NodeJS.ProcessEnv; + pollIntervalMs?: number; + onDeviceOpened?: () => void; + }, +): () => void { + // Only Hyprland/Wayland sessions need raw evdev buttons: on X11 the uiohook + // already captures clicks, and double-counting them corrupts the telemetry. + const platform = options?.platform ?? process.platform; + if ( + platform !== "linux" || + !getHyprlandRequestSocketPath(options?.env ?? process.env, platform) + ) { + return () => undefined; + } + const fsApi = options?.fsApi ?? fs; + const stoppers = (options?.devicePaths ?? listMouseEventDevices()).map((devicePath) => { + let fd: number | null = null; + let timer: NodeJS.Timeout | null = null; + let stopped = false; + let readInFlight = false; + let pendingBytes = Buffer.alloc(0); + const buffer = Buffer.alloc(INPUT_EVENT_SIZE * 64); + const stop = () => { + stopped = true; + if (timer) { + clearInterval(timer); + timer = null; + } + if (fd !== null) { + const fdToClose = fd; + fd = null; + fsApi.close(fdToClose, () => undefined); + } + }; + fsApi.open( + devicePath, + fs.constants.O_RDONLY | fs.constants.O_NONBLOCK, + (openError, openedFd) => { + if (openError || openedFd === undefined) { + if (openError?.code === "EACCES") { + console.warn( + "[CursorTelemetry] Mouse click capture needs access to /dev/input.", + ); + } + stop(); + return; + } + if (stopped) { + // stop() ran while fs.open was in flight — close the descriptor + // immediately instead of leaking it. + fsApi.close(openedFd, () => undefined); + return; + } + fd = openedFd; + options?.onDeviceOpened?.(); + const readAvailable = () => { + if (stopped || fd === null) { + readInFlight = false; + return; + } + fsApi.read(fd, buffer, 0, buffer.length, null, (readError, bytesRead) => { + if (stopped || readError || bytesRead <= 0) { + readInFlight = false; + return; + } + const decoded = decodeEvdevButtonChunk( + pendingBytes, + buffer.subarray(0, bytesRead), + ); + pendingBytes = decoded.pending; + for (const event of decoded.events) { + if (event.pressed) { + handlers.onMouseDown(event.button); + } else { + handlers.onMouseUp(); + } + } + if (bytesRead === buffer.length) { + readAvailable(); + } else { + readInFlight = false; + } + }); + }; + timer = setInterval(() => { + if (stopped || fd === null || readInFlight) { + return; + } + readInFlight = true; + readAvailable(); + }, options?.pollIntervalMs ?? EVDEV_POLL_INTERVAL_MS); + }, + ); + return stop; + }); + + return () => { + for (const stop of stoppers) { + stop(); + } + }; +} diff --git a/electron/ipc/cursor/hyprlandBindings.lua.test.ts b/electron/ipc/cursor/hyprlandBindings.lua.test.ts new file mode 100644 index 000000000..f5bdf4008 --- /dev/null +++ b/electron/ipc/cursor/hyprlandBindings.lua.test.ts @@ -0,0 +1,204 @@ +import { spawnSync } from "node:child_process"; +import { describe, expect, it, vi } from "vitest"; + +vi.mock("electron", () => ({ app: { getPath: vi.fn(() => "/tmp") } })); + +import { buildHyprlandButtonCommand } from "./hyprlandButtons"; + +const lua = spawnSync("lua", ["-v"], { encoding: "utf8" }); +const fixture = ` +local now, binds, timers, events = 0, {}, {}, {} +local failBind, failTimer +hl = {dsp = {}} +hl.version = function() return "0.56.0" end +hl.dsp.event = function(event) return function() table.insert(events, event) end end +hl.dispatch = function(dispatcher) dispatcher() end +hl.unbind = function() error("unsafe broad unbind") end +hl.bind = function(key, callback, flags) + if failBind and #binds == failBind then error("bind failure") end + local bind = {key = key, callback = callback, flags = flags, enabled = true} + function bind:set_enabled(enabled) self.enabled = enabled end + function bind:remove() error("unsafe matching-key removal") end + table.insert(binds, bind) + return bind +end +hl.timer = function(callback, options) + if failTimer then error("timer failure") end + assert(options.type == "repeat") + local timer = {callback = callback, timeout = options.timeout, deadline = now + options.timeout} + function timer:set_enabled(enabled) + self.deadline = enabled and now + self.timeout or nil + end + function timer:set_timeout(timeout) + self.timeout = timeout + self.deadline = now + timeout + end + table.insert(timers, timer) + return timer +end +local function advance(ms) + now = now + ms + for _, timer in ipairs(timers) do + if timer.deadline and timer.deadline <= now then + timer.deadline = now + timer.timeout + timer.callback() + end + end +end +local function click(key, release) + for _, bind in ipairs(binds) do + if bind.enabled and bind.key == key and (bind.flags.release or false) == release then + bind.callback() + end + end +end +local function captureDisabled() + local state = rawget(_G, "__recordly_button_capture_v1") + assert(state.token == nil) + for _, bind in pairs(state.binds) do assert(not bind.enabled) end + assert(not state.timer or state.timer.deadline == nil) +end +`; + +function command(action: "start" | "renew" | "stop", token = "recordly-test") { + return buildHyprlandButtonCommand(action, token).slice("eval ".length); +} + +function runLua(script: string) { + const result = spawnSync("lua", ["-"], { + input: `${fixture}\n${script}`, + encoding: "utf8", + timeout: 5000, + }); + expect(result.error).toBeUndefined(); + expect(result.stderr).toBe(""); + expect(result.status).toBe(0); +} + +describe.skipIf(lua.error || lua.status !== 0)("Hyprland capture Lua lifecycle", () => { + it("preserves user bindings and reuses exactly six handles across recordings", () => { + runLua(` +local userClicks = 0 +local userBind = hl.bind("mouse:272", function() userClicks = userClicks + 1 end, {}) +${command("start")} +assert(#binds == 7 and #timers == 1) +for index = 2, 7 do + local flags = binds[index].flags + assert(flags.non_consuming and flags.ignore_mods and flags.transparent and flags.submap_universal) +end +click("mouse:272", false) +click("mouse:272", true) +assert(userClicks == 1) +assert(events[1] == "recordly-test-down-1" and events[2] == "recordly-test-up-1") +${command("stop")} +captureDisabled() +assert(userBind.enabled) +click("mouse:272", false) +assert(userClicks == 2 and #events == 2) +${command("start", "recordly-second")} +assert(#binds == 7 and #timers == 1) +click("mouse:274", false) +assert(events[3] == "recordly-second-down-3") +${command("stop", "recordly-test")} +assert(__recordly_button_capture_v1.token == "recordly-second") +${command("stop", "recordly-second")} +captureDisabled() +`); + }); + + it("expires the compositor lease after a crash and renews from the last heartbeat", () => { + runLua(` +${command("start")} +advance(4000) +${command("renew")} +advance(4000) +click("mouse:273", false) +assert(events[1] == "recordly-test-down-2") +advance(1000) +captureDisabled() +click("mouse:273", false) +assert(#events == 1) +${command("start", "recordly-restarted")} +assert(#binds == 6 and #timers == 1) +advance(5000) +captureDisabled() +`); + }); + + it("handles an actual reload and a failed reload without duplicate binds", () => { + runLua(` +${command("start")} +${command("start")} +assert(#binds == 6 and #timers == 1) +binds, timers = {}, {} +rawset(_G, "__recordly_button_capture_v1", nil) +${command("start")} +assert(#binds == 6 and #timers == 1) +click("mouse:272", false) +assert(#events == 1) +${command("stop")} +captureDisabled() +`); + }); + + it("rejects another owner and foreign registries without changing their state", () => { + runLua(` +${command("start")} +local ok = pcall(function() ${command("start", "recordly-other")} end) +assert(not ok and __recordly_button_capture_v1.token == "recordly-test") +assert(#binds == 6) +rawset(_G, "__recordly_button_capture_v1", {schema = "foreign", token = "keep"}) +ok = pcall(function() ${command("start")} end) +assert(not ok and __recordly_button_capture_v1.token == "keep") +assert(#binds == 6) +`); + }); + + it("disables partial registrations and retries without growing the handle pool", () => { + runLua(` +failBind = 3 +local ok = pcall(function() ${command("start")} end) +assert(not ok and #binds == 3) +captureDisabled() +failBind = nil +${command("start")} +assert(#binds == 6 and #timers == 1) +${command("stop")} +captureDisabled() +`); + }); + + it("rejects absent capabilities before installing binds and recovers timer failures", () => { + runLua(` +local timerFn = hl.timer +hl.timer = nil +local ok = pcall(function() ${command("start")} end) +assert(not ok and #binds == 0 and __recordly_button_capture_v1 == nil) +hl.timer = timerFn +failTimer = true +ok = pcall(function() ${command("start")} end) +assert(not ok and #binds == 0) +captureDisabled() +failTimer = false +${command("start")} +assert(#binds == 6 and #timers == 1) +${command("stop")} +captureDisabled() +`); + }); + + it.each([ + "0.54.3", + "0.4.0", + "unknown", + "", + "broken.55.0", + ])("rejects unsupported version %s before creating bindings", (version) => { + runLua(` +hl.version = function() return ${JSON.stringify(version)} end +local ok = pcall(function() ${command("start")} end) +assert(not ok and #binds == 0 and #timers == 0) +assert(__recordly_button_capture_v1 == nil) +`); + }); +}); diff --git a/electron/ipc/cursor/hyprlandButtons.test.ts b/electron/ipc/cursor/hyprlandButtons.test.ts new file mode 100644 index 000000000..63b23bd00 --- /dev/null +++ b/electron/ipc/cursor/hyprlandButtons.test.ts @@ -0,0 +1,306 @@ +import { EventEmitter } from "node:events"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const connection = vi.hoisted(() => ({ create: vi.fn() })); +vi.mock("node:net", () => ({ default: { createConnection: connection.create } })); +vi.mock("electron", () => ({ app: { getPath: () => "/tmp" } })); + +import { + buildHyprlandButtonCommand, + requestHyprlandCommand, + startHyprlandButtonCapture, +} from "./hyprlandButtons"; + +class Socket extends EventEmitter { + destroyed = false; + setEncoding = vi.fn(); + setTimeout = vi.fn(); + end = vi.fn(); + destroy() { + if (this.destroyed) return; + this.destroyed = true; + this.emit("close"); + } +} + +const env = { + XDG_RUNTIME_DIR: "/tmp/recordly-fixture", + XDG_SESSION_TYPE: "wayland", + HYPRLAND_INSTANCE_SIGNATURE: "synthetic", +}; + +async function flush() { + for (let i = 0; i < 20; i++) await Promise.resolve(); +} + +describe("Hyprland button capture", () => { + let socket: Socket; + let request: ReturnType; + let handlers: { onMouseDown: ReturnType; onMouseUp: ReturnType }; + const stops: (() => void)[] = []; + const options = () => ({ env, platform: "linux" as const, request }); + const token = () => /s.token = "(recordly-[a-z0-9-]+)"/.exec(request.mock.calls[0][1])?.[1]; + const start = async () => { + const capture = await startHyprlandButtonCapture(handlers, options()); + stops.push(capture.stop); + return capture; + }; + beforeEach(() => { + vi.useFakeTimers(); + connection.create.mockReset(); + socket = new Socket(); + request = vi.fn().mockResolvedValue(true); + handlers = { onMouseDown: vi.fn(), onMouseUp: vi.fn() }; + connection.create.mockImplementation(() => { + Promise.resolve().then(() => socket.emit("connect")); + return socket; + }); + }); + afterEach(async () => { + for (const stop of stops.splice(0)) stop(); + await flush(); + vi.useRealTimers(); + }); + + it("subscribes before registration and decodes only this recording's fragmented events", async () => { + expect((await start()).available).toBe(true); + expect(connection.create).toHaveBeenCalledWith( + "/tmp/recordly-fixture/hypr/synthetic/.socket2.sock", + ); + socket.emit("data", `custom>>foreign-down-1\ncustom>>${token()}-do`); + expect(handlers.onMouseDown).not.toHaveBeenCalled(); + socket.emit( + "data", + `wn-1\ncustom>>${token()}-up-1\ncustom>>${token()}-down-2\ncustom>>${token()}-down-3\ncustom>>${token()}-down-4\n`, + ); + expect(handlers.onMouseDown.mock.calls).toEqual([[1], [2], [3]]); + expect(handlers.onMouseUp).toHaveBeenCalledOnce(); + }); + + it("separates the eval verb from Lua with a space for Hyprland IPC", () => { + for (const action of ["start", "renew", "stop"] as const) { + expect(buildHyprlandButtonCommand(action, "recordly-fixture")).toMatch(/^eval /); + } + expect(() => buildHyprlandButtonCommand("start", 'unsafe";hl.unbind()')).toThrow(); + }); + + it("does not emit during startup, and cleans up once on stop", async () => { + let registered!: (ok: boolean) => void; + request.mockImplementationOnce( + () => + new Promise((resolve) => { + registered = resolve; + }), + ); + const starting = start(); + await flush(); + socket.emit("data", `custom>>${token()}-down-1\n`); + expect(handlers.onMouseDown).not.toHaveBeenCalled(); + registered(true); + const capture = await starting; + capture.stop(); + capture.stop(); + socket.emit("data", `custom>>${token()}-down-1\n`); + await flush(); + expect(handlers.onMouseDown).not.toHaveBeenCalled(); + expect(request).toHaveBeenCalledTimes(2); + expect(request.mock.calls[1][1]).toContain("s.stop()"); + expect(socket.destroyed).toBe(true); + }); + + it("aborts pending registration and disables its binds after the response", async () => { + let registered!: (ok: boolean) => void; + request.mockImplementationOnce( + () => + new Promise((resolve) => { + registered = resolve; + }), + ); + const controller = new AbortController(); + const starting = startHyprlandButtonCapture(handlers, { + ...options(), + signal: controller.signal, + }); + await flush(); + controller.abort(); + registered(true); + expect((await starting).available).toBe(false); + await flush(); + expect(request).toHaveBeenCalledTimes(2); + expect(socket.destroyed).toBe(true); + }); + + it("serializes old cleanup before restart and gives the new capture a distinct token", async () => { + const first = await start(); + const oldToken = token(); + first.stop(); + socket = new Socket(); + expect((await start()).available).toBe(true); + expect(request.mock.calls[1][1]).toContain(`s.token == "${oldToken}" then s.stop()`); + const newToken = /s.token = "(recordly-[a-z0-9-]+)"/.exec(request.mock.calls[2][1])?.[1]; + expect(newToken).not.toBe(oldToken); + socket.emit("data", `custom>>${oldToken}-down-1\ncustom>>${newToken}-down-1\n`); + expect(handlers.onMouseDown).toHaveBeenCalledOnce(); + }); + + it("renews the lease serially and falls back once on failure", async () => { + const onUnavailable = vi.fn(); + const capture = await startHyprlandButtonCapture(handlers, { ...options(), onUnavailable }); + stops.push(capture.stop); + request.mockResolvedValueOnce(false); + await vi.advanceTimersByTimeAsync(1000); + expect(request.mock.calls[1][1]).toContain("capture lease lost"); + expect(onUnavailable).toHaveBeenCalledOnce(); + socket.emit("error", new Error("closed")); + expect(onUnavailable).toHaveBeenCalledOnce(); + expect(vi.getTimerCount()).toBe(0); + }); + + it("reinstalls after reload while suppressing events during registration", async () => { + await start(); + let registered!: (ok: boolean) => void; + request.mockImplementationOnce( + () => + new Promise((resolve) => { + registered = resolve; + }), + ); + socket.emit("data", `configreloaded>>\ncustom>>${token()}-down-1\n`); + await flush(); + expect(handlers.onMouseDown).not.toHaveBeenCalled(); + registered(true); + await flush(); + expect(request.mock.calls[1][1]).toContain('schema = "recordly-1"'); + socket.emit("data", `custom>>${token()}-down-1\n`); + expect(handlers.onMouseDown).toHaveBeenCalledOnce(); + }); + + it("ignores a stale failed renewal when reload is already reinstalling", async () => { + const onUnavailable = vi.fn(); + const capture = await startHyprlandButtonCapture(handlers, { ...options(), onUnavailable }); + stops.push(capture.stop); + let renewed!: (ok: boolean) => void; + request.mockImplementationOnce( + () => + new Promise((resolve) => { + renewed = resolve; + }), + ); + await vi.advanceTimersByTimeAsync(1000); + socket.emit("data", "configreloaded>>\n"); + renewed(false); + await flush(); + expect(onUnavailable).not.toHaveBeenCalled(); + expect(socket.destroyed).toBe(false); + socket.emit("data", `custom>>${token()}-down-1\n`); + expect(handlers.onMouseDown).toHaveBeenCalledOnce(); + }); + + it("suppresses subsequent lines when a handler stops capture within a chunk", async () => { + const capture = await start(); + handlers.onMouseDown.mockImplementationOnce(capture.stop); + socket.emit("data", `custom>>${token()}-down-1\ncustom>>${token()}-down-2\n`); + expect(handlers.onMouseDown).toHaveBeenCalledOnce(); + }); + + it("cancels queued reloads after stop without extending the compositor lease", async () => { + const capture = await start(); + let registered!: (ok: boolean) => void; + request.mockImplementationOnce( + () => + new Promise((resolve) => { + registered = resolve; + }), + ); + socket.emit("data", "configreloaded>>\n"); + await flush(); + socket.emit("data", "configreloaded>>\nconfigreloaded>>\nconfigreloaded>>\n"); + capture.stop(); + registered(true); + await flush(); + expect(request).toHaveBeenCalledTimes(3); + expect(request.mock.calls[2][1]).toContain("s.stop()"); + expect(socket.destroyed).toBe(true); + expect(vi.getTimerCount()).toBe(0); + }); + + it("rejects unavailable capabilities without attempting legacy binds", async () => { + request.mockResolvedValue(false); + expect((await start()).available).toBe(false); + expect(request.mock.calls.every(([, command]) => !command.includes("keyword"))).toBe(true); + expect(socket.destroyed).toBe(true); + }); + + it.each([ + false, + true, + ])("discards oversized foreign lines without losing capture (terminated=%s)", async (terminated) => { + const onUnavailable = vi.fn(); + const capture = await startHyprlandButtonCapture(handlers, { ...options(), onUnavailable }); + stops.push(capture.stop); + socket.emit("data", "activewindow>>" + "x".repeat(4097) + (terminated ? "\n" : "")); + if (!terminated) { + socket.emit("data", `custom>>${token()}-down-2`); + socket.emit("data", "\n"); + } + socket.emit("data", `custom>>${token()}-down-1\n`); + expect(socket.destroyed).toBe(false); + expect(onUnavailable).not.toHaveBeenCalled(); + expect(handlers.onMouseDown.mock.calls).toEqual([[1]]); + }); + + it("bounds UTF-8 byte length and discards through the delimiter before decoding later events", async () => { + await start(); + socket.emit("data", "windowtitlev2>>" + "ü".repeat(2048)); + socket.emit( + "data", + "ignored fragment\n" + "x".repeat(5000) + `\ncustom>>${token()}-down-3\n`, + ); + expect(socket.destroyed).toBe(false); + expect(handlers.onMouseDown.mock.calls).toEqual([[3]]); + }); + + it("times out event connection without registering binds", async () => { + connection.create.mockReturnValue(socket); + const starting = start(); + await vi.advanceTimersByTimeAsync(250); + expect((await starting).available).toBe(false); + await flush(); + expect(request.mock.calls.every(([, command]) => !command.includes("hl.bind"))).toBe(true); + }); + + it("does not touch X11, non-Linux, unsafe signatures or already aborted captures", async () => { + for (const extra of [ + { platform: "darwin" as const }, + { env: { ...env, XDG_SESSION_TYPE: "x11" } }, + { env: { ...env, HYPRLAND_INSTANCE_SIGNATURE: "../unsafe" } }, + { signal: AbortSignal.abort() }, + ]) { + expect( + (await startHyprlandButtonCapture(handlers, { ...options(), ...extra })).available, + ).toBe(false); + } + expect(connection.create).not.toHaveBeenCalled(); + }); + + it("accepts only a complete successful IPC response and bounds failures", async () => { + const result = requestHyprlandCommand("/tmp/synthetic", "eval test"); + await flush(); + expect(socket.end).toHaveBeenCalledWith("eval test"); + socket.emit("data", "o"); + socket.emit("data", "k\n"); + socket.emit("end"); + expect(await result).toBe(true); + for (const response of ["error: unsupported", "ok\nerror", "x".repeat(4097)]) { + socket = new Socket(); + const failed = requestHyprlandCommand("/tmp/synthetic", "eval test"); + socket.emit("data", response); + socket.emit("end"); + expect(await failed).toBe(false); + } + socket = new Socket(); + const timedOut = requestHyprlandCommand("/tmp/synthetic", "eval test"); + socket.setTimeout.mock.calls[0][1](); + expect(await timedOut).toBe(false); + }); +}); diff --git a/electron/ipc/cursor/hyprlandButtons.ts b/electron/ipc/cursor/hyprlandButtons.ts new file mode 100644 index 000000000..c0e4681d2 --- /dev/null +++ b/electron/ipc/cursor/hyprlandButtons.ts @@ -0,0 +1,222 @@ +import { randomUUID } from "node:crypto"; +import net from "node:net"; +import path from "node:path"; +import { getHyprlandRequestSocketPath } from "./hyprland"; + +const REQUEST_TIMEOUT_MS = 250; +const MAX_RESPONSE_BYTES = 4096; +const HEARTBEAT_MS = 1000; +const REGISTRY = "__recordly_button_capture_v1"; +const requestQueues = new Map>(); + +type ButtonHandlers = { + onMouseDown: (button: 1 | 2 | 3) => void; + onMouseUp: () => void; +}; + +// Hyprland 0.55/0.56 remove() also removes the user's matching binds. +// Keep six disabled handles for reuse until reload; a compositor timer expires +// the lease even if Recordly crashes or cannot send its cleanup request. +export function buildHyprlandButtonCommand(action: "start" | "renew" | "stop", token: string) { + if (!/^[a-zA-Z0-9-]+$/.test(token)) throw new Error("Invalid capture token"); + const state = `local s = rawget(_G, "${REGISTRY}")`; + if (action === "stop") { + return `eval ${state}; if s and s.token == "${token}" then s.stop() end`; + } + if (action === "renew") { + return `eval ${state}; assert(s and s.token == "${token}", "capture lease lost"); s.timer:set_timeout(5000)`; + } + return `eval assert(type(hl) == "table" and type(hl.version) == "function" and type(hl.bind) == "function" and type(hl.timer) == "function" and type(hl.dispatch) == "function" and type(hl.dsp) == "table" and type(hl.dsp.event) == "function", "capture capabilities unavailable") +local version = hl.version() +local major, minor = tostring(version):match("^v?(%d+)%.(%d+)%.%d+") +assert(major and (tonumber(major) > 0 or tonumber(minor) >= 55), "capture requires Hyprland 0.55 or newer") +${state} +assert(s == nil or (type(s) == "table" and s.schema == "recordly-1"), "capture registry conflict") +if not s then + s = {schema = "recordly-1", binds = {}} + rawset(_G, "${REGISTRY}", s) + s.stop = function() + s.token = nil + for _, bind in pairs(s.binds) do bind:set_enabled(false) end + if s.timer then s.timer:set_enabled(false) end + end +end +assert(not s.token or s.token == "${token}", "capture already in use") +local ok, err = pcall(function() + if not s.timer then + s.timer = hl.timer(function() s.stop() end, {timeout = 5000, type = "repeat"}) + assert(s.timer and type(s.timer.set_enabled) == "function" and type(s.timer.set_timeout) == "function", "capture timer unavailable") + s.timer:set_enabled(false) + end + for button = 1, 3 do + for release = 0, 1 do + local index = (button - 1) * 2 + release + 1 + if not s.binds[index] then + local suffix = (release == 0 and "-down-" or "-up-") .. button + local bind = hl.bind("mouse:" .. (271 + button), function() + if s.token then hl.dispatch(hl.dsp.event(s.token .. suffix)) end + end, {non_consuming = true, release = release == 1, ignore_mods = true, transparent = true, submap_universal = true}) + assert(bind and type(bind.set_enabled) == "function", "capture bind unavailable") + s.binds[index] = bind + bind:set_enabled(false) + end + end + end + s.token = "${token}" + s.timer:set_timeout(5000) + for _, bind in pairs(s.binds) do bind:set_enabled(true) end +end) +if not ok then s.stop(); error(err) end +`; +} + +export function requestHyprlandCommand(socketPath: string, command: string): Promise { + return new Promise((resolve) => { + let output = ""; + let settled = false; + const socket = net.createConnection(socketPath); + const finish = (ok: boolean) => { + if (settled) return; + settled = true; + socket.destroy(); + resolve(ok); + }; + socket.setEncoding("utf8"); + socket.setTimeout(REQUEST_TIMEOUT_MS, () => finish(false)); + socket.once("connect", () => socket.end(command)); + socket.on("data", (chunk: string) => { + output += chunk; + if (Buffer.byteLength(output) > MAX_RESPONSE_BYTES) finish(false); + }); + socket.once("end", () => finish(output.trim() === "ok")); + socket.once("error", () => finish(false)); + socket.once("close", () => finish(false)); + }); +} + +export async function startHyprlandButtonCapture( + handlers: ButtonHandlers, + options?: { + env?: NodeJS.ProcessEnv; + platform?: NodeJS.Platform; + signal?: AbortSignal; + onUnavailable?: () => void; + request?: typeof requestHyprlandCommand; + connect?: typeof net.createConnection; + }, +): Promise<{ available: boolean; stop: () => void }> { + const requestPath = getHyprlandRequestSocketPath( + options?.env ?? process.env, + options?.platform ?? process.platform, + ); + if (!requestPath || options?.signal?.aborted) { + return { available: false, stop: () => undefined }; + } + const request = options?.request ?? requestHyprlandCommand; + const token = `recordly-${randomUUID()}`; + const socket = (options?.connect ?? net.createConnection)( + path.join(path.dirname(requestPath), ".socket2.sock"), + ); + let stopped = false; + let active = false; + let ready = false; + let refreshGeneration = 0; + let buffered = ""; + let discardingOversizedLine = false; + let timer: NodeJS.Timeout | null = null; + const send = (action: "start" | "renew" | "stop") => { + const pending = (requestQueues.get(requestPath) ?? Promise.resolve(true)) + .then(() => + stopped && action !== "stop" + ? false + : request(requestPath, buildHyprlandButtonCommand(action, token)), + ) + .catch(() => false); + requestQueues.set(requestPath, pending); + void pending.then(() => { + if (requestQueues.get(requestPath) === pending) requestQueues.delete(requestPath); + }); + return pending; + }; + const stop = () => { + if (stopped) return; + stopped = true; + active = false; + if (timer) clearTimeout(timer); + options?.signal?.removeEventListener("abort", stop); + socket.destroy(); + void send("stop"); + }; + const fail = () => { + if (stopped) return; + stop(); + if (ready) options?.onUnavailable?.(); + }; + options?.signal?.addEventListener("abort", stop, { once: true }); + socket.setEncoding("utf8"); + const connected = new Promise((resolve) => { + const timeout = setTimeout(() => resolve(false), REQUEST_TIMEOUT_MS); + const finish = (ok: boolean) => { + clearTimeout(timeout); + resolve(ok); + }; + socket.once("connect", () => finish(true)); + socket.once("close", () => finish(false)); + socket.once("error", () => finish(false)); + }); + socket.on("error", fail); + socket.on("close", fail); + const refresh = async (action: "start" | "renew") => { + const generation = ++refreshGeneration; + const ok = await send(action); + if (stopped || generation !== refreshGeneration) return false; + if (!ok) { + fail(); + return false; + } + active = true; + if (timer) clearTimeout(timer); + timer = setTimeout(() => void refresh("renew"), HEARTBEAT_MS); + timer.unref(); + return true; + }; + socket.on("data", (chunk: string) => { + if (stopped) return; + if (discardingOversizedLine) { + const newline = chunk.indexOf("\n"); + if (newline === -1) return; + chunk = chunk.slice(newline + 1); + discardingOversizedLine = false; + } + buffered += chunk; + let newline = buffered.indexOf("\n"); + while (newline !== -1) { + const line = buffered.slice(0, newline); + buffered = buffered.slice(newline + 1); + if (Buffer.byteLength(line) > MAX_RESPONSE_BYTES) { + newline = buffered.indexOf("\n"); + continue; + } + if (line === "configreloaded>>") { + active = false; + if (timer) clearTimeout(timer); + void refresh("start"); + } else if (active && line.startsWith(`custom>>${token}-`)) { + const match = /^(down|up)-([123])$/.exec(line.slice(`custom>>${token}-`.length)); + if (match?.[1] === "down") handlers.onMouseDown(Number(match[2]) as 1 | 2 | 3); + else if (match?.[1] === "up") handlers.onMouseUp(); + } + newline = buffered.indexOf("\n"); + } + if (Buffer.byteLength(buffered) > MAX_RESPONSE_BYTES) { + buffered = ""; + discardingOversizedLine = true; + } + }); + if (!(await connected) || stopped || !(await refresh("start"))) { + stop(); + return { available: false, stop }; + } + ready = true; + return { available: true, stop }; +} diff --git a/electron/ipc/cursor/interaction.test.ts b/electron/ipc/cursor/interaction.test.ts index 4ea662414..7a7c7f2af 100644 --- a/electron/ipc/cursor/interaction.test.ts +++ b/electron/ipc/cursor/interaction.test.ts @@ -1,7 +1,25 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const { loadHook, startEvdev, startPrimary } = vi.hoisted(() => ({ + loadHook: vi.fn(), + startEvdev: vi.fn(() => vi.fn()), + startPrimary: vi.fn(async () => ({ available: false, stop: vi.fn() })), +})); + +vi.mock("./hyprlandButtons", () => ({ startHyprlandButtonCapture: startPrimary })); + +vi.mock("node:module", async (importOriginal) => ({ + ...(await importOriginal()), + createRequire: () => loadHook, +})); + +vi.mock("./hyprland", async (importOriginal) => ({ + ...(await importOriginal()), + startEvdevButtonCapture: startEvdev, +})); vi.mock("electron", () => ({ app: { @@ -11,10 +29,251 @@ vi.mock("electron", () => ({ }, })); +vi.mock("../utils", () => ({ + getTelemetryPathForVideo: vi.fn(() => "/tmp/recording.cursor.json"), + getScreen: vi.fn(() => ({ + getCursorScreenPoint: () => ({ x: 480, y: 270 }), + getPrimaryDisplay: () => ({ scaleFactor: 1 }), + getDisplayNearestPoint: () => ({ bounds: { x: 0, y: 0, width: 1920, height: 1080 } }), + getAllDisplays: () => [], + })), +})); + +import { buildInteractionZoomSuggestions } from "../../../src/components/video-editor/timeline/zoomSuggestionUtils"; +import { + activeCursorSamples, + setActiveCursorSamples, + setCursorCaptureStartTimeMs, + setInteractionCaptureCleanup, + setIsCursorCaptureActive, + setLastLeftClick, + setLinuxCursorScreenPoint, +} from "../state"; + import { + recordCursorMouseDown, + recordCursorMouseUp, repairBundledUiohookBinaryForCurrentArch, shouldStartGlobalInteractionHook, + startInteractionCapture, + stopInteractionCapture, } from "./interaction"; +import { pauseCursorCapture, resetCursorCaptureClock, resumeCursorCapture } from "./telemetry"; + +describe("captured cursor interactions", () => { + const platformDescriptor = Object.getOwnPropertyDescriptor( + process, + "platform", + ) as PropertyDescriptor; + + beforeEach(() => { + Object.defineProperty(process, "platform", { ...platformDescriptor, value: "linux" }); + startPrimary.mockReset(); + startPrimary.mockResolvedValue({ available: false, stop: vi.fn() }); + startEvdev.mockClear(); + loadHook.mockReset(); + vi.useFakeTimers(); + vi.setSystemTime(10_000); + setIsCursorCaptureActive(true); + setCursorCaptureStartTimeMs(10_000); + setActiveCursorSamples([]); + setLastLeftClick(null); + setLinuxCursorScreenPoint(null); + resetCursorCaptureClock(); + }); + + afterEach(() => { + stopInteractionCapture(); + setIsCursorCaptureActive(false); + resetCursorCaptureClock(); + vi.useRealTimers(); + Object.defineProperty(process, "platform", platformDescriptor); + }); + + it("records buttons and releases against the media clock and creates zooms", () => { + vi.setSystemTime(11_000); + recordCursorMouseDown(1); + vi.setSystemTime(11_100); + recordCursorMouseUp(); + vi.setSystemTime(11_200); + recordCursorMouseDown(1); + vi.setSystemTime(14_000); + recordCursorMouseDown(2); + vi.setSystemTime(17_000); + recordCursorMouseDown(3); + + expect( + activeCursorSamples.map(({ timeMs, interactionType }) => ({ timeMs, interactionType })), + ).toEqual([ + { timeMs: 1000, interactionType: "click" }, + { timeMs: 1100, interactionType: "mouseup" }, + { timeMs: 1200, interactionType: "double-click" }, + { timeMs: 4000, interactionType: "right-click" }, + { timeMs: 7000, interactionType: "middle-click" }, + ]); + const zooms = buildInteractionZoomSuggestions({ + cursorTelemetry: activeCursorSamples, + totalMs: 10_000, + defaultDurationMs: 3000, + }); + expect(zooms.status).toBe("ok"); + expect(zooms.suggestions).toEqual([ + { start: 500, end: 1700, focus: { cx: 0.25, cy: 0.25 } }, + { start: 3500, end: 4500, focus: { cx: 0.25, cy: 0.25 } }, + { start: 6500, end: 7500, focus: { cx: 0.25, cy: 0.25 } }, + ]); + }); + + it("ignores paused and stopped clicks and excludes pause duration after resuming", () => { + pauseCursorCapture(11_000); + vi.setSystemTime(13_000); + recordCursorMouseDown(1); + recordCursorMouseUp(); + expect(activeCursorSamples).toEqual([]); + resumeCursorCapture(14_000); + vi.setSystemTime(14_100); + recordCursorMouseDown(1); + expect(activeCursorSamples).toMatchObject([ + { timeMs: 1100, interactionType: "click", cx: 0.25, cy: 0.25 }, + ]); + setIsCursorCaptureActive(false); + recordCursorMouseDown(2); + recordCursorMouseUp(); + expect(activeCursorSamples).toHaveLength(1); + }); + + it("does not reenter cleanup when its provider stops capture", () => { + const cleanup = vi.fn(() => stopInteractionCapture()); + setInteractionCaptureCleanup(cleanup); + stopInteractionCapture(); + expect(cleanup).toHaveBeenCalledOnce(); + }); + + it("ignores callbacks from a stopped provider after restarting capture", async () => { + const on = vi.fn(); + const off = vi.fn(); + loadHook.mockReturnValue({ uIOhook: { on, off, start: vi.fn(), stop: vi.fn() } }); + await startInteractionCapture(); + const staleMouseDown = on.mock.calls.find(([event]) => event === "mousedown")?.[1]; + expect(staleMouseDown).toBeTypeOf("function"); + await startInteractionCapture(); + vi.setSystemTime(11_000); + staleMouseDown({ button: 1 }); + expect(activeCursorSamples).toEqual([]); + const currentMouseDown = on.mock.calls + .filter(([event]) => event === "mousedown") + .at(-1)?.[1]; + currentMouseDown({ button: 1 }); + expect(activeCursorSamples).toHaveLength(1); + expect(off).toHaveBeenCalledWith("mousedown", staleMouseDown); + }); + + it("uses compositor clicks exclusively while the primary capture is available", async () => { + startPrimary.mockResolvedValue({ available: true, stop: vi.fn() }); + await startInteractionCapture(); + expect(startEvdev).not.toHaveBeenCalled(); + expect(loadHook).not.toHaveBeenCalled(); + const handlers = startPrimary.mock.calls[0][0]; + vi.setSystemTime(11_000); + handlers.onMouseDown(1); + handlers.onMouseUp(); + expect(activeCursorSamples.map(({ interactionType }) => interactionType)).toEqual([ + "click", + "mouseup", + ]); + }); + + it("aborts a pending primary startup and disposes its late result without starting fallback", async () => { + let resolveStartup!: (result: { available: boolean; stop: () => void }) => void; + startPrimary.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveStartup = resolve; + }), + ); + const startup = startInteractionCapture(); + const signal = startPrimary.mock.calls[0][1].signal; + stopInteractionCapture(); + expect(signal.aborted).toBe(true); + const stop = vi.fn(); + resolveStartup({ available: true, stop }); + await startup; + expect(stop).toHaveBeenCalledOnce(); + expect(startEvdev).not.toHaveBeenCalled(); + }); + + it("preserves new capture when a previous pending startup resolves", async () => { + let resolveStartup!: (result: { available: boolean; stop: () => void }) => void; + startPrimary.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveStartup = resolve; + }), + ); + const previousStartup = startInteractionCapture(); + const previousHandlers = startPrimary.mock.calls[0][0]; + const currentStop = vi.fn(); + startPrimary.mockResolvedValueOnce({ available: true, stop: currentStop }); + await startInteractionCapture(); + const previousStop = vi.fn(); + resolveStartup({ available: true, stop: previousStop }); + await previousStartup; + previousHandlers.onMouseDown(1); + expect(previousStop).toHaveBeenCalledOnce(); + expect(currentStop).not.toHaveBeenCalled(); + expect(activeCursorSamples).toEqual([]); + stopInteractionCapture(); + expect(currentStop).toHaveBeenCalledOnce(); + }); + + it("switches to fallback once after failure and ignores late primary events", async () => { + const stop = vi.fn(); + startPrimary.mockResolvedValue({ available: true, stop }); + loadHook.mockReturnValue({ uIOhook: { on: vi.fn(), start: vi.fn(), stop: vi.fn() } }); + await startInteractionCapture(); + const [handlers, options] = startPrimary.mock.calls[0]; + options.onUnavailable(); + options.onUnavailable(); + handlers.onMouseDown(1); + handlers.onMouseUp(); + expect(startEvdev).toHaveBeenCalledOnce(); + expect(loadHook).toHaveBeenCalledOnce(); + expect(activeCursorSamples).toEqual([]); + }); + + it("suppresses hook click duplicates once an evdev device opens", async () => { + const on = vi.fn(); + loadHook.mockReturnValue({ uIOhook: { on, start: vi.fn(), stop: vi.fn() } }); + await startInteractionCapture(); + const [handlers, options] = startEvdev.mock.calls[0]; + options.onDeviceOpened(); + vi.setSystemTime(11_000); + handlers.onMouseDown(1); + on.mock.calls.find(([event]) => event === "mousedown")?.[1]({ button: 1 }); + handlers.onMouseUp(); + on.mock.calls.find(([event]) => event === "mouseup")?.[1](); + expect(activeCursorSamples.map(({ interactionType }) => interactionType)).toEqual([ + "click", + "mouseup", + ]); + }); + + it("does not start fallback when a cancelled primary request rejects", async () => { + let rejectStartup!: (error: Error) => void; + startPrimary.mockImplementationOnce( + () => + new Promise((_, reject) => { + rejectStartup = reject; + }), + ); + const startup = startInteractionCapture(); + stopInteractionCapture(); + rejectStartup(new Error("socket closed")); + await startup; + expect(startEvdev).not.toHaveBeenCalled(); + expect(loadHook).not.toHaveBeenCalled(); + }); +}); describe("shouldStartGlobalInteractionHook", () => { it("does not start the synchronous uiohook event tap on macOS", () => { diff --git a/electron/ipc/cursor/interaction.ts b/electron/ipc/cursor/interaction.ts index 47c42437f..fff33ac64 100644 --- a/electron/ipc/cursor/interaction.ts +++ b/electron/ipc/cursor/interaction.ts @@ -17,6 +17,8 @@ import type { UiohookLike, UiohookModuleNamespace, } from "../types"; +import { isHyprlandCursorProviderActive, startEvdevButtonCapture } from "./hyprland"; +import { startHyprlandButtonCapture } from "./hyprlandButtons"; import { getCursorCaptureElapsedMs, getHookCursorScreenPoint, @@ -26,6 +28,7 @@ import { } from "./telemetry"; const nodeRequire = createRequire(import.meta.url); +let interactionCaptureGeneration = 0; export function normalizeHookMouseButton(rawButton: unknown): 1 | 2 | 3 { if (typeof rawButton !== "number" || !Number.isFinite(rawButton)) { @@ -50,10 +53,10 @@ export function getHookMouseButton(event: HookMouseEvent | null | undefined): 1 } export function stopInteractionCapture() { - if (interactionCaptureCleanup) { - interactionCaptureCleanup(); - setInteractionCaptureCleanup(null); - } + interactionCaptureGeneration += 1; + const cleanup = interactionCaptureCleanup; + setInteractionCaptureCleanup(null); + cleanup?.(); } function isUiohookLike(value: unknown): value is UiohookLike { @@ -234,11 +237,7 @@ export function recordCursorMouseUp() { } export async function startInteractionCapture() { - if (!isCursorCaptureActive) { - return; - } - - if (!["darwin", "win32", "linux"].includes(process.platform)) { + if (!isCursorCaptureActive || !["darwin", "win32", "linux"].includes(process.platform)) { return; } @@ -248,86 +247,132 @@ export async function startInteractionCapture() { } stopInteractionCapture(); - - try { - const hook = loadUiohookModule(); - console.log( - "[CursorTelemetry] hook loaded:", - !!hook, - "has.on:", - typeof hook?.on, - "has.start:", - typeof hook?.start, + const generation = interactionCaptureGeneration; + const isCurrentCapture = () => + generation === interactionCaptureGeneration && isCursorCaptureActive; + const controller = new AbortController(); + let stopPrimary: () => void = () => undefined; + let stopFallback: () => void = () => undefined; + let fallbackStarted = false; + + setInteractionCaptureCleanup(() => { + controller.abort(); + stopPrimary(); + stopFallback(); + }); + + const startFallback = () => { + if (!isCurrentCapture() || fallbackStarted) return; + fallbackStarted = true; + let evdevAvailable = false; + const stopEvdevCapture = startEvdevButtonCapture( + { + onMouseDown: (button) => { + if (isCurrentCapture()) recordCursorMouseDown(button); + }, + onMouseUp: () => { + if (isCurrentCapture()) recordCursorMouseUp(); + }, + }, + { + onDeviceOpened: () => { + evdevAvailable = true; + }, + }, ); - if (!isCursorCaptureActive) { - return; - } - - if (!hook || typeof hook.on !== "function" || typeof hook.start !== "function") { - console.log("[CursorTelemetry] hook unusable — aborting interaction capture"); - return; - } - - const onMouseDown = (event: HookMouseEvent) => { - recordCursorMouseDown(getHookMouseButton(event)); - }; - - const onMouseUp = () => { - recordCursorMouseUp(); - }; - - const onMouseMove = (event: HookMouseEvent) => { - if (process.platform !== "linux" || !isCursorCaptureActive || isCursorCapturePaused()) { - return; - } + stopFallback = stopEvdevCapture; - const point = getHookCursorScreenPoint(event); - if (!point) { + try { + const hook = loadUiohookModule(); + if (!hook || typeof hook.on !== "function" || typeof hook.start !== "function") { + console.warn("[CursorTelemetry] Global interaction hook unavailable."); return; } - - setLinuxCursorScreenPoint({ x: point.x, y: point.y, updatedAt: Date.now() }); - }; - - hook.on("mousedown", onMouseDown); - hook.on("mouseup", onMouseUp); - if (process.platform === "linux") { - hook.on("mousemove", onMouseMove); - } - - setInteractionCaptureCleanup(() => { - try { - if (typeof hook.off === "function") { - hook.off("mousedown", onMouseDown); - hook.off("mouseup", onMouseUp); - if (process.platform === "linux") { - hook.off("mousemove", onMouseMove); - } - } else if (typeof hook.removeListener === "function") { - hook.removeListener("mousedown", onMouseDown); - hook.removeListener("mouseup", onMouseUp); - if (process.platform === "linux") { - hook.removeListener("mousemove", onMouseMove); - } + const onMouseDown = (event: HookMouseEvent) => { + if (isCurrentCapture() && !evdevAvailable) { + recordCursorMouseDown(getHookMouseButton(event)); } - } catch { - // ignore listener cleanup errors - } - - try { - if (typeof hook.stop === "function") { - hook.stop(); + }; + const onMouseUp = () => { + if (isCurrentCapture() && !evdevAvailable) recordCursorMouseUp(); + }; + const onMouseMove = (event: HookMouseEvent) => { + if ( + !isCurrentCapture() || + process.platform !== "linux" || + isHyprlandCursorProviderActive() || + isCursorCapturePaused() + ) + return; + const point = getHookCursorScreenPoint(event); + if (!point) return; + setLinuxCursorScreenPoint({ + x: point.x, + y: point.y, + updatedAt: Date.now(), + coordinateSpace: "physical", + source: "uiohook", + }); + }; + + hook.on("mousedown", onMouseDown); + hook.on("mouseup", onMouseUp); + if (process.platform === "linux") hook.on("mousemove", onMouseMove); + stopFallback = () => { + stopEvdevCapture(); + try { + const removeListener = hook.off ?? hook.removeListener; + removeListener?.call(hook, "mousedown", onMouseDown); + removeListener?.call(hook, "mouseup", onMouseUp); + if (process.platform === "linux") + removeListener?.call(hook, "mousemove", onMouseMove); + } catch { + // ignore listener cleanup errors + } + try { + hook.stop?.(); + } catch { + // ignore hook shutdown errors } - } catch { - // ignore hook shutdown errors + }; + hook.start(); + } catch (error) { + if (!hasLoggedInteractionHookFailure) { + setHasLoggedInteractionHookFailure(true); + console.warn("[CursorTelemetry] Global interaction capture unavailable:", error); } - }); + } + }; - hook.start(); - } catch (error) { - if (!hasLoggedInteractionHookFailure) { - setHasLoggedInteractionHookFailure(true); - console.warn("[CursorTelemetry] Global interaction capture unavailable:", error); + try { + const primary = await startHyprlandButtonCapture( + { + onMouseDown: (button) => { + if (isCurrentCapture() && !fallbackStarted) recordCursorMouseDown(button); + }, + onMouseUp: () => { + if (isCurrentCapture() && !fallbackStarted) recordCursorMouseUp(); + }, + }, + { + signal: controller.signal, + onUnavailable: () => { + if (!isCurrentCapture() || fallbackStarted) return; + stopPrimary(); + startFallback(); + }, + }, + ); + if (!isCurrentCapture()) { + primary.stop(); + return; + } + stopPrimary = primary.stop; + if (!primary.available || fallbackStarted) { + stopPrimary(); + startFallback(); } + } catch { + startFallback(); } } diff --git a/electron/ipc/cursor/telemetry.ts b/electron/ipc/cursor/telemetry.ts index 8318497bd..126e0fb6a 100644 --- a/electron/ipc/cursor/telemetry.ts +++ b/electron/ipc/cursor/telemetry.ts @@ -4,6 +4,7 @@ import { CURSOR_TELEMETRY_VERSION, MAX_CURSOR_SAMPLES, } from "../constants"; +import { getSourceArea } from "../sourceArea"; import { activeCursorSamples, currentCursorVisualType, @@ -23,7 +24,6 @@ import { setPendingCursorSamples, } from "../state"; import type { CursorInteractionType, CursorTelemetryPoint, CursorVisualType } from "../types"; -import { getSourceArea } from "../sourceArea"; import { getScreen, getTelemetryPathForVideo } from "../utils"; export function clamp(value: number, min: number, max: number) { @@ -173,8 +173,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; // Areas are fixed rectangles, so they normalize like a window that never moves. diff --git a/electron/ipc/register/recording.ts b/electron/ipc/register/recording.ts index 4c29dded9..158e190bd 100644 --- a/electron/ipc/register/recording.ts +++ b/electron/ipc/register/recording.ts @@ -22,6 +22,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 { @@ -88,6 +93,7 @@ import { shouldStartWindowsBrowserMicrophoneFallback, shouldUseWindowsBrowserMicrophoneFallback, } from "../recording/windowsFallbacks"; +import { getSourceArea } from "../sourceArea"; import { cachedSystemCursorAssets, cachedSystemCursorAssetsSourceMtimeMs, @@ -157,7 +163,6 @@ import { parseWindowId, } from "../utils"; import { resolveWindowsCaptureTarget } from "../windowsCaptureSelection"; -import { getSourceArea } from "../sourceArea"; import { bringSelectedWindowForward } from "./sources"; const execFileAsync = promisify(execFile); @@ -404,6 +409,8 @@ async function resolveExistingPath(...candidates: Array void, ) { + let cursorCaptureGeneration = 0; + ipcMain.handle( "start-native-screen-recording", async (_, source: SelectedSource, options?: NativeMacRecordingOptions) => { @@ -1878,7 +1885,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(); @@ -1887,10 +1896,26 @@ 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(); @@ -1898,6 +1923,7 @@ export function registerRecordingHandlers( setIsCursorCaptureActive(false); stopCursorCapture(); stopInteractionCapture(); + stopHyprlandCursorProvider(); stopWindowBoundsCapture(); stopNativeCursorMonitor(); showCursor(); @@ -1926,6 +1952,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.test.ts b/electron/ipc/register/sourceMapping.test.ts index ce7ff4fa6..fa3d404dc 100644 --- a/electron/ipc/register/sourceMapping.test.ts +++ b/electron/ipc/register/sourceMapping.test.ts @@ -28,6 +28,20 @@ describe("getScreenSourceIdForDisplay", () => { ).toBe(LINUX_PORTAL_SCREEN_SOURCE_ID); }); + it("keeps the portal source when Electron uses X11 inside a Wayland session", () => { + expect( + getScreenSourceIdForDisplay({ + displayId: "42", + env: { + XDG_SESSION_TYPE: "wayland", + WAYLAND_DISPLAY: "wayland-0", + OZONE_PLATFORM: "x11", + }, + platform: "linux", + }), + ).toBe(LINUX_PORTAL_SCREEN_SOURCE_ID); + }); + it("keeps unmatched Linux X11 screens on the explicit fallback id", () => { expect( getScreenSourceIdForDisplay({ diff --git a/electron/ipc/register/sourceMapping.ts b/electron/ipc/register/sourceMapping.ts index 9ae9372bc..df38ab44b 100644 --- a/electron/ipc/register/sourceMapping.ts +++ b/electron/ipc/register/sourceMapping.ts @@ -2,13 +2,8 @@ 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; - } - + if (sessionType === "wayland") return true; + if (sessionType === "x11") return false; return Boolean(env.WAYLAND_DISPLAY); } 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 46cef83c8..d897bcb92 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -634,8 +634,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); @@ -1019,9 +1022,6 @@ contextBridge.exposeInMainWorld("electronAPI", { ipcRenderer.on("window-fullscreen-changed", listener); return () => ipcRenderer.removeListener("window-fullscreen-changed", listener); }, - getLinuxWindowSystem: () => { - return ipcRenderer.invoke("get-linux-window-system"); - }, ackAuthCallbackUrl: (url: string) => ipcRenderer.invoke("auth:ack-callback", url), getPendingAuthCallbackUrl: () => ipcRenderer.invoke("auth:get-pending-callback"), onAuthCallbackUrl: (callback: (url: string) => void) => { diff --git a/src/hooks/useScreenRecorder.test.ts b/src/hooks/useScreenRecorder.test.ts index 6ecd34ec5..783f62cd8 100644 --- a/src/hooks/useScreenRecorder.test.ts +++ b/src/hooks/useScreenRecorder.test.ts @@ -6,6 +6,8 @@ import { normalizeBrowserMicrophoneProfile, resolveBrowserCaptureCursorPolicy, shouldUseNativeWindowsCaptureForSource, + startMediaRecorderAtTimelineBoundary, + stopAndDiscardBrowserCapture, stopAndDiscardNativeCapture, } from "./useScreenRecorder"; @@ -160,6 +162,122 @@ 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); + expect(recorder.start).toHaveBeenCalledTimes(1); + expect(vi.getTimerCount()).toBe(0); + 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); + }); + + it("rejects if capture stops while waiting for its media timeline", async () => { + const recorder = Object.assign(new EventTarget(), { + start: vi.fn(), + }) as unknown as MediaRecorder; + const startedAt = startMediaRecorderAtTimelineBoundary(recorder, 250); + + recorder.dispatchEvent(new Event("stop")); + recorder.dispatchEvent(new Event("start")); + + await expect(startedAt).rejects.toThrow("failed before its media timeline started"); + expect(recorder.start).toHaveBeenCalledTimes(1); + }); +}); + +describe("stopAndDiscardBrowserCapture", () => { + it.each([ + "recording", + "paused", + ] as const)("discards a cancelled %s startup before its final chunk can be saved", (state) => { + const saveChunk = vi.fn(); + const saveRecording = vi.fn(); + const reportError = vi.fn(); + const recorder = { + state, + ondataavailable: saveChunk, + onstop: saveRecording, + onerror: reportError, + stop: vi.fn(() => { + recorder.ondataavailable?.(); + recorder.onstop?.(); + recorder.onerror?.(); + }), + }; + + stopAndDiscardBrowserCapture(recorder); + + expect(recorder.stop).toHaveBeenCalledTimes(1); + expect(saveChunk).not.toHaveBeenCalled(); + expect(saveRecording).not.toHaveBeenCalled(); + expect(reportError).not.toHaveBeenCalled(); + }); + + it("does not stop an inactive recorder when startup was cancelled before start", () => { + const recorder = { + state: "inactive" as const, + ondataavailable: vi.fn(), + onstop: vi.fn(), + onerror: vi.fn(), + stop: vi.fn(), + }; + + stopAndDiscardBrowserCapture(recorder); + + expect(recorder.stop).not.toHaveBeenCalled(); + expect(recorder.ondataavailable).toBeNull(); + expect(recorder.onstop).toBeNull(); + expect(recorder.onerror).toBeNull(); + }); +}); + 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 954860746..f52db5489 100644 --- a/src/hooks/useScreenRecorder.ts +++ b/src/hooks/useScreenRecorder.ts @@ -1,9 +1,9 @@ -import { acquireBrowserScreenCapture, isCapturePermissionDismissed } from "./browserScreenCapture"; import { fixWebmDuration } from "@fix-webm-duration/fix"; import { useCallback, useEffect, useRef, useState } from "react"; import { toast } from "@/components/ui/toast"; -import { getEffectiveRecordingDurationMs } from "@/lib/mediaTiming"; import { hasRecordlySession } from "@/lib/auth/recordlyAuth"; +import { getEffectiveRecordingDurationMs } from "@/lib/mediaTiming"; +import { acquireBrowserScreenCapture, isCapturePermissionDismissed } from "./browserScreenCapture"; import { getVideoExtensionForMimeType, isWebmMimeType, @@ -197,7 +197,6 @@ export function resolveBrowserCaptureCursorPolicy({ hideEditorOverlayCursorByDefault: true, }; } - return { streamCursor: "never", hideOsCursorBeforeRecording: true, @@ -205,6 +204,65 @@ 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 stopAndDiscardBrowserCapture( + recorder: Pick, +) { + // A cancelled startup may already have produced media while waiting for `start`. + // Detach the save handlers before stopping so its final chunk cannot be persisted. + recorder.ondataavailable = null; + recorder.onstop = null; + recorder.onerror = null; + if (recorder.state !== "inactive") recorder.stop(); +} + export function shouldUseNativeWindowsCaptureForSource( source: Pick | null | undefined, ): boolean { @@ -1642,6 +1700,13 @@ export function useScreenRecorder(): UseScreenRecorderReturn { const startGeneration = recordingStartGeneration.current + 1; recordingStartGeneration.current = startGeneration; const startWasCancelled = () => recordingStartGeneration.current !== startGeneration; + const discardPendingBrowserCapture = () => { + if (mediaRecorder.current) { + stopAndDiscardBrowserCapture(mediaRecorder.current); + mediaRecorder.current = null; + } + chunks.current = []; + }; let usingLinuxPortal = false; let hudSourceSelectionActive = false; @@ -1674,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); @@ -2087,7 +2156,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 }, @@ -2119,6 +2187,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 (useLinuxPortal && 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 @@ -2136,6 +2223,19 @@ export function useScreenRecorder(): UseScreenRecorderReturn { ); mediaRecorder.current = recorder; + if (startWasCancelled()) return; + const mainStartedAt = await startMediaRecorderAtTimelineBoundary( + recorder, + RECORDER_TIMESLICE_MS, + ); + if (startWasCancelled()) return; + beginWebcamCapture(); + resetRecordingClock(mainStartedAt); + webcamTimeOffsetMs.current = + webcamStartTime.current === null ? 0 : webcamStartTime.current - mainStartedAt; + + // Save handlers are installed only after startup succeeds, so a stop or error + // while waiting for the media timeline cannot persist a partial recording. recorder.ondataavailable = (event) => { if (event.data && event.data.size > 0) chunks.current.push(event.data); }; @@ -2220,20 +2320,19 @@ export function useScreenRecorder(): UseScreenRecorderReturn { recorder.onerror = () => { setRecording(false); }; - const mainStartedAt = Date.now(); - beginWebcamCapture(); - resetRecordingClock(mainStartedAt); - webcamTimeOffsetMs.current = - webcamStartTime.current === null ? 0 : webcamStartTime.current - mainStartedAt; - if (startWasCancelled()) return; - 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); } } catch (error) { + discardPendingBrowserCapture(); if ( !startWasCancelled() && !(usingLinuxPortal && isCapturePermissionDismissed(error)) @@ -2261,6 +2360,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn { try { if (startWasCancelled()) { try { + discardPendingBrowserCapture(); await discardActiveNativeCapture(); } finally { cleanupCapturedMedia();