@kortix/agent-tunnel 0.1.0
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/package.json +37 -0
- package/src/agent/agent.ts +331 -0
- package/src/agent/capabilities/desktop/atspi-helper.ts +345 -0
- package/src/agent/capabilities/desktop/csharp-helper.ts +914 -0
- package/src/agent/capabilities/desktop/linux-driver.ts +368 -0
- package/src/agent/capabilities/desktop/macos-driver.ts +601 -0
- package/src/agent/capabilities/desktop/swift-helper.ts +736 -0
- package/src/agent/capabilities/desktop/types.ts +201 -0
- package/src/agent/capabilities/desktop/windows-driver.ts +220 -0
- package/src/agent/capabilities/desktop.ts +196 -0
- package/src/agent/capabilities/filesystem.ts +133 -0
- package/src/agent/capabilities/index.ts +42 -0
- package/src/agent/capabilities/shell.ts +96 -0
- package/src/agent/cli.ts +222 -0
- package/src/agent/config.ts +54 -0
- package/src/agent/index.ts +11 -0
- package/src/agent/security/command-validator.ts +61 -0
- package/src/agent/security/path-validator.ts +55 -0
- package/src/agent/security/permission-guard.ts +66 -0
- package/src/client/index.ts +4 -0
- package/src/client/tools.ts +603 -0
- package/src/client/tunnel-client.ts +270 -0
- package/src/index.ts +62 -0
- package/src/server/heartbeat.ts +84 -0
- package/src/server/index.ts +7 -0
- package/src/server/relay.ts +266 -0
- package/src/server/routes.ts +61 -0
- package/src/server/server.ts +114 -0
- package/src/server/ws-handler.ts +54 -0
- package/src/shared/crypto.ts +58 -0
- package/src/shared/index.ts +33 -0
- package/src/shared/types.ts +164 -0
|
@@ -0,0 +1,736 @@
|
|
|
1
|
+
import { spawn } from 'child_process';
|
|
2
|
+
import { existsSync, mkdirSync, writeFileSync, chmodSync } from 'fs';
|
|
3
|
+
import { join } from 'path';
|
|
4
|
+
import { homedir } from 'os';
|
|
5
|
+
|
|
6
|
+
const HELPER_VERSION = 'v4';
|
|
7
|
+
const BIN_DIR = join(homedir(), '.kortix-tunnel', 'bin');
|
|
8
|
+
const HELPER_PATH = join(BIN_DIR, `desktop-helper-${HELPER_VERSION}`);
|
|
9
|
+
|
|
10
|
+
const SWIFT_SOURCE = `
|
|
11
|
+
import Foundation
|
|
12
|
+
import CoreGraphics
|
|
13
|
+
|
|
14
|
+
struct Request: Decodable {
|
|
15
|
+
let action: String
|
|
16
|
+
let x: Double?
|
|
17
|
+
let y: Double?
|
|
18
|
+
let toX: Double?
|
|
19
|
+
let toY: Double?
|
|
20
|
+
let button: String?
|
|
21
|
+
let clicks: Int?
|
|
22
|
+
let modifiers: [String]?
|
|
23
|
+
let deltaX: Int?
|
|
24
|
+
let deltaY: Int?
|
|
25
|
+
let keys: [String]?
|
|
26
|
+
let pid: Int?
|
|
27
|
+
let maxDepth: Int?
|
|
28
|
+
let roles: [String]?
|
|
29
|
+
let elementId: String?
|
|
30
|
+
let action_name: String?
|
|
31
|
+
let query: String?
|
|
32
|
+
let role: String?
|
|
33
|
+
let maxResults: Int?
|
|
34
|
+
let value: String?
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
struct Response: Encodable {
|
|
38
|
+
let ok: Bool
|
|
39
|
+
let x: Double?
|
|
40
|
+
let y: Double?
|
|
41
|
+
let error: String?
|
|
42
|
+
let elements: String?
|
|
43
|
+
let elementCount: Int?
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// ─── AX Helpers ──────────────────────────────────────────────
|
|
47
|
+
import ApplicationServices
|
|
48
|
+
|
|
49
|
+
var axElementCount = 0
|
|
50
|
+
|
|
51
|
+
func esc(_ s: String) -> String {
|
|
52
|
+
return s.replacingOccurrences(of: "\\\\", with: "\\\\\\\\")
|
|
53
|
+
.replacingOccurrences(of: "\\"", with: "\\\\\\"")
|
|
54
|
+
.replacingOccurrences(of: "\\n", with: "\\\\n")
|
|
55
|
+
.replacingOccurrences(of: "\\r", with: "\\\\r")
|
|
56
|
+
.replacingOccurrences(of: "\\t", with: "\\\\t")
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
func axStr(_ element: AXUIElement, _ attr: String) -> String {
|
|
60
|
+
var ref: AnyObject?
|
|
61
|
+
let err = AXUIElementCopyAttributeValue(element, attr as CFString, &ref)
|
|
62
|
+
if err != .success { return "" }
|
|
63
|
+
if let s = ref as? String { return s }
|
|
64
|
+
if let n = ref as? NSNumber { return n.stringValue }
|
|
65
|
+
if ref != nil { return "\\(ref!)" }
|
|
66
|
+
return ""
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
struct AXProps {
|
|
70
|
+
var role: String
|
|
71
|
+
var subrole: String
|
|
72
|
+
var title: String
|
|
73
|
+
var value: String
|
|
74
|
+
var description: String
|
|
75
|
+
var label: String
|
|
76
|
+
var roleDescription: String
|
|
77
|
+
var placeholder: String
|
|
78
|
+
var identifier: String
|
|
79
|
+
var help: String
|
|
80
|
+
var bounds: (x: Int, y: Int, w: Int, h: Int)
|
|
81
|
+
var enabled: Bool
|
|
82
|
+
var focused: Bool
|
|
83
|
+
var actions: [String]
|
|
84
|
+
var children: [AXUIElement]
|
|
85
|
+
|
|
86
|
+
// All searchable text combined
|
|
87
|
+
var searchText: String {
|
|
88
|
+
return [title, value, description, label, roleDescription, placeholder, identifier, help]
|
|
89
|
+
.joined(separator: " ")
|
|
90
|
+
.lowercased()
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Best display label
|
|
94
|
+
var displayLabel: String {
|
|
95
|
+
if !title.isEmpty { return title }
|
|
96
|
+
if !label.isEmpty { return label }
|
|
97
|
+
if !value.isEmpty {
|
|
98
|
+
let v = value.count > 60 ? String(value.prefix(60)) + "…" : value
|
|
99
|
+
return v
|
|
100
|
+
}
|
|
101
|
+
if !description.isEmpty { return description }
|
|
102
|
+
if !roleDescription.isEmpty { return roleDescription }
|
|
103
|
+
if !placeholder.isEmpty { return "[\\(placeholder)]" }
|
|
104
|
+
if !help.isEmpty { return help }
|
|
105
|
+
return "(unnamed)"
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
func toJson(id: String) -> String {
|
|
109
|
+
var json = "{"
|
|
110
|
+
json += "\\"id\\":\\"\\(esc(id))\\""
|
|
111
|
+
json += ",\\"role\\":\\"\\(esc(role))\\""
|
|
112
|
+
if !subrole.isEmpty { json += ",\\"subrole\\":\\"\\(esc(subrole))\\"" }
|
|
113
|
+
json += ",\\"title\\":\\"\\(esc(displayLabel))\\""
|
|
114
|
+
json += ",\\"value\\":\\"\\(esc(value))\\""
|
|
115
|
+
json += ",\\"description\\":\\"\\(esc(description))\\""
|
|
116
|
+
if !label.isEmpty { json += ",\\"label\\":\\"\\(esc(label))\\"" }
|
|
117
|
+
if !placeholder.isEmpty { json += ",\\"placeholder\\":\\"\\(esc(placeholder))\\"" }
|
|
118
|
+
if !identifier.isEmpty { json += ",\\"identifier\\":\\"\\(esc(identifier))\\"" }
|
|
119
|
+
json += ",\\"bounds\\":{\\"x\\":\\(bounds.x),\\"y\\":\\(bounds.y),\\"width\\":\\(bounds.w),\\"height\\":\\(bounds.h)}"
|
|
120
|
+
json += ",\\"enabled\\":\\(enabled)"
|
|
121
|
+
json += ",\\"focused\\":\\(focused)"
|
|
122
|
+
json += ",\\"actions\\":["
|
|
123
|
+
json += actions.map { "\\"\\(esc($0))\\"" }.joined(separator: ",")
|
|
124
|
+
json += "]"
|
|
125
|
+
return json
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
func readAXProps(_ element: AXUIElement) -> AXProps {
|
|
130
|
+
let role = axStr(element, kAXRoleAttribute as String)
|
|
131
|
+
let subrole = axStr(element, kAXSubroleAttribute as String)
|
|
132
|
+
let title = axStr(element, kAXTitleAttribute as String)
|
|
133
|
+
let description = axStr(element, kAXDescriptionAttribute as String)
|
|
134
|
+
let label = axStr(element, "AXLabel")
|
|
135
|
+
let roleDescription = axStr(element, kAXRoleDescriptionAttribute as String)
|
|
136
|
+
let placeholder = axStr(element, kAXPlaceholderValueAttribute as String)
|
|
137
|
+
let identifier = axStr(element, "AXIdentifier")
|
|
138
|
+
let help = axStr(element, kAXHelpAttribute as String)
|
|
139
|
+
|
|
140
|
+
// Value: read carefully, handle different types
|
|
141
|
+
var valueStr = ""
|
|
142
|
+
var valueRef: AnyObject?
|
|
143
|
+
let valErr = AXUIElementCopyAttributeValue(element, kAXValueAttribute as CFString, &valueRef)
|
|
144
|
+
if valErr == .success, let v = valueRef {
|
|
145
|
+
if let s = v as? String { valueStr = s }
|
|
146
|
+
else if let n = v as? NSNumber { valueStr = n.stringValue }
|
|
147
|
+
else { valueStr = "\\(v)" }
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Bounds
|
|
151
|
+
var posPoint = CGPoint.zero
|
|
152
|
+
var posRef: AnyObject?
|
|
153
|
+
if AXUIElementCopyAttributeValue(element, kAXPositionAttribute as CFString, &posRef) == .success, let p = posRef {
|
|
154
|
+
AXValueGetValue(p as! AXValue, .cgPoint, &posPoint)
|
|
155
|
+
}
|
|
156
|
+
var sizVal = CGSize.zero
|
|
157
|
+
var sizRef: AnyObject?
|
|
158
|
+
if AXUIElementCopyAttributeValue(element, kAXSizeAttribute as CFString, &sizRef) == .success, let s = sizRef {
|
|
159
|
+
AXValueGetValue(s as! AXValue, .cgSize, &sizVal)
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// States
|
|
163
|
+
var en: AnyObject?
|
|
164
|
+
AXUIElementCopyAttributeValue(element, kAXEnabledAttribute as CFString, &en)
|
|
165
|
+
let enabled = (en as? Bool) ?? true
|
|
166
|
+
var foc: AnyObject?
|
|
167
|
+
AXUIElementCopyAttributeValue(element, kAXFocusedAttribute as CFString, &foc)
|
|
168
|
+
let focused = (foc as? Bool) ?? false
|
|
169
|
+
|
|
170
|
+
// Actions
|
|
171
|
+
var actionsArray: CFArray?
|
|
172
|
+
AXUIElementCopyActionNames(element, &actionsArray)
|
|
173
|
+
let actions = (actionsArray as? [String]) ?? []
|
|
174
|
+
|
|
175
|
+
// Children
|
|
176
|
+
var childrenRef: AnyObject?
|
|
177
|
+
AXUIElementCopyAttributeValue(element, kAXChildrenAttribute as CFString, &childrenRef)
|
|
178
|
+
let children = (childrenRef as? [AXUIElement]) ?? []
|
|
179
|
+
|
|
180
|
+
return AXProps(
|
|
181
|
+
role: role, subrole: subrole, title: title, value: valueStr,
|
|
182
|
+
description: description, label: label, roleDescription: roleDescription,
|
|
183
|
+
placeholder: placeholder, identifier: identifier, help: help,
|
|
184
|
+
bounds: (Int(posPoint.x), Int(posPoint.y), Int(sizVal.width), Int(sizVal.height)),
|
|
185
|
+
enabled: enabled, focused: focused, actions: actions, children: children
|
|
186
|
+
)
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
func axTreeToJson(_ element: AXUIElement, depth: Int, maxDepth: Int, roles: [String]?, pathPrefix: String) -> String? {
|
|
190
|
+
if depth > maxDepth { return nil }
|
|
191
|
+
axElementCount += 1
|
|
192
|
+
|
|
193
|
+
let p = readAXProps(element)
|
|
194
|
+
|
|
195
|
+
// If role filter active and this element doesn't match, skip but walk children
|
|
196
|
+
if let r = roles, !r.isEmpty, !r.contains(p.role.lowercased()) {
|
|
197
|
+
var childJsons: [String] = []
|
|
198
|
+
for (i, child) in p.children.enumerated() {
|
|
199
|
+
let childPath = pathPrefix.isEmpty ? "\\(i)" : "\\(pathPrefix).\\(i)"
|
|
200
|
+
if let cj = axTreeToJson(child, depth: depth, maxDepth: maxDepth, roles: roles, pathPrefix: childPath) {
|
|
201
|
+
childJsons.append(cj)
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
return childJsons.isEmpty ? nil : childJsons.joined(separator: ",")
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
var json = p.toJson(id: pathPrefix)
|
|
208
|
+
|
|
209
|
+
json += ",\\"children\\":["
|
|
210
|
+
if depth < maxDepth {
|
|
211
|
+
var childJsons: [String] = []
|
|
212
|
+
for (i, child) in p.children.enumerated() {
|
|
213
|
+
let childPath = pathPrefix.isEmpty ? "\\(i)" : "\\(pathPrefix).\\(i)"
|
|
214
|
+
if let cj = axTreeToJson(child, depth: depth + 1, maxDepth: maxDepth, roles: roles, pathPrefix: childPath) {
|
|
215
|
+
childJsons.append(cj)
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
json += childJsons.joined(separator: ",")
|
|
219
|
+
}
|
|
220
|
+
json += "]}"
|
|
221
|
+
return json
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
func navigateToElement(_ root: AXUIElement, path: String) -> AXUIElement? {
|
|
225
|
+
let parts = path.split(separator: ".").compactMap { Int($0) }
|
|
226
|
+
var current = root
|
|
227
|
+
for idx in parts {
|
|
228
|
+
var childrenRef: AnyObject?
|
|
229
|
+
AXUIElementCopyAttributeValue(current, kAXChildrenAttribute as CFString, &childrenRef)
|
|
230
|
+
guard let children = childrenRef as? [AXUIElement], idx < children.count else { return nil }
|
|
231
|
+
current = children[idx]
|
|
232
|
+
}
|
|
233
|
+
return current
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
func resolveAppElement(_ pid: Int) -> AXUIElement {
|
|
237
|
+
if pid > 0 {
|
|
238
|
+
return AXUIElementCreateApplication(pid_t(pid))
|
|
239
|
+
}
|
|
240
|
+
// pid=0: get the focused (frontmost) application
|
|
241
|
+
let systemWide = AXUIElementCreateSystemWide()
|
|
242
|
+
var focusedApp: AnyObject?
|
|
243
|
+
let err = AXUIElementCopyAttributeValue(systemWide, kAXFocusedApplicationAttribute as CFString, &focusedApp)
|
|
244
|
+
if err == .success, let app = focusedApp {
|
|
245
|
+
return (app as! AXUIElement)
|
|
246
|
+
}
|
|
247
|
+
// fallback to system-wide (limited)
|
|
248
|
+
return systemWide
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
func searchAXTree(_ element: AXUIElement, query: String, roleFilter: String?, maxResults: Int, results: inout [String], pathPrefix: String, depth: Int, maxDepth: Int) {
|
|
252
|
+
if results.count >= maxResults || depth > maxDepth { return }
|
|
253
|
+
|
|
254
|
+
let p = readAXProps(element)
|
|
255
|
+
let q = query.lowercased()
|
|
256
|
+
|
|
257
|
+
// Search across ALL text attributes
|
|
258
|
+
var match = p.searchText.contains(q)
|
|
259
|
+
|
|
260
|
+
if let rf = roleFilter, !rf.isEmpty, p.role.lowercased() != rf.lowercased() {
|
|
261
|
+
match = false
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
if match {
|
|
265
|
+
results.append(p.toJson(id: pathPrefix) + ",\\"children\\":[]}")
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// Walk children
|
|
269
|
+
for (i, child) in p.children.enumerated() {
|
|
270
|
+
if results.count >= maxResults { break }
|
|
271
|
+
let childPath = pathPrefix.isEmpty ? "\\(i)" : "\\(pathPrefix).\\(i)"
|
|
272
|
+
searchAXTree(child, query: query, roleFilter: roleFilter, maxResults: maxResults, results: &results, pathPrefix: childPath, depth: depth + 1, maxDepth: maxDepth)
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
func modifierFlags(_ names: [String]) -> CGEventFlags {
|
|
277
|
+
var flags = CGEventFlags()
|
|
278
|
+
for name in names {
|
|
279
|
+
switch name.lowercased() {
|
|
280
|
+
case "cmd", "command": flags.insert(.maskCommand)
|
|
281
|
+
case "shift": flags.insert(.maskShift)
|
|
282
|
+
case "alt", "option": flags.insert(.maskAlternate)
|
|
283
|
+
case "ctrl", "control": flags.insert(.maskControl)
|
|
284
|
+
case "fn": flags.insert(.maskSecondaryFn)
|
|
285
|
+
default: break
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
return flags
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
let keyMap: [String: UInt16] = [
|
|
292
|
+
"return": 36, "enter": 36, "tab": 48, "space": 49, "delete": 51, "backspace": 51,
|
|
293
|
+
"escape": 53, "esc": 53,
|
|
294
|
+
"up": 126, "down": 125, "left": 123, "right": 124,
|
|
295
|
+
"f1": 122, "f2": 120, "f3": 99, "f4": 118, "f5": 96, "f6": 97,
|
|
296
|
+
"f7": 98, "f8": 100, "f9": 101, "f10": 109, "f11": 103, "f12": 111,
|
|
297
|
+
"home": 115, "end": 119, "pageup": 116, "pagedown": 121,
|
|
298
|
+
"a": 0, "b": 11, "c": 8, "d": 2, "e": 14, "f": 3, "g": 5, "h": 4,
|
|
299
|
+
"i": 34, "j": 38, "k": 40, "l": 37, "m": 46, "n": 45, "o": 31,
|
|
300
|
+
"p": 35, "q": 12, "r": 15, "s": 1, "t": 17, "u": 32, "v": 9,
|
|
301
|
+
"w": 13, "x": 7, "y": 16, "z": 6,
|
|
302
|
+
"0": 29, "1": 18, "2": 19, "3": 20, "4": 21, "5": 23,
|
|
303
|
+
"6": 22, "7": 26, "8": 28, "9": 25,
|
|
304
|
+
"-": 27, "=": 24, "[": 33, "]": 30, "\\\\": 42, ";": 41,
|
|
305
|
+
"'": 39, ",": 43, ".": 47, "/": 44, "\`": 50,
|
|
306
|
+
]
|
|
307
|
+
|
|
308
|
+
func mouseButton(_ name: String?) -> CGMouseButton {
|
|
309
|
+
switch name?.lowercased() {
|
|
310
|
+
case "right": return .right
|
|
311
|
+
case "middle": return .center
|
|
312
|
+
default: return .left
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
func mouseDownType(_ btn: CGMouseButton) -> CGEventType {
|
|
317
|
+
switch btn {
|
|
318
|
+
case .right: return .rightMouseDown
|
|
319
|
+
case .center: return .otherMouseDown
|
|
320
|
+
default: return .leftMouseDown
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
func mouseUpType(_ btn: CGMouseButton) -> CGEventType {
|
|
325
|
+
switch btn {
|
|
326
|
+
case .right: return .rightMouseUp
|
|
327
|
+
case .center: return .otherMouseUp
|
|
328
|
+
default: return .leftMouseUp
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
func mouseDragType(_ btn: CGMouseButton) -> CGEventType {
|
|
333
|
+
switch btn {
|
|
334
|
+
case .right: return .rightMouseDragged
|
|
335
|
+
case .center: return .otherMouseDragged
|
|
336
|
+
default: return .leftMouseDragged
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
func respond(_ r: Response) {
|
|
341
|
+
let data = try! JSONEncoder().encode(r)
|
|
342
|
+
FileHandle.standardOutput.write(data)
|
|
343
|
+
FileHandle.standardOutput.write("\\n".data(using: .utf8)!)
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
func handleRequest(_ req: Request) {
|
|
347
|
+
switch req.action {
|
|
348
|
+
case "click":
|
|
349
|
+
let point = CGPoint(x: req.x ?? 0, y: req.y ?? 0)
|
|
350
|
+
let btn = mouseButton(req.button)
|
|
351
|
+
let clicks = req.clicks ?? 1
|
|
352
|
+
let mods = modifierFlags(req.modifiers ?? [])
|
|
353
|
+
|
|
354
|
+
for i in 0..<clicks {
|
|
355
|
+
if let down = CGEvent(mouseEventSource: nil, mouseType: mouseDownType(btn), mouseCursorPosition: point, mouseButton: btn) {
|
|
356
|
+
down.setIntegerValueField(.mouseEventClickState, value: Int64(i + 1))
|
|
357
|
+
if !mods.isEmpty { down.flags = mods }
|
|
358
|
+
down.post(tap: .cghidEventTap)
|
|
359
|
+
}
|
|
360
|
+
if let up = CGEvent(mouseEventSource: nil, mouseType: mouseUpType(btn), mouseCursorPosition: point, mouseButton: btn) {
|
|
361
|
+
up.setIntegerValueField(.mouseEventClickState, value: Int64(i + 1))
|
|
362
|
+
if !mods.isEmpty { up.flags = mods }
|
|
363
|
+
up.post(tap: .cghidEventTap)
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
respond(Response(ok: true, x: nil, y: nil, error: nil, elements: nil, elementCount: nil))
|
|
367
|
+
|
|
368
|
+
case "move":
|
|
369
|
+
let point = CGPoint(x: req.x ?? 0, y: req.y ?? 0)
|
|
370
|
+
if let event = CGEvent(mouseEventSource: nil, mouseType: .mouseMoved, mouseCursorPosition: point, mouseButton: .left) {
|
|
371
|
+
event.post(tap: .cghidEventTap)
|
|
372
|
+
}
|
|
373
|
+
respond(Response(ok: true, x: nil, y: nil, error: nil, elements: nil, elementCount: nil))
|
|
374
|
+
|
|
375
|
+
case "drag":
|
|
376
|
+
let from = CGPoint(x: req.x ?? 0, y: req.y ?? 0)
|
|
377
|
+
let to = CGPoint(x: req.toX ?? 0, y: req.toY ?? 0)
|
|
378
|
+
let btn = mouseButton(req.button)
|
|
379
|
+
|
|
380
|
+
if let down = CGEvent(mouseEventSource: nil, mouseType: mouseDownType(btn), mouseCursorPosition: from, mouseButton: btn) {
|
|
381
|
+
down.post(tap: .cghidEventTap)
|
|
382
|
+
}
|
|
383
|
+
usleep(50000)
|
|
384
|
+
|
|
385
|
+
let steps = 10
|
|
386
|
+
for i in 1...steps {
|
|
387
|
+
let t = Double(i) / Double(steps)
|
|
388
|
+
let mid = CGPoint(x: from.x + (to.x - from.x) * t, y: from.y + (to.y - from.y) * t)
|
|
389
|
+
if let drag = CGEvent(mouseEventSource: nil, mouseType: mouseDragType(btn), mouseCursorPosition: mid, mouseButton: btn) {
|
|
390
|
+
drag.post(tap: .cghidEventTap)
|
|
391
|
+
}
|
|
392
|
+
usleep(10000)
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
if let up = CGEvent(mouseEventSource: nil, mouseType: mouseUpType(btn), mouseCursorPosition: to, mouseButton: btn) {
|
|
396
|
+
up.post(tap: .cghidEventTap)
|
|
397
|
+
}
|
|
398
|
+
respond(Response(ok: true, x: nil, y: nil, error: nil, elements: nil, elementCount: nil))
|
|
399
|
+
|
|
400
|
+
case "scroll":
|
|
401
|
+
let point = CGPoint(x: req.x ?? 0, y: req.y ?? 0)
|
|
402
|
+
if let move = CGEvent(mouseEventSource: nil, mouseType: .mouseMoved, mouseCursorPosition: point, mouseButton: .left) {
|
|
403
|
+
move.post(tap: .cghidEventTap)
|
|
404
|
+
}
|
|
405
|
+
usleep(10000)
|
|
406
|
+
|
|
407
|
+
let dy = Int32(req.deltaY ?? 0)
|
|
408
|
+
let dx = Int32(req.deltaX ?? 0)
|
|
409
|
+
if let scroll = CGEvent(scrollWheelEvent2Source: nil, units: .pixel, wheelCount: 3, wheel1: dy, wheel2: dx, wheel3: 0) {
|
|
410
|
+
scroll.post(tap: .cghidEventTap)
|
|
411
|
+
}
|
|
412
|
+
respond(Response(ok: true, x: nil, y: nil, error: nil, elements: nil, elementCount: nil))
|
|
413
|
+
|
|
414
|
+
case "key":
|
|
415
|
+
let keys = req.keys ?? []
|
|
416
|
+
var mods: [String] = []
|
|
417
|
+
var mainKeys: [String] = []
|
|
418
|
+
|
|
419
|
+
for k in keys {
|
|
420
|
+
let lower = k.lowercased()
|
|
421
|
+
if ["cmd", "command", "shift", "alt", "option", "ctrl", "control", "fn"].contains(lower) {
|
|
422
|
+
mods.append(lower)
|
|
423
|
+
} else {
|
|
424
|
+
mainKeys.append(lower)
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
let flags = modifierFlags(mods)
|
|
429
|
+
|
|
430
|
+
for key in mainKeys {
|
|
431
|
+
guard let code = keyMap[key] else {
|
|
432
|
+
respond(Response(ok: false, x: nil, y: nil, error: "Unknown key: \\(key)", elements: nil, elementCount: nil))
|
|
433
|
+
return
|
|
434
|
+
}
|
|
435
|
+
if let down = CGEvent(keyboardEventSource: nil, virtualKey: code, keyDown: true) {
|
|
436
|
+
if !flags.isEmpty { down.flags = flags }
|
|
437
|
+
down.post(tap: .cghidEventTap)
|
|
438
|
+
}
|
|
439
|
+
if let up = CGEvent(keyboardEventSource: nil, virtualKey: code, keyDown: false) {
|
|
440
|
+
if !flags.isEmpty { up.flags = flags }
|
|
441
|
+
up.post(tap: .cghidEventTap)
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
respond(Response(ok: true, x: nil, y: nil, error: nil, elements: nil, elementCount: nil))
|
|
445
|
+
|
|
446
|
+
case "position":
|
|
447
|
+
let loc = CGEvent(source: nil)!.location
|
|
448
|
+
respond(Response(ok: true, x: Double(loc.x), y: Double(loc.y), error: nil, elements: nil, elementCount: nil))
|
|
449
|
+
|
|
450
|
+
case "ax_tree":
|
|
451
|
+
let pid = req.pid ?? 0
|
|
452
|
+
let maxD = req.maxDepth ?? 8
|
|
453
|
+
let rolesFilter = req.roles?.map { $0.lowercased() }
|
|
454
|
+
|
|
455
|
+
let appElement = resolveAppElement(pid)
|
|
456
|
+
|
|
457
|
+
axElementCount = 0
|
|
458
|
+
let treeJson = axTreeToJson(appElement, depth: 0, maxDepth: maxD, roles: rolesFilter, pathPrefix: "0") ?? "null"
|
|
459
|
+
let treeOut = "{\\"ok\\":true,\\"root\\":" + treeJson + ",\\"elementCount\\":" + "\\(axElementCount)" + "}"
|
|
460
|
+
FileHandle.standardOutput.write(treeOut.data(using: .utf8)!)
|
|
461
|
+
FileHandle.standardOutput.write("\\n".data(using: .utf8)!)
|
|
462
|
+
|
|
463
|
+
case "ax_action":
|
|
464
|
+
let pid = req.pid ?? 0
|
|
465
|
+
let elementId = req.elementId ?? "0"
|
|
466
|
+
let actionName = req.action_name ?? ""
|
|
467
|
+
|
|
468
|
+
let appElement = resolveAppElement(pid)
|
|
469
|
+
|
|
470
|
+
guard let target = navigateToElement(appElement, path: elementId) else {
|
|
471
|
+
respond(Response(ok: false, x: nil, y: nil, error: "Element not found: \\(elementId)", elements: nil, elementCount: nil))
|
|
472
|
+
return
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
// Read state BEFORE action
|
|
476
|
+
let beforeProps = readAXProps(target)
|
|
477
|
+
let beforeFocused = beforeProps.focused
|
|
478
|
+
let beforeValue = beforeProps.value
|
|
479
|
+
|
|
480
|
+
let result = AXUIElementPerformAction(target, actionName as CFString)
|
|
481
|
+
if result != .success {
|
|
482
|
+
respond(Response(ok: false, x: nil, y: nil, error: "Action failed: \\(actionName) (error \\(result.rawValue))", elements: nil, elementCount: nil))
|
|
483
|
+
return
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
// Brief pause for state to settle
|
|
487
|
+
usleep(50000)
|
|
488
|
+
|
|
489
|
+
// Read state AFTER action for verification
|
|
490
|
+
let afterProps = readAXProps(target)
|
|
491
|
+
var verifyJson = "{\\"ok\\":true"
|
|
492
|
+
verifyJson += ",\\"action\\":\\"\\(esc(actionName))\\""
|
|
493
|
+
verifyJson += ",\\"elementId\\":\\"\\(esc(elementId))\\""
|
|
494
|
+
verifyJson += ",\\"before\\":{\\"focused\\":\\(beforeFocused),\\"value\\":\\"\\(esc(beforeValue))\\"}"
|
|
495
|
+
verifyJson += ",\\"after\\":{\\"focused\\":\\(afterProps.focused),\\"value\\":\\"\\(esc(afterProps.value))\\"}"
|
|
496
|
+
verifyJson += ",\\"role\\":\\"\\(esc(afterProps.role))\\""
|
|
497
|
+
verifyJson += ",\\"title\\":\\"\\(esc(afterProps.displayLabel))\\""
|
|
498
|
+
let changed = (beforeFocused != afterProps.focused) || (beforeValue != afterProps.value)
|
|
499
|
+
verifyJson += ",\\"stateChanged\\":\\(changed)"
|
|
500
|
+
verifyJson += "}"
|
|
501
|
+
FileHandle.standardOutput.write(verifyJson.data(using: .utf8)!)
|
|
502
|
+
FileHandle.standardOutput.write("\\n".data(using: .utf8)!)
|
|
503
|
+
|
|
504
|
+
case "ax_set_value":
|
|
505
|
+
let pid = req.pid ?? 0
|
|
506
|
+
let elementId = req.elementId ?? "0"
|
|
507
|
+
let newValue = req.value ?? ""
|
|
508
|
+
|
|
509
|
+
let appElement = resolveAppElement(pid)
|
|
510
|
+
|
|
511
|
+
guard let target = navigateToElement(appElement, path: elementId) else {
|
|
512
|
+
respond(Response(ok: false, x: nil, y: nil, error: "Element not found: \\(elementId)", elements: nil, elementCount: nil))
|
|
513
|
+
return
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
// First focus the element
|
|
517
|
+
AXUIElementSetAttributeValue(target, kAXFocusedAttribute as CFString, kCFBooleanTrue)
|
|
518
|
+
usleep(30000)
|
|
519
|
+
|
|
520
|
+
// Set the value directly
|
|
521
|
+
let setResult = AXUIElementSetAttributeValue(target, kAXValueAttribute as CFString, newValue as CFTypeRef)
|
|
522
|
+
usleep(50000)
|
|
523
|
+
|
|
524
|
+
// Verify by reading back
|
|
525
|
+
let verifyValue = axStr(target, kAXValueAttribute as String)
|
|
526
|
+
let success = (setResult == .success) && (verifyValue == newValue || verifyValue.contains(newValue))
|
|
527
|
+
|
|
528
|
+
var svJson = "{\\"ok\\":\\(success)"
|
|
529
|
+
svJson += ",\\"elementId\\":\\"\\(esc(elementId))\\""
|
|
530
|
+
svJson += ",\\"requestedValue\\":\\"\\(esc(newValue))\\""
|
|
531
|
+
svJson += ",\\"actualValue\\":\\"\\(esc(verifyValue))\\""
|
|
532
|
+
if !success {
|
|
533
|
+
if setResult != .success {
|
|
534
|
+
svJson += ",\\"error\\":\\"SetAttributeValue failed (error \\(setResult.rawValue)). Element may not support direct value setting.\\""
|
|
535
|
+
} else {
|
|
536
|
+
svJson += ",\\"error\\":\\"Value was set but verification failed. Expected \\\\\\"\\" + esc(newValue) + \\"\\\\\\", got \\\\\\"\\"+ esc(verifyValue) + \\"\\\\\\"\\""
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
svJson += "}"
|
|
540
|
+
FileHandle.standardOutput.write(svJson.data(using: .utf8)!)
|
|
541
|
+
FileHandle.standardOutput.write("\\n".data(using: .utf8)!)
|
|
542
|
+
|
|
543
|
+
case "ax_focus":
|
|
544
|
+
let pid = req.pid ?? 0
|
|
545
|
+
let elementId = req.elementId ?? "0"
|
|
546
|
+
|
|
547
|
+
let appElement = resolveAppElement(pid)
|
|
548
|
+
|
|
549
|
+
guard let target = navigateToElement(appElement, path: elementId) else {
|
|
550
|
+
respond(Response(ok: false, x: nil, y: nil, error: "Element not found: \\(elementId)", elements: nil, elementCount: nil))
|
|
551
|
+
return
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
// Read focus state before
|
|
555
|
+
let beforeFocProps = readAXProps(target)
|
|
556
|
+
|
|
557
|
+
// Set focused attribute
|
|
558
|
+
let focResult = AXUIElementSetAttributeValue(target, kAXFocusedAttribute as CFString, kCFBooleanTrue)
|
|
559
|
+
usleep(50000)
|
|
560
|
+
|
|
561
|
+
// Verify focus
|
|
562
|
+
let afterFocProps = readAXProps(target)
|
|
563
|
+
let focSuccess = afterFocProps.focused
|
|
564
|
+
|
|
565
|
+
var focJson = "{\\"ok\\":\\(focSuccess)"
|
|
566
|
+
focJson += ",\\"elementId\\":\\"\\(esc(elementId))\\""
|
|
567
|
+
focJson += ",\\"role\\":\\"\\(esc(afterFocProps.role))\\""
|
|
568
|
+
focJson += ",\\"title\\":\\"\\(esc(afterFocProps.displayLabel))\\""
|
|
569
|
+
focJson += ",\\"before\\":{\\"focused\\":\\(beforeFocProps.focused)}"
|
|
570
|
+
focJson += ",\\"after\\":{\\"focused\\":\\(afterFocProps.focused)}"
|
|
571
|
+
if !focSuccess {
|
|
572
|
+
if focResult != .success {
|
|
573
|
+
focJson += ",\\"error\\":\\"SetAttributeValue(kAXFocusedAttribute) failed (error \\(focResult.rawValue))\\""
|
|
574
|
+
} else {
|
|
575
|
+
focJson += ",\\"error\\":\\"Focus was requested but element reports not focused. It may not be focusable.\\""
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
focJson += "}"
|
|
579
|
+
FileHandle.standardOutput.write(focJson.data(using: .utf8)!)
|
|
580
|
+
FileHandle.standardOutput.write("\\n".data(using: .utf8)!)
|
|
581
|
+
|
|
582
|
+
case "ax_search":
|
|
583
|
+
let pid = req.pid ?? 0
|
|
584
|
+
let query = req.query ?? ""
|
|
585
|
+
let roleFilter = req.role
|
|
586
|
+
let maxRes = req.maxResults ?? 20
|
|
587
|
+
|
|
588
|
+
let appElement = resolveAppElement(pid)
|
|
589
|
+
|
|
590
|
+
var searchResults: [String] = []
|
|
591
|
+
searchAXTree(appElement, query: query, roleFilter: roleFilter, maxResults: maxRes, results: &searchResults, pathPrefix: "0", depth: 0, maxDepth: 20)
|
|
592
|
+
|
|
593
|
+
let searchOut = "{\\"ok\\":true,\\"elements\\":[" + searchResults.joined(separator: ",") + "]}"
|
|
594
|
+
FileHandle.standardOutput.write(searchOut.data(using: .utf8)!)
|
|
595
|
+
FileHandle.standardOutput.write("\\n".data(using: .utf8)!)
|
|
596
|
+
|
|
597
|
+
default:
|
|
598
|
+
respond(Response(ok: false, x: nil, y: nil, error: "Unknown action: \\(req.action)", elements: nil, elementCount: nil))
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
let input = FileHandle.standardInput.readDataToEndOfFile()
|
|
603
|
+
guard let req = try? JSONDecoder().decode(Request.self, from: input) else {
|
|
604
|
+
respond(Response(ok: false, x: nil, y: nil, error: "Invalid JSON input", elements: nil, elementCount: nil))
|
|
605
|
+
exit(1)
|
|
606
|
+
}
|
|
607
|
+
handleRequest(req)
|
|
608
|
+
`;
|
|
609
|
+
|
|
610
|
+
let compiled = false;
|
|
611
|
+
|
|
612
|
+
export async function ensureHelper(): Promise<string> {
|
|
613
|
+
if (compiled && existsSync(HELPER_PATH)) return HELPER_PATH;
|
|
614
|
+
|
|
615
|
+
if (existsSync(HELPER_PATH)) {
|
|
616
|
+
compiled = true;
|
|
617
|
+
return HELPER_PATH;
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
mkdirSync(BIN_DIR, { recursive: true });
|
|
621
|
+
|
|
622
|
+
const srcPath = join(BIN_DIR, `desktop-helper-${HELPER_VERSION}.swift`);
|
|
623
|
+
writeFileSync(srcPath, SWIFT_SOURCE);
|
|
624
|
+
|
|
625
|
+
await new Promise<void>((resolve, reject) => {
|
|
626
|
+
const proc = spawn('swiftc', ['-O', '-o', HELPER_PATH, srcPath], {
|
|
627
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
628
|
+
});
|
|
629
|
+
|
|
630
|
+
let stderr = '';
|
|
631
|
+
proc.stderr?.on('data', (d: Buffer) => { stderr += d.toString(); });
|
|
632
|
+
|
|
633
|
+
proc.on('close', (code) => {
|
|
634
|
+
if (code === 0) {
|
|
635
|
+
chmodSync(HELPER_PATH, 0o755);
|
|
636
|
+
compiled = true;
|
|
637
|
+
resolve();
|
|
638
|
+
} else {
|
|
639
|
+
reject(new Error(`swiftc failed (exit ${code}): ${stderr}`));
|
|
640
|
+
}
|
|
641
|
+
});
|
|
642
|
+
|
|
643
|
+
proc.on('error', (err) => {
|
|
644
|
+
reject(new Error(`swiftc not found: ${err.message}. Install Xcode CLI tools: xcode-select --install`));
|
|
645
|
+
});
|
|
646
|
+
});
|
|
647
|
+
|
|
648
|
+
return HELPER_PATH;
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
export interface HelperRequest {
|
|
652
|
+
action: string;
|
|
653
|
+
x?: number;
|
|
654
|
+
y?: number;
|
|
655
|
+
toX?: number;
|
|
656
|
+
toY?: number;
|
|
657
|
+
button?: string;
|
|
658
|
+
clicks?: number;
|
|
659
|
+
modifiers?: string[];
|
|
660
|
+
deltaX?: number;
|
|
661
|
+
deltaY?: number;
|
|
662
|
+
keys?: string[];
|
|
663
|
+
pid?: number;
|
|
664
|
+
maxDepth?: number;
|
|
665
|
+
roles?: string[];
|
|
666
|
+
elementId?: string;
|
|
667
|
+
action_name?: string;
|
|
668
|
+
query?: string;
|
|
669
|
+
role?: string;
|
|
670
|
+
maxResults?: number;
|
|
671
|
+
value?: string;
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
export interface HelperResponse {
|
|
675
|
+
ok: boolean;
|
|
676
|
+
x?: number;
|
|
677
|
+
y?: number;
|
|
678
|
+
error?: string;
|
|
679
|
+
elements?: any[];
|
|
680
|
+
elementCount?: number;
|
|
681
|
+
root?: any;
|
|
682
|
+
// Verification fields for ax_action/ax_set_value/ax_focus
|
|
683
|
+
before?: { focused?: boolean; value?: string };
|
|
684
|
+
after?: { focused?: boolean; value?: string };
|
|
685
|
+
stateChanged?: boolean;
|
|
686
|
+
action?: string;
|
|
687
|
+
requestedValue?: string;
|
|
688
|
+
actualValue?: string;
|
|
689
|
+
role?: string;
|
|
690
|
+
title?: string;
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
export async function execHelper(request: HelperRequest): Promise<HelperResponse> {
|
|
694
|
+
const helperPath = await ensureHelper();
|
|
695
|
+
|
|
696
|
+
return new Promise((resolve, reject) => {
|
|
697
|
+
const proc = spawn(helperPath, [], {
|
|
698
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
699
|
+
});
|
|
700
|
+
|
|
701
|
+
let stdout = '';
|
|
702
|
+
let stderr = '';
|
|
703
|
+
|
|
704
|
+
proc.stdout.on('data', (d: Buffer) => { stdout += d.toString(); });
|
|
705
|
+
proc.stderr.on('data', (d: Buffer) => { stderr += d.toString(); });
|
|
706
|
+
|
|
707
|
+
proc.on('close', (code) => {
|
|
708
|
+
if (code !== 0) {
|
|
709
|
+
if (stderr.includes('accessibility') || stderr.includes('kAXError')) {
|
|
710
|
+
reject(new Error(
|
|
711
|
+
'Accessibility permission required. Open System Settings → Privacy & Security → Accessibility → Enable your terminal app.'
|
|
712
|
+
));
|
|
713
|
+
return;
|
|
714
|
+
}
|
|
715
|
+
reject(new Error(`Helper failed (exit ${code}): ${stderr}`));
|
|
716
|
+
return;
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
try {
|
|
720
|
+
const response = JSON.parse(stdout.trim()) as HelperResponse;
|
|
721
|
+
if (!response.ok && response.error) {
|
|
722
|
+
reject(new Error(response.error));
|
|
723
|
+
return;
|
|
724
|
+
}
|
|
725
|
+
resolve(response);
|
|
726
|
+
} catch {
|
|
727
|
+
reject(new Error(`Invalid helper output: ${stdout}`));
|
|
728
|
+
}
|
|
729
|
+
});
|
|
730
|
+
|
|
731
|
+
proc.on('error', reject);
|
|
732
|
+
|
|
733
|
+
proc.stdin.write(JSON.stringify(request));
|
|
734
|
+
proc.stdin.end();
|
|
735
|
+
});
|
|
736
|
+
}
|