| 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 |