fix: strengthen accessibility snapshot parsing
Seto Elkahfi committed
Aug 25, 2026 at 11:01 UTC
62f2f8d8b87a037bda7e7801b89492167576e490
3 files changed
+81
-26
Runners/Shared/ControlKitRPCServer.swift
+15
-7
@@ -108,10 +108,10 @@ 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 == .runningForeground else {
112
- throw RunnerError.applicationNotInForeground(bundleIdentifier)
113
- }
114
- let hierarchy = try accessibilityHierarchy(application)
111
+ let hierarchy = try accessibilityHierarchy(
112
+ application,
113
+ bundleIdentifier: bundleIdentifier
114
+ )
115
return try response(result: dictionary(hierarchy), id: request.id)
116
case "device.apps.launch":
117
let bundleIdentifier = try stringParameter("bundleId", request.params)
@@ -207,10 +207,18 @@ final class ControlKitRPCServer {
207
}
208
209
private func accessibilityHierarchy(
210
- _ application: XCUIApplication
210
+ _ application: XCUIApplication,
211
+ bundleIdentifier: String
212
) throws -> XCRSControlKitAccessibilityNode {
212
- try XCRSControlKitAccessibilityParser.parse(
213
- debugDescription: application.debugDescription
213
+ guard application.state == .runningForeground else {
214
+ throw RunnerError.applicationNotInForeground(bundleIdentifier)
215
+ }
216
+ let debugDescription = application.debugDescription
217
+ guard application.state == .runningForeground else {
218
+ throw RunnerError.applicationNotInForeground(bundleIdentifier)
219
+ }
220
+ return try XCRSControlKitAccessibilityParser.parse(
221
+ debugDescription: debugDescription
222
)
223
}
224
Sources/XCRSControlKit/XCRSControlKit.swift
+30
-19
@@ -320,19 +320,11 @@ public enum XCRSControlKitAccessibilityParser {
320
}
321
322
var index = valueStart
323
- var escaped = false
323
while index < line.endIndex {
324
let character = line[index]
326
- if character == "'", !escaped {
327
- let nextIndex = line.index(after: index)
328
- if nextIndex == line.endIndex || line[nextIndex] == "," {
329
- let value = String(line[valueStart..<index])
330
- return value.isEmpty ? nil : value
331
- }
332
- }
333
- escaped = character == "\\" && !escaped
334
- if character != "\\" {
335
- escaped = false
325
+ if character == "'", isClosingQuote(at: index, in: line) {
326
+ let value = String(line[valueStart..<index])
327
+ return value.isEmpty ? nil : value
328
}
329
index = line.index(after: index)
330
}
@@ -347,14 +339,12 @@ public enum XCRSControlKitAccessibilityParser {
339
var segmentStart = line.startIndex
340
var index = line.startIndex
341
var quoted = false
350
- var escaped = false
342
343
while index < line.endIndex {
344
let character = line[index]
354
- if character == "'", !escaped {
345
+ if character == "'" {
346
if quoted {
356
- let nextIndex = line.index(after: index)
357
- if nextIndex == line.endIndex || line[nextIndex] == "," {
347
+ if isClosingQuote(at: index, in: line) {
348
quoted = false
349
}
350
} else {
@@ -366,15 +356,36 @@ public enum XCRSControlKitAccessibilityParser {
356
}
357
segmentStart = line.index(after: index)
358
}
369
- escaped = character == "\\" && !escaped
370
- if character != "\\" {
371
- escaped = false
372
- }
359
index = line.index(after: index)
360
}
361
362
return line[segmentStart...].trimmingCharacters(in: .whitespaces) == attribute
363
}
364
+
365
+ private static func isClosingQuote(
366
+ at quoteIndex: String.Index,
367
+ in line: String
368
+ ) -> Bool {
369
+ var backslashCount = 0
370
+ var index = quoteIndex
371
+ while index > line.startIndex {
372
+ let previousIndex = line.index(before: index)
373
+ guard line[previousIndex] == "\\" else {
374
+ break
375
+ }
376
+ backslashCount += 1
377
+ index = previousIndex
378
+ }
379
+ guard backslashCount.isMultiple(of: 2) else {
380
+ return false
381
+ }
382
+
383
+ index = line.index(after: quoteIndex)
384
+ while index < line.endIndex, line[index].isWhitespace {
385
+ index = line.index(after: index)
386
+ }
387
+ return index == line.endIndex || line[index] == "," || line[index] == "}"
388
+ }
389
}
390
391
/// A transport-neutral command sent to a runner.
Tests/XCRSControlKitTests/XCRSControlKitTests.swift
+36
@@ -118,6 +118,42 @@ final class XCRSControlKitTests: XCTestCase {
118
XCTAssertEqual(button.enabled, false)
119
XCTAssertEqual(button.selected, true)
120
XCTAssertEqual(button.focused, true)
121
+ XCTAssertEqual(root.enabled, true)
122
+ }
123
+
124
+ func testAccessibilityParserKeepsSiblingsAtTheSameIndentation() throws {
125
+ let hierarchy = """
126
+ Element subtree:
127
+ →Application, 0x1
128
+ Button, 0x2, label: 'First'
129
+ Button, 0x3, label: 'Second'
130
+ Path to element:
131
+ """
132
+
133
+ let root = try XCRSControlKitAccessibilityParser.parse(
134
+ debugDescription: hierarchy
135
+ )
136
+
137
+ XCTAssertEqual(root.children.map(\.label), ["First", "Second"])
138
+ XCTAssertTrue(root.children.allSatisfy(\.children.isEmpty))
139
+ }
140
+
141
+ func testAccessibilityParserReadsEscapedAndBraceTerminatedAttributes() throws {
142
+ let hierarchy = """
143
+ Element subtree:
144
+ →Application, 0x1, label: 'Sample'}
145
+ Button, 0x2, label: 'It\\'s ready', Selected
146
+ Path to element:
147
+ """
148
+
149
+ let root = try XCRSControlKitAccessibilityParser.parse(
150
+ debugDescription: hierarchy
151
+ )
152
+
153
+ XCTAssertEqual(root.label, "Sample")
154
+ let button = try XCTUnwrap(root.children.first)
155
+ XCTAssertEqual(button.label, "It\\'s ready")
156
+ XCTAssertEqual(button.selected, true)
157
}
158
159
func testAccessibilityParserRejectsMultipleRootElements() {