返回 CodeWhale
darwin-accessibility.m
根目录 / crates / tui / plugins / computer-use / src / backends / darwin-accessibility.m
1 #import <Cocoa/Cocoa.h>
2 #import <ApplicationServices/ApplicationServices.h>
3 #import <dlfcn.h>
4 #include <unistd.h>
5 #include <signal.h>
6 #include <poll.h>
7 #include <fcntl.h>
8 #include <sys/file.h>
9 #include <sys/stat.h>
10 #import "darwin-recording.h"
11 #import "darwin-ocr.h"
12
13 static volatile sig_atomic_t cuCancelled = 0;
14 static BOOL cuOwnerPipe = NO;
15 static NSDictionary *cuLeaseKey = nil;
16 static pid_t cuLeasePid = 0;
17 static NSRunningApplication *cuLeaseApp = nil;
18 static BOOL cuLeaseButtons[3] = {NO,NO,NO};
19 static CGPoint cuLeasePoint;
20 #ifdef CU_TEST
21 static NSString *cuTestLockDir = nil;
22 static NSString *cuTestReleaseFile = nil;
23 static CGEventFlags cuTestInheritedTextFlags = 0;
24 static NSNumber *cuTestIdleSeconds = nil;
25 #endif
26 static void cuCancel(int signum) { cuCancelled = 1; }
27 static void cuCheckCancelled(void) {
28 if(cuOwnerPipe) { struct pollfd fd={STDIN_FILENO,POLLHUP,0}; if(poll(&fd,1,0)>0 && (fd.revents&POLLHUP)) cuCancelled=1; }
29 if(cuCancelled) @throw [NSException exceptionWithName:@"cancelled" reason:@"computer request cancelled" userInfo:nil];
30 }
31 static void cuLockInput(void) {
32 // One physical desktop, including separately launched direct MCP hosts.
33 // The kernel releases this lock if the native owner itself crashes.
34 NSString *dir=[NSHomeDirectory() stringByAppendingPathComponent:@".codewhale-cu"];
35 #ifdef CU_TEST
36 if(cuTestLockDir) dir=cuTestLockDir;
37 #endif
38 [NSFileManager.defaultManager createDirectoryAtPath:dir withIntermediateDirectories:YES attributes:@{NSFilePosixPermissions:@0700} error:nil];
39 int fd=open([[dir stringByAppendingPathComponent:@"input.lock"] fileSystemRepresentation],O_CREAT|O_RDWR|O_NOFOLLOW|O_CLOEXEC,0600);
40 if(fd<0) @throw [NSException exceptionWithName:@"input_lock" reason:[NSString stringWithFormat:@"cannot open Computer Use input ownership lock: %s",strerror(errno)] userInfo:nil];
41 struct stat st;
42 if(fd<0 || fstat(fd,&st)!=0 || !S_ISREG(st.st_mode) || st.st_uid!=getuid() || flock(fd,LOCK_EX|LOCK_NB)!=0) {
43 if(fd>=0) close(fd);
44 @throw [NSException exceptionWithName:@"input_busy" reason:@"another Computer Use session owns held input; release its key or pointer before sending input" userInfo:nil];
45 }
46 }
47 static BOOL cuFrontmostIsPid(pid_t pid);
48 static void cuRequireForeground(NSRunningApplication *expected) {
49 NSRunningApplication *actual=NSWorkspace.sharedWorkspace.frontmostApplication;
50 if(!cuFrontmostIsPid(expected.processIdentifier))
51 @throw [NSException exceptionWithName:@"focus" reason:[NSString stringWithFormat:@"foreground changed to %@ (pid %d); expected %@ (pid %d). No key-down or text was sent to the new foreground application.",actual.localizedName?:@"unknown application",actual.processIdentifier,expected.localizedName?:@"bound application",expected.processIdentifier] userInfo:nil];
52 }
53 static id cuPostKey(NSDictionary *args, pid_t destination) {
54 CGEventRef event=CGEventCreateKeyboardEvent(NULL,[args[@"code"] unsignedShortValue],[args[@"down"] boolValue]);
55 CGEventSetFlags(event,[args[@"flags"] unsignedLongLongValue]);
56 if([args[@"foreground_input"] boolValue]) CGEventPost(kCGHIDEventTap,event);
57 else CGEventPostToPid(destination,event);
58 CFRelease(event);
59 return @{@"action_sent":@YES};
60 }
61 static void cuPrint(id result) {
62 NSData *data=[NSJSONSerialization dataWithJSONObject:result options:NSJSONWritingFragmentsAllowed error:nil];
63 puts([[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding].UTF8String); fflush(stdout);
64 }
65 static void cuReleaseLease(void) {
66 #ifdef CU_TEST
67 if(cuTestReleaseFile) { [@"released" writeToFile:cuTestReleaseFile atomically:YES encoding:NSUTF8StringEncoding error:nil]; cuTestReleaseFile=nil; }
68 #endif
69 if(cuLeaseKey) {
70 NSMutableDictionary *up=[cuLeaseKey mutableCopy]; up[@"down"]=@NO;
71 if([up[@"foreground_input"] boolValue] || !cuLeaseApp.terminated) cuPostKey(up,cuLeasePid);
72 cuLeaseKey=nil; cuLeaseApp=nil;
73 }
74 for(int button=0;button<3;button++) if(cuLeaseButtons[button]) {
75 CGEventType up=button==0?kCGEventLeftMouseUp:button==1?kCGEventRightMouseUp:kCGEventOtherMouseUp;
76 CGEventRef event=CGEventCreateMouseEvent(NULL,up,cuLeasePoint,button);
77 CGEventPost(kCGHIDEventTap,event); CFRelease(event); cuLeaseButtons[button]=NO;
78 }
79 }
80 static void cuWaitForLease(void) {
81 NSMutableData *buffer=[NSMutableData data];
82 @try {
83 while(!cuCancelled) {
84 struct pollfd fd={STDIN_FILENO,POLLIN|POLLHUP,0};
85 int ready=poll(&fd,1,100);
86 if(ready<=0) continue;
87 char byte; ssize_t n=read(STDIN_FILENO,&byte,1);
88 if(n<=0) break;
89 if(byte!='\n') { if(buffer.length>=4096) break; [buffer appendBytes:&byte length:1]; continue; }
90 NSDictionary *message=[NSJSONSerialization JSONObjectWithData:buffer options:0 error:nil];
91 [buffer setLength:0];
92 if(![message isKindOfClass:NSDictionary.class]) break;
93 NSDictionary *point=message[@"point"];
94 if([point[@"x"] isKindOfClass:NSNumber.class] && [point[@"y"] isKindOfClass:NSNumber.class]) {
95 cuLeasePoint=CGPointMake([point[@"x"] doubleValue],[point[@"y"] doubleValue]);
96 }
97 if([message[@"release"] boolValue]) break;
98 cuCheckCancelled();
99 if(!cuLeaseButtons[0] || !point) break;
100 cuRequireForeground(cuLeaseApp);
101 CGEventSourceRef source=CGEventSourceCreate(kCGEventSourceStateHIDSystemState);
102 CGEventRef event=CGEventCreateMouseEvent(source,kCGEventLeftMouseDragged,cuLeasePoint,kCGMouseButtonLeft);
103 CGEventSetIntegerValueField(event,kCGMouseEventClickState,1);
104 CGEventPost(kCGHIDEventTap,event); CFRelease(event); CFRelease(source);
105 cuPrint(@{@"action_sent":@YES,@"restored":@NO});
106 }
107 } @finally { cuReleaseLease(); }
108 }
109
110 /**
111 * Window-routed events require exclusive foreground control. Addressing a
112 * window avoids cursor movement but its key-window lease redirects the
113 * person's keyboard. It must never be used as a background fallback.
114 */
115 static void cuRequireFocusControl(NSDictionary *args) {
116 if(![args[@"foreground_input"] boolValue])
117 @throw [NSException exceptionWithName:@"background_focus_required" reason:@"background_focus_required: this action requires keyboard focus; no input was sent. Use accessibility, browser control, or a separate computer." userInfo:nil];
118 }
119 typedef OSStatus (*cuGetFrontFn)(ProcessSerialNumber *);
120 typedef OSStatus (*cuGetPSNFn)(pid_t, ProcessSerialNumber *);
121 typedef OSStatus (*cuSetFrontFn)(ProcessSerialNumber *, uint32_t, uint32_t);
122 typedef OSStatus (*cuPostRecordFn)(ProcessSerialNumber *, const void *);
123 typedef void (*cuSetWinLocFn)(CGEventRef, CGPoint);
124 static BOOL axActivate(pid_t pid);
125 static BOOL cuBgResolved = NO;
126 static cuGetFrontFn cuGetFront;
127 static cuGetPSNFn cuGetPSN;
128 static cuSetFrontFn cuSetFront;
129 static cuPostRecordFn cuPostRecord;
130 static cuSetWinLocFn cuSetWinLoc;
131 static BOOL cuResolveBgPointer(void) {
132 if(cuBgResolved) return cuGetFront && cuGetPSN && cuSetFront && cuPostRecord && cuSetWinLoc;
133 cuBgResolved = YES;
134 void *sl = dlopen("/System/Library/PrivateFrameworks/SkyLight.framework/SkyLight", RTLD_LAZY);
135 void *hs = dlopen("/System/Library/Frameworks/ApplicationServices.framework/Frameworks/HIServices.framework/HIServices", RTLD_LAZY);
136 if(sl) {
137 cuGetFront = (cuGetFrontFn)dlsym(sl, "_SLPSGetFrontProcess");
138 cuSetFront = (cuSetFrontFn)dlsym(sl, "SLPSSetFrontProcessWithOptions");
139 cuPostRecord = (cuPostRecordFn)dlsym(sl, "SLPSPostEventRecordTo");
140 cuSetWinLoc = (cuSetWinLocFn)dlsym(sl, "CGEventSetWindowLocation");
141 }
142 if(hs) cuGetPSN = (cuGetPSNFn)dlsym(hs, "GetProcessForPID");
143 return cuGetFront && cuGetPSN && cuSetFront && cuPostRecord && cuSetWinLoc;
144 }
145 /** Smallest layer-0 window of pid containing p (a sheet beats its parent). */
146 static BOOL cuWindowAtPointForPid(pid_t pid, CGPoint p, uint32_t *outWin, CGRect *outFrame) {
147 NSArray *windows = CFBridgingRelease(CGWindowListCopyWindowInfo(kCGWindowListOptionAll, kCGNullWindowID));
148 double bestArea = 0;
149 BOOL found = NO;
150 for(NSDictionary *w in windows) {
151 if([w[(__bridge NSString *)kCGWindowOwnerPID] intValue] != pid) continue;
152 if([w[(__bridge NSString *)kCGWindowLayer] intValue] != 0) continue;
153 NSNumber *alpha = w[(__bridge NSString *)kCGWindowAlpha];
154 if(alpha && [alpha doubleValue] <= 0) continue;
155 CGRect b;
156 if(!CGRectMakeWithDictionaryRepresentation((__bridge CFDictionaryRef)w[(__bridge NSString *)kCGWindowBounds], &b)) continue;
157 if(b.size.width < 1 || b.size.height < 1 || !CGRectContainsPoint(b, p)) continue;
158 double area = b.size.width * b.size.height;
159 if(!found || area < bestArea) {
160 found = YES; bestArea = area;
161 *outWin = [w[(__bridge NSString *)kCGWindowNumber] unsignedIntValue];
162 *outFrame = b;
163 }
164 }
165 return found;
166 }
167 /**
168 * State for a window-routed action. Measured on macOS 26.1: the record
169 * channel only delivers to views when the target window is key, and the
170 * front-process lease (kCPSNoWindows-style options, no windows raised) is
171 * what makes it key — the window-focus record alone makes it main, which
172 * leaves events arriving at the process and swallowed by first-mouse
173 * semantics. The lease is taken for every gesture and restored in @finally;
174 * menus opened during it are held across calls (watchdog-capped) because a
175 * menu closes the moment the lease ends. Chromium rebuilds its AX tree
176 * lazily across the first transitions, so observes poll through the rebuild.
177 */
178 typedef struct { ProcessSerialNumber frontPSN, targetPSN, postPSN; pid_t frontPid; pid_t targetPid; pid_t postPid; uint32_t postWin; BOOL swapped; double t0, idleBefore, idleAfter, leaseMs, yieldMs; } cuBgLease;
179 static BOOL axActivate(pid_t pid);
180 static BOOL cuFrontmostIsPid(pid_t pid);
181 static id attr(AXUIElementRef el, NSString *name);
182 static void axPrepare(AXUIElementRef app);
183 /**
184 * The frame and owner pid of a CGWindow by number — no pid filter, because
185 * the window an action is routed to may belong to a hosting service.
186 */
187 static BOOL cuWindowInfoForNumber(uint32_t winNum, CGRect *outFrame, pid_t *outPid) {
188 NSArray *windows = CFBridgingRelease(CGWindowListCopyWindowInfo(kCGWindowListOptionAll, kCGNullWindowID));
189 for(NSDictionary *w in windows) {
190 if([w[(__bridge NSString *)kCGWindowNumber] unsignedIntValue] != winNum) continue;
191 if(outPid) *outPid = [w[(__bridge NSString *)kCGWindowOwnerPID] intValue];
192 if(outFrame) CGRectMakeWithDictionaryRepresentation((__bridge CFDictionaryRef)w[(__bridge NSString *)kCGWindowBounds], outFrame);
193 return YES;
194 }
195 return NO;
196 }
197 /**
198 * Service-hosted UI (openAndSavePanelService panels and similar XPC surfaces)
199 * presents as TWO co-located windows at the same frame: a proxy owned by the
200 * client app and the real window owned by the service. Records addressed to
201 * the client die in its event queue — the key-equivalent and text handlers
202 * run in the service's AppKit. A foreign window only takes over routing when
203 * its owner is an XPC service: two ordinary apps can share a frame (two
204 * maximized windows), and that must never redirect the user's input.
205 */
206 static BOOL cuHostedWindowAtSameFrame(pid_t excludePid, CGRect f, uint32_t *outWin, pid_t *outPid) {
207 NSArray *windows = CFBridgingRelease(CGWindowListCopyWindowInfo(kCGWindowListOptionAll, kCGNullWindowID));
208 for(NSDictionary *w in windows) {
209 pid_t owner = [w[(__bridge NSString *)kCGWindowOwnerPID] intValue];
210 if(owner == excludePid || [w[(__bridge NSString *)kCGWindowLayer] intValue] != 0) continue;
211 CGRect b;
212 if(!CGRectMakeWithDictionaryRepresentation((__bridge CFDictionaryRef)w[(__bridge NSString *)kCGWindowBounds], &b)) continue;
213 if(fabs(b.origin.x - f.origin.x) > 2 || fabs(b.origin.y - f.origin.y) > 2) continue;
214 if(fabs(b.size.width - f.size.width) > 2 || fabs(b.size.height - f.size.height) > 2) continue;
215 NSRunningApplication *ra = [NSRunningApplication runningApplicationWithProcessIdentifier:owner];
216 if(![ra.bundleIdentifier containsString:@".xpc."]) continue;
217 *outWin = [w[(__bridge NSString *)kCGWindowNumber] unsignedIntValue];
218 if(outPid) *outPid = owner;
219 return YES;
220 }
221 return NO;
222 }
223 // User-activity yield: before a moment that touches shared input — a front
224 // lease, a real-pointer gesture, foreground keys — wait for a gap in the
225 // person's hardware input rather than cutting between their keystrokes.
226 // kCGAnyInputEventType only counts hardware events (verified 2026-09-17:
227 // our posted events never tick it), so it is exactly "is the human using
228 // the machine right now". yield_gap_ms is the quiet window we wait for (0
229 // disables); yield_wait_ms bounds the wait. A busy desktop refuses before
230 // input is sent; a deadline is not permission to interrupt the person.
231 // Returns milliseconds yielded, 0 when the surface was already free.
232 static double cuYieldToUser(NSDictionary *args) {
233 double gap=[args[@"yield_gap_ms"] doubleValue], wait=[args[@"yield_wait_ms"] doubleValue];
234 if(gap<=0 || wait<=0) return 0;
235 NSTimeInterval start=NSProcessInfo.processInfo.systemUptime;
236 while(YES) {
237 cuCheckCancelled();
238 double idle=CGEventSourceSecondsSinceLastEventType(kCGEventSourceStateHIDSystemState,kCGAnyInputEventType);
239 #ifdef CU_TEST
240 if(cuTestIdleSeconds) idle=cuTestIdleSeconds.doubleValue;
241 #endif
242 double elapsed=(NSProcessInfo.processInfo.systemUptime-start)*1000.0;
243 if(isfinite(idle) && idle>=0 && idle*1000.0>=gap) return elapsed;
244 if(elapsed>=wait)
245 @throw [NSException exceptionWithName:@"user_busy" reason:@"user_busy: no quiet input window became available; no input was sent. Wait for the user to finish before trying again." userInfo:nil];
246 usleep((useconds_t)(fmin(40.0,wait-elapsed)*1000.0));
247 }
248 }
249 static BOOL cuBgLeaseBegin(NSRunningApplication *inputApp, uint32_t winNum, BOOL swap, NSDictionary *args, cuBgLease *lease, NSString **why) {
250 cuRequireFocusControl(args);
251 lease->targetPid = inputApp.processIdentifier;
252 lease->swapped = NO;
253 lease->t0 = lease->idleBefore = lease->idleAfter = lease->leaseMs = lease->yieldMs = 0;
254 if(cuGetPSN(lease->targetPid, &lease->targetPSN) != 0) {
255 *why = @"could not resolve the process serial number for a window-routed action; no input was sent";
256 return NO;
257 }
258 cuCheckCancelled();
259 if(swap && !cuFrontmostIsPid(lease->targetPid)) {
260 // An already-frontmost target needs no swap: the record already addresses
261 // the window, and setting an app front of itself changes nothing. Skipping
262 // it keeps receipts truthful (front_lease:false) and avoids a restore
263 // attempt for focus that was never borrowed.
264 // Yield to the person first: borrowing the front while they are
265 // mid-keystroke can redirect their input into our window. The wait is
266 // reported as yield_ms, not hidden, and runs before t0 so the borrow
267 // window measures only the time focus was actually held.
268 lease->yieldMs = cuYieldToUser(args);
269 NSRunningApplication *frontApp = NSWorkspace.sharedWorkspace.frontmostApplication;
270 lease->frontPid = frontApp.processIdentifier;
271 if(cuGetFront(&lease->frontPSN) != 0 || cuSetFront(&lease->targetPSN, 0, 0x400) != 0) {
272 *why = @"the window server refused the background focus lease; no input was sent";
273 return NO;
274 }
275 lease->swapped = YES;
276 // Interference accounting (SHA-6643): the borrow window and the HID idle
277 // clock around it. Synthesized events do not tick this clock (verified
278 // 2026-09-17: a posted key leaves it advancing), so a clock that fails to
279 // advance across the window means hardware input arrived mid-lease.
280 lease->t0 = CFAbsoluteTimeGetCurrent();
281 lease->idleBefore = CGEventSourceSecondsSinceLastEventType(kCGEventSourceStateHIDSystemState, kCGAnyInputEventType);
282 }
283 // Records are addressed to the window's real owner, which is not always
284 // the bound app: a service-hosted panel (openAndSavePanelService) leaves
285 // a same-frame proxy in the client while its handlers run in the service.
286 // Events posted to the client's PSN die in its queue. The front lease
287 // stays on the client — that is what makes its panels key — while the
288 // focus record and event records go to the owning process.
289 lease->postPid = lease->targetPid;
290 lease->postPSN = lease->targetPSN;
291 lease->postWin = winNum;
292 {
293 CGRect wf = CGRectZero;
294 pid_t owner = 0;
295 if(cuWindowInfoForNumber(winNum, &wf, &owner)) {
296 pid_t real = 0; uint32_t realWin = 0;
297 if(owner != lease->targetPid) {
298 NSRunningApplication *ra = [NSRunningApplication runningApplicationWithProcessIdentifier:owner];
299 if([ra.bundleIdentifier containsString:@".xpc."]) { real = owner; realWin = winNum; }
300 } else cuHostedWindowAtSameFrame(lease->targetPid, wf, &realWin, &real);
301 if(real && cuGetPSN(real, &lease->postPSN) == 0) { lease->postPid = real; lease->postWin = realWin; }
302 else { lease->postPSN = lease->targetPSN; }
303 }
304 }
305 uint8_t rec[0xf8];
306 memset(rec, 0, sizeof(rec));
307 rec[0x24] = 0xf8; rec[0x28] = 0x0d;
308 rec[0x5c] = (lease->postWin >> 24) & 0xff; rec[0x5d] = (lease->postWin >> 16) & 0xff;
309 rec[0x5e] = (lease->postWin >> 8) & 0xff; rec[0x5f] = lease->postWin & 0xff;
310 rec[0xaa] = 0x01;
311 cuPostRecord(&lease->postPSN, rec);
312 usleep(30000);
313 return YES;
314 }
315 static void cuLeaseAccounting(NSMutableDictionary *receipt, cuBgLease *lease) {
316 // No numbers when nothing was borrowed, or when the lease is still held
317 // across calls (menu path): a zero window would claim an instant lease.
318 // Millisecond precision: these ride every lease receipt and its trajectory.
319 if(!lease->swapped || lease->leaseMs <= 0) return;
320 if(lease->yieldMs > 0) receipt[@"yield_ms"] = @(round(lease->yieldMs));
321 receipt[@"lease_ms"] = @(round(lease->leaseMs * 1000.0) / 1000.0);
322 receipt[@"idle_before_s"] = @(round(lease->idleBefore * 1000.0) / 1000.0);
323 receipt[@"idle_after_s"] = @(round(lease->idleAfter * 1000.0) / 1000.0);
324 }
325 static BOOL cuBgLeaseEnd(cuBgLease *lease) {
326 if(!lease->swapped) return YES; // nothing was borrowed
327 cuSetFront(&lease->frontPSN, 0, 0x400);
328 // NSWorkspace's frontmost view is stale in a one-shot helper; the SLS
329 // front-process read is authoritative. Re-assert through AX while the
330 // lease is still visible. The outcome is reported, not assumed: a failed
331 // restore means the person's next keystrokes land in the wrong app.
332 for(int i = 0; i < 20; i++) {
333 if(!cuFrontmostIsPid(lease->targetPid)) break;
334 axActivate(lease->frontPid);
335 usleep(50000);
336 }
337 // Measured after restore: the borrow window runs take -> handed back, and
338 // input that lands during a struggling restore counts as mid-lease.
339 lease->idleAfter = CGEventSourceSecondsSinceLastEventType(kCGEventSourceStateHIDSystemState, kCGAnyInputEventType);
340 lease->leaseMs = (CFAbsoluteTimeGetCurrent() - lease->t0) * 1000.0;
341 return !cuFrontmostIsPid(lease->targetPid);
342 }
343 static BOOL cuFrontmostIsPid(pid_t pid) {
344 ProcessSerialNumber front, want;
345 if(cuResolveBgPointer() && cuGetFront(&front) == 0 && cuGetPSN(pid, &want) == 0)
346 return front.highLongOfPSN == want.highLongOfPSN && front.lowLongOfPSN == want.lowLongOfPSN;
347 [NSRunLoop.currentRunLoop runUntilDate:[NSDate dateWithTimeIntervalSinceNow:0.02]];
348 return NSWorkspace.sharedWorkspace.frontmostApplication.processIdentifier == pid;
349 }
350 /**
351 * A menu opened under a front-process lease closes the moment the lease
352 * ends, so for those (Chromium) the lease is held in a state file across
353 * calls and given back by the next raw-input call or a 6 s watchdog —
354 * and only while the target is still frontmost: the user taking another
355 * app in the meantime is a choice, never something to yank back.
356 */
357 static NSString *cuFrontLeaseFile(void) {
358 return [NSHomeDirectory() stringByAppendingPathComponent:@".codewhale-cu/front-lease.json"];
359 }
360 static void cuFrontLeaseRestoreIfHeld(void) {
361 NSString *file = cuFrontLeaseFile();
362 NSData *data = [NSData dataWithContentsOfFile:file];
363 if(!data) return;
364 NSDictionary *held = [NSJSONSerialization JSONObjectWithData:data options:0 error:nil];
365 if(![held isKindOfClass:NSDictionary.class]) return;
366 [NSFileManager.defaultManager removeItemAtPath:file error:nil];
367 if(!cuResolveBgPointer()) return;
368 pid_t targetPid = [held[@"targetPid"] intValue];
369 if(!cuFrontmostIsPid(targetPid)) return;
370 ProcessSerialNumber psn = { (UInt32)[held[@"frontHi"] unsignedIntValue], (UInt32)[held[@"frontLo"] unsignedIntValue] };
371 cuSetFront(&psn, 0, 0x400);
372 for(int i = 0; i < 10; i++) {
373 if(!cuFrontmostIsPid(targetPid)) break;
374 axActivate([held[@"frontPid"] intValue]);
375 usleep(50000);
376 }
377 }
378 static BOOL cuFrontLeaseHeldForPid(pid_t pid) {
379 NSData *data = [NSData dataWithContentsOfFile:cuFrontLeaseFile()];
380 if(!data) return NO;
381 NSDictionary *held = [NSJSONSerialization JSONObjectWithData:data options:0 error:nil];
382 return [held isKindOfClass:NSDictionary.class] && [held[@"targetPid"] intValue] == pid;
383 }
384 static BOOL cuMenuOpenForApp(pid_t pid) {
385 AXUIElementRef app = AXUIElementCreateApplication(pid);
386 AXUIElementSetMessagingTimeout(app, 1.0);
387 axPrepare(app);
388 // Chromium vends an open select popup inside the window's subtree rather
389 // than as an app-level AXMenu, so both shapes are searched, bounded.
390 int budget = 400;
391 NSMutableArray *stack = [NSMutableArray array];
392 [stack addObjectsFromArray:attr(app, @"AXChildren") ?: @[]];
393 [stack addObjectsFromArray:attr(app, @"AXWindows") ?: @[]];
394 BOOL open = NO;
395 while(stack.count && budget-- > 0 && !open) {
396 id node = stack.lastObject;
397 [stack removeLastObject];
398 NSString *role = attr((__bridge AXUIElementRef)node, @"AXRole");
399 if([role isEqual:@"AXMenu"]) { open = YES; break; }
400 if([role isEqual:@"AXMenuBar"] || [role isEqual:@"AXMenuBarItem"]) continue;
401 [stack addObjectsFromArray:attr((__bridge AXUIElementRef)node, @"AXChildren") ?: @[]];
402 }
403 CFRelease(app);
404 return open;
405 }
406 static void cuFrontLeaseHold(cuBgLease *lease) {
407 NSString *token = NSUUID.UUID.UUIDString;
408 // Menu items take seconds to reappear in Chromium's rebuilt tree; the
409 // observe+pick must fit inside the hold.
410 NSNumber *deadline = @((long long)([NSDate new].timeIntervalSince1970 * 1000) + 15000);
411 NSDictionary *state = @{ @"token": token, @"targetPid": @(lease->targetPid), @"frontPid": @(lease->frontPid),
412 @"frontHi": @(lease->frontPSN.highLongOfPSN), @"frontLo": @(lease->frontPSN.lowLongOfPSN),
413 @"deadline": deadline };
414 NSString *file = cuFrontLeaseFile();
415 [NSFileManager.defaultManager createDirectoryAtPath:file.stringByDeletingLastPathComponent withIntermediateDirectories:YES attributes:@{ NSFilePosixPermissions: @0700 } error:nil];
416 NSData *data = [NSJSONSerialization dataWithJSONObject:state options:0 error:nil];
417 if(!data || ![data writeToFile:file atomically:YES]) { cuBgLeaseEnd(lease); return; }
418 NSDictionary *req = @{ @"tool": @"front_lease_watchdog", @"args": @{ @"token": token, @"deadline": deadline } };
419 NSData *reqData = [NSJSONSerialization dataWithJSONObject:req options:0 error:nil];
420 if(reqData) {
421 NSTask *watch = [NSTask new];
422 watch.executableURL = [NSURL fileURLWithPath:NSProcessInfo.processInfo.arguments[0]];
423 watch.arguments = @[ [[NSString alloc] initWithData:reqData encoding:NSUTF8StringEncoding] ];
424 watch.standardInput = NSFileHandle.fileHandleWithNullDevice;
425 watch.standardOutput = NSFileHandle.fileHandleWithNullDevice;
426 watch.standardError = NSFileHandle.fileHandleWithNullDevice;
427 [watch launchAndReturnError:nil];
428 }
429 }
430 /** Field-set + record post shared by mouse, wheel and keyboard events. */
431 static void cuPostEventRecord(cuBgLease *lease, CGEventRef e, CGPoint winLoc) {
432 CGEventSetIntegerValueField(e, 0, 3);
433 CGEventSetIntegerValueField(e, 7, 3);
434 CGEventSetIntegerValueField(e, 0x28, lease->postPid);
435 CGEventSetIntegerValueField(e, 0x33, lease->postWin);
436 CGEventSetIntegerValueField(e, 0x5b, lease->postWin);
437 CGEventSetIntegerValueField(e, 0x5c, lease->postWin);
438 cuSetWinLoc(e, winLoc);
439 void *record = *(void **)((char *)e + 0x18);
440 if(record) cuPostRecord(&lease->postPSN, record); else CGEventPostToPid(lease->postPid, e);
441 }
442 static id attr(AXUIElementRef el, NSString *name);
443 static void axPrepare(AXUIElementRef app);
444 /** The CGWindowNumber and frame of the layer-0 window of pid whose frame matches an AX window rect. */
445 static BOOL cuWindowNumberForFrame(pid_t pid, CGRect axFrame, uint32_t *outWin) {
446 NSArray *windows = CFBridgingRelease(CGWindowListCopyWindowInfo(kCGWindowListOptionAll, kCGNullWindowID));
447 for(NSDictionary *w in windows) {
448 if([w[(__bridge NSString *)kCGWindowOwnerPID] intValue] != pid) continue;
449 if([w[(__bridge NSString *)kCGWindowLayer] intValue] != 0) continue;
450 CGRect b;
451 if(!CGRectMakeWithDictionaryRepresentation((__bridge CFDictionaryRef)w[(__bridge NSString *)kCGWindowBounds], &b)) continue;
452 if(fabs(b.origin.x - axFrame.origin.x) > 2 || fabs(b.origin.y - axFrame.origin.y) > 2) continue;
453 if(fabs(b.size.width - axFrame.size.width) > 2 || fabs(b.size.height - axFrame.size.height) > 2) continue;
454 *outWin = [w[(__bridge NSString *)kCGWindowNumber] unsignedIntValue];
455 return YES;
456 }
457 return NO;
458 }
459 static NSDictionary *cuBgPointer(NSRunningApplication *inputApp, NSDictionary *args) {
460 if(!cuResolveBgPointer())
461 @throw [NSException exceptionWithName:@"bg_dispatch_unavailable" reason:@"window-routed background pointer is unavailable: SLPSPostEventRecordTo/CGEventSetWindowLocation not resolvable via SkyLight; no input was sent" userInfo:nil];
462 NSArray *steps = args[@"steps"];
463 if(![steps isKindOfClass:NSArray.class] || !steps.count || steps.count > 400)
464 @throw [NSException exceptionWithName:@"args" reason:@"bg_pointer needs 1..400 steps" userInfo:nil];
465 CGPoint anchor = CGPointZero;
466 BOOL haveAnchor = NO;
467 for(NSDictionary *step in steps) {
468 // A scroll step carries its point alongside the deltas.
469 if([step[@"x"] isKindOfClass:NSNumber.class] && [step[@"y"] isKindOfClass:NSNumber.class]) {
470 anchor = CGPointMake([step[@"x"] doubleValue], [step[@"y"] doubleValue]);
471 haveAnchor = YES; break;
472 }
473 }
474 if(!haveAnchor) @throw [NSException exceptionWithName:@"args" reason:@"bg_pointer steps carry no point" userInfo:nil];
475 uint32_t winNum = 0;
476 CGRect frame = CGRectZero;
477 if(!cuWindowAtPointForPid(inputApp.processIdentifier, anchor, &winNum, &frame))
478 @throw [NSException exceptionWithName:@"window" reason:@"no window of the bound application covers the start point; no input was sent" userInfo:nil];
479 cuBgLease lease;
480 NSString *why = nil;
481 // Measured on macOS 26.1: view-level mouse delivery requires the window to
482 // be key, and only the front-process lease makes it key. The window-focus
483 // record alone makes it main — events reach the process and are swallowed.
484 if(!cuBgLeaseBegin(inputApp, winNum, YES, args, &lease, &why))
485 @throw [NSException exceptionWithName:@"bg_dispatch_unavailable" reason:why userInfo:nil];
486 BOOL menuLeaseHeld = NO;
487 BOOL restored = YES;
488 @try {
489 for(NSDictionary *step in steps) {
490 cuCheckCancelled();
491 if([step[@"scroll"] isKindOfClass:NSArray.class]) {
492 NSArray *d = step[@"scroll"];
493 // Pixel units: Chromium ignores line-unit wheel events entirely
494 // (measured). One notch ≈ one line ≈ 40 px.
495 CGEventRef wheel = CGEventCreateScrollWheelEvent(NULL, kCGScrollEventUnitPixel, 2, [d[1] intValue] * 40, [d[0] intValue] * 40);
496 cuPostEventRecord(&lease, wheel, CGPointMake(anchor.x - frame.origin.x, anchor.y - frame.origin.y));
497 CFRelease(wheel);
498 } else {
499 CGPoint p = CGPointMake([step[@"x"] doubleValue], [step[@"y"] doubleValue]);
500 int type = [step[@"type"] intValue], button = [step[@"button"] intValue];
501 CGEventRef e = CGEventCreateMouseEvent(NULL, (CGEventType)type, p, (CGMouseButton)button);
502 CGEventSetIntegerValueField(e, kCGMouseEventClickState, [step[@"clickState"] longLongValue]);
503 BOOL pressed = type == kCGEventLeftMouseDown || type == kCGEventRightMouseDown || type == kCGEventOtherMouseDown
504 || type == kCGEventLeftMouseDragged || type == kCGEventRightMouseDragged || type == kCGEventOtherMouseDragged;
505 CGEventSetDoubleValueField(e, 2, pressed ? 1.0 : 0.0);
506 cuPostEventRecord(&lease, e, CGPointMake(p.x - frame.origin.x, p.y - frame.origin.y));
507 CFRelease(e);
508 }
509 usleep((useconds_t)([step[@"delayMs"] intValue] ?: 20) * 1000);
510 }
511 // A menu opened under a front lease dies when the lease ends; menus
512 // animate in after the mouse-up, so a click-ending gesture polls briefly.
513 // A web popup needs the poll to ride out Chromium's post-activation AX
514 // rebuild (seconds), which the caller asks for with menu_poll_ms.
515 if(lease.swapped) {
516 int lastType = -1;
517 for(NSDictionary *step in [steps reverseObjectEnumerator]) {
518 if([step[@"type"] isKindOfClass:NSNumber.class]) { lastType = [step[@"type"] intValue]; break; }
519 }
520 BOOL clickEnded = lastType == kCGEventLeftMouseUp || lastType == kCGEventRightMouseUp || lastType == kCGEventOtherMouseUp;
521 NSInteger pollMs = MAX(0, MIN(10000, [args[@"menu_poll_ms"] integerValue] ?: 720));
522 menuLeaseHeld = cuMenuOpenForApp(inputApp.processIdentifier);
523 if(!menuLeaseHeld && clickEnded) {
524 for(NSInteger waited = 0; waited < pollMs && !menuLeaseHeld; waited += 60) {
525 usleep(60000);
526 menuLeaseHeld = cuMenuOpenForApp(inputApp.processIdentifier);
527 }
528 }
529 }
530 } @finally {
531 if(menuLeaseHeld) cuFrontLeaseHold(&lease);
532 else restored = cuBgLeaseEnd(&lease);
533 }
534 NSMutableDictionary *receipt = [@{@"action_sent":@YES, @"strategy":@"window-record", @"pointer_moved":@NO,
535 @"front_lease":@(lease.swapped), @"window":@{@"id":@(lease.postWin)}} mutableCopy];
536 if(lease.postPid != lease.targetPid) receipt[@"window_owner_pid"] = @(lease.postPid);
537 if(lease.swapped) receipt[@"front_restored"] = @(restored);
538 cuLeaseAccounting(receipt, &lease);
539 if(menuLeaseHeld) receipt[@"menu_lease_held"] = @YES;
540 return receipt;
541 }
542 static id attr(AXUIElementRef el, NSString *name) {
543 #ifdef CU_TEST
544 // The observation fixture exercises the real walker without reading a GUI.
545 if([(__bridge id)el isKindOfClass:NSDictionary.class]) return ((__bridge NSDictionary *)el)[name];
546 #endif
547 CFTypeRef out = NULL;
548 AXError e = AXUIElementCopyAttributeValue(el, (__bridge CFStringRef)name, &out);
549 return e == kAXErrorSuccess ? CFBridgingRelease(out) : nil;
550 }
551 /**
552 * Opt the target into full accessibility. Chromium-family apps (Chrome,
553 * Electron) and WebKit content do not vend AXWebArea descendants until an
554 * assistive client sets AXEnhancedUserInterface; Firefox-style engines gate
555 * behind AXManualAccessibility. Without this an observe returns the chrome
556 * of the window — menu bar, toolbar, tab strip — and no page content at all.
557 * Apps that do not know these attributes simply refuse the write.
558 */
559 static void axPrepare(AXUIElementRef app) {
560 #ifdef CU_TEST
561 if([(__bridge id)app isKindOfClass:NSDictionary.class]) return;
562 #endif
563 AXUIElementSetAttributeValue(app,(__bridge CFStringRef)@"AXEnhancedUserInterface",kCFBooleanTrue);
564 AXUIElementSetAttributeValue(app,(__bridge CFStringRef)@"AXManualAccessibility",kCFBooleanTrue);
565 }
566 static NSDictionary *geometry(id v, BOOL size) {
567 if (!v || CFGetTypeID((__bridge CFTypeRef)v) != AXValueGetTypeID()) return nil;
568 if (size) { CGSize s; if (AXValueGetValue((__bridge AXValueRef)v,kAXValueCGSizeType,&s)) return @{ @"w":@(s.width), @"h":@(s.height) }; }
569 else { CGPoint p; if (AXValueGetValue((__bridge AXValueRef)v,kAXValueCGPointType,&p)) return @{ @"x":@(p.x), @"y":@(p.y) }; }
570 return nil;
571 }
572 static NSDictionary *info(AXUIElementRef el, NSInteger index, NSInteger win, NSArray *path) {
573 NSMutableDictionary *d = [@{@"index":@(index), @"windowIndex":@(win), @"path":path} mutableCopy];
574 for (NSString *key in @[@"role",@"subrole",@"value",@"enabled",@"focused"]) {
575 NSDictionary *names = @{@"role":@"AXRole",@"subrole":@"AXSubrole",@"value":@"AXValue",@"enabled":@"AXEnabled",@"focused":@"AXFocused"};
576 id v = attr(el,names[key]);
577 if ([v isKindOfClass:NSString.class]) d[key] = [v length]>12000 ? [v substringToIndex:12000] : v;
578 else if ([v isKindOfClass:NSNumber.class]) d[key] = v;
579 }
580 id label = attr(el,@"AXTitle");
581 if (![label isKindOfClass:NSString.class] || ![label length]) label = attr(el,@"AXDescription");
582 if ([label isKindOfClass:NSString.class]) d[@"label"] = label;
583 id p=geometry(attr(el,@"AXPosition"),NO), s=geometry(attr(el,@"AXSize"),YES);
584 if(p) d[@"position"]=p; if(s) d[@"size"]=s;
585 CFArrayRef actions=NULL;
586 #ifdef CU_TEST
587 if([(__bridge id)el isKindOfClass:NSDictionary.class]) d[@"actions"]=attr(el,@"actions")?:@[];
588 else
589 #endif
590 if(AXUIElementCopyActionNames(el,&actions)==kAXErrorSuccess) d[@"actions"]=CFBridgingRelease(actions);
591 else d[@"actions"]=@[];
592 return d;
593 }
594 static void cuValidateElementIdentity(AXUIElementRef el, NSDictionary *target) {
595 NSDictionary *current=info(el,0,0,@[]);
596 for(NSString *key in @[@"role",@"label"]) {
597 id expected=target[key]?:NSNull.null, actual=current[key]?:NSNull.null;
598 if(![expected isEqual:actual]) @throw [NSException exceptionWithName:@"stale" reason:[NSString stringWithFormat:@"element changed %@; observe again before acting",key] userInfo:nil];
599 }
600 }
601 static void walk(AXUIElementRef el, NSInteger win, NSArray *path, NSInteger depth, NSInteger limit, NSInteger max, BOOL depthIsTruncation, NSMutableArray *out, BOOL *truncated) {
602 // Breadth first keeps a long file listing from hiding its dialog buttons.
603 NSMutableArray *queue=[NSMutableArray arrayWithObject:@{@"el":(__bridge id)el,@"path":path,@"depth":@(depth)}];
604 for(NSUInteger cursor=0;cursor<queue.count;cursor++) {
605 if(out.count>=max){ *truncated=YES; break; }
606 NSDictionary *item=queue[cursor];
607 AXUIElementRef current=(__bridge AXUIElementRef)item[@"el"];
608 NSArray *currentPath=item[@"path"];
609 NSInteger currentDepth=[item[@"depth"] integerValue];
610 [out addObject:info(current,out.count,win,currentPath)];
611 NSArray *kids=attr(current,@"AXChildren");
612 if(currentDepth>=limit){ if(kids.count && depthIsTruncation) *truncated=YES; continue; }
613 for(NSUInteger i=0;i<kids.count;i++) {
614 if(queue.count-cursor>=(NSUInteger)max){ *truncated=YES; break; }
615 [queue addObject:@{@"el":kids[i],@"path":[currentPath arrayByAddingObject:@(i)],@"depth":@(currentDepth+1)}];
616 }
617 }
618 }
619 static NSArray *observeElements(AXUIElementRef app, NSArray *ws, NSDictionary *args, BOOL listWindows, BOOL *truncated) {
620 NSMutableArray *out=[NSMutableArray array];
621 BOOL full=[args[@"detail"] isEqual:@"full"];
622 // Web content nests deep: a browser form's controls commonly sit 12+ levels
623 // under the window, and a real page holds hundreds of controls. Depth and
624 // budget must cover that or every browser observe is silently headless.
625 // A filtered observe (query/role) is a targeted search, not a page dump, so
626 // it earns the deep budget — a control past the summary depth must still be
627 // findable.
628 BOOL deep=full||args[@"query"]||args[@"role"];
629 NSInteger limit=deep?24:16, max=deep?1600:900;
630 if(!listWindows && !args[@"window_id"]) {
631 // Open popup menus remain useful. Hidden menu-bar descendants belong in
632 // the full view; summary reserves their budget for the app's actual UI.
633 NSInteger menuMax=max/4;
634 NSArray *children=attr(app,@"AXChildren");
635 for(NSUInteger i=0;i<children.count;i++) {
636 NSString *role=attr((__bridge AXUIElementRef)children[i],@"AXRole");
637 if([role isEqual:@"AXMenu"]) walk((__bridge AXUIElementRef)children[i],-2,@[@(i)],0,limit,menuMax/2,YES,out,truncated);
638 }
639 id menu=attr(app,@"AXMenuBar");
640 if(menu) walk((__bridge AXUIElementRef)menu,-1,@[],0,full?limit:1,menuMax,full,out,truncated);
641 }
642 for(NSUInteger i=0;i<ws.count;i++) {
643 if(args[@"window_id"] && i!=[args[@"window_id"] unsignedIntegerValue]) continue;
644 if(listWindows){ NSMutableDictionary *d=[info((__bridge AXUIElementRef)ws[i],i,i,@[]) mutableCopy]; d[@"title"]=d[@"label"]?:@""; [out addObject:d]; }
645 else walk((__bridge AXUIElementRef)ws[i],i,@[],0,limit,max,YES,out,truncated);
646 }
647 return out;
648 }
649 /**
650 * An AXWebArea whose recorded path produced no descendants is a page the walk
651 * could not see into — typically Chromium still assembling its accessibility
652 * subtree right after AXEnhancedUserInterface was set. Worth one re-observe
653 * after a short settle rather than reporting an empty page.
654 */
655 static BOOL hasOrphanWebArea(NSArray *out) {
656 for(NSDictionary *d in out) {
657 if(![d[@"role"] isEqual:@"AXWebArea"]) continue;
658 NSArray *p=d[@"path"]; NSInteger w=[d[@"windowIndex"] integerValue];
659 BOOL kids=NO;
660 for(NSDictionary *e in out) {
661 if(e==d || [e[@"windowIndex"] integerValue]!=w) continue;
662 NSArray *q=e[@"path"];
663 if(q.count<=p.count) continue;
664 BOOL prefix=YES;
665 for(NSUInteger i=0;i<p.count;i++) if(![q[i] isEqual:p[i]]) { prefix=NO; break; }
666 if(prefix) { kids=YES; break; }
667 }
668 if(!kids) return YES;
669 }
670 return NO;
671 }
672 static BOOL cuFrame(AXUIElementRef el, CGRect *out) {
673 NSDictionary *p=geometry(attr(el,@"AXPosition"),NO), *z=geometry(attr(el,@"AXSize"),YES);
674 if(!p || !z) return NO;
675 *out=CGRectMake([p[@"x"] doubleValue],[p[@"y"] doubleValue],[z[@"w"] doubleValue],[z[@"h"] doubleValue]);
676 return YES;
677 }
678 /**
679 * The CGWindow that should receive input for an element. An AXSheet ancestor
680 * is preferred over the app AXWindow: a hosted panel (openAndSavePanelService)
681 * is bridged into the client's tree as a sheet but is its own real window,
682 * owned by the service. The window lookup prefers the client pid, then a
683 * co-located XPC-service window — the service's window is the one whose
684 * handlers actually consume events. outRole names the resolved ancestor.
685 */
686 static BOOL cuElementWindow(id element, pid_t preferPid, uint32_t *outWin, pid_t *outOwner, NSString **outRole) {
687 id sheet = nil, win = nil;
688 id node = element;
689 for(int depth = 0; node && depth < 64; depth++) {
690 NSString *r = attr((__bridge AXUIElementRef)node, @"AXRole");
691 if([r isEqual:@"AXSheet"] && !sheet) sheet = node;
692 else if([r isEqual:@"AXWindow"]) { win = node; break; }
693 else if([r isEqual:@"AXApplication"]) break;
694 id parent = attr((__bridge AXUIElementRef)node, @"AXParent");
695 if(!parent || CFEqual((__bridge CFTypeRef)parent, (__bridge CFTypeRef)node)) break;
696 node = parent;
697 }
698 for(id cand in @[sheet ?: [NSNull null], win ?: [NSNull null]]) {
699 if(cand == [NSNull null]) continue;
700 CGRect f;
701 if(!cuFrame((__bridge AXUIElementRef)cand, &f)) continue;
702 uint32_t w = 0; pid_t o = 0;
703 if(cuWindowNumberForFrame(preferPid, f, &w)) {
704 o = preferPid;
705 uint32_t fw = 0; pid_t fo = 0;
706 if(cuHostedWindowAtSameFrame(preferPid, f, &fw, &fo)) { w = fw; o = fo; }
707 } else if(!cuHostedWindowAtSameFrame(preferPid, f, &w, &o)) continue;
708 *outWin = w;
709 if(outOwner) *outOwner = o;
710 if(outRole) *outRole = attr((__bridge AXUIElementRef)cand, @"AXRole");
711 return YES;
712 }
713 return NO;
714 }
715 static NSDictionary *capturableWindow(NSArray *windows, pid_t pid, NSString *name, CGRect preferred) {
716 NSDictionary *matched=nil;
717 for(NSDictionary *w in windows) {
718 if([w[(__bridge NSString *)kCGWindowOwnerPID] intValue]!=pid || [w[(__bridge NSString *)kCGWindowLayer] intValue]!=0) continue;
719 CGRect b; if(!CGRectMakeWithDictionaryRepresentation((__bridge CFDictionaryRef)w[(__bridge NSString *)kCGWindowBounds],&b) || b.size.width<1 || b.size.height<1) continue;
720 if(fabs(b.origin.x-preferred.origin.x)>1 || fabs(b.origin.y-preferred.origin.y)>1 || fabs(b.size.width-preferred.size.width)>1 || fabs(b.size.height-preferred.size.height)>1) continue;
721 if(matched) @throw [NSException exceptionWithName:@"window" reason:@"the selected window is ambiguous; observe the app windows again" userInfo:nil];
722 matched=@{@"window_id":w[(__bridge NSString *)kCGWindowNumber],@"name":name?:@"App",@"points":@{@"x":@(b.origin.x),@"y":@(b.origin.y),@"w":@(b.size.width),@"h":@(b.size.height)}};
723 }
724 if(!matched) @throw [NSException exceptionWithName:@"window" reason:@"the selected app window is not capturable; observe the app windows again" userInfo:nil];
725 return matched;
726 }
727 static NSArray *cuActions(AXUIElementRef el) {
728 CFArrayRef names=NULL;
729 #ifdef CU_TEST
730 if([(__bridge id)el isKindOfClass:NSDictionary.class]) return attr(el,@"actions")?:@[];
731 #endif
732 return AXUIElementCopyActionNames(el,&names)==kAXErrorSuccess?CFBridgingRelease(names):@[];
733 }
734 static BOOL cuSettable(AXUIElementRef el, NSString *name) {
735 #ifdef CU_TEST
736 if([(__bridge id)el isKindOfClass:NSDictionary.class]) return [attr(el,@"settable") containsObject:name];
737 #endif
738 Boolean settable=false;
739 return AXUIElementIsAttributeSettable(el,(__bridge CFStringRef)name,&settable)==kAXErrorSuccess && settable;
740 }
741 static BOOL axHasWebAncestor(AXUIElementRef el);
742 static NSString *cuClickAction(AXUIElementRef el, BOOL context) {
743 id enabled=attr(el,@"AXEnabled");
744 if([enabled isKindOfClass:NSNumber.class] && ![enabled boolValue]) return nil;
745 NSArray *actions=cuActions(el);
746 if(context) return [actions containsObject:@"AXShowMenu"]?@"AXShowMenu":nil;
747 NSString *role=attr(el,@"AXRole");
748 // A web popup button's AXPress does not open the native menu — only a real
749 // mouse event does. Reporting it unpressable routes the click through the
750 // window-record path, which opens the menu the page actually shows.
751 if([@[@"AXMenuButton",@"AXPopUpButton"] containsObject:role] && axHasWebAncestor(el)) return nil;
752 // Pressing a text field is toolkit-dependent; focus its insertion point directly.
753 if([@[@"AXTextField",@"AXTextArea",@"AXComboBox"] containsObject:role] && cuSettable(el,@"AXFocused")) return @"AXFocused";
754 if([actions containsObject:@"AXPress"]) return @"AXPress";
755 if([role isEqual:@"AXMenuItem"] && [actions containsObject:@"AXPick"]) return @"AXPick";
756 if([@[@"AXRow",@"AXCell"] containsObject:role] && cuSettable(el,@"AXSelected")) return @"AXSelected";
757 // Focusing is the missing middle between "not pressable" and a real click,
758 // but only for explicit text-entry roles. Anything else — a span, a group,
759 // Chromium's page-content container, Qt composers — is better served by the
760 // window-record click the caller falls back to: focusing a container is not
761 // a click, and reporting one would swallow the press.
762 if(cuSettable(el,@"AXFocused") && [@[@"AXTextField",@"AXTextArea",@"AXComboBox",@"AXSearchField",@"AXSecureTextField"] containsObject:role]) return @"AXFocused";
763 return nil;
764 }
765 static NSDictionary *cuClick(AXUIElementRef el, BOOL context) {
766 // A control whose rendered frame is empty is one a user could not click:
767 // virtualized lists and collapsed regions vend elements that do not exist on
768 // screen. Pressing one either does nothing or toggles a row the caller
769 // cannot see. Refuse with the recovery spelled out.
770 NSDictionary *sz=geometry(attr(el,@"AXSize"),YES);
771 if(sz && ([sz[@"w"] doubleValue]<=0 || [sz[@"h"] doubleValue]<=0))
772 @throw [NSException exceptionWithName:@"degenerate_frame" reason:@"target element has a degenerate frame (zero size); it is hidden or collapsed in a virtualized container — scroll it into view and observe again before clicking" userInfo:nil];
773 NSString *action=cuClickAction(el,context);
774 if(!action) @throw [NSException exceptionWithName:@"background_action_unavailable" reason:@"this control has no supported accessibility click; observe its advertised actions or use a separate computer" userInfo:nil];
775 cuCheckCancelled();
776 BOOL attribute=[action isEqual:@"AXFocused"] || [action isEqual:@"AXSelected"];
777 AXError error=attribute?AXUIElementSetAttributeValue(el,(__bridge CFStringRef)action,kCFBooleanTrue):AXUIElementPerformAction(el,(__bridge CFStringRef)action);
778 if(error!=kAXErrorSuccess) @throw [NSException exceptionWithName:@"action" reason:[NSString stringWithFormat:@"accessibility %@ failed: %d; no pointer fallback was sent",action,error] userInfo:nil];
779 return @{@"action_sent":@YES,@"strategy":@"a11y",@"action":action,@"pointer_moved":@NO,
780 @"verified":@(attribute && [attr(el,action) boolValue])};
781 }
782 static id cuScrollBar(AXUIElementRef el, BOOL horizontal) {
783 id enabled=attr(el,@"AXEnabled");
784 if([enabled isKindOfClass:NSNumber.class] && ![enabled boolValue]) return nil;
785 return attr(el,horizontal?@"AXHorizontalScrollBar":@"AXVerticalScrollBar");
786 }
787 static NSDictionary *cuScroll(AXUIElementRef el, NSDictionary *args) {
788 BOOL horizontal=[@[@"left",@"right"] containsObject:args[@"direction"]];
789 id bar=nil;
790 for(id cur=(__bridge id)el;cur && !bar;) {
791 AXUIElementRef node=(__bridge AXUIElementRef)cur;
792 bar=cuScrollBar(node,horizontal);
793 if([attr(node,@"AXRole") isEqual:@"AXWindow"]) break;
794 cur=attr(node,@"AXParent");
795 }
796 if(!bar) @throw [NSException exceptionWithName:@"background_scroll_unavailable" reason:@"no accessibility scrollbar at this target; choose an observed scroll area or a separate computer" userInfo:nil];
797 AXUIElementRef control=(__bridge AXUIElementRef)bar;
798 BOOL forward=[@[@"down",@"right"] containsObject:args[@"direction"]];
799 NSString *action=forward?@"AXIncrement":@"AXDecrement";
800 NSInteger count=MAX(1,MIN(100,[args[@"amount"] integerValue]));
801 id before=attr(control,@"AXValue");
802 BOOL advertised=[cuActions(control) containsObject:action];
803 // Native scrollbars commonly expose a normalized value instead of actions.
804 // Report that unit explicitly: it is not a claim about a toolkit's line size.
805 BOOL normalized=!advertised && [before isKindOfClass:NSNumber.class] && [before doubleValue]>=0 && [before doubleValue]<=1 && cuSettable(control,@"AXValue");
806 if(!advertised && !normalized) @throw [NSException exceptionWithName:@"background_scroll_unavailable" reason:@"the accessibility scrollbar has no supported action or writable normalized value" userInfo:nil];
807 for(NSInteger i=0;i<(advertised?count:1);i++) {
808 cuCheckCancelled();
809 NSNumber *value=@(MAX(0,MIN(1,[before doubleValue]+(forward?1:-1)*0.05*count)));
810 AXError error=advertised?AXUIElementPerformAction(control,(__bridge CFStringRef)action):AXUIElementSetAttributeValue(control,kAXValueAttribute,(__bridge CFTypeRef)value);
811 if(error!=kAXErrorSuccess) @throw [NSException exceptionWithName:@"action" reason:[NSString stringWithFormat:@"accessibility scroll failed: %d; no pointer fallback was sent",error] userInfo:nil];
812 }
813 id after=attr(control,@"AXValue");
814 return @{@"action_sent":@YES,@"strategy":@"a11y",@"pointer_moved":@NO,@"action":advertised?action:@"AXValue",
815 @"unit":advertised?@"accessibility_increment":@"normalized_scrollbar",@"before":before?:NSNull.null,@"after":after?:NSNull.null,
816 @"verified":@(before && after && ![before isEqual:after])};
817 }
818 /**
819 * Smallest pressable element whose frame contains p.
820 *
821 * AXUIElementCopyElementAtPosition is the first resolver, but several toolkits
822 * (Chromium's browser process among them) answer it with the window rather
823 * than the control the user sees, so a coordinate would silently degrade to a
824 * raw event. Searching the subtree geometrically recovers the real target;
825 * "smallest containing" is what picks the button instead of its group. Bounded
826 * so a huge tree cannot stall an action.
827 */
828 static void cuSearch(AXUIElementRef el, CGPoint p, int depth, int *budget, id *best, double *bestArea, NSString *operation) {
829 if(depth>24 || (*budget)--<=0) return;
830 CGRect frame;
831 if(cuFrame(el,&frame)) {
832 // Children are laid out inside their parent (and clipped when they are
833 // not), so a frame that misses the point prunes the whole subtree.
834 if(!CGRectContainsPoint(frame,p)) return;
835 double area=frame.size.width*frame.size.height;
836 BOOL suitable=[operation hasPrefix:@"scroll"]?cuScrollBar(el,[operation isEqual:@"scroll-horizontal"])!=nil:cuClickAction(el,[operation isEqual:@"context"])!=nil;
837 if(suitable && (!*best || area<=*bestArea)) { *best=(__bridge id)el; *bestArea=area; }
838 }
839 for(id kid in attr(el,@"AXChildren")) cuSearch((__bridge AXUIElementRef)kid,p,depth+1,budget,best,bestArea,operation);
840 }
841 /**
842 * Every key an app_ref supplies must match. Matching any one of them would let
843 * {pid, bundle_id} land on a *different* process of the same bundle — the
844 * user's own browser instead of the one the agent opened — and then type into
845 * their window. Identity here is a conjunction, deliberately.
846 */
847 static BOOL matchesName(NSString *have, NSString *want) {
848 return have && [have caseInsensitiveCompare:want]==NSOrderedSame;
849 }
850 /**
851 * Bring an application forward. -[NSRunningApplication activateWithOptions:]
852 * is ignored on macOS 14+ when the caller is not itself frontmost, which a
853 * background helper never is. The WindowServer's own front-process channel
854 * (0x200 = raising) works from any TCC-trusted process; setting AXFrontmost
855 * through the Accessibility grant is the fallback.
856 */
857 static BOOL axActivate(pid_t pid) {
858 if(cuResolveBgPointer()) {
859 ProcessSerialNumber psn;
860 if(cuGetPSN(pid,&psn)==0 && cuSetFront(&psn,0,0x200)==0) return YES;
861 }
862 AXUIElementRef app=AXUIElementCreateApplication(pid);
863 AXError e=AXUIElementSetAttributeValue(app,kAXFrontmostAttribute,kCFBooleanTrue);
864 CFRelease(app);
865 return e==kAXErrorSuccess;
866 }
867 static NSRunningApplication *resolve(NSDictionary *ref) {
868 // Only omission selects the frontmost app. An explicit but malformed
869 // identity must never redirect observation or input to the user's app.
870 if(!ref) return NSWorkspace.sharedWorkspace.frontmostApplication;
871 if(![ref isKindOfClass:NSDictionary.class] || !ref.count) return nil;
872 for(id key in ref) {
873 id value=ref[key];
874 if([key isEqual:@"pid"]) {
875 if(![value isKindOfClass:NSNumber.class] || CFGetTypeID((__bridge CFTypeRef)value)==CFBooleanGetTypeID()
876 || [value doubleValue]<=0 || [value doubleValue]>INT_MAX || [value doubleValue]!=[value intValue]) return nil;
877 } else if([key isEqual:@"name"] || [key isEqual:@"bundle_id"]) {
878 if(![value isKindOfClass:NSString.class] || ![value stringByTrimmingCharactersInSet:NSCharacterSet.whitespaceAndNewlineCharacterSet].length) return nil;
879 } else return nil;
880 }
881 NSString *bundle=ref[@"bundle_id"], *name=ref[@"name"];
882 for(NSRunningApplication *a in NSWorkspace.sharedWorkspace.runningApplications) {
883 if(ref[@"pid"] && a.processIdentifier!=[ref[@"pid"] intValue]) continue;
884 if(bundle && !matchesName(a.bundleIdentifier,bundle)) continue;
885 if(name && !matchesName(a.localizedName,name)) continue;
886 return a;
887 }
888 return nil;
889 }
890 static CGEventRef textEvent(NSString *text, BOOL down) {
891 UniChar *chars=calloc(text.length,sizeof(UniChar)); [text getCharacters:chars range:NSMakeRange(0,text.length)];
892 CGEventRef event=CGEventCreateKeyboardEvent(NULL,0,down);
893 #ifdef CU_TEST
894 // Simulate physical modifier state without posting a system key event.
895 CGEventSetFlags(event,cuTestInheritedTextFlags);
896 #endif
897 // Literal text must not inherit the user's held Command/Control/Option/Shift.
898 CGEventSetFlags(event,0);
899 CGEventKeyboardSetUnicodeString(event,text.length,chars); free(chars); return event;
900 }
901 static BOOL cuTextRole(NSString *role) {
902 return [@[@"AXTextField",@"AXTextArea",@"AXComboBox",@"AXSearchField",@"AXSecureTextField",@"AXWebArea"] containsObject:role];
903 }
904 /**
905 * Whether an element lives inside a browser/webview subtree. Chromium accepts
906 * AXSelectedText and AXValue writes on web controls and then ignores them —
907 * or, worse, a numeric control coerces the write to empty. Web elements get
908 * real keystrokes (after AXFocused) instead of semantic writes.
909 */
910 static BOOL axHasWebAncestor(AXUIElementRef el) {
911 // `node` stays an `id` so ARC keeps each ancestor alive through the walk —
912 // a raw AXUIElementRef would dangle the moment `parent` is reassigned.
913 id node=(__bridge id)el;
914 for(int depth=0;node && depth<64;depth++) {
915 NSString *role=attr((__bridge AXUIElementRef)node,@"AXRole");
916 if([role isEqual:@"AXWebArea"]) return YES;
917 id parent=attr((__bridge AXUIElementRef)node,@"AXParent");
918 if(!parent || CFEqual((CFTypeRef)parent,(CFTypeRef)node)) break;
919 node=parent;
920 }
921 return NO;
922 }
923 // Without a readable selection range only an exact append can be verified.
924 // Matching length or an already-present suffix is not evidence of delivery.
925 static BOOL cuTypeVerified(NSString *before, NSString *after, NSString *text) {
926 return before && after && [after isEqual:[before stringByAppendingString:text]];
927 }
928 static id cuFocusedElement(pid_t pid) {
929 AXUIElementRef appEl=AXUIElementCreateApplication(pid);
930 AXUIElementSetMessagingTimeout(appEl,2.0);
931 axPrepare(appEl);
932 id focused=attr(appEl,@"AXFocusedUIElement");
933 CFRelease(appEl);
934 return focused;
935 }
936 /**
937 * Resolve a {windowIndex, path} element target inside the app's AX tree.
938 * Returns nil when the window or any path step no longer exists — the same
939 * staleness contract as the element-action tools.
940 */
941 static id cuResolvePathTarget(pid_t pid, NSDictionary *t) {
942 AXUIElementRef appEl = AXUIElementCreateApplication(pid);
943 AXUIElementSetMessagingTimeout(appEl, 2.0);
944 axPrepare(appEl);
945 NSArray *ws = attr(appEl, @"AXWindows") ?: @[];
946 NSInteger wi = [t[@"windowIndex"] integerValue];
947 id el = wi == -1 ? attr(appEl, @"AXMenuBar") : wi == -2 ? (__bridge id)appEl : (wi >= 0 && wi < ws.count ? ws[wi] : nil);
948 for(NSNumber *i in t[@"path"] ?: @[]) {
949 NSArray *kids = el ? attr((__bridge AXUIElementRef)el, @"AXChildren") : nil;
950 if(i.unsignedIntegerValue >= kids.count) { el = nil; break; }
951 el = kids[i.unsignedIntegerValue];
952 }
953 CFRelease(appEl);
954 return el;
955 }
956 /**
957 * Type into whatever holds focus in the bound app, then prove it landed.
958 * Dispatch succeeding is not delivery (a process with no text receiver drops
959 * the events silently), so the receipt reports `verified` from the focused
960 * control's own value. Failure to verify is reported, not thrown — the events
961 * already went out. The one throw is before any event is posted: a focused
962 * element that is clearly not a text control.
963 */
964 static NSDictionary *cuType(NSDictionary *args, NSRunningApplication *inputApp, id focused, BOOL simulated) {
965 NSString *text=args[@"text"];
966 if(![text isKindOfClass:NSString.class]) @throw [NSException exceptionWithName:@"text" reason:@"text must be a string" userInfo:nil];
967 NSString *role=focused?attr((__bridge AXUIElementRef)focused,@"AXRole"):nil;
968 BOOL secure=[role isEqual:@"AXSecureTextField"];
969 NSString *before=nil;
970 // A secure field's value is never read; it verifies as unverifiable.
971 if(focused && !secure) { id v=attr((__bridge AXUIElementRef)focused,@"AXValue"); if([v isKindOfClass:NSString.class]) before=v; }
972 // Fail closed only on strong evidence: something holds focus and it is
973 // clearly not text. No focused element at all still receives the events —
974 // some apps take process-directed keys without reporting AX focus.
975 if(focused && !cuTextRole(role) && !before)
976 @throw [NSException exceptionWithName:@"focus" reason:[NSString stringWithFormat:@"focused element is a %@, not a text control — click or focus a text field first",role?:@"unknown element"] userInfo:nil];
977 NSString *expected=nil;
978 if(before && focused) {
979 id range=attr((__bridge AXUIElementRef)focused,@"AXSelectedTextRange"); CFRange selected;
980 if(range && CFGetTypeID((__bridge CFTypeRef)range)==AXValueGetTypeID() && AXValueGetValue((__bridge AXValueRef)range,kAXValueCFRangeType,&selected)
981 && selected.location>=0 && selected.length>=0 && selected.location<=before.length && selected.length<=before.length-selected.location)
982 expected=[before stringByReplacingCharactersInRange:NSMakeRange(selected.location,selected.length) withString:text];
983 }
984 // delivery:"events" forces the real keystroke stream. Service-backed
985 // fields (a hosted panel's path box) accept an AXSelectedText write and
986 // then overwrite it from their own model — the write verifies at read
987 // time and reverts on commit. Real key events reach the field's editor.
988 BOOL semantic=!simulated && focused && ![args[@"foreground_input"] boolValue] && ![args[@"delivery"] isEqual:@"events"] && cuSettable((__bridge AXUIElementRef)focused,@"AXSelectedText")
989 && !axHasWebAncestor((__bridge AXUIElementRef)focused);
990 if(semantic) {
991 cuCheckCancelled();
992 AXError error=AXUIElementSetAttributeValue((__bridge AXUIElementRef)focused,kAXSelectedTextAttribute,(__bridge CFStringRef)text);
993 if(error!=kAXErrorSuccess) @throw [NSException exceptionWithName:@"action" reason:[NSString stringWithFormat:@"accessibility text insertion failed: %d; observe before retrying; no keyboard fallback was sent",error] userInfo:nil];
994 }
995 // One grapheme per event, the way a keyboard delivers them. Batching
996 // several into one CGEventKeyboardSetUnicodeString is faster but Electron
997 // apps coalesce the pending payload and keep only the final batch, so a
998 // typed string silently arrives truncated to its tail.
999 //
1000 // Astral graphemes (surrogate pairs, emoji, flags, ZWJ sequences) are
1001 // dropped by the WindowServer's key translation whenever the target window
1002 // is not front-and-visible — measured: occluded Chrome keeps every BMP
1003 // grapheme and loses 🐳. The window-record channel delivers the real event
1004 // instead. The trigger is the text, not an occlusion guess: if the string
1005 // carries any multi-unit grapheme, the whole stream rides the record
1006 // channel under one lease. Background mode refuses that lease before input;
1007 // it cannot promise reliable astral text delivery through a focus swap.
1008 uint32_t typeWin = 0;
1009 CGRect typeFrame = CGRectZero;
1010 pid_t typeOwner = 0;
1011 BOOL needsRecord = NO;
1012 if(!simulated && !semantic && focused && ![args[@"foreground_input"] boolValue]) {
1013 for(NSUInteger i = 0; i < text.length && !needsRecord && cuResolveBgPointer();) {
1014 NSRange r = [text rangeOfComposedCharacterSequencesForRange:NSMakeRange(i, 1)];
1015 if(r.length > 1) needsRecord = YES;
1016 i = NSMaxRange(r);
1017 }
1018 // The focused element may live in a hosted panel whose window belongs to
1019 // a service, not the app — resolve its real window and owner either way,
1020 // so process-posted text also reaches the right queue.
1021 if(cuElementWindow(focused, inputApp.processIdentifier, &typeWin, &typeOwner, nil)
1022 && cuWindowInfoForNumber(typeWin, &typeFrame, nil)) {
1023 // A service-owned window's queue is where the panel's field lives;
1024 // process posting to the app would drop the text. The record route
1025 // under a lease delivers it and supplies key status.
1026 if(typeOwner && typeOwner != inputApp.processIdentifier) needsRecord = YES;
1027 }
1028 }
1029 cuBgLease lease = {0};
1030 BOOL leasing = NO;
1031 BOOL typeRestored = YES;
1032 if(needsRecord && typeWin && cuResolveBgPointer()) {
1033 NSString *why = nil;
1034 leasing = cuBgLeaseBegin(inputApp, typeWin, YES, args, &lease, &why);
1035 }
1036 // Foreground keystrokes share the user's keyboard: wait for a hardware-
1037 // input gap once before the stream, not per grapheme — the stream itself
1038 // is already paced like a fast typist.
1039 double yieldMs=(!simulated && !semantic && [args[@"foreground_input"] boolValue]) ? cuYieldToUser(args) : 0;
1040 @try {
1041 for(NSUInteger i=0;!semantic && i<text.length && !cuCancelled;) {
1042 if([args[@"foreground_input"] boolValue]) cuRequireForeground(inputApp);
1043 cuCheckCancelled();
1044 NSRange range=[text rangeOfComposedCharacterSequencesForRange:NSMakeRange(i,1)];
1045 NSString *chunk=[text substringWithRange:range];
1046 if(!simulated) for(int down=1;down>=0;down--) {
1047 CGEventRef event=textEvent(chunk,down);
1048 if([args[@"foreground_input"] boolValue]) CGEventPost(kCGHIDEventTap,event);
1049 else if(leasing) cuPostEventRecord(&lease, event, CGPointMake(CGRectGetMidX(typeFrame) - typeFrame.origin.x, CGRectGetMidY(typeFrame) - typeFrame.origin.y));
1050 else CGEventPostToPid(typeOwner ? typeOwner : inputApp.processIdentifier,event);
1051 CFRelease(event);
1052 }
1053 i=NSMaxRange(range); usleep(10000);
1054 }
1055 } @finally { if(leasing) typeRestored = cuBgLeaseEnd(&lease); }
1056 if(cuCancelled) @throw [NSException exceptionWithName:@"cancelled" reason:@"computer request cancelled" userInfo:nil];
1057 NSString *after=nil;
1058 if(focused && !secure) {
1059 #ifdef CU_TEST
1060 if(simulated) { NSString *s=((NSMutableDictionary *)focused)[@"after"]; if(s) ((NSMutableDictionary *)focused)[@"AXValue"]=s; }
1061 else
1062 #endif
1063 usleep(80000);
1064 id v=attr((__bridge AXUIElementRef)focused,@"AXValue");
1065 if([v isKindOfClass:NSString.class]) after=v;
1066 }
1067 BOOL verified=expected?[after isEqual:expected]:cuTypeVerified(before,after,text);
1068 NSMutableDictionary *receipt=[@{@"action_sent":@YES,@"chars":@(text.length),@"strategy":semantic?@"a11y-selected-text":@"unicode-events",
1069 @"keyboard_delivery":semantic?@"accessibility":[args[@"foreground_input"] boolValue]?@"foreground-guarded":leasing?@"window-record":@"process",
1070 @"verified":@(verified),@"focused_role":role?:[NSNull null]} mutableCopy];
1071 if(leasing) receipt[@"window_focused"]=@YES;
1072 if(lease.swapped) receipt[@"front_restored"]=@(typeRestored);
1073 if(yieldMs>0) receipt[@"yield_ms"]=@(round(yieldMs));
1074 cuLeaseAccounting(receipt,&lease);
1075 if(!verified) receipt[@"verification_required"]=@"screenshot";
1076 return receipt;
1077 }
1078 static NSDictionary *windowAtPoint(NSArray *windows, CGPoint p) {
1079 NSMutableArray *skipped=[NSMutableArray array];
1080 for(NSDictionary *w in windows) { // front to back
1081 CGRect b;
1082 if(!CGRectMakeWithDictionaryRepresentation((__bridge CFDictionaryRef)w[(__bridge NSString *)kCGWindowBounds],&b)) continue;
1083 if(!CGRectContainsPoint(b,p)) continue;
1084 pid_t owner=[w[(__bridge NSString *)kCGWindowOwnerPID] intValue];
1085 NSString *name=w[(__bridge NSString *)kCGWindowOwnerName]?:@"";
1086 NSNumber *alpha=w[(__bridge NSString *)kCGWindowAlpha], *layer=w[(__bridge NSString *)kCGWindowLayer]?:@0;
1087 // Visible floating windows occlude input just like normal windows.
1088 if(alpha && [alpha doubleValue]<=0) { [skipped addObject:@{@"owner":name,@"why":@"transparent"}]; continue; }
1089 return @{@"found":@YES,@"owner_pid":@(owner),@"owner_name":name,
1090 @"window_id":w[(__bridge NSString *)kCGWindowNumber]?:@0,@"layer":layer,
1091 @"skipped":skipped};
1092 }
1093 return @{@"found":@NO,@"skipped":skipped};
1094 }
1095
1096 static id execute(NSDictionary *p) {
1097 NSString *tool=p[@"tool"]; NSDictionary *args=p[@"args"]?:@{};
1098 if([@[@"bg_key",@"bg_pointer"] containsObject:tool] || ([tool isEqual:@"pointer_sequence"] && [args[@"app_scoped"] boolValue])) cuRequireFocusControl(args);
1099 if([tool isEqual:@"pointer_sequence"] && ![args[@"foreground_input"] boolValue])
1100 @throw [NSException exceptionWithName:@"shared_pointer_required" reason:@"shared macOS pointer input is unavailable in background mode; use an accessibility action or a separate computer" userInfo:nil];
1101 cuOwnerPipe=[args[@"owner_pipe"] boolValue];
1102 BOOL mutates=[@[@"type",@"key_event",@"bg_key",@"mouse_event",@"scroll",@"pointer_sequence",@"bg_pointer",@"release_input",@"set_value",@"focus_element",@"select_text",@"perform_action",@"click_element",@"scroll_element"] containsObject:tool]
1103 || ([tool isEqual:@"hit_test"] && [args[@"perform"] boolValue])
1104 || ([tool isEqual:@"app_info"] && [args[@"activate"] boolValue]);
1105 if([tool isEqual:@"release_input"]) {
1106 if(!AXIsProcessTrusted()) @throw [NSException exceptionWithName:@"permission" reason:@"Accessibility permission is missing" userInfo:nil];
1107 cuLockInput();
1108 NSDictionary *point=args[@"point"];
1109 CGPoint at=CGPointMake([point[@"x"] doubleValue],[point[@"y"] doubleValue]);
1110 CGMouseButton button=[args[@"button"] unsignedIntValue];
1111 CGEventType up=button==0?kCGEventLeftMouseUp:button==1?kCGEventRightMouseUp:kCGEventOtherMouseUp;
1112 CGEventRef event=CGEventCreateMouseEvent(NULL,up,at,button);
1113 CGEventPost(kCGHIDEventTap,event); CFRelease(event);
1114 return @{@"released":@YES};
1115 }
1116 if([tool isEqual:@"key_event"] && ![args[@"down"] boolValue] && [args[@"owned_release"] boolValue]) {
1117 if(!AXIsProcessTrusted()) @throw [NSException exceptionWithName:@"permission" reason:@"Accessibility permission is missing" userInfo:nil];
1118 cuLockInput();
1119 // Release a confirmed/ambiguous press even if its original app has exited.
1120 return cuPostKey(args,[args[@"input_app_ref"][@"pid"] intValue]);
1121 }
1122 cuCheckCancelled();
1123 if([tool isEqual:@"input_capabilities"]) return @{@"input_lease":@1,@"owner_pipe":@YES,@"record_owner_pipe":@1,@"window_ocr":@1,@"element_identity":@1,@"background_actions":@1,@"background_focus_guard":@1,@"window_record":@(cuResolveBgPointer()?1:0)};
1124 if([tool isEqual:@"front_lease_watchdog"]) {
1125 long long remaining = [args[@"deadline"] longLongValue] - (long long)([NSDate new].timeIntervalSince1970 * 1000);
1126 if(remaining > 0 && remaining < 30000) usleep((useconds_t)remaining * 1000);
1127 NSData *data = [NSData dataWithContentsOfFile:cuFrontLeaseFile()];
1128 NSDictionary *held = data ? [NSJSONSerialization JSONObjectWithData:data options:0 error:nil] : nil;
1129 if([held isKindOfClass:NSDictionary.class] && [held[@"token"] isEqual:args[@"token"]]) cuFrontLeaseRestoreIfHeld();
1130 return @{@"ok":@YES};
1131 }
1132 if([tool isEqual:@"record"]) return cuRecord(args);
1133 if([tool isEqual:@"recognize_text"]) return cuRecognizeText(args[@"file"]);
1134 #ifdef CU_TEST
1135 if([tool isEqual:@"inspect_focus_control"]) { cuRequireFocusControl(args); return @{@"allowed":@YES}; }
1136 if([tool isEqual:@"inspect_user_yield"]) {
1137 cuTestIdleSeconds=args[@"idle_seconds"];
1138 if([args[@"cancelled"] boolValue]) cuCancelled=1;
1139 return @{@"yield_ms":@(cuYieldToUser(args))};
1140 }
1141 if([tool isEqual:@"inspect_click_action"]) return @{@"action":cuClickAction((__bridge AXUIElementRef)args[@"element"],[args[@"context"] boolValue])?:NSNull.null};
1142 if([tool isEqual:@"inspect_element_identity"]) {
1143 cuValidateElementIdentity((__bridge AXUIElementRef)args[@"element"],args[@"target"]);
1144 return @{@"identity_matches":@YES};
1145 }
1146 if([tool isEqual:@"inspect_window_at_point"]) return windowAtPoint(args[@"windows"],CGPointMake([args[@"x"] doubleValue],[args[@"y"] doubleValue]));
1147 if([tool isEqual:@"inspect_window_match"]) {
1148 NSDictionary *b=args[@"bounds"];
1149 CGRect bounds=CGRectMake([b[@"x"] doubleValue],[b[@"y"] doubleValue],[b[@"w"] doubleValue],[b[@"h"] doubleValue]);
1150 return capturableWindow(args[@"windows"],[args[@"pid"] intValue],@"Fixture",bounds);
1151 }
1152 if([tool isEqual:@"inspect_observation"]) {
1153 BOOL truncated=NO;
1154 NSArray *elements=observeElements((__bridge AXUIElementRef)args[@"app"],args[@"windows"]?:@[],args,NO,&truncated);
1155 return @{@"elements":elements,@"truncated":@(truncated)};
1156 }
1157 if([tool isEqual:@"test_input_lease"]) {
1158 cuTestLockDir=args[@"lock_dir"]; cuLockInput();
1159 cuTestReleaseFile=args[@"release_file"];
1160 if([args[@"work_ms"] intValue]>0) {
1161 cuPrint(@{@"action_sent":@YES,@"input_lease":@YES});
1162 for(int elapsed=0;elapsed<[args[@"work_ms"] intValue];elapsed+=20) { cuCheckCancelled(); usleep(20000); }
1163 }
1164 return @{@"action_sent":@YES};
1165 }
1166 if([tool isEqual:@"inspect_text_event"]) {
1167 cuTestInheritedTextFlags=[args[@"inherited_flags"] unsignedLongLongValue];
1168 CGEventRef event=textEvent(args[@"text"],YES); UniChar chars[4096]; UniCharCount length=0;
1169 CGEventKeyboardGetUnicodeString(event,4096,&length,chars); CGEventFlags flags=CGEventGetFlags(event); CFRelease(event);
1170 return @{@"text":[NSString stringWithCharacters:chars length:length],@"flags":@(flags)};
1171 }
1172 // Drives the real typing logic against a fixture focused element instead of
1173 // a live app: `after` is the value the element reports once the text lands,
1174 // which a fixture omits to model an app that swallows the events.
1175 if([tool isEqual:@"inspect_type"]) {
1176 id fixture=args[@"focused"];
1177 return cuType(args, nil, [fixture isKindOfClass:NSDictionary.class]?[fixture mutableCopy]:nil, YES);
1178 }
1179 #endif
1180 if([tool isEqual:@"permissions"]) return @{@"trusted":@(AXIsProcessTrusted())};
1181 if([tool isEqual:@"list_apps"]) {
1182 NSMutableArray *apps=[NSMutableArray array];
1183 for(NSRunningApplication *a in NSWorkspace.sharedWorkspace.runningApplications) {
1184 NSInteger policy = a.activationPolicy;
1185 NSString *policyName = (policy >= 0 && policy <= 2) ? @[@"regular",@"accessory",@"prohibited"][policy] : @"unknown";
1186 [apps addObject:@{@"name":a.localizedName?:@"",@"pid":@(a.processIdentifier),@"bundle_id":a.bundleIdentifier?:@"",@"frontmost":@(a.active),@"hidden":@(a.hidden),@"activation_policy":policyName}];
1187 }
1188 return @{@"apps":apps};
1189 }
1190 if([tool isEqual:@"displays"]) {
1191 uint32_t n=0; CGGetActiveDisplayList(0,NULL,&n); CGDirectDisplayID ids[n]; CGGetActiveDisplayList(n,ids,&n);
1192 NSMutableArray *out=[NSMutableArray array];
1193 for(uint32_t i=0;i<n;i++){ CGRect b=CGDisplayBounds(ids[i]); CGDisplayModeRef mode=CGDisplayCopyDisplayMode(ids[i]);
1194 size_t w=CGDisplayModeGetPixelWidth(mode),h=CGDisplayModeGetPixelHeight(mode); CGDisplayModeRelease(mode);
1195 [out addObject:@{@"index":@(i+1),@"id":@(ids[i]),@"main":@(ids[i]==CGMainDisplayID()),@"points":@{@"x":@(b.origin.x),@"y":@(b.origin.y),@"w":@(b.size.width),@"h":@(b.size.height)},@"pixels":@{@"w":@(w),@"h":@(h)},@"scale":@(w/b.size.width)}]; }
1196 return out;
1197 }
1198 if([tool isEqual:@"preview_notify"]) {
1199 [[NSDistributedNotificationCenter defaultCenter] postNotificationName:@"net.codewhale.computer-use.preview" object:nil userInfo:args deliverImmediately:YES];
1200 return @{@"updated":@YES};
1201 }
1202 if([tool isEqual:@"window_info"]) {
1203 NSRunningApplication *a=resolve(args[@"app_ref"]?:args[@"input_app_ref"]);
1204 if(!a) @throw [NSException exceptionWithName:@"app" reason:@"application not found" userInfo:nil];
1205 AXUIElementRef ax=AXUIElementCreateApplication(a.processIdentifier);
1206 axPrepare(ax);
1207 NSArray *axWindows=attr(ax,@"AXWindows");
1208 NSInteger index=[args[@"window_id"] integerValue];
1209 CGRect preferred;
1210 BOOL integerIndex=!args[@"window_id"] || ([args[@"window_id"] isKindOfClass:NSNumber.class] && [args[@"window_id"] doubleValue]==index);
1211 BOOL valid=integerIndex && index>=0 && index<axWindows.count && cuFrame((__bridge AXUIElementRef)axWindows[index],&preferred);
1212 CFRelease(ax);
1213 if(!valid) @throw [NSException exceptionWithName:@"window" reason:@"the selected app window has no accessibility geometry; call list_windows for a valid window index" userInfo:nil];
1214 NSArray *windows=CFBridgingRelease(CGWindowListCopyWindowInfo(kCGWindowListOptionAll,kCGNullWindowID));
1215 return capturableWindow(windows,a.processIdentifier,a.localizedName,preferred);
1216 }
1217 // Which application owns the point a pointer event would land on. A global
1218 // pointer event goes to whatever is on top, so this is what stops a click
1219 // meant for the agent's app from landing in the user's window.
1220 if([tool isEqual:@"window_at_point"]) {
1221 CGPoint p=CGPointMake([args[@"x"] doubleValue],[args[@"y"] doubleValue]);
1222 NSArray *windows=CFBridgingRelease(CGWindowListCopyWindowInfo(kCGWindowListOptionOnScreenOnly|kCGWindowListExcludeDesktopElements,kCGNullWindowID));
1223 return windowAtPoint(windows,p);
1224 }
1225 if([tool isEqual:@"app_info"]) {
1226 NSRunningApplication *a=resolve(args[@"app_ref"]);
1227 if(!a) @throw [NSException exceptionWithName:@"app" reason:@"application not found" userInfo:nil];
1228 if([a.bundleIdentifier isEqual:@"net.codewhale.computer-use"] && [args[@"activate"] boolValue]) @throw [NSException exceptionWithName:@"protected" reason:@"Computer Use safety controls belong to the user." userInfo:nil];
1229 double yieldMs=0;
1230 if([args[@"activate"] boolValue]) {
1231 cuLockInput();
1232 // Activation takes the person's foreground — wait for a hardware-
1233 // input gap first so a mid-type activation cannot swallow their
1234 // next keystrokes.
1235 yieldMs=cuYieldToUser(args);
1236 }
1237 cuCheckCancelled();
1238 if([args[@"activate"] boolValue] && !axActivate(a.processIdentifier)) [a activateWithOptions:0];
1239 if([args[@"activate"] boolValue]) for(int i=0;i<120;i++) {
1240 cuCheckCancelled();
1241 // NSWorkspace only refreshes its frontmost view through run-loop
1242 // notifications; a one-shot helper that never services them reads a
1243 // stale answer for seconds and misreports a working activation.
1244 [NSRunLoop.currentRunLoop runUntilDate:[NSDate dateWithTimeIntervalSinceNow:0.025]];
1245 if(NSWorkspace.sharedWorkspace.frontmostApplication.processIdentifier==a.processIdentifier) break;
1246 }
1247 NSMutableDictionary *info=[@{@"found":@YES,@"name":a.localizedName?:@"",@"pid":@(a.processIdentifier),@"bundle_id":a.bundleIdentifier?:@"",@"frontmost":@(a.active)} mutableCopy];
1248 if(yieldMs>0) info[@"yield_ms"]=@(round(yieldMs));
1249 return info;
1250 }
1251 if([tool isEqual:@"kill_app"]) {
1252 NSString *bundle=args[@"bundle_id"], *name=args[@"name"]; NSNumber *pidNum=args[@"pid"];
1253 if(!bundle && !name && !pidNum) @throw [NSException exceptionWithName:@"args" reason:@"kill_app needs name, bundle_id or pid" userInfo:nil];
1254 if(pidNum && (![pidNum isKindOfClass:NSNumber.class] || [pidNum doubleValue]<=0 || [pidNum doubleValue]>INT_MAX || [pidNum doubleValue]!=[pidNum intValue])) @throw [NSException exceptionWithName:@"args" reason:@"kill_app pid must be a positive integer" userInfo:nil];
1255 // A name that matches two running apps must not guess which one to end.
1256 NSMutableArray *hits=[NSMutableArray array];
1257 for(NSRunningApplication *a in NSWorkspace.sharedWorkspace.runningApplications) {
1258 if(pidNum && a.processIdentifier!=[pidNum intValue]) continue;
1259 if(bundle && !matchesName(a.bundleIdentifier?:@"",bundle)) continue;
1260 if(name && !matchesName(a.localizedName?:@"",name)) continue;
1261 [hits addObject:a];
1262 }
1263 if(!hits.count) @throw [NSException exceptionWithName:@"app" reason:@"application not found" userInfo:nil];
1264 if(hits.count>1) {
1265 NSMutableArray *desc=[NSMutableArray array];
1266 for(NSRunningApplication *a in hits) [desc addObject:[NSString stringWithFormat:@"%@ (pid %d)",a.localizedName?:@"?",a.processIdentifier]];
1267 @throw [NSException exceptionWithName:@"app" reason:[NSString stringWithFormat:@"several running applications match (%@); pass pid to choose one",[desc componentsJoinedByString:@", "]] userInfo:nil];
1268 }
1269 NSRunningApplication *target=hits[0];
1270 pid_t tp=target.processIdentifier;
1271 // The helper, its host (the daemon or MCP server), and the app bundle that
1272 // owns this process must never be terminable through the agent surface.
1273 if([(target.bundleIdentifier?:@"") isEqual:@"net.codewhale.computer-use"] || tp==getpid() || tp==getppid())
1274 @throw [NSException exceptionWithName:@"protected" reason:@"the Computer Use helper and its host cannot be terminated by this plugin" userInfo:nil];
1275 [target terminate];
1276 for(int i=0;i<60 && !target.isTerminated;i++) { cuCheckCancelled(); [NSRunLoop.currentRunLoop runUntilDate:[NSDate dateWithTimeIntervalSinceNow:0.05]]; }
1277 BOOL forced=NO;
1278 if(!target.isTerminated && [args[@"force"] boolValue]) {
1279 [target forceTerminate]; forced=YES;
1280 for(int i=0;i<40 && !target.isTerminated;i++) { cuCheckCancelled(); [NSRunLoop.currentRunLoop runUntilDate:[NSDate dateWithTimeIntervalSinceNow:0.05]]; }
1281 }
1282 return @{@"killed":@(target.isTerminated),@"pid":@(tp),@"name":target.localizedName?:@"",@"force_used":@(forced)};
1283 }
1284 if([tool isEqual:@"installed_apps"]) {
1285 // Installed catalog: the apps a person could open, running or not. Root +
1286 // one level of subdirectories (e.g. /Applications/Utilities); bundle
1287 // identity comes from the bundle itself, never from the directory name.
1288 NSMutableDictionary *running=@{}.mutableCopy;
1289 for(NSRunningApplication *a in NSWorkspace.sharedWorkspace.runningApplications) {
1290 if(a.bundleIdentifier) running[a.bundleIdentifier]=@(a.processIdentifier);
1291 }
1292 NSMutableDictionary *byId=@{}.mutableCopy;
1293 NSFileManager *fm=NSFileManager.defaultManager;
1294 NSArray *roots=@[@"/Applications", @"/System/Applications", [NSHomeDirectory() stringByAppendingPathComponent:@"Applications"]];
1295 for(NSString *root in roots) {
1296 cuCheckCancelled();
1297 NSString *top=[root stringByResolvingSymlinksInPath];
1298 NSMutableArray *dirs=[NSMutableArray array]; [dirs addObject:top];
1299 for(NSString *sub in ([fm contentsOfDirectoryAtPath:top error:nil]?:@[])) {
1300 if([sub hasPrefix:@"."]||[sub hasSuffix:@".app"]) continue;
1301 NSString *p=[top stringByAppendingPathComponent:sub];
1302 BOOL isDir=NO;
1303 if([fm fileExistsAtPath:p isDirectory:&isDir] && isDir) [dirs addObject:p];
1304 }
1305 for(NSString *dir in dirs) {
1306 for(NSString *item in ([fm contentsOfDirectoryAtPath:dir error:nil]?:@[])) {
1307 if(![item hasSuffix:@".app"]) continue;
1308 NSString *p=[dir stringByAppendingPathComponent:item];
1309 NSBundle *b=[NSBundle bundleWithPath:p];
1310 NSString *bid=b.bundleIdentifier;
1311 if(!bid || byId[bid]) continue;
1312 NSString *name=[b objectForInfoDictionaryKey:@"CFBundleDisplayName"];
1313 if(!name.length) name=[b objectForInfoDictionaryKey:@"CFBundleName"];
1314 if(!name.length) name=[item stringByDeletingPathExtension];
1315 byId[bid]=@{@"name":name,@"bundle_id":bid,@"path":p};
1316 }
1317 }
1318 }
1319 NSMutableArray *out=[NSMutableArray array];
1320 for(NSString *bid in byId) {
1321 NSMutableDictionary *e=[byId[bid] mutableCopy];
1322 NSNumber *pid=running[bid];
1323 e[@"running"]=(pid!=nil)?@YES:@NO;
1324 if(pid) e[@"pid"]=pid;
1325 [out addObject:e];
1326 }
1327 [out sortUsingComparator:^NSComparisonResult(NSDictionary *a, NSDictionary *b){ return [a[@"name"] localizedCaseInsensitiveCompare:b[@"name"]]; }];
1328 return @{@"apps":out,@"count":@(out.count)};
1329 }
1330 if([tool isEqual:@"set_window_frame"]) {
1331 NSRunningApplication *a=resolve(args[@"app_ref"]);
1332 if(!a) @throw [NSException exceptionWithName:@"app" reason:@"application not found" userInfo:nil];
1333 NSDictionary *frame=args[@"frame"];
1334 double fx=NAN,fy=NAN,fw=NAN,fh=NAN;
1335 if([frame isKindOfClass:NSDictionary.class]) {
1336 fx=[frame[@"x"] doubleValue]; fy=[frame[@"y"] doubleValue];
1337 fw=[frame[@"w"] doubleValue]; fh=[frame[@"h"] doubleValue];
1338 }
1339 if(!isfinite(fx)||!isfinite(fy)||!isfinite(fw)||!isfinite(fh)||fw<=0||fh<=0)
1340 @throw [NSException exceptionWithName:@"args" reason:@"set_window_frame needs frame {x,y,w,h} with positive w/h" userInfo:nil];
1341 NSNumber *idxNum=args[@"window_id"];
1342 if(![idxNum isKindOfClass:NSNumber.class] || [idxNum doubleValue]!=[idxNum intValue] || [idxNum intValue]<0)
1343 @throw [NSException exceptionWithName:@"args" reason:@"set_window_frame needs window_id (a non-negative window index from list_windows)" userInfo:nil];
1344 AXUIElementRef app=AXUIElementCreateApplication(a.processIdentifier);
1345 axPrepare(app);
1346 NSArray *windows=attr(app,@"AXWindows");
1347 NSInteger idx=[idxNum intValue];
1348 if(idx>=(NSInteger)windows.count) { CFRelease(app); @throw [NSException exceptionWithName:@"window" reason:@"window_id is out of range; call list_windows for valid indices" userInfo:nil]; }
1349 AXUIElementRef win=(__bridge AXUIElementRef)windows[idx];
1350 CGRect before=CGRectNull; cuFrame(win,&before);
1351 cuCheckCancelled();
1352 CGPoint p=CGPointMake(fx,fy); CGSize z=CGSizeMake(fw,fh);
1353 AXValueRef pos=AXValueCreate(kAXValueCGPointType,&p), size=AXValueCreate(kAXValueCGSizeType,&z);
1354 AXError pe=AXUIElementSetAttributeValue(win,(__bridge CFStringRef)@"AXPosition",pos);
1355 AXError se=AXUIElementSetAttributeValue(win,(__bridge CFStringRef)@"AXSize",size);
1356 // Some apps re-anchor a window's origin when its size changes; re-assert
1357 // the position once after the size has had a run-loop turn to settle.
1358 if(pe==kAXErrorSuccess) {
1359 [NSRunLoop.currentRunLoop runUntilDate:[NSDate dateWithTimeIntervalSinceNow:0.05]];
1360 AXError pe2=AXUIElementSetAttributeValue(win,(__bridge CFStringRef)@"AXPosition",pos);
1361 if(pe2!=kAXErrorSuccess) pe=pe2;
1362 }
1363 if(pos) CFRelease(pos); if(size) CFRelease(size);
1364 if(pe!=kAXErrorSuccess && se!=kAXErrorSuccess) {
1365 CFRelease(app);
1366 @throw [NSException exceptionWithName:@"window" reason:@"the app refused the window frame change (it may be fullscreen, tiled or non-resizable)" userInfo:nil];
1367 }
1368 // Apps apply frame changes over a few run-loop turns; verify by reading the
1369 // window's own geometry back, not by trusting the set call.
1370 CGRect after=before;
1371 for(int i=0;i<40;i++) {
1372 cuCheckCancelled();
1373 [NSRunLoop.currentRunLoop runUntilDate:[NSDate dateWithTimeIntervalSinceNow:0.05]];
1374 cuFrame(win,&after);
1375 if(fabs(after.origin.x-fx)<1 && fabs(after.origin.y-fy)<1 && fabs(after.size.width-fw)<1 && fabs(after.size.height-fh)<1) break;
1376 }
1377 CFRelease(app);
1378 BOOL verified = fabs(after.origin.x-fx)<1 && fabs(after.origin.y-fy)<1 && fabs(after.size.width-fw)<1 && fabs(after.size.height-fh)<1;
1379 NSMutableDictionary *done=[@{@"action_sent":@YES,@"window_id":@(idx),
1380 @"before":@{@"x":@(before.origin.x),@"y":@(before.origin.y),@"w":@(before.size.width),@"h":@(before.size.height)},
1381 @"after":@{@"x":@(after.origin.x),@"y":@(after.origin.y),@"w":@(after.size.width),@"h":@(after.size.height)},
1382 @"verified":@(verified)} mutableCopy];
1383 if(pe!=kAXErrorSuccess || se!=kAXErrorSuccess) {
1384 done[@"ax_errors"]=@{@"position":@(pe),@"size":@(se)};
1385 done[@"note"]=@"the app constrained or refused part of the frame (minimum sizes and fixed-size windows are common); the after readback is what actually happened";
1386 }
1387 return done;
1388 }
1389 NSRunningApplication *inputApp=nil;
1390 if([@[@"type",@"key_event",@"bg_key",@"mouse_event",@"scroll",@"hit_test",@"pointer_sequence",@"bg_pointer"] containsObject:tool]) {
1391 if(![args[@"input_app_ref"] isKindOfClass:NSDictionary.class]) @throw [NSException exceptionWithName:@"focus" reason:@"open_application first to bind the input destination" userInfo:nil];
1392 inputApp=resolve(args[@"input_app_ref"]);
1393 if(!inputApp || inputApp.terminated) @throw [NSException exceptionWithName:@"focus" reason:@"input application is no longer running; open_application again" userInfo:nil];
1394 if([inputApp.bundleIdentifier isEqual:@"net.codewhale.computer-use"]) @throw [NSException exceptionWithName:@"protected" reason:@"Computer Use safety controls belong to the user." userInfo:nil];
1395 }
1396 // A held menu lease is given back before fresh raw input or an explicit
1397 // activation; AX element actions (the pick itself) leave it alone.
1398 if([@[@"bg_pointer",@"type",@"key_event",@"bg_key",@"pointer_sequence"] containsObject:tool]
1399 || ([tool isEqual:@"app_info"] && [args[@"activate"] boolValue]))
1400 cuFrontLeaseRestoreIfHeld();
1401 if(mutates) { cuCheckCancelled(); cuLockInput(); }
1402 if(!AXIsProcessTrusted()) @throw [NSException exceptionWithName:@"permission" reason:@"Accessibility permission is missing for Codewhale Computer Use (or the direct host)." userInfo:nil];
1403 if([tool isEqual:@"type"]) {
1404 id focused = cuFocusedElement(inputApp.processIdentifier);
1405 NSDictionary *tt = args[@"target"];
1406 // An explicit element target overrides app-level focus for delivery
1407 // routing — a hosted panel's field is never the app's AXFocusedUIElement.
1408 if([tt isKindOfClass:NSDictionary.class]) {
1409 id el = cuResolvePathTarget(inputApp.processIdentifier, tt);
1410 if(!el) @throw [NSException exceptionWithName:@"stale" reason:@"element is no longer available; observe again" userInfo:nil];
1411 focused = el;
1412 }
1413 return cuType(args, inputApp, focused, NO);
1414 }
1415 if([tool isEqual:@"key_event"]) {
1416 double yieldMs=0;
1417 if([args[@"foreground_input"] boolValue] && [args[@"down"] boolValue]) {
1418 cuRequireForeground(inputApp);
1419 // Real hardware taps land between the person's keystrokes unless we
1420 // wait for a gap first; the up event is part of our own press and
1421 // must not wait.
1422 yieldMs=cuYieldToUser(args);
1423 cuRequireForeground(inputApp);
1424 }
1425 cuCheckCancelled();
1426 id result=cuPostKey(args,inputApp.processIdentifier);
1427 if(yieldMs>0) { NSMutableDictionary *r=[result mutableCopy]; r[@"yield_ms"]=@(round(yieldMs)); result=r; }
1428 if([args[@"input_lease"] boolValue] && [args[@"down"] boolValue]) { cuLeaseKey=args; cuLeasePid=inputApp.processIdentifier; cuLeaseApp=inputApp; }
1429 return result;
1430 }
1431 // A key chord with modifier flags through the window-record channel: menu
1432 // key equivalents (cmd+a, cmd+shift+g) only validate against a key window,
1433 // which the lease provides. Used by background select-all/replace flows.
1434 if([tool isEqual:@"bg_key"]) {
1435 if(!cuResolveBgPointer())
1436 @throw [NSException exceptionWithName:@"bg_dispatch_unavailable" reason:@"window-routed background keys are unavailable; no input was sent" userInfo:nil];
1437 // The window to post into: an explicit element target's window when the
1438 // caller names one (a panel's field, say), otherwise the focused
1439 // element's. An app-level AXFocusedUIElement always reports the app's own
1440 // window focus, which misses hosted panels entirely.
1441 id anchor = nil;
1442 NSDictionary *t = args[@"target"];
1443 if([t isKindOfClass:NSDictionary.class]) {
1444 anchor = cuResolvePathTarget(inputApp.processIdentifier, t);
1445 if(!anchor) @throw [NSException exceptionWithName:@"stale" reason:@"element is no longer available; observe again" userInfo:nil];
1446 } else anchor = cuFocusedElement(inputApp.processIdentifier);
1447 uint32_t keyWin = 0;
1448 CGRect keyFrame = CGRectZero;
1449 pid_t keyOwner = 0;
1450 NSString *winRole = nil;
1451 if(anchor) cuElementWindow(anchor, inputApp.processIdentifier, &keyWin, &keyOwner, &winRole)
1452 && cuWindowInfoForNumber(keyWin, &keyFrame, nil);
1453 if(!keyWin) @throw [NSException exceptionWithName:@"focus" reason:@"no focused window for a window-routed key; focus a control first" userInfo:nil];
1454 cuBgLease lease;
1455 NSString *why = nil;
1456 if(!cuBgLeaseBegin(inputApp, keyWin, YES, args, &lease, &why))
1457 @throw [NSException exceptionWithName:@"bg_dispatch_unavailable" reason:why userInfo:nil];
1458 BOOL keyRestored = YES;
1459 @try {
1460 for(int down = 1; down >= 0; down--) {
1461 CGEventRef event = CGEventCreateKeyboardEvent(NULL, [args[@"code"] unsignedShortValue], down ? true : false);
1462 CGEventSetFlags(event, [args[@"flags"] unsignedLongLongValue]);
1463 cuPostEventRecord(&lease, event, CGPointMake(CGRectGetMidX(keyFrame) - keyFrame.origin.x, CGRectGetMidY(keyFrame) - keyFrame.origin.y));
1464 CFRelease(event);
1465 usleep(30000);
1466 }
1467 } @finally { keyRestored = cuBgLeaseEnd(&lease); }
1468 NSMutableDictionary *receipt = [@{@"action_sent":@YES, @"strategy":@"window-record", @"keyboard_delivery":@"window-record", @"front_lease":@(lease.swapped)} mutableCopy];
1469 if(winRole) receipt[@"window_role"] = winRole;
1470 if(lease.postPid != lease.targetPid) receipt[@"window_owner_pid"] = @(lease.postPid);
1471 if(lease.swapped) receipt[@"front_restored"] = @(keyRestored);
1472 cuLeaseAccounting(receipt, &lease);
1473 return receipt;
1474 }
1475 if([tool isEqual:@"mouse_event"]) {
1476 CGPoint p=CGPointMake([args[@"x"] doubleValue],[args[@"y"] doubleValue]);
1477 CGEventRef event=CGEventCreateMouseEvent(NULL,[args[@"type"] unsignedIntValue],p,[args[@"button"] unsignedIntValue]);
1478 CGEventSetIntegerValueField(event,kCGMouseEventClickState,[args[@"clickState"] longLongValue]);
1479 // Address the window as well as the process using public event fields.
1480 // Dispatch is not delivery: a toolkit may still discard these events.
1481 // This primitive needs effect readback before a caller can rely on it.
1482 if([args[@"windowNumber"] longLongValue]>0) {
1483 CGEventSetIntegerValueField(event,kCGMouseEventWindowUnderMousePointer,[args[@"windowNumber"] longLongValue]);
1484 CGEventSetIntegerValueField(event,kCGMouseEventWindowUnderMousePointerThatCanHandleThisEvent,[args[@"windowNumber"] longLongValue]);
1485 }
1486 cuCheckCancelled();
1487 CGEventPostToPid(inputApp.processIdentifier,event); CFRelease(event); return @{@"action_sent":@YES};
1488 }
1489 // Accessibility-first coordinate action: resolve the point against the bound
1490 // application's AX tree and press the element it names. Callers fall back to
1491 // raw CGEvents when this reports found=NO, so it must fail closed rather than
1492 // guess: a point owned by another process, or a point that only lands on a
1493 // container, is not a press.
1494 if([tool isEqual:@"hit_test"]) {
1495 CGPoint p=CGPointMake([args[@"x"] doubleValue],[args[@"y"] doubleValue]);
1496 AXUIElementRef appEl=AXUIElementCreateApplication(inputApp.processIdentifier);
1497 AXUIElementSetMessagingTimeout(appEl,2.0);
1498 axPrepare(appEl);
1499 AXUIElementRef raw=NULL;
1500 AXError err=AXUIElementCopyElementAtPosition(appEl,(float)p.x,(float)p.y,&raw);
1501 id hit=nil;
1502 if(err==kAXErrorSuccess && raw) {
1503 pid_t owner=0;
1504 if(AXUIElementGetPid(raw,&owner)==kAXErrorSuccess && owner==inputApp.processIdentifier) hit=CFBridgingRelease(raw);
1505 else CFRelease(raw); // Another app may cover a background window. Search only our own tree below.
1506 }
1507
1508 id chosen=nil;
1509 BOOL insideSheet=NO;
1510 NSString *operation=args[@"operation"]?:@"click";
1511 BOOL scrolling=[operation hasPrefix:@"scroll"];
1512 // 1. The element under the point, or the nearest ancestor that can be
1513 // pressed — a label inside a button is the common case.
1514 for(id cur=hit; cur && !chosen;) {
1515 AXUIElementRef el=(__bridge AXUIElementRef)cur;
1516 id role=attr(el,@"AXRole");
1517 if([role isEqual:@"AXSheet"]) insideSheet=YES;
1518 if([role isEqual:@"AXWindow"] || [role isEqual:@"AXApplication"]) break;
1519 if(scrolling?cuScrollBar(el,[operation isEqual:@"scroll-horizontal"])!=nil:cuClickAction(el,[operation isEqual:@"context"])!=nil) { chosen=cur; break; }
1520 cur=attr(el,@"AXParent");
1521 }
1522 // 2. Otherwise search downward for the smallest control covering the point.
1523 if(!chosen) {
1524 int budget=1500; double area=0; id best=nil;
1525 if(hit) cuSearch((__bridge AXUIElementRef)hit,p,0,&budget,&best,&area,operation);
1526 else for(id w in attr(appEl,@"AXWindows")) {
1527 CGRect frame;
1528 if(!cuFrame((__bridge AXUIElementRef)w,&frame) || !CGRectContainsPoint(frame,p)) continue;
1529 // A sheet owns the window's interaction, even when the sheet does not cover p.
1530 NSArray *sheets=attr((__bridge AXUIElementRef)w,@"AXSheets");
1531 if(sheets.count) {
1532 for(id sheet in sheets) cuSearch((__bridge AXUIElementRef)sheet,p,0,&budget,&best,&area,operation);
1533 insideSheet=YES;
1534 } else cuSearch((__bridge AXUIElementRef)w,p,0,&budget,&best,&area,operation);
1535 break; // Never click through another window of the same app.
1536 }
1537 chosen=best;
1538 }
1539 CFRelease(appEl);
1540 if(!chosen) {
1541 // A web popup button reports unpressable on purpose (its menu only opens
1542 // from a real mouse event); name it so the caller can poll for the menu
1543 // through Chromium's post-activation AX rebuild.
1544 if(hit) {
1545 for(id cur=hit; cur;) {
1546 AXUIElementRef el=(__bridge AXUIElementRef)cur;
1547 id role=attr(el,@"AXRole");
1548 if([role isEqual:@"AXWindow"] || [role isEqual:@"AXApplication"]) break;
1549 if([@[@"AXMenuButton",@"AXPopUpButton"] containsObject:role] && axHasWebAncestor(el))
1550 return @{@"found":@NO,@"reason":@"web_popup_requires_real_click"};
1551 cur=attr(el,@"AXParent");
1552 }
1553 }
1554 return @{@"found":@NO,@"reason":hit?@"no_pressable_element_at_point":@"no_element_at_point"};
1555 }
1556
1557 // A press invokes the control's action directly, which would sail straight
1558 // past a window-modal sheet that a real click cannot cross. Refuse instead:
1559 // the caller must deal with the sheet.
1560 if(!insideSheet) {
1561 id owner=chosen;
1562 for(int up=0; up<12 && owner; up++) {
1563 AXUIElementRef el=(__bridge AXUIElementRef)owner;
1564 id role=attr(el,@"AXRole");
1565 if([role isEqual:@"AXSheet"]) { insideSheet=YES; break; }
1566 if([role isEqual:@"AXWindow"]) {
1567 for(id kid in attr(el,@"AXChildren")) {
1568 if([attr((__bridge AXUIElementRef)kid,@"AXRole") isEqual:@"AXSheet"])
1569 return @{@"found":@NO,@"reason":@"window_blocked_by_modal_sheet"};
1570 }
1571 break;
1572 }
1573 owner=attr(el,@"AXParent");
1574 }
1575 }
1576
1577 NSDictionary *element=info((__bridge AXUIElementRef)chosen,0,0,@[]);
1578 if(![args[@"perform"] boolValue]) return @{@"found":@YES,@"element":element,@"action_sent":@NO};
1579 cuCheckCancelled();
1580 NSMutableDictionary *receipt=[(scrolling?cuScroll((__bridge AXUIElementRef)chosen,args):cuClick((__bridge AXUIElementRef)chosen,[operation isEqual:@"context"])) mutableCopy];
1581 receipt[@"found"]=@YES; receipt[@"element"]=element; return receipt;
1582 }
1583 /**
1584 * One pointer gesture, posted to the window server.
1585 *
1586 * The tested AppKit fixture dropped process-directed mouse/scroll events.
1587 * This qualified raw path therefore uses the shared event tap, requiring
1588 * explicit foreground control. It moves the real cursor, so the gesture
1589 * runs in one call and restores its starting position when requested.
1590 * Restoration does not make concurrent desktop use safe.
1591 */
1592 if([tool isEqual:@"bg_pointer"]) return cuBgPointer(inputApp, args);
1593 if([tool isEqual:@"pointer_sequence"]) {
1594 CGEventRef probe=CGEventCreate(NULL); CGPoint home=CGEventGetLocation(probe); CFRelease(probe);
1595 // Shared input is allowed only while the explicitly selected app remains
1596 // foreground. A new gesture never reactivates it after the user switches.
1597 NSRunningApplication *front=NSWorkspace.sharedWorkspace.frontmostApplication;
1598 NSString *before=front.localizedName?:@"";
1599 BOOL takes=front.processIdentifier!=inputApp.processIdentifier;
1600 cuCheckCancelled();
1601 // Activation is a separate, explicit operation. A stale foreground mode
1602 // must never reclaim focus after the user has switched applications.
1603 // App-scoped clicks stay inside the bound window and do not steal the
1604 // foreground; they still move the real cursor and restore it.
1605 if([args[@"foreground_input"] boolValue]) cuRequireForeground(inputApp);
1606 // A real-pointer stream interleaved with the person's typing is
1607 // indistinguishable from a fight over the machine. Wait for a hardware-
1608 // input gap before the gesture — app_scoped moves the cursor too, so
1609 // the yield is unconditional, not just for foreground mode.
1610 double yieldMs=cuYieldToUser(args);
1611 // AppKit only assembles a drag out of events that look like they came from
1612 // the input hardware; a NULL-source stream delivers down and up but drops
1613 // every mouseDragged in between.
1614 CGEventSourceRef source=CGEventSourceCreate(kCGEventSourceStateHIDSystemState);
1615 BOOL held[3]={NO,NO,NO};
1616 CGPoint last=home;
1617 for(NSDictionary *step in args[@"steps"]) {
1618 @try { cuCheckCancelled(); if([args[@"foreground_input"] boolValue]) cuRequireForeground(inputApp); } @catch(NSException *e) { cuCancelled=1; break; }
1619 CGEventRef event;
1620 if(step[@"scroll"]) {
1621 NSArray *d=step[@"scroll"];
1622 event=CGEventCreateScrollWheelEvent(source,kCGScrollEventUnitLine,2,[d[1] intValue],[d[0] intValue]);
1623 } else {
1624 CGPoint p=CGPointMake([step[@"x"] doubleValue],[step[@"y"] doubleValue]);
1625 last=p;
1626 int button=[step[@"button"] intValue], kind=[step[@"type"] intValue];
1627 if(button>=0 && button<3) {
1628 if(kind==kCGEventLeftMouseDown || kind==kCGEventRightMouseDown || kind==kCGEventOtherMouseDown) held[button]=YES;
1629 if(kind==kCGEventLeftMouseUp || kind==kCGEventRightMouseUp || kind==kCGEventOtherMouseUp) held[button]=NO;
1630 }
1631 event=CGEventCreateMouseEvent(source,[step[@"type"] unsignedIntValue],p,[step[@"button"] unsignedIntValue]);
1632 CGEventSetIntegerValueField(event,kCGMouseEventClickState,[step[@"clickState"] longLongValue]);
1633 }
1634 CGEventPost(kCGHIDEventTap,event);
1635 CFRelease(event);
1636 usleep((useconds_t)([step[@"delayMs"] intValue]?:40)*1000);
1637 }
1638 if([args[@"input_lease"] boolValue] && !cuCancelled) {
1639 for(int button=0;button<3;button++) cuLeaseButtons[button]=held[button];
1640 cuLeasePoint=last;
1641 cuLeaseApp=inputApp;
1642 }
1643 if(cuCancelled || ![args[@"input_lease"] boolValue]) for(int button=0;button<3;button++) if(held[button]) {
1644 CGEventType up=button==0?kCGEventLeftMouseUp:button==1?kCGEventRightMouseUp:kCGEventOtherMouseUp;
1645 CGEventRef event=CGEventCreateMouseEvent(source,up,last,button);
1646 CGEventPost(kCGHIDEventTap,event); CFRelease(event);
1647 }
1648 BOOL restore=[args[@"restore"] boolValue] && !cuCancelled;
1649 if(restore) {
1650 usleep(60000);
1651 CGEventRef back=CGEventCreateMouseEvent(source,kCGEventMouseMoved,home,kCGMouseButtonLeft);
1652 CGEventPost(kCGHIDEventTap,back); CFRelease(back);
1653 }
1654 if(source) CFRelease(source);
1655 if(cuCancelled) @throw [NSException exceptionWithName:@"cancelled" reason:@"computer request cancelled" userInfo:nil];
1656 usleep(150000); // let the window server settle before reading it back
1657 NSString *after=NSWorkspace.sharedWorkspace.frontmostApplication.localizedName?:@"";
1658 NSMutableDictionary *gesture=[@{@"action_sent":@YES,@"pointer_moved":@YES,@"restored":@(restore),
1659 @"foreground_taken":@(takes),
1660 @"foreground_before":before,@"foreground_after":after,
1661 @"home":@{@"x":@(home.x),@"y":@(home.y)}} mutableCopy];
1662 if(yieldMs>0) gesture[@"yield_ms"]=@(round(yieldMs));
1663 return gesture;
1664 }
1665 if([tool isEqual:@"scroll"]) {
1666 cuCheckCancelled();
1667 CGEventRef event=CGEventCreateScrollWheelEvent(NULL,kCGScrollEventUnitLine,2,[args[@"dy"] intValue],[args[@"dx"] intValue]); CGEventPostToPid(inputApp.processIdentifier,event); CFRelease(event); return @{@"action_sent":@YES};
1668 }
1669 if([tool isEqual:@"cursor_position"]) {
1670 CGEventRef event=CGEventCreate(NULL); CGPoint p=CGEventGetLocation(event); CFRelease(event); return @{@"x":@(p.x),@"y":@(p.y)};
1671 }
1672 NSRunningApplication *a=resolve(args[@"app_ref"]?:args[@"target"][@"app_ref"]);
1673 if(!a) @throw [NSException exceptionWithName:@"app" reason:@"application not found" userInfo:nil];
1674 if(mutates && [a.bundleIdentifier isEqual:@"net.codewhale.computer-use"]) @throw [NSException exceptionWithName:@"protected" reason:@"Computer Use safety controls belong to the user." userInfo:nil];
1675 AXUIElementRef app=AXUIElementCreateApplication(a.processIdentifier);
1676 AXUIElementSetMessagingTimeout(app,2.0);
1677 axPrepare(app);
1678 @try {
1679 NSArray *ws=attr(app,@"AXWindows")?:@[];
1680 NSDictionary *identity=@{@"found":@YES,@"name":a.localizedName?:@"",@"pid":@(a.processIdentifier),@"bundle_id":a.bundleIdentifier?:@"",@"frontmost":@(a.active)};
1681 if([tool isEqual:@"get_app_state"] || [tool isEqual:@"list_windows"]) {
1682 BOOL truncated=NO;
1683 BOOL list=[tool isEqual:@"list_windows"];
1684 NSArray *out=observeElements(app,ws,args,list,&truncated);
1685 // A window that vends no descendants at all is not a page — it is an app
1686 // whose content tree is mid-rebuild (Chromium tears down and rebuilds
1687 // its accessibility tree across activation transitions, which takes
1688 // seconds). Poll briefly rather than returning an empty UI. A filtered
1689 // observe (query/role) legitimately matches nothing, so only unfiltered
1690 // observes earn the wait.
1691 // While a menu lease is held for this app, the thing being waited on is
1692 // the open menu's items: they reappear only when Chromium's rebuilt
1693 // tree re-vends them, so poll until an in-window AXMenuItem exists.
1694 BOOL leaseHeld = cuFrontLeaseHeldForPid(a.processIdentifier);
1695 int maxAttempts = leaseHeld ? 20 : 8;
1696 for(int attempt=0; !list && attempt<maxAttempts; attempt++) {
1697 BOOL empty = hasOrphanWebArea(out);
1698 if(!empty && !args[@"query"] && !args[@"role"]) {
1699 if(leaseHeld) {
1700 empty = YES;
1701 for(NSDictionary *d in out) {
1702 if([d[@"role"] isEqual:@"AXMenuItem"] && [d[@"windowIndex"] integerValue] >= 0) { empty = NO; break; }
1703 }
1704 } else if(ws.count) {
1705 BOOL anyContent = NO;
1706 for(NSDictionary *d in out) {
1707 if([d[@"windowIndex"] integerValue] >= 0 && [d[@"path"] count] > 0) { anyContent = YES; break; }
1708 }
1709 empty = !anyContent;
1710 }
1711 }
1712 if(!empty) break;
1713 usleep(300000);
1714 ws=attr(app,@"AXWindows")?:ws;
1715 truncated=NO;
1716 out=observeElements(app,ws,args,list,&truncated);
1717 }
1718 NSMutableDictionary *d=[identity mutableCopy]; d[list?@"windows":@"elements"]=out; d[@"truncated"]=@(truncated); return d;
1719 }
1720 if([tool isEqual:@"resolve_element"]) {
1721 NSInteger wi=[args[@"windowIndex"] integerValue];
1722 id el=wi==-1?attr(app,@"AXMenuBar"):wi==-2?(__bridge id)app:(wi>=0 && wi<ws.count?ws[wi]:nil);
1723 if(!el) return @{@"found":@NO,@"element":[NSNull null],@"reason":@"window_not_found"};
1724 for(NSNumber *i in args[@"path"]?:@[]) { NSArray *kids=attr((__bridge AXUIElementRef)el,@"AXChildren"); if(i.unsignedIntegerValue>=kids.count) return @{@"found":@NO,@"element":[NSNull null],@"reason":@"path_not_found"}; el=kids[i.unsignedIntegerValue]; }
1725 return @{@"found":@YES,@"element":info((__bridge AXUIElementRef)el,0,wi,args[@"path"]?:@[]),@"reason":[NSNull null]};
1726 }
1727 NSDictionary *t=args[@"target"]; NSInteger wi=[t[@"windowIndex"] integerValue];
1728 id el=wi==-1?attr(app,@"AXMenuBar"):wi==-2?(__bridge id)app:(wi>=0 && wi<ws.count?ws[wi]:nil);
1729 if(!el) @throw [NSException exceptionWithName:@"stale" reason:@"window is no longer available; observe again" userInfo:nil];
1730 for(NSNumber *i in t[@"path"]) { NSArray *kids=attr((__bridge AXUIElementRef)el,@"AXChildren"); if(i.unsignedIntegerValue>=kids.count) @throw [NSException exceptionWithName:@"stale" reason:@"element is no longer available; observe again" userInfo:nil]; el=kids[i.unsignedIntegerValue]; }
1731 if(wi>=0) {
1732 NSArray *sheets=attr((__bridge AXUIElementRef)ws[wi],@"AXSheets");
1733 if(sheets.count) {
1734 BOOL inside=NO; id ancestor=el;
1735 for(int depth=0;ancestor && depth<64;depth++) {
1736 for(id sheet in sheets) if(CFEqual((__bridge CFTypeRef)ancestor,(__bridge CFTypeRef)sheet)) inside=YES;
1737 if(inside) break;
1738 ancestor=attr((__bridge AXUIElementRef)ancestor,@"AXParent");
1739 }
1740 if(!inside) @throw [NSException exceptionWithName:@"modal" reason:@"window blocked by modal sheet; observe and handle the dialog first" userInfo:nil];
1741 }
1742 }
1743 cuCheckCancelled();
1744 if([t[@"type"] isEqual:@"element"]) cuValidateElementIdentity((__bridge AXUIElementRef)el,t);
1745 if([tool isEqual:@"click_element"]) return cuClick((__bridge AXUIElementRef)el,[args[@"context"] boolValue]);
1746 if([tool isEqual:@"scroll_element"]) return cuScroll((__bridge AXUIElementRef)el,args);
1747 AXError e=kAXErrorFailure;
1748 if([tool isEqual:@"get_value"]) {
1749 id v=attr((__bridge AXUIElementRef)el,@"AXValue");
1750 return @{@"ok":@YES,@"strategy":@"a11y",@"value":v?:[NSNull null],@"role":attr((__bridge AXUIElementRef)el,@"AXRole")?:[NSNull null]};
1751 }
1752 if([tool isEqual:@"set_value"]) {
1753 NSString *role=attr((__bridge AXUIElementRef)el,@"AXRole");
1754 if(axHasWebAncestor((__bridge AXUIElementRef)el))
1755 @throw [NSException exceptionWithName:@"value" reason:@"this element lives in a web area, which ignores background AXValue writes (numeric controls may even coerce them to empty). No write was sent — focus the element and use type instead, then verify with get_value." userInfo:nil];
1756 BOOL numeric=[@[@"AXIncrementor",@"AXSlider",@"AXStepper",@"AXValueIndicator",@"AXProgressIndicator"] containsObject:role];
1757 id value=args[@"value"];
1758 if(numeric) {
1759 // These controls type AXValue as a number. Writing a string is the
1760 // classic "clears the field instead of setting it" bug — a web
1761 // incrementor may coerce "" over "150" and report success.
1762 if(![value isKindOfClass:NSNumber.class] || CFGetTypeID((__bridge CFTypeRef)value)==CFBooleanGetTypeID()) {
1763 NSString *s=[value isKindOfClass:NSString.class]?value:[value description];
1764 static NSNumberFormatter *fmt=nil;
1765 if(!fmt) { fmt=[NSNumberFormatter new]; fmt.locale=[NSLocale localeWithLocaleIdentifier:@"en_US_POSIX"]; fmt.numberStyle=NSNumberFormatterDecimalStyle; }
1766 NSNumber *n=[fmt numberFromString:s];
1767 if(!n) @throw [NSException exceptionWithName:@"value" reason:[NSString stringWithFormat:@"%@ takes a numeric AXValue; %@ does not parse — focus the control and type instead",role?:@"this control",s] userInfo:nil];
1768 value=n;
1769 }
1770 }
1771 e=AXUIElementSetAttributeValue((__bridge AXUIElementRef)el,kAXValueAttribute,(__bridge CFTypeRef)value);
1772 if(e==kAXErrorSuccess) {
1773 usleep(60000);
1774 id after=attr((__bridge AXUIElementRef)el,@"AXValue");
1775 BOOL verified=NO;
1776 if(numeric) verified=[after isKindOfClass:NSNumber.class] && fabs([after doubleValue]-[value doubleValue])<1e-6;
1777 else verified=[after isKindOfClass:NSString.class] && [after isEqual:value];
1778 NSMutableDictionary *done=[@{@"action_sent":@YES,@"strategy":@"a11y",@"role":role?:[NSNull null],@"after":after?:[NSNull null],@"verified":@(verified)} mutableCopy];
1779 if(!verified) done[@"note"]=@"AXValue write did not verify: the control kept its own value (Electron/web text elements and numeric steppers commonly ignore background AXValue writes). Focus the element and use type instead, then verify with get_value.";
1780 return done;
1781 }
1782 }
1783 else if([tool isEqual:@"focus_element"]) e=AXUIElementSetAttributeValue((__bridge AXUIElementRef)el,kAXFocusedAttribute,kCFBooleanTrue);
1784 else if([tool isEqual:@"select_text"]){ NSArray *r=args[@"text_range"]?:@[@0,@0]; if(r.count!=2 || [r[0] longValue]<0 || [r[1] longValue]<0) @throw [NSException exceptionWithName:@"range" reason:@"text_range must be [start, length], both nonnegative" userInfo:nil]; CFRange range=CFRangeMake([r[0] longValue],[r[1] longValue]); AXValueRef v=AXValueCreate(kAXValueCFRangeType,&range); e=AXUIElementSetAttributeValue((__bridge AXUIElementRef)el,kAXSelectedTextRangeAttribute,v); CFRelease(v); }
1785 else if([tool isEqual:@"perform_action"]){ CFArrayRef actions=NULL; AXUIElementCopyActionNames((__bridge AXUIElementRef)el,&actions); NSArray *names=CFBridgingRelease(actions); if(![names containsObject:args[@"action"]]) @throw [NSException exceptionWithName:@"action" reason:@"action is not advertised by this element" userInfo:nil]; cuCheckCancelled(); e=AXUIElementPerformAction((__bridge AXUIElementRef)el,(__bridge CFStringRef)args[@"action"]); }
1786 if(e!=kAXErrorSuccess) @throw [NSException exceptionWithName:@"action" reason:[NSString stringWithFormat:@"accessibility action failed: %d",e] userInfo:nil];
1787 NSMutableDictionary *done=[@{@"action_sent":@YES,@"strategy":@"a11y"} mutableCopy];
1788 if([tool isEqual:@"focus_element"]) done[@"focused"]=@YES;
1789 return done;
1790 } @finally { CFRelease(app); }
1791 }
1792 int main(int argc, const char **argv){ @autoreleasepool {
1793 signal(SIGTERM,cuCancel); signal(SIGINT,cuCancel); signal(SIGPIPE,SIG_IGN);
1794 @try { if(argc!=2) @throw [NSException exceptionWithName:@"args" reason:@"expected one JSON argument" userInfo:nil];
1795 NSError *error=nil; id p=[NSJSONSerialization JSONObjectWithData:[[NSString stringWithUTF8String:argv[1]] dataUsingEncoding:NSUTF8StringEncoding] options:0 error:&error];
1796 if(![p isKindOfClass:NSDictionary.class]) @throw [NSException exceptionWithName:@"json" reason:@"invalid request" userInfo:nil];
1797 id result=execute(p);
1798 if([p[@"args"][@"input_lease"] boolValue]) { NSMutableDictionary *ack=[result mutableCopy]; ack[@"input_lease"]=@YES; result=ack; }
1799 NSData *data=[NSJSONSerialization dataWithJSONObject:result options:NSJSONWritingFragmentsAllowed error:&error];
1800 if(!data) @throw [NSException exceptionWithName:@"json" reason:error.localizedDescription userInfo:nil];
1801 puts([[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding].UTF8String); fflush(stdout);
1802 if([p[@"args"][@"input_lease"] boolValue]) cuWaitForLease();
1803 return 0;
1804 } @catch(NSException *e){ cuReleaseLease(); fprintf(stderr,"%s\n",e.reason.UTF8String); return 1; }
1805 } }
1806
1806 lines Plain Text