diff --git a/docs/superpowers/plans/2026-07-22-hyprland-cursor-position.md b/docs/superpowers/plans/2026-07-22-hyprland-cursor-position.md new file mode 100644 index 000000000..57e045d4e --- /dev/null +++ b/docs/superpowers/plans/2026-07-22-hyprland-cursor-position.md @@ -0,0 +1,468 @@ +# Hyprland Cursor Position Telemetry Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Fix the Linux cursor-position telemetry so recordings on Hyprland show a tracked cursor instead of a frozen `(0, 0)`, by sourcing position from Hyprland's own IPC socket instead of Electron's broken `screen.getCursorScreenPoint()`. + +**Architecture:** A new pure protocol module (`hyprlandCursorIpc.ts`, no Electron dependency, fully unit-testable) talks to Hyprland's Unix domain control socket. A new `CursorRecordingSession` implementation (`hyprlandCursorRecordingSession.ts`, matching the shape of the existing `TelemetryRecordingSession`) polls that protocol module on an interval and feeds the *existing* `provider: "none"` cursor-overlay pipeline — no editor/rendering changes needed. `factory.ts` picks this session over `TelemetryRecordingSession` only when `HYPRLAND_INSTANCE_SIGNATURE` is set. + +**Tech Stack:** TypeScript, Node's built-in `net` module (Unix domain sockets), Vitest. + +## Global Constraints + +- No changes to `CursorRecordingSession`, `CursorRecordingData`, `CursorRecordingSample`, or any consumer of recording data (`electron/ipc/handlers.ts`, `src/lib/cursor/nativeCursor.ts`, the editor) — the spec requires the new session to produce the exact existing wire format. +- Non-Hyprland Linux compositors must keep using `TelemetryRecordingSession` unchanged — no regression risk for them. +- Socket/IPC failures must never throw out of the session or crash a recording — always degrade to a missed sample (reuse last known position) per the spec's Error Handling section. +- Sample cadence matches the existing `CURSOR_SAMPLE_INTERVAL_MS = 33` (~30Hz) used today in `electron/ipc/handlers.ts:400`. +- Spec source of truth: `docs/superpowers/specs/2026-07-22-hyprland-cursor-position-design.md`. + +--- + +### Task 1: Hyprland IPC protocol module + +**Files:** +- Create: `electron/native-bridge/cursor/recording/hyprlandCursorIpc.ts` +- Test: `electron/native-bridge/cursor/recording/hyprlandCursorIpc.test.ts` + +**Interfaces:** +- Produces: + - `resolveHyprlandSocketPath(): string | null` — reads `process.env.HYPRLAND_INSTANCE_SIGNATURE` and `process.env.XDG_RUNTIME_DIR`; returns `null` if either is unset, otherwise `path.join(runtimeDir, "hypr", signature, ".socket.sock")`. + - `queryHyprlandCursorPos(socketPath: string): Promise<{ x: number; y: number } | null>` — connects to the given Unix socket, writes `"j/cursorpos"`, reads the response until the peer closes the connection, parses it as JSON, and resolves with `{ x, y }` on success or `null` on any failure (bad JSON, missing fields, connection error, 200ms timeout). Never rejects. + +This module has **no Electron import** — it's plain Node (`node:net`, `node:path`), which is what makes it unit-testable under the project's Vitest/jsdom setup (Electron's `screen`/`app` modules can't be imported outside a running Electron process, which is why no other native-bridge cursor session has unit tests today — see `macNativeCursorRecordingSession.ts`, `windowsNativeRecordingSession.ts`, `telemetryRecordingSession.ts`). + +- [ ] **Step 1: Write the failing tests** + +Create `electron/native-bridge/cursor/recording/hyprlandCursorIpc.test.ts`: + +```ts +import fs from "node:fs"; +import net from "node:net"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { queryHyprlandCursorPos, resolveHyprlandSocketPath } from "./hyprlandCursorIpc"; + +describe("resolveHyprlandSocketPath", () => { + const originalSignature = process.env.HYPRLAND_INSTANCE_SIGNATURE; + const originalRuntimeDir = process.env.XDG_RUNTIME_DIR; + + afterEach(() => { + if (originalSignature === undefined) { + delete process.env.HYPRLAND_INSTANCE_SIGNATURE; + } else { + process.env.HYPRLAND_INSTANCE_SIGNATURE = originalSignature; + } + if (originalRuntimeDir === undefined) { + delete process.env.XDG_RUNTIME_DIR; + } else { + process.env.XDG_RUNTIME_DIR = originalRuntimeDir; + } + }); + + it("returns null when HYPRLAND_INSTANCE_SIGNATURE is unset", () => { + delete process.env.HYPRLAND_INSTANCE_SIGNATURE; + process.env.XDG_RUNTIME_DIR = "/run/user/1000"; + + expect(resolveHyprlandSocketPath()).toBeNull(); + }); + + it("returns null when XDG_RUNTIME_DIR is unset", () => { + process.env.HYPRLAND_INSTANCE_SIGNATURE = "abc123"; + delete process.env.XDG_RUNTIME_DIR; + + expect(resolveHyprlandSocketPath()).toBeNull(); + }); + + it("joins the runtime dir, signature, and socket filename when both are set", () => { + process.env.HYPRLAND_INSTANCE_SIGNATURE = "abc123"; + process.env.XDG_RUNTIME_DIR = "/run/user/1000"; + + expect(resolveHyprlandSocketPath()).toBe("/run/user/1000/hypr/abc123/.socket.sock"); + }); +}); + +describe("queryHyprlandCursorPos", () => { + const testDirs: string[] = []; + const servers: net.Server[] = []; + + function createTestSocketPath() { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openscreen-hypr-ipc-test-")); + testDirs.push(dir); + return path.join(dir, ".socket.sock"); + } + + function startFakeHyprlandServer(socketPath: string, respond: (command: string) => string) { + const server = net.createServer((socket) => { + socket.once("data", (chunk) => { + socket.end(respond(chunk.toString("utf8"))); + }); + }); + server.listen(socketPath); + servers.push(server); + return new Promise((resolve) => server.once("listening", () => resolve())); + } + + afterEach(async () => { + for (const server of servers.splice(0)) { + await new Promise((resolve) => server.close(() => resolve())); + } + for (const dir of testDirs.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + it("resolves with the parsed position for a valid response", async () => { + const socketPath = createTestSocketPath(); + await startFakeHyprlandServer(socketPath, () => JSON.stringify({ x: 960, y: 540 })); + + await expect(queryHyprlandCursorPos(socketPath)).resolves.toEqual({ x: 960, y: 540 }); + }); + + it("sends the j/cursorpos command", async () => { + const socketPath = createTestSocketPath(); + let receivedCommand = ""; + await startFakeHyprlandServer(socketPath, (command) => { + receivedCommand = command; + return JSON.stringify({ x: 0, y: 0 }); + }); + + await queryHyprlandCursorPos(socketPath); + + expect(receivedCommand).toBe("j/cursorpos"); + }); + + it("resolves with null for a malformed JSON response", async () => { + const socketPath = createTestSocketPath(); + await startFakeHyprlandServer(socketPath, () => "not json"); + + await expect(queryHyprlandCursorPos(socketPath)).resolves.toBeNull(); + }); + + it("resolves with null for a response missing x/y fields", async () => { + const socketPath = createTestSocketPath(); + await startFakeHyprlandServer(socketPath, () => JSON.stringify({ foo: "bar" })); + + await expect(queryHyprlandCursorPos(socketPath)).resolves.toBeNull(); + }); + + it("resolves with null when the socket doesn't exist", async () => { + const socketPath = createTestSocketPath(); + // Never started a server on this path. + + await expect(queryHyprlandCursorPos(socketPath)).resolves.toBeNull(); + }); +}); +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `node_modules/.bin/vitest --run electron/native-bridge/cursor/recording/hyprlandCursorIpc.test.ts` +Expected: FAIL — `Cannot find module './hyprlandCursorIpc'` (file doesn't exist yet). + +- [ ] **Step 3: Write the implementation** + +Create `electron/native-bridge/cursor/recording/hyprlandCursorIpc.ts`: + +```ts +import net from "node:net"; +import path from "node:path"; + +const SOCKET_TIMEOUT_MS = 200; + +export function resolveHyprlandSocketPath(): string | null { + const signature = process.env.HYPRLAND_INSTANCE_SIGNATURE; + const runtimeDir = process.env.XDG_RUNTIME_DIR; + if (!signature || !runtimeDir) { + return null; + } + return path.join(runtimeDir, "hypr", signature, ".socket.sock"); +} + +export function queryHyprlandCursorPos( + socketPath: string, +): Promise<{ x: number; y: number } | null> { + return new Promise((resolve) => { + const socket = net.createConnection(socketPath); + const chunks: Buffer[] = []; + let settled = false; + + const finish = (value: { x: number; y: number } | null) => { + if (settled) return; + settled = true; + socket.destroy(); + resolve(value); + }; + + socket.setTimeout(SOCKET_TIMEOUT_MS, () => finish(null)); + socket.on("error", () => finish(null)); + socket.on("connect", () => socket.write("j/cursorpos")); + socket.on("data", (chunk) => chunks.push(chunk)); + socket.on("close", () => { + if (settled) return; + try { + const parsed = JSON.parse(Buffer.concat(chunks).toString("utf8")); + if (typeof parsed?.x === "number" && typeof parsed?.y === "number") { + finish({ x: parsed.x, y: parsed.y }); + return; + } + } catch { + // Falls through to finish(null) below. + } + finish(null); + }); + }); +} +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `node_modules/.bin/vitest --run electron/native-bridge/cursor/recording/hyprlandCursorIpc.test.ts` +Expected: PASS — all 8 tests green. + +- [ ] **Step 5: Typecheck and lint** + +Run: `node_modules/.bin/tsc --noEmit -p tsconfig.json && node_modules/.bin/biome check electron/native-bridge/cursor/recording/hyprlandCursorIpc.ts electron/native-bridge/cursor/recording/hyprlandCursorIpc.test.ts` +Expected: No errors from either command. + +- [ ] **Step 6: Commit** + +```bash +git add electron/native-bridge/cursor/recording/hyprlandCursorIpc.ts electron/native-bridge/cursor/recording/hyprlandCursorIpc.test.ts +git commit -m "feat(linux): add Hyprland IPC socket protocol module for cursor position" +``` + +--- + +### Task 2: Hyprland cursor recording session + +**Files:** +- Create: `electron/native-bridge/cursor/recording/hyprlandCursorRecordingSession.ts` +- Reference (read-only, don't modify): `electron/native-bridge/cursor/recording/telemetryRecordingSession.ts` (this task mirrors its shape), `electron/native-bridge/cursor/recording/session.ts` (the `CursorRecordingSession` interface it implements) + +**Interfaces:** +- Consumes: `resolveHyprlandSocketPath()` and `queryHyprlandCursorPos(socketPath: string): Promise<{x: number, y: number} | null>` from Task 1's `./hyprlandCursorIpc`. +- Produces: `HyprlandCursorRecordingSession` class implementing `CursorRecordingSession` (from `./session`), constructed with the same option shape as `TelemetryRecordingSession` (`getDisplayBounds: () => Rectangle | null`, `maxSamples: number`, `sampleIntervalMs: number`, `startTimeMs?: number`), plus an optional `socketPath?: string` override used only by tests. Consumed by Task 3's `factory.ts` change. + +This class **does** import Electron's `screen` module (for the same `getDisplayNearestPoint` fallback `TelemetryRecordingSession` already uses), which is why — matching the existing convention for this family of session classes (none of `macNativeCursorRecordingSession.ts`, `windowsNativeRecordingSession.ts`, or `telemetryRecordingSession.ts` have unit tests, since Electron's modules can't be imported outside a running Electron process) — this task has no automated test. It's covered by the manual verification in Task 4. + +- [ ] **Step 1: Write the implementation** + +Create `electron/native-bridge/cursor/recording/hyprlandCursorRecordingSession.ts`: + +```ts +import { type Rectangle, screen } from "electron"; +import type { CursorRecordingData, CursorRecordingSample } from "../../../../src/native/contracts"; +import { queryHyprlandCursorPos, resolveHyprlandSocketPath } from "./hyprlandCursorIpc"; +import type { CursorRecordingSession } from "./session"; + +interface HyprlandCursorRecordingSessionOptions { + getDisplayBounds: () => Rectangle | null; + maxSamples: number; + sampleIntervalMs: number; + startTimeMs?: number; + /** Overrides the resolved Hyprland socket path. Test-only. */ + socketPath?: string; +} + +function clamp(value: number, min: number, max: number) { + return Math.min(max, Math.max(min, value)); +} + +export class HyprlandCursorRecordingSession implements CursorRecordingSession { + private samples: CursorRecordingSample[] = []; + private interval: NodeJS.Timeout | null = null; + private startTimeMs = 0; + private isSampling = false; + private lastPosition: { x: number; y: number } | null = null; + private readonly socketPath: string | null; + + constructor(private readonly options: HyprlandCursorRecordingSessionOptions) { + this.socketPath = options.socketPath ?? resolveHyprlandSocketPath(); + } + + async start(): Promise { + this.samples = []; + this.lastPosition = null; + this.startTimeMs = this.options.startTimeMs ?? Date.now(); + await this.captureSample(); + this.interval = setInterval(() => { + void this.captureSample(); + }, this.options.sampleIntervalMs); + } + + async stop(): Promise { + if (this.interval) { + clearInterval(this.interval); + this.interval = null; + } + + return { + version: 2, + provider: "none", + samples: this.samples, + assets: [], + }; + } + + private async captureSample() { + if (this.isSampling || !this.socketPath) { + return; + } + this.isSampling = true; + try { + const position = (await queryHyprlandCursorPos(this.socketPath)) ?? this.lastPosition; + if (!position) { + return; + } + this.lastPosition = position; + + const display = + this.options.getDisplayBounds() ?? screen.getDisplayNearestPoint(position).bounds; + const width = Math.max(1, display.width); + const height = Math.max(1, display.height); + + this.samples.push({ + timeMs: Math.max(0, Date.now() - this.startTimeMs), + cx: clamp((position.x - display.x) / width, 0, 1), + cy: clamp((position.y - display.y) / height, 0, 1), + visible: true, + }); + + if (this.samples.length > this.options.maxSamples) { + this.samples.shift(); + } + } finally { + this.isSampling = false; + } + } +} +``` + +- [ ] **Step 2: Typecheck and lint** + +Run: `node_modules/.bin/tsc --noEmit -p tsconfig.json && node_modules/.bin/biome check electron/native-bridge/cursor/recording/hyprlandCursorRecordingSession.ts` +Expected: No errors from either command. + +- [ ] **Step 3: Commit** + +```bash +git add electron/native-bridge/cursor/recording/hyprlandCursorRecordingSession.ts +git commit -m "feat(linux): add Hyprland cursor recording session" +``` + +--- + +### Task 3: Wire into the session factory + +**Files:** +- Modify: `electron/native-bridge/cursor/recording/factory.ts` + +**Interfaces:** +- Consumes: `HyprlandCursorRecordingSession` from Task 2's `./hyprlandCursorRecordingSession`. + +- [ ] **Step 1: Update the factory** + +In `electron/native-bridge/cursor/recording/factory.ts`, add the import and replace the Linux fallback branch: + +```ts +import type { Rectangle } from "electron"; +import { HyprlandCursorRecordingSession } from "./hyprlandCursorRecordingSession"; +import { MacNativeCursorRecordingSession } from "./macNativeCursorRecordingSession"; +import type { CursorRecordingSession } from "./session"; +import { TelemetryRecordingSession } from "./telemetryRecordingSession"; +import { WindowsNativeRecordingSession } from "./windowsNativeRecordingSession"; + +interface CreateCursorRecordingSessionOptions { + getDisplayBounds: () => Rectangle | null; + maxSamples: number; + platform: NodeJS.Platform; + sampleIntervalMs: number; + sourceId?: string | null; + startTimeMs?: number; +} + +export function createCursorRecordingSession( + options: CreateCursorRecordingSessionOptions, +): CursorRecordingSession { + if (options.platform === "win32") { + return new WindowsNativeRecordingSession({ + getDisplayBounds: options.getDisplayBounds, + maxSamples: options.maxSamples, + sampleIntervalMs: options.sampleIntervalMs, + sourceId: options.sourceId, + startTimeMs: options.startTimeMs, + }); + } + + if (options.platform === "darwin") { + return new MacNativeCursorRecordingSession({ + getDisplayBounds: options.getDisplayBounds, + maxSamples: options.maxSamples, + sampleIntervalMs: options.sampleIntervalMs, + startTimeMs: options.startTimeMs, + }); + } + + // Linux: capture cursor positions via an interval sampler. Hyprland's own IPC + // socket gives an accurate position at any time (moving or static); Electron's + // screen.getCursorScreenPoint() is known-broken (frozen at 0,0) there. Other + // compositors don't have an equivalent IPC channel today, so they keep using + // the Electron API. + if (process.env.HYPRLAND_INSTANCE_SIGNATURE) { + return new HyprlandCursorRecordingSession({ + getDisplayBounds: options.getDisplayBounds, + maxSamples: options.maxSamples, + sampleIntervalMs: options.sampleIntervalMs, + startTimeMs: options.startTimeMs, + }); + } + + return new TelemetryRecordingSession({ + getDisplayBounds: options.getDisplayBounds, + maxSamples: options.maxSamples, + sampleIntervalMs: options.sampleIntervalMs, + startTimeMs: options.startTimeMs, + }); +} +``` + +- [ ] **Step 2: Typecheck, lint, and run the full test suite** + +Run: `node_modules/.bin/tsc --noEmit -p tsconfig.json && node_modules/.bin/biome check electron/native-bridge/cursor/recording/factory.ts && node_modules/.bin/vitest --run` +Expected: No typecheck/lint errors; full test suite passes (including the new tests from Task 1). + +- [ ] **Step 3: Commit** + +```bash +git add electron/native-bridge/cursor/recording/factory.ts +git commit -m "feat(linux): use Hyprland IPC for cursor telemetry when available" +``` + +--- + +### Task 4: Manual verification on Hyprland + +**Files:** None — this task runs the app, no code changes. + +- [ ] **Step 1: Build and run** + +Run: `npm run dev` + +- [ ] **Step 2: Record with movement and stillness** + +In the running app, start a recording. Move the cursor around the screen, then deliberately hold it still in a couple of different spots for 2-3 seconds each (this is the exact case the old `screen.getCursorScreenPoint()` telemetry — and any frame-diffing-based approach — silently failed on). Stop the recording. + +- [ ] **Step 3: Inspect the raw telemetry file** + +Run: `cat ~/.config/openscreen/recordings/recording-.webm.cursor.json | python3 -c "import json,sys; d=json.load(sys.stdin); xs=set(s['cx'] for s in d['samples']); print('unique cx values:', len(xs)); print('sample:', d['samples'][:3])"` + +Expected: `unique cx values` is greater than 1 (position genuinely varies — this is the exact check that proved the bug originally, where every sample had `cx: 0, cy: 0`), and the printed samples show plausible `cx`/`cy` values in the `[0, 1]` range. + +- [ ] **Step 4: Confirm the overlay renders** + +Open the recording in the OpenScreen editor. Confirm a generic cursor icon (SVG arrow from `src/assets/cursors/`) appears and visibly tracks where the mouse was during the recording, including staying parked correctly during the still periods (not disappearing or snapping to a wrong position). + +- [ ] **Step 5: Confirm no regression on the audio path** + +Repeat steps 2-4 once with "record system audio" enabled in the recorder UI, to confirm this change (cursor-telemetry only) hasn't affected the unrelated audio-capture path. diff --git a/docs/superpowers/specs/2026-07-22-hyprland-cursor-position-design.md b/docs/superpowers/specs/2026-07-22-hyprland-cursor-position-design.md new file mode 100644 index 000000000..5c8d3b50b --- /dev/null +++ b/docs/superpowers/specs/2026-07-22-hyprland-cursor-position-design.md @@ -0,0 +1,182 @@ +# Hyprland cursor position telemetry + +## Context + +On Linux/Wayland (specifically reported on Hyprland), the mouse cursor never appears in +OpenScreen recordings, even though it renders fine live on screen during capture. + +Extensive debugging (see below) ruled out several plausible causes before landing on the +real one. This spec covers the fix for that real cause. + +### What was investigated and ruled out + +- **App-level cursor request**: `useScreenRecorder.ts`'s Linux capture path used the legacy + `chromeMediaSource` constraints, which have no cursor toggle at all (unlike the `cursor` + constraint available via `getDisplayMedia`). This was fixed (Linux now requests + `cursor: "always"` via `getDisplayMedia`, mirroring the Windows path) and is a real, + independent improvement, but it did **not** fix the reported bug — frame extraction from + real recordings, before and after, showed zero cursor pixels either way. +- **Hyprland hardware-cursor-plane theory**: suspected the GPU's hardware cursor overlay + bypasses the buffer PipeWire shares for screen capture. Config (`cursor.no_hardware_cursors`) + and env var (`WLR_NO_HARDWARE_CURSORS`) changes were tried across multiple full reboots. + Neither took effect, and a decisive test (recording via OBS's PipeWire capture on the same + machine, same portal) **showed the cursor correctly** — proving the system/compositor/portal + is not at fault. +- **DMA-BUF/EGL modifier negotiation errors** (`EGL_BAD_MATCH` in Chromium's log): real, but + a normal/benign renegotiation that resolves after 1-2 retries at stream start. Chromium ships + usable video either way. Not the cause. +- **`useSystemPicker` (native portal picker) toggle**: no effect, because OpenScreen's + `setDisplayMediaRequestHandler` in `electron/main.ts` always supplies a pre-selected + `DesktopCapturerSource` directly, so the native-picker code path never runs regardless of + this flag. + +### The actual root cause + +Electron's `desktopCapturer.getSources()` creates one PipeWire ScreenCast session that is +reused for both the source-picker thumbnails *and* the later recording (confirmed via an +Electron PR description: "Chromium has been patched to use the same generic capturer to +ensure that the source IDs remain valid for `getUserMedia`"). Whatever cursor mode gets +negotiated for that shared session at thumbnail time is what the recording is stuck with — +there is no public Electron/Chromium JS API to change it per-recording. OBS works because it +opens its own dedicated ScreenCast session just for recording. + +Digging further: `xdg-desktop-portal-hyprland`'s `AvailableCursorModes` is `3` (Hidden | +Embedded — no Metadata support), confirmed via a direct D-Bus query. Without Metadata mode, +there is no way to get cursor position/shape as data separate from baked-in pixels — the only +way to "read" the cursor via the portal is to decode it back out of Embedded-mode video, which +is expensive and, worse, **cannot detect a stationary cursor** (no frame-to-frame diff signal +when nothing moves). + +That ruled out a portal/PipeWire-based fix entirely for this compositor. The practical +alternative: Hyprland exposes cursor position directly over its own IPC control socket +(`hyprctl cursorpos`), independent of the portal, and it reports accurate coordinates whether +the cursor is moving or sitting still (verified directly against the compositor during this +investigation). + +Separately, the client-side rendering already fully supports a "position samples only, no +captured sprite" cursor overlay: `hasNativeCursorRecordingData` in `src/lib/cursor/nativeCursor.ts` +explicitly treats `provider: "none"` (today's Linux telemetry format) as valid, and draws one of +the app's existing generic pretty cursor SVGs (`src/assets/cursors/*.svg`) at the tracked +position. That path already renders correctly — it's exactly what macOS/Windows fall back to +today when they lack a captured asset. **No editor/rendering changes are needed.** + +The only actual bug: `TelemetryRecordingSession` samples position via Electron's +`screen.getCursorScreenPoint()`, which returns a frozen `(0, 0)` on Hyprland (confirmed from +real recording telemetry: every sample in a captured `.webm.cursor.json` had `cx: 0, cy: 0` +for the full recording). This is a known-class Electron/wlroots limitation — unprivileged +Wayland clients generally can't query global pointer position, and `screen.getCursorScreenPoint()` +doesn't have a working fallback for wlroots compositors. + +## Goal + +Get an accurate cursor position stream on Hyprland, feeding the *existing* +`provider: "none"` generic-cursor overlay pipeline. No sprite/shape capture, no clicks — just +position, matching the scope of what's genuinely fixable without a portal/PipeWire-based +native module. + +Out of scope, deliberately: +- Other Wayland compositors (GNOME, KDE, Sway, ...). They keep today's + `TelemetryRecordingSession` behavior unchanged. This spec doesn't claim to fix or regress + them — they were not part of the reported bug and weren't tested. +- Real captured cursor sprites/shapes (arrow vs. text-beam vs. resize handle, etc.) or click + visualization on Linux. Not achievable without Metadata-mode portal support, which this + compositor doesn't have. +- Any change to video capture itself. It already works. + +## Design + +### New position source: Hyprland IPC socket + +Hyprland exposes a Unix domain socket for `hyprctl`-style commands at: + +``` +${XDG_RUNTIME_DIR}/hypr/${HYPRLAND_INSTANCE_SIGNATURE}/.socket.sock +``` + +Writing `j/cursorpos` to that socket and reading the response back returns JSON: +`{"x": , "y": }` (global/layout coordinates, i.e. same coordinate space Electron's +`screen` module uses). The server closes the connection after responding — it's one +request-response pair per connection, not a persistent session. + +`HYPRLAND_INSTANCE_SIGNATURE` being set is both the detection signal ("are we running under +Hyprland?") and part of the socket path. It's already read elsewhere in this codebase +(`electron/main.ts`'s Wayland flag logic checks `XDG_SESSION_TYPE`/`WAYLAND_DISPLAY`; this is +the Hyprland-specific analogue). + +### New file: `electron/native-bridge/cursor/recording/hyprlandCursorRecordingSession.ts` + +Implements `CursorRecordingSession` (same interface as `TelemetryRecordingSession`, +`MacNativeCursorRecordingSession`, `WindowsNativeRecordingSession`). Shape mirrors +`TelemetryRecordingSession` closely: + +- `start()`: reset sample buffer, kick off a `setInterval` at `sampleIntervalMs` (same 33ms/30Hz + cadence already used today). +- Each tick: open a short-lived connection to the Hyprland socket, send `j/cursorpos`, parse the + JSON response, normalize `(x, y)` against `getDisplayBounds()` into the same `cx`/`cy` (0-1 + range) format `CursorRecordingSample` already uses — reuse the exact normalization logic + (clamp, divide by width/height) that's already in `TelemetryRecordingSession`. +- **Overlap guard**: since each sample now does socket I/O (not a synchronous call like + `screen.getCursorScreenPoint()`), guard against a slow tick overlapping the next timer fire + with a simple `isSampling` boolean flag — skip a tick if the previous one hasn't resolved yet, + rather than queuing. +- **Failure handling**: if the socket connection fails or times out (Hyprland IPC hiccup, socket + path race at startup), log a warning once and reuse the last-known good position for that + sample rather than dropping it or crashing the recording. Recording must never fail because + cursor telemetry hiccups. +- `stop()`: same shape as today — clear the interval, return + `{ version: 2, provider: "none", samples, assets: [] }`. + +A small internal helper (e.g. `queryHyprlandCursorPos(): Promise<{x: number, y: number} | null>`) +wraps the socket round-trip so it's independently testable (mock `net.Socket`). + +### `factory.ts` change + +```ts +// Linux: capture cursor positions via an interval sampler. +// Hyprland's own IPC socket gives an accurate position at any time (moving or static); +// Electron's screen.getCursorScreenPoint() is known-broken (frozen at 0,0) there. Other +// compositors don't have an equivalent IPC channel today, so they keep using the Electron API. +if (process.env.HYPRLAND_INSTANCE_SIGNATURE) { + return new HyprlandCursorRecordingSession({ ...same options... }); +} + +return new TelemetryRecordingSession({ ...unchanged... }); +``` + +No changes to `CursorRecordingSession`, `CursorRecordingData`, `CursorRecordingSample`, or any +consumer of recording data (`electron/ipc/handlers.ts`, `src/lib/cursor/nativeCursor.ts`, the +editor). The new session type produces the exact same wire format Linux already produces today +— just with real coordinates instead of frozen zeros. + +## Error handling + +- Socket connect/write/read errors → treated as a missed sample (reuse last known position), + not a fatal error. Recording continues. +- `HYPRLAND_INSTANCE_SIGNATURE` set but socket missing/unreachable (e.g. mid-session Hyprland + restart, corrupted env) → same missed-sample handling; if *every* sample fails, the recording + still completes with a flat cursor position (current-day behavior), not a crash. +- JSON parse failure on a malformed response → treated as a missed sample. + +## Testing + +### Unit test + +`electron/native-bridge/cursor/recording/hyprlandCursorRecordingSession.test.ts` (new), mocking +`net.createConnection` the way `useCameraDevices.test.ts` mocks `navigator.mediaDevices` — mock +socket emits a canned `{"x":100,"y":200}` response, assert the produced sample's `cx`/`cy` match +the expected normalized value against a given display-bounds rectangle. Cover: normal response, +malformed JSON, connection error (each should not throw out of `captureSample`). + +### Manual test (required — no automated coverage exists for real Hyprland IPC or the video +pipeline) + +1. On a Hyprland session, run OpenScreen (`npm run dev`), start a recording. +2. Move the cursor around, and also **let it sit still** for a couple of seconds in different + spots (this is the case the old code silently failed on: diffing-based approaches lose a + static cursor entirely). +3. Stop recording, open the result in the editor. +4. Confirm the generic cursor icon appears and tracks the recorded mouse movement, including + staying visible/parked correctly during the still periods. +5. Inspect `.webm.cursor.json` directly if needed — samples should show varying, + non-zero `cx`/`cy` values matching where the mouse actually was (this is exactly the file + that proved the bug: it showed `cx: 0, cy: 0` for every sample before this fix). diff --git a/electron/native-bridge/cursor/recording/factory.ts b/electron/native-bridge/cursor/recording/factory.ts index 9a82ffd55..b063cd571 100644 --- a/electron/native-bridge/cursor/recording/factory.ts +++ b/electron/native-bridge/cursor/recording/factory.ts @@ -1,4 +1,5 @@ import type { Rectangle } from "electron"; +import { HyprlandCursorRecordingSession } from "./hyprlandCursorRecordingSession"; import { MacNativeCursorRecordingSession } from "./macNativeCursorRecordingSession"; import type { CursorRecordingSession } from "./session"; import { TelemetryRecordingSession } from "./telemetryRecordingSession"; @@ -35,8 +36,20 @@ export function createCursorRecordingSession( }); } - // Linux: capture cursor positions via Electron's `screen` API on an interval. - // No cursor sprites/assets and no clicks, just position telemetry. + // Linux: capture cursor positions via an interval sampler. Hyprland's own IPC + // socket gives an accurate position at any time (moving or static); Electron's + // screen.getCursorScreenPoint() is known-broken (frozen at 0,0) there. Other + // compositors don't have an equivalent IPC channel today, so they keep using + // the Electron API. + if (process.env.HYPRLAND_INSTANCE_SIGNATURE) { + return new HyprlandCursorRecordingSession({ + getDisplayBounds: options.getDisplayBounds, + maxSamples: options.maxSamples, + sampleIntervalMs: options.sampleIntervalMs, + startTimeMs: options.startTimeMs, + }); + } + return new TelemetryRecordingSession({ getDisplayBounds: options.getDisplayBounds, maxSamples: options.maxSamples, diff --git a/electron/native-bridge/cursor/recording/hyprlandCursorIpc.test.ts b/electron/native-bridge/cursor/recording/hyprlandCursorIpc.test.ts new file mode 100644 index 000000000..a61e84f89 --- /dev/null +++ b/electron/native-bridge/cursor/recording/hyprlandCursorIpc.test.ts @@ -0,0 +1,117 @@ +import fs from "node:fs"; +import net from "node:net"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { queryHyprlandCursorPos, resolveHyprlandSocketPath } from "./hyprlandCursorIpc"; + +describe("resolveHyprlandSocketPath", () => { + const originalSignature = process.env.HYPRLAND_INSTANCE_SIGNATURE; + const originalRuntimeDir = process.env.XDG_RUNTIME_DIR; + + afterEach(() => { + if (originalSignature === undefined) { + delete process.env.HYPRLAND_INSTANCE_SIGNATURE; + } else { + process.env.HYPRLAND_INSTANCE_SIGNATURE = originalSignature; + } + if (originalRuntimeDir === undefined) { + delete process.env.XDG_RUNTIME_DIR; + } else { + process.env.XDG_RUNTIME_DIR = originalRuntimeDir; + } + }); + + it("returns null when HYPRLAND_INSTANCE_SIGNATURE is unset", () => { + delete process.env.HYPRLAND_INSTANCE_SIGNATURE; + process.env.XDG_RUNTIME_DIR = "/run/user/1000"; + + expect(resolveHyprlandSocketPath()).toBeNull(); + }); + + it("returns null when XDG_RUNTIME_DIR is unset", () => { + process.env.HYPRLAND_INSTANCE_SIGNATURE = "abc123"; + delete process.env.XDG_RUNTIME_DIR; + + expect(resolveHyprlandSocketPath()).toBeNull(); + }); + + it("joins the runtime dir, signature, and socket filename when both are set", () => { + process.env.HYPRLAND_INSTANCE_SIGNATURE = "abc123"; + process.env.XDG_RUNTIME_DIR = "/run/user/1000"; + + expect(resolveHyprlandSocketPath()).toBe("/run/user/1000/hypr/abc123/.socket.sock"); + }); +}); + +describe("queryHyprlandCursorPos", () => { + const testDirs: string[] = []; + const servers: net.Server[] = []; + + function createTestSocketPath() { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openscreen-hypr-ipc-test-")); + testDirs.push(dir); + return path.join(dir, ".socket.sock"); + } + + function startFakeHyprlandServer(socketPath: string, respond: (command: string) => string) { + const server = net.createServer((socket) => { + socket.once("data", (chunk) => { + socket.end(respond(chunk.toString("utf8"))); + }); + }); + server.listen(socketPath); + servers.push(server); + return new Promise((resolve) => server.once("listening", () => resolve())); + } + + afterEach(async () => { + for (const server of servers.splice(0)) { + await new Promise((resolve) => server.close(() => resolve())); + } + for (const dir of testDirs.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + it("resolves with the parsed position for a valid response", async () => { + const socketPath = createTestSocketPath(); + await startFakeHyprlandServer(socketPath, () => JSON.stringify({ x: 960, y: 540 })); + + await expect(queryHyprlandCursorPos(socketPath)).resolves.toEqual({ x: 960, y: 540 }); + }); + + it("sends the j/cursorpos command", async () => { + const socketPath = createTestSocketPath(); + let receivedCommand = ""; + await startFakeHyprlandServer(socketPath, (command) => { + receivedCommand = command; + return JSON.stringify({ x: 0, y: 0 }); + }); + + await queryHyprlandCursorPos(socketPath); + + expect(receivedCommand).toBe("j/cursorpos"); + }); + + it("resolves with null for a malformed JSON response", async () => { + const socketPath = createTestSocketPath(); + await startFakeHyprlandServer(socketPath, () => "not json"); + + await expect(queryHyprlandCursorPos(socketPath)).resolves.toBeNull(); + }); + + it("resolves with null for a response missing x/y fields", async () => { + const socketPath = createTestSocketPath(); + await startFakeHyprlandServer(socketPath, () => JSON.stringify({ foo: "bar" })); + + await expect(queryHyprlandCursorPos(socketPath)).resolves.toBeNull(); + }); + + it("resolves with null when the socket doesn't exist", async () => { + const socketPath = createTestSocketPath(); + // Never started a server on this path. + + await expect(queryHyprlandCursorPos(socketPath)).resolves.toBeNull(); + }); +}); diff --git a/electron/native-bridge/cursor/recording/hyprlandCursorIpc.ts b/electron/native-bridge/cursor/recording/hyprlandCursorIpc.ts new file mode 100644 index 000000000..095a6a859 --- /dev/null +++ b/electron/native-bridge/cursor/recording/hyprlandCursorIpc.ts @@ -0,0 +1,48 @@ +import net from "node:net"; +import path from "node:path"; + +const SOCKET_TIMEOUT_MS = 200; + +export function resolveHyprlandSocketPath(): string | null { + const signature = process.env.HYPRLAND_INSTANCE_SIGNATURE; + const runtimeDir = process.env.XDG_RUNTIME_DIR; + if (!signature || !runtimeDir) { + return null; + } + return path.join(runtimeDir, "hypr", signature, ".socket.sock"); +} + +export function queryHyprlandCursorPos( + socketPath: string, +): Promise<{ x: number; y: number } | null> { + return new Promise((resolve) => { + const socket = net.createConnection(socketPath); + const chunks: Buffer[] = []; + let settled = false; + + const finish = (value: { x: number; y: number } | null) => { + if (settled) return; + settled = true; + socket.destroy(); + resolve(value); + }; + + socket.setTimeout(SOCKET_TIMEOUT_MS, () => finish(null)); + socket.on("error", () => finish(null)); + socket.on("connect", () => socket.write("j/cursorpos")); + socket.on("data", (chunk) => chunks.push(chunk)); + socket.on("close", () => { + if (settled) return; + try { + const parsed = JSON.parse(Buffer.concat(chunks).toString("utf8")); + if (typeof parsed?.x === "number" && typeof parsed?.y === "number") { + finish({ x: parsed.x, y: parsed.y }); + return; + } + } catch { + // Falls through to finish(null) below. + } + finish(null); + }); + }); +} diff --git a/electron/native-bridge/cursor/recording/hyprlandCursorRecordingSession.ts b/electron/native-bridge/cursor/recording/hyprlandCursorRecordingSession.ts new file mode 100644 index 000000000..e5f4d7bd7 --- /dev/null +++ b/electron/native-bridge/cursor/recording/hyprlandCursorRecordingSession.ts @@ -0,0 +1,91 @@ +import { type Rectangle, screen } from "electron"; +import type { CursorRecordingData, CursorRecordingSample } from "../../../../src/native/contracts"; +import { queryHyprlandCursorPos, resolveHyprlandSocketPath } from "./hyprlandCursorIpc"; +import type { CursorRecordingSession } from "./session"; + +interface HyprlandCursorRecordingSessionOptions { + getDisplayBounds: () => Rectangle | null; + maxSamples: number; + sampleIntervalMs: number; + startTimeMs?: number; + /** Overrides the resolved Hyprland socket path. Test-only. */ + socketPath?: string; +} + +function clamp(value: number, min: number, max: number) { + return Math.min(max, Math.max(min, value)); +} + +export class HyprlandCursorRecordingSession implements CursorRecordingSession { + private samples: CursorRecordingSample[] = []; + private interval: NodeJS.Timeout | null = null; + private startTimeMs = 0; + private isSampling = false; + private lastPosition: { x: number; y: number } | null = null; + private readonly socketPath: string | null; + + constructor(private readonly options: HyprlandCursorRecordingSessionOptions) { + this.socketPath = options.socketPath ?? resolveHyprlandSocketPath(); + } + + async start(): Promise { + this.samples = []; + this.lastPosition = null; + this.startTimeMs = this.options.startTimeMs ?? Date.now(); + await this.captureSample(); + this.interval = setInterval(() => { + void this.captureSample(); + }, this.options.sampleIntervalMs); + } + + async stop(): Promise { + if (this.interval) { + clearInterval(this.interval); + this.interval = null; + } + + return { + version: 2, + provider: "none", + samples: this.samples, + assets: [], + }; + } + + private async captureSample() { + if (this.isSampling || !this.socketPath) { + return; + } + this.isSampling = true; + // Stamp the sample with the time the query was *sent*, not when the response + // came back. Stamping after the await biases every sample's timeMs later than + // when the cursor was actually there by one IPC round-trip, which reads as a + // constant lag between the cursor overlay and the recording during playback. + const requestedAtMs = Date.now(); + try { + const position = (await queryHyprlandCursorPos(this.socketPath)) ?? this.lastPosition; + if (!position) { + return; + } + this.lastPosition = position; + + const display = + this.options.getDisplayBounds() ?? screen.getDisplayNearestPoint(position).bounds; + const width = Math.max(1, display.width); + const height = Math.max(1, display.height); + + this.samples.push({ + timeMs: Math.max(0, requestedAtMs - this.startTimeMs), + cx: clamp((position.x - display.x) / width, 0, 1), + cy: clamp((position.y - display.y) / height, 0, 1), + visible: true, + }); + + if (this.samples.length > this.options.maxSamples) { + this.samples.shift(); + } + } finally { + this.isSampling = false; + } + } +} diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index e0cb2f154..97c9dbe79 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -325,10 +325,13 @@ export default function VideoEditor() { const { shortcuts, isMac } = useShortcuts(); // Windows recordings include captured cursor assets. macOS hides the system // cursor in ScreenCaptureKit and renders telemetry samples with OpenScreen's - // default arrow asset for the editable overlay. + // default arrow asset for the editable overlay. Linux never captures real + // cursor assets either (provider is always "none"), same as the macOS + // telemetry fallback, so it renders the same default arrow asset from + // position samples alone. const hasEditableCursorRecording = recordingCursorCaptureMode === "editable-overlay" && - (nativePlatform === "win32" || nativePlatform === "darwin") && + (nativePlatform === "win32" || nativePlatform === "darwin" || nativePlatform === "linux") && hasNativeCursorRecordingData(cursorRecordingData); const effectiveShowCursor = showCursor && hasEditableCursorRecording; const showCursorSettings = hasEditableCursorRecording; diff --git a/src/hooks/useScreenRecorder.ts b/src/hooks/useScreenRecorder.ts index af95b4722..4bfbc2466 100644 --- a/src/hooks/useScreenRecorder.ts +++ b/src/hooks/useScreenRecorder.ts @@ -38,6 +38,60 @@ const DEFAULT_HEIGHT = 1080; const CODEC_ALIGNMENT = 2; const BITS_PER_MEGABIT = 1_000_000; + +const FIRST_FRAME_TIMEOUT_MS = 3000; + +/** + * Resolve once `track` has produced a real, decodable frame -- not merely once + * `track.muted` reads false. On Linux/PipeWire the desktop-capture portal can still be + * negotiating (DMA-BUF modifier renegotiation, `no_hardware_cursors`, etc.) well after + * the track reports unmuted, so anchoring recording start to "now" makes MediaRecorder's + * own internal frame-0 arrive silently late: the declared recording duration (wall time + * from button-press to stop) ends up longer than the video's real content span (playback + * jumps to the end early), and cursor telemetry -- anchored to the same "now" -- drifts + * out of sync with the picture. Waiting for an actual rendered frame here, before + * `recorder.start()` is ever called, keeps duration accounting, cursor telemetry, and + * MediaRecorder's timeline all pointing at the same wall-clock instant. + * + * Falls back to `Date.now()` after `FIRST_FRAME_TIMEOUT_MS` if no frame arrives (e.g. a + * genuinely stalled capture), so recording start is never blocked indefinitely. + */ +function waitForFirstVideoFrame(track: MediaStreamTrack): Promise { + return new Promise((resolve) => { + const video = document.createElement("video"); + video.muted = true; + video.playsInline = true; + video.srcObject = new MediaStream([track]); + + let settled = false; + const cleanup = () => { + video.srcObject = null; + video.remove(); + }; + const finish = (timeMs: number) => { + if (settled) return; + settled = true; + clearTimeout(timeoutId); + cleanup(); + resolve(timeMs); + }; + + const timeoutId = setTimeout(() => finish(Date.now()), FIRST_FRAME_TIMEOUT_MS); + + if (typeof video.requestVideoFrameCallback !== "function") { + // API unavailable (older Chromium); fall back to a short settle delay rather + // than blocking on the full timeout every time. + finish(Date.now()); + return; + } + + video.requestVideoFrameCallback(() => finish(Date.now())); + void video.play().catch(() => { + // Autoplay/play() rejection still lets requestVideoFrameCallback or the + // timeout resolve this promise; nothing else to do here. + }); + }); +} const CHROME_MEDIA_SOURCE = "desktop"; const RECORDING_FILE_PREFIX = "recording-"; const VIDEO_FILE_EXTENSION = ".webm"; @@ -1193,18 +1247,75 @@ export function useScreenRecorder(): UseScreenRecorderReturn { const screenCapture = (async (): Promise => { const platform = await window.electronAPI.getPlatform(); - if (platform === "win32") { + if (platform === "win32" || platform === "linux") { // getDisplayMedia + setDisplayMediaRequestHandler (main.ts) supplies the - // pre-selected source. Editable cursor mode excludes the system cursor so - // the editor can render a replacement; system mode bakes it into the video. + // pre-selected source. On Windows, editable cursor mode excludes the system + // cursor so the editor can render a replacement; system mode bakes it into + // the video. On Linux, the editor's cursor overlay (see + // src/lib/cursor/nativeCursor.ts's provider:"none" handling) always runs — + // createCursorRecordingSession's Linux sessions (HyprlandCursorRecordingSession + // on Hyprland, TelemetryRecordingSession elsewhere) always produce position + // telemetry the overlay draws a generic cursor from. So Linux always requests + // "never" here too: on setups where the system cursor *does* end up baked into + // the raw frames (e.g. X11, or a Wayland compositor whose portal supports + // Embedded cursor mode), requesting "always" would double it up with the + // overlay. On Hyprland specifically this constraint has no effect either way + // (the portal never bakes the cursor in regardless of what's requested), so the + // overlay remains the only cursor source there. + const cursor: "always" | "never" = + platform === "linux" || cursorCaptureMode === "editable-overlay" ? "never" : "always"; + + if (platform === "linux" && systemAudioEnabled) { + // Electron's getDisplayMedia loopback audio (main.ts's + // setDisplayMediaRequestHandler) only supports win32/macOS, so system + // audio on Linux still has to go through the legacy desktop-audio + // constraints and get merged with the getDisplayMedia video track. + const [videoOnlyStream, audioOnlyStream] = await Promise.all([ + navigator.mediaDevices.getDisplayMedia({ + video: { + cursor, + width: { max: TARGET_WIDTH }, + height: { max: TARGET_HEIGHT }, + frameRate: { ideal: TARGET_FRAME_RATE }, + } as MediaTrackConstraints, + audio: false, + } as DisplayMediaStreamOptions), + navigator.mediaDevices + .getUserMedia({ + audio: { + mandatory: { + chromeMediaSource: CHROME_MEDIA_SOURCE, + chromeMediaSourceId: selectedSource.id, + }, + }, + video: false, + } as unknown as MediaStreamConstraints) + .catch((audioErr) => { + console.warn( + "System audio capture failed, falling back to video-only:", + audioErr, + ); + toast.error(t("recording.systemAudioUnavailable")); + return null; + }), + ]); + + const combined = new MediaStream(); + for (const track of videoOnlyStream.getVideoTracks()) combined.addTrack(track); + if (audioOnlyStream) { + for (const track of audioOnlyStream.getAudioTracks()) combined.addTrack(track); + } + return combined; + } + return navigator.mediaDevices.getDisplayMedia({ video: { - cursor: cursorCaptureMode === "editable-overlay" ? "never" : "always", + cursor, width: { max: TARGET_WIDTH }, height: { max: TARGET_HEIGHT }, frameRate: { ideal: TARGET_FRAME_RATE }, } as MediaTrackConstraints, - audio: systemAudioEnabled, + audio: platform === "win32" ? systemAudioEnabled : false, } as DisplayMediaStreamOptions); } @@ -1359,6 +1470,11 @@ export function useScreenRecorder(): UseScreenRecorderReturn { ); } + // See waitForFirstVideoFrame's doc comment: block here until the track is + // actually producing frames, so duration accounting, cursor telemetry, and + // MediaRecorder's own timeline all anchor to the same wall-clock instant. + const firstFrameAtMs = await waitForFirstVideoFrame(videoTrack); + if (!isCountdownRunActive(countdownRunToken)) { teardownMedia(); return; @@ -1388,7 +1504,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn { return; } - recordingId.current = Date.now(); + recordingId.current = firstFrameAtMs; const activeRecordingId = recordingId.current; screenRecorder.current = createRecorderHandle( stream.current, @@ -1418,12 +1534,12 @@ export function useScreenRecorder(): UseScreenRecorderReturn { } accumulatedDurationMs.current = 0; - segmentStartedAt.current = Date.now(); + segmentStartedAt.current = firstFrameAtMs; allowAutoFinalize.current = true; setRecording(true); setPaused(false); setElapsedSeconds(0); - window.electronAPI?.setRecordingState(true, recordingId.current, cursorCaptureMode); + window.electronAPI?.setRecordingState(true, firstFrameAtMs, cursorCaptureMode); const activeScreenRecorder = screenRecorder.current; const activeWebcamRecorder = webcamRecorder.current;