From 18e31e394ee427513608abcaab59eb4f8609b515 Mon Sep 17 00:00:00 2001 From: Eduardo Augusto Lima Bueno Date: Wed, 22 Jul 2026 16:59:27 -0300 Subject: [PATCH 01/11] docs: add design spec for Hyprland cursor position telemetry fix Documents the investigation (ruled out app-level cursor constraint, hardware-cursor-plane theory, EGL renegotiation noise, useSystemPicker) and the actual root cause: Electron's desktopCapturer reuses one cursor-less PipeWire session across picker + recording, and xdg-desktop-portal-hyprland lacks Metadata cursor mode entirely. Fix sources cursor position from Hyprland's own IPC socket instead of the broken screen.getCursorScreenPoint(), feeding the existing provider:"none" generic-cursor overlay pipeline. Co-Authored-By: Claude Sonnet 5 --- ...6-07-22-hyprland-cursor-position-design.md | 182 ++++++++++++++++++ 1 file changed, 182 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-22-hyprland-cursor-position-design.md 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). From 1dea378149b5ac9e4ecb36853ee629d671f154b4 Mon Sep 17 00:00:00 2001 From: Eduardo Augusto Lima Bueno Date: Wed, 22 Jul 2026 17:03:17 -0300 Subject: [PATCH 02/11] docs: add implementation plan for Hyprland cursor position telemetry Co-Authored-By: Claude Sonnet 5 --- .../2026-07-22-hyprland-cursor-position.md | 468 ++++++++++++++++++ 1 file changed, 468 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-22-hyprland-cursor-position.md 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. From 460f2dd17ecea411ffeacfddc66151d7743bb0ed Mon Sep 17 00:00:00 2001 From: Eduardo Augusto Lima Bueno Date: Wed, 22 Jul 2026 17:06:06 -0300 Subject: [PATCH 03/11] fix(linux): request embedded cursor via getDisplayMedia The Linux capture path used the legacy chromeMediaSource constraints, which have no cursor toggle at all (unlike the cursor constraint available via getDisplayMedia). Mirrors the Windows path: system audio still goes through the legacy desktop-audio API since Electron's loopback audio isn't supported on Linux. This alone doesn't fix cursor visibility on Hyprland (see the design spec for the real root cause and fix), but it's the correct constraint to request regardless of compositor. Co-Authored-By: Claude Sonnet 5 --- src/hooks/useScreenRecorder.ts | 68 +++++++++++++++++++++++++++++++--- 1 file changed, 63 insertions(+), 5 deletions(-) diff --git a/src/hooks/useScreenRecorder.ts b/src/hooks/useScreenRecorder.ts index af95b4722..0e9644afe 100644 --- a/src/hooks/useScreenRecorder.ts +++ b/src/hooks/useScreenRecorder.ts @@ -1193,18 +1193,76 @@ 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. Linux has no native cursor-sprite capture (see + // createCursorRecordingSession's TelemetryRecordingSession fallback, which + // only records position, not a drawable cursor), so there's nothing to draw + // as a replacement overlay — the system cursor must always be baked into the + // raw frames there, or recordings end up with no cursor at all. The legacy + // chromeMediaSource desktop-capture constraints used below for other platforms + // have no cursor constraint at all, which is why Linux recordings lost the + // cursor when going through PipeWire on Wayland. + const cursor: "always" | "never" = + platform === "linux" + ? "always" + : 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); } From e08b10780143357e5b91edfe9bbcc0127aaa6066 Mon Sep 17 00:00:00 2001 From: Eduardo Augusto Lima Bueno Date: Wed, 22 Jul 2026 17:07:36 -0300 Subject: [PATCH 04/11] feat(linux): add Hyprland IPC socket protocol module for cursor position --- .../recording/hyprlandCursorIpc.test.ts | 117 ++++++++++++++++++ .../cursor/recording/hyprlandCursorIpc.ts | 48 +++++++ 2 files changed, 165 insertions(+) create mode 100644 electron/native-bridge/cursor/recording/hyprlandCursorIpc.test.ts create mode 100644 electron/native-bridge/cursor/recording/hyprlandCursorIpc.ts 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); + }); + }); +} From 5fe7d488888cf55f91d237a5e3a48484618fa50d Mon Sep 17 00:00:00 2001 From: Eduardo Augusto Lima Bueno Date: Wed, 22 Jul 2026 17:10:22 -0300 Subject: [PATCH 05/11] feat(linux): add Hyprland cursor recording session --- .../hyprlandCursorRecordingSession.ts | 86 +++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100644 electron/native-bridge/cursor/recording/hyprlandCursorRecordingSession.ts diff --git a/electron/native-bridge/cursor/recording/hyprlandCursorRecordingSession.ts b/electron/native-bridge/cursor/recording/hyprlandCursorRecordingSession.ts new file mode 100644 index 000000000..68668cf74 --- /dev/null +++ b/electron/native-bridge/cursor/recording/hyprlandCursorRecordingSession.ts @@ -0,0 +1,86 @@ +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; + } + } +} From 860f320f5a8372cae616693f11f1ac35059f6f86 Mon Sep 17 00:00:00 2001 From: Eduardo Augusto Lima Bueno Date: Wed, 22 Jul 2026 17:13:01 -0300 Subject: [PATCH 06/11] feat(linux): use Hyprland IPC for cursor telemetry when available --- .../native-bridge/cursor/recording/factory.ts | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) 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, From ba46002dd10b1f0bca229f0cd582d3e654aa149d Mon Sep 17 00:00:00 2001 From: Eduardo Augusto Lima Bueno Date: Wed, 22 Jul 2026 17:21:05 -0300 Subject: [PATCH 07/11] fix(linux): request cursor:never to avoid doubling the editor's overlay cursor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The editor's cursor overlay (src/lib/cursor/nativeCursor.ts) always draws a generic cursor for Linux recordings, since Linux cursor sessions always produce provider:"none" position telemetry. Requesting cursor:"always" from getDisplayMedia was based on the (incorrect, now-superseded) assumption that Linux had no overlay at all — on any setup where that constraint actually bakes the cursor into the raw video (X11, or a Wayland compositor with Embedded cursor mode support, unlike Hyprland's portal), it would double up with the overlay. Requesting "never" matches how Windows already avoids this in editable-overlay mode. Co-Authored-By: Claude Sonnet 5 --- src/hooks/useScreenRecorder.ts | 25 ++++++++++++------------- 1 file changed, 12 insertions(+), 13 deletions(-) diff --git a/src/hooks/useScreenRecorder.ts b/src/hooks/useScreenRecorder.ts index 0e9644afe..06c22c403 100644 --- a/src/hooks/useScreenRecorder.ts +++ b/src/hooks/useScreenRecorder.ts @@ -1197,20 +1197,19 @@ export function useScreenRecorder(): UseScreenRecorderReturn { // getDisplayMedia + setDisplayMediaRequestHandler (main.ts) supplies the // 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. Linux has no native cursor-sprite capture (see - // createCursorRecordingSession's TelemetryRecordingSession fallback, which - // only records position, not a drawable cursor), so there's nothing to draw - // as a replacement overlay — the system cursor must always be baked into the - // raw frames there, or recordings end up with no cursor at all. The legacy - // chromeMediaSource desktop-capture constraints used below for other platforms - // have no cursor constraint at all, which is why Linux recordings lost the - // cursor when going through PipeWire on Wayland. + // 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" - ? "always" - : cursorCaptureMode === "editable-overlay" - ? "never" - : "always"; + platform === "linux" || cursorCaptureMode === "editable-overlay" ? "never" : "always"; if (platform === "linux" && systemAudioEnabled) { // Electron's getDisplayMedia loopback audio (main.ts's From 82ceedfee72a936ed0ee90f96eb5a0d67531f3ec Mon Sep 17 00:00:00 2001 From: Eduardo Augusto Lima Bueno Date: Wed, 22 Jul 2026 17:33:43 -0300 Subject: [PATCH 08/11] fix(linux): allow the editable-overlay cursor to render on Linux hasEditableCursorRecording gated the whole cursor-overlay feature (preview and export) to win32/darwin only, regardless of whether valid recording data existed. This predates the Hyprland telemetry fix and made sense while Linux's position samples were always frozen garbage -- but it also means the newly-fixed, real position data silently never got drawn. Linux never captures real cursor assets either way (provider is always "none"), same as the existing macOS telemetry fallback, so it renders the same default arrow asset from position samples alone. Co-Authored-By: Claude Sonnet 5 --- src/components/video-editor/VideoEditor.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) 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; From fe3ddb9f887f41479e2df65ab049a259fa6fe509 Mon Sep 17 00:00:00 2001 From: Eduardo Augusto Lima Bueno Date: Wed, 22 Jul 2026 17:41:54 -0300 Subject: [PATCH 09/11] fix(linux): timestamp Hyprland cursor samples before the IPC round-trip captureSample stamped timeMs with Date.now() after awaiting the socket query, so every sample's timestamp was biased later than the moment the cursor was actually at that position by one IPC round-trip. That reads as a constant lag between the drawn cursor and the recording during playback. Stamp it right before the query goes out instead. Co-Authored-By: Claude Sonnet 5 --- .../cursor/recording/hyprlandCursorRecordingSession.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/electron/native-bridge/cursor/recording/hyprlandCursorRecordingSession.ts b/electron/native-bridge/cursor/recording/hyprlandCursorRecordingSession.ts index 68668cf74..e5f4d7bd7 100644 --- a/electron/native-bridge/cursor/recording/hyprlandCursorRecordingSession.ts +++ b/electron/native-bridge/cursor/recording/hyprlandCursorRecordingSession.ts @@ -57,6 +57,11 @@ export class HyprlandCursorRecordingSession implements CursorRecordingSession { 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) { @@ -70,7 +75,7 @@ export class HyprlandCursorRecordingSession implements CursorRecordingSession { const height = Math.max(1, display.height); this.samples.push({ - timeMs: Math.max(0, Date.now() - this.startTimeMs), + 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, From 342b4f93c272bf978b7d2d366b5b3fc10e3ab42e Mon Sep 17 00:00:00 2001 From: Eduardo Augusto Lima Bueno Date: Wed, 22 Jul 2026 18:39:59 -0300 Subject: [PATCH 10/11] fix(linux): anchor cursor telemetry to when the capture track goes live recordingId.current (Date.now() right after acquiring the track) was used as cursor telemetry's zero-point, but on Linux/PipeWire the track can still be muted at that moment -- mid DMA-BUF modifier renegotiation (see main.ts) or renegotiating again from applyConstraints just above. Every telemetry sample ends up timestamped slightly before the frame that should show that position actually exists, which plays back as the cursor lagging the recording, confirmed even scrubbing to a single paused frame (so it wasn't a live-playback rendering artifact). Starts listening for the track's unmute event right after track acquisition (resolves immediately if already unmuted) and uses that resolved time -- instead of recordingId.current -- as the cursor session's startTimeMs. Fire-and-forget like the original call, so recorder.start() and the recording UI state are unaffected; only the cursor telemetry's anchor point changes. recordingId.current is left untouched for file naming. Co-Authored-By: Claude Sonnet 5 --- src/hooks/useScreenRecorder.ts | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/src/hooks/useScreenRecorder.ts b/src/hooks/useScreenRecorder.ts index 06c22c403..cd7d40634 100644 --- a/src/hooks/useScreenRecorder.ts +++ b/src/hooks/useScreenRecorder.ts @@ -1416,6 +1416,28 @@ export function useScreenRecorder(): UseScreenRecorderReturn { ); } + // On Linux/PipeWire the capture track can still be muted here (mid-negotiation -- + // see main.ts's DMA-BUF modifier renegotiation comment, and applyConstraints above + // can itself trigger another round). Cursor telemetry anchored to Date.now() right + // after acquiring the track, instead of to when frames actually start flowing, makes + // every sample know the cursor's position slightly before the frame showing it + // exists -- which plays back as the cursor lagging the recording. Start listening + // now (cheap, non-blocking) so this is almost always already resolved by the time + // it's consumed below; only a genuinely slow negotiation waits, capped at 500ms. + const cursorStartTimeMsPromise: Promise = videoTrack.muted + ? new Promise((resolve) => { + const onUnmute = () => { + clearTimeout(timeoutId); + resolve(Date.now()); + }; + const timeoutId = setTimeout(() => { + videoTrack.removeEventListener("unmute", onUnmute); + resolve(Date.now()); + }, 500); + videoTrack.addEventListener("unmute", onUnmute, { once: true }); + }) + : Promise.resolve(Date.now()); + if (!isCountdownRunActive(countdownRunToken)) { teardownMedia(); return; @@ -1480,7 +1502,9 @@ export function useScreenRecorder(): UseScreenRecorderReturn { setRecording(true); setPaused(false); setElapsedSeconds(0); - window.electronAPI?.setRecordingState(true, recordingId.current, cursorCaptureMode); + void cursorStartTimeMsPromise.then((cursorStartTimeMs) => { + window.electronAPI?.setRecordingState(true, cursorStartTimeMs, cursorCaptureMode); + }); const activeScreenRecorder = screenRecorder.current; const activeWebcamRecorder = webcamRecorder.current; From 646a66d3c190088cd164229a39fc7ac4e68d7b07 Mon Sep 17 00:00:00 2001 From: Eduardo Augusto Lima Bueno Date: Sun, 26 Jul 2026 01:06:50 -0300 Subject: [PATCH 11/11] fix(linux): anchor recording start to the first real captured frame MediaRecorder's internal frame-0 timeline starts whenever the track delivers its first real frame, not when recorder.start() is called. On Linux/PipeWire the desktop-capture portal can still be negotiating (DMA-BUF modifier renegotiation) for several seconds after the track reports unmuted, so anchoring duration accounting and cursor telemetry to Date.now() at button-press time let both drift out of sync with the actual video content: declared duration exceeded the real content span (playback jumped to the end early) and the cursor overlay lagged the picture. waitForFirstVideoFrame() now blocks on an actual rendered frame (via requestVideoFrameCallback, capped at 3s) before recorder.start() is ever called, so recordingId, segmentStartedAt, and the cursor-telemetry start time all anchor to the same verified wall-clock instant. --- src/hooks/useScreenRecorder.ts | 87 ++++++++++++++++++++++++---------- 1 file changed, 61 insertions(+), 26 deletions(-) diff --git a/src/hooks/useScreenRecorder.ts b/src/hooks/useScreenRecorder.ts index cd7d40634..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"; @@ -1416,27 +1470,10 @@ export function useScreenRecorder(): UseScreenRecorderReturn { ); } - // On Linux/PipeWire the capture track can still be muted here (mid-negotiation -- - // see main.ts's DMA-BUF modifier renegotiation comment, and applyConstraints above - // can itself trigger another round). Cursor telemetry anchored to Date.now() right - // after acquiring the track, instead of to when frames actually start flowing, makes - // every sample know the cursor's position slightly before the frame showing it - // exists -- which plays back as the cursor lagging the recording. Start listening - // now (cheap, non-blocking) so this is almost always already resolved by the time - // it's consumed below; only a genuinely slow negotiation waits, capped at 500ms. - const cursorStartTimeMsPromise: Promise = videoTrack.muted - ? new Promise((resolve) => { - const onUnmute = () => { - clearTimeout(timeoutId); - resolve(Date.now()); - }; - const timeoutId = setTimeout(() => { - videoTrack.removeEventListener("unmute", onUnmute); - resolve(Date.now()); - }, 500); - videoTrack.addEventListener("unmute", onUnmute, { once: true }); - }) - : Promise.resolve(Date.now()); + // 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(); @@ -1467,7 +1504,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn { return; } - recordingId.current = Date.now(); + recordingId.current = firstFrameAtMs; const activeRecordingId = recordingId.current; screenRecorder.current = createRecorderHandle( stream.current, @@ -1497,14 +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); - void cursorStartTimeMsPromise.then((cursorStartTimeMs) => { - window.electronAPI?.setRecordingState(true, cursorStartTimeMs, cursorCaptureMode); - }); + window.electronAPI?.setRecordingState(true, firstFrameAtMs, cursorCaptureMode); const activeScreenRecorder = screenRecorder.current; const activeWebcamRecorder = webcamRecorder.current;