@@ -598,6 +598,63 @@ function spawnAsync(command, args) {
598598 } ) ;
599599}
600600
601+ /**
602+ * @summary Delivers a wake digest via osascript, retrying transient frontmost-loss races.
603+ *
604+ * macOS focus-stealing prevention makes a background daemon's `activate` / `set frontmost`
605+ * best-effort: when another app holds frontmost during the multi-second activate → paste →
606+ * restore sequence, `assertTargetFrontmost` aborts the osascript with a `-2700`
607+ * "lost frontmost status" error. Focus contention is transient, so we re-attempt the whole
608+ * delivery a few times before giving up.
609+ *
610+ * Phase-aware idempotency guard: the wake payload is submitted (`key code 36` / Enter) BEFORE
611+ * the "user input restore" phases. A frontmost-loss reported for a restore phase therefore means
612+ * the wake already landed — only the user's draft-restore failed (cosmetic). We must NOT retry
613+ * that case (it would double-submit the wake). Non-race errors (syntax/permissions) re-throw
614+ * immediately.
615+ * @param {String[] } osascriptArgs The fully-built `osascript -e …` argument list.
616+ * @param {String } subscriptionId For log attribution.
617+ * @param {String } appName For log attribution.
618+ * @returns {Promise<void> }
619+ */
620+ async function deliverViaOsascriptWithRetry ( osascriptArgs , subscriptionId , appName ) {
621+ const maxAttempts = 4 ,
622+ backoffMs = 800 ;
623+
624+ for ( let attempt = 1 ; attempt <= maxAttempts ; attempt ++ ) {
625+ try {
626+ await spawnAsync ( 'osascript' , osascriptArgs ) ;
627+ writeLog ( 'INFO' ,
628+ `[Bridge Daemon] Delivered ${ subscriptionId } via osascript to ${ appName } ` +
629+ ( attempt > 1 ? ` (attempt ${ attempt } /${ maxAttempts } )` : '' ) ) ;
630+ return
631+ } catch ( err ) {
632+ const message = err . message || '' ,
633+ isFrontmostRace = / l o s t f r o n t m o s t s t a t u s | - 2 7 0 0 / . test ( message ) ,
634+ afterSubmit = / u s e r i n p u t r e s t o r e / . test ( message ) ;
635+
636+ // Wake already submitted; only the draft-restore lost frontmost → delivered, do not retry.
637+ if ( isFrontmostRace && afterSubmit ) {
638+ writeLog ( 'WARN' ,
639+ `[Bridge Daemon] ${ subscriptionId } wake landed but draft-restore lost frontmost; ` +
640+ `not retrying (avoids double-send). ${ message } ` ) ;
641+ return
642+ }
643+
644+ // Lost frontmost before submit → transient focus contention → retry the whole delivery.
645+ if ( attempt < maxAttempts && isFrontmostRace ) {
646+ writeLog ( 'WARN' ,
647+ `[Bridge Daemon] Wake delivery ${ subscriptionId } attempt ${ attempt } /${ maxAttempts } ` +
648+ `lost frontmost before submit (focus contention); retrying in ${ backoffMs } ms. ${ message } ` ) ;
649+ await wait ( backoffMs ) ;
650+ continue
651+ }
652+
653+ throw err
654+ }
655+ }
656+ }
657+
601658/**
602659 * @summary Escapes values interpolated into AppleScript string literals.
603660 * @param {String } value
@@ -774,9 +831,17 @@ async function deliverDigest(subscription, digest) {
774831 '-e' , ' set savedClipboard to ""' ,
775832 '-e' , ' end try' ,
776833 '-e' , ' try' ,
834+ '-e' , ' set targetRaised to false' ,
835+ '-e' , ' repeat 12 times' ,
777836 '-e' , activateLine ,
778- '-e' , ' delay 0.5' ,
779- '-e' , ' my assertTargetFrontmost(targetAppName, targetBundleId, targetProcessId, "after activation")' ,
837+ '-e' , ' delay 0.25' ,
838+ '-e' , ' try' ,
839+ '-e' , ' my assertTargetFrontmost(targetAppName, targetBundleId, targetProcessId, "after activation")' ,
840+ '-e' , ' set targetRaised to true' ,
841+ '-e' , ' exit repeat' ,
842+ '-e' , ' end try' ,
843+ '-e' , ' end repeat' ,
844+ '-e' , ' if not targetRaised then my assertTargetFrontmost(targetAppName, targetBundleId, targetProcessId, "after activation")' ,
780845 '-e' , ' tell application "System Events"' ,
781846 '-e' , ' set frontmostProcess to first application process whose frontmost is true' ,
782847 '-e' , ' tell frontmostProcess'
@@ -863,8 +928,7 @@ async function deliverDigest(subscription, digest) {
863928 digest
864929 ) ;
865930
866- await spawnAsync ( 'osascript' , osascriptArgs ) ;
867- writeLog ( 'INFO' , `[Bridge Daemon] Delivered ${ subscription . id } via osascript to ${ appName } ` ) ;
931+ await deliverViaOsascriptWithRetry ( osascriptArgs , subscription . id , appName ) ;
868932 } else if ( adapter === 'test' ) {
869933 writeLog ( 'INFO' , `[Bridge Daemon Test Adapter] Delivered ${ subscription . id } : ${ digest } ` ) ;
870934 } else {
0 commit comments