node-mac-recorder 2.24.10 → 2.24.12
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/MultiWindowRecorder.js +85 -4
- package/README.md +18 -1
- package/binding.gyp +2 -1
- package/index-multiprocess.js +50 -6
- package/index.js +154 -41
- package/package.json +1 -1
- package/recorder-worker.js +159 -69
- package/recorder_runtime_safety.cjs +101 -0
- package/src/audio_recorder.mm +43 -43
- package/src/avfoundation_recorder.mm +45 -62
- package/src/camera_recorder.mm +69 -78
- package/src/ios_device_recorder.mm +651 -78
- package/src/keyboard_tracker.mm +8 -1
- package/src/mac_recorder.mm +84 -13
- package/src/recording_writer_safety.h +66 -0
- package/src/screen_capture_kit.h +4 -2
- package/src/screen_capture_kit.mm +175 -195
- package/src/sync_timeline.h +9 -0
- package/src/sync_timeline.mm +54 -0
|
@@ -2,16 +2,20 @@
|
|
|
2
2
|
#import <AVFoundation/AVFoundation.h>
|
|
3
3
|
#import <CoreMediaIO/CMIOHardware.h>
|
|
4
4
|
#import <Foundation/Foundation.h>
|
|
5
|
+
#import <IOKit/IOKitLib.h>
|
|
5
6
|
#import "logging.h"
|
|
6
7
|
#import "sync_timeline.h"
|
|
7
8
|
|
|
8
9
|
extern "C" bool startCameraRecording(NSString *outputPath, NSString *deviceId, NSError **error);
|
|
9
10
|
extern "C" bool waitForCameraRecordingStart(double timeoutSeconds);
|
|
10
11
|
extern "C" bool stopCameraRecording(void);
|
|
12
|
+
extern "C" bool stopIOSDeviceRecording(void);
|
|
11
13
|
extern "C" bool isCameraRecording(void);
|
|
12
14
|
extern "C" bool startStandaloneAudioRecording(NSString *outputPath, NSString *preferredDeviceId, NSError **error);
|
|
13
15
|
extern "C" bool stopStandaloneAudioRecording(void);
|
|
14
16
|
extern "C" bool isStandaloneAudioRecording(void);
|
|
17
|
+
extern "C" bool pauseIOSDeviceRecording(void);
|
|
18
|
+
extern "C" bool resumeIOSDeviceRecording(void);
|
|
15
19
|
|
|
16
20
|
@interface MRIOSDeviceRecorder : NSObject <AVCaptureFileOutputRecordingDelegate>
|
|
17
21
|
@property(nonatomic, strong) AVCaptureSession *session;
|
|
@@ -21,7 +25,19 @@ extern "C" bool isStandaloneAudioRecording(void);
|
|
|
21
25
|
@property(atomic) BOOL recording;
|
|
22
26
|
@property(atomic) BOOL startCompleted;
|
|
23
27
|
@property(atomic) BOOL finishCompleted;
|
|
28
|
+
@property(atomic) BOOL startRequested;
|
|
29
|
+
@property(atomic) BOOL stopRequested;
|
|
24
30
|
@property(atomic, strong) NSError *finishError;
|
|
31
|
+
@property(atomic) BOOL paused;
|
|
32
|
+
@property(atomic) BOOL segmentStartCompleted;
|
|
33
|
+
@property(atomic) BOOL segmentFinishCompleted;
|
|
34
|
+
@property(atomic, strong) NSError *segmentFinishError;
|
|
35
|
+
@property(nonatomic, strong) NSMutableArray<NSString *> *segmentPaths;
|
|
36
|
+
@property(nonatomic, copy) NSString *currentSegmentPath;
|
|
37
|
+
@property(nonatomic) NSUInteger nextSegmentIndex;
|
|
38
|
+
@property(nonatomic) BOOL segmentFailure;
|
|
39
|
+
@property(nonatomic, strong) NSMutableArray<NSDictionary<NSString *, NSNumber *> *> *pauseRanges;
|
|
40
|
+
@property(nonatomic) NSTimeInterval pauseStartedAtSeconds;
|
|
25
41
|
@property(nonatomic) BOOL captureCamera;
|
|
26
42
|
@property(nonatomic) BOOL captureMicrophone;
|
|
27
43
|
@property(nonatomic, copy) NSString *cameraOutputPath;
|
|
@@ -29,16 +45,45 @@ extern "C" bool isStandaloneAudioRecording(void);
|
|
|
29
45
|
@property(nonatomic, strong) NSDate *primaryStartedAt;
|
|
30
46
|
@end
|
|
31
47
|
|
|
48
|
+
static BOOL MRIOSHasProducedMedia(MRIOSDeviceRecorder *recorder) {
|
|
49
|
+
if (!recorder.movieOutput.isRecording) return NO;
|
|
50
|
+
CMTime duration = recorder.movieOutput.recordedDuration;
|
|
51
|
+
return CMTIME_IS_NUMERIC(duration) &&
|
|
52
|
+
CMTIME_COMPARE_INLINE(duration, >, kCMTimeZero);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
static NSError *MRIOSNoFramesError(void) {
|
|
56
|
+
return [NSError errorWithDomain:@"MacRecorderIOS"
|
|
57
|
+
code:17
|
|
58
|
+
userInfo:@{
|
|
59
|
+
NSLocalizedDescriptionKey:
|
|
60
|
+
@"iPhone is connected but is not sending video. Unlock it, keep the screen awake, then try again."
|
|
61
|
+
}];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
static void MRIOSMarkSegmentStarted(MRIOSDeviceRecorder *recorder,
|
|
65
|
+
BOOL confirmedByDelegate) {
|
|
66
|
+
recorder.recording = YES;
|
|
67
|
+
recorder.segmentStartCompleted = YES;
|
|
68
|
+
if (!recorder.startCompleted) {
|
|
69
|
+
recorder.startCompleted = YES;
|
|
70
|
+
recorder.primaryStartedAt = [NSDate date];
|
|
71
|
+
if (!recorder.stopRequested) {
|
|
72
|
+
MRSyncMarkPrimaryStarted(CMClockGetTime(CMClockGetHostTimeClock()));
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
if (!confirmedByDelegate) {
|
|
76
|
+
MRLog(@"✅ iPhone capture start confirmed from recorded media progress");
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
32
80
|
@implementation MRIOSDeviceRecorder
|
|
33
81
|
|
|
34
82
|
- (void)captureOutput:(AVCaptureFileOutput *)captureOutput
|
|
35
83
|
didStartRecordingToOutputFileAtURL:(NSURL *)fileURL
|
|
36
84
|
fromConnections:(NSArray<AVCaptureConnection *> *)connections {
|
|
37
|
-
self
|
|
38
|
-
|
|
39
|
-
self.primaryStartedAt = [NSDate date];
|
|
40
|
-
MRSyncMarkPrimaryStarted(CMClockGetTime(CMClockGetHostTimeClock()));
|
|
41
|
-
MRLog(@"📱 iPhone capture started: %@", fileURL.path);
|
|
85
|
+
MRIOSMarkSegmentStarted(self, YES);
|
|
86
|
+
MRLog(@"📱 iPhone capture segment started: %@", fileURL.path);
|
|
42
87
|
}
|
|
43
88
|
|
|
44
89
|
- (void)captureOutput:(AVCaptureFileOutput *)captureOutput
|
|
@@ -46,16 +91,26 @@ extern "C" bool isStandaloneAudioRecording(void);
|
|
|
46
91
|
fromConnections:(NSArray<AVCaptureConnection *> *)connections
|
|
47
92
|
error:(NSError *)error {
|
|
48
93
|
self.recording = NO;
|
|
49
|
-
self.
|
|
50
|
-
self.
|
|
94
|
+
self.segmentFinishError = error;
|
|
95
|
+
self.segmentFinishCompleted = YES;
|
|
96
|
+
if (self.stopRequested) {
|
|
97
|
+
self.finishError = error;
|
|
98
|
+
self.finishCompleted = YES;
|
|
99
|
+
}
|
|
51
100
|
if (error) {
|
|
52
101
|
NSNumber *successfullyFinished = error.userInfo[AVErrorRecordingSuccessfullyFinishedKey];
|
|
53
102
|
if (![successfullyFinished boolValue]) {
|
|
54
|
-
|
|
103
|
+
self.segmentFailure = YES;
|
|
104
|
+
MRLog(@"❌ iPhone capture segment finalize failed: %@ (domain=%@, code=%ld, reason=%@, userInfo=%@)",
|
|
105
|
+
error.localizedDescription,
|
|
106
|
+
error.domain,
|
|
107
|
+
(long)error.code,
|
|
108
|
+
error.localizedFailureReason ?: @"",
|
|
109
|
+
error.userInfo ?: @{});
|
|
55
110
|
return;
|
|
56
111
|
}
|
|
57
112
|
}
|
|
58
|
-
MRLog(@"✅ iPhone capture finalized: %@", outputFileURL.path);
|
|
113
|
+
MRLog(@"✅ iPhone capture segment finalized: %@", outputFileURL.path);
|
|
59
114
|
}
|
|
60
115
|
|
|
61
116
|
@end
|
|
@@ -63,28 +118,31 @@ extern "C" bool isStandaloneAudioRecording(void);
|
|
|
63
118
|
static MRIOSDeviceRecorder *g_iosRecorder = nil;
|
|
64
119
|
|
|
65
120
|
static void MREnableIOSScreenCaptureDevices(void) {
|
|
66
|
-
|
|
67
|
-
dispatch_once
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
121
|
+
// Reapply this process-level opt-in before every discovery. CoreMediaIO can
|
|
122
|
+
// restart after a cable/session transition; dispatch_once would then leave
|
|
123
|
+
// the replacement service without the flag and later scans would be empty.
|
|
124
|
+
CMIOObjectPropertyAddress address = {
|
|
125
|
+
kCMIOHardwarePropertyAllowScreenCaptureDevices,
|
|
126
|
+
kCMIOObjectPropertyScopeGlobal,
|
|
127
|
+
kCMIOObjectPropertyElementMain
|
|
128
|
+
};
|
|
129
|
+
UInt32 allow = 1;
|
|
130
|
+
OSStatus status = CMIOObjectSetPropertyData(
|
|
131
|
+
kCMIOObjectSystemObject,
|
|
132
|
+
&address,
|
|
133
|
+
0,
|
|
134
|
+
NULL,
|
|
135
|
+
sizeof(allow),
|
|
136
|
+
&allow
|
|
137
|
+
);
|
|
138
|
+
if (status == noErr) {
|
|
139
|
+
static dispatch_once_t successLogToken;
|
|
140
|
+
dispatch_once(&successLogToken, ^{
|
|
83
141
|
MRLog(@"✅ CoreMediaIO iPhone screen capture devices enabled");
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
}
|
|
142
|
+
});
|
|
143
|
+
} else {
|
|
144
|
+
MRLog(@"❌ CoreMediaIO could not enable iPhone screen capture devices (OSStatus=%d)", (int)status);
|
|
145
|
+
}
|
|
88
146
|
}
|
|
89
147
|
|
|
90
148
|
static NSArray<AVCaptureDevice *> *MRDiscoverIOSCaptureDevices(void) {
|
|
@@ -121,17 +179,94 @@ static NSArray<AVCaptureDevice *> *MRDiscoverIOSCaptureDevices(void) {
|
|
|
121
179
|
return result;
|
|
122
180
|
}
|
|
123
181
|
|
|
124
|
-
static
|
|
182
|
+
static NSString *MRUSBStringProperty(io_service_t service, CFStringRef key) {
|
|
183
|
+
CFTypeRef value = IORegistryEntryCreateCFProperty(service, key, kCFAllocatorDefault, 0);
|
|
184
|
+
if (!value) return nil;
|
|
185
|
+
NSString *result = nil;
|
|
186
|
+
if (CFGetTypeID(value) == CFStringGetTypeID()) {
|
|
187
|
+
result = [NSString stringWithString:(NSString *)value];
|
|
188
|
+
}
|
|
189
|
+
CFRelease(value);
|
|
190
|
+
return result;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
static NSNumber *MRUSBNumberProperty(io_service_t service, CFStringRef key) {
|
|
194
|
+
CFTypeRef value = IORegistryEntryCreateCFProperty(service, key, kCFAllocatorDefault, 0);
|
|
195
|
+
if (!value) return nil;
|
|
196
|
+
NSNumber *result = nil;
|
|
197
|
+
if (CFGetTypeID(value) == CFNumberGetTypeID() ||
|
|
198
|
+
CFGetTypeID(value) == CFBooleanGetTypeID()) {
|
|
199
|
+
result = [NSNumber numberWithLongLong:[(NSNumber *)value longLongValue]];
|
|
200
|
+
}
|
|
201
|
+
CFRelease(value);
|
|
202
|
+
return result;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
static NSArray<NSDictionary *> *MRUSBConnectedIOSDevices(void) {
|
|
206
|
+
NSMutableArray<NSDictionary *> *devices = [NSMutableArray array];
|
|
207
|
+
CFMutableDictionaryRef matching = IOServiceMatching("IOUSBHostDevice");
|
|
208
|
+
if (!matching) return devices;
|
|
209
|
+
|
|
210
|
+
io_iterator_t iterator = IO_OBJECT_NULL;
|
|
211
|
+
#pragma clang diagnostic push
|
|
212
|
+
#pragma clang diagnostic ignored "-Wdeprecated-declarations"
|
|
213
|
+
kern_return_t status = IOServiceGetMatchingServices(kIOMasterPortDefault, matching, &iterator);
|
|
214
|
+
#pragma clang diagnostic pop
|
|
215
|
+
if (status != KERN_SUCCESS || iterator == IO_OBJECT_NULL) return devices;
|
|
216
|
+
|
|
217
|
+
io_service_t service = IO_OBJECT_NULL;
|
|
218
|
+
while ((service = IOIteratorNext(iterator))) {
|
|
219
|
+
NSNumber *supportsIOS = MRUSBNumberProperty(service, CFSTR("SupportsIPhoneOS"));
|
|
220
|
+
NSNumber *vendorId = MRUSBNumberProperty(service, CFSTR("idVendor"));
|
|
221
|
+
if (supportsIOS.boolValue && vendorId.unsignedIntegerValue == 0x05ac) {
|
|
222
|
+
NSString *name = MRUSBStringProperty(service, CFSTR("USB Product Name"));
|
|
223
|
+
NSString *serial = MRUSBStringProperty(service, CFSTR("USB Serial Number"));
|
|
224
|
+
if (serial.length == 0) {
|
|
225
|
+
serial = MRUSBStringProperty(service, CFSTR("kUSBSerialNumberString"));
|
|
226
|
+
}
|
|
227
|
+
NSNumber *location = MRUSBNumberProperty(service, CFSTR("locationID"));
|
|
228
|
+
NSString *identifier = serial.length > 0
|
|
229
|
+
? [NSString stringWithFormat:@"usb:%@", serial]
|
|
230
|
+
: [NSString stringWithFormat:@"usb:%llu", location.unsignedLongLongValue];
|
|
231
|
+
[devices addObject:@{
|
|
232
|
+
@"id": identifier,
|
|
233
|
+
@"name": name.length > 0 ? name : @"iPhone",
|
|
234
|
+
@"manufacturer": @"Apple",
|
|
235
|
+
@"model": @"",
|
|
236
|
+
@"connected": @YES,
|
|
237
|
+
@"suspended": @YES,
|
|
238
|
+
@"captureReady": @NO,
|
|
239
|
+
@"width": @0,
|
|
240
|
+
@"height": @0,
|
|
241
|
+
@"hasAudio": @YES,
|
|
242
|
+
@"transport": @"usb"
|
|
243
|
+
}];
|
|
244
|
+
}
|
|
245
|
+
IOObjectRelease(service);
|
|
246
|
+
}
|
|
247
|
+
IOObjectRelease(iterator);
|
|
248
|
+
return devices;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
static NSArray<AVCaptureDevice *> *MRIOSCaptureDevices(NSTimeInterval timeoutSeconds) {
|
|
125
252
|
MREnableIOSScreenCaptureDevices();
|
|
126
253
|
|
|
127
|
-
// CoreMediaIO publishes the USB screen device asynchronously
|
|
128
|
-
//
|
|
129
|
-
//
|
|
130
|
-
NSDate *deadline = [NSDate dateWithTimeIntervalSinceNow:
|
|
254
|
+
// CoreMediaIO publishes and resumes the USB screen device asynchronously.
|
|
255
|
+
// Recording startup may wait briefly for that transition. Inventory scans
|
|
256
|
+
// request an immediate snapshot so renderer monitoring never blocks the UI.
|
|
257
|
+
NSDate *deadline = [NSDate dateWithTimeIntervalSinceNow:MAX(0.0, timeoutSeconds)];
|
|
131
258
|
NSArray<AVCaptureDevice *> *devices = nil;
|
|
132
259
|
do {
|
|
133
260
|
devices = MRDiscoverIOSCaptureDevices();
|
|
134
|
-
|
|
261
|
+
BOOL hasActiveDevice = NO;
|
|
262
|
+
for (AVCaptureDevice *device in devices) {
|
|
263
|
+
if (device.isConnected && !device.isSuspended) {
|
|
264
|
+
hasActiveDevice = YES;
|
|
265
|
+
break;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
if (hasActiveDevice) break;
|
|
269
|
+
if ([deadline timeIntervalSinceNow] <= 0) break;
|
|
135
270
|
[[NSRunLoop currentRunLoop] runMode:NSDefaultRunLoopMode
|
|
136
271
|
beforeDate:[NSDate dateWithTimeIntervalSinceNow:0.05]];
|
|
137
272
|
} while ([deadline timeIntervalSinceNow] > 0);
|
|
@@ -139,10 +274,16 @@ static NSArray<AVCaptureDevice *> *MRIOSCaptureDevices(void) {
|
|
|
139
274
|
}
|
|
140
275
|
|
|
141
276
|
static AVCaptureDevice *MRIOSDeviceForId(NSString *deviceId) {
|
|
142
|
-
NSArray<AVCaptureDevice *> *devices = MRIOSCaptureDevices();
|
|
143
|
-
if (deviceId.length == 0)
|
|
277
|
+
NSArray<AVCaptureDevice *> *devices = MRIOSCaptureDevices(3.0);
|
|
278
|
+
if (deviceId.length == 0) {
|
|
279
|
+
for (AVCaptureDevice *device in devices) {
|
|
280
|
+
if (device.isConnected && !device.isSuspended) return device;
|
|
281
|
+
}
|
|
282
|
+
return nil;
|
|
283
|
+
}
|
|
144
284
|
for (AVCaptureDevice *device in devices) {
|
|
145
|
-
if ([device.uniqueID isEqualToString:deviceId]
|
|
285
|
+
if ([device.uniqueID isEqualToString:deviceId] &&
|
|
286
|
+
device.isConnected && !device.isSuspended) return device;
|
|
146
287
|
}
|
|
147
288
|
return nil;
|
|
148
289
|
}
|
|
@@ -156,9 +297,295 @@ static bool MRWaitForFlag(bool (^readFlag)(void), NSTimeInterval timeoutSeconds)
|
|
|
156
297
|
return readFlag();
|
|
157
298
|
}
|
|
158
299
|
|
|
300
|
+
static BOOL MRIOSFileOutputSucceeded(NSError *error) {
|
|
301
|
+
if (!error) return YES;
|
|
302
|
+
NSNumber *successfullyFinished = error.userInfo[AVErrorRecordingSuccessfullyFinishedKey];
|
|
303
|
+
return [successfullyFinished boolValue];
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
static NSTimeInterval MRIOSRecordedDurationSeconds(MRIOSDeviceRecorder *recorder) {
|
|
307
|
+
CMTime duration = recorder.movieOutput.recordedDuration;
|
|
308
|
+
if (CMTIME_IS_NUMERIC(duration) &&
|
|
309
|
+
CMTIME_COMPARE_INLINE(duration, >=, kCMTimeZero)) {
|
|
310
|
+
return MAX(0.0, CMTimeGetSeconds(duration));
|
|
311
|
+
}
|
|
312
|
+
if (recorder.primaryStartedAt) {
|
|
313
|
+
return MAX(0.0, -[recorder.primaryStartedAt timeIntervalSinceNow]);
|
|
314
|
+
}
|
|
315
|
+
return 0.0;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
static void MRIOSBeginPauseRange(MRIOSDeviceRecorder *recorder) {
|
|
319
|
+
recorder.pauseStartedAtSeconds = MRIOSRecordedDurationSeconds(recorder);
|
|
320
|
+
MRLog(@"⏸️ iPhone capture pause marker at %.3f seconds",
|
|
321
|
+
recorder.pauseStartedAtSeconds);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
static void MRIOSEndPauseRange(MRIOSDeviceRecorder *recorder) {
|
|
325
|
+
if (recorder.pauseStartedAtSeconds < 0.0) return;
|
|
326
|
+
NSTimeInterval end = MRIOSRecordedDurationSeconds(recorder);
|
|
327
|
+
NSTimeInterval start = recorder.pauseStartedAtSeconds;
|
|
328
|
+
recorder.pauseStartedAtSeconds = -1.0;
|
|
329
|
+
if (end <= start) return;
|
|
330
|
+
[recorder.pauseRanges addObject:@{
|
|
331
|
+
@"start": @(start),
|
|
332
|
+
@"end": @(end)
|
|
333
|
+
}];
|
|
334
|
+
MRLog(@"▶️ iPhone capture resume marker at %.3f seconds (trim %.3f seconds)",
|
|
335
|
+
end, end - start);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
static NSString *MRIOSNextSegmentPath(MRIOSDeviceRecorder *recorder) {
|
|
339
|
+
NSUInteger segmentIndex = recorder.nextSegmentIndex;
|
|
340
|
+
recorder.nextSegmentIndex += 1;
|
|
341
|
+
// A single continuously captured iPhone movie does not need an intermediate
|
|
342
|
+
// filename. More importantly, some CoreMediaIO muxed devices reject a
|
|
343
|
+
// re-targeted AVCaptureMovieFileOutput URL even though the same session can
|
|
344
|
+
// record to the original requested path (the pre-segmentation behavior).
|
|
345
|
+
if (segmentIndex == 0) return recorder.outputPath;
|
|
346
|
+
NSString *base = [recorder.outputPath stringByDeletingPathExtension];
|
|
347
|
+
NSString *path = [NSString stringWithFormat:@"%@.iphone-part-%03lu.mov",
|
|
348
|
+
base, (unsigned long)segmentIndex];
|
|
349
|
+
return path;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
static BOOL MRIOSStartNextSegment(MRIOSDeviceRecorder *recorder, NSError **errorOut) {
|
|
353
|
+
NSString *segmentPath = MRIOSNextSegmentPath(recorder);
|
|
354
|
+
[[NSFileManager defaultManager] removeItemAtPath:segmentPath error:nil];
|
|
355
|
+
recorder.currentSegmentPath = segmentPath;
|
|
356
|
+
recorder.segmentStartCompleted = NO;
|
|
357
|
+
recorder.segmentFinishCompleted = NO;
|
|
358
|
+
recorder.segmentFinishError = nil;
|
|
359
|
+
recorder.recording = NO;
|
|
360
|
+
|
|
361
|
+
[recorder.movieOutput startRecordingToOutputFileURL:[NSURL fileURLWithPath:segmentPath]
|
|
362
|
+
recordingDelegate:recorder];
|
|
363
|
+
// AVCaptureMovieFileOutput can begin writing before its delegate callback
|
|
364
|
+
// is delivered. That callback may be queued behind Electron's synchronous
|
|
365
|
+
// native call, so requiring only the callback creates a false timeout even
|
|
366
|
+
// though real frames are already reaching the file. Recorded duration is
|
|
367
|
+
// an independent, frame-backed confirmation and is safe to use as the
|
|
368
|
+
// fallback start signal.
|
|
369
|
+
BOOL startObserved = MRWaitForFlag(^bool{
|
|
370
|
+
return recorder.segmentStartCompleted ||
|
|
371
|
+
recorder.segmentFinishCompleted ||
|
|
372
|
+
MRIOSHasProducedMedia(recorder);
|
|
373
|
+
}, 10.0);
|
|
374
|
+
if (!recorder.segmentStartCompleted &&
|
|
375
|
+
!recorder.segmentFinishCompleted &&
|
|
376
|
+
MRIOSHasProducedMedia(recorder)) {
|
|
377
|
+
MRIOSMarkSegmentStarted(recorder, NO);
|
|
378
|
+
}
|
|
379
|
+
BOOL started = startObserved && recorder.segmentStartCompleted &&
|
|
380
|
+
recorder.movieOutput.isRecording && !recorder.segmentFinishCompleted;
|
|
381
|
+
if (!started) {
|
|
382
|
+
CMTime duration = recorder.movieOutput.recordedDuration;
|
|
383
|
+
double durationSeconds = CMTIME_IS_NUMERIC(duration)
|
|
384
|
+
? MAX(0.0, CMTimeGetSeconds(duration))
|
|
385
|
+
: 0.0;
|
|
386
|
+
MRLog(@"❌ iPhone segment start timed out (sessionRunning=%@, outputRecording=%@, mediaDuration=%.3f, suspended=%@, connections=%lu)",
|
|
387
|
+
recorder.session.isRunning ? @"YES" : @"NO",
|
|
388
|
+
recorder.movieOutput.isRecording ? @"YES" : @"NO",
|
|
389
|
+
durationSeconds,
|
|
390
|
+
recorder.deviceInput.device.isSuspended ? @"YES" : @"NO",
|
|
391
|
+
(unsigned long)recorder.movieOutput.connections.count);
|
|
392
|
+
if (errorOut) {
|
|
393
|
+
BOOL connectedButNoFrames = recorder.session.isRunning &&
|
|
394
|
+
recorder.movieOutput.isRecording && durationSeconds <= 0.0;
|
|
395
|
+
*errorOut = recorder.segmentFinishError ?: (connectedButNoFrames
|
|
396
|
+
? MRIOSNoFramesError()
|
|
397
|
+
: [NSError errorWithDomain:@"MacRecorderIOS"
|
|
398
|
+
code:11
|
|
399
|
+
userInfo:@{NSLocalizedDescriptionKey: @"Timed out waiting for an iPhone recording segment"}]);
|
|
400
|
+
}
|
|
401
|
+
return NO;
|
|
402
|
+
}
|
|
403
|
+
[recorder.segmentPaths addObject:segmentPath];
|
|
404
|
+
return YES;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
static BOOL MRIOSStopCurrentSegment(MRIOSDeviceRecorder *recorder) {
|
|
408
|
+
if (recorder.movieOutput.isRecording) {
|
|
409
|
+
[recorder.movieOutput stopRecording];
|
|
410
|
+
}
|
|
411
|
+
if (recorder.segmentStartCompleted && !recorder.segmentFinishCompleted) {
|
|
412
|
+
if (!MRWaitForFlag(^bool{ return recorder.segmentFinishCompleted; }, 20.0)) {
|
|
413
|
+
MRLog(@"⚠️ iPhone segment is still finalizing");
|
|
414
|
+
return NO;
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
if (!recorder.segmentStartCompleted) return YES;
|
|
418
|
+
BOOL exists = recorder.currentSegmentPath.length > 0 &&
|
|
419
|
+
[[NSFileManager defaultManager] fileExistsAtPath:recorder.currentSegmentPath];
|
|
420
|
+
return recorder.segmentFinishCompleted &&
|
|
421
|
+
MRIOSFileOutputSucceeded(recorder.segmentFinishError) && exists;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
static BOOL MRIOSAssembleSegments(MRIOSDeviceRecorder *recorder, NSError **errorOut) {
|
|
425
|
+
NSArray<NSString *> *segments = recorder.segmentPaths ?: @[];
|
|
426
|
+
if (segments.count == 0 || recorder.outputPath.length == 0) {
|
|
427
|
+
if (errorOut) {
|
|
428
|
+
*errorOut = [NSError errorWithDomain:@"MacRecorderIOS"
|
|
429
|
+
code:12
|
|
430
|
+
userInfo:@{NSLocalizedDescriptionKey: @"No iPhone recording segments were produced"}];
|
|
431
|
+
}
|
|
432
|
+
return NO;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
NSFileManager *fileManager = [NSFileManager defaultManager];
|
|
436
|
+
NSArray<NSDictionary<NSString *, NSNumber *> *> *pauseRanges = recorder.pauseRanges ?: @[];
|
|
437
|
+
if (segments.count == 1 && pauseRanges.count == 0) {
|
|
438
|
+
if ([segments.firstObject isEqualToString:recorder.outputPath]) {
|
|
439
|
+
BOOL exists = [fileManager fileExistsAtPath:recorder.outputPath];
|
|
440
|
+
if (exists) MRLog(@"✅ iPhone recording finalized in its destination path");
|
|
441
|
+
return exists;
|
|
442
|
+
}
|
|
443
|
+
[fileManager removeItemAtPath:recorder.outputPath error:nil];
|
|
444
|
+
BOOL moved = [fileManager moveItemAtPath:segments.firstObject
|
|
445
|
+
toPath:recorder.outputPath
|
|
446
|
+
error:errorOut];
|
|
447
|
+
if (moved) MRLog(@"✅ iPhone recording finalized without a pause merge");
|
|
448
|
+
return moved;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
AVMutableComposition *composition = [AVMutableComposition composition];
|
|
452
|
+
CMTime insertionTime = kCMTimeZero;
|
|
453
|
+
for (NSUInteger segmentIndex = 0; segmentIndex < segments.count; segmentIndex++) {
|
|
454
|
+
NSString *segmentPath = segments[segmentIndex];
|
|
455
|
+
AVURLAsset *asset = [AVURLAsset URLAssetWithURL:[NSURL fileURLWithPath:segmentPath]
|
|
456
|
+
options:nil];
|
|
457
|
+
CMTime duration = asset.duration;
|
|
458
|
+
if (!CMTIME_IS_NUMERIC(duration) || CMTIME_COMPARE_INLINE(duration, <=, kCMTimeZero)) {
|
|
459
|
+
if (errorOut) {
|
|
460
|
+
*errorOut = [NSError errorWithDomain:@"MacRecorderIOS"
|
|
461
|
+
code:13
|
|
462
|
+
userInfo:@{NSLocalizedDescriptionKey: @"An iPhone recording segment has no playable duration"}];
|
|
463
|
+
}
|
|
464
|
+
return NO;
|
|
465
|
+
}
|
|
466
|
+
// New recordings always contain one continuously written iPhone movie.
|
|
467
|
+
// Keeping AVCaptureMovieFileOutput alive across pause avoids re-arming
|
|
468
|
+
// the fragile USB muxed compressor. Remove paused ranges here instead.
|
|
469
|
+
if (segments.count == 1 && pauseRanges.count > 0) {
|
|
470
|
+
NSTimeInterval assetDuration = CMTimeGetSeconds(duration);
|
|
471
|
+
NSTimeInterval sourceCursor = 0.0;
|
|
472
|
+
int32_t timeScale = duration.timescale > 0 ? duration.timescale : 600;
|
|
473
|
+
for (NSDictionary<NSString *, NSNumber *> *range in pauseRanges) {
|
|
474
|
+
NSTimeInterval pauseStart = MIN(assetDuration,
|
|
475
|
+
MAX(sourceCursor, range[@"start"].doubleValue));
|
|
476
|
+
NSTimeInterval pauseEnd = MIN(assetDuration,
|
|
477
|
+
MAX(pauseStart, range[@"end"].doubleValue));
|
|
478
|
+
if (pauseStart > sourceCursor) {
|
|
479
|
+
CMTime sourceStart = CMTimeMakeWithSeconds(sourceCursor, timeScale);
|
|
480
|
+
CMTime keptDuration = CMTimeMakeWithSeconds(pauseStart - sourceCursor, timeScale);
|
|
481
|
+
NSError *insertError = nil;
|
|
482
|
+
if (![composition insertTimeRange:CMTimeRangeMake(sourceStart, keptDuration)
|
|
483
|
+
ofAsset:asset
|
|
484
|
+
atTime:insertionTime
|
|
485
|
+
error:&insertError]) {
|
|
486
|
+
if (errorOut) *errorOut = insertError;
|
|
487
|
+
return NO;
|
|
488
|
+
}
|
|
489
|
+
insertionTime = CMTimeAdd(insertionTime, keptDuration);
|
|
490
|
+
}
|
|
491
|
+
sourceCursor = MAX(sourceCursor, pauseEnd);
|
|
492
|
+
}
|
|
493
|
+
if (assetDuration > sourceCursor) {
|
|
494
|
+
CMTime sourceStart = CMTimeMakeWithSeconds(sourceCursor, timeScale);
|
|
495
|
+
CMTime keptDuration = CMTimeMakeWithSeconds(assetDuration - sourceCursor, timeScale);
|
|
496
|
+
NSError *insertError = nil;
|
|
497
|
+
if (![composition insertTimeRange:CMTimeRangeMake(sourceStart, keptDuration)
|
|
498
|
+
ofAsset:asset
|
|
499
|
+
atTime:insertionTime
|
|
500
|
+
error:&insertError]) {
|
|
501
|
+
if (errorOut) *errorOut = insertError;
|
|
502
|
+
return NO;
|
|
503
|
+
}
|
|
504
|
+
insertionTime = CMTimeAdd(insertionTime, keptDuration);
|
|
505
|
+
}
|
|
506
|
+
} else {
|
|
507
|
+
NSError *insertError = nil;
|
|
508
|
+
if (![composition insertTimeRange:CMTimeRangeMake(kCMTimeZero, duration)
|
|
509
|
+
ofAsset:asset
|
|
510
|
+
atTime:insertionTime
|
|
511
|
+
error:&insertError]) {
|
|
512
|
+
if (errorOut) *errorOut = insertError;
|
|
513
|
+
return NO;
|
|
514
|
+
}
|
|
515
|
+
insertionTime = CMTimeAdd(insertionTime, duration);
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
if (CMTIME_COMPARE_INLINE(insertionTime, <=, kCMTimeZero)) {
|
|
519
|
+
if (errorOut) {
|
|
520
|
+
*errorOut = [NSError errorWithDomain:@"MacRecorderIOS"
|
|
521
|
+
code:16
|
|
522
|
+
userInfo:@{NSLocalizedDescriptionKey: @"The iPhone recording contains no unpaused media"}];
|
|
523
|
+
}
|
|
524
|
+
return NO;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
AVAssetExportSession *exporter = [[AVAssetExportSession alloc]
|
|
528
|
+
initWithAsset:composition presetName:AVAssetExportPresetPassthrough];
|
|
529
|
+
if (!exporter) {
|
|
530
|
+
if (errorOut) {
|
|
531
|
+
*errorOut = [NSError errorWithDomain:@"MacRecorderIOS"
|
|
532
|
+
code:14
|
|
533
|
+
userInfo:@{NSLocalizedDescriptionKey: @"The iPhone recording segments could not be joined"}];
|
|
534
|
+
}
|
|
535
|
+
return NO;
|
|
536
|
+
}
|
|
537
|
+
NSString *assembledPath = [[recorder.outputPath stringByDeletingPathExtension]
|
|
538
|
+
stringByAppendingString:@".iphone-assembled.mov"];
|
|
539
|
+
[fileManager removeItemAtPath:assembledPath error:nil];
|
|
540
|
+
exporter.outputURL = [NSURL fileURLWithPath:assembledPath];
|
|
541
|
+
exporter.outputFileType = AVFileTypeQuickTimeMovie;
|
|
542
|
+
exporter.shouldOptimizeForNetworkUse = NO;
|
|
543
|
+
|
|
544
|
+
__block volatile BOOL exportFinished = NO;
|
|
545
|
+
[exporter exportAsynchronouslyWithCompletionHandler:^{ exportFinished = YES; }];
|
|
546
|
+
BOOL completedInTime = MRWaitForFlag(^bool{ return exportFinished; }, 60.0);
|
|
547
|
+
if (!completedInTime) [exporter cancelExport];
|
|
548
|
+
BOOL completed = completedInTime && exporter.status == AVAssetExportSessionStatusCompleted;
|
|
549
|
+
NSError *exportError = [[exporter.error retain] autorelease];
|
|
550
|
+
if (!completed && errorOut) {
|
|
551
|
+
*errorOut = exportError ?: [NSError errorWithDomain:@"MacRecorderIOS"
|
|
552
|
+
code:15
|
|
553
|
+
userInfo:@{NSLocalizedDescriptionKey: @"Timed out while joining iPhone recording segments"}];
|
|
554
|
+
}
|
|
555
|
+
[exporter release];
|
|
556
|
+
if (!completed) {
|
|
557
|
+
[fileManager removeItemAtPath:assembledPath error:nil];
|
|
558
|
+
return NO;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
[fileManager removeItemAtPath:recorder.outputPath error:nil];
|
|
562
|
+
NSError *moveError = nil;
|
|
563
|
+
if (![fileManager moveItemAtPath:assembledPath
|
|
564
|
+
toPath:recorder.outputPath
|
|
565
|
+
error:&moveError]) {
|
|
566
|
+
if (errorOut) *errorOut = moveError;
|
|
567
|
+
return NO;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
for (NSString *segmentPath in segments) {
|
|
571
|
+
if (![segmentPath isEqualToString:recorder.outputPath]) {
|
|
572
|
+
[fileManager removeItemAtPath:segmentPath error:nil];
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
if (pauseRanges.count > 0) {
|
|
576
|
+
MRLog(@"✅ Removed %lu paused range(s) from the iPhone recording",
|
|
577
|
+
(unsigned long)pauseRanges.count);
|
|
578
|
+
} else {
|
|
579
|
+
MRLog(@"✅ Joined %lu iPhone recording segments", (unsigned long)segments.count);
|
|
580
|
+
}
|
|
581
|
+
return YES;
|
|
582
|
+
}
|
|
583
|
+
|
|
159
584
|
extern "C" NSArray<NSDictionary *> *listIOSCaptureDevices(void) {
|
|
160
585
|
NSMutableArray<NSDictionary *> *devices = [NSMutableArray array];
|
|
161
|
-
|
|
586
|
+
// Device listing is called by the renderer's lightweight monitor. Return a
|
|
587
|
+
// snapshot immediately; the start path still gets a bounded readiness wait.
|
|
588
|
+
for (AVCaptureDevice *device in MRIOSCaptureDevices(0.0)) {
|
|
162
589
|
CMVideoDimensions largest = {0, 0};
|
|
163
590
|
for (AVCaptureDeviceFormat *format in device.formats) {
|
|
164
591
|
CMVideoDimensions dimensions = CMVideoFormatDescriptionGetDimensions(format.formatDescription);
|
|
@@ -174,12 +601,16 @@ extern "C" NSArray<NSDictionary *> *listIOSCaptureDevices(void) {
|
|
|
174
601
|
@"model": device.modelID ?: @"",
|
|
175
602
|
@"connected": @(device.isConnected),
|
|
176
603
|
@"suspended": @(device.isSuspended),
|
|
604
|
+
@"captureReady": @(!device.isSuspended && device.isConnected),
|
|
177
605
|
@"width": @(largest.width),
|
|
178
606
|
@"height": @(largest.height),
|
|
179
607
|
@"hasAudio": @YES,
|
|
180
608
|
@"transport": @"usb"
|
|
181
609
|
}];
|
|
182
610
|
}
|
|
611
|
+
if (devices.count == 0) {
|
|
612
|
+
[devices addObjectsFromArray:MRUSBConnectedIOSDevices()];
|
|
613
|
+
}
|
|
183
614
|
return devices;
|
|
184
615
|
}
|
|
185
616
|
|
|
@@ -192,8 +623,8 @@ extern "C" bool startIOSDeviceRecording(NSString *outputPath,
|
|
|
192
623
|
NSString *audioOutputPath,
|
|
193
624
|
NSString *audioDeviceId,
|
|
194
625
|
NSError **errorOut) {
|
|
195
|
-
@
|
|
196
|
-
if (g_iosRecorder
|
|
626
|
+
@try {
|
|
627
|
+
if (g_iosRecorder) {
|
|
197
628
|
if (errorOut) {
|
|
198
629
|
*errorOut = [NSError errorWithDomain:@"MacRecorderIOS"
|
|
199
630
|
code:1
|
|
@@ -247,18 +678,30 @@ extern "C" bool startIOSDeviceRecording(NSString *outputPath,
|
|
|
247
678
|
recorder.startCompleted = NO;
|
|
248
679
|
recorder.finishCompleted = NO;
|
|
249
680
|
recorder.finishError = nil;
|
|
681
|
+
recorder.paused = NO;
|
|
682
|
+
recorder.segmentStartCompleted = NO;
|
|
683
|
+
recorder.segmentFinishCompleted = NO;
|
|
684
|
+
recorder.segmentFinishError = nil;
|
|
685
|
+
recorder.segmentPaths = [NSMutableArray array];
|
|
686
|
+
recorder.currentSegmentPath = nil;
|
|
687
|
+
recorder.nextSegmentIndex = 0;
|
|
688
|
+
recorder.segmentFailure = NO;
|
|
689
|
+
recorder.pauseRanges = [NSMutableArray array];
|
|
690
|
+
recorder.pauseStartedAtSeconds = -1.0;
|
|
250
691
|
recorder.captureCamera = captureCamera;
|
|
251
692
|
recorder.captureMicrophone = captureMicrophone;
|
|
252
693
|
recorder.cameraOutputPath = cameraOutputPath;
|
|
253
694
|
recorder.audioOutputPath = audioOutputPath;
|
|
254
695
|
recorder.primaryStartedAt = nil;
|
|
255
696
|
|
|
697
|
+
g_iosRecorder = recorder;
|
|
256
698
|
[recorder.session beginConfiguration];
|
|
257
699
|
if ([recorder.session canSetSessionPreset:AVCaptureSessionPresetHigh]) {
|
|
258
700
|
recorder.session.sessionPreset = AVCaptureSessionPresetHigh;
|
|
259
701
|
}
|
|
260
702
|
if (![recorder.session canAddInput:input]) {
|
|
261
703
|
[recorder.session commitConfiguration];
|
|
704
|
+
stopIOSDeviceRecording();
|
|
262
705
|
if (errorOut) {
|
|
263
706
|
*errorOut = [NSError errorWithDomain:@"MacRecorderIOS"
|
|
264
707
|
code:3
|
|
@@ -269,6 +712,7 @@ extern "C" bool startIOSDeviceRecording(NSString *outputPath,
|
|
|
269
712
|
[recorder.session addInput:input];
|
|
270
713
|
if (![recorder.session canAddOutput:recorder.movieOutput]) {
|
|
271
714
|
[recorder.session commitConfiguration];
|
|
715
|
+
stopIOSDeviceRecording();
|
|
272
716
|
if (errorOut) {
|
|
273
717
|
*errorOut = [NSError errorWithDomain:@"MacRecorderIOS"
|
|
274
718
|
code:4
|
|
@@ -297,7 +741,7 @@ extern "C" bool startIOSDeviceRecording(NSString *outputPath,
|
|
|
297
741
|
!startCameraRecording(cameraOutputPath, cameraDeviceId, &cameraError)) {
|
|
298
742
|
MRSyncConfigurePrimaryStart(NO);
|
|
299
743
|
MRSyncConfigure(NO);
|
|
300
|
-
|
|
744
|
+
stopIOSDeviceRecording();
|
|
301
745
|
if (errorOut) {
|
|
302
746
|
*errorOut = cameraError ?: [NSError errorWithDomain:@"MacRecorderIOS"
|
|
303
747
|
code:7
|
|
@@ -314,7 +758,7 @@ extern "C" bool startIOSDeviceRecording(NSString *outputPath,
|
|
|
314
758
|
if (isCameraRecording()) stopCameraRecording();
|
|
315
759
|
MRSyncConfigurePrimaryStart(NO);
|
|
316
760
|
MRSyncConfigure(NO);
|
|
317
|
-
|
|
761
|
+
stopIOSDeviceRecording();
|
|
318
762
|
if (errorOut) {
|
|
319
763
|
*errorOut = audioError ?: [NSError errorWithDomain:@"MacRecorderIOS"
|
|
320
764
|
code:8
|
|
@@ -335,40 +779,37 @@ extern "C" bool startIOSDeviceRecording(NSString *outputPath,
|
|
|
335
779
|
code:5
|
|
336
780
|
userInfo:@{NSLocalizedDescriptionKey: @"The iPhone capture session did not start"}];
|
|
337
781
|
}
|
|
338
|
-
|
|
782
|
+
stopIOSDeviceRecording();
|
|
339
783
|
return false;
|
|
340
784
|
}
|
|
341
785
|
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
if (!started) {
|
|
348
|
-
if (recorder.movieOutput.isRecording) [recorder.movieOutput stopRecording];
|
|
786
|
+
recorder.startRequested = YES;
|
|
787
|
+
NSError *segmentError = nil;
|
|
788
|
+
if (!MRIOSStartNextSegment(recorder, &segmentError)) {
|
|
789
|
+
NSError *reportedSegmentError = [[segmentError retain] autorelease];
|
|
790
|
+
MRIOSStopCurrentSegment(recorder);
|
|
349
791
|
[recorder.session stopRunning];
|
|
350
792
|
if (isCameraRecording()) stopCameraRecording();
|
|
351
793
|
if (isStandaloneAudioRecording()) stopStandaloneAudioRecording();
|
|
352
794
|
MRSyncConfigurePrimaryStart(NO);
|
|
353
795
|
MRSyncConfigure(NO);
|
|
354
|
-
|
|
796
|
+
stopIOSDeviceRecording();
|
|
355
797
|
if (errorOut) {
|
|
356
|
-
*errorOut = [NSError errorWithDomain:@"MacRecorderIOS"
|
|
357
|
-
|
|
358
|
-
|
|
798
|
+
*errorOut = reportedSegmentError ?: [NSError errorWithDomain:@"MacRecorderIOS"
|
|
799
|
+
code:6
|
|
800
|
+
userInfo:@{NSLocalizedDescriptionKey: @"Timed out waiting for the first iPhone frame"}];
|
|
359
801
|
}
|
|
360
802
|
return false;
|
|
361
803
|
}
|
|
362
804
|
if (captureCamera && !waitForCameraRecordingStart(8.0)) {
|
|
363
805
|
MRLog(@"❌ Camera did not produce a synchronized frame for iPhone recording");
|
|
364
|
-
|
|
365
|
-
MRWaitForFlag(^bool{ return recorder.finishCompleted; }, 10.0);
|
|
806
|
+
MRIOSStopCurrentSegment(recorder);
|
|
366
807
|
[recorder.session stopRunning];
|
|
367
808
|
if (isCameraRecording()) stopCameraRecording();
|
|
368
809
|
if (isStandaloneAudioRecording()) stopStandaloneAudioRecording();
|
|
369
810
|
MRSyncConfigurePrimaryStart(NO);
|
|
370
811
|
MRSyncConfigure(NO);
|
|
371
|
-
|
|
812
|
+
stopIOSDeviceRecording();
|
|
372
813
|
if (errorOut) {
|
|
373
814
|
*errorOut = [NSError errorWithDomain:@"MacRecorderIOS"
|
|
374
815
|
code:9
|
|
@@ -377,47 +818,99 @@ extern "C" bool startIOSDeviceRecording(NSString *outputPath,
|
|
|
377
818
|
return false;
|
|
378
819
|
}
|
|
379
820
|
return true;
|
|
821
|
+
} @catch (NSException *exception) {
|
|
822
|
+
// NSError must live in the caller's autorelease pool. The previous
|
|
823
|
+
// inner pool returned a dangling NSError on device/startup failures.
|
|
824
|
+
if (g_iosRecorder && !g_iosRecorder.startCompleted && !g_iosRecorder.movieOutput.isRecording) {
|
|
825
|
+
g_iosRecorder.startRequested = NO;
|
|
826
|
+
}
|
|
827
|
+
@try { [g_iosRecorder.session commitConfiguration]; } @catch (NSException *ignored) {}
|
|
828
|
+
stopIOSDeviceRecording();
|
|
829
|
+
if (errorOut) *errorOut = [NSError errorWithDomain:@"MacRecorderIOS" code:10
|
|
830
|
+
userInfo:@{NSLocalizedDescriptionKey: exception.reason ?: @"iPhone capture startup failed"}];
|
|
831
|
+
return false;
|
|
380
832
|
}
|
|
381
833
|
}
|
|
382
834
|
|
|
383
835
|
extern "C" bool stopIOSDeviceRecording(void) {
|
|
384
836
|
@autoreleasepool {
|
|
837
|
+
@try {
|
|
385
838
|
MRIOSDeviceRecorder *recorder = g_iosRecorder;
|
|
386
839
|
if (!recorder) return true;
|
|
387
840
|
|
|
841
|
+
recorder.stopRequested = YES;
|
|
388
842
|
if (recorder.primaryStartedAt) {
|
|
389
|
-
NSTimeInterval duration = MAX(0.0,
|
|
843
|
+
NSTimeInterval duration = MAX(0.0,
|
|
844
|
+
-[recorder.primaryStartedAt timeIntervalSinceNow] - MRSyncGetPausedDurationSeconds());
|
|
390
845
|
MRSyncSetStopLimitSeconds(duration);
|
|
391
846
|
}
|
|
847
|
+
if (recorder.paused) {
|
|
848
|
+
MRIOSEndPauseRange(recorder);
|
|
849
|
+
}
|
|
392
850
|
|
|
393
851
|
BOOL cameraStopped = YES;
|
|
394
852
|
BOOL microphoneStopped = YES;
|
|
395
|
-
if (recorder.captureCamera
|
|
853
|
+
if (recorder.captureCamera) {
|
|
396
854
|
cameraStopped = stopCameraRecording();
|
|
397
855
|
}
|
|
398
|
-
if (recorder.captureMicrophone
|
|
856
|
+
if (recorder.captureMicrophone) {
|
|
399
857
|
microphoneStopped = stopStandaloneAudioRecording();
|
|
400
858
|
}
|
|
401
859
|
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
860
|
+
BOOL primaryStopped = MRIOSStopCurrentSegment(recorder);
|
|
861
|
+
if (!primaryStopped && recorder.segmentStartCompleted &&
|
|
862
|
+
!recorder.segmentFinishCompleted) {
|
|
863
|
+
// Keep ownership while AVCaptureMovieFileOutput still owns the
|
|
864
|
+
// segment so a later stop retry cannot race its delegate callback.
|
|
865
|
+
NSLog(@"[Recorder] iPhone is still finalizing; retaining the session");
|
|
866
|
+
return false;
|
|
407
867
|
}
|
|
408
868
|
if (recorder.session.isRunning) [recorder.session stopRunning];
|
|
409
869
|
|
|
410
|
-
NSError *
|
|
411
|
-
BOOL
|
|
870
|
+
NSError *assemblyError = nil;
|
|
871
|
+
BOOL recordingSucceeded = primaryStopped && !recorder.segmentFailure;
|
|
872
|
+
BOOL assembled = recordingSucceeded && MRIOSAssembleSegments(recorder, &assemblyError);
|
|
873
|
+
if (!assembled && recorder.startCompleted) {
|
|
874
|
+
MRLog(@"❌ iPhone recording assembly failed: %@",
|
|
875
|
+
assemblyError.localizedDescription ?: @"Unknown segment error");
|
|
876
|
+
}
|
|
877
|
+
BOOL finished = !recorder.startCompleted || assembled;
|
|
412
878
|
BOOL fileExists = [[NSFileManager defaultManager] fileExistsAtPath:recorder.outputPath];
|
|
413
879
|
MRSyncConfigurePrimaryStart(NO);
|
|
414
880
|
MRSyncConfigure(NO);
|
|
415
|
-
g_iosRecorder = nil;
|
|
416
881
|
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
882
|
+
// AVCaptureSession keeps its input (and therefore the USB screen
|
|
883
|
+
// device) retained after stopRunning. Detach everything before dropping
|
|
884
|
+
// the recorder so the same iPhone is discoverable on the next attempt.
|
|
885
|
+
@try {
|
|
886
|
+
[recorder.session beginConfiguration];
|
|
887
|
+
if (recorder.movieOutput && [recorder.session.outputs containsObject:recorder.movieOutput]) {
|
|
888
|
+
[recorder.session removeOutput:recorder.movieOutput];
|
|
889
|
+
}
|
|
890
|
+
if (recorder.deviceInput && [recorder.session.inputs containsObject:recorder.deviceInput]) {
|
|
891
|
+
[recorder.session removeInput:recorder.deviceInput];
|
|
892
|
+
}
|
|
893
|
+
[recorder.session commitConfiguration];
|
|
894
|
+
} @catch (NSException *exception) {
|
|
895
|
+
MRLog(@"⚠️ iPhone capture teardown warning: %@", exception.reason);
|
|
420
896
|
}
|
|
897
|
+
recorder.movieOutput = nil;
|
|
898
|
+
recorder.deviceInput = nil;
|
|
899
|
+
recorder.session = nil;
|
|
900
|
+
recorder.finishError = nil;
|
|
901
|
+
recorder.segmentFinishError = nil;
|
|
902
|
+
recorder.segmentPaths = nil;
|
|
903
|
+
recorder.pauseRanges = nil;
|
|
904
|
+
recorder.pauseStartedAtSeconds = -1.0;
|
|
905
|
+
recorder.currentSegmentPath = nil;
|
|
906
|
+
recorder.outputPath = nil;
|
|
907
|
+
recorder.cameraOutputPath = nil;
|
|
908
|
+
recorder.audioOutputPath = nil;
|
|
909
|
+
recorder.primaryStartedAt = nil;
|
|
910
|
+
g_iosRecorder = nil;
|
|
911
|
+
[recorder release];
|
|
912
|
+
|
|
913
|
+
if (!recordingSucceeded) return false;
|
|
421
914
|
if (!cameraStopped || !microphoneStopped) {
|
|
422
915
|
MRLog(@"⚠️ iPhone recording finalized, but an auxiliary camera/microphone writer reported a stop error");
|
|
423
916
|
}
|
|
@@ -425,11 +918,64 @@ extern "C" bool stopIOSDeviceRecording(void) {
|
|
|
425
918
|
// auxiliary source failed to finalize. The JS layer validates each
|
|
426
919
|
// returned path independently before packaging it.
|
|
427
920
|
return finished && fileExists;
|
|
921
|
+
} @catch (NSException *exception) {
|
|
922
|
+
NSLog(@"[Recorder] iPhone stop failed safely: %@", exception.reason);
|
|
923
|
+
return false;
|
|
924
|
+
}
|
|
428
925
|
}
|
|
429
926
|
}
|
|
430
927
|
|
|
928
|
+
extern "C" bool isIOSDeviceRecordingPending(void) {
|
|
929
|
+
return g_iosRecorder != nil;
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
extern "C" bool isIOSDeviceRecordingStopping(void) {
|
|
933
|
+
return g_iosRecorder && g_iosRecorder.stopRequested;
|
|
934
|
+
}
|
|
935
|
+
|
|
431
936
|
extern "C" bool isIOSDeviceRecording(void) {
|
|
432
|
-
return g_iosRecorder &&
|
|
937
|
+
return g_iosRecorder && !g_iosRecorder.stopRequested &&
|
|
938
|
+
(g_iosRecorder.recording || g_iosRecorder.paused || g_iosRecorder.movieOutput.isRecording);
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
extern "C" bool pauseIOSDeviceRecording(void) {
|
|
942
|
+
@autoreleasepool {
|
|
943
|
+
MRIOSDeviceRecorder *recorder = g_iosRecorder;
|
|
944
|
+
if (!recorder) return false;
|
|
945
|
+
if (recorder.paused) return true;
|
|
946
|
+
if (!recorder.movieOutput.isRecording) return false;
|
|
947
|
+
@try {
|
|
948
|
+
// Do not stop or pause AVCaptureMovieFileOutput here. USB iPhone
|
|
949
|
+
// muxed sources may fail to re-arm their compressor on resume.
|
|
950
|
+
// Capture continuously and trim this time range during finalization.
|
|
951
|
+
MRIOSBeginPauseRange(recorder);
|
|
952
|
+
MRSyncPause();
|
|
953
|
+
recorder.paused = YES;
|
|
954
|
+
return true;
|
|
955
|
+
} @catch (NSException *exception) {
|
|
956
|
+
MRSyncResume();
|
|
957
|
+
MRLog(@"❌ iPhone pause failed: %@", exception.reason);
|
|
958
|
+
return false;
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
extern "C" bool resumeIOSDeviceRecording(void) {
|
|
964
|
+
@autoreleasepool {
|
|
965
|
+
MRIOSDeviceRecorder *recorder = g_iosRecorder;
|
|
966
|
+
if (!recorder) return false;
|
|
967
|
+
if (!recorder.paused) return recorder.movieOutput.isRecording;
|
|
968
|
+
if (!recorder.movieOutput.isRecording) return false;
|
|
969
|
+
@try {
|
|
970
|
+
MRIOSEndPauseRange(recorder);
|
|
971
|
+
recorder.paused = NO;
|
|
972
|
+
MRSyncResume();
|
|
973
|
+
return true;
|
|
974
|
+
} @catch (NSException *exception) {
|
|
975
|
+
MRLog(@"❌ iPhone resume failed: %@", exception.reason);
|
|
976
|
+
return false;
|
|
977
|
+
}
|
|
978
|
+
}
|
|
433
979
|
}
|
|
434
980
|
|
|
435
981
|
extern "C" NSString *currentIOSDeviceRecordingPath(void) {
|
|
@@ -438,6 +984,8 @@ extern "C" NSString *currentIOSDeviceRecordingPath(void) {
|
|
|
438
984
|
|
|
439
985
|
Napi::Value GetIOSCaptureDevices(const Napi::CallbackInfo& info) {
|
|
440
986
|
Napi::Env env = info.Env();
|
|
987
|
+
@autoreleasepool {
|
|
988
|
+
@try {
|
|
441
989
|
NSArray<NSDictionary *> *devices = listIOSCaptureDevices();
|
|
442
990
|
Napi::Array result = Napi::Array::New(env, devices.count);
|
|
443
991
|
for (NSUInteger index = 0; index < devices.count; index++) {
|
|
@@ -449,6 +997,7 @@ Napi::Value GetIOSCaptureDevices(const Napi::CallbackInfo& info) {
|
|
|
449
997
|
item.Set("model", Napi::String::New(env, [device[@"model"] UTF8String]));
|
|
450
998
|
item.Set("connected", Napi::Boolean::New(env, [device[@"connected"] boolValue]));
|
|
451
999
|
item.Set("suspended", Napi::Boolean::New(env, [device[@"suspended"] boolValue]));
|
|
1000
|
+
item.Set("captureReady", Napi::Boolean::New(env, [device[@"captureReady"] boolValue]));
|
|
452
1001
|
item.Set("width", Napi::Number::New(env, [device[@"width"] intValue]));
|
|
453
1002
|
item.Set("height", Napi::Number::New(env, [device[@"height"] intValue]));
|
|
454
1003
|
item.Set("hasAudio", Napi::Boolean::New(env, true));
|
|
@@ -456,10 +1005,18 @@ Napi::Value GetIOSCaptureDevices(const Napi::CallbackInfo& info) {
|
|
|
456
1005
|
result.Set(index, item);
|
|
457
1006
|
}
|
|
458
1007
|
return result;
|
|
1008
|
+
} @catch (NSException *exception) {
|
|
1009
|
+
Napi::Error::New(env, exception.reason.UTF8String ?: "iPhone capture failed").ThrowAsJavaScriptException();
|
|
1010
|
+
return env.Null();
|
|
1011
|
+
}
|
|
1012
|
+
}
|
|
1013
|
+
|
|
459
1014
|
}
|
|
460
1015
|
|
|
461
1016
|
Napi::Value StartIOSDeviceRecording(const Napi::CallbackInfo& info) {
|
|
462
1017
|
Napi::Env env = info.Env();
|
|
1018
|
+
@autoreleasepool {
|
|
1019
|
+
@try {
|
|
463
1020
|
if (info.Length() < 1 || !info[0].IsString()) {
|
|
464
1021
|
Napi::TypeError::New(env, "Output path is required").ThrowAsJavaScriptException();
|
|
465
1022
|
return env.Null();
|
|
@@ -505,21 +1062,35 @@ Napi::Value StartIOSDeviceRecording(const Napi::CallbackInfo& info) {
|
|
|
505
1062
|
audioDeviceId,
|
|
506
1063
|
&error);
|
|
507
1064
|
if (!success && error) {
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
// stable user-facing error and avoids a dangling Objective-C object.
|
|
511
|
-
MRLog(@"❌ iPhone capture could not start");
|
|
1065
|
+
Napi::Error::New(env, error.localizedDescription.UTF8String ?: "iPhone capture could not start").ThrowAsJavaScriptException();
|
|
1066
|
+
return env.Null();
|
|
512
1067
|
}
|
|
513
1068
|
return Napi::Boolean::New(env, success);
|
|
1069
|
+
} @catch (NSException *exception) {
|
|
1070
|
+
Napi::Error::New(env, exception.reason.UTF8String ?: "iPhone capture failed").ThrowAsJavaScriptException();
|
|
1071
|
+
return env.Null();
|
|
1072
|
+
}
|
|
1073
|
+
}
|
|
1074
|
+
|
|
514
1075
|
}
|
|
515
1076
|
|
|
516
1077
|
Napi::Value StopIOSDeviceRecording(const Napi::CallbackInfo& info) {
|
|
517
1078
|
return Napi::Boolean::New(info.Env(), stopIOSDeviceRecording());
|
|
518
1079
|
}
|
|
519
1080
|
|
|
1081
|
+
Napi::Value PauseIOSDeviceRecording(const Napi::CallbackInfo& info) {
|
|
1082
|
+
return Napi::Boolean::New(info.Env(), pauseIOSDeviceRecording());
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
Napi::Value ResumeIOSDeviceRecording(const Napi::CallbackInfo& info) {
|
|
1086
|
+
return Napi::Boolean::New(info.Env(), resumeIOSDeviceRecording());
|
|
1087
|
+
}
|
|
1088
|
+
|
|
520
1089
|
Napi::Value GetIOSDeviceRecordingStatus(const Napi::CallbackInfo& info) {
|
|
521
1090
|
Napi::Object status = Napi::Object::New(info.Env());
|
|
522
1091
|
status.Set("isRecording", Napi::Boolean::New(info.Env(), isIOSDeviceRecording()));
|
|
1092
|
+
status.Set("isPaused", Napi::Boolean::New(info.Env(),
|
|
1093
|
+
g_iosRecorder && g_iosRecorder.paused));
|
|
523
1094
|
NSString *path = currentIOSDeviceRecordingPath();
|
|
524
1095
|
if (path.length > 0) status.Set("outputPath", Napi::String::New(info.Env(), [path UTF8String]));
|
|
525
1096
|
return status;
|
|
@@ -529,6 +1100,8 @@ Napi::Object InitIOSDeviceRecorder(Napi::Env env, Napi::Object exports) {
|
|
|
529
1100
|
exports.Set("getIOSCaptureDevices", Napi::Function::New(env, GetIOSCaptureDevices));
|
|
530
1101
|
exports.Set("startIOSDeviceRecording", Napi::Function::New(env, StartIOSDeviceRecording));
|
|
531
1102
|
exports.Set("stopIOSDeviceRecording", Napi::Function::New(env, StopIOSDeviceRecording));
|
|
1103
|
+
exports.Set("pauseIOSDeviceRecording", Napi::Function::New(env, PauseIOSDeviceRecording));
|
|
1104
|
+
exports.Set("resumeIOSDeviceRecording", Napi::Function::New(env, ResumeIOSDeviceRecording));
|
|
532
1105
|
exports.Set("getIOSDeviceRecordingStatus", Napi::Function::New(env, GetIOSDeviceRecordingStatus));
|
|
533
1106
|
return exports;
|
|
534
1107
|
}
|