dsh-mobilecode 0.1.4 → 0.2.0

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 CHANGED
@@ -46,14 +46,32 @@ drawer:
46
46
  failed or incomplete), the Metro state, and `complete`.
47
47
  - `device_detect` — which platforms a directory supports, the framework, and
48
48
  the first attached Android device.
49
+ - A bundled **`device-ui-automation` playbook skill** (registered through
50
+ `ctx.skills.register()`, defensively — hosts without the skill service just
51
+ skip it): the observe-once → act-with-an-assertion → observe-again workflow,
52
+ which observer to reach for first, how to confirm an action landed via
53
+ `expect_text`/`expect_gone`, and the real-device safety rules.
49
54
  - `device_screen` — see what is on an attached Android device: a PNG screenshot
50
55
  plus the uiautomator UI hierarchy (text + pixel bounds) and **local PaddleOCR**
51
56
  text recognition (text + confidence + box), so an agent can read the screen
52
57
  and tap by coordinates. Also returns the foreground activity and screen size.
58
+ - `device_ui_tree` — the default screen observer: the uiautomator hierarchy as a
59
+ typed node tree (`type`/`text`/`contentDesc`/`resourceId`/`bounds`, with
60
+ `enabled`/`focused`/`clickable`/`scrollable` emitted only in their interesting
61
+ state), case-insensitive `filter` that keeps ancestors of matches, `max_depth`,
62
+ and a 40 KB cap that prunes the deepest levels first. Resource-ids are the most
63
+ stable tap handles.
64
+ - `device_tap_element` — tap a control by identity: `resource_id` matches the
65
+ node's resource-id, `text` matches its text or content-desc; exact match wins
66
+ over substring, nested duplicates collapse to the outermost control, ambiguity
67
+ lists up to 8 candidates instead of guessing, and disabled / off-screen nodes
68
+ are refused with the fix. `expect_text` / `expect_gone` verify the tap in the
69
+ same call — one round trip, no separate screenshot.
53
70
  - `device_input` — act on the device: tap / swipe / type / press a key at
54
71
  **absolute pixel coordinates** (the same space `device_screen` returns — take
55
72
  the box center `x=(x1+x2)/2, y=(y1+y2)/2`). The deterministic control loop is
56
- `device_screendevice_input device_screen`.
73
+ `device_ui_treedevice_tap_element`, falling back to
74
+ `device_screen → device_input` when a surface exposes no accessibility tree.
57
75
  - `device_log` — device logs: logcat `main`/`crash`/`events`/`kernel` buffers
58
76
  (kernel = dmesg, needs adb root — works on emulators) with an optional
59
77
  case-insensitive substring filter, capped line count.
@@ -108,11 +108,19 @@ export async function capture(command, args, options = {}) {
108
108
  })
109
109
  running.stdout?.setEncoding("utf8")
110
110
  running.stdout?.on("data", (chunk) => out.push(chunk))
111
+ let timer
112
+ if (options.timeoutMs) {
113
+ timer = setTimeout(() => running.kill(), options.timeoutMs)
114
+ timer.unref?.()
115
+ }
111
116
  const code = await new Promise((resolve) => {
112
117
  running.once("error", () => resolve(-1))
113
118
  running.once("close", (value) => resolve(value ?? -1))
114
119
  })
115
- return code === 0 ? out.join("") : ""
120
+ if (timer) clearTimeout(timer)
121
+ if (code !== 0) return ""
122
+ const text = out.join("")
123
+ return options.maxBytes && Buffer.byteLength(text) > options.maxBytes ? text.slice(0, options.maxBytes) : text
116
124
  }
117
125
 
118
126
  /** Direct child pids of `pid`. POSIX only; returns nothing when pgrep is unavailable. */
package/lib/index.js CHANGED
@@ -17,8 +17,10 @@
17
17
 
18
18
  import { defineTool } from '@deepseek-ai/dsh-tools'
19
19
  import * as DeviceBuild from './device-build.js'
20
+ import * as UiTree from './uitree.js'
20
21
  import { DevicePreviewEngine } from './device-preview.js'
21
22
  import * as Setup from './setup.js'
23
+ import { registerMobileSkill } from './skill.js'
22
24
 
23
25
  export const name = 'mobilecode'
24
26
  export const inject = ['webServer', 'tools', 'systemPrompt']
@@ -717,6 +719,144 @@ async function captureScreenSize(serial) {
717
719
  return match ? { width: Number(match[1]), height: Number(match[2]) } : undefined
718
720
  }
719
721
 
722
+ /** Recursive node schema is not expressible here; tree children stay open objects. */
723
+ const UI_TREE_ITEM_SCHEMA = { type: 'object', additionalProperties: true }
724
+
725
+ function deviceUiTreeTool() {
726
+ return defineTool({
727
+ name: 'device_ui_tree',
728
+ description: 'Dump the foreground Android app\'s uiautomator hierarchy as a compact node tree — type, text, ' +
729
+ 'contentDesc, resourceId, pixel bounds, enabled/focused/clickable flags. The default observer for UI automation: ' +
730
+ 'resource-ids are the most stable tap handles. Use device_tap_element to tap by identity instead of guessing ' +
731
+ 'pixel coordinates. When the tree comes back shallow or empty on a WebView/Compose/canvas, fall back to ' +
732
+ 'device_screen (OCR reads pixels and needs no idle).',
733
+ parameters: {
734
+ serial: { type: 'string', description: 'Android device serial. Omit to use the first attached device.' },
735
+ max_depth: { type: 'integer', description: 'Maximum hierarchy depth to include (omit for the full tree).' },
736
+ filter: { type: 'string', description: 'Case-insensitive substring over text/content-desc/resource-id/type; matching nodes and their ancestors are kept.' },
737
+ },
738
+ output: {
739
+ schema: {
740
+ type: 'object',
741
+ additionalProperties: false,
742
+ properties: {
743
+ serial: { type: 'string', required: true },
744
+ rotation: { type: 'integer' },
745
+ nodes: { type: 'integer', required: true },
746
+ tree: { type: 'array', required: true, items: UI_TREE_ITEM_SCHEMA },
747
+ truncated: { type: 'boolean' },
748
+ },
749
+ },
750
+ render: (_args, value) => {
751
+ const v = value ?? { serial: '', nodes: 0, tree: [] }
752
+ const lines = [`Device ${v.serial}: ${v.nodes} nodes${v.truncated ? ' (truncated at 40 KB — narrow with max_depth or filter)' : ''}${v.rotation !== undefined ? `, rotation=${v.rotation}` : ''}`]
753
+ const walk = (nodes, indent) => {
754
+ for (const node of nodes) {
755
+ const label = [node.text, node.contentDesc].find(Boolean) ?? ''
756
+ const id = node.resourceId ? ` [${node.resourceId}]` : ''
757
+ const flags = []
758
+ if (node.enabled === false) flags.push('disabled')
759
+ if (node.clickable) flags.push('clickable')
760
+ if (node.scrollable) flags.push('scrollable')
761
+ const b = node.bounds
762
+ lines.push(`${indent}- ${node.type}${label ? ` "${label}"` : ''}${id}${flags.length > 0 ? ` (${flags.join(',')})` : ''} @${b.x},${b.y} ${b.w}x${b.h}`)
763
+ if (indent.length < 8) walk(node.children, indent + ' ')
764
+ }
765
+ }
766
+ walk(v.tree, ' ')
767
+ if (v.nodes === 0) lines.push(' (no nodes — the surface may expose no accessibility; try device_screen OCR)')
768
+ return [{ type: 'text', text: lines.join('\n') }]
769
+ },
770
+ },
771
+ async execute(args) {
772
+ const serial = await requireAndroidDevice(args.serial)
773
+ const parsed = await UiTree.readUiTree(serial)
774
+ const { tree, count } = UiTree.buildCompactTree(parsed.roots, args.max_depth, args.filter)
775
+ const capped = UiTree.capTreeToBytes(tree)
776
+ const out = {
777
+ serial,
778
+ nodes: count,
779
+ tree: capped.tree,
780
+ ...(capped.truncated ? { truncated: true } : {}),
781
+ ...(parsed.rotation !== undefined ? { rotation: parsed.rotation } : {}),
782
+ }
783
+ return out
784
+ },
785
+ })
786
+ }
787
+
788
+ function deviceTapElementTool() {
789
+ return defineTool({
790
+ name: 'device_tap_element',
791
+ description: 'Tap an Android UI element by identity — resource_id matches the node\'s resource-id; text matches its ' +
792
+ 'text or content-desc. Exact match first, then case-insensitive substring; nested duplicates collapse to one ' +
793
+ 'target and an ambiguous match lists up to 8 candidates instead of picking one. Disabled or off-screen elements ' +
794
+ 'are refused with the fix. Pass expect_text / expect_gone and the tap plus its verification become one round trip.',
795
+ parameters: {
796
+ serial: { type: 'string', description: 'Android device serial. Omit to use the first attached device.' },
797
+ resource_id: { type: 'string', description: 'The resource-id to match (e.g. com.android.settings:id/search_bar).' },
798
+ text: { type: 'string', description: 'Text or content-desc to match. Exact wins over substring.' },
799
+ expect_text: { type: 'string', description: 'After the tap, re-dump and verify this text is present.' },
800
+ expect_gone: { type: 'string', description: 'After the tap, re-dump and verify this text is gone.' },
801
+ allow_offscreen: { type: 'boolean', description: 'Tap a node whose bounds lie outside the screen anyway (default false).' },
802
+ },
803
+ output: {
804
+ schema: {
805
+ type: 'object',
806
+ additionalProperties: false,
807
+ properties: {
808
+ serial: { type: 'string', required: true },
809
+ tapped: { type: 'string', required: true },
810
+ matchedBy: { type: 'string', required: true },
811
+ x: { type: 'integer', required: true },
812
+ y: { type: 'integer', required: true },
813
+ expected: {
814
+ type: 'object',
815
+ additionalProperties: false,
816
+ properties: {
817
+ mode: { type: 'string', required: true },
818
+ text: { type: 'string', required: true },
819
+ matched: { type: 'boolean', required: true },
820
+ },
821
+ },
822
+ },
823
+ },
824
+ render: (_args, value) => {
825
+ const v = value ?? { serial: '', tapped: '', matchedBy: '', x: 0, y: 0 }
826
+ const lines = [`Tapped ${v.tapped} (${v.matchedBy}) at ${v.x},${v.y} on ${v.serial}`]
827
+ if (v.expected) lines.push(`Expect ${v.expected.mode} "${v.expected.text}": ${v.expected.matched ? 'VERIFIED' : 'NOT matched — the action did not land as expected'}`)
828
+ return [{ type: 'text', text: lines.join('\n') }]
829
+ },
830
+ },
831
+ async execute(args) {
832
+ const serial = await requireAndroidDevice(args.serial)
833
+ const parsed = await UiTree.readUiTree(serial)
834
+ const selector = { identifier: args.resource_id, label: args.text }
835
+ const { node, matchedBy } = UiTree.resolveTapTarget(parsed.roots, selector, { tool: 'device_tap_element', allowOffscreen: args.allow_offscreen === true })
836
+ const center = UiTree.boundsCenter(node.bounds)
837
+ await DeviceBuild.exec(DeviceBuild.adb(), ['-s', serial, 'shell', 'input', 'tap', String(center.x), String(center.y)]).exit
838
+ const describe = () => {
839
+ const parts = []
840
+ if (node.resourceId) parts.push(`resource_id ${node.resourceId}`)
841
+ if (node.text) parts.push(`text ${JSON.stringify(node.text)}`)
842
+ if (node.contentDesc) parts.push(`content-desc ${JSON.stringify(node.contentDesc)}`)
843
+ return parts.join(', ') || node.type
844
+ }
845
+ const out = { serial, tapped: describe(), matchedBy, x: center.x, y: center.y }
846
+ const mode = args.expect_gone !== undefined && args.expect_gone !== '' ? 'gone' : (args.expect_text !== undefined && args.expect_text !== '' ? 'appear' : undefined)
847
+ if (mode !== undefined) {
848
+ const text = mode === 'gone' ? String(args.expect_gone) : String(args.expect_text)
849
+ await new Promise((resolve) => setTimeout(resolve, 600))
850
+ const fresh = await UiTree.readUiTree(serial).catch(() => undefined)
851
+ const labels = fresh ? UiTree.collectLabels(fresh.roots) : []
852
+ const matched = mode === 'gone' ? !labels.some((label) => label.toLowerCase().includes(text.toLowerCase())) : labels.some((label) => label.toLowerCase().includes(text.toLowerCase()))
853
+ out.expected = { mode, text, matched }
854
+ }
855
+ return out
856
+ },
857
+ })
858
+ }
859
+
720
860
  function deviceLogTool(engine) {
721
861
  return defineTool({
722
862
  name: 'device_log',
@@ -898,6 +1038,10 @@ function guidance() {
898
1038
  ' Pass directory when the project root is not the working directory. A run takes minutes; use a generous timeout.',
899
1039
  '- device_screen: capture the attached Android screen as a PNG plus the UI hierarchy and OCR text, so you can see what',
900
1040
  ' the app shows and tap by pixel coordinates. Call it after a run to confirm the app rendered, and to drive UI flows.',
1041
+ '- device_ui_tree: the default screen observer — the uiautomator hierarchy as a typed node tree with resource-ids,',
1042
+ ' text, and pixel bounds. Narrow with filter/max_depth; on a textless surface (WebView, Compose, canvas) use device_screen.',
1043
+ '- device_tap_element: tap a control by resource_id or text/content-desc, with one-call verification via',
1044
+ ' expect_text / expect_gone (no separate screenshot needed to know the tap landed).',
901
1045
  '- device_input: tap/swipe/type/press on the attached Android device at ABSOLUTE pixel coordinates (take the center of a',
902
1046
  ' device_screen box: x=(x1+x2)/2, y=(y1+y2)/2). The control loop is device_screen → device_input → device_screen.',
903
1047
  '- device_log: read device logs (logcat main/crash/events, kernel dmesg). Call it when a run fails or an app misbehaves.',
@@ -914,6 +1058,14 @@ export function apply(ctx, config) {
914
1058
  Setup.ensureHome()
915
1059
  Setup.writeOcrReadme()
916
1060
 
1061
+ // The playbook skill is host-independent; register it once at apply-time.
1062
+ let disposeSkill = () => {}
1063
+ try {
1064
+ disposeSkill = registerMobileSkill(ctx) ?? (() => {})
1065
+ } catch {
1066
+ // The skills service is optional; the plugin works without the playbook.
1067
+ }
1068
+
917
1069
  const resolve = () => ({
918
1070
  enabled: config?.enabled ?? true,
919
1071
  announceToAgent: config?.announceToAgent ?? true,
@@ -959,6 +1111,8 @@ export function apply(ctx, config) {
959
1111
  deviceRunTool(engine, config),
960
1112
  deviceDetectTool(engine, config),
961
1113
  deviceScreenTool(engine),
1114
+ deviceUiTreeTool(),
1115
+ deviceTapElementTool(),
962
1116
  deviceLogTool(engine),
963
1117
  deviceStatusTool(engine),
964
1118
  deviceInputTool(),
@@ -968,6 +1122,7 @@ export function apply(ctx, config) {
968
1122
  }
969
1123
 
970
1124
  ctx.effect(() => () => {
1125
+ disposeSkill()
971
1126
  void engine.dispose()
972
1127
  }, 'dsh-mobilecode: engine')
973
1128
 
package/lib/skill.js ADDED
@@ -0,0 +1,104 @@
1
+ /**
2
+ * dsh-mobilecode — the plugin's bundled playbook, contributed through
3
+ * ctx.skills.register().
4
+ *
5
+ * A tool description answers "what does this argument mean", one tool at a
6
+ * time. What agents re-derive every session is the WORKFLOW between the tools
7
+ * — which observer to reach for, how to confirm an action landed, what a
8
+ * device refuses to tell you. This skill carries that workflow so the model
9
+ * loads it once per UI task instead of rediscovering it by trial and error.
10
+ *
11
+ * Registration is DEFENSIVE: a host without the skill service still loads the
12
+ * plugin, it just does not advertise the playbook (the scoped inject never
13
+ * runs when the service is absent).
14
+ *
15
+ * Playbook design credit: ZSeven-W/dsh-android (MIT) src/skill.ts.
16
+ */
17
+
18
+ export const SKILL_NAME = "device-ui-automation"
19
+
20
+ export const SKILL_DESCRIPTION =
21
+ "Drive an attached Android device through the dsh-mobilecode device_* tools: read the screen, tap by identity, " +
22
+ "type, and confirm that an action landed. Load this before the first device_ui_tree / device_tap_element call of a UI task."
23
+
24
+ export const SKILL_WHEN_TO_USE =
25
+ "Any task that operates an Android app through the device_* tools — opening apps, tapping controls, filling " +
26
+ "fields, scrolling, reading logs, or verifying what is on screen, on an emulator or a real device."
27
+
28
+ export const SKILL_CONTENT = `# Driving Android with dsh-mobilecode
29
+
30
+ The loop is **observe once → act with an assertion → observe again only if the assertion could not settle it**. Everything goes through adb; an emulator and a USB phone take exactly the same tools with the same arguments.
31
+
32
+ ## Reading the screen
33
+
34
+ | Tool | Cost | Use it for |
35
+ | --- | --- | --- |
36
+ | \`device_ui_tree\` | ~0.6–1.5 s | hierarchy, resource-ids, text, enabled state — the default observer |
37
+ | \`device_screen\` | ~0.3 s + OCR | the picture for the user, plus PaddleOCR text+boxes when the tree is blind |
38
+ | \`device_log\` | fast | what the device logs (main/crash/events buffers) |
39
+
40
+ - Start with \`device_ui_tree\`: the accessibility tree carries \`resource-id\` (e.g. \`com.android.settings:id/search_bar\`), the most stable handle a control can have — far better than its text, which changes with the device language.
41
+ - \`uiautomator\` dumps a snapshot of the CURRENT frame. If an animation is still running the tree can be a half-finished layout; when a read looks wrong, re-read once rather than reasoning about the wrong frame.
42
+ - A shallow or empty tree is NEVER evidence that an app lacks accessibility support. Attribute an unlabeled read to ONE of three causes — (a) the depth/filter cut it: re-read wider; (b) a WebView, Compose or canvas surface that publishes little: \`device_screen\` OCR is the fallback; (c) a DEEP, unfiltered read with no labels — only then may "little accessibility information" be reported. Never jump from a shallow read to "OCR the screen".
43
+ - The uiautomator dump fails on a continuously animating foreground (a page still loading) — the error says so. Do not retry; use \`device_screen\` (OCR reads pixels and needs no idle).
44
+
45
+ ## Acting
46
+
47
+ - Prefer \`device_tap_element\` (resource-id / text / content-desc). Raw pixel coordinates through \`device_input action=tap\` are the last resort — they break on the next layout change.
48
+ - When you must tap pixels, take the center of a box from \`device_ui_tree\`/\`device_screen\`: x=(x1+x2)/2, y=(y1+y2)/2. Coordinates are ABSOLUTE pixels of the current display.
49
+ - \`device_input action=swipe\` scrolls; \`action=key\` with "back" is a first-class verb on Android — use it instead of hunting for an on-screen back arrow.
50
+ - Typing is ASCII-only through adb. Non-ASCII text (Chinese, emoji) cannot be delivered by \`input text\` — do not retry; type via the app's own UI instead.
51
+
52
+ ## Never guess a package name or a control
53
+
54
+ - A package name that looks plausible is routinely NOT the installed one. Check \`device_status\` / \`device_log filter=<package>\` for what is actually running before assuming.
55
+ - Icon-only controls carry no OCR text by definition: the tree's \`content-desc\` is the only reliable handle — a bare tree means "look deeper", never "start guessing".
56
+
57
+ ## Confirming an action landed
58
+
59
+ - Pass \`expect_text\` (or \`expect_gone\`) to \`device_tap_element\`: the tap and its verification become ONE round trip, and the result carries \`expected.matched\`.
60
+ - Waiting for something slow (a load, an animation, a network round trip) is a \`device_ui_tree\`/\`device_screen\` re-read after a pause — one observation, not a poll loop.
61
+ - Never compare screenshots or count pixels to decide whether something happened. Read the text back.
62
+
63
+ ## Logs
64
+
65
+ - \`device_log\` buffers: \`main\` (default), \`crash\` (holds ONLY fatal Java/native crashes — that is where a stack trace lives), \`events\` (activity lifecycle), \`kernel\` (needs adb root; emulators usually allow it).
66
+ - An idle emulator emits hundreds of lines a second. Narrow BEFORE widening: \`filter\` (package or tag substring) and a small \`lines\` count first.
67
+
68
+ ## Real devices
69
+
70
+ - **Every tap on a real phone has real consequences** — posts, likes, purchases, messages. NEVER tap an unidentified control to find out what it does. If a control cannot be identified (no resource-id after a deep tree, no distinguishing text), STOP and report what you see and ask how to proceed. Do not guess coordinates on someone's live account.
71
+ - A phone must be UNLOCKED for anything to be visible; \`device_input action=key key="wakeup"\` wakes it, but a PIN/pattern lock cannot be passed from here.
72
+ - \`device_status\` reports each device's adb state: \`unauthorized\` means the USB-debugging prompt has not been accepted ON the device — no tool can fix that from this side.
73
+ `
74
+
75
+ /**
76
+ * Register the playbook when the host provides the skill service. The scoped
77
+ * inject is the optional-service pattern: a profile without the skill service
78
+ * simply never runs the callback, and the plugin loads without the playbook.
79
+ */
80
+ export function registerMobileSkill(ctx) {
81
+ if (typeof ctx.inject === "function") {
82
+ const fiber = ctx.inject(["skills"], (skillCtx) => {
83
+ skillCtx.effect(() => skillCtx.skills.register({
84
+ name: SKILL_NAME,
85
+ description: SKILL_DESCRIPTION,
86
+ whenToUse: SKILL_WHEN_TO_USE,
87
+ content: SKILL_CONTENT,
88
+ source: "bundled",
89
+ }), "dsh-mobilecode: skill")
90
+ })
91
+ return () => { try { fiber.dispose() } catch { /* already disposed */ } }
92
+ }
93
+ // Very old hosts: direct registration when the service exists, else skip.
94
+ if (ctx.skills && typeof ctx.skills.register === "function") {
95
+ return ctx.skills.register({
96
+ name: SKILL_NAME,
97
+ description: SKILL_DESCRIPTION,
98
+ whenToUse: SKILL_WHEN_TO_USE,
99
+ content: SKILL_CONTENT,
100
+ source: "bundled",
101
+ })
102
+ }
103
+ return undefined
104
+ }
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.0",
5
5
  "type": "module",
6
6
  "packageManager": "pnpm@11.22.0",
7
7
  "engines": {