screenhand 0.2.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (212) hide show
  1. package/README.md +165 -446
  2. package/bin/darwin-arm64/macos-bridge +0 -0
  3. package/dist/mcp-desktop.js +3615 -400
  4. package/dist/scripts/export-help-center.js +112 -0
  5. package/dist/scripts/marketing-loop.js +117 -0
  6. package/dist/scripts/observer-daemon.js +288 -0
  7. package/dist/scripts/orchestrator-daemon.js +399 -0
  8. package/dist/scripts/threads-campaign.js +208 -0
  9. package/dist/src/community/fetcher.js +109 -0
  10. package/dist/src/community/index.js +6 -0
  11. package/dist/src/community/publisher.js +191 -0
  12. package/dist/src/community/remote-api.js +121 -0
  13. package/dist/src/community/types.js +3 -0
  14. package/dist/src/community/validator.js +95 -0
  15. package/dist/src/context-tracker.js +489 -0
  16. package/dist/src/ingestion/coverage-auditor.js +233 -0
  17. package/dist/src/ingestion/doc-parser.js +164 -0
  18. package/dist/src/ingestion/index.js +8 -0
  19. package/dist/src/ingestion/menu-scanner.js +152 -0
  20. package/dist/src/ingestion/reference-merger.js +186 -0
  21. package/dist/src/ingestion/shortcut-extractor.js +180 -0
  22. package/dist/src/ingestion/tutorial-extractor.js +170 -0
  23. package/dist/src/ingestion/types.js +3 -0
  24. package/dist/src/jobs/manager.js +82 -14
  25. package/dist/src/jobs/runner.js +138 -15
  26. package/dist/src/learning/engine.js +356 -0
  27. package/dist/src/learning/index.js +9 -0
  28. package/dist/src/learning/locator-policy.js +120 -0
  29. package/dist/src/learning/pattern-policy.js +89 -0
  30. package/dist/src/learning/recovery-policy.js +116 -0
  31. package/dist/src/learning/sensor-policy.js +115 -0
  32. package/dist/src/learning/timing-model.js +204 -0
  33. package/dist/src/learning/topology-policy.js +90 -0
  34. package/dist/src/learning/types.js +9 -0
  35. package/dist/src/logging/timeline-logger.js +4 -1
  36. package/dist/src/memory/playbook-seeds.js +200 -0
  37. package/dist/src/memory/recall.js +60 -8
  38. package/dist/src/memory/service.js +30 -5
  39. package/dist/src/memory/store.js +34 -5
  40. package/dist/src/native/bridge-client.js +253 -31
  41. package/dist/src/observer/state.js +199 -0
  42. package/dist/src/observer/types.js +43 -0
  43. package/dist/src/orchestrator/state.js +68 -0
  44. package/dist/src/orchestrator/types.js +22 -0
  45. package/dist/src/perception/ax-source.js +162 -0
  46. package/dist/src/perception/cdp-source.js +162 -0
  47. package/dist/src/perception/coordinator.js +771 -0
  48. package/dist/src/perception/frame-differ.js +287 -0
  49. package/dist/src/perception/index.js +22 -0
  50. package/dist/src/perception/manager.js +199 -0
  51. package/dist/src/perception/types.js +47 -0
  52. package/dist/src/perception/vision-source.js +399 -0
  53. package/dist/src/planner/deterministic.js +298 -0
  54. package/dist/src/planner/executor.js +870 -0
  55. package/dist/src/planner/goal-store.js +92 -0
  56. package/dist/src/planner/index.js +21 -0
  57. package/dist/src/planner/planner.js +520 -0
  58. package/dist/src/planner/tool-registry.js +71 -0
  59. package/dist/src/planner/types.js +22 -0
  60. package/dist/src/platform/explorer.js +213 -0
  61. package/dist/src/platform/help-center-markdown.js +527 -0
  62. package/dist/src/platform/learner.js +257 -0
  63. package/dist/src/playbook/engine.js +296 -11
  64. package/dist/src/playbook/mcp-recorder.js +204 -0
  65. package/dist/src/playbook/recorder.js +3 -2
  66. package/dist/src/playbook/runner.js +1 -1
  67. package/dist/src/playbook/store.js +139 -10
  68. package/dist/src/recovery/detectors.js +156 -0
  69. package/dist/src/recovery/engine.js +327 -0
  70. package/dist/src/recovery/index.js +20 -0
  71. package/dist/src/recovery/strategies.js +274 -0
  72. package/dist/src/recovery/types.js +20 -0
  73. package/dist/src/runtime/accessibility-adapter.js +55 -18
  74. package/dist/src/runtime/applescript-adapter.js +8 -2
  75. package/dist/src/runtime/cdp-chrome-adapter.js +1 -1
  76. package/dist/src/runtime/executor.js +23 -3
  77. package/dist/src/runtime/locator-cache.js +24 -2
  78. package/dist/src/runtime/service.js +59 -15
  79. package/dist/src/runtime/session-manager.js +4 -1
  80. package/dist/src/runtime/vision-adapter.js +2 -1
  81. package/dist/src/state/app-map-types.js +72 -0
  82. package/dist/src/state/app-map.js +1974 -0
  83. package/dist/src/state/entity-tracker.js +108 -0
  84. package/dist/src/state/fusion.js +96 -0
  85. package/dist/src/state/index.js +21 -0
  86. package/dist/src/state/ladder-generator.js +236 -0
  87. package/dist/src/state/persistence.js +156 -0
  88. package/dist/src/state/types.js +17 -0
  89. package/dist/src/state/world-model.js +1456 -0
  90. package/dist/src/util/atomic-write.js +19 -4
  91. package/dist/src/util/sanitize.js +146 -0
  92. package/dist-app-maps/com.figma.Desktop.json +959 -0
  93. package/dist-app-maps/com.hnc.Discord.json +1146 -0
  94. package/dist-app-maps/notion.id.json +2831 -0
  95. package/dist-playbooks/canva-screenhand-carousel.json +445 -0
  96. package/dist-playbooks/codex-desktop.json +76 -0
  97. package/dist-playbooks/competitor-research-stack.json +122 -0
  98. package/dist-playbooks/davinci-color-grade.json +153 -0
  99. package/dist-playbooks/davinci-edit-timeline.json +162 -0
  100. package/dist-playbooks/davinci-render.json +114 -0
  101. package/dist-playbooks/devto.json +52 -0
  102. package/dist-playbooks/discord.json +41 -0
  103. package/dist-playbooks/google-flow-create-project.json +59 -0
  104. package/dist-playbooks/google-flow-edit-image.json +90 -0
  105. package/dist-playbooks/google-flow-edit-video.json +90 -0
  106. package/dist-playbooks/google-flow-generate-image.json +68 -0
  107. package/dist-playbooks/google-flow-generate-video.json +191 -0
  108. package/dist-playbooks/google-flow-open-project.json +48 -0
  109. package/dist-playbooks/google-flow-open-scenebuilder.json +64 -0
  110. package/dist-playbooks/google-flow-search-assets.json +64 -0
  111. package/dist-playbooks/instagram.json +57 -0
  112. package/dist-playbooks/linkedin.json +52 -0
  113. package/dist-playbooks/n8n.json +43 -0
  114. package/dist-playbooks/reddit.json +52 -0
  115. package/dist-playbooks/threads.json +59 -0
  116. package/dist-playbooks/x-twitter.json +59 -0
  117. package/dist-playbooks/youtube.json +59 -0
  118. package/dist-references/canva.json +646 -0
  119. package/dist-references/codex-desktop.json +305 -0
  120. package/dist-references/davinci-resolve-keyboard.json +594 -0
  121. package/dist-references/davinci-resolve-menu-map.json +1139 -0
  122. package/dist-references/davinci-resolve-menus-batch1.json +116 -0
  123. package/dist-references/davinci-resolve-menus-batch2.json +372 -0
  124. package/dist-references/davinci-resolve-menus-batch3.json +330 -0
  125. package/dist-references/davinci-resolve-menus-batch4.json +297 -0
  126. package/dist-references/davinci-resolve-shortcuts.json +333 -0
  127. package/dist-references/devpost.json +186 -0
  128. package/dist-references/devto.json +317 -0
  129. package/dist-references/discord.json +549 -0
  130. package/dist-references/figma.json +1186 -0
  131. package/dist-references/finder.json +146 -0
  132. package/dist-references/google-ads-transparency.json +95 -0
  133. package/dist-references/google-flow.json +649 -0
  134. package/dist-references/instagram.json +341 -0
  135. package/dist-references/linkedin.json +324 -0
  136. package/dist-references/meta-ad-library.json +86 -0
  137. package/dist-references/n8n.json +387 -0
  138. package/dist-references/notes.json +27 -0
  139. package/dist-references/notion.json +163 -0
  140. package/dist-references/reddit.json +341 -0
  141. package/dist-references/threads.json +337 -0
  142. package/dist-references/x-twitter.json +403 -0
  143. package/dist-references/youtube.json +373 -0
  144. package/native/macos-bridge/Package.swift +22 -0
  145. package/native/macos-bridge/Sources/AccessibilityBridge.swift +482 -0
  146. package/native/macos-bridge/Sources/AppManagement.swift +339 -0
  147. package/native/macos-bridge/Sources/CoreGraphicsBridge.swift +537 -0
  148. package/native/macos-bridge/Sources/ObserverBridge.swift +120 -0
  149. package/native/macos-bridge/Sources/StreamCapture.swift +136 -0
  150. package/native/macos-bridge/Sources/VisionBridge.swift +238 -0
  151. package/native/macos-bridge/Sources/main.swift +498 -0
  152. package/native/windows-bridge/AppManagement.cs +234 -0
  153. package/native/windows-bridge/InputBridge.cs +436 -0
  154. package/native/windows-bridge/Program.cs +270 -0
  155. package/native/windows-bridge/ScreenCapture.cs +453 -0
  156. package/native/windows-bridge/UIAutomationBridge.cs +571 -0
  157. package/native/windows-bridge/WindowsBridge.csproj +17 -0
  158. package/package.json +12 -1
  159. package/scripts/postinstall.cjs +127 -0
  160. package/dist/.audit-log.jsonl +0 -55
  161. package/dist/.screenhand/memory/.lock +0 -1
  162. package/dist/.screenhand/memory/actions.jsonl +0 -85
  163. package/dist/.screenhand/memory/errors.jsonl +0 -5
  164. package/dist/.screenhand/memory/errors.jsonl.bak +0 -4
  165. package/dist/.screenhand/memory/state.json +0 -35
  166. package/dist/.screenhand/memory/state.json.bak +0 -35
  167. package/dist/.screenhand/memory/strategies.jsonl +0 -12
  168. package/dist/agent/cli.js +0 -73
  169. package/dist/agent/loop.js +0 -258
  170. package/dist/config.js +0 -9
  171. package/dist/index.js +0 -56
  172. package/dist/logging/timeline-logger.js +0 -29
  173. package/dist/mcp/mcp-stdio-server.js +0 -448
  174. package/dist/mcp/server.js +0 -347
  175. package/dist/mcp-entry.js +0 -59
  176. package/dist/memory/recall.js +0 -160
  177. package/dist/memory/research.js +0 -98
  178. package/dist/memory/seeds.js +0 -89
  179. package/dist/memory/session.js +0 -161
  180. package/dist/memory/store.js +0 -391
  181. package/dist/memory/types.js +0 -4
  182. package/dist/monitor/codex-monitor.js +0 -377
  183. package/dist/monitor/task-queue.js +0 -84
  184. package/dist/monitor/types.js +0 -49
  185. package/dist/native/bridge-client.js +0 -174
  186. package/dist/native/macos-bridge-client.js +0 -5
  187. package/dist/npm-publish-helper.js +0 -117
  188. package/dist/npm-token-cdp.js +0 -113
  189. package/dist/npm-token-create.js +0 -135
  190. package/dist/npm-token-finish.js +0 -126
  191. package/dist/playbook/engine.js +0 -193
  192. package/dist/playbook/index.js +0 -4
  193. package/dist/playbook/recorder.js +0 -519
  194. package/dist/playbook/runner.js +0 -392
  195. package/dist/playbook/store.js +0 -166
  196. package/dist/playbook/types.js +0 -4
  197. package/dist/runtime/accessibility-adapter.js +0 -377
  198. package/dist/runtime/app-adapter.js +0 -48
  199. package/dist/runtime/applescript-adapter.js +0 -283
  200. package/dist/runtime/ax-role-map.js +0 -80
  201. package/dist/runtime/browser-adapter.js +0 -36
  202. package/dist/runtime/cdp-chrome-adapter.js +0 -505
  203. package/dist/runtime/composite-adapter.js +0 -205
  204. package/dist/runtime/executor.js +0 -250
  205. package/dist/runtime/locator-cache.js +0 -12
  206. package/dist/runtime/planning-loop.js +0 -47
  207. package/dist/runtime/service.js +0 -372
  208. package/dist/runtime/session-manager.js +0 -28
  209. package/dist/runtime/state-observer.js +0 -105
  210. package/dist/runtime/vision-adapter.js +0 -208
  211. package/dist/test-mcp-protocol.js +0 -138
  212. package/dist/types.js +0 -1
@@ -0,0 +1,482 @@
1
+ import ApplicationServices
2
+ import AppKit
3
+ import Foundation
4
+
5
+ class AccessibilityBridge {
6
+
7
+ func isAccessibilityTrusted() -> Bool {
8
+ return AXIsProcessTrusted()
9
+ }
10
+
11
+ // MARK: - Element Tree
12
+
13
+ /// Max total nodes to emit in a single tree build to prevent runaway traversal on heavy DOMs (Canva, Figma)
14
+ private static let treeBuildNodeBudget = 3000
15
+ /// Max siblings to include per parent during tree build
16
+ private static let treeBuildSiblingCap = 80
17
+
18
+ func getElementTree(pid: pid_t, maxDepth: Int, windowId: Int? = nil) throws -> [String: Any] {
19
+ let appElement = AXUIElementCreateApplication(pid)
20
+ var nodesEmitted = 0
21
+
22
+ // If windowId specified, scope to that window instead of full app tree
23
+ if let wid = windowId, wid > 0 {
24
+ if let windowsRef = getAttribute(appElement, kAXWindowsAttribute) as? [AXUIElement] {
25
+ // Match by CGWindowID via position/size comparison with CG window list
26
+ let cgWindows = CGWindowListCopyWindowInfo([.optionOnScreenOnly], kCGNullWindowID) as? [[String: Any]] ?? []
27
+ let appCGWindows = cgWindows.filter { ($0[kCGWindowOwnerPID as String] as? Int) == Int(pid) }
28
+
29
+ for win in windowsRef {
30
+ // Get AX window position and size
31
+ var axPos = CGPoint.zero
32
+ var axSize = CGSize.zero
33
+ if let posValue = getAttribute(win, kAXPositionAttribute) {
34
+ AXValueGetValue(posValue as! AXValue, .cgPoint, &axPos)
35
+ }
36
+ if let sizeValue = getAttribute(win, kAXSizeAttribute) {
37
+ AXValueGetValue(sizeValue as! AXValue, .cgSize, &axSize)
38
+ }
39
+
40
+ // Match against CG window with target windowId
41
+ for cgWin in appCGWindows {
42
+ guard let cgId = cgWin[kCGWindowNumber as String] as? Int, cgId == wid else { continue }
43
+ if let bounds = cgWin[kCGWindowBounds as String] as? [String: Any],
44
+ let cgX = bounds["X"] as? Double,
45
+ let cgY = bounds["Y"] as? Double {
46
+ // Match by position (within tolerance for rounding)
47
+ if abs(Double(axPos.x) - cgX) < 2 && abs(Double(axPos.y) - cgY) < 2 {
48
+ var tree = try buildTree(element: win, depth: 0, maxDepth: maxDepth, nodesEmitted: &nodesEmitted, isAppRoot: false)
49
+ tree["_nodeCount"] = nodesEmitted
50
+ return tree
51
+ }
52
+ }
53
+ }
54
+ }
55
+ }
56
+ // Fallback: if window not found by ID, use app root
57
+ }
58
+
59
+ var tree = try buildTree(element: appElement, depth: 0, maxDepth: maxDepth, nodesEmitted: &nodesEmitted, isAppRoot: true)
60
+ tree["_nodeCount"] = nodesEmitted
61
+ return tree
62
+ }
63
+
64
+ private func buildTree(element: AXUIElement, depth: Int, maxDepth: Int, nodesEmitted: inout Int, isAppRoot: Bool = false) throws -> [String: Any] {
65
+ // Global node budget — stop adding nodes once exceeded
66
+ nodesEmitted += 1
67
+ if nodesEmitted > AccessibilityBridge.treeBuildNodeBudget {
68
+ return ["role": "BudgetExceeded", "_truncated": true]
69
+ }
70
+
71
+ var node: [String: Any] = [:]
72
+
73
+ node["role"] = getAttribute(element, kAXRoleAttribute) as? String ?? "Unknown"
74
+ if let title = getAttribute(element, kAXTitleAttribute) as? String, !title.isEmpty {
75
+ node["title"] = title
76
+ }
77
+ if let value = getAttribute(element, kAXValueAttribute) {
78
+ node["value"] = "\(value)"
79
+ }
80
+ if let desc = getAttribute(element, kAXDescriptionAttribute) as? String, !desc.isEmpty {
81
+ node["description"] = desc
82
+ }
83
+ if let identifier = getAttribute(element, kAXIdentifierAttribute) as? String, !identifier.isEmpty {
84
+ node["identifier"] = identifier
85
+ }
86
+ if let enabled = getAttribute(element, kAXEnabledAttribute) as? Bool {
87
+ node["enabled"] = enabled
88
+ }
89
+ if let focused = getAttribute(element, kAXFocusedAttribute) as? Bool {
90
+ node["focused"] = focused
91
+ }
92
+
93
+ // Position and size
94
+ if let posValue = getAttribute(element, kAXPositionAttribute) {
95
+ var point = CGPoint.zero
96
+ if AXValueGetValue(posValue as! AXValue, .cgPoint, &point) {
97
+ node["position"] = ["x": Double(point.x), "y": Double(point.y)]
98
+ }
99
+ }
100
+ if let sizeValue = getAttribute(element, kAXSizeAttribute) {
101
+ var size = CGSize.zero
102
+ if AXValueGetValue(sizeValue as! AXValue, .cgSize, &size) {
103
+ node["size"] = ["width": Double(size.width), "height": Double(size.height)]
104
+ }
105
+ }
106
+
107
+ // Children (if not at max depth and within global budget)
108
+ if depth < maxDepth && nodesEmitted < AccessibilityBridge.treeBuildNodeBudget {
109
+ if let children = getAttribute(element, kAXChildrenAttribute) as? [AXUIElement] {
110
+ var childNodes: [[String: Any]] = []
111
+ for (index, child) in children.enumerated() {
112
+ if index >= AccessibilityBridge.treeBuildSiblingCap { break }
113
+ if nodesEmitted >= AccessibilityBridge.treeBuildNodeBudget { break }
114
+ // Skip self-referential AXApplication children at the app root level.
115
+ // Some apps (Notes, Safari) list AXApplication elements as children
116
+ // of themselves, causing infinite recursion. Only check at depth 0
117
+ // (the app root) to avoid affecting legitimate nested elements.
118
+ if isAppRoot {
119
+ let childRole = getAttribute(child, kAXRoleAttribute) as? String ?? ""
120
+ if childRole == "AXApplication" { continue }
121
+ }
122
+ if let childNode = try? buildTree(element: child, depth: depth + 1, maxDepth: maxDepth, nodesEmitted: &nodesEmitted) {
123
+ childNodes.append(childNode)
124
+ }
125
+ }
126
+ if !childNodes.isEmpty {
127
+ node["children"] = childNodes
128
+ }
129
+ }
130
+ }
131
+
132
+ return node
133
+ }
134
+
135
+ // MARK: - Menu Bar Tree
136
+
137
+ func getMenuBarTree(pid: pid_t, maxDepth: Int) throws -> [String: Any] {
138
+ let appElement = AXUIElementCreateApplication(pid)
139
+ guard let menuBar = getAttribute(appElement, kAXMenuBarAttribute) as AnyObject? else {
140
+ throw BridgeError.notFound("Menu bar not found for pid \(pid)")
141
+ }
142
+ let menuBarElement = menuBar as! AXUIElement
143
+ return try buildMenuTree(element: menuBarElement, depth: 0, maxDepth: maxDepth, expandMenus: true)
144
+ }
145
+
146
+ private func buildMenuTree(element: AXUIElement, depth: Int, maxDepth: Int, expandMenus: Bool = false) throws -> [String: Any] {
147
+ var node: [String: Any] = [:]
148
+
149
+ let role = getAttribute(element, kAXRoleAttribute) as? String ?? "Unknown"
150
+ node["role"] = role
151
+ if let title = getAttribute(element, kAXTitleAttribute) as? String, !title.isEmpty {
152
+ node["title"] = title
153
+ }
154
+ if let desc = getAttribute(element, kAXDescriptionAttribute) as? String, !desc.isEmpty {
155
+ node["description"] = desc
156
+ }
157
+ if let enabled = getAttribute(element, kAXEnabledAttribute) as? Bool {
158
+ node["enabled"] = enabled
159
+ }
160
+
161
+ // Menu-specific attributes
162
+ if let cmdChar = getAttribute(element, "AXMenuItemCmdChar") as? String, !cmdChar.isEmpty {
163
+ node["AXMenuItemCmdChar"] = cmdChar
164
+ }
165
+ if let cmdMods = getAttribute(element, "AXMenuItemCmdModifiers") {
166
+ node["AXMenuItemCmdModifiers"] = cmdMods
167
+ }
168
+ if let markChar = getAttribute(element, "AXMenuItemMarkChar") as? String, !markChar.isEmpty {
169
+ node["AXMenuItemMarkChar"] = markChar
170
+ }
171
+
172
+ // Children (if not at max depth)
173
+ if depth < maxDepth {
174
+ var children = getAttribute(element, kAXChildrenAttribute) as? [AXUIElement]
175
+
176
+ // macOS AXMenuBarItem has a child AXMenu, but the AXMenu's children
177
+ // (actual AXMenuItems) are empty until the menu is opened via AXPress.
178
+ // Always press AXMenuBarItem to populate its submenu items.
179
+ let shouldExpand = expandMenus && role == "AXMenuBarItem"
180
+ if shouldExpand {
181
+ AXUIElementPerformAction(element, kAXPressAction as CFString)
182
+ // Poll for the AXMenu child's children to appear (max 200ms)
183
+ let deadline = Date().addingTimeInterval(0.2)
184
+ while Date() < deadline {
185
+ // Re-read children — the AXMenu inside should now have items
186
+ children = getAttribute(element, kAXChildrenAttribute) as? [AXUIElement]
187
+ if let menu = children?.first {
188
+ let menuChildren = getAttribute(menu, kAXChildrenAttribute) as? [AXUIElement]
189
+ if menuChildren != nil && !menuChildren!.isEmpty { break }
190
+ }
191
+ Thread.sleep(forTimeInterval: 0.02)
192
+ }
193
+ }
194
+
195
+ if let children = children {
196
+ var childNodes: [[String: Any]] = []
197
+ for (index, child) in children.enumerated() {
198
+ if index > 100 { break } // Safety limit
199
+ if let childNode = try? buildMenuTree(element: child, depth: depth + 1, maxDepth: maxDepth, expandMenus: expandMenus) {
200
+ childNodes.append(childNode)
201
+ }
202
+ }
203
+ if !childNodes.isEmpty {
204
+ node["children"] = childNodes
205
+ }
206
+ }
207
+
208
+ // Close the menu we opened
209
+ if shouldExpand {
210
+ AXUIElementPerformAction(element, kAXCancelAction as CFString)
211
+ Thread.sleep(forTimeInterval: 0.03)
212
+ }
213
+ }
214
+
215
+ return node
216
+ }
217
+
218
+ // MARK: - Find Element
219
+
220
+ func findElement(pid: pid_t, role: String?, title: String?, value: String?,
221
+ identifier: String?, exact: Bool, maxDepth: Int = 30) throws -> [String: Any] {
222
+ let appElement = AXUIElementCreateApplication(pid)
223
+ var visited = 0
224
+ guard let result = searchElement(
225
+ element: appElement, path: [], depth: 0, maxDepth: maxDepth,
226
+ nodesVisited: &visited,
227
+ role: role, title: title,
228
+ value: value, identifier: identifier, exact: exact,
229
+ isAppRoot: true
230
+ ) else {
231
+ throw BridgeError.notFound("Element not found matching criteria")
232
+ }
233
+ return result
234
+ }
235
+
236
+ /// Max total nodes to visit in a single search to prevent runaway traversal on heavy DOMs
237
+ private static let searchNodeBudget = 2000
238
+ /// Max siblings to check per parent during search
239
+ private static let searchSiblingCap = 100
240
+
241
+ private func searchElement(element: AXUIElement, path: [Int], depth: Int, maxDepth: Int,
242
+ nodesVisited: inout Int,
243
+ role: String?,
244
+ title: String?, value: String?, identifier: String?,
245
+ exact: Bool, isAppRoot: Bool = false) -> [String: Any]? {
246
+ // Bail if we've exceeded the node budget or depth limit
247
+ nodesVisited += 1
248
+ if nodesVisited > AccessibilityBridge.searchNodeBudget { return nil }
249
+ if depth > maxDepth { return nil }
250
+
251
+ // Check if this element matches
252
+ let elementRole = getAttribute(element, kAXRoleAttribute) as? String ?? ""
253
+ let elementSubrole = getAttribute(element, kAXSubroleAttribute) as? String ?? ""
254
+ let elementTitle = getAttribute(element, kAXTitleAttribute) as? String ?? ""
255
+ let elementValue = getAttribute(element, kAXValueAttribute).flatMap { "\($0)" } ?? ""
256
+ let elementId = getAttribute(element, kAXIdentifierAttribute) as? String ?? ""
257
+ let elementDesc = getAttribute(element, kAXDescriptionAttribute) as? String ?? ""
258
+
259
+ var matches = true
260
+ if let role = role {
261
+ // Match role OR subrole — allows searching by "AXCloseButton" subrole
262
+ let roleMatch = matchString(elementRole, role, exact: exact)
263
+ let subroleMatch = !elementSubrole.isEmpty && matchString(elementSubrole, role, exact: exact)
264
+ matches = matches && (roleMatch || subroleMatch)
265
+ }
266
+ if let title = title {
267
+ // Match title, description, OR subrole — many elements have no title but do
268
+ // have AXDescription or a meaningful subrole (e.g. AXCloseButton, AXMinimizeButton).
269
+ let titleMatch = matchString(elementTitle, title, exact: exact)
270
+ let descMatch = !elementDesc.isEmpty && matchString(elementDesc, title, exact: exact)
271
+ let subroleMatch = !elementSubrole.isEmpty && matchString(elementSubrole, title, exact: exact)
272
+ matches = matches && (titleMatch || descMatch || subroleMatch)
273
+ }
274
+ if let value = value {
275
+ matches = matches && matchString(elementValue, value, exact: exact)
276
+ }
277
+ if let identifier = identifier {
278
+ matches = matches && matchString(elementId, identifier, exact: exact)
279
+ }
280
+
281
+ if matches && (role != nil || title != nil || value != nil || identifier != nil) {
282
+ var result: [String: Any] = [
283
+ "role": elementRole,
284
+ "title": elementTitle,
285
+ "elementPath": path,
286
+ "handleId": "ax_\(path.map { String($0) }.joined(separator: "_"))",
287
+ ]
288
+ if !elementValue.isEmpty { result["value"] = elementValue }
289
+ if !elementId.isEmpty { result["identifier"] = elementId }
290
+ if !elementDesc.isEmpty { result["description"] = elementDesc }
291
+ if !elementSubrole.isEmpty { result["subrole"] = elementSubrole }
292
+
293
+ // Get position for coordinates
294
+ if let posValue = getAttribute(element, kAXPositionAttribute) {
295
+ var point = CGPoint.zero
296
+ if AXValueGetValue(posValue as! AXValue, .cgPoint, &point) {
297
+ if let sizeValue = getAttribute(element, kAXSizeAttribute) {
298
+ var size = CGSize.zero
299
+ if AXValueGetValue(sizeValue as! AXValue, .cgSize, &size) {
300
+ result["bounds"] = [
301
+ "x": Double(point.x), "y": Double(point.y),
302
+ "width": Double(size.width), "height": Double(size.height)
303
+ ]
304
+ }
305
+ }
306
+ }
307
+ }
308
+
309
+ return result
310
+ }
311
+
312
+ // Search children (with breadth + depth + budget limits)
313
+ if depth < maxDepth {
314
+ if let children = getAttribute(element, kAXChildrenAttribute) as? [AXUIElement] {
315
+ for (index, child) in children.enumerated() {
316
+ if index >= AccessibilityBridge.searchSiblingCap { break }
317
+ if nodesVisited > AccessibilityBridge.searchNodeBudget { break }
318
+ // Skip self-referential AXApplication children at app root
319
+ if isAppRoot {
320
+ let childRole = getAttribute(child, kAXRoleAttribute) as? String ?? ""
321
+ if childRole == "AXApplication" { continue }
322
+ }
323
+ var childPath = path
324
+ childPath.append(index)
325
+ if let found = searchElement(
326
+ element: child, path: childPath, depth: depth + 1, maxDepth: maxDepth,
327
+ nodesVisited: &nodesVisited,
328
+ role: role, title: title,
329
+ value: value, identifier: identifier, exact: exact
330
+ ) {
331
+ return found
332
+ }
333
+ }
334
+ }
335
+ }
336
+
337
+ return nil
338
+ }
339
+
340
+ // MARK: - Actions
341
+
342
+ func performAction(pid: pid_t, elementPath: [Int], action: String, expectedTitle: String? = nil) throws {
343
+ let element = try resolveElement(pid: pid, path: elementPath, expectedTitle: expectedTitle)
344
+ let result = AXUIElementPerformAction(element, action as CFString)
345
+ if result != .success {
346
+ throw BridgeError.general("AX action '\(action)' failed with code \(result.rawValue)")
347
+ }
348
+ }
349
+
350
+ func setElementValue(pid: pid_t, elementPath: [Int], value: String) throws {
351
+ let element = try resolveElement(pid: pid, path: elementPath)
352
+ let result = AXUIElementSetAttributeValue(element, kAXValueAttribute as CFString, value as CFTypeRef)
353
+ if result != .success {
354
+ // Try focused approach: set focus then type
355
+ let focusResult = AXUIElementSetAttributeValue(element, kAXFocusedAttribute as CFString, true as CFTypeRef)
356
+ if focusResult != .success {
357
+ throw BridgeError.general("Cannot focus element for value set, code \(focusResult.rawValue)")
358
+ }
359
+ // Use CG to type the value — PID-targeted to the correct process
360
+ CoreGraphicsBridge().typeText(text: value, targetPid: pid)
361
+ }
362
+ // Verify the value was actually set — some apps (Notes, etc.) silently ignore AXSetValue
363
+ usleep(50_000) // 50ms settle time
364
+ let readBack = getAttribute(element, kAXValueAttribute).flatMap { "\($0)" } ?? ""
365
+ if !readBack.contains(value.prefix(20)) && readBack != value {
366
+ throw BridgeError.general("Value set reported success but verification failed — element still shows \"\(String(readBack.prefix(60)))\" instead of \"\(String(value.prefix(60)))\". This element may not support programmatic value changes.")
367
+ }
368
+ }
369
+
370
+ func getElementValue(pid: pid_t, elementPath: [Int]) throws -> [String: Any] {
371
+ let element = try resolveElement(pid: pid, path: elementPath)
372
+ let value = getAttribute(element, kAXValueAttribute)
373
+ return ["value": value.flatMap { "\($0)" } ?? ""]
374
+ }
375
+
376
+ // MARK: - Menu Click
377
+
378
+ func menuClick(pid: pid_t, menuPath: [String]) throws {
379
+ guard !menuPath.isEmpty else {
380
+ throw BridgeError.missingParam("menuPath must not be empty")
381
+ }
382
+
383
+ let appElement = AXUIElementCreateApplication(pid)
384
+ guard let menuBar = getAttribute(appElement, kAXMenuBarAttribute) as AnyObject? else {
385
+ throw BridgeError.notFound("Menu bar not found")
386
+ }
387
+ let menuBarElement = menuBar as! AXUIElement
388
+
389
+ var currentElement: AXUIElement = menuBarElement
390
+
391
+ for menuItem in menuPath {
392
+ guard let children = getAttribute(currentElement, kAXChildrenAttribute) as? [AXUIElement] else {
393
+ throw BridgeError.notFound("No children found in menu for '\(menuItem)'")
394
+ }
395
+
396
+ var found = false
397
+ // Strip invisible Unicode direction marks (LTR U+200E, RTL U+200F, etc.)
398
+ // that apps like WhatsApp prepend to menu titles.
399
+ let cleanItem = menuItem.filter { !$0.unicodeScalars.allSatisfy { s in
400
+ (0x200B...0x200F).contains(s.value) || (0x2028...0x202F).contains(s.value) ||
401
+ (0xFEFF...0xFEFF).contains(s.value)
402
+ }}
403
+ for child in children {
404
+ let rawTitle = getAttribute(child, kAXTitleAttribute) as? String ?? ""
405
+ let title = rawTitle.filter { !$0.unicodeScalars.allSatisfy { s in
406
+ (0x200B...0x200F).contains(s.value) || (0x2028...0x202F).contains(s.value) ||
407
+ (0xFEFF...0xFEFF).contains(s.value)
408
+ }}
409
+ if title == cleanItem || rawTitle == menuItem {
410
+ // Press this menu item to open it (for submenus) or activate it
411
+ AXUIElementPerformAction(child, kAXPressAction as CFString)
412
+
413
+ // Poll for children to appear (max 500ms, 50ms intervals)
414
+ // instead of a fixed 100ms sleep
415
+ let pollDeadline = Date().addingTimeInterval(0.5)
416
+ var submenuResolved = false
417
+ while Date() < pollDeadline {
418
+ if let submenu = getAttribute(child, kAXChildrenAttribute) as? [AXUIElement],
419
+ let firstChild = submenu.first {
420
+ currentElement = firstChild
421
+ submenuResolved = true
422
+ break
423
+ }
424
+ Thread.sleep(forTimeInterval: 0.05)
425
+ }
426
+
427
+ // If no submenu appeared after polling, still check once more
428
+ if !submenuResolved {
429
+ if let submenu = getAttribute(child, kAXChildrenAttribute) as? [AXUIElement],
430
+ let firstChild = submenu.first {
431
+ currentElement = firstChild
432
+ }
433
+ }
434
+
435
+ found = true
436
+ break
437
+ }
438
+ }
439
+
440
+ if !found {
441
+ throw BridgeError.notFound("Menu item '\(menuItem)' not found")
442
+ }
443
+ }
444
+ }
445
+
446
+ // MARK: - Helpers
447
+
448
+ private func resolveElement(pid: pid_t, path: [Int], expectedTitle: String? = nil) throws -> AXUIElement {
449
+ var current = AXUIElementCreateApplication(pid) as AXUIElement
450
+ for index in path {
451
+ guard let children = getAttribute(current, kAXChildrenAttribute) as? [AXUIElement] else {
452
+ throw BridgeError.notFound("No children at path index \(index)")
453
+ }
454
+ guard index < children.count else {
455
+ throw BridgeError.notFound("Index \(index) out of bounds (count: \(children.count))")
456
+ }
457
+ current = children[index]
458
+ }
459
+ // Verify the resolved element still matches the expected identity
460
+ if let expected = expectedTitle {
461
+ let actualTitle = getAttribute(current, kAXTitleAttribute) as? String ?? ""
462
+ let actualDesc = getAttribute(current, kAXDescriptionAttribute) as? String ?? ""
463
+ if actualTitle != expected && actualDesc != expected {
464
+ throw BridgeError.general("Element at path has changed: expected '\(expected)' but found '\(actualTitle.isEmpty ? actualDesc : actualTitle)'")
465
+ }
466
+ }
467
+ return current
468
+ }
469
+
470
+ func getAttribute(_ element: AXUIElement, _ attribute: String) -> AnyObject? {
471
+ var value: AnyObject?
472
+ let result = AXUIElementCopyAttributeValue(element, attribute as CFString, &value)
473
+ return result == .success ? value : nil
474
+ }
475
+
476
+ private func matchString(_ haystack: String, _ needle: String, exact: Bool) -> Bool {
477
+ if exact {
478
+ return haystack == needle
479
+ }
480
+ return haystack.localizedCaseInsensitiveContains(needle)
481
+ }
482
+ }