Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions Apps/CLI/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Fixed
- Prevent dialog discovery and element traversal from recursing indefinitely when an app reports cyclic accessibility relationships.

## [3.9.6] - 2026-07-19

### Highlights
Expand Down
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@

## [Unreleased]

### Fixed
- Prevent dialog discovery and element traversal from recursing indefinitely when an app reports cyclic accessibility relationships.

## [3.9.6] - 2026-07-19

### Highlights
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,25 +3,56 @@ import AXorcist
import Foundation
import PeekabooFoundation

@MainActor
extension DialogService {
func collectTextFields(from element: Element) -> [Element] {
var fields: [Element] = []
func collectUniqueDepthFirst<Node: Hashable>(
from root: Node,
matching predicate: (Node) -> Bool,
children: (Node) -> [Node]) -> [Node]
{
var matches: [Node] = []
var visited: Set<Node> = []
var stack = [root]

while let node = stack.popLast() {
guard visited.insert(node).inserted else { continue }

if predicate(node) {
matches.append(node)
}

func collectFields(from el: Element) {
if el.role() == "AXTextField" || el.role() == "AXTextArea" {
fields.append(el)
}
stack.append(contentsOf: children(node).reversed())
}

if let children = el.children() {
for child in children {
collectFields(from: child)
}
}
return matches
}

func firstUniqueDepthFirst<Node: Hashable>(
from root: Node,
matching predicate: (Node) -> Bool,
children: (Node) -> [Node]) -> Node?
{
var visited: Set<Node> = []
var stack = [root]

while let node = stack.popLast() {
guard visited.insert(node).inserted else { continue }

if predicate(node) {
return node
}

collectFields(from: element)
return fields
stack.append(contentsOf: children(node).reversed())
}

return nil
}

@MainActor
extension DialogService {
func collectTextFields(from element: Element) -> [Element] {
collectUniqueDepthFirst(
from: element,
matching: { $0.role() == "AXTextField" || $0.role() == "AXTextArea" },
children: { $0.children() ?? [] })
}

func selectTextField(in textFields: [Element], identifier: String?) throws -> Element {
Expand Down Expand Up @@ -112,22 +143,10 @@ extension DialogService {
}

func collectButtons(from element: Element) -> [Element] {
var buttons: [Element] = []

func collect(from el: Element) {
if el.role() == "AXButton" {
buttons.append(el)
}

if let children = el.children() {
for child in children {
collect(from: child)
}
}
}

collect(from: element)
return buttons
collectUniqueDepthFirst(
from: element,
matching: { $0.role() == "AXButton" },
children: { $0.children() ?? [] })
}

func dialogButtons(from dialog: Element) -> [DialogButton] {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,44 +49,31 @@ extension DialogService {
let identifierAttribute = Attribute<String>("AXIdentifier")

func findDisclosureCandidate(in element: Element) -> Element? {
if element.role() == "AXDisclosureTriangle" {
return element
}

let identifier = element.attribute(identifierAttribute) ?? ""
if identifier.localizedCaseInsensitiveContains("DISCLOSURE_TRIANGLE") ||
identifier.localizedCaseInsensitiveContains("DISCLOSURE") ||
identifier.localizedCaseInsensitiveContains("ShowDetails") ||
identifier.localizedCaseInsensitiveContains("HideDetails")
{
return element
}

let title = (element.title() ?? "").lowercased()
if title.contains("show details") || title.contains("hide details") {
return element
}

let description = (element.attribute(Attribute<String>("AXDescription")) ?? "").lowercased()
if description.contains("show details") || description.contains("hide details") {
return element
}
firstUniqueDepthFirst(
from: element,
matching: { current in
if current.role() == "AXDisclosureTriangle" {
return true
}

for sheet in self.sheetElements(for: element) {
if let match = findDisclosureCandidate(in: sheet) {
return match
}
}
let identifier = current.attribute(identifierAttribute) ?? ""
if identifier.localizedCaseInsensitiveContains("DISCLOSURE_TRIANGLE") ||
identifier.localizedCaseInsensitiveContains("DISCLOSURE") ||
identifier.localizedCaseInsensitiveContains("ShowDetails") ||
identifier.localizedCaseInsensitiveContains("HideDetails")
{
return true
}

if let children = element.children() {
for child in children {
if let match = findDisclosureCandidate(in: child) {
return match
let title = (current.title() ?? "").lowercased()
if title.contains("show details") || title.contains("hide details") {
return true
}
}
}

return nil
let description = (current.attribute(Attribute<String>("AXDescription")) ?? "").lowercased()
return description.contains("show details") || description.contains("hide details")
},
children: { self.sheetElements(for: $0) + ($0.children() ?? []) })
}

guard let disclosure = findDisclosureCandidate(in: dialog) else { return }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,24 +17,9 @@ extension DialogService {
}

private func findActiveFileDialogCandidate(in element: Element) -> Element? {
if self.isFileDialogElement(element) {
return element
}

for sheet in self.sheetElements(for: element) {
if let candidate = self.findActiveFileDialogCandidate(in: sheet) {
return candidate
}
}

if let children = element.children() {
for child in children {
if let candidate = self.findActiveFileDialogCandidate(in: child) {
return candidate
}
}
}

return nil
firstUniqueDepthFirst(
from: element,
matching: self.isFileDialogElement,
children: { self.sheetElements(for: $0) + ($0.children() ?? []) })
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -190,29 +190,13 @@ extension DialogService {
}

func resolveDialogCandidate(in element: Element, matching title: String?) -> Element? {
if self.isDialogElement(element, matching: title) {
return element
}

let sheets = title == nil ? (element.sheets() ?? []) : self.sheetElements(for: element)
for sheet in sheets {
if let candidate = self.resolveDialogCandidate(in: sheet, matching: title) {
return candidate
}
}

guard title != nil else {
return nil
}

if let children = element.children() {
for child in children {
if let candidate = self.resolveDialogCandidate(in: child, matching: title) {
return candidate
}
}
}

return nil
firstUniqueDepthFirst(
from: element,
matching: { self.isDialogElement($0, matching: title) },
children: { current in
let sheets = title == nil ? (current.sheets() ?? []) : self.sheetElements(for: current)
guard title != nil else { return sheets }
return sheets + (current.children() ?? [])
})
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import Testing
@testable import PeekabooAutomationKit

struct DialogElementTraversalTests {
@Test
func `cycles and shared nodes are visited once in pre-order`() {
let dialog = TraversalNode(name: "dialog")
let firstGroup = TraversalNode(name: "first group")
let secondGroup = TraversalNode(name: "second group")
let primaryButton = TraversalNode(name: "primary button", isMatch: true)
let sharedButton = TraversalNode(name: "shared button", isMatch: true)
let secondaryButton = TraversalNode(name: "secondary button", isMatch: true)

dialog.children = [firstGroup, secondGroup]
firstGroup.children = [primaryButton, sharedButton]
primaryButton.children = [dialog]
secondGroup.children = [sharedButton, secondaryButton]

let matches = collectUniqueDepthFirst(
from: dialog,
matching: { $0.isMatch },
children: { $0.children })

#expect(matches.map(\.name) == ["primary button", "shared button", "secondary button"])
}

@Test
func `first match terminates a cyclic traversal in pre-order`() {
let dialog = TraversalNode(name: "dialog")
let firstGroup = TraversalNode(name: "first group")
let secondGroup = TraversalNode(name: "second group")
let primaryButton = TraversalNode(name: "primary button", isMatch: true)
let secondaryButton = TraversalNode(name: "secondary button", isMatch: true)
var inspected: [String] = []

dialog.children = [firstGroup, secondGroup]
firstGroup.children = [dialog, primaryButton]
secondGroup.children = [secondaryButton]

let match = firstUniqueDepthFirst(
from: dialog,
matching: {
inspected.append($0.name)
return $0.isMatch
},
children: { $0.children })

#expect(match === primaryButton)
#expect(inspected == ["dialog", "first group", "primary button"])
}

@Test
func `deep hierarchies do not consume the call stack`() {
let root = TraversalNode(name: "0")
var nodes = [root]
var current = root

for index in 1...20000 {
let child = TraversalNode(name: "\(index)", isMatch: index == 20000)
current.children = [child]
nodes.append(child)
current = child
}

let matches = collectUniqueDepthFirst(
from: root,
matching: { $0.isMatch },
children: { $0.children })

#expect(matches.count == 1)
#expect(matches.first === current)

for node in nodes {
node.children.removeAll()
}
}
}

private final class TraversalNode: Hashable {
let name: String
let isMatch: Bool
var children: [TraversalNode] = []

init(name: String, isMatch: Bool = false) {
self.name = name
self.isMatch = isMatch
}

static func == (lhs: TraversalNode, rhs: TraversalNode) -> Bool {
lhs === rhs
}

func hash(into hasher: inout Hasher) {
hasher.combine(ObjectIdentifier(self))
}
}
Loading