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
}