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.
- package/.claude/settings.local.json +6 -1
- package/README.md +17 -79
- package/backup/binding.gyp +44 -0
- package/backup/src/audio_capture.mm +116 -0
- package/backup/src/cursor_tracker.mm +518 -0
- package/backup/src/mac_recorder.mm +829 -0
- package/backup/src/screen_capture.h +19 -0
- package/backup/src/screen_capture.mm +162 -0
- package/backup/src/screen_capture_kit.h +15 -0
- package/backup/src/window_selector.mm +1457 -0
- package/binding.gyp +15 -10
- package/index.js +22 -62
- package/package.json +3 -8
- package/src/audio_capture.mm +96 -40
- package/src/cursor_tracker.mm +3 -4
- package/src/mac_recorder.mm +604 -715
- package/src/screen_capture.h +5 -0
- package/src/screen_capture.mm +141 -60
- package/src/window_selector.mm +12 -22
- package/test-api-compatibility.js +92 -0
- package/test-audio.js +94 -0
- package/test-comprehensive.js +164 -0
- package/test-recording.js +142 -0
- package/test-sck-simple.js +37 -0
- package/test-sck.js +50 -44
- package/test-sync.js +52 -0
- package/test-windows.js +57 -0
- package/prebuilds/darwin-arm64/node.napi.node +0 -0
- package/prebuilds/darwin-x64/node.napi.node +0 -0
- package/scripts/test-exclude.js +0 -72
- package/src/screen_capture_kit.mm +0 -222
package/src/mac_recorder.mm
CHANGED
|
@@ -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
|
-
|
|
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
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
|
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
|
|
45
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
55
|
-
return env
|
|
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
|
-
//
|
|
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
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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
|
-
|
|
195
|
-
|
|
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
|
-
|
|
200
|
-
|
|
201
|
-
|
|
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
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
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
|
-
|
|
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
|
-
|
|
267
|
-
|
|
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
|
-
|
|
270
|
-
|
|
271
|
-
|
|
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
|
-
|
|
280
|
-
|
|
281
|
-
|
|
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
|
-
|
|
300
|
-
return windowArray;
|
|
301
|
-
}
|
|
318
|
+
SCDisplay *targetDisplay = nil;
|
|
302
319
|
|
|
303
|
-
|
|
304
|
-
|
|
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
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
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
|
-
|
|
336
|
-
|
|
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
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
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
|
-
|
|
407
|
-
|
|
352
|
+
// Update displayID for subsequent use
|
|
353
|
+
displayID = targetDisplay.displayID;
|
|
408
354
|
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
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
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
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
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
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
|
-
|
|
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
|
-
|
|
504
|
-
|
|
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:
|
|
509
|
-
Napi::Value
|
|
551
|
+
// NAPI Function: Stop Recording
|
|
552
|
+
Napi::Value StopRecording(const Napi::CallbackInfo& info) {
|
|
510
553
|
Napi::Env env = info.Env();
|
|
511
554
|
|
|
512
|
-
|
|
513
|
-
|
|
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
|
|
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
|
|
553
|
-
Napi::Value
|
|
569
|
+
// NAPI Function: Get Displays
|
|
570
|
+
Napi::Value GetDisplays(const Napi::CallbackInfo& info) {
|
|
554
571
|
Napi::Env env = info.Env();
|
|
555
572
|
|
|
556
|
-
if (
|
|
557
|
-
|
|
558
|
-
return
|
|
573
|
+
if (!isScreenCaptureKitAvailable()) {
|
|
574
|
+
// Fallback to legacy method
|
|
575
|
+
return GetAvailableDisplays(info);
|
|
559
576
|
}
|
|
560
577
|
|
|
561
|
-
|
|
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
|
-
|
|
564
|
-
|
|
565
|
-
|
|
583
|
+
[SCShareableContent getShareableContentWithCompletionHandler:^(SCShareableContent * _Nullable content, NSError * _Nullable err) {
|
|
584
|
+
shareableContent = content;
|
|
585
|
+
error = err;
|
|
586
|
+
dispatch_semaphore_signal(semaphore);
|
|
587
|
+
}];
|
|
566
588
|
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
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
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
);
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
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
|
-
|
|
650
|
-
|
|
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 (
|
|
654
|
-
|
|
655
|
-
return
|
|
617
|
+
if (!isScreenCaptureKitAvailable()) {
|
|
618
|
+
// Use legacy CGWindowList method
|
|
619
|
+
return GetWindowList(info);
|
|
656
620
|
}
|
|
657
621
|
|
|
658
|
-
|
|
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
|
-
|
|
661
|
-
|
|
662
|
-
|
|
627
|
+
[SCShareableContent getShareableContentWithCompletionHandler:^(SCShareableContent * _Nullable content, NSError * _Nullable err) {
|
|
628
|
+
shareableContent = content;
|
|
629
|
+
error = err;
|
|
630
|
+
dispatch_semaphore_signal(semaphore);
|
|
631
|
+
}];
|
|
663
632
|
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
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
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
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
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
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
|
-
|
|
818
|
-
|
|
819
|
-
|
|
722
|
+
const char *name = CFStringGetCStringPtr(deviceName, kCFStringEncodingUTF8);
|
|
723
|
+
if (name) {
|
|
724
|
+
deviceObj.Set("name", Napi::String::New(env, name));
|
|
820
725
|
} else {
|
|
821
|
-
|
|
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
|
|
739
|
+
// Initialize the addon
|
|
837
740
|
Napi::Object Init(Napi::Env env, Napi::Object exports) {
|
|
838
|
-
exports.Set(
|
|
839
|
-
exports.Set(
|
|
840
|
-
|
|
841
|
-
exports.Set(
|
|
842
|
-
exports.Set(
|
|
843
|
-
exports.Set(
|
|
844
|
-
exports.Set(
|
|
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)
|