@@ -37,6 +37,22 @@ - (BOOL)installIPAAtPath:(NSString *)ipaPath simulatorUDID:(NSString *)udid erro
3737
3838@end
3939
40+ @interface XCWScreenRecordingSession : NSObject
41+
42+ @property (nonatomic , copy ) NSString *identifier;
43+ @property (nonatomic , copy ) NSString *udid;
44+ @property (nonatomic , copy ) NSString *path;
45+ @property (nonatomic , strong ) NSTask *task;
46+ @property (nonatomic , strong ) NSMutableData *stdoutData;
47+ @property (nonatomic , strong ) NSMutableData *stderrData;
48+ @property (nonatomic , strong ) NSFileHandle *stdoutHandle;
49+ @property (nonatomic , strong ) NSFileHandle *stderrHandle;
50+
51+ @end
52+
53+ @implementation XCWScreenRecordingSession
54+ @end
55+
4056static NSArray *XCWArrayPayload (id payload, NSString *nestedKey) {
4157 if ([payload isKindOfClass: [NSArray class ]]) {
4258 return payload;
@@ -95,6 +111,58 @@ static BOOL XCWWaitForTaskExit(NSTask *task, NSTimeInterval timeoutSeconds) {
95111 return NO ;
96112}
97113
114+ static NSMutableDictionary <NSString *, XCWScreenRecordingSession *> *XCWScreenRecordingSessions (void ) {
115+ static NSMutableDictionary <NSString *, XCWScreenRecordingSession *> *sessions = nil ;
116+ static dispatch_once_t onceToken;
117+ dispatch_once (&onceToken, ^{
118+ sessions = [NSMutableDictionary dictionary ];
119+ });
120+ return sessions;
121+ }
122+
123+ static NSString * _Nullable XCWResolveSimctlPath (NSError * _Nullable __autoreleasing *error) {
124+ XCWProcessResult *resolvedSimctl = [XCWProcessRunner runLaunchPath: @" /usr/bin/xcrun"
125+ arguments: @[@" -f" , @" simctl" ]
126+ inputData: nil
127+ error: error];
128+ if (resolvedSimctl == nil ) {
129+ return nil ;
130+ }
131+ NSString *simctlPath = XCWTrimmedString (XCWStringFromData (resolvedSimctl.stdoutData ));
132+ if (resolvedSimctl.terminationStatus != 0 || simctlPath.length == 0 ) {
133+ if (error != NULL ) {
134+ NSString *message = resolvedSimctl.stderrString .length > 0 ? resolvedSimctl.stderrString : @" Unable to locate simctl." ;
135+ *error = [XCWSimctl errorWithDescription: message code: 34 ];
136+ }
137+ return nil ;
138+ }
139+ return simctlPath;
140+ }
141+
142+ static BOOL XCWStopRecordingTask (XCWScreenRecordingSession *session, NSTimeInterval timeoutSeconds) {
143+ NSTask *task = session.task ;
144+ if (task.running ) {
145+ [task interrupt ];
146+ }
147+ if (!XCWWaitForTaskExit (task, timeoutSeconds) && task.running ) {
148+ [task terminate ];
149+ if (!XCWWaitForTaskExit (task, 2.0 ) && task.running ) {
150+ kill (task.processIdentifier , SIGKILL );
151+ XCWWaitForTaskExit (task, 2.0 );
152+ }
153+ }
154+ return !task.running ;
155+ }
156+
157+ static void XCWCloseRecordingPipes (XCWScreenRecordingSession *session) {
158+ session.stdoutHandle .readabilityHandler = nil ;
159+ session.stderrHandle .readabilityHandler = nil ;
160+ if (!session.task .running ) {
161+ XCWAppendAvailableData (session.stdoutHandle , session.stdoutData );
162+ XCWAppendAvailableData (session.stderrHandle , session.stderrData );
163+ }
164+ }
165+
98166static NSString * _Nullable XCWCreateTemporaryDirectory (NSString *prefix, NSError * _Nullable __autoreleasing *error) {
99167 NSString *templatePath = [NSTemporaryDirectory () stringByAppendingPathComponent: [NSString stringWithFormat: @" %@ -XXXXXX" , prefix]];
100168 char *directoryTemplate = strdup (templatePath.fileSystemRepresentation );
@@ -859,6 +927,194 @@ - (nullable NSData *)screenRecordingMP4ForSimulatorUDID:(NSString *)udid
859927 return nil ;
860928}
861929
930+ - (nullable NSString *)startScreenRecordingForSimulatorUDID : (NSString *)udid
931+ error : (NSError * _Nullable __autoreleasing *)error {
932+ if (udid.length == 0 ) {
933+ if (error != NULL ) {
934+ *error = [self .class errorWithDescription: @" Screen recording requires a simulator UDID." code: 34 ];
935+ }
936+ return nil ;
937+ }
938+
939+ NSMutableDictionary <NSString *, XCWScreenRecordingSession *> *sessions = XCWScreenRecordingSessions ();
940+ @synchronized (sessions) {
941+ for (XCWScreenRecordingSession *session in sessions.objectEnumerator ) {
942+ if ([session.udid isEqualToString: udid]) {
943+ if (error != NULL ) {
944+ *error = [self .class errorWithDescription: @" A screen recording is already in progress for this simulator." code: 34 ];
945+ }
946+ return nil ;
947+ }
948+ }
949+ }
950+
951+ NSString *filename = [NSString stringWithFormat: @" simdeck-%@ .mp4" , NSUUID .UUID .UUIDString];
952+ NSString *path = [NSTemporaryDirectory () stringByAppendingPathComponent: filename];
953+ NSString *simctlPath = XCWResolveSimctlPath (error);
954+ if (simctlPath.length == 0 ) {
955+ [[NSFileManager defaultManager ] removeItemAtPath: path error: nil ];
956+ return nil ;
957+ }
958+
959+ XCWScreenRecordingSession *session = [[XCWScreenRecordingSession alloc ] init ];
960+ session.identifier = NSUUID .UUID .UUIDString ;
961+ session.udid = udid;
962+ session.path = path;
963+ session.stdoutData = [NSMutableData data ];
964+ session.stderrData = [NSMutableData data ];
965+
966+ NSTask *task = [[NSTask alloc ] init ];
967+ task.launchPath = simctlPath;
968+ task.arguments = @[@" io" , udid, @" recordVideo" , @" --codec=h264" , @" --force" , path];
969+ task.standardInput = NSFileHandle .fileHandleWithNullDevice ;
970+ NSPipe *stdoutPipe = [NSPipe pipe ];
971+ NSPipe *stderrPipe = [NSPipe pipe ];
972+ task.standardOutput = stdoutPipe;
973+ task.standardError = stderrPipe;
974+ session.task = task;
975+ session.stdoutHandle = stdoutPipe.fileHandleForReading ;
976+ session.stderrHandle = stderrPipe.fileHandleForReading ;
977+
978+ dispatch_semaphore_t recordingStartedSemaphore = dispatch_semaphore_create (0 );
979+ __block BOOL recordingStarted = NO ;
980+ __weak XCWScreenRecordingSession *weakSession = session;
981+
982+ session.stdoutHandle .readabilityHandler = ^(NSFileHandle *handle) {
983+ XCWScreenRecordingSession *strongSession = weakSession;
984+ if (strongSession == nil ) {
985+ return ;
986+ }
987+ XCWAppendAvailableData (handle, strongSession.stdoutData );
988+ };
989+ session.stderrHandle .readabilityHandler = ^(NSFileHandle *handle) {
990+ XCWScreenRecordingSession *strongSession = weakSession;
991+ if (strongSession == nil ) {
992+ return ;
993+ }
994+ @try {
995+ NSData *chunk = [handle availableData ];
996+ if (chunk.length == 0 ) {
997+ return ;
998+ }
999+ BOOL shouldSignal = NO ;
1000+ @synchronized (strongSession.stderrData ) {
1001+ [strongSession.stderrData appendData: chunk];
1002+ NSString *text = XCWStringFromData (strongSession.stderrData );
1003+ if (!recordingStarted && [text rangeOfString: @" Recording started" ].location != NSNotFound ) {
1004+ recordingStarted = YES ;
1005+ shouldSignal = YES ;
1006+ }
1007+ }
1008+ if (shouldSignal) {
1009+ dispatch_semaphore_signal (recordingStartedSemaphore);
1010+ }
1011+ } @catch (NSException *exception) {
1012+ }
1013+ };
1014+
1015+ NSError *launchError = nil ;
1016+ if (![task launchAndReturnError: &launchError]) {
1017+ XCWCloseRecordingPipes (session);
1018+ [[NSFileManager defaultManager ] removeItemAtPath: path error: nil ];
1019+ if (error != NULL ) {
1020+ *error = launchError ?: [self .class errorWithDescription: @" Unable to launch simulator screen recording." code: 34 ];
1021+ }
1022+ return nil ;
1023+ }
1024+
1025+ NSDate *startDeadline = [NSDate dateWithTimeIntervalSinceNow: 10.0 ];
1026+ BOOL didStart = NO ;
1027+ while ([startDeadline timeIntervalSinceNow ] > 0 ) {
1028+ if (dispatch_semaphore_wait (recordingStartedSemaphore, DISPATCH_TIME_NOW ) == 0 ) {
1029+ didStart = YES ;
1030+ break ;
1031+ }
1032+ if (!task.running ) {
1033+ break ;
1034+ }
1035+ usleep (10 * 1000 );
1036+ }
1037+
1038+ if (!didStart) {
1039+ XCWStopRecordingTask (session, 2.0 );
1040+ XCWCloseRecordingPipes (session);
1041+ [[NSFileManager defaultManager ] removeItemAtPath: path error: nil ];
1042+ if (error != NULL ) {
1043+ NSString *stderrString = XCWStringFromData (XCWDataSnapshot (session.stderrData ));
1044+ NSString *stdoutString = XCWStringFromData (XCWDataSnapshot (session.stdoutData ));
1045+ NSString *details = stderrString.length > 0 ? stderrString : stdoutString;
1046+ if (details.length == 0 ) {
1047+ details = @" Simulator screen recording did not start before the timeout." ;
1048+ }
1049+ *error = [self .class errorWithDescription: details code: 34 ];
1050+ }
1051+ return nil ;
1052+ }
1053+
1054+ @synchronized (sessions) {
1055+ sessions[session.identifier] = session;
1056+ }
1057+ return session.identifier ;
1058+ }
1059+
1060+ - (nullable NSData *)stopScreenRecordingWithID : (NSString *)recordingID
1061+ error : (NSError * _Nullable __autoreleasing *)error {
1062+ NSString *trimmedID = XCWTrimmedString (recordingID ?: @" " );
1063+ if (trimmedID.length == 0 ) {
1064+ if (error != NULL ) {
1065+ *error = [self .class errorWithDescription: @" Screen recording stop requires a recording ID." code: 34 ];
1066+ }
1067+ return nil ;
1068+ }
1069+
1070+ NSMutableDictionary <NSString *, XCWScreenRecordingSession *> *sessions = XCWScreenRecordingSessions ();
1071+ XCWScreenRecordingSession *session = nil ;
1072+ @synchronized (sessions) {
1073+ session = sessions[trimmedID];
1074+ if (session != nil ) {
1075+ [sessions removeObjectForKey: trimmedID];
1076+ }
1077+ }
1078+
1079+ if (session == nil ) {
1080+ if (error != NULL ) {
1081+ *error = [self .class errorWithDescription: @" No active simulator screen recording matched that ID." code: 34 ];
1082+ }
1083+ return nil ;
1084+ }
1085+
1086+ XCWStopRecordingTask (session, 10.0 );
1087+ XCWCloseRecordingPipes (session);
1088+
1089+ NSError *readError = nil ;
1090+ NSData *data = nil ;
1091+ NSDate *fileDeadline = [NSDate dateWithTimeIntervalSinceNow: 2.0 ];
1092+ do {
1093+ data = [NSData dataWithContentsOfFile: session.path options: 0 error: &readError];
1094+ if (data.length > 0 ) {
1095+ break ;
1096+ }
1097+ usleep (50 * 1000 );
1098+ } while ([fileDeadline timeIntervalSinceNow ] > 0 );
1099+ [[NSFileManager defaultManager ] removeItemAtPath: session.path error: nil ];
1100+ if (data.length > 0 ) {
1101+ return data;
1102+ }
1103+
1104+ if (error != NULL ) {
1105+ NSString *stderrString = XCWStringFromData (XCWDataSnapshot (session.stderrData ));
1106+ NSString *stdoutString = XCWStringFromData (XCWDataSnapshot (session.stdoutData ));
1107+ NSString *details = stderrString.length > 0 ? stderrString : stdoutString;
1108+ if (details.length == 0 && readError.localizedDescription .length > 0 ) {
1109+ details = readError.localizedDescription ;
1110+ } else if (details.length == 0 ) {
1111+ details = @" Simulator screen recording command produced an empty MP4." ;
1112+ }
1113+ *error = [self .class errorWithDescription: details code: 34 ];
1114+ }
1115+ return nil ;
1116+ }
1117+
8621118- (BOOL )eraseSimulatorWithUDID : (NSString *)udid error : (NSError * _Nullable __autoreleasing *)error {
8631119 XCWProcessResult *result = [self .class runSimctl: @[@" erase" , udid] error: error];
8641120 if (result == nil ) {
0 commit comments