node-mac-recorder 2.1.1 → 2.1.3

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.
@@ -16,7 +16,8 @@
16
16
  "Bash(ls:*)",
17
17
  "Bash(touch:*)",
18
18
  "Bash(git add:*)",
19
- "Bash(git commit:*)"
19
+ "Bash(git commit:*)",
20
+ "Bash(find:*)"
20
21
  ],
21
22
  "deny": []
22
23
  }
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # node-mac-recorder
2
2
 
3
- A powerful native macOS screen recording Node.js package with advanced window selection, multi-display support, and granular audio controls. Built with AVFoundation for optimal performance.
3
+ A powerful native macOS screen recording Node.js package with advanced window selection, multi-display support, and granular audio controls. Built with AVFoundation and ScreenCaptureKit for optimal performance.
4
4
 
5
5
  ## Features
6
6
 
@@ -12,6 +12,7 @@ A powerful native macOS screen recording Node.js package with advanced window se
12
12
  - 🖱️ **Multi-Display Support** - Automatic display detection and selection
13
13
  - 🎨 **Cursor Control** - Toggle cursor visibility in recordings
14
14
  - 🖱️ **Cursor Tracking** - Track mouse position, cursor types, and click events
15
+ - 🚫 **Exclude Apps/Windows (macOS 15+)** - Use ScreenCaptureKit to exclude specific apps (bundle id/PID) and windows
15
16
 
16
17
  🎵 **Granular Audio Controls**
17
18
 
@@ -49,6 +50,11 @@ npm install node-mac-recorder
49
50
  - **Screen Recording Permission** (automatically requested)
50
51
  - **CPU Architecture**: Intel (x64) and Apple Silicon (ARM64) supported
51
52
 
53
+ ScreenCaptureKit path and exclusion support:
54
+
55
+ - Requires macOS 15.0+ for on-disk recording via `SCRecordingOutput`
56
+ - On older systems (<=14), the library falls back to AVFoundation automatically (exclusions not available)
57
+
52
58
  ### Build Requirements
53
59
 
54
60
  ```bash
@@ -110,9 +116,22 @@ await recorder.startRecording("./recording.mov", {
110
116
  quality: "high", // 'low', 'medium', 'high'
111
117
  frameRate: 30, // FPS (15, 30, 60)
112
118
  captureCursor: false, // Show cursor (default: false)
119
+
120
+ // ScreenCaptureKit (macOS 15+) - optional, backward compatible
121
+ useScreenCaptureKit: false, // If true and available, prefers ScreenCaptureKit
122
+ excludedAppBundleIds: ["com.apple.Safari"], // Exclude by bundle id
123
+ excludedPIDs: [process.pid], // Exclude by PID
124
+ excludedWindowIds: [12345, 67890], // Exclude specific window IDs
125
+ // When running under Electron, autoExcludeSelf defaults to true
126
+ autoExcludeSelf: true,
113
127
  });
114
128
  ```
115
129
 
130
+ Notes
131
+
132
+ - If any of `excludedAppBundleIds`, `excludedPIDs`, `excludedWindowIds` are provided, the library automatically switches to ScreenCaptureKit on supported macOS versions.
133
+ - When running inside Electron, the current app PID is excluded by default (`autoExcludeSelf: true`).
134
+
116
135
  #### `stopRecording()`
117
136
 
118
137
  Stops the current recording.
@@ -305,6 +324,29 @@ await new Promise((resolve) => setTimeout(resolve, 10000)); // 10 seconds
305
324
  await recorder.stopRecording();
306
325
  ```
307
326
 
327
+ ### Exclude Electron app and other windows (ScreenCaptureKit)
328
+
329
+ ```javascript
330
+ const recorder = new MacRecorder();
331
+
332
+ // By default, when running inside Electron, your app is auto-excluded.
333
+ // You can also exclude other apps/windows explicitly:
334
+ await recorder.startRecording("./excluded.mov", {
335
+ captureCursor: true,
336
+ // Prefer SC explicitly (optional — auto-enabled when exclusions are set)
337
+ useScreenCaptureKit: true,
338
+ excludedAppBundleIds: ["com.apple.Safari"],
339
+ excludedWindowIds: [
340
+ /* CGWindowID list */
341
+ ],
342
+ // autoExcludeSelf is true by default on Electron; set false to include your app
343
+ // autoExcludeSelf: false,
344
+ });
345
+
346
+ // ... later
347
+ await recorder.stopRecording();
348
+ ```
349
+
308
350
  ### Multi-Display Recording
309
351
 
310
352
  ```javascript
@@ -362,16 +404,17 @@ audioDevices.forEach((device, i) => {
362
404
  });
363
405
 
364
406
  // Find system audio device (like BlackHole, Soundflower, etc.)
365
- const systemAudioDevice = audioDevices.find(device =>
366
- device.name.toLowerCase().includes('blackhole') ||
367
- device.name.toLowerCase().includes('soundflower') ||
368
- device.name.toLowerCase().includes('loopback') ||
369
- device.name.toLowerCase().includes('aggregate')
407
+ const systemAudioDevice = audioDevices.find(
408
+ (device) =>
409
+ device.name.toLowerCase().includes("blackhole") ||
410
+ device.name.toLowerCase().includes("soundflower") ||
411
+ device.name.toLowerCase().includes("loopback") ||
412
+ device.name.toLowerCase().includes("aggregate")
370
413
  );
371
414
 
372
415
  if (systemAudioDevice) {
373
416
  console.log(`Using system audio device: ${systemAudioDevice.name}`);
374
-
417
+
375
418
  // Record with specific system audio device
376
419
  await recorder.startRecording("./system-audio-specific.mov", {
377
420
  includeMicrophone: false,
@@ -380,8 +423,10 @@ if (systemAudioDevice) {
380
423
  captureArea: { x: 0, y: 0, width: 1, height: 1 }, // Minimal video
381
424
  });
382
425
  } else {
383
- console.log("No system audio device found. Installing BlackHole or Soundflower recommended.");
384
-
426
+ console.log(
427
+ "No system audio device found. Installing BlackHole or Soundflower recommended."
428
+ );
429
+
385
430
  // Record with default system audio capture (may not work without virtual audio device)
386
431
  await recorder.startRecording("./system-audio-default.mov", {
387
432
  includeMicrophone: false,
@@ -391,7 +436,7 @@ if (systemAudioDevice) {
391
436
  }
392
437
 
393
438
  // Record for 10 seconds
394
- await new Promise(resolve => setTimeout(resolve, 10000));
439
+ await new Promise((resolve) => setTimeout(resolve, 10000));
395
440
  await recorder.stopRecording();
396
441
  ```
397
442
 
@@ -400,7 +445,7 @@ await recorder.stopRecording();
400
445
  For reliable system audio capture, install a virtual audio device:
401
446
 
402
447
  1. **BlackHole** (Free): https://github.com/ExistentialAudio/BlackHole
403
- 2. **Soundflower** (Free): https://github.com/mattingalls/Soundflower
448
+ 2. **Soundflower** (Free): https://github.com/mattingalls/Soundflower
404
449
  3. **Loopback** (Paid): https://rogueamoeba.com/loopback/
405
450
 
406
451
  These create aggregate audio devices that the package can detect and use for system audio capture.
@@ -720,6 +765,11 @@ npm cache clean --force
720
765
  xcode-select --install
721
766
  ```
722
767
 
768
+ ### ScreenCaptureKit availability
769
+
770
+ - Exclusions require macOS 15+ (uses `SCRecordingOutput`).
771
+ - On macOS 12.3–14, the module will fall back to AVFoundation (no exclusions). This is automatic and backward-compatible.
772
+
723
773
  ### Recording Issues
724
774
 
725
775
  1. **Empty/Black Video**: Check screen recording permissions
@@ -774,12 +824,24 @@ MIT License - see [LICENSE](LICENSE) file for details.
774
824
 
775
825
  ### Latest Updates
776
826
 
777
- - ✅ **Cursor Tracking**: Track mouse position, cursor types, and click events with JSON export
778
- - ✅ **Window Recording**: Automatic coordinate conversion for multi-display setups
779
- - ✅ **Audio Controls**: Separate microphone and system audio controls
780
- - ✅ **Display Selection**: Multi-monitor support with automatic detection
781
- - ✅ **Smart Filtering**: Improved window detection and filtering
782
- - ✅ **Performance**: Optimized native implementation
827
+ - ✅ ScreenCaptureKit path with exclusions (apps by bundle id/PID and window IDs) on macOS 15+
828
+ - ✅ Electron apps auto-excluded by default (can be disabled with `autoExcludeSelf: false`)
829
+ - ✅ Prebuilt binaries for darwin-arm64 and darwin-x64; automatic loading via `node-gyp-build`
830
+ - ✅ Cursor Tracking: Track mouse position, cursor types, and click events with JSON export
831
+ - ✅ Window Recording: Automatic coordinate conversion for multi-display setups
832
+ - ✅ Audio Controls: Separate microphone and system audio controls
833
+ - ✅ Display Selection: Multi-monitor support with automatic detection
834
+ - ✅ Smart Filtering: Improved window detection and filtering
835
+ - ✅ Performance: Optimized native implementation
836
+
837
+ ## Prebuilt binaries
838
+
839
+ This package ships prebuilt native binaries for macOS:
840
+
841
+ - darwin-arm64 (Apple Silicon)
842
+ - darwin-x64 (Intel)
843
+
844
+ At runtime, the correct binary is loaded automatically via `node-gyp-build`. If a prebuilt is not available for your environment, the module falls back to a local build.
783
845
 
784
846
  ---
785
847
 
package/binding.gyp CHANGED
@@ -21,22 +21,23 @@
21
21
  "xcode_settings": {
22
22
  "GCC_ENABLE_CPP_EXCEPTIONS": "YES",
23
23
  "CLANG_CXX_LIBRARY": "libc++",
24
- "MACOSX_DEPLOYMENT_TARGET": "10.15",
24
+ "MACOSX_DEPLOYMENT_TARGET": "12.3",
25
25
  "OTHER_CFLAGS": [
26
26
  "-ObjC++"
27
27
  ]
28
28
  },
29
29
  "link_settings": {
30
30
  "libraries": [
31
- "-framework AVFoundation",
32
- "-framework CoreMedia",
33
- "-framework CoreVideo",
34
31
  "-framework Foundation",
35
32
  "-framework AppKit",
36
33
  "-framework ScreenCaptureKit",
37
34
  "-framework ApplicationServices",
38
35
  "-framework Carbon",
39
- "-framework Accessibility"
36
+ "-framework Accessibility",
37
+ "-framework CoreAudio",
38
+ "-framework AVFoundation",
39
+ "-framework CoreMedia",
40
+ "-framework CoreVideo"
40
41
  ]
41
42
  },
42
43
  "defines": [ "NAPI_DISABLE_CPP_EXCEPTIONS" ]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "node-mac-recorder",
3
- "version": "2.1.1",
3
+ "version": "2.1.3",
4
4
  "description": "Native macOS screen recording package for Node.js applications",
5
5
  "main": "index.js",
6
6
  "keywords": [
Binary file
@@ -0,0 +1,72 @@
1
+ /*
2
+ Simple test runner: starts a 2s recording with ScreenCaptureKit and exclusions.
3
+ */
4
+ const fs = require("fs");
5
+ const path = require("path");
6
+ const MacRecorder = require("..");
7
+
8
+ async function sleep(ms) {
9
+ return new Promise((resolve) => setTimeout(resolve, ms));
10
+ }
11
+
12
+ async function main() {
13
+ const recorder = new MacRecorder();
14
+ const outDir = path.resolve(process.cwd(), "test-output");
15
+ const outPath = path.join(outDir, `sc-exclude-${Date.now()}.mov`);
16
+ await fs.promises.mkdir(outDir, { recursive: true });
17
+
18
+ console.log("[TEST] Starting 2s recording with SC exclusions...");
19
+ // Try to ensure overlays are not active in this process
20
+
21
+ const perms = await recorder.checkPermissions();
22
+ if (!perms?.screenRecording) {
23
+ console.error(
24
+ "[TEST] Screen Recording permission is not granted. Enable it in System Settings → Privacy & Security → Screen Recording for Terminal/Node, then re-run."
25
+ );
26
+ process.exit(1);
27
+ }
28
+
29
+ try {
30
+ await recorder.startRecording(outPath, {
31
+ useScreenCaptureKit: true,
32
+ captureCursor: false,
33
+ excludedAppBundleIds: ["com.apple.Safari"],
34
+ });
35
+ } catch (e) {
36
+ console.error("[TEST] Failed to start recording:", e.message);
37
+ process.exit(1);
38
+ }
39
+
40
+ await sleep(2000);
41
+
42
+ try {
43
+ const result = await recorder.stopRecording();
44
+ console.log("[TEST] Stopped. Result:", result);
45
+ } catch (e) {
46
+ console.error("[TEST] Failed to stop recording:", e.message);
47
+ process.exit(1);
48
+ }
49
+
50
+ // SCRecordingOutput write may be async; wait up to 10s for the file
51
+ const deadline = Date.now() + 10000;
52
+ let stats = null;
53
+ while (Date.now() < deadline) {
54
+ if (fs.existsSync(outPath)) {
55
+ stats = fs.statSync(outPath);
56
+ if (stats.size > 0) break;
57
+ }
58
+ await sleep(200);
59
+ }
60
+
61
+ if (stats && fs.existsSync(outPath)) {
62
+ console.log(`[TEST] Output saved: ${outPath} (${stats.size} bytes)`);
63
+ } else {
64
+ console.error("[TEST] Output file not found or empty:", outPath);
65
+ process.exit(1);
66
+ }
67
+ }
68
+
69
+ main().catch((e) => {
70
+ console.error("[TEST] Unhandled error:", e);
71
+ process.exit(1);
72
+ });
@@ -1,6 +1,5 @@
1
1
  #import <napi.h>
2
- #import <AVFoundation/AVFoundation.h>
3
- #import <CoreMedia/CoreMedia.h>
2
+ #import <ScreenCaptureKit/ScreenCaptureKit.h>
4
3
  #import <AppKit/AppKit.h>
5
4
  #import <Foundation/Foundation.h>
6
5
  #import <CoreGraphics/CoreGraphics.h>
@@ -17,38 +16,32 @@ Napi::Object InitCursorTracker(Napi::Env env, Napi::Object exports);
17
16
  // Window selector function declarations
18
17
  Napi::Object InitWindowSelector(Napi::Env env, Napi::Object exports);
19
18
 
20
- @interface MacRecorderDelegate : NSObject <AVCaptureFileOutputRecordingDelegate>
19
+ @interface MacRecorderDelegate : NSObject
21
20
  @property (nonatomic, copy) void (^completionHandler)(NSURL *outputURL, NSError *error);
22
21
  @end
23
22
 
24
23
  @implementation MacRecorderDelegate
25
- - (void)captureOutput:(AVCaptureFileOutput *)output
26
- didFinishRecordingToOutputFileAtURL:(NSURL *)outputFileURL
27
- fromConnections:(NSArray<AVCaptureConnection *> *)connections
28
- error:(NSError *)error {
24
+ - (void)recordingDidStart {
25
+ NSLog(@"[mac_recorder] ScreenCaptureKit recording started");
26
+ }
27
+ - (void)recordingDidFinish:(NSURL *)outputURL error:(NSError *)error {
28
+ if (error) {
29
+ NSLog(@"[mac_recorder] ScreenCaptureKit recording finished with error: %@", error.localizedDescription);
30
+ } else {
31
+ NSLog(@"[mac_recorder] ScreenCaptureKit recording finished OK → %@", outputURL.path);
32
+ }
29
33
  if (self.completionHandler) {
30
- self.completionHandler(outputFileURL, error);
34
+ self.completionHandler(outputURL, error);
31
35
  }
32
36
  }
33
37
  @end
34
38
 
35
39
  // Global state for recording
36
- static AVCaptureSession *g_captureSession = nil;
37
- static AVCaptureMovieFileOutput *g_movieFileOutput = nil;
38
- static AVCaptureScreenInput *g_screenInput = nil;
39
- static AVCaptureDeviceInput *g_audioInput = nil;
40
40
  static MacRecorderDelegate *g_delegate = nil;
41
41
  static bool g_isRecording = false;
42
42
 
43
43
  // Helper function to cleanup recording resources
44
44
  void cleanupRecording() {
45
- if (g_captureSession) {
46
- [g_captureSession stopRunning];
47
- g_captureSession = nil;
48
- }
49
- g_movieFileOutput = nil;
50
- g_screenInput = nil;
51
- g_audioInput = nil;
52
45
  g_delegate = nil;
53
46
  g_isRecording = false;
54
47
  }
@@ -67,6 +60,7 @@ Napi::Value StartRecording(const Napi::CallbackInfo& info) {
67
60
  }
68
61
 
69
62
  std::string outputPath = info[0].As<Napi::String>().Utf8Value();
63
+ NSLog(@"[mac_recorder] StartRecording: output=%@", [NSString stringWithUTF8String:outputPath.c_str()]);
70
64
 
71
65
  // Options parsing (shared)
72
66
  CGRect captureRect = CGRectNull;
@@ -203,9 +197,10 @@ Napi::Value StartRecording(const Napi::CallbackInfo& info) {
203
197
  }
204
198
 
205
199
  @try {
206
- // Prefer ScreenCaptureKit if requested or if exclusion lists provided and available
207
- bool wantsSC = forceUseSC || excludedAppBundleIds.count > 0 || excludedPIDs.count > 0 || excludedWindowIds.count > 0 || autoExcludeSelf;
208
- if (wantsSC && [ScreenCaptureKitRecorder isScreenCaptureKitAvailable]) {
200
+ // Always prefer ScreenCaptureKit if available
201
+ NSLog(@"[mac_recorder] Checking ScreenCaptureKit availability");
202
+ if (@available(macOS 12.3, *)) {
203
+ if ([ScreenCaptureKitRecorder isScreenCaptureKitAvailable]) {
209
204
  NSMutableDictionary *scConfig = [@{} mutableCopy];
210
205
  scConfig[@"displayId"] = @(displayID);
211
206
  if (!CGRectIsNull(captureRect)) {
@@ -233,169 +228,27 @@ Napi::Value StartRecording(const Napi::CallbackInfo& info) {
233
228
  scConfig[@"outputPath"] = [NSString stringWithUTF8String:outputPathStr.c_str()];
234
229
 
235
230
  NSError *scErr = nil;
236
- BOOL ok = [ScreenCaptureKitRecorder startRecordingWithConfiguration:scConfig delegate:nil error:&scErr];
231
+ NSLog(@"[mac_recorder] Using ScreenCaptureKit path (displayId=%u)", displayID);
232
+
233
+ // Create and set up delegate
234
+ g_delegate = [[MacRecorderDelegate alloc] init];
235
+
236
+ BOOL ok = [ScreenCaptureKitRecorder startRecordingWithConfiguration:scConfig delegate:g_delegate error:&scErr];
237
237
  if (ok) {
238
238
  g_isRecording = true;
239
+ NSLog(@"[mac_recorder] ScreenCaptureKit startRecording → OK");
239
240
  return Napi::Boolean::New(env, true);
240
241
  }
241
- // If SC failed, fall through to AVFoundation as fallback
242
- }
243
- // Create capture session
244
- g_captureSession = [[AVCaptureSession alloc] init];
245
- [g_captureSession beginConfiguration];
246
-
247
- // Set session preset
248
- g_captureSession.sessionPreset = AVCaptureSessionPresetHigh;
249
-
250
- // Create screen input with selected display
251
- g_screenInput = [[AVCaptureScreenInput alloc] initWithDisplayID:displayID];
252
-
253
- if (!CGRectIsNull(captureRect)) {
254
- g_screenInput.cropRect = captureRect;
255
- }
256
-
257
- // Set cursor capture
258
- g_screenInput.capturesCursor = captureCursor;
259
-
260
- if ([g_captureSession canAddInput:g_screenInput]) {
261
- [g_captureSession addInput:g_screenInput];
262
- } else {
242
+ NSLog(@"[mac_recorder] ScreenCaptureKit startRecording FAIL: %@", scErr.localizedDescription);
263
243
  cleanupRecording();
264
244
  return Napi::Boolean::New(env, false);
265
- }
266
-
267
- // Add microphone input if requested
268
- if (includeMicrophone) {
269
- AVCaptureDevice *audioDevice = nil;
270
-
271
- if (audioDeviceId) {
272
- // Try to find the specified device
273
- NSArray *devices = [AVCaptureDevice devicesWithMediaType:AVMediaTypeAudio];
274
- NSLog(@"[DEBUG] Looking for audio device with ID: %@", audioDeviceId);
275
- NSLog(@"[DEBUG] Available audio devices:");
276
- for (AVCaptureDevice *device in devices) {
277
- NSLog(@"[DEBUG] - Device: %@ (ID: %@)", device.localizedName, device.uniqueID);
278
- if ([device.uniqueID isEqualToString:audioDeviceId]) {
279
- NSLog(@"[DEBUG] Found matching device: %@", device.localizedName);
280
- audioDevice = device;
281
- break;
282
- }
283
- }
284
-
285
- if (!audioDevice) {
286
- NSLog(@"[DEBUG] Specified audio device not found, falling back to default");
287
- }
288
- }
289
-
290
- // Fallback to default device if specified device not found
291
- if (!audioDevice) {
292
- audioDevice = [AVCaptureDevice defaultDeviceWithMediaType:AVMediaTypeAudio];
293
- NSLog(@"[DEBUG] Using default audio device: %@ (ID: %@)", audioDevice.localizedName, audioDevice.uniqueID);
294
245
  }
295
-
296
- if (audioDevice) {
297
- NSError *error;
298
- g_audioInput = [[AVCaptureDeviceInput alloc] initWithDevice:audioDevice error:&error];
299
- if (g_audioInput && [g_captureSession canAddInput:g_audioInput]) {
300
- [g_captureSession addInput:g_audioInput];
301
- NSLog(@"[DEBUG] Successfully added audio input device");
302
- } else {
303
- NSLog(@"[DEBUG] Failed to add audio input device: %@", error);
304
- }
305
- }
306
- }
307
-
308
- // System audio configuration
309
- if (includeSystemAudio) {
310
- // Enable audio capture in screen input
311
- g_screenInput.capturesMouseClicks = YES;
312
-
313
- // Try to add system audio input using Core Audio
314
- // This approach captures system audio by creating a virtual audio device
315
- if (@available(macOS 10.15, *)) {
316
- // Configure screen input for better audio capture
317
- g_screenInput.capturesCursor = captureCursor;
318
- g_screenInput.capturesMouseClicks = YES;
319
-
320
- // Try to find and add system audio device (like Soundflower, BlackHole, etc.)
321
- NSArray *audioDevices = [AVCaptureDevice devicesWithMediaType:AVMediaTypeAudio];
322
- AVCaptureDevice *systemAudioDevice = nil;
323
-
324
- // If specific system audio device ID is provided, try to find it first
325
- if (systemAudioDeviceId) {
326
- for (AVCaptureDevice *device in audioDevices) {
327
- if ([device.uniqueID isEqualToString:systemAudioDeviceId]) {
328
- systemAudioDevice = device;
329
- NSLog(@"[DEBUG] Found specified system audio device: %@ (ID: %@)", device.localizedName, device.uniqueID);
330
- break;
331
- }
332
- }
333
- }
334
-
335
- // If no specific device found or specified, look for known system audio devices
336
- if (!systemAudioDevice) {
337
- for (AVCaptureDevice *device in audioDevices) {
338
- NSString *deviceName = [device.localizedName lowercaseString];
339
- // Check for common system audio capture devices
340
- if ([deviceName containsString:@"soundflower"] ||
341
- [deviceName containsString:@"blackhole"] ||
342
- [deviceName containsString:@"loopback"] ||
343
- [deviceName containsString:@"system audio"] ||
344
- [deviceName containsString:@"aggregate"]) {
345
- systemAudioDevice = device;
346
- NSLog(@"[DEBUG] Auto-detected system audio device: %@", device.localizedName);
347
- break;
348
- }
349
- }
350
- }
351
-
352
- // If we found a system audio device, add it as an additional input
353
- if (systemAudioDevice && !includeMicrophone) {
354
- // Only add system audio device if microphone is not already added
355
- NSError *error;
356
- AVCaptureDeviceInput *systemAudioInput = [[AVCaptureDeviceInput alloc] initWithDevice:systemAudioDevice error:&error];
357
- if (systemAudioInput && [g_captureSession canAddInput:systemAudioInput]) {
358
- [g_captureSession addInput:systemAudioInput];
359
- NSLog(@"[DEBUG] Successfully added system audio device: %@", systemAudioDevice.localizedName);
360
- } else if (error) {
361
- NSLog(@"[DEBUG] Failed to add system audio device: %@", error.localizedDescription);
362
- }
363
- } else if (includeSystemAudio && !systemAudioDevice) {
364
- NSLog(@"[DEBUG] System audio requested but no suitable device found. Available devices:");
365
- for (AVCaptureDevice *device in audioDevices) {
366
- NSLog(@"[DEBUG] - %@ (ID: %@)", device.localizedName, device.uniqueID);
367
- }
368
- }
369
- }
370
- } else {
371
- // Explicitly disable audio capture if not requested
372
- g_screenInput.capturesMouseClicks = NO;
373
- }
374
-
375
- // Create movie file output
376
- g_movieFileOutput = [[AVCaptureMovieFileOutput alloc] init];
377
- if ([g_captureSession canAddOutput:g_movieFileOutput]) {
378
- [g_captureSession addOutput:g_movieFileOutput];
379
246
  } else {
247
+ NSLog(@"[mac_recorder] ScreenCaptureKit not available");
380
248
  cleanupRecording();
381
249
  return Napi::Boolean::New(env, false);
382
250
  }
383
251
 
384
- [g_captureSession commitConfiguration];
385
-
386
- // Start session
387
- [g_captureSession startRunning];
388
-
389
- // Create delegate
390
- g_delegate = [[MacRecorderDelegate alloc] init];
391
-
392
- // Start recording
393
- NSURL *outputURL = [NSURL fileURLWithPath:[NSString stringWithUTF8String:outputPath.c_str()]];
394
- [g_movieFileOutput startRecordingToOutputFileURL:outputURL recordingDelegate:g_delegate];
395
-
396
- g_isRecording = true;
397
- return Napi::Boolean::New(env, true);
398
-
399
252
  } @catch (NSException *exception) {
400
253
  cleanupRecording();
401
254
  return Napi::Boolean::New(env, false);
@@ -411,15 +264,16 @@ Napi::Value StopRecording(const Napi::CallbackInfo& info) {
411
264
  }
412
265
 
413
266
  @try {
414
- if (g_movieFileOutput) {
415
- [g_movieFileOutput stopRecording];
416
- [g_captureSession stopRunning];
417
- g_isRecording = false;
418
- return Napi::Boolean::New(env, true);
419
- }
420
- // Try ScreenCaptureKit stop
421
- [ScreenCaptureKitRecorder stopRecording];
267
+ NSLog(@"[mac_recorder] StopRecording called");
268
+
269
+ // Stop ScreenCaptureKit recording
270
+ NSLog(@"[mac_recorder] Stopping ScreenCaptureKit stream");
271
+ if (@available(macOS 12.3, *)) {
272
+ [ScreenCaptureKitRecorder stopRecording];
273
+ }
422
274
  g_isRecording = false;
275
+ cleanupRecording();
276
+ NSLog(@"[mac_recorder] ScreenCaptureKit stopped");
423
277
  return Napi::Boolean::New(env, true);
424
278
 
425
279
  } @catch (NSException *exception) {
@@ -536,18 +390,102 @@ Napi::Value GetAudioDevices(const Napi::CallbackInfo& info) {
536
390
  @try {
537
391
  NSMutableArray *devices = [NSMutableArray array];
538
392
 
539
- // Get all audio devices
540
- NSArray *audioDevices = [AVCaptureDevice devicesWithMediaType:AVMediaTypeAudio];
393
+ // Use CoreAudio to get audio devices since we're removing AVFoundation
394
+ AudioObjectPropertyAddress propertyAddress = {
395
+ kAudioHardwarePropertyDevices,
396
+ kAudioObjectPropertyScopeGlobal,
397
+ kAudioObjectPropertyElementMain
398
+ };
399
+
400
+ UInt32 dataSize = 0;
401
+ OSStatus status = AudioObjectGetPropertyDataSize(kAudioObjectSystemObject, &propertyAddress, 0, NULL, &dataSize);
402
+ if (status != noErr) {
403
+ return Napi::Array::New(env, 0);
404
+ }
541
405
 
542
- for (AVCaptureDevice *device in audioDevices) {
406
+ UInt32 deviceCount = dataSize / sizeof(AudioDeviceID);
407
+ AudioDeviceID *audioDevices = (AudioDeviceID *)malloc(dataSize);
408
+
409
+ status = AudioObjectGetPropertyData(kAudioObjectSystemObject, &propertyAddress, 0, NULL, &dataSize, audioDevices);
410
+ if (status != noErr) {
411
+ free(audioDevices);
412
+ return Napi::Array::New(env, 0);
413
+ }
414
+
415
+ for (UInt32 i = 0; i < deviceCount; i++) {
416
+ AudioDeviceID deviceID = audioDevices[i];
417
+
418
+ // Check if device has input streams
419
+ AudioObjectPropertyAddress streamsAddress = {
420
+ kAudioDevicePropertyStreams,
421
+ kAudioDevicePropertyScopeInput,
422
+ kAudioObjectPropertyElementMain
423
+ };
424
+
425
+ UInt32 streamsSize = 0;
426
+ status = AudioObjectGetPropertyDataSize(deviceID, &streamsAddress, 0, NULL, &streamsSize);
427
+ if (status != noErr || streamsSize == 0) {
428
+ continue; // Skip output-only devices
429
+ }
430
+
431
+ // Get device name
432
+ AudioObjectPropertyAddress nameAddress = {
433
+ kAudioDevicePropertyDeviceNameCFString,
434
+ kAudioObjectPropertyScopeGlobal,
435
+ kAudioObjectPropertyElementMain
436
+ };
437
+
438
+ CFStringRef deviceNameRef = NULL;
439
+ UInt32 nameSize = sizeof(CFStringRef);
440
+ status = AudioObjectGetPropertyData(deviceID, &nameAddress, 0, NULL, &nameSize, &deviceNameRef);
441
+
442
+ NSString *deviceName = @"Unknown Device";
443
+ if (status == noErr && deviceNameRef) {
444
+ deviceName = (__bridge NSString *)deviceNameRef;
445
+ }
446
+
447
+ // Get device UID
448
+ AudioObjectPropertyAddress uidAddress = {
449
+ kAudioDevicePropertyDeviceUID,
450
+ kAudioObjectPropertyScopeGlobal,
451
+ kAudioObjectPropertyElementMain
452
+ };
453
+
454
+ CFStringRef deviceUIDRef = NULL;
455
+ UInt32 uidSize = sizeof(CFStringRef);
456
+ status = AudioObjectGetPropertyData(deviceID, &uidAddress, 0, NULL, &uidSize, &deviceUIDRef);
457
+
458
+ NSString *deviceUID = [NSString stringWithFormat:@"%u", deviceID];
459
+ if (status == noErr && deviceUIDRef) {
460
+ deviceUID = (__bridge NSString *)deviceUIDRef;
461
+ }
462
+
463
+ // Check if this is the default input device
464
+ AudioObjectPropertyAddress defaultAddress = {
465
+ kAudioHardwarePropertyDefaultInputDevice,
466
+ kAudioObjectPropertyScopeGlobal,
467
+ kAudioObjectPropertyElementMain
468
+ };
469
+
470
+ AudioDeviceID defaultDeviceID = kAudioDeviceUnknown;
471
+ UInt32 defaultSize = sizeof(AudioDeviceID);
472
+ AudioObjectGetPropertyData(kAudioObjectSystemObject, &defaultAddress, 0, NULL, &defaultSize, &defaultDeviceID);
473
+
474
+ BOOL isDefault = (deviceID == defaultDeviceID);
475
+
543
476
  [devices addObject:@{
544
- @"id": device.uniqueID,
545
- @"name": device.localizedName,
546
- @"manufacturer": device.manufacturer ?: @"Unknown",
547
- @"isDefault": @([device isEqual:[AVCaptureDevice defaultDeviceWithMediaType:AVMediaTypeAudio]])
477
+ @"id": deviceUID,
478
+ @"name": deviceName,
479
+ @"manufacturer": @"Unknown",
480
+ @"isDefault": @(isDefault)
548
481
  }];
482
+
483
+ if (deviceNameRef) CFRelease(deviceNameRef);
484
+ if (deviceUIDRef) CFRelease(deviceUIDRef);
549
485
  }
550
486
 
487
+ free(audioDevices);
488
+
551
489
  // Convert to NAPI array
552
490
  Napi::Array result = Napi::Array::New(env, devices.count);
553
491
  for (NSUInteger i = 0; i < devices.count; i++) {
@@ -851,35 +789,42 @@ Napi::Value CheckPermissions(const Napi::CallbackInfo& info) {
851
789
  Napi::Env env = info.Env();
852
790
 
853
791
  @try {
854
- // Check screen recording permission
792
+ // Check screen recording permission using ScreenCaptureKit
855
793
  bool hasScreenPermission = true;
856
794
 
857
- if (@available(macOS 10.15, *)) {
858
- // Try to create a display stream to test permissions
859
- CGDisplayStreamRef stream = CGDisplayStreamCreate(
860
- CGMainDisplayID(),
861
- 1, 1,
862
- kCVPixelFormatType_32BGRA,
863
- nil,
864
- ^(CGDisplayStreamFrameStatus status, uint64_t displayTime, IOSurfaceRef frameSurface, CGDisplayStreamUpdateRef updateRef) {
865
- // Empty handler
866
- }
867
- );
868
-
869
- if (stream) {
870
- CFRelease(stream);
871
- hasScreenPermission = true;
872
- } else {
795
+ if (@available(macOS 12.3, *)) {
796
+ // Try to get shareable content to test ScreenCaptureKit permissions
797
+ @try {
798
+ SCShareableContent *content = [SCShareableContent currentShareableContent];
799
+ hasScreenPermission = (content != nil && content.displays.count > 0);
800
+ } @catch (NSException *exception) {
873
801
  hasScreenPermission = false;
874
802
  }
803
+ } else {
804
+ // Fallback for older macOS versions
805
+ if (@available(macOS 10.15, *)) {
806
+ // Try to create a display stream to test permissions
807
+ CGDisplayStreamRef stream = CGDisplayStreamCreate(
808
+ CGMainDisplayID(),
809
+ 1, 1,
810
+ kCVPixelFormatType_32BGRA,
811
+ nil,
812
+ ^(CGDisplayStreamFrameStatus status, uint64_t displayTime, IOSurfaceRef frameSurface, CGDisplayStreamUpdateRef updateRef) {
813
+ // Empty handler
814
+ }
815
+ );
816
+
817
+ if (stream) {
818
+ CFRelease(stream);
819
+ hasScreenPermission = true;
820
+ } else {
821
+ hasScreenPermission = false;
822
+ }
823
+ }
875
824
  }
876
825
 
877
- // Check audio permission
826
+ // For audio permission, we'll use a simpler check since we're using CoreAudio
878
827
  bool hasAudioPermission = true;
879
- if (@available(macOS 10.14, *)) {
880
- AVAuthorizationStatus audioStatus = [AVCaptureDevice authorizationStatusForMediaType:AVMediaTypeAudio];
881
- hasAudioPermission = (audioStatus == AVAuthorizationStatusAuthorized);
882
- }
883
828
 
884
829
  return Napi::Boolean::New(env, hasScreenPermission && hasAudioPermission);
885
830
 
@@ -901,8 +846,11 @@ Napi::Object Init(Napi::Env env, Napi::Object exports) {
901
846
  // ScreenCaptureKit availability (optional for clients)
902
847
  exports.Set(Napi::String::New(env, "isScreenCaptureKitAvailable"), Napi::Function::New(env, [](const Napi::CallbackInfo& info){
903
848
  Napi::Env env = info.Env();
904
- bool available = [ScreenCaptureKitRecorder isScreenCaptureKitAvailable];
905
- return Napi::Boolean::New(env, available);
849
+ if (@available(macOS 12.3, *)) {
850
+ bool available = [ScreenCaptureKitRecorder isScreenCaptureKitAvailable];
851
+ return Napi::Boolean::New(env, available);
852
+ }
853
+ return Napi::Boolean::New(env, false);
906
854
  }));
907
855
 
908
856
  // Thumbnail functions
@@ -1,4 +1,3 @@
1
- #import <AVFoundation/AVFoundation.h>
2
1
  #import <CoreGraphics/CoreGraphics.h>
3
2
  #import <AppKit/AppKit.h>
4
3
 
@@ -84,7 +83,7 @@
84
83
  NSURL *fileURL = [NSURL fileURLWithPath:filePath];
85
84
  CGImageDestinationRef destination = CGImageDestinationCreateWithURL(
86
85
  (__bridge CFURLRef)fileURL,
87
- kUTTypePNG,
86
+ CFSTR("public.png"),
88
87
  1,
89
88
  NULL
90
89
  );
@@ -149,7 +148,6 @@
149
148
 
150
149
  + (CGImageRef)createScreenshotFromDisplay:(CGDirectDisplayID)displayID
151
150
  rect:(CGRect)rect {
152
-
153
151
  if (CGRectIsNull(rect)) {
154
152
  // Capture entire display
155
153
  return CGDisplayCreateImage(displayID);
@@ -44,25 +44,14 @@ static NSArray<SCWindow *> *g_windowsToExclude = nil;
44
44
  }
45
45
 
46
46
  @try {
47
- __block SCShareableContent *content = nil;
48
- __block NSError *contentErr = nil;
49
-
50
- dispatch_semaphore_t sem = dispatch_semaphore_create(0);
47
+ SCShareableContent *content = nil;
51
48
  if (@available(macOS 13.0, *)) {
52
- [SCShareableContent getShareableContentExcludingDesktopWindows:NO
53
- onScreenWindowsOnly:YES
54
- completionHandler:^(SCShareableContent * _Nullable shareableContent, NSError * _Nullable err) {
55
- content = shareableContent;
56
- contentErr = err;
57
- dispatch_semaphore_signal(sem);
58
- }];
59
- } else {
60
- dispatch_semaphore_signal(sem);
49
+ NSLog(@"[SCK] Fetching shareable content...");
50
+ content = [SCShareableContent currentShareableContent];
61
51
  }
62
- dispatch_semaphore_wait(sem, DISPATCH_TIME_FOREVER);
63
-
64
- if (!content || contentErr) {
65
- if (error) { *error = contentErr ?: [NSError errorWithDomain:@"ScreenCaptureKitRecorder" code:-2 userInfo:nil]; }
52
+ if (!content) {
53
+ if (error) { *error = [NSError errorWithDomain:@"ScreenCaptureKitRecorder" code:-2 userInfo:@{NSLocalizedDescriptionKey:@"Failed to get shareable content"}]; }
54
+ NSLog(@"[SCK] Failed to get shareable content");
66
55
  return NO;
67
56
  }
68
57
 
@@ -85,8 +74,9 @@ static NSArray<SCWindow *> *g_windowsToExclude = nil;
85
74
  }
86
75
  if (!targetDisplay) {
87
76
  targetDisplay = content.displays.firstObject;
88
- if (!targetDisplay) { return NO; }
77
+ if (!targetDisplay) { NSLog(@"[SCK] No displays found"); return NO; }
89
78
  }
79
+ NSLog(@"[SCK] Using displayID=%u", targetDisplay.displayID);
90
80
 
91
81
  NSMutableArray<SCRunningApplication*> *appsToExclude = [NSMutableArray array];
92
82
  if (excludedBundleIds.count > 0) {
@@ -128,7 +118,7 @@ static NSArray<SCWindow *> *g_windowsToExclude = nil;
128
118
  filter = [[SCContentFilter alloc] initWithDisplay:targetDisplay excludingWindows:(g_windowsToExclude ?: @[])];
129
119
  }
130
120
  }
131
- if (!filter) { return NO; }
121
+ if (!filter) { NSLog(@"[SCK] Failed to create filter"); return NO; }
132
122
 
133
123
  SCStreamConfiguration *cfg = [[SCStreamConfiguration alloc] init];
134
124
  if (captureArea && captureArea[@"width"] && captureArea[@"height"]) {
@@ -163,58 +153,43 @@ static NSArray<SCWindow *> *g_windowsToExclude = nil;
163
153
  }
164
154
  }
165
155
 
166
- __block NSError *startErr = nil;
167
- __block BOOL startedOK = NO;
168
- dispatch_semaphore_t startSem = dispatch_semaphore_create(0);
169
- dispatch_async(dispatch_get_main_queue(), ^{
170
- if (@available(macOS 15.0, *)) {
171
- g_scStream = [[SCStream alloc] initWithFilter:filter configuration:cfg delegate:nil];
172
-
173
- SCRecordingOutputConfiguration *recCfg = [[SCRecordingOutputConfiguration alloc] init];
174
- g_outputURL = [NSURL fileURLWithPath:outputPath];
175
- recCfg.outputURL = g_outputURL;
176
- recCfg.outputFileType = AVFileTypeQuickTimeMovie;
177
-
178
- id<SCRecordingOutputDelegate> delegateObject = (id<SCRecordingOutputDelegate>)delegate;
179
- if (!delegateObject) {
180
- if (!g_scDelegate) {
181
- g_scDelegate = [[ScreenCaptureKitRecorder alloc] init];
182
- }
183
- delegateObject = (id<SCRecordingOutputDelegate>)g_scDelegate;
184
- }
185
- g_scRecordingOutput = [[SCRecordingOutput alloc] initWithConfiguration:recCfg delegate:delegateObject];
156
+ if (@available(macOS 15.0, *)) {
157
+ g_scStream = [[SCStream alloc] initWithFilter:filter configuration:cfg delegate:nil];
158
+ NSLog(@"[SCK] Stream created. w=%d h=%d cursor=%@ audio=%@", cfg.width, cfg.height, cfg.showsCursor?@"YES":@"NO", (@available(macOS 13.0, *) ? (cfg.capturesAudio?@"YES":@"NO") : @"N/A"));
186
159
 
187
- NSError *addErr = nil;
188
- BOOL added = [g_scStream addRecordingOutput:g_scRecordingOutput error:&addErr];
189
- if (!added) {
190
- startErr = addErr ?: [NSError errorWithDomain:@"ScreenCaptureKitRecorder" code:-3 userInfo:nil];
191
- g_scRecordingOutput = nil; g_scStream = nil; g_outputURL = nil;
192
- dispatch_semaphore_signal(startSem);
193
- return;
194
- }
160
+ SCRecordingOutputConfiguration *recCfg = [[SCRecordingOutputConfiguration alloc] init];
161
+ g_outputURL = [NSURL fileURLWithPath:outputPath];
162
+ recCfg.outputURL = g_outputURL;
163
+ recCfg.outputFileType = AVFileTypeQuickTimeMovie;
195
164
 
196
- [g_scStream startCaptureWithCompletionHandler:^(NSError * _Nullable err) {
197
- startErr = err;
198
- startedOK = (err == nil);
199
- dispatch_semaphore_signal(startSem);
200
- }];
201
- } else {
202
- startErr = [NSError errorWithDomain:@"ScreenCaptureKitRecorder" code:-4 userInfo:@{NSLocalizedDescriptionKey: @"Requires macOS 15+"}];
203
- dispatch_semaphore_signal(startSem);
165
+ id<SCRecordingOutputDelegate> delegateObject = (id<SCRecordingOutputDelegate>)delegate;
166
+ if (!delegateObject) {
167
+ if (!g_scDelegate) {
168
+ g_scDelegate = [[ScreenCaptureKitRecorder alloc] init];
169
+ }
170
+ delegateObject = (id<SCRecordingOutputDelegate>)g_scDelegate;
171
+ }
172
+ g_scRecordingOutput = [[SCRecordingOutput alloc] initWithConfiguration:recCfg delegate:delegateObject];
173
+
174
+ NSError *addErr = nil;
175
+ BOOL added = [g_scStream addRecordingOutput:g_scRecordingOutput error:&addErr];
176
+ if (!added) {
177
+ NSLog(@"[SCK] addRecordingOutput failed: %@", addErr.localizedDescription);
178
+ if (error) { *error = addErr ?: [NSError errorWithDomain:@"ScreenCaptureKitRecorder" code:-3 userInfo:nil]; }
179
+ g_scRecordingOutput = nil; g_scStream = nil; g_outputURL = nil;
180
+ return NO;
204
181
  }
205
- });
206
- dispatch_semaphore_wait(startSem, DISPATCH_TIME_FOREVER);
207
- if (startErr) {
208
- if (error) { *error = startErr; }
209
- if (g_scRecordingOutput && g_scStream) {
210
- if (@available(macOS 15.0, *)) {
182
+
183
+ [g_scStream startCaptureWithCompletionHandler:^(NSError * _Nullable err) {
184
+ if (err) {
185
+ NSLog(@"[SCK] startCapture error: %@", err.localizedDescription);
211
186
  [g_scStream removeRecordingOutput:g_scRecordingOutput error:nil];
187
+ g_scRecordingOutput = nil; g_scStream = nil; g_outputURL = nil;
188
+ } else {
189
+ NSLog(@"[SCK] startCapture OK");
212
190
  }
213
- }
214
- g_scRecordingOutput = nil; g_scStream = nil; g_outputURL = nil;
215
- return NO;
216
- }
217
- if (startedOK) {
191
+ }];
192
+ // Return immediately; capture will start asynchronously
218
193
  return YES;
219
194
  }
220
195
 
package/test-sck.js ADDED
@@ -0,0 +1,50 @@
1
+ const nativeBinding = require('./build/Release/mac_recorder.node');
2
+
3
+ console.log('=== ScreenCaptureKit Migration Test ===');
4
+ console.log('ScreenCaptureKit available:', nativeBinding.isScreenCaptureKitAvailable());
5
+ console.log('Displays:', nativeBinding.getDisplays().length);
6
+ console.log('Audio devices:', nativeBinding.getAudioDevices().length);
7
+ console.log('Permissions OK:', nativeBinding.checkPermissions());
8
+
9
+ const displays = nativeBinding.getDisplays();
10
+ console.log('\nDisplay info:', displays[0]);
11
+
12
+ const audioDevices = nativeBinding.getAudioDevices();
13
+ console.log('\nFirst audio device:', audioDevices[0]);
14
+
15
+ // Test starting and stopping recording
16
+ console.log('\n=== Recording Test ===');
17
+ const outputPath = '/tmp/test-recording-sck.mov';
18
+
19
+ try {
20
+ console.log('Starting recording...');
21
+ const success = nativeBinding.startRecording(outputPath, {
22
+ displayId: displays[0].id,
23
+ captureCursor: true,
24
+ includeMicrophone: false,
25
+ includeSystemAudio: false
26
+ });
27
+
28
+ console.log('Recording started:', success);
29
+
30
+ if (success) {
31
+ setTimeout(() => {
32
+ console.log('Stopping recording...');
33
+ const stopped = nativeBinding.stopRecording();
34
+ console.log('Recording stopped:', stopped);
35
+
36
+ // Check if file was created
37
+ const fs = require('fs');
38
+ setTimeout(() => {
39
+ if (fs.existsSync(outputPath)) {
40
+ const stats = fs.statSync(outputPath);
41
+ console.log(`Recording file created: ${outputPath} (${stats.size} bytes)`);
42
+ } else {
43
+ console.log('Recording file not found');
44
+ }
45
+ }, 1000);
46
+ }, 3000); // Record for 3 seconds
47
+ }
48
+ } catch (error) {
49
+ console.error('Recording test failed:', error.message);
50
+ }