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() {