fix: harden accessibility hierarchy introspection

Seto Elkahfi committed Aug 25, 2026 at 10:23 UTC 63d41fa6227c3b2b610faa85244e9a6028799cba
4 files changed +61 -68
Runners/Shared/ControlKitRPCServer.swift
+5 -11
@@ -108,14 +108,8 @@ final class ControlKitRPCServer {
108 case "device.dump.ui":
109 let bundleIdentifier = try stringParameter("bundleId", request.params)
110 let application = XCUIApplication(bundleIdentifier: bundleIdentifier)
111 - guard application.state != .notRunning else {
112 - throw RunnerError.applicationNotRunning(bundleIdentifier)
113 - }
114 - if request.params["format"] as? String == "debug" {
115 - return try response(
116 - result: ["description": application.debugDescription],
117 - id: request.id
118 - )
111 + guard application.state == .runningForeground else {
112 + throw RunnerError.applicationNotInForeground(bundleIdentifier)
113 }
114 let hierarchy = try accessibilityHierarchy(application)
115 return try response(result: dictionary(hierarchy), id: request.id)
@@ -334,7 +328,7 @@ private final class RPCHandler: ChannelInboundHandler {
328 }
329
330 private enum RunnerError: LocalizedError {
337 - case applicationNotRunning(String)
331 + case applicationNotInForeground(String)
332 case invalidAccessibilityHierarchy
333 case invalidParameter(String)
334 case unsupportedButton(String)
@@ -342,8 +336,8 @@ private enum RunnerError: LocalizedError {
336
337 var errorDescription: String? {
338 switch self {
345 - case .applicationNotRunning(let bundleIdentifier):
346 - return "Application is not running: \(bundleIdentifier)"
339 + case .applicationNotInForeground(let bundleIdentifier):
340 + return "Application is not running in the foreground: \(bundleIdentifier)"
341 case .invalidAccessibilityHierarchy:
342 return "Could not serialize the accessibility hierarchy"
343 case .invalidParameter(let name):
Runners/XCRSControlKitRunner.xcodeproj/project.pbxproj
-26
@@ -718,9 +718,6 @@
718 buildSettings = {
719 ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
720 CODE_SIGN_IDENTITY = "iPhone Developer";
721 - CODE_SIGN_STYLE = Manual;
722 - DEVELOPMENT_TEAM = "";
723 - "DEVELOPMENT_TEAM[sdk=iphoneos*]" = L83LZMRLR7;
721 INFOPLIST_KEY_UILaunchScreen_Generation = YES;
722 INFOPLIST_KEY_UIRequiresFullScreen = YES;
723 INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait;
@@ -730,8 +727,6 @@
727 );
728 PRODUCT_BUNDLE_IDENTIFIER = "com.smbcloud.xcrs-controlkit.ios.runner";
729 PRODUCT_NAME = ControlKit;
733 - PROVISIONING_PROFILE_SPECIFIER = "";
734 - "PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "Test Runner";
730 SDKROOT = iphoneos;
731 TARGETED_DEVICE_FAMILY = "1,2";
732 };
@@ -759,9 +754,6 @@
754 buildSettings = {
755 ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
756 CODE_SIGN_IDENTITY = "iPhone Developer";
762 - CODE_SIGN_STYLE = Manual;
763 - DEVELOPMENT_TEAM = "";
764 - "DEVELOPMENT_TEAM[sdk=iphoneos*]" = L83LZMRLR7;
757 INFOPLIST_KEY_UILaunchScreen_Generation = YES;
758 INFOPLIST_KEY_UIRequiresFullScreen = YES;
759 INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait;
@@ -771,8 +763,6 @@
763 );
764 PRODUCT_BUNDLE_IDENTIFIER = "com.smbcloud.xcrs-controlkit.ios.runner";
765 PRODUCT_NAME = ControlKit;
774 - PROVISIONING_PROFILE_SPECIFIER = "";
775 - "PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "Test Runner";
766 SDKROOT = iphoneos;
767 TARGETED_DEVICE_FAMILY = "1,2";
768 };
@@ -952,16 +942,12 @@
942 isa = XCBuildConfiguration;
943 buildSettings = {
944 BUNDLE_LOADER = "$(TEST_HOST)";
955 - "CODE_SIGN_IDENTITY[sdk=appletvos*]" = "Apple Development";
956 - CODE_SIGN_STYLE = Manual;
957 - DEVELOPMENT_TEAM = L83LZMRLR7;
945 LD_RUNPATH_SEARCH_PATHS = (
946 "$(inherited)",
947 "@executable_path/Frameworks",
948 "@loader_path/Frameworks",
949 );
950 PRODUCT_BUNDLE_IDENTIFIER = "com.smbcloud.xcrs-controlkit.tvos.runnerUITests";
964 - PROVISIONING_PROFILE_SPECIFIER = "Test Runner TV";
951 SDKROOT = appletvos;
952 TARGETED_DEVICE_FAMILY = 3;
953 TEST_TARGET_NAME = "ControlKit-tvOS";
@@ -972,16 +958,12 @@
958 isa = XCBuildConfiguration;
959 buildSettings = {
960 BUNDLE_LOADER = "$(TEST_HOST)";
975 - "CODE_SIGN_IDENTITY[sdk=appletvos*]" = "Apple Development";
976 - CODE_SIGN_STYLE = Manual;
977 - DEVELOPMENT_TEAM = L83LZMRLR7;
961 LD_RUNPATH_SEARCH_PATHS = (
962 "$(inherited)",
963 "@executable_path/Frameworks",
964 "@loader_path/Frameworks",
965 );
966 PRODUCT_BUNDLE_IDENTIFIER = "com.smbcloud.xcrs-controlkit.tvos.runnerUITests";
984 - PROVISIONING_PROFILE_SPECIFIER = "Test Runner TV";
967 SDKROOT = appletvos;
968 TARGETED_DEVICE_FAMILY = 3;
969 TEST_TARGET_NAME = "ControlKit-tvOS";
@@ -1009,16 +991,12 @@
991 buildSettings = {
992 ASSETCATALOG_COMPILER_APPICON_NAME = "App Icon & Top Shelf Image";
993 ASSETCATALOG_COMPILER_LAUNCHIMAGE_NAME = LaunchImage;
1012 - "CODE_SIGN_IDENTITY[sdk=appletvos*]" = "Apple Development";
1013 - CODE_SIGN_STYLE = Manual;
1014 - DEVELOPMENT_TEAM = L83LZMRLR7;
994 LD_RUNPATH_SEARCH_PATHS = (
995 "$(inherited)",
996 "@executable_path/Frameworks",
997 );
998 PRODUCT_BUNDLE_IDENTIFIER = "com.smbcloud.xcrs-controlkit.tvos.runner";
999 PRODUCT_NAME = ControlKit;
1021 - PROVISIONING_PROFILE_SPECIFIER = "Test Runner TV";
1000 SDKROOT = appletvos;
1001 TARGETED_DEVICE_FAMILY = 3;
1002 };
@@ -1070,16 +1048,12 @@
1048 buildSettings = {
1049 ASSETCATALOG_COMPILER_APPICON_NAME = "App Icon & Top Shelf Image";
1050 ASSETCATALOG_COMPILER_LAUNCHIMAGE_NAME = LaunchImage;
1073 - "CODE_SIGN_IDENTITY[sdk=appletvos*]" = "Apple Development";
1074 - CODE_SIGN_STYLE = Manual;
1075 - DEVELOPMENT_TEAM = L83LZMRLR7;
1051 LD_RUNPATH_SEARCH_PATHS = (
1052 "$(inherited)",
1053 "@executable_path/Frameworks",
1054 );
1055 PRODUCT_BUNDLE_IDENTIFIER = "com.smbcloud.xcrs-controlkit.tvos.runner";
1056 PRODUCT_NAME = ControlKit;
1082 - PROVISIONING_PROFILE_SPECIFIER = "Test Runner TV";
1057 SDKROOT = appletvos;
1058 TARGETED_DEVICE_FAMILY = 3;
1059 };
Sources/XCRSControlKit/XCRSControlKit.swift
+33 -30
@@ -177,13 +177,9 @@ public struct XCRSControlKitAccessibilityNode: Codable, Equatable, Sendable {
177 public enum XCRSControlKitAccessibilityParser {
178 private struct ParsedNode {
179 let node: XCRSControlKitAccessibilityNode
180 - let depth: Int
180 + let indentation: Int
181 }
182
183 - private static let rectangleExpression = try? NSRegularExpression(
184 - pattern: #"\{\{(-?\d+(?:\.\d+)?),\s*(-?\d+(?:\.\d+)?)\},\s*\{(-?\d+(?:\.\d+)?),\s*(-?\d+(?:\.\d+)?)\}\}"#
185 - )
186 -
183 public static func parse(
184 debugDescription: String
185 ) throws -> XCRSControlKitAccessibilityNode {
@@ -206,7 +202,13 @@ public enum XCRSControlKitAccessibilityParser {
202 }
203
204 var index = 0
209 - return tree(from: nodes, at: &index)
205 + let root = tree(from: nodes, at: &index, depth: 0)
206 + guard index == nodes.endIndex else {
207 + throw XCRSControlKitError.invalidRequest(
208 + "XCTest returned multiple root accessibility elements"
209 + )
210 + }
211 + return root
212 }
213
214 private static func parseLine(
@@ -229,7 +231,6 @@ public enum XCRSControlKitAccessibilityParser {
231 width: 0,
232 height: 0
233 )
232 - let depth = indentation / 2
234 return ParsedNode(
235 node: XCRSControlKitAccessibilityNode(
236 type: type,
@@ -239,25 +240,25 @@ public enum XCRSControlKitAccessibilityParser {
240 placeholderValue: quotedAttribute("placeholderValue", in: content),
241 rawIdentifier: identifier,
242 rect: rect,
242 - depth: depth,
243 enabled: !hasStandaloneAttribute("Disabled", in: content),
244 selected: hasStandaloneAttribute("Selected", in: content),
245 focused: hasStandaloneAttribute("Focused", in: content)
246 ),
247 - depth: depth
247 + indentation: indentation
248 )
249 }
250
251 private static func tree(
252 from nodes: [ParsedNode],
253 - at index: inout Int
253 + at index: inout Int,
254 + depth: Int
255 ) -> XCRSControlKitAccessibilityNode {
256 let parsedNode = nodes[index]
257 index += 1
258
259 var children: [XCRSControlKitAccessibilityNode] = []
259 - while index < nodes.count, nodes[index].depth > parsedNode.depth {
260 - children.append(tree(from: nodes, at: &index))
260 + while index < nodes.count, nodes[index].indentation > parsedNode.indentation {
261 + children.append(tree(from: nodes, at: &index, depth: depth + 1))
262 }
263
264 let node = parsedNode.node
@@ -269,7 +270,7 @@ public enum XCRSControlKitAccessibilityParser {
270 placeholderValue: node.placeholderValue,
271 rawIdentifier: node.rawIdentifier,
272 rect: node.rect,
272 - depth: node.depth,
273 + depth: depth,
274 enabled: node.enabled,
275 selected: node.selected,
276 focused: node.focused,
@@ -280,30 +281,32 @@ public enum XCRSControlKitAccessibilityParser {
281
282 private static func rectangle(in line: String) -> XCRSControlKitRect? {
283 guard
283 - let expression = rectangleExpression,
284 - let match = expression.firstMatch(
285 - in: line,
286 - range: NSRange(line.startIndex..., in: line)
287 - ),
288 - match.numberOfRanges == 5
284 + let rectangleStart = line.range(of: "{{")?.upperBound,
285 + let rectangleEnd = line[rectangleStart...].range(of: "}}")?.lowerBound
286 else {
287 return nil
288 }
289
293 - let values = (1..<5).compactMap { index -> Double? in
294 - guard let range = Range(match.range(at: index), in: line) else {
295 - return nil
296 - }
297 - return Double(line[range])
298 - }
299 - guard values.count == 4 else {
290 + let components = line[rectangleStart..<rectangleEnd]
291 + .split(whereSeparator: { character in
292 + character == "{" || character == "}" || character == ","
293 + })
294 + .map { $0.trimmingCharacters(in: .whitespaces) }
295 + .filter { !$0.isEmpty }
296 + guard
297 + components.count == 4,
298 + let x = Double(components[0]),
299 + let y = Double(components[1]),
300 + let width = Double(components[2]),
301 + let height = Double(components[3])
302 + else {
303 return nil
304 }
305 return XCRSControlKitRect(
303 - x: values[0],
304 - y: values[1],
305 - width: values[2],
306 - height: values[3]
306 + x: x,
307 + y: y,
308 + width: width,
309 + height: height
310 )
311 }
312
Tests/XCRSControlKitTests/XCRSControlKitTests.swift
+23 -1
@@ -93,7 +93,9 @@ final class XCRSControlKitTests: XCTestCase {
93 XCTAssertEqual(button.label, "Hem")
94 XCTAssertEqual(button.rawIdentifier, "Start")
95 XCTAssertEqual(button.rect.x, 576)
96 - XCTAssertGreaterThan(button.depth ?? 0, root.depth ?? 0)
96 + XCTAssertEqual(root.depth, 0)
97 + XCTAssertEqual(window.depth, 1)
98 + XCTAssertEqual(button.depth, 2)
99 XCTAssertEqual(button.selected, false)
100 XCTAssertEqual(button.focused, true)
101 }
@@ -117,4 +119,24 @@ final class XCRSControlKitTests: XCTestCase {
119 XCTAssertEqual(button.selected, true)
120 XCTAssertEqual(button.focused, true)
121 }
122 +
123 + func testAccessibilityParserRejectsMultipleRootElements() {
124 + let hierarchy = """
125 + Element subtree:
126 + →Application, 0x1
127 + →Window, 0x2
128 + Path to element:
129 + """
130 +
131 + XCTAssertThrowsError(
132 + try XCRSControlKitAccessibilityParser.parse(
133 + debugDescription: hierarchy
134 + )
135 + ) { error in
136 + XCTAssertEqual(
137 + error as? XCRSControlKitError,
138 + .invalidRequest("XCTest returned multiple root accessibility elements")
139 + )
140 + }
141 + }
142 }