node-mac-recorder 2.15.0 → 2.15.2

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.
@@ -0,0 +1,558 @@
1
+ #import "screen_capture_electron.h"
2
+ #import <AVFoundation/AVFoundation.h>
3
+ #import <CoreMedia/CoreMedia.h>
4
+ #import <AppKit/AppKit.h>
5
+
6
+ // Thread-safe recording state management
7
+ static SCStream * API_AVAILABLE(macos(12.3)) g_electronSafeStream = nil;
8
+ static BOOL g_electronSafeIsRecording = NO;
9
+ static NSString *g_electronSafeOutputPath = nil;
10
+ static dispatch_queue_t g_electronSafeQueue = nil;
11
+
12
+ // Initialize the safe queue once
13
+ static void initializeSafeQueue() {
14
+ static dispatch_once_t onceToken;
15
+ dispatch_once(&onceToken, ^{
16
+ g_electronSafeQueue = dispatch_queue_create("com.macrecorder.electron.safe", DISPATCH_QUEUE_SERIAL);
17
+ });
18
+ }
19
+
20
+ @interface ElectronSafeStreamDelegate : NSObject <SCStreamDelegate>
21
+ @end
22
+
23
+ @implementation ElectronSafeStreamDelegate
24
+
25
+ - (void)stream:(SCStream * API_AVAILABLE(macos(12.3)))stream didStopWithError:(NSError *)error API_AVAILABLE(macos(12.3)) {
26
+ NSLog(@"🛑 Electron-safe stream stopped");
27
+
28
+ // Use the safe queue to prevent race conditions
29
+ dispatch_async(g_electronSafeQueue, ^{
30
+ g_electronSafeIsRecording = NO;
31
+
32
+ if (error) {
33
+ NSLog(@"❌ Stream error: %@", error);
34
+ } else {
35
+ NSLog(@"✅ Stream stopped cleanly");
36
+ }
37
+
38
+ // Clean up safely
39
+ dispatch_async(dispatch_get_main_queue(), ^{
40
+ [ElectronSafeScreenCapture cleanupSafely];
41
+ });
42
+ });
43
+ }
44
+
45
+ @end
46
+
47
+ @implementation ElectronSafeScreenCapture
48
+
49
+ + (void)load {
50
+ initializeSafeQueue();
51
+ }
52
+
53
+ + (BOOL)startRecordingWithPath:(NSString *)outputPath options:(NSDictionary *)options {
54
+ if (@available(macOS 12.3, *)) {
55
+ return [self startRecordingModern:outputPath options:options];
56
+ } else {
57
+ NSLog(@"❌ ScreenCaptureKit not available on this macOS version");
58
+ return NO;
59
+ }
60
+ }
61
+
62
+ + (BOOL)startRecordingModern:(NSString *)outputPath options:(NSDictionary *)options API_AVAILABLE(macos(12.3)) {
63
+ __block BOOL success = NO;
64
+
65
+ dispatch_sync(g_electronSafeQueue, ^{
66
+ if (g_electronSafeIsRecording) {
67
+ NSLog(@"⚠️ Recording already in progress");
68
+ return;
69
+ }
70
+
71
+ g_electronSafeOutputPath = [outputPath copy];
72
+
73
+ // Get shareable content safely
74
+ [SCShareableContent getShareableContentWithCompletionHandler:^(SCShareableContent *content, NSError *error) {
75
+ if (error) {
76
+ NSLog(@"❌ Failed to get shareable content: %@", error);
77
+ return;
78
+ }
79
+
80
+ // Configure recording safely
81
+ [self configureAndStartRecording:content options:options completion:^(BOOL recordingSuccess) {
82
+ success = recordingSuccess;
83
+ }];
84
+ }];
85
+ });
86
+
87
+ return success;
88
+ }
89
+
90
+ + (void)configureAndStartRecording:(SCShareableContent *)content
91
+ options:(NSDictionary *)options
92
+ completion:(void(^)(BOOL))completion API_AVAILABLE(macos(12.3)) {
93
+
94
+ @try {
95
+ // Create content filter based on options
96
+ SCContentFilter *filter = nil;
97
+
98
+ NSNumber *windowId = options[@"windowId"];
99
+ NSNumber *displayId = options[@"displayId"];
100
+
101
+ if (windowId && [windowId unsignedIntValue] != 0) {
102
+ // Window recording
103
+ SCWindow *targetWindow = nil;
104
+ for (SCWindow *window in content.windows) {
105
+ if (window.windowID == [windowId unsignedIntValue]) {
106
+ targetWindow = window;
107
+ break;
108
+ }
109
+ }
110
+
111
+ if (targetWindow) {
112
+ filter = [[SCContentFilter alloc] initWithDesktopIndependentWindow:targetWindow];
113
+ NSLog(@"✅ Window filter created for window ID: %@", windowId);
114
+ } else {
115
+ NSLog(@"❌ Window not found: %@", windowId);
116
+ completion(NO);
117
+ return;
118
+ }
119
+ } else {
120
+ // Display recording (default)
121
+ SCDisplay *targetDisplay = nil;
122
+
123
+ if (displayId) {
124
+ for (SCDisplay *display in content.displays) {
125
+ if (display.displayID == [displayId unsignedIntValue]) {
126
+ targetDisplay = display;
127
+ break;
128
+ }
129
+ }
130
+ }
131
+
132
+ if (!targetDisplay && content.displays.count > 0) {
133
+ targetDisplay = content.displays[0]; // Default to first display
134
+ }
135
+
136
+ if (targetDisplay) {
137
+ filter = [[SCContentFilter alloc] initWithDisplay:targetDisplay excludingWindows:@[]];
138
+ NSLog(@"✅ Display filter created for display ID: %u", targetDisplay.displayID);
139
+ } else {
140
+ NSLog(@"❌ No display available");
141
+ completion(NO);
142
+ return;
143
+ }
144
+ }
145
+
146
+ // Configure stream
147
+ SCStreamConfiguration *config = [[SCStreamConfiguration alloc] init];
148
+
149
+ // Audio configuration (only available on macOS 13.0+)
150
+ if (@available(macOS 13.0, *)) {
151
+ config.capturesAudio = [options[@"includeMicrophone"] boolValue] || [options[@"includeSystemAudio"] boolValue];
152
+ config.sampleRate = 44100;
153
+ config.channelCount = 2;
154
+ }
155
+
156
+ // Video configuration
157
+ config.width = 1920;
158
+ config.height = 1080;
159
+ config.minimumFrameInterval = CMTimeMake(1, 30); // 30 FPS
160
+ config.queueDepth = 8;
161
+
162
+ // Capture area if specified
163
+ NSDictionary *captureArea = options[@"captureArea"];
164
+ if (captureArea) {
165
+ CGRect sourceRect = CGRectMake(
166
+ [captureArea[@"x"] doubleValue],
167
+ [captureArea[@"y"] doubleValue],
168
+ [captureArea[@"width"] doubleValue],
169
+ [captureArea[@"height"] doubleValue]
170
+ );
171
+ config.sourceRect = sourceRect;
172
+ config.width = (size_t)sourceRect.size.width;
173
+ config.height = (size_t)sourceRect.size.height;
174
+ }
175
+
176
+ // Cursor capture
177
+ config.showsCursor = [options[@"captureCursor"] boolValue];
178
+
179
+ // Create delegate
180
+ ElectronSafeStreamDelegate *delegate = [[ElectronSafeStreamDelegate alloc] init];
181
+
182
+ // Create stream
183
+ NSError *streamError = nil;
184
+ g_electronSafeStream = [[SCStream alloc] initWithFilter:filter
185
+ configuration:config
186
+ delegate:delegate];
187
+
188
+ if (!g_electronSafeStream) {
189
+ NSLog(@"❌ Failed to create stream: %@", streamError);
190
+ completion(NO);
191
+ return;
192
+ }
193
+
194
+ // Start recording to file
195
+ [self startRecordingToFile:g_electronSafeOutputPath completion:completion];
196
+
197
+ } @catch (NSException *e) {
198
+ NSLog(@"❌ Exception in configureAndStartRecording: %@", e.reason);
199
+ completion(NO);
200
+ }
201
+ }
202
+
203
+ + (void)startRecordingToFile:(NSString *)outputPath completion:(void(^)(BOOL))completion API_AVAILABLE(macos(12.3)) {
204
+ if (@available(macOS 15.0, *)) {
205
+ // Use SCRecordingOutput for macOS 15.0+
206
+ [self startRecordingWithSCRecordingOutput:outputPath completion:completion];
207
+ } else {
208
+ // Fallback to sample buffer capture for macOS 12.3-14.x
209
+ [self startRecordingWithSampleBuffers:outputPath completion:completion];
210
+ }
211
+ }
212
+
213
+ + (void)startRecordingWithSCRecordingOutput:(NSString *)outputPath completion:(void(^)(BOOL))completion API_AVAILABLE(macos(15.0)) {
214
+ @try {
215
+ // Note: SCRecordingOutput is only available on macOS 15.0+
216
+ // For now, we'll use the sample buffer approach for compatibility
217
+ NSLog(@"⚠️ SCRecordingOutput not implemented for compatibility - using fallback");
218
+
219
+ [g_electronSafeStream startCaptureWithCompletionHandler:^(NSError *error) {
220
+ dispatch_async(g_electronSafeQueue, ^{
221
+ if (error) {
222
+ NSLog(@"❌ Failed to start capture: %@", error);
223
+ g_electronSafeIsRecording = NO;
224
+ completion(NO);
225
+ } else {
226
+ NSLog(@"✅ Electron-safe recording started with SCRecordingOutput");
227
+ g_electronSafeIsRecording = YES;
228
+ completion(YES);
229
+ }
230
+ });
231
+ }];
232
+
233
+ } @catch (NSException *e) {
234
+ NSLog(@"❌ Exception in startRecordingWithSCRecordingOutput: %@", e.reason);
235
+ completion(NO);
236
+ }
237
+ }
238
+
239
+ + (void)startRecordingWithSampleBuffers:(NSString *)outputPath completion:(void(^)(BOOL))completion API_AVAILABLE(macos(12.3)) {
240
+ @try {
241
+ // For macOS 12.3-14.x, we'll use a simpler approach
242
+ // This is a fallback implementation
243
+
244
+ [g_electronSafeStream startCaptureWithCompletionHandler:^(NSError *error) {
245
+ dispatch_async(g_electronSafeQueue, ^{
246
+ if (error) {
247
+ NSLog(@"❌ Failed to start capture: %@", error);
248
+ g_electronSafeIsRecording = NO;
249
+ completion(NO);
250
+ } else {
251
+ NSLog(@"✅ Electron-safe recording started with sample buffers");
252
+ g_electronSafeIsRecording = YES;
253
+ completion(YES);
254
+ }
255
+ });
256
+ }];
257
+
258
+ } @catch (NSException *e) {
259
+ NSLog(@"❌ Exception in startRecordingWithSampleBuffers: %@", e.reason);
260
+ completion(NO);
261
+ }
262
+ }
263
+
264
+ + (BOOL)stopRecordingSafely {
265
+ __block BOOL success = NO;
266
+
267
+ dispatch_sync(g_electronSafeQueue, ^{
268
+ if (!g_electronSafeIsRecording) {
269
+ NSLog(@"⚠️ No recording in progress");
270
+ success = YES; // Not an error
271
+ return;
272
+ }
273
+
274
+ @try {
275
+ if (@available(macOS 12.3, *)) {
276
+ if (g_electronSafeStream) {
277
+ [g_electronSafeStream stopCaptureWithCompletionHandler:^(NSError *error) {
278
+ dispatch_async(g_electronSafeQueue, ^{
279
+ if (error) {
280
+ NSLog(@"❌ Failed to stop capture: %@", error);
281
+ } else {
282
+ NSLog(@"✅ Capture stopped successfully");
283
+ }
284
+
285
+ [ElectronSafeScreenCapture cleanupSafely];
286
+ });
287
+ }];
288
+ success = YES;
289
+ } else {
290
+ NSLog(@"⚠️ No stream to stop");
291
+ success = YES;
292
+ }
293
+ }
294
+ } @catch (NSException *e) {
295
+ NSLog(@"❌ Exception stopping recording: %@", e.reason);
296
+ [ElectronSafeScreenCapture cleanupSafely];
297
+ success = NO;
298
+ }
299
+ });
300
+
301
+ return success;
302
+ }
303
+
304
+ + (void)cleanupSafely {
305
+ dispatch_async(g_electronSafeQueue, ^{
306
+ @try {
307
+ if (@available(macOS 12.3, *)) {
308
+ g_electronSafeStream = nil;
309
+ }
310
+ g_electronSafeIsRecording = NO;
311
+ g_electronSafeOutputPath = nil;
312
+
313
+ NSLog(@"✅ Electron-safe cleanup completed");
314
+
315
+ } @catch (NSException *e) {
316
+ NSLog(@"❌ Exception during cleanup: %@", e.reason);
317
+ }
318
+ });
319
+ }
320
+
321
+ + (BOOL)isRecording {
322
+ __block BOOL recording = NO;
323
+ dispatch_sync(g_electronSafeQueue, ^{
324
+ recording = g_electronSafeIsRecording;
325
+ });
326
+ return recording;
327
+ }
328
+
329
+ + (NSArray *)getAvailableDisplays {
330
+ NSMutableArray *displays = [NSMutableArray array];
331
+
332
+ @try {
333
+ // Get all displays using Core Graphics
334
+ uint32_t displayCount = 0;
335
+ CGGetActiveDisplayList(0, NULL, &displayCount);
336
+
337
+ if (displayCount > 0) {
338
+ CGDirectDisplayID *displayList = (CGDirectDisplayID *)malloc(displayCount * sizeof(CGDirectDisplayID));
339
+ CGGetActiveDisplayList(displayCount, displayList, &displayCount);
340
+
341
+ for (uint32_t i = 0; i < displayCount; i++) {
342
+ CGDirectDisplayID displayID = displayList[i];
343
+
344
+ CGRect bounds = CGDisplayBounds(displayID);
345
+ NSString *name = [NSString stringWithFormat:@"Display %u", displayID];
346
+
347
+ NSDictionary *displayInfo = @{
348
+ @"id": @(displayID),
349
+ @"name": name,
350
+ @"width": @((int)bounds.size.width),
351
+ @"height": @((int)bounds.size.height),
352
+ @"x": @((int)bounds.origin.x),
353
+ @"y": @((int)bounds.origin.y),
354
+ @"isPrimary": @(CGDisplayIsMain(displayID))
355
+ };
356
+
357
+ [displays addObject:displayInfo];
358
+ }
359
+
360
+ free(displayList);
361
+ }
362
+
363
+ } @catch (NSException *e) {
364
+ NSLog(@"❌ Exception getting displays: %@", e.reason);
365
+ }
366
+
367
+ return [displays copy];
368
+ }
369
+
370
+ + (NSArray *)getAvailableWindows {
371
+ NSMutableArray *windows = [NSMutableArray array];
372
+
373
+ @try {
374
+ if (@available(macOS 12.3, *)) {
375
+ [SCShareableContent getShareableContentWithCompletionHandler:^(SCShareableContent *content, NSError *error) {
376
+ if (!error && content) {
377
+ for (SCWindow *window in content.windows) {
378
+ // Skip system windows and our own
379
+ if (window.frame.size.width < 50 || window.frame.size.height < 50) continue;
380
+ if (!window.title || window.title.length == 0) continue;
381
+
382
+ NSString *appName = window.owningApplication.applicationName ?: @"Unknown";
383
+
384
+ // Skip Electron windows (our overlay)
385
+ if ([appName containsString:@"Electron"] || [appName containsString:@"node"]) continue;
386
+
387
+ NSDictionary *windowInfo = @{
388
+ @"id": @(window.windowID),
389
+ @"name": window.title,
390
+ @"appName": appName,
391
+ @"x": @((int)window.frame.origin.x),
392
+ @"y": @((int)window.frame.origin.y),
393
+ @"width": @((int)window.frame.size.width),
394
+ @"height": @((int)window.frame.size.height),
395
+ @"isOnScreen": @(window.isOnScreen)
396
+ };
397
+
398
+ [windows addObject:windowInfo];
399
+ }
400
+ }
401
+ }];
402
+ }
403
+
404
+ } @catch (NSException *e) {
405
+ NSLog(@"❌ Exception getting windows: %@", e.reason);
406
+ }
407
+
408
+ return [windows copy];
409
+ }
410
+
411
+ + (BOOL)checkPermissions {
412
+ @try {
413
+ // Check screen recording permission
414
+ if (@available(macOS 10.15, *)) {
415
+ CGRequestScreenCaptureAccess();
416
+
417
+ // Create a small test image to verify permission
418
+ CGImageRef testImage = CGDisplayCreateImage(CGMainDisplayID());
419
+ if (testImage) {
420
+ CFRelease(testImage);
421
+ return YES;
422
+ }
423
+ }
424
+
425
+ return NO;
426
+
427
+ } @catch (NSException *e) {
428
+ NSLog(@"❌ Exception checking permissions: %@", e.reason);
429
+ return NO;
430
+ }
431
+ }
432
+
433
+ + (NSString *)getDisplayThumbnailBase64:(CGDirectDisplayID)displayID
434
+ maxWidth:(NSInteger)maxWidth
435
+ maxHeight:(NSInteger)maxHeight {
436
+ @try {
437
+ CGImageRef screenshot = CGDisplayCreateImage(displayID);
438
+ if (!screenshot) {
439
+ return nil;
440
+ }
441
+
442
+ // Resize image if needed
443
+ CGSize originalSize = CGSizeMake(CGImageGetWidth(screenshot), CGImageGetHeight(screenshot));
444
+ CGSize newSize = [self calculateThumbnailSize:originalSize maxWidth:maxWidth maxHeight:maxHeight];
445
+
446
+ NSData *imageData = [self createPNGDataFromImage:screenshot size:newSize];
447
+ CFRelease(screenshot);
448
+
449
+ if (imageData) {
450
+ return [imageData base64EncodedStringWithOptions:0];
451
+ }
452
+
453
+ return nil;
454
+
455
+ } @catch (NSException *e) {
456
+ NSLog(@"❌ Exception creating display thumbnail: %@", e.reason);
457
+ return nil;
458
+ }
459
+ }
460
+
461
+ + (NSString *)getWindowThumbnailBase64:(uint32_t)windowID
462
+ maxWidth:(NSInteger)maxWidth
463
+ maxHeight:(NSInteger)maxHeight {
464
+ @try {
465
+ CGImageRef screenshot = CGWindowListCreateImage(CGRectNull,
466
+ kCGWindowListOptionIncludingWindow,
467
+ windowID,
468
+ kCGWindowImageDefault);
469
+ if (!screenshot) {
470
+ return nil;
471
+ }
472
+
473
+ // Resize image if needed
474
+ CGSize originalSize = CGSizeMake(CGImageGetWidth(screenshot), CGImageGetHeight(screenshot));
475
+ CGSize newSize = [self calculateThumbnailSize:originalSize maxWidth:maxWidth maxHeight:maxHeight];
476
+
477
+ NSData *imageData = [self createPNGDataFromImage:screenshot size:newSize];
478
+ CFRelease(screenshot);
479
+
480
+ if (imageData) {
481
+ return [imageData base64EncodedStringWithOptions:0];
482
+ }
483
+
484
+ return nil;
485
+
486
+ } @catch (NSException *e) {
487
+ NSLog(@"❌ Exception creating window thumbnail: %@", e.reason);
488
+ return nil;
489
+ }
490
+ }
491
+
492
+ + (CGSize)calculateThumbnailSize:(CGSize)originalSize maxWidth:(NSInteger)maxWidth maxHeight:(NSInteger)maxHeight {
493
+ CGFloat aspectRatio = originalSize.width / originalSize.height;
494
+ CGFloat newWidth = originalSize.width;
495
+ CGFloat newHeight = originalSize.height;
496
+
497
+ if (newWidth > maxWidth) {
498
+ newWidth = maxWidth;
499
+ newHeight = newWidth / aspectRatio;
500
+ }
501
+
502
+ if (newHeight > maxHeight) {
503
+ newHeight = maxHeight;
504
+ newWidth = newHeight * aspectRatio;
505
+ }
506
+
507
+ return CGSizeMake(newWidth, newHeight);
508
+ }
509
+
510
+ + (NSData *)createPNGDataFromImage:(CGImageRef)image size:(CGSize)size {
511
+ @try {
512
+ CGColorSpaceRef colorSpace = CGColorSpaceCreateDeviceRGB();
513
+ CGContextRef context = CGBitmapContextCreate(NULL,
514
+ (size_t)size.width,
515
+ (size_t)size.height,
516
+ 8,
517
+ 0,
518
+ colorSpace,
519
+ kCGImageAlphaPremultipliedLast);
520
+
521
+ if (!context) {
522
+ CGColorSpaceRelease(colorSpace);
523
+ return nil;
524
+ }
525
+
526
+ CGContextDrawImage(context, CGRectMake(0, 0, size.width, size.height), image);
527
+ CGImageRef resizedImage = CGBitmapContextCreateImage(context);
528
+
529
+ CGContextRelease(context);
530
+ CGColorSpaceRelease(colorSpace);
531
+
532
+ if (!resizedImage) {
533
+ return nil;
534
+ }
535
+
536
+ NSMutableData *data = [NSMutableData data];
537
+ CGImageDestinationRef destination = CGImageDestinationCreateWithData((__bridge CFMutableDataRef)data,
538
+ kUTTypePNG,
539
+ 1,
540
+ NULL);
541
+
542
+ if (destination) {
543
+ CGImageDestinationAddImage(destination, resizedImage, NULL);
544
+ CGImageDestinationFinalize(destination);
545
+ CFRelease(destination);
546
+ }
547
+
548
+ CGImageRelease(resizedImage);
549
+
550
+ return [data copy];
551
+
552
+ } @catch (NSException *e) {
553
+ NSLog(@"❌ Exception creating PNG data: %@", e.reason);
554
+ return nil;
555
+ }
556
+ }
557
+
558
+ @end