Skip to content

Commit bb751db

Browse files
committed
fix: rebase cursor samples on capture bounds, use window bounds for picked windows
Implement updateCaptureBounds on MacNativeCursorRecordingSession (optional on CursorRecordingSession). Samples recorded before the helper reports its bounds are rebased onto them, and later samples use the new bounds. For a one-window capture filter on macOS 15.2+, take the capture frame from kCGWindowBounds by window id instead of filter.contentRect, which sits below the window's on-screen origin for a window chosen in Apple's picker.
1 parent f0d8c29 commit bb751db

3 files changed

Lines changed: 49 additions & 16 deletions

File tree

‎electron/native-bridge/cursor/recording/macNativeCursorRecordingSession.ts‎

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -234,6 +234,8 @@ export class MacNativeCursorRecordingSession implements CursorRecordingSession {
234234
private readyTimer: NodeJS.Timeout | null = null;
235235
private previousLeftButtonDown = false;
236236
private consecutiveOutsideSamples = 0;
237+
private screenPoints: Array<{ x: number; y: number }> = [];
238+
private boundsOverride: Rectangle | null = null;
237239
// Hide only after this many consecutive out-of-bounds samples (~100ms at 33ms interval).
238240
// Fast swipes that briefly exit the display are clipped by clip-path instead of disappearing.
239241
private static readonly OUTSIDE_HIDE_THRESHOLD = 3;
@@ -242,6 +244,8 @@ export class MacNativeCursorRecordingSession implements CursorRecordingSession {
242244

243245
async start(): Promise<void> {
244246
this.samples = [];
247+
this.screenPoints = [];
248+
this.boundsOverride = null;
245249
this.assets.clear();
246250
this.lineBuffer = "";
247251
this.startTimeMs = this.options.startTimeMs ?? Date.now();
@@ -330,6 +334,20 @@ export class MacNativeCursorRecordingSession implements CursorRecordingSession {
330334
};
331335
}
332336

337+
updateCaptureBounds(bounds: Rectangle): void {
338+
this.boundsOverride = bounds;
339+
const width = Math.max(1, bounds.width);
340+
const height = Math.max(1, bounds.height);
341+
for (let index = 0; index < this.samples.length; index++) {
342+
const point = this.screenPoints[index];
343+
if (!point) {
344+
continue;
345+
}
346+
this.samples[index].cx = clamp((point.x - bounds.x) / width, 0, 1);
347+
this.samples[index].cy = clamp((point.y - bounds.y) / height, 0, 1);
348+
}
349+
}
350+
333351
private startPositionOnlyFallback() {
334352
this.captureSample(Date.now(), null, null, false, false, false);
335353
this.fallbackInterval = setInterval(() => {
@@ -408,7 +426,10 @@ export class MacNativeCursorRecordingSession implements CursorRecordingSession {
408426
leftButtonReleased: boolean,
409427
) {
410428
const cursor = screen.getCursorScreenPoint();
411-
const bounds = this.options.getDisplayBounds() ?? screen.getDisplayNearestPoint(cursor).bounds;
429+
const bounds =
430+
this.boundsOverride ??
431+
this.options.getDisplayBounds() ??
432+
screen.getDisplayNearestPoint(cursor).bounds;
412433
const width = Math.max(1, bounds.width);
413434
const height = Math.max(1, bounds.height);
414435
const normalizedX = (cursor.x - bounds.x) / width;
@@ -442,9 +463,11 @@ export class MacNativeCursorRecordingSession implements CursorRecordingSession {
442463
...(assetId ? { assetId } : {}),
443464
...(cursorType ? { cursorType } : {}),
444465
});
466+
this.screenPoints.push({ x: cursor.x, y: cursor.y });
445467

446468
if (this.samples.length > this.options.maxSamples) {
447469
this.samples.shift();
470+
this.screenPoints.shift();
448471
}
449472
}
450473

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
1+
import type { Rectangle } from "electron";
12
import type { CursorRecordingData } from "../../../../src/native/contracts";
23

34
export interface CursorRecordingSession {
45
start(): Promise<void>;
56
stop(): Promise<CursorRecordingData>;
7+
updateCaptureBounds?(bounds: Rectangle): void;
68
}

‎electron/native/screencapturekit/Sources/OpenScreenScreenCaptureKitHelper/ScreenCaptureRecorder.swift‎

Lines changed: 23 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -119,9 +119,7 @@ final class ScreenCaptureRecorder: NSObject, SCStreamOutput, SCStreamDelegate {
119119
let filter: SCContentFilter
120120
let width: Int
121121
let height: Int
122-
// Global frame (points, top-left origin) of the captured region. The cursor is
123-
// normalised against this, so it is the filter's content rect — the same
124-
// rectangle the output buffer is sized from — wherever that exists.
122+
// Global frame (points, top-left origin) the cursor is normalised against.
125123
let captureFrame: CGRect
126124
}
127125

@@ -1151,23 +1149,33 @@ final class ScreenCaptureRecorder: NSObject, SCStreamOutput, SCStreamDelegate {
11511149
return status == .complete
11521150
}
11531151

1154-
/// On-screen rectangle of the pixels this filter will rasterise, in points.
1155-
///
1156-
/// `captureSize` already takes its dimensions from `contentRect`: that product is
1157-
/// the buffer ScreenCaptureKit fills. The cursor is a global Electron point
1158-
/// normalised against a frame reported separately, so the frame has to be that
1159-
/// same rectangle, origin included. `SCWindow.frame` and the frame stored with a
1160-
/// system-picker choice describe the window, which on macOS 26 sits below the
1161-
/// pixels the stream writes; the pointer then draws above the click (#1130).
1162-
/// No constant is added here to compensate. `contentRect` is screen points and
1163-
/// exists from macOS 14. On 13 the source frame is the only measurement available.
1152+
/// One-window filters use `kCGWindowBounds`: a picker window's `contentRect` sits
1153+
/// below the pixels the cursor is on (#1130). Displays keep `contentRect`.
11641154
private func captureContentFrame(for filter: SCContentFilter, fallback: CGRect) -> CGRect {
1165-
guard #available(macOS 14.0, *) else {
1166-
return fallback
1155+
if #available(macOS 15.2, *),
1156+
filter.includedWindows.count == 1,
1157+
let windowID = filter.includedWindows.first?.windowID,
1158+
let bounds = Self.cgWindowBounds(windowID) {
1159+
return bounds
11671160
}
1161+
guard #available(macOS 14.0, *) else { return fallback }
11681162
return filter.contentRect
11691163
}
11701164

1165+
private static func cgWindowBounds(_ windowID: UInt32) -> CGRect? {
1166+
guard let entries = CGWindowListCopyWindowInfo(.optionIncludingWindow, CGWindowID(windowID))
1167+
as? [[String: Any]],
1168+
let raw = entries.first(where: {
1169+
($0[kCGWindowNumber as String] as? NSNumber)?.uint32Value == windowID
1170+
})?[kCGWindowBounds as String] as? NSDictionary
1171+
else { return nil }
1172+
var rect = CGRect.null
1173+
guard CGRectMakeWithDictionaryRepresentation(raw, &rect), rect.width > 0, rect.height > 0 else {
1174+
return nil
1175+
}
1176+
return rect
1177+
}
1178+
11711179
/// Output-buffer size for a filter, capped by the resolution the app asked for.
11721180
///
11731181
/// The filter is asked first, because `contentRect × pointPixelScale` is by definition the

0 commit comments

Comments
 (0)