node-mac-recorder 2.24.9 → 2.24.11
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/MultiWindowRecorder.js +85 -4
- package/README.md +18 -1
- package/index-multiprocess.js +50 -6
- package/index.js +153 -41
- package/package.json +1 -1
- package/recorder-worker.js +159 -69
- package/recorder_runtime_safety.cjs +101 -0
- package/src/audio_recorder.mm +43 -43
- package/src/avfoundation_recorder.mm +103 -69
- package/src/camera_recorder.mm +69 -78
- package/src/ios_device_recorder.mm +121 -20
- package/src/keyboard_tracker.mm +8 -1
- package/src/mac_recorder.mm +84 -13
- package/src/recording_writer_safety.h +66 -0
- package/src/screen_capture_kit.h +4 -2
- package/src/screen_capture_kit.mm +264 -199
- package/src/sync_timeline.h +9 -0
- package/src/sync_timeline.mm +54 -0
|
@@ -8,10 +8,13 @@
|
|
|
8
8
|
extern "C" bool startCameraRecording(NSString *outputPath, NSString *deviceId, NSError **error);
|
|
9
9
|
extern "C" bool waitForCameraRecordingStart(double timeoutSeconds);
|
|
10
10
|
extern "C" bool stopCameraRecording(void);
|
|
11
|
+
extern "C" bool stopIOSDeviceRecording(void);
|
|
11
12
|
extern "C" bool isCameraRecording(void);
|
|
12
13
|
extern "C" bool startStandaloneAudioRecording(NSString *outputPath, NSString *preferredDeviceId, NSError **error);
|
|
13
14
|
extern "C" bool stopStandaloneAudioRecording(void);
|
|
14
15
|
extern "C" bool isStandaloneAudioRecording(void);
|
|
16
|
+
extern "C" bool pauseIOSDeviceRecording(void);
|
|
17
|
+
extern "C" bool resumeIOSDeviceRecording(void);
|
|
15
18
|
|
|
16
19
|
@interface MRIOSDeviceRecorder : NSObject <AVCaptureFileOutputRecordingDelegate>
|
|
17
20
|
@property(nonatomic, strong) AVCaptureSession *session;
|
|
@@ -21,6 +24,8 @@ extern "C" bool isStandaloneAudioRecording(void);
|
|
|
21
24
|
@property(atomic) BOOL recording;
|
|
22
25
|
@property(atomic) BOOL startCompleted;
|
|
23
26
|
@property(atomic) BOOL finishCompleted;
|
|
27
|
+
@property(atomic) BOOL startRequested;
|
|
28
|
+
@property(atomic) BOOL stopRequested;
|
|
24
29
|
@property(atomic, strong) NSError *finishError;
|
|
25
30
|
@property(nonatomic) BOOL captureCamera;
|
|
26
31
|
@property(nonatomic) BOOL captureMicrophone;
|
|
@@ -37,7 +42,7 @@ extern "C" bool isStandaloneAudioRecording(void);
|
|
|
37
42
|
self.recording = YES;
|
|
38
43
|
self.startCompleted = YES;
|
|
39
44
|
self.primaryStartedAt = [NSDate date];
|
|
40
|
-
MRSyncMarkPrimaryStarted(CMClockGetTime(CMClockGetHostTimeClock()));
|
|
45
|
+
if (!self.stopRequested) MRSyncMarkPrimaryStarted(CMClockGetTime(CMClockGetHostTimeClock()));
|
|
41
46
|
MRLog(@"📱 iPhone capture started: %@", fileURL.path);
|
|
42
47
|
}
|
|
43
48
|
|
|
@@ -192,8 +197,8 @@ extern "C" bool startIOSDeviceRecording(NSString *outputPath,
|
|
|
192
197
|
NSString *audioOutputPath,
|
|
193
198
|
NSString *audioDeviceId,
|
|
194
199
|
NSError **errorOut) {
|
|
195
|
-
@
|
|
196
|
-
if (g_iosRecorder
|
|
200
|
+
@try {
|
|
201
|
+
if (g_iosRecorder) {
|
|
197
202
|
if (errorOut) {
|
|
198
203
|
*errorOut = [NSError errorWithDomain:@"MacRecorderIOS"
|
|
199
204
|
code:1
|
|
@@ -253,12 +258,14 @@ extern "C" bool startIOSDeviceRecording(NSString *outputPath,
|
|
|
253
258
|
recorder.audioOutputPath = audioOutputPath;
|
|
254
259
|
recorder.primaryStartedAt = nil;
|
|
255
260
|
|
|
261
|
+
g_iosRecorder = recorder;
|
|
256
262
|
[recorder.session beginConfiguration];
|
|
257
263
|
if ([recorder.session canSetSessionPreset:AVCaptureSessionPresetHigh]) {
|
|
258
264
|
recorder.session.sessionPreset = AVCaptureSessionPresetHigh;
|
|
259
265
|
}
|
|
260
266
|
if (![recorder.session canAddInput:input]) {
|
|
261
267
|
[recorder.session commitConfiguration];
|
|
268
|
+
stopIOSDeviceRecording();
|
|
262
269
|
if (errorOut) {
|
|
263
270
|
*errorOut = [NSError errorWithDomain:@"MacRecorderIOS"
|
|
264
271
|
code:3
|
|
@@ -269,6 +276,7 @@ extern "C" bool startIOSDeviceRecording(NSString *outputPath,
|
|
|
269
276
|
[recorder.session addInput:input];
|
|
270
277
|
if (![recorder.session canAddOutput:recorder.movieOutput]) {
|
|
271
278
|
[recorder.session commitConfiguration];
|
|
279
|
+
stopIOSDeviceRecording();
|
|
272
280
|
if (errorOut) {
|
|
273
281
|
*errorOut = [NSError errorWithDomain:@"MacRecorderIOS"
|
|
274
282
|
code:4
|
|
@@ -297,7 +305,7 @@ extern "C" bool startIOSDeviceRecording(NSString *outputPath,
|
|
|
297
305
|
!startCameraRecording(cameraOutputPath, cameraDeviceId, &cameraError)) {
|
|
298
306
|
MRSyncConfigurePrimaryStart(NO);
|
|
299
307
|
MRSyncConfigure(NO);
|
|
300
|
-
|
|
308
|
+
stopIOSDeviceRecording();
|
|
301
309
|
if (errorOut) {
|
|
302
310
|
*errorOut = cameraError ?: [NSError errorWithDomain:@"MacRecorderIOS"
|
|
303
311
|
code:7
|
|
@@ -314,7 +322,7 @@ extern "C" bool startIOSDeviceRecording(NSString *outputPath,
|
|
|
314
322
|
if (isCameraRecording()) stopCameraRecording();
|
|
315
323
|
MRSyncConfigurePrimaryStart(NO);
|
|
316
324
|
MRSyncConfigure(NO);
|
|
317
|
-
|
|
325
|
+
stopIOSDeviceRecording();
|
|
318
326
|
if (errorOut) {
|
|
319
327
|
*errorOut = audioError ?: [NSError errorWithDomain:@"MacRecorderIOS"
|
|
320
328
|
code:8
|
|
@@ -335,23 +343,24 @@ extern "C" bool startIOSDeviceRecording(NSString *outputPath,
|
|
|
335
343
|
code:5
|
|
336
344
|
userInfo:@{NSLocalizedDescriptionKey: @"The iPhone capture session did not start"}];
|
|
337
345
|
}
|
|
338
|
-
|
|
346
|
+
stopIOSDeviceRecording();
|
|
339
347
|
return false;
|
|
340
348
|
}
|
|
341
349
|
|
|
350
|
+
recorder.startRequested = YES;
|
|
342
351
|
[recorder.movieOutput startRecordingToOutputFileURL:[NSURL fileURLWithPath:outputPath]
|
|
343
352
|
recordingDelegate:recorder];
|
|
344
353
|
bool started = MRWaitForFlag(^bool{
|
|
345
|
-
return recorder.startCompleted;
|
|
354
|
+
return recorder.startCompleted || recorder.finishCompleted;
|
|
346
355
|
}, 10.0);
|
|
347
|
-
if (!started) {
|
|
356
|
+
if (!started || !recorder.startCompleted || recorder.finishCompleted) {
|
|
348
357
|
if (recorder.movieOutput.isRecording) [recorder.movieOutput stopRecording];
|
|
349
358
|
[recorder.session stopRunning];
|
|
350
359
|
if (isCameraRecording()) stopCameraRecording();
|
|
351
360
|
if (isStandaloneAudioRecording()) stopStandaloneAudioRecording();
|
|
352
361
|
MRSyncConfigurePrimaryStart(NO);
|
|
353
362
|
MRSyncConfigure(NO);
|
|
354
|
-
|
|
363
|
+
stopIOSDeviceRecording();
|
|
355
364
|
if (errorOut) {
|
|
356
365
|
*errorOut = [NSError errorWithDomain:@"MacRecorderIOS"
|
|
357
366
|
code:6
|
|
@@ -368,7 +377,7 @@ extern "C" bool startIOSDeviceRecording(NSString *outputPath,
|
|
|
368
377
|
if (isStandaloneAudioRecording()) stopStandaloneAudioRecording();
|
|
369
378
|
MRSyncConfigurePrimaryStart(NO);
|
|
370
379
|
MRSyncConfigure(NO);
|
|
371
|
-
|
|
380
|
+
stopIOSDeviceRecording();
|
|
372
381
|
if (errorOut) {
|
|
373
382
|
*errorOut = [NSError errorWithDomain:@"MacRecorderIOS"
|
|
374
383
|
code:9
|
|
@@ -377,33 +386,52 @@ extern "C" bool startIOSDeviceRecording(NSString *outputPath,
|
|
|
377
386
|
return false;
|
|
378
387
|
}
|
|
379
388
|
return true;
|
|
389
|
+
} @catch (NSException *exception) {
|
|
390
|
+
// NSError must live in the caller's autorelease pool. The previous
|
|
391
|
+
// inner pool returned a dangling NSError on device/startup failures.
|
|
392
|
+
if (g_iosRecorder && !g_iosRecorder.startCompleted && !g_iosRecorder.movieOutput.isRecording) {
|
|
393
|
+
g_iosRecorder.startRequested = NO;
|
|
394
|
+
}
|
|
395
|
+
@try { [g_iosRecorder.session commitConfiguration]; } @catch (NSException *ignored) {}
|
|
396
|
+
stopIOSDeviceRecording();
|
|
397
|
+
if (errorOut) *errorOut = [NSError errorWithDomain:@"MacRecorderIOS" code:10
|
|
398
|
+
userInfo:@{NSLocalizedDescriptionKey: exception.reason ?: @"iPhone capture startup failed"}];
|
|
399
|
+
return false;
|
|
380
400
|
}
|
|
381
401
|
}
|
|
382
402
|
|
|
383
403
|
extern "C" bool stopIOSDeviceRecording(void) {
|
|
384
404
|
@autoreleasepool {
|
|
405
|
+
@try {
|
|
385
406
|
MRIOSDeviceRecorder *recorder = g_iosRecorder;
|
|
386
407
|
if (!recorder) return true;
|
|
387
408
|
|
|
409
|
+
recorder.stopRequested = YES;
|
|
388
410
|
if (recorder.primaryStartedAt) {
|
|
389
|
-
NSTimeInterval duration = MAX(0.0,
|
|
411
|
+
NSTimeInterval duration = MAX(0.0,
|
|
412
|
+
-[recorder.primaryStartedAt timeIntervalSinceNow] - MRSyncGetPausedDurationSeconds());
|
|
390
413
|
MRSyncSetStopLimitSeconds(duration);
|
|
391
414
|
}
|
|
392
415
|
|
|
393
416
|
BOOL cameraStopped = YES;
|
|
394
417
|
BOOL microphoneStopped = YES;
|
|
395
|
-
if (recorder.captureCamera
|
|
418
|
+
if (recorder.captureCamera) {
|
|
396
419
|
cameraStopped = stopCameraRecording();
|
|
397
420
|
}
|
|
398
|
-
if (recorder.captureMicrophone
|
|
421
|
+
if (recorder.captureMicrophone) {
|
|
399
422
|
microphoneStopped = stopStandaloneAudioRecording();
|
|
400
423
|
}
|
|
401
424
|
|
|
402
425
|
if (recorder.movieOutput.isRecording) {
|
|
403
426
|
[recorder.movieOutput stopRecording];
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
427
|
+
}
|
|
428
|
+
// isRecording becomes false BEFORE the delegate finishes its file.
|
|
429
|
+
// Keep ownership until that callback, including after USB removal.
|
|
430
|
+
if (recorder.startRequested && !recorder.finishCompleted) {
|
|
431
|
+
if (!MRWaitForFlag(^bool{ return recorder.finishCompleted; }, 20.0)) {
|
|
432
|
+
NSLog(@"[Recorder] iPhone is still finalizing; retaining the session");
|
|
433
|
+
return false;
|
|
434
|
+
}
|
|
407
435
|
}
|
|
408
436
|
if (recorder.session.isRunning) [recorder.session stopRunning];
|
|
409
437
|
|
|
@@ -425,19 +453,68 @@ extern "C" bool stopIOSDeviceRecording(void) {
|
|
|
425
453
|
// auxiliary source failed to finalize. The JS layer validates each
|
|
426
454
|
// returned path independently before packaging it.
|
|
427
455
|
return finished && fileExists;
|
|
456
|
+
} @catch (NSException *exception) {
|
|
457
|
+
NSLog(@"[Recorder] iPhone stop failed safely: %@", exception.reason);
|
|
458
|
+
return false;
|
|
459
|
+
}
|
|
428
460
|
}
|
|
429
461
|
}
|
|
430
462
|
|
|
463
|
+
extern "C" bool isIOSDeviceRecordingPending(void) {
|
|
464
|
+
return g_iosRecorder != nil;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
extern "C" bool isIOSDeviceRecordingStopping(void) {
|
|
468
|
+
return g_iosRecorder && g_iosRecorder.stopRequested;
|
|
469
|
+
}
|
|
470
|
+
|
|
431
471
|
extern "C" bool isIOSDeviceRecording(void) {
|
|
432
472
|
return g_iosRecorder && (g_iosRecorder.recording || g_iosRecorder.movieOutput.isRecording);
|
|
433
473
|
}
|
|
434
474
|
|
|
475
|
+
extern "C" bool pauseIOSDeviceRecording(void) {
|
|
476
|
+
@autoreleasepool {
|
|
477
|
+
MRIOSDeviceRecorder *recorder = g_iosRecorder;
|
|
478
|
+
if (!recorder || !recorder.movieOutput.isRecording) return false;
|
|
479
|
+
if (recorder.movieOutput.isRecordingPaused) return true;
|
|
480
|
+
@try {
|
|
481
|
+
[recorder.movieOutput pauseRecording];
|
|
482
|
+
MRSyncPause();
|
|
483
|
+
return true;
|
|
484
|
+
} @catch (NSException *exception) {
|
|
485
|
+
MRLog(@"❌ iPhone pause failed: %@", exception.reason);
|
|
486
|
+
return false;
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
extern "C" bool resumeIOSDeviceRecording(void) {
|
|
492
|
+
@autoreleasepool {
|
|
493
|
+
MRIOSDeviceRecorder *recorder = g_iosRecorder;
|
|
494
|
+
if (!recorder || !recorder.movieOutput.isRecording) return false;
|
|
495
|
+
if (!recorder.movieOutput.isRecordingPaused) {
|
|
496
|
+
MRSyncResume();
|
|
497
|
+
return true;
|
|
498
|
+
}
|
|
499
|
+
@try {
|
|
500
|
+
[recorder.movieOutput resumeRecording];
|
|
501
|
+
MRSyncResume();
|
|
502
|
+
return true;
|
|
503
|
+
} @catch (NSException *exception) {
|
|
504
|
+
MRLog(@"❌ iPhone resume failed: %@", exception.reason);
|
|
505
|
+
return false;
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
|
|
435
510
|
extern "C" NSString *currentIOSDeviceRecordingPath(void) {
|
|
436
511
|
return g_iosRecorder.outputPath;
|
|
437
512
|
}
|
|
438
513
|
|
|
439
514
|
Napi::Value GetIOSCaptureDevices(const Napi::CallbackInfo& info) {
|
|
440
515
|
Napi::Env env = info.Env();
|
|
516
|
+
@autoreleasepool {
|
|
517
|
+
@try {
|
|
441
518
|
NSArray<NSDictionary *> *devices = listIOSCaptureDevices();
|
|
442
519
|
Napi::Array result = Napi::Array::New(env, devices.count);
|
|
443
520
|
for (NSUInteger index = 0; index < devices.count; index++) {
|
|
@@ -456,10 +533,18 @@ Napi::Value GetIOSCaptureDevices(const Napi::CallbackInfo& info) {
|
|
|
456
533
|
result.Set(index, item);
|
|
457
534
|
}
|
|
458
535
|
return result;
|
|
536
|
+
} @catch (NSException *exception) {
|
|
537
|
+
Napi::Error::New(env, exception.reason.UTF8String ?: "iPhone capture failed").ThrowAsJavaScriptException();
|
|
538
|
+
return env.Null();
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
|
|
459
542
|
}
|
|
460
543
|
|
|
461
544
|
Napi::Value StartIOSDeviceRecording(const Napi::CallbackInfo& info) {
|
|
462
545
|
Napi::Env env = info.Env();
|
|
546
|
+
@autoreleasepool {
|
|
547
|
+
@try {
|
|
463
548
|
if (info.Length() < 1 || !info[0].IsString()) {
|
|
464
549
|
Napi::TypeError::New(env, "Output path is required").ThrowAsJavaScriptException();
|
|
465
550
|
return env.Null();
|
|
@@ -505,21 +590,35 @@ Napi::Value StartIOSDeviceRecording(const Napi::CallbackInfo& info) {
|
|
|
505
590
|
audioDeviceId,
|
|
506
591
|
&error);
|
|
507
592
|
if (!success && error) {
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
// stable user-facing error and avoids a dangling Objective-C object.
|
|
511
|
-
MRLog(@"❌ iPhone capture could not start");
|
|
593
|
+
Napi::Error::New(env, error.localizedDescription.UTF8String ?: "iPhone capture could not start").ThrowAsJavaScriptException();
|
|
594
|
+
return env.Null();
|
|
512
595
|
}
|
|
513
596
|
return Napi::Boolean::New(env, success);
|
|
597
|
+
} @catch (NSException *exception) {
|
|
598
|
+
Napi::Error::New(env, exception.reason.UTF8String ?: "iPhone capture failed").ThrowAsJavaScriptException();
|
|
599
|
+
return env.Null();
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
|
|
514
603
|
}
|
|
515
604
|
|
|
516
605
|
Napi::Value StopIOSDeviceRecording(const Napi::CallbackInfo& info) {
|
|
517
606
|
return Napi::Boolean::New(info.Env(), stopIOSDeviceRecording());
|
|
518
607
|
}
|
|
519
608
|
|
|
609
|
+
Napi::Value PauseIOSDeviceRecording(const Napi::CallbackInfo& info) {
|
|
610
|
+
return Napi::Boolean::New(info.Env(), pauseIOSDeviceRecording());
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
Napi::Value ResumeIOSDeviceRecording(const Napi::CallbackInfo& info) {
|
|
614
|
+
return Napi::Boolean::New(info.Env(), resumeIOSDeviceRecording());
|
|
615
|
+
}
|
|
616
|
+
|
|
520
617
|
Napi::Value GetIOSDeviceRecordingStatus(const Napi::CallbackInfo& info) {
|
|
521
618
|
Napi::Object status = Napi::Object::New(info.Env());
|
|
522
619
|
status.Set("isRecording", Napi::Boolean::New(info.Env(), isIOSDeviceRecording()));
|
|
620
|
+
status.Set("isPaused", Napi::Boolean::New(info.Env(),
|
|
621
|
+
g_iosRecorder && g_iosRecorder.movieOutput.isRecordingPaused));
|
|
523
622
|
NSString *path = currentIOSDeviceRecordingPath();
|
|
524
623
|
if (path.length > 0) status.Set("outputPath", Napi::String::New(info.Env(), [path UTF8String]));
|
|
525
624
|
return status;
|
|
@@ -529,6 +628,8 @@ Napi::Object InitIOSDeviceRecorder(Napi::Env env, Napi::Object exports) {
|
|
|
529
628
|
exports.Set("getIOSCaptureDevices", Napi::Function::New(env, GetIOSCaptureDevices));
|
|
530
629
|
exports.Set("startIOSDeviceRecording", Napi::Function::New(env, StartIOSDeviceRecording));
|
|
531
630
|
exports.Set("stopIOSDeviceRecording", Napi::Function::New(env, StopIOSDeviceRecording));
|
|
631
|
+
exports.Set("pauseIOSDeviceRecording", Napi::Function::New(env, PauseIOSDeviceRecording));
|
|
632
|
+
exports.Set("resumeIOSDeviceRecording", Napi::Function::New(env, ResumeIOSDeviceRecording));
|
|
532
633
|
exports.Set("getIOSDeviceRecordingStatus", Napi::Function::New(env, GetIOSDeviceRecordingStatus));
|
|
533
634
|
return exports;
|
|
534
635
|
}
|
package/src/keyboard_tracker.mm
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
#import <CoreGraphics/CoreGraphics.h>
|
|
12
12
|
#import <Carbon/Carbon.h>
|
|
13
13
|
#import "logging.h"
|
|
14
|
+
#import "sync_timeline.h"
|
|
14
15
|
|
|
15
16
|
// ---- Global durum (tek aktif oturum) --------------------------------------
|
|
16
17
|
static CFMachPortRef g_kbEventTap = NULL;
|
|
@@ -110,6 +111,10 @@ static CGEventRef KeyboardEventCallback(CGEventTapProxy proxy,
|
|
|
110
111
|
return event;
|
|
111
112
|
}
|
|
112
113
|
|
|
114
|
+
if (MRSyncIsPaused()) {
|
|
115
|
+
return event;
|
|
116
|
+
}
|
|
117
|
+
|
|
113
118
|
@autoreleasepool {
|
|
114
119
|
CGEventFlags flags = CGEventGetFlags(event);
|
|
115
120
|
BOOL hasCommand = (flags & kCGEventFlagMaskCommand) != 0;
|
|
@@ -146,7 +151,9 @@ static CGEventRef KeyboardEventCallback(CGEventTapProxy proxy,
|
|
|
146
151
|
}
|
|
147
152
|
|
|
148
153
|
double unixMs = NowUnixMs();
|
|
149
|
-
double relMs = g_kbStartUnixMs > 0
|
|
154
|
+
double relMs = g_kbStartUnixMs > 0
|
|
155
|
+
? (unixMs - g_kbStartUnixMs - (MRSyncGetPausedDurationSeconds() * 1000.0))
|
|
156
|
+
: 0.0;
|
|
150
157
|
if (relMs < 0) relMs = 0;
|
|
151
158
|
|
|
152
159
|
NSString *json = [NSString stringWithFormat:
|
package/src/mac_recorder.mm
CHANGED
|
@@ -25,12 +25,19 @@ extern "C" {
|
|
|
25
25
|
NSString* qualityPreset);
|
|
26
26
|
bool stopAVFoundationRecording();
|
|
27
27
|
bool isAVFoundationRecording();
|
|
28
|
+
bool hasAVFoundationRecordingResources();
|
|
29
|
+
bool isAVFoundationRecordingStopping();
|
|
28
30
|
NSString* getAVFoundationAudioPath();
|
|
29
31
|
|
|
30
32
|
NSArray<NSDictionary *> *listCameraDevices();
|
|
31
33
|
bool startCameraRecording(NSString *outputPath, NSString *deviceId, NSError **error);
|
|
32
34
|
bool stopCameraRecording();
|
|
33
35
|
bool isCameraRecording();
|
|
36
|
+
bool isCameraRecordingStopping();
|
|
37
|
+
bool hasCameraRecordingResources();
|
|
38
|
+
bool isIOSDeviceRecording();
|
|
39
|
+
bool isIOSDeviceRecordingPending();
|
|
40
|
+
bool isIOSDeviceRecordingStopping();
|
|
34
41
|
NSString *currentCameraRecordingPath();
|
|
35
42
|
bool waitForCameraRecordingStart(double timeoutSeconds);
|
|
36
43
|
double currentCameraRecordingStartTime(void);
|
|
@@ -85,7 +92,8 @@ static double MRComputeElapsedRecordingSeconds(void) {
|
|
|
85
92
|
if (!g_hasRecordingStartTime) {
|
|
86
93
|
return -1.0;
|
|
87
94
|
}
|
|
88
|
-
CFTimeInterval elapsed = CFAbsoluteTimeGetCurrent() - g_recordingStartTime
|
|
95
|
+
CFTimeInterval elapsed = CFAbsoluteTimeGetCurrent() - g_recordingStartTime
|
|
96
|
+
- MRSyncGetPausedDurationSeconds();
|
|
89
97
|
if (elapsed <= 0.0) {
|
|
90
98
|
return -1.0;
|
|
91
99
|
}
|
|
@@ -218,11 +226,11 @@ void cleanupRecording() {
|
|
|
218
226
|
}
|
|
219
227
|
|
|
220
228
|
// AVFoundation cleanup (supports both Node.js and Electron)
|
|
221
|
-
if (
|
|
229
|
+
if (hasAVFoundationRecordingResources()) {
|
|
222
230
|
stopAVFoundationRecording();
|
|
223
231
|
}
|
|
224
232
|
|
|
225
|
-
if (
|
|
233
|
+
if (hasCameraRecordingResources()) {
|
|
226
234
|
stopCameraRecording();
|
|
227
235
|
}
|
|
228
236
|
if (isStandaloneAudioRecording()) {
|
|
@@ -240,11 +248,20 @@ void cleanupRecording() {
|
|
|
240
248
|
Napi::Value StartRecording(const Napi::CallbackInfo& info) {
|
|
241
249
|
Napi::Env env = info.Env();
|
|
242
250
|
|
|
243
|
-
if (info.Length() < 1) {
|
|
251
|
+
if (info.Length() < 1 || !info[0].IsString()) {
|
|
244
252
|
Napi::TypeError::New(env, "Output path required").ThrowAsJavaScriptException();
|
|
245
253
|
return env.Null();
|
|
246
254
|
}
|
|
247
255
|
|
|
256
|
+
// Do not tear down an active/pending capture from another invocation.
|
|
257
|
+
if (isCameraRecordingStopping() || isIOSDeviceRecordingPending() || isAVFoundationRecording() || isAVFoundationRecordingStopping()) {
|
|
258
|
+
return Napi::Boolean::New(env, false);
|
|
259
|
+
}
|
|
260
|
+
if (@available(macOS 12.3, *)) {
|
|
261
|
+
if ([ScreenCaptureKitRecorder isRecording] || [ScreenCaptureKitRecorder isScheduling] ||
|
|
262
|
+
[ScreenCaptureKitRecorder isCleaningUp]) return Napi::Boolean::New(env, false);
|
|
263
|
+
}
|
|
264
|
+
|
|
248
265
|
// IMPORTANT: Clean up any stale recording state before starting
|
|
249
266
|
// This fixes the issue where macOS 14/13 users get "recording already in progress"
|
|
250
267
|
MRLog(@"🧹 Cleaning up any previous recording state...");
|
|
@@ -863,11 +880,12 @@ Napi::Value StopRecording(const Napi::CallbackInfo& info) {
|
|
|
863
880
|
|
|
864
881
|
// Try ScreenCaptureKit first
|
|
865
882
|
if (@available(macOS 12.3, *)) {
|
|
866
|
-
if ([ScreenCaptureKitRecorder isRecording]
|
|
883
|
+
if ([ScreenCaptureKitRecorder isRecording] || [ScreenCaptureKitRecorder isScheduling] ||
|
|
884
|
+
[ScreenCaptureKitRecorder isCleaningUp]) {
|
|
867
885
|
MRLog(@"🛑 Stopping ScreenCaptureKit recording");
|
|
868
886
|
|
|
869
887
|
// CRITICAL FIX: Stop camera and audio FIRST (they are synchronous)
|
|
870
|
-
if (
|
|
888
|
+
if (hasCameraRecordingResources()) {
|
|
871
889
|
MRLog(@"🛑 Stopping camera recording...");
|
|
872
890
|
bool cameraStopped = stopCameraRecording();
|
|
873
891
|
if (cameraStopped) {
|
|
@@ -878,7 +896,7 @@ Napi::Value StopRecording(const Napi::CallbackInfo& info) {
|
|
|
878
896
|
}
|
|
879
897
|
|
|
880
898
|
// Stop standalone microphone if it was used (macOS 13/14)
|
|
881
|
-
if (g_usingStandaloneAudio
|
|
899
|
+
if (g_usingStandaloneAudio) {
|
|
882
900
|
MRLog(@"🛑 Stopping standalone microphone recording...");
|
|
883
901
|
bool micStopped = stopStandaloneAudioRecording();
|
|
884
902
|
if (micStopped) {
|
|
@@ -894,8 +912,8 @@ Napi::Value StopRecording(const Napi::CallbackInfo& info) {
|
|
|
894
912
|
// It will set g_isRecording = NO in its completion handler
|
|
895
913
|
[ScreenCaptureKitRecorder stopRecording];
|
|
896
914
|
|
|
897
|
-
//
|
|
898
|
-
|
|
915
|
+
// Native lifecycle status separately reports asynchronous cleanup.
|
|
916
|
+
g_isRecording = false;
|
|
899
917
|
g_usingStandaloneAudio = false;
|
|
900
918
|
return Napi::Boolean::New(env, true);
|
|
901
919
|
}
|
|
@@ -907,11 +925,11 @@ Napi::Value StopRecording(const Napi::CallbackInfo& info) {
|
|
|
907
925
|
extern NSString* getAVFoundationAudioPath();
|
|
908
926
|
|
|
909
927
|
@try {
|
|
910
|
-
if (
|
|
928
|
+
if (hasAVFoundationRecordingResources()) {
|
|
911
929
|
MRLog(@"🛑 Stopping AVFoundation recording");
|
|
912
930
|
|
|
913
|
-
BOOL cameraWasRecording =
|
|
914
|
-
BOOL audioWasRecording = g_usingStandaloneAudio
|
|
931
|
+
BOOL cameraWasRecording = hasCameraRecordingResources();
|
|
932
|
+
BOOL audioWasRecording = g_usingStandaloneAudio;
|
|
915
933
|
__block BOOL cameraStopResult = YES;
|
|
916
934
|
__block BOOL audioStopResult = YES;
|
|
917
935
|
|
|
@@ -983,14 +1001,43 @@ Napi::Value StopRecording(const Napi::CallbackInfo& info) {
|
|
|
983
1001
|
}
|
|
984
1002
|
|
|
985
1003
|
MRLog(@"⚠️ No active recording found to stop");
|
|
986
|
-
if (
|
|
1004
|
+
if (hasCameraRecordingResources()) {
|
|
987
1005
|
stopCameraRecording();
|
|
988
1006
|
}
|
|
1007
|
+
stopStandaloneAudioRecording();
|
|
1008
|
+
g_usingStandaloneAudio = false;
|
|
989
1009
|
g_isRecording = false;
|
|
990
1010
|
MRSyncSetStopLimitSeconds(-1.0);
|
|
991
1011
|
return Napi::Boolean::New(env, true);
|
|
992
1012
|
}
|
|
993
1013
|
|
|
1014
|
+
Napi::Value PauseRecording(const Napi::CallbackInfo& info) {
|
|
1015
|
+
Napi::Env env = info.Env();
|
|
1016
|
+
bool recording = g_isRecording || hasAVFoundationRecordingResources();
|
|
1017
|
+
if (@available(macOS 12.3, *)) {
|
|
1018
|
+
recording = recording || [ScreenCaptureKitRecorder isRecording];
|
|
1019
|
+
}
|
|
1020
|
+
if (!recording) {
|
|
1021
|
+
Napi::Error::New(env, "No active recording to pause").ThrowAsJavaScriptException();
|
|
1022
|
+
return env.Null();
|
|
1023
|
+
}
|
|
1024
|
+
MRSyncPause();
|
|
1025
|
+
return Napi::Boolean::New(env, true);
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
Napi::Value ResumeRecording(const Napi::CallbackInfo& info) {
|
|
1029
|
+
Napi::Env env = info.Env();
|
|
1030
|
+
if (!MRSyncIsPaused()) {
|
|
1031
|
+
return Napi::Boolean::New(env, true);
|
|
1032
|
+
}
|
|
1033
|
+
MRSyncResume();
|
|
1034
|
+
return Napi::Boolean::New(env, true);
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
Napi::Value GetRecordingPauseStatus(const Napi::CallbackInfo& info) {
|
|
1038
|
+
return Napi::Boolean::New(info.Env(), MRSyncIsPaused());
|
|
1039
|
+
}
|
|
1040
|
+
|
|
994
1041
|
|
|
995
1042
|
|
|
996
1043
|
// NAPI Function: Get Windows List
|
|
@@ -1375,6 +1422,26 @@ Napi::Value PrewarmScreenCapture(const Napi::CallbackInfo& info) {
|
|
|
1375
1422
|
return Napi::Boolean::New(env, false);
|
|
1376
1423
|
}
|
|
1377
1424
|
|
|
1425
|
+
// Actual native state, independent of the legacy first-ten-frames/UI flag.
|
|
1426
|
+
Napi::Value GetRecordingLifecycleStatus(const Napi::CallbackInfo& info) {
|
|
1427
|
+
bool recording = hasAVFoundationRecordingResources() || isIOSDeviceRecordingPending();
|
|
1428
|
+
bool starting = false;
|
|
1429
|
+
bool stopping = isCameraRecordingStopping() || isIOSDeviceRecordingStopping() || isAVFoundationRecordingStopping();
|
|
1430
|
+
if (@available(macOS 12.3, *)) {
|
|
1431
|
+
recording = recording || [ScreenCaptureKitRecorder isRecording];
|
|
1432
|
+
starting = [ScreenCaptureKitRecorder isScheduling];
|
|
1433
|
+
stopping = stopping || [ScreenCaptureKitRecorder isCleaningUp];
|
|
1434
|
+
}
|
|
1435
|
+
Napi::Object result = Napi::Object::New(info.Env());
|
|
1436
|
+
result.Set("isRecording", recording);
|
|
1437
|
+
result.Set("isStarting", starting);
|
|
1438
|
+
result.Set("isStopping", stopping);
|
|
1439
|
+
result.Set("isPaused", MRSyncIsPaused());
|
|
1440
|
+
result.Set("pausedDuration", MRSyncGetPausedDurationSeconds());
|
|
1441
|
+
result.Set("hasAuxiliaryRecording", hasCameraRecordingResources() || isStandaloneAudioRecording());
|
|
1442
|
+
return result;
|
|
1443
|
+
}
|
|
1444
|
+
|
|
1378
1445
|
Napi::Value GetRecordingStatus(const Napi::CallbackInfo& info) {
|
|
1379
1446
|
Napi::Env env = info.Env();
|
|
1380
1447
|
|
|
@@ -1755,8 +1822,12 @@ Napi::Value CheckPermissions(const Napi::CallbackInfo& info) {
|
|
|
1755
1822
|
|
|
1756
1823
|
// Initialize NAPI Module
|
|
1757
1824
|
Napi::Object Init(Napi::Env env, Napi::Object exports) {
|
|
1825
|
+
exports.Set("getRecordingLifecycleStatus", Napi::Function::New(env, GetRecordingLifecycleStatus));
|
|
1758
1826
|
exports.Set(Napi::String::New(env, "startRecording"), Napi::Function::New(env, StartRecording));
|
|
1759
1827
|
exports.Set(Napi::String::New(env, "stopRecording"), Napi::Function::New(env, StopRecording));
|
|
1828
|
+
exports.Set(Napi::String::New(env, "pauseRecording"), Napi::Function::New(env, PauseRecording));
|
|
1829
|
+
exports.Set(Napi::String::New(env, "resumeRecording"), Napi::Function::New(env, ResumeRecording));
|
|
1830
|
+
exports.Set(Napi::String::New(env, "getRecordingPauseStatus"), Napi::Function::New(env, GetRecordingPauseStatus));
|
|
1760
1831
|
|
|
1761
1832
|
exports.Set(Napi::String::New(env, "getAudioDevices"), Napi::Function::New(env, GetAudioDevices));
|
|
1762
1833
|
exports.Set(Napi::String::New(env, "getCameraDevices"), Napi::Function::New(env, GetCameraDevices));
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
#pragma once
|
|
2
|
+
#import <AVFoundation/AVFoundation.h>
|
|
3
|
+
|
|
4
|
+
// Call only after detaching and draining sample callbacks. A writer which has
|
|
5
|
+
// not received its first frame cannot be marked/finished: AVFoundation raises
|
|
6
|
+
// NSInternalInconsistencyException, which a JavaScript catch cannot intercept.
|
|
7
|
+
static BOOL MRFinishAssetWriterSafely(AVAssetWriter *writer,
|
|
8
|
+
NSTimeInterval timeoutSeconds,
|
|
9
|
+
double stopLimitSeconds = -1.0) {
|
|
10
|
+
if (!writer) return YES;
|
|
11
|
+
dispatch_semaphore_t finished = nil;
|
|
12
|
+
@try {
|
|
13
|
+
AVAssetWriterStatus status = writer.status;
|
|
14
|
+
if (status == AVAssetWriterStatusCompleted) return YES;
|
|
15
|
+
if (status == AVAssetWriterStatusUnknown) {
|
|
16
|
+
[writer cancelWriting];
|
|
17
|
+
return NO;
|
|
18
|
+
}
|
|
19
|
+
if (status != AVAssetWriterStatusWriting) return NO;
|
|
20
|
+
if (stopLimitSeconds > 0 && isfinite(stopLimitSeconds)) {
|
|
21
|
+
[writer endSessionAtSourceTime:CMTimeMakeWithSeconds(stopLimitSeconds, 600)];
|
|
22
|
+
}
|
|
23
|
+
// Respect each recorder's bounded wait. A global 60-second floor blocks
|
|
24
|
+
// the native stop path synchronously and makes the app appear frozen.
|
|
25
|
+
// Timing out is safe because the writer is deliberately not cancelled.
|
|
26
|
+
NSTimeInterval waitSeconds =
|
|
27
|
+
isfinite(timeoutSeconds) && timeoutSeconds > 0 ? timeoutSeconds : 5.0;
|
|
28
|
+
finished = dispatch_semaphore_create(0);
|
|
29
|
+
// finishWriting marks all inputs as finished. The completion captures
|
|
30
|
+
// only its semaphore, never mutable globals from a subsequent session.
|
|
31
|
+
[writer finishWritingWithCompletionHandler:^{ dispatch_semaphore_signal(finished); }];
|
|
32
|
+
long timedOut = dispatch_semaphore_wait(finished,
|
|
33
|
+
dispatch_time(DISPATCH_TIME_NOW, (int64_t)(waitSeconds * NSEC_PER_SEC)));
|
|
34
|
+
if (timedOut) {
|
|
35
|
+
// NEVER cancel here. cancelWriting deletes the output file, so a
|
|
36
|
+
// finalization that is merely slow would destroy the user's
|
|
37
|
+
// recording — the editor then opens with no video. Waiting stops,
|
|
38
|
+
// the writer keeps finalizing in the background and still produces
|
|
39
|
+
// the file; only the "completed" confirmation is lost.
|
|
40
|
+
NSLog(@"[Recorder] Writer still finalizing after %.3fs; leaving it to finish", waitSeconds);
|
|
41
|
+
return NO;
|
|
42
|
+
}
|
|
43
|
+
return writer.status == AVAssetWriterStatusCompleted;
|
|
44
|
+
} @catch (NSException *exception) {
|
|
45
|
+
NSLog(@"[Recorder] Writer finalization failed safely: %@", exception.reason);
|
|
46
|
+
@try {
|
|
47
|
+
if (writer.status == AVAssetWriterStatusUnknown) {
|
|
48
|
+
// No first frame arrived, so there is no output file to lose.
|
|
49
|
+
[writer cancelWriting];
|
|
50
|
+
} else if (writer.status == AVAssetWriterStatusWriting) {
|
|
51
|
+
// Frames were written: cancelling would delete them. Trigger
|
|
52
|
+
// finalization best effort instead and do not wait on it.
|
|
53
|
+
[writer finishWritingWithCompletionHandler:^{}];
|
|
54
|
+
}
|
|
55
|
+
} @catch (NSException *cancelError) {
|
|
56
|
+
NSLog(@"[Recorder] Writer cancellation failed: %@", cancelError.reason);
|
|
57
|
+
}
|
|
58
|
+
return NO;
|
|
59
|
+
} @finally {
|
|
60
|
+
#if !__has_feature(objc_arc)
|
|
61
|
+
// The copied completion block owns a separate reference, including
|
|
62
|
+
// when it runs after a timeout. Balance this call's create reference.
|
|
63
|
+
if (finished) dispatch_release(finished);
|
|
64
|
+
#endif
|
|
65
|
+
}
|
|
66
|
+
}
|
package/src/screen_capture_kit.h
CHANGED
|
@@ -13,9 +13,9 @@ API_AVAILABLE(macos(12.3))
|
|
|
13
13
|
+ (void)prewarmShareableContent;
|
|
14
14
|
|
|
15
15
|
// MULTI-SESSION API: New session-based recording
|
|
16
|
-
+ (
|
|
16
|
+
+ (BOOL)startRecordingWithConfiguration:(NSDictionary *)config
|
|
17
17
|
delegate:(id)delegate
|
|
18
|
-
error:(NSError **)error;
|
|
18
|
+
error:(NSError **)error;
|
|
19
19
|
+ (BOOL)stopRecording:(NSString *)sessionId; // Stop specific session
|
|
20
20
|
+ (BOOL)isRecording:(NSString *)sessionId; // Check specific session
|
|
21
21
|
+ (BOOL)isFullyInitialized:(NSString *)sessionId; // Check if session's first frames received
|
|
@@ -26,6 +26,8 @@ API_AVAILABLE(macos(12.3))
|
|
|
26
26
|
// LEGACY API: For backward compatibility (uses implicit default session)
|
|
27
27
|
+ (void)stopRecording; // Stops all sessions
|
|
28
28
|
+ (BOOL)isRecording; // Returns YES if ANY session is recording
|
|
29
|
+
+ (BOOL)isScheduling;
|
|
30
|
+
+ (BOOL)isCleaningUp;
|
|
29
31
|
+ (BOOL)isFullyInitialized; // Check if default session initialized
|
|
30
32
|
+ (NSTimeInterval)getVideoStartTimestamp; // Get default session timestamp
|
|
31
33
|
|