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 }