@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.
@@ -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
+ }