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.
- package/README.md +165 -446
- package/bin/darwin-arm64/macos-bridge +0 -0
- package/dist/mcp-desktop.js +3615 -400
- package/dist/scripts/export-help-center.js +112 -0
- package/dist/scripts/marketing-loop.js +117 -0
- package/dist/scripts/observer-daemon.js +288 -0
- package/dist/scripts/orchestrator-daemon.js +399 -0
- package/dist/scripts/threads-campaign.js +208 -0
- package/dist/src/community/fetcher.js +109 -0
- package/dist/src/community/index.js +6 -0
- package/dist/src/community/publisher.js +191 -0
- package/dist/src/community/remote-api.js +121 -0
- package/dist/src/community/types.js +3 -0
- package/dist/src/community/validator.js +95 -0
- package/dist/src/context-tracker.js +489 -0
- package/dist/src/ingestion/coverage-auditor.js +233 -0
- package/dist/src/ingestion/doc-parser.js +164 -0
- package/dist/src/ingestion/index.js +8 -0
- package/dist/src/ingestion/menu-scanner.js +152 -0
- package/dist/src/ingestion/reference-merger.js +186 -0
- package/dist/src/ingestion/shortcut-extractor.js +180 -0
- package/dist/src/ingestion/tutorial-extractor.js +170 -0
- package/dist/src/ingestion/types.js +3 -0
- package/dist/src/jobs/manager.js +82 -14
- package/dist/src/jobs/runner.js +138 -15
- package/dist/src/learning/engine.js +356 -0
- package/dist/src/learning/index.js +9 -0
- package/dist/src/learning/locator-policy.js +120 -0
- package/dist/src/learning/pattern-policy.js +89 -0
- package/dist/src/learning/recovery-policy.js +116 -0
- package/dist/src/learning/sensor-policy.js +115 -0
- package/dist/src/learning/timing-model.js +204 -0
- package/dist/src/learning/topology-policy.js +90 -0
- package/dist/src/learning/types.js +9 -0
- package/dist/src/logging/timeline-logger.js +4 -1
- package/dist/src/memory/playbook-seeds.js +200 -0
- package/dist/src/memory/recall.js +60 -8
- package/dist/src/memory/service.js +30 -5
- package/dist/src/memory/store.js +34 -5
- package/dist/src/native/bridge-client.js +253 -31
- package/dist/src/observer/state.js +199 -0
- package/dist/src/observer/types.js +43 -0
- package/dist/src/orchestrator/state.js +68 -0
- package/dist/src/orchestrator/types.js +22 -0
- package/dist/src/perception/ax-source.js +162 -0
- package/dist/src/perception/cdp-source.js +162 -0
- package/dist/src/perception/coordinator.js +771 -0
- package/dist/src/perception/frame-differ.js +287 -0
- package/dist/src/perception/index.js +22 -0
- package/dist/src/perception/manager.js +199 -0
- package/dist/src/perception/types.js +47 -0
- package/dist/src/perception/vision-source.js +399 -0
- package/dist/src/planner/deterministic.js +298 -0
- package/dist/src/planner/executor.js +870 -0
- package/dist/src/planner/goal-store.js +92 -0
- package/dist/src/planner/index.js +21 -0
- package/dist/src/planner/planner.js +520 -0
- package/dist/src/planner/tool-registry.js +71 -0
- package/dist/src/planner/types.js +22 -0
- package/dist/src/platform/explorer.js +213 -0
- package/dist/src/platform/help-center-markdown.js +527 -0
- package/dist/src/platform/learner.js +257 -0
- package/dist/src/playbook/engine.js +296 -11
- package/dist/src/playbook/mcp-recorder.js +204 -0
- package/dist/src/playbook/recorder.js +3 -2
- package/dist/src/playbook/runner.js +1 -1
- package/dist/src/playbook/store.js +139 -10
- package/dist/src/recovery/detectors.js +156 -0
- package/dist/src/recovery/engine.js +327 -0
- package/dist/src/recovery/index.js +20 -0
- package/dist/src/recovery/strategies.js +274 -0
- package/dist/src/recovery/types.js +20 -0
- package/dist/src/runtime/accessibility-adapter.js +55 -18
- package/dist/src/runtime/applescript-adapter.js +8 -2
- package/dist/src/runtime/cdp-chrome-adapter.js +1 -1
- package/dist/src/runtime/executor.js +23 -3
- package/dist/src/runtime/locator-cache.js +24 -2
- package/dist/src/runtime/service.js +59 -15
- package/dist/src/runtime/session-manager.js +4 -1
- package/dist/src/runtime/vision-adapter.js +2 -1
- package/dist/src/state/app-map-types.js +72 -0
- package/dist/src/state/app-map.js +1974 -0
- package/dist/src/state/entity-tracker.js +108 -0
- package/dist/src/state/fusion.js +96 -0
- package/dist/src/state/index.js +21 -0
- package/dist/src/state/ladder-generator.js +236 -0
- package/dist/src/state/persistence.js +156 -0
- package/dist/src/state/types.js +17 -0
- package/dist/src/state/world-model.js +1456 -0
- package/dist/src/util/atomic-write.js +19 -4
- package/dist/src/util/sanitize.js +146 -0
- package/dist-app-maps/com.figma.Desktop.json +959 -0
- package/dist-app-maps/com.hnc.Discord.json +1146 -0
- package/dist-app-maps/notion.id.json +2831 -0
- package/dist-playbooks/canva-screenhand-carousel.json +445 -0
- package/dist-playbooks/codex-desktop.json +76 -0
- package/dist-playbooks/competitor-research-stack.json +122 -0
- package/dist-playbooks/davinci-color-grade.json +153 -0
- package/dist-playbooks/davinci-edit-timeline.json +162 -0
- package/dist-playbooks/davinci-render.json +114 -0
- package/dist-playbooks/devto.json +52 -0
- package/dist-playbooks/discord.json +41 -0
- package/dist-playbooks/google-flow-create-project.json +59 -0
- package/dist-playbooks/google-flow-edit-image.json +90 -0
- package/dist-playbooks/google-flow-edit-video.json +90 -0
- package/dist-playbooks/google-flow-generate-image.json +68 -0
- package/dist-playbooks/google-flow-generate-video.json +191 -0
- package/dist-playbooks/google-flow-open-project.json +48 -0
- package/dist-playbooks/google-flow-open-scenebuilder.json +64 -0
- package/dist-playbooks/google-flow-search-assets.json +64 -0
- package/dist-playbooks/instagram.json +57 -0
- package/dist-playbooks/linkedin.json +52 -0
- package/dist-playbooks/n8n.json +43 -0
- package/dist-playbooks/reddit.json +52 -0
- package/dist-playbooks/threads.json +59 -0
- package/dist-playbooks/x-twitter.json +59 -0
- package/dist-playbooks/youtube.json +59 -0
- package/dist-references/canva.json +646 -0
- package/dist-references/codex-desktop.json +305 -0
- package/dist-references/davinci-resolve-keyboard.json +594 -0
- package/dist-references/davinci-resolve-menu-map.json +1139 -0
- package/dist-references/davinci-resolve-menus-batch1.json +116 -0
- package/dist-references/davinci-resolve-menus-batch2.json +372 -0
- package/dist-references/davinci-resolve-menus-batch3.json +330 -0
- package/dist-references/davinci-resolve-menus-batch4.json +297 -0
- package/dist-references/davinci-resolve-shortcuts.json +333 -0
- package/dist-references/devpost.json +186 -0
- package/dist-references/devto.json +317 -0
- package/dist-references/discord.json +549 -0
- package/dist-references/figma.json +1186 -0
- package/dist-references/finder.json +146 -0
- package/dist-references/google-ads-transparency.json +95 -0
- package/dist-references/google-flow.json +649 -0
- package/dist-references/instagram.json +341 -0
- package/dist-references/linkedin.json +324 -0
- package/dist-references/meta-ad-library.json +86 -0
- package/dist-references/n8n.json +387 -0
- package/dist-references/notes.json +27 -0
- package/dist-references/notion.json +163 -0
- package/dist-references/reddit.json +341 -0
- package/dist-references/threads.json +337 -0
- package/dist-references/x-twitter.json +403 -0
- package/dist-references/youtube.json +373 -0
- package/native/macos-bridge/Package.swift +22 -0
- package/native/macos-bridge/Sources/AccessibilityBridge.swift +482 -0
- package/native/macos-bridge/Sources/AppManagement.swift +339 -0
- package/native/macos-bridge/Sources/CoreGraphicsBridge.swift +537 -0
- package/native/macos-bridge/Sources/ObserverBridge.swift +120 -0
- package/native/macos-bridge/Sources/StreamCapture.swift +136 -0
- package/native/macos-bridge/Sources/VisionBridge.swift +238 -0
- package/native/macos-bridge/Sources/main.swift +498 -0
- package/native/windows-bridge/AppManagement.cs +234 -0
- package/native/windows-bridge/InputBridge.cs +436 -0
- package/native/windows-bridge/Program.cs +270 -0
- package/native/windows-bridge/ScreenCapture.cs +453 -0
- package/native/windows-bridge/UIAutomationBridge.cs +571 -0
- package/native/windows-bridge/WindowsBridge.csproj +17 -0
- package/package.json +12 -1
- package/scripts/postinstall.cjs +127 -0
- package/dist/.audit-log.jsonl +0 -55
- package/dist/.screenhand/memory/.lock +0 -1
- package/dist/.screenhand/memory/actions.jsonl +0 -85
- package/dist/.screenhand/memory/errors.jsonl +0 -5
- package/dist/.screenhand/memory/errors.jsonl.bak +0 -4
- package/dist/.screenhand/memory/state.json +0 -35
- package/dist/.screenhand/memory/state.json.bak +0 -35
- package/dist/.screenhand/memory/strategies.jsonl +0 -12
- package/dist/agent/cli.js +0 -73
- package/dist/agent/loop.js +0 -258
- package/dist/config.js +0 -9
- package/dist/index.js +0 -56
- package/dist/logging/timeline-logger.js +0 -29
- package/dist/mcp/mcp-stdio-server.js +0 -448
- package/dist/mcp/server.js +0 -347
- package/dist/mcp-entry.js +0 -59
- package/dist/memory/recall.js +0 -160
- package/dist/memory/research.js +0 -98
- package/dist/memory/seeds.js +0 -89
- package/dist/memory/session.js +0 -161
- package/dist/memory/store.js +0 -391
- package/dist/memory/types.js +0 -4
- package/dist/monitor/codex-monitor.js +0 -377
- package/dist/monitor/task-queue.js +0 -84
- package/dist/monitor/types.js +0 -49
- package/dist/native/bridge-client.js +0 -174
- package/dist/native/macos-bridge-client.js +0 -5
- package/dist/npm-publish-helper.js +0 -117
- package/dist/npm-token-cdp.js +0 -113
- package/dist/npm-token-create.js +0 -135
- package/dist/npm-token-finish.js +0 -126
- package/dist/playbook/engine.js +0 -193
- package/dist/playbook/index.js +0 -4
- package/dist/playbook/recorder.js +0 -519
- package/dist/playbook/runner.js +0 -392
- package/dist/playbook/store.js +0 -166
- package/dist/playbook/types.js +0 -4
- package/dist/runtime/accessibility-adapter.js +0 -377
- package/dist/runtime/app-adapter.js +0 -48
- package/dist/runtime/applescript-adapter.js +0 -283
- package/dist/runtime/ax-role-map.js +0 -80
- package/dist/runtime/browser-adapter.js +0 -36
- package/dist/runtime/cdp-chrome-adapter.js +0 -505
- package/dist/runtime/composite-adapter.js +0 -205
- package/dist/runtime/executor.js +0 -250
- package/dist/runtime/locator-cache.js +0 -12
- package/dist/runtime/planning-loop.js +0 -47
- package/dist/runtime/service.js +0 -372
- package/dist/runtime/session-manager.js +0 -28
- package/dist/runtime/state-observer.js +0 -105
- package/dist/runtime/vision-adapter.js +0 -208
- package/dist/test-mcp-protocol.js +0 -138
- 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
|
+
}
|