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,339 @@
1
+ import AppKit
2
+ import Foundation
3
+
4
+ class AppManagement {
5
+
6
+ private let ax: AccessibilityBridge
7
+
8
+ init(ax: AccessibilityBridge) {
9
+ self.ax = ax
10
+ }
11
+
12
+ func launchApp(bundleId: String) throws -> [String: Any] {
13
+ let workspace = NSWorkspace.shared
14
+
15
+ guard let url = workspace.urlForApplication(withBundleIdentifier: bundleId) else {
16
+ throw BridgeError.notFound("Application with bundle ID '\(bundleId)' not found")
17
+ }
18
+
19
+ let config = NSWorkspace.OpenConfiguration()
20
+ config.activates = true
21
+
22
+ let semaphore = DispatchSemaphore(value: 0)
23
+ var launchedApp: NSRunningApplication?
24
+ var launchError: Error?
25
+
26
+ workspace.openApplication(at: url, configuration: config) { app, error in
27
+ launchedApp = app
28
+ launchError = error
29
+ semaphore.signal()
30
+ }
31
+
32
+ semaphore.wait()
33
+
34
+ if let error = launchError {
35
+ throw BridgeError.general("Failed to launch '\(bundleId)': \(error.localizedDescription)")
36
+ }
37
+
38
+ guard let app = launchedApp else {
39
+ throw BridgeError.general("Launch returned nil for '\(bundleId)'")
40
+ }
41
+
42
+ // Wait for the app to finish launching (up to 10 seconds)
43
+ let deadline = Date().addingTimeInterval(10)
44
+ while !app.isFinishedLaunching && Date() < deadline {
45
+ Thread.sleep(forTimeInterval: 0.1)
46
+ }
47
+
48
+ return [
49
+ "bundleId": bundleId,
50
+ "appName": app.localizedName ?? bundleId,
51
+ "pid": Int(app.processIdentifier),
52
+ "windowTitle": "",
53
+ ]
54
+ }
55
+
56
+ func focusApp(bundleId: String) throws {
57
+ guard let app = NSRunningApplication.runningApplications(withBundleIdentifier: bundleId).first else {
58
+ throw BridgeError.notFound("No running application with bundle ID '\(bundleId)'")
59
+ }
60
+
61
+ // Attempt 1: activateIgnoringOtherApps (strongest NSRunningApplication API)
62
+ app.activate(options: .activateIgnoringOtherApps)
63
+
64
+ // Poll up to 400ms for focus to switch
65
+ let pollEnd = Date().addingTimeInterval(0.4)
66
+ while Date() < pollEnd {
67
+ if NSWorkspace.shared.frontmostApplication?.bundleIdentifier == bundleId {
68
+ return
69
+ }
70
+ Thread.sleep(forTimeInterval: 0.05)
71
+ }
72
+
73
+ // Attempt 2: AppleScript activation via bundle ID — goes through Apple Events,
74
+ // often succeeds when NSRunningApplication.activate fails (e.g. VS Code holding focus).
75
+ // Uses "id bundleId" form to avoid issues with Unicode chars in app names (WhatsApp).
76
+ let script = NSAppleScript(source: "tell application id \"\(bundleId)\" to activate")
77
+ script?.executeAndReturnError(nil)
78
+
79
+ // Poll up to 400ms more
80
+ let asEnd = Date().addingTimeInterval(0.4)
81
+ while Date() < asEnd {
82
+ if NSWorkspace.shared.frontmostApplication?.bundleIdentifier == bundleId {
83
+ return
84
+ }
85
+ Thread.sleep(forTimeInterval: 0.05)
86
+ }
87
+
88
+ // Attempt 3: Raise the main window via AX API
89
+ let pid = app.processIdentifier
90
+ let appElement = AXUIElementCreateApplication(pid)
91
+ if let axWindows = self.ax.getAttribute(appElement, kAXWindowsAttribute) as? [AXUIElement],
92
+ let mainWindow = axWindows.first {
93
+ AXUIElementPerformAction(mainWindow, kAXRaiseAction as CFString)
94
+ // One more activate after raising — the combo often works when neither alone does
95
+ app.activate(options: .activateIgnoringOtherApps)
96
+ }
97
+
98
+ // Final poll — 300ms
99
+ let finalEnd = Date().addingTimeInterval(0.3)
100
+ while Date() < finalEnd {
101
+ if NSWorkspace.shared.frontmostApplication?.bundleIdentifier == bundleId {
102
+ return
103
+ }
104
+ Thread.sleep(forTimeInterval: 0.05)
105
+ }
106
+
107
+ // Focus never changed — report honestly
108
+ let actual = NSWorkspace.shared.frontmostApplication?.bundleIdentifier ?? "unknown"
109
+ throw BridgeError.general("Focus failed: \(actual) is frontmost instead of \(bundleId)")
110
+ }
111
+
112
+ /// Focus a specific window by its CG windowId.
113
+ /// Activates the owning app and raises the target window via AX.
114
+ func focusWindow(windowId: Int) throws {
115
+ // Find the window's owner PID from CG
116
+ guard let windowList = CGWindowListCopyWindowInfo([.optionOnScreenOnly, .excludeDesktopElements], kCGNullWindowID) as? [[String: Any]] else {
117
+ throw BridgeError.notFound("Cannot enumerate windows")
118
+ }
119
+ guard let target = windowList.first(where: { ($0[kCGWindowNumber as String] as? Int) == windowId }),
120
+ let ownerPid = target[kCGWindowOwnerPID as String] as? Int else {
121
+ throw BridgeError.notFound("Window \(windowId) not found")
122
+ }
123
+
124
+ // Activate the owning app
125
+ if let app = NSRunningApplication(processIdentifier: pid_t(ownerPid)) {
126
+ app.activate(options: .activateIgnoringOtherApps)
127
+ }
128
+
129
+ // Raise the specific window via AX
130
+ let appElement = AXUIElementCreateApplication(pid_t(ownerPid))
131
+ guard let axWindows = self.ax.getAttribute(appElement, kAXWindowsAttribute) as? [AXUIElement] else {
132
+ return // App activated, but can't raise specific window
133
+ }
134
+
135
+ // Match AX window to CG window by position
136
+ var targetPos = CGPoint.zero
137
+ if let bounds = target[kCGWindowBounds as String] as? [String: Any] {
138
+ targetPos.x = CGFloat((bounds["X"] as? NSNumber)?.doubleValue ?? 0)
139
+ targetPos.y = CGFloat((bounds["Y"] as? NSNumber)?.doubleValue ?? 0)
140
+ }
141
+
142
+ for axWin in axWindows {
143
+ var axPos = CGPoint.zero
144
+ if let posValue = self.ax.getAttribute(axWin, kAXPositionAttribute) {
145
+ AXValueGetValue(posValue as! AXValue, .cgPoint, &axPos)
146
+ }
147
+ if abs(axPos.x - targetPos.x) < 2 && abs(axPos.y - targetPos.y) < 2 {
148
+ AXUIElementPerformAction(axWin, kAXRaiseAction as CFString)
149
+ return
150
+ }
151
+ }
152
+ }
153
+
154
+ func listRunningApps() -> [[String: Any]] {
155
+ let workspace = NSWorkspace.shared
156
+ return workspace.runningApplications
157
+ .filter { $0.activationPolicy == .regular }
158
+ .map { app in
159
+ [
160
+ "bundleId": app.bundleIdentifier ?? "unknown",
161
+ "name": app.localizedName ?? "Unknown",
162
+ "pid": Int(app.processIdentifier),
163
+ "isActive": app.isActive,
164
+ ] as [String: Any]
165
+ }
166
+ }
167
+
168
+ func listWindows() -> [[String: Any]] {
169
+ // Use .optionAll to include minimized/off-screen windows (e.g. Safari minimized to dock).
170
+ // layer == 0 filter below already excludes desktop elements and system overlays.
171
+ guard let windowList = CGWindowListCopyWindowInfo([.optionAll, .excludeDesktopElements], kCGNullWindowID) as? [[String: Any]] else {
172
+ return []
173
+ }
174
+
175
+ return windowList.compactMap { window -> [String: Any]? in
176
+ guard let windowId = window[kCGWindowNumber as String] as? Int,
177
+ let ownerPid = window[kCGWindowOwnerPID as String] as? Int,
178
+ let boundsRaw = window[kCGWindowBounds as String],
179
+ let layer = window[kCGWindowLayer as String] as? Int,
180
+ layer == 0 else { // Only normal windows (layer 0)
181
+ return nil
182
+ }
183
+
184
+ // Parse bounds — CGWindowListCopyWindowInfo returns a dict with CGFloat values
185
+ var rect = CGRect.zero
186
+ if let boundsDict = boundsRaw as? [String: Any] {
187
+ let bx = (boundsDict["X"] as? NSNumber)?.doubleValue ?? 0
188
+ let by = (boundsDict["Y"] as? NSNumber)?.doubleValue ?? 0
189
+ let bw = (boundsDict["Width"] as? NSNumber)?.doubleValue ?? 0
190
+ let bh = (boundsDict["Height"] as? NSNumber)?.doubleValue ?? 0
191
+ rect = CGRect(x: bx, y: by, width: bw, height: bh)
192
+ }
193
+
194
+ let title = window[kCGWindowName as String] as? String ?? ""
195
+ let ownerName = window[kCGWindowOwnerName as String] as? String ?? ""
196
+ let isOnScreen = window[kCGWindowIsOnscreen as String] as? Bool ?? true
197
+
198
+ // Skip zero-size windows (system placeholders, UI services)
199
+ if rect.width < 10 && rect.height < 10 { return nil }
200
+
201
+ // Look up bundle ID from PID
202
+ let bundleId = NSRunningApplication(processIdentifier: pid_t(ownerPid))?.bundleIdentifier ?? ""
203
+
204
+ return [
205
+ "windowId": windowId,
206
+ "title": title,
207
+ "bundleId": bundleId,
208
+ "pid": ownerPid,
209
+ "appName": ownerName,
210
+ "bounds": [
211
+ "x": Double(rect.origin.x),
212
+ "y": Double(rect.origin.y),
213
+ "width": Double(rect.size.width),
214
+ "height": Double(rect.size.height),
215
+ ] as [String: Double],
216
+ "isOnScreen": isOnScreen,
217
+ ]
218
+ }
219
+ }
220
+
221
+ /// List windows with AX-enriched metadata (focused, isMain) merged onto CG window data.
222
+ /// This is the preferred method for window resolution — it tells callers which window
223
+ /// is actually focused/main, avoiding wrong-window attachment.
224
+ ///
225
+ /// Matching uses a multi-signal join: position + title + size to avoid mis-annotation
226
+ /// when windows share geometry or move during polling.
227
+ func listWindowsWithAX() -> [[String: Any]] {
228
+ var cgWindows = listWindows()
229
+
230
+ // Group by PID so we only query AX for apps that have windows
231
+ var pidSet = Set<Int>()
232
+ for win in cgWindows {
233
+ if let pid = win["pid"] as? Int { pidSet.insert(pid) }
234
+ }
235
+
236
+ // For each PID, get AX windows and extract focused/isMain
237
+ struct AXWindowInfo {
238
+ let posX: Double
239
+ let posY: Double
240
+ let width: Double
241
+ let height: Double
242
+ let title: String
243
+ let focused: Bool
244
+ let isMain: Bool
245
+ let subrole: String
246
+ }
247
+
248
+ var axInfoByPid: [Int: [AXWindowInfo]] = [:]
249
+
250
+ for pid in pidSet {
251
+ let appElement = AXUIElementCreateApplication(pid_t(pid))
252
+ guard let axWindows = self.ax.getAttribute(appElement, kAXWindowsAttribute) as? [AXUIElement] else {
253
+ continue
254
+ }
255
+
256
+ var infos: [AXWindowInfo] = []
257
+ for axWin in axWindows {
258
+ var pos = CGPoint.zero
259
+ var size = CGSize.zero
260
+ if let posValue = self.ax.getAttribute(axWin, kAXPositionAttribute) {
261
+ AXValueGetValue(posValue as! AXValue, .cgPoint, &pos)
262
+ }
263
+ if let sizeValue = self.ax.getAttribute(axWin, kAXSizeAttribute) {
264
+ AXValueGetValue(sizeValue as! AXValue, .cgSize, &size)
265
+ }
266
+ let title = self.ax.getAttribute(axWin, kAXTitleAttribute) as? String ?? ""
267
+ let focused = self.ax.getAttribute(axWin, kAXFocusedAttribute) as? Bool ?? false
268
+ let isMain = self.ax.getAttribute(axWin, kAXMainAttribute) as? Bool ?? false
269
+ let subrole = self.ax.getAttribute(axWin, kAXSubroleAttribute) as? String ?? ""
270
+ infos.append(AXWindowInfo(
271
+ posX: Double(pos.x), posY: Double(pos.y),
272
+ width: Double(size.width), height: Double(size.height),
273
+ title: title,
274
+ focused: focused, isMain: isMain, subrole: subrole
275
+ ))
276
+ }
277
+ axInfoByPid[pid] = infos
278
+ }
279
+
280
+ // Merge AX info into CG windows using multi-signal matching
281
+ for i in 0..<cgWindows.count {
282
+ guard let pid = cgWindows[i]["pid"] as? Int,
283
+ let bounds = cgWindows[i]["bounds"] as? [String: Double],
284
+ let bx = bounds["x"], let by = bounds["y"],
285
+ let bw = bounds["width"], let bh = bounds["height"],
286
+ let infos = axInfoByPid[pid] else {
287
+ continue
288
+ }
289
+
290
+ let cgTitle = cgWindows[i]["title"] as? String ?? ""
291
+
292
+ // Score each AX window — higher score = better match
293
+ var bestScore = 0
294
+ var bestInfo: AXWindowInfo? = nil
295
+
296
+ for info in infos {
297
+ var score = 0
298
+ // Position match (within 2px) — required baseline
299
+ let posMatch = abs(info.posX - bx) < 2 && abs(info.posY - by) < 2
300
+ if !posMatch { continue }
301
+ score += 10
302
+
303
+ // Size match (within 5px)
304
+ if abs(info.width - bw) < 5 && abs(info.height - bh) < 5 {
305
+ score += 5
306
+ }
307
+
308
+ // Title match — strongest signal for disambiguation
309
+ if !cgTitle.isEmpty && !info.title.isEmpty && cgTitle == info.title {
310
+ score += 20
311
+ }
312
+
313
+ if score > bestScore {
314
+ bestScore = score
315
+ bestInfo = info
316
+ }
317
+ }
318
+
319
+ if let info = bestInfo {
320
+ cgWindows[i]["focused"] = info.focused
321
+ cgWindows[i]["isMain"] = info.isMain
322
+ cgWindows[i]["subrole"] = info.subrole
323
+ }
324
+ }
325
+
326
+ return cgWindows
327
+ }
328
+
329
+ func frontmostApp() -> [String: Any] {
330
+ guard let app = NSWorkspace.shared.frontmostApplication else {
331
+ return ["error": "No frontmost application"]
332
+ }
333
+ return [
334
+ "bundleId": app.bundleIdentifier ?? "unknown",
335
+ "name": app.localizedName ?? "Unknown",
336
+ "pid": Int(app.processIdentifier),
337
+ ]
338
+ }
339
+ }