mirror of
https://github.com/priyanshujain/sanderling.git
synced 2026-10-04 12:07:09 +00:00
fix(ioscompanion): atomic erase and single-send paste to prevent doubling
This commit is contained in:
1 parent
02d3722820
commit
432154ee02
1 file changed
+56
-32
@@ -126,29 +126,21 @@ func inputText(ctx context.Context, run runner, text string, field fieldTarget)
|
|||||||
return pasteText(ctx, run, text, field)
|
return pasteText(ctx, run, text, field)
|
||||||
}
|
}
|
||||||
|
|
||||||
// pasteText copies the full text to the pasteboard, sends the paste chord, and
|
// pasteText copies the full text to the pasteboard, sends the paste chord
|
||||||
// loops past the permission dialog until the field reflects the text or the
|
// once, and polls until the field reflects the text. The chord is re-sent ONLY
|
||||||
// attempts are exhausted.
|
// after dismissing a permission dialog (the dialog swallowed that paste);
|
||||||
|
// re-sending it on a slow render would paste the text twice. When the field
|
||||||
|
// cannot be verified (no identifier resolved), one chord plus a settle is the
|
||||||
|
// best available behavior.
|
||||||
func pasteText(ctx context.Context, run runner, text string, field fieldTarget) error {
|
func pasteText(ctx context.Context, run runner, text string, field fieldTarget) error {
|
||||||
if err := run.setPasteboard(ctx, text); err != nil {
|
if err := run.setPasteboard(ctx, text); err != nil {
|
||||||
return fmt.Errorf("set pasteboard: %w", err)
|
return fmt.Errorf("set pasteboard: %w", err)
|
||||||
}
|
}
|
||||||
|
if err := run.sendHID(ctx, pasteChordEvents()...); err != nil {
|
||||||
|
return fmt.Errorf("send paste chord: %w", err)
|
||||||
|
}
|
||||||
|
verifiable := field.identifier != ""
|
||||||
for attempt := 0; attempt < pasteAttempts; attempt++ {
|
for attempt := 0; attempt < pasteAttempts; attempt++ {
|
||||||
if err := run.sendHID(ctx, pasteChordEvents()...); err != nil {
|
|
||||||
return fmt.Errorf("send paste chord: %w", err)
|
|
||||||
}
|
|
||||||
// A warm paste lands within a frame or two; check once before paying
|
|
||||||
// the settle sleep so the common case stays fast.
|
|
||||||
quick, err := run.describeAll(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("describe accessibility: %w", err)
|
|
||||||
}
|
|
||||||
if pasteLanded(quick, field.identifier, text) {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if err := run.sleep(ctx, pasteSettle); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
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)
|
||||||
@@ -156,23 +148,30 @@ func pasteText(ctx context.Context, run runner, text string, field fieldTarget)
|
|||||||
if pasteLanded(dump, field.identifier, text) {
|
if pasteLanded(dump, field.identifier, text) {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
button, found := findAllowPasteButton(dump)
|
if button, found := findAllowPasteButton(dump); found {
|
||||||
if !found {
|
if err := run.sendHID(ctx, tapEvents(button.centerX, button.centerY)...); err != nil {
|
||||||
|
return fmt.Errorf("tap allow button: %w", err)
|
||||||
|
}
|
||||||
if err := run.sleep(ctx, dialogSettle); err != nil {
|
if err := run.sleep(ctx, dialogSettle); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if err := run.sendHID(ctx, tapEvents(field.centerX, field.centerY)...); err != nil {
|
||||||
|
return fmt.Errorf("refocus field: %w", err)
|
||||||
|
}
|
||||||
|
if err := run.sleep(ctx, dialogSettle); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := run.sendHID(ctx, pasteChordEvents()...); err != nil {
|
||||||
|
return fmt.Errorf("send paste chord: %w", err)
|
||||||
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if err := run.sendHID(ctx, tapEvents(button.centerX, button.centerY)...); err != nil {
|
if !verifiable && attempt > 0 {
|
||||||
return fmt.Errorf("tap allow button: %w", err)
|
// No way to confirm landing; the chord went out and no dialog is
|
||||||
|
// blocking it. Give it one settle and move on.
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
if err := run.sleep(ctx, dialogSettle); err != nil {
|
if err := run.sleep(ctx, pasteSettle); err != nil {
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := run.sendHID(ctx, tapEvents(field.centerX, field.centerY)...); err != nil {
|
|
||||||
return fmt.Errorf("refocus field: %w", err)
|
|
||||||
}
|
|
||||||
if err := run.sleep(ctx, dialogSettle); err != nil {
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -205,13 +204,27 @@ func warmUpPaste(ctx context.Context, run runner) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// eraseText deletes characterCount characters from the focused field by sending
|
// eraseBackspaceThreshold is the largest erase still sent as individual
|
||||||
// that many backspaces in one HID stream.
|
// backspaces. Backspaces render progressively on the simulator (tens of
|
||||||
|
// milliseconds per character), so clearing a long field key-by-key leaves the
|
||||||
|
// screen churning long after the HID call returns and races whatever input
|
||||||
|
// follows. Above the threshold the field is cleared atomically instead.
|
||||||
|
const eraseBackspaceThreshold = 3
|
||||||
|
|
||||||
|
// eraseText deletes characterCount characters from the focused field. Small
|
||||||
|
// counts go as backspaces in one HID stream; larger counts clear the whole
|
||||||
|
// field via select-all plus one backspace. The runner asks for the field's
|
||||||
|
// full length when it pre-erases (replace semantics), so treating a large
|
||||||
|
// count as clear-the-field matches its intent while landing in one frame.
|
||||||
func eraseText(ctx context.Context, run runner, characterCount int) error {
|
func eraseText(ctx context.Context, run runner, characterCount int) error {
|
||||||
if characterCount <= 0 {
|
if characterCount <= 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return run.sendHID(ctx, keyPressEvents(backspaces(characterCount))...)
|
if characterCount <= eraseBackspaceThreshold {
|
||||||
|
return run.sendHID(ctx, keyPressEvents(backspaces(characterCount))...)
|
||||||
|
}
|
||||||
|
events := append(selectAllChordEvents(), keyPressEvents(backspaces(1))...)
|
||||||
|
return run.sendHID(ctx, events...)
|
||||||
}
|
}
|
||||||
|
|
||||||
// keyPressEvents flattens key presses into a HID event stream. A shifted press
|
// keyPressEvents flattens key presses into a HID event stream. A shifted press
|
||||||
@@ -242,6 +255,17 @@ func pasteChordEvents() []transport.HIDEvent {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// selectAllChordEvents is the command+A chord selecting the focused field's
|
||||||
|
// whole content.
|
||||||
|
func selectAllChordEvents() []transport.HIDEvent {
|
||||||
|
return []transport.HIDEvent{
|
||||||
|
transport.KeyDown(LeftGUI),
|
||||||
|
transport.KeyDown(usageA),
|
||||||
|
transport.KeyUp(usageA),
|
||||||
|
transport.KeyUp(LeftGUI),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// tapEvents is a single tap: finger down then up at one point.
|
// tapEvents is a single tap: finger down then up at one point.
|
||||||
func tapEvents(x, y float64) []transport.HIDEvent {
|
func tapEvents(x, y float64) []transport.HIDEvent {
|
||||||
return []transport.HIDEvent{transport.TouchDown(x, y), transport.TouchUp(x, y)}
|
return []transport.HIDEvent{transport.TouchDown(x, y), transport.TouchUp(x, y)}
|
||||||
|
|||||||
Reference in new issue
Block a user