Skip to content

Commit 5dd5dd4

Browse files
mvanhornEtienneLescot
authored andcommitted
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 94df22f commit 5dd5dd4

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

@@ -1182,23 +1180,33 @@ final class ScreenCaptureRecorder: NSObject, SCStreamOutput, SCStreamDelegate {
11821180
return status == .complete
11831181
}
11841182

1185-
/// On-screen rectangle of the pixels this filter will rasterise, in points.
1186-
///
1187-
/// `captureSize` already takes its dimensions from `contentRect`: that product is
1188-
/// the buffer ScreenCaptureKit fills. The cursor is a global Electron point
1189-
/// normalised against a frame reported separately, so the frame has to be that
1190-
/// same rectangle, origin included. `SCWindow.frame` and the frame stored with a
1191-
/// system-picker choice describe the window, which on macOS 26 sits below the
1192-
/// pixels the stream writes; the pointer then draws above the click (#1130).
1193-
/// No constant is added here to compensate. `contentRect` is screen points and
1194-
/// exists from macOS 14. On 13 the source frame is the only measurement available.
1183+
/// One-window filters use `kCGWindowBounds`: a picker window's `contentRect` sits
1184+
/// below the pixels the cursor is on (#1130). Displays keep `contentRect`.
11951185
private func captureContentFrame(for filter: SCContentFilter, fallback: CGRect) -> CGRect {
1196-
guard #available(macOS 14.0, *) else {
1197-
return fallback
1186+
if #available(macOS 15.2, *),
1187+
filter.includedWindows.count == 1,
1188+
let windowID = filter.includedWindows.first?.windowID,
1189+
let bounds = Self.cgWindowBounds(windowID) {
1190+
return bounds
11981191
}
1192+
guard #available(macOS 14.0, *) else { return fallback }
11991193
return filter.contentRect
12001194
}
12011195

1196+
private static func cgWindowBounds(_ windowID: UInt32) -> CGRect? {
1197+
guard let entries = CGWindowListCopyWindowInfo(.optionIncludingWindow, CGWindowID(windowID))
1198+
as? [[String: Any]],
1199+
let raw = entries.first(where: {
1200+
($0[kCGWindowNumber as String] as? NSNumber)?.uint32Value == windowID
1201+
})?[kCGWindowBounds as String] as? NSDictionary
1202+
else { return nil }
1203+
var rect = CGRect.null
1204+
guard CGRectMakeWithDictionaryRepresentation(raw, &rect), rect.width > 0, rect.height > 0 else {
1205+
return nil
1206+
}
1207+
return rect
1208+
}
1209+
12021210
/// Output-buffer size for a filter, capped by the resolution the app asked for.
12031211
///
12041212
/// The filter is asked first, because `contentRect × pointPixelScale` is by definition the

0 commit comments

Comments
 (0)