mirror of
https://github.com/siddharthvaddem/openscreen.git
synced 2026-08-29 03:08:30 +08:00
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:
@@ -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 } : {}),
|
||||
});
|
||||
|
||||
|
||||
@@ -1,11 +1,22 @@
|
||||
import AppKit
|
||||
import ApplicationServices
|
||||
import CryptoKit
|
||||
import Foundation
|
||||
|
||||
struct CursorHelperRequest: Decodable {
|
||||
let sampleIntervalMs: Int?
|
||||
}
|
||||
|
||||
struct CapturedCursorAsset {
|
||||
let id: String
|
||||
let imageDataUrl: String
|
||||
let width: Int
|
||||
let height: Int
|
||||
let hotspotX: Double
|
||||
let hotspotY: Double
|
||||
let scaleFactor: Double
|
||||
}
|
||||
|
||||
final class MouseButtonTracker {
|
||||
private let lock = NSLock()
|
||||
private var leftDownCount = 0
|
||||
@@ -211,10 +222,60 @@ func currentCursorType() -> String? {
|
||||
)
|
||||
|
||||
guard result == .success, let element else {
|
||||
return "arrow"
|
||||
return nil
|
||||
}
|
||||
|
||||
return cursorTypeForElement(element) ?? "arrow"
|
||||
// Returns nil for anything that is not a text/pointer affordance so the
|
||||
// renderer falls through to the natively captured cursor bitmap (this is
|
||||
// what makes default and custom cursors render as their real images).
|
||||
return cursorTypeForElement(element)
|
||||
}
|
||||
|
||||
func currentCursorAsset() -> CapturedCursorAsset? {
|
||||
guard let cursor = NSCursor.currentSystem ?? NSCursor.current as NSCursor? else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let image = cursor.image
|
||||
let pointSize = image.size
|
||||
guard pointSize.width > 0, pointSize.height > 0 else {
|
||||
return nil
|
||||
}
|
||||
|
||||
var proposedRect = NSRect(origin: .zero, size: pointSize)
|
||||
guard let cgImage = image.cgImage(forProposedRect: &proposedRect, context: nil, hints: nil) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let bitmap = NSBitmapImageRep(cgImage: cgImage)
|
||||
guard let png = bitmap.representation(using: .png, properties: [:]) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let pixelsWide = bitmap.pixelsWide
|
||||
let pixelsHigh = bitmap.pixelsHigh
|
||||
guard pixelsWide > 0, pixelsHigh > 0 else {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Intrinsic backing scale of the cursor image (e.g. 2.0 on Retina). The
|
||||
// renderer divides pixel dimensions/hotspot by this to recover point sizes.
|
||||
let scaleFactor = Double(pixelsWide) / Double(pointSize.width)
|
||||
let hotSpot = cursor.hotSpot
|
||||
|
||||
let digest = SHA256.hash(data: png)
|
||||
let id = digest.map { String(format: "%02x", $0) }.joined()
|
||||
let imageDataUrl = "data:image/png;base64,\(png.base64EncodedString())"
|
||||
|
||||
return CapturedCursorAsset(
|
||||
id: id,
|
||||
imageDataUrl: imageDataUrl,
|
||||
width: pixelsWide,
|
||||
height: pixelsHigh,
|
||||
hotspotX: hotSpot.x * scaleFactor,
|
||||
hotspotY: hotSpot.y * scaleFactor,
|
||||
scaleFactor: scaleFactor
|
||||
)
|
||||
}
|
||||
|
||||
func timestampMs() -> Int {
|
||||
@@ -253,13 +314,33 @@ emit([
|
||||
"mouseTapReady": mouseTapReady,
|
||||
])
|
||||
|
||||
var lastEmittedAssetId: String?
|
||||
|
||||
while true {
|
||||
mouseTracker.pump()
|
||||
let mouseEvents = mouseTracker.consume()
|
||||
let asset = currentCursorAsset()
|
||||
// Only ship the (large) base64 payload the first time a cursor shape is seen;
|
||||
// subsequent samples reference it by assetId so stdout stays small.
|
||||
var assetPayload: [String: Any]?
|
||||
if let asset, asset.id != lastEmittedAssetId {
|
||||
lastEmittedAssetId = asset.id
|
||||
assetPayload = [
|
||||
"id": asset.id,
|
||||
"imageDataUrl": asset.imageDataUrl,
|
||||
"width": asset.width,
|
||||
"height": asset.height,
|
||||
"hotspotX": asset.hotspotX,
|
||||
"hotspotY": asset.hotspotY,
|
||||
"scaleFactor": asset.scaleFactor,
|
||||
]
|
||||
}
|
||||
emit([
|
||||
"type": "sample",
|
||||
"timestampMs": timestampMs(),
|
||||
"cursorType": currentCursorType(),
|
||||
"assetId": asset?.id,
|
||||
"asset": assetPayload,
|
||||
"leftButtonDown": leftButtonDown(),
|
||||
"leftButtonPressed": mouseEvents.leftDownCount > 0,
|
||||
"leftButtonReleased": mouseEvents.leftUpCount > 0,
|
||||
|
||||
Reference in New Issue
Block a user