Skip to content

fix(ios): publish a placed iPad window's capture in screen space - #3920

Open
thymikee wants to merge 10 commits into
mainfrom
fix/3899-ipad-windowed-screen-space
Open

thymikee wants to merge 10 commits into
mainfrom
fix/3899-ipad-windowed-screen-space

Conversation

@thymikee

@thymikee thymikee commented Oct 22, 2026 •

Copy link
Copy Markdown
Member

Summary

An iPad window that does not fill the screen (Windowed Apps, Stage Manager) reported (0,0)-anchored subtree frames while its viewport was the window's screen box: captures clipped, rects named no screenshot position, taps landed at point minus window origin. The capture now publishes screen space through one rule, SnapshotContentTranslation.offset, in the same SnapshotGeometrySpace.normalized pass that turns quarter-turned subtrees; dispatch and probe-path snapshot reads follow the ADR 0004 amendment. The probe-side screen frame has one owner, ResolvedTextEntryInput.frameOnScreen: the content vector is read once per command, stored on the target and each read it produces, and the readiness retap dispatches through it. Full-screen iPad, iPhone, and ADR 0025 foldable panels read no vector and publish unchanged. Closes #3899.

Validation

Head 70f6faa552: pnpm check:affected --run --head passed all runnable checks; swift test 25/25; runner unit lane on iPhone 17 (iOS 27.0): focused 119-test lane 0 failures, RunnerTests+TextEntryLatencyTests 6/6 with the fill read-count pin unchanged at 17, retap translated-snapshot unit test passed. Fill/type timing on the same simulator, base a9fc9a19 vs head, 5 samples each: fill 3568 vs 3571 ms median, type 967 vs 943 ms. Windowed iPad (iPad Pro 11-inch M5, iOS 27.0): coordinate press on a placed window's published button rect opened the sheet; fill, replacement, and type verified by read-back inside the window. Point-by-point review round with full numbers: #3920 (comment)

Device: iPad Pro 11-inch (M5), iOS 27.0, Calendar, window at (324,307,386x682), then moved. Base b08b5e796a vs head, same window: base viewport 211x277 with window-relative frames; head viewport 386x682, rects at screen positions.

  1. snapshot -i --json in three grid scroll states: success, no regular-node-outside-cumulative-clip.
  2. Viewport = window box; toolbar, day strip, Today present.
  3. Six rects pixel-matched ≤1 pt (Add at (659,321); (509,419) after the move).
  4. press @Add opened the sheet, @Inbox opened the inbox, @Wed 21 selected (verified by [selected] + header).
  5. press 678 339 / press 528 437 (moved) opened the sheet.
  6. fill, type, caret re-fill verified by read-back; gesture pan accepted.
  7. Full-screen parity, base vs head on one fresh iPad: 48/48 node rects identical, viewport equal, press @Search → (721,54) focused search on both.

Landscape windowed is pinned by fixture arithmetic, not measured (ADR 0004).

Condition-by-condition device evidence: #3920 (comment)

An iPad window that does not fill the screen reports its own screen box
while its subtree arrives anchored at its own top-left. The capture
published a screen-space viewport around window-relative rects: the fold
clipped content to (w - x) x (h - y), a rect named no screenshot
position, and a synthesized tap landed at the point minus the window
origin.

SnapshotContentTranslation reads the one vector from a carrier's
(0,0)-anchored copy of the viewport box to the viewport itself, and
SnapshotGeometrySpace.normalized translates the subtree it declares in
the same pass that turns a quarter-turned subtree.
CoordinateSpaceRotation now turns axes only; the digitizer origin stays
where the display put it. Replayed from
contracts/fixtures/window-coordinate-space.json in both languages.

Refs #3899
The refusal wording belongs to the snapshot-route failure policy, not to
this fix.

Refs #3899
#3899)

XCUIElement.frame reports a placed window's content in screen space, but
XCUIElementSnapshot.frame reports it in the window's own content space,
so the text-input probe saw two spaces on an iPad Windowed Apps window
and refused a fill whose point was on the screen. The probe, the focus
tap, and the typing reader now read the same SnapshotContentTranslation
vector the capture reads, from the two live boxes that name it.

Refs #3899
@github-actions

github-actions Bot commented Oct 22, 2026 •

Copy link
Copy Markdown
Contributor

Size Report

Metric Base Current Diff
Installed (including dependencies) 5.58 MB 5.58 MB +6.8 kB
Package (unpacked) 5.58 MB 5.58 MB +6.8 kB
Package (download) 1.69 MB 1.69 MB +1.3 kB

Startup median (7 runs, lower is better):

Scenario Base Current Diff
CLI --version 34.9 ms 36.7 ms +1.8 ms
CLI --help 71.8 ms 71.1 ms -0.8 ms

@thymikee

thymikee commented Oct 22, 2026 •

Copy link
Copy Markdown
Member Author

Device evidence (#3899 conditions 1–7)

Device: iPad Pro 11-inch (M5, 12GB), iOS 27.0, simulator "Apex iPad Windowed 2". Calendar window at (324,307,386x682), later moved by drag to (175,405). Head ec7aecbf92; base b08b5e796a.

# Check Result
1 snapshot -i --json in three grid scroll states (top/mid/bottom) success, no regular-node-outside-cumulative-clip, quality healthy
2 viewport = window box; toolbar/day strip/Today present viewport {386,682}; base at the same window published viewport {211,277} with window-relative frames
3 ≥5 rects match on-screen positions ≤1 pt Add (659,321,37x36), Search (603,321), Today (544,388), October (402,317), Calendars (596,939), Inbox (651,939); pixel-verified against the screenshot (e.g. Today pill red bbox (563,402)–(580,414) inside its rect)
4 ref presses change app state press @Add opened New Event; press @Inbox opened the empty inbox ("No Invitations", close button); @Wed 21 then @Today flip the [selected] day and the header ("Wednesday – Oct 21, 2026")
5 press x y at Add's screen position opens the sheet press 678 339 before the move, press 528 437 after — both opened New Event
6 fill + type into a focused field; gesture pan accepted fill @ref "windowed fill ok" → read-back; type " then typed" → read-back; re-fill replaced ("final text"); gesture pan 517 700 0 ±200..-600 accepted
6b same conditions after a window move root (175,405,386,682); every node rect translated by exactly the window delta; Today red bbox moved (563,402)→(414,500); sheet opens at new coords
7 Full Screen parity fresh iPad sim, base vs head same commands: 48/48 node rects identical, viewport 834x1210 both, press @Search → tap (721,54) and focused search field on both

Validation at head: pnpm check:affected --run --head ec7aecbf92 all runnable checks passed; pnpm check:production-exports clean; swift test 25/25; vitest parity suites pass; runner unit tests testAWindowedSubtreeIsPublishedInScreenSpaceAndKeepsEveryNode, testAFullScreenCapturePublishesWhatThePlatformReported, testAFoldablePanelReportsNoContentTranslation pass on-device.

Screenshots (windowed capture, moved window, opened sheet, typed field) were held on the verification host at /private/tmp/apex-ipad-windowed/; the numeric pixel bounds in rows 3 and 6b are measured from those images against the published rects.

@thymikee

Copy link
Copy Markdown
Member Author

Follow-up for the #3914 owner (not blocking this PR): the bridge-refusal warning in packages/platform-apple/src/snapshot-route.ts still says "XCTest geometry for a windowed app can also be offset from the screen". After this PR the runner's windowed capture is screen space, so the sentence describes the pre-#3899 behavior; it should say the runner republishes it in screen space.

@thymikee

Copy link
Copy Markdown
Member Author

I found two problems at ec7aecb that need a fix before merge. CI is green (16/16 checks), but PR CI does not run the on-device runner unit lane, so the fill read-count pin has not run at this head. The two device results listed below are also still missing.

The readiness retap still uses an unconverted frame. waitForTextEntryReadinessAfterTap re-reads the input as a bare XCUIElementSnapshot and taps its center through tapAt, which now takes screen points. On a windowed iPad, if the first tap of press/click on a text input does not confirm focus in time, the retap uses window-local coordinates as screen coordinates. For example, a field at window-local (279,14) in a window at (324,307) is re-tapped at screen (279,14), outside the window. The command still reports tapped, and the next fill or type fails with TEXT_INPUT_NOT_FOCUSED, the same shape as #3899. The rule should be: every snapshot frame that becomes a dispatched point, or is compared with a screen point, passes through the one content translation, and raw frames are only compared with each other. ResolvedTextEntryInput can own that, so this function uses it instead of a bare element.snapshot(). Please add a unit test that drives the retap with a translated snapshot.

The live window lookup is also expensive. liveContentTranslation runs whole-app queries (windows, exists, frame, app.frame), and one fill calls it about 4-6 times on every iOS device, including iPhone and full-screen iPad where the result is always nil. The call sites are queryTextInputs, the focus tap, focusedTextEntryTarget, the TextEntryReader init, and moveCaretToEnd. RunnerTests+TextEntryLatencyTests.swift:164 pins the keyboard-raising fill at 17 counted reads and says each query counts, so I expect the pin to be red at this head. The queries also cost roughly 90 ms each on heavy React Native screens, which would undo the recent fill-read work (#3881, #3906, #3909). Each extra window resolution is another chance for a transient window to record an XCTest issue. Please read the vector once per command and carry it on the owning type. Then run RunnerTests+TextEntryLatencyTests on an iPhone simulator at head, and report fill and type timing for b08b5e7 against head on the same device.

Could the first two items share one fix? Keep the capture rule and the native() deletion as they are. Read the vector once where TextEntryTarget is first built, and store it on the target. ResolvedTextEntryInput then exposes frameOnScreen (snapshot frame plus that vector, or the live frame when there is no snapshot). Probe matching, focus tap, refresh point, caret tap and the readiness retap would all read that one property. That removes the translation: parameter threading, cuts the window queries to one per command, and closes the retap bug by construction. The ADR 0004 amendment would then name ResolvedTextEntryInput as the probe-side owner.

Not blocking, and fine to take or leave: (1) in SnapshotCoordinateSpace.swift:203 a surface host that declares no space of its own should keep the window-content space its carrier declared, with a windowContentCases row for a smaller direct window child; (2) the once-per-command keyboard check in RunnerTests+TextTyping.swift:98 is a second behavior change on every iOS device, and its comment is wrong for typeApplicationWide, where app.typeText without a receiver records the failure the guard near line 116 describes, so keep the start-of-command answer for the synthesized branch only or fix the comment and test the flapping keyboard; (3) CoordinateSpaceTests.swift:641 turns a windowed landscape point about the window's side lengths, while the digitizer turns about the screen's, and the ADR says landscape under Windowed Apps is unmeasured, so drop that assertion or mark it as the documented gap; (4) the bridge-refusal warning at snapshot-route.ts:515 and its pin at snapshot-route.test.ts:410 still say windowed XCTest geometry is offset from the screen, which this PR makes false, and #3914 is already on main, so please reword both here; (5) the ADR at 0004-ios-snapshot-backend-strategy.md:663 cites Add at (641,321) while the PR evidence shows (659,321), and the interactionCoordinate comment in RunnerTests+Interaction.swift:621 should name screen space and the app.frame origin assumption; (6) the fixture row 'a half-tolerance origin miss' at window-coordinate-space.json:221 varies width, so rename it to a side-length miss and cut the TS foot comment to one line pointing at the Swift owner.

The windowed-iPad, moved-window and full-screen parity results come from the author's comment only, and I could not run a device or the latency pin. Landscape under Windowed Apps, Stage Manager, and a placed window partly off screen remain unmeasured. #3918 (open) adds a windowed back-fallback screen point that lands correctly only with this PR's origin-free native(), so #3920 should land first or #3918 should rebase on it. The two open items to prove are the iPhone fill read-count pin with fill timing at the new head, and a unit test or windowed-iPad run showing the readiness retap lands inside the field's published rect.

Before merge, route the readiness retap through one screen-frame owner on ResolvedTextEntryInput, read the content vector once per command, and report the latency results above.

* origin/main:
  fix(android): bound the dumpsys reads behind dialog and foreground checks (#3936)
  feat(android): detect Fire OS devices as Android TV targets and document Fire TV support (#3923)
  docs(macos): document Electron/Chromium app automation through the accessibility tree (#3932)
  test(capture-kit): encode solid test PNGs unfiltered to stay inside the unit budget (#3933)
  fix: guard bounded lifecycle and claim recovery (#3911)
  fix(ios): clear a long text value wherever the caret lands (#3908)
  fix(snapshot): stop iOS 27 floating-bar containers from covering content (#3912)
  ci: cache and bound the Linux desktop dependency install (#3919)
…frame (#3899)

The readiness retap re-read the input as a bare XCUIElementSnapshot and tapped
its raw center through tapAt, which takes screen points, so on a windowed iPad
the retap landed outside the window. The live window lookup also ran whole-app
queries four to six times per fill on every iOS device.

Every snapshot frame that becomes a dispatched point now passes through the one
content translation, held on ResolvedTextEntryInput.frameOnScreen. The vector is
read once per command and stored on TextEntryTarget.contentTranslation; the probe
matching, focus tap, refresh point, caret tap and readiness retap all read that
one property. The threaded translation: parameters and the per-read lookups are
gone, and the once-per-command keyboard read in the typing command is reverted to
the base behavior with the retap fixed.
…sis (#3899)

Amends ADR 0004 to name ResolvedTextEntryInput as the probe-side owner of the
content vector, cites the measured Add-button rect at (659,321), records that the
runner republishes a windowed capture in screen space where the bridge-refusal
warning said it was offset, and names screen space plus the app.frame origin
assumption in the interactionCoordinate comment.
… offset (#3899)

The vector on the target replaces the offset arithmetic in
textEntryRefreshPoint, which read the snapshot frame and added the vector
itself. One owner now: ResolvedTextEntryInput.frameOnScreen.

The comments that restated the capture's rule at every call site are cut to
the constraint the code cannot show. SnapshotCoordinateSpace.swift ships in
the npm package without comment stripping, so its prose lives in ADR 0004 and
the golden table instead; the same rule text is removed from the TypeScript
decoder's foot note.
@thymikee

Copy link
Copy Markdown
Member Author

Replies point-by-point to the 2026-10-22T23:30 review. Head 70f6faa552 (three commits after ec7aecbf92: the probe-side owner refactor, the ADR amendment, the comment/duplication cut). Design adopted exactly as proposed; old threading gone: zero bare translation: parameters remain, snapshotFrameOnScreen is deleted, and liveContentTranslation has exactly five call sites — each a command entry (type's synthesized-coordinate route, the focus/stabilize entry, the readiness-retap branch only, the coordinate-tap probe, readTextAt) — never inside a typing or polling loop.

Blocking

1. Readiness retap used an unconverted frame. Done. waitForTextEntryReadinessAfterTap re-reads through ResolvedTextEntryInput(reading:contentTranslation:) with the live vector (contained like the probe lookups), and the tap is the split-out retapAt(read:app:element:), which dispatches frameOnScreen's center and taps nothing when the read has no snapshot. Every dispatched point and every screen-point comparison now goes through that one property; raw frames are compared only with each other. Unit test testReadinessRetapDispatchesTheReadsScreenSpaceCenter (RunnerTests+TextEntryReadinessTests.swift) drives retapAt with a snapshot translated by a (324,307) window vector: the untranslated read dispatches outside a witness button and the translated read dispatches at its center — the button asserts the landing point on the device. Passed on the iPhone simulator at head.

2. Live window lookup was expensive. Done as proposed. The vector is read once where TextEntryTarget is first built and stored on it; ResolvedTextEntryInput carries the same vector from that command, so probe matching, the focus tap, the refresh point, the caret tap and the readiness retap read frameOnScreen and make no window query of their own. The retap reads the vector only on its own branch — a tap that confirms focus dispatches no window lookup. RunnerTests+TextEntryLatencyTests ran on the iPhone simulator at 70f6faa552 with AGENT_DEVICE_XCUITEST_INCLUDE_UNIT_TESTS=1 into AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH: 6/6 passed with the fill read-count pin unchanged at 17 (testFillThatRaisesTheSoftwareKeyboardReusesTheReadThatConfirmedFocus included).

The shared fix. Done: ResolvedTextEntryInput exposes frameOnScreen (snapshot frame plus the command's vector, live frame with no snapshot — that property is already screen space), the translation: parameter threading is gone, and ADR 0004 names ResolvedTextEntryInput.frameOnScreen as the probe-side owner and the once-per-command rule.

Non-blocking

  1. Surface host keeps the carrier's window-content space — declined. The measured surfaces under a placed window (the keyboard window, a SpringBoard overlay) report the screen's own box, so inheriting the carrier's vector would move every key by the window origin. Head pins that instead: a comment at the .appOrientation fallthrough in space(...) states the reason, and windowContentCases already carries the null-offset rows for both surfaces plus "a carrier anchored inside the screen names some content box, not the app's window". An unmeasured third host shape stays on the measured-behavior side rather than inheriting a space nothing observed.
  2. Once-per-command keyboard check — done: RunnerTests+TextTyping.swift is reverted to identical with origin/main (0 diff). The per-post isKeyboardVisible answer is back; the flapping-keyboard claim is no longer load-bearing anywhere in the diff.
  3. Windowed-landscape native(point:) assertion — done: dropped; the test comment now names those rows the documented unmeasured gap (window side lengths vs the screen's, ADR 0004).
  4. snapshot-route.ts warning + pin — done: both reworded to "the runner republishes a windowed capture in screen space".
  5. ADR coordinates + interactionCoordinate comment — done: the ADR cites the measured (659,321); the comment names screen space and the app.frame-at-screen-origin assumption.
  6. Fixture row + TS foot — done: the row is renamed "a half-tolerance side-length miss is float representation, not a different box"; the nine-line foot comment is gone, replaced by one line in the header doc block pointing at the Swift SnapshotContentTranslation owner.

Evidence (all at 70f6faa552 unless noted)

Runner unit lane, iPhone 17 simulator (iOS 27.0), unit tests built via AGENT_DEVICE_XCUITEST_INCLUDE_UNIT_TESTS=1:

  • RunnerTests+TextEntryLatencyTests: 6/6 passed, fill read pin ≤17 unchanged.
  • Full focused lane (all TextEntry*/TextInputProbe/CoordinateSpace/SnapshotPresentation/typing unit tests, 119 tests): 119 executed, 0 failures (445 s).
  • swift test in apple/snapshot-presentation: 25/25.
  • pnpm check:xctest-selection: passes, 0 methods reachable by no lane.
  • pnpm check:affected --run --head 70f6faa552…: all runnable checks passed (format, lint, typecheck, layering, fallow, build, vitest-related, xctest-selection, packaged-runner-swift), exact-commit receipt.

Fill/type timing — same iPhone 17 simulator, same hour, Release test-app form field field-email, 5 samples each, a9fc9a19 (= origin/main, base) vs head:

fill ms type ms
base 3518, 3721, 3685, 3531, 3568 (median 3568) 970, 949, 967, 966, 967 (median 967)
head 3571, 3532, 3621, 3593, 3525 (median 3571) 943, 937, 943, 960, 955 (median 943)

fill +3 ms median (noise), type −24 ms. No regression against the #3881/#3906/#3909 read work.

Windowed iPad — iPad Pro 11-inch (M5), iOS 27.0, Windowed Apps, agent-device 70f6faa552: a shrunken test-app window publishes its root at (230,305,375,486) in screen space; a coordinate press at the + button's published rect (554,319,37,36) opened the New Event sheet (the #3899 failure shape, now landing); fill into the field at its published rect verified "final text", fill replaced, type "!" appended with route xctest-element and read back "final text!" — the focus tap and refresh point inside a placed window land in the field. The retap branch itself fires only when focus confirmation times out and could not be forced on-device; the translated-snapshot unit test above is the requested alternative proof. One caveat, not a regression: a bare type after re-pressing the field (no fill, no replacement mode) returned TEXT_INPUT_NOT_FOCUSED on the RN field — the base runner at a9fc9a19 reproduces this identically in the same window, so it is the pre-existing synthesized-first-responder route, unchanged by this PR.

CI at 70f6faa552: 14 pass, 2 skip (deferred lanes), 0 fail.

Diff

git diff --stat origin/main...HEAD: 20 files, +890 / −132 (reviewed head ec7aecbf92: 11 files, +698/−74 on its own base). Production code-only lines (runner UITests sources + snapshot-presentation/Sources + the TS decoder, comments/blank excluded) went 245 → 418 vs the respective merge-bases. That growth is the requested design itself: frameOnScreen, the vector stored on the target and the read, threaded through the seven probe/resolve functions that previously re-derived it at call sites, the split retapAt, and the five command-entry reads. Files that grew vs the reviewed head (numstat vs ec7aecbf92, rebased-comparable):

  • RunnerTests+TextEntry.swift: +22/−6 → +73/−18 — vector on both types, frameOnScreen, resolve/read paths carry it
  • RunnerTests+TextInputProbe.swift: +35/−1 → +78/−16 — vector parameter on the probe functions, candidate matching on frameOnScreen, contained liveContentTranslation
  • RunnerTests+TextEntryFocus.swift: +45/−10 → +63/−18 — one vector read at the stabilize entry, focus tap/refresh point via frameOnScreen
  • RunnerTests+TextEntryReadiness.swift: new → +32/−6 — the blocking retap fix
  • RunnerTests+CommandExecution.swift: new → +7/−1; RunnerTests+Interaction.swift: new → +14/−7 (probe vector + the item-5 comment); RunnerTests+TypeExecution.swift: new → +4/−1 (the type route's single read)
  • UnitTests/RunnerTests+TextEntryReadinessTests.swift: new +57 (retap unit test); probe/latency/coordinate unit tests: signature updates only

Files that shrank or are gone: SnapshotCoordinateSpace.swift +88/−24 → +77/−24 and window-coordinate-space.ts +35/−10 → +24/−10 (prose moved to ADR 0004 and the golden table), CoordinateSpaceTests.swift +162/−6 → +156/−6, RunnerTests+TextTyping.swift +30/−0 → 0 (item 2 reverted). ADR 0004 +43/−0 → +48/−4; snapshot-route.ts/test +1/−1 each.

@thymikee

Copy link
Copy Markdown
Member Author

At 70f6faa, the earlier blocking items from the first review (#3920 (comment)) are fixed, and I found no new blocking problems in the code. CI shows 16 checks with none failing, and there are no conflicts. Before merge, the on-device evidence still needs to be confirmed, because PR CI does not run the on-device runner unit lane that exercises the changed Swift text-entry paths. Green CI therefore says nothing about the retap test or the read-count pin.

The windowed-iPad run (coordinate press, fill, type inside a placed window), the iPhone RunnerTests+TextEntryLatencyTests 6/6 with the 17-read pin, the 119-test focused unit lane and the base-vs-head fill/type timing are author-reported. I could not run a device or the runner unit lane myself. The readiness retap branch was not forced on a device; its only coverage is the unit test that drives retapAt with a synthetic vector on an iPhone fixture. Coordinate-tap (press x y) latency with the added per-command window read on heavy React Native screens is not measured, and neither are landscape under Windowed Apps, Stage Manager, or a placed window partly off screen. Could you share the output of a run that reaches the readiness retap on a placed iPad window, plus a press x y timing on a heavy screen?

Not blocking, and you can take or leave these: frameOnScreen falls back to the live element.frame without a snapshot, yet four of its six consumers must refuse that fallback (#3060) and each re-add a snapshot != nil guard (withResolvedInput, moveCaretToEnd, textEntryRefreshPoint, retapAt). Making it CGRect? (nil without a snapshot) and having the probe matching and focus tap write ?? element.frame would delete those guards. Also, liveContentTranslation returns nil both for "the app fills the screen" and for "the window read failed", so a transient-window issue on a placed iPad window can still send the readiness retap (TextEntryReadiness.swift:100) to a raw window-local point. Last, the contentTranslation: CGSize? = nil defaults on init?(reading:), init?(readingListed:), TextEntryTarget.init and stabilizeTextInputBeforeTyping let an omitted vector silently mean full screen.

Would it be simpler to keep this owner but let it carry the #3060 rule too, with a non-defaulted vector that is known, none or unknown, so a failed window read cannot look like full screen?

This branch has not been deployed

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

iOS: XCTest runner publishes window-relative frames and shifted taps for an iPad app that does not fill the screen (Windowed Apps / Stage Manager)

1 participant