node-mac-recorder 2.24.7 → 2.24.9
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 +3 -1
- package/index.js +172 -1
- package/package.json +1 -1
- package/src/audio_recorder.mm +6 -0
- package/src/camera_recorder.mm +6 -0
- package/src/cursor_tracker.mm +21 -0
- package/src/ios_device_recorder.mm +534 -0
- package/src/mac_recorder.mm +6 -0
- package/src/sync_timeline.h +8 -0
- package/src/sync_timeline.mm +88 -0
- package/src/window_selector.mm +49 -29
package/binding.gyp
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
"src/screen_capture_kit.mm",
|
|
8
8
|
"src/avfoundation_recorder.mm",
|
|
9
9
|
"src/camera_recorder.mm",
|
|
10
|
+
"src/ios_device_recorder.mm",
|
|
10
11
|
"src/sync_timeline.mm",
|
|
11
12
|
"src/audio_recorder.mm",
|
|
12
13
|
"src/audio_mixer.mm",
|
|
@@ -42,7 +43,8 @@
|
|
|
42
43
|
"-framework ApplicationServices",
|
|
43
44
|
"-framework Carbon",
|
|
44
45
|
"-framework Accessibility",
|
|
45
|
-
"-framework CoreAudio"
|
|
46
|
+
"-framework CoreAudio",
|
|
47
|
+
"-framework CoreMediaIO"
|
|
46
48
|
]
|
|
47
49
|
},
|
|
48
50
|
"defines": [ "NAPI_DISABLE_CPP_EXCEPTIONS" ]
|
package/index.js
CHANGED
|
@@ -153,6 +153,170 @@ class MacRecorder extends EventEmitter {
|
|
|
153
153
|
});
|
|
154
154
|
}
|
|
155
155
|
|
|
156
|
+
/**
|
|
157
|
+
* USB uzerinden AVFoundation muxed capture kaynagi olarak gorunen
|
|
158
|
+
* iPhone/iPad cihazlarini listeler. Continuity Camera cihazlari bu listeye
|
|
159
|
+
* dahil edilmez; bu kaynak telefonun kamera sensoru degil ekran akışıdır.
|
|
160
|
+
*/
|
|
161
|
+
async getIOSCaptureDevices() {
|
|
162
|
+
if (typeof nativeBinding.getIOSCaptureDevices !== "function") return [];
|
|
163
|
+
const devices = nativeBinding.getIOSCaptureDevices();
|
|
164
|
+
if (!Array.isArray(devices)) return [];
|
|
165
|
+
return devices.map((device) => ({
|
|
166
|
+
id: device?.id || "",
|
|
167
|
+
name: device?.name || "iPhone",
|
|
168
|
+
manufacturer: device?.manufacturer || "Apple",
|
|
169
|
+
model: device?.model || null,
|
|
170
|
+
connected: device?.connected !== false,
|
|
171
|
+
suspended: device?.suspended === true,
|
|
172
|
+
width: Number(device?.width) || 0,
|
|
173
|
+
height: Number(device?.height) || 0,
|
|
174
|
+
hasAudio: device?.hasAudio !== false,
|
|
175
|
+
transport: device?.transport || "usb",
|
|
176
|
+
}));
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* QuickTime benzeri dogrudan USB iPhone ekran kaydi. Muxed aygitin kendi
|
|
181
|
+
* video ve sesini tek, uzun-kayitlara dayanikli MOV dosyasina yazar.
|
|
182
|
+
*/
|
|
183
|
+
async startIOSRecording(outputPath, options = {}) {
|
|
184
|
+
if (this.isRecording) throw new Error("Recording is already in progress");
|
|
185
|
+
if (!outputPath) throw new Error("Output path is required");
|
|
186
|
+
if (typeof nativeBinding.startIOSDeviceRecording !== "function") {
|
|
187
|
+
throw new Error("This recorder build does not support USB iPhone capture");
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const outputDir = path.dirname(outputPath);
|
|
191
|
+
if (!fs.existsSync(outputDir)) fs.mkdirSync(outputDir, { recursive: true });
|
|
192
|
+
this.outputPath = outputPath;
|
|
193
|
+
const sessionTimestamp = options.sessionTimestamp || Date.now();
|
|
194
|
+
const cameraOutputPath = options.captureCamera === true
|
|
195
|
+
? path.join(outputDir, `temp_camera_${sessionTimestamp}.mov`)
|
|
196
|
+
: null;
|
|
197
|
+
const audioOutputPath = options.includeMicrophone === true
|
|
198
|
+
? path.join(outputDir, `temp_audio_${sessionTimestamp}.mov`)
|
|
199
|
+
: null;
|
|
200
|
+
this.options = {
|
|
201
|
+
...this.options,
|
|
202
|
+
...options,
|
|
203
|
+
sourceType: "iphone",
|
|
204
|
+
captureCursor: false,
|
|
205
|
+
// The muxed USB source already contains the iPhone's own system audio.
|
|
206
|
+
// includeSystemAudio refers to the Mac and must never be added here.
|
|
207
|
+
includeSystemAudio: false,
|
|
208
|
+
};
|
|
209
|
+
this.cameraCaptureFile = cameraOutputPath;
|
|
210
|
+
this.audioCaptureFile = audioOutputPath;
|
|
211
|
+
this.cameraCaptureActive = options.captureCamera === true;
|
|
212
|
+
this.audioCaptureActive = options.includeMicrophone === true;
|
|
213
|
+
this.sessionTimestamp = sessionTimestamp;
|
|
214
|
+
this.recordingMode = "iphone";
|
|
215
|
+
|
|
216
|
+
try {
|
|
217
|
+
const success = nativeBinding.startIOSDeviceRecording(
|
|
218
|
+
outputPath,
|
|
219
|
+
options.deviceId || options.iosDeviceId || "",
|
|
220
|
+
{
|
|
221
|
+
captureCamera: options.captureCamera === true,
|
|
222
|
+
cameraOutputPath,
|
|
223
|
+
cameraDeviceId: options.cameraDeviceId || "",
|
|
224
|
+
includeMicrophone: options.includeMicrophone === true,
|
|
225
|
+
audioOutputPath,
|
|
226
|
+
audioDeviceId: options.audioDeviceId || "",
|
|
227
|
+
},
|
|
228
|
+
);
|
|
229
|
+
if (!success) throw new Error("The iPhone capture session could not be started");
|
|
230
|
+
|
|
231
|
+
this.isRecording = true;
|
|
232
|
+
this.recordingStartTime = Date.now();
|
|
233
|
+
this.timelineStartTimestamp = this.recordingStartTime;
|
|
234
|
+
this.syncTimestamp = this.recordingStartTime;
|
|
235
|
+
this.recordingTimer = setInterval(() => {
|
|
236
|
+
this.emit(
|
|
237
|
+
"timeUpdate",
|
|
238
|
+
Math.floor((Date.now() - this.recordingStartTime) / 1000),
|
|
239
|
+
);
|
|
240
|
+
}, 1000);
|
|
241
|
+
|
|
242
|
+
const event = {
|
|
243
|
+
outputPath,
|
|
244
|
+
timestamp: this.recordingStartTime,
|
|
245
|
+
options: this.options,
|
|
246
|
+
nativeConfirmed: true,
|
|
247
|
+
cursorOutputPath: null,
|
|
248
|
+
keyboardOutputPath: null,
|
|
249
|
+
audioOutputPath,
|
|
250
|
+
cameraOutputPath,
|
|
251
|
+
sessionTimestamp: this.sessionTimestamp,
|
|
252
|
+
syncTimestamp: this.syncTimestamp,
|
|
253
|
+
fileTimestamp: this.sessionTimestamp,
|
|
254
|
+
sourceType: "iphone",
|
|
255
|
+
};
|
|
256
|
+
this.emit("recordingStarted", event);
|
|
257
|
+
this.emit("started", outputPath);
|
|
258
|
+
return outputPath;
|
|
259
|
+
} catch (error) {
|
|
260
|
+
this.recordingMode = null;
|
|
261
|
+
this.isRecording = false;
|
|
262
|
+
this.cameraCaptureActive = false;
|
|
263
|
+
this.audioCaptureActive = false;
|
|
264
|
+
this.cameraCaptureFile = null;
|
|
265
|
+
this.audioCaptureFile = null;
|
|
266
|
+
throw error;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
async stopIOSRecording() {
|
|
271
|
+
if (this.recordingMode !== "iphone" || !this.isRecording) {
|
|
272
|
+
throw new Error("No iPhone recording in progress");
|
|
273
|
+
}
|
|
274
|
+
let success = false;
|
|
275
|
+
try {
|
|
276
|
+
success = nativeBinding.stopIOSDeviceRecording();
|
|
277
|
+
} finally {
|
|
278
|
+
if (this.recordingTimer) clearInterval(this.recordingTimer);
|
|
279
|
+
this.recordingTimer = null;
|
|
280
|
+
this.isRecording = false;
|
|
281
|
+
this.recordingMode = null;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const result = {
|
|
285
|
+
code: success ? 0 : 1,
|
|
286
|
+
outputPath: this.outputPath,
|
|
287
|
+
cameraOutputPath: this.cameraCaptureFile || null,
|
|
288
|
+
audioOutputPath: this.audioCaptureFile || null,
|
|
289
|
+
sessionTimestamp: this.sessionTimestamp,
|
|
290
|
+
syncTimestamp: this.syncTimestamp,
|
|
291
|
+
sourceType: "iphone",
|
|
292
|
+
};
|
|
293
|
+
if (this.cameraCaptureActive) {
|
|
294
|
+
this.emit("cameraCaptureStopped", {
|
|
295
|
+
outputPath: this.cameraCaptureFile,
|
|
296
|
+
success,
|
|
297
|
+
sessionTimestamp: this.sessionTimestamp,
|
|
298
|
+
syncTimestamp: this.syncTimestamp,
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
if (this.audioCaptureActive) {
|
|
302
|
+
this.emit("audioCaptureStopped", {
|
|
303
|
+
outputPath: this.audioCaptureFile,
|
|
304
|
+
success,
|
|
305
|
+
sessionTimestamp: this.sessionTimestamp,
|
|
306
|
+
syncTimestamp: this.syncTimestamp,
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
this.cameraCaptureActive = false;
|
|
310
|
+
this.audioCaptureActive = false;
|
|
311
|
+
this.emit("stopped", result);
|
|
312
|
+
if (success && fs.existsSync(this.outputPath)) {
|
|
313
|
+
this.emit("completed", this.outputPath);
|
|
314
|
+
}
|
|
315
|
+
this.sessionTimestamp = null;
|
|
316
|
+
this.syncTimestamp = null;
|
|
317
|
+
return result;
|
|
318
|
+
}
|
|
319
|
+
|
|
156
320
|
/**
|
|
157
321
|
* macOS ekranlarını listeler
|
|
158
322
|
*/
|
|
@@ -1227,6 +1391,9 @@ class MacRecorder extends EventEmitter {
|
|
|
1227
1391
|
if (!this.isRecording) {
|
|
1228
1392
|
throw new Error("No recording in progress");
|
|
1229
1393
|
}
|
|
1394
|
+
if (this.recordingMode === "iphone") {
|
|
1395
|
+
return this.stopIOSRecording();
|
|
1396
|
+
}
|
|
1230
1397
|
|
|
1231
1398
|
return new Promise(async (resolve, reject) => {
|
|
1232
1399
|
const stopRequestedAt = Date.now();
|
|
@@ -1399,7 +1566,11 @@ class MacRecorder extends EventEmitter {
|
|
|
1399
1566
|
* Kayıt durumunu döndürür
|
|
1400
1567
|
*/
|
|
1401
1568
|
getStatus() {
|
|
1402
|
-
const nativeStatus =
|
|
1569
|
+
const nativeStatus =
|
|
1570
|
+
this.recordingMode === "iphone" &&
|
|
1571
|
+
typeof nativeBinding.getIOSDeviceRecordingStatus === "function"
|
|
1572
|
+
? nativeBinding.getIOSDeviceRecordingStatus().isRecording === true
|
|
1573
|
+
: nativeBinding.getRecordingStatus();
|
|
1403
1574
|
return {
|
|
1404
1575
|
isRecording: this.isRecording && nativeStatus,
|
|
1405
1576
|
outputPath: this.outputPath,
|
package/package.json
CHANGED
package/src/audio_recorder.mm
CHANGED
|
@@ -337,6 +337,12 @@ static NSString *g_lastStandaloneAudioOutputPath = nil;
|
|
|
337
337
|
|
|
338
338
|
CMTime timestamp = CMSampleBufferGetPresentationTimeStamp(sampleBuffer);
|
|
339
339
|
|
|
340
|
+
// Keep microphone warm-up outside the recording until the USB iPhone movie
|
|
341
|
+
// output has actually started writing its first frame.
|
|
342
|
+
if (MRSyncShouldHoldForPrimary(timestamp)) {
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
|
|
340
346
|
// A/V SYNC: Hold audio samples until camera produces first frame
|
|
341
347
|
// This ensures both audio and camera files start from the same wall-clock moment
|
|
342
348
|
if (MRSyncShouldHoldAudioSample(timestamp)) {
|
package/src/camera_recorder.mm
CHANGED
|
@@ -526,6 +526,12 @@ didOutputSampleBuffer:(CMSampleBufferRef)sampleBuffer
|
|
|
526
526
|
|
|
527
527
|
CMTime timestamp = CMSampleBufferGetPresentationTimeStamp(sampleBuffer);
|
|
528
528
|
|
|
529
|
+
// Drop camera warm-up frames until the primary source (USB iPhone screen)
|
|
530
|
+
// has committed its first frame. This keeps all files on one t=0 boundary.
|
|
531
|
+
if (MRSyncShouldHoldForPrimary(timestamp)) {
|
|
532
|
+
return;
|
|
533
|
+
}
|
|
534
|
+
|
|
529
535
|
// A/V SYNC: Signal camera's first frame to release audio hold
|
|
530
536
|
MRSyncMarkCameraFirstFrame(timestamp);
|
|
531
537
|
|
package/src/cursor_tracker.mm
CHANGED
|
@@ -865,9 +865,30 @@ static bool g_leftMouseDown = false;
|
|
|
865
865
|
static bool g_rightMouseDown = false;
|
|
866
866
|
static NSString *g_lastEventType = @"move";
|
|
867
867
|
|
|
868
|
+
// Erisilebilirlik izni durumu (istem GOSTERMEDEN).
|
|
869
|
+
// NEDEN: izin yokken bile AX cagrilari yapiliyordu; macOS bunun uzerine
|
|
870
|
+
// "Erisilebilirlik" izin dialogunu aciyor ve bu fonksiyon kayit boyunca
|
|
871
|
+
// yuksek frekansta cagrildigi icin istem tekrar tekrar cikiyor. Durum
|
|
872
|
+
// saniyede bir tazelenir: kullanici izni verdigi anda yol kendiliginden
|
|
873
|
+
// devreye girer.
|
|
874
|
+
static bool accessibilityTrustedCached(void) {
|
|
875
|
+
static CFAbsoluteTime lastCheck = 0;
|
|
876
|
+
static bool trusted = false;
|
|
877
|
+
CFAbsoluteTime now = CFAbsoluteTimeGetCurrent();
|
|
878
|
+
if (lastCheck == 0 || now - lastCheck > 1.0) {
|
|
879
|
+
trusted = AXIsProcessTrusted();
|
|
880
|
+
lastCheck = now;
|
|
881
|
+
}
|
|
882
|
+
return trusted;
|
|
883
|
+
}
|
|
884
|
+
|
|
868
885
|
// Accessibility tabanlı cursor tip tespiti
|
|
869
886
|
static NSString* detectCursorTypeUsingAccessibility(CGPoint cursorPos) {
|
|
870
887
|
@autoreleasepool {
|
|
888
|
+
if (!accessibilityTrustedCached()) {
|
|
889
|
+
return nil;
|
|
890
|
+
}
|
|
891
|
+
|
|
871
892
|
AXUIElementRef systemWide = AXUIElementCreateSystemWide();
|
|
872
893
|
if (!systemWide) {
|
|
873
894
|
return nil;
|
|
@@ -0,0 +1,534 @@
|
|
|
1
|
+
#import <napi.h>
|
|
2
|
+
#import <AVFoundation/AVFoundation.h>
|
|
3
|
+
#import <CoreMediaIO/CMIOHardware.h>
|
|
4
|
+
#import <Foundation/Foundation.h>
|
|
5
|
+
#import "logging.h"
|
|
6
|
+
#import "sync_timeline.h"
|
|
7
|
+
|
|
8
|
+
extern "C" bool startCameraRecording(NSString *outputPath, NSString *deviceId, NSError **error);
|
|
9
|
+
extern "C" bool waitForCameraRecordingStart(double timeoutSeconds);
|
|
10
|
+
extern "C" bool stopCameraRecording(void);
|
|
11
|
+
extern "C" bool isCameraRecording(void);
|
|
12
|
+
extern "C" bool startStandaloneAudioRecording(NSString *outputPath, NSString *preferredDeviceId, NSError **error);
|
|
13
|
+
extern "C" bool stopStandaloneAudioRecording(void);
|
|
14
|
+
extern "C" bool isStandaloneAudioRecording(void);
|
|
15
|
+
|
|
16
|
+
@interface MRIOSDeviceRecorder : NSObject <AVCaptureFileOutputRecordingDelegate>
|
|
17
|
+
@property(nonatomic, strong) AVCaptureSession *session;
|
|
18
|
+
@property(nonatomic, strong) AVCaptureDeviceInput *deviceInput;
|
|
19
|
+
@property(nonatomic, strong) AVCaptureMovieFileOutput *movieOutput;
|
|
20
|
+
@property(nonatomic, copy) NSString *outputPath;
|
|
21
|
+
@property(atomic) BOOL recording;
|
|
22
|
+
@property(atomic) BOOL startCompleted;
|
|
23
|
+
@property(atomic) BOOL finishCompleted;
|
|
24
|
+
@property(atomic, strong) NSError *finishError;
|
|
25
|
+
@property(nonatomic) BOOL captureCamera;
|
|
26
|
+
@property(nonatomic) BOOL captureMicrophone;
|
|
27
|
+
@property(nonatomic, copy) NSString *cameraOutputPath;
|
|
28
|
+
@property(nonatomic, copy) NSString *audioOutputPath;
|
|
29
|
+
@property(nonatomic, strong) NSDate *primaryStartedAt;
|
|
30
|
+
@end
|
|
31
|
+
|
|
32
|
+
@implementation MRIOSDeviceRecorder
|
|
33
|
+
|
|
34
|
+
- (void)captureOutput:(AVCaptureFileOutput *)captureOutput
|
|
35
|
+
didStartRecordingToOutputFileAtURL:(NSURL *)fileURL
|
|
36
|
+
fromConnections:(NSArray<AVCaptureConnection *> *)connections {
|
|
37
|
+
self.recording = YES;
|
|
38
|
+
self.startCompleted = YES;
|
|
39
|
+
self.primaryStartedAt = [NSDate date];
|
|
40
|
+
MRSyncMarkPrimaryStarted(CMClockGetTime(CMClockGetHostTimeClock()));
|
|
41
|
+
MRLog(@"📱 iPhone capture started: %@", fileURL.path);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
- (void)captureOutput:(AVCaptureFileOutput *)captureOutput
|
|
45
|
+
didFinishRecordingToOutputFileAtURL:(NSURL *)outputFileURL
|
|
46
|
+
fromConnections:(NSArray<AVCaptureConnection *> *)connections
|
|
47
|
+
error:(NSError *)error {
|
|
48
|
+
self.recording = NO;
|
|
49
|
+
self.finishError = error;
|
|
50
|
+
self.finishCompleted = YES;
|
|
51
|
+
if (error) {
|
|
52
|
+
NSNumber *successfullyFinished = error.userInfo[AVErrorRecordingSuccessfullyFinishedKey];
|
|
53
|
+
if (![successfullyFinished boolValue]) {
|
|
54
|
+
MRLog(@"❌ iPhone capture finalize failed: %@", error.localizedDescription);
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
MRLog(@"✅ iPhone capture finalized: %@", outputFileURL.path);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
@end
|
|
62
|
+
|
|
63
|
+
static MRIOSDeviceRecorder *g_iosRecorder = nil;
|
|
64
|
+
|
|
65
|
+
static void MREnableIOSScreenCaptureDevices(void) {
|
|
66
|
+
static dispatch_once_t onceToken;
|
|
67
|
+
dispatch_once(&onceToken, ^{
|
|
68
|
+
CMIOObjectPropertyAddress address = {
|
|
69
|
+
kCMIOHardwarePropertyAllowScreenCaptureDevices,
|
|
70
|
+
kCMIOObjectPropertyScopeGlobal,
|
|
71
|
+
kCMIOObjectPropertyElementMain
|
|
72
|
+
};
|
|
73
|
+
UInt32 allow = 1;
|
|
74
|
+
OSStatus status = CMIOObjectSetPropertyData(
|
|
75
|
+
kCMIOObjectSystemObject,
|
|
76
|
+
&address,
|
|
77
|
+
0,
|
|
78
|
+
NULL,
|
|
79
|
+
sizeof(allow),
|
|
80
|
+
&allow
|
|
81
|
+
);
|
|
82
|
+
if (status == noErr) {
|
|
83
|
+
MRLog(@"✅ CoreMediaIO iPhone screen capture devices enabled");
|
|
84
|
+
} else {
|
|
85
|
+
MRLog(@"❌ CoreMediaIO could not enable iPhone screen capture devices (OSStatus=%d)", (int)status);
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
static NSArray<AVCaptureDevice *> *MRDiscoverIOSCaptureDevices(void) {
|
|
91
|
+
NSMutableArray<AVCaptureDevice *> *result = [NSMutableArray array];
|
|
92
|
+
NSMutableSet<NSString *> *seenIds = [NSMutableSet set];
|
|
93
|
+
|
|
94
|
+
#pragma clang diagnostic push
|
|
95
|
+
#pragma clang diagnostic ignored "-Wdeprecated-declarations"
|
|
96
|
+
for (AVCaptureDevice *device in [AVCaptureDevice devicesWithMediaType:AVMediaTypeMuxed]) {
|
|
97
|
+
if (device.uniqueID.length == 0 || [seenIds containsObject:device.uniqueID]) continue;
|
|
98
|
+
[seenIds addObject:device.uniqueID];
|
|
99
|
+
[result addObject:device];
|
|
100
|
+
}
|
|
101
|
+
#pragma clang diagnostic pop
|
|
102
|
+
|
|
103
|
+
NSMutableArray<AVCaptureDeviceType> *deviceTypes = [NSMutableArray array];
|
|
104
|
+
if (@available(macOS 10.15, *)) {
|
|
105
|
+
[deviceTypes addObject:AVCaptureDeviceTypeExternalUnknown];
|
|
106
|
+
}
|
|
107
|
+
if (@available(macOS 14.0, *)) {
|
|
108
|
+
[deviceTypes addObject:AVCaptureDeviceTypeExternal];
|
|
109
|
+
}
|
|
110
|
+
if (deviceTypes.count > 0) {
|
|
111
|
+
AVCaptureDeviceDiscoverySession *discovery =
|
|
112
|
+
[AVCaptureDeviceDiscoverySession discoverySessionWithDeviceTypes:deviceTypes
|
|
113
|
+
mediaType:AVMediaTypeMuxed
|
|
114
|
+
position:AVCaptureDevicePositionUnspecified];
|
|
115
|
+
for (AVCaptureDevice *device in discovery.devices) {
|
|
116
|
+
if (device.uniqueID.length == 0 || [seenIds containsObject:device.uniqueID]) continue;
|
|
117
|
+
[seenIds addObject:device.uniqueID];
|
|
118
|
+
[result addObject:device];
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return result;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
static NSArray<AVCaptureDevice *> *MRIOSCaptureDevices(void) {
|
|
125
|
+
MREnableIOSScreenCaptureDevices();
|
|
126
|
+
|
|
127
|
+
// CoreMediaIO publishes the USB screen device asynchronously after the
|
|
128
|
+
// allow flag changes. Poll briefly so the first click works without asking
|
|
129
|
+
// the user to close and reopen the recorder.
|
|
130
|
+
NSDate *deadline = [NSDate dateWithTimeIntervalSinceNow:3.0];
|
|
131
|
+
NSArray<AVCaptureDevice *> *devices = nil;
|
|
132
|
+
do {
|
|
133
|
+
devices = MRDiscoverIOSCaptureDevices();
|
|
134
|
+
if (devices.count > 0) break;
|
|
135
|
+
[[NSRunLoop currentRunLoop] runMode:NSDefaultRunLoopMode
|
|
136
|
+
beforeDate:[NSDate dateWithTimeIntervalSinceNow:0.05]];
|
|
137
|
+
} while ([deadline timeIntervalSinceNow] > 0);
|
|
138
|
+
return devices ?: @[];
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
static AVCaptureDevice *MRIOSDeviceForId(NSString *deviceId) {
|
|
142
|
+
NSArray<AVCaptureDevice *> *devices = MRIOSCaptureDevices();
|
|
143
|
+
if (deviceId.length == 0) return devices.firstObject;
|
|
144
|
+
for (AVCaptureDevice *device in devices) {
|
|
145
|
+
if ([device.uniqueID isEqualToString:deviceId]) return device;
|
|
146
|
+
}
|
|
147
|
+
return nil;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
static bool MRWaitForFlag(bool (^readFlag)(void), NSTimeInterval timeoutSeconds) {
|
|
151
|
+
NSDate *deadline = [NSDate dateWithTimeIntervalSinceNow:timeoutSeconds];
|
|
152
|
+
while (!readFlag() && [deadline timeIntervalSinceNow] > 0) {
|
|
153
|
+
[[NSRunLoop currentRunLoop] runMode:NSDefaultRunLoopMode
|
|
154
|
+
beforeDate:[NSDate dateWithTimeIntervalSinceNow:0.01]];
|
|
155
|
+
}
|
|
156
|
+
return readFlag();
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
extern "C" NSArray<NSDictionary *> *listIOSCaptureDevices(void) {
|
|
160
|
+
NSMutableArray<NSDictionary *> *devices = [NSMutableArray array];
|
|
161
|
+
for (AVCaptureDevice *device in MRIOSCaptureDevices()) {
|
|
162
|
+
CMVideoDimensions largest = {0, 0};
|
|
163
|
+
for (AVCaptureDeviceFormat *format in device.formats) {
|
|
164
|
+
CMVideoDimensions dimensions = CMVideoFormatDescriptionGetDimensions(format.formatDescription);
|
|
165
|
+
if ((int64_t)dimensions.width * dimensions.height >
|
|
166
|
+
(int64_t)largest.width * largest.height) {
|
|
167
|
+
largest = dimensions;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
[devices addObject:@{
|
|
171
|
+
@"id": device.uniqueID ?: @"",
|
|
172
|
+
@"name": device.localizedName ?: @"iPhone",
|
|
173
|
+
@"manufacturer": device.manufacturer ?: @"Apple",
|
|
174
|
+
@"model": device.modelID ?: @"",
|
|
175
|
+
@"connected": @(device.isConnected),
|
|
176
|
+
@"suspended": @(device.isSuspended),
|
|
177
|
+
@"width": @(largest.width),
|
|
178
|
+
@"height": @(largest.height),
|
|
179
|
+
@"hasAudio": @YES,
|
|
180
|
+
@"transport": @"usb"
|
|
181
|
+
}];
|
|
182
|
+
}
|
|
183
|
+
return devices;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
extern "C" bool startIOSDeviceRecording(NSString *outputPath,
|
|
187
|
+
NSString *deviceId,
|
|
188
|
+
BOOL captureCamera,
|
|
189
|
+
NSString *cameraOutputPath,
|
|
190
|
+
NSString *cameraDeviceId,
|
|
191
|
+
BOOL captureMicrophone,
|
|
192
|
+
NSString *audioOutputPath,
|
|
193
|
+
NSString *audioDeviceId,
|
|
194
|
+
NSError **errorOut) {
|
|
195
|
+
@autoreleasepool {
|
|
196
|
+
if (g_iosRecorder && (g_iosRecorder.recording || g_iosRecorder.startCompleted)) {
|
|
197
|
+
if (errorOut) {
|
|
198
|
+
*errorOut = [NSError errorWithDomain:@"MacRecorderIOS"
|
|
199
|
+
code:1
|
|
200
|
+
userInfo:@{NSLocalizedDescriptionKey: @"An iPhone recording is already active"}];
|
|
201
|
+
}
|
|
202
|
+
return false;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
AVCaptureDevice *device = MRIOSDeviceForId(deviceId);
|
|
206
|
+
if (!device) {
|
|
207
|
+
if (errorOut) {
|
|
208
|
+
*errorOut = [NSError errorWithDomain:@"MacRecorderIOS"
|
|
209
|
+
code:2
|
|
210
|
+
userInfo:@{NSLocalizedDescriptionKey: @"No trusted USB iPhone capture device was found"}];
|
|
211
|
+
}
|
|
212
|
+
return false;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
NSError *directoryError = nil;
|
|
216
|
+
NSString *directory = [outputPath stringByDeletingLastPathComponent];
|
|
217
|
+
[[NSFileManager defaultManager] createDirectoryAtPath:directory
|
|
218
|
+
withIntermediateDirectories:YES
|
|
219
|
+
attributes:nil
|
|
220
|
+
error:&directoryError];
|
|
221
|
+
if (directoryError) {
|
|
222
|
+
if (errorOut) *errorOut = directoryError;
|
|
223
|
+
return false;
|
|
224
|
+
}
|
|
225
|
+
[[NSFileManager defaultManager] removeItemAtPath:outputPath error:nil];
|
|
226
|
+
if (captureCamera && cameraOutputPath.length > 0) {
|
|
227
|
+
[[NSFileManager defaultManager] removeItemAtPath:cameraOutputPath error:nil];
|
|
228
|
+
}
|
|
229
|
+
if (captureMicrophone && audioOutputPath.length > 0) {
|
|
230
|
+
[[NSFileManager defaultManager] removeItemAtPath:audioOutputPath error:nil];
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
NSError *inputError = nil;
|
|
234
|
+
AVCaptureDeviceInput *input = [AVCaptureDeviceInput deviceInputWithDevice:device
|
|
235
|
+
error:&inputError];
|
|
236
|
+
if (!input) {
|
|
237
|
+
if (errorOut) *errorOut = inputError;
|
|
238
|
+
return false;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
MRIOSDeviceRecorder *recorder = [[MRIOSDeviceRecorder alloc] init];
|
|
242
|
+
recorder.session = [[AVCaptureSession alloc] init];
|
|
243
|
+
recorder.deviceInput = input;
|
|
244
|
+
recorder.movieOutput = [[AVCaptureMovieFileOutput alloc] init];
|
|
245
|
+
recorder.outputPath = outputPath;
|
|
246
|
+
recorder.recording = NO;
|
|
247
|
+
recorder.startCompleted = NO;
|
|
248
|
+
recorder.finishCompleted = NO;
|
|
249
|
+
recorder.finishError = nil;
|
|
250
|
+
recorder.captureCamera = captureCamera;
|
|
251
|
+
recorder.captureMicrophone = captureMicrophone;
|
|
252
|
+
recorder.cameraOutputPath = cameraOutputPath;
|
|
253
|
+
recorder.audioOutputPath = audioOutputPath;
|
|
254
|
+
recorder.primaryStartedAt = nil;
|
|
255
|
+
|
|
256
|
+
[recorder.session beginConfiguration];
|
|
257
|
+
if ([recorder.session canSetSessionPreset:AVCaptureSessionPresetHigh]) {
|
|
258
|
+
recorder.session.sessionPreset = AVCaptureSessionPresetHigh;
|
|
259
|
+
}
|
|
260
|
+
if (![recorder.session canAddInput:input]) {
|
|
261
|
+
[recorder.session commitConfiguration];
|
|
262
|
+
if (errorOut) {
|
|
263
|
+
*errorOut = [NSError errorWithDomain:@"MacRecorderIOS"
|
|
264
|
+
code:3
|
|
265
|
+
userInfo:@{NSLocalizedDescriptionKey: @"The iPhone capture input could not be attached"}];
|
|
266
|
+
}
|
|
267
|
+
return false;
|
|
268
|
+
}
|
|
269
|
+
[recorder.session addInput:input];
|
|
270
|
+
if (![recorder.session canAddOutput:recorder.movieOutput]) {
|
|
271
|
+
[recorder.session commitConfiguration];
|
|
272
|
+
if (errorOut) {
|
|
273
|
+
*errorOut = [NSError errorWithDomain:@"MacRecorderIOS"
|
|
274
|
+
code:4
|
|
275
|
+
userInfo:@{NSLocalizedDescriptionKey: @"The iPhone movie output could not be attached"}];
|
|
276
|
+
}
|
|
277
|
+
return false;
|
|
278
|
+
}
|
|
279
|
+
[recorder.session addOutput:recorder.movieOutput];
|
|
280
|
+
[recorder.session commitConfiguration];
|
|
281
|
+
|
|
282
|
+
// Frequent movie fragments keep long recordings recoverable if the cable
|
|
283
|
+
// disconnects or the app exits before the final movie atom is written.
|
|
284
|
+
recorder.movieOutput.movieFragmentInterval = CMTimeMakeWithSeconds(2.0, 600);
|
|
285
|
+
g_iosRecorder = recorder;
|
|
286
|
+
|
|
287
|
+
// Camera and microphone sessions are prepared first but discard their
|
|
288
|
+
// warm-up samples until AVCaptureMovieFileOutput confirms the iPhone's
|
|
289
|
+
// first frame. Every produced file therefore starts at the same t=0.
|
|
290
|
+
MRSyncConfigure(captureMicrophone);
|
|
291
|
+
MRSyncConfigureCamera(captureCamera);
|
|
292
|
+
MRSyncConfigurePrimaryStart(captureCamera || captureMicrophone);
|
|
293
|
+
|
|
294
|
+
if (captureCamera) {
|
|
295
|
+
NSError *cameraError = nil;
|
|
296
|
+
if (cameraOutputPath.length == 0 ||
|
|
297
|
+
!startCameraRecording(cameraOutputPath, cameraDeviceId, &cameraError)) {
|
|
298
|
+
MRSyncConfigurePrimaryStart(NO);
|
|
299
|
+
MRSyncConfigure(NO);
|
|
300
|
+
g_iosRecorder = nil;
|
|
301
|
+
if (errorOut) {
|
|
302
|
+
*errorOut = cameraError ?: [NSError errorWithDomain:@"MacRecorderIOS"
|
|
303
|
+
code:7
|
|
304
|
+
userInfo:@{NSLocalizedDescriptionKey: @"The selected camera could not be started"}];
|
|
305
|
+
}
|
|
306
|
+
return false;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
if (captureMicrophone) {
|
|
311
|
+
NSError *audioError = nil;
|
|
312
|
+
if (audioOutputPath.length == 0 ||
|
|
313
|
+
!startStandaloneAudioRecording(audioOutputPath, audioDeviceId, &audioError)) {
|
|
314
|
+
if (isCameraRecording()) stopCameraRecording();
|
|
315
|
+
MRSyncConfigurePrimaryStart(NO);
|
|
316
|
+
MRSyncConfigure(NO);
|
|
317
|
+
g_iosRecorder = nil;
|
|
318
|
+
if (errorOut) {
|
|
319
|
+
*errorOut = audioError ?: [NSError errorWithDomain:@"MacRecorderIOS"
|
|
320
|
+
code:8
|
|
321
|
+
userInfo:@{NSLocalizedDescriptionKey: @"The selected microphone could not be started"}];
|
|
322
|
+
}
|
|
323
|
+
return false;
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
[recorder.session startRunning];
|
|
328
|
+
if (!recorder.session.isRunning) {
|
|
329
|
+
if (isCameraRecording()) stopCameraRecording();
|
|
330
|
+
if (isStandaloneAudioRecording()) stopStandaloneAudioRecording();
|
|
331
|
+
MRSyncConfigurePrimaryStart(NO);
|
|
332
|
+
MRSyncConfigure(NO);
|
|
333
|
+
if (errorOut) {
|
|
334
|
+
*errorOut = [NSError errorWithDomain:@"MacRecorderIOS"
|
|
335
|
+
code:5
|
|
336
|
+
userInfo:@{NSLocalizedDescriptionKey: @"The iPhone capture session did not start"}];
|
|
337
|
+
}
|
|
338
|
+
g_iosRecorder = nil;
|
|
339
|
+
return false;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
[recorder.movieOutput startRecordingToOutputFileURL:[NSURL fileURLWithPath:outputPath]
|
|
343
|
+
recordingDelegate:recorder];
|
|
344
|
+
bool started = MRWaitForFlag(^bool{
|
|
345
|
+
return recorder.startCompleted;
|
|
346
|
+
}, 10.0);
|
|
347
|
+
if (!started) {
|
|
348
|
+
if (recorder.movieOutput.isRecording) [recorder.movieOutput stopRecording];
|
|
349
|
+
[recorder.session stopRunning];
|
|
350
|
+
if (isCameraRecording()) stopCameraRecording();
|
|
351
|
+
if (isStandaloneAudioRecording()) stopStandaloneAudioRecording();
|
|
352
|
+
MRSyncConfigurePrimaryStart(NO);
|
|
353
|
+
MRSyncConfigure(NO);
|
|
354
|
+
g_iosRecorder = nil;
|
|
355
|
+
if (errorOut) {
|
|
356
|
+
*errorOut = [NSError errorWithDomain:@"MacRecorderIOS"
|
|
357
|
+
code:6
|
|
358
|
+
userInfo:@{NSLocalizedDescriptionKey: @"Timed out waiting for the first iPhone frame"}];
|
|
359
|
+
}
|
|
360
|
+
return false;
|
|
361
|
+
}
|
|
362
|
+
if (captureCamera && !waitForCameraRecordingStart(8.0)) {
|
|
363
|
+
MRLog(@"❌ Camera did not produce a synchronized frame for iPhone recording");
|
|
364
|
+
if (recorder.movieOutput.isRecording) [recorder.movieOutput stopRecording];
|
|
365
|
+
MRWaitForFlag(^bool{ return recorder.finishCompleted; }, 10.0);
|
|
366
|
+
[recorder.session stopRunning];
|
|
367
|
+
if (isCameraRecording()) stopCameraRecording();
|
|
368
|
+
if (isStandaloneAudioRecording()) stopStandaloneAudioRecording();
|
|
369
|
+
MRSyncConfigurePrimaryStart(NO);
|
|
370
|
+
MRSyncConfigure(NO);
|
|
371
|
+
g_iosRecorder = nil;
|
|
372
|
+
if (errorOut) {
|
|
373
|
+
*errorOut = [NSError errorWithDomain:@"MacRecorderIOS"
|
|
374
|
+
code:9
|
|
375
|
+
userInfo:@{NSLocalizedDescriptionKey: @"Timed out waiting for the selected camera"}];
|
|
376
|
+
}
|
|
377
|
+
return false;
|
|
378
|
+
}
|
|
379
|
+
return true;
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
extern "C" bool stopIOSDeviceRecording(void) {
|
|
384
|
+
@autoreleasepool {
|
|
385
|
+
MRIOSDeviceRecorder *recorder = g_iosRecorder;
|
|
386
|
+
if (!recorder) return true;
|
|
387
|
+
|
|
388
|
+
if (recorder.primaryStartedAt) {
|
|
389
|
+
NSTimeInterval duration = MAX(0.0, -[recorder.primaryStartedAt timeIntervalSinceNow]);
|
|
390
|
+
MRSyncSetStopLimitSeconds(duration);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
BOOL cameraStopped = YES;
|
|
394
|
+
BOOL microphoneStopped = YES;
|
|
395
|
+
if (recorder.captureCamera && isCameraRecording()) {
|
|
396
|
+
cameraStopped = stopCameraRecording();
|
|
397
|
+
}
|
|
398
|
+
if (recorder.captureMicrophone && isStandaloneAudioRecording()) {
|
|
399
|
+
microphoneStopped = stopStandaloneAudioRecording();
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
if (recorder.movieOutput.isRecording) {
|
|
403
|
+
[recorder.movieOutput stopRecording];
|
|
404
|
+
MRWaitForFlag(^bool{
|
|
405
|
+
return recorder.finishCompleted;
|
|
406
|
+
}, 20.0);
|
|
407
|
+
}
|
|
408
|
+
if (recorder.session.isRunning) [recorder.session stopRunning];
|
|
409
|
+
|
|
410
|
+
NSError *finishError = recorder.finishError;
|
|
411
|
+
BOOL finished = recorder.finishCompleted || !recorder.startCompleted;
|
|
412
|
+
BOOL fileExists = [[NSFileManager defaultManager] fileExistsAtPath:recorder.outputPath];
|
|
413
|
+
MRSyncConfigurePrimaryStart(NO);
|
|
414
|
+
MRSyncConfigure(NO);
|
|
415
|
+
g_iosRecorder = nil;
|
|
416
|
+
|
|
417
|
+
if (finishError) {
|
|
418
|
+
NSNumber *successfullyFinished = finishError.userInfo[AVErrorRecordingSuccessfullyFinishedKey];
|
|
419
|
+
if (![successfullyFinished boolValue]) return false;
|
|
420
|
+
}
|
|
421
|
+
if (!cameraStopped || !microphoneStopped) {
|
|
422
|
+
MRLog(@"⚠️ iPhone recording finalized, but an auxiliary camera/microphone writer reported a stop error");
|
|
423
|
+
}
|
|
424
|
+
// Never discard a valid phone screen recording because an optional
|
|
425
|
+
// auxiliary source failed to finalize. The JS layer validates each
|
|
426
|
+
// returned path independently before packaging it.
|
|
427
|
+
return finished && fileExists;
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
extern "C" bool isIOSDeviceRecording(void) {
|
|
432
|
+
return g_iosRecorder && (g_iosRecorder.recording || g_iosRecorder.movieOutput.isRecording);
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
extern "C" NSString *currentIOSDeviceRecordingPath(void) {
|
|
436
|
+
return g_iosRecorder.outputPath;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
Napi::Value GetIOSCaptureDevices(const Napi::CallbackInfo& info) {
|
|
440
|
+
Napi::Env env = info.Env();
|
|
441
|
+
NSArray<NSDictionary *> *devices = listIOSCaptureDevices();
|
|
442
|
+
Napi::Array result = Napi::Array::New(env, devices.count);
|
|
443
|
+
for (NSUInteger index = 0; index < devices.count; index++) {
|
|
444
|
+
NSDictionary *device = devices[index];
|
|
445
|
+
Napi::Object item = Napi::Object::New(env);
|
|
446
|
+
item.Set("id", Napi::String::New(env, [device[@"id"] UTF8String]));
|
|
447
|
+
item.Set("name", Napi::String::New(env, [device[@"name"] UTF8String]));
|
|
448
|
+
item.Set("manufacturer", Napi::String::New(env, [device[@"manufacturer"] UTF8String]));
|
|
449
|
+
item.Set("model", Napi::String::New(env, [device[@"model"] UTF8String]));
|
|
450
|
+
item.Set("connected", Napi::Boolean::New(env, [device[@"connected"] boolValue]));
|
|
451
|
+
item.Set("suspended", Napi::Boolean::New(env, [device[@"suspended"] boolValue]));
|
|
452
|
+
item.Set("width", Napi::Number::New(env, [device[@"width"] intValue]));
|
|
453
|
+
item.Set("height", Napi::Number::New(env, [device[@"height"] intValue]));
|
|
454
|
+
item.Set("hasAudio", Napi::Boolean::New(env, true));
|
|
455
|
+
item.Set("transport", Napi::String::New(env, "usb"));
|
|
456
|
+
result.Set(index, item);
|
|
457
|
+
}
|
|
458
|
+
return result;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
Napi::Value StartIOSDeviceRecording(const Napi::CallbackInfo& info) {
|
|
462
|
+
Napi::Env env = info.Env();
|
|
463
|
+
if (info.Length() < 1 || !info[0].IsString()) {
|
|
464
|
+
Napi::TypeError::New(env, "Output path is required").ThrowAsJavaScriptException();
|
|
465
|
+
return env.Null();
|
|
466
|
+
}
|
|
467
|
+
NSString *outputPath = [NSString stringWithUTF8String:info[0].As<Napi::String>().Utf8Value().c_str()];
|
|
468
|
+
NSString *deviceId = nil;
|
|
469
|
+
if (info.Length() > 1 && info[1].IsString()) {
|
|
470
|
+
deviceId = [NSString stringWithUTF8String:info[1].As<Napi::String>().Utf8Value().c_str()];
|
|
471
|
+
}
|
|
472
|
+
BOOL captureCamera = NO;
|
|
473
|
+
BOOL captureMicrophone = NO;
|
|
474
|
+
NSString *cameraOutputPath = nil;
|
|
475
|
+
NSString *cameraDeviceId = nil;
|
|
476
|
+
NSString *audioOutputPath = nil;
|
|
477
|
+
NSString *audioDeviceId = nil;
|
|
478
|
+
if (info.Length() > 2 && info[2].IsObject()) {
|
|
479
|
+
Napi::Object options = info[2].As<Napi::Object>();
|
|
480
|
+
auto readBool = [&](const char *key) -> BOOL {
|
|
481
|
+
Napi::Value value = options.Get(key);
|
|
482
|
+
return value.IsBoolean() && value.As<Napi::Boolean>().Value();
|
|
483
|
+
};
|
|
484
|
+
auto readString = [&](const char *key) -> NSString * {
|
|
485
|
+
Napi::Value value = options.Get(key);
|
|
486
|
+
if (!value.IsString()) return nil;
|
|
487
|
+
std::string text = value.As<Napi::String>().Utf8Value();
|
|
488
|
+
return text.empty() ? nil : [NSString stringWithUTF8String:text.c_str()];
|
|
489
|
+
};
|
|
490
|
+
captureCamera = readBool("captureCamera");
|
|
491
|
+
captureMicrophone = readBool("includeMicrophone");
|
|
492
|
+
cameraOutputPath = readString("cameraOutputPath");
|
|
493
|
+
cameraDeviceId = readString("cameraDeviceId");
|
|
494
|
+
audioOutputPath = readString("audioOutputPath");
|
|
495
|
+
audioDeviceId = readString("audioDeviceId");
|
|
496
|
+
}
|
|
497
|
+
NSError *error = nil;
|
|
498
|
+
bool success = startIOSDeviceRecording(outputPath,
|
|
499
|
+
deviceId,
|
|
500
|
+
captureCamera,
|
|
501
|
+
cameraOutputPath,
|
|
502
|
+
cameraDeviceId,
|
|
503
|
+
captureMicrophone,
|
|
504
|
+
audioOutputPath,
|
|
505
|
+
audioDeviceId,
|
|
506
|
+
&error);
|
|
507
|
+
if (!success && error) {
|
|
508
|
+
// startIOSDeviceRecording owns an inner autorelease pool. Do not bridge
|
|
509
|
+
// the NSError past that pool into V8; the JS wrapper turns false into a
|
|
510
|
+
// stable user-facing error and avoids a dangling Objective-C object.
|
|
511
|
+
MRLog(@"❌ iPhone capture could not start");
|
|
512
|
+
}
|
|
513
|
+
return Napi::Boolean::New(env, success);
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
Napi::Value StopIOSDeviceRecording(const Napi::CallbackInfo& info) {
|
|
517
|
+
return Napi::Boolean::New(info.Env(), stopIOSDeviceRecording());
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
Napi::Value GetIOSDeviceRecordingStatus(const Napi::CallbackInfo& info) {
|
|
521
|
+
Napi::Object status = Napi::Object::New(info.Env());
|
|
522
|
+
status.Set("isRecording", Napi::Boolean::New(info.Env(), isIOSDeviceRecording()));
|
|
523
|
+
NSString *path = currentIOSDeviceRecordingPath();
|
|
524
|
+
if (path.length > 0) status.Set("outputPath", Napi::String::New(info.Env(), [path UTF8String]));
|
|
525
|
+
return status;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
Napi::Object InitIOSDeviceRecorder(Napi::Env env, Napi::Object exports) {
|
|
529
|
+
exports.Set("getIOSCaptureDevices", Napi::Function::New(env, GetIOSCaptureDevices));
|
|
530
|
+
exports.Set("startIOSDeviceRecording", Napi::Function::New(env, StartIOSDeviceRecording));
|
|
531
|
+
exports.Set("stopIOSDeviceRecording", Napi::Function::New(env, StopIOSDeviceRecording));
|
|
532
|
+
exports.Set("getIOSDeviceRecordingStatus", Napi::Function::New(env, GetIOSDeviceRecordingStatus));
|
|
533
|
+
return exports;
|
|
534
|
+
}
|
package/src/mac_recorder.mm
CHANGED
|
@@ -55,6 +55,9 @@ Napi::Object InitKeyboardTracker(Napi::Env env, Napi::Object exports);
|
|
|
55
55
|
// Window selector function declarations
|
|
56
56
|
Napi::Object InitWindowSelector(Napi::Env env, Napi::Object exports);
|
|
57
57
|
|
|
58
|
+
// USB iPhone/iPad screen capture functions
|
|
59
|
+
Napi::Object InitIOSDeviceRecorder(Napi::Env env, Napi::Object exports);
|
|
60
|
+
|
|
58
61
|
// Window selector overlay functions (external)
|
|
59
62
|
extern "C" void hideOverlays();
|
|
60
63
|
extern "C" void showOverlays();
|
|
@@ -1779,6 +1782,9 @@ Napi::Object Init(Napi::Env env, Napi::Object exports) {
|
|
|
1779
1782
|
|
|
1780
1783
|
// Initialize window selector
|
|
1781
1784
|
InitWindowSelector(env, exports);
|
|
1785
|
+
|
|
1786
|
+
// Initialize direct USB iPhone/iPad capture
|
|
1787
|
+
InitIOSDeviceRecorder(env, exports);
|
|
1782
1788
|
|
|
1783
1789
|
return exports;
|
|
1784
1790
|
}
|
package/src/sync_timeline.h
CHANGED
|
@@ -32,6 +32,14 @@ void MRSyncConfigureCamera(BOOL expectCamera);
|
|
|
32
32
|
void MRSyncMarkCameraFirstFrame(CMTime timestamp);
|
|
33
33
|
BOOL MRSyncShouldHoldAudioSample(CMTime timestamp);
|
|
34
34
|
|
|
35
|
+
// Some primary sources (for example a USB iPhone muxed device) need a short
|
|
36
|
+
// asynchronous warm-up before their first frame is committed. Camera and
|
|
37
|
+
// microphone writers can use this barrier to discard their warm-up samples and
|
|
38
|
+
// begin at the same host-clock instant as that primary source.
|
|
39
|
+
void MRSyncConfigurePrimaryStart(BOOL expectPrimary);
|
|
40
|
+
void MRSyncMarkPrimaryStarted(CMTime timestamp);
|
|
41
|
+
BOOL MRSyncShouldHoldForPrimary(CMTime timestamp);
|
|
42
|
+
|
|
35
43
|
// Optional hard stop limit (seconds) shared across capture components.
|
|
36
44
|
void MRSyncSetStopLimitSeconds(double seconds);
|
|
37
45
|
double MRSyncGetStopLimitSeconds(void);
|
package/src/sync_timeline.mm
CHANGED
|
@@ -25,6 +25,13 @@ static CMTime g_cameraFirstTimestamp = kCMTimeInvalid;
|
|
|
25
25
|
static CMTime g_audioHoldFirstTimestamp = kCMTimeInvalid;
|
|
26
26
|
static BOOL g_audioHoldLogged = NO;
|
|
27
27
|
|
|
28
|
+
// Primary-source start barrier (USB iPhone screen capture, etc.).
|
|
29
|
+
static BOOL g_expectPrimary = NO;
|
|
30
|
+
static BOOL g_primaryReady = YES;
|
|
31
|
+
static CMTime g_primaryStartTimestamp = kCMTimeInvalid;
|
|
32
|
+
static CMTime g_primaryHoldFirstTimestamp = kCMTimeInvalid;
|
|
33
|
+
static BOOL g_primaryHoldLogged = NO;
|
|
34
|
+
|
|
28
35
|
void MRSyncConfigure(BOOL expectAudio) {
|
|
29
36
|
dispatch_sync(MRSyncQueue(), ^{
|
|
30
37
|
g_expectAudio = expectAudio;
|
|
@@ -40,6 +47,11 @@ void MRSyncConfigure(BOOL expectAudio) {
|
|
|
40
47
|
g_cameraFirstTimestamp = kCMTimeInvalid;
|
|
41
48
|
g_audioHoldFirstTimestamp = kCMTimeInvalid;
|
|
42
49
|
g_audioHoldLogged = NO;
|
|
50
|
+
g_expectPrimary = NO;
|
|
51
|
+
g_primaryReady = YES;
|
|
52
|
+
g_primaryStartTimestamp = kCMTimeInvalid;
|
|
53
|
+
g_primaryHoldFirstTimestamp = kCMTimeInvalid;
|
|
54
|
+
g_primaryHoldLogged = NO;
|
|
43
55
|
});
|
|
44
56
|
}
|
|
45
57
|
|
|
@@ -245,6 +257,82 @@ BOOL MRSyncShouldHoldAudioSample(CMTime timestamp) {
|
|
|
245
257
|
return shouldHold;
|
|
246
258
|
}
|
|
247
259
|
|
|
260
|
+
void MRSyncConfigurePrimaryStart(BOOL expectPrimary) {
|
|
261
|
+
dispatch_sync(MRSyncQueue(), ^{
|
|
262
|
+
g_expectPrimary = expectPrimary;
|
|
263
|
+
g_primaryReady = expectPrimary ? NO : YES;
|
|
264
|
+
g_primaryStartTimestamp = kCMTimeInvalid;
|
|
265
|
+
g_primaryHoldFirstTimestamp = kCMTimeInvalid;
|
|
266
|
+
g_primaryHoldLogged = NO;
|
|
267
|
+
});
|
|
268
|
+
if (expectPrimary) {
|
|
269
|
+
MRLog(@"🔄 A/V SYNC: Primary-source start barrier enabled");
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
void MRSyncMarkPrimaryStarted(CMTime timestamp) {
|
|
274
|
+
if (!CMTIME_IS_VALID(timestamp)) return;
|
|
275
|
+
|
|
276
|
+
__block BOOL logRelease = NO;
|
|
277
|
+
dispatch_sync(MRSyncQueue(), ^{
|
|
278
|
+
if (g_primaryReady) return;
|
|
279
|
+
g_primaryStartTimestamp = timestamp;
|
|
280
|
+
g_primaryReady = YES;
|
|
281
|
+
g_primaryHoldFirstTimestamp = kCMTimeInvalid;
|
|
282
|
+
g_primaryHoldLogged = NO;
|
|
283
|
+
logRelease = YES;
|
|
284
|
+
});
|
|
285
|
+
if (logRelease) {
|
|
286
|
+
MRLog(@"🎯 A/V SYNC: Primary source started - releasing camera and microphone");
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
BOOL MRSyncShouldHoldForPrimary(CMTime timestamp) {
|
|
291
|
+
if (!CMTIME_IS_VALID(timestamp)) return NO;
|
|
292
|
+
|
|
293
|
+
__block BOOL shouldHold = NO;
|
|
294
|
+
__block BOOL logHold = NO;
|
|
295
|
+
__block BOOL logRelease = NO;
|
|
296
|
+
dispatch_sync(MRSyncQueue(), ^{
|
|
297
|
+
if (!g_expectPrimary || g_primaryReady) {
|
|
298
|
+
if (CMTIME_IS_VALID(g_primaryStartTimestamp) &&
|
|
299
|
+
CMTIME_COMPARE_INLINE(timestamp, <, g_primaryStartTimestamp)) {
|
|
300
|
+
shouldHold = YES;
|
|
301
|
+
}
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
if (!CMTIME_IS_VALID(g_primaryHoldFirstTimestamp)) {
|
|
306
|
+
g_primaryHoldFirstTimestamp = timestamp;
|
|
307
|
+
shouldHold = YES;
|
|
308
|
+
if (!g_primaryHoldLogged) {
|
|
309
|
+
g_primaryHoldLogged = YES;
|
|
310
|
+
logHold = YES;
|
|
311
|
+
}
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// Fail open if a future primary source forgets to signal start.
|
|
316
|
+
CMTime elapsed = CMTimeSubtract(timestamp, g_primaryHoldFirstTimestamp);
|
|
317
|
+
if (CMTIME_COMPARE_INLINE(elapsed, >, CMTimeMakeWithSeconds(12.0, 600))) {
|
|
318
|
+
g_primaryReady = YES;
|
|
319
|
+
g_primaryHoldFirstTimestamp = kCMTimeInvalid;
|
|
320
|
+
g_primaryHoldLogged = NO;
|
|
321
|
+
shouldHold = NO;
|
|
322
|
+
logRelease = YES;
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
shouldHold = YES;
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
if (logHold) {
|
|
329
|
+
MRLog(@"⏸️ A/V SYNC: Camera/microphone waiting for primary source");
|
|
330
|
+
} else if (logRelease) {
|
|
331
|
+
MRLog(@"▶️ A/V SYNC: Primary-source hold released by safety timeout");
|
|
332
|
+
}
|
|
333
|
+
return shouldHold;
|
|
334
|
+
}
|
|
335
|
+
|
|
248
336
|
void MRSyncSetStopLimitSeconds(double seconds) {
|
|
249
337
|
dispatch_sync(MRSyncQueue(), ^{
|
|
250
338
|
g_stopLimitSeconds = seconds;
|
package/src/window_selector.mm
CHANGED
|
@@ -898,10 +898,57 @@ bool activateWindowOwnerApp(int windowId) {
|
|
|
898
898
|
}
|
|
899
899
|
}
|
|
900
900
|
|
|
901
|
+
// Hedef pencerenin sahibi uygulamayi ONE ALIR (Accessibility GEREKTIRMEZ).
|
|
902
|
+
// bringWindowToFront'un AX yolu izin isterken bu yol yalnizca
|
|
903
|
+
// NSRunningApplication kullanir; izin yokken tek calisan yol budur.
|
|
904
|
+
static bool activateOwnerAppForWindow(int windowId) {
|
|
905
|
+
@autoreleasepool {
|
|
906
|
+
// Method 2: Light activation fallback (minimal app activation)
|
|
907
|
+
NSLog(@" 🔄 Trying minimal activation for window %d", windowId);
|
|
908
|
+
|
|
909
|
+
// Get window info to find the process
|
|
910
|
+
CFArrayRef cgWindowList = CGWindowListCopyWindowInfo(kCGWindowListOptionAll, kCGNullWindowID);
|
|
911
|
+
if (cgWindowList) {
|
|
912
|
+
NSArray *windowArray = (__bridge NSArray *)cgWindowList;
|
|
913
|
+
|
|
914
|
+
for (NSDictionary *windowInfo in windowArray) {
|
|
915
|
+
NSNumber *cgWindowId = [windowInfo objectForKey:(NSString *)kCGWindowNumber];
|
|
916
|
+
if ([cgWindowId intValue] == windowId) {
|
|
917
|
+
// Get process ID
|
|
918
|
+
NSNumber *processId = [windowInfo objectForKey:(NSString *)kCGWindowOwnerPID];
|
|
919
|
+
if (processId) {
|
|
920
|
+
// Light activation - only bring app to front, don't activate all windows
|
|
921
|
+
NSRunningApplication *app = [NSRunningApplication runningApplicationWithProcessIdentifier:[processId intValue]];
|
|
922
|
+
if (app) {
|
|
923
|
+
// Use NSApplicationActivateIgnoringOtherApps only (no NSApplicationActivateAllWindows)
|
|
924
|
+
[app activateWithOptions:NSApplicationActivateIgnoringOtherApps];
|
|
925
|
+
NSLog(@" ✅ App minimally activated: PID %d (specific window should be frontmost)", [processId intValue]);
|
|
926
|
+
CFRelease(cgWindowList);
|
|
927
|
+
return true;
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
break;
|
|
931
|
+
}
|
|
932
|
+
}
|
|
933
|
+
CFRelease(cgWindowList);
|
|
934
|
+
}
|
|
935
|
+
return false;
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
|
|
901
939
|
// Bring window to front using Accessibility API
|
|
902
940
|
bool bringWindowToFront(int windowId) {
|
|
903
941
|
@autoreleasepool {
|
|
904
942
|
@try {
|
|
943
|
+
// TCC istemi CIKMASIN: untrusted bir surecin systemWide element
|
|
944
|
+
// uzerindeki AX cagrilari macOS'a "Erisilebilirlik" dialogunu
|
|
945
|
+
// actiriyor — pencere kaydi HER baslatildiginda yeniden. Izin yoksa
|
|
946
|
+
// AX yolunu hic deneme; hafif aktivasyon zaten ayni isi goruyor.
|
|
947
|
+
// AXIsProcessTrusted() kendisi istem GOSTERMEZ.
|
|
948
|
+
if (!AXIsProcessTrusted()) {
|
|
949
|
+
return activateOwnerAppForWindow(windowId);
|
|
950
|
+
}
|
|
951
|
+
|
|
905
952
|
// Method 1: Using Accessibility API (most reliable)
|
|
906
953
|
AXUIElementRef systemWide = AXUIElementCreateSystemWide();
|
|
907
954
|
if (!systemWide) return false;
|
|
@@ -965,35 +1012,8 @@ bool bringWindowToFront(int windowId) {
|
|
|
965
1012
|
|
|
966
1013
|
CFRelease(systemWide);
|
|
967
1014
|
|
|
968
|
-
//
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
// Get window info to find the process
|
|
972
|
-
CFArrayRef cgWindowList = CGWindowListCopyWindowInfo(kCGWindowListOptionAll, kCGNullWindowID);
|
|
973
|
-
if (cgWindowList) {
|
|
974
|
-
NSArray *windowArray = (__bridge NSArray *)cgWindowList;
|
|
975
|
-
|
|
976
|
-
for (NSDictionary *windowInfo in windowArray) {
|
|
977
|
-
NSNumber *cgWindowId = [windowInfo objectForKey:(NSString *)kCGWindowNumber];
|
|
978
|
-
if ([cgWindowId intValue] == windowId) {
|
|
979
|
-
// Get process ID
|
|
980
|
-
NSNumber *processId = [windowInfo objectForKey:(NSString *)kCGWindowOwnerPID];
|
|
981
|
-
if (processId) {
|
|
982
|
-
// Light activation - only bring app to front, don't activate all windows
|
|
983
|
-
NSRunningApplication *app = [NSRunningApplication runningApplicationWithProcessIdentifier:[processId intValue]];
|
|
984
|
-
if (app) {
|
|
985
|
-
// Use NSApplicationActivateIgnoringOtherApps only (no NSApplicationActivateAllWindows)
|
|
986
|
-
[app activateWithOptions:NSApplicationActivateIgnoringOtherApps];
|
|
987
|
-
NSLog(@" ✅ App minimally activated: PID %d (specific window should be frontmost)", [processId intValue]);
|
|
988
|
-
CFRelease(cgWindowList);
|
|
989
|
-
return true;
|
|
990
|
-
}
|
|
991
|
-
}
|
|
992
|
-
break;
|
|
993
|
-
}
|
|
994
|
-
}
|
|
995
|
-
CFRelease(cgWindowList);
|
|
996
|
-
}
|
|
1015
|
+
// AX yolu pencereyi bulamadi → izinsiz de calisan hafif aktivasyon.
|
|
1016
|
+
if (activateOwnerAppForWindow(windowId)) return true;
|
|
997
1017
|
|
|
998
1018
|
return false;
|
|
999
1019
|
|