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