Skip to content

fix(macos): correct cursor offset in single-window capture#22

Merged
EtienneLescot merged 4 commits into
getopenscreen:mainfrom
giulio333:fix/macos-window-capture-cursor-offset
Jun 27, 2026
Merged

fix(macos): correct cursor offset in single-window capture#22
EtienneLescot merged 4 commits into
getopenscreen:mainfrom
giulio333:fix/macos-window-capture-cursor-offset

Conversation

@giulio333

@giulio333 giulio333 commented Jun 23, 2026

Copy link
Copy Markdown

Summary

Fixes a cursor-position offset in the exported video when recording a single application window on macOS. The cursor (and therefore clicks) appeared shifted by a fixed translation from where they actually were on screen. Full-screen capture was already correct.

Root cause

The cursor sampler normalizes the global cursor position into the captured region's coordinate space. For window captures it was normalizing against the selected display's bounds instead of the window's bounds:

  • getSelectedSourceBounds() resolved a window source to its display and never subtracted the window origin.
  • The captured video, however, only contains the window's region.

So the normalized coordinates carried a constant offset equal to the window origin — exactly the "fixed translation" reported.

Fix

  • The ScreenCaptureKit helper now reports the captured region's global frame (the window frame for window captures, the display frame for display captures) via a new captureBounds field on its ready and recording-started events.
  • The main process stores it, and getSelectedSourceBounds() now returns the window frame for window sources, so the cursor is normalized into the captured window's coordinate space.
  • Display capture behavior is unchanged.

Changes

  • electron/native/screencapturekit/.../main.swift — carry and emit the captured frame as captureBounds.
  • electron/ipc/handlers.ts — track the helper-reported bounds and use them for window captures.
  • src/lib/nativeMacRecording.ts — add captureBounds to the helper event types.

Testing

  • Swift helper builds (release, arm64); tsc --noEmit and Biome pass.
  • Manually verified in dev: recording a single window now aligns the cursor in the export with the real cursor position; full-screen capture remains correct.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes
    • Improved macOS window-region recording accuracy by correctly using native-reported capture bounds, preventing cursor offset issues across certain window and display configurations.
    • Enhanced macOS recording start events to include capture bounds details, enabling precise cursor placement relative to captured content.
    • Prevented stale capture bounds from carrying across sessions by resetting stored bounds when native recording starts/stops.

When recording a single window on macOS, the cursor in the exported
video was offset by a fixed translation: clicks landed in the wrong
place. Full-screen capture was correct.

Root cause: the cursor sampler normalized the global cursor position
against the selected display's bounds, never against the captured
window's region. `getSelectedSourceBounds()` resolved a window source
to its display (the window origin was never subtracted), so the
normalized coordinates carried a constant offset equal to the window
origin.

Fix: the ScreenCaptureKit helper now reports the captured region's
global frame (the window frame for window captures, the display frame
for display captures) via a `captureBounds` field on its `ready` and
`recording-started` events. The main process stores it and
`getSelectedSourceBounds()` returns the window frame for window
sources, so the cursor is normalized into the captured window's
coordinate space. Display capture is unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013iccbhodxNjMBraYPXpX6q
@giulio333 giulio333 requested a review from EtienneLescot as a code owner June 23, 2026 19:23
@coderabbitai

coderabbitai Bot commented Jun 23, 2026

Copy link
Copy Markdown

Review Change Stack

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: b232496e-8cea-451f-9d5d-30975b54e95c

📥 Commits

Reviewing files that changed from the base of the PR and between 0266538 and d69a15b.

📒 Files selected for processing (1)
  • electron/ipc/handlers.ts

📝 Walkthrough

Walkthrough

The macOS helper now emits capture bounds for recording-started events, TypeScript accepts the new field, and Electron IPC stores the bounds for window source selection while clearing them around recording lifecycle transitions.

Changes

macOS capture bounds propagation

Layer / File(s) Summary
Swift helper capture bounds
electron/native/screencapturekit/.../main.swift
CaptureTarget stores a global capture frame, ScreenCaptureRecorder copies it at start, and recording-started emits captureBounds; ready is reformatted without changing values.
TypeScript captureBounds type
src/lib/nativeMacRecording.ts
NativeMacHelperRecordingStartedEvent adds an optional captureBounds?: Rectangle field.
Electron IPC capture bounds state
electron/ipc/handlers.ts
Adds activeMacCaptureBounds, updates helper event dispatch to store valid bounds before emitting, uses stored bounds in getSelectedSourceBounds() for window sources, and clears state on recording start and stop.

Sequence Diagram

sequenceDiagram
  participant Helper as Native macOS helper
  participant Handlers as electron/ipc/handlers.ts
  participant State as activeMacCaptureBounds
  participant Bounds as getSelectedSourceBounds()
  Helper->>Handlers: parsed helper event with captureBounds
  Handlers->>State: store captureBounds
  Handlers-->>Handlers: emit nativeMacCaptureEvents
  Bounds->>State: read activeMacCaptureBounds for window source
  Handlers->>State: clear on recording start/stop
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Poem

🐰 I hop where the window frame may be,
Bounds come sailing back to me.
Swift and IPC now dance in tune,
No stale corners lingering too soon.
Capture tracks beneath the moon 🌙

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title matches the main change: fixing the macOS cursor offset in single-window capture.
Description check ✅ Passed The description covers summary, root cause, fix, changes, and testing, though several template sections are omitted.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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

EtienneLescot added a commit that referenced this pull request Jun 24, 2026
- Add ROADMAP.md at repo root for top-level discoverability.
- Stability queue pulled from open issues / PRs on getopenscreen/openscreen (#8, #21, #22, #19, #18, #24).
- AI direction framed as opt-in / off by default, with per-feature tags.
- Extend .github/workflows/discord.yaml with a roadmap-notify job that posts an embed to the #🗺️・roadmap Discord channel whenever a PR changes ROADMAP.md (or docs/roadmap.md). Mirrors the existing PR -> #pr-reviews sync pattern. Activated by setting the DISCORD_ROADMAP_WEBHOOK_URL repo secret; silently skipped otherwise so CI never breaks.
EtienneLescot added a commit that referenced this pull request Jun 24, 2026
- Add ROADMAP.md at repo root for top-level discoverability.
- Stability queue pulled from open issues / PRs on getopenscreen/openscreen (#8, #21, #22, #19, #18, #24).
- AI direction framed as opt-in / off by default, with per-feature tags.
- Link the new roadmap from the README Community section.
- Extend .github/workflows/discord.yaml with a roadmap-notify job that posts an embed to the #🗺️・roadmap Discord channel whenever a PR changes ROADMAP.md (or docs/roadmap.md). Mirrors the existing PR -> #pr-reviews sync pattern. Activated by setting the DISCORD_ROADMAP_WEBHOOK_URL repo secret; silently skipped otherwise so CI never breaks.
EtienneLescot added a commit that referenced this pull request Jun 24, 2026
- Add ROADMAP.md at repo root for top-level discoverability.
- Stability queue pulled from open issues / PRs on getopenscreen/openscreen (#8, #21, #22, #19, #18, #24).
- AI direction framed as opt-in / off by default, with per-feature tags.
- Link the new roadmap from the README Community section.
- Extend .github/workflows/discord.yaml with a roadmap-notify job that posts an embed to the #🗺️・roadmap Discord channel whenever a PR changes ROADMAP.md (or docs/roadmap.md). Mirrors the existing PR -> #pr-reviews sync pattern. Activated by setting the DISCORD_ROADMAP_WEBHOOK_URL repo secret; silently skipped otherwise so CI never breaks.

@EtienneLescot EtienneLescot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Ey @giulio333 thanks for the PR!!

Ponytail review — over-engineering

electron/ipc/handlers.ts

  • shrink parseCaptureBounds (21 lines for a payload the Swift helper emits with hardcoded dict literals). We control both ends of this IPC. Replace with one inline check at the single use site, or activeMacCaptureBounds = event.captureBounds as Rectangle | undefined and delete the helper.
  • shrink the 3-line comment block above activeMacCaptureBounds (it duplicates the use-site comment in getSelectedSourceBounds). One short line, or move the explanation to the call site only.
  • shrink the 2-line comment inside dispatchNativeMacHelperEvent. The variable name + one-line body are self-explanatory.
  • shrink the 2-line comment before activeMacCaptureBounds = null in the recording-start block. activeMacCaptureBounds = null; reads itself.

electron/native/screencapturekit/Sources/OpenScreenScreenCaptureKitHelper/main.swift

  • delete captureBounds from the ready event payload. Cursor polling only starts after waitForNativeMacCaptureStart resolves on recording-started; the ready payload's captureBounds is read by nothing.

src/lib/nativeMacRecording.ts

  • delete the captureBounds? field on NativeMacHelperReadyEvent (consequence of the Swift delete above).

Net lines removable: ~28.


Functional review

Correctness — looks right. Root cause matches the symptom. Window source predicate (selectedSource.id.startsWith("window:")) is consistent with the rest of the codebase (useScreenRecorder.ts:810, nativeMacRecording.ts:94, SourceSelector.tsx:37,63). Display path untouched.

Race / ordering — safe. nativeMacCaptureOutput is cleared (line 1810) before the new helper spawns, so inspectNativeMacCaptureOutput() replay sees an empty buffer. recording-started always fires after ready in Swift, so activeMacCaptureBounds is populated before cursor polling starts.

Real gap — window resize mid-recording. Swift captures window.frame once in makeCaptureTarget. If the user resizes the window while recording, the cursor offset returns. Cheap upgrade later: emit captureBounds again from SCStreamDelegate.stream(_:didChangeWith:). Not blocking; file as a follow-up.

No test added. The JS pipeline (captureBounds → global → getSelectedSourceBounds) is pure and worth a 20-line Vitest covering: window source + bounds set → returns bounds; window source + bounds null → falls through to display; display source → ignores bounds. CI can't catch macOS regressions but it can lock the JS contract.

Coordinate units. Swift window.frame is in points, Electron screen bounds are in pixels — macOS reports consistently in points through this stack, but worth a manual sanity check on a retina vs. non-retina machine.

Verdict

Ship-able. The four shrinks and the one delete above are concrete polish items that would tighten this PR. Functional concerns are follow-ups.

Comment thread electron/ipc/handlers.ts
Comment thread electron/ipc/handlers.ts Outdated
Comment thread electron/ipc/handlers.ts
Comment thread electron/ipc/handlers.ts
Comment thread src/lib/nativeMacRecording.ts Outdated
@EtienneLescot

Copy link
Copy Markdown
Collaborator

@giulio333 just to let you know, my review is an automated review made with https://github.com/DietrichGebert/ponytail This is why it can feel a bit nitpicky. In the future, those reviews will be better marked as automated, with better P0 → nitpicky ranking.

…ady-event field

- inline the captureBounds check in dispatchNativeMacHelperEvent and remove
  parseCaptureBounds; we control both ends of this IPC
- drop captureBounds from the `ready` event (Swift + TS): cursor polling only
  starts after `recording-started` resolves, so it was read by nothing
- trim the duplicated/self-explanatory comments around activeMacCaptureBounds

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S4UdyNkRoEn2B8JaG5YdLK
@giulio333

Copy link
Copy Markdown
Author

Thanks for the review, @EtienneLescot — good catches, all addressed in 0266538.

Over-engineering

  • parseCaptureBounds removed; the check is now inlined in dispatchNativeMacHelperEvent (event.captureBounds as Rectangle | undefined + a width/height sanity guard), since we control both ends of the IPC.
  • Dropped captureBounds from the ready event in both the Swift helper and NativeMacHelperReadyEvent — cursor polling only starts after recording-started resolves, so nothing read it.
  • Trimmed the duplicated / self-explanatory comments around activeMacCaptureBounds; the canonical explanation now lives only at the getSelectedSourceBounds call site.

Functional follow-ups (not in this PR, agree they're non-blocking):

  • Window resize mid-recordingwindow.frame is captured once in makeCaptureTarget, so the offset returns if the window is resized while recording. Will file a follow-up to re-emit captureBounds from SCStreamDelegate.stream(_:didChangeWith:).
  • Vitest for the JS contract — agree it's worth locking down; happy to add it as a follow-up (it needs a small extraction since getSelectedSourceBounds reads Electron's screen + module state directly).
  • Coordinate units — sanity-checked, the stack stays in points throughout; will re-verify on a non-retina display.

@EtienneLescot EtienneLescot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

All five ponytail findings applied. ~45 lines removed vs. the original +78. Remaining follow-ups (window resize mid-recording, JS contract test) are non-blocking. Ship.

@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: 1

🤖 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 `@electron/ipc/handlers.ts`:
- Around line 2915-2919: The diagnostics payload currently serializes raw
helperOutput and mainProcessLogs, which can expose sensitive capture details.
Update the diagnostics assembly in the handler that builds this response to
redact these values before they are written, using the existing helper-output
and main-log collection points to apply scrubbing just before serialization.
Keep the structure intact, but ensure the windows/mac helper tails and the
mainLogBuffer snapshot are sanitized for local paths, source/window names, and
device metadata before saving.
🪄 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: b232496e-8cea-451f-9d5d-30975b54e95c

📥 Commits

Reviewing files that changed from the base of the PR and between 0266538 and d69a15b.

📒 Files selected for processing (1)
  • electron/ipc/handlers.ts

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

Caution

Inline review comments failed to post. This is likely due to GitHub's internal server error or limits when posting large numbers of comments. If you are seeing this consistently it is likely a permissions issue. Please check "Moderation" -> "Code review limits" under your organization settings.

Actionable comments posted: 1

🤖 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 `@electron/ipc/handlers.ts`:
- Around line 2915-2919: The diagnostics payload currently serializes raw
helperOutput and mainProcessLogs, which can expose sensitive capture details.
Update the diagnostics assembly in the handler that builds this response to
redact these values before they are written, using the existing helper-output
and main-log collection points to apply scrubbing just before serialization.
Keep the structure intact, but ensure the windows/mac helper tails and the
mainLogBuffer snapshot are sanitized for local paths, source/window names, and
device metadata before saving.
🪄 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: b232496e-8cea-451f-9d5d-30975b54e95c

📥 Commits

Reviewing files that changed from the base of the PR and between 0266538 and d69a15b.

📒 Files selected for processing (1)
  • electron/ipc/handlers.ts
🛑 Comments failed to post (1)
electron/ipc/handlers.ts (1)

2915-2919: 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Redact helper and main-process logs before saving diagnostics.

These outputs can include local paths, selected source/window names, and device metadata from capture logs. Since diagnostic files are likely shared for support, scrub sensitive values before serialization.

Proposed localized redaction
 			const HELPER_OUTPUT_MAX_BYTES = 64 * 1024;
-			const tail = (s: string, max: number) => (s.length <= max ? s : s.slice(s.length - max));
+			const redactDiagnosticText = (value: string) =>
+				value
+					.replaceAll(app.getPath("home"), "~")
+					.replaceAll(RECORDINGS_DIR, "<recordings-dir>");
+			const tail = (s: string, max: number) => {
+				const redacted = redactDiagnosticText(s);
+				return redacted.length <= max ? redacted : redacted.slice(redacted.length - max);
+			};

 			const diagnostic = {
 				timestamp: new Date().toISOString(),
@@
 				helperOutput: {
 					windows: tail(nativeWindowsCaptureOutput, HELPER_OUTPUT_MAX_BYTES),
 					mac: tail(nativeMacCaptureOutput, HELPER_OUTPUT_MAX_BYTES),
 				},
-				mainProcessLogs: mainLogBuffer.snapshot(),
+				mainProcessLogs: mainLogBuffer.snapshot().map(redactDiagnosticText),
 			};
📝 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.

			const HELPER_OUTPUT_MAX_BYTES = 64 * 1024;
			const redactDiagnosticText = (value: string) =>
				value
					.replaceAll(app.getPath("home"), "~")
					.replaceAll(RECORDINGS_DIR, "<recordings-dir>");
			const tail = (s: string, max: number) => {
				const redacted = redactDiagnosticText(s);
				return redacted.length <= max ? redacted : redacted.slice(redacted.length - max);
			};

			const diagnostic = {
				timestamp: new Date().toISOString(),
				helperOutput: {
					windows: tail(nativeWindowsCaptureOutput, HELPER_OUTPUT_MAX_BYTES),
					mac: tail(nativeMacCaptureOutput, HELPER_OUTPUT_MAX_BYTES),
				},
				mainProcessLogs: mainLogBuffer.snapshot().map(redactDiagnosticText),
🧰 Tools
🪛 ast-grep (0.44.0)

[warning] Importing child_process exposes a command-execution surface; ensure any command/argument built from input is validated, and prefer execFile/spawn with an argument array over exec.
Context: import { type ChildProcessWithoutNullStreams, spawn } from "node:child_process";
Note: [CWE-78] Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection').

(detect-child-process-typescript)

🤖 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/ipc/handlers.ts` around lines 2915 - 2919, The diagnostics payload
currently serializes raw helperOutput and mainProcessLogs, which can expose
sensitive capture details. Update the diagnostics assembly in the handler that
builds this response to redact these values before they are written, using the
existing helper-output and main-log collection points to apply scrubbing just
before serialization. Keep the structure intact, but ensure the windows/mac
helper tails and the mainLogBuffer snapshot are sanitized for local paths,
source/window names, and device metadata before saving.

@EtienneLescot EtienneLescot merged commit 1790ccb into getopenscreen:main Jun 27, 2026
26 checks passed
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.

2 participants