dsh-mobilecode 0.1.4 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/uitree.js ADDED
@@ -0,0 +1,638 @@
1
+ /**
2
+ * dsh-mobilecode — uiautomator semantic backend.
3
+ *
4
+ * Dumps the frontmost window's view hierarchy over plain adb, parses it with a
5
+ * hand-written quote-aware XML reader (no runtime dependencies — uiautomator's
6
+ * output is a tiny attribute-only dialect), and shapes it into the compact
7
+ * node tree the semantic tools reason over. The selector resolver ports the
8
+ * dsh-android design: exact match wins over substring, nested duplicates
9
+ * collapse into one chain by bounds containment (the chain's outermost control
10
+ * is the tap target), off-screen and disabled matches are refused with
11
+ * actionable copy, and ambiguity lists up to 8 candidates instead of guessing.
12
+ *
13
+ * Ported design credit: ZSeven-W/dsh-android (MIT) src/uitree.ts.
14
+ */
15
+
16
+ import { adb, capture, exec } from "./device-build.js"
17
+
18
+ const DUMP_TIMEOUT_MS = 60_000
19
+ const DUMP_MAX_BYTES = 8 * 1024 * 1024
20
+ /** Compact tree output cap: past this the deepest levels are pruned. */
21
+ export const UI_TREE_CAP_BYTES = 40 * 1024
22
+
23
+ // ── XML ───────────────────────────────────────────────────────────────────────
24
+
25
+ const NAMED_ENTITIES = { amp: "&", lt: "<", gt: ">", quot: '"', apos: "'" }
26
+
27
+ /**
28
+ * Decode the five XML entities plus numeric character references. Unknown or
29
+ * malformed references stay verbatim — a literal `&` in a label must survive.
30
+ */
31
+ export function decodeXmlEntities(value) {
32
+ if (!value.includes("&")) return value
33
+ return value.replace(/&(#x[0-9a-fA-F]+|#\d+|[a-zA-Z]+);/g, (match, body) => {
34
+ if (body.startsWith("#x") || body.startsWith("#X")) {
35
+ const code = Number.parseInt(body.slice(2), 16)
36
+ return Number.isFinite(code) && code >= 0 && code <= 0x10ffff ? String.fromCodePoint(code) : match
37
+ }
38
+ if (body.startsWith("#")) {
39
+ const code = Number.parseInt(body.slice(1), 10)
40
+ return Number.isFinite(code) && code >= 0 && code <= 0x10ffff ? String.fromCodePoint(code) : match
41
+ }
42
+ return NAMED_ENTITIES[body] ?? match
43
+ })
44
+ }
45
+
46
+ function isSpace(ch) {
47
+ return ch === " " || ch === "\t" || ch === "\n" || ch === "\r"
48
+ }
49
+
50
+ /**
51
+ * Scan one start tag. Quote-aware: `>` inside an attribute value never ends
52
+ * the tag early — uiautomator labels legitimately contain that character.
53
+ */
54
+ function scanStartTag(source, start) {
55
+ const length = source.length
56
+ let index = start
57
+ while (index < length && !isSpace(source[index]) && source[index] !== "/" && source[index] !== ">") index += 1
58
+ const name = source.slice(start, index)
59
+ const attributes = {}
60
+ let selfClosing = false
61
+ for (;;) {
62
+ while (index < length && isSpace(source[index])) index += 1
63
+ if (index >= length) return { name, attributes, selfClosing, next: -1 }
64
+ const ch = source[index]
65
+ if (ch === "/") { selfClosing = true; index += 1; continue }
66
+ if (ch === ">") return { name, attributes, selfClosing, next: index + 1 }
67
+ const nameStart = index
68
+ while (index < length && !isSpace(source[index]) && source[index] !== "=" && source[index] !== "/" && source[index] !== ">") index += 1
69
+ const attributeName = source.slice(nameStart, index)
70
+ while (index < length && isSpace(source[index])) index += 1
71
+ let raw = ""
72
+ if (source[index] === "=") {
73
+ index += 1
74
+ while (index < length && isSpace(source[index])) index += 1
75
+ const quote = source[index]
76
+ if (quote === '"' || quote === "'") {
77
+ index += 1
78
+ const valueStart = index
79
+ while (index < length && source[index] !== quote) index += 1
80
+ raw = source.slice(valueStart, index)
81
+ index += 1
82
+ } else {
83
+ const valueStart = index
84
+ while (index < length && !isSpace(source[index]) && source[index] !== ">") index += 1
85
+ raw = source.slice(valueStart, index)
86
+ }
87
+ }
88
+ if (attributeName !== "") attributes[attributeName] = decodeXmlEntities(raw)
89
+ // A degenerate attribute name (nothing consumed) would spin forever.
90
+ if (attributeName === "" && raw === "") index += 1
91
+ }
92
+ }
93
+
94
+ /**
95
+ * Parse an attribute-only XML document into its element forest. Prologs,
96
+ * comments, doctypes and CDATA are skipped; character data is ignored.
97
+ * Mismatched close tags unwind to the nearest matching ancestor instead of
98
+ * throwing — a truncated dump still yields the part that arrived.
99
+ */
100
+ export function parseXmlElements(source) {
101
+ const roots = []
102
+ const stack = []
103
+ const length = source.length
104
+ let index = 0
105
+ while (index < length) {
106
+ const open = source.indexOf("<", index)
107
+ if (open < 0) break
108
+ index = open + 1
109
+ if (index >= length) break
110
+ if (source.startsWith("!--", index)) {
111
+ const end = source.indexOf("-->", index)
112
+ index = end < 0 ? length : end + 3
113
+ continue
114
+ }
115
+ if (source.startsWith("![CDATA[", index)) {
116
+ const end = source.indexOf("]]>", index)
117
+ index = end < 0 ? length : end + 3
118
+ continue
119
+ }
120
+ if (source[index] === "?" || source[index] === "!") {
121
+ const end = source.indexOf(">", index)
122
+ index = end < 0 ? length : end + 1
123
+ continue
124
+ }
125
+ if (source[index] === "/") {
126
+ const end = source.indexOf(">", index)
127
+ if (end < 0) break
128
+ const name = source.slice(index + 1, end).trim()
129
+ for (let depth = stack.length - 1; depth >= 0; depth -= 1) {
130
+ if (stack[depth].name === name) { stack.length = depth; break }
131
+ }
132
+ index = end + 1
133
+ continue
134
+ }
135
+ const tag = scanStartTag(source, index)
136
+ if (tag.next < 0) break
137
+ index = tag.next
138
+ if (tag.name === "") continue
139
+ const element = { name: tag.name, attributes: tag.attributes, children: [] }
140
+ const parent = stack[stack.length - 1]
141
+ if (parent === undefined) roots.push(element)
142
+ else parent.children.push(element)
143
+ if (!tag.selfClosing) stack.push(element)
144
+ }
145
+ return roots
146
+ }
147
+
148
+ // ── uiautomator hierarchy → compact nodes ─────────────────────────────────────
149
+
150
+ const BOUNDS_PATTERN = /^\[(-?\d+),(-?\d+)\]\[(-?\d+),(-?\d+)\]$/
151
+
152
+ /** Parse `bounds="[l,t][r,b]"` into an origin+size box; unparseable → undefined. */
153
+ export function parseBounds(raw) {
154
+ if (raw === undefined) return undefined
155
+ const match = BOUNDS_PATTERN.exec(raw.trim())
156
+ if (match === null) return undefined
157
+ const [left, top, right, bottom] = match.slice(1).map(Number)
158
+ if (![left, top, right, bottom].every(Number.isFinite)) return undefined
159
+ return { x: left, y: top, w: right - left, h: bottom - top }
160
+ }
161
+
162
+ /** `android.widget.FrameLayout` → `FrameLayout`; empty class → `Node`. */
163
+ export function classTail(className) {
164
+ const trimmed = (className ?? "").trim()
165
+ if (trimmed === "") return "Node"
166
+ const tail = trimmed.slice(trimmed.lastIndexOf(".") + 1)
167
+ return tail === "" ? trimmed : tail
168
+ }
169
+
170
+ function attributeText(attributes, key) {
171
+ const value = attributes[key]
172
+ if (value === undefined) return undefined
173
+ return value.trim() === "" ? undefined : value
174
+ }
175
+
176
+ function isTrue(attributes, key) {
177
+ return attributes[key] === "true"
178
+ }
179
+
180
+ function toNode(element) {
181
+ const attributes = element.attributes
182
+ const node = {
183
+ type: classTail(attributes.class),
184
+ bounds: parseBounds(attributes.bounds) ?? { x: 0, y: 0, w: 0, h: 0 },
185
+ children: [],
186
+ }
187
+ const text = attributeText(attributes, "text")
188
+ if (text !== undefined) node.text = text
189
+ const contentDesc = attributeText(attributes, "content-desc")
190
+ if (contentDesc !== undefined) node.contentDesc = contentDesc
191
+ const resourceId = attributeText(attributes, "resource-id")
192
+ if (resourceId !== undefined) node.resourceId = resourceId
193
+ // Interesting state only: absent means enabled / not focused / not
194
+ // clickable / not scrollable — never "unknown".
195
+ if (attributes.enabled === "false") node.enabled = false
196
+ if (isTrue(attributes, "focused")) node.focused = true
197
+ if (isTrue(attributes, "clickable")) node.clickable = true
198
+ if (isTrue(attributes, "scrollable")) node.scrollable = true
199
+ for (const child of element.children) {
200
+ if (child.name === "node") node.children.push(toNode(child))
201
+ }
202
+ return node
203
+ }
204
+
205
+ /**
206
+ * Convert one uiautomator XML document into the compact node forest. The
207
+ * `<hierarchy>` wrapper is unwrapped; a dump without it falls back to any
208
+ * top-level `node` elements so a hand-trimmed fixture still parses.
209
+ */
210
+ export function parseUiTree(xml) {
211
+ const elements = parseXmlElements(xml)
212
+ const hierarchy = elements.find((element) => element.name === "hierarchy")
213
+ const source = hierarchy?.children ?? elements
214
+ const roots = source.filter((element) => element.name === "node").map(toNode)
215
+ const rotationRaw = hierarchy?.attributes.rotation
216
+ const rotation = rotationRaw === undefined ? undefined : Number(rotationRaw)
217
+ const out = { roots }
218
+ if (rotation !== undefined && Number.isInteger(rotation)) out.rotation = rotation
219
+ return out
220
+ }
221
+
222
+ /**
223
+ * Strip everything around the hierarchy document. `uiautomator dump /dev/tty`
224
+ * writes the XML and then its own confirmation line ("UI hierchary dumped to:
225
+ * /dev/tty" — the typo is upstream's) onto the SAME stream, and a tty may
226
+ * translate `\n` into `\r\n` on the way out.
227
+ */
228
+ export function extractHierarchyXml(raw) {
229
+ const text = raw.replace(/\r\n/g, "\n")
230
+ const end = text.lastIndexOf("</hierarchy>")
231
+ if (end >= 0) {
232
+ const start = text.indexOf("<")
233
+ return text.slice(start < 0 ? 0 : start, end + "</hierarchy>".length)
234
+ }
235
+ // A self-closed or empty hierarchy still counts as a valid (if useless) dump.
236
+ const empty = /<hierarchy\b[^>]*\/>/.exec(text)
237
+ if (empty !== null) return empty[0]
238
+ const snippet = text.trim().slice(0, 200)
239
+ throw new Error(
240
+ "the uiautomator dump did not contain a <hierarchy> document"
241
+ + (snippet === "" ? " (the device produced no output)" : `: ${snippet}`),
242
+ )
243
+ }
244
+
245
+ // ── dump over adb ─────────────────────────────────────────────────────────────
246
+
247
+ /**
248
+ * Dump the frontmost window hierarchy of `serial`.
249
+ *
250
+ * Primary path: `adb exec-out uiautomator dump /dev/tty` — one round trip, no
251
+ * device-side file. Some vendor images refuse `/dev/tty`; the fallback writes
252
+ * `/sdcard/window_dump.xml`, cats it back, and removes it. "could not get
253
+ * idle state" earns exactly one retry after 800 ms — a transient animation
254
+ * settles; a continuously animating foreground (a web page with a spinner)
255
+ * will fail again, and the error then routes the caller to OCR instead of a
256
+ * retry loop.
257
+ */
258
+ export async function dumpUiTreeXml(serial) {
259
+ const options = { timeoutMs: DUMP_TIMEOUT_MS, maxBytes: DUMP_MAX_BYTES }
260
+ let primaryFailure
261
+ for (let attempt = 0; attempt < 2; attempt += 1) {
262
+ try {
263
+ const buffer = await capture(adb(), ["-s", serial, "exec-out", "uiautomator", "dump", "/dev/tty"], options)
264
+ if (buffer === "") throw new Error("the device produced no output")
265
+ return extractHierarchyXml(buffer)
266
+ } catch (error) {
267
+ primaryFailure = error instanceof Error ? error.message : String(error)
268
+ if (attempt === 0 && /could not get idle state/i.test(primaryFailure)) {
269
+ await new Promise((resolve) => setTimeout(resolve, 800))
270
+ continue
271
+ }
272
+ break
273
+ }
274
+ }
275
+ if (primaryFailure !== undefined && /could not get idle state/i.test(primaryFailure)) {
276
+ // The /sdcard fallback runs the SAME dump against the same never-idle
277
+ // foreground; paying its cost only to fail identically helps nobody.
278
+ throw new Error(
279
+ `uiautomator could not dump the window hierarchy of ${serial} (${primaryFailure}). `
280
+ + "The foreground app is continuously animating (web pages in a browser are the classic case), "
281
+ + "so uiautomator can never reach its idle state — do not retry this tool; read the screen with "
282
+ + "device_screen (OCR reads pixels and needs no idle).",
283
+ )
284
+ }
285
+ const remotePath = "/sdcard/window_dump.xml"
286
+ try {
287
+ const notice = await capture(adb(), ["-s", serial, "shell", "uiautomator", "dump", remotePath], options)
288
+ const buffer = await capture(adb(), ["-s", serial, "exec-out", "cat", remotePath], options)
289
+ const xml = extractHierarchyXml(buffer)
290
+ await exec(adb(), ["-s", serial, "shell", "rm", "-f", remotePath]).exit.catch(() => {})
291
+ if (xml.trim() === "") throw new Error(notice.trim() || "empty dump")
292
+ return xml
293
+ } catch (error) {
294
+ await exec(adb(), ["-s", serial, "shell", "rm", "-f", remotePath]).exit.catch(() => {})
295
+ const fallbackFailure = error instanceof Error ? error.message : String(error)
296
+ const idleStarved = /could not get idle state/i.test(`${primaryFailure} ${fallbackFailure}`)
297
+ throw new Error(
298
+ `uiautomator could not dump the window hierarchy of ${serial} `
299
+ + `(exec-out /dev/tty: ${primaryFailure ?? "unknown"}; ${remotePath} fallback: ${fallbackFailure}). `
300
+ + (idleStarved
301
+ ? "The foreground app is continuously animating, so uiautomator can never reach its idle state — "
302
+ + "do not retry this tool; read the screen with device_screen (OCR) instead."
303
+ : "uiautomator needs the screen ON and an idle window — wake the device (device_input action=key "
304
+ + 'key="wakeup"), wait for animations to settle, and retry; if it keeps failing the screen is '
305
+ + "likely secure (FLAG_SECURE) and only device_screen's OCR can read it."),
306
+ )
307
+ }
308
+ }
309
+
310
+ /** Dump and parse in one step. */
311
+ export async function readUiTree(serial) {
312
+ return parseUiTree(await dumpUiTreeXml(serial))
313
+ }
314
+
315
+ // ── tree shaping ──────────────────────────────────────────────────────────────
316
+
317
+ /** Screen bounds in display pixels, taken from the widest/tallest root. */
318
+ export function screenBoundsOf(roots) {
319
+ let width = 0
320
+ let height = 0
321
+ for (const root of roots) {
322
+ width = Math.max(width, root.bounds.x + root.bounds.w)
323
+ height = Math.max(height, root.bounds.y + root.bounds.h)
324
+ }
325
+ if (width <= 0 || height <= 0) {
326
+ const fallback = roots.length > 0 ? roots[0].bounds : { w: 0, h: 0 }
327
+ width = fallback.w
328
+ height = fallback.h
329
+ }
330
+ return { width, height }
331
+ }
332
+
333
+ /**
334
+ * True when `bounds` lies ENTIRELY outside the screen. uiautomator keeps
335
+ * scrolled-out rows in the dump with their real (off-screen) coordinates and
336
+ * exposes no visibility flag, so geometry is the only signal. A zero-size box
337
+ * can never be tapped, so it counts as off-screen too.
338
+ */
339
+ export function isOffscreenBounds(bounds, screen) {
340
+ if (screen.width <= 0 || screen.height <= 0) return false
341
+ return bounds.x + bounds.w <= 0
342
+ || bounds.y + bounds.h <= 0
343
+ || bounds.x >= screen.width
344
+ || bounds.y >= screen.height
345
+ }
346
+
347
+ /** Case-insensitive substring match over text, content-desc, resource-id and type. */
348
+ export function nodeMatchesFilter(node, needle) {
349
+ const haystacks = [node.type, node.text, node.contentDesc, node.resourceId]
350
+ return haystacks.some((value) => value !== undefined && value.toLowerCase().includes(needle))
351
+ }
352
+
353
+ function copyNode(node) {
354
+ const copy = { type: node.type, bounds: { ...node.bounds }, children: [] }
355
+ if (node.text !== undefined) copy.text = node.text
356
+ if (node.contentDesc !== undefined) copy.contentDesc = node.contentDesc
357
+ if (node.resourceId !== undefined) copy.resourceId = node.resourceId
358
+ if (node.enabled !== undefined) copy.enabled = node.enabled
359
+ if (node.focused !== undefined) copy.focused = node.focused
360
+ if (node.clickable !== undefined) copy.clickable = node.clickable
361
+ if (node.scrollable !== undefined) copy.scrollable = node.scrollable
362
+ return copy
363
+ }
364
+
365
+ /**
366
+ * Build the output tree: an optional case-insensitive substring filter (a node
367
+ * survives when it or any descendant matches — ancestors of matches are kept
368
+ * so the tree stays connected) and an optional nesting depth cap.
369
+ */
370
+ export function buildCompactTree(roots, maxDepth, filter) {
371
+ const needle = filter !== undefined && filter.trim() !== "" ? filter.trim().toLowerCase() : undefined
372
+ let count = 0
373
+ const walk = (node, depth) => {
374
+ const selfMatches = needle === undefined || nodeMatchesFilter(node, needle)
375
+ const children = []
376
+ if (maxDepth === undefined || depth < maxDepth) {
377
+ for (const child of node.children) {
378
+ const compact = walk(child, depth + 1)
379
+ if (compact !== undefined) children.push(compact)
380
+ }
381
+ }
382
+ if (!selfMatches && children.length === 0) return undefined
383
+ const copy = copyNode(node)
384
+ copy.children = children
385
+ count += 1
386
+ return copy
387
+ }
388
+ const tree = []
389
+ for (const root of roots) {
390
+ const compact = walk(root, 0)
391
+ if (compact !== undefined) tree.push(compact)
392
+ }
393
+ return { tree, count }
394
+ }
395
+
396
+ /** Depth-first flatten, roots first; depth annotates each copy. */
397
+ export function flattenNodes(roots) {
398
+ const flat = []
399
+ const walk = (node, depth) => {
400
+ const copy = copyNode(node)
401
+ copy.depth = depth
402
+ flat.push(copy)
403
+ for (const child of node.children) walk(child, depth + 1)
404
+ }
405
+ for (const root of roots) walk(root, 0)
406
+ return flat
407
+ }
408
+
409
+ function treeDepth(nodes) {
410
+ let depth = 0
411
+ for (const node of nodes) {
412
+ if (node.children.length > 0) depth = Math.max(depth, 1 + treeDepth(node.children))
413
+ }
414
+ return depth
415
+ }
416
+
417
+ function pruneDeepestLevel(nodes) {
418
+ const depth = treeDepth(nodes)
419
+ if (depth === 0) return
420
+ const pruneAt = (list, level) => {
421
+ for (const node of list) {
422
+ if (level === depth - 1) node.children = []
423
+ else pruneAt(node.children, level + 1)
424
+ }
425
+ }
426
+ pruneAt(nodes, 0)
427
+ }
428
+
429
+ function treeBytes(nodes) {
430
+ return Buffer.byteLength(JSON.stringify(nodes), "utf8")
431
+ }
432
+
433
+ /**
434
+ * Fit a compact tree under `capBytes` by pruning the deepest levels first —
435
+ * the same strategy the `max_depth` hint offers interactively. Mutates the
436
+ * nodes it is handed (they are already the tool's private copies).
437
+ */
438
+ export function capTreeToBytes(tree, capBytes = UI_TREE_CAP_BYTES) {
439
+ let truncated = treeBytes(tree) > capBytes
440
+ while (treeBytes(tree) > capBytes && treeDepth(tree) > 0) {
441
+ pruneDeepestLevel(tree)
442
+ }
443
+ if (!truncated) truncated = treeBytes(tree) > capBytes
444
+ return { tree, truncated }
445
+ }
446
+
447
+ // ── selector resolution ───────────────────────────────────────────────────────
448
+
449
+ /** Tolerance (pixels) for containment checks — rounding, not layout, slack. */
450
+ const BOUNDS_EPSILON = 1
451
+
452
+ /** True when `outer` (approximately) contains `inner`. */
453
+ export function containsBounds(outer, inner) {
454
+ return outer.x <= inner.x + BOUNDS_EPSILON
455
+ && outer.y <= inner.y + BOUNDS_EPSILON
456
+ && outer.x + outer.w >= inner.x + inner.w - BOUNDS_EPSILON
457
+ && outer.y + outer.h >= inner.y + inner.h - BOUNDS_EPSILON
458
+ }
459
+
460
+ /** True when two boxes are the same box (mutual containment). */
461
+ export function sameBounds(a, b) {
462
+ return containsBounds(a, b) && containsBounds(b, a)
463
+ }
464
+
465
+ /**
466
+ * Widget classes that ARE controls even when the platform did not mark them
467
+ * clickable (a disabled Button reports clickable="false"). `clickable=true`
468
+ * remains the primary signal; this set only rescues the chain-folding step.
469
+ */
470
+ const CONTROL_TYPES = new Set([
471
+ "Button", "ImageButton", "CompoundButton", "CheckBox", "CheckedTextView",
472
+ "RadioButton", "Switch", "SwitchCompat", "ToggleButton", "MaterialButton",
473
+ "EditText", "AutoCompleteTextView", "SearchView", "SeekBar", "RatingBar",
474
+ "Spinner", "TabWidget", "ActionMenuItemView", "MenuItem", "Chip",
475
+ "FloatingActionButton", "BottomNavigationItemView", "NavigationMenuItemView",
476
+ ])
477
+
478
+ function isControl(node) {
479
+ return node.clickable === true || CONTROL_TYPES.has(node.type)
480
+ }
481
+
482
+ function describeCandidate(node, index) {
483
+ const text = node.text === undefined ? "" : ` text=${JSON.stringify(node.text)}`
484
+ const desc = node.contentDesc === undefined ? "" : ` content-desc=${JSON.stringify(node.contentDesc)}`
485
+ const id = node.resourceId === undefined ? "" : ` resource-id=${JSON.stringify(node.resourceId)}`
486
+ const flags = node.enabled === false ? " enabled=false" : ""
487
+ const bounds = `bounds={x:${node.bounds.x},y:${node.bounds.y},w:${node.bounds.w},h:${node.bounds.h}}`
488
+ return `${index}) type=${node.type}${text}${desc}${id}${flags} ${bounds}`
489
+ }
490
+
491
+ /** Actionable refusal when every selector match is off-screen or disabled. */
492
+ function tapGateFailure(tool, representatives, screen, wanted, allowOffscreen) {
493
+ const offscreen = representatives.filter((node) => isOffscreenBounds(node.bounds, screen))
494
+ const disabled = representatives.filter((node) => node.enabled === false)
495
+ const hint = allowOffscreen ? " (allow_offscreen=true bypasses only the off-screen check — disabled stays refused)" : ""
496
+ if (offscreen.length > 0 && disabled.length > 0) {
497
+ throw new Error(
498
+ `${tool}: ${wanted} matched ${representatives.length} node(s) that are off-screen or disabled`
499
+ + " — scroll the off-screen ones into view first and enable the disabled ones" + hint,
500
+ )
501
+ }
502
+ if (offscreen.length > 0) {
503
+ const noun = representatives.length === 1 ? "matched an off-screen node" : `matched ${representatives.length} off-screen nodes`
504
+ throw new Error(
505
+ `${tool}: ${wanted} ${noun} — scroll it into view first (device_input action=swipe), `
506
+ + "then re-run device_ui_tree so the fresh dump re-locates it"
507
+ + `; pass allow_offscreen=true to tap the recorded coordinates anyway` + hint,
508
+ )
509
+ }
510
+ const noun = representatives.length === 1 ? "matched a disabled node" : `matched ${representatives.length} disabled nodes`
511
+ throw new Error(
512
+ `${tool}: ${wanted} ${noun} — the control is disabled, so a tap would do nothing; enable it first` + hint,
513
+ )
514
+ }
515
+
516
+ /**
517
+ * Resolve one node from a selector.
518
+ *
519
+ * `identifier` matches the resource-id; `label` matches the text OR the
520
+ * content-desc. Exact (case-sensitive) equality wins; otherwise
521
+ * case-insensitive substring. When both fields are given both must match.
522
+ *
523
+ * Nested duplicates — a list row mirrors its text onto a child TextView, and
524
+ * the clickable container wraps them both — collapse into ONE chain by bounds
525
+ * containment; the chain's outermost control (clickable, or a control widget
526
+ * class) is the tap target, falling back to the deepest node when the chain
527
+ * contains no control at all.
528
+ *
529
+ * Safety gate: matches that are off-screen or disabled are NOT tappable. When
530
+ * every match fails the gate the resolver throws an actionable error naming
531
+ * the fix; `allowOffscreen` skips only the off-screen half — a disabled node
532
+ * always refuses. Distinct nodes that all survive the gate raise an ambiguity
533
+ * error listing up to 8 candidates.
534
+ */
535
+ export function resolveTapTarget(roots, selector, options = {}) {
536
+ const tool = options.tool ?? "device_tap_element"
537
+ const identifier = selector.identifier !== undefined && selector.identifier.trim() !== "" ? selector.identifier.trim() : undefined
538
+ const label = selector.label !== undefined && selector.label.trim() !== "" ? selector.label.trim() : undefined
539
+ if (identifier === undefined && label === undefined) {
540
+ throw new Error(
541
+ `${tool} requires an element selector: resource_id and/or text `
542
+ + "(text also matches content-desc). Run device_ui_tree to see what the screen exposes.",
543
+ )
544
+ }
545
+ const flat = flattenNodes(roots)
546
+ const matchesValue = (actual, wanted, mode) => {
547
+ if (actual === undefined) return false
548
+ return mode === "exact" ? actual === wanted : actual.toLowerCase().includes(wanted.toLowerCase())
549
+ }
550
+ const matchesNode = (node, mode) => {
551
+ if (identifier !== undefined && !matchesValue(node.resourceId, identifier, mode)) return false
552
+ if (label !== undefined && !matchesValue(node.text, label, mode) && !matchesValue(node.contentDesc, label, mode)) return false
553
+ return true
554
+ }
555
+ let candidates = flat.filter((node) => matchesNode(node, "exact"))
556
+ let matchedBy = "exact"
557
+ if (candidates.length === 0) {
558
+ candidates = flat.filter((node) => matchesNode(node, "contains"))
559
+ matchedBy = "contains"
560
+ }
561
+ const wantedParts = []
562
+ if (identifier !== undefined) wantedParts.push(`resource_id ${JSON.stringify(identifier)}`)
563
+ if (label !== undefined) wantedParts.push(`text ${JSON.stringify(label)}`)
564
+ const wanted = wantedParts.join(" and ")
565
+ if (candidates.length === 0) {
566
+ throw new Error(
567
+ `${tool}: no node matches ${wanted} on the current screen — run device_ui_tree to inspect what is `
568
+ + "actually there, or device_screen to OCR labels the view hierarchy does not carry "
569
+ + "(Compose/Flutter/WebView/game canvases often expose none).",
570
+ )
571
+ }
572
+ // Drop exact box duplicates of the same class (a wrapper listed twice).
573
+ const unique = candidates.filter((node, index) => !candidates
574
+ .slice(0, index)
575
+ .some((other) => other.type === node.type && sameBounds(other.bounds, node.bounds)))
576
+ // Group containment chains: an ancestor that mirrors its child's text is
577
+ // the same row, not an ambiguity.
578
+ const chains = []
579
+ for (const node of unique) {
580
+ const chain = chains.find((group) => group.some((other) =>
581
+ !sameBounds(node.bounds, other.bounds)
582
+ && (containsBounds(node.bounds, other.bounds) || containsBounds(other.bounds, node.bounds)),
583
+ ))
584
+ if (chain === undefined) chains.push([node])
585
+ else chain.push(node)
586
+ }
587
+ const representatives = chains.map((chain) => {
588
+ const controls = chain.filter(isControl)
589
+ if (controls.length > 0) {
590
+ // Outermost control of the chain: not contained in another control.
591
+ const outer = controls.find((node) => !controls.some((other) =>
592
+ other !== node && containsBounds(other.bounds, node.bounds) && !sameBounds(other.bounds, node.bounds),
593
+ ))
594
+ return outer ?? controls[0]
595
+ }
596
+ // No control in the chain: the deepest (most specific) node it is.
597
+ return chain.reduce((deepest, node) => (node.depth > deepest.depth ? node : deepest), chain[0])
598
+ })
599
+ const screen = screenBoundsOf(roots)
600
+ const allowOffscreen = options.allowOffscreen === true
601
+ const viable = representatives.filter((node) =>
602
+ node.enabled !== false && (allowOffscreen || !isOffscreenBounds(node.bounds, screen)),
603
+ )
604
+ if (viable.length === 0) tapGateFailure(tool, representatives, screen, wanted, allowOffscreen)
605
+ if (viable.length > 1) {
606
+ const skipped = representatives.length - viable.length
607
+ const skippedSentence = skipped > 0 ? ` (${skipped} skipped: off-screen or disabled)` : ""
608
+ const shown = representatives.slice(0, 8)
609
+ const more = representatives.length - shown.length
610
+ throw new Error(
611
+ `${tool}: ${representatives.length} nodes match ${wanted}${skippedSentence} — use a more specific `
612
+ + "selector (an exact text, a resource_id, or device_ui_tree to disambiguate). Candidates:\n"
613
+ + shown.map((node, index) => ` ${describeCandidate(node, index + 1)}`).join("\n")
614
+ + (more > 0 ? `\n …and ${more} more` : ""),
615
+ )
616
+ }
617
+ return { node: viable[0], matchedBy }
618
+ }
619
+
620
+ /** Center of a box in display pixels (integers: `input tap` takes pixels). */
621
+ export function boundsCenter(bounds) {
622
+ return {
623
+ x: Math.round(bounds.x + bounds.w / 2),
624
+ y: Math.round(bounds.y + bounds.h / 2),
625
+ }
626
+ }
627
+
628
+ /** Every label (text or content-desc) in the tree, for expect_* verification. */
629
+ export function collectLabels(roots) {
630
+ const labels = []
631
+ const walk = (node) => {
632
+ if (node.text !== undefined) labels.push(node.text)
633
+ if (node.contentDesc !== undefined) labels.push(node.contentDesc)
634
+ for (const child of node.children) walk(child)
635
+ }
636
+ for (const root of roots) walk(root)
637
+ return labels
638
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-mobilecode",
3
3
  "description": "MobileCode for the dsh web GUI: detect iOS/Android projects, run serve-sim / serve-avd preview servers, and build-install-launch the app on the simulator or emulator from the session — plus agent tools (device_run, device_detect). Hot-pluggable — mounted via the profile bundle list + cordis.patch.yml, no dsh source changes.",
4
- "version": "0.1.4",
4
+ "version": "0.2.1",
5
5
  "type": "module",
6
6
  "packageManager": "pnpm@11.22.0",
7
7
  "engines": {