node-mac-recorder 2.24.15 → 2.24.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -17,7 +17,7 @@ extern "C" bool isStandaloneAudioRecording(void);
17
17
  extern "C" bool pauseIOSDeviceRecording(void);
18
18
  extern "C" bool resumeIOSDeviceRecording(void);
19
19
 
20
- @interface MRIOSDeviceRecorder : NSObject <AVCaptureFileOutputRecordingDelegate>
20
+ @interface MRIOSDeviceRecorder : NSObject <AVCaptureFileOutputRecordingDelegate, AVCaptureFileOutputDelegate>
21
21
  @property(nonatomic, strong) AVCaptureSession *session;
22
22
  @property(nonatomic, strong) AVCaptureDeviceInput *deviceInput;
23
23
  @property(nonatomic, strong) AVCaptureMovieFileOutput *movieOutput;
@@ -27,6 +27,7 @@ extern "C" bool resumeIOSDeviceRecording(void);
27
27
  @property(atomic) BOOL finishCompleted;
28
28
  @property(atomic) BOOL startRequested;
29
29
  @property(atomic) BOOL stopRequested;
30
+ @property(atomic) BOOL segmentStartPending;
30
31
  @property(atomic, strong) NSError *finishError;
31
32
  @property(atomic) BOOL paused;
32
33
  @property(atomic) BOOL segmentStartCompleted;
@@ -42,7 +43,8 @@ extern "C" bool resumeIOSDeviceRecording(void);
42
43
  @property(nonatomic) BOOL captureMicrophone;
43
44
  @property(nonatomic, copy) NSString *cameraOutputPath;
44
45
  @property(nonatomic, copy) NSString *audioOutputPath;
45
- @property(nonatomic, strong) NSDate *primaryStartedAt;
46
+ @property(atomic) CMTime primaryStartHostTime;
47
+ @property(atomic) CMTime stopHostTime;
46
48
  @end
47
49
 
48
50
  static BOOL MRIOSHasProducedMedia(MRIOSDeviceRecorder *recorder) {
@@ -67,10 +69,6 @@ static void MRIOSMarkSegmentStarted(MRIOSDeviceRecorder *recorder,
67
69
  recorder.segmentStartCompleted = YES;
68
70
  if (!recorder.startCompleted) {
69
71
  recorder.startCompleted = YES;
70
- recorder.primaryStartedAt = [NSDate date];
71
- if (!recorder.stopRequested) {
72
- MRSyncMarkPrimaryStarted(CMClockGetTime(CMClockGetHostTimeClock()));
73
- }
74
72
  }
75
73
  if (!confirmedByDelegate) {
76
74
  MRLog(@"✅ iPhone capture start confirmed from recorded media progress");
@@ -79,6 +77,36 @@ static void MRIOSMarkSegmentStarted(MRIOSDeviceRecorder *recorder,
79
77
 
80
78
  @implementation MRIOSDeviceRecorder
81
79
 
80
+ - (BOOL)captureOutputShouldProvideSampleAccurateRecordingStart:(AVCaptureOutput *)output {
81
+ return YES;
82
+ }
83
+
84
+ - (void)captureOutput:(AVCaptureFileOutput *)output
85
+ didOutputSampleBuffer:(CMSampleBufferRef)sampleBuffer
86
+ fromConnection:(AVCaptureConnection *)connection {
87
+ if (!self.segmentStartPending || !CMSampleBufferDataIsReady(sampleBuffer)) return;
88
+ @synchronized (self) {
89
+ if (!self.segmentStartPending || self.stopRequested) return;
90
+ CMTime hostTime = MRSyncHostTimestamp(
91
+ CMSampleBufferGetPresentationTimeStamp(sampleBuffer), self.session.masterClock);
92
+ if (!CMTIME_IS_NUMERIC(hostTime)) return;
93
+ self.segmentStartPending = NO;
94
+ @try {
95
+ // macOS guarantees that a start requested inside this delegate
96
+ // includes this exact sample. The later didStart/progress signal
97
+ // confirms success but must never redefine the media's origin.
98
+ [output startRecordingToOutputFileURL:[NSURL fileURLWithPath:self.currentSegmentPath]
99
+ recordingDelegate:self];
100
+ self.primaryStartHostTime = hostTime;
101
+ MRSyncMarkPrimaryStarted(hostTime);
102
+ } @catch (NSException *exception) {
103
+ self.segmentFinishError = [NSError errorWithDomain:@"MacRecorderIOS" code:18
104
+ userInfo:@{NSLocalizedDescriptionKey: exception.reason ?: @"iPhone sample start failed"}];
105
+ self.segmentFinishCompleted = YES;
106
+ }
107
+ }
108
+ }
109
+
82
110
  - (void)captureOutput:(AVCaptureFileOutput *)captureOutput
83
111
  didStartRecordingToOutputFileAtURL:(NSURL *)fileURL
84
112
  fromConnections:(NSArray<AVCaptureConnection *> *)connections {
@@ -303,27 +331,27 @@ static BOOL MRIOSFileOutputSucceeded(NSError *error) {
303
331
  return [successfullyFinished boolValue];
304
332
  }
305
333
 
306
- static NSTimeInterval MRIOSRecordedDurationSeconds(MRIOSDeviceRecorder *recorder) {
334
+ static NSTimeInterval MRIOSRecordedDurationSeconds(MRIOSDeviceRecorder *recorder, CMTime hostTime) {
335
+ if (CMTIME_IS_NUMERIC(recorder.primaryStartHostTime)) {
336
+ return MAX(0.0, CMTimeGetSeconds(CMTimeSubtract(hostTime, recorder.primaryStartHostTime)));
337
+ }
307
338
  CMTime duration = recorder.movieOutput.recordedDuration;
308
339
  if (CMTIME_IS_NUMERIC(duration) &&
309
340
  CMTIME_COMPARE_INLINE(duration, >=, kCMTimeZero)) {
310
341
  return MAX(0.0, CMTimeGetSeconds(duration));
311
342
  }
312
- if (recorder.primaryStartedAt) {
313
- return MAX(0.0, -[recorder.primaryStartedAt timeIntervalSinceNow]);
314
- }
315
343
  return 0.0;
316
344
  }
317
345
 
318
- static void MRIOSBeginPauseRange(MRIOSDeviceRecorder *recorder) {
319
- recorder.pauseStartedAtSeconds = MRIOSRecordedDurationSeconds(recorder);
346
+ static void MRIOSBeginPauseRange(MRIOSDeviceRecorder *recorder, CMTime hostTime) {
347
+ recorder.pauseStartedAtSeconds = MRIOSRecordedDurationSeconds(recorder, hostTime);
320
348
  MRLog(@"⏸️ iPhone capture pause marker at %.3f seconds",
321
349
  recorder.pauseStartedAtSeconds);
322
350
  }
323
351
 
324
- static void MRIOSEndPauseRange(MRIOSDeviceRecorder *recorder) {
352
+ static void MRIOSEndPauseRange(MRIOSDeviceRecorder *recorder, CMTime hostTime) {
325
353
  if (recorder.pauseStartedAtSeconds < 0.0) return;
326
- NSTimeInterval end = MRIOSRecordedDurationSeconds(recorder);
354
+ NSTimeInterval end = MRIOSRecordedDurationSeconds(recorder, hostTime);
327
355
  NSTimeInterval start = recorder.pauseStartedAtSeconds;
328
356
  recorder.pauseStartedAtSeconds = -1.0;
329
357
  if (end <= start) return;
@@ -358,8 +386,7 @@ static BOOL MRIOSStartNextSegment(MRIOSDeviceRecorder *recorder, NSError **error
358
386
  recorder.segmentFinishError = nil;
359
387
  recorder.recording = NO;
360
388
 
361
- [recorder.movieOutput startRecordingToOutputFileURL:[NSURL fileURLWithPath:segmentPath]
362
- recordingDelegate:recorder];
389
+ recorder.segmentStartPending = YES;
363
390
  // AVCaptureMovieFileOutput can begin writing before its delegate callback
364
391
  // is delivered. That callback may be queued behind Electron's synchronous
365
392
  // native call, so requiring only the callback creates a false timeout even
@@ -391,7 +418,7 @@ static BOOL MRIOSStartNextSegment(MRIOSDeviceRecorder *recorder, NSError **error
391
418
  (unsigned long)recorder.movieOutput.connections.count);
392
419
  if (errorOut) {
393
420
  BOOL connectedButNoFrames = recorder.session.isRunning &&
394
- recorder.movieOutput.isRecording && durationSeconds <= 0.0;
421
+ (recorder.segmentStartPending || recorder.movieOutput.isRecording) && durationSeconds <= 0.0;
395
422
  *errorOut = recorder.segmentFinishError ?: (connectedButNoFrames
396
423
  ? MRIOSNoFramesError()
397
424
  : [NSError errorWithDomain:@"MacRecorderIOS"
@@ -405,9 +432,10 @@ static BOOL MRIOSStartNextSegment(MRIOSDeviceRecorder *recorder, NSError **error
405
432
  }
406
433
 
407
434
  static BOOL MRIOSStopCurrentSegment(MRIOSDeviceRecorder *recorder) {
408
- if (recorder.movieOutput.isRecording) {
409
- [recorder.movieOutput stopRecording];
435
+ @synchronized (recorder) {
436
+ recorder.segmentStartPending = NO;
410
437
  }
438
+ if (recorder.movieOutput.isRecording) [recorder.movieOutput stopRecording];
411
439
  if (recorder.segmentStartCompleted && !recorder.segmentFinishCompleted) {
412
440
  if (!MRWaitForFlag(^bool{ return recorder.segmentFinishCompleted; }, 20.0)) {
413
441
  MRLog(@"⚠️ iPhone segment is still finalizing");
@@ -692,7 +720,9 @@ extern "C" bool startIOSDeviceRecording(NSString *outputPath,
692
720
  recorder.captureMicrophone = captureMicrophone;
693
721
  recorder.cameraOutputPath = cameraOutputPath;
694
722
  recorder.audioOutputPath = audioOutputPath;
695
- recorder.primaryStartedAt = nil;
723
+ recorder.primaryStartHostTime = kCMTimeInvalid;
724
+ recorder.stopHostTime = kCMTimeInvalid;
725
+ recorder.movieOutput.delegate = recorder;
696
726
 
697
727
  g_iosRecorder = recorder;
698
728
  [recorder.session beginConfiguration];
@@ -728,12 +758,11 @@ extern "C" bool startIOSDeviceRecording(NSString *outputPath,
728
758
  recorder.movieOutput.movieFragmentInterval = CMTimeMakeWithSeconds(2.0, 600);
729
759
  g_iosRecorder = recorder;
730
760
 
731
- // Camera and microphone sessions are prepared first but discard their
732
- // warm-up samples until AVCaptureMovieFileOutput confirms the iPhone's
733
- // first frame. Every produced file therefore starts at the same t=0.
761
+ // Use the sample-accurate USB origin for every file, including a camera
762
+ // or microphone whose first callback arrives later.
734
763
  MRSyncConfigure(captureMicrophone);
735
764
  MRSyncConfigureCamera(captureCamera);
736
- MRSyncConfigurePrimaryStart(captureCamera || captureMicrophone);
765
+ MRSyncConfigurePrimaryStart(YES);
737
766
 
738
767
  if (captureCamera) {
739
768
  NSError *cameraError = nil;
@@ -838,15 +867,24 @@ extern "C" bool stopIOSDeviceRecording(void) {
838
867
  MRIOSDeviceRecorder *recorder = g_iosRecorder;
839
868
  if (!recorder) return true;
840
869
 
841
- recorder.stopRequested = YES;
842
- if (recorder.primaryStartedAt) {
843
- NSTimeInterval duration = MAX(0.0,
844
- -[recorder.primaryStartedAt timeIntervalSinceNow] - MRSyncGetPausedDurationSeconds());
845
- MRSyncSetStopLimitSeconds(duration);
870
+ CMTime stopHostTime;
871
+ @synchronized (recorder) {
872
+ if (!CMTIME_IS_NUMERIC(recorder.stopHostTime)) {
873
+ recorder.stopHostTime = CMClockGetTime(CMClockGetHostTimeClock());
874
+ }
875
+ stopHostTime = recorder.stopHostTime;
876
+ recorder.stopRequested = YES;
877
+ recorder.segmentStartPending = NO;
846
878
  }
879
+ // Request the USB stop before waiting for either auxiliary writer.
880
+ // Do not hold the delegate lock across an AVFoundation stop call.
881
+ if (recorder.movieOutput.isRecording) [recorder.movieOutput stopRecording];
847
882
  if (recorder.paused) {
848
- MRIOSEndPauseRange(recorder);
883
+ MRIOSEndPauseRange(recorder, stopHostTime);
884
+ MRSyncResumeAtHostTime(stopHostTime);
849
885
  }
886
+ CMTime mediaStopTime = MRSyncPrimaryMediaTime(stopHostTime);
887
+ if (CMTIME_IS_NUMERIC(mediaStopTime)) MRSyncSetStopLimitSeconds(CMTimeGetSeconds(mediaStopTime));
850
888
 
851
889
  BOOL cameraStopped = YES;
852
890
  BOOL microphoneStopped = YES;
@@ -894,6 +932,7 @@ extern "C" bool stopIOSDeviceRecording(void) {
894
932
  } @catch (NSException *exception) {
895
933
  MRLog(@"⚠️ iPhone capture teardown warning: %@", exception.reason);
896
934
  }
935
+ recorder.movieOutput.delegate = nil;
897
936
  recorder.movieOutput = nil;
898
937
  recorder.deviceInput = nil;
899
938
  recorder.session = nil;
@@ -906,7 +945,7 @@ extern "C" bool stopIOSDeviceRecording(void) {
906
945
  recorder.outputPath = nil;
907
946
  recorder.cameraOutputPath = nil;
908
947
  recorder.audioOutputPath = nil;
909
- recorder.primaryStartedAt = nil;
948
+ recorder.primaryStartHostTime = kCMTimeInvalid;
910
949
  g_iosRecorder = nil;
911
950
  [recorder release];
912
951
 
@@ -948,12 +987,13 @@ extern "C" bool pauseIOSDeviceRecording(void) {
948
987
  // Do not stop or pause AVCaptureMovieFileOutput here. USB iPhone
949
988
  // muxed sources may fail to re-arm their compressor on resume.
950
989
  // Capture continuously and trim this time range during finalization.
951
- MRIOSBeginPauseRange(recorder);
952
- MRSyncPause();
990
+ CMTime pauseHostTime = CMClockGetTime(CMClockGetHostTimeClock());
991
+ MRIOSBeginPauseRange(recorder, pauseHostTime);
992
+ MRSyncPauseAtHostTime(pauseHostTime);
953
993
  recorder.paused = YES;
954
994
  return true;
955
995
  } @catch (NSException *exception) {
956
- MRSyncResume();
996
+ MRSyncResumeAtHostTime(CMClockGetTime(CMClockGetHostTimeClock()));
957
997
  MRLog(@"❌ iPhone pause failed: %@", exception.reason);
958
998
  return false;
959
999
  }
@@ -967,9 +1007,10 @@ extern "C" bool resumeIOSDeviceRecording(void) {
967
1007
  if (!recorder.paused) return recorder.movieOutput.isRecording;
968
1008
  if (!recorder.movieOutput.isRecording) return false;
969
1009
  @try {
970
- MRIOSEndPauseRange(recorder);
1010
+ CMTime resumeHostTime = CMClockGetTime(CMClockGetHostTimeClock());
1011
+ MRIOSEndPauseRange(recorder, resumeHostTime);
971
1012
  recorder.paused = NO;
972
- MRSyncResume();
1013
+ MRSyncResumeAtHostTime(resumeHostTime);
973
1014
  return true;
974
1015
  } @catch (NSException *exception) {
975
1016
  MRLog(@"❌ iPhone resume failed: %@", exception.reason);
@@ -40,6 +40,16 @@ void MRSyncConfigurePrimaryStart(BOOL expectPrimary);
40
40
  void MRSyncMarkPrimaryStarted(CMTime timestamp);
41
41
  BOOL MRSyncShouldHoldForPrimary(CMTime timestamp);
42
42
 
43
+ // iPhone-only timeline. Convert each capture session's PTS to the host clock
44
+ // before comparing sources; preserve late arrivals instead of rebasing each
45
+ // file to its own first sample. Invalid media time means discard the sample.
46
+ BOOL MRSyncUsesPrimaryTimeline(void);
47
+ CMTime MRSyncPrimaryStartTimestamp(void);
48
+ CMTime MRSyncHostTimestamp(CMTime timestamp, CMClockRef captureClock);
49
+ CMTime MRSyncPrimaryMediaTime(CMTime hostTimestamp);
50
+ void MRSyncPauseAtHostTime(CMTime timestamp);
51
+ void MRSyncResumeAtHostTime(CMTime timestamp);
52
+
43
53
  // Optional hard stop limit (seconds) shared across capture components.
44
54
  void MRSyncSetStopLimitSeconds(double seconds);
45
55
  double MRSyncGetStopLimitSeconds(void);
@@ -1,5 +1,6 @@
1
1
  #import "sync_timeline.h"
2
2
  #import "logging.h"
3
+ #include <vector>
3
4
 
4
5
  static dispatch_queue_t MRSyncQueue() {
5
6
  static dispatch_once_t onceToken;
@@ -34,6 +35,8 @@ static BOOL g_primaryReady = YES;
34
35
  static CMTime g_primaryStartTimestamp = kCMTimeInvalid;
35
36
  static CMTime g_primaryHoldFirstTimestamp = kCMTimeInvalid;
36
37
  static BOOL g_primaryHoldLogged = NO;
38
+ struct MRPrimaryPauseRange { CMTime start; CMTime end; };
39
+ static std::vector<MRPrimaryPauseRange> g_primaryPauses;
37
40
 
38
41
  void MRSyncConfigure(BOOL expectAudio) {
39
42
  dispatch_sync(MRSyncQueue(), ^{
@@ -58,6 +61,7 @@ void MRSyncConfigure(BOOL expectAudio) {
58
61
  g_primaryStartTimestamp = kCMTimeInvalid;
59
62
  g_primaryHoldFirstTimestamp = kCMTimeInvalid;
60
63
  g_primaryHoldLogged = NO;
64
+ g_primaryPauses.clear();
61
65
  });
62
66
  }
63
67
 
@@ -94,7 +98,10 @@ double MRSyncGetPausedDurationSeconds(void) {
94
98
  __block double duration = 0;
95
99
  dispatch_sync(MRSyncQueue(), ^{
96
100
  duration = g_totalPausedSeconds;
97
- if (g_isPaused && g_pauseStartedAt > 0) {
101
+ if (g_expectPrimary && g_isPaused && !g_primaryPauses.empty()) {
102
+ duration += MAX(0, CMTimeGetSeconds(CMTimeSubtract(
103
+ CMClockGetTime(CMClockGetHostTimeClock()), g_primaryPauses.back().start)));
104
+ } else if (g_isPaused && g_pauseStartedAt > 0) {
98
105
  duration += MAX(0, CFAbsoluteTimeGetCurrent() - g_pauseStartedAt);
99
106
  }
100
107
  });
@@ -318,12 +325,67 @@ void MRSyncConfigurePrimaryStart(BOOL expectPrimary) {
318
325
  g_primaryStartTimestamp = kCMTimeInvalid;
319
326
  g_primaryHoldFirstTimestamp = kCMTimeInvalid;
320
327
  g_primaryHoldLogged = NO;
328
+ g_primaryPauses.clear();
321
329
  });
322
330
  if (expectPrimary) {
323
331
  MRLog(@"🔄 A/V SYNC: Primary-source start barrier enabled");
324
332
  }
325
333
  }
326
334
 
335
+ BOOL MRSyncUsesPrimaryTimeline(void) {
336
+ __block BOOL enabled = NO;
337
+ dispatch_sync(MRSyncQueue(), ^{ enabled = g_expectPrimary; });
338
+ return enabled;
339
+ }
340
+
341
+ CMTime MRSyncPrimaryStartTimestamp(void) {
342
+ __block CMTime timestamp = kCMTimeInvalid;
343
+ dispatch_sync(MRSyncQueue(), ^{ timestamp = g_primaryStartTimestamp; });
344
+ return timestamp;
345
+ }
346
+
347
+ CMTime MRSyncHostTimestamp(CMTime timestamp, CMClockRef captureClock) {
348
+ if (!CMTIME_IS_NUMERIC(timestamp) || !captureClock) return kCMTimeInvalid;
349
+ return CMSyncConvertTime(timestamp, captureClock, CMClockGetHostTimeClock());
350
+ }
351
+
352
+ CMTime MRSyncPrimaryMediaTime(CMTime hostTimestamp) {
353
+ if (!CMTIME_IS_NUMERIC(hostTimestamp)) return kCMTimeInvalid;
354
+ __block CMTime result = kCMTimeInvalid;
355
+ dispatch_sync(MRSyncQueue(), ^{
356
+ if (!g_expectPrimary || !CMTIME_IS_NUMERIC(g_primaryStartTimestamp) ||
357
+ CMTimeCompare(hostTimestamp, g_primaryStartTimestamp) < 0) return;
358
+ CMTime paused = kCMTimeZero;
359
+ for (const auto &range : g_primaryPauses) {
360
+ if (CMTimeCompare(hostTimestamp, range.start) < 0) break;
361
+ if (!CMTIME_IS_NUMERIC(range.end) || CMTimeCompare(hostTimestamp, range.end) < 0) return;
362
+ paused = CMTimeAdd(paused, CMTimeSubtract(range.end, range.start));
363
+ }
364
+ result = CMTimeSubtract(CMTimeSubtract(hostTimestamp, g_primaryStartTimestamp), paused);
365
+ });
366
+ return result;
367
+ }
368
+
369
+ void MRSyncPauseAtHostTime(CMTime timestamp) {
370
+ if (!CMTIME_IS_NUMERIC(timestamp)) return;
371
+ dispatch_sync(MRSyncQueue(), ^{
372
+ if (!g_expectPrimary || g_isPaused) return;
373
+ g_primaryPauses.push_back({timestamp, kCMTimeInvalid});
374
+ g_isPaused = YES;
375
+ });
376
+ }
377
+
378
+ void MRSyncResumeAtHostTime(CMTime timestamp) {
379
+ if (!CMTIME_IS_NUMERIC(timestamp)) return;
380
+ dispatch_sync(MRSyncQueue(), ^{
381
+ if (!g_expectPrimary || !g_isPaused || g_primaryPauses.empty()) return;
382
+ auto &range = g_primaryPauses.back();
383
+ range.end = CMTimeMaximum(timestamp, range.start);
384
+ g_totalPausedSeconds += CMTimeGetSeconds(CMTimeSubtract(range.end, range.start));
385
+ g_isPaused = NO;
386
+ });
387
+ }
388
+
327
389
  void MRSyncMarkPrimaryStarted(CMTime timestamp) {
328
390
  if (!CMTIME_IS_VALID(timestamp)) return;
329
391
 
@@ -0,0 +1,207 @@
1
+ // Exercise the production detector with real AppKit images, including detached
2
+ // bitmap copies as returned for cursors belonging to other applications.
3
+ #import "../src/cursor_tracker.mm"
4
+
5
+ static Napi::Value RunCursorTests(const Napi::CallbackInfo &info) {
6
+ @autoreleasepool {
7
+ NSMutableArray<NSString *> *failures = [NSMutableArray array];
8
+ NSUInteger checks = 0;
9
+ auto expect = [&](NSString *actual, NSString *expected, NSString *label) {
10
+ checks++;
11
+ if (!StringsEqual(actual, expected)) {
12
+ [failures addObject:[NSString stringWithFormat:@"%@: expected %@, got %@", label, expected, actual]];
13
+ }
14
+ };
15
+ InitializeCursorFingerprintMap();
16
+ auto checkCursor = [&](NSCursor *cursor, NSString *expected, NSString *label) {
17
+ expect(normalizeCursorTypeForDesktop(cursorTypeFromNSCursor(cursor)), expected, label);
18
+ NSImage *image = cursor.image;
19
+ if (!image || image.size.width <= 0 || image.size.height <= 0) {
20
+ [failures addObject:[label stringByAppendingString:@": AppKit image unavailable"]];
21
+ return;
22
+ }
23
+ for (NSUInteger scale = 1; scale <= 2; scale++) {
24
+ NSSize size = image.size;
25
+ NSBitmapImageRep *bitmap = [[[NSBitmapImageRep alloc]
26
+ initWithBitmapDataPlanes:NULL pixelsWide:lround(size.width * scale)
27
+ pixelsHigh:lround(size.height * scale) bitsPerSample:8 samplesPerPixel:4
28
+ hasAlpha:YES isPlanar:NO colorSpaceName:NSDeviceRGBColorSpace
29
+ bytesPerRow:0 bitsPerPixel:0] autorelease];
30
+ bitmap.size = size;
31
+ [NSGraphicsContext saveGraphicsState];
32
+ [NSGraphicsContext setCurrentContext:[NSGraphicsContext graphicsContextWithBitmapImageRep:bitmap]];
33
+ [image drawInRect:NSMakeRect(0, 0, size.width, size.height) fromRect:NSZeroRect
34
+ operation:NSCompositingOperationCopy fraction:1.0];
35
+ [NSGraphicsContext restoreGraphicsState];
36
+ NSImage *copy = [[[NSImage alloc] initWithSize:size] autorelease];
37
+ [copy addRepresentation:bitmap];
38
+ NSCursor *detached = [[[NSCursor alloc] initWithImage:copy hotSpot:cursor.hotSpot] autorelease];
39
+ expect(normalizeCursorTypeForDesktop(cursorTypeFromNSCursor(detached)), expected,
40
+ [NSString stringWithFormat:@"%@ detached %lux", label, (unsigned long)scale]);
41
+ }
42
+ };
43
+
44
+ NSDictionary *names = @{
45
+ @"contextualMenuCursor": @"default", @"dragLinkCursor": @"alias",
46
+ @"move": @"all-scroll", @"help": @"help", @"zoomOutCursor": @"zoom-out",
47
+ @"n-resize": @"ns-resize", @"s-resize": @"ns-resize",
48
+ @"e-resize": @"col-resize", @"w-resize": @"col-resize",
49
+ @"ne-resize": @"nesw-resize", @"sw-resize": @"nesw-resize",
50
+ @"nw-resize": @"nwse-resize", @"se-resize": @"nwse-resize",
51
+ @"resizeNorthEastCursor": @"nesw-resize", @"resizeNorthWestCursor": @"nwse-resize",
52
+ @"row-resize": @"row-resize", @"col-resize": @"col-resize",
53
+ @"resizeNorthSouthCursor": @"ns-resize", @"resizeEastWestCursor": @"col-resize"
54
+ };
55
+ for (NSString *name in names) {
56
+ expect(normalizeCursorTypeForDesktop(cursorTypeFromCursorName(name)), names[name], name);
57
+ }
58
+ expect(cursorTypeFromCursorName(@"unknown-resize"), nil, @"unknown direction must remain unknown");
59
+
60
+ checkCursor(NSCursor.arrowCursor, @"default", @"arrow");
61
+ checkCursor(NSCursor.pointingHandCursor, @"pointer", @"pointing hand");
62
+ checkCursor(NSCursor.IBeamCursor, @"text", @"text");
63
+ checkCursor(NSCursor.crosshairCursor, @"crosshair", @"crosshair");
64
+ checkCursor(NSCursor.openHandCursor, @"grab", @"open hand");
65
+ checkCursor(NSCursor.closedHandCursor, @"grabbing", @"closed hand");
66
+ checkCursor(NSCursor.dragCopyCursor, @"copy", @"copy");
67
+ checkCursor(NSCursor.dragLinkCursor, @"alias", @"alias");
68
+ checkCursor(NSCursor.operationNotAllowedCursor, @"not-allowed", @"not allowed");
69
+ if ([NSCursor instancesRespondToSelector:NSSelectorFromString(@"_coreCursorType")]) {
70
+ checkCursor([[[MRSystemReferenceCursor alloc] initWithCoreType:39] autorelease], @"all-scroll", @"CoreCursor move");
71
+ checkCursor([[[MRSystemReferenceCursor alloc] initWithCoreType:11] autorelease], @"grabbing", @"CoreCursor closed hand");
72
+ checkCursor([[[MRSystemReferenceCursor alloc] initWithCoreType:12] autorelease], @"grab", @"CoreCursor open hand");
73
+ }
74
+ if (@available(macOS 15.0, *)) {
75
+ checkCursor(NSCursor.zoomInCursor, @"zoom-in", @"zoom in");
76
+ checkCursor(NSCursor.zoomOutCursor, @"zoom-out", @"zoom out");
77
+ for (NSUInteger direction = 1; direction <= 3; direction++) {
78
+ checkCursor([NSCursor rowResizeCursorInDirections:(NSVerticalDirections)direction], @"row-resize", @"row");
79
+ checkCursor([NSCursor columnResizeCursorInDirections:(NSHorizontalDirections)direction], @"col-resize", @"column");
80
+ const NSCursorFrameResizePosition positions[] = {
81
+ NSCursorFrameResizePositionTop, NSCursorFrameResizePositionBottom,
82
+ NSCursorFrameResizePositionLeft, NSCursorFrameResizePositionRight,
83
+ NSCursorFrameResizePositionTopLeft, NSCursorFrameResizePositionBottomRight,
84
+ NSCursorFrameResizePositionTopRight, NSCursorFrameResizePositionBottomLeft
85
+ };
86
+ NSArray *expected = @[@"ns-resize", @"ns-resize", @"col-resize", @"col-resize",
87
+ @"nwse-resize", @"nwse-resize", @"nesw-resize", @"nesw-resize"];
88
+ for (NSUInteger i = 0; i < 8; i++) {
89
+ checkCursor([NSCursor frameResizeCursorFromPosition:positions[i]
90
+ inDirections:(NSCursorFrameResizeDirections)direction], expected[i],
91
+ [NSString stringWithFormat:@"frame %lu direction %lu", (unsigned long)i, (unsigned long)direction]);
92
+ }
93
+ }
94
+ }
95
+
96
+ NSDictionary *resources = @{
97
+ @"help": @"help", @"busybutclickable": @"progress", @"move": @"all-scroll",
98
+ @"zoomin": @"zoom-in", @"zoomout": @"zoom-out",
99
+ @"resizenortheast": @"nesw-resize", @"resizesouthwest": @"nesw-resize",
100
+ @"resizenorthwest": @"nwse-resize", @"resizesoutheast": @"nwse-resize",
101
+ @"resizenortheastsouthwest": @"nesw-resize", @"resizenorthwestsoutheast": @"nwse-resize",
102
+ @"resizenorthsouth": @"ns-resize", @"resizeupdown": @"row-resize"
103
+ };
104
+ NSString *resourceRoot = @"/System/Library/Frameworks/ApplicationServices.framework/Versions/A/Frameworks/HIServices.framework/Versions/A/Resources/cursors";
105
+ for (NSString *resource in resources) {
106
+ NSString *directory = [resourceRoot stringByAppendingPathComponent:resource];
107
+ NSImage *image = [[[NSImage alloc] initWithContentsOfFile:[directory stringByAppendingPathComponent:@"cursor.pdf"]] autorelease];
108
+ if (!image) continue; // Legacy resources are optional on future macOS versions.
109
+ NSDictionary *metadata = [NSDictionary dictionaryWithContentsOfFile:[directory stringByAppendingPathComponent:@"info.plist"]];
110
+ NSCursor *cursor = [[[NSCursor alloc] initWithImage:image
111
+ hotSpot:NSMakePoint([metadata[@"hotx"] doubleValue], [metadata[@"hoty"] doubleValue])] autorelease];
112
+ checkCursor(cursor, resources[resource], resource);
113
+ }
114
+
115
+ // Unrelated custom cursors may have exactly the old heuristic dimensions.
116
+ NSImage *custom = [[[NSImage alloc] initWithSize:NSMakeSize(22, 22)] autorelease];
117
+ [custom lockFocus];
118
+ [[NSColor redColor] setFill];
119
+ NSRectFill(NSMakeRect(0, 0, 22, 22));
120
+ [custom unlockFocus];
121
+ NSCursor *customCursor = [[[NSCursor alloc] initWithImage:custom hotSpot:NSMakePoint(11, 11)] autorelease];
122
+ expect(cursorTypeFromNSCursor(customCursor), @"default", @"unknown 22x22 custom image");
123
+
124
+ ResetCursorEventHistory();
125
+ RememberCursorEvent(CGPointMake(100, 100), @"default", @"move");
126
+ expect(ShouldEmitCursorEvent(CGPointMake(100, 100), @"nesw-resize", @"move") ? @"yes" : @"no", @"yes", @"stationary shape change");
127
+ expect(ShouldEmitCursorEvent(CGPointMake(100, 100), @"default", @"move") ? @"yes" : @"no", @"no", @"stationary duplicate");
128
+ RememberCursorEvent(CGPointMake(100, 100), @"nwse-resize", @"drag");
129
+ expect(ShouldEmitCursorEvent(CGPointMake(100, 100), @"nesw-resize", @"drag") ? @"yes" : @"no", @"yes", @"stationary drag shape change");
130
+ ResetCursorEventHistory();
131
+
132
+ Napi::Object result = Napi::Object::New(info.Env());
133
+ result.Set("checks", Napi::Number::New(info.Env(), checks));
134
+ result.Set("failures", Napi::String::New(info.Env(), [[failures componentsJoinedByString:@"\n"] UTF8String]));
135
+ return result;
136
+ }
137
+ }
138
+
139
+ // Opt-in integration test: briefly display owned cursors and read them back from
140
+ // WindowServer. Restore the previous system cursor even when an assertion fails.
141
+ static Napi::Value RunLiveCursorTests(const Napi::CallbackInfo &info) {
142
+ @autoreleasepool {
143
+ [NSApplication sharedApplication];
144
+ InitializeCursorFingerprintMap();
145
+ NSMutableArray *failures = [NSMutableArray array];
146
+ NSMutableArray<NSCursor *> *cursors = [NSMutableArray arrayWithObjects:NSCursor.arrowCursor,
147
+ NSCursor.IBeamCursor, NSCursor.pointingHandCursor, NSCursor.openHandCursor,
148
+ NSCursor.closedHandCursor, NSCursor.dragCopyCursor, NSCursor.dragLinkCursor,
149
+ NSCursor.operationNotAllowedCursor, NSCursor.crosshairCursor, nil];
150
+ NSMutableArray *types = [NSMutableArray arrayWithArray:@[@"default", @"text", @"pointer",
151
+ @"grab", @"grabbing", @"copy", @"alias", @"not-allowed", @"crosshair"]];
152
+ if ([NSCursor instancesRespondToSelector:NSSelectorFromString(@"_coreCursorType")]) {
153
+ [cursors addObject:[[[MRSystemReferenceCursor alloc] initWithCoreType:39] autorelease]];
154
+ [types addObject:@"all-scroll"];
155
+ }
156
+ if (@available(macOS 15.0, *)) {
157
+ [cursors addObjectsFromArray:@[NSCursor.zoomInCursor, NSCursor.zoomOutCursor,
158
+ NSCursor.columnResizeCursor, NSCursor.rowResizeCursor,
159
+ [NSCursor frameResizeCursorFromPosition:NSCursorFrameResizePositionTop inDirections:NSCursorFrameResizeDirectionsAll],
160
+ [NSCursor frameResizeCursorFromPosition:NSCursorFrameResizePositionTopLeft inDirections:NSCursorFrameResizeDirectionsAll],
161
+ [NSCursor frameResizeCursorFromPosition:NSCursorFrameResizePositionTopRight inDirections:NSCursorFrameResizeDirectionsAll]]];
162
+ [types addObjectsFromArray:@[@"zoom-in", @"zoom-out", @"col-resize", @"row-resize", @"ns-resize", @"nwse-resize", @"nesw-resize"]];
163
+ }
164
+ NSCursor *previous = [[NSCursor currentSystemCursor] retain];
165
+ NSRunningApplication *previousApp = [[NSWorkspace sharedWorkspace].frontmostApplication retain];
166
+ NSPoint mouse = NSEvent.mouseLocation;
167
+ [NSApp setActivationPolicy:NSApplicationActivationPolicyAccessory];
168
+ NSWindow *window = [[NSWindow alloc] initWithContentRect:NSMakeRect(mouse.x - 80, mouse.y - 60, 160, 120)
169
+ styleMask:NSWindowStyleMaskTitled backing:NSBackingStoreBuffered defer:NO];
170
+ window.releasedWhenClosed = NO;
171
+ window.title = @"Cursor detection test";
172
+ [window disableCursorRects];
173
+ @try {
174
+ [window makeKeyAndOrderFront:nil];
175
+ [NSApp activateIgnoringOtherApps:YES];
176
+ [[NSRunLoop currentRunLoop] runUntilDate:[NSDate dateWithTimeIntervalSinceNow:0.2]];
177
+ for (NSUInteger i = 0; i < cursors.count; i++) {
178
+ [cursors[i] set];
179
+ [[NSRunLoop currentRunLoop] runUntilDate:[NSDate dateWithTimeIntervalSinceNow:0.03]];
180
+ NSString *actual = getCursorType();
181
+ if (!StringsEqual(actual, types[i])) {
182
+ NSCursor *system = NSCursor.currentSystemCursor;
183
+ [failures addObject:[NSString stringWithFormat:@"%@: got %@ (system image %@, hotspot %@)",
184
+ types[i], actual, NSStringFromSize(system.image.size), NSStringFromPoint(system.hotSpot)]];
185
+ }
186
+ }
187
+ } @finally {
188
+ [window close];
189
+ [window release];
190
+ [previousApp activateWithOptions:NSApplicationActivateIgnoringOtherApps];
191
+ [previousApp release];
192
+ [previous set];
193
+ [previous release];
194
+ }
195
+ Napi::Object result = Napi::Object::New(info.Env());
196
+ result.Set("checks", Napi::Number::New(info.Env(), cursors.count));
197
+ result.Set("failures", Napi::String::New(info.Env(), [[failures componentsJoinedByString:@"\n"] UTF8String]));
198
+ return result;
199
+ }
200
+ }
201
+
202
+ static Napi::Object InitProbe(Napi::Env env, Napi::Object exports) {
203
+ exports.Set("run", Napi::Function::New(env, RunCursorTests));
204
+ exports.Set("runLive", Napi::Function::New(env, RunLiveCursorTests));
205
+ return exports;
206
+ }
207
+ NODE_API_MODULE(cursor_detection_probe, InitProbe)
@@ -0,0 +1,37 @@
1
+ const { test } = require('node:test');
2
+ const assert = require('node:assert/strict');
3
+ const { execFileSync } = require('node:child_process');
4
+ const fs = require('node:fs');
5
+ const os = require('node:os');
6
+ const path = require('node:path');
7
+
8
+ test('native system cursor detection preserves resize axes, shapes and stationary changes', {
9
+ skip: process.platform !== 'darwin', timeout: 120000,
10
+ }, (t) => {
11
+ const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'recorder-cursor-test-'));
12
+ t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
13
+ const version = process.versions.node;
14
+ const candidates = [
15
+ process.env.npm_config_nodedir && path.join(process.env.npm_config_nodedir, 'include/node'),
16
+ path.join(os.homedir(), 'Library/Caches/node-gyp', version, 'include/node'),
17
+ path.join(os.homedir(), '.cache/node-gyp', version, 'include/node'),
18
+ path.resolve(path.dirname(process.execPath), '../include/node'),
19
+ ].filter(Boolean);
20
+ const headers = candidates.find((p) => fs.existsSync(path.join(p, 'node_api.h')));
21
+ assert.ok(headers, 'Node headers required; run npm run rebuild first');
22
+ const binary = path.join(directory, 'cursor-probe.node');
23
+ execFileSync('xcrun', ['clang++', '-std=c++17', '-bundle', '-undefined', 'dynamic_lookup',
24
+ '-DNAPI_DISABLE_CPP_EXCEPTIONS', '-Wno-deprecated-declarations',
25
+ '-I', headers, '-I', path.dirname(require.resolve('node-addon-api')),
26
+ path.join(__dirname, 'cursor-detection-probe.mm'), '-o', binary,
27
+ '-framework', 'AppKit', '-framework', 'ApplicationServices', '-framework', 'Carbon',
28
+ ], { timeout: 60000, stdio: 'pipe' });
29
+ const result = require(binary).run();
30
+ t.diagnostic(`${result.checks} native cursor checks`);
31
+ assert.equal(result.failures, '');
32
+ if (process.env.MAC_RECORDER_TEST_LIVE_CURSOR === '1') {
33
+ const live = require(binary).runLive();
34
+ t.diagnostic(`${live.checks} WindowServer cursor checks`);
35
+ assert.equal(live.failures, '');
36
+ }
37
+ });
@@ -0,0 +1,26 @@
1
+ const { test } = require('node:test');
2
+ const assert = require('node:assert/strict');
3
+ const { spawnSync } = require('node:child_process');
4
+ const path = require('node:path');
5
+
6
+ test('Chromium move and hand cursors survive native capture and press/release', {
7
+ skip: process.platform !== 'darwin', timeout: 20000,
8
+ }, (t) => {
9
+ let electron = process.env.MAC_RECORDER_ELECTRON;
10
+ if (!electron) {
11
+ try { electron = require('electron'); } catch {}
12
+ }
13
+ assert.equal(typeof electron, 'string', 'Set MAC_RECORDER_ELECTRON to the Electron executable');
14
+ const env = { ...process.env };
15
+ delete env.ELECTRON_RUN_AS_NODE;
16
+ const run = spawnSync(electron, [path.join(__dirname, 'electron-cursor-fixture.cjs')], {
17
+ env, encoding: 'utf8', timeout: 18000,
18
+ });
19
+ assert.ifError(run.error);
20
+ const output = run.stdout.split('\n').find(line => line.startsWith('CURSOR_RESULTS='));
21
+ assert.ok(output, run.stderr || 'Electron did not return cursor samples');
22
+ const results = JSON.parse(output.slice('CURSOR_RESULTS='.length));
23
+ assert.deepEqual(results.filter(({ expected, actual }) => expected !== actual), []);
24
+ assert.equal(run.status, 0, run.stderr);
25
+ t.diagnostic(`${results.length} real Electron CSS cursor checks`);
26
+ });