feat: capture native cursor bitmaps on macOS

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
This commit is contained in:
Kelly Yang
2026-05-29 11:33:01 -07:00
parent cf74b76d39
commit e82fc0d8cc
3 changed files with 159 additions and 6 deletions
@@ -6,10 +6,21 @@ import { type Rectangle, screen, systemPreferences } from "electron";
import type {
CursorRecordingData,
CursorRecordingSample,
NativeCursorAsset,
NativeCursorType,
} from "../../../../src/native/contracts";
import type { CursorRecordingSession } from "./session";
interface MacCursorAssetPayload {
id: string;
imageDataUrl: string;
width: number;
height: number;
hotspotX: number;
hotspotY: number;
scaleFactor?: number;
}
interface MacNativeCursorRecordingSessionOptions {
getDisplayBounds: () => Rectangle | null;
maxSamples: number;
@@ -28,6 +39,8 @@ type MacCursorEvent =
type: "sample";
timestampMs: number;
cursorType?: NativeCursorType | null;
assetId?: string | null;
asset?: MacCursorAssetPayload | null;
leftButtonDown?: boolean;
leftButtonPressed?: boolean;
leftButtonReleased?: boolean;
@@ -170,6 +183,7 @@ function normalizeCursorType(value: unknown): NativeCursorType | null {
export class MacNativeCursorRecordingSession implements CursorRecordingSession {
private samples: CursorRecordingSample[] = [];
private assets = new Map<string, NativeCursorAsset>();
private process: ChildProcessByStdio<null, Readable, Readable> | null = null;
private lineBuffer = "";
private startTimeMs = 0;
@@ -187,6 +201,7 @@ export class MacNativeCursorRecordingSession implements CursorRecordingSession {
async start(): Promise<void> {
this.samples = [];
this.assets.clear();
this.lineBuffer = "";
this.startTimeMs = this.options.startTimeMs ?? Date.now();
this.previousLeftButtonDown = false;
@@ -263,19 +278,38 @@ export class MacNativeCursorRecordingSession implements CursorRecordingSession {
return {
version: 2,
provider: "none",
provider: this.assets.size > 0 ? "native" : "none",
samples: this.samples,
assets: [],
assets: [...this.assets.values()],
};
}
private startPositionOnlyFallback() {
this.captureSample(Date.now(), null, false, false, false);
this.captureSample(Date.now(), null, null, false, false, false);
this.fallbackInterval = setInterval(() => {
this.captureSample(Date.now(), null, false, false, false);
this.captureSample(Date.now(), null, null, false, false, false);
}, this.options.sampleIntervalMs);
}
private rememberAsset(asset: MacCursorAssetPayload | null | undefined) {
if (!asset?.id || this.assets.has(asset.id)) {
return;
}
const cursor = screen.getCursorScreenPoint();
const displayScaleFactor = screen.getDisplayNearestPoint(cursor).scaleFactor;
this.assets.set(asset.id, {
id: asset.id,
platform: "darwin",
imageDataUrl: asset.imageDataUrl,
width: asset.width,
height: asset.height,
hotspotX: asset.hotspotX,
hotspotY: asset.hotspotY,
scaleFactor: asset.scaleFactor ?? displayScaleFactor,
});
}
private handleStdoutChunk(chunk: string) {
this.lineBuffer += chunk;
const lines = this.lineBuffer.split(/\r?\n/);
@@ -307,9 +341,11 @@ export class MacNativeCursorRecordingSession implements CursorRecordingSession {
}
if (payload.type === "sample") {
this.rememberAsset(payload.asset);
this.captureSample(
payload.timestampMs,
normalizeCursorType(payload.cursorType),
payload.assetId ?? null,
payload.leftButtonDown === true,
payload.leftButtonPressed === true,
payload.leftButtonReleased === true,
@@ -320,6 +356,7 @@ export class MacNativeCursorRecordingSession implements CursorRecordingSession {
private captureSample(
timestampMs: number,
cursorType: NativeCursorType | null,
assetId: string | null,
leftButtonDown: boolean,
leftButtonPressed: boolean,
leftButtonReleased: boolean,
@@ -357,6 +394,7 @@ export class MacNativeCursorRecordingSession implements CursorRecordingSession {
cy: clamp(normalizedY, 0, 1),
visible,
interactionType,
...(assetId ? { assetId } : {}),
...(cursorType ? { cursorType } : {}),
});