@@ -4,6 +4,13 @@ import {
44 hasAndroidSystemChromeProvenance ,
55 isAndroidSystemChromeWindowResourceId ,
66} from '@agent-device/contracts/android-system-chrome' ;
7+ import {
8+ hasIosKeyboardRole ,
9+ IOS_KEYBOARD_CHROME_MARKER_ROLES ,
10+ IOS_KEYBOARD_SETTLE_CHROME_ROLES ,
11+ IOS_KEYBOARD_SUBTREE_CHROME_ROLES ,
12+ IOS_KEYBOARD_SURFACE_ROLES ,
13+ } from '@agent-device/contracts/ios-keyboard-ownership' ;
714import type { SnapshotNode } from '@agent-device/kernel/snapshot' ;
815import { normalizeType } from '@agent-device/contracts/snapshot' ;
916
@@ -74,65 +81,30 @@ type SettleChrome = {
7481
7582const EMPTY_SETTLE_CHROME : SettleChrome = { strippedIndexes : new Set ( ) , refs : new Set ( ) } ;
7683
77- /**
78- * UIKit classes the keyboard owns whose nodes the regular projection can flatten beside app
79- * content — the key plane and a key when no `[Keyboard]` container survives, the system
80- * input-assistant (QuickType) bar, and the dock's "Next keyboard" / "Dictate" buttons. Once the
81- * projection has dropped the keyboard's own window, neither the container-subtree nor the
82- * window walk can reach them, but each node still carries its producing class in `role`.
83- *
84- * Exact class names only, so a near miss fails closed rather than widening what counts as
85- * chrome (the same discipline `geometry-policy.ts` applies to private UIKit roles). Classes are
86- * locale-independent, unlike every label these nodes carry, and private to the keyboard
87- * surfaces — an app cannot host one in its own view tree.
88- */
89- const IOS_KEYBOARD_OWNED_ROLES : ReadonlySet < string > = new Set ( [
90- 'UIKeyboardLayoutStar' ,
91- 'UIAccessibilityElementKBKey' ,
92- 'UIKeyboardDockItemButton' ,
93- 'TUISystemInputAssistantView' ,
94- 'TUISystemInputAssistantPageView' ,
95- 'TUIPredictionView' ,
96- 'TUIPredictionViewCell' ,
97- ] ) ;
98-
99- /**
100- * The system `inputView` host frame: chrome itself, but it OWNS NO SUBTREE — an app's custom
101- * `UITextField.inputView` content (a drawn keypad, a composer) renders under this placeholder and
102- * stays a control the agent must see. Stripping only the marker keeps the keyboard's appearance
103- * at one visible line without swallowing app content; if the class is ever renamed, the inert
104- * marker line returns as noise rather than app content vanishing — noise fails loudly.
105- */
106- const IOS_KEYBOARD_CHROME_MARKER_ROLES : ReadonlySet < string > = new Set ( [
107- '_UIRemoteKeyboardPlaceholderView' ,
108- ] ) ;
109-
110- function hasRole ( node : SnapshotNode , roles : ReadonlySet < string > ) : boolean {
111- return node . role !== undefined && roles . has ( node . role ) ;
112- }
113-
114- function isKeyboardOwnedRoleNode ( node : SnapshotNode ) : boolean {
115- return hasRole ( node , IOS_KEYBOARD_OWNED_ROLES ) ;
116- }
117-
11884function collectKeyboardChrome ( nodes : SnapshotNode [ ] ) : SettleChrome {
11985 const byIndex = new Map ( nodes . map ( ( node ) => [ node . index , node ] ) ) ;
12086 const containerIndexes = new Set (
12187 nodes . filter ( ( node ) => normalizeType ( node . type ?? '' ) === 'keyboard' ) . map ( ( node ) => node . index ) ,
12288 ) ;
123- const ownedSubtreeRootIndexes = new Set (
124- nodes . filter ( isKeyboardOwnedRoleNode ) . map ( ( node ) => node . index ) ,
89+ // Role-declared keyboard surfaces, reached because the interactive projection flattens them
90+ // beside app content: chrome PER NODE (surface roles), chrome by SUBTREE (system-only hosts),
91+ // or chrome AS A MARKER with no subtree (hosts the app can also fill — its custom inputView,
92+ // its inputAssistantItem bar buttons — so the app content under a marker survives).
93+ const surfaceIndexes = new Set (
94+ nodes
95+ . filter (
96+ ( node ) =>
97+ hasIosKeyboardRole ( node , IOS_KEYBOARD_SURFACE_ROLES ) ||
98+ hasIosKeyboardRole ( node , IOS_KEYBOARD_SETTLE_CHROME_ROLES ) ,
99+ )
100+ . map ( ( node ) => node . index ) ,
125101 ) ;
126102 const markerIndexes = new Set (
127103 nodes
128- . filter ( ( node ) => hasRole ( node , IOS_KEYBOARD_CHROME_MARKER_ROLES ) )
104+ . filter ( ( node ) => hasIosKeyboardRole ( node , IOS_KEYBOARD_CHROME_MARKER_ROLES ) )
129105 . map ( ( node ) => node . index ) ,
130106 ) ;
131- if (
132- containerIndexes . size === 0 &&
133- ownedSubtreeRootIndexes . size === 0 &&
134- markerIndexes . size === 0
135- ) {
107+ if ( containerIndexes . size === 0 && surfaceIndexes . size === 0 && markerIndexes . size === 0 ) {
136108 return EMPTY_SETTLE_CHROME ;
137109 }
138110 const containerSubtreeIndexes = collectSubtreeIndexes ( nodes , byIndex , containerIndexes ) ;
@@ -143,36 +115,31 @@ function collectKeyboardChrome(nodes: SnapshotNode[]): SettleChrome {
143115 }
144116 // Preserve app-owned accessory controls hosted above the keyboard: they were
145117 // swept in by the whole-window pass but are not keyboard chrome.
146- const accessoryExemptIndexes = collectKeyboardAccessoryIndexes (
118+ for ( const index of collectKeyboardAccessoryIndexes (
147119 nodes ,
148120 byIndex ,
149121 windowIndexes ,
150122 containerIndexes ,
151123 containerSubtreeIndexes ,
152- ) ;
153- for ( const index of accessoryExemptIndexes ) {
124+ ) ) {
154125 chromeIndexes . delete ( index ) ;
155126 }
156- // Keyboard-owned classes the projection flattened out of every reachable subtree are chrome
157- // by their own declaration — but the walk may NOT outrank the app-accessory exemption above:
158- // an app's `UITextField.inputAssistantItem` bar buttons render UNDER the system assistant
159- // view, above the keys, and the window pass just exempted them; re-claiming them through the
160- // assistant's subtree would hide real app controls from the settle diff. An exempted node
161- // only re-joins chrome when IT declares a keyboard-owned role (a prediction cell is
162- // system-owned wherever it sits); an app node under an owned host stays exempt. In the
163- // flattened interactive shape there is no window anchor, the exemption is empty, and the
164- // walk claims the key plane exactly as the container-descendant walk always did.
165- for ( const index of collectOwnedKeyboardChromeIndexes (
127+ // No role claim may reach app content: surface roles are chrome per node, subtree claims only
128+ // start at system-only hosts the app can never fill, and markers own no subtree. That is why
129+ // these passes need no interaction with the accessory exemption or an editable-text guard —
130+ // an app node under the assistant view or the inputView placeholder is simply never claimed.
131+ for ( const index of surfaceIndexes ) chromeIndexes . add ( index ) ;
132+ for ( const index of collectSubtreeIndexes (
166133 nodes ,
167134 byIndex ,
168- ownedSubtreeRootIndexes ,
169- containerSubtreeIndexes ,
170- accessoryExemptIndexes ,
135+ new Set (
136+ nodes
137+ . filter ( ( node ) => hasIosKeyboardRole ( node , IOS_KEYBOARD_SUBTREE_CHROME_ROLES ) )
138+ . map ( ( node ) => node . index ) ,
139+ ) ,
171140 ) ) {
172141 chromeIndexes . add ( index ) ;
173142 }
174- // The system inputView host frame is itself chrome, but owning no subtree: an app's custom
175- // input view content under it survives classification.
176143 for ( const index of markerIndexes ) chromeIndexes . add ( index ) ;
177144 const refs = new Set (
178145 nodes . filter ( ( node ) => node . ref && chromeIndexes . has ( node . index ) ) . map ( ( node ) => node . ref ) ,
@@ -185,70 +152,6 @@ function collectKeyboardChrome(nodes: SnapshotNode[]): SettleChrome {
185152 return { strippedIndexes, refs } ;
186153}
187154
188- /**
189- * The subtree under each keyboard-owned-class root, minus two app-content guards: indexes the
190- * app-accessory exemption already granted (an app's `inputAssistantItem` bar buttons ride the
191- * system assistant view above the keys), and editable text the app hosts inside an owned root
192- * outside the keyboard container, with its subtree. A keyboard's own `Key` nodes trip neither —
193- * they sit inside the container subtree, which the accessory pass never exempts — and the
194- * flattened interactive shape carries no window anchor, so the exemption is empty there and the
195- * walk claims the whole key plane exactly as the container-descendant walk always did.
196- */
197- function collectOwnedKeyboardChromeIndexes (
198- nodes : SnapshotNode [ ] ,
199- byIndex : Map < number , SnapshotNode > ,
200- ownedSubtreeRootIndexes : ReadonlySet < number > ,
201- containerSubtreeIndexes : ReadonlySet < number > ,
202- accessoryExemptIndexes : ReadonlySet < number > ,
203- ) : Set < number > {
204- const chromeIndexes = collectSubtreeIndexes ( nodes , byIndex , ownedSubtreeRootIndexes ) ;
205- // An exempted node re-joins chrome only when IT declares a keyboard-owned role: a system
206- // prediction cell is chrome wherever it sits, while an app's `inputAssistantItem` bar button
207- // under the assistant view declares no owned role and keeps its exemption.
208- for ( const index of accessoryExemptIndexes ) {
209- if ( ! ownedSubtreeRootIndexes . has ( index ) ) chromeIndexes . delete ( index ) ;
210- }
211- for ( const index of collectHostedEditableTextIndexes (
212- nodes ,
213- byIndex ,
214- chromeIndexes ,
215- containerSubtreeIndexes ,
216- ) ) {
217- chromeIndexes . delete ( index ) ;
218- }
219- return chromeIndexes ;
220- }
221-
222- /**
223- * Editable text the app hosts under an owned root outside the keyboard container, and the
224- * chrome claimed beneath it — the inputAccessoryView guard in disguise: an app that rides its
225- * own composer on the assistant host keeps the field it is typing into.
226- */
227- function collectHostedEditableTextIndexes (
228- nodes : SnapshotNode [ ] ,
229- byIndex : Map < number , SnapshotNode > ,
230- chromeIndexes : ReadonlySet < number > ,
231- containerSubtreeIndexes : ReadonlySet < number > ,
232- ) : Set < number > {
233- const dropped = new Set < number > ( ) ;
234- const hostedTextIndexes = new Set (
235- nodes
236- . filter (
237- ( node ) =>
238- chromeIndexes . has ( node . index ) &&
239- EDITABLE_TEXT_TYPES . has ( normalizeType ( node . type ?? '' ) ) &&
240- ! containerSubtreeIndexes . has ( node . index ) ,
241- )
242- . map ( ( node ) => node . index ) ,
243- ) ;
244- for ( const node of nodes ) {
245- if ( ! chromeIndexes . has ( node . index ) ) continue ;
246- if ( hasAncestorIn ( node , byIndex , hostedTextIndexes ) ) dropped . add ( node . index ) ;
247- }
248- for ( const index of hostedTextIndexes ) dropped . add ( index ) ;
249- return dropped ;
250- }
251-
252155/**
253156 * Indexes of app-owned accessory content in a keyboard window: non-keyboard
254157 * nodes whose center sits ABOVE the keyboard container's top edge (an
0 commit comments