FBSimulatorControl/Framebuffer/FBSimulatorVideo.m (173 lines of code) (raw):
/*
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
#import "FBSimulatorVideo.h"
#import <objc/runtime.h>
#import <FBControlCore/FBControlCore.h>
#import "FBAppleSimctlCommandExecutor.h"
#import "FBSimulatorError.h"
@interface FBSimulatorVideo ()
@property (nonatomic, copy, readonly) NSString *filePath;
@property (nonatomic, strong, readonly) id<FBControlCoreLogger> logger;
@property (nonatomic, strong, readonly) dispatch_queue_t queue;
@property (nonatomic, strong, readonly) FBMutableFuture<NSNull *> *completedFuture;
- (instancetype)initWithFilePath:(NSString *)filePath logger:(id<FBControlCoreLogger>)logger;
@end
@interface FBSimulatorVideo_SimCtl : FBSimulatorVideo
@property (nonatomic, strong, readonly) FBAppleSimctlCommandExecutor *simctlExecutor;
@property (nonatomic, strong, nullable, readwrite) FBFuture<FBProcess<NSNull *, id<FBControlCoreLogger>, id<FBControlCoreLogger>> *> *recordingStarted;
- (instancetype)initWithWithSimctlExecutor:(FBAppleSimctlCommandExecutor *)simctlExecutor filePath:(NSString *)filePath logger:(id<FBControlCoreLogger>)logger;
@end
@implementation FBSimulatorVideo
#pragma mark Initializers
+ (instancetype)videoWithSimctlExecutor:(FBAppleSimctlCommandExecutor *)simctlExecutor filePath:(NSString *)filePath logger:(id<FBControlCoreLogger>)logger
{
return [[FBSimulatorVideo_SimCtl alloc] initWithWithSimctlExecutor:simctlExecutor filePath:filePath logger:logger];
}
- (instancetype)initWithFilePath:(NSString *)filePath logger:(id<FBControlCoreLogger>)logger
{
self = [super init];
if (!self) {
return nil;
}
_filePath = filePath;
_logger = logger;
_queue = dispatch_queue_create("com.facebook.simulatorvideo.simctl", DISPATCH_QUEUE_SERIAL);
_completedFuture = FBMutableFuture.future;
return self;
}
#pragma mark Public Methods
- (FBFuture<NSNull *> *)startRecording
{
NSAssert(NO, @"-[%@ %@] is abstract and should be overridden", NSStringFromClass(self.class), NSStringFromSelector(_cmd));
return nil;
}
- (FBFuture<NSNull *> *)stopRecording
{
NSAssert(NO, @"-[%@ %@] is abstract and should be overridden", NSStringFromClass(self.class), NSStringFromSelector(_cmd));
return nil;
}
#pragma mark FBiOSTargetOperation
- (FBFuture<NSNull *> *)completed
{
return [self.completedFuture onQueue:self.queue respondToCancellation:^{
return [self stopRecording];
}];
}
@end
@implementation FBSimulatorVideo_SimCtl
- (instancetype)initWithWithSimctlExecutor:(FBAppleSimctlCommandExecutor *)simctlExecutor filePath:(NSString *)filePath logger:(id<FBControlCoreLogger>)logger
{
self = [super initWithFilePath:filePath logger:logger];
if (!self) {
return nil;
}
_simctlExecutor = simctlExecutor;
return self;
}
#pragma mark Public
- (FBFuture<NSNull *> *)startRecording
{
// Fail early if there's a task running.
if (self.recordingStarted) {
return [[FBSimulatorError
describe:@"Cannot Start Recording, there is already an recording task running"]
failFuture];
}
self.recordingStarted = [[self
simctlVersionNumber]
onQueue:self.queue fmap:^(NSDecimalNumber *simctlVersion) {
// Earlier versions use --type=codec instead of --type, so we need to switch on the version of simctl
NSArray<NSString *> *recordVideoParameters = @[@"--type=mp4"];
if ([simctlVersion isGreaterThanOrEqualTo:[NSDecimalNumber decimalNumberWithString:@"681.14"]]) {
recordVideoParameters = @[@"--codec=h264", @"--force"];
}
NSArray<NSString *> *ioCommandArguments = [[@[@"recordVideo"]
arrayByAddingObjectsFromArray:recordVideoParameters]
arrayByAddingObject:self.filePath];
return [[[[[self.simctlExecutor
taskBuilderWithCommand:@"io" arguments:ioCommandArguments]
withStdOutToLogger:self.logger]
withStdErrToLogger:self.logger]
withTaskLifecycleLoggingTo:self.logger]
start];
}];
return [self.recordingStarted mapReplace:NSNull.null];
}
static NSTimeInterval const recordingTaskWaitTimeout = 10.0;
- (FBFuture<NSNull *> *)stopRecording
{
// Fail early if there's no task running.
FBFuture<FBProcess<NSNull *, id<FBControlCoreLogger>, id<FBControlCoreLogger>> *> *recordingStarted = self.recordingStarted;
if (!recordingStarted) {
return [[FBSimulatorError
describe:@"Cannot Stop Recording, there is no recording task started"]
failFuture];
}
FBProcess<NSNull *, id<FBControlCoreLogger>, id<FBControlCoreLogger>> *recordingTask = recordingStarted.result;
if (!recordingTask) {
return [[FBSimulatorError
describe:@"Cannot Stop Recording, the recording task hasn't started"]
failFuture];
}
// Grab the task and see if it died already.
if (recordingTask.statLoc.hasCompleted) {
[self.logger logFormat:@"Stop Recording requested, but it's completed with output '%@' '%@', perhaps the video is damaged", recordingTask.stdOut, recordingTask.stdErr];
return FBFuture.empty;
}
// Stop for real be interrupting the task itself.
FBFuture<NSNull *> *completed = [[[[recordingTask
sendSignal:SIGINT backingOffToKillWithTimeout:recordingTaskWaitTimeout logger:self.logger]
logCompletion:self.logger withPurpose:@"The video recording task terminated"]
onQueue:self.queue fmap:^(NSNumber *result) {
self.recordingStarted = nil;
return [FBSimulatorVideo_SimCtl confirmFileHasBeenWritten:self.filePath queue:self.queue logger:self.logger];
}]
onQueue:self.queue handleError:^(NSError *error) {
[self.logger logFormat:@"Failed confirm video file been written %@", error];
return [FBFuture futureWithResult:NSNull.null];
}];
[self.completedFuture resolveFromFuture:completed];
return completed;
}
#pragma mark Private
static NSTimeInterval const SimctlResolveFileTimeout = 10;
// simctl, may exit before the underlying video file has been written out to disk.
// This is unfortunate as we can't guarantee that video file is valid until this happens.
// Therefore, we must check (with some timeout) for the existence of the file on disk.
// It's not simply enough to check that the file exists, we must also check that it has a nonzero size.
// The reason for this is that simctl itself (since Xcode 10) isn't doing the writing, this is instead delegated to SimStreamProcessorService.
// Since this writing is asynchronous with simctl, it's possible that it isn't written out when simctl has terminated.
+ (FBFuture<NSNull *> *)confirmFileHasBeenWritten:(NSString *)filePath queue:(dispatch_queue_t)queue logger:(id<FBControlCoreLogger>)logger
{
return [[FBFuture
onQueue:queue resolveWhen:^{
NSDictionary<NSString *, id> *fileAttributes = [NSFileManager.defaultManager attributesOfItemAtPath:filePath error:nil];
NSUInteger fileSize = [fileAttributes[NSFileSize] unsignedIntegerValue];
if (fileSize > 0) {
[logger logFormat:@"simctl has written out the video to %@ with file size %lu", filePath, fileSize];
return YES;
}
return NO;
}]
timeout:SimctlResolveFileTimeout waitingFor:@"simctl to write file to %@", filePath];
}
- (FBFuture<NSDecimalNumber *> *)simctlVersionNumber
{
return [[[[[[FBProcessBuilder
withLaunchPath:@"/usr/bin/what"
arguments:@[@"/Library/Developer/PrivateFrameworks/CoreSimulator.framework/Versions/A/Resources/bin/simctl"]]
withStdOutInMemoryAsString]
withStdErrToDevNull]
runUntilCompletionWithAcceptableExitCodes:nil]
onQueue:self.queue fmap:^(FBProcess<NSNull *, NSString *, NSNull *> *task) {
NSString *output = task.stdOut;
NSString *pattern = @"CoreSimulator-([0-9\\.]+)";
NSRegularExpression* regex = [NSRegularExpression
regularExpressionWithPattern:pattern
options:0
error:nil];
NSArray* matches = [regex
matchesInString:output
options:0
range:NSMakeRange(0, output.length)];
// Some versions can output information twice, pick the first one
if (matches.count < 1) {
[self.logger logFormat:@"Couldn't find simctl version from: %@, return 0.0", output];
return [FBFuture futureWithResult:NSDecimalNumber.zero];
}
NSTextCheckingResult *match = matches[0];
NSString *result = [output substringWithRange:[match rangeAtIndex:1]];
return [FBFuture futureWithResult:[NSDecimalNumber decimalNumberWithString:result]];
}]
onQueue:self.queue handleError:^(NSError *error) {
[self.logger logFormat:@"Abnormal exit of 'what' process %@, assuming version 0.0", error];
return [FBFuture futureWithResult:NSDecimalNumber.zero];
}];
}
@end