node-mac-recorder 2.1.3 → 2.4.0

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,5 +1,7 @@
1
1
  #import <napi.h>
2
2
  #import <ScreenCaptureKit/ScreenCaptureKit.h>
3
+ #import <AVFoundation/AVFoundation.h>
4
+ #import <CoreMedia/CoreMedia.h>
3
5
  #import <AppKit/AppKit.h>
4
6
  #import <Foundation/Foundation.h>
5
7
  #import <CoreGraphics/CoreGraphics.h>
@@ -16,846 +18,733 @@ Napi::Object InitCursorTracker(Napi::Env env, Napi::Object exports);
16
18
  // Window selector function declarations
17
19
  Napi::Object InitWindowSelector(Napi::Env env, Napi::Object exports);
18
20
 
19
- @interface MacRecorderDelegate : NSObject
21
+ // ScreenCaptureKit Recording Delegate
22
+ API_AVAILABLE(macos(12.3))
23
+ @interface SCKRecorderDelegate : NSObject <SCStreamDelegate, SCStreamOutput>
20
24
  @property (nonatomic, copy) void (^completionHandler)(NSURL *outputURL, NSError *error);
25
+ @property (nonatomic, copy) void (^startedHandler)(void);
26
+ @property (nonatomic, strong) AVAssetWriter *assetWriter;
27
+ @property (nonatomic, strong) AVAssetWriterInput *videoInput;
28
+ @property (nonatomic, strong) AVAssetWriterInput *audioInput;
29
+ @property (nonatomic, strong) NSURL *outputURL;
30
+ @property (nonatomic, assign) BOOL isWriting;
31
+ @property (nonatomic, assign) CMTime startTime;
32
+ @property (nonatomic, assign) BOOL hasStartTime;
33
+ @property (nonatomic, assign) BOOL startAttempted;
34
+ @property (nonatomic, assign) BOOL startFailed;
21
35
  @end
22
36
 
23
- @implementation MacRecorderDelegate
24
- - (void)recordingDidStart {
25
- NSLog(@"[mac_recorder] ScreenCaptureKit recording started");
37
+ @implementation SCKRecorderDelegate
38
+
39
+ // Standard SCStreamDelegate method - should be called automatically
40
+ - (void)stream:(SCStream *)stream didOutputSampleBuffer:(CMSampleBufferRef)sampleBuffer ofType:(SCStreamOutputType)type {
41
+ NSLog(@"📹 SCStreamDelegate received sample buffer of type: %ld", (long)type);
42
+ [self handleSampleBuffer:sampleBuffer ofType:type fromStream:stream];
26
43
  }
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
- }
44
+
45
+ - (void)stream:(SCStream *)stream didStopWithError:(NSError *)error {
46
+ NSLog(@"🛑 Stream stopped with error: %@", error ? error.localizedDescription : @"none");
33
47
  if (self.completionHandler) {
34
- self.completionHandler(outputURL, error);
48
+ self.completionHandler(self.outputURL, error);
35
49
  }
36
50
  }
51
+
52
+
53
+ // Main sample buffer handler (renamed to avoid conflicts)
54
+ - (void)handleSampleBuffer:(CMSampleBufferRef)sampleBuffer ofType:(SCStreamOutputType)type fromStream:(SCStream *)stream {
55
+ NSLog(@"📹 Handling sample buffer of type: %ld", (long)type);
56
+
57
+ if (!self.isWriting || !self.assetWriter) {
58
+ NSLog(@"⚠️ Not writing or no asset writer available");
59
+ return;
60
+ }
61
+ if (self.startFailed) {
62
+ NSLog(@"⚠️ Asset writer start previously failed; ignoring buffers");
63
+ return;
64
+ }
65
+
66
+ // Start asset writer on first sample buffer
67
+ if (!self.hasStartTime) {
68
+ NSLog(@"🚀 Starting asset writer with first sample buffer");
69
+ if (self.startAttempted) {
70
+ // Another thread already attempted start; wait for success/fail flag to flip
71
+ return;
72
+ }
73
+ self.startAttempted = YES;
74
+ if (![self.assetWriter startWriting]) {
75
+ NSLog(@"❌ Failed to start asset writer: %@", self.assetWriter.error.localizedDescription);
76
+ self.startFailed = YES;
77
+ return;
78
+ }
79
+
80
+ NSLog(@"✅ Asset writer started successfully");
81
+ self.startTime = CMSampleBufferGetPresentationTimeStamp(sampleBuffer);
82
+ self.hasStartTime = YES;
83
+ [self.assetWriter startSessionAtSourceTime:self.startTime];
84
+ NSLog(@"✅ Asset writer session started at time: %lld", self.startTime.value);
85
+ }
86
+
87
+ switch (type) {
88
+ case SCStreamOutputTypeScreen: {
89
+ NSLog(@"📺 Processing screen sample buffer");
90
+ if (self.videoInput && self.videoInput.isReadyForMoreMediaData) {
91
+ BOOL success = [self.videoInput appendSampleBuffer:sampleBuffer];
92
+ NSLog(@"📺 Video sample buffer appended: %@", success ? @"SUCCESS" : @"FAILED");
93
+ } else {
94
+ NSLog(@"⚠️ Video input not ready for more data");
95
+ }
96
+ break;
97
+ }
98
+ case SCStreamOutputTypeAudio: {
99
+ NSLog(@"🔊 Processing audio sample buffer");
100
+ if (self.audioInput && self.audioInput.isReadyForMoreMediaData) {
101
+ BOOL success = [self.audioInput appendSampleBuffer:sampleBuffer];
102
+ NSLog(@"🔊 Audio sample buffer appended: %@", success ? @"SUCCESS" : @"FAILED");
103
+ } else {
104
+ NSLog(@"⚠️ Audio input not ready for more data (or no audio input)");
105
+ }
106
+ break;
107
+ }
108
+ case SCStreamOutputTypeMicrophone: {
109
+ NSLog(@"🎤 Processing microphone sample buffer");
110
+ if (self.audioInput && self.audioInput.isReadyForMoreMediaData) {
111
+ BOOL success = [self.audioInput appendSampleBuffer:sampleBuffer];
112
+ NSLog(@"🎤 Microphone sample buffer appended: %@", success ? @"SUCCESS" : @"FAILED");
113
+ } else {
114
+ NSLog(@"⚠️ Microphone input not ready for more data (or no audio input)");
115
+ }
116
+ break;
117
+ }
118
+ }
119
+ }
120
+
37
121
  @end
38
122
 
39
- // Global state for recording
40
- static MacRecorderDelegate *g_delegate = nil;
123
+ // Global state for ScreenCaptureKit recording
124
+ static SCStream *g_scStream = nil;
125
+ static SCKRecorderDelegate *g_scDelegate = nil;
41
126
  static bool g_isRecording = false;
42
127
 
43
- // Helper function to cleanup recording resources
44
- void cleanupRecording() {
45
- g_delegate = nil;
128
+ // Helper function to cleanup ScreenCaptureKit recording resources
129
+ void cleanupSCKRecording() {
130
+ NSLog(@"🛑 Cleaning up ScreenCaptureKit recording");
131
+
132
+ if (g_scStream) {
133
+ NSLog(@"🛑 Stopping SCStream");
134
+ [g_scStream stopCaptureWithCompletionHandler:^(NSError * _Nullable error) {
135
+ if (error) {
136
+ NSLog(@"❌ Error stopping SCStream: %@", error.localizedDescription);
137
+ } else {
138
+ NSLog(@"✅ SCStream stopped successfully");
139
+ }
140
+ }];
141
+ g_scStream = nil;
142
+ }
143
+
144
+ if (g_scDelegate) {
145
+ if (g_scDelegate.assetWriter && g_scDelegate.isWriting) {
146
+ NSLog(@"🛑 Finishing asset writer (status: %ld)", (long)g_scDelegate.assetWriter.status);
147
+ g_scDelegate.isWriting = NO;
148
+
149
+ // Only mark inputs as finished if asset writer is actually writing
150
+ if (g_scDelegate.assetWriter.status == AVAssetWriterStatusWriting) {
151
+ if (g_scDelegate.videoInput) {
152
+ [g_scDelegate.videoInput markAsFinished];
153
+ }
154
+ if (g_scDelegate.audioInput) {
155
+ [g_scDelegate.audioInput markAsFinished];
156
+ }
157
+
158
+ [g_scDelegate.assetWriter finishWritingWithCompletionHandler:^{
159
+ NSLog(@"✅ Asset writer finished. Status: %ld", (long)g_scDelegate.assetWriter.status);
160
+ if (g_scDelegate.assetWriter.error) {
161
+ NSLog(@"❌ Asset writer error: %@", g_scDelegate.assetWriter.error.localizedDescription);
162
+ }
163
+ }];
164
+ } else {
165
+ NSLog(@"⚠️ Asset writer not in writing status, cannot finish normally");
166
+ if (g_scDelegate.assetWriter.status == AVAssetWriterStatusFailed) {
167
+ NSLog(@"❌ Asset writer failed: %@", g_scDelegate.assetWriter.error.localizedDescription);
168
+ }
169
+ }
170
+ }
171
+ g_scDelegate = nil;
172
+ }
46
173
  g_isRecording = false;
47
174
  }
48
175
 
49
- // NAPI Function: Start Recording
176
+ // Check if ScreenCaptureKit is available
177
+ bool isScreenCaptureKitAvailable() {
178
+ if (@available(macOS 12.3, *)) {
179
+ return true;
180
+ }
181
+ return false;
182
+ }
183
+
184
+ // NAPI Function: Start Recording with ScreenCaptureKit
50
185
  Napi::Value StartRecording(const Napi::CallbackInfo& info) {
51
186
  Napi::Env env = info.Env();
52
187
 
188
+ if (!isScreenCaptureKitAvailable()) {
189
+ NSLog(@"ScreenCaptureKit requires macOS 12.3 or later");
190
+ return Napi::Boolean::New(env, false);
191
+ }
192
+
53
193
  if (info.Length() < 1) {
54
- Napi::TypeError::New(env, "Output path required").ThrowAsJavaScriptException();
55
- return env.Null();
194
+ NSLog(@"Output path required");
195
+ return Napi::Boolean::New(env, false);
56
196
  }
57
197
 
58
198
  if (g_isRecording) {
199
+ NSLog(@"⚠️ Already recording");
59
200
  return Napi::Boolean::New(env, false);
60
201
  }
61
202
 
203
+ // Verify permissions before starting
204
+ if (!CGPreflightScreenCaptureAccess()) {
205
+ NSLog(@"❌ Screen recording permission not granted - requesting access");
206
+ bool requestResult = CGRequestScreenCaptureAccess();
207
+ NSLog(@"📋 Permission request result: %@", requestResult ? @"SUCCESS" : @"FAILED");
208
+
209
+ if (!CGPreflightScreenCaptureAccess()) {
210
+ NSLog(@"❌ Screen recording permission still not available");
211
+ return Napi::Boolean::New(env, false);
212
+ }
213
+ }
214
+ NSLog(@"✅ Screen recording permission verified");
215
+
62
216
  std::string outputPath = info[0].As<Napi::String>().Utf8Value();
63
- NSLog(@"[mac_recorder] StartRecording: output=%@", [NSString stringWithUTF8String:outputPath.c_str()]);
64
217
 
65
- // Options parsing (shared)
218
+ // Default options
219
+ bool captureCursor = false;
220
+ bool includeSystemAudio = true;
221
+ CGDirectDisplayID displayID = 0; // Will be set to first available display
222
+ uint32_t windowID = 0;
66
223
  CGRect captureRect = CGRectNull;
67
- bool captureCursor = false; // Default olarak cursor gizli
68
- bool includeMicrophone = false; // Default olarak mikrofon kapalı
69
- bool includeSystemAudio = true; // Default olarak sistem sesi açık
70
- CGDirectDisplayID displayID = CGMainDisplayID(); // Default ana ekran
71
- NSString *audioDeviceId = nil; // Default audio device ID
72
- NSString *systemAudioDeviceId = nil; // System audio device ID
73
- bool forceUseSC = false;
74
- // Exclude options for ScreenCaptureKit (optional, backward compatible)
75
- NSMutableArray<NSString*> *excludedAppBundleIds = [NSMutableArray array];
76
- NSMutableArray<NSNumber*> *excludedPIDs = [NSMutableArray array];
77
- NSMutableArray<NSNumber*> *excludedWindowIds = [NSMutableArray array];
78
- bool autoExcludeSelf = false;
79
224
 
225
+ // Parse options
80
226
  if (info.Length() > 1 && info[1].IsObject()) {
81
227
  Napi::Object options = info[1].As<Napi::Object>();
82
228
 
83
- // Capture area
84
- if (options.Has("captureArea") && options.Get("captureArea").IsObject()) {
85
- Napi::Object rectObj = options.Get("captureArea").As<Napi::Object>();
86
- if (rectObj.Has("x") && rectObj.Has("y") && rectObj.Has("width") && rectObj.Has("height")) {
87
- captureRect = CGRectMake(
88
- rectObj.Get("x").As<Napi::Number>().DoubleValue(),
89
- rectObj.Get("y").As<Napi::Number>().DoubleValue(),
90
- rectObj.Get("width").As<Napi::Number>().DoubleValue(),
91
- rectObj.Get("height").As<Napi::Number>().DoubleValue()
92
- );
93
- }
94
- }
95
-
96
- // Capture cursor
97
229
  if (options.Has("captureCursor")) {
98
230
  captureCursor = options.Get("captureCursor").As<Napi::Boolean>();
99
231
  }
100
232
 
101
- // Microphone
102
- if (options.Has("includeMicrophone")) {
103
- includeMicrophone = options.Get("includeMicrophone").As<Napi::Boolean>();
104
- }
105
233
 
106
- // Audio device ID
107
- if (options.Has("audioDeviceId") && !options.Get("audioDeviceId").IsNull()) {
108
- std::string deviceId = options.Get("audioDeviceId").As<Napi::String>().Utf8Value();
109
- audioDeviceId = [NSString stringWithUTF8String:deviceId.c_str()];
110
- }
111
-
112
- // System audio
113
234
  if (options.Has("includeSystemAudio")) {
114
235
  includeSystemAudio = options.Get("includeSystemAudio").As<Napi::Boolean>();
115
236
  }
116
237
 
117
- // System audio device ID
118
- if (options.Has("systemAudioDeviceId") && !options.Get("systemAudioDeviceId").IsNull()) {
119
- std::string sysDeviceId = options.Get("systemAudioDeviceId").As<Napi::String>().Utf8Value();
120
- systemAudioDeviceId = [NSString stringWithUTF8String:sysDeviceId.c_str()];
121
- }
122
-
123
- // ScreenCaptureKit toggle (optional)
124
- if (options.Has("useScreenCaptureKit")) {
125
- forceUseSC = options.Get("useScreenCaptureKit").As<Napi::Boolean>();
126
- }
127
-
128
- // Exclusion lists (optional)
129
- if (options.Has("excludedAppBundleIds") && options.Get("excludedAppBundleIds").IsArray()) {
130
- Napi::Array arr = options.Get("excludedAppBundleIds").As<Napi::Array>();
131
- for (uint32_t i = 0; i < arr.Length(); i++) {
132
- if (!arr.Get(i).IsUndefined() && !arr.Get(i).IsNull()) {
133
- std::string s = arr.Get(i).As<Napi::String>().Utf8Value();
134
- [excludedAppBundleIds addObject:[NSString stringWithUTF8String:s.c_str()]];
135
- }
136
- }
137
- }
138
- if (options.Has("excludedPIDs") && options.Get("excludedPIDs").IsArray()) {
139
- Napi::Array arr = options.Get("excludedPIDs").As<Napi::Array>();
140
- for (uint32_t i = 0; i < arr.Length(); i++) {
141
- if (!arr.Get(i).IsUndefined() && !arr.Get(i).IsNull()) {
142
- double v = arr.Get(i).As<Napi::Number>().DoubleValue();
143
- [excludedPIDs addObject:@( (pid_t)v )];
144
- }
145
- }
146
- }
147
- if (options.Has("excludedWindowIds") && options.Get("excludedWindowIds").IsArray()) {
148
- Napi::Array arr = options.Get("excludedWindowIds").As<Napi::Array>();
149
- for (uint32_t i = 0; i < arr.Length(); i++) {
150
- if (!arr.Get(i).IsUndefined() && !arr.Get(i).IsNull()) {
151
- double v = arr.Get(i).As<Napi::Number>().DoubleValue();
152
- [excludedWindowIds addObject:@( (uint32_t)v )];
153
- }
154
- }
155
- }
156
- if (options.Has("autoExcludeSelf")) {
157
- autoExcludeSelf = options.Get("autoExcludeSelf").As<Napi::Boolean>();
158
- }
159
-
160
- // Display ID
161
238
  if (options.Has("displayId") && !options.Get("displayId").IsNull()) {
162
- double displayIdNum = options.Get("displayId").As<Napi::Number>().DoubleValue();
163
-
164
- // Use the display ID directly (not as an index)
165
- // The JavaScript layer passes the actual CGDirectDisplayID
166
- displayID = (CGDirectDisplayID)displayIdNum;
167
-
168
- // Verify that this display ID is valid
169
- uint32_t displayCount;
170
- CGGetActiveDisplayList(0, NULL, &displayCount);
171
- if (displayCount > 0) {
172
- CGDirectDisplayID *displays = (CGDirectDisplayID*)malloc(displayCount * sizeof(CGDirectDisplayID));
173
- CGGetActiveDisplayList(displayCount, displays, &displayCount);
174
-
175
- bool validDisplay = false;
176
- for (uint32_t i = 0; i < displayCount; i++) {
177
- if (displays[i] == displayID) {
178
- validDisplay = true;
179
- break;
180
- }
181
- }
182
-
183
- if (!validDisplay) {
184
- // Fallback to main display if invalid ID provided
185
- displayID = CGMainDisplayID();
186
- }
187
-
188
- free(displays);
239
+ uint32_t tempDisplayID = options.Get("displayId").As<Napi::Number>().Uint32Value();
240
+ if (tempDisplayID != 0) {
241
+ displayID = tempDisplayID;
189
242
  }
190
243
  }
191
244
 
192
- // Window ID için gelecekte kullanım (şimdilik captureArea ile hallediliyor)
193
245
  if (options.Has("windowId") && !options.Get("windowId").IsNull()) {
194
- // WindowId belirtilmiş ama captureArea JavaScript tarafında ayarlanıyor
195
- // Bu parametre gelecekte native level pencere seçimi için kullanılabilir
246
+ windowID = options.Get("windowId").As<Napi::Number>().Uint32Value();
247
+ }
248
+
249
+ if (options.Has("captureArea") && options.Get("captureArea").IsObject()) {
250
+ Napi::Object rectObj = options.Get("captureArea").As<Napi::Object>();
251
+ if (rectObj.Has("x") && rectObj.Has("y") && rectObj.Has("width") && rectObj.Has("height")) {
252
+ captureRect = CGRectMake(
253
+ rectObj.Get("x").As<Napi::Number>().DoubleValue(),
254
+ rectObj.Get("y").As<Napi::Number>().DoubleValue(),
255
+ rectObj.Get("width").As<Napi::Number>().DoubleValue(),
256
+ rectObj.Get("height").As<Napi::Number>().DoubleValue()
257
+ );
258
+ }
196
259
  }
197
260
  }
198
261
 
199
- @try {
200
- // Always prefer ScreenCaptureKit if available
201
- NSLog(@"[mac_recorder] Checking ScreenCaptureKit availability");
202
- if (@available(macOS 12.3, *)) {
203
- if ([ScreenCaptureKitRecorder isScreenCaptureKitAvailable]) {
204
- NSMutableDictionary *scConfig = [@{} mutableCopy];
205
- scConfig[@"displayId"] = @(displayID);
206
- if (!CGRectIsNull(captureRect)) {
207
- scConfig[@"captureArea"] = @{ @"x": @(captureRect.origin.x),
208
- @"y": @(captureRect.origin.y),
209
- @"width": @(captureRect.size.width),
210
- @"height": @(captureRect.size.height) };
211
- }
212
- scConfig[@"captureCursor"] = @(captureCursor);
213
- scConfig[@"includeMicrophone"] = @(includeMicrophone);
214
- scConfig[@"includeSystemAudio"] = @(includeSystemAudio);
215
- if (excludedAppBundleIds.count) scConfig[@"excludedAppBundleIds"] = excludedAppBundleIds;
216
- if (excludedPIDs.count) scConfig[@"excludedPIDs"] = excludedPIDs;
217
- if (excludedWindowIds.count) scConfig[@"excludedWindowIds"] = excludedWindowIds;
218
- // Auto exclude current app by PID if requested
219
- if (autoExcludeSelf) {
220
- pid_t pid = getpid();
221
- NSMutableArray *arr = [NSMutableArray arrayWithArray:scConfig[@"excludedPIDs"] ?: @[]];
222
- [arr addObject:@(pid)];
223
- scConfig[@"excludedPIDs"] = arr;
224
- }
225
-
226
- // Output path for SC
227
- std::string outputPathStr = info[0].As<Napi::String>().Utf8Value();
228
- scConfig[@"outputPath"] = [NSString stringWithUTF8String:outputPathStr.c_str()];
262
+ // Create output URL
263
+ NSURL *outputURL = [NSURL fileURLWithPath:[NSString stringWithUTF8String:outputPath.c_str()]];
264
+ NSLog(@"📁 Output URL: %@", outputURL.absoluteString);
229
265
 
230
- NSError *scErr = nil;
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
- if (ok) {
238
- g_isRecording = true;
239
- NSLog(@"[mac_recorder] ScreenCaptureKit startRecording → OK");
240
- return Napi::Boolean::New(env, true);
241
- }
242
- NSLog(@"[mac_recorder] ScreenCaptureKit startRecording → FAIL: %@", scErr.localizedDescription);
243
- cleanupRecording();
244
- return Napi::Boolean::New(env, false);
245
- }
246
- } else {
247
- NSLog(@"[mac_recorder] ScreenCaptureKit not available");
248
- cleanupRecording();
249
- return Napi::Boolean::New(env, false);
266
+ // Remove existing file if present to avoid AVAssetWriter "Cannot Save" error
267
+ NSFileManager *fm = [NSFileManager defaultManager];
268
+ if ([fm fileExistsAtPath:outputURL.path]) {
269
+ NSError *rmErr = nil;
270
+ [fm removeItemAtURL:outputURL error:&rmErr];
271
+ if (rmErr) {
272
+ NSLog(@"⚠️ Failed to remove existing output file (%@): %@", outputURL.path, rmErr.localizedDescription);
250
273
  }
251
-
252
- } @catch (NSException *exception) {
253
- cleanupRecording();
254
- return Napi::Boolean::New(env, false);
255
274
  }
256
- }
257
-
258
- // NAPI Function: Stop Recording
259
- Napi::Value StopRecording(const Napi::CallbackInfo& info) {
260
- Napi::Env env = info.Env();
261
275
 
262
- if (!g_isRecording) {
276
+ // Get shareable content
277
+ dispatch_semaphore_t semaphore = dispatch_semaphore_create(0);
278
+ __block NSError *contentError = nil;
279
+ __block SCShareableContent *shareableContent = nil;
280
+
281
+ [SCShareableContent getShareableContentWithCompletionHandler:^(SCShareableContent * _Nullable content, NSError * _Nullable error) {
282
+ shareableContent = content;
283
+ contentError = error;
284
+ dispatch_semaphore_signal(semaphore);
285
+ }];
286
+
287
+ dispatch_semaphore_wait(semaphore, DISPATCH_TIME_FOREVER);
288
+
289
+ if (contentError) {
290
+ NSLog(@"ScreenCaptureKit error: %@", contentError.localizedDescription);
291
+ NSLog(@"This is likely due to missing screen recording permissions");
263
292
  return Napi::Boolean::New(env, false);
264
293
  }
265
294
 
266
- @try {
267
- NSLog(@"[mac_recorder] StopRecording called");
295
+ // Find target display or window
296
+ SCContentFilter *contentFilter = nil;
297
+
298
+ if (windowID > 0) {
299
+ // Window recording
300
+ SCWindow *targetWindow = nil;
301
+ for (SCWindow *window in shareableContent.windows) {
302
+ if (window.windowID == windowID) {
303
+ targetWindow = window;
304
+ break;
305
+ }
306
+ }
268
307
 
269
- // Stop ScreenCaptureKit recording
270
- NSLog(@"[mac_recorder] Stopping ScreenCaptureKit stream");
271
- if (@available(macOS 12.3, *)) {
272
- [ScreenCaptureKitRecorder stopRecording];
308
+ if (!targetWindow) {
309
+ NSLog(@"Window not found with ID: %u", windowID);
310
+ return Napi::Boolean::New(env, false);
273
311
  }
274
- g_isRecording = false;
275
- cleanupRecording();
276
- NSLog(@"[mac_recorder] ScreenCaptureKit stopped");
277
- return Napi::Boolean::New(env, true);
278
312
 
279
- } @catch (NSException *exception) {
280
- cleanupRecording();
281
- return Napi::Boolean::New(env, false);
282
- }
283
- }
284
-
285
-
286
-
287
- // NAPI Function: Get Windows List
288
- Napi::Value GetWindows(const Napi::CallbackInfo& info) {
289
- Napi::Env env = info.Env();
290
- Napi::Array windowArray = Napi::Array::New(env);
291
-
292
- @try {
293
- // Get window list
294
- CFArrayRef windowList = CGWindowListCopyWindowInfo(
295
- kCGWindowListOptionOnScreenOnly | kCGWindowListExcludeDesktopElements,
296
- kCGNullWindowID
297
- );
313
+ contentFilter = [[SCContentFilter alloc] initWithDesktopIndependentWindow:targetWindow];
314
+ } else {
315
+ // Display recording
316
+ NSLog(@"🔍 Selecting display among %lu available displays", (unsigned long)shareableContent.displays.count);
298
317
 
299
- if (!windowList) {
300
- return windowArray;
301
- }
318
+ SCDisplay *targetDisplay = nil;
302
319
 
303
- CFIndex windowCount = CFArrayGetCount(windowList);
304
- uint32_t arrayIndex = 0;
320
+ // Log all available displays first
321
+ for (SCDisplay *display in shareableContent.displays) {
322
+ NSLog(@"📺 Available display: ID=%u, width=%d, height=%d", display.displayID, (int)display.width, (int)display.height);
323
+ }
305
324
 
306
- for (CFIndex i = 0; i < windowCount; i++) {
307
- CFDictionaryRef window = (CFDictionaryRef)CFArrayGetValueAtIndex(windowList, i);
308
-
309
- // Get window ID
310
- CFNumberRef windowIDRef = (CFNumberRef)CFDictionaryGetValue(window, kCGWindowNumber);
311
- if (!windowIDRef) continue;
312
-
313
- uint32_t windowID;
314
- CFNumberGetValue(windowIDRef, kCFNumberSInt32Type, &windowID);
315
-
316
- // Get window name
317
- CFStringRef windowNameRef = (CFStringRef)CFDictionaryGetValue(window, kCGWindowName);
318
- std::string windowName = "";
319
- if (windowNameRef) {
320
- const char* windowNameCStr = CFStringGetCStringPtr(windowNameRef, kCFStringEncodingUTF8);
321
- if (windowNameCStr) {
322
- windowName = std::string(windowNameCStr);
323
- } else {
324
- // Fallback for non-ASCII characters
325
- CFIndex length = CFStringGetLength(windowNameRef);
326
- CFIndex maxSize = CFStringGetMaximumSizeForEncoding(length, kCFStringEncodingUTF8) + 1;
327
- char* buffer = (char*)malloc(maxSize);
328
- if (CFStringGetCString(windowNameRef, buffer, maxSize, kCFStringEncodingUTF8)) {
329
- windowName = std::string(buffer);
330
- }
331
- free(buffer);
325
+ if (displayID != 0) {
326
+ // Look for specific display ID
327
+ for (SCDisplay *display in shareableContent.displays) {
328
+ if (display.displayID == displayID) {
329
+ targetDisplay = display;
330
+ break;
332
331
  }
333
332
  }
334
333
 
335
- // Get application name
336
- CFStringRef appNameRef = (CFStringRef)CFDictionaryGetValue(window, kCGWindowOwnerName);
337
- std::string appName = "";
338
- if (appNameRef) {
339
- const char* appNameCStr = CFStringGetCStringPtr(appNameRef, kCFStringEncodingUTF8);
340
- if (appNameCStr) {
341
- appName = std::string(appNameCStr);
342
- } else {
343
- CFIndex length = CFStringGetLength(appNameRef);
344
- CFIndex maxSize = CFStringGetMaximumSizeForEncoding(length, kCFStringEncodingUTF8) + 1;
345
- char* buffer = (char*)malloc(maxSize);
346
- if (CFStringGetCString(appNameRef, buffer, maxSize, kCFStringEncodingUTF8)) {
347
- appName = std::string(buffer);
348
- }
349
- free(buffer);
350
- }
351
- }
352
-
353
- // Get window bounds
354
- CFDictionaryRef boundsRef = (CFDictionaryRef)CFDictionaryGetValue(window, kCGWindowBounds);
355
- CGRect bounds = CGRectZero;
356
- if (boundsRef) {
357
- CGRectMakeWithDictionaryRepresentation(boundsRef, &bounds);
358
- }
359
-
360
- // Skip windows without name or very small windows
361
- if (windowName.empty() || bounds.size.width < 50 || bounds.size.height < 50) {
362
- continue;
334
+ if (!targetDisplay) {
335
+ NSLog(@"❌ Display not found with ID: %u", displayID);
363
336
  }
364
-
365
- // Create window object
366
- Napi::Object windowObj = Napi::Object::New(env);
367
- windowObj.Set("id", Napi::Number::New(env, windowID));
368
- windowObj.Set("name", Napi::String::New(env, windowName));
369
- windowObj.Set("appName", Napi::String::New(env, appName));
370
- windowObj.Set("x", Napi::Number::New(env, bounds.origin.x));
371
- windowObj.Set("y", Napi::Number::New(env, bounds.origin.y));
372
- windowObj.Set("width", Napi::Number::New(env, bounds.size.width));
373
- windowObj.Set("height", Napi::Number::New(env, bounds.size.height));
374
-
375
- windowArray.Set(arrayIndex++, windowObj);
376
337
  }
377
338
 
378
- CFRelease(windowList);
379
- return windowArray;
380
-
381
- } @catch (NSException *exception) {
382
- return windowArray;
383
- }
384
- }
385
-
386
- // NAPI Function: Get Audio Devices
387
- Napi::Value GetAudioDevices(const Napi::CallbackInfo& info) {
388
- Napi::Env env = info.Env();
389
-
390
- @try {
391
- NSMutableArray *devices = [NSMutableArray array];
392
-
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);
339
+ // If no specific display was requested or found, use the first available
340
+ if (!targetDisplay) {
341
+ if (shareableContent.displays.count > 0) {
342
+ targetDisplay = shareableContent.displays.firstObject;
343
+ NSLog(@"✅ Using first available display: ID=%u, %dx%d", targetDisplay.displayID, (int)targetDisplay.width, (int)targetDisplay.height);
344
+ } else {
345
+ NSLog(@"❌ No displays available at all");
346
+ return Napi::Boolean::New(env, false);
347
+ }
348
+ } else {
349
+ NSLog(@"✅ Using specified display: ID=%u, %dx%d", targetDisplay.displayID, (int)targetDisplay.width, (int)targetDisplay.height);
404
350
  }
405
351
 
406
- UInt32 deviceCount = dataSize / sizeof(AudioDeviceID);
407
- AudioDeviceID *audioDevices = (AudioDeviceID *)malloc(dataSize);
352
+ // Update displayID for subsequent use
353
+ displayID = targetDisplay.displayID;
408
354
 
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;
355
+ // Build exclusion windows array if provided
356
+ NSMutableArray<SCWindow *> *excluded = [NSMutableArray array];
357
+ BOOL excludeCurrentApp = NO;
358
+ if (info.Length() > 1 && info[1].IsObject()) {
359
+ Napi::Object options = info[1].As<Napi::Object>();
360
+ if (options.Has("excludeCurrentApp")) {
361
+ excludeCurrentApp = options.Get("excludeCurrentApp").As<Napi::Boolean>();
445
362
  }
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;
363
+ if (options.Has("excludeWindowIds") && options.Get("excludeWindowIds").IsArray()) {
364
+ Napi::Array arr = options.Get("excludeWindowIds").As<Napi::Array>();
365
+ for (uint32_t i = 0; i < arr.Length(); i++) {
366
+ Napi::Value v = arr.Get(i);
367
+ if (v.IsNumber()) {
368
+ uint32_t wid = v.As<Napi::Number>().Uint32Value();
369
+ for (SCWindow *w in shareableContent.windows) {
370
+ if (w.windowID == wid) {
371
+ [excluded addObject:w];
372
+ break;
373
+ }
374
+ }
375
+ }
376
+ }
461
377
  }
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
-
476
- [devices addObject:@{
477
- @"id": deviceUID,
478
- @"name": deviceName,
479
- @"manufacturer": @"Unknown",
480
- @"isDefault": @(isDefault)
481
- }];
482
-
483
- if (deviceNameRef) CFRelease(deviceNameRef);
484
- if (deviceUIDRef) CFRelease(deviceUIDRef);
485
378
  }
486
379
 
487
- free(audioDevices);
488
-
489
- // Convert to NAPI array
490
- Napi::Array result = Napi::Array::New(env, devices.count);
491
- for (NSUInteger i = 0; i < devices.count; i++) {
492
- NSDictionary *device = devices[i];
493
- Napi::Object deviceObj = Napi::Object::New(env);
494
- deviceObj.Set("id", Napi::String::New(env, [device[@"id"] UTF8String]));
495
- deviceObj.Set("name", Napi::String::New(env, [device[@"name"] UTF8String]));
496
- deviceObj.Set("manufacturer", Napi::String::New(env, [device[@"manufacturer"] UTF8String]));
497
- deviceObj.Set("isDefault", Napi::Boolean::New(env, [device[@"isDefault"] boolValue]));
498
- result[i] = deviceObj;
380
+ if (excludeCurrentApp) {
381
+ pid_t pid = [[NSProcessInfo processInfo] processIdentifier];
382
+ for (SCWindow *w in shareableContent.windows) {
383
+ if (w.owningApplication && w.owningApplication.processID == pid) {
384
+ [excluded addObject:w];
385
+ }
386
+ }
499
387
  }
500
388
 
501
- return result;
389
+ contentFilter = [[SCContentFilter alloc] initWithDisplay:targetDisplay excludingWindows:excluded];
390
+ NSLog(@"✅ Content filter created for display recording");
391
+ }
392
+
393
+ // Get actual display dimensions for proper video configuration
394
+ CGRect displayBounds = CGDisplayBounds(displayID);
395
+ NSSize videoSize = NSMakeSize(displayBounds.size.width, displayBounds.size.height);
396
+
397
+ // Create stream configuration
398
+ SCStreamConfiguration *config = [[SCStreamConfiguration alloc] init];
399
+ config.width = videoSize.width;
400
+ config.height = videoSize.height;
401
+ config.minimumFrameInterval = CMTimeMake(1, 30); // 30 FPS
402
+
403
+ // Try a more compatible pixel format
404
+ config.pixelFormat = kCVPixelFormatType_32BGRA;
405
+
406
+ NSLog(@"📐 Stream configuration: %dx%d, FPS=30, cursor=%@", (int)config.width, (int)config.height, captureCursor ? @"YES" : @"NO");
407
+
408
+ if (@available(macOS 13.0, *)) {
409
+ config.capturesAudio = includeSystemAudio;
410
+ config.excludesCurrentProcessAudio = YES;
411
+ NSLog(@"🔊 Audio configuration: capture=%@, excludeProcess=%@", includeSystemAudio ? @"YES" : @"NO", @"YES");
412
+ } else {
413
+ NSLog(@"⚠️ macOS 13.0+ features not available");
414
+ }
415
+ config.showsCursor = captureCursor;
416
+
417
+ if (!CGRectIsNull(captureRect)) {
418
+ config.sourceRect = captureRect;
419
+ // Update video size if capture rect is specified
420
+ videoSize = NSMakeSize(captureRect.size.width, captureRect.size.height);
421
+ }
422
+
423
+ // Create delegate
424
+ g_scDelegate = [[SCKRecorderDelegate alloc] init];
425
+ g_scDelegate.outputURL = outputURL;
426
+ g_scDelegate.hasStartTime = NO;
427
+ g_scDelegate.startAttempted = NO;
428
+ g_scDelegate.startFailed = NO;
429
+
430
+ // Setup AVAssetWriter
431
+ NSError *writerError = nil;
432
+ g_scDelegate.assetWriter = [[AVAssetWriter alloc] initWithURL:outputURL fileType:AVFileTypeQuickTimeMovie error:&writerError];
433
+
434
+ if (writerError) {
435
+ NSLog(@"❌ Failed to create asset writer: %@", writerError.localizedDescription);
436
+ return Napi::Boolean::New(env, false);
437
+ }
438
+
439
+ NSLog(@"✅ Asset writer created successfully");
440
+
441
+ // Video input settings using actual dimensions
442
+ NSLog(@"📺 Setting up video input: %dx%d", (int)videoSize.width, (int)videoSize.height);
443
+ NSDictionary *videoSettings = @{
444
+ AVVideoCodecKey: AVVideoCodecTypeH264,
445
+ AVVideoWidthKey: @((NSInteger)videoSize.width),
446
+ AVVideoHeightKey: @((NSInteger)videoSize.height)
447
+ };
448
+
449
+ g_scDelegate.videoInput = [[AVAssetWriterInput alloc] initWithMediaType:AVMediaTypeVideo outputSettings:videoSettings];
450
+ g_scDelegate.videoInput.expectsMediaDataInRealTime = YES;
451
+
452
+ if ([g_scDelegate.assetWriter canAddInput:g_scDelegate.videoInput]) {
453
+ [g_scDelegate.assetWriter addInput:g_scDelegate.videoInput];
454
+ NSLog(@"✅ Video input added to asset writer");
455
+ } else {
456
+ NSLog(@"❌ Cannot add video input to asset writer");
457
+ }
458
+
459
+ // Audio input settings (if needed)
460
+ if (includeSystemAudio) {
461
+ NSDictionary *audioSettings = @{
462
+ AVFormatIDKey: @(kAudioFormatMPEG4AAC),
463
+ AVSampleRateKey: @44100,
464
+ AVNumberOfChannelsKey: @2
465
+ };
466
+
467
+ g_scDelegate.audioInput = [[AVAssetWriterInput alloc] initWithMediaType:AVMediaTypeAudio outputSettings:audioSettings];
468
+ g_scDelegate.audioInput.expectsMediaDataInRealTime = YES;
502
469
 
503
- } @catch (NSException *exception) {
504
- return Napi::Array::New(env, 0);
470
+ if ([g_scDelegate.assetWriter canAddInput:g_scDelegate.audioInput]) {
471
+ [g_scDelegate.assetWriter addInput:g_scDelegate.audioInput];
472
+ }
505
473
  }
474
+
475
+ // Create callback queue for the delegate
476
+ dispatch_queue_t delegateQueue = dispatch_get_global_queue(QOS_CLASS_USER_INITIATED, 0);
477
+
478
+ // Create and start stream first
479
+ g_scStream = [[SCStream alloc] initWithFilter:contentFilter configuration:config delegate:g_scDelegate];
480
+
481
+ // Attach outputs to actually receive sample buffers
482
+ NSLog(@"✅ Setting up stream output callback for sample buffers");
483
+ dispatch_queue_t outputQueue = dispatch_get_global_queue(QOS_CLASS_USER_INITIATED, 0);
484
+ NSError *outputError = nil;
485
+ BOOL addedScreenOutput = [g_scStream addStreamOutput:g_scDelegate type:SCStreamOutputTypeScreen sampleHandlerQueue:outputQueue error:&outputError];
486
+ if (addedScreenOutput) {
487
+ NSLog(@"✅ Screen output attached to SCStream");
488
+ } else {
489
+ NSLog(@"❌ Failed to attach screen output to SCStream: %@", outputError.localizedDescription);
490
+ }
491
+ if (includeSystemAudio) {
492
+ outputError = nil;
493
+ BOOL addedAudioOutput = [g_scStream addStreamOutput:g_scDelegate type:SCStreamOutputTypeAudio sampleHandlerQueue:outputQueue error:&outputError];
494
+ if (addedAudioOutput) {
495
+ NSLog(@"✅ Audio output attached to SCStream");
496
+ } else {
497
+ NSLog(@"⚠️ Failed to attach audio output to SCStream (audio may be disabled): %@", outputError.localizedDescription);
498
+ }
499
+ }
500
+
501
+ if (!g_scStream) {
502
+ NSLog(@"❌ Failed to create SCStream");
503
+ return Napi::Boolean::New(env, false);
504
+ }
505
+
506
+ NSLog(@"✅ SCStream created successfully");
507
+
508
+ // Add callback queue for sample buffers (this might be important)
509
+ if (@available(macOS 14.0, *)) {
510
+ // In macOS 14+, we can set a specific queue
511
+ // For now, we'll rely on the default behavior
512
+ }
513
+
514
+ // Start capture and wait for it to begin
515
+ dispatch_semaphore_t startSemaphore = dispatch_semaphore_create(0);
516
+ __block NSError *startError = nil;
517
+
518
+ NSLog(@"🚀 Starting ScreenCaptureKit capture");
519
+ [g_scStream startCaptureWithCompletionHandler:^(NSError * _Nullable error) {
520
+ startError = error;
521
+ dispatch_semaphore_signal(startSemaphore);
522
+ }];
523
+
524
+ dispatch_semaphore_wait(startSemaphore, dispatch_time(DISPATCH_TIME_NOW, 5 * NSEC_PER_SEC));
525
+
526
+ if (startError) {
527
+ NSLog(@"❌ Failed to start capture: %@", startError.localizedDescription);
528
+ return Napi::Boolean::New(env, false);
529
+ }
530
+
531
+ NSLog(@"✅ ScreenCaptureKit capture started successfully");
532
+
533
+ // Mark that we're ready to write (asset writer will be started in first sample buffer)
534
+ g_scDelegate.isWriting = YES;
535
+ g_isRecording = true;
536
+
537
+ // Wait a moment to see if we get any sample buffers
538
+ NSLog(@"⏱️ Waiting 1 second for sample buffers to arrive...");
539
+ dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(1.0 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
540
+ if (g_scDelegate && !g_scDelegate.hasStartTime) {
541
+ NSLog(@"⚠️ No sample buffers received after 1 second - this might indicate a permission or configuration issue");
542
+ } else if (g_scDelegate && g_scDelegate.hasStartTime) {
543
+ NSLog(@"✅ Sample buffers are being received successfully");
544
+ }
545
+ });
546
+
547
+ NSLog(@"🎬 Recording initialized successfully");
548
+ return Napi::Boolean::New(env, true);
506
549
  }
507
550
 
508
- // NAPI Function: Get Displays
509
- Napi::Value GetDisplays(const Napi::CallbackInfo& info) {
551
+ // NAPI Function: Stop Recording
552
+ Napi::Value StopRecording(const Napi::CallbackInfo& info) {
510
553
  Napi::Env env = info.Env();
511
554
 
512
- @try {
513
- NSArray *displays = [ScreenCapture getAvailableDisplays];
514
- Napi::Array result = Napi::Array::New(env, displays.count);
515
-
516
- NSLog(@"Found %lu displays", (unsigned long)displays.count);
517
-
518
- for (NSUInteger i = 0; i < displays.count; i++) {
519
- NSDictionary *display = displays[i];
520
- NSLog(@"Display %lu: ID=%u, Name=%@, Size=%@x%@",
521
- (unsigned long)i,
522
- [display[@"id"] unsignedIntValue],
523
- display[@"name"],
524
- display[@"width"],
525
- display[@"height"]);
526
-
527
- Napi::Object displayObj = Napi::Object::New(env);
528
- displayObj.Set("id", Napi::Number::New(env, [display[@"id"] unsignedIntValue]));
529
- displayObj.Set("name", Napi::String::New(env, [display[@"name"] UTF8String]));
530
- displayObj.Set("width", Napi::Number::New(env, [display[@"width"] doubleValue]));
531
- displayObj.Set("height", Napi::Number::New(env, [display[@"height"] doubleValue]));
532
- displayObj.Set("x", Napi::Number::New(env, [display[@"x"] doubleValue]));
533
- displayObj.Set("y", Napi::Number::New(env, [display[@"y"] doubleValue]));
534
- displayObj.Set("isPrimary", Napi::Boolean::New(env, [display[@"isPrimary"] boolValue]));
535
- result[i] = displayObj;
536
- }
537
-
538
- return result;
539
-
540
- } @catch (NSException *exception) {
541
- NSLog(@"Exception in GetDisplays: %@", exception);
542
- return Napi::Array::New(env, 0);
555
+ if (!g_isRecording) {
556
+ return Napi::Boolean::New(env, false);
543
557
  }
558
+
559
+ cleanupSCKRecording();
560
+ return Napi::Boolean::New(env, true);
544
561
  }
545
562
 
546
563
  // NAPI Function: Get Recording Status
547
- Napi::Value GetRecordingStatus(const Napi::CallbackInfo& info) {
564
+ Napi::Value IsRecording(const Napi::CallbackInfo& info) {
548
565
  Napi::Env env = info.Env();
549
566
  return Napi::Boolean::New(env, g_isRecording);
550
567
  }
551
568
 
552
- // NAPI Function: Get Window Thumbnail
553
- Napi::Value GetWindowThumbnail(const Napi::CallbackInfo& info) {
569
+ // NAPI Function: Get Displays
570
+ Napi::Value GetDisplays(const Napi::CallbackInfo& info) {
554
571
  Napi::Env env = info.Env();
555
572
 
556
- if (info.Length() < 1) {
557
- Napi::TypeError::New(env, "Window ID is required").ThrowAsJavaScriptException();
558
- return env.Null();
573
+ if (!isScreenCaptureKitAvailable()) {
574
+ // Fallback to legacy method
575
+ return GetAvailableDisplays(info);
559
576
  }
560
577
 
561
- uint32_t windowID = info[0].As<Napi::Number>().Uint32Value();
578
+ // Use ScreenCaptureKit
579
+ dispatch_semaphore_t semaphore = dispatch_semaphore_create(0);
580
+ __block SCShareableContent *shareableContent = nil;
581
+ __block NSError *error = nil;
562
582
 
563
- // Optional parameters
564
- int maxWidth = 300; // Default thumbnail width
565
- int maxHeight = 200; // Default thumbnail height
583
+ [SCShareableContent getShareableContentWithCompletionHandler:^(SCShareableContent * _Nullable content, NSError * _Nullable err) {
584
+ shareableContent = content;
585
+ error = err;
586
+ dispatch_semaphore_signal(semaphore);
587
+ }];
566
588
 
567
- if (info.Length() >= 2 && !info[1].IsNull()) {
568
- maxWidth = info[1].As<Napi::Number>().Int32Value();
569
- }
570
- if (info.Length() >= 3 && !info[2].IsNull()) {
571
- maxHeight = info[2].As<Napi::Number>().Int32Value();
589
+ dispatch_semaphore_wait(semaphore, DISPATCH_TIME_FOREVER);
590
+
591
+ if (error) {
592
+ NSLog(@"Failed to get displays: %@", error.localizedDescription);
593
+ return Napi::Array::New(env, 0);
572
594
  }
573
595
 
574
- @try {
575
- // Create window image
576
- CGImageRef windowImage = CGWindowListCreateImage(
577
- CGRectNull,
578
- kCGWindowListOptionIncludingWindow,
579
- windowID,
580
- kCGWindowImageBoundsIgnoreFraming | kCGWindowImageShouldBeOpaque
581
- );
582
-
583
- if (!windowImage) {
584
- return env.Null();
585
- }
586
-
587
- // Get original dimensions
588
- size_t originalWidth = CGImageGetWidth(windowImage);
589
- size_t originalHeight = CGImageGetHeight(windowImage);
590
-
591
- // Calculate scaled dimensions maintaining aspect ratio
592
- double scaleX = (double)maxWidth / originalWidth;
593
- double scaleY = (double)maxHeight / originalHeight;
594
- double scale = std::min(scaleX, scaleY);
595
-
596
- size_t thumbnailWidth = (size_t)(originalWidth * scale);
597
- size_t thumbnailHeight = (size_t)(originalHeight * scale);
598
-
599
- // Create scaled image
600
- CGColorSpaceRef colorSpace = CGColorSpaceCreateDeviceRGB();
601
- CGContextRef context = CGBitmapContextCreate(
602
- NULL,
603
- thumbnailWidth,
604
- thumbnailHeight,
605
- 8,
606
- thumbnailWidth * 4,
607
- colorSpace,
608
- kCGImageAlphaPremultipliedLast
609
- );
610
-
611
- if (context) {
612
- CGContextDrawImage(context, CGRectMake(0, 0, thumbnailWidth, thumbnailHeight), windowImage);
613
- CGImageRef thumbnailImage = CGBitmapContextCreateImage(context);
614
-
615
- if (thumbnailImage) {
616
- // Convert to PNG data
617
- NSBitmapImageRep *imageRep = [[NSBitmapImageRep alloc] initWithCGImage:thumbnailImage];
618
- NSData *pngData = [imageRep representationUsingType:NSBitmapImageFileTypePNG properties:@{}];
619
-
620
- if (pngData) {
621
- // Convert to Base64
622
- NSString *base64String = [pngData base64EncodedStringWithOptions:0];
623
- std::string base64Std = [base64String UTF8String];
624
-
625
- CGImageRelease(thumbnailImage);
626
- CGContextRelease(context);
627
- CGColorSpaceRelease(colorSpace);
628
- CGImageRelease(windowImage);
629
-
630
- return Napi::String::New(env, base64Std);
631
- }
632
-
633
- CGImageRelease(thumbnailImage);
634
- }
635
-
636
- CGContextRelease(context);
637
- }
638
-
639
- CGColorSpaceRelease(colorSpace);
640
- CGImageRelease(windowImage);
641
-
642
- return env.Null();
643
-
644
- } @catch (NSException *exception) {
645
- return env.Null();
596
+ Napi::Array displaysArray = Napi::Array::New(env);
597
+ uint32_t index = 0;
598
+
599
+ for (SCDisplay *display in shareableContent.displays) {
600
+ Napi::Object displayObj = Napi::Object::New(env);
601
+ displayObj.Set("id", Napi::Number::New(env, display.displayID));
602
+ displayObj.Set("width", Napi::Number::New(env, display.width));
603
+ displayObj.Set("height", Napi::Number::New(env, display.height));
604
+ displayObj.Set("frame", Napi::Object::New(env)); // TODO: Add frame details
605
+
606
+ displaysArray.Set(index++, displayObj);
646
607
  }
608
+
609
+ return displaysArray;
647
610
  }
648
611
 
649
- // NAPI Function: Get Display Thumbnail
650
- Napi::Value GetDisplayThumbnail(const Napi::CallbackInfo& info) {
612
+
613
+ // NAPI Function: Get Windows
614
+ Napi::Value GetWindows(const Napi::CallbackInfo& info) {
651
615
  Napi::Env env = info.Env();
652
616
 
653
- if (info.Length() < 1) {
654
- Napi::TypeError::New(env, "Display ID is required").ThrowAsJavaScriptException();
655
- return env.Null();
617
+ if (!isScreenCaptureKitAvailable()) {
618
+ // Use legacy CGWindowList method
619
+ return GetWindowList(info);
656
620
  }
657
621
 
658
- uint32_t displayID = info[0].As<Napi::Number>().Uint32Value();
622
+ // Use ScreenCaptureKit
623
+ dispatch_semaphore_t semaphore = dispatch_semaphore_create(0);
624
+ __block SCShareableContent *shareableContent = nil;
625
+ __block NSError *error = nil;
659
626
 
660
- // Optional parameters
661
- int maxWidth = 300; // Default thumbnail width
662
- int maxHeight = 200; // Default thumbnail height
627
+ [SCShareableContent getShareableContentWithCompletionHandler:^(SCShareableContent * _Nullable content, NSError * _Nullable err) {
628
+ shareableContent = content;
629
+ error = err;
630
+ dispatch_semaphore_signal(semaphore);
631
+ }];
663
632
 
664
- if (info.Length() >= 2 && !info[1].IsNull()) {
665
- maxWidth = info[1].As<Napi::Number>().Int32Value();
666
- }
667
- if (info.Length() >= 3 && !info[2].IsNull()) {
668
- maxHeight = info[2].As<Napi::Number>().Int32Value();
633
+ dispatch_semaphore_wait(semaphore, DISPATCH_TIME_FOREVER);
634
+
635
+ if (error) {
636
+ NSLog(@"Failed to get windows: %@", error.localizedDescription);
637
+ return Napi::Array::New(env, 0);
669
638
  }
670
639
 
671
- @try {
672
- // Verify display exists
673
- CGDirectDisplayID activeDisplays[32];
674
- uint32_t displayCount;
675
- CGError err = CGGetActiveDisplayList(32, activeDisplays, &displayCount);
676
-
677
- if (err != kCGErrorSuccess) {
678
- NSLog(@"Failed to get active display list: %d", err);
679
- return env.Null();
680
- }
681
-
682
- bool displayFound = false;
683
- for (uint32_t i = 0; i < displayCount; i++) {
684
- if (activeDisplays[i] == displayID) {
685
- displayFound = true;
686
- break;
687
- }
688
- }
689
-
690
- if (!displayFound) {
691
- NSLog(@"Display ID %u not found in active displays", displayID);
692
- return env.Null();
693
- }
694
-
695
- // Create display image
696
- CGImageRef displayImage = CGDisplayCreateImage(displayID);
697
-
698
- if (!displayImage) {
699
- NSLog(@"CGDisplayCreateImage failed for display ID: %u", displayID);
700
- return env.Null();
701
- }
702
-
703
- // Get original dimensions
704
- size_t originalWidth = CGImageGetWidth(displayImage);
705
- size_t originalHeight = CGImageGetHeight(displayImage);
706
-
707
- NSLog(@"Original dimensions: %zux%zu", originalWidth, originalHeight);
708
-
709
- // Calculate scaled dimensions maintaining aspect ratio
710
- double scaleX = (double)maxWidth / originalWidth;
711
- double scaleY = (double)maxHeight / originalHeight;
712
- double scale = std::min(scaleX, scaleY);
713
-
714
- size_t thumbnailWidth = (size_t)(originalWidth * scale);
715
- size_t thumbnailHeight = (size_t)(originalHeight * scale);
716
-
717
- NSLog(@"Thumbnail dimensions: %zux%zu (scale: %f)", thumbnailWidth, thumbnailHeight, scale);
718
-
719
- // Create scaled image
720
- CGColorSpaceRef colorSpace = CGColorSpaceCreateDeviceRGB();
721
- CGContextRef context = CGBitmapContextCreate(
722
- NULL,
723
- thumbnailWidth,
724
- thumbnailHeight,
725
- 8,
726
- thumbnailWidth * 4,
727
- colorSpace,
728
- kCGImageAlphaPremultipliedLast | kCGBitmapByteOrder32Big
729
- );
730
-
731
- if (!context) {
732
- NSLog(@"Failed to create bitmap context");
733
- CGImageRelease(displayImage);
734
- CGColorSpaceRelease(colorSpace);
735
- return env.Null();
736
- }
737
-
738
- // Set interpolation quality for better scaling
739
- CGContextSetInterpolationQuality(context, kCGInterpolationHigh);
740
-
741
- // Draw the image
742
- CGContextDrawImage(context, CGRectMake(0, 0, thumbnailWidth, thumbnailHeight), displayImage);
743
- CGImageRef thumbnailImage = CGBitmapContextCreateImage(context);
744
-
745
- if (!thumbnailImage) {
746
- NSLog(@"Failed to create thumbnail image");
747
- CGContextRelease(context);
748
- CGImageRelease(displayImage);
749
- CGColorSpaceRelease(colorSpace);
750
- return env.Null();
751
- }
752
-
753
- // Convert to PNG data
754
- NSBitmapImageRep *imageRep = [[NSBitmapImageRep alloc] initWithCGImage:thumbnailImage];
755
- NSDictionary *properties = @{NSImageCompressionFactor: @0.8};
756
- NSData *pngData = [imageRep representationUsingType:NSBitmapImageFileTypePNG properties:properties];
757
-
758
- if (!pngData) {
759
- NSLog(@"Failed to convert image to PNG data");
760
- CGImageRelease(thumbnailImage);
761
- CGContextRelease(context);
762
- CGImageRelease(displayImage);
763
- CGColorSpaceRelease(colorSpace);
764
- return env.Null();
640
+ Napi::Array windowsArray = Napi::Array::New(env);
641
+ uint32_t index = 0;
642
+
643
+ for (SCWindow *window in shareableContent.windows) {
644
+ if (window.isOnScreen && window.frame.size.width > 50 && window.frame.size.height > 50) {
645
+ Napi::Object windowObj = Napi::Object::New(env);
646
+ windowObj.Set("id", Napi::Number::New(env, window.windowID));
647
+ windowObj.Set("title", Napi::String::New(env, window.title ? [window.title UTF8String] : ""));
648
+ windowObj.Set("ownerName", Napi::String::New(env, window.owningApplication.applicationName ? [window.owningApplication.applicationName UTF8String] : ""));
649
+ windowObj.Set("bounds", Napi::Object::New(env)); // TODO: Add bounds details
650
+
651
+ windowsArray.Set(index++, windowObj);
765
652
  }
766
-
767
- // Convert to Base64
768
- NSString *base64String = [pngData base64EncodedStringWithOptions:0];
769
- std::string base64Std = [base64String UTF8String];
770
-
771
- NSLog(@"Successfully created thumbnail with base64 length: %lu", (unsigned long)base64Std.length());
772
-
773
- // Cleanup
774
- CGImageRelease(thumbnailImage);
775
- CGContextRelease(context);
776
- CGColorSpaceRelease(colorSpace);
777
- CGImageRelease(displayImage);
778
-
779
- return Napi::String::New(env, base64Std);
780
-
781
- } @catch (NSException *exception) {
782
- NSLog(@"Exception in GetDisplayThumbnail: %@", exception);
783
- return env.Null();
784
653
  }
654
+
655
+ return windowsArray;
785
656
  }
786
657
 
787
658
  // NAPI Function: Check Permissions
788
659
  Napi::Value CheckPermissions(const Napi::CallbackInfo& info) {
789
660
  Napi::Env env = info.Env();
790
661
 
791
- @try {
792
- // Check screen recording permission using ScreenCaptureKit
793
- bool hasScreenPermission = true;
794
-
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) {
801
- hasScreenPermission = false;
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
- );
662
+ // Check screen recording permission
663
+ bool hasPermission = CGPreflightScreenCaptureAccess();
664
+
665
+ // If we don't have permission, try to request it
666
+ if (!hasPermission) {
667
+ NSLog(@"⚠️ Screen recording permission not granted, requesting access");
668
+ bool requestResult = CGRequestScreenCaptureAccess();
669
+ NSLog(@"📋 Permission request result: %@", requestResult ? @"SUCCESS" : @"FAILED");
670
+
671
+ // Check again after request
672
+ hasPermission = CGPreflightScreenCaptureAccess();
673
+ }
674
+
675
+ return Napi::Boolean::New(env, hasPermission);
676
+ }
677
+
678
+ // NAPI Function: Get Audio Devices
679
+ Napi::Value GetAudioDevices(const Napi::CallbackInfo& info) {
680
+ Napi::Env env = info.Env();
681
+
682
+ Napi::Array devices = Napi::Array::New(env);
683
+ uint32_t index = 0;
684
+
685
+ AudioObjectPropertyAddress propertyAddress = {
686
+ kAudioHardwarePropertyDevices,
687
+ kAudioObjectPropertyScopeGlobal,
688
+ kAudioObjectPropertyElementMain
689
+ };
690
+
691
+ UInt32 dataSize = 0;
692
+ OSStatus status = AudioObjectGetPropertyDataSize(kAudioObjectSystemObject, &propertyAddress, 0, NULL, &dataSize);
693
+
694
+ if (status != noErr) {
695
+ return devices;
696
+ }
697
+
698
+ UInt32 deviceCount = dataSize / sizeof(AudioDeviceID);
699
+ AudioDeviceID *audioDevices = (AudioDeviceID *)malloc(dataSize);
700
+
701
+ status = AudioObjectGetPropertyData(kAudioObjectSystemObject, &propertyAddress, 0, NULL, &dataSize, audioDevices);
702
+
703
+ if (status == noErr) {
704
+ for (UInt32 i = 0; i < deviceCount; ++i) {
705
+ AudioDeviceID deviceID = audioDevices[i];
706
+
707
+ // Get device name
708
+ CFStringRef deviceName = NULL;
709
+ UInt32 size = sizeof(deviceName);
710
+ AudioObjectPropertyAddress nameAddress = {
711
+ kAudioDevicePropertyDeviceNameCFString,
712
+ kAudioDevicePropertyScopeInput,
713
+ kAudioObjectPropertyElementMain
714
+ };
715
+
716
+ status = AudioObjectGetPropertyData(deviceID, &nameAddress, 0, NULL, &size, &deviceName);
717
+
718
+ if (status == noErr && deviceName) {
719
+ Napi::Object deviceObj = Napi::Object::New(env);
720
+ deviceObj.Set("id", Napi::String::New(env, std::to_string(deviceID)));
816
721
 
817
- if (stream) {
818
- CFRelease(stream);
819
- hasScreenPermission = true;
722
+ const char *name = CFStringGetCStringPtr(deviceName, kCFStringEncodingUTF8);
723
+ if (name) {
724
+ deviceObj.Set("name", Napi::String::New(env, name));
820
725
  } else {
821
- hasScreenPermission = false;
726
+ deviceObj.Set("name", Napi::String::New(env, "Unknown Device"));
822
727
  }
728
+
729
+ devices.Set(index++, deviceObj);
730
+ CFRelease(deviceName);
823
731
  }
824
732
  }
825
-
826
- // For audio permission, we'll use a simpler check since we're using CoreAudio
827
- bool hasAudioPermission = true;
828
-
829
- return Napi::Boolean::New(env, hasScreenPermission && hasAudioPermission);
830
-
831
- } @catch (NSException *exception) {
832
- return Napi::Boolean::New(env, false);
833
733
  }
734
+
735
+ free(audioDevices);
736
+ return devices;
834
737
  }
835
738
 
836
- // Initialize NAPI Module
739
+ // Initialize the addon
837
740
  Napi::Object Init(Napi::Env env, Napi::Object exports) {
838
- exports.Set(Napi::String::New(env, "startRecording"), Napi::Function::New(env, StartRecording));
839
- exports.Set(Napi::String::New(env, "stopRecording"), Napi::Function::New(env, StopRecording));
840
-
841
- exports.Set(Napi::String::New(env, "getAudioDevices"), Napi::Function::New(env, GetAudioDevices));
842
- exports.Set(Napi::String::New(env, "getDisplays"), Napi::Function::New(env, GetDisplays));
843
- exports.Set(Napi::String::New(env, "getWindows"), Napi::Function::New(env, GetWindows));
844
- exports.Set(Napi::String::New(env, "getRecordingStatus"), Napi::Function::New(env, GetRecordingStatus));
845
- exports.Set(Napi::String::New(env, "checkPermissions"), Napi::Function::New(env, CheckPermissions));
846
- // ScreenCaptureKit availability (optional for clients)
847
- exports.Set(Napi::String::New(env, "isScreenCaptureKitAvailable"), Napi::Function::New(env, [](const Napi::CallbackInfo& info){
848
- Napi::Env env = info.Env();
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);
854
- }));
855
-
856
- // Thumbnail functions
857
- exports.Set(Napi::String::New(env, "getWindowThumbnail"), Napi::Function::New(env, GetWindowThumbnail));
858
- exports.Set(Napi::String::New(env, "getDisplayThumbnail"), Napi::Function::New(env, GetDisplayThumbnail));
741
+ exports.Set("startRecording", Napi::Function::New(env, StartRecording));
742
+ exports.Set("stopRecording", Napi::Function::New(env, StopRecording));
743
+ exports.Set("isRecording", Napi::Function::New(env, IsRecording));
744
+ exports.Set("getDisplays", Napi::Function::New(env, GetDisplays));
745
+ exports.Set("getWindows", Napi::Function::New(env, GetWindows));
746
+ exports.Set("checkPermissions", Napi::Function::New(env, CheckPermissions));
747
+ exports.Set("getAudioDevices", Napi::Function::New(env, GetAudioDevices));
859
748
 
860
749
  // Initialize cursor tracker
861
750
  InitCursorTracker(env, exports);
@@ -866,4 +755,4 @@ Napi::Object Init(Napi::Env env, Napi::Object exports) {
866
755
  return exports;
867
756
  }
868
757
 
869
- NODE_API_MODULE(mac_recorder, Init)
758
+ NODE_API_MODULE(mac_recorder, Init)