Skip to content

fix(ios): publish captured keyboard geometry in the app's orientation space - #2653

Merged
thymikee merged 1 commit into
mainfrom
fix/ios-keyboard-coordinate-space
Sep 18, 2026
Merged

thymikee merged 1 commit into
mainfrom
fix/ios-keyboard-coordinate-space

Conversation

@thymikee

@thymikee thymikee commented Sep 16, 2026

Copy link
Copy Markdown
Member

Summary

iOS hosts UIRemoteKeyboardWindow in the device's native portrait space, so in landscape the keyboard's whole subtree arrives quarter-turned: a key measured 45x72 at x 154 inside an 874x402 app, drawing a strip down the left edge where the screenshot shows a 724x204 band at y 198. Rules that read those numbers refused app content the keyboard was nowhere near and let a tap land on a key.

The Apple runner now reads the app's interface orientation at capture time and publishes every rect under a turned surface host in the app's space, using the exact inverse of the rotation its synthesized touches rotate forward by — one table, both directions, so a reported rect and a performed tap cannot disagree about which pixel is which. The Simulator AX bridge reader carries no orientation, so it refuses such a capture (window-coordinate-space-unresolved) and the route serves the runner for that capture only, without retiring the app generation. Contracts pins both rules in one table replayed in TypeScript and Swift.

21 files, 630 production lines; the 1.64k gross adds 372 unit-test-only Swift lines behind #if AGENT_DEVICE_RUNNER_UNIT_TESTS, the golden table and its replays, and an ADR amendment. The rotation table moved into the new RunnerCoordinateSpace.swift out of RunnerTests+Interaction.swift; the rule's TypeScript home is private to snapshot-source, so no new package export is declared.

snapshot -i --json   # landscape, keyboard up -> band (75,238,724,162)
press @e65           # -> "behind the visible keyboard and cannot be tapped safely"

Closes #2612.

Validation

Head 9b0935cc61. pnpm check:affected --run exit 0; unit suite 1333 files / 10,383 tests; lint and typecheck clean. 6 runner XCTest units on iPhone 17 Pro (iOS 26.2). Device: after orientation landscape-left with the keyboard up, the capture warns it left the bridge and publishes the band at y 238 and key q at (77,243,72,35); pressing the tab item inside the band is refused, the next capture is served by the bridge again, and portrait is unchanged. Unresolved: coverage and Integration Tests jobs on this head; iPad and physical-device keyboards unmeasured.

@thymikee

Copy link
Copy Markdown
Member Author

Reviewed at 9b0935c. The rotation logic looks right: the rect transform is the exact inverse of the touch rotation, only nodes under a turned surface host are rotated, and the bridge refusal sends only that one capture to the runner, keyed on the typed window-coordinate-space-unresolved code. The PR body reports a live landscape run on iPhone 17 Pro that reaches this path. I did not re-run it.

One size question before this is ready. The production diff is about 771 lines, which is over the 700-line review threshold, although much of RunnerCoordinateSpace.swift is unit-test-only code. The one smaller design I can see is to keep isQuarterTurnedWindowFrame private to packages/platform-apple/src/snapshot-source, its only reader, instead of adding a new contracts subpath. Would that work, or does the shared golden table need the subpath, as tap-keyboard-occlusion does?

The unit tests do not cover the walk wiring. Every privateAXAcquisition test in RunnerTests+PrivateAXPresentation.swift uses portrait with no turned window, so if a walk drops its geometrySpace argument, all tests still pass. Can you add one landscapeRight case with a keyboard window and a key under it? It should assert that the key's rect is (77,203,72,45) and that a sibling under the app window does not change.

In collapsedTabCandidateNode (RunnerTests+Snapshot.swift L1263), the code compares live XCUIElement.frame values with snapshot frames and then rotates the live frame. If live frames under the keyboard window are already in app space, can this path rotate them twice? The path may be unreachable today. Skipping the expansion when the space is not .appOrientation would remove the doubt.

Two smaller notes. In RunnerCoordinateSpace.swift, a turned host with portrait or unknown orientation adds the app frame origin back, which contradicts the "publishes what the platform reported" doc comment (L129), and upside-down is never detected. In tree.test.ts (L246), the window-order test only asserts notEqual 0, so it does not prove the "symmetric" detection comment in tree.ts (L300).

Two questions on the live run. The reported key q rect (77,243,72,35) is different from the unit fixture (77,203,72,45). Is that difference expected from a different keyboard state? And in landscape without a keyboard, does the bridge still serve the capture, or does its root frame cause the refusal every time? The second case would cost speed, not correctness.

Only the CodeQL checks have run on this head. The main CI jobs, including Integration Tests and Coverage, have not reported yet. The PR conflicts with main in CHANGELOG.md. Next step: rebase, answer the size question, and let CI run on the new head.

… space

iOS hosts the system keyboard in the device's native portrait space, so in landscape its whole
subtree arrives quarter-turned: a key measured 45x72 at x 154 in an 874x402 app, drawing a strip down
the left edge where the screenshot shows a 724x204 band docked at y 198. Rules that read those numbers
refused app content the keyboard was nowhere near and let a tap land on a key (#2612).

The Apple runner now reads the app's interface orientation at capture time and publishes every rect
under a turned surface host in the app's space, through the exact inverse of the rotation its
synthesized touches rotate forward by, so a reported rect and a performed tap cannot disagree about
which pixel is which. One table serves both directions, and the new coordinate-space module owns it.
An orientation that names no quarter turn declares nothing, and the collapsed-tab expansion, which
reads live element frames, stays out of a declared native space rather than turning them twice.

The Simulator AX bridge reader carries no interface orientation, so it counts surface hosts reporting
the app box quarter-turned and refuses that capture with window-coordinate-space-unresolved; the route
serves the runner for that capture alone rather than retiring the app generation, so the next capture
of a healthy app still uses the bridge. Both languages replay one table,
contracts/fixtures/window-coordinate-space.json, which pins the turn and the box that declares it.
@thymikee
thymikee force-pushed the fix/ios-keyboard-coordinate-space branch from 9b0935c to bc80c28 Compare September 18, 2026 05:11
@thymikee

Copy link
Copy Markdown
Member Author

Rebased and addressed at bc80c28d. Production diff is now 630 lines (was ~771); gross 1,642, of which 372 are unit-test-only Swift lines behind #if AGENT_DEVICE_RUNNER_UNIT_TESTS.

Size question — took it. isQuarterTurnedWindowFrame now lives in packages/platform-apple/src/snapshot-source/window-coordinate-space.ts, its only reader, so the new contracts subpath, its package.json export and the contracts-exports.snapshot.json regeneration are all gone — the chore(gates) commit disappeared with them. The table itself stays in contracts/fixtures/ because the Swift capture replays the same JSON and a copy would be a second source of truth; a contracts fixture with no contracts module already has a precedent in ios-snapshot-backends.json, which only packages/capture-kit reads.

Walk wiringtestPrivateAXAcquisitionPublishesATurnedSurfaceHostInAppOrientationSpace drives privateAXAcquisition with landscapeRight, a keyboard window, a key under it and a sibling under the app window: key (77,203,72,45), sibling (204,323,91,55).

collapsedTabCandidateNode — it did read live XCUIElement.frame values and rotate them, so the expansion now returns early unless the space is .appOrientation. A collapsed tab container is app chrome no rotated surface hosts, so nothing real loses nodes.

Portrait/unknown and upside-down — an orientation that names no quarter turn now declares nothing, so no app-frame origin is ever added back and the doc comment says what the code does. Upside-down is called out as what the box rule cannot decide: an upside-down app's own box is already the native box, which is also why the consumer rules stay.

Symmetrytree.test.ts now passes two roots and asserts 1, not notEqual 0: read the turned window as the app frame and the app's own window becomes the turned one.

The two live-run questions. The q difference is keyboard layout, not the transform: that run had the 163 pt URL-field keyboard, the fixture row the 202 pt email keyboard. Same rule, both sides: native (123.67,77,35.33,72.33)(77, 402-159, 72.33, 35.33) = the reported (77,242,72,36). And landscape without a keyboard costs nothing — re-ran on the new head: dismissed keyboard, a fresh launch and a screen with no focused field are all served by the bridge with no fallback warning. Only a capture holding a turned surface host pays the runner.

Still open: the main CI jobs (Integration Tests, Coverage) on this head, and iPad or physical-device keyboards, which stay unmeasured.

@github-actions

Copy link
Copy Markdown

Size Report

Metric Base Current Diff
Installed (including dependencies) 4.59 MB 4.60 MB +9.2 kB
Package (unpacked) 4.59 MB 4.60 MB +9.2 kB
Package (download) 1.37 MB 1.37 MB +1.8 kB

Startup median (7 runs, lower is better):

Scenario Base Current Diff
CLI --version 27.5 ms 28.4 ms +0.9 ms
CLI --help 78.6 ms 78.0 ms -0.7 ms

@thymikee

Copy link
Copy Markdown
Member Author

Reviewed at bc80c28. The points from the 9b0935c review are addressed. isQuarterTurnedWindowFrame is now private to platform-apple/src/snapshot-source, and the parity table stays in contracts/fixtures, read by both Swift and TS. The new landscapeRight walk test would fail if a walk dropped its space: the key lands at (77,203,72,45) and the sibling does not move. collapsedTabCandidateNode returns early unless the space is .appOrientation, portrait and unknown orientations declare nothing, and the window-order test asserts exactly 1. The landscape rect transform did not change in this delta, so the earlier landscape keyboard run still covers the keyboard path, and your no-keyboard landscape re-run on bc80c28 covers the fallthrough.

Not blocking: after the guard at line 1172, collapsedTabCandidateNode only runs with .appOrientation, so its geometrySpace parameter and the orientedFrame call do nothing, and the comment above it describes a rotation that can no longer happen; could you drop the parameter or note that it is always .appOrientation?

I did not run the gated Swift XCTests; I checked the new test's expected rect by hand. CI is green: 18 checks, 0 failing at bc80c28.

@thymikee thymikee added the ready-for-human Valid work that needs human implementation, judgment, or maintainer merge label Sep 18, 2026
@thymikee
thymikee merged commit 91652a8 into main Sep 18, 2026
18 checks passed
@thymikee
thymikee deleted the fix/ios-keyboard-coordinate-space branch September 18, 2026 07:46
@github-actions

Copy link
Copy Markdown
PR Preview Action v1.8.1
Preview removed because the pull request was closed.
2026-09-18 07:46 UTC

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ready-for-human Valid work that needs human implementation, judgment, or maintainer merge

Projects

None yet

Development

Successfully merging this pull request may close these issues.

iOS reports the landscape iPhone keyboard's rects in the keyboard's own rotated space

1 participant