node-mac-recorder 1.17.1 → 2.0.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/binding.gyp +1 -0
- package/index.js +84 -31
- package/package.json +8 -3
- package/prebuilds/darwin-arm64/node.napi.node +0 -0
- package/prebuilds/darwin-x64/node.napi.node +0 -0
- package/src/mac_recorder.mm +97 -5
- package/src/screen_capture_kit.mm +202 -0
package/binding.gyp
CHANGED
package/index.js
CHANGED
|
@@ -2,19 +2,24 @@ const { EventEmitter } = require("events");
|
|
|
2
2
|
const path = require("path");
|
|
3
3
|
const fs = require("fs");
|
|
4
4
|
|
|
5
|
-
// Native modülü yükle
|
|
5
|
+
// Native modülü yükle (prebuilt > local build fallback)
|
|
6
6
|
let nativeBinding;
|
|
7
7
|
try {
|
|
8
|
-
|
|
9
|
-
|
|
8
|
+
// Prebuilt
|
|
9
|
+
nativeBinding = require("node-gyp-build")(__dirname);
|
|
10
|
+
} catch (e) {
|
|
10
11
|
try {
|
|
11
|
-
nativeBinding = require("./build/
|
|
12
|
-
} catch (
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
12
|
+
nativeBinding = require("./build/Release/mac_recorder.node");
|
|
13
|
+
} catch (error) {
|
|
14
|
+
try {
|
|
15
|
+
nativeBinding = require("./build/Debug/mac_recorder.node");
|
|
16
|
+
} catch (debugError) {
|
|
17
|
+
throw new Error(
|
|
18
|
+
'Native module not found. Please run "npm run build" to compile the native module.\n' +
|
|
19
|
+
"Original error: " +
|
|
20
|
+
(error?.message || e?.message)
|
|
21
|
+
);
|
|
22
|
+
}
|
|
18
23
|
}
|
|
19
24
|
}
|
|
20
25
|
|
|
@@ -45,6 +50,12 @@ class MacRecorder extends EventEmitter {
|
|
|
45
50
|
showClicks: false,
|
|
46
51
|
displayId: null, // Hangi ekranı kaydedeceği (null = ana ekran)
|
|
47
52
|
windowId: null, // Hangi pencereyi kaydedeceği (null = tam ekran)
|
|
53
|
+
// SC (gizli, opsiyonel) - mevcut kullanıcıları bozmaz
|
|
54
|
+
useScreenCaptureKit: false,
|
|
55
|
+
excludedAppBundleIds: [],
|
|
56
|
+
excludedPIDs: [],
|
|
57
|
+
excludedWindowIds: [],
|
|
58
|
+
autoExcludeSelf: !!(process.versions && process.versions.electron),
|
|
48
59
|
};
|
|
49
60
|
|
|
50
61
|
// Display cache için async initialization
|
|
@@ -118,6 +129,14 @@ class MacRecorder extends EventEmitter {
|
|
|
118
129
|
audioDeviceId: options.audioDeviceId || null, // null = default device
|
|
119
130
|
systemAudioDeviceId: options.systemAudioDeviceId || null, // null = auto-detect system audio device
|
|
120
131
|
captureArea: options.captureArea || null,
|
|
132
|
+
useScreenCaptureKit: options.useScreenCaptureKit || false,
|
|
133
|
+
excludedAppBundleIds: options.excludedAppBundleIds || [],
|
|
134
|
+
excludedPIDs: options.excludedPIDs || [],
|
|
135
|
+
excludedWindowIds: options.excludedWindowIds || [],
|
|
136
|
+
autoExcludeSelf:
|
|
137
|
+
typeof options.autoExcludeSelf === "boolean"
|
|
138
|
+
? options.autoExcludeSelf
|
|
139
|
+
: !!(process.versions && process.versions.electron),
|
|
121
140
|
};
|
|
122
141
|
}
|
|
123
142
|
|
|
@@ -167,11 +186,12 @@ class MacRecorder extends EventEmitter {
|
|
|
167
186
|
targetDisplayId = display.id; // Use actual display ID, not array index
|
|
168
187
|
// Koordinatları display'e göre normalize et
|
|
169
188
|
adjustedX = targetWindow.x - display.x;
|
|
170
|
-
|
|
189
|
+
|
|
171
190
|
// Y coordinate conversion: CGWindow (top-left) to AVFoundation (bottom-left)
|
|
172
191
|
// Overlay'deki dönüşümle aynı mantık: screenHeight - windowY - windowHeight
|
|
173
192
|
const displayHeight = parseInt(display.resolution.split("x")[1]);
|
|
174
|
-
const convertedY =
|
|
193
|
+
const convertedY =
|
|
194
|
+
displayHeight - targetWindow.y - targetWindow.height;
|
|
175
195
|
adjustedY = Math.max(0, convertedY - display.y);
|
|
176
196
|
break;
|
|
177
197
|
}
|
|
@@ -206,7 +226,9 @@ class MacRecorder extends EventEmitter {
|
|
|
206
226
|
this.options.displayId = targetDisplayId;
|
|
207
227
|
|
|
208
228
|
// Recording için display bilgisini sakla (cursor capture için)
|
|
209
|
-
const targetDisplay = displays.find(
|
|
229
|
+
const targetDisplay = displays.find(
|
|
230
|
+
(d) => d.id === targetDisplayId
|
|
231
|
+
);
|
|
210
232
|
this.recordingDisplayInfo = {
|
|
211
233
|
displayId: targetDisplayId,
|
|
212
234
|
x: targetDisplay.x,
|
|
@@ -239,7 +261,9 @@ class MacRecorder extends EventEmitter {
|
|
|
239
261
|
if (this.options.displayId !== null && !this.recordingDisplayInfo) {
|
|
240
262
|
try {
|
|
241
263
|
const displays = await this.getDisplays();
|
|
242
|
-
const targetDisplay = displays.find(
|
|
264
|
+
const targetDisplay = displays.find(
|
|
265
|
+
(d) => d.id === this.options.displayId
|
|
266
|
+
);
|
|
243
267
|
if (targetDisplay) {
|
|
244
268
|
this.recordingDisplayInfo = {
|
|
245
269
|
displayId: this.options.displayId,
|
|
@@ -273,6 +297,11 @@ class MacRecorder extends EventEmitter {
|
|
|
273
297
|
windowId: this.options.windowId || null, // null = tam ekran
|
|
274
298
|
audioDeviceId: this.options.audioDeviceId || null, // null = default device
|
|
275
299
|
systemAudioDeviceId: this.options.systemAudioDeviceId || null, // null = auto-detect system audio device
|
|
300
|
+
useScreenCaptureKit: this.options.useScreenCaptureKit || false,
|
|
301
|
+
excludedAppBundleIds: this.options.excludedAppBundleIds || [],
|
|
302
|
+
excludedPIDs: this.options.excludedPIDs || [],
|
|
303
|
+
excludedWindowIds: this.options.excludedWindowIds || [],
|
|
304
|
+
autoExcludeSelf: this.options.autoExcludeSelf === true,
|
|
276
305
|
};
|
|
277
306
|
|
|
278
307
|
// Manuel captureArea varsa onu kullan
|
|
@@ -285,10 +314,34 @@ class MacRecorder extends EventEmitter {
|
|
|
285
314
|
};
|
|
286
315
|
}
|
|
287
316
|
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
317
|
+
// SC yolu: kullanıcıdan SC talebi varsa veya exclude listeleri doluysa ve SC mevcutsa otomatik kullan
|
|
318
|
+
let success;
|
|
319
|
+
try {
|
|
320
|
+
const wantsSC = !!(
|
|
321
|
+
this.options.useScreenCaptureKit ||
|
|
322
|
+
this.options.excludedAppBundleIds?.length ||
|
|
323
|
+
this.options.excludedPIDs?.length ||
|
|
324
|
+
this.options.excludedWindowIds?.length
|
|
325
|
+
);
|
|
326
|
+
const scAvailable =
|
|
327
|
+
typeof nativeBinding.isScreenCaptureKitAvailable === "function" &&
|
|
328
|
+
nativeBinding.isScreenCaptureKitAvailable();
|
|
329
|
+
if (wantsSC && scAvailable) {
|
|
330
|
+
const scOptions = {
|
|
331
|
+
...recordingOptions,
|
|
332
|
+
useScreenCaptureKit: true,
|
|
333
|
+
};
|
|
334
|
+
success = nativeBinding.startRecording(outputPath, scOptions);
|
|
335
|
+
} else {
|
|
336
|
+
success = nativeBinding.startRecording(
|
|
337
|
+
outputPath,
|
|
338
|
+
recordingOptions
|
|
339
|
+
);
|
|
340
|
+
}
|
|
341
|
+
} catch (e) {
|
|
342
|
+
// Fallback AVFoundation
|
|
343
|
+
success = nativeBinding.startRecording(outputPath, recordingOptions);
|
|
344
|
+
}
|
|
292
345
|
|
|
293
346
|
if (success) {
|
|
294
347
|
this.isRecording = true;
|
|
@@ -310,13 +363,13 @@ class MacRecorder extends EventEmitter {
|
|
|
310
363
|
if (nativeStatus && !recordingStartedEmitted) {
|
|
311
364
|
recordingStartedEmitted = true;
|
|
312
365
|
clearInterval(checkRecordingStatus);
|
|
313
|
-
|
|
366
|
+
|
|
314
367
|
// Kayıt gerçekten başladığı anda event emit et
|
|
315
368
|
this.emit("recordingStarted", {
|
|
316
369
|
outputPath: this.outputPath,
|
|
317
370
|
timestamp: Date.now(), // Gerçek başlangıç zamanı
|
|
318
371
|
options: this.options,
|
|
319
|
-
nativeConfirmed: true
|
|
372
|
+
nativeConfirmed: true,
|
|
320
373
|
});
|
|
321
374
|
}
|
|
322
375
|
} catch (error) {
|
|
@@ -328,12 +381,12 @@ class MacRecorder extends EventEmitter {
|
|
|
328
381
|
outputPath: this.outputPath,
|
|
329
382
|
timestamp: this.recordingStartTime,
|
|
330
383
|
options: this.options,
|
|
331
|
-
nativeConfirmed: false
|
|
384
|
+
nativeConfirmed: false,
|
|
332
385
|
});
|
|
333
386
|
}
|
|
334
387
|
}
|
|
335
388
|
}, 50); // Her 50ms kontrol et
|
|
336
|
-
|
|
389
|
+
|
|
337
390
|
// Timeout fallback - 5 saniye sonra hala başlamamışsa emit et
|
|
338
391
|
setTimeout(() => {
|
|
339
392
|
if (!recordingStartedEmitted) {
|
|
@@ -343,11 +396,11 @@ class MacRecorder extends EventEmitter {
|
|
|
343
396
|
outputPath: this.outputPath,
|
|
344
397
|
timestamp: this.recordingStartTime,
|
|
345
398
|
options: this.options,
|
|
346
|
-
nativeConfirmed: false
|
|
399
|
+
nativeConfirmed: false,
|
|
347
400
|
});
|
|
348
401
|
}
|
|
349
402
|
}, 5000);
|
|
350
|
-
|
|
403
|
+
|
|
351
404
|
this.emit("started", this.outputPath);
|
|
352
405
|
resolve(this.outputPath);
|
|
353
406
|
} else {
|
|
@@ -588,7 +641,7 @@ class MacRecorder extends EventEmitter {
|
|
|
588
641
|
width: options.windowInfo.width,
|
|
589
642
|
height: options.windowInfo.height,
|
|
590
643
|
windowRelative: true,
|
|
591
|
-
windowInfo: options.windowInfo
|
|
644
|
+
windowInfo: options.windowInfo,
|
|
592
645
|
};
|
|
593
646
|
} else if (this.recordingDisplayInfo) {
|
|
594
647
|
// Recording başlatılmışsa o display'i kullan
|
|
@@ -644,7 +697,7 @@ class MacRecorder extends EventEmitter {
|
|
|
644
697
|
if (this.cursorDisplayInfo.windowRelative) {
|
|
645
698
|
// Window-relative koordinatlar
|
|
646
699
|
coordinateSystem = "window-relative";
|
|
647
|
-
|
|
700
|
+
|
|
648
701
|
// Window bounds kontrolü - cursor window dışındaysa kaydetme
|
|
649
702
|
if (
|
|
650
703
|
x < 0 ||
|
|
@@ -657,7 +710,7 @@ class MacRecorder extends EventEmitter {
|
|
|
657
710
|
} else {
|
|
658
711
|
// Display-relative koordinatlar
|
|
659
712
|
coordinateSystem = "display-relative";
|
|
660
|
-
|
|
713
|
+
|
|
661
714
|
// Display bounds kontrolü
|
|
662
715
|
if (
|
|
663
716
|
x < 0 ||
|
|
@@ -682,9 +735,9 @@ class MacRecorder extends EventEmitter {
|
|
|
682
735
|
windowInfo: {
|
|
683
736
|
width: this.cursorDisplayInfo.width,
|
|
684
737
|
height: this.cursorDisplayInfo.height,
|
|
685
|
-
originalWindow: this.cursorDisplayInfo.windowInfo
|
|
686
|
-
}
|
|
687
|
-
})
|
|
738
|
+
originalWindow: this.cursorDisplayInfo.windowInfo,
|
|
739
|
+
},
|
|
740
|
+
}),
|
|
688
741
|
};
|
|
689
742
|
|
|
690
743
|
// Sadece eventType değiştiğinde veya pozisyon değiştiğinde kaydet
|
|
@@ -931,6 +984,6 @@ class MacRecorder extends EventEmitter {
|
|
|
931
984
|
}
|
|
932
985
|
|
|
933
986
|
// WindowSelector modülünü de export edelim
|
|
934
|
-
MacRecorder.WindowSelector = require(
|
|
987
|
+
MacRecorder.WindowSelector = require("./window-selector");
|
|
935
988
|
|
|
936
989
|
module.exports = MacRecorder;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "node-mac-recorder",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.0.0",
|
|
4
4
|
"description": "Native macOS screen recording package for Node.js applications",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"keywords": [
|
|
@@ -38,14 +38,19 @@
|
|
|
38
38
|
"build": "node-gyp build",
|
|
39
39
|
"rebuild": "node-gyp rebuild",
|
|
40
40
|
"clean": "node-gyp clean",
|
|
41
|
+
"prebuild:darwin:arm64": "prebuildify --napi --strip --platform darwin --arch arm64",
|
|
42
|
+
"prebuild:darwin:x64": "prebuildify --napi --strip --platform darwin --arch x64",
|
|
43
|
+
"prebuild": "npm run prebuild:darwin:arm64 && npm run prebuild:darwin:x64",
|
|
41
44
|
"test:window-selector": "node window-selector-test.js",
|
|
42
45
|
"example:window-selector": "node examples/window-selector-example.js"
|
|
43
46
|
},
|
|
44
47
|
"dependencies": {
|
|
45
|
-
"node-addon-api": "^7.0.0"
|
|
48
|
+
"node-addon-api": "^7.0.0",
|
|
49
|
+
"node-gyp-build": "^4.6.0"
|
|
46
50
|
},
|
|
47
51
|
"devDependencies": {
|
|
48
|
-
"node-gyp": "^10.0.0"
|
|
52
|
+
"node-gyp": "^10.0.0",
|
|
53
|
+
"prebuildify": "^5.0.0"
|
|
49
54
|
},
|
|
50
55
|
"gypfile": true
|
|
51
56
|
}
|
|
Binary file
|
|
Binary file
|
package/src/mac_recorder.mm
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
|
|
10
10
|
// Import screen capture
|
|
11
11
|
#import "screen_capture.h"
|
|
12
|
+
#import "screen_capture_kit.h"
|
|
12
13
|
|
|
13
14
|
// Cursor tracker function declarations
|
|
14
15
|
Napi::Object InitCursorTracker(Napi::Env env, Napi::Object exports);
|
|
@@ -67,7 +68,7 @@ Napi::Value StartRecording(const Napi::CallbackInfo& info) {
|
|
|
67
68
|
|
|
68
69
|
std::string outputPath = info[0].As<Napi::String>().Utf8Value();
|
|
69
70
|
|
|
70
|
-
// Options parsing
|
|
71
|
+
// Options parsing (shared)
|
|
71
72
|
CGRect captureRect = CGRectNull;
|
|
72
73
|
bool captureCursor = false; // Default olarak cursor gizli
|
|
73
74
|
bool includeMicrophone = false; // Default olarak mikrofon kapalı
|
|
@@ -75,6 +76,12 @@ Napi::Value StartRecording(const Napi::CallbackInfo& info) {
|
|
|
75
76
|
CGDirectDisplayID displayID = CGMainDisplayID(); // Default ana ekran
|
|
76
77
|
NSString *audioDeviceId = nil; // Default audio device ID
|
|
77
78
|
NSString *systemAudioDeviceId = nil; // System audio device ID
|
|
79
|
+
bool forceUseSC = false;
|
|
80
|
+
// Exclude options for ScreenCaptureKit (optional, backward compatible)
|
|
81
|
+
NSMutableArray<NSString*> *excludedAppBundleIds = [NSMutableArray array];
|
|
82
|
+
NSMutableArray<NSNumber*> *excludedPIDs = [NSMutableArray array];
|
|
83
|
+
NSMutableArray<NSNumber*> *excludedWindowIds = [NSMutableArray array];
|
|
84
|
+
bool autoExcludeSelf = false;
|
|
78
85
|
|
|
79
86
|
if (info.Length() > 1 && info[1].IsObject()) {
|
|
80
87
|
Napi::Object options = info[1].As<Napi::Object>();
|
|
@@ -118,6 +125,43 @@ Napi::Value StartRecording(const Napi::CallbackInfo& info) {
|
|
|
118
125
|
std::string sysDeviceId = options.Get("systemAudioDeviceId").As<Napi::String>().Utf8Value();
|
|
119
126
|
systemAudioDeviceId = [NSString stringWithUTF8String:sysDeviceId.c_str()];
|
|
120
127
|
}
|
|
128
|
+
|
|
129
|
+
// ScreenCaptureKit toggle (optional)
|
|
130
|
+
if (options.Has("useScreenCaptureKit")) {
|
|
131
|
+
forceUseSC = options.Get("useScreenCaptureKit").As<Napi::Boolean>();
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Exclusion lists (optional)
|
|
135
|
+
if (options.Has("excludedAppBundleIds") && options.Get("excludedAppBundleIds").IsArray()) {
|
|
136
|
+
Napi::Array arr = options.Get("excludedAppBundleIds").As<Napi::Array>();
|
|
137
|
+
for (uint32_t i = 0; i < arr.Length(); i++) {
|
|
138
|
+
if (!arr.Get(i).IsUndefined() && !arr.Get(i).IsNull()) {
|
|
139
|
+
std::string s = arr.Get(i).As<Napi::String>().Utf8Value();
|
|
140
|
+
[excludedAppBundleIds addObject:[NSString stringWithUTF8String:s.c_str()]];
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
if (options.Has("excludedPIDs") && options.Get("excludedPIDs").IsArray()) {
|
|
145
|
+
Napi::Array arr = options.Get("excludedPIDs").As<Napi::Array>();
|
|
146
|
+
for (uint32_t i = 0; i < arr.Length(); i++) {
|
|
147
|
+
if (!arr.Get(i).IsUndefined() && !arr.Get(i).IsNull()) {
|
|
148
|
+
double v = arr.Get(i).As<Napi::Number>().DoubleValue();
|
|
149
|
+
[excludedPIDs addObject:@( (pid_t)v )];
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
if (options.Has("excludedWindowIds") && options.Get("excludedWindowIds").IsArray()) {
|
|
154
|
+
Napi::Array arr = options.Get("excludedWindowIds").As<Napi::Array>();
|
|
155
|
+
for (uint32_t i = 0; i < arr.Length(); i++) {
|
|
156
|
+
if (!arr.Get(i).IsUndefined() && !arr.Get(i).IsNull()) {
|
|
157
|
+
double v = arr.Get(i).As<Napi::Number>().DoubleValue();
|
|
158
|
+
[excludedWindowIds addObject:@( (uint32_t)v )];
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
if (options.Has("autoExcludeSelf")) {
|
|
163
|
+
autoExcludeSelf = options.Get("autoExcludeSelf").As<Napi::Boolean>();
|
|
164
|
+
}
|
|
121
165
|
|
|
122
166
|
// Display ID
|
|
123
167
|
if (options.Has("displayId") && !options.Get("displayId").IsNull()) {
|
|
@@ -159,6 +203,43 @@ Napi::Value StartRecording(const Napi::CallbackInfo& info) {
|
|
|
159
203
|
}
|
|
160
204
|
|
|
161
205
|
@try {
|
|
206
|
+
// Prefer ScreenCaptureKit if requested or if exclusion lists provided and available
|
|
207
|
+
bool wantsSC = forceUseSC || excludedAppBundleIds.count > 0 || excludedPIDs.count > 0 || excludedWindowIds.count > 0 || autoExcludeSelf;
|
|
208
|
+
if (wantsSC && [ScreenCaptureKitRecorder isScreenCaptureKitAvailable]) {
|
|
209
|
+
NSMutableDictionary *scConfig = [@{} mutableCopy];
|
|
210
|
+
scConfig[@"displayId"] = @(displayID);
|
|
211
|
+
if (!CGRectIsNull(captureRect)) {
|
|
212
|
+
scConfig[@"captureArea"] = @{ @"x": @(captureRect.origin.x),
|
|
213
|
+
@"y": @(captureRect.origin.y),
|
|
214
|
+
@"width": @(captureRect.size.width),
|
|
215
|
+
@"height": @(captureRect.size.height) };
|
|
216
|
+
}
|
|
217
|
+
scConfig[@"captureCursor"] = @(captureCursor);
|
|
218
|
+
scConfig[@"includeMicrophone"] = @(includeMicrophone);
|
|
219
|
+
scConfig[@"includeSystemAudio"] = @(includeSystemAudio);
|
|
220
|
+
if (excludedAppBundleIds.count) scConfig[@"excludedAppBundleIds"] = excludedAppBundleIds;
|
|
221
|
+
if (excludedPIDs.count) scConfig[@"excludedPIDs"] = excludedPIDs;
|
|
222
|
+
if (excludedWindowIds.count) scConfig[@"excludedWindowIds"] = excludedWindowIds;
|
|
223
|
+
// Auto exclude current app by PID if requested
|
|
224
|
+
if (autoExcludeSelf) {
|
|
225
|
+
pid_t pid = getpid();
|
|
226
|
+
NSMutableArray *arr = [NSMutableArray arrayWithArray:scConfig[@"excludedPIDs"] ?: @[]];
|
|
227
|
+
[arr addObject:@(pid)];
|
|
228
|
+
scConfig[@"excludedPIDs"] = arr;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Output path for SC
|
|
232
|
+
std::string outputPathStr = info[0].As<Napi::String>().Utf8Value();
|
|
233
|
+
scConfig[@"outputPath"] = [NSString stringWithUTF8String:outputPathStr.c_str()];
|
|
234
|
+
|
|
235
|
+
NSError *scErr = nil;
|
|
236
|
+
BOOL ok = [ScreenCaptureKitRecorder startRecordingWithConfiguration:scConfig delegate:nil error:&scErr];
|
|
237
|
+
if (ok) {
|
|
238
|
+
g_isRecording = true;
|
|
239
|
+
return Napi::Boolean::New(env, true);
|
|
240
|
+
}
|
|
241
|
+
// If SC failed, fall through to AVFoundation as fallback
|
|
242
|
+
}
|
|
162
243
|
// Create capture session
|
|
163
244
|
g_captureSession = [[AVCaptureSession alloc] init];
|
|
164
245
|
[g_captureSession beginConfiguration];
|
|
@@ -325,14 +406,19 @@ Napi::Value StartRecording(const Napi::CallbackInfo& info) {
|
|
|
325
406
|
Napi::Value StopRecording(const Napi::CallbackInfo& info) {
|
|
326
407
|
Napi::Env env = info.Env();
|
|
327
408
|
|
|
328
|
-
if (!g_isRecording
|
|
409
|
+
if (!g_isRecording) {
|
|
329
410
|
return Napi::Boolean::New(env, false);
|
|
330
411
|
}
|
|
331
412
|
|
|
332
413
|
@try {
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
414
|
+
if (g_movieFileOutput) {
|
|
415
|
+
[g_movieFileOutput stopRecording];
|
|
416
|
+
[g_captureSession stopRunning];
|
|
417
|
+
g_isRecording = false;
|
|
418
|
+
return Napi::Boolean::New(env, true);
|
|
419
|
+
}
|
|
420
|
+
// Try ScreenCaptureKit stop
|
|
421
|
+
[ScreenCaptureKitRecorder stopRecording];
|
|
336
422
|
g_isRecording = false;
|
|
337
423
|
return Napi::Boolean::New(env, true);
|
|
338
424
|
|
|
@@ -812,6 +898,12 @@ Napi::Object Init(Napi::Env env, Napi::Object exports) {
|
|
|
812
898
|
exports.Set(Napi::String::New(env, "getWindows"), Napi::Function::New(env, GetWindows));
|
|
813
899
|
exports.Set(Napi::String::New(env, "getRecordingStatus"), Napi::Function::New(env, GetRecordingStatus));
|
|
814
900
|
exports.Set(Napi::String::New(env, "checkPermissions"), Napi::Function::New(env, CheckPermissions));
|
|
901
|
+
// ScreenCaptureKit availability (optional for clients)
|
|
902
|
+
exports.Set(Napi::String::New(env, "isScreenCaptureKitAvailable"), Napi::Function::New(env, [](const Napi::CallbackInfo& info){
|
|
903
|
+
Napi::Env env = info.Env();
|
|
904
|
+
bool available = [ScreenCaptureKitRecorder isScreenCaptureKitAvailable];
|
|
905
|
+
return Napi::Boolean::New(env, available);
|
|
906
|
+
}));
|
|
815
907
|
|
|
816
908
|
// Thumbnail functions
|
|
817
909
|
exports.Set(Napi::String::New(env, "getWindowThumbnail"), Napi::Function::New(env, GetWindowThumbnail));
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
#import "screen_capture_kit.h"
|
|
2
|
+
#import <ScreenCaptureKit/ScreenCaptureKit.h>
|
|
3
|
+
#import <AVFoundation/AVFoundation.h>
|
|
4
|
+
|
|
5
|
+
static SCStream *g_scStream = nil;
|
|
6
|
+
static SCRecordingOutput *g_scRecordingOutput = nil;
|
|
7
|
+
static NSURL *g_outputURL = nil;
|
|
8
|
+
|
|
9
|
+
@interface ScreenCaptureKitRecorder () <SCRecordingOutputDelegate>
|
|
10
|
+
@end
|
|
11
|
+
|
|
12
|
+
@implementation ScreenCaptureKitRecorder
|
|
13
|
+
|
|
14
|
+
+ (BOOL)isScreenCaptureKitAvailable {
|
|
15
|
+
if (@available(macOS 14.0, *)) {
|
|
16
|
+
Class streamClass = NSClassFromString(@"SCStream");
|
|
17
|
+
Class recordingOutputClass = NSClassFromString(@"SCRecordingOutput");
|
|
18
|
+
return (streamClass != nil && recordingOutputClass != nil);
|
|
19
|
+
}
|
|
20
|
+
return NO;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
+ (BOOL)isRecording {
|
|
24
|
+
return g_scStream != nil;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
+ (BOOL)startRecordingWithConfiguration:(NSDictionary *)config
|
|
28
|
+
delegate:(id)delegate
|
|
29
|
+
error:(NSError **)error {
|
|
30
|
+
if (![self isScreenCaptureKitAvailable]) {
|
|
31
|
+
if (error) {
|
|
32
|
+
*error = [NSError errorWithDomain:@"ScreenCaptureKitRecorder"
|
|
33
|
+
code:-1
|
|
34
|
+
userInfo:@{NSLocalizedDescriptionKey: @"ScreenCaptureKit not available"}];
|
|
35
|
+
}
|
|
36
|
+
return NO;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (g_scStream) {
|
|
40
|
+
return NO;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
@try {
|
|
44
|
+
__block SCShareableContent *content = nil;
|
|
45
|
+
__block NSError *contentErr = nil;
|
|
46
|
+
|
|
47
|
+
dispatch_semaphore_t sem = dispatch_semaphore_create(0);
|
|
48
|
+
if (@available(macOS 13.0, *)) {
|
|
49
|
+
[SCShareableContent getShareableContentExcludingDesktopWindows:NO
|
|
50
|
+
onScreenWindowsOnly:YES
|
|
51
|
+
completionHandler:^(SCShareableContent * _Nullable shareableContent, NSError * _Nullable err) {
|
|
52
|
+
content = shareableContent;
|
|
53
|
+
contentErr = err;
|
|
54
|
+
dispatch_semaphore_signal(sem);
|
|
55
|
+
}];
|
|
56
|
+
} else {
|
|
57
|
+
dispatch_semaphore_signal(sem);
|
|
58
|
+
}
|
|
59
|
+
dispatch_semaphore_wait(sem, DISPATCH_TIME_FOREVER);
|
|
60
|
+
|
|
61
|
+
if (!content || contentErr) {
|
|
62
|
+
if (error) { *error = contentErr ?: [NSError errorWithDomain:@"ScreenCaptureKitRecorder" code:-2 userInfo:nil]; }
|
|
63
|
+
return NO;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
NSNumber *displayIdNumber = config[@"displayId"]; // CGDirectDisplayID
|
|
67
|
+
NSDictionary *captureArea = config[@"captureArea"]; // {x,y,width,height}
|
|
68
|
+
NSNumber *captureCursorNum = config[@"captureCursor"];
|
|
69
|
+
NSNumber *includeMicNum = config[@"includeMicrophone"];
|
|
70
|
+
NSNumber *includeSystemAudioNum = config[@"includeSystemAudio"];
|
|
71
|
+
NSArray<NSString *> *excludedBundleIds = config[@"excludedAppBundleIds"];
|
|
72
|
+
NSArray<NSNumber *> *excludedPIDs = config[@"excludedPIDs"];
|
|
73
|
+
NSArray<NSNumber *> *excludedWindowIds = config[@"excludedWindowIds"];
|
|
74
|
+
NSString *outputPath = config[@"outputPath"];
|
|
75
|
+
|
|
76
|
+
SCDisplay *targetDisplay = nil;
|
|
77
|
+
if (displayIdNumber) {
|
|
78
|
+
uint32_t wanted = (uint32_t)displayIdNumber.unsignedIntValue;
|
|
79
|
+
for (SCDisplay *d in content.displays) {
|
|
80
|
+
if (d.displayID == wanted) { targetDisplay = d; break; }
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
if (!targetDisplay) {
|
|
84
|
+
targetDisplay = content.displays.firstObject;
|
|
85
|
+
if (!targetDisplay) { return NO; }
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
NSMutableArray<SCRunningApplication*> *appsToExclude = [NSMutableArray array];
|
|
89
|
+
if (excludedBundleIds.count > 0) {
|
|
90
|
+
for (SCRunningApplication *app in content.applications) {
|
|
91
|
+
if ([excludedBundleIds containsObject:app.bundleIdentifier]) {
|
|
92
|
+
[appsToExclude addObject:app];
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
if (excludedPIDs.count > 0) {
|
|
97
|
+
for (SCRunningApplication *app in content.applications) {
|
|
98
|
+
if ([excludedPIDs containsObject:@(app.processID)]) {
|
|
99
|
+
[appsToExclude addObject:app];
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
NSMutableArray<SCWindow*> *windowsToExclude = [NSMutableArray array];
|
|
105
|
+
if (excludedWindowIds.count > 0) {
|
|
106
|
+
for (SCWindow *w in content.windows) {
|
|
107
|
+
if ([excludedWindowIds containsObject:@(w.windowID)]) {
|
|
108
|
+
[windowsToExclude addObject:w];
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
SCContentFilter *filter = nil;
|
|
114
|
+
if (appsToExclude.count > 0) {
|
|
115
|
+
if (@available(macOS 13.0, *)) {
|
|
116
|
+
filter = [[SCContentFilter alloc] initWithDisplay:targetDisplay excludingApplications:appsToExclude exceptingWindows:windowsToExclude];
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
if (!filter) {
|
|
120
|
+
if (@available(macOS 13.0, *)) {
|
|
121
|
+
filter = [[SCContentFilter alloc] initWithDisplay:targetDisplay excludingWindows:windowsToExclude];
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
if (!filter) { return NO; }
|
|
125
|
+
|
|
126
|
+
SCStreamConfiguration *cfg = [[SCStreamConfiguration alloc] init];
|
|
127
|
+
if (captureArea && captureArea[@"width"] && captureArea[@"height"]) {
|
|
128
|
+
CGRect src = CGRectMake([captureArea[@"x"] doubleValue],
|
|
129
|
+
[captureArea[@"y"] doubleValue],
|
|
130
|
+
[captureArea[@"width"] doubleValue],
|
|
131
|
+
[captureArea[@"height"] doubleValue]);
|
|
132
|
+
cfg.sourceRect = src;
|
|
133
|
+
cfg.width = (int)src.size.width;
|
|
134
|
+
cfg.height = (int)src.size.height;
|
|
135
|
+
} else {
|
|
136
|
+
cfg.width = (int)targetDisplay.width;
|
|
137
|
+
cfg.height = (int)targetDisplay.height;
|
|
138
|
+
}
|
|
139
|
+
cfg.showsCursor = captureCursorNum.boolValue;
|
|
140
|
+
if (includeMicNum || includeSystemAudioNum) {
|
|
141
|
+
cfg.capturesAudio = YES;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
g_scStream = [[SCStream alloc] initWithFilter:filter configuration:cfg delegate:nil];
|
|
145
|
+
|
|
146
|
+
if (@available(macOS 14.0, *)) {
|
|
147
|
+
SCRecordingOutputConfiguration *recCfg = [[SCRecordingOutputConfiguration alloc] init];
|
|
148
|
+
g_outputURL = [NSURL fileURLWithPath:outputPath];
|
|
149
|
+
recCfg.outputURL = g_outputURL;
|
|
150
|
+
recCfg.outputFileType = AVFileTypeQuickTimeMovie;
|
|
151
|
+
g_scRecordingOutput = [[SCRecordingOutput alloc] initWithConfiguration:recCfg delegate:(id<SCRecordingOutputDelegate>)delegate ?: (id<SCRecordingOutputDelegate>)self];
|
|
152
|
+
NSError *addErr = nil;
|
|
153
|
+
BOOL added = [g_scStream addRecordingOutput:g_scRecordingOutput error:&addErr];
|
|
154
|
+
if (!added) {
|
|
155
|
+
if (error) { *error = addErr ?: [NSError errorWithDomain:@"ScreenCaptureKitRecorder" code:-3 userInfo:nil]; }
|
|
156
|
+
g_scRecordingOutput = nil; g_scStream = nil; g_outputURL = nil;
|
|
157
|
+
return NO;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
__block NSError *startErr = nil;
|
|
161
|
+
dispatch_semaphore_t startSem = dispatch_semaphore_create(0);
|
|
162
|
+
[g_scStream startCaptureWithCompletionHandler:^(NSError * _Nullable err) {
|
|
163
|
+
startErr = err;
|
|
164
|
+
dispatch_semaphore_signal(startSem);
|
|
165
|
+
}];
|
|
166
|
+
dispatch_semaphore_wait(startSem, DISPATCH_TIME_FOREVER);
|
|
167
|
+
if (startErr) {
|
|
168
|
+
if (error) { *error = startErr; }
|
|
169
|
+
[g_scStream removeRecordingOutput:g_scRecordingOutput error:nil];
|
|
170
|
+
g_scRecordingOutput = nil; g_scStream = nil; g_outputURL = nil;
|
|
171
|
+
return NO;
|
|
172
|
+
}
|
|
173
|
+
return YES;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return NO;
|
|
177
|
+
} @catch (__unused NSException *ex) {
|
|
178
|
+
return NO;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
+ (void)stopRecording {
|
|
183
|
+
if (!g_scStream) { return; }
|
|
184
|
+
@try {
|
|
185
|
+
dispatch_semaphore_t sem = dispatch_semaphore_create(0);
|
|
186
|
+
[g_scStream stopCaptureWithCompletionHandler:^(NSError * _Nullable error) {
|
|
187
|
+
dispatch_semaphore_signal(sem);
|
|
188
|
+
}];
|
|
189
|
+
dispatch_semaphore_wait(sem, DISPATCH_TIME_FOREVER);
|
|
190
|
+
} @catch (__unused NSException *ex) {
|
|
191
|
+
}
|
|
192
|
+
if (g_scRecordingOutput) {
|
|
193
|
+
[g_scStream removeRecordingOutput:g_scRecordingOutput error:nil];
|
|
194
|
+
}
|
|
195
|
+
g_scRecordingOutput = nil;
|
|
196
|
+
g_scStream = nil;
|
|
197
|
+
g_outputURL = nil;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
@end
|
|
201
|
+
|
|
202
|
+
|