/ internal / tools / axserver / Sources / WaitManager.swift
WaitManager.swift
  1  import ApplicationServices
  2  import AppKit
  3  
  4  /// Polls for UI conditions until satisfied or timeout.
  5  func waitFor(pid: Int, condition: String, value: String?, query: String?, role: String?,
  6               timeout: Double, interval: Double) -> (ActionResult?, ErrorInfo?) {
  7  
  8      let deadline = Date().addingTimeInterval(timeout)
  9      let appRef = AXUIElementCreateApplication(Int32(pid))
 10      let hasElementSelector = !(query == nil || query!.isEmpty) || !(role == nil || role!.isEmpty)
 11  
 12      guard condition == "titleContains" || condition == "urlContains" ||
 13          condition == "titleChanged" || condition == "urlChanged" ||
 14          hasElementSelector else {
 15          return (nil, ErrorInfo(
 16              code: -1,
 17              message: "elementExists/elementGone require at least one of 'query' or 'role'"
 18          ))
 19      }
 20  
 21      // Capture initial values for "changed" conditions
 22      let initialTitle: String?
 23      let initialURL: String?
 24      switch condition {
 25      case "titleChanged":
 26          initialTitle = windowTitle(appRef: appRef)
 27          initialURL = nil
 28      case "urlChanged":
 29          initialTitle = nil
 30          initialURL = browserURL(appRef: appRef)
 31      default:
 32          initialTitle = nil
 33          initialURL = nil
 34      }
 35  
 36      // Validate: elementExists/elementGone require at least a query or role
 37      if condition == "elementExists" || condition == "elementGone" {
 38          if (query == nil || query!.isEmpty) && (role == nil || role!.isEmpty) {
 39              return (nil, ErrorInfo(code: -1, message: "\(condition) requires at least 'query' or 'role' to identify the target element"))
 40          }
 41      }
 42  
 43      while Date() < deadline {
 44          switch condition {
 45          case "elementExists":
 46              let found = findElements(pid: pid, query: query, role: role, identifier: nil)
 47              if !found.isEmpty {
 48                  return (ActionResult(result: "element found: \(found[0].role) '\(found[0].title)'"), nil)
 49              }
 50  
 51          case "elementGone":
 52              let found = findElements(pid: pid, query: query, role: role, identifier: nil)
 53              if found.isEmpty {
 54                  return (ActionResult(result: "element gone"), nil)
 55              }
 56  
 57          case "titleContains":
 58              guard let substring = value else {
 59                  return (nil, ErrorInfo(code: -1, message: "titleContains requires 'value'"))
 60              }
 61              if let title = windowTitle(appRef: appRef),
 62                 title.lowercased().contains(substring.lowercased()) {
 63                  return (ActionResult(result: "title contains '\(substring)': \(title)"), nil)
 64              }
 65  
 66          case "urlContains":
 67              guard let substring = value else {
 68                  return (nil, ErrorInfo(code: -1, message: "urlContains requires 'value'"))
 69              }
 70              if let url = browserURL(appRef: appRef),
 71                 url.lowercased().contains(substring.lowercased()) {
 72                  return (ActionResult(result: "URL contains '\(substring)': \(url)"), nil)
 73              }
 74  
 75          case "titleChanged":
 76              if let current = windowTitle(appRef: appRef), current != initialTitle {
 77                  return (ActionResult(result: "title changed from '\(initialTitle ?? "")' to '\(current)'"), nil)
 78              }
 79  
 80          case "urlChanged":
 81              if let current = browserURL(appRef: appRef), current != initialURL {
 82                  return (ActionResult(result: "URL changed from '\(initialURL ?? "")' to '\(current)'"), nil)
 83              }
 84  
 85          default:
 86              return (nil, ErrorInfo(code: -1,
 87                  message: "unknown condition '\(condition)' — valid: elementExists, elementGone, titleContains, urlContains, titleChanged, urlChanged"))
 88          }
 89  
 90          Thread.sleep(forTimeInterval: interval)
 91      }
 92  
 93      // Timeout — report current state
 94      var detail = ""
 95      switch condition {
 96      case "elementExists":
 97          detail = "element matching query=\(query ?? "nil") role=\(role ?? "nil") not found"
 98      case "elementGone":
 99          detail = "element still exists"
100      case "titleContains":
101          let current = windowTitle(appRef: appRef) ?? "(none)"
102          detail = "title is '\(current)', does not contain '\(value ?? "")'"
103      case "urlContains":
104          let current = browserURL(appRef: appRef) ?? "(none)"
105          detail = "URL is '\(current)', does not contain '\(value ?? "")'"
106      case "titleChanged":
107          detail = "title unchanged: '\(windowTitle(appRef: appRef) ?? "(none)")'"
108      case "urlChanged":
109          detail = "URL unchanged: '\(browserURL(appRef: appRef) ?? "(none)")'"
110      default:
111          break
112      }
113  
114      return (nil, ErrorInfo(code: -2, message: "timeout after \(timeout)s — \(detail)"))
115  }
116  
117  // MARK: - Helpers
118  
119  /// Returns the title of the first window.
120  private func windowTitle(appRef: AXUIElement) -> String? {
121      guard let windows = axValue(appRef, "AXWindows") as? [AXUIElement],
122            let win = windows.first else { return nil }
123      return axString(win, "AXTitle")
124  }
125  
126  /// Finds the browser URL bar value by looking for AXTextField inside AXToolbar.
127  private func browserURL(appRef: AXUIElement) -> String? {
128      guard let windows = axValue(appRef, "AXWindows") as? [AXUIElement],
129            let win = windows.first else { return nil }
130  
131      // Search for a text field in the toolbar (browser URL bar pattern)
132      if let toolbar = findChild(of: win, role: "AXToolbar") {
133          if let urlField = findURLField(in: toolbar) {
134              if let val = axValue(urlField, "AXValue") {
135                  return "\(val)"
136              }
137          }
138      }
139  
140      // Fallback: search the whole window for a text field with URL-like value
141      return findURLInTree(win, depth: 0, maxDepth: 5)
142  }
143  
144  /// Finds a direct or nested child with the given role.
145  private func findChild(of el: AXUIElement, role: String) -> AXUIElement? {
146      guard let children = axChildren(el) else { return nil }
147      for child in children {
148          if axString(child, "AXRole") == role {
149              return child
150          }
151      }
152      // One level deeper
153      for child in children {
154          if let found = findChild(of: child, role: role) {
155              return found
156          }
157      }
158      return nil
159  }
160  
161  /// Finds a text field that looks like a URL bar inside a toolbar.
162  private func findURLField(in el: AXUIElement) -> AXUIElement? {
163      guard let children = axChildren(el) else { return nil }
164      for child in children {
165          let role = axString(child, "AXRole") ?? ""
166          if role == "AXTextField" || role == "AXComboBox" {
167              return child
168          }
169          if let found = findURLField(in: child) {
170              return found
171          }
172      }
173      return nil
174  }
175  
176  /// Searches for a URL-like value in a text field anywhere in the tree.
177  private func findURLInTree(_ el: AXUIElement, depth: Int, maxDepth: Int) -> String? {
178      guard depth < maxDepth else { return nil }
179      let role = axString(el, "AXRole") ?? ""
180      if role == "AXTextField" || role == "AXComboBox" {
181          if let val = axValue(el, "AXValue") {
182              let s = "\(val)"
183              if s.contains(".") || s.hasPrefix("http") {
184                  return s
185              }
186          }
187      }
188      guard let children = axChildren(el) else { return nil }
189      for child in children {
190          if let found = findURLInTree(child, depth: depth + 1, maxDepth: maxDepth) {
191              return found
192          }
193      }
194      return nil
195  }