Files
appsandbox/tools/accessibility_host/accessibility_element.m
James Stringer b10c3de086 Add guest accessibility support to macOS VM viewers
Expose guest accessibility trees through a host AppKit helper and forward attributes, actions, text operations, and input through shared transport.

Package macOS and Windows guest collectors and integrate viewer lifecycle, refresh scheduling, and geometry handling.
2026-09-16 18:13:23 -04:00

213 lines
13 KiB
Objective-C

#import "accessibility_element.h"
#import "../../src/backend_mac/vm_accessibility_snapshot.h"
#import "../transport/accessibility_attributes_mac.h"
#import "../../tools/transport/accessibility_value_mac.h"
#import <os/log.h>
// String-based AX dispatch is deliberate: guest apps can advertise attributes
// and actions that do not have corresponding NSAccessibility protocol methods.
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Wdeprecated-implementations"
#pragma clang diagnostic ignored "-Wdeprecated-declarations"
@implementation VmAccessibilityElement
- (void)retire {
if (_retired) return;
_retired = YES;
NSAccessibilityPostNotification(self, NSAccessibilityUIElementDestroyedNotification);
}
- (void)dealloc {
if (!_retired) NSAccessibilityPostNotification(self, NSAccessibilityUIElementDestroyedNotification);
}
- (BOOL)isAccessibilityElement { return !self.retired && [self.owner isCurrentElement:self]; }
- (BOOL)accessibilityIsIgnored { return !self.isAccessibilityElement; }
- (BOOL)accessibilityNotifiesWhenDestroyed { return YES; }
- (NSString *)accessibilityRole {
NSString *role = self.record[@"role"];
// Source windows and sheets are embedded content; the viewer owns the host window.
if (self.container || [role isEqual:@"AXWindow"] || [role isEqual:@"AXSheet"] ||
[role isEqual:@"AXApplication"]) return NSAccessibilityGroupRole;
return role ?: NSAccessibilityStaticTextRole;
}
- (id)accessibilityValue {
if (self.record[@"attributeErrors"][@"AXValue"]) return nil;
id encoded = self.record[@"attributes"][@"AXValue"];
if ([self.record[@"secure"] boolValue]) {
id value = encoded ?: self.record[@"value"];
return AsbAXIsMaskedValue(value) ? value : nil;
}
id value = encoded ? [self decode:encoded geometry:YES] : self.record[@"value"];
return value == NSNull.null ? nil : value;
}
- (id)accessibilityParent { return [self.owner parentForElement:self]; }
- (id)accessibilityWindow { return self.owner.window; }
- (id)accessibilityTopLevelUIElement { return self.owner.window; }
- (NSArray *)accessibilityChildren {
NSDictionary *record = self.record;
if (record[@"attributeErrors"][@"AXChildren"]) return nil;
if (!self.isAccessibilityElement) return @[];
id encoded = record[@"attributes"][@"AXChildren"];
if (encoded) {
id children = [self decode:encoded geometry:NO];
return [children isKindOfClass:NSArray.class] ? children : nil;
}
return [self.owner childrenForElement:self];
}
- (NSRect)accessibilityFrame { return [self.owner frameForElement:self]; }
- (NSRect)accessibilityFrameInParentSpace {
NSRect frame = self.accessibilityFrame;
id parent = self.accessibilityParent;
if ([parent respondsToSelector:@selector(accessibilityFrame)]) {
NSRect parentFrame = [parent accessibilityFrame];
frame.origin.x -= parentFrame.origin.x;
frame.origin.y -= parentFrame.origin.y;
}
return frame;
}
- (BOOL)hasVisibleGuestContent { return !NSIsEmptyRect(self.accessibilityFrame); }
- (id)accessibilityFocusedUIElement { return [self.owner focusedGuestElement]; }
- (id)hitTestGuest:(NSPoint)point {
if (!self.isAccessibilityElement) return nil;
for (VmAccessibilityElement *child in [self.owner childrenForElement:self]) {
id hit = [child hitTestGuest:point];
if (hit) return hit;
}
return NSPointInRect(point, self.accessibilityFrame) ? self : nil;
}
- (id)accessibilityHitTest:(NSPoint)point { return [self hitTestGuest:point]; }
- (id)decode:(id)value geometry:(BOOL)geometry {
return AsbAXDecodeValue(value, NO, ^id(NSString *identifier) {
return [self.owner elementForIdentifier:identifier session:self.session];
}, geometry ? ^NSValue *(NSValue *v) { return [self.owner transformGeometry:v toGuest:NO]; } : nil, 0);
}
- (id)encode:(id)value geometry:(BOOL)geometry {
id wire = AsbAXEncodeValue(value, ^id(id candidate) {
if (![candidate isKindOfClass:VmAccessibilityElement.class]) return nil;
VmAccessibilityElement *element = candidate;
if (element.owner != self.owner || ![element.session isEqual:self.session]) return NSNull.null;
return @{@"$ax": @"element", @"value": element.nodeID};
}, 0);
if (!geometry || !wire) return wire;
id converted = AsbAXDecodeValue(wire, NO, ^id(NSString *identifier) {
return [self.owner elementForIdentifier:identifier session:self.session];
}, ^NSValue *(NSValue *v) { return [self.owner transformGeometry:v toGuest:YES]; }, 0);
return converted ? [self encode:converted geometry:NO] : nil;
}
- (NSArray *)accessibilityAttributeNames {
if (!self.isAccessibilityElement) return @[];
NSMutableOrderedSet *names = [NSMutableOrderedSet orderedSetWithArray:self.record[@"attributeNames"] ?: @[]];
if (!self.record[@"attributeNames"] || self.container || self.status)
[names addObjectsFromArray:@[@"AXRole", @"AXRoleDescription", @"AXParent", @"AXWindow", @"AXTopLevelUIElement",
@"AXChildren", @"AXPosition", @"AXSize", @"AXEnabled", @"AXFocused"]];
NSDictionary *keys = @{@"title": @"AXTitle", @"label": @"AXDescription", @"value": @"AXValue", @"subrole": @"AXSubrole", @"selectedTextRange": @"AXSelectedTextRange"};
for (NSString *key in keys) if (self.record[key]) [names addObject:keys[key]];
[names addObjectsFromArray:[self.record[@"attributes"] allKeys] ?: @[]];
if ([self.record[@"secure"] boolValue]) {
for (NSString *name in [names.array copy])
if ([name containsString:@"Text"] || [name containsString:@"Character"] ||
([name isEqual:@"AXValue"] && !self.accessibilityValue)) [names removeObject:name];
}
return names.array;
}
- (id)accessibilityAttributeValue:(NSString *)attribute {
if (!self.isAccessibilityElement) return nil;
if (self.record[@"attributeErrors"][attribute]) return nil;
if ([attribute isEqual:@"AXRole"]) return self.accessibilityRole;
if ([attribute isEqual:@"AXParent"]) return self.accessibilityParent;
if ([attribute isEqual:@"AXWindow"] || [attribute isEqual:@"AXTopLevelUIElement"]) return self.owner.window;
if ([attribute isEqual:@"AXChildren"]) return self.accessibilityChildren;
if ([attribute isEqual:@"AXPosition"]) return [NSValue valueWithPoint:self.accessibilityFrame.origin];
if ([attribute isEqual:@"AXSize"]) return [NSValue valueWithSize:self.accessibilityFrame.size];
if ([attribute isEqual:@"AXFrame"]) return [NSValue valueWithRect:self.accessibilityFrame];
if ([attribute isEqual:@"AXFocused"]) return @([self.owner focusedGuestElement] == self);
if ([attribute isEqual:@"AXEnabled"]) return @(!self.status && [self.record[@"enabled"] boolValue]);
if ([attribute isEqual:@"AXTitle"]) return self.record[@"title"];
if ([attribute isEqual:@"AXDescription"]) return self.record[@"label"];
if ([attribute isEqual:@"AXValue"]) return self.accessibilityValue;
if ([attribute isEqual:@"AXSubrole"]) return self.container ? nil : self.record[@"subrole"];
if ([self.record[@"secure"] boolValue] && ([attribute containsString:@"Text"] || [attribute containsString:@"Character"])) return nil;
if (self.record[@"attributes"][attribute]) {
id result = [self decode:self.record[@"attributes"][attribute] geometry:YES];
return result == NSNull.null ? nil : result;
}
if ([attribute isEqual:@"AXRoleDescription"]) return NSAccessibilityRoleDescription(self.accessibilityRole, self.record[@"subrole"]);
if ([attribute isEqual:@"AXSelectedTextRange"] && self.record[@"selectedTextRange"]) {
NSArray *range = self.record[@"selectedTextRange"];
return [NSValue valueWithRange:NSMakeRange([range[0] unsignedIntegerValue], [range[1] unsignedIntegerValue])];
}
if (AsbAXAttributeRequiresLiveQuery(attribute) && [self.record[@"attributeNames"] containsObject:attribute]) {
NSDictionary *response = [self.owner request:@{@"action": @"getAttribute", @"attribute": attribute} element:self];
id result = [response[@"ok"] boolValue] ? [self decode:response[@"value"] geometry:YES] : nil;
return result == NSNull.null ? nil : result;
}
return nil;
}
- (BOOL)accessibilityIsAttributeSettable:(NSString *)attribute {
if (!self.isAccessibilityElement || self.container || self.status) return NO;
if (self.record[@"settable"][attribute]) return [self.record[@"settable"][attribute] boolValue];
NSArray *writable = self.record[@"writableAttributes"];
if (writable) return [writable containsObject:attribute];
if ([attribute isEqual:@"AXValue"]) return [self.record[@"writableValue"] boolValue];
if ([attribute isEqual:@"AXFocused"]) return [self.record[@"writableFocused"] boolValue];
if ([attribute isEqual:@"AXSelectedTextRange"]) return [self.record[@"writableSelectedTextRange"] boolValue];
return NO;
}
- (void)accessibilitySetValue:(id)value forAttribute:(NSString *)attribute {
if (![self accessibilityIsAttributeSettable:attribute]) {
NSAccessibilityRaiseBadArgumentException(self, attribute, value);
return;
}
id wire = nil;
if ([attribute isEqual:@"AXPosition"] && [value isKindOfClass:NSValue.class] && !strcmp([value objCType], @encode(NSPoint))) {
NSRect frame = self.accessibilityFrame;
frame.origin = [value pointValue];
NSRect guest = [[self.owner transformGeometry:[NSValue valueWithRect:frame] toGuest:YES] rectValue];
wire = AsbAXEncodeValue([NSValue valueWithPoint:guest.origin], nil, 0);
} else wire = [self encode:value geometry:YES];
NSDictionary *response = wire ? [self.owner request:@{@"action": @"setAttribute", @"attribute": attribute, @"value": wire} element:self] : nil;
if (![response[@"ok"] boolValue]) NSAccessibilityRaiseBadArgumentException(self, attribute, value);
}
- (NSArray *)accessibilityActionNames {
if (self.record[@"actionNamesError"]) return nil;
return self.isAccessibilityElement ? (self.record[@"actions"] ?: @[]) : @[];
}
- (NSString *)accessibilityActionDescription:(NSString *)action {
return self.record[@"actionDescriptions"][action] ?: NSAccessibilityActionDescription(action);
}
- (void)accessibilityPerformAction:(NSString *)action {
if (![[self accessibilityActionNames] containsObject:action] || ![self.owner performAction:action value:nil element:self])
NSAccessibilityRaiseBadArgumentException(self, action, nil);
}
- (NSArray *)accessibilityParameterizedAttributeNames {
return self.isAccessibilityElement && ![self.record[@"secure"] boolValue] ? (self.record[@"parameterizedNames"] ?: @[]) : @[];
}
- (id)accessibilityAttributeValue:(NSString *)attribute forParameter:(id)parameter {
if (![[self accessibilityParameterizedAttributeNames] containsObject:attribute]) return nil;
BOOL inputGeometry = ![attribute isEqual:@"AXScreenPointForLayoutPoint"] && ![attribute isEqual:@"AXScreenSizeForLayoutSize"];
BOOL outputGeometry = ![attribute isEqual:@"AXLayoutPointForScreenPoint"] && ![attribute isEqual:@"AXLayoutSizeForScreenSize"];
id wire = [self encode:parameter geometry:inputGeometry];
if (getenv("ASB_AX_TRACE_REQUESTS"))
os_log(OS_LOG_DEFAULT, "[AX parameter] node=%{public}@ attribute=%{public}@ range=%{public}@", self.nodeID, attribute,
[wire isKindOfClass:NSDictionary.class] && [wire[@"$ax"] isEqual:@"range"] ? wire[@"value"] : @"non-range");
NSDictionary *response = wire ? [self.owner request:@{@"action": @"getParameterizedAttribute", @"attribute": attribute, @"value": wire} element:self] : nil;
id result = [response[@"ok"] boolValue] ? [self decode:response[@"value"] geometry:outputGeometry] : nil;
return result == NSNull.null ? nil : result;
}
- (NSUInteger)accessibilityArrayAttributeCount:(NSString *)attribute {
id value = [self accessibilityAttributeValue:attribute];
return [value isKindOfClass:NSArray.class] ? [value count] : 0;
}
- (NSArray *)accessibilityArrayAttributeValues:(NSString *)attribute index:(NSUInteger)index maxCount:(NSUInteger)maximum {
id value = [self accessibilityAttributeValue:attribute];
if (![value isKindOfClass:NSArray.class]) return nil;
if (getenv("ASB_AX_TRACE_REQUESTS"))
os_log(OS_LOG_DEFAULT, "[AX array] node=%{public}@ attribute=%{public}@ index=%lu requested=%lu total=%lu", self.nodeID, attribute,
(unsigned long)index, (unsigned long)maximum, (unsigned long)[value count]);
if (index >= [value count]) return @[];
return [value subarrayWithRange:NSMakeRange(index, MIN(maximum, [value count] - index))];
}
- (NSUInteger)accessibilityIndexOfChild:(id)child { return [self.accessibilityChildren indexOfObjectIdenticalTo:child]; }
@end
#pragma clang diagnostic pop