node-mac-recorder 2.2.1 → 2.4.1
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/README.md +17 -79
- package/binding.gyp +1 -12
- package/index.js +14 -59
- package/install.js +19 -2
- package/package.json +6 -7
- package/prebuilds/darwin-arm64/node.napi.node +0 -0
- package/src/mac_recorder.mm +70 -388
- package/src/screen_capture.mm +0 -16
- package/test-sck.js +1 -54
- package/window-selector.js +50 -34
- 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/screen_capture.mm
CHANGED
|
@@ -1,9 +1,6 @@
|
|
|
1
|
-
<<<<<<< HEAD
|
|
2
|
-
=======
|
|
3
1
|
#import "screen_capture.h"
|
|
4
2
|
#import <ScreenCaptureKit/ScreenCaptureKit.h>
|
|
5
3
|
#import <AVFoundation/AVFoundation.h>
|
|
6
|
-
>>>>>>> screencapture
|
|
7
4
|
#import <CoreGraphics/CoreGraphics.h>
|
|
8
5
|
#import <AppKit/AppKit.h>
|
|
9
6
|
|
|
@@ -77,11 +74,7 @@
|
|
|
77
74
|
NSURL *fileURL = [NSURL fileURLWithPath:filePath];
|
|
78
75
|
CGImageDestinationRef destination = CGImageDestinationCreateWithURL(
|
|
79
76
|
(__bridge CFURLRef)fileURL,
|
|
80
|
-
<<<<<<< HEAD
|
|
81
|
-
CFSTR("public.png"),
|
|
82
|
-
=======
|
|
83
77
|
(__bridge CFStringRef)@"public.png",
|
|
84
|
-
>>>>>>> screencapture
|
|
85
78
|
1,
|
|
86
79
|
NULL
|
|
87
80
|
);
|
|
@@ -110,14 +103,6 @@
|
|
|
110
103
|
|
|
111
104
|
+ (CGImageRef)createScreenshotFromDisplay:(CGDirectDisplayID)displayID
|
|
112
105
|
rect:(CGRect)rect {
|
|
113
|
-
<<<<<<< HEAD
|
|
114
|
-
if (CGRectIsNull(rect)) {
|
|
115
|
-
// Capture entire display
|
|
116
|
-
return CGDisplayCreateImage(displayID);
|
|
117
|
-
} else {
|
|
118
|
-
// Capture specific rect
|
|
119
|
-
return CGDisplayCreateImageForRect(displayID, rect);
|
|
120
|
-
=======
|
|
121
106
|
if (CGRectIsNull(rect) || CGRectIsEmpty(rect)) {
|
|
122
107
|
rect = CGDisplayBounds(displayID);
|
|
123
108
|
}
|
|
@@ -156,7 +141,6 @@ Napi::Value GetAvailableDisplays(const Napi::CallbackInfo& info) {
|
|
|
156
141
|
displayObj.Set("isPrimary", Napi::Boolean::New(env, [[displayInfo objectForKey:@"isPrimary"] boolValue]));
|
|
157
142
|
|
|
158
143
|
displaysArray.Set(static_cast<uint32_t>(i), displayObj);
|
|
159
|
-
>>>>>>> screencapture
|
|
160
144
|
}
|
|
161
145
|
|
|
162
146
|
return displaysArray;
|
package/test-sck.js
CHANGED
|
@@ -1,55 +1,3 @@
|
|
|
1
|
-
<<<<<<< HEAD
|
|
2
|
-
const nativeBinding = require('./build/Release/mac_recorder.node');
|
|
3
|
-
|
|
4
|
-
console.log('=== ScreenCaptureKit Migration Test ===');
|
|
5
|
-
console.log('ScreenCaptureKit available:', nativeBinding.isScreenCaptureKitAvailable());
|
|
6
|
-
console.log('Displays:', nativeBinding.getDisplays().length);
|
|
7
|
-
console.log('Audio devices:', nativeBinding.getAudioDevices().length);
|
|
8
|
-
console.log('Permissions OK:', nativeBinding.checkPermissions());
|
|
9
|
-
|
|
10
|
-
const displays = nativeBinding.getDisplays();
|
|
11
|
-
console.log('\nDisplay info:', displays[0]);
|
|
12
|
-
|
|
13
|
-
const audioDevices = nativeBinding.getAudioDevices();
|
|
14
|
-
console.log('\nFirst audio device:', audioDevices[0]);
|
|
15
|
-
|
|
16
|
-
// Test starting and stopping recording
|
|
17
|
-
console.log('\n=== Recording Test ===');
|
|
18
|
-
const outputPath = '/tmp/test-recording-sck.mov';
|
|
19
|
-
|
|
20
|
-
try {
|
|
21
|
-
console.log('Starting recording...');
|
|
22
|
-
const success = nativeBinding.startRecording(outputPath, {
|
|
23
|
-
displayId: displays[0].id,
|
|
24
|
-
captureCursor: true,
|
|
25
|
-
includeMicrophone: false,
|
|
26
|
-
includeSystemAudio: false
|
|
27
|
-
});
|
|
28
|
-
|
|
29
|
-
console.log('Recording started:', success);
|
|
30
|
-
|
|
31
|
-
if (success) {
|
|
32
|
-
setTimeout(() => {
|
|
33
|
-
console.log('Stopping recording...');
|
|
34
|
-
const stopped = nativeBinding.stopRecording();
|
|
35
|
-
console.log('Recording stopped:', stopped);
|
|
36
|
-
|
|
37
|
-
// Check if file was created
|
|
38
|
-
const fs = require('fs');
|
|
39
|
-
setTimeout(() => {
|
|
40
|
-
if (fs.existsSync(outputPath)) {
|
|
41
|
-
const stats = fs.statSync(outputPath);
|
|
42
|
-
console.log(`Recording file created: ${outputPath} (${stats.size} bytes)`);
|
|
43
|
-
} else {
|
|
44
|
-
console.log('Recording file not found');
|
|
45
|
-
}
|
|
46
|
-
}, 1000);
|
|
47
|
-
}, 3000); // Record for 3 seconds
|
|
48
|
-
}
|
|
49
|
-
} catch (error) {
|
|
50
|
-
console.error('Recording test failed:', error.message);
|
|
51
|
-
}
|
|
52
|
-
=======
|
|
53
1
|
const MacRecorder = require('./index');
|
|
54
2
|
|
|
55
3
|
function testScreenCaptureKit() {
|
|
@@ -105,5 +53,4 @@ function testScreenCaptureKit() {
|
|
|
105
53
|
}
|
|
106
54
|
}
|
|
107
55
|
|
|
108
|
-
testScreenCaptureKit();
|
|
109
|
-
>>>>>>> screencapture
|
|
56
|
+
testScreenCaptureKit();
|
package/window-selector.js
CHANGED
|
@@ -1,19 +1,27 @@
|
|
|
1
1
|
const { EventEmitter } = require("events");
|
|
2
2
|
const path = require("path");
|
|
3
3
|
|
|
4
|
-
// Native modülü yükle
|
|
4
|
+
// Native modülü yükle (arm64 prebuild öncelikli)
|
|
5
5
|
let nativeBinding;
|
|
6
6
|
try {
|
|
7
|
-
|
|
7
|
+
if (process.platform === "darwin" && process.arch === "arm64") {
|
|
8
|
+
nativeBinding = require("./prebuilds/darwin-arm64/node.napi.node");
|
|
9
|
+
} else {
|
|
10
|
+
nativeBinding = require("./build/Release/mac_recorder.node");
|
|
11
|
+
}
|
|
8
12
|
} catch (error) {
|
|
9
13
|
try {
|
|
10
|
-
nativeBinding = require("./build/
|
|
11
|
-
} catch (
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
14
|
+
nativeBinding = require("./build/Release/mac_recorder.node");
|
|
15
|
+
} catch (_) {
|
|
16
|
+
try {
|
|
17
|
+
nativeBinding = require("./build/Debug/mac_recorder.node");
|
|
18
|
+
} catch (debugError) {
|
|
19
|
+
throw new Error(
|
|
20
|
+
'Native module not found. Please run "npm run build" to compile the native module.\n' +
|
|
21
|
+
"Original error: " +
|
|
22
|
+
error.message
|
|
23
|
+
);
|
|
24
|
+
}
|
|
17
25
|
}
|
|
18
26
|
}
|
|
19
27
|
|
|
@@ -40,11 +48,11 @@ class WindowSelector extends EventEmitter {
|
|
|
40
48
|
try {
|
|
41
49
|
// Native window selection başlat
|
|
42
50
|
const success = nativeBinding.startWindowSelection();
|
|
43
|
-
|
|
51
|
+
|
|
44
52
|
if (success) {
|
|
45
53
|
this.isSelecting = true;
|
|
46
54
|
this.selectedWindow = null;
|
|
47
|
-
|
|
55
|
+
|
|
48
56
|
// Status polling timer başlat (higher frequency for overlay updates)
|
|
49
57
|
this.selectionTimer = setInterval(() => {
|
|
50
58
|
this.checkSelectionStatus();
|
|
@@ -72,7 +80,7 @@ class WindowSelector extends EventEmitter {
|
|
|
72
80
|
return new Promise((resolve, reject) => {
|
|
73
81
|
try {
|
|
74
82
|
const success = nativeBinding.stopWindowSelection();
|
|
75
|
-
|
|
83
|
+
|
|
76
84
|
// Timer'ı durdur
|
|
77
85
|
if (this.selectionTimer) {
|
|
78
86
|
clearInterval(this.selectionTimer);
|
|
@@ -98,14 +106,14 @@ class WindowSelector extends EventEmitter {
|
|
|
98
106
|
|
|
99
107
|
try {
|
|
100
108
|
const status = nativeBinding.getWindowSelectionStatus();
|
|
101
|
-
|
|
109
|
+
|
|
102
110
|
// Seçim tamamlandı mı kontrol et
|
|
103
111
|
if (status.hasSelectedWindow && !this.selectedWindow) {
|
|
104
112
|
const windowInfo = nativeBinding.getSelectedWindowInfo();
|
|
105
113
|
if (windowInfo) {
|
|
106
114
|
this.selectedWindow = windowInfo;
|
|
107
115
|
this.isSelecting = false;
|
|
108
|
-
|
|
116
|
+
|
|
109
117
|
// Timer'ı durdur
|
|
110
118
|
if (this.selectionTimer) {
|
|
111
119
|
clearInterval(this.selectionTimer);
|
|
@@ -121,17 +129,20 @@ class WindowSelector extends EventEmitter {
|
|
|
121
129
|
if (this.lastStatus) {
|
|
122
130
|
const lastWindow = this.lastStatus.currentWindow;
|
|
123
131
|
const currentWindow = status.currentWindow;
|
|
124
|
-
|
|
132
|
+
|
|
125
133
|
if (!lastWindow && currentWindow) {
|
|
126
134
|
// Yeni pencere üstüne gelindi
|
|
127
135
|
this.emit("windowEntered", currentWindow);
|
|
128
136
|
} else if (lastWindow && !currentWindow) {
|
|
129
137
|
// Pencere üstünden ayrıldı
|
|
130
138
|
this.emit("windowLeft", lastWindow);
|
|
131
|
-
} else if (
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
139
|
+
} else if (
|
|
140
|
+
lastWindow &&
|
|
141
|
+
currentWindow &&
|
|
142
|
+
(lastWindow.id !== currentWindow.id ||
|
|
143
|
+
lastWindow.title !== currentWindow.title ||
|
|
144
|
+
lastWindow.appName !== currentWindow.appName)
|
|
145
|
+
) {
|
|
135
146
|
// Farklı bir pencereye geçildi
|
|
136
147
|
this.emit("windowLeft", lastWindow);
|
|
137
148
|
this.emit("windowEntered", currentWindow);
|
|
@@ -164,14 +175,14 @@ class WindowSelector extends EventEmitter {
|
|
|
164
175
|
isSelecting: this.isSelecting && nativeStatus.isSelecting,
|
|
165
176
|
hasSelectedWindow: !!this.selectedWindow,
|
|
166
177
|
selectedWindow: this.selectedWindow,
|
|
167
|
-
nativeStatus: nativeStatus
|
|
178
|
+
nativeStatus: nativeStatus,
|
|
168
179
|
};
|
|
169
180
|
} catch (error) {
|
|
170
181
|
return {
|
|
171
182
|
isSelecting: this.isSelecting,
|
|
172
183
|
hasSelectedWindow: !!this.selectedWindow,
|
|
173
184
|
selectedWindow: this.selectedWindow,
|
|
174
|
-
error: error.message
|
|
185
|
+
error: error.message,
|
|
175
186
|
};
|
|
176
187
|
}
|
|
177
188
|
}
|
|
@@ -205,7 +216,6 @@ class WindowSelector extends EventEmitter {
|
|
|
205
216
|
|
|
206
217
|
// Seçimi başlat
|
|
207
218
|
await this.startSelection();
|
|
208
|
-
|
|
209
219
|
} catch (error) {
|
|
210
220
|
this.removeAllListeners("windowSelected");
|
|
211
221
|
this.removeAllListeners("error");
|
|
@@ -242,7 +252,9 @@ class WindowSelector extends EventEmitter {
|
|
|
242
252
|
nativeBinding.setBringToFrontEnabled(enabled);
|
|
243
253
|
// Only log if explicitly setting, not on startup
|
|
244
254
|
if (arguments.length > 0) {
|
|
245
|
-
console.log(
|
|
255
|
+
console.log(
|
|
256
|
+
`🔄 Auto bring-to-front: ${enabled ? "ENABLED" : "DISABLED"}`
|
|
257
|
+
);
|
|
246
258
|
}
|
|
247
259
|
} catch (error) {
|
|
248
260
|
throw new Error(`Failed to set bring to front: ${error.message}`);
|
|
@@ -376,14 +388,14 @@ class WindowSelector extends EventEmitter {
|
|
|
376
388
|
try {
|
|
377
389
|
// Start screen selection
|
|
378
390
|
await this.startScreenSelection();
|
|
379
|
-
|
|
391
|
+
|
|
380
392
|
// Poll for selection completion
|
|
381
393
|
return new Promise((resolve, reject) => {
|
|
382
394
|
let isResolved = false;
|
|
383
|
-
|
|
395
|
+
|
|
384
396
|
const checkSelection = () => {
|
|
385
397
|
if (isResolved) return; // Prevent multiple resolutions
|
|
386
|
-
|
|
398
|
+
|
|
387
399
|
const selectedScreen = this.getSelectedScreen();
|
|
388
400
|
if (selectedScreen) {
|
|
389
401
|
isResolved = true;
|
|
@@ -394,19 +406,19 @@ class WindowSelector extends EventEmitter {
|
|
|
394
406
|
} else {
|
|
395
407
|
// Selection was cancelled (probably ESC key)
|
|
396
408
|
isResolved = true;
|
|
397
|
-
reject(new Error(
|
|
409
|
+
reject(new Error("Screen selection was cancelled"));
|
|
398
410
|
}
|
|
399
411
|
};
|
|
400
|
-
|
|
412
|
+
|
|
401
413
|
// Start polling
|
|
402
414
|
checkSelection();
|
|
403
|
-
|
|
415
|
+
|
|
404
416
|
// Timeout after 60 seconds
|
|
405
417
|
setTimeout(() => {
|
|
406
418
|
if (!isResolved) {
|
|
407
419
|
isResolved = true;
|
|
408
420
|
this.stopScreenSelection();
|
|
409
|
-
reject(new Error(
|
|
421
|
+
reject(new Error("Screen selection timed out"));
|
|
410
422
|
}
|
|
411
423
|
}, 60000);
|
|
412
424
|
});
|
|
@@ -430,7 +442,9 @@ class WindowSelector extends EventEmitter {
|
|
|
430
442
|
const success = nativeBinding.showScreenRecordingPreview(screenInfo);
|
|
431
443
|
return success;
|
|
432
444
|
} catch (error) {
|
|
433
|
-
throw new Error(
|
|
445
|
+
throw new Error(
|
|
446
|
+
`Failed to show screen recording preview: ${error.message}`
|
|
447
|
+
);
|
|
434
448
|
}
|
|
435
449
|
}
|
|
436
450
|
|
|
@@ -443,7 +457,9 @@ class WindowSelector extends EventEmitter {
|
|
|
443
457
|
const success = nativeBinding.hideScreenRecordingPreview();
|
|
444
458
|
return success;
|
|
445
459
|
} catch (error) {
|
|
446
|
-
throw new Error(
|
|
460
|
+
throw new Error(
|
|
461
|
+
`Failed to hide screen recording preview: ${error.message}`
|
|
462
|
+
);
|
|
447
463
|
}
|
|
448
464
|
}
|
|
449
465
|
|
|
@@ -460,10 +476,10 @@ class WindowSelector extends EventEmitter {
|
|
|
460
476
|
return {
|
|
461
477
|
screenRecording: false,
|
|
462
478
|
accessibility: false,
|
|
463
|
-
error: error.message
|
|
479
|
+
error: error.message,
|
|
464
480
|
};
|
|
465
481
|
}
|
|
466
482
|
}
|
|
467
483
|
}
|
|
468
484
|
|
|
469
|
-
module.exports = WindowSelector;
|
|
485
|
+
module.exports = WindowSelector;
|
|
Binary file
|
package/scripts/test-exclude.js
DELETED
|
@@ -1,72 +0,0 @@
|
|
|
1
|
-
/*
|
|
2
|
-
Simple test runner: starts a 2s recording with ScreenCaptureKit and exclusions.
|
|
3
|
-
*/
|
|
4
|
-
const fs = require("fs");
|
|
5
|
-
const path = require("path");
|
|
6
|
-
const MacRecorder = require("..");
|
|
7
|
-
|
|
8
|
-
async function sleep(ms) {
|
|
9
|
-
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
async function main() {
|
|
13
|
-
const recorder = new MacRecorder();
|
|
14
|
-
const outDir = path.resolve(process.cwd(), "test-output");
|
|
15
|
-
const outPath = path.join(outDir, `sc-exclude-${Date.now()}.mov`);
|
|
16
|
-
await fs.promises.mkdir(outDir, { recursive: true });
|
|
17
|
-
|
|
18
|
-
console.log("[TEST] Starting 2s recording with SC exclusions...");
|
|
19
|
-
// Try to ensure overlays are not active in this process
|
|
20
|
-
|
|
21
|
-
const perms = await recorder.checkPermissions();
|
|
22
|
-
if (!perms?.screenRecording) {
|
|
23
|
-
console.error(
|
|
24
|
-
"[TEST] Screen Recording permission is not granted. Enable it in System Settings → Privacy & Security → Screen Recording for Terminal/Node, then re-run."
|
|
25
|
-
);
|
|
26
|
-
process.exit(1);
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
try {
|
|
30
|
-
await recorder.startRecording(outPath, {
|
|
31
|
-
useScreenCaptureKit: true,
|
|
32
|
-
captureCursor: false,
|
|
33
|
-
excludedAppBundleIds: ["com.apple.Safari"],
|
|
34
|
-
});
|
|
35
|
-
} catch (e) {
|
|
36
|
-
console.error("[TEST] Failed to start recording:", e.message);
|
|
37
|
-
process.exit(1);
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
await sleep(2000);
|
|
41
|
-
|
|
42
|
-
try {
|
|
43
|
-
const result = await recorder.stopRecording();
|
|
44
|
-
console.log("[TEST] Stopped. Result:", result);
|
|
45
|
-
} catch (e) {
|
|
46
|
-
console.error("[TEST] Failed to stop recording:", e.message);
|
|
47
|
-
process.exit(1);
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
// SCRecordingOutput write may be async; wait up to 10s for the file
|
|
51
|
-
const deadline = Date.now() + 10000;
|
|
52
|
-
let stats = null;
|
|
53
|
-
while (Date.now() < deadline) {
|
|
54
|
-
if (fs.existsSync(outPath)) {
|
|
55
|
-
stats = fs.statSync(outPath);
|
|
56
|
-
if (stats.size > 0) break;
|
|
57
|
-
}
|
|
58
|
-
await sleep(200);
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
if (stats && fs.existsSync(outPath)) {
|
|
62
|
-
console.log(`[TEST] Output saved: ${outPath} (${stats.size} bytes)`);
|
|
63
|
-
} else {
|
|
64
|
-
console.error("[TEST] Output file not found or empty:", outPath);
|
|
65
|
-
process.exit(1);
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
main().catch((e) => {
|
|
70
|
-
console.error("[TEST] Unhandled error:", e);
|
|
71
|
-
process.exit(1);
|
|
72
|
-
});
|
|
@@ -1,222 +0,0 @@
|
|
|
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
|
-
static ScreenCaptureKitRecorder *g_scDelegate = nil;
|
|
9
|
-
static NSArray<SCRunningApplication *> *g_appsToExclude = nil;
|
|
10
|
-
static NSArray<SCWindow *> *g_windowsToExclude = nil;
|
|
11
|
-
|
|
12
|
-
@interface ScreenCaptureKitRecorder () <SCRecordingOutputDelegate>
|
|
13
|
-
@end
|
|
14
|
-
|
|
15
|
-
@implementation ScreenCaptureKitRecorder
|
|
16
|
-
|
|
17
|
-
+ (BOOL)isScreenCaptureKitAvailable {
|
|
18
|
-
if (@available(macOS 14.0, *)) {
|
|
19
|
-
Class streamClass = NSClassFromString(@"SCStream");
|
|
20
|
-
Class recordingOutputClass = NSClassFromString(@"SCRecordingOutput");
|
|
21
|
-
return (streamClass != nil && recordingOutputClass != nil);
|
|
22
|
-
}
|
|
23
|
-
return NO;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
+ (BOOL)isRecording {
|
|
27
|
-
return g_scStream != nil;
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
+ (BOOL)startRecordingWithConfiguration:(NSDictionary *)config
|
|
31
|
-
delegate:(id)delegate
|
|
32
|
-
error:(NSError **)error {
|
|
33
|
-
if (![self isScreenCaptureKitAvailable]) {
|
|
34
|
-
if (error) {
|
|
35
|
-
*error = [NSError errorWithDomain:@"ScreenCaptureKitRecorder"
|
|
36
|
-
code:-1
|
|
37
|
-
userInfo:@{NSLocalizedDescriptionKey: @"ScreenCaptureKit not available"}];
|
|
38
|
-
}
|
|
39
|
-
return NO;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
if (g_scStream) {
|
|
43
|
-
return NO;
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
@try {
|
|
47
|
-
SCShareableContent *content = nil;
|
|
48
|
-
if (@available(macOS 13.0, *)) {
|
|
49
|
-
NSLog(@"[SCK] Fetching shareable content...");
|
|
50
|
-
content = [SCShareableContent currentShareableContent];
|
|
51
|
-
}
|
|
52
|
-
if (!content) {
|
|
53
|
-
if (error) { *error = [NSError errorWithDomain:@"ScreenCaptureKitRecorder" code:-2 userInfo:@{NSLocalizedDescriptionKey:@"Failed to get shareable content"}]; }
|
|
54
|
-
NSLog(@"[SCK] Failed to get shareable content");
|
|
55
|
-
return NO;
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
NSNumber *displayIdNumber = config[@"displayId"]; // CGDirectDisplayID
|
|
59
|
-
NSDictionary *captureArea = config[@"captureArea"]; // {x,y,width,height}
|
|
60
|
-
NSNumber *captureCursorNum = config[@"captureCursor"];
|
|
61
|
-
NSNumber *includeMicNum = config[@"includeMicrophone"];
|
|
62
|
-
NSNumber *includeSystemAudioNum = config[@"includeSystemAudio"];
|
|
63
|
-
NSArray<NSString *> *excludedBundleIds = config[@"excludedAppBundleIds"];
|
|
64
|
-
NSArray<NSNumber *> *excludedPIDs = config[@"excludedPIDs"];
|
|
65
|
-
NSArray<NSNumber *> *excludedWindowIds = config[@"excludedWindowIds"];
|
|
66
|
-
NSString *outputPath = config[@"outputPath"];
|
|
67
|
-
|
|
68
|
-
SCDisplay *targetDisplay = nil;
|
|
69
|
-
if (displayIdNumber) {
|
|
70
|
-
uint32_t wanted = (uint32_t)displayIdNumber.unsignedIntValue;
|
|
71
|
-
for (SCDisplay *d in content.displays) {
|
|
72
|
-
if (d.displayID == wanted) { targetDisplay = d; break; }
|
|
73
|
-
}
|
|
74
|
-
}
|
|
75
|
-
if (!targetDisplay) {
|
|
76
|
-
targetDisplay = content.displays.firstObject;
|
|
77
|
-
if (!targetDisplay) { NSLog(@"[SCK] No displays found"); return NO; }
|
|
78
|
-
}
|
|
79
|
-
NSLog(@"[SCK] Using displayID=%u", targetDisplay.displayID);
|
|
80
|
-
|
|
81
|
-
NSMutableArray<SCRunningApplication*> *appsToExclude = [NSMutableArray array];
|
|
82
|
-
if (excludedBundleIds.count > 0) {
|
|
83
|
-
for (SCRunningApplication *app in content.applications) {
|
|
84
|
-
if ([excludedBundleIds containsObject:app.bundleIdentifier]) {
|
|
85
|
-
[appsToExclude addObject:app];
|
|
86
|
-
}
|
|
87
|
-
}
|
|
88
|
-
}
|
|
89
|
-
if (excludedPIDs.count > 0) {
|
|
90
|
-
for (SCRunningApplication *app in content.applications) {
|
|
91
|
-
if ([excludedPIDs containsObject:@(app.processID)]) {
|
|
92
|
-
[appsToExclude addObject:app];
|
|
93
|
-
}
|
|
94
|
-
}
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
NSMutableArray<SCWindow*> *windowsToExclude = [NSMutableArray array];
|
|
98
|
-
if (excludedWindowIds.count > 0) {
|
|
99
|
-
for (SCWindow *w in content.windows) {
|
|
100
|
-
if ([excludedWindowIds containsObject:@(w.windowID)]) {
|
|
101
|
-
[windowsToExclude addObject:w];
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
// Keep strong references to excluded items for the lifetime of the stream
|
|
107
|
-
g_appsToExclude = [appsToExclude copy];
|
|
108
|
-
g_windowsToExclude = [windowsToExclude copy];
|
|
109
|
-
|
|
110
|
-
SCContentFilter *filter = nil;
|
|
111
|
-
if (appsToExclude.count > 0) {
|
|
112
|
-
if (@available(macOS 13.0, *)) {
|
|
113
|
-
filter = [[SCContentFilter alloc] initWithDisplay:targetDisplay excludingApplications:(g_appsToExclude ?: @[]) exceptingWindows:(g_windowsToExclude ?: @[])];
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
|
-
if (!filter) {
|
|
117
|
-
if (@available(macOS 13.0, *)) {
|
|
118
|
-
filter = [[SCContentFilter alloc] initWithDisplay:targetDisplay excludingWindows:(g_windowsToExclude ?: @[])];
|
|
119
|
-
}
|
|
120
|
-
}
|
|
121
|
-
if (!filter) { NSLog(@"[SCK] Failed to create filter"); return NO; }
|
|
122
|
-
|
|
123
|
-
SCStreamConfiguration *cfg = [[SCStreamConfiguration alloc] init];
|
|
124
|
-
if (captureArea && captureArea[@"width"] && captureArea[@"height"]) {
|
|
125
|
-
CGRect displayFrame = targetDisplay.frame;
|
|
126
|
-
double x = [captureArea[@"x"] doubleValue];
|
|
127
|
-
double yBottom = [captureArea[@"y"] doubleValue];
|
|
128
|
-
double w = [captureArea[@"width"] doubleValue];
|
|
129
|
-
double h = [captureArea[@"height"] doubleValue];
|
|
130
|
-
|
|
131
|
-
// Convert bottom-left origin (used by legacy path) to top-left for SC
|
|
132
|
-
double y = displayFrame.size.height - yBottom - h;
|
|
133
|
-
|
|
134
|
-
// Clamp to display bounds to avoid invalid sourceRect
|
|
135
|
-
if (w < 1) w = 1; if (h < 1) h = 1;
|
|
136
|
-
if (x < 0) x = 0; if (y < 0) y = 0;
|
|
137
|
-
if (x + w > displayFrame.size.width) w = MAX(1, displayFrame.size.width - x);
|
|
138
|
-
if (y + h > displayFrame.size.height) h = MAX(1, displayFrame.size.height - y);
|
|
139
|
-
|
|
140
|
-
CGRect src = CGRectMake(x, y, w, h);
|
|
141
|
-
cfg.sourceRect = src;
|
|
142
|
-
cfg.width = (int)src.size.width;
|
|
143
|
-
cfg.height = (int)src.size.height;
|
|
144
|
-
} else {
|
|
145
|
-
CGRect displayFrame = targetDisplay.frame;
|
|
146
|
-
cfg.width = (int)displayFrame.size.width;
|
|
147
|
-
cfg.height = (int)displayFrame.size.height;
|
|
148
|
-
}
|
|
149
|
-
cfg.showsCursor = captureCursorNum.boolValue;
|
|
150
|
-
if (includeMicNum || includeSystemAudioNum) {
|
|
151
|
-
if (@available(macOS 13.0, *)) {
|
|
152
|
-
cfg.capturesAudio = YES;
|
|
153
|
-
}
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
if (@available(macOS 15.0, *)) {
|
|
157
|
-
g_scStream = [[SCStream alloc] initWithFilter:filter configuration:cfg delegate:nil];
|
|
158
|
-
NSLog(@"[SCK] Stream created. w=%d h=%d cursor=%@ audio=%@", cfg.width, cfg.height, cfg.showsCursor?@"YES":@"NO", (@available(macOS 13.0, *) ? (cfg.capturesAudio?@"YES":@"NO") : @"N/A"));
|
|
159
|
-
|
|
160
|
-
SCRecordingOutputConfiguration *recCfg = [[SCRecordingOutputConfiguration alloc] init];
|
|
161
|
-
g_outputURL = [NSURL fileURLWithPath:outputPath];
|
|
162
|
-
recCfg.outputURL = g_outputURL;
|
|
163
|
-
recCfg.outputFileType = AVFileTypeQuickTimeMovie;
|
|
164
|
-
|
|
165
|
-
id<SCRecordingOutputDelegate> delegateObject = (id<SCRecordingOutputDelegate>)delegate;
|
|
166
|
-
if (!delegateObject) {
|
|
167
|
-
if (!g_scDelegate) {
|
|
168
|
-
g_scDelegate = [[ScreenCaptureKitRecorder alloc] init];
|
|
169
|
-
}
|
|
170
|
-
delegateObject = (id<SCRecordingOutputDelegate>)g_scDelegate;
|
|
171
|
-
}
|
|
172
|
-
g_scRecordingOutput = [[SCRecordingOutput alloc] initWithConfiguration:recCfg delegate:delegateObject];
|
|
173
|
-
|
|
174
|
-
NSError *addErr = nil;
|
|
175
|
-
BOOL added = [g_scStream addRecordingOutput:g_scRecordingOutput error:&addErr];
|
|
176
|
-
if (!added) {
|
|
177
|
-
NSLog(@"[SCK] addRecordingOutput failed: %@", addErr.localizedDescription);
|
|
178
|
-
if (error) { *error = addErr ?: [NSError errorWithDomain:@"ScreenCaptureKitRecorder" code:-3 userInfo:nil]; }
|
|
179
|
-
g_scRecordingOutput = nil; g_scStream = nil; g_outputURL = nil;
|
|
180
|
-
return NO;
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
[g_scStream startCaptureWithCompletionHandler:^(NSError * _Nullable err) {
|
|
184
|
-
if (err) {
|
|
185
|
-
NSLog(@"[SCK] startCapture error: %@", err.localizedDescription);
|
|
186
|
-
[g_scStream removeRecordingOutput:g_scRecordingOutput error:nil];
|
|
187
|
-
g_scRecordingOutput = nil; g_scStream = nil; g_outputURL = nil;
|
|
188
|
-
} else {
|
|
189
|
-
NSLog(@"[SCK] startCapture OK");
|
|
190
|
-
}
|
|
191
|
-
}];
|
|
192
|
-
// Return immediately; capture will start asynchronously
|
|
193
|
-
return YES;
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
return NO;
|
|
197
|
-
} @catch (__unused NSException *ex) {
|
|
198
|
-
return NO;
|
|
199
|
-
}
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
+ (void)stopRecording {
|
|
203
|
-
if (!g_scStream) { return; }
|
|
204
|
-
@try {
|
|
205
|
-
dispatch_semaphore_t sem = dispatch_semaphore_create(0);
|
|
206
|
-
[g_scStream stopCaptureWithCompletionHandler:^(NSError * _Nullable error) {
|
|
207
|
-
dispatch_semaphore_signal(sem);
|
|
208
|
-
}];
|
|
209
|
-
dispatch_semaphore_wait(sem, DISPATCH_TIME_FOREVER);
|
|
210
|
-
} @catch (__unused NSException *ex) {
|
|
211
|
-
}
|
|
212
|
-
if (g_scRecordingOutput) {
|
|
213
|
-
[g_scStream removeRecordingOutput:g_scRecordingOutput error:nil];
|
|
214
|
-
}
|
|
215
|
-
g_scRecordingOutput = nil;
|
|
216
|
-
g_scStream = nil;
|
|
217
|
-
g_outputURL = nil;
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
@end
|
|
221
|
-
|
|
222
|
-
|