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/README.md +34 -1
- package/lib/device-build.js +60 -1
- package/lib/index.js +482 -5
- package/lib/skill.js +104 -0
- package/lib/uitree.js +638 -0
- package/package.json +1 -1
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
|
+
"version": "0.2.1",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"packageManager": "pnpm@11.22.0",
|
|
7
7
|
"engines": {
|