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,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
|
+
}
|