ax.js
1 import { execFileSync, execSync } from 'node:child_process'; 2 const AX_READ_SCRIPT = ` 3 import Cocoa 4 import ApplicationServices 5 6 func attr(_ el: AXUIElement, _ name: String) -> AnyObject? { 7 var value: CFTypeRef? 8 guard AXUIElementCopyAttributeValue(el, name as CFString, &value) == .success else { return nil } 9 return value as AnyObject? 10 } 11 12 func s(_ el: AXUIElement, _ name: String) -> String? { 13 if let v = attr(el, name) as? String, !v.isEmpty { return v } 14 return nil 15 } 16 17 func children(_ el: AXUIElement) -> [AXUIElement] { 18 (attr(el, kAXChildrenAttribute as String) as? [AnyObject] ?? []).map { $0 as! AXUIElement } 19 } 20 21 func collectLists(_ el: AXUIElement, into out: inout [AXUIElement]) { 22 let role = s(el, kAXRoleAttribute as String) ?? "" 23 if role == kAXListRole as String { out.append(el) } 24 for c in children(el) { collectLists(c, into: &out) } 25 } 26 27 func collectTexts(_ el: AXUIElement, into out: inout [String]) { 28 let role = s(el, kAXRoleAttribute as String) ?? "" 29 if role == kAXStaticTextRole as String { 30 if let text = s(el, kAXDescriptionAttribute as String), !text.isEmpty { 31 out.append(text) 32 } 33 } 34 for c in children(el) { collectTexts(c, into: &out) } 35 } 36 37 guard let app = NSRunningApplication.runningApplications(withBundleIdentifier: "com.openai.chat").first else { 38 fputs("ChatGPT not running\\n", stderr) 39 exit(1) 40 } 41 42 let axApp = AXUIElementCreateApplication(app.processIdentifier) 43 guard let win = attr(axApp, kAXFocusedWindowAttribute as String) as! AXUIElement? else { 44 fputs("No focused ChatGPT window\\n", stderr) 45 exit(1) 46 } 47 48 var lists: [AXUIElement] = [] 49 collectLists(win, into: &lists) 50 51 var best: [String] = [] 52 for list in lists { 53 var texts: [String] = [] 54 collectTexts(list, into: &texts) 55 if texts.count > best.count { 56 best = texts 57 } 58 } 59 60 let data = try! JSONSerialization.data(withJSONObject: best, options: []) 61 print(String(data: data, encoding: .utf8)!) 62 `; 63 const AX_SEND_SCRIPT = ` 64 import Cocoa 65 import ApplicationServices 66 67 func attr(_ el: AXUIElement, _ name: String) -> AnyObject? { 68 var value: CFTypeRef? 69 guard AXUIElementCopyAttributeValue(el, name as CFString, &value) == .success else { return nil } 70 return value as AnyObject? 71 } 72 73 func s(_ el: AXUIElement, _ name: String) -> String? { 74 if let v = attr(el, name) as? String { return v } 75 return nil 76 } 77 78 func isEnabled(_ el: AXUIElement) -> Bool { 79 (attr(el, kAXEnabledAttribute as String) as? Bool) ?? true 80 } 81 82 func children(_ el: AXUIElement) -> [AXUIElement] { 83 (attr(el, kAXChildrenAttribute as String) as? [AnyObject] ?? []).map { $0 as! AXUIElement } 84 } 85 86 func collectEditableInputs(_ el: AXUIElement, into out: inout [AXUIElement], depth: Int = 0) { 87 guard depth < 25 else { return } 88 let role = s(el, kAXRoleAttribute as String) ?? "" 89 if (role == kAXTextAreaRole as String || role == kAXTextFieldRole as String) && isEnabled(el) { 90 out.append(el) 91 } 92 for c in children(el) { collectEditableInputs(c, into: &out, depth: depth + 1) } 93 } 94 95 func isInput(_ el: AXUIElement) -> Bool { 96 let role = s(el, kAXRoleAttribute as String) ?? "" 97 return role == kAXTextAreaRole as String || role == kAXTextFieldRole as String 98 } 99 100 func focusedInput(_ axApp: AXUIElement) -> AXUIElement? { 101 guard let focused = attr(axApp, kAXFocusedUIElementAttribute as String) as! AXUIElement? else { 102 return nil 103 } 104 return isInput(focused) && isEnabled(focused) ? focused : nil 105 } 106 107 func findByDescriptions(_ el: AXUIElement, _ targets: [String], depth: Int = 0) -> AXUIElement? { 108 guard depth < 25 else { return nil } 109 let role = s(el, kAXRoleAttribute as String) ?? "" 110 let desc = s(el, kAXDescriptionAttribute as String) ?? "" 111 if role == "AXButton" && targets.contains(desc) && isEnabled(el) { return el } 112 for c in children(el) { 113 if let found = findByDescriptions(c, targets, depth: depth + 1) { return found } 114 } 115 return nil 116 } 117 118 func press(_ el: AXUIElement) { 119 AXUIElementPerformAction(el, kAXPressAction as CFString) 120 } 121 122 let args = CommandLine.arguments 123 guard args.count > 1 else { 124 fputs("Missing prompt text\\n", stderr) 125 exit(1) 126 } 127 let text = args[1] 128 129 guard let app = NSRunningApplication.runningApplications(withBundleIdentifier: "com.openai.chat").first else { 130 fputs("ChatGPT not running\\n", stderr) 131 exit(1) 132 } 133 134 let axApp = AXUIElementCreateApplication(app.processIdentifier) 135 guard let win = attr(axApp, kAXFocusedWindowAttribute as String) as! AXUIElement? else { 136 fputs("No focused ChatGPT window\\n", stderr) 137 exit(1) 138 } 139 140 var inputs: [AXUIElement] = [] 141 collectEditableInputs(win, into: &inputs) 142 guard let input = focusedInput(axApp) ?? inputs.last else { 143 fputs("Could not find editable input area\\n", stderr) 144 exit(1) 145 } 146 147 guard AXUIElementSetAttributeValue(input, kAXValueAttribute as CFString, text as CFTypeRef) == .success else { 148 fputs("Failed to set input value\\n", stderr) 149 exit(1) 150 } 151 152 Thread.sleep(forTimeInterval: 0.2) 153 154 guard s(input, kAXValueAttribute as String) == text else { 155 fputs("Failed to verify input value after AX set\\n", stderr) 156 exit(1) 157 } 158 159 guard let sendButton = findByDescriptions(win, ["发送", "Send"]) else { 160 fputs("Could not find send button\\n", stderr) 161 exit(1) 162 } 163 164 press(sendButton) 165 166 var submitted = false 167 for _ in 0..<15 { 168 Thread.sleep(forTimeInterval: 0.1) 169 if s(input, kAXValueAttribute as String) != text { 170 submitted = true 171 break 172 } 173 } 174 175 guard submitted else { 176 fputs("Prompt did not leave input after pressing send\\n", stderr) 177 exit(1) 178 } 179 180 print("Sent") 181 `; 182 const AX_MODEL_SCRIPT = ` 183 import Cocoa 184 import ApplicationServices 185 186 func attr(_ el: AXUIElement, _ name: String) -> AnyObject? { 187 var value: CFTypeRef? 188 guard AXUIElementCopyAttributeValue(el, name as CFString, &value) == .success else { return nil } 189 return value as AnyObject? 190 } 191 192 func s(_ el: AXUIElement, _ name: String) -> String? { 193 if let v = attr(el, name) as? String, !v.isEmpty { return v } 194 return nil 195 } 196 197 func children(_ el: AXUIElement) -> [AXUIElement] { 198 (attr(el, kAXChildrenAttribute as String) as? [AnyObject] ?? []).map { $0 as! AXUIElement } 199 } 200 201 func press(_ el: AXUIElement) { 202 AXUIElementPerformAction(el, kAXPressAction as CFString) 203 } 204 205 func findByDesc(_ el: AXUIElement, _ target: String, prefix: Bool = false, depth: Int = 0) -> AXUIElement? { 206 guard depth < 20 else { return nil } 207 let desc = s(el, kAXDescriptionAttribute as String) ?? "" 208 if prefix ? desc.hasPrefix(target) : (desc == target) { return el } 209 for c in children(el) { 210 if let found = findByDesc(c, target, prefix: prefix, depth: depth + 1) { return found } 211 } 212 return nil 213 } 214 215 func findPopover(_ el: AXUIElement, depth: Int = 0) -> AXUIElement? { 216 guard depth < 20 else { return nil } 217 let role = s(el, kAXRoleAttribute as String) ?? "" 218 if role == "AXPopover" { return el } 219 for c in children(el) { 220 if let found = findPopover(c, depth: depth + 1) { return found } 221 } 222 return nil 223 } 224 225 func pressEscape() { 226 let src = CGEventSource(stateID: .combinedSessionState) 227 if let esc = CGEvent(keyboardEventSource: src, virtualKey: 0x35, keyDown: true) { esc.post(tap: .cghidEventTap) } 228 if let esc = CGEvent(keyboardEventSource: src, virtualKey: 0x35, keyDown: false) { esc.post(tap: .cghidEventTap) } 229 } 230 231 guard let app = NSRunningApplication.runningApplications(withBundleIdentifier: "com.openai.chat").first else { 232 fputs("ChatGPT not running\\n", stderr); exit(1) 233 } 234 let axApp = AXUIElementCreateApplication(app.processIdentifier) 235 guard let win = attr(axApp, kAXFocusedWindowAttribute as String) as! AXUIElement? else { 236 fputs("No focused ChatGPT window\\n", stderr); exit(1) 237 } 238 239 let args = CommandLine.arguments 240 let target = args.count > 1 ? args[1] : "" 241 let needsLegacy = args.count > 2 && args[2] == "legacy" 242 243 // Step 1: Click the "Options" button to open the popover (support both English and Chinese UI) 244 var optionsBtn: AXUIElement? = nil 245 if let btn = findByDesc(win, "Options") { optionsBtn = btn } 246 else if let btn = findByDesc(win, "选项") { optionsBtn = btn } 247 guard let options = optionsBtn else { 248 fputs("Could not find Options button\\n", stderr); exit(1) 249 } 250 press(options) 251 Thread.sleep(forTimeInterval: 0.8) 252 253 // Step 2: Find the popover that appeared, search ONLY within it 254 guard let popover = findPopover(win) else { 255 pressEscape() 256 fputs("Popover did not appear\\n", stderr); exit(1) 257 } 258 259 // Step 3: If legacy, click "Legacy models" to expand submenu 260 if needsLegacy { 261 guard let legacyBtn = findByDesc(popover, "Legacy models") else { 262 pressEscape() 263 fputs("Could not find Legacy models button\\n", stderr); exit(1) 264 } 265 press(legacyBtn) 266 Thread.sleep(forTimeInterval: 0.8) 267 } 268 269 // Step 4: Click the target model button within the popover (prefix match) 270 guard let modelBtn = findByDesc(popover, target, prefix: true) else { 271 pressEscape() 272 fputs("Could not find button starting with '\\(target)' in popover\\n", stderr); exit(1) 273 } 274 press(modelBtn) 275 print("Selected: \\(target)") 276 `; 277 const AX_GENERATING_SCRIPT = ` 278 import Cocoa 279 import ApplicationServices 280 281 func attr(_ el: AXUIElement, _ name: String) -> AnyObject? { 282 var value: CFTypeRef? 283 guard AXUIElementCopyAttributeValue(el, name as CFString, &value) == .success else { return nil } 284 return value as AnyObject? 285 } 286 287 func s(_ el: AXUIElement, _ name: String) -> String? { 288 if let v = attr(el, name) as? String, !v.isEmpty { return v } 289 return nil 290 } 291 292 func children(_ el: AXUIElement) -> [AXUIElement] { 293 (attr(el, kAXChildrenAttribute as String) as? [AnyObject] ?? []).map { $0 as! AXUIElement } 294 } 295 296 func hasButton(_ el: AXUIElement, desc target: String, depth: Int = 0) -> Bool { 297 guard depth < 15 else { return false } 298 let role = s(el, kAXRoleAttribute as String) ?? "" 299 let desc = s(el, kAXDescriptionAttribute as String) ?? "" 300 if role == "AXButton" && desc == target { return true } 301 for c in children(el) { 302 if hasButton(c, desc: target, depth: depth + 1) { return true } 303 } 304 return false 305 } 306 307 guard let app = NSRunningApplication.runningApplications(withBundleIdentifier: "com.openai.chat").first else { 308 print("false"); exit(0) 309 } 310 let axApp = AXUIElementCreateApplication(app.processIdentifier) 311 guard let win = attr(axApp, kAXFocusedWindowAttribute as String) as! AXUIElement? else { 312 print("false"); exit(0) 313 } 314 let targets = ["Stop generating", "停止生成"] 315 print(targets.contains(where: { hasButton(win, desc: $0) }) ? "true" : "false") 316 `; 317 const MODEL_MAP = { 318 'auto': { desc: 'Auto' }, 319 'instant': { desc: 'Instant' }, 320 'thinking': { desc: 'Thinking' }, 321 '5.2-instant': { desc: 'GPT-5.2 Instant', legacy: true }, 322 '5.2-thinking': { desc: 'GPT-5.2 Thinking', legacy: true }, 323 }; 324 export const MODEL_CHOICES = Object.keys(MODEL_MAP); 325 export function activateChatGPT(delaySeconds = 0.5) { 326 execSync("osascript -e 'tell application \"ChatGPT\" to activate'"); 327 execSync(`osascript -e 'delay ${delaySeconds}'`); 328 } 329 export function selectModel(model) { 330 const entry = MODEL_MAP[model]; 331 if (!entry) { 332 throw new Error(`Unknown model "${model}". Choose from: ${MODEL_CHOICES.join(', ')}`); 333 } 334 const swiftArgs = ['-', entry.desc]; 335 if (entry.legacy) 336 swiftArgs.push('legacy'); 337 const output = execFileSync('swift', swiftArgs, { 338 input: AX_MODEL_SCRIPT, 339 encoding: 'utf-8', 340 maxBuffer: 10 * 1024 * 1024, 341 }).trim(); 342 return output; 343 } 344 export function sendPrompt(text) { 345 return execFileSync('swift', ['-', text], { 346 input: AX_SEND_SCRIPT, 347 encoding: 'utf-8', 348 maxBuffer: 10 * 1024 * 1024, 349 }).trim(); 350 } 351 export function isGenerating() { 352 try { 353 const output = execFileSync('swift', ['-'], { 354 input: AX_GENERATING_SCRIPT, 355 encoding: 'utf-8', 356 maxBuffer: 10 * 1024 * 1024, 357 }).trim(); 358 return output === 'true'; 359 } 360 catch { 361 return false; 362 } 363 } 364 export function getVisibleChatMessages() { 365 const output = execFileSync('swift', ['-'], { 366 input: AX_READ_SCRIPT, 367 encoding: 'utf-8', 368 maxBuffer: 10 * 1024 * 1024, 369 }).trim(); 370 if (!output) 371 return []; 372 const parsed = JSON.parse(output); 373 if (!Array.isArray(parsed)) 374 return []; 375 return parsed 376 .filter((item) => typeof item === 'string') 377 .map((item) => item.replace(/[\uFFFC\u200B-\u200D\uFEFF]/g, '').trim()) 378 .filter((item) => item.length > 0); 379 } 380 export const __test__ = { 381 AX_SEND_SCRIPT, 382 AX_GENERATING_SCRIPT, 383 };