Skip to content

Commit 68bba6a

Browse files
committed
Stabilize simulator screen recording
1 parent f963a71 commit 68bba6a

1 file changed

Lines changed: 157 additions & 21 deletions

File tree

cli/XCWSimctl.m

Lines changed: 157 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
#import "XCWPrivateSimulatorBooter.h"
77
#import "XCWProcessRunner.h"
88

9+
#import <dispatch/dispatch.h>
910
#import <errno.h>
1011
#import <math.h>
1112
#import <signal.h>
@@ -53,10 +54,47 @@ - (BOOL)installIPAAtPath:(NSString *)ipaPath simulatorUDID:(NSString *)udid erro
5354
return [value isKindOfClass:[NSString class]] ? value : @"";
5455
}
5556

57+
static NSString *XCWTrimmedString(NSString *value) {
58+
return [value stringByTrimmingCharactersInSet:NSCharacterSet.whitespaceAndNewlineCharacterSet];
59+
}
60+
5661
static NSNumber *XCWNumberValue(id value) {
5762
return [value isKindOfClass:[NSNumber class]] ? value : nil;
5863
}
5964

65+
static NSString *XCWStringFromData(NSData *data) {
66+
return [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding] ?: @"";
67+
}
68+
69+
static NSData *XCWDataSnapshot(NSMutableData *data) {
70+
@synchronized(data) {
71+
return [data copy];
72+
}
73+
}
74+
75+
static void XCWAppendAvailableData(NSFileHandle *handle, NSMutableData *destination) {
76+
@try {
77+
NSData *chunk = [handle availableData];
78+
if (chunk.length > 0) {
79+
@synchronized(destination) {
80+
[destination appendData:chunk];
81+
}
82+
}
83+
} @catch (NSException *exception) {
84+
}
85+
}
86+
87+
static BOOL XCWWaitForTaskExit(NSTask *task, NSTimeInterval timeoutSeconds) {
88+
NSDate *deadline = [NSDate dateWithTimeIntervalSinceNow:timeoutSeconds];
89+
while (task.running && [deadline timeIntervalSinceNow] > 0) {
90+
usleep(10 * 1000);
91+
}
92+
if (!task.running) {
93+
return YES;
94+
}
95+
return NO;
96+
}
97+
6098
static NSString * _Nullable XCWCreateTemporaryDirectory(NSString *prefix, NSError * _Nullable __autoreleasing *error) {
6199
NSString *templatePath = [NSTemporaryDirectory() stringByAppendingPathComponent:[NSString stringWithFormat:@"%@-XXXXXX", prefix]];
62100
char *directoryTemplate = strdup(templatePath.fileSystemRepresentation);
@@ -682,38 +720,136 @@ - (nullable NSData *)screenRecordingMP4ForSimulatorUDID:(NSString *)udid
682720

683721
NSString *filename = [NSString stringWithFormat:@"simdeck-%@.mp4", NSUUID.UUID.UUIDString];
684722
NSString *path = [NSTemporaryDirectory() stringByAppendingPathComponent:filename];
685-
XCWProcessResult *result = [self.class runSimctl:@[
686-
@"io",
687-
udid,
688-
@"recordVideo",
689-
@"--codec=h264",
690-
@"--force",
691-
path,
692-
]
693-
timeoutSec:durationSeconds
694-
timeoutSignal:SIGINT
695-
error:error];
696-
if (result == nil) {
723+
XCWProcessResult *resolvedSimctl = [XCWProcessRunner runLaunchPath:@"/usr/bin/xcrun"
724+
arguments:@[@"-f", @"simctl"]
725+
inputData:nil
726+
error:error];
727+
if (resolvedSimctl == nil) {
697728
[[NSFileManager defaultManager] removeItemAtPath:path error:nil];
698729
return nil;
699730
}
700-
701-
BOOL expectedStop = result.terminationStatus == 0 || result.terminationStatus == 124 || result.terminationStatus == 130;
702-
if (expectedStop) {
703-
NSData *data = [NSData dataWithContentsOfFile:path options:0 error:error];
731+
NSString *simctlPath = XCWTrimmedString(XCWStringFromData(resolvedSimctl.stdoutData));
732+
if (resolvedSimctl.terminationStatus != 0 || simctlPath.length == 0) {
704733
[[NSFileManager defaultManager] removeItemAtPath:path error:nil];
705-
if (data.length > 0) {
706-
return data;
734+
if (error != NULL) {
735+
NSString *message = resolvedSimctl.stderrString.length > 0 ? resolvedSimctl.stderrString : @"Unable to locate simctl.";
736+
*error = [self.class errorWithDescription:message code:34];
707737
}
708-
if (error != NULL && *error == nil) {
709-
*error = [self.class errorWithDescription:@"Simulator screen recording command produced an empty MP4." code:34];
738+
return nil;
739+
}
740+
741+
NSTask *task = [[NSTask alloc] init];
742+
task.launchPath = simctlPath;
743+
task.arguments = @[@"io", udid, @"recordVideo", @"--codec=h264", @"--force", path];
744+
task.standardInput = NSFileHandle.fileHandleWithNullDevice;
745+
NSPipe *stdoutPipe = [NSPipe pipe];
746+
NSPipe *stderrPipe = [NSPipe pipe];
747+
task.standardOutput = stdoutPipe;
748+
task.standardError = stderrPipe;
749+
750+
NSMutableData *stdoutData = [NSMutableData data];
751+
NSMutableData *stderrData = [NSMutableData data];
752+
NSFileHandle *stdoutHandle = stdoutPipe.fileHandleForReading;
753+
NSFileHandle *stderrHandle = stderrPipe.fileHandleForReading;
754+
dispatch_semaphore_t recordingStartedSemaphore = dispatch_semaphore_create(0);
755+
__block BOOL recordingStarted = NO;
756+
757+
stdoutHandle.readabilityHandler = ^(NSFileHandle *handle) {
758+
XCWAppendAvailableData(handle, stdoutData);
759+
};
760+
stderrHandle.readabilityHandler = ^(NSFileHandle *handle) {
761+
@try {
762+
NSData *chunk = [handle availableData];
763+
if (chunk.length == 0) {
764+
return;
765+
}
766+
BOOL shouldSignal = NO;
767+
@synchronized(stderrData) {
768+
[stderrData appendData:chunk];
769+
NSString *text = XCWStringFromData(stderrData);
770+
if (!recordingStarted && [text rangeOfString:@"Recording started"].location != NSNotFound) {
771+
recordingStarted = YES;
772+
shouldSignal = YES;
773+
}
774+
}
775+
if (shouldSignal) {
776+
dispatch_semaphore_signal(recordingStartedSemaphore);
777+
}
778+
} @catch (NSException *exception) {
779+
}
780+
};
781+
782+
NSError *launchError = nil;
783+
if (![task launchAndReturnError:&launchError]) {
784+
stdoutHandle.readabilityHandler = nil;
785+
stderrHandle.readabilityHandler = nil;
786+
[[NSFileManager defaultManager] removeItemAtPath:path error:nil];
787+
if (error != NULL) {
788+
*error = launchError ?: [self.class errorWithDescription:@"Unable to launch simulator screen recording." code:34];
710789
}
711790
return nil;
712791
}
713792

793+
NSTimeInterval startTimeout = MAX(10.0, MIN(30.0, durationSeconds + 10.0));
794+
NSDate *startDeadline = [NSDate dateWithTimeIntervalSinceNow:startTimeout];
795+
BOOL didStart = NO;
796+
while ([startDeadline timeIntervalSinceNow] > 0) {
797+
if (dispatch_semaphore_wait(recordingStartedSemaphore, DISPATCH_TIME_NOW) == 0) {
798+
didStart = YES;
799+
break;
800+
}
801+
if (!task.running) {
802+
break;
803+
}
804+
usleep(10 * 1000);
805+
}
806+
807+
if (didStart) {
808+
NSDate *stopAt = [NSDate dateWithTimeIntervalSinceNow:durationSeconds];
809+
while (task.running && [stopAt timeIntervalSinceNow] > 0) {
810+
usleep(10 * 1000);
811+
}
812+
}
813+
if (task.running) {
814+
[task interrupt];
815+
}
816+
if (!XCWWaitForTaskExit(task, didStart ? 10.0 : 2.0) && task.running) {
817+
[task terminate];
818+
XCWWaitForTaskExit(task, 2.0);
819+
}
820+
821+
stdoutHandle.readabilityHandler = nil;
822+
stderrHandle.readabilityHandler = nil;
823+
XCWAppendAvailableData(stdoutHandle, stdoutData);
824+
XCWAppendAvailableData(stderrHandle, stderrData);
825+
826+
NSError *readError = nil;
827+
NSData *data = nil;
828+
NSDate *fileDeadline = [NSDate dateWithTimeIntervalSinceNow:2.0];
829+
do {
830+
data = [NSData dataWithContentsOfFile:path options:0 error:&readError];
831+
if (data.length > 0) {
832+
break;
833+
}
834+
usleep(50 * 1000);
835+
} while ([fileDeadline timeIntervalSinceNow] > 0);
714836
[[NSFileManager defaultManager] removeItemAtPath:path error:nil];
837+
if (data.length > 0) {
838+
return data;
839+
}
840+
715841
if (error != NULL) {
716-
*error = [self.class errorWithDescription:result.stderrString.length > 0 ? result.stderrString : @"Unable to record simulator screen." code:34];
842+
NSString *stderrString = XCWStringFromData(XCWDataSnapshot(stderrData));
843+
NSString *stdoutString = XCWStringFromData(XCWDataSnapshot(stdoutData));
844+
NSString *details = stderrString.length > 0 ? stderrString : stdoutString;
845+
if (!didStart && details.length == 0) {
846+
details = @"Simulator screen recording did not start before the timeout.";
847+
} else if (details.length == 0 && readError.localizedDescription.length > 0) {
848+
details = readError.localizedDescription;
849+
} else if (details.length == 0) {
850+
details = @"Simulator screen recording command produced an empty MP4.";
851+
}
852+
*error = [self.class errorWithDescription:details code:34];
717853
}
718854
return nil;
719855
}

0 commit comments

Comments
 (0)