@@ -47,8 +47,19 @@ export function create({ exec }) {
4747 fs . writeFileSync ( file , script ) ;
4848 // Payload travels as a plain argv string: osascript is spawned without a
4949 // shell, so JSON content can never become script syntax.
50+ // Input and accessibility scripts need the Accessibility grant. When the
51+ // last probe saw it denied, refuse here with the remedy instead of
52+ // letting osascript hang on a TCC prompt nobody can answer (#5917).
53+ if ( state . tcc ?. accessibility === false && / C G E v e n t P o s t | S y s t e m E v e n t s / . test ( script ) ) {
54+ throw new ExecError ( `accessibility permission is not granted to ${ await grantTarget ( ) } : ${ TCC_FIX . accessibility } ` ) ;
55+ }
5056 const r = await runL ( "osascript" , [ "-l" , "JavaScript" , file , JSON . stringify ( payload ) ] , { timeoutMs } ) ;
51- if ( r . timedOut ) throw new ExecError ( "osascript timed out" , r ) ;
57+ if ( r . timedOut ) {
58+ const hint = state . tcc ?. accessibility === true
59+ ? ""
60+ : ` (if macOS is showing a permission prompt, grant Accessibility to ${ await grantTarget ( ) } : ${ TCC_FIX . accessibility } )` ;
61+ throw new ExecError ( `osascript timed out${ hint } ` , r ) ;
62+ }
5263 if ( r . code !== 0 ) {
5364 const msg = ( r . stderr || r . stdout ) . trim ( ) . split ( "\n" ) [ 0 ] || "osascript failed" ;
5465 throw new ExecError ( / ( n o t a l l o w e d a s s i s t i v e | a s s i s t i v e a c c e s s | 2 5 0 ) / i. test ( r . stderr || "" ) || / ( - 2 5 2 1 1 | - 1 7 1 9 | n o t a l l o w e d ) / i. test ( msg )
@@ -223,11 +234,16 @@ export function create({ exec }) {
223234 }
224235
225236 // ---------- raw input via CGEvent ----------
226- async function cg ( script , timeoutMs = 10_000 ) {
237+ // Every caller hands its values as `payload`; the script reads them as `P`.
238+ // The old `(script, timeoutMs)` shape silently swallowed the payload (so
239+ // `P.code`, `P.x`, `P.text` were undefined) and coerced the object to a
240+ // zero timeout, which is why every CGEvent input on macOS reported
241+ // "osascript timed out" instantly (#5917).
242+ async function cg ( script , payload = { } , timeoutMs = 10_000 ) {
227243 return jxa ( `ObjC.import('CoreGraphics');
228244 function run(argv){ var P = JSON.parse(argv[0]);
229245 ${ script }
230- }` , { } , timeoutMs ) ;
246+ }` , payload , timeoutMs ) ;
231247 }
232248
233249 async function postMouseEvent ( type , x , y , button , clickState ) {
@@ -326,7 +342,16 @@ export function create({ exec }) {
326342 }
327343 args . push ( file ) ;
328344 const r = await runL ( "screencapture" , args , { timeoutMs : 20_000 } ) ;
329- if ( r . code !== 0 ) throw new ExecError ( `screencapture exited ${ r . code } : ${ r . stderr . trim ( ) . slice ( 0 , 300 ) } ` , r ) ;
345+ if ( r . code !== 0 ) {
346+ const detail = r . stderr . trim ( ) . slice ( 0 , 300 ) ;
347+ // This is what screencapture says when the display is locked, asleep, or
348+ // the session is not the console user — not a permission problem
349+ // (without the Screen Recording grant it exits 0 and omits windows).
350+ const remedy = / c o u l d n o t c r e a t e i m a g e / i. test ( detail )
351+ ? " — the display is locked, asleep, or this session is not at the console; unlock or wake it and retry"
352+ : "" ;
353+ throw new ExecError ( `screencapture exited ${ r . code } : ${ detail } ${ remedy } ` , r ) ;
354+ }
330355 const stat = fs . statSync ( file ) ;
331356 const displays = await displayInfo ( ) ;
332357 const d = displays . find ( ( x ) => x . index === ( disp === "all" ? 1 : disp ) ) ?? displays [ 0 ] ;
@@ -442,6 +467,33 @@ export function create({ exec }) {
442467 }` , { } , 25_000 ) ;
443468 }
444469
470+ // Which app owns the keyboard right now. Raw CGEvents go to it no matter
471+ // what the caller meant (#5927), so every input receipt names it.
472+ async function frontmostApp ( ) {
473+ const r = await jxa ( `${ JXA_PRELUDE }
474+ var se = Application('System Events');
475+ var list = se.applicationProcesses.whose({ frontmost: true })();
476+ if (!list.length) return JSON.stringify({ found: false });
477+ var p = list[0];
478+ return JSON.stringify({ found: true, name: g(function(){ return String(p.name()); }), pid: num(function(){ return p.unixId(); }),
479+ bundle_id: g(function(){ var b = p.bundleIdentifier(); return b ? String(b) : null; }) });
480+ }` , { probe : "frontmost-app" } , 8_000 ) ;
481+ return r && r . found ? { name : r . name , pid : r . pid , bundle_id : r . bundle_id } : null ;
482+ }
483+
484+ // Refuse to post keystrokes when the app the caller named is not the one
485+ // that would receive them. Returns the frontmost app for the receipt.
486+ async function guardInput ( appRef ) {
487+ const front = await frontmostApp ( ) . catch ( ( ) => null ) ;
488+ if ( ! appRef ) return front ;
489+ const target = await findProcess ( appRef ) ;
490+ if ( ! target . found ) throw new ExecError ( "application not found — call list_apps for exact names/pids" ) ;
491+ if ( ! front || front . pid !== target . pid ) {
492+ throw new ExecError ( `refusing to send input: "${ target . name } " is not frontmost${ front ? ` ("${ front . name } " is)` : "" } ; bring it forward first with open_application { activate: true } or click into it` ) ;
493+ }
494+ return front ;
495+ }
496+
445497 async function listWindows ( appRef ) {
446498 const p = await findProcess ( appRef ?? { } ) ;
447499 if ( ! p . found ) throw new ExecError ( "application not found — call list_apps for exact names/pids" ) ;
@@ -456,11 +508,25 @@ export function create({ exec }) {
456508 if ( activate ) args . unshift ( "-F" ) ;
457509 const r = await runL ( "open" , args , { timeoutMs : 25_000 } ) ;
458510 if ( r . code !== 0 ) throw new ExecError ( `open failed: ${ r . stderr . trim ( ) . slice ( 0 , 200 ) } ` , r ) ;
459- await new Promise ( ( res ) => setTimeout ( res , 600 ) ) ;
460511 const find = { } ;
461512 if ( bid ) find . bundle_id = bid ; else if ( pid ) find . pid = pid ; else find . name = String ( name ) . replace ( / \. a p p $ / , "" ) ;
462- const p = await findProcess ( find ) . catch ( ( ) => null ) ;
463- return { launched : true , activate, url : urlArg ?? null , resolved : p ?. found ? { name : p . name , pid : p . pid , bundle_id : p . bundle_id , frontmost : p . frontmost } : null } ;
513+ // `open -F` returns before the app is in front. Wait for the process to
514+ // exist and, when activation was asked for, to actually be frontmost;
515+ // otherwise the next keystroke lands in whatever app is (#5927).
516+ let p = null ;
517+ for ( let attempt = 0 ; attempt < 10 ; attempt ++ ) {
518+ await new Promise ( ( res ) => setTimeout ( res , 300 ) ) ;
519+ p = await findProcess ( find ) . catch ( ( ) => null ) ;
520+ if ( p ?. found && ( ! activate || p . frontmost ) ) break ;
521+ }
522+ const resolved = p ?. found ? { name : p . name , pid : p . pid , bundle_id : p . bundle_id , frontmost : ! ! p . frontmost } : null ;
523+ const frontmost = ! ! resolved ?. frontmost ;
524+ const note = activate && ! frontmost
525+ ? ( resolved
526+ ? `"${ resolved . name } " is running but did not come to the front within 3 s; keystrokes would go to another app — retry activation or click into its window before typing`
527+ : `the app did not appear within 3 s of \`open\`; call list_apps to see what is running` )
528+ : undefined ;
529+ return { launched : true , activate, frontmost, pid : resolved ?. pid ?? null , url : urlArg ?? null , resolved, ...( note ? { note } : { } ) } ;
464530 }
465531
466532 // ---------- clipboard / cursor / waits ----------
@@ -493,21 +559,94 @@ print('{\"x\": %d, \"y\": %d}' % (l.x, l.y))`;
493559 }
494560
495561 // ---------- probe ----------
562+ const TCC_FIX = {
563+ accessibility : "System Settings → Privacy & Security → Accessibility → enable the host app, then relaunch it" ,
564+ screen_recording : "System Settings → Privacy & Security → Screen & System Audio Recording → enable the host app, then relaunch it" ,
565+ } ;
566+ // TCC attributes grants to the .app that owns this process tree (the
567+ // terminal or IDE hosting the engine), never to node or osascript. Name it so
568+ // the remedy says which row to flip.
569+ async function hostAppName ( ) {
570+ if ( state . hostApp !== undefined ) return state . hostApp ;
571+ // Keep the outermost bundle: framework binaries also live inside an .app
572+ // (python3 runs from Python.app), but TCC holds the launching app
573+ // responsible for everything under it.
574+ let pid = process . ppid ;
575+ let found = null ;
576+ for ( let depth = 0 ; depth < 12 && pid > 1 ; depth ++ ) {
577+ const r = await runL ( "ps" , [ "-o" , "ppid=,comm=" , "-p" , String ( pid ) ] , { timeoutMs : 4_000 } ) ;
578+ if ( r . code !== 0 ) break ;
579+ const m = / ^ \s * ( \d + ) \s + ( .* ) $ / . exec ( r . stdout . trim ( ) ) ;
580+ if ( ! m ) break ;
581+ const app = / ( [ ^ / ] + ) \. a p p \/ / . exec ( m [ 2 ] ) ;
582+ if ( app ) found = app [ 1 ] ;
583+ pid = Number ( m [ 1 ] ) ;
584+ }
585+ state . hostApp = found ;
586+ return found ;
587+ }
588+ async function grantTarget ( ) {
589+ const host = await hostAppName ( ) . catch ( ( ) => null ) ;
590+ return host ? `"${ host } "` : "the app hosting the Codewhale engine (your terminal)" ;
591+ }
592+ // Ask TCC instead of guessing from tool presence: screencapture exits 0
593+ // without the grant (it just omits windows) and osascript hangs on the
594+ // prompt, so probing by running them proves nothing.
595+ // The JXA bridge does not expose CGPreflightScreenCaptureAccess, so the
596+ // Screen Recording state is read the way TCC enforces it: without the grant,
597+ // CGWindowListCopyWindowInfo strips kCGWindowName from every other
598+ // process's window. No other windows on screen means the answer is unknown.
599+ async function tccState ( ) {
600+ const r = await jxa ( `ObjC.import('ApplicationServices'); ObjC.import('CoreGraphics'); ObjC.import('Foundation');
601+ function run(){
602+ const out = { accessibility: !!$.AXIsProcessTrusted(), screen_recording: null };
603+ const me = $.NSProcessInfo.processInfo.processIdentifier;
604+ const list = $.CGWindowListCopyWindowInfo($.kCGWindowListOptionOnScreenOnly | $.kCGWindowListExcludeDesktopElements, $.kCGNullWindowID);
605+ const n = Number($.CFArrayGetCount(list));
606+ let others = 0, named = 0;
607+ for (let i = 0; i < n; i++) {
608+ const d = ObjC.deepUnwrap(ObjC.castRefToObject($.CFArrayGetValueAtIndex(list, i)));
609+ if (!d || d.kCGWindowOwnerPID === me || d.kCGWindowLayer !== 0) continue;
610+ others++;
611+ if (typeof d.kCGWindowName === 'string' && d.kCGWindowName.length) named++;
612+ }
613+ if (others > 0) out.screen_recording = named > 0;
614+ return JSON.stringify(out);
615+ }` , { } , 8_000 ) ;
616+ return r && typeof r === "object" ? r : { } ;
617+ }
496618 async function probe ( ) {
497- const caps = { screenshot : true , recording : true , accessibility_tree : true , clipboard : true , displays : true } ;
619+ const caps = { screenshot : true , recording : true , accessibility_tree : true , raw_input : true , clipboard : true , displays : true } ;
498620 const perms = { } ;
499- try { await jxa ( `function run(argv){ return JSON.stringify({n: Application('System Events').applicationProcesses.length}); }` , { } , 8_000 ) ; perms . accessibility = "granted" ; }
500- catch ( e ) { perms . accessibility = "denied_or_unavailable" ; caps . accessibility_tree = false ; caps . raw_input = "unreliable" ; }
501- try {
502- const t = os . tmpdir ( ) + `/cu-probe-${ crypto . randomBytes ( 3 ) . toString ( "hex" ) } .png` ;
503- const r = await runL ( "screencapture" , [ "-x" , "-R0,0,2,2" , "-t" , "png" , t ] , { timeoutMs : 8_000 } ) ;
504- perms . screen_capture = r . code === 0 ? "ok" : "failed" ;
505- try { fs . rmSync ( t , { force : true } ) ; } catch { }
506- } catch { perms . screen_capture = "failed" ; }
507- const hasRecording = fs . existsSync ( "/usr/sbin/screencapture" ) ;
508- return { platform : "darwin" , capabilities : caps , permissions : perms , note : "macOS does not expose Screen-Recording TCC state to CLI; a black/empty screenshot means Screen Recording permission is missing. Raw pointer/keyboard events go to whatever is frontmost at the target point — activate the app first for click-type actions." } ;
621+ const missing = [ ] ;
622+ let tcc = { } ;
623+ try { tcc = await tccState ( ) ; } catch ( e ) { perms . probe_error = String ( e ?. message || e ) . slice ( 0 , 200 ) ; }
624+ state . tcc = tcc ;
625+ const target = await grantTarget ( ) ;
626+ if ( tcc . accessibility === false ) {
627+ perms . accessibility = "denied" ;
628+ caps . accessibility_tree = false ;
629+ caps . raw_input = false ;
630+ missing . push ( "accessibility" ) ;
631+ } else {
632+ perms . accessibility = tcc . accessibility === true ? "granted" : "unknown" ;
633+ }
634+ if ( tcc . screen_recording === false ) {
635+ perms . screen_recording = "denied" ;
636+ caps . screenshot = false ;
637+ caps . recording = false ;
638+ missing . push ( "screen_recording" ) ;
639+ } else {
640+ perms . screen_recording = tcc . screen_recording === true ? "granted" : "unknown" ;
641+ }
642+ const how_to_fix = Object . fromEntries ( missing . map ( ( m ) => [ m , `${ TCC_FIX [ m ] } — grant it to ${ target } ` ] ) ) ;
643+ const note = missing . length
644+ ? `Missing: ${ missing . join ( ", " ) } . Grants belong to ${ target } , not to node or osascript. ${ Object . values ( how_to_fix ) . join ( " " ) } `
645+ : "Raw pointer/keyboard events go to whatever is frontmost at the target point — activate the app first for click-type actions." ;
646+ return { platform : "darwin" , capabilities : caps , permissions : perms , missing, how_to_fix, host_app : state . hostApp ?? null , note } ;
509647 }
510648
649+
511650 return {
512651 platform : "darwin" ,
513652 probe,
@@ -565,33 +704,36 @@ print('{\"x\": %d, \"y\": %d}' % (l.x, l.y))`;
565704 $.CGEventPost($.kCGHIDEventTap, ev);
566705 return JSON.stringify({ ok: true });` , { dx, dy } ) . then ( ( ) => ( { action_sent : true , direction, amount } ) ) ;
567706 } ,
568- type : async ( { text } ) => {
707+ type : async ( { text, app_ref } ) => {
569708 if ( ! text ) return { action_sent : false , note : "empty text" } ;
709+ const frontmost_app = await guardInput ( app_ref ) ;
570710 const r = await cg ( `var ev = $.CGEventCreateKeyboardEvent($(), 0, true);
571711 $.CGEventKeyboardSetUnicodeString(ev, P.text.length, P.text);
572712 $.CGEventPost($.kCGHIDEventTap, ev);
573713 var ev2 = $.CGEventCreateKeyboardEvent($(), 0, false);
574714 $.CGEventKeyboardSetUnicodeString(ev2, P.text.length, P.text);
575715 $.CGEventPost($.kCGHIDEventTap, ev2);
576716 return JSON.stringify({ ok: true, chars: P.text.length });` , { text } , 15_000 ) ;
577- return { action_sent : true , chars : text . length , strategy : "unicode-events" } ;
717+ return { action_sent : true , chars : text . length , strategy : "unicode-events" , frontmost_app } ;
578718 } ,
579- key : async ( { text, repeat = 1 } ) => {
719+ key : async ( { text, repeat = 1 , app_ref } ) => {
580720 const { flags, code, key } = parseChord ( text ) ;
721+ const frontmost_app = await guardInput ( app_ref ) ;
581722 for ( let i = 0 ; i < Math . max ( 1 , Math . min ( 100 , repeat ) ) ; i ++ ) {
582723 await keyEvent ( code , flags , true ) ;
583724 await keyEvent ( code , flags , false ) ;
584725 if ( i < repeat - 1 ) await new Promise ( ( r ) => setTimeout ( r , 30 ) ) ;
585726 }
586- return { action_sent : true , key, code, repeat : Math . max ( 1 , Math . min ( 100 , repeat ) ) } ;
727+ return { action_sent : true , key, code, repeat : Math . max ( 1 , Math . min ( 100 , repeat ) ) , frontmost_app } ;
587728 } ,
588- hold_key : async ( { text, duration } ) => {
729+ hold_key : async ( { text, duration, app_ref } ) => {
589730 const { flags, code, key } = parseChord ( text ) ;
731+ const frontmost_app = await guardInput ( app_ref ) ;
590732 const d = Math . max ( 0.05 , Math . min ( 30 , Number ( duration ) || 1 ) ) ;
591733 await keyEvent ( code , flags , true ) ;
592734 await new Promise ( ( r ) => setTimeout ( r , d * 1000 ) ) ;
593735 await keyEvent ( code , flags , false ) ;
594- return { action_sent : true , key, heldSec : d } ;
736+ return { action_sent : true , key, heldSec : d , frontmost_app } ;
595737 } ,
596738 set_value : async ( { target, value } ) => {
597739 const el = await elementAction ( target . app_ref , target . windowIndex , target . path , { kind : "set_value" , value } ) ;
0 commit comments