FBSimulatorControl/Commands/FBSimulatorSettingsCommands.m (443 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 "FBSimulatorSettingsCommands.h" #import <CoreSimulator/SimDevice.h> #import <FBControlCore/FBControlCore.h> #import "FBDefaultsModificationStrategy.h" #import "FBSimulator.h" #import "FBSimulatorBootConfiguration.h" #import "FBSimulatorBridge.h" #import "FBSimulatorError.h" static NSString *const SpringBoardServiceName = @"com.apple.SpringBoard"; @interface FBSimulatorSettingsCommands () @property (nonatomic, weak, readonly) FBSimulator *simulator; @end @implementation FBSimulatorSettingsCommands + (instancetype)commandsWithTarget:(FBSimulator *)target { return [[self alloc] initWithSimulator:target]; } - (instancetype)initWithSimulator:(FBSimulator *)simulator { self = [super init]; if (!self) { return nil; } _simulator = simulator; return self; } #pragma mark Public - (FBFuture<NSNull *> *)setHardwareKeyboardEnabled:(BOOL)enabled { if ([self.simulator.device respondsToSelector:(@selector(setHardwareKeyboardEnabled:keyboardType:error:))]) { return [FBFuture onQueue:self.simulator.workQueue resolve:^ FBFuture<NSNull *> * () { NSError *error = nil; [self.simulator.device setHardwareKeyboardEnabled:enabled keyboardType:0 error:&error]; return FBFuture.empty; }]; } return [[self.simulator connectToBridge] onQueue:self.simulator.workQueue fmap:^ FBFuture<NSNull *> * (FBSimulatorBridge *bridge) { return [bridge setHardwareKeyboardEnabled:enabled]; }]; } - (FBFuture<NSNull *> *)setPreference:(NSString *)name value:(NSString *)value type:(nullable NSString *)type domain:(nullable NSString *)domain; { return [[FBPreferenceModificationStrategy strategyWithSimulator:self.simulator] setPreference:name value:value type: type domain:domain]; } - (FBFuture<NSString *> *)getCurrentPreference:(NSString *)name domain:(nullable NSString *)domain; { return [[FBPreferenceModificationStrategy strategyWithSimulator:self.simulator] getCurrentPreference:name domain:domain]; } - (FBFuture<NSNull *> *)grantAccess:(NSSet<NSString *> *)bundleIDs toServices:(NSSet<FBSettingsApprovalService> *)services { // We need at least one approval in the input if (services.count == 0) { return [[FBSimulatorError describeFormat:@"Cannot approve any services for %@ since no services were provided", bundleIDs] failFuture]; } // We also need at least one bundle id in the input. if (services.count == 0) { return [[FBSimulatorError describeFormat:@"Cannot approve %@ since no bundle ids were provided", services] failFuture]; } // Composing different futures due to differences in how these operate. NSMutableArray<FBFuture<NSNull *> *> *futures = [NSMutableArray array]; NSMutableSet<NSString *> *toApprove = [NSMutableSet setWithSet:services]; FBOSVersion *iosVer = [self.simulator osVersion]; NSDictionary<FBSettingsApprovalService, NSString *> *coreSimulatorSettingMapping; if (iosVer.version.majorVersion >= 13) { coreSimulatorSettingMapping = FBSimulatorSettingsCommands.coreSimulatorSettingMappingPostIos13; } else { coreSimulatorSettingMapping = FBSimulatorSettingsCommands.coreSimulatorSettingMappingPreIos13; } // Go through each of the internal APIs, removing them from the pending set as we go. if ([self.simulator.device respondsToSelector:@selector(setPrivacyAccessForService:bundleID:granted:error:)]) { NSMutableSet<NSString *> *simDeviceServices = [toApprove mutableCopy]; [simDeviceServices intersectSet:[NSSet setWithArray:coreSimulatorSettingMapping.allKeys]]; // Only approve these services, where they are serviced by the CoreSimulator API if (simDeviceServices.count > 0) { NSMutableSet<NSString *> *internalServices = [NSMutableSet set]; for (NSString *service in simDeviceServices) { NSString *internalService = coreSimulatorSettingMapping[service]; [internalServices addObject:internalService]; } [toApprove minusSet:simDeviceServices]; [futures addObject:[self coreSimulatorApproveWithBundleIDs:bundleIDs toServices:internalServices]]; } } if (toApprove.count > 0 && [[NSSet setWithArray:FBSimulatorSettingsCommands.tccDatabaseMapping.allKeys] intersectsSet:toApprove]) { NSMutableSet<NSString *> *tccServices = [toApprove mutableCopy]; [tccServices intersectSet:[NSSet setWithArray:FBSimulatorSettingsCommands.tccDatabaseMapping.allKeys]]; [toApprove minusSet:tccServices]; [futures addObject:[self modifyTCCDatabaseWithBundleIDs:bundleIDs toServices:tccServices]]; } if (toApprove.count > 0 && [toApprove containsObject:FBSettingsApprovalServiceLocation]) { [futures addObject:[self authorizeLocationSettings:bundleIDs.allObjects]]; [toApprove removeObject:FBSettingsApprovalServiceLocation]; } if (toApprove.count > 0 && [toApprove containsObject:FBSettingsApprovalServiceNotification]) { [futures addObject:[self authorizeNotificationService:bundleIDs.allObjects]]; [toApprove removeObject:FBSettingsApprovalServiceNotification]; } // Error out if there's nothing we can do to handle a specific approval. if (toApprove.count > 0) { return [[FBSimulatorError describeFormat:@"Cannot approve %@ since there is no handling of it", [FBCollectionInformation oneLineDescriptionFromArray:toApprove.allObjects]] failFuture]; } // Nothing to do with zero futures. if (futures.count == 0) { return FBFuture.empty; } // Don't wrap if there's only one future. if (futures.count == 1) { return futures.firstObject; } return [[FBFuture futureWithFutures:futures] mapReplace:NSNull.null]; } - (FBFuture<NSNull *> *)grantAccess:(NSSet<NSString *> *)bundleIDs toDeeplink:(NSString *)scheme { if ([scheme length] == 0) { return [[FBSimulatorError describe:@"Empty scheme provided to url approve"] failFuture]; } if ([bundleIDs count] == 0) { return [[FBSimulatorError describe:@"Empty bundleID set provided to url approve"] failFuture]; } NSString *preferencesDirectory = [self.simulator.dataDirectory stringByAppendingPathComponent:@"Library/Preferences"]; NSString *schemeApprovalPlistPath = [preferencesDirectory stringByAppendingPathComponent:@"com.apple.launchservices.schemeapproval.plist"]; //Read the existing file if it exists. Otherwise create a new dictionary NSMutableDictionary<NSString *, NSString *> *schemeApprovalProperties = [NSMutableDictionary new]; if ([NSFileManager.defaultManager fileExistsAtPath:schemeApprovalPlistPath]) { schemeApprovalProperties = [[NSDictionary dictionaryWithContentsOfFile:schemeApprovalPlistPath] mutableCopy]; if (schemeApprovalProperties == nil) { return [[FBSimulatorError describeFormat:@"Failed to read the file at %@", schemeApprovalPlistPath] failFuture]; } } //Add magic strings to our plist. This is necessary to skip the dialog when using `idb open` NSString *urlKey = [NSString stringWithFormat:@"com.apple.CoreSimulator.CoreSimulatorBridge-->%@", scheme]; for (NSString *bundleID in bundleIDs) { schemeApprovalProperties[urlKey] = bundleID; } //Write our plist back NSError *error = nil; BOOL success = [NSFileManager.defaultManager createDirectoryAtPath:preferencesDirectory withIntermediateDirectories:YES attributes:nil error:&error]; if (!success) { return [[FBSimulatorError describe:@"Failed to create folders for scheme approval plist"] failFuture]; } success = [schemeApprovalProperties writeToFile:schemeApprovalPlistPath atomically:YES]; if (!success) { return [[FBSimulatorError describe:@"Failed to write scheme approval plist"] failFuture]; } return FBFuture.empty; } - (FBFuture<NSNull *> *)updateContacts:(NSString *)databaseDirectory { // Get and confirm the destination directory exists. NSString *destinationDirectory = [self.simulator.dataDirectory stringByAppendingPathComponent:@"Library/AddressBook"]; if (![NSFileManager.defaultManager fileExistsAtPath:destinationDirectory]) { return [[FBSimulatorError describeFormat:@"Expected Address Book path to exist at %@ but it was not there", destinationDirectory] failFuture]; } // Obtain the relevant file paths NSError *error = nil; NSArray<NSString *> *sourceFilePaths = [FBSimulatorSettingsCommands contactsDatabaseFilePathsFromContainingDirectory:databaseDirectory error:&error]; if (!sourceFilePaths) { return [FBFuture futureWithError:error]; } // Perform the copies for (NSString *sourceFilePath in sourceFilePaths) { NSString *destinationFilePath = [destinationDirectory stringByAppendingPathComponent:sourceFilePath.lastPathComponent]; if ([NSFileManager.defaultManager fileExistsAtPath:destinationFilePath] && ! [NSFileManager.defaultManager removeItemAtPath:destinationFilePath error:&error]) { return [FBFuture futureWithError:error]; } if (![NSFileManager.defaultManager copyItemAtPath:sourceFilePath toPath:destinationFilePath error:&error]) { return [FBFuture futureWithError:error]; } } return FBFuture.empty; } #pragma mark Private - (FBFuture<NSNull *> *)authorizeLocationSettings:(NSArray<NSString *> *)bundleIDs { return [[FBLocationServicesModificationStrategy strategyWithSimulator:self.simulator] approveLocationServicesForBundleIDs:bundleIDs]; } - (FBFuture<NSNull *> *)authorizeNotificationService:(NSArray<NSString *> *)bundleIDs { if ([bundleIDs count] == 0) { return [[FBSimulatorError describe:@"Empty bundleID set provided to notifications approve"] failFuture]; } NSString *bulletinDirectory = [self.simulator.dataDirectory stringByAppendingPathComponent:@"Library/BulletinBoard"]; NSString *notificationsApprovalPlistPath = [bulletinDirectory stringByAppendingPathComponent:@"VersionedSectionInfo.plist"]; NSMutableDictionary<NSString *, id> *sectionInfo = [NSMutableDictionary dictionaryWithContentsOfFile:notificationsApprovalPlistPath]; if (sectionInfo == nil) { return [[FBSimulatorError describe:@"Failed to load sectionInfo"] failFuture]; } for (NSString *bundleID in bundleIDs) { NSData *data = sectionInfo[@"sectionInfo"][bundleID]; if (data == nil) { data = [[sectionInfo[@"sectionInfo"] allValues] firstObject]; } if (data == nil) { return [[FBSimulatorError describeFormat:@"No section info for %@", bundleID] failFuture]; } NSError *readError = nil; NSDictionary<NSString *, id> *properties = [NSPropertyListSerialization propertyListWithData:data options:NSPropertyListMutableContainersAndLeaves format:nil error:&readError]; if (readError != nil) { return [FBSimulatorError failFutureWithError:readError]; } properties[@"$objects"][2] = bundleID; properties[@"$objects"][3][@"allowsNotifications"] = @(YES); NSError *writeError = nil; NSData *resultData = [NSPropertyListSerialization dataWithPropertyList:properties format:NSPropertyListBinaryFormat_v1_0 options:0 error:&writeError]; if (writeError != nil) { return [FBSimulatorError failFutureWithError:writeError]; } sectionInfo[@"sectionInfo"][bundleID] = resultData; } BOOL result = [sectionInfo writeToFile:notificationsApprovalPlistPath atomically:YES]; if (!result) { return [[FBSimulatorError describe:@"Failed to write sectionInfo data to plist"] failFuture]; } if (self.simulator.state == FBiOSTargetStateBooted) { return [[self.simulator stopServiceWithName:SpringBoardServiceName] mapReplace:NSNull.null]; } else { return FBFuture.empty; } } - (FBFuture<NSNull *> *)modifyTCCDatabaseWithBundleIDs:(NSSet<NSString *> *)bundleIDs toServices:(NSSet<FBSettingsApprovalService> *)services { NSString *databasePath = [self.simulator.dataDirectory stringByAppendingPathComponent:@"Library/TCC/TCC.db"]; BOOL isDirectory = YES; if (![NSFileManager.defaultManager fileExistsAtPath:databasePath isDirectory:&isDirectory]) { return [[FBSimulatorError describeFormat:@"Expected file to exist at path %@ but it was not there", databasePath] failFuture]; } if (isDirectory) { return [[FBSimulatorError describeFormat:@"Expected file to exist at path %@ but it is a directory", databasePath] failFuture]; } if ([NSFileManager.defaultManager isWritableFileAtPath:databasePath] == NO) { return [[FBSimulatorError describeFormat:@"Database file at path %@ is not writable", databasePath] failFuture]; } id<FBControlCoreLogger> logger = [self.simulator.logger withName:@"sqlite_auth"]; dispatch_queue_t queue = self.simulator.asyncQueue; return [[[FBSimulatorSettingsCommands buildRowsForDatabase:databasePath bundleIDs:bundleIDs services:services queue:queue logger:logger] onQueue:self.simulator.workQueue fmap:^(NSString *rows) { return [FBSimulatorSettingsCommands runSqliteCommandOnDatabase:databasePath arguments:@[[NSString stringWithFormat:@"INSERT or REPLACE INTO access VALUES %@", rows]] queue:queue logger:logger]; }] mapReplace:NSNull.null]; } - (FBFuture<NSNull *> *)coreSimulatorApproveWithBundleIDs:(NSSet<NSString *> *)bundleIDs toServices:(NSSet<NSString *> *)services { for (NSString *bundleID in bundleIDs) { for (NSString *internalService in services) { NSError *error = nil; if (![self.simulator.device setPrivacyAccessForService:internalService bundleID:bundleID granted:YES error:&error]) { return [FBFuture futureWithError:error]; } } } return FBFuture.empty; } + (NSDictionary<FBSettingsApprovalService, NSString *> *)tccDatabaseMapping { static dispatch_once_t onceToken; static NSDictionary<FBSettingsApprovalService, NSString *> *mapping; dispatch_once(&onceToken, ^{ mapping = @{ FBSettingsApprovalServiceContacts: @"kTCCServiceAddressBook", FBSettingsApprovalServicePhotos: @"kTCCServicePhotos", FBSettingsApprovalServiceCamera: @"kTCCServiceCamera", FBSettingsApprovalServiceMicrophone: @"kTCCServiceMicrophone", }; }); return mapping; } + (NSDictionary<FBSettingsApprovalService, NSString *> *)coreSimulatorSettingMappingPreIos13 { static dispatch_once_t onceToken; static NSDictionary<FBSettingsApprovalService, NSString *> *mapping; dispatch_once(&onceToken, ^{ mapping = @{ FBSettingsApprovalServiceContacts: @"kTCCServiceContactsFull", FBSettingsApprovalServicePhotos: @"kTCCServicePhotos", FBSettingsApprovalServiceCamera: @"camera", FBSettingsApprovalServiceLocation: @"__CoreLocationAlways", FBSettingsApprovalServiceMicrophone: @"kTCCServiceMicrophone", }; }); return mapping; } + (NSDictionary<FBSettingsApprovalService, NSString *> *)coreSimulatorSettingMappingPostIos13 { static dispatch_once_t onceToken; static NSDictionary<FBSettingsApprovalService, NSString *> *mapping; dispatch_once(&onceToken, ^{ mapping = @{ FBSettingsApprovalServicePhotos: @"kTCCServicePhotos", FBSettingsApprovalServiceLocation: @"__CoreLocationAlways", }; }); return mapping; } + (NSSet<NSString *> *)permissibleAddressBookDBFilenames { static dispatch_once_t onceToken; static NSSet<NSString *> *filenames; dispatch_once(&onceToken, ^{ filenames = [NSSet setWithArray:@[ @"AddressBook.sqlitedb", @"AddressBook.sqlitedb-shm", @"AddressBook.sqlitedb-wal", @"AddressBookImages.sqlitedb", @"AddressBookImages.sqlitedb-shm", @"AddressBookImages.sqlitedb-wal", ]]; }); return filenames; } + (NSSet<FBSettingsApprovalService> *)filteredTCCApprovals:(NSSet<FBSettingsApprovalService> *)approvals { NSMutableSet<FBSettingsApprovalService> *filtered = [NSMutableSet setWithSet:approvals]; [filtered intersectSet:[NSSet setWithArray:self.tccDatabaseMapping.allKeys]]; return [filtered copy]; } + (FBFuture<NSString *> *)buildRowsForDatabase:(NSString *)databasePath bundleIDs:(NSSet<NSString *> *)bundleIDs services:(NSSet<FBSettingsApprovalService> *)services queue:(dispatch_queue_t)queue logger:(id<FBControlCoreLogger>)logger { NSParameterAssert(bundleIDs.count >= 1); NSParameterAssert(services.count >= 1); return [[self runSqliteCommandOnDatabase:databasePath arguments:@[@".schema access"] queue:queue logger:logger] onQueue:queue map:^(NSString *result) { if ([result containsString:@"auth_value"]) { return [FBSimulatorSettingsCommands postiOS15ApprovalRowsForBundleIDs:bundleIDs services:services]; } else if ([result containsString:@"last_modified"]) { return [FBSimulatorSettingsCommands postiOS12ApprovalRowsForBundleIDs:bundleIDs services:services]; } else { return [FBSimulatorSettingsCommands preiOS12ApprovalRowsForBundleIDs:bundleIDs services:services]; } }]; } + (NSString *)preiOS12ApprovalRowsForBundleIDs:(NSSet<NSString *> *)bundleIDs services:(NSSet<FBSettingsApprovalService> *)services { NSMutableArray<NSString *> *tuples = [NSMutableArray array]; for (NSString *bundleID in bundleIDs) { for (FBSettingsApprovalService service in [self filteredTCCApprovals:services]) { NSString *serviceName = self.tccDatabaseMapping[service]; [tuples addObject:[NSString stringWithFormat:@"('%@', '%@', 0, 1, 0, 0, 0)", serviceName, bundleID]]; } } return [tuples componentsJoinedByString:@", "]; } + (NSString *)postiOS15ApprovalRowsForBundleIDs:(NSSet<NSString *> *)bundleIDs services:(NSSet<FBSettingsApprovalService> *)services { NSUInteger timestamp = (NSUInteger) NSDate.date.timeIntervalSince1970; NSMutableArray<NSString *> *tuples = [NSMutableArray array]; for (NSString *bundleID in bundleIDs) { for (FBSettingsApprovalService service in [self filteredTCCApprovals:services]) { NSString *serviceName = self.tccDatabaseMapping[service]; // The first 2 is for auth_value, 2 corresponds to "allowed" // The other two 2 and 2 that we set here correspond to auth_reason and auth_version // Both has to be 2 for AVCaptureDevice.authorizationStatus(... to return something different from notDetermined // It is also possible that in the future auth_version has to be bumped up to 3 and above with newer minor version of iOS [tuples addObject:[NSString stringWithFormat:@"('%@', '%@', 0, 2, 2, 2, NULL, NULL, NULL, 'UNUSED', NULL, NULL, %lu)", serviceName, bundleID, timestamp]]; } } return [tuples componentsJoinedByString:@", "]; } + (NSString *)postiOS12ApprovalRowsForBundleIDs:(NSSet<NSString *> *)bundleIDs services:(NSSet<FBSettingsApprovalService> *)services { NSUInteger timestamp = (NSUInteger) NSDate.date.timeIntervalSince1970; NSMutableArray<NSString *> *tuples = [NSMutableArray array]; for (NSString *bundleID in bundleIDs) { for (FBSettingsApprovalService service in [self filteredTCCApprovals:services]) { NSString *serviceName = self.tccDatabaseMapping[service]; [tuples addObject:[NSString stringWithFormat:@"('%@', '%@', 0, 1, 1, NULL, NULL, NULL, 'UNUSED', NULL, NULL, %lu)", serviceName, bundleID, timestamp]]; } } return [tuples componentsJoinedByString:@", "]; } + (FBFuture<NSString *> *)runSqliteCommandOnDatabase:(NSString *)databasePath arguments:(NSArray<NSString *> *)arguments queue:(dispatch_queue_t)queue logger:(id<FBControlCoreLogger>)logger { arguments = [@[databasePath] arrayByAddingObjectsFromArray:arguments]; [logger logFormat:@"Running sqlite3 %@", [FBCollectionInformation oneLineDescriptionFromArray:arguments]]; return [[[[[[FBProcessBuilder withLaunchPath:@"/usr/bin/sqlite3" arguments:arguments] withStdOutInMemoryAsString] withStdErrInMemoryAsString] withTaskLifecycleLoggingTo:logger] runUntilCompletionWithAcceptableExitCodes:[NSSet setWithArray:@[@0, @1]]] onQueue:queue fmap:^(FBProcess<NSNull *, NSString *, NSString *> *task) { if (![task.exitCode.result isEqualToNumber:@0]) { return [[FBSimulatorError describeFormat:@"Task did not exit 0: %@ %@ %@", task.exitCode.result, task.stdOut, task.stdErr] failFuture]; } if ([task.stdErr hasPrefix:@"Error"]) { return [[FBSimulatorError describeFormat:@"Failed to execute sqlite command: %@", task.stdErr] failFuture]; } return [FBFuture futureWithResult:task.stdOut]; }]; } + (NSArray<NSString *> *)contactsDatabaseFilePathsFromContainingDirectory:(NSString *)databaseDirectory error:(NSError **)error { NSMutableArray<NSString *> *filePaths = [NSMutableArray array]; NSDirectoryEnumerator *enumerator = [NSFileManager.defaultManager enumeratorAtPath:databaseDirectory]; NSSet<NSString *> *permissibleDatabaseFilepaths = FBSimulatorSettingsCommands.permissibleAddressBookDBFilenames; for (NSString *path in enumerator) { if (![permissibleDatabaseFilepaths containsObject:path.lastPathComponent]) { continue; } NSString *fullPath = [databaseDirectory stringByAppendingPathComponent:path]; [filePaths addObject:fullPath]; } // Fail if nothing is provided if (!filePaths.count) { return [[FBSimulatorError describe:@"Could not update Address Book DBs when no databases are provided"] fail:error]; } return [filePaths copy]; } @end