Skip to content

fix(linux): Hyprland cursor telemetry + anchor recording start to the first real frame#158

Closed
eduardoaugustolb wants to merge 11 commits into
getopenscreen:mainfrom
eduardoaugustolb:feature/hyprland-cursor-telemetry
Closed

fix(linux): Hyprland cursor telemetry + anchor recording start to the first real frame#158
eduardoaugustolb wants to merge 11 commits into
getopenscreen:mainfrom
eduardoaugustolb:feature/hyprland-cursor-telemetry

Conversation

@eduardoaugustolb

@eduardoaugustolb eduardoaugustolb commented Jul 26, 2026

Copy link
Copy Markdown

Summary

  • Adds Hyprland IPC-based cursor position telemetry so the cursor is visible in Wayland/Hyprland screen recordings.
  • Fixes the double-cursor regression by requesting cursor:never from the portal and relying on the editor's editable overlay on Linux.
  • Timestamps Hyprland cursor samples before the IPC round-trip instead of after, removing a fixed cursor/video lag bias.
  • Anchors recording start (duration accounting, recordingId, and cursor telemetry start time) to the first real captured frame instead of to Date.now() at button-press time. On Linux/PipeWire, the desktop-capture portal can still be negotiating (DMA-BUF modifier renegotiation) for several seconds after the track reports unmuted; anchoring to wall-clock "button pressed" time let the declared recording duration exceed the video's real content span (playback would jump to the end early) and let the cursor overlay drift out of sync with the picture. waitForFirstVideoFrame() now blocks on an actual rendered frame (via requestVideoFrameCallback, capped at 3s) before recorder.start() is called, so everything anchors to the same verified instant.

Fixes #99 (comment)

Test plan

  • tsc --noEmit passes clean
  • Full test suite passes (435 tests / 55 files)
  • Manually recorded on Hyprland/Wayland: cursor now visible and in sync with the video
  • Manually verified declared recording duration now matches actual playable content (no more early jump-to-end)

Summary by CodeRabbit

  • New Features

    • Linux Hyprland recordings now capture and display moving cursor positions.
    • Linux recordings support editable cursor overlays.
    • Linux system audio capture now combines audio and video streams when available, with video-only fallback if audio capture fails.
    • Recordings now begin timing from the first decodable video frame for improved synchronization.
  • Bug Fixes

    • Cursor sampling failures are handled gracefully while preserving the last known position.

eduardoaugustolb and others added 11 commits July 22, 2026 16:59
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 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
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.
…ay cursor

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.
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.
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.
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.
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.
@coderabbitai

coderabbitai Bot commented Jul 26, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Adds Hyprland IPC cursor sampling and normalized recording support, routes Hyprland Linux sessions through it, enables Linux editable cursor overlays, and updates browser recording capture and timeline initialization.

Changes

Hyprland cursor telemetry

Layer / File(s) Summary
Hyprland IPC source and protocol validation
docs/superpowers/specs/..., electron/native-bridge/cursor/recording/hyprlandCursorIpc.ts, electron/native-bridge/cursor/recording/hyprlandCursorIpc.test.ts, docs/superpowers/plans/...
Resolves the Hyprland socket, sends j/cursorpos, parses numeric coordinates, returns null on failures, and tests these behaviors.
Normalized Hyprland recording session
electron/native-bridge/cursor/recording/hyprlandCursorRecordingSession.ts, docs/superpowers/specs/..., docs/superpowers/plans/...
Polls cursor position, normalizes coordinates to display bounds, reuses the last position after failed queries, bounds samples, and emits the existing provider: "none" format.
Linux session routing and manual verification
electron/native-bridge/cursor/recording/factory.ts, docs/superpowers/plans/...
Selects the Hyprland session when HYPRLAND_INSTANCE_SIGNATURE is set and retains existing fallbacks for other platforms and compositors.

Linux browser recording flow

Layer / File(s) Summary
Capture setup and first-frame timeline anchoring
src/hooks/useScreenRecorder.ts
Adds Linux cursor constraints, separate Linux video/audio capture when enabled, and first-decodable-frame timestamping for recording state and segments.
Linux editable cursor recording eligibility
src/components/video-editor/VideoEditor.tsx
Treats Linux editable-overlay recordings as eligible when native cursor recording data is available.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant RecordingFactory
  participant HyprlandCursorRecordingSession
  participant HyprlandIPC
  participant HyprlandSocket
  RecordingFactory->>HyprlandCursorRecordingSession: create on Linux with HYPRLAND_INSTANCE_SIGNATURE
  HyprlandCursorRecordingSession->>HyprlandIPC: query cursor position
  HyprlandIPC->>HyprlandSocket: send j/cursorpos
  HyprlandSocket-->>HyprlandIPC: return JSON coordinates
  HyprlandIPC-->>HyprlandCursorRecordingSession: return parsed position or null
  HyprlandCursorRecordingSession-->>RecordingFactory: emit normalized cursor samples
Loading

Possibly related PRs

Suggested reviewers: etiennelescot

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Description check ⚠️ Warning The description covers the summary, related issue, and testing, but it omits required template sections like type, release impact, desktop impact, and screenshots. Add the missing template sections: type of change, release impact, desktop impact, screenshots/video, and use the repository's Fixes/Refs issue format.
Out of Scope Changes check ⚠️ Warning The PR also changes recording-start anchoring and Linux system-audio capture in useScreenRecorder, which are outside the cursor-only scope of issue #99. Split the recording-timing and Linux audio work into a separate PR, or justify them explicitly in the issue scope before merging.
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title is specific and accurately captures the main Linux/Hyprland cursor telemetry and recording-start timing changes.
Linked Issues check ✅ Passed The Hyprland IPC cursor telemetry, Linux overlay, and cursor-mode fixes directly address issue #99's missing-cursor behavior.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@eduardoaugustolb

Copy link
Copy Markdown
Author

Fechando para reabrir sem os commits de docs/plans do superpowers (não deveriam fazer parte deste PR).

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 8

🤖 Prompt for all review comments with 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.

Inline comments:
In `@docs/superpowers/plans/2026-07-22-hyprland-cursor-position.md`:
- Around line 308-339: Update the captureSample method to record requestedAtMs
immediately before awaiting queryHyprlandCursorPos, then use that captured
timestamp to calculate samples’ timeMs instead of calling Date.now() after the
IPC response. Preserve the existing position fallback and sampling behavior.

In `@docs/superpowers/specs/2026-07-22-hyprland-cursor-position-design.md`:
- Around line 92-94: Label the fenced code block containing the Hyprland socket
path with an appropriate language identifier such as text to satisfy the
fenced-code-language lint rule.
- Around line 13-18: Update the cursor-flow specification in the sections
describing Linux capture and verification to reflect the finalized behavior:
Linux should request cursor: "never" and use the editable overlay path to
prevent duplicate cursors. Remove or revise the outdated statement that Linux
requests cursor: "always" and requires no editor change, including the
corresponding repeated guidance in the later section.

In `@electron/native-bridge/cursor/recording/hyprlandCursorIpc.test.ts`:
- Around line 111-116: Add a test alongside the existing queryHyprlandCursorPos
socket tests that starts a server accepting the cursor-position request while
intentionally neither responding nor closing; assert queryHyprlandCursorPos
resolves to null after its timeout, and clean up the server and socket
resources.

In `@electron/native-bridge/cursor/recording/hyprlandCursorRecordingSession.ts`:
- Around line 41-52: Update HyprlandCursorRecordingSession’s sampling lifecycle
to track the active captureSample() promise, await it in stop() after clearing
the interval, and only then construct the result. Return a stable snapshot of
this.samples rather than the mutable array so no in-flight sample can alter the
returned recording data.
- Around line 19-91: Add same-package tests for HyprlandCursorRecordingSession,
mocking Electron’s screen API and queryHyprlandCursorPos. Cover coordinate
normalization, initial IPC failure fallback, prevention of overlapping capture
requests, maxSamples bounding, and timestamps based on the pre-request time;
exercise start, capture scheduling, and stop behavior through the public session
API.
- Around line 55-70: Initialize a non-null fallback cursor position for the
Hyprland recording session, using the existing Electron cursor point, so
captureSample emits flat samples when the initial queryHyprlandCursorPos request
fails. Update the lastPosition fallback path while preserving replacement with
the IPC position after a successful response.

In `@src/hooks/useScreenRecorder.ts`:
- Around line 1268-1309: Update the Linux system-audio branch around the paired
video and audio capture promises to retain the audio stream when the video
capture fails, stop all audio tracks before propagating the video error, and
preserve the existing video-only fallback for audio capture errors. Follow the
cleanup pattern used for the outer screen-vs-mic race, while keeping the
combined-stream behavior unchanged on success.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 45b15c79-afca-428d-bbbe-ccf72388d2af

📥 Commits

Reviewing files that changed from the base of the PR and between f508a21 and cb6e6d7.

📒 Files selected for processing (8)
  • docs/superpowers/plans/2026-07-22-hyprland-cursor-position.md
  • docs/superpowers/specs/2026-07-22-hyprland-cursor-position-design.md
  • electron/native-bridge/cursor/recording/factory.ts
  • electron/native-bridge/cursor/recording/hyprlandCursorIpc.test.ts
  • electron/native-bridge/cursor/recording/hyprlandCursorIpc.ts
  • electron/native-bridge/cursor/recording/hyprlandCursorRecordingSession.ts
  • src/components/video-editor/VideoEditor.tsx
  • src/hooks/useScreenRecorder.ts

Comment on lines +308 to +339
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;
}
}
}

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

Keep the implementation plan aligned with the timing fix.

Line 326 timestamps after the IPC response. Capture requestedAtMs before await queryHyprlandCursorPos(...) and use it for timeMs, matching the finalized session and avoiding round-trip cursor lag.

🤖 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/plans/2026-07-22-hyprland-cursor-position.md` around lines
308 - 339, Update the captureSample method to record requestedAtMs immediately
before awaiting queryHyprlandCursorPos, then use that captured timestamp to
calculate samples’ timeMs instead of calling Date.now() after the IPC response.
Preserve the existing position fallback and sampling behavior.

Comment on lines +13 to +18
- **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.

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

Update the spec to match the finalized cursor flow.

It says Linux requests cursor: "always" and needs no editor change, while this PR’s objective is cursor: "never" to avoid duplicate cursors and enabling the Linux editable overlay. Leaving both statements here makes the verification guidance misleading.

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 cursor-flow specification in the sections describing
Linux capture and verification to reflect the finalized behavior: Linux should
request cursor: "never" and use the editable overlay path to prevent duplicate
cursors. Remove or revise the outdated statement that Linux requests cursor:
"always" and requires no editor change, including the corresponding repeated
guidance in the later section.

Comment on lines +92 to +94
```
${XDG_RUNTIME_DIR}/hypr/${HYPRLAND_INSTANCE_SIGNATURE}/.socket.sock
```

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

Label the fenced block.

Add a language such as text to satisfy markdownlint’s fenced-code-language rule.

🧰 Tools
🪛 markdownlint-cli2 (0.23.0)

[warning] 92-92: Fenced code blocks should have a language specified

(MD040, fenced-code-language)

🤖 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 92 - 94, Label the fenced code block containing the Hyprland socket path
with an appropriate language identifier such as text to satisfy the
fenced-code-language lint rule.

Source: Linters/SAST tools

Comment on lines +111 to +116
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();
});

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 | 🟠 Major | ⚡ Quick win

Test the accepted-but-unresponsive socket path.

This only covers an immediate connection error. Add a server that accepts j/cursorpos but neither responds nor closes, then assert resolution to null; otherwise the required timeout behavior can regress without detection.

As per coding guidelines, “Add a test for every new behavior in the same package as the code under test.”

🤖 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.test.ts` around
lines 111 - 116, Add a test alongside the existing queryHyprlandCursorPos socket
tests that starts a server accepting the cursor-position request while
intentionally neither responding nor closing; assert queryHyprlandCursorPos
resolves to null after its timeout, and clean up the server and socket
resources.

Source: Coding guidelines

Comment on lines +19 to +91
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<void> {
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<CursorRecordingData> {
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;
}
}
}

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 | 🟠 Major | 🏗️ Heavy lift

Add same-package tests for the session behavior.

Mock Electron’s screen and the IPC helper to cover normalization, initial IPC failure fallback, overlap prevention, bounded samples, and pre-request timestamps. This is the core new recording behavior and currently has no automated regression coverage.

As per coding guidelines, “Add a test for every new behavior in the same package as the code under test.”

🤖 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/hyprlandCursorRecordingSession.ts`
around lines 19 - 91, Add same-package tests for HyprlandCursorRecordingSession,
mocking Electron’s screen API and queryHyprlandCursorPos. Cover coordinate
normalization, initial IPC failure fallback, prevention of overlapping capture
requests, maxSamples bounding, and timestamps based on the pre-request time;
exercise start, capture scheduling, and stop behavior through the public session
API.

Source: Coding guidelines

Comment on lines +41 to +52
async stop(): Promise<CursorRecordingData> {
if (this.interval) {
clearInterval(this.interval);
this.interval = null;
}

return {
version: 2,
provider: "none",
samples: this.samples,
assets: [],
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Wait for an in-flight sample before returning recording data.

clearInterval() does not await a captureSample() already blocked on IPC. That call can push into this.samples after stop() returns, so persisted cursor data can miss the final sample while the returned object mutates afterward. Track the active sampling promise, await it in stop(), and return a stable snapshot.

🤖 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/hyprlandCursorRecordingSession.ts`
around lines 41 - 52, Update HyprlandCursorRecordingSession’s sampling lifecycle
to track the active captureSample() promise, await it in stop() after clearing
the interval, and only then construct the result. Return a stable snapshot of
this.samples rather than the mutable array so no in-flight sample can alter the
returned recording data.

Comment on lines +55 to +70
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;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Preserve a flat fallback when the first IPC request fails.

When IPC is unavailable before any successful response, lastPosition is null and this returns without emitting samples. Since the factory selects this session whenever the Hyprland signature exists, a startup socket race can make the cursor disappear entirely rather than degrading to the prior flat telemetry behavior. Seed a fallback position (for example, Electron’s existing cursor point) and emit it until IPC succeeds.

🤖 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/hyprlandCursorRecordingSession.ts`
around lines 55 - 70, Initialize a non-null fallback cursor position for the
Hyprland recording session, using the existing Electron cursor point, so
captureSample emits flat samples when the initial queryHyprlandCursorPos request
fails. Update the lastPosition fallback path while preserving replacement with
the IPC position after a successful response.

Comment on lines +1268 to +1309
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;
}

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

Orphaned system-audio stream leaks when the paired video getDisplayMedia call fails.

Promise.all([videoOnlyStream, audioOnlyStream]) rejects as soon as videoOnlyStream rejects, but audioOnlyStream already swallows its own errors via .catch(() => null), so it never causes the Promise.all to reject — it just keeps resolving independently. If the video capture fails/rejects after the audio getUserMedia call has already succeeded, that live audio track is never stopped: it leaks and keeps the OS-level capture indicator active.

This is exactly the class of bug already fixed for the outer screen-vs-mic race at Lines 1387-1399 ("the screen capture rejects it would otherwise orphan a mic stream"). Apply the same pattern here.

🛠️ Proposed fix
 					if (platform === "linux" && systemAudioEnabled) {
-						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 audioOnlyStreamPromise = 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;
+							});
+
+						let videoOnlyStream: MediaStream;
+						try {
+							videoOnlyStream = await navigator.mediaDevices.getDisplayMedia({
+								video: {
+									cursor,
+									width: { max: TARGET_WIDTH },
+									height: { max: TARGET_HEIGHT },
+									frameRate: { ideal: TARGET_FRAME_RATE },
+								} as MediaTrackConstraints,
+								audio: false,
+							} as DisplayMediaStreamOptions);
+						} catch (videoErr) {
+							void audioOnlyStreamPromise.then((s) =>
+								s?.getTracks().forEach((track) => track.stop()),
+							);
+							throw videoErr;
+						}
+						const audioOnlyStream = await audioOnlyStreamPromise;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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;
}
if (platform === "linux" && systemAudioEnabled) {
const audioOnlyStreamPromise = 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;
});
let videoOnlyStream: MediaStream;
try {
videoOnlyStream = await navigator.mediaDevices.getDisplayMedia({
video: {
cursor,
width: { max: TARGET_WIDTH },
height: { max: TARGET_HEIGHT },
frameRate: { ideal: TARGET_FRAME_RATE },
} as MediaTrackConstraints,
audio: false,
} as DisplayMediaStreamOptions);
} catch (videoErr) {
void audioOnlyStreamPromise.then((s) =>
s?.getTracks().forEach((track) => track.stop()),
);
throw videoErr;
}
const audioOnlyStream = await audioOnlyStreamPromise;
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;
}
🤖 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 `@src/hooks/useScreenRecorder.ts` around lines 1268 - 1309, Update the Linux
system-audio branch around the paired video and audio capture promises to retain
the audio stream when the video capture fails, stop all audio tracks before
propagating the video error, and preserve the existing video-only fallback for
audio capture errors. Follow the cleanup pattern used for the outer
screen-vs-mic race, while keeping the combined-stream behavior unchanged on
success.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[BUG]: Mouse cursor not captured/recognized during screen recording on Hyprland (Wayland)

1 participant