node-mac-recorder 2.21.4 → 2.21.6

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,7 +8,9 @@
8
8
  "Read(//Users/onur/codes/**)",
9
9
  "Bash(log show:*)",
10
10
  "Bash(MAC_RECORDER_DEBUG=1 node:*)",
11
- "Read(//private/tmp/test-recording/**)"
11
+ "Read(//private/tmp/test-recording/**)",
12
+ "Bash(MAC_RECORDER_DEBUG=1 timeout 5 node:*)",
13
+ "Read(//private/tmp/**)"
12
14
  ],
13
15
  "deny": [],
14
16
  "ask": []
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "node-mac-recorder",
3
- "version": "2.21.4",
3
+ "version": "2.21.6",
4
4
  "description": "Native macOS screen recording package for Node.js applications",
5
5
  "main": "index.js",
6
6
  "keywords": [
@@ -127,11 +127,9 @@ static BOOL MRIsContinuityCamera(AVCaptureDevice *device) {
127
127
  [deviceTypes addObject:AVCaptureDeviceTypeExternalUnknown];
128
128
  }
129
129
 
130
- // Only add Continuity Camera type if allowed
131
- if (allowContinuity) {
132
- if (@available(macOS 14.0, *)) {
133
- [deviceTypes addObject:AVCaptureDeviceTypeContinuityCamera];
134
- }
130
+ // ALWAYS add Continuity Camera type - filtering happens later
131
+ if (@available(macOS 14.0, *)) {
132
+ [deviceTypes addObject:AVCaptureDeviceTypeContinuityCamera];
135
133
  }
136
134
 
137
135
  AVCaptureDeviceDiscoverySession *discoverySession =
@@ -328,29 +328,29 @@ Napi::Value StartRecording(const Napi::CallbackInfo& info) {
328
328
  (NSBundle.mainBundle.bundlePath &&
329
329
  [NSBundle.mainBundle.bundlePath containsString:@"Electron"]);
330
330
 
331
- if (isElectron) {
332
- MRLog(@"⚡ Electron environment detected - continuing with ScreenCaptureKit");
333
- MRLog(@"⚠️ Warning: ScreenCaptureKit in Electron may require additional stability measures");
334
- }
335
-
336
331
  // Check macOS version for ScreenCaptureKit compatibility
337
332
  NSOperatingSystemVersion osVersion = [[NSProcessInfo processInfo] operatingSystemVersion];
338
333
  BOOL isM15Plus = (osVersion.majorVersion >= 15);
339
334
  BOOL isM14Plus = (osVersion.majorVersion >= 14);
340
335
  BOOL isM13Plus = (osVersion.majorVersion >= 13);
341
-
342
- MRLog(@"🖥️ macOS Version: %ld.%ld.%ld",
336
+
337
+ MRLog(@"🖥️ macOS Version: %ld.%ld.%ld",
343
338
  (long)osVersion.majorVersion, (long)osVersion.minorVersion, (long)osVersion.patchVersion);
344
-
345
- // Force AVFoundation for debugging/testing
346
- BOOL forceAVFoundation = (getenv("FORCE_AVFOUNDATION") != NULL);
347
- if (forceAVFoundation) {
348
- MRLog(@"🔧 FORCE_AVFOUNDATION environment variable detected - skipping ScreenCaptureKit");
339
+
340
+ if (isElectron) {
341
+ MRLog(@"⚡ Electron environment detected");
342
+ MRLog(@"🔧 CRITICAL FIX: Forcing AVFoundation for Electron stability");
343
+ MRLog(@" Reason: ScreenCaptureKit has thread safety issues in Electron (SIGTRAP crashes)");
349
344
  }
350
-
351
- // Electron-first priority: This application is built for Electron.js
352
- // macOS 15+ ScreenCaptureKit (including Electron)
353
- // macOS 14/13 AVFoundation (including Electron)
345
+
346
+ // Force AVFoundation for debugging/testing OR Electron
347
+ BOOL forceAVFoundation = (getenv("FORCE_AVFOUNDATION") != NULL) || isElectron;
348
+ if (getenv("FORCE_AVFOUNDATION") != NULL) {
349
+ MRLog(@"🔧 FORCE_AVFOUNDATION environment variable detected");
350
+ }
351
+
352
+ // Electron-first priority: ALWAYS use AVFoundation in Electron for stability
353
+ // ScreenCaptureKit has severe thread safety issues in Electron causing SIGTRAP crashes
354
354
  if (isM15Plus && !forceAVFoundation) {
355
355
  if (isElectron) {
356
356
  MRLog(@"⚡ ELECTRON PRIORITY: macOS 15+ Electron → ScreenCaptureKit with full support");
@@ -225,10 +225,15 @@ extern "C" NSString *ScreenCaptureKitCurrentAudioPath(void) {
225
225
 
226
226
  @implementation ScreenCaptureAudioOutput
227
227
  - (void)stream:(SCStream *)stream didOutputSampleBuffer:(CMSampleBufferRef)sampleBuffer ofType:(SCStreamOutputType)type API_AVAILABLE(macos(12.3)) {
228
+ static dispatch_once_t onceToken;
229
+ dispatch_once(&onceToken, ^{
230
+ MRLog(@"🎤 First audio sample callback received from ScreenCaptureKit");
231
+ });
232
+
228
233
  if (!g_isRecording || !g_shouldCaptureAudio) {
229
234
  return;
230
235
  }
231
-
236
+
232
237
  if (@available(macOS 13.0, *)) {
233
238
  if (type != SCStreamOutputTypeAudio) {
234
239
  return;
@@ -236,8 +241,9 @@ extern "C" NSString *ScreenCaptureKitCurrentAudioPath(void) {
236
241
  } else {
237
242
  return;
238
243
  }
239
-
244
+
240
245
  if (!CMSampleBufferDataIsReady(sampleBuffer)) {
246
+ MRLog(@"⚠️ Audio sample buffer data not ready");
241
247
  return;
242
248
  }
243
249
 
@@ -263,11 +269,21 @@ extern "C" NSString *ScreenCaptureKitCurrentAudioPath(void) {
263
269
  }
264
270
 
265
271
  if (!g_audioInput.readyForMoreMediaData) {
272
+ static int notReadyCount = 0;
273
+ if (notReadyCount++ % 100 == 0) {
274
+ MRLog(@"⚠️ Audio input not ready for data (count: %d)", notReadyCount);
275
+ }
266
276
  return;
267
277
  }
268
-
269
- if (![g_audioInput appendSampleBuffer:sampleBuffer]) {
278
+
279
+ BOOL success = [g_audioInput appendSampleBuffer:sampleBuffer];
280
+ if (!success) {
270
281
  NSLog(@"⚠️ Failed appending audio sample buffer: %@", g_audioWriter.error);
282
+ } else {
283
+ static int appendCount = 0;
284
+ if (appendCount++ % 100 == 0) {
285
+ MRLog(@"✅ Audio sample appended successfully (count: %d)", appendCount);
286
+ }
271
287
  }
272
288
  }
273
289
  @end
@@ -365,13 +381,20 @@ extern "C" NSString *ScreenCaptureKitCurrentAudioPath(void) {
365
381
  [[NSFileManager defaultManager] removeItemAtURL:audioURL error:nil];
366
382
 
367
383
  NSError *writerError = nil;
384
+ // CRITICAL FIX: AVAssetWriter does NOT support WebM for audio
385
+ // Always use QuickTime Movie format (.mov) for audio files
368
386
  AVFileType requestedFileType = AVFileTypeQuickTimeMovie;
369
- BOOL requestedWebM = NO;
370
- if (@available(macOS 15.0, *)) {
371
- requestedFileType = @"public.webm";
372
- requestedWebM = YES;
387
+
388
+ // Ensure path has .mov extension for audio
389
+ NSString *audioPath = originalPath;
390
+ if (![audioPath.pathExtension.lowercaseString isEqualToString:@"mov"]) {
391
+ MRLog(@"⚠️ Audio path has wrong extension '%@', changing to .mov", audioPath.pathExtension);
392
+ audioPath = [[audioPath stringByDeletingPathExtension] stringByAppendingPathExtension:@"mov"];
393
+ g_audioOutputPath = audioPath;
373
394
  }
374
-
395
+ audioURL = [NSURL fileURLWithPath:audioPath];
396
+ [[NSFileManager defaultManager] removeItemAtURL:audioURL error:nil];
397
+
375
398
  @try {
376
399
  g_audioWriter = [[AVAssetWriter alloc] initWithURL:audioURL fileType:requestedFileType error:&writerError];
377
400
  } @catch (NSException *exception) {
@@ -382,28 +405,6 @@ extern "C" NSString *ScreenCaptureKitCurrentAudioPath(void) {
382
405
  g_audioWriter = nil;
383
406
  }
384
407
 
385
- if ((!g_audioWriter || writerError) && requestedWebM) {
386
- MRLog(@"⚠️ ScreenCaptureKit audio writer unavailable (%@) – falling back to QuickTime container", writerError.localizedDescription);
387
- NSString *fallbackPath = [[originalPath stringByDeletingPathExtension] stringByAppendingPathExtension:@"mov"];
388
- if (!fallbackPath || [fallbackPath length] == 0) {
389
- fallbackPath = [originalPath stringByAppendingString:@".mov"];
390
- }
391
- [[NSFileManager defaultManager] removeItemAtPath:fallbackPath error:nil];
392
- NSURL *fallbackURL = [NSURL fileURLWithPath:fallbackPath];
393
- g_audioOutputPath = fallbackPath;
394
- writerError = nil;
395
- @try {
396
- g_audioWriter = [[AVAssetWriter alloc] initWithURL:fallbackURL fileType:AVFileTypeQuickTimeMovie error:&writerError];
397
- } @catch (NSException *exception) {
398
- NSDictionary *info = @{
399
- NSLocalizedDescriptionKey: exception.reason ?: @"Failed to initialize audio writer"
400
- };
401
- writerError = [NSError errorWithDomain:@"ScreenCaptureKitRecorder" code:-202 userInfo:info];
402
- g_audioWriter = nil;
403
- }
404
- audioURL = fallbackURL;
405
- }
406
-
407
408
  if (!g_audioWriter || writerError) {
408
409
  NSLog(@"❌ Failed to create audio writer: %@", writerError);
409
410
  return NO;
@@ -433,7 +434,10 @@ extern "C" NSString *ScreenCaptureKitCurrentAudioPath(void) {
433
434
 
434
435
  g_audioInput = [AVAssetWriterInput assetWriterInputWithMediaType:AVMediaTypeAudio outputSettings:audioSettings];
435
436
  g_audioInput.expectsMediaDataInRealTime = YES;
436
-
437
+
438
+ MRLog(@"🎙️ Audio input created: sampleRate=%ld, channels=%ld, bitrate=192k",
439
+ (long)g_configuredSampleRate, (long)channelCount);
440
+
437
441
  if (![g_audioWriter canAddInput:g_audioInput]) {
438
442
  NSLog(@"❌ Audio writer cannot add input");
439
443
  return NO;
@@ -441,7 +445,8 @@ extern "C" NSString *ScreenCaptureKitCurrentAudioPath(void) {
441
445
  [g_audioWriter addInput:g_audioInput];
442
446
  g_audioWriterStarted = NO;
443
447
  g_audioStartTime = kCMTimeInvalid;
444
-
448
+
449
+ MRLog(@"✅ Audio writer prepared successfully (path: %@)", g_audioOutputPath);
445
450
  return YES;
446
451
  }
447
452
 
@@ -608,24 +613,43 @@ extern "C" NSString *ScreenCaptureKitCurrentAudioPath(void) {
608
613
  BOOL shouldCaptureMic = includeMicrophone ? [includeMicrophone boolValue] : NO;
609
614
  BOOL shouldCaptureSystemAudio = includeSystemAudio ? [includeSystemAudio boolValue] : NO;
610
615
  g_shouldCaptureAudio = shouldCaptureMic || shouldCaptureSystemAudio;
611
- g_audioOutputPath = audioOutputPath;
616
+
617
+ // SAFETY: Ensure audioOutputPath is NSString, not NSURL or other type
618
+ if (audioOutputPath && ![audioOutputPath isKindOfClass:[NSString class]]) {
619
+ MRLog(@"⚠️ audioOutputPath type mismatch: %@, converting...", NSStringFromClass([audioOutputPath class]));
620
+ g_audioOutputPath = nil;
621
+ } else {
622
+ g_audioOutputPath = audioOutputPath;
623
+ }
624
+
612
625
  if (g_shouldCaptureAudio && (!g_audioOutputPath || [g_audioOutputPath length] == 0)) {
613
626
  NSLog(@"⚠️ Audio capture requested but no audio output path supplied – audio will be disabled");
614
627
  g_shouldCaptureAudio = NO;
615
628
  }
616
629
 
617
630
  if (@available(macos 13.0, *)) {
631
+ // capturesAudio enables audio capture (both mic and system audio)
618
632
  streamConfig.capturesAudio = g_shouldCaptureAudio;
619
- streamConfig.sampleRate = g_configuredSampleRate;
620
- streamConfig.channelCount = g_configuredChannelCount;
633
+ streamConfig.sampleRate = g_configuredSampleRate ?: 48000;
634
+ streamConfig.channelCount = g_configuredChannelCount ?: 2;
635
+
636
+ // excludesCurrentProcessAudio = YES means ONLY microphone
637
+ // excludesCurrentProcessAudio = NO means system audio + mic
621
638
  streamConfig.excludesCurrentProcessAudio = !shouldCaptureSystemAudio;
639
+
640
+ MRLog(@"🎤 Audio config (macOS 13+): capturesAudio=%d, excludeProcess=%d (mic=%d sys=%d)",
641
+ g_shouldCaptureAudio, streamConfig.excludesCurrentProcessAudio,
642
+ shouldCaptureMic, shouldCaptureSystemAudio);
622
643
  }
623
-
644
+
624
645
  if (@available(macos 15.0, *)) {
646
+ // macOS 15+ has explicit microphone control
625
647
  streamConfig.captureMicrophone = shouldCaptureMic;
626
648
  if (microphoneDeviceId && microphoneDeviceId.length > 0) {
627
649
  streamConfig.microphoneCaptureDeviceID = microphoneDeviceId;
628
650
  }
651
+ MRLog(@"🎤 Microphone (macOS 15+): enabled=%d, deviceID=%@",
652
+ shouldCaptureMic, microphoneDeviceId ?: @"default");
629
653
  }
630
654
 
631
655
  // Apply crop area using sourceRect - CONVERT GLOBAL TO DISPLAY-RELATIVE COORDINATES
@@ -1,103 +0,0 @@
1
- const MacRecorder = require("../index");
2
- const path = require("path");
3
- const fs = require("fs");
4
-
5
- async function main() {
6
- const recorder = new MacRecorder();
7
-
8
- // Optional: list audio and camera devices for reference
9
- const audioDevices = await recorder.getAudioDevices();
10
- const cameraDevices = await recorder.getCameraDevices();
11
-
12
- console.log("Audio devices:");
13
- audioDevices.forEach((device, idx) => {
14
- console.log(`${idx + 1}. ${device.name} (id: ${device.id})`);
15
- });
16
-
17
- console.log("\nCamera devices:");
18
- cameraDevices.forEach((device, idx) => {
19
- console.log(`${idx + 1}. ${device.name} (id: ${device.id})`);
20
- });
21
-
22
- // Pick the first available devices (customize as needed)
23
- const preferredCamera = cameraDevices.find(device => !device.requiresContinuityCameraPermission);
24
- const selectedCameraId = preferredCamera ? preferredCamera.id : null;
25
- if (!selectedCameraId && cameraDevices.length > 0) {
26
- console.warn("Skipping camera capture: only Continuity Camera devices detected. Add NSCameraUseContinuityCameraDeviceType to Info.plist or set ALLOW_CONTINUITY_CAMERA=1.");
27
- }
28
-
29
- if (selectedCameraId) {
30
- console.log(`\nSelected camera: ${preferredCamera.name} (id: ${selectedCameraId})`);
31
- } else {
32
- console.log("\nSelected camera: none (camera capture disabled)");
33
- }
34
- const selectedMicId = audioDevices[0]?.id || null;
35
-
36
- if (selectedCameraId) {
37
- recorder.setCameraDevice(selectedCameraId);
38
- recorder.setCameraEnabled(true);
39
- }
40
-
41
- recorder.setAudioSettings({
42
- microphone: !!selectedMicId,
43
- systemAudio: true,
44
- });
45
-
46
- if (selectedMicId) {
47
- recorder.setAudioDevice(selectedMicId);
48
- }
49
-
50
- const outputDir = path.resolve(__dirname, "../tmp-tests");
51
- if (!fs.existsSync(outputDir)) {
52
- fs.mkdirSync(outputDir, { recursive: true });
53
- }
54
-
55
- const outputPath = path.join(outputDir, `test_capture_${Date.now()}.mov`);
56
- console.log("\nStarting recording to:", outputPath);
57
-
58
- recorder.on("recordingStarted", (payload) => {
59
- console.log("recordingStarted", payload);
60
- });
61
- recorder.on("cameraCaptureStarted", (payload) => {
62
- console.log("cameraCaptureStarted", payload);
63
- });
64
- recorder.on("audioCaptureStarted", (payload) => {
65
- console.log("audioCaptureStarted", payload);
66
- });
67
- recorder.on("cameraCaptureStopped", (payload) => {
68
- console.log("cameraCaptureStopped", payload);
69
- });
70
- recorder.on("audioCaptureStopped", (payload) => {
71
- console.log("audioCaptureStopped", payload);
72
- });
73
- recorder.on("stopped", (payload) => {
74
- console.log("stopped", payload);
75
- });
76
- recorder.on("completed", (filePath) => {
77
- console.log("completed", filePath);
78
- });
79
-
80
- await recorder.startRecording(outputPath, {
81
- includeMicrophone: !!selectedMicId,
82
- includeSystemAudio: true,
83
- captureCursor: true,
84
- captureCamera: !!selectedCameraId,
85
- });
86
-
87
- console.log("Recording for 10 seconds...");
88
- await new Promise((resolve) => setTimeout(resolve, 10_000));
89
-
90
- const result = await recorder.stopRecording();
91
- console.log("\nRecording finished:", result);
92
-
93
- console.log("\nArtifacts:");
94
- console.log("Video:", result.outputPath);
95
- console.log("Camera:", result.cameraOutputPath);
96
- console.log("Audio:", result.audioOutputPath);
97
- console.log("Session timestamp:", result.sessionTimestamp);
98
- }
99
-
100
- main().catch((error) => {
101
- console.error("Test capture failed:", error);
102
- process.exit(1);
103
- });
@@ -1,90 +0,0 @@
1
- #!/usr/bin/env node
2
- /**
3
- * Test script for Electron crash fix
4
- * Tests ScreenCaptureKit recording with synchronous semaphore-based approach
5
- */
6
-
7
- const MacRecorder = require('./index.js');
8
- const path = require('path');
9
- const fs = require('fs');
10
-
11
- async function testRecording() {
12
- console.log('🧪 Testing ScreenCaptureKit Electron crash fix...\n');
13
-
14
- const recorder = new MacRecorder();
15
-
16
- // Check permissions first
17
- console.log('1️⃣ Checking permissions...');
18
- const permissions = await recorder.checkPermissions();
19
- console.log(' Permissions:', permissions);
20
-
21
- if (!permissions.screenRecording) {
22
- console.error('❌ Screen recording permission not granted');
23
- console.log(' Please enable screen recording in System Settings > Privacy & Security');
24
- process.exit(1);
25
- }
26
-
27
- // Get displays
28
- console.log('\n2️⃣ Getting displays...');
29
- const displays = await recorder.getDisplays();
30
- console.log(` Found ${displays.length} display(s):`);
31
- displays.forEach(d => {
32
- console.log(` - Display ${d.id}: ${d.width}x${d.height} (Primary: ${d.isPrimary})`);
33
- });
34
-
35
- // Prepare output path
36
- const outputDir = path.join(__dirname, 'test-output');
37
- if (!fs.existsSync(outputDir)) {
38
- fs.mkdirSync(outputDir, { recursive: true });
39
- }
40
-
41
- const outputPath = path.join(outputDir, `electron-fix-test-${Date.now()}.mov`);
42
-
43
- try {
44
- // Start recording
45
- console.log('\n3️⃣ Starting recording...');
46
- console.log(` Output: ${outputPath}`);
47
-
48
- await recorder.startRecording(outputPath, {
49
- displayId: displays[0].id,
50
- captureCursor: true,
51
- includeMicrophone: false,
52
- includeSystemAudio: false
53
- });
54
-
55
- console.log('✅ Recording started successfully!');
56
- console.log(' Recording for 3 seconds...\n');
57
-
58
- // Record for 3 seconds
59
- await new Promise(resolve => setTimeout(resolve, 3000));
60
-
61
- // Stop recording
62
- console.log('4️⃣ Stopping recording...');
63
- const result = await recorder.stopRecording();
64
- console.log('✅ Recording stopped successfully!');
65
- console.log(' Result:', result);
66
-
67
- // Check output file
68
- if (fs.existsSync(outputPath)) {
69
- const stats = fs.statSync(outputPath);
70
- console.log(`\n✅ Output file created: ${outputPath}`);
71
- console.log(` File size: ${(stats.size / 1024).toFixed(2)} KB`);
72
- } else {
73
- console.log('\n⚠️ Output file not found (may still be finalizing)');
74
- }
75
-
76
- console.log('\n🎉 Test completed successfully! No crashes detected.');
77
- console.log(' The Electron crash fix appears to be working.\n');
78
-
79
- } catch (error) {
80
- console.error('\n❌ Test failed:', error.message);
81
- console.error(' Stack:', error.stack);
82
- process.exit(1);
83
- }
84
- }
85
-
86
- // Run test
87
- testRecording().catch(error => {
88
- console.error('Fatal error:', error);
89
- process.exit(1);
90
- });