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.
@@ -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
- @autoreleasepool {
196
- if (g_iosRecorder && (g_iosRecorder.recording || g_iosRecorder.startCompleted)) {
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
- g_iosRecorder = nil;
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
- g_iosRecorder = nil;
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
- g_iosRecorder = nil;
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
- g_iosRecorder = nil;
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
- g_iosRecorder = nil;
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, -[recorder.primaryStartedAt timeIntervalSinceNow]);
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 && isCameraRecording()) {
418
+ if (recorder.captureCamera) {
396
419
  cameraStopped = stopCameraRecording();
397
420
  }
398
- if (recorder.captureMicrophone && isStandaloneAudioRecording()) {
421
+ if (recorder.captureMicrophone) {
399
422
  microphoneStopped = stopStandaloneAudioRecording();
400
423
  }
401
424
 
402
425
  if (recorder.movieOutput.isRecording) {
403
426
  [recorder.movieOutput stopRecording];
404
- MRWaitForFlag(^bool{
405
- return recorder.finishCompleted;
406
- }, 20.0);
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
- // startIOSDeviceRecording owns an inner autorelease pool. Do not bridge
509
- // the NSError past that pool into V8; the JS wrapper turns false into a
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
  }
@@ -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 ? (unixMs - g_kbStartUnixMs) : 0.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:
@@ -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 (isAVFoundationRecording()) {
229
+ if (hasAVFoundationRecordingResources()) {
222
230
  stopAVFoundationRecording();
223
231
  }
224
232
 
225
- if (isCameraRecording()) {
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 (isCameraRecording()) {
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 && isStandaloneAudioRecording()) {
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
- // DO NOT set g_isRecording here - let ScreenCaptureKit completion handler do it
898
- // Otherwise we have a race condition where JS thinks recording stopped but it's still running
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 (isAVFoundationRecording()) {
928
+ if (hasAVFoundationRecordingResources()) {
911
929
  MRLog(@"🛑 Stopping AVFoundation recording");
912
930
 
913
- BOOL cameraWasRecording = isCameraRecording();
914
- BOOL audioWasRecording = g_usingStandaloneAudio && isStandaloneAudioRecording();
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 (isCameraRecording()) {
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
+ }
@@ -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
- + (NSString *)startRecordingWithConfiguration:(NSDictionary *)config
16
+ + (BOOL)startRecordingWithConfiguration:(NSDictionary *)config
17
17
  delegate:(id)delegate
18
- error:(NSError **)error; // Returns sessionId
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