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.
@@ -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.recording = YES;
38
- self.startCompleted = YES;
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.finishError = error;
50
- self.finishCompleted = YES;
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
- MRLog(@"❌ iPhone capture finalize failed: %@", error.localizedDescription);
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
- 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) {
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
- } else {
85
- MRLog(@"❌ CoreMediaIO could not enable iPhone screen capture devices (OSStatus=%d)", (int)status);
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 NSArray<AVCaptureDevice *> *MRIOSCaptureDevices(void) {
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 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];
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
- if (devices.count > 0) break;
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) return devices.firstObject;
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]) return device;
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
- for (AVCaptureDevice *device in MRIOSCaptureDevices()) {
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
- @autoreleasepool {
196
- if (g_iosRecorder && (g_iosRecorder.recording || g_iosRecorder.startCompleted)) {
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
- g_iosRecorder = nil;
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
- g_iosRecorder = nil;
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
- g_iosRecorder = nil;
782
+ stopIOSDeviceRecording();
339
783
  return false;
340
784
  }
341
785
 
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];
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
- g_iosRecorder = nil;
796
+ stopIOSDeviceRecording();
355
797
  if (errorOut) {
356
- *errorOut = [NSError errorWithDomain:@"MacRecorderIOS"
357
- code:6
358
- userInfo:@{NSLocalizedDescriptionKey: @"Timed out waiting for the first iPhone frame"}];
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
- if (recorder.movieOutput.isRecording) [recorder.movieOutput stopRecording];
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
- g_iosRecorder = nil;
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, -[recorder.primaryStartedAt timeIntervalSinceNow]);
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 && isCameraRecording()) {
853
+ if (recorder.captureCamera) {
396
854
  cameraStopped = stopCameraRecording();
397
855
  }
398
- if (recorder.captureMicrophone && isStandaloneAudioRecording()) {
856
+ if (recorder.captureMicrophone) {
399
857
  microphoneStopped = stopStandaloneAudioRecording();
400
858
  }
401
859
 
402
- if (recorder.movieOutput.isRecording) {
403
- [recorder.movieOutput stopRecording];
404
- MRWaitForFlag(^bool{
405
- return recorder.finishCompleted;
406
- }, 20.0);
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 *finishError = recorder.finishError;
411
- BOOL finished = recorder.finishCompleted || !recorder.startCompleted;
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
- if (finishError) {
418
- NSNumber *successfullyFinished = finishError.userInfo[AVErrorRecordingSuccessfullyFinishedKey];
419
- if (![successfullyFinished boolValue]) return false;
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 && (g_iosRecorder.recording || g_iosRecorder.movieOutput.isRecording);
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
- // 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");
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
  }