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.
@@ -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