Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
468 changes: 468 additions & 0 deletions docs/superpowers/plans/2026-07-22-hyprland-cursor-position.md

Large diffs are not rendered by default.

182 changes: 182 additions & 0 deletions docs/superpowers/specs/2026-07-22-hyprland-cursor-position-design.md
Original file line number Diff line number Diff line change
@@ -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.
Comment on lines +13 to +18

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Align the design with the implemented capture and editor behavior.

The PR requests cursor: "never" to avoid duplicate cursors and includes Linux editor eligibility work. Update the references to cursor: "always" and “No editor/rendering changes are needed” so this remains an accurate implementation record.

Also applies to: 56-61

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/superpowers/specs/2026-07-22-hyprland-cursor-position-design.md` around
lines 13 - 18, Update the design document’s capture and editor-behavior
references to match the implementation: describe the Linux capture path in
useScreenRecorder.ts as requesting cursor "always", and revise the “No
editor/rendering changes are needed” statements to reflect the implemented Linux
editor eligibility work. Apply the same corrections to both referenced sections
while preserving the documented distinction between capture changes and the
original cursor-pixel bug.

- **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": <int>, "y": <int>}` (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 `<recording>.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).
17 changes: 15 additions & 2 deletions electron/native-bridge/cursor/recording/factory.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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,
Expand Down
117 changes: 117 additions & 0 deletions electron/native-bridge/cursor/recording/hyprlandCursorIpc.test.ts
Original file line number Diff line number Diff line change
@@ -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<void>((resolve) => server.once("listening", () => resolve()));
}

afterEach(async () => {
for (const server of servers.splice(0)) {
await new Promise<void>((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();
});
});
48 changes: 48 additions & 0 deletions electron/native-bridge/cursor/recording/hyprlandCursorIpc.ts
Original file line number Diff line number Diff line change
@@ -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", () => {
Comment on lines +30 to +34

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Make the IPC deadline absolute and bounded. socket.setTimeout() only fires after inactivity, so a peer that continuously streams data without closing can keep cursor sampling stuck and allows unbounded response buffering. Use a wall-clock deadline plus a max-response byte limit, and add coverage for a non-closing peer that writes data periodically.

📍 Affects 3 files
  • electron/native-bridge/cursor/recording/hyprlandCursorIpc.ts#L30-L34 (this comment)
  • electron/native-bridge/cursor/recording/hyprlandCursorIpc.test.ts#L77-L116
  • docs/superpowers/plans/2026-07-22-hyprland-cursor-position.md#L135-L154
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@electron/native-bridge/cursor/recording/hyprlandCursorIpc.ts` around lines 30
- 34, The IPC handling around socket.setTimeout in hyprlandCursorIpc.ts must use
an absolute wall-clock deadline instead of relying solely on inactivity, enforce
a maximum response byte limit before appending to chunks, and finish safely on
either limit while preserving existing cleanup behavior. Add coverage in
hyprlandCursorIpc.test.ts for a peer that periodically writes without closing,
plus assertions for oversized responses. Update the corresponding implementation
plan in docs/superpowers/plans/2026-07-22-hyprland-cursor-position.md to
document the deadline, response limit, and test coverage.

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);
});
});
}
Loading
Loading