node-mac-recorder 2.24.11 → 2.24.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/binding.gyp CHANGED
@@ -44,7 +44,8 @@
44
44
  "-framework Carbon",
45
45
  "-framework Accessibility",
46
46
  "-framework CoreAudio",
47
- "-framework CoreMediaIO"
47
+ "-framework CoreMediaIO",
48
+ "-framework IOKit"
48
49
  ]
49
50
  },
50
51
  "defines": [ "NAPI_DISABLE_CPP_EXCEPTIONS" ]
package/index.js CHANGED
@@ -176,6 +176,7 @@ class MacRecorder extends EventEmitter {
176
176
  model: device?.model || null,
177
177
  connected: device?.connected !== false,
178
178
  suspended: device?.suspended === true,
179
+ captureReady: device?.captureReady !== false,
179
180
  width: Number(device?.width) || 0,
180
181
  height: Number(device?.height) || 0,
181
182
  hasAudio: device?.hasAudio !== false,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "node-mac-recorder",
3
- "version": "2.24.11",
3
+ "version": "2.24.13",
4
4
  "description": "Native macOS screen recording package for Node.js applications",
5
5
  "main": "index.js",
6
6
  "keywords": [
@@ -2,6 +2,7 @@
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
 
@@ -27,6 +28,16 @@ extern "C" bool resumeIOSDeviceRecording(void);
27
28
  @property(atomic) BOOL startRequested;
28
29
  @property(atomic) BOOL stopRequested;
29
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;
30
41
  @property(nonatomic) BOOL captureCamera;
31
42
  @property(nonatomic) BOOL captureMicrophone;
32
43
  @property(nonatomic, copy) NSString *cameraOutputPath;
@@ -34,16 +45,45 @@ extern "C" bool resumeIOSDeviceRecording(void);
34
45
  @property(nonatomic, strong) NSDate *primaryStartedAt;
35
46
  @end
36
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
+
37
80
  @implementation MRIOSDeviceRecorder
38
81
 
39
82
  - (void)captureOutput:(AVCaptureFileOutput *)captureOutput
40
83
  didStartRecordingToOutputFileAtURL:(NSURL *)fileURL
41
84
  fromConnections:(NSArray<AVCaptureConnection *> *)connections {
42
- self.recording = YES;
43
- self.startCompleted = YES;
44
- self.primaryStartedAt = [NSDate date];
45
- if (!self.stopRequested) MRSyncMarkPrimaryStarted(CMClockGetTime(CMClockGetHostTimeClock()));
46
- MRLog(@"📱 iPhone capture started: %@", fileURL.path);
85
+ MRIOSMarkSegmentStarted(self, YES);
86
+ MRLog(@"📱 iPhone capture segment started: %@", fileURL.path);
47
87
  }
48
88
 
49
89
  - (void)captureOutput:(AVCaptureFileOutput *)captureOutput
@@ -51,16 +91,26 @@ extern "C" bool resumeIOSDeviceRecording(void);
51
91
  fromConnections:(NSArray<AVCaptureConnection *> *)connections
52
92
  error:(NSError *)error {
53
93
  self.recording = NO;
54
- self.finishError = error;
55
- self.finishCompleted = YES;
94
+ self.segmentFinishError = error;
95
+ self.segmentFinishCompleted = YES;
96
+ if (self.stopRequested) {
97
+ self.finishError = error;
98
+ self.finishCompleted = YES;
99
+ }
56
100
  if (error) {
57
101
  NSNumber *successfullyFinished = error.userInfo[AVErrorRecordingSuccessfullyFinishedKey];
58
102
  if (![successfullyFinished boolValue]) {
59
- 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 ?: @{});
60
110
  return;
61
111
  }
62
112
  }
63
- MRLog(@"✅ iPhone capture finalized: %@", outputFileURL.path);
113
+ MRLog(@"✅ iPhone capture segment finalized: %@", outputFileURL.path);
64
114
  }
65
115
 
66
116
  @end
@@ -68,28 +118,31 @@ extern "C" bool resumeIOSDeviceRecording(void);
68
118
  static MRIOSDeviceRecorder *g_iosRecorder = nil;
69
119
 
70
120
  static void MREnableIOSScreenCaptureDevices(void) {
71
- static dispatch_once_t onceToken;
72
- dispatch_once(&onceToken, ^{
73
- CMIOObjectPropertyAddress address = {
74
- kCMIOHardwarePropertyAllowScreenCaptureDevices,
75
- kCMIOObjectPropertyScopeGlobal,
76
- kCMIOObjectPropertyElementMain
77
- };
78
- UInt32 allow = 1;
79
- OSStatus status = CMIOObjectSetPropertyData(
80
- kCMIOObjectSystemObject,
81
- &address,
82
- 0,
83
- NULL,
84
- sizeof(allow),
85
- &allow
86
- );
87
- 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, ^{
88
141
  MRLog(@"✅ CoreMediaIO iPhone screen capture devices enabled");
89
- } else {
90
- MRLog(@"❌ CoreMediaIO could not enable iPhone screen capture devices (OSStatus=%d)", (int)status);
91
- }
92
- });
142
+ });
143
+ } else {
144
+ MRLog(@"❌ CoreMediaIO could not enable iPhone screen capture devices (OSStatus=%d)", (int)status);
145
+ }
93
146
  }
94
147
 
95
148
  static NSArray<AVCaptureDevice *> *MRDiscoverIOSCaptureDevices(void) {
@@ -126,17 +179,94 @@ static NSArray<AVCaptureDevice *> *MRDiscoverIOSCaptureDevices(void) {
126
179
  return result;
127
180
  }
128
181
 
129
- 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) {
130
252
  MREnableIOSScreenCaptureDevices();
131
253
 
132
- // CoreMediaIO publishes the USB screen device asynchronously after the
133
- // allow flag changes. Poll briefly so the first click works without asking
134
- // the user to close and reopen the recorder.
135
- 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)];
136
258
  NSArray<AVCaptureDevice *> *devices = nil;
137
259
  do {
138
260
  devices = MRDiscoverIOSCaptureDevices();
139
- 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;
140
270
  [[NSRunLoop currentRunLoop] runMode:NSDefaultRunLoopMode
141
271
  beforeDate:[NSDate dateWithTimeIntervalSinceNow:0.05]];
142
272
  } while ([deadline timeIntervalSinceNow] > 0);
@@ -144,10 +274,16 @@ static NSArray<AVCaptureDevice *> *MRIOSCaptureDevices(void) {
144
274
  }
145
275
 
146
276
  static AVCaptureDevice *MRIOSDeviceForId(NSString *deviceId) {
147
- NSArray<AVCaptureDevice *> *devices = MRIOSCaptureDevices();
148
- 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
+ }
149
284
  for (AVCaptureDevice *device in devices) {
150
- if ([device.uniqueID isEqualToString:deviceId]) return device;
285
+ if ([device.uniqueID isEqualToString:deviceId] &&
286
+ device.isConnected && !device.isSuspended) return device;
151
287
  }
152
288
  return nil;
153
289
  }
@@ -161,9 +297,295 @@ static bool MRWaitForFlag(bool (^readFlag)(void), NSTimeInterval timeoutSeconds)
161
297
  return readFlag();
162
298
  }
163
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
+
164
584
  extern "C" NSArray<NSDictionary *> *listIOSCaptureDevices(void) {
165
585
  NSMutableArray<NSDictionary *> *devices = [NSMutableArray array];
166
- 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)) {
167
589
  CMVideoDimensions largest = {0, 0};
168
590
  for (AVCaptureDeviceFormat *format in device.formats) {
169
591
  CMVideoDimensions dimensions = CMVideoFormatDescriptionGetDimensions(format.formatDescription);
@@ -179,12 +601,16 @@ extern "C" NSArray<NSDictionary *> *listIOSCaptureDevices(void) {
179
601
  @"model": device.modelID ?: @"",
180
602
  @"connected": @(device.isConnected),
181
603
  @"suspended": @(device.isSuspended),
604
+ @"captureReady": @(!device.isSuspended && device.isConnected),
182
605
  @"width": @(largest.width),
183
606
  @"height": @(largest.height),
184
607
  @"hasAudio": @YES,
185
608
  @"transport": @"usb"
186
609
  }];
187
610
  }
611
+ if (devices.count == 0) {
612
+ [devices addObjectsFromArray:MRUSBConnectedIOSDevices()];
613
+ }
188
614
  return devices;
189
615
  }
190
616
 
@@ -252,6 +678,16 @@ extern "C" bool startIOSDeviceRecording(NSString *outputPath,
252
678
  recorder.startCompleted = NO;
253
679
  recorder.finishCompleted = NO;
254
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;
255
691
  recorder.captureCamera = captureCamera;
256
692
  recorder.captureMicrophone = captureMicrophone;
257
693
  recorder.cameraOutputPath = cameraOutputPath;
@@ -348,13 +784,10 @@ extern "C" bool startIOSDeviceRecording(NSString *outputPath,
348
784
  }
349
785
 
350
786
  recorder.startRequested = YES;
351
- [recorder.movieOutput startRecordingToOutputFileURL:[NSURL fileURLWithPath:outputPath]
352
- recordingDelegate:recorder];
353
- bool started = MRWaitForFlag(^bool{
354
- return recorder.startCompleted || recorder.finishCompleted;
355
- }, 10.0);
356
- if (!started || !recorder.startCompleted || recorder.finishCompleted) {
357
- if (recorder.movieOutput.isRecording) [recorder.movieOutput stopRecording];
787
+ NSError *segmentError = nil;
788
+ if (!MRIOSStartNextSegment(recorder, &segmentError)) {
789
+ NSError *reportedSegmentError = [[segmentError retain] autorelease];
790
+ MRIOSStopCurrentSegment(recorder);
358
791
  [recorder.session stopRunning];
359
792
  if (isCameraRecording()) stopCameraRecording();
360
793
  if (isStandaloneAudioRecording()) stopStandaloneAudioRecording();
@@ -362,16 +795,15 @@ extern "C" bool startIOSDeviceRecording(NSString *outputPath,
362
795
  MRSyncConfigure(NO);
363
796
  stopIOSDeviceRecording();
364
797
  if (errorOut) {
365
- *errorOut = [NSError errorWithDomain:@"MacRecorderIOS"
366
- code:6
367
- 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"}];
368
801
  }
369
802
  return false;
370
803
  }
371
804
  if (captureCamera && !waitForCameraRecordingStart(8.0)) {
372
805
  MRLog(@"❌ Camera did not produce a synchronized frame for iPhone recording");
373
- if (recorder.movieOutput.isRecording) [recorder.movieOutput stopRecording];
374
- MRWaitForFlag(^bool{ return recorder.finishCompleted; }, 10.0);
806
+ MRIOSStopCurrentSegment(recorder);
375
807
  [recorder.session stopRunning];
376
808
  if (isCameraRecording()) stopCameraRecording();
377
809
  if (isStandaloneAudioRecording()) stopStandaloneAudioRecording();
@@ -412,6 +844,9 @@ extern "C" bool stopIOSDeviceRecording(void) {
412
844
  -[recorder.primaryStartedAt timeIntervalSinceNow] - MRSyncGetPausedDurationSeconds());
413
845
  MRSyncSetStopLimitSeconds(duration);
414
846
  }
847
+ if (recorder.paused) {
848
+ MRIOSEndPauseRange(recorder);
849
+ }
415
850
 
416
851
  BOOL cameraStopped = YES;
417
852
  BOOL microphoneStopped = YES;
@@ -422,30 +857,60 @@ extern "C" bool stopIOSDeviceRecording(void) {
422
857
  microphoneStopped = stopStandaloneAudioRecording();
423
858
  }
424
859
 
425
- if (recorder.movieOutput.isRecording) {
426
- [recorder.movieOutput stopRecording];
427
- }
428
- // isRecording becomes false BEFORE the delegate finishes its file.
429
- // Keep ownership until that callback, including after USB removal.
430
- if (recorder.startRequested && !recorder.finishCompleted) {
431
- if (!MRWaitForFlag(^bool{ return recorder.finishCompleted; }, 20.0)) {
432
- NSLog(@"[Recorder] iPhone is still finalizing; retaining the session");
433
- return false;
434
- }
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;
435
867
  }
436
868
  if (recorder.session.isRunning) [recorder.session stopRunning];
437
869
 
438
- NSError *finishError = recorder.finishError;
439
- 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;
440
878
  BOOL fileExists = [[NSFileManager defaultManager] fileExistsAtPath:recorder.outputPath];
441
879
  MRSyncConfigurePrimaryStart(NO);
442
880
  MRSyncConfigure(NO);
443
- g_iosRecorder = nil;
444
881
 
445
- if (finishError) {
446
- NSNumber *successfullyFinished = finishError.userInfo[AVErrorRecordingSuccessfullyFinishedKey];
447
- 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);
448
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;
449
914
  if (!cameraStopped || !microphoneStopped) {
450
915
  MRLog(@"⚠️ iPhone recording finalized, but an auxiliary camera/microphone writer reported a stop error");
451
916
  }
@@ -469,19 +934,26 @@ extern "C" bool isIOSDeviceRecordingStopping(void) {
469
934
  }
470
935
 
471
936
  extern "C" bool isIOSDeviceRecording(void) {
472
- 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);
473
939
  }
474
940
 
475
941
  extern "C" bool pauseIOSDeviceRecording(void) {
476
942
  @autoreleasepool {
477
943
  MRIOSDeviceRecorder *recorder = g_iosRecorder;
478
- if (!recorder || !recorder.movieOutput.isRecording) return false;
479
- if (recorder.movieOutput.isRecordingPaused) return true;
944
+ if (!recorder) return false;
945
+ if (recorder.paused) return true;
946
+ if (!recorder.movieOutput.isRecording) return false;
480
947
  @try {
481
- [recorder.movieOutput pauseRecording];
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);
482
952
  MRSyncPause();
953
+ recorder.paused = YES;
483
954
  return true;
484
955
  } @catch (NSException *exception) {
956
+ MRSyncResume();
485
957
  MRLog(@"❌ iPhone pause failed: %@", exception.reason);
486
958
  return false;
487
959
  }
@@ -491,13 +963,12 @@ extern "C" bool pauseIOSDeviceRecording(void) {
491
963
  extern "C" bool resumeIOSDeviceRecording(void) {
492
964
  @autoreleasepool {
493
965
  MRIOSDeviceRecorder *recorder = g_iosRecorder;
494
- if (!recorder || !recorder.movieOutput.isRecording) return false;
495
- if (!recorder.movieOutput.isRecordingPaused) {
496
- MRSyncResume();
497
- return true;
498
- }
966
+ if (!recorder) return false;
967
+ if (!recorder.paused) return recorder.movieOutput.isRecording;
968
+ if (!recorder.movieOutput.isRecording) return false;
499
969
  @try {
500
- [recorder.movieOutput resumeRecording];
970
+ MRIOSEndPauseRange(recorder);
971
+ recorder.paused = NO;
501
972
  MRSyncResume();
502
973
  return true;
503
974
  } @catch (NSException *exception) {
@@ -526,6 +997,7 @@ Napi::Value GetIOSCaptureDevices(const Napi::CallbackInfo& info) {
526
997
  item.Set("model", Napi::String::New(env, [device[@"model"] UTF8String]));
527
998
  item.Set("connected", Napi::Boolean::New(env, [device[@"connected"] boolValue]));
528
999
  item.Set("suspended", Napi::Boolean::New(env, [device[@"suspended"] boolValue]));
1000
+ item.Set("captureReady", Napi::Boolean::New(env, [device[@"captureReady"] boolValue]));
529
1001
  item.Set("width", Napi::Number::New(env, [device[@"width"] intValue]));
530
1002
  item.Set("height", Napi::Number::New(env, [device[@"height"] intValue]));
531
1003
  item.Set("hasAudio", Napi::Boolean::New(env, true));
@@ -618,7 +1090,7 @@ Napi::Value GetIOSDeviceRecordingStatus(const Napi::CallbackInfo& info) {
618
1090
  Napi::Object status = Napi::Object::New(info.Env());
619
1091
  status.Set("isRecording", Napi::Boolean::New(info.Env(), isIOSDeviceRecording()));
620
1092
  status.Set("isPaused", Napi::Boolean::New(info.Env(),
621
- g_iosRecorder && g_iosRecorder.movieOutput.isRecordingPaused));
1093
+ g_iosRecorder && g_iosRecorder.paused));
622
1094
  NSString *path = currentIOSDeviceRecordingPath();
623
1095
  if (path.length > 0) status.Set("outputPath", Napi::String::New(info.Env(), [path UTF8String]));
624
1096
  return status;