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.
- package/CURSOR_MAPPING.md +33 -54
- package/package.json +3 -1
- package/src/audio_recorder.mm +67 -8
- package/src/camera_recorder.mm +24 -4
- package/src/cursor_tracker.mm +191 -780
- package/src/ios_device_recorder.mm +77 -36
- package/src/sync_timeline.h +10 -0
- package/src/sync_timeline.mm +63 -1
- package/tests/cursor-detection-probe.mm +207 -0
- package/tests/cursor-detection.test.cjs +37 -0
- package/tests/cursor-electron.test.cjs +26 -0
- package/tests/electron-cursor-fixture.cjs +67 -0
- package/tests/ios-sync-probe.mm +249 -0
- package/tests/ios-sync.test.cjs +30 -0
|
@@ -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(
|
|
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
|
-
|
|
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
|
-
|
|
409
|
-
|
|
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.
|
|
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
|
-
//
|
|
732
|
-
//
|
|
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(
|
|
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
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
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.
|
|
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
|
-
|
|
952
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1010
|
+
CMTime resumeHostTime = CMClockGetTime(CMClockGetHostTimeClock());
|
|
1011
|
+
MRIOSEndPauseRange(recorder, resumeHostTime);
|
|
971
1012
|
recorder.paused = NO;
|
|
972
|
-
|
|
1013
|
+
MRSyncResumeAtHostTime(resumeHostTime);
|
|
973
1014
|
return true;
|
|
974
1015
|
} @catch (NSException *exception) {
|
|
975
1016
|
MRLog(@"❌ iPhone resume failed: %@", exception.reason);
|
package/src/sync_timeline.h
CHANGED
|
@@ -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);
|
package/src/sync_timeline.mm
CHANGED
|
@@ -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 (
|
|
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
|
+
});
|