fix(ioscompanion): verify paste on a time budget that outlasts the bridge blackout

This commit is contained in:
pj committed 2026-06-07 00:56:25 +05:30
1 parent 0e6f3e7a7a
commit 3ee26ca56c
1 file changed
+44 -35
+44 -35
View File
@@ -21,21 +21,23 @@ import (
// as a parameter so the value stays configurable. // as a parameter so the value stays configurable.
const DefaultDoubleTapGapMilliseconds = 70 const DefaultDoubleTapGapMilliseconds = 70
// pasteAttempts bounds the paste-and-dismiss-dialog retry loop. Each attempt // pasteVerifyTimeout bounds the whole paste-and-verify loop. Dismissing the
// resends the paste chord and checks for the permission dialog or a landed // permission dialog blacks out the accessibility bridge for around 2.5s (the
// value, so eight attempts comfortably covers the one-to-two dialogs iOS shows // dump collapses to the root element), and the pasted value only becomes
// per app session plus a couple of refocus retries. // readable once it recovers, so the budget has to outlast that blackout.
const pasteAttempts = 8 const pasteVerifyTimeout = 8 * time.Second
// pasteSettle is how long to wait after a paste chord before reading the // pastePoll is the interval between describe-all reads while verifying a paste.
// hierarchy. The warm-paste measurement in the validated spike settled around const pastePoll = 250 * time.Millisecond
// 124ms; this leaves margin for a cold paste while keeping the loop tight.
const pasteSettle = 350 * time.Millisecond
// dialogSettle is the pause after tapping the dialog's allow button and after // dialogSettle is the pause after tapping the dialog's allow button and after
// refocusing the field, before the next paste attempt. // refocusing the field, before the paste chord is resent.
const dialogSettle = 200 * time.Millisecond const dialogSettle = 200 * time.Millisecond
// pasteAttempts bounds the warm-up paste's dialog poll. Warm-up only needs to
// surface and dismiss the permission dialog once.
const pasteAttempts = 8
// warmUpPrimer is the throwaway string pasted at session start so the // warmUpPrimer is the throwaway string pasted at session start so the
// permission dialog fires and gets handled before any real input. // permission dialog fires and gets handled before any real input.
const warmUpPrimer = "sanderling" const warmUpPrimer = "sanderling"
@@ -99,25 +101,19 @@ type fieldTarget struct {
centerY float64 centerY float64
} }
// pasteLengthThreshold is the rune count above which mappable text still goes // usesPasteboard reports whether text takes the pasteboard path. Only
// through the pasteboard. Typed keys render progressively on the simulator // unmappable runes force it: on this OS generation every external pasteboard
// (roughly 75ms per character), so a long typed string keeps the screen // write re-triggers the paste-permission dialog and dismissing it blacks out
// churning well past the HID call and stretches the post-action settle; a // the accessibility bridge for seconds, so the hardware keyboard stays the
// paste lands the whole value in one frame. // default for everything it can express.
const pasteLengthThreshold = 3
// usesPasteboard reports whether text takes the pasteboard path: any
// unmappable rune forces it, and longer mappable text uses it so the value
// lands atomically.
func usesPasteboard(text string) bool { func usesPasteboard(text string) bool {
_, skipped := typeString(text) _, skipped := typeString(text)
return len(skipped) > 0 || len([]rune(text)) > pasteLengthThreshold return len(skipped) > 0
} }
// inputText types text into the focused field. Short mappable text goes // inputText types text into the focused field. Mappable text goes through the
// through the hardware keyboard in one HID stream; everything else goes // hardware keyboard in one HID stream; anything else falls back to the
// through the pasteboard. The field target is only consulted on the // pasteboard. The field target is only consulted on the pasteboard path.
// pasteboard path.
func inputText(ctx context.Context, run runner, text string, field fieldTarget) error { func inputText(ctx context.Context, run runner, text string, field fieldTarget) error {
if !usesPasteboard(text) { if !usesPasteboard(text) {
presses, _ := typeString(text) presses, _ := typeString(text)
@@ -140,7 +136,8 @@ func pasteText(ctx context.Context, run runner, text string, field fieldTarget)
return fmt.Errorf("send paste chord: %w", err) return fmt.Errorf("send paste chord: %w", err)
} }
verifiable := field.identifier != "" verifiable := field.identifier != ""
for attempt := 0; attempt < pasteAttempts; attempt++ { maxPolls := int(pasteVerifyTimeout / pastePoll)
for poll := 0; poll < maxPolls; poll++ {
dump, err := run.describeAll(ctx) dump, err := run.describeAll(ctx)
if err != nil { if err != nil {
return fmt.Errorf("describe accessibility: %w", err) return fmt.Errorf("describe accessibility: %w", err)
@@ -149,6 +146,9 @@ func pasteText(ctx context.Context, run runner, text string, field fieldTarget)
return nil return nil
} }
if button, found := findAllowPasteButton(dump); found { if button, found := findAllowPasteButton(dump); found {
// The dialog swallowed the paste; dismiss it, refocus, and resend
// the chord exactly once. Dismissing blacks out the bridge, so the
// landed value only appears on a later poll.
if err := run.sendHID(ctx, tapEvents(button.centerX, button.centerY)...); err != nil { if err := run.sendHID(ctx, tapEvents(button.centerX, button.centerY)...); err != nil {
return fmt.Errorf("tap allow button: %w", err) return fmt.Errorf("tap allow button: %w", err)
} }
@@ -166,22 +166,28 @@ func pasteText(ctx context.Context, run runner, text string, field fieldTarget)
} }
continue continue
} }
if !verifiable && attempt > 0 { if !verifiable {
// No way to confirm landing; the chord went out and no dialog is // Without a field identifier the paste cannot be confirmed. The
// blocking it. Give it one settle and move on. // chord went out and no dialog is blocking it, so one settle is the
return nil // best available behavior.
return run.sleep(ctx, pastePoll)
} }
if err := run.sleep(ctx, pasteSettle); err != nil { // Field not yet showing the text: either the bridge is still blacked
// out from the dialog or the paste has not rendered. Keep polling until
// the value lands or the budget runs out.
if err := run.sleep(ctx, pastePoll); err != nil {
return err return err
} }
} }
return fmt.Errorf("paste did not land after %d attempts", pasteAttempts) return fmt.Errorf("paste did not land within %s", pasteVerifyTimeout)
} }
// warmUpPaste pastes a throwaway primer once so the iOS pasteboard-permission // warmUpPaste pastes a throwaway primer once so the iOS pasteboard-permission
// dialog fires and gets dismissed at a moment the driver chooses (session // dialog fires and gets dismissed at a moment the driver chooses (session
// start) rather than mid-run. It does not assert the paste landed: the goal is // start) rather than mid-run. It polls for the dialog (which appears around
// only to clear the dialog. A best-effort allow-button tap handles the prompt. // half a second after the chord) and taps allow once. It does not assert the
// paste landed: the goal is only to clear the dialog so real input never has
// to.
func warmUpPaste(ctx context.Context, run runner) error { func warmUpPaste(ctx context.Context, run runner) error {
if err := run.setPasteboard(ctx, warmUpPrimer); err != nil { if err := run.setPasteboard(ctx, warmUpPrimer); err != nil {
return fmt.Errorf("set pasteboard: %w", err) return fmt.Errorf("set pasteboard: %w", err)
@@ -189,7 +195,8 @@ func warmUpPaste(ctx context.Context, run runner) error {
if err := run.sendHID(ctx, pasteChordEvents()...); err != nil { if err := run.sendHID(ctx, pasteChordEvents()...); err != nil {
return fmt.Errorf("send paste chord: %w", err) return fmt.Errorf("send paste chord: %w", err)
} }
if err := run.sleep(ctx, pasteSettle); err != nil { for poll := 0; poll < pasteAttempts; poll++ {
if err := run.sleep(ctx, pastePoll); err != nil {
return err return err
} }
dump, err := run.describeAll(ctx) dump, err := run.describeAll(ctx)
@@ -200,6 +207,8 @@ func warmUpPaste(ctx context.Context, run runner) error {
if err := run.sendHID(ctx, tapEvents(button.centerX, button.centerY)...); err != nil { if err := run.sendHID(ctx, tapEvents(button.centerX, button.centerY)...); err != nil {
return fmt.Errorf("tap allow button: %w", err) return fmt.Errorf("tap allow button: %w", err)
} }
return nil
}
} }
return nil return nil
} }