node-mac-recorder 2.24.10 → 2.24.12

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.
@@ -1,6 +1,8 @@
1
1
  #import "screen_capture_kit.h"
2
2
  #import "logging.h"
3
3
  #import "sync_timeline.h"
4
+ #import "recording_writer_safety.h"
5
+ #include <atomic>
4
6
  #import <AVFoundation/AVFoundation.h>
5
7
  #import <CoreVideo/CoreVideo.h>
6
8
  #import <CoreMedia/CoreMedia.h>
@@ -132,9 +134,10 @@ static dispatch_queue_t g_sessionsQueue = nil;
132
134
  // Legacy global state for backward compatibility (points to first/default session)
133
135
  static SCStream * API_AVAILABLE(macos(12.3)) g_stream = nil;
134
136
  static id<SCStreamDelegate> API_AVAILABLE(macos(12.3)) g_streamDelegate = nil;
135
- static BOOL g_isRecording = NO;
136
- static BOOL g_isCleaningUp = NO;
137
- static BOOL g_isScheduling = NO;
137
+ static std::atomic<bool> g_isRecording{false};
138
+ static std::atomic<bool> g_isCleaningUp{false};
139
+ static std::atomic<bool> g_isScheduling{false};
140
+ static uint64_t g_schedulingGeneration = 0;
138
141
  static NSString *g_outputPath = nil;
139
142
  static BOOL g_firstFrameReceived = NO;
140
143
  static NSInteger g_frameCountSinceStart = 0;
@@ -156,6 +159,7 @@ static CMTime g_audioStartTime = kCMTimeInvalid;
156
159
  static BOOL g_audioWriterStarted = NO;
157
160
  static BOOL g_captureMicrophoneEnabled = NO;
158
161
  static BOOL g_captureSystemAudioEnabled = NO;
162
+ static BOOL g_captureCameraEnabled = NO;
159
163
  static BOOL g_mixAudioEnabled = YES;
160
164
  static float g_mixMicGain = 0.8f;
161
165
  static float g_mixSystemGain = 0.4f;
@@ -177,6 +181,16 @@ static NSInteger g_lastVideoFramesAppended = 0;
177
181
  static NSInteger g_lastVideoFramesDropped = 0;
178
182
  static NSInteger g_lastVideoTargetFPS = 0;
179
183
 
184
+ // This target uses manual reference counting. Async configuration copies no
185
+ // longer leak, so globals must explicitly own paths used after setup returns.
186
+ static void SCKSetOwnedString(NSString **slot, NSString *value) {
187
+ @synchronized([ScreenCaptureKitRecorder class]) {
188
+ NSString *owned = [value copy];
189
+ [*slot release];
190
+ *slot = owned;
191
+ }
192
+ }
193
+
180
194
  // ---- Prewarm edilmis SCShareableContent onbellegi ----
181
195
  // Kayit baslatilirken envanter cagrisini tamamen atlayabilmek icin saklanir.
182
196
  // ARC KAPALI: retain/release elle yonetiliyor.
@@ -331,7 +345,9 @@ static void SCKQualityBitrateForDimensions(NSString *preset,
331
345
  static dispatch_queue_t ScreenCaptureControlQueue(void);
332
346
  static void SCKMarkSchedulingComplete(void);
333
347
  static void SCKFailScheduling(void);
334
- static void SCKPerformRecordingSetup(NSDictionary *config, SCShareableContent *content) API_AVAILABLE(macos(12.3));
348
+ static void SCKPerformRecordingSetup(NSDictionary *config, SCShareableContent *content, uint64_t generation) API_AVAILABLE(macos(12.3));
349
+ static void SCKCompleteStop(void);
350
+ static void SCKRequestStop(SCStream *expectedStream) API_AVAILABLE(macos(12.3));
335
351
 
336
352
  static void CleanupWriters(void);
337
353
 
@@ -437,25 +453,13 @@ static NSString *MRNormalizePath(id value) {
437
453
  }
438
454
 
439
455
  static void FinishWriter(AVAssetWriter *writer, AVAssetWriterInput *input) {
440
- if (!writer) {
441
- return;
442
- }
443
-
444
- if (input) {
445
- [input markAsFinished];
446
- }
447
-
448
- dispatch_semaphore_t semaphore = dispatch_semaphore_create(0);
449
- [writer finishWritingWithCompletionHandler:^{
450
- dispatch_semaphore_signal(semaphore);
451
- }];
452
- dispatch_time_t timeout = dispatch_time(DISPATCH_TIME_NOW, (int64_t)(5 * NSEC_PER_SEC));
453
- dispatch_semaphore_wait(semaphore, timeout);
456
+ MRFinishAssetWriterSafely(writer, 5.0);
454
457
  }
455
458
 
456
459
  static void CleanupWriters(void) {
457
460
  if (g_videoWriter) {
458
461
  FinishWriter(g_videoWriter, g_videoInput);
462
+ [g_videoWriter release];
459
463
  g_videoWriter = nil;
460
464
  g_videoInput = nil;
461
465
  if (g_pixelBufferAdaptorRef) {
@@ -493,14 +497,11 @@ static void CleanupWriters(void) {
493
497
  }
494
498
 
495
499
  if (g_audioWriter) {
496
- if (g_systemAudioInput) {
497
- [g_systemAudioInput markAsFinished];
498
- }
499
- if (g_microphoneAudioInput) {
500
- [g_microphoneAudioInput markAsFinished];
501
- }
502
500
  FinishWriter(g_audioWriter, nil);
501
+ [g_audioWriter release];
503
502
  g_audioWriter = nil;
503
+ // Factory-created inputs are borrowed from the writer, just like the
504
+ // video input. Releasing them again would over-release on stop.
504
505
  g_systemAudioInput = nil;
505
506
  g_microphoneAudioInput = nil;
506
507
  g_audioWriterStarted = NO;
@@ -544,47 +545,62 @@ extern "C" void ScreenCaptureKitGetFrameStats(long *appendedOut,
544
545
  }
545
546
 
546
547
  extern "C" NSString *ScreenCaptureKitCurrentAudioPath(void) {
547
- if (!g_audioOutputPath) {
548
- return nil;
549
- }
550
- if ([g_audioOutputPath isKindOfClass:[NSArray class]]) {
551
- id first = [(NSArray *)g_audioOutputPath firstObject];
552
- if ([first isKindOfClass:[NSString class]]) {
553
- return first;
554
- }
555
- return nil;
548
+ @synchronized([ScreenCaptureKitRecorder class]) {
549
+ // Native stop may clear the owned path on another queue while N-API
550
+ // is converting it to a JS string. Return a caller-owned pool copy.
551
+ return [[MRNormalizePath(g_audioOutputPath) copy] autorelease];
556
552
  }
557
- return g_audioOutputPath;
558
553
  }
559
554
 
560
- @implementation PureScreenCaptureDelegate
561
- - (void)stream:(SCStream * API_AVAILABLE(macos(12.3)))stream didStopWithError:(NSError *)error API_AVAILABLE(macos(12.3)) {
562
- // ELECTRON FIX: Run cleanup on background thread to avoid blocking Electron
563
- dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_HIGH, 0), ^{
564
- MRLog(@"🛑 Pure ScreenCapture stream stopped");
565
-
566
- // Prevent recursive calls during cleanup
567
- if (g_isCleaningUp) {
568
- MRLog(@"⚠️ Already cleaning up, ignoring delegate callback");
569
- return;
570
- }
571
-
572
- @synchronized([ScreenCaptureKitRecorder class]) {
573
- g_isRecording = NO;
574
- }
555
+ // All finalization runs on the control queue. Stop publishing samples before
556
+ // draining both queues; only then may the pixel buffer adaptor be released.
557
+ static void SCKCompleteStop(void) {
558
+ g_isRecording = NO;
559
+ g_isCleaningUp = YES;
560
+ if (g_videoQueue) dispatch_sync(g_videoQueue, ^{});
561
+ if (g_audioQueue) dispatch_sync(g_audioQueue, ^{});
562
+ @try {
563
+ CleanupWriters();
564
+ } @catch (NSException *exception) {
565
+ NSLog(@"[Recorder] Capture finalization failed safely: %@", exception.reason);
566
+ } @finally {
567
+ [ScreenCaptureKitRecorder cleanupVideoWriter];
568
+ SCKMarkSchedulingComplete();
569
+ }
570
+ }
575
571
 
576
- if (error) {
577
- NSLog(@"❌ Stream error: %@", error);
578
- } else {
579
- MRLog(@"✅ Stream stopped cleanly");
580
- }
572
+ static void SCKRequestStop(SCStream *expectedStream) {
573
+ if ((expectedStream && expectedStream != g_stream) || g_isCleaningUp) return;
574
+ ++g_schedulingGeneration; // invalidate pending inventory/start callbacks
575
+ g_isCleaningUp = YES;
576
+ g_isRecording = NO;
577
+ g_isScheduling = NO;
578
+ SCStream *streamToStop = g_stream;
579
+ if (!streamToStop) {
580
+ SCKCompleteStop();
581
+ return;
582
+ }
583
+ @try {
584
+ [streamToStop stopCaptureWithCompletionHandler:^(NSError *error) {
585
+ dispatch_async(ScreenCaptureControlQueue(), ^{
586
+ if (streamToStop != g_stream) return;
587
+ if (error) NSLog(@"[Recorder] Capture stop error: %@", error);
588
+ SCKCompleteStop();
589
+ });
590
+ }];
591
+ } @catch (NSException *exception) {
592
+ NSLog(@"[Recorder] Capture stop failed safely: %@", exception.reason);
593
+ SCKCompleteStop();
594
+ }
595
+ }
581
596
 
582
- // Finalize on background thread with synchronization
583
- @synchronized([ScreenCaptureKitRecorder class]) {
584
- if (!g_isCleaningUp) {
585
- [ScreenCaptureKitRecorder finalizeRecording];
586
- }
587
- }
597
+ @implementation PureScreenCaptureDelegate
598
+ - (void)stream:(SCStream *)stream didStopWithError:(NSError *)error API_AVAILABLE(macos(12.3)) {
599
+ dispatch_async(ScreenCaptureControlQueue(), ^{
600
+ if (stream != g_stream || g_isCleaningUp) return;
601
+ NSLog(@"[Recorder] Capture source stopped: %@", error);
602
+ ++g_schedulingGeneration;
603
+ SCKCompleteStop();
588
604
  });
589
605
  }
590
606
  @end
@@ -599,9 +615,15 @@ extern "C" NSString *ScreenCaptureKitCurrentAudioPath(void) {
599
615
 
600
616
  @implementation ScreenCaptureVideoOutput
601
617
  - (void)stream:(SCStream *)stream didOutputSampleBuffer:(CMSampleBufferRef)sampleBuffer ofType:(SCStreamOutputType)type API_AVAILABLE(macos(12.3)) {
618
+ if (g_isCleaningUp || stream != g_stream) return;
619
+ @try {
602
620
  if (!g_isRecording || type != SCStreamOutputTypeScreen) {
603
621
  return;
604
622
  }
623
+
624
+ if (MRSyncIsPaused()) {
625
+ return;
626
+ }
605
627
 
606
628
  if (!CMSampleBufferDataIsReady(sampleBuffer)) {
607
629
  return;
@@ -686,6 +708,7 @@ extern "C" NSString *ScreenCaptureKitCurrentAudioPath(void) {
686
708
  relativePresentation = kCMTimeZero;
687
709
  }
688
710
  }
711
+ relativePresentation = MRSyncAdjustForPauses(relativePresentation);
689
712
 
690
713
  double stopLimit = MRSyncGetStopLimitSeconds();
691
714
  if (stopLimit > 0) {
@@ -717,6 +740,11 @@ extern "C" NSString *ScreenCaptureKitCurrentAudioPath(void) {
717
740
  double actualFPS = g_frameCount / elapsed;
718
741
  MRLog(@"📊 Frame stats: %ld frames in %.1fs = %.1f FPS", (long)g_frameCount, elapsed, actualFPS);
719
742
  }
743
+ } @catch (NSException *exception) {
744
+ NSLog(@"[Recorder] Sample callback failed safely: %@", exception.reason);
745
+ dispatch_async(ScreenCaptureControlQueue(), ^{ SCKRequestStop(stream); });
746
+ }
747
+
720
748
  }
721
749
  @end
722
750
 
@@ -725,6 +753,8 @@ extern "C" NSString *ScreenCaptureKitCurrentAudioPath(void) {
725
753
 
726
754
  @implementation ScreenCaptureAudioOutput
727
755
  - (void)stream:(SCStream *)stream didOutputSampleBuffer:(CMSampleBufferRef)sampleBuffer ofType:(SCStreamOutputType)type API_AVAILABLE(macos(12.3)) {
756
+ if (g_isCleaningUp || stream != g_stream) return;
757
+ @try {
728
758
  static dispatch_once_t onceToken;
729
759
  dispatch_once(&onceToken, ^{
730
760
  MRLog(@"🎤 First audio sample callback received from ScreenCaptureKit");
@@ -734,6 +764,10 @@ extern "C" NSString *ScreenCaptureKitCurrentAudioPath(void) {
734
764
  return;
735
765
  }
736
766
 
767
+ if (MRSyncIsPaused()) {
768
+ return;
769
+ }
770
+
737
771
  BOOL isMicrophoneSample = NO;
738
772
  BOOL isSupportedSample = NO;
739
773
  if (@available(macOS 15.0, *)) {
@@ -783,6 +817,17 @@ extern "C" NSString *ScreenCaptureKitCurrentAudioPath(void) {
783
817
 
784
818
  CMTime presentationTime = CMSampleBufferGetPresentationTimeStamp(sampleBuffer);
785
819
 
820
+ // When camera and microphone are both active, lip sync must be anchored to
821
+ // the microphone clock. System audio can arrive first on macOS 15; letting
822
+ // it start the shared writer makes camera alignment depend on callback
823
+ // ordering. Drop only the short system-audio lead-in until mic t=0 exists.
824
+ if (g_captureCameraEnabled &&
825
+ g_captureMicrophoneEnabled &&
826
+ !routeToMicrophoneTrack &&
827
+ CMTIME_IS_INVALID(MRSyncAudioFirstTimestamp())) {
828
+ return;
829
+ }
830
+
786
831
  // A/V SYNC: Hold audio samples until camera produces first frame
787
832
  if (MRSyncShouldHoldAudioSample(presentationTime)) {
788
833
  return;
@@ -838,7 +883,7 @@ extern "C" NSString *ScreenCaptureKitCurrentAudioPath(void) {
838
883
  if (CMTIME_COMPARE_INLINE(adjustedPTS, <, kCMTimeZero)) {
839
884
  adjustedPTS = kCMTimeZero;
840
885
  }
841
- timingInfo[i].presentationTimeStamp = adjustedPTS;
886
+ timingInfo[i].presentationTimeStamp = MRSyncAdjustForPauses(adjustedPTS);
842
887
  } else {
843
888
  timingInfo[i].presentationTimeStamp = kCMTimeZero;
844
889
  }
@@ -848,7 +893,7 @@ extern "C" NSString *ScreenCaptureKitCurrentAudioPath(void) {
848
893
  if (CMTIME_COMPARE_INLINE(adjustedDTS, <, kCMTimeZero)) {
849
894
  adjustedDTS = kCMTimeZero;
850
895
  }
851
- timingInfo[i].decodeTimeStamp = adjustedDTS;
896
+ timingInfo[i].decodeTimeStamp = MRSyncAdjustForPauses(adjustedDTS);
852
897
  }
853
898
  }
854
899
 
@@ -902,6 +947,11 @@ extern "C" NSString *ScreenCaptureKitCurrentAudioPath(void) {
902
947
  if (bufferToAppend != sampleBuffer) {
903
948
  CFRelease(bufferToAppend);
904
949
  }
950
+ } @catch (NSException *exception) {
951
+ NSLog(@"[Recorder] Sample callback failed safely: %@", exception.reason);
952
+ dispatch_async(ScreenCaptureControlQueue(), ^{ SCKRequestStop(stream); });
953
+ }
954
+
905
955
  }
906
956
  @end
907
957
 
@@ -1123,7 +1173,7 @@ extern "C" NSString *ScreenCaptureKitCurrentAudioPath(void) {
1123
1173
  if (![audioPath.pathExtension.lowercaseString isEqualToString:@"mov"]) {
1124
1174
  MRLog(@"⚠️ Audio path has wrong extension '%@', changing to .mov", audioPath.pathExtension);
1125
1175
  audioPath = [[audioPath stringByDeletingPathExtension] stringByAppendingPathExtension:@"mov"];
1126
- g_audioOutputPath = audioPath;
1176
+ SCKSetOwnedString(&g_audioOutputPath, audioPath);
1127
1177
  }
1128
1178
  audioURL = [NSURL fileURLWithPath:audioPath];
1129
1179
  [[NSFileManager defaultManager] removeItemAtURL:audioURL error:nil];
@@ -1280,6 +1330,7 @@ extern "C" NSString *ScreenCaptureKitCurrentAudioPath(void) {
1280
1330
  // yapiliyordu. Artik sonucu saklayip kayitta yeniden kullaniyoruz,
1281
1331
  // yani bu adim tamamen atlanabiliyor.
1282
1332
  dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_BACKGROUND, 0), ^{
1333
+ @try {
1283
1334
  CFAbsoluteTime fetchStart = CFAbsoluteTimeGetCurrent();
1284
1335
  [SCShareableContent getShareableContentWithCompletionHandler:^(SCShareableContent *content, NSError *contentError) {
1285
1336
  if (contentError || !content) {
@@ -1292,6 +1343,9 @@ extern "C" NSString *ScreenCaptureKitCurrentAudioPath(void) {
1292
1343
  (unsigned long)content.displays.count,
1293
1344
  (unsigned long)content.windows.count);
1294
1345
  }];
1346
+ } @catch (NSException *exception) {
1347
+ NSLog(@"[Recorder] Prewarm failed safely: %@", exception.reason);
1348
+ }
1295
1349
  });
1296
1350
  }
1297
1351
  }
@@ -1304,19 +1358,22 @@ extern "C" NSString *ScreenCaptureKitCurrentAudioPath(void) {
1304
1358
  NSDictionary *configCopy = [config copy];
1305
1359
  dispatch_queue_t controlQueue = ScreenCaptureControlQueue();
1306
1360
  __block BOOL accepted = NO;
1361
+ __block uint64_t generation = 0;
1307
1362
 
1308
1363
  dispatch_sync(controlQueue, ^{
1309
1364
  if (g_isRecording || g_isCleaningUp || g_isScheduling) {
1310
- MRLog(@"⚠️ ScreenCaptureKit busy (recording:%d cleaning:%d scheduling:%d)", g_isRecording, g_isCleaningUp, g_isScheduling);
1365
+ MRLog(@"⚠️ ScreenCaptureKit busy (recording:%d cleaning:%d scheduling:%d)", g_isRecording.load(), g_isCleaningUp.load(), g_isScheduling.load());
1311
1366
  accepted = NO;
1312
1367
  return;
1313
1368
  }
1314
1369
  g_isCleaningUp = NO;
1315
1370
  g_isScheduling = YES;
1371
+ generation = ++g_schedulingGeneration;
1316
1372
  accepted = YES;
1317
1373
  });
1318
1374
 
1319
1375
  if (!accepted) {
1376
+ [configCopy release];
1320
1377
  return NO;
1321
1378
  }
1322
1379
 
@@ -1328,8 +1385,9 @@ extern "C" NSString *ScreenCaptureKitCurrentAudioPath(void) {
1328
1385
  if (prewarmed) {
1329
1386
  NSLog(@"⚡ Prewarmed shareable content kullanildi — envanter cagrisi atlandi");
1330
1387
  dispatch_async(controlQueue, ^{
1331
- SCKPerformRecordingSetup(configCopy, prewarmed);
1388
+ SCKPerformRecordingSetup(configCopy, prewarmed, generation);
1332
1389
  });
1390
+ [configCopy release];
1333
1391
  // NOT: Burada onbellegi tazelemek YOK.
1334
1392
  // Kayit setup'i calisirken yeni bir SCShareableContent istegi
1335
1393
  // baslatmak sistem envanterini kayitla ayni anda sorgular ve
@@ -1344,115 +1402,36 @@ extern "C" NSString *ScreenCaptureKitCurrentAudioPath(void) {
1344
1402
  NSLog(@"🚀 Requesting shareable content...");
1345
1403
  CFAbsoluteTime contentFetchStart = CFAbsoluteTimeGetCurrent();
1346
1404
  dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_HIGH, 0), ^{
1347
- [SCShareableContent getShareableContentWithCompletionHandler:^(SCShareableContent *content, NSError *contentError) {
1348
- if (contentError || !content) {
1349
- NSLog(@"❌ Content error: %@", contentError);
1350
- SCKFailScheduling();
1351
- return;
1352
- }
1353
- NSLog(@"✅ Got shareable content in %.0fms, starting recording setup...",
1354
- (CFAbsoluteTimeGetCurrent() - contentFetchStart) * 1000.0);
1405
+ @try {
1406
+ [SCShareableContent getShareableContentWithCompletionHandler:^(SCShareableContent *content, NSError *contentError) {
1407
+ dispatch_async(controlQueue, ^{
1408
+ if (generation != g_schedulingGeneration) return;
1409
+ if (contentError || !content) {
1410
+ NSLog(@"[Recorder] Content error: %@", contentError);
1411
+ SCKFailScheduling();
1412
+ return;
1413
+ }
1414
+ SCKPerformRecordingSetup(configCopy, content, generation);
1415
+ });
1416
+ }];
1417
+ } @catch (NSException *exception) {
1418
+ NSLog(@"[Recorder] Content discovery failed safely: %@", exception.reason);
1355
1419
  dispatch_async(controlQueue, ^{
1356
- SCKPerformRecordingSetup(configCopy, content);
1420
+ if (generation == g_schedulingGeneration) SCKFailScheduling();
1357
1421
  });
1358
- }];
1422
+ }
1359
1423
  });
1424
+ [configCopy release];
1360
1425
 
1361
1426
  return YES;
1362
1427
  }
1363
1428
 
1364
1429
  + (void)stopRecording {
1365
- if (!g_isRecording || !g_stream || g_isCleaningUp) {
1366
- NSLog(@"⚠️ Cannot stop: recording=%d stream=%@ cleaning=%d", g_isRecording, g_stream, g_isCleaningUp);
1367
- SCKMarkSchedulingComplete();
1368
- return;
1369
- }
1370
-
1371
- MRLog(@"🛑 Stopping pure ScreenCaptureKit recording");
1372
-
1373
- // CRITICAL FIX: Set cleanup flag IMMEDIATELY to prevent race conditions
1374
- // This prevents startRecording from being called while stop is in progress
1375
- @synchronized([ScreenCaptureKitRecorder class]) {
1376
- g_isCleaningUp = YES;
1377
- }
1378
-
1379
- // Store stream reference to prevent it from being deallocated
1380
- SCStream *streamToStop = g_stream;
1381
-
1382
- // ELECTRON FIX: Stop FULLY ASYNCHRONOUSLY - NO blocking, NO semaphores
1383
- [streamToStop stopCaptureWithCompletionHandler:^(NSError *stopError) {
1384
- @autoreleasepool {
1385
- if (stopError) {
1386
- NSLog(@"❌ Stop error: %@", stopError);
1387
- } else {
1388
- MRLog(@"✅ Pure stream stopped");
1389
- }
1390
-
1391
- // Reset recording state to allow new recordings
1392
- @synchronized([ScreenCaptureKitRecorder class]) {
1393
- g_isRecording = NO;
1394
- g_isCleaningUp = NO; // CRITICAL: Reset cleanup flag when done
1395
- }
1396
-
1397
- // Cleanup after stop completes
1398
- CleanupWriters();
1399
- [ScreenCaptureKitRecorder cleanupVideoWriter];
1400
-
1401
- // Post-process: mix (if enabled) then mux audio into video file
1402
- if (g_shouldCaptureAudio && g_audioOutputPath) {
1403
- NSString *primaryAudioPath = ScreenCaptureKitCurrentAudioPath();
1404
- if ([primaryAudioPath isKindOfClass:[NSArray class]]) {
1405
- id first = [(NSArray *)primaryAudioPath firstObject];
1406
- if ([first isKindOfClass:[NSString class]]) {
1407
- primaryAudioPath = (NSString *)first;
1408
- } else {
1409
- primaryAudioPath = nil;
1410
- }
1411
- }
1412
- if (primaryAudioPath && [primaryAudioPath length] > 0) {
1413
- BOOL preferInternal = NO;
1414
- if (@available(macOS 15.0, *)) {
1415
- preferInternal = (g_captureSystemAudioEnabled && g_captureMicrophoneEnabled);
1416
- }
1417
- NSString *externalMicPath = nil;
1418
- if (currentStandaloneAudioRecordingPath) {
1419
- externalMicPath = currentStandaloneAudioRecordingPath();
1420
- }
1421
- if (!externalMicPath || [externalMicPath length] == 0) {
1422
- if (lastStandaloneAudioRecordingPath) {
1423
- externalMicPath = lastStandaloneAudioRecordingPath();
1424
- }
1425
- }
1426
- dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_BACKGROUND, 0), ^{
1427
- NSString *audioForMux = primaryAudioPath;
1428
- if (g_mixAudioEnabled) {
1429
- BOOL mixed = NO;
1430
- // Try gain-aware mix first
1431
- mixed = MRMixAudioToSingleTrackWithGains(primaryAudioPath, externalMicPath, preferInternal, g_mixMicGain, g_mixSystemGain);
1432
- if (!mixed) {
1433
- mixed = MRMixAudioToSingleTrack(primaryAudioPath, externalMicPath, preferInternal);
1434
- }
1435
- if (mixed) {
1436
- MRLog(@"🎧 Post-mix completed: %@", primaryAudioPath);
1437
- } else {
1438
- MRLog(@"ℹ️ Post-mix skipped or failed; proceeding to mux");
1439
- }
1440
- }
1441
- if (g_outputPath && [g_outputPath length] > 0) {
1442
- BOOL muxed = MRMuxAudioIntoVideo(g_outputPath, audioForMux);
1443
- if (muxed) {
1444
- MRLog(@"🔗 Muxed audio into video: %@", g_outputPath);
1445
- } else {
1446
- MRLog(@"⚠️ Failed to mux audio into video %@", g_outputPath);
1447
- }
1448
- }
1449
- });
1450
- }
1451
- }
1430
+ dispatch_sync(ScreenCaptureControlQueue(), ^{ SCKRequestStop(nil); });
1431
+ }
1452
1432
 
1453
- SCKMarkSchedulingComplete();
1454
- }
1455
- }];
1433
+ + (BOOL)isScheduling {
1434
+ return g_isScheduling;
1456
1435
  }
1457
1436
 
1458
1437
  + (BOOL)isRecording {
@@ -1523,15 +1502,19 @@ BOOL isScreenCaptureKitCleaningUp() API_AVAILABLE(macos(12.3)) {
1523
1502
 
1524
1503
  // Clean up in proper order to prevent crashes
1525
1504
  if (g_stream) {
1505
+ [g_stream release];
1526
1506
  g_stream = nil;
1527
1507
  MRLog(@"✅ Stream reference cleared");
1528
1508
  }
1529
1509
 
1530
1510
  if (g_streamDelegate) {
1511
+ [(id)g_streamDelegate release];
1531
1512
  g_streamDelegate = nil;
1532
1513
  MRLog(@"✅ Stream delegate reference cleared");
1533
1514
  }
1534
1515
 
1516
+ [g_videoStreamOutput release];
1517
+ [g_audioStreamOutput release];
1535
1518
  g_videoStreamOutput = nil;
1536
1519
  g_audioStreamOutput = nil;
1537
1520
  g_videoQueue = nil;
@@ -1540,14 +1523,15 @@ BOOL isScreenCaptureKitCleaningUp() API_AVAILABLE(macos(12.3)) {
1540
1523
  CFRelease(g_pixelBufferAdaptorRef);
1541
1524
  g_pixelBufferAdaptorRef = NULL;
1542
1525
  }
1543
- g_audioOutputPath = nil;
1526
+ SCKSetOwnedString(&g_audioOutputPath, nil);
1544
1527
  g_shouldCaptureAudio = NO;
1545
1528
  g_captureMicrophoneEnabled = NO;
1546
1529
  g_captureSystemAudioEnabled = NO;
1530
+ g_captureCameraEnabled = NO;
1547
1531
 
1548
1532
  g_isRecording = NO;
1549
1533
  g_isCleaningUp = NO; // Reset cleanup flag
1550
- g_outputPath = nil;
1534
+ SCKSetOwnedString(&g_outputPath, nil);
1551
1535
 
1552
1536
  // ELECTRON FIX: Reset frame tracking
1553
1537
  g_firstFrameReceived = NO;
@@ -1572,12 +1556,13 @@ static void SCKMarkSchedulingComplete(void) {
1572
1556
  }
1573
1557
 
1574
1558
  static void SCKFailScheduling(void) {
1575
- g_isScheduling = NO;
1576
- g_isRecording = NO;
1559
+ SCKRequestStop(nil);
1577
1560
  }
1578
1561
 
1579
- static void SCKPerformRecordingSetup(NSDictionary *config, SCShareableContent *content) API_AVAILABLE(macos(12.3)) {
1562
+ static void SCKPerformRecordingSetup(NSDictionary *config, SCShareableContent *content, uint64_t generation) API_AVAILABLE(macos(12.3)) {
1580
1563
  @autoreleasepool {
1564
+ @try {
1565
+ if (generation != g_schedulingGeneration) return;
1581
1566
  if (!config || !content) {
1582
1567
  SCKFailScheduling();
1583
1568
  return;
@@ -1594,7 +1579,7 @@ static void SCKPerformRecordingSetup(NSDictionary *config, SCShareableContent *c
1594
1579
  SCKFailScheduling();
1595
1580
  return;
1596
1581
  }
1597
- g_outputPath = outputPath;
1582
+ SCKSetOwnedString(&g_outputPath, outputPath);
1598
1583
 
1599
1584
  NSNumber *displayId = config[@"displayId"];
1600
1585
  NSNumber *windowId = config[@"windowId"];
@@ -1620,7 +1605,7 @@ static void SCKPerformRecordingSetup(NSDictionary *config, SCShareableContent *c
1620
1605
  if (g_mixSystemGain < 0.f) g_mixSystemGain = 0.f;
1621
1606
  if (g_mixSystemGain > 2.f) g_mixSystemGain = 2.f;
1622
1607
  }
1623
- g_qualityPreset = SCKNormalizeQualityPreset(config[@"quality"]);
1608
+ SCKSetOwnedString(&g_qualityPreset, SCKNormalizeQualityPreset(config[@"quality"]));
1624
1609
  MRLog(@"🎚️ Requested quality preset: %@", g_qualityPreset);
1625
1610
  NSNumber *captureCamera = config[@"captureCamera"];
1626
1611
 
@@ -1907,12 +1892,13 @@ static void SCKPerformRecordingSetup(NSDictionary *config, SCShareableContent *c
1907
1892
  g_shouldCaptureAudio = shouldCaptureSystemAudio || shouldCaptureMic;
1908
1893
  g_captureMicrophoneEnabled = shouldCaptureMic;
1909
1894
  g_captureSystemAudioEnabled = shouldCaptureSystemAudio;
1895
+ g_captureCameraEnabled = captureCamera ? [captureCamera boolValue] : NO;
1910
1896
 
1911
1897
  if (audioOutputPath && ![audioOutputPath isKindOfClass:[NSString class]]) {
1912
1898
  MRLog(@"⚠️ audioOutputPath type mismatch: %@, converting...", NSStringFromClass([audioOutputPath class]));
1913
- g_audioOutputPath = nil;
1899
+ SCKSetOwnedString(&g_audioOutputPath, nil);
1914
1900
  } else {
1915
- g_audioOutputPath = audioOutputPath;
1901
+ SCKSetOwnedString(&g_audioOutputPath, audioOutputPath);
1916
1902
  }
1917
1903
 
1918
1904
  if (g_shouldCaptureAudio && (!g_audioOutputPath || [g_audioOutputPath length] == 0)) {
@@ -1995,7 +1981,6 @@ static void SCKPerformRecordingSetup(NSDictionary *config, SCShareableContent *c
1995
1981
  if (![ScreenCaptureKitRecorder prepareVideoWriterWithWidth:recordingWidth height:recordingHeight error:&writerError]) {
1996
1982
  NSLog(@"❌ Failed to prepare video writer: %@", writerError);
1997
1983
  SCKFailScheduling();
1998
- CleanupWriters();
1999
1984
  return;
2000
1985
  }
2001
1986
 
@@ -2012,7 +1997,6 @@ static void SCKPerformRecordingSetup(NSDictionary *config, SCShareableContent *c
2012
1997
  g_stream = [[SCStream alloc] initWithFilter:filter configuration:streamConfig delegate:g_streamDelegate];
2013
1998
  if (!g_stream) {
2014
1999
  NSLog(@"❌ Failed to create pure stream");
2015
- CleanupWriters();
2016
2000
  SCKFailScheduling();
2017
2001
  return;
2018
2002
  }
@@ -2021,10 +2005,6 @@ static void SCKPerformRecordingSetup(NSDictionary *config, SCShareableContent *c
2021
2005
  BOOL videoOutputAdded = [g_stream addStreamOutput:g_videoStreamOutput type:SCStreamOutputTypeScreen sampleHandlerQueue:g_videoQueue error:&outputError];
2022
2006
  if (!videoOutputAdded || outputError) {
2023
2007
  NSLog(@"❌ Failed to add video output: %@", outputError);
2024
- CleanupWriters();
2025
- @synchronized([ScreenCaptureKitRecorder class]) {
2026
- g_stream = nil;
2027
- }
2028
2008
  SCKFailScheduling();
2029
2009
  return;
2030
2010
  }
@@ -2044,8 +2024,6 @@ static void SCKPerformRecordingSetup(NSDictionary *config, SCShareableContent *c
2044
2024
  error:&audioError];
2045
2025
  if (!micAdded || audioError) {
2046
2026
  NSLog(@"❌ Failed to add microphone output: %@", audioError);
2047
- CleanupWriters();
2048
- @synchronized([ScreenCaptureKitRecorder class]) { g_stream = nil; }
2049
2027
  SCKFailScheduling();
2050
2028
  return;
2051
2029
  }
@@ -2061,8 +2039,6 @@ static void SCKPerformRecordingSetup(NSDictionary *config, SCShareableContent *c
2061
2039
  error:&audioError];
2062
2040
  if (!sysAdded || audioError) {
2063
2041
  NSLog(@"❌ Failed to add system audio output: %@", audioError);
2064
- CleanupWriters();
2065
- @synchronized([ScreenCaptureKitRecorder class]) { g_stream = nil; }
2066
2042
  SCKFailScheduling();
2067
2043
  return;
2068
2044
  }
@@ -2079,8 +2055,6 @@ static void SCKPerformRecordingSetup(NSDictionary *config, SCShareableContent *c
2079
2055
  error:&audioError];
2080
2056
  if (!audAdded || audioError) {
2081
2057
  NSLog(@"❌ Failed to add audio output: %@", audioError);
2082
- CleanupWriters();
2083
- @synchronized([ScreenCaptureKitRecorder class]) { g_stream = nil; }
2084
2058
  SCKFailScheduling();
2085
2059
  return;
2086
2060
  }
@@ -2090,8 +2064,6 @@ static void SCKPerformRecordingSetup(NSDictionary *config, SCShareableContent *c
2090
2064
 
2091
2065
  if (!anyAudioAdded) {
2092
2066
  NSLog(@"❌ No audio outputs added (unexpected configuration)");
2093
- CleanupWriters();
2094
- @synchronized([ScreenCaptureKitRecorder class]) { g_stream = nil; }
2095
2067
  SCKFailScheduling();
2096
2068
  return;
2097
2069
  }
@@ -2109,18 +2081,22 @@ static void SCKPerformRecordingSetup(NSDictionary *config, SCShareableContent *c
2109
2081
  }
2110
2082
 
2111
2083
  NSLog(@"🚀 CALLING startCaptureWithCompletionHandler (async)...");
2112
- [g_stream startCaptureWithCompletionHandler:^(NSError *startError) {
2084
+ SCStream *startingStream = g_stream;
2085
+ [startingStream startCaptureWithCompletionHandler:^(NSError *startError) {
2113
2086
  dispatch_async(ScreenCaptureControlQueue(), ^{
2087
+ if (generation != g_schedulingGeneration || startingStream != g_stream) {
2088
+ // Cancellation can precede the asynchronous start reply.
2089
+ if (!startError) {
2090
+ @try { [startingStream stopCaptureWithCompletionHandler:^(NSError *error) {}]; }
2091
+ @catch (NSException *exception) { NSLog(@"[Recorder] Stale stream stop: %@", exception.reason); }
2092
+ }
2093
+ return;
2094
+ }
2114
2095
  if (startError) {
2115
2096
  NSLog(@"❌ Failed to start pure capture: %@", startError);
2116
2097
  NSLog(@"❌ Error domain: %@, code: %ld", startError.domain, (long)startError.code);
2117
2098
  NSLog(@"❌ Error userInfo: %@", startError.userInfo);
2118
- CleanupWriters();
2119
- @synchronized([ScreenCaptureKitRecorder class]) {
2120
- g_isRecording = NO;
2121
- g_stream = nil;
2122
- }
2123
- SCKFailScheduling();
2099
+ SCKFailScheduling();
2124
2100
  } else {
2125
2101
  NSLog(@"🎉 PURE ScreenCaptureKit recording started successfully!");
2126
2102
  NSLog(@"🎤 Audio capture enabled: %d (mic=%d, system=%d)", g_shouldCaptureAudio, g_captureMicrophoneEnabled, g_captureSystemAudioEnabled);
@@ -2131,5 +2107,9 @@ static void SCKPerformRecordingSetup(NSDictionary *config, SCShareableContent *c
2131
2107
  }
2132
2108
  });
2133
2109
  }];
2110
+ } @catch (NSException *exception) {
2111
+ NSLog(@"[Recorder] Capture setup failed safely: %@", exception.reason);
2112
+ SCKFailScheduling();
2113
+ }
2134
2114
  }
2135
2115
  }