Skip to content

feat: native macOS cursor capture using NSCursor#671

Merged
siddharthvaddem merged 2 commits into
siddharthvaddem:mainfrom
kaili-yang:mac-native-cursor-capture
May 30, 2026
Merged

feat: native macOS cursor capture using NSCursor#671
siddharthvaddem merged 2 commits into
siddharthvaddem:mainfrom
kaili-yang:mac-native-cursor-capture

Conversation

@kaili-yang
Copy link
Copy Markdown
Contributor

@kaili-yang kaili-yang commented May 29, 2026

Description

Implements native cursor capture on macOS, bringing it in line with the existing Windows WGC path. Custom and default cursors now render from their real system bitmaps instead of being mapped to bundled SVG approximations.

No third-party libraries. The implementation is built entirely on AppKit APIs that ship with macOS — no uiohook, no CGEventTap pixel-scraping, no overlay heuristics.

Test

My PC is Mac Mini 4, Apple M4 chip, , MacOS 26.5 (25F71)
✅ Local test passed, take video, export, cursor capture & highlight are all good.

How it works

Swift helper (openscreen-macos-cursor-helper)

  • Calls NSCursor.currentSystem every sample interval to grab the live system cursor image
  • Converts to PNG, computes a SHA-256 content hash as a stable asset ID
  • Emits the full base64 bitmap payload once per unique cursor shape; subsequent samples carry only the assetId reference — stdout stays small even across many cursor shape changes
  • Records pixel dimensions, intrinsic scale factor (pixelsWide / pointWidth, e.g. 2.0 on Retina), and hotspot in pixel coordinates so the renderer can convert back to points correctly
  • currentCursorType() now returns nil instead of falling back to "arrow" — default and custom cursors fall through to the captured bitmap; only explicit text / pointer affordances (detected via Accessibility) continue to use the bundled pretty SVGs

TypeScript session (MacNativeCursorRecordingSession)

  • Collects and deduplicates assets received from the helper into a Map
  • Tags each CursorRecordingSample with assetId
  • Reports provider: "native" when at least one bitmap was captured, "none" as a graceful fallback (e.g. helper not found, Accessibility denied)

The rendering, persistence, and export layers (nativeCursor.ts, frameRenderer.ts, etc.) already supported the assetId + imageDataUrl path used by Windows — no changes needed there.

Features

  • Real cursor bitmaps — default arrow, resize handles, crosshair, and any custom application cursor render as their actual system images
  • Content-addressed deduplication — identical cursor shapes share one asset entry regardless of how many times they appear in the recording
  • Retina / HiDPI aware — scale factor and hotspot are preserved in pixel coordinates and converted to points at render time
  • Click detection — left-button down/up events are still tracked via CGEventTap; click bounce animation works as before
  • Graceful degradation — if the helper binary is missing or Accessibility is not granted, falls back to position-only telemetry (provider: "none") without crashing

Summary by CodeRabbit

  • New Features

    • Native cursor recording now captures actual cursor bitmap assets, enabling recorded sessions to preserve the exact appearance of custom cursors instead of using generic replacements.
    • Optimized cursor asset transmission by tracking and caching assets, reducing redundant data included in session recordings.
  • Tests

    • Added test coverage to verify native cursor asset rendering behaves correctly.

Review Change Stack

Capture the real system cursor image during macOS recording so custom and
default cursors render natively instead of being mapped to bundled SVGs,
bringing macOS in line with the Windows WGC capture path.

- macOS cursor helper grabs NSCursor.currentSystem as a PNG asset (SHA256 id,
  intrinsic scale factor, pixel hotspot); the bitmap payload is emitted once per
  shape and referenced by assetId thereafter
- helper returns nil cursorType instead of an arrow fallback so default/custom
  cursors fall through to the captured bitmap while text/pointer stay beautified
- MacNativeCursorRecordingSession collects deduped assets, tags samples with
  assetId, and reports provider "native" when bitmaps are captured
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 29, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 4d77103d-d96e-4227-9eb6-4caa9882c4bd

📥 Commits

Reviewing files that changed from the base of the PR and between e82fc0d and eac5cc6.

📒 Files selected for processing (2)
  • electron/native-bridge/cursor/recording/macNativeCursorRecordingSession.ts
  • electron/native/screencapturekit/Sources/OpenScreenMacOSCursorHelper/main.swift
🚧 Files skipped from review as they are similar to previous changes (1)
  • electron/native-bridge/cursor/recording/macNativeCursorRecordingSession.ts

📝 Walkthrough

Walkthrough

Adds macOS native cursor bitmap capture and stable asset ids in Swift, emits asset metadata conditionally during sampling, and wires those assets into the TypeScript recording session which stores assets and includes them in final reports; adds a test validating rendering of untyped native cursor samples using captured bitmaps.

Changes

Native Cursor Asset Capture Pipeline

Layer / File(s) Summary
Asset data models and type definitions
electron/native/screencapturekit/Sources/OpenScreenMacOSCursorHelper/main.swift, electron/native-bridge/cursor/recording/macNativeCursorRecordingSession.ts
CapturedCursorAsset struct in Swift and MacCursorAssetPayload type in TypeScript define the asset shape (id, base64 PNG data URL, pixel dimensions, hotspots, scaleFactor); MacCursorEvent sample variant extended with optional assetId and asset fields.
Swift cursor capture & affordance detection
electron/native/screencapturekit/Sources/OpenScreenMacOSCursorHelper/main.swift
currentCursorType() now may return nil for non-text/pointer affordances; new currentCursorAsset() captures NSCursor bitmap, encodes PNG, computes SHA-256 id, base64-encodes as data URL, and derives scaled hotspot/dimensions and scaleFactor.
Swift sampling loop asset emission
electron/native/screencapturekit/Sources/OpenScreenMacOSCursorHelper/main.swift
Sampling loop tracks emitted asset ids and emits assetId for every sample while including the full asset payload only the first time a new asset id is observed.
TypeScript recording session storage & sample wiring
electron/native-bridge/cursor/recording/macNativeCursorRecordingSession.ts
MacNativeCursorRecordingSession now keeps an assets map cleared on start, rememberAsset populates it (derives scaleFactor when missing), captureSample accepts assetId, samples conditionally include assetId, and stop() sets provider to "native" and returns collected assets when present.
Test for untyped macOS native cursor rendering
src/lib/cursor/nativeCursor.test.ts
Adds test importing resolveNativeCursorRenderAsset that verifies samples lacking cursorType render from the captured bitmap asset (preserving imageDataUrl) and that asset scaleFactor adjusts output width and hotspotX.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Suggested reviewers

  • siddharthvaddem
  • FabLrc

Poem

🖱️ pixels hashed and wrapped in base64 light,
swift snaps the cursor in the dead of night,
typescript tucks the asset in its map,
samples point to ids — no repeat payload flap,
tiny bitmap triumph, lowkey elegant sight.

🚥 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 accurately describes the main feature being added: native macOS cursor capture using NSCursor APIs.
Description check ✅ Passed The description covers motivation, implementation details, testing, and features comprehensively, though it doesn't strictly follow the template structure with checkboxes.
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.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 ESLint

If the error stems from missing dependencies, add them to the package.json file. For unrecoverable errors (e.g., due to private dependencies), disable the tool in the CodeRabbit configuration.

ESLint skipped: no ESLint configuration detected in root package.json. To enable, add eslint to devDependencies.


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 and usage tips.

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: e82fc0d8cc

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread electron/native/screencapturekit/Sources/OpenScreenMacOSCursorHelper/main.swift Outdated
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
electron/native-bridge/cursor/recording/macNativeCursorRecordingSession.ts (1)

210-214: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

The Accessibility warning is stale now.

With the bitmap path, missing Accessibility only disables text/pointer affordance detection; it doesn't force arrow-only rendering anymore. This message/comment will send debugging down the wrong branch pretty fast.

Also applies to: 333-337

🤖 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/macNativeCursorRecordingSession.ts`
around lines 210 - 214, Update the stale comment around the
systemPreferences.isTrustedAccessibilityClient(true) call in
macNativeCursorRecordingSession.ts (and the similar block at lines 333-337) to
accurately describe the current behavior: missing Accessibility only disables
text/pointer affordance detection (bitmap path still renders cursor shapes), not
"arrow-only" rendering; change any nearby log messages or comments that claim it
forces arrow-only rendering to reflect this precise limitation so future
debugging isn't misled. Ensure references are made near the existing try/catch
that calls systemPreferences.isTrustedAccessibilityClient(true) and the catch
comment is replaced with the corrected explanation.
🤖 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/native/screencapturekit/Sources/OpenScreenMacOSCursorHelper/main.swift`:
- Around line 239-268: The code is re-rasterizing, PNG-encoding, hashing and
base64-encoding the cursor on every poll; cache the last computed signature and
asset and only regenerate when the cursor image or hotspot changes. Introduce a
cached state (e.g. lastDigestId, lastHotSpot, lastImageDataUrl) alongside the
polling logic that reads cursor.image and cursor.hotSpot, compute
digest/id/png/base64 only if either the current hotSpot differs from lastHotSpot
or the image content differs (compare a lightweight marker such as image.size
and proposedRect/cgImage reference or compute digest once and reuse), then
update the cache variables (lastDigestId, lastHotSpot, lastImageDataUrl);
otherwise reuse the cached id and imageDataUrl to avoid repeated work in the
loop that currently creates png, digest, id, and imageDataUrl from cursor.image
and cursor.hotSpot.
- Around line 317-337: The current dedupe uses lastEmittedAssetId so only
adjacent identical cursor shapes are deduped; replace this with a process-wide
Set to track all emitted asset ids: introduce a Set<String> (e.g.,
emittedAssetIds) instead of lastEmittedAssetId, and in the loop call
currentCursorAsset() and check if asset.id is not in emittedAssetIds before
building assetPayload, then insert asset.id into emittedAssetIds when you emit
it; update any references to lastEmittedAssetId accordingly (remove it) so each
unique cursor shape is only serialized once.

---

Outside diff comments:
In `@electron/native-bridge/cursor/recording/macNativeCursorRecordingSession.ts`:
- Around line 210-214: Update the stale comment around the
systemPreferences.isTrustedAccessibilityClient(true) call in
macNativeCursorRecordingSession.ts (and the similar block at lines 333-337) to
accurately describe the current behavior: missing Accessibility only disables
text/pointer affordance detection (bitmap path still renders cursor shapes), not
"arrow-only" rendering; change any nearby log messages or comments that claim it
forces arrow-only rendering to reflect this precise limitation so future
debugging isn't misled. Ensure references are made near the existing try/catch
that calls systemPreferences.isTrustedAccessibilityClient(true) and the catch
comment is replaced with the corrected explanation.
🪄 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: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 8510e47c-e397-41b3-a8c4-4807fb4d8cb8

📥 Commits

Reviewing files that changed from the base of the PR and between cf74b76 and e82fc0d.

📒 Files selected for processing (3)
  • electron/native-bridge/cursor/recording/macNativeCursorRecordingSession.ts
  • electron/native/screencapturekit/Sources/OpenScreenMacOSCursorHelper/main.swift
  • src/lib/cursor/nativeCursor.test.ts

Comment thread electron/native/screencapturekit/Sources/OpenScreenMacOSCursorHelper/main.swift Outdated
- Replace lastEmittedAssetId with a process-wide Set<String> so each unique
  cursor shape is serialised at most once even across non-adjacent repeats
  (e.g. arrow → text → arrow no longer resends the arrow bitmap)
- Wrap the sampling loop body in autoreleasepool{} to prevent Cocoa objects
  (NSBitmapImageRep, PNG Data, base64 String) from accumulating for the
  lifetime of the helper during long recordings
- Update stale Accessibility comments: missing Accessibility only disables
  text/pointer affordance detection; native bitmap capture is unaffected
@kaili-yang
Copy link
Copy Markdown
Contributor Author

Addressed all actionable review comments in eac5cc6:

  • lastEmittedAssetIdSet<String>: fixed — each unique cursor shape is now serialised at most once per recording session, regardless of repetition order.
  • Autorelease pool: fixed — autoreleasepool { ... } now wraps the full sampling loop body so Cocoa objects are drained every iteration.
  • Stale Accessibility comments: fixed — updated both the catch-block comment and the log message to accurately reflect that missing Accessibility only disables text/pointer affordance detection; native bitmap capture remains active.
  • Avoid rebuilding PNG on every poll: skipped intentionally — PNG-encoding a cursor bitmap (~64×64) costs ~0.1–0.5 ms at a 33 ms interval, which is negligible. Adding a size+hotspot cache would introduce meaningful complexity for marginal gain; the autorelease pool fix already handles the memory side of this concern.

@siddharthvaddem
Copy link
Copy Markdown
Owner

please include video recordings

@kaili-yang
Copy link
Copy Markdown
Contributor Author

kaili-yang commented May 30, 2026

Due to the 10MB file upload limit, this is all I could record. If you want to see any other screen recordings, just let me know.

trim.mp4

@siddharthvaddem siddharthvaddem merged commit 324f4e0 into siddharthvaddem:main May 30, 2026
15 of 19 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