@flxapps/adbax 0.0.0-stage → 0.1.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/src/select.js ADDED
@@ -0,0 +1,95 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ import { kind, label, shortId, tappable } from './tree.js';
3
+ import { fail } from './util.js';
4
+
5
+ const norm = (s) => (s || '').toLowerCase().trim();
6
+
7
+ /**
8
+ * How well a node matches a query: exact before prefix before substring, and
9
+ * within each, text before content-desc before hint and resource-id.
10
+ */
11
+ function score(n, q, re) {
12
+ const fields = [n.text, n.desc, n.hint, shortId(n), n.id];
13
+ let best = 0;
14
+ fields.forEach((f, i) => {
15
+ if (!f) return;
16
+ const weight = i === 0 ? 0 : i === 1 ? 1 : 4;
17
+ if (re) { if (re.test(f)) best = Math.max(best, 80 - weight); return; }
18
+ const nf = norm(f);
19
+ if (nf === q) best = Math.max(best, 100 - weight);
20
+ else if (nf.startsWith(q)) best = Math.max(best, 75 - weight);
21
+ else if (nf.includes(q)) best = Math.max(best, 55 - weight);
22
+ });
23
+ return best;
24
+ }
25
+
26
+ /** The best-scoring nodes for a query; several only when they tie (within 4 points). */
27
+ export function match(nodes, query, { regex = false, preferTappable = false } = {}) {
28
+ const re = regex ? new RegExp(query, 'i') : null;
29
+ const q = norm(query);
30
+ const hits = nodes
31
+ .filter((n) => n.w > 0 && n.h > 0)
32
+ .map((n) => ({ n, s: score(n, q, re) }))
33
+ .filter((h) => h.s > 0);
34
+ if (!hits.length) return [];
35
+ // A tap prefers something you can actually tap.
36
+ if (preferTappable) hits.forEach((h) => { if (tappable(h.n)) h.s += 12; });
37
+ hits.sort((a, b) => b.s - a.s || a.n.i - b.n.i);
38
+ const top = hits[0].s;
39
+ return hits.filter((h) => h.s >= top - 4).map((h) => h.n);
40
+ }
41
+
42
+ export function tappableAncestor(n) {
43
+ for (let c = n; c; c = c.parent) if (c.clickable && c.enabled) return c;
44
+ return n;
45
+ }
46
+
47
+ /** One line describing a node, for messages. */
48
+ export function describe(n) {
49
+ const sid = shortId(n);
50
+ return `${kind(n)} ${JSON.stringify(label(n))}${sid ? ' id=' + sid : ''}`;
51
+ }
52
+
53
+ /**
54
+ * Resolves a selector to a point: "540,1200" is taken as is, "3" or "#3" is a
55
+ * row of the last `adbax ui`, anything else is matched against a fresh tree.
56
+ *
57
+ * `ctx` supplies the tree (`nodes()`), the last rows (`lastRows()`) and a line
58
+ * naming the current screen for error messages (`where()`).
59
+ */
60
+ export async function resolve(sel, ctx, { forTap = false, nth, first = false, regex = false } = {}) {
61
+ if (/^-?\d+\s*,\s*-?\d+$/.test(sel)) {
62
+ const [x, y] = sel.split(',').map((v) => +v.trim());
63
+ return { x, y, how: 'coords' };
64
+ }
65
+ if (/^#?\d+$/.test(sel)) {
66
+ const idx = +sel.replace('#', '');
67
+ const rows = ctx.lastRows();
68
+ if (!rows) fail('no cached dump. Run `adbax ui` first, or select by text.');
69
+ const r = rows[idx] || fail(`index ${idx} not in the last dump (${rows.length} rows).`);
70
+ return { x: r.x, y: r.y, how: `[${idx}] ${r.k} ${JSON.stringify(r.label)}` };
71
+ }
72
+ const nodes = await ctx.nodes();
73
+ let hits = match(nodes, sel, { regex, preferTappable: forTap });
74
+ if (!hits.length) {
75
+ fail(`no node matching ${JSON.stringify(sel)}.\n Current screen: ${await ctx.where()}\n` +
76
+ ' Run `adbax ui` to see what is there.');
77
+ }
78
+ if (nth !== undefined) {
79
+ const k = +nth;
80
+ if (!hits[k]) fail(`--nth ${k} out of range (${hits.length} matches).`);
81
+ hits = [hits[k]];
82
+ }
83
+ if (hits.length > 1 && !first) {
84
+ const lines = hits.map((n, k) => ` --nth ${k} ${describe(n)} @${n.cx},${n.cy}`);
85
+ fail(`${JSON.stringify(sel)} matches ${hits.length} nodes:\n${lines.join('\n')}\n` +
86
+ ' Narrow the query, or add --nth N / --first.', 2);
87
+ }
88
+ const hit = hits[0];
89
+ const target = forTap && !tappable(hit) ? tappableAncestor(hit) : hit;
90
+ const sid = shortId(target);
91
+ return {
92
+ x: target.cx, y: target.cy, node: target,
93
+ how: `${kind(target)} ${JSON.stringify(label(hit))}${sid ? ' id=' + sid : ''}`,
94
+ };
95
+ }
package/src/tree.js ADDED
@@ -0,0 +1,183 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ /*
3
+ * The accessibility tree as `uiautomator dump` writes it (the on-device server
4
+ * writes the same format), parsed into nodes and reduced to the rows worth
5
+ * showing.
6
+ */
7
+
8
+ const ENTITIES = { lt: '<', gt: '>', amp: '&', quot: '"', apos: "'" };
9
+
10
+ function unescape(s) {
11
+ return s.replace(/&(lt|gt|amp|quot|apos|#\d+|#x[0-9a-f]+);/gi, (m, e) => {
12
+ if (e[0] !== '#') return ENTITIES[e];
13
+ return String.fromCodePoint(e[1] === 'x' || e[1] === 'X' ? parseInt(e.slice(2), 16) : +e.slice(1));
14
+ });
15
+ }
16
+
17
+ function attributes(s) {
18
+ const a = {};
19
+ const re = /([\w:-]+)="([^"]*)"/g;
20
+ let m;
21
+ while ((m = re.exec(s))) a[m[1]] = unescape(m[2]);
22
+ return a;
23
+ }
24
+
25
+ /** {width, height, rotation} from the <hierarchy> element, where the server provides it. */
26
+ export function screenInfo(xml) {
27
+ const m = xml.match(/<hierarchy([^>]*)>/);
28
+ if (!m) return {};
29
+ const a = attributes(m[1]);
30
+ return { width: +a.width || 0, height: +a.height || 0, rotation: +a.rotation || 0 };
31
+ }
32
+
33
+ export function parseTree(xml) {
34
+ const nodes = [];
35
+ const stack = [];
36
+ const re = /<node((?:\s+[\w:-]+="[^"]*")*)\s*(\/?)>|<\/node>/g;
37
+ let m;
38
+ while ((m = re.exec(xml))) {
39
+ if (m[0] === '</node>') { stack.pop(); continue; }
40
+ const a = attributes(m[1]);
41
+ const b = (a.bounds || '').match(/\[(-?\d+),(-?\d+)\]\[(-?\d+),(-?\d+)\]/);
42
+ const n = {
43
+ i: nodes.length,
44
+ cls: (a.class || '').split('.').pop(),
45
+ fullCls: a.class || '',
46
+ text: (a.text || '').trim(),
47
+ desc: (a['content-desc'] || '').trim(),
48
+ hint: (a.hint || '').trim(),
49
+ id: a['resource-id'] || '',
50
+ pkg: a.package || '',
51
+ window: a.window || '',
52
+ clickable: a.clickable === 'true',
53
+ longClickable: a['long-clickable'] === 'true',
54
+ checkable: a.checkable === 'true',
55
+ checked: a.checked === 'true',
56
+ selected: a.selected === 'true',
57
+ scrollable: a.scrollable === 'true',
58
+ enabled: a.enabled !== 'false',
59
+ focused: a.focused === 'true',
60
+ focusable: a.focusable === 'true',
61
+ password: a.password === 'true',
62
+ x1: b ? +b[1] : 0, y1: b ? +b[2] : 0, x2: b ? +b[3] : 0, y2: b ? +b[4] : 0,
63
+ parent: stack.length ? stack[stack.length - 1] : null,
64
+ kids: [],
65
+ };
66
+ n.w = n.x2 - n.x1;
67
+ n.h = n.y2 - n.y1;
68
+ n.cx = Math.round((n.x1 + n.x2) / 2);
69
+ n.cy = Math.round((n.y1 + n.y2) / 2);
70
+ n.editable = /EditText|AutoComplete|TextInputEditText/.test(n.fullCls) ||
71
+ (n.focusable && n.clickable && /Edit/.test(n.fullCls));
72
+ if (n.parent) n.parent.kids.push(n);
73
+ nodes.push(n);
74
+ if (!m[2]) stack.push(n);
75
+ }
76
+ return nodes;
77
+ }
78
+
79
+ /**
80
+ * Up to three labels from a node's descendants, for wrappers that carry none
81
+ * themselves. Clickable descendants are rows of their own (the star button in
82
+ * a list row), so their labels only count when nothing else has one.
83
+ */
84
+ function descendantLabels(n, skipClickable, out = [], depth = 0) {
85
+ if (depth > 4 || out.length >= 3) return out;
86
+ for (const k of n.kids) {
87
+ if (skipClickable && (k.clickable || k.editable)) continue;
88
+ const t = k.text || k.desc;
89
+ if (t && !out.includes(t)) out.push(t);
90
+ descendantLabels(k, skipClickable, out, depth + 1);
91
+ }
92
+ return out;
93
+ }
94
+
95
+ export function label(n) {
96
+ if (n.text) return n.text;
97
+ if (n.desc) return n.desc;
98
+ if (n.hint) return '(hint) ' + n.hint;
99
+ const own = descendantLabels(n, true);
100
+ return (own.length ? own : descendantLabels(n, false)).join(' / ');
101
+ }
102
+
103
+ /** "submit" for "com.example:id/submit" inside com.example; the full id otherwise. */
104
+ export function shortId(n) {
105
+ if (!n.id) return '';
106
+ const m = n.id.match(/^([^:]+):id\/(.+)$/);
107
+ if (!m) return n.id;
108
+ return m[1] === n.pkg ? m[2] : n.id;
109
+ }
110
+
111
+ export function kind(n) {
112
+ if (n.editable) return 'EDIT';
113
+ if (n.checkable) return n.checked ? 'CHK[x]' : 'CHK[ ]';
114
+ if (n.clickable || n.longClickable) return 'TAP';
115
+ if (n.scrollable) return 'SCRL';
116
+ return 'TXT';
117
+ }
118
+
119
+ export const actionable = (n) => n.clickable || n.longClickable || n.checkable || n.editable;
120
+ export const tappable = (n) => n.clickable || n.editable || n.checkable;
121
+
122
+ /**
123
+ * Reduces the raw tree to the rows worth printing: everything you can act on,
124
+ * scroll containers, and labelled nodes that no actionable ancestor has
125
+ * already absorbed. `all` keeps absorbed labels and unlabelled nodes with ids.
126
+ */
127
+ export function interesting(nodes, all = false) {
128
+ const absorbed = new Set();
129
+ const picked = new Set();
130
+ const rows = [];
131
+ for (const n of nodes) {
132
+ if (n.w <= 0 || n.h <= 0 || !actionable(n)) continue;
133
+ // A clickable wrapper eats the labels of its descendants.
134
+ const stack = n.kids.slice();
135
+ while (stack.length) {
136
+ const k = stack.pop();
137
+ if (k.clickable || k.editable || k.scrollable) continue;
138
+ absorbed.add(k.i);
139
+ stack.push(...k.kids);
140
+ }
141
+ rows.push(n);
142
+ picked.add(n.i);
143
+ }
144
+ for (const n of nodes) {
145
+ if (n.w <= 0 || n.h <= 0 || picked.has(n.i)) continue;
146
+ if (n.scrollable) { rows.push(n); continue; }
147
+ if (absorbed.has(n.i) && !all) continue;
148
+ if (n.text || n.desc) rows.push(n);
149
+ else if (all && n.id) rows.push(n);
150
+ }
151
+ rows.sort((a, b) => a.i - b.i);
152
+ return rows;
153
+ }
154
+
155
+ export function renderRow(n, idx, { full = false } = {}) {
156
+ const parts = [`[${idx}]`, kind(n).padEnd(6)];
157
+ const l = label(n);
158
+ parts.push(JSON.stringify(l));
159
+ const sid = shortId(n);
160
+ if (sid) parts.push('id=' + sid);
161
+ if (full) parts.push('cls=' + n.cls);
162
+ if (n.window && n.window !== 'application') parts.push('win=' + n.window);
163
+ parts.push(`@${n.cx},${n.cy}`);
164
+ parts.push(`${n.w}x${n.h}`);
165
+ if (!n.enabled) parts.push('!disabled');
166
+ if (n.focused) parts.push('*focused');
167
+ if (n.selected) parts.push('*selected');
168
+ if (n.password) parts.push('*password');
169
+ if (n.scrollable && !n.clickable) parts.push('*scrollable');
170
+ return ' ' + parts.join(' ');
171
+ }
172
+
173
+ export function render(rows, opts) {
174
+ return rows.map((n, idx) => renderRow(n, idx, opts)).join('\n');
175
+ }
176
+
177
+ /** The compact form `adbax ui --json` prints and `tap <index>` reads back. */
178
+ export function rowRecord(n) {
179
+ return {
180
+ i: n.i, k: kind(n), label: label(n), id: shortId(n), fullId: n.id, cls: n.cls,
181
+ x: n.cx, y: n.cy, w: n.w, h: n.h, enabled: n.enabled, checked: n.checked,
182
+ };
183
+ }
package/src/util.js ADDED
@@ -0,0 +1,56 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ import fs from 'node:fs';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+
6
+ /** An error the CLI prints as `adbax: <message>` and exits with `code`. */
7
+ export class AdbaxError extends Error {
8
+ constructor(message, code = 1) {
9
+ super(message);
10
+ this.code = code;
11
+ }
12
+ }
13
+
14
+ export function fail(message, code = 1) {
15
+ throw new AdbaxError(message, code);
16
+ }
17
+
18
+ export const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
19
+
20
+ /** Quotes one word for the device's /bin/sh. */
21
+ export function q(s) {
22
+ s = String(s);
23
+ return /^[\w@%+=:,./-]+$/.test(s) ? s : `'${s.replace(/'/g, `'\\''`)}'`;
24
+ }
25
+
26
+ export const CACHE_DIR = process.env.ADBAX_CACHE ||
27
+ path.join(process.env.XDG_CACHE_HOME || path.join(os.homedir(), '.cache'), 'adbax');
28
+
29
+ export function cachePath(name) {
30
+ fs.mkdirSync(CACHE_DIR, { recursive: true });
31
+ return path.join(CACHE_DIR, name);
32
+ }
33
+
34
+ export function readJson(file, fallback = null) {
35
+ try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return fallback; }
36
+ }
37
+
38
+ export function readText(file) {
39
+ try { return fs.readFileSync(file, 'utf8').trim(); } catch { return ''; }
40
+ }
41
+
42
+ export function writeJson(file, value) {
43
+ fs.writeFileSync(file, JSON.stringify(value));
44
+ }
45
+
46
+ /** A filesystem-safe form of a device serial ("emulator-5554", "192.168.1.5:5555"). */
47
+ export const serialKey = (serial) => serial.replace(/[^\w.-]/g, '_');
48
+
49
+ const T0 = performance.now();
50
+ /** ADBAX_TRACE=1 prints every adb call and server request with its duration to stderr. */
51
+ export const tracing = !!process.env.ADBAX_TRACE;
52
+ export function trace(what, started) {
53
+ if (!tracing) return;
54
+ const now = performance.now();
55
+ process.stderr.write(`[${(now - T0).toFixed(0).padStart(5)} ms] ${(now - started).toFixed(0).padStart(4)} ms ${what}\n`);
56
+ }