FBSimulatorControl/Commands/FBSimulatorAccessibilityCommands.m (318 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 "FBSimulatorAccessibilityCommands.h" #import <objc/runtime.h> #import <CoreSimulator/SimDevice.h> #import <AccessibilityPlatformTranslation/AXPTranslator.h> #import <AccessibilityPlatformTranslation/AXPTranslationObject.h> #import <AccessibilityPlatformTranslation/AXPTranslatorResponse.h> #import <AccessibilityPlatformTranslation/AXPTranslatorRequest.h> #import <AccessibilityPlatformTranslation/AXPMacPlatformElement.h> #import "FBSimulator.h" #import "FBSimulatorBridge.h" // // # About the implementation of Accessibility within CoreSimulator // // In Xcode 12, using the SimulatorBridge for accessibility is now gone. // Instead, this functionality is bridged via CoreSimulator. However, there are more mechanisms in play than just calling a function. // The Private Framework AccessibilityPlatformTranslation, is used by Simulator.app via SimulatorKit. // In Simulator.app, it uses NSView semantics for obtaining information about a Simulator, in the case of FBSimulatorControl we aren't necessarily view-backed. // As a result we are using a reverse-engineered implementation of how SimulatorKit functions, based on inputs to this API. // // For this to work the process is as follows: // - The AXPTranslator is used to do all of the wiring for providing high-level objects that can be interrogated. // - To do this AXPTranslator uses delegation for performing the underlying accessibility request. // - The delegation can be tokenized (optionally) // - The requests are implemented by bridging to CoreSimulator. This is essentially the glue between high-level Accessibility APIs and CoreSimulator's implementation of them. // - CoreSimulator doesn't actually implement the Accessibility fetches itself. Instead it calls out to an XPC service that is running inside the Simulator. // - CoreSimulator's API for doing this fetch is Asynchronous, but AXPTranslator's delegation & fetching is not. To smooth over the gaps we have to wait on the result. // - The reason for non-async APIs here is that AXMacPlatformElement has lazy property access; over time each of the values that are referenced will be filled out with this delegation. // - The lazy property access can be seen in the logging here, where the AXPTranslatorRequest has a nice description of the object. // - Additional methods are required in the delegation, depending on whether there needs to be additional transformation, as is in the case with translating co-ordinate systems. // - We smooth over the differences in the values returned in the legacy API by replicating the values returned by the SimulatorBridge, calling the appropriate methods on AXMacPlatformElement. // - To get an idea of what methods are usable, take a look as NSAccessibilityElement which is a supertype of AXMacPlatformElement. // - The tokenized method appears to be the more recent one. The token isn't significant for us so in this case we can just pass a meaningless token that will be received from all delegate callbacks.s // // All of the above could be implemented without the delegation system. However, this requires dumping large enums and going much lower in the protocol level. // Instead having the higher level object, liberated from SimulatorKit (and therefore views) is the best compromise and the lightest touch. // // The only exception here is the usage of -[NSAccessibility accessibilityParent] which calls a delegate method with an unknown implementation. // Since all values are enumerated recursively downwards, this is fine for the time being. // // We must also remember to set the `bridgeDelegateToken` on all created `AXPTranslationObject`s. // This applies to those created by us when the `AXPTranslationObject` as well`AXPMacPlatformElement`'s that are created inside `AccessibilityPlatformTranslation` // This is needed so that we know which Simulator the request belongs to, since the Translator is a singleton object, we need to be able to de-duplicate here. // static NSString *const DummyBridgeToken = @"FBSimulatorAccessibilityCommandsDummyBridgeToken"; @interface FBSimulatorAccessibilityCommands_SimulatorBridge : NSObject <FBAccessibilityOperations> @property (nonatomic, strong, readonly) FBSimulatorBridge *bridge; @end @implementation FBSimulatorAccessibilityCommands_SimulatorBridge - (instancetype)initWithBridge:(FBSimulatorBridge *)bridge { self = [super init]; if (!self) { return nil; } _bridge = bridge; return self; } #pragma mark FBSimulatorAccessibilityCommands Implementation - (FBFuture<NSArray<NSDictionary<NSString *, id> *> *> *)accessibilityElementsWithNestedFormat:(BOOL)nestedFormat { if (nestedFormat) { return [[FBControlCoreError describe:@"Nested Format is not supported for SimulatorBridge based accessibility"] failFuture]; } return [self.bridge accessibilityElements]; } - (FBFuture<NSDictionary<NSString *, id> *> *)accessibilityElementAtPoint:(CGPoint)point nestedFormat:(BOOL)nestedFormat { if (nestedFormat) { return [[FBControlCoreError describe:@"Nested Format is not supported for SimulatorBridge based accessibility"] failFuture]; } return [self.bridge accessibilityElementAtPoint:point]; } @end @interface FBSimulator_TranslationDispatcher : NSObject <AXPTranslationTokenDelegateHelper> @property (nonatomic, weak, readonly) AXPTranslator *translator; @property (nonatomic, strong, readonly) id<FBControlCoreLogger> logger; @property (nonatomic, strong, readonly) dispatch_queue_t callbackQueue; @property (nonatomic, strong, readonly) NSMapTable<NSString *, FBSimulator *> *tokenToSimulator; @end @implementation FBSimulator_TranslationDispatcher #pragma mark Initializers - (instancetype)initWithTranslator:(AXPTranslator *)translator logger:(id<FBControlCoreLogger>)logger { self = [super init]; if (!self) { return nil; } _translator = translator; _logger = logger; _callbackQueue = dispatch_queue_create("com.facebook.fbsimulatorcontrol.accessibility_translator.callback", DISPATCH_QUEUE_SERIAL); _tokenToSimulator = [NSMapTable mapTableWithKeyOptions:NSPointerFunctionsCopyIn valueOptions:NSPointerFunctionsWeakMemory]; return self; } + (instancetype)sharedInstance { static dispatch_once_t onceToken; static FBSimulator_TranslationDispatcher *dispatcher; dispatch_once(&onceToken, ^{ AXPTranslator *translator = [objc_getClass("AXPTranslator") sharedInstance]; // bridgeTokenDelegate is preferred by AXPTranslator. dispatcher = [[FBSimulator_TranslationDispatcher alloc] initWithTranslator:translator logger:nil]; translator.bridgeTokenDelegate = dispatcher; }); return dispatcher; } #pragma mark Public - (FBFuture<NSArray<NSDictionary<NSString *, id> *> *> *)frontmostApplicationForSimulator:(FBSimulator *)simulator displayId:(unsigned int)displayId nestedFormat:(BOOL)nestedFormat { return [FBFuture onQueue:simulator.workQueue resolveValue:^(NSError **error) { NSString *token = [self pushSimulator:simulator]; AXPTranslationObject *translation = [self.translator frontmostApplicationWithDisplayId:displayId bridgeDelegateToken:token]; translation.bridgeDelegateToken = token; AXPMacPlatformElement *element = [self.translator macPlatformElementFromTranslation:translation]; element.translation.bridgeDelegateToken = token; NSArray<NSDictionary<NSString *, id> *> *formatted = [self.class recursiveDescriptionFromElement:element token:token nestedFormat:nestedFormat]; [self popSimulator:token]; return formatted; }]; } - (FBFuture<NSDictionary<NSString *, id> *> *)objectAtPointForSimulator:(FBSimulator *)simulator displayId:(unsigned int)displayId atPoint:(CGPoint)point nestedFormat:(BOOL)nestedFormat { return [FBFuture onQueue:simulator.workQueue resolveValue:^(NSError **error) { NSString *token = [self pushSimulator:simulator]; AXPTranslationObject *translation = [self.translator objectAtPoint:point displayId:displayId bridgeDelegateToken:token]; translation.bridgeDelegateToken = token; AXPMacPlatformElement *element = [self.translator macPlatformElementFromTranslation:translation]; element.translation.bridgeDelegateToken = token; NSDictionary<NSString *, id> *formatted = [self.class formattedDescriptionOfElement:element token:token nestedFormat:nestedFormat]; [self popSimulator:token]; return formatted; }]; } #pragma mark Private - (NSString *)pushSimulator:(FBSimulator *)simulator { NSString *token = NSUUID.UUID.UUIDString; NSParameterAssert([self.tokenToSimulator objectForKey:token] == nil); [self.tokenToSimulator setObject:simulator forKey:token]; [self.logger logFormat:@"Simulator %@ backed by token %@", simulator, token]; return token; } - (FBSimulator *)popSimulator:(NSString *)token { FBSimulator *simulator = [self.tokenToSimulator objectForKey:token]; NSParameterAssert(simulator); [self.tokenToSimulator removeObjectForKey:token]; [self.logger logFormat:@"Removing token %@", token]; return simulator; } // Since we're using an async callback-based function in CoreSimulator this needs to be converted to a synchronous variant for the AXTranslator callbacks. // In order to do this we have a dispatch group acting as a mutex. // This also means that the queue that this happens on should **never be the main queue**. An async global queue will suffice here. - (AXPTranslationCallback)translationCallbackForToken:(NSString *)token { FBSimulator *simulator = [self.tokenToSimulator objectForKey:token]; if (!simulator) { return ^ AXPTranslatorResponse * (AXPTranslatorRequest *request) { [self.logger logFormat:@"Simlator with token %@ is gone for request %@. Returning empty response", token, request]; return [objc_getClass("AXPTranslatorResponse") emptyResponse]; }; } return ^ AXPTranslatorResponse * (AXPTranslatorRequest *request){ [simulator.logger logFormat:@"Sending Accessibility Request %@", request]; dispatch_group_t group = dispatch_group_create(); dispatch_group_enter(group); __block AXPTranslatorResponse *response = nil; [simulator.device sendAccessibilityRequestAsync:request completionQueue:self.callbackQueue completionHandler:^(AXPTranslatorResponse *innerResponse) { response = innerResponse; dispatch_group_leave(group); }]; dispatch_group_wait(group, DISPATCH_TIME_FOREVER); [simulator.logger logFormat:@"Got Accessibility Response %@", response]; return response; }; } static NSString *const AXPrefix = @"AX"; + (NSArray<NSDictionary<NSString *, id> *> *)recursiveDescriptionFromElement:(AXPMacPlatformElement *)element token:(NSString *)token nestedFormat:(BOOL)nestedFormat { element.translation.bridgeDelegateToken = token; if (nestedFormat) { return @[[self.class nestedRecursiveDescriptionFromElement:element token:token]]; } return [self.class flatRecursiveDescriptionFromElement:element token:token]; } + (NSDictionary<NSString *, id> *)formattedDescriptionOfElement:(AXPMacPlatformElement *)element token:(NSString *)token nestedFormat:(BOOL)nestedFormat { element.translation.bridgeDelegateToken = token; if (nestedFormat) { return [self.class nestedRecursiveDescriptionFromElement:element token:token]; } return [self.class accessibilityDictionaryForElement:element token:token]; } // The values here are intended to mirror the values in the old SimulatorBridge implementation for compatibility downstream. + (NSDictionary<NSString *, id> *)accessibilityDictionaryForElement:(AXPMacPlatformElement *)element token:(NSString *)token { // The token must always be set so that the right callback is called element.translation.bridgeDelegateToken = token; NSRect frame = element.accessibilityFrame; // The value returned in accessibilityRole is may be prefixed with "AX". // If that's the case, then let's strip it to make it like the SimulatorBridge implementation. NSString *role = element.accessibilityRole; if ([role hasPrefix:AXPrefix]) { role = [role substringFromIndex:2]; } return @{ // These values are the "legacy" values that mirror their equivalents in SimulatorBridge @"AXLabel": element.accessibilityLabel ?: NSNull.null, @"AXFrame": NSStringFromRect(frame), @"AXValue": element.accessibilityValue ?: NSNull.null, @"AXUniqueId": element.accessibilityIdentifier ?: NSNull.null, // There are additional synthetic values from the old output. @"type": role ?: NSNull.null, // These are new values in this output @"title": element.accessibilityTitle ?: NSNull.null, @"frame": @{ @"x": @(frame.origin.x), @"y": @(frame.origin.y), @"width": @(frame.size.width), @"height": @(frame.size.height), }, @"help": element.accessibilityHelp ?: NSNull.null, @"enabled": @(element.accessibilityEnabled), @"custom_actions": [element.accessibilityCustomActions valueForKey:@"name"] ?: @[], @"role": element.accessibilityRole ?: NSNull.null, @"role_description": element.accessibilityRoleDescription ?: NSNull.null, @"subrole": element.accessibilitySubrole ?: NSNull.null, @"content_required": @(element.accessibilityRequired), }; } // This replicates the non-heirarchical system that was previously present in SimulatorBridge. // In this case the values of frames must be relative to the root, rather than the parent frame. + (NSArray<NSDictionary<NSString *, id> *> *)flatRecursiveDescriptionFromElement:(AXPMacPlatformElement *)element token:(NSString *)token { NSMutableArray<NSDictionary<NSString *, id> *> *values = NSMutableArray.array; [values addObject:[self accessibilityDictionaryForElement:element token:token]]; for (AXPMacPlatformElement *childElement in element.accessibilityChildren) { childElement.translation.bridgeDelegateToken = token; NSArray<NSDictionary<NSString *, id> *> *childValues = [self flatRecursiveDescriptionFromElement:childElement token:token]; [values addObjectsFromArray:childValues]; } return values; } + (NSDictionary<NSString *, id> *)nestedRecursiveDescriptionFromElement:(AXPMacPlatformElement *)element token:(NSString *)token { NSMutableDictionary<NSString *, id> *values = [[self accessibilityDictionaryForElement:element token:token] mutableCopy]; NSMutableArray<NSDictionary<NSString *, id> *> *childrenValues = NSMutableArray.array; for (AXPMacPlatformElement *childElement in element.accessibilityChildren) { childElement.translation.bridgeDelegateToken = token; NSDictionary<NSString *, id> *childValues = [self nestedRecursiveDescriptionFromElement:childElement token:token]; [childrenValues addObject:childValues]; } values[@"children"] = childrenValues; return values; } #pragma mark AXPTranslationTokenDelegateHelper - (AXPTranslationCallback)accessibilityTranslationDelegateBridgeCallbackWithToken:(NSString *)token { return [self translationCallbackForToken:token]; } - (CGRect)accessibilityTranslationConvertPlatformFrameToSystem:(CGRect)rect withToken:(NSString *)token { return rect; } - (id)accessibilityTranslationRootParentWithToken:(NSString *)token { [self.logger logFormat:@"Delegate method '%@', with unknown implementation called with token %@. Returning nil.", NSStringFromSelector(_cmd), token]; return nil; } @end @interface FBSimulatorAccessibilityCommands_CoreSimulator : NSObject <FBAccessibilityOperations> @property (nonatomic, weak, readonly) FBSimulator *simulator; @property (nonatomic, strong, readonly) dispatch_queue_t queue; @property (nonatomic, strong, readonly) id<FBControlCoreLogger> logger; @end @implementation FBSimulatorAccessibilityCommands_CoreSimulator - (instancetype)initWithSimulator:(FBSimulator *)simulator queue:(dispatch_queue_t)queue logger:(id<FBControlCoreLogger>)logger { self = [super init]; if (!self) { return nil; } _simulator = simulator; _queue = queue; _logger = logger; return self; } #pragma mark FBSimulatorAccessibilityCommands Implementation - (FBFuture<NSArray<NSDictionary<NSString *, id> *> *> *)accessibilityElementsWithNestedFormat:(BOOL)nestedFormat { return [FBSimulator_TranslationDispatcher.sharedInstance frontmostApplicationForSimulator:self.simulator displayId:0 nestedFormat:nestedFormat]; } - (FBFuture<NSDictionary<NSString *, id> *> *)accessibilityElementAtPoint:(CGPoint)point nestedFormat:(BOOL)nestedFormat { return [FBSimulator_TranslationDispatcher.sharedInstance objectAtPointForSimulator:self.simulator displayId:0 atPoint:point nestedFormat:nestedFormat]; } @end @interface FBSimulatorAccessibilityCommands () @property (nonatomic, weak, readonly) FBSimulator *simulator; @end @implementation FBSimulatorAccessibilityCommands #pragma mark Initializers + (instancetype)commandsWithTarget:(FBSimulator *)targets { return [[self alloc] initWithSimulator:targets]; } - (instancetype)initWithSimulator:(FBSimulator *)simulator { self = [super init]; if (!self) { return nil; } _simulator = simulator; return self; } #pragma mark FBSimulatorAccessibilityCommands Protocol Implementation - (FBFuture<NSArray<NSDictionary<NSString *, id> *> *> *)accessibilityElementsWithNestedFormat:(BOOL)nestedFormat { return [[self implementationWithNestedFormat:nestedFormat] onQueue:self.simulator.asyncQueue fmap:^(id<FBAccessibilityOperations> implementation) { return [implementation accessibilityElementsWithNestedFormat:nestedFormat]; }]; } - (FBFuture<NSDictionary<NSString *, id> *> *)accessibilityElementAtPoint:(CGPoint)point nestedFormat:(BOOL)nestedFormat { return [[self implementationWithNestedFormat:nestedFormat] onQueue:self.simulator.asyncQueue fmap:^(id<FBAccessibilityOperations> implementation) { return [implementation accessibilityElementAtPoint:point nestedFormat:nestedFormat]; }]; } #pragma mark Private - (FBFuture<id<FBAccessibilityOperations>> *)implementationWithNestedFormat:(BOOL)nestedFormat { // Post Xcode 12, FBSimulatorBridge will not work with accessibility. // Additionally, CoreSimulator **should** be upgraded, but if it hasn't then this will fail. // The CoreSimulator API **is** backwards compatible, since it updates CoreSimulator.framework at the system level. // However, this API is only usable from CoreSimulator if Xcode 12 has been *installed at some point in the past on the host*. FBSimulator *simulator = self.simulator; if (simulator.state != FBiOSTargetStateBooted) { return [[FBControlCoreError describeFormat:@"Cannot run accessibility commands against %@ as it is not booted", simulator] failFuture]; } SimDevice *device = simulator.device; if (nestedFormat || FBXcodeConfiguration.isXcode12OrGreater) { if (![device respondsToSelector:@selector(sendAccessibilityRequestAsync:completionQueue:completionHandler:)]) { return [[FBControlCoreError describeFormat:@"-[SimDevice %@] is not present on this host, you must install and/or use Xcode 12 to use the nested accessibility format.", NSStringFromSelector(@selector(sendAccessibilityRequestAsync:completionQueue:completionHandler:))] failFuture]; } return [FBFuture futureWithResult:[[FBSimulatorAccessibilityCommands_CoreSimulator alloc] initWithSimulator:simulator queue:simulator.asyncQueue logger:simulator.logger]]; } return [[self.simulator connectToBridge] onQueue:self.simulator.asyncQueue map:^(FBSimulatorBridge *bridge) { return [[FBSimulatorAccessibilityCommands_SimulatorBridge alloc] initWithBridge:bridge]; }]; } @end