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.
- package/MultiWindowRecorder.js +85 -4
- package/README.md +18 -1
- package/binding.gyp +2 -1
- package/index-multiprocess.js +50 -6
- package/index.js +154 -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 +45 -62
- package/src/camera_recorder.mm +69 -78
- package/src/ios_device_recorder.mm +651 -78
- 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 +175 -195
- package/src/sync_timeline.h +9 -0
- package/src/sync_timeline.mm +54 -0
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
|
|