@ppppaaaaa/adb-control 0.0.0-stage → 2.0.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/cordis.patch.yml +4 -0
- package/icon.svg +10 -0
- package/index.js +1079 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +12 -4
- package/README.md +0 -3
package/cordis.patch.yml
ADDED
package/icon.svg
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
|
|
2
|
+
<rect x="2" y="2" width="60" height="60" rx="14" fill="#1f2430"/>
|
|
3
|
+
<rect x="18" y="10" width="28" height="44" rx="6" fill="#2c3546" stroke="#3ddc84" stroke-width="2.5"/>
|
|
4
|
+
<rect x="24" y="16" width="16" height="24" rx="2" fill="#3ddc84" opacity="0.25"/>
|
|
5
|
+
<circle cx="32" cy="46" r="2.6" fill="#3ddc84"/>
|
|
6
|
+
<path d="M14 26a10 10 0 0 1 0 12" fill="none" stroke="#3ddc84" stroke-width="2.5" stroke-linecap="round" opacity="0.9"/>
|
|
7
|
+
<path d="M9 29a15 15 0 0 1 0 6" fill="none" stroke="#3ddc84" stroke-width="2.5" stroke-linecap="round" opacity="0.6"/>
|
|
8
|
+
<path d="M50 26a10 10 0 0 0 0 12" fill="none" stroke="#3ddc84" stroke-width="2.5" stroke-linecap="round" opacity="0.9"/>
|
|
9
|
+
<path d="M55 29a15 15 0 0 0 0 6" fill="none" stroke="#3ddc84" stroke-width="2.5" stroke-linecap="round" opacity="0.6"/>
|
|
10
|
+
</svg>
|
package/index.js
ADDED
|
@@ -0,0 +1,1079 @@
|
|
|
1
|
+
// @local/adb-control — control Android phones over wired or wireless ADB.
|
|
2
|
+
//
|
|
3
|
+
// Design principle: the UI hierarchy is the PRIMARY interface. The agent
|
|
4
|
+
// reads a compact, indexed element tree (adb_ui_map) and acts on elements by
|
|
5
|
+
// their index (adb_tap / adb_set_text / adb_scroll / adb_wait_for). Screenshots
|
|
6
|
+
// are SECONDARY: a confirmation/fallback channel (adb_screenshot) for game
|
|
7
|
+
// surfaces, lock screens, WebViews that hide their tree, and for verifying an
|
|
8
|
+
// action landed — never the first thing to reach for.
|
|
9
|
+
//
|
|
10
|
+
// Tools:
|
|
11
|
+
// adb_ui_map PRIMARY READ. Indexed element tree + screen + focused input.
|
|
12
|
+
// adb_tap tap/long-press by element ref (text/desc/id) or raw x,y fallback.
|
|
13
|
+
// adb_set_text focus a field by ref and type into it, replacing existing text.
|
|
14
|
+
// adb_scroll scroll by direction/fraction, optionally inside a ref's bounds.
|
|
15
|
+
// adb_wait_for poll the tree until an element appears/leaves (replaces sleeping).
|
|
16
|
+
// adb_devices list connected devices (USB serials and wireless ip:port)
|
|
17
|
+
// adb_connect wireless connect / pair / disconnect / switch a USB device to tcpip
|
|
18
|
+
// adb_input low-level tap/swipe/text/key on raw coordinates (no tree lookup)
|
|
19
|
+
// adb_shell run an arbitrary shell command on the device
|
|
20
|
+
// adb_screenshot SECONDARY. PNG on the host, for fallback/verification only.
|
|
21
|
+
// adb_ui_dump raw uiautomator XML when adb_ui_map is not enough
|
|
22
|
+
// adb_files push / pull files between host and device
|
|
23
|
+
// adb_install install an APK from a host path
|
|
24
|
+
// adb_configure persist default adb path, default device, screenshot dir
|
|
25
|
+
//
|
|
26
|
+
// Settings persist in ~/.dsh/adb-control.json; the default adb binary is
|
|
27
|
+
// settings.adbPath || $DSH_ADB || "adb" on PATH.
|
|
28
|
+
|
|
29
|
+
import { execFile } from 'node:child_process';
|
|
30
|
+
import { mkdir, writeFile } from 'node:fs/promises';
|
|
31
|
+
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
32
|
+
import os from 'node:os';
|
|
33
|
+
import path from 'node:path';
|
|
34
|
+
|
|
35
|
+
const PLUGIN = 'adb-control';
|
|
36
|
+
const SETTINGS_PATH = path.join(os.homedir(), '.dsh', 'adb-control.json');
|
|
37
|
+
const MAX_TEXT_OUTPUT = 60000;
|
|
38
|
+
const MAX_XML_OUTPUT = 120000;
|
|
39
|
+
const MAX_MAP_CHARS = 24000;
|
|
40
|
+
|
|
41
|
+
//#region settings
|
|
42
|
+
|
|
43
|
+
function loadSettings() {
|
|
44
|
+
try {
|
|
45
|
+
const raw = JSON.parse(readFileSync(SETTINGS_PATH, 'utf8'));
|
|
46
|
+
return raw && typeof raw === 'object' ? raw : {};
|
|
47
|
+
} catch {
|
|
48
|
+
return {};
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function saveSettings(patch) {
|
|
53
|
+
const next = { ...loadSettings(), ...patch };
|
|
54
|
+
for (const key of Object.keys(next)) if (next[key] === undefined) delete next[key];
|
|
55
|
+
mkdirSync(path.dirname(SETTINGS_PATH), { recursive: true });
|
|
56
|
+
writeFileSync(SETTINGS_PATH, JSON.stringify(next, null, 2) + '\n', 'utf8');
|
|
57
|
+
return next;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function resolveAdb() {
|
|
61
|
+
return loadSettings().adbPath || process.env.DSH_ADB || 'adb';
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function screenshotDir() {
|
|
65
|
+
return loadSettings().screenshotDir || path.join(os.homedir(), 'dsh-adb-screenshots');
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
//#endregion
|
|
69
|
+
|
|
70
|
+
//#region adb plumbing
|
|
71
|
+
|
|
72
|
+
function runAdb(args, { timeoutMs = 30000, signal, maxBuffer = 8 * 1024 * 1024, encoding = 'utf8' } = {}) {
|
|
73
|
+
return new Promise((resolve, reject) => {
|
|
74
|
+
execFile(resolveAdb(), args, { timeout: timeoutMs, signal, maxBuffer, encoding, windowsHide: true }, (error, stdout, stderr) => {
|
|
75
|
+
if (error) {
|
|
76
|
+
if (signal?.aborted) return reject(new Error('cancelled by the caller'));
|
|
77
|
+
if (error.code === 'ENOENT') return reject(new Error('adb executable not found ("' + resolveAdb() + '"). Install adb (e.g. sudo apt install adb) or set the path with the adb_configure tool.'));
|
|
78
|
+
const detail = [stderr, error.message].filter(Boolean).join('\n').trim();
|
|
79
|
+
return reject(new Error(detail || 'adb exited with an error'));
|
|
80
|
+
}
|
|
81
|
+
resolve({ stdout, stderr });
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Parse `adb devices -l` into structured rows; the serial contains ":" for wireless targets. */
|
|
87
|
+
function parseDevices(text) {
|
|
88
|
+
return text.split('\n')
|
|
89
|
+
.map((line) => line.trim())
|
|
90
|
+
.filter((line) => line && !line.startsWith('List of devices') && !line.startsWith('*'))
|
|
91
|
+
.map((line) => {
|
|
92
|
+
const fields = Object.fromEntries(line.split(/\s+/).slice(2).map((f) => {
|
|
93
|
+
const i = f.indexOf(':');
|
|
94
|
+
return i > 0 ? [f.slice(0, i), f.slice(i + 1)] : [f, true];
|
|
95
|
+
}));
|
|
96
|
+
const [serial, state] = line.split(/\s+/);
|
|
97
|
+
return {
|
|
98
|
+
serial,
|
|
99
|
+
state,
|
|
100
|
+
connection: serial.includes(':') ? 'wireless' : 'usb',
|
|
101
|
+
...(fields.model ? { model: fields.model } : {}),
|
|
102
|
+
...(fields.device ? { device: fields.device } : {}),
|
|
103
|
+
...(fields.product ? { product: fields.product } : {}),
|
|
104
|
+
...(fields.transport_id ? { transportId: fields.transport_id } : {})
|
|
105
|
+
};
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Resolve the `-s` prefix for a call. Explicit argument wins, then the saved
|
|
111
|
+
* default device, then a single connected device; ambiguity fails loud with
|
|
112
|
+
* the candidate list so the caller can pick one.
|
|
113
|
+
*/
|
|
114
|
+
async function deviceArgs(device) {
|
|
115
|
+
const target = device || loadSettings().defaultDevice;
|
|
116
|
+
if (target) return ['-s', target];
|
|
117
|
+
const online = parseDevices((await runAdb(['devices', '-l'])).stdout).filter((d) => d.state === 'device');
|
|
118
|
+
if (online.length === 1) return ['-s', online[0].serial];
|
|
119
|
+
if (online.length === 0) throw new Error('no Android device is currently online. Connect one over USB (with USB debugging enabled) or use adb_connect for wireless.');
|
|
120
|
+
throw new Error('multiple devices online (' + online.map((d) => d.serial).join(', ') + '). Pass "device" with one serial, or set a default with adb_configure.');
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** KEYCODE names accepted by adb_input; bare numbers pass through as raw codes. */
|
|
124
|
+
const KEYCODES = {
|
|
125
|
+
home: 3, back: 4, menu: 82, power: 26, wake: 224, sleep: 223,
|
|
126
|
+
volume_up: 24, volume_down: 25, mute: 164, recents: 187, app_switch: 187,
|
|
127
|
+
enter: 66, del: 67, tab: 61, space: 62, search: 84, notification: 83,
|
|
128
|
+
dpad_up: 19, dpad_down: 20, dpad_left: 21, dpad_right: 22, dpad_center: 23,
|
|
129
|
+
media_play: 126, media_pause: 127, media_next: 87, media_prev: 88,
|
|
130
|
+
volume_mute: 164, brightness_up: 221, brightness_down: 220, camera: 27
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
function requireNumber(value, name) {
|
|
134
|
+
const n = Number(value);
|
|
135
|
+
if (!Number.isFinite(n)) throw new Error('this action requires a numeric "' + name + '"');
|
|
136
|
+
return n;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function clip(text, limit) {
|
|
140
|
+
return text.length > limit ? text.slice(0, limit) + '\n… [truncated ' + (text.length - limit) + ' characters]' : text;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function escapeRe(s) {
|
|
144
|
+
return String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
//#endregion
|
|
148
|
+
|
|
149
|
+
//#region ui tree model
|
|
150
|
+
//
|
|
151
|
+
// The tree is the primary interface, so this region carries the real weight:
|
|
152
|
+
// fetch the dump, decode XML entities, flatten it into an indexed list of
|
|
153
|
+
// meaningful (actionable or labelled) nodes, render it as a compact outline,
|
|
154
|
+
// and resolve stable refs before an action runs.
|
|
155
|
+
|
|
156
|
+
const XML_ENTITIES = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ', '#39': "'", '#34': '"' };
|
|
157
|
+
|
|
158
|
+
function decodeEntities(text) {
|
|
159
|
+
return text.replace(/&(#x?[0-9a-fA-F]+|[a-zA-Z]+);/g, (whole, body) => {
|
|
160
|
+
if (XML_ENTITIES[body] !== undefined) return XML_ENTITIES[body];
|
|
161
|
+
if (body[0] === '#') {
|
|
162
|
+
const code = body[1] === 'x' || body[1] === 'X' ? parseInt(body.slice(2), 16) : parseInt(body.slice(1), 10);
|
|
163
|
+
if (Number.isFinite(code)) {
|
|
164
|
+
try { return String.fromCodePoint(code); } catch { return whole; }
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return whole;
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Extract the raw uiautomator XML for the current screen. */
|
|
172
|
+
async function dumpUiXml(base, signal) {
|
|
173
|
+
await runAdb([...base, 'shell', 'uiautomator', 'dump', '/sdcard/window_dump.xml'], { signal, timeoutMs: 30000 });
|
|
174
|
+
const xml = (await runAdb([...base, 'shell', 'cat', '/sdcard/window_dump.xml'], { signal, timeoutMs: 20000 })).stdout;
|
|
175
|
+
if (!xml.trim()) throw new Error('uiautomator produced an empty dump (the screen may not expose a view hierarchy, e.g. a game or the lock screen). Fall back to adb_screenshot.');
|
|
176
|
+
return xml;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function parseBounds(raw) {
|
|
180
|
+
const m = /\[(-?\d+),(-?\d+)\]\[(-?\d+),(-?\d+)\]/.exec(raw || '');
|
|
181
|
+
if (!m) return undefined;
|
|
182
|
+
const [x1, y1, x2, y2] = [Number(m[1]), Number(m[2]), Number(m[3]), Number(m[4])];
|
|
183
|
+
return { x1, y1, x2, y2, cx: Math.round((x1 + x2) / 2), cy: Math.round((y1 + y2) / 2), w: x2 - x1, h: y2 - y1 };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function readAttr(tag, name) {
|
|
187
|
+
const m = new RegExp('\\b' + name + '="([^"]*)"').exec(tag);
|
|
188
|
+
return m ? decodeEntities(m[1]) : '';
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Actionable classes: containers worth naming even when their own text is empty. */
|
|
192
|
+
const SCROLLABLE_CLASSES = /ScrollView|RecyclerView|ListView|GridView|ViewPager|HorizontalScrollView|NestedScrollView|WebView/i;
|
|
193
|
+
const INPUT_CLASSES = /EditText|AutoCompleteTextView|SearchView/i;
|
|
194
|
+
const LABEL_CLASSES = /TextView|Button|CheckBox|RadioButton|Switch|ToggleButton|ImageView|ImageButton|Tab|Chip|MenuItem/i;
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Flatten uiautomator XML into an indexed list of useful nodes.
|
|
198
|
+
*
|
|
199
|
+
* Everything visible gets a path number, but only nodes that can be acted on
|
|
200
|
+
* or that carry a label get their own line: an unlabelled, unclickable
|
|
201
|
+
* FrameLayout is noise for an agent picking a target. `ref` indexes follow
|
|
202
|
+
* document order, so ref 3 stays ref 3 as long as the screen does not change.
|
|
203
|
+
*/
|
|
204
|
+
function buildTree(xml) {
|
|
205
|
+
const nodes = [];
|
|
206
|
+
const stack = [];
|
|
207
|
+
const tagRe = /<(\/?)([A-Za-z][\w.:-]*)((?:"[^"]*"|[^>"])*?)(\/?)>/g;
|
|
208
|
+
let m;
|
|
209
|
+
while ((m = tagRe.exec(xml)) !== null) {
|
|
210
|
+
const [, closing, tagName, attrText, selfClosing] = m;
|
|
211
|
+
if (closing) { stack.pop(); continue; }
|
|
212
|
+
const tag = tagName;
|
|
213
|
+
const cls = readAttr(attrText, 'class');
|
|
214
|
+
const attrs = {
|
|
215
|
+
text: readAttr(attrText, 'text'),
|
|
216
|
+
desc: readAttr(attrText, 'content-desc'),
|
|
217
|
+
id: readAttr(attrText, 'resource-id'),
|
|
218
|
+
cls,
|
|
219
|
+
pkg: readAttr(attrText, 'package'),
|
|
220
|
+
clickable: readAttr(attrText, 'clickable') === 'true',
|
|
221
|
+
longClickable: readAttr(attrText, 'long-clickable') === 'true',
|
|
222
|
+
scrollable: readAttr(attrText, 'scrollable') === 'true',
|
|
223
|
+
checkable: readAttr(attrText, 'checkable') === 'true',
|
|
224
|
+
checked: readAttr(attrText, 'checked') === 'true',
|
|
225
|
+
selected: readAttr(attrText, 'selected') === 'true',
|
|
226
|
+
enabled: readAttr(attrText, 'enabled') !== 'false',
|
|
227
|
+
focused: readAttr(attrText, 'focused') === 'true',
|
|
228
|
+
editable: INPUT_CLASSES.test(cls) || INPUT_CLASSES.test(tag),
|
|
229
|
+
password: readAttr(attrText, 'password') === 'true',
|
|
230
|
+
bounds: parseBounds(readAttr(attrText, 'bounds'))
|
|
231
|
+
};
|
|
232
|
+
const node = {
|
|
233
|
+
ref: nodes.length,
|
|
234
|
+
depth: stack.length,
|
|
235
|
+
parent: stack.length ? stack[stack.length - 1] : -1,
|
|
236
|
+
shortClass: (attrs.cls.split('.').pop() || tag),
|
|
237
|
+
...attrs,
|
|
238
|
+
children: 0
|
|
239
|
+
};
|
|
240
|
+
if (node.parent >= 0) nodes[node.parent].children += 1;
|
|
241
|
+
nodes.push(node);
|
|
242
|
+
if (!selfClosing) stack.push(node.ref);
|
|
243
|
+
}
|
|
244
|
+
return nodes;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function isMeaningful(node) {
|
|
248
|
+
if (!node.bounds || node.bounds.w <= 0 || node.bounds.h <= 0) return false;
|
|
249
|
+
if (node.text || node.desc || node.id) return true;
|
|
250
|
+
if (node.clickable || node.longClickable || node.scrollable || node.editable || node.checkable) return true;
|
|
251
|
+
if (node.focused || node.selected || node.checked) return true;
|
|
252
|
+
return LABEL_CLASSES.test(node.shortClass);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** Indent a node by its depth relative to the nearest meaningful ancestor. */
|
|
256
|
+
function depthByMeaning(nodes) {
|
|
257
|
+
const depth = new Array(nodes.length).fill(0);
|
|
258
|
+
for (const node of nodes) {
|
|
259
|
+
let parent = node.parent;
|
|
260
|
+
while (parent >= 0 && !isMeaningful(nodes[parent])) parent = nodes[parent].parent;
|
|
261
|
+
depth[node.ref] = parent >= 0 ? depth[parent] + 1 : 0;
|
|
262
|
+
}
|
|
263
|
+
return depth;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function describeNode(node) {
|
|
267
|
+
const bits = [];
|
|
268
|
+
bits.push(node.shortClass);
|
|
269
|
+
if (node.id) bits.push('#' + node.id.replace(/^.*\//, ''));
|
|
270
|
+
if (node.text) bits.push(JSON.stringify(node.text));
|
|
271
|
+
if (node.desc && node.desc !== node.text) bits.push('desc=' + JSON.stringify(node.desc));
|
|
272
|
+
const flags = [];
|
|
273
|
+
if (node.clickable) flags.push(node.longClickable ? 'tap/hold' : 'tap');
|
|
274
|
+
else if (node.longClickable) flags.push('hold');
|
|
275
|
+
if (node.editable) flags.push('input');
|
|
276
|
+
if (node.scrollable) flags.push('scroll');
|
|
277
|
+
if (node.checkable) flags.push(node.checked ? 'checked' : 'unchecked');
|
|
278
|
+
if (node.selected) flags.push('selected');
|
|
279
|
+
if (node.focused) flags.push('focused');
|
|
280
|
+
if (!node.enabled) flags.push('disabled');
|
|
281
|
+
if (node.password) flags.push('password');
|
|
282
|
+
if (flags.length) bits.push('[' + flags.join(',') + ']');
|
|
283
|
+
return bits.join(' ');
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/** Render the meaningful nodes as an indented outline with refs. */
|
|
287
|
+
function renderTree(nodes) {
|
|
288
|
+
const depths = depthByMeaning(nodes);
|
|
289
|
+
const lines = [];
|
|
290
|
+
for (const node of nodes) {
|
|
291
|
+
if (!isMeaningful(node)) continue;
|
|
292
|
+
const b = node.bounds;
|
|
293
|
+
lines.push('[' + node.ref + '] ' + ' '.repeat(Math.min(depths[node.ref], 12)) + describeNode(node) +
|
|
294
|
+
' @(' + b.cx + ',' + b.cy + ') ' + b.w + 'x' + b.h);
|
|
295
|
+
}
|
|
296
|
+
return lines.join('\n');
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** Serialize the meaningful nodes for the structured tool result. */
|
|
300
|
+
function serializeTree(nodes) {
|
|
301
|
+
const depths = depthByMeaning(nodes);
|
|
302
|
+
const out = [];
|
|
303
|
+
for (const node of nodes) {
|
|
304
|
+
if (!isMeaningful(node)) continue;
|
|
305
|
+
const b = node.bounds;
|
|
306
|
+
out.push({
|
|
307
|
+
ref: node.ref,
|
|
308
|
+
depth: depths[node.ref],
|
|
309
|
+
class: node.shortClass,
|
|
310
|
+
...(node.id ? { id: node.id } : {}),
|
|
311
|
+
...(node.text ? { text: node.text } : {}),
|
|
312
|
+
...(node.desc ? { desc: node.desc } : {}),
|
|
313
|
+
bounds: [b.x1, b.y1, b.x2, b.y2],
|
|
314
|
+
center: [b.cx, b.cy],
|
|
315
|
+
...(node.clickable ? { clickable: true } : {}),
|
|
316
|
+
...(node.longClickable ? { longClickable: true } : {}),
|
|
317
|
+
...(node.editable ? { editable: true } : {}),
|
|
318
|
+
...(node.scrollable ? { scrollable: true } : {}),
|
|
319
|
+
...(node.checkable ? { checkable: true, checked: node.checked } : {}),
|
|
320
|
+
...(node.selected ? { selected: true } : {}),
|
|
321
|
+
...(node.focused ? { focused: true } : {}),
|
|
322
|
+
...(node.enabled ? {} : { enabled: false }),
|
|
323
|
+
...(node.password ? { password: true } : {})
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
return out;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* The tree plus enough device context that an agent can pick a target and
|
|
331
|
+
* sanity-check orientation without a screenshot.
|
|
332
|
+
*/
|
|
333
|
+
async function uiMap(args, exec) {
|
|
334
|
+
const base = await deviceArgs(args.device);
|
|
335
|
+
const xml = await dumpUiXml(base, exec.signal);
|
|
336
|
+
|
|
337
|
+
let wmSize = '';
|
|
338
|
+
let rotation = '';
|
|
339
|
+
if (args.include_system_info !== false) {
|
|
340
|
+
try {
|
|
341
|
+
wmSize = (await runAdb([...base, 'shell', 'wm', 'size'], { signal: exec.signal, timeoutMs: 10000 })).stdout.trim();
|
|
342
|
+
} catch { /* informational only */ }
|
|
343
|
+
try {
|
|
344
|
+
rotation = (await runAdb([...base, 'shell', 'dumpsys', 'input', '|', 'grep', 'SurfaceOrientation'], { timeoutMs: 10000, signal: exec.signal })).stdout.trim();
|
|
345
|
+
} catch { /* informational only */ }
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
const nodes = buildTree(xml);
|
|
349
|
+
const meaningful = nodes.filter(isMeaningful);
|
|
350
|
+
const sizeMatch = /(\d+)x(\d+)/.exec(wmSize);
|
|
351
|
+
const screen = sizeMatch ? { width: Number(sizeMatch[1]), height: Number(sizeMatch[2]) } : undefined;
|
|
352
|
+
|
|
353
|
+
// The focused editable node is what adb_set_text will target when no ref is given.
|
|
354
|
+
const focusedEditable = meaningful.find((n) => n.editable && n.focused) || meaningful.find((n) => n.focused && n.editable);
|
|
355
|
+
|
|
356
|
+
const packages = [...new Set(nodes.map((n) => n.pkg).filter(Boolean))];
|
|
357
|
+
let outline = renderTree(nodes);
|
|
358
|
+
if (!outline.trim()) {
|
|
359
|
+
outline = '(no labelled or actionable nodes found — this screen hides its view hierarchy, e.g. a game or a secure surface. Fall back to adb_screenshot.)';
|
|
360
|
+
}
|
|
361
|
+
outline = clip(outline, MAX_MAP_CHARS);
|
|
362
|
+
|
|
363
|
+
const header = [
|
|
364
|
+
'screen ' + (screen ? screen.width + 'x' + screen.height : 'unknown') +
|
|
365
|
+
(packages.length ? ' package ' + packages[0] : '') +
|
|
366
|
+
(rotation ? ' ' + rotation.replace(/\s+/g, ' ') : ''),
|
|
367
|
+
meaningful.length + ' elements with refs (act on them with adb_tap / adb_set_text / adb_scroll / adb_wait_for)',
|
|
368
|
+
focusedEditable ? 'focused input: [' + focusedEditable.ref + '] ' + describeNode(focusedEditable) : 'no focused input'
|
|
369
|
+
].join('\n');
|
|
370
|
+
|
|
371
|
+
return {
|
|
372
|
+
device: base[1] ?? 'auto',
|
|
373
|
+
...(screen ? { screen } : {}),
|
|
374
|
+
...(packages.length ? { packages } : {}),
|
|
375
|
+
elementCount: meaningful.length,
|
|
376
|
+
...(focusedEditable ? { focusedInput: { ref: focusedEditable.ref, ...(focusedEditable.text ? { text: focusedEditable.text } : {}) } } : {}),
|
|
377
|
+
elements: serializeTree(nodes),
|
|
378
|
+
text: header + '\n\n' + outline
|
|
379
|
+
};
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* Match nodes against a selector. Every selector string in this plugin can be
|
|
384
|
+
* a bare ref number ("12"), or `text:`, `desc:`, `id:`, `class:` with either
|
|
385
|
+
* exact or `~` fuzzy matching.
|
|
386
|
+
*/
|
|
387
|
+
function parseSelector(selector) {
|
|
388
|
+
const raw = String(selector).trim();
|
|
389
|
+
if (/^\d+$/.test(raw)) return { kind: 'ref', value: Number(raw) };
|
|
390
|
+
const m = /^(text|desc|id|class|contains|regex)\s*(:|~=|=)\s*([\S\s]*)$/.exec(raw);
|
|
391
|
+
if (m) {
|
|
392
|
+
const kind = m[1] === 'contains' ? 'text' : m[1];
|
|
393
|
+
const fuzzy = m[2] === '~=';
|
|
394
|
+
return { kind, value: m[3], fuzzy, raw };
|
|
395
|
+
}
|
|
396
|
+
if (raw.startsWith('~')) return { kind: 'text', value: raw.slice(1), fuzzy: true, raw };
|
|
397
|
+
return { kind: 'text', value: raw, fuzzy: true, raw };
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
function selectorMatches(node, sel) {
|
|
401
|
+
if (sel.fuzzy) {
|
|
402
|
+
const needle = sel.value.toLowerCase();
|
|
403
|
+
if (sel.kind === 'text') return (node.text + ' ' + node.desc).toLowerCase().includes(needle);
|
|
404
|
+
if (sel.kind === 'desc') return node.desc.toLowerCase().includes(needle);
|
|
405
|
+
if (sel.kind === 'id') return node.id.toLowerCase().includes(needle);
|
|
406
|
+
if (sel.kind === 'class') return node.shortClass.toLowerCase().includes(needle);
|
|
407
|
+
}
|
|
408
|
+
try {
|
|
409
|
+
if (sel.kind === 'regex') return new RegExp(sel.value, 'i').test(node.text + ' ' + node.desc + ' ' + node.id);
|
|
410
|
+
} catch {
|
|
411
|
+
throw new Error('invalid regex selector: ' + sel.value);
|
|
412
|
+
}
|
|
413
|
+
if (sel.kind === 'text') return node.text === sel.value || node.desc === sel.value;
|
|
414
|
+
if (sel.kind === 'desc') return node.desc === sel.value;
|
|
415
|
+
if (sel.kind === 'id') return node.id === sel.value || node.id.replace(/^.*\//, '') === sel.value;
|
|
416
|
+
if (sel.kind === 'class') return node.shortClass.toLowerCase() === sel.value.toLowerCase();
|
|
417
|
+
return false;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* Resolve a selector against a live dump.
|
|
422
|
+
*
|
|
423
|
+
* Ambiguity is an error, not a coin flip: acting on the wrong element is worse
|
|
424
|
+
* than asking again, so the message lists the candidates with their refs.
|
|
425
|
+
*/
|
|
426
|
+
async function resolveTarget(base, selector, { need = 'any', signal } = {}) {
|
|
427
|
+
const nodes = buildTree(await dumpUiXml(base, signal));
|
|
428
|
+
const meaningful = nodes.filter(isMeaningful);
|
|
429
|
+
const sel = parseSelector(selector);
|
|
430
|
+
|
|
431
|
+
let candidates;
|
|
432
|
+
if (sel.kind === 'ref') {
|
|
433
|
+
const node = nodes[sel.value];
|
|
434
|
+
if (!node) throw new Error('ref ' + sel.value + ' is out of range for this screen (' + meaningful.length + ' elements). Call adb_ui_map to refresh refs.');
|
|
435
|
+
candidates = [node];
|
|
436
|
+
} else {
|
|
437
|
+
candidates = meaningful.filter((n) => selectorMatches(n, sel));
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
if (!candidates.length) {
|
|
441
|
+
throw new Error('no element matched "' + selector + '" on the current screen. Call adb_ui_map to see the available refs.');
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// A clickable node is usually the real target, even when a child label matched first.
|
|
445
|
+
const preferred = need === 'clickable'
|
|
446
|
+
? candidates.filter((n) => n.clickable || n.longClickable)
|
|
447
|
+
: need === 'editable'
|
|
448
|
+
? candidates.filter((n) => n.editable || n.clickable)
|
|
449
|
+
: need === 'scrollable'
|
|
450
|
+
? candidates.filter((n) => n.scrollable)
|
|
451
|
+
: candidates;
|
|
452
|
+
const pool = preferred.length ? preferred : candidates;
|
|
453
|
+
|
|
454
|
+
if (pool.length > 1) {
|
|
455
|
+
const detail = pool.slice(0, 8).map((n) => ' [' + n.ref + '] ' + describeNode(n)).join('\n');
|
|
456
|
+
throw new Error(pool.length + ' elements matched "' + selector + '". Pick one by ref:\n' + detail +
|
|
457
|
+
(pool.length > 8 ? '\n … and ' + (pool.length - 8) + ' more' : ''));
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
const node = pool[0];
|
|
461
|
+
if (!node.bounds) throw new Error('the matched element has no bounds and cannot be clicked');
|
|
462
|
+
if (need === 'editable' && !node.editable && !node.clickable) {
|
|
463
|
+
throw new Error('element [' + node.ref + '] is neither an input field nor clickable: ' + describeNode(node));
|
|
464
|
+
}
|
|
465
|
+
return { nodes, node, sel };
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
function firstScrollable(nodes, node) {
|
|
469
|
+
if (node?.scrollable) return node;
|
|
470
|
+
if (node) {
|
|
471
|
+
for (let p = node.parent; p >= 0; p = nodes[p].parent) if (nodes[p].scrollable) return nodes[p];
|
|
472
|
+
}
|
|
473
|
+
return nodes.filter((n) => n.scrollable && n.bounds && n.bounds.h > 0).sort((a, b) => (b.bounds.w * b.bounds.h) - (a.bounds.w * a.bounds.h))[0];
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/** Read the current text of an editable node, for replace-style set_text. */
|
|
477
|
+
async function readFieldText(base, ref, signal) {
|
|
478
|
+
try {
|
|
479
|
+
const xml = await dumpUiXml(base, signal);
|
|
480
|
+
const node = buildTree(xml)[ref];
|
|
481
|
+
return node?.text || '';
|
|
482
|
+
} catch {
|
|
483
|
+
return '';
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
//#endregion
|
|
488
|
+
|
|
489
|
+
//#region tool factory
|
|
490
|
+
|
|
491
|
+
/** Build a ToolDefinition-shaped plain object (no external schema dependency). */
|
|
492
|
+
function defineTool({ name, description, parameters, execute, concurrencySafe = false, presentCall }) {
|
|
493
|
+
return {
|
|
494
|
+
name,
|
|
495
|
+
description,
|
|
496
|
+
parameters,
|
|
497
|
+
output: {
|
|
498
|
+
schema: { type: 'object', description: 'JSON result; the rendered text carries the same content.' },
|
|
499
|
+
render(args, value) {
|
|
500
|
+
return [{ type: 'text', text: typeof value?.text === 'string' && value.text ? value.text : JSON.stringify(value, null, 2) }];
|
|
501
|
+
}
|
|
502
|
+
},
|
|
503
|
+
isConcurrencySafe: () => concurrencySafe,
|
|
504
|
+
...(presentCall ? { presentCall } : {}),
|
|
505
|
+
async execute(args, exec) {
|
|
506
|
+
const value = await execute(args ?? {}, exec);
|
|
507
|
+
return { ...value, text: value.text ?? JSON.stringify(value) };
|
|
508
|
+
}
|
|
509
|
+
};
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
const deviceParam = { type: 'string', description: 'Target device serial or wireless address (ip:port). Omit to use the saved default device, or the only connected device.' };
|
|
513
|
+
const selectorParam = {
|
|
514
|
+
type: 'string',
|
|
515
|
+
description: 'Element selector: a ref number from adb_ui_map ("12"), or text:/desc:/id:/class: with the exact ("text:Sign in") or fuzzy ("text~=sign") form. Bare strings match text or content-desc, fuzzy by default.'
|
|
516
|
+
};
|
|
517
|
+
|
|
518
|
+
//#endregion
|
|
519
|
+
|
|
520
|
+
//#region primary tools — UI tree first
|
|
521
|
+
|
|
522
|
+
const adbUiMap = defineTool({
|
|
523
|
+
name: 'adb_ui_map',
|
|
524
|
+
description: 'PRIMARY way to see the phone. Dumps the current UI hierarchy as a compact indexed tree: one line per labelled or interactive element with its ref, class, resource-id, text, content-desc, flags (tap/input/scroll/checked/disabled) and center coordinates, plus screen size and the focused input field. Start every interaction here, then act by ref with adb_tap, adb_set_text, adb_scroll or adb_wait_for. Call it again after each action because refs are only valid for the screen they were read on. Use adb_screenshot only when a screen exposes no usable tree (games, video, secure surfaces).',
|
|
525
|
+
parameters: {
|
|
526
|
+
type: 'object',
|
|
527
|
+
properties: {
|
|
528
|
+
device: deviceParam,
|
|
529
|
+
include_system_info: { type: 'boolean', description: 'Also read screen size and orientation (default true; small extra adb round trip).' }
|
|
530
|
+
}
|
|
531
|
+
},
|
|
532
|
+
concurrencySafe: true,
|
|
533
|
+
presentCall() {
|
|
534
|
+
return { card: 'terminal', title: 'adb ui map' };
|
|
535
|
+
},
|
|
536
|
+
execute: uiMap
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
const adbTap = defineTool({
|
|
540
|
+
name: 'adb_tap',
|
|
541
|
+
description: 'THE way to press things. Resolves an element selector against the live UI tree and taps its center, so the agent never has to guess pixel coordinates. Give "selector" (a ref from adb_ui_map such as "12", or "text:Sign in" / "desc:Search" / "id:com.app:id/ok"), or fall back to raw "x","y" only when the tree has nothing usable. Ambiguous selectors fail with the candidate refs instead of tapping something wrong. Set action "long_press" to hold.',
|
|
542
|
+
parameters: {
|
|
543
|
+
type: 'object',
|
|
544
|
+
properties: {
|
|
545
|
+
action: { type: 'string', enum: ['tap', 'long_press'], description: 'Tap (default) or long press.' },
|
|
546
|
+
selector: selectorParam,
|
|
547
|
+
x: { type: 'integer', description: 'Raw X fallback, only when no selector matches (device pixels, origin top-left).' },
|
|
548
|
+
y: { type: 'integer', description: 'Raw Y fallback.' },
|
|
549
|
+
duration_ms: { type: 'integer', description: 'Hold duration for long_press (default 800).' },
|
|
550
|
+
device: deviceParam
|
|
551
|
+
}
|
|
552
|
+
},
|
|
553
|
+
presentCall(args) {
|
|
554
|
+
return { card: 'terminal', title: 'adb ' + (args.action === 'long_press' ? 'long press' : 'tap') + ' ' + (args.selector ?? args.x + ',' + args.y) };
|
|
555
|
+
},
|
|
556
|
+
async execute(args, exec) {
|
|
557
|
+
const base = await deviceArgs(args.device);
|
|
558
|
+
const long = args.action === 'long_press';
|
|
559
|
+
let x;
|
|
560
|
+
let y;
|
|
561
|
+
let target;
|
|
562
|
+
|
|
563
|
+
if (args.selector !== undefined && args.selector !== null && String(args.selector).length) {
|
|
564
|
+
const { node } = await resolveTarget(base, args.selector, { need: 'clickable', signal: exec.signal });
|
|
565
|
+
({ cx: x, cy: y } = node.bounds);
|
|
566
|
+
target = { ref: node.ref, class: node.shortClass, ...(node.text ? { text: node.text } : {}), ...(node.desc ? { desc: node.desc } : {}), center: [x, y] };
|
|
567
|
+
} else if (args.x !== undefined && args.y !== undefined) {
|
|
568
|
+
x = Math.round(requireNumber(args.x, 'x'));
|
|
569
|
+
y = Math.round(requireNumber(args.y, 'y'));
|
|
570
|
+
target = { center: [x, y], source: 'raw coordinates' };
|
|
571
|
+
} else {
|
|
572
|
+
throw new Error('adb_tap needs "selector" (preferred) or both "x" and "y". Call adb_ui_map to get refs.');
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
if (long) {
|
|
576
|
+
const ms = Number.isFinite(Number(args.duration_ms)) ? Math.max(1, Math.round(Number(args.duration_ms))) : 800;
|
|
577
|
+
await runAdb([...base, 'shell', 'input', 'swipe', String(x), String(y), String(x), String(y), String(ms)], { signal: exec.signal, timeoutMs: 30000 });
|
|
578
|
+
return { action: 'long_press', device: base[1] ?? 'auto', ...target, durationMs: ms, text: 'long pressed ' + JSON.stringify(target) + ' for ' + ms + 'ms' };
|
|
579
|
+
}
|
|
580
|
+
await runAdb([...base, 'shell', 'input', 'tap', String(x), String(y)], { signal: exec.signal, timeoutMs: 30000 });
|
|
581
|
+
return { action: 'tap', device: base[1] ?? 'auto', ...target, text: 'tapped ' + JSON.stringify(target) + '. Read the tree again with adb_ui_map to confirm the screen changed.' };
|
|
582
|
+
}
|
|
583
|
+
});
|
|
584
|
+
|
|
585
|
+
const adbSetText = defineTool({
|
|
586
|
+
name: 'adb_set_text',
|
|
587
|
+
description: 'Type text into an input field located through the UI tree. Give "selector" for the field (ref or text:/id:/desc:); if omitted, the focused input from adb_ui_map is used, or you may pass raw "x","y" to tap a field first. Existing text is cleared by default (select-all + delete), so this sets rather than appends. Punctuation-heavy text is routed through base64 so special characters survive.',
|
|
588
|
+
parameters: {
|
|
589
|
+
type: 'object',
|
|
590
|
+
properties: {
|
|
591
|
+
text: { type: 'string', description: 'Text to enter.', required: true },
|
|
592
|
+
selector: selectorParam,
|
|
593
|
+
x: { type: 'integer', description: 'Raw fallback: tap this X to focus the field before typing.' },
|
|
594
|
+
y: { type: 'integer', description: 'Raw fallback: tap this Y to focus the field before typing.' },
|
|
595
|
+
clear: { type: 'boolean', description: 'Clear the field first (default true). Set false to append at the cursor.' },
|
|
596
|
+
submit: { type: 'boolean', description: 'Press Enter after typing (default false).' },
|
|
597
|
+
device: deviceParam
|
|
598
|
+
},
|
|
599
|
+
required: ['text']
|
|
600
|
+
},
|
|
601
|
+
presentCall(args) {
|
|
602
|
+
return { card: 'terminal', title: 'adb text', description: String(args.text).slice(0, 80) };
|
|
603
|
+
},
|
|
604
|
+
async execute(args, exec) {
|
|
605
|
+
const base = await deviceArgs(args.device);
|
|
606
|
+
const clear = args.clear !== false;
|
|
607
|
+
let focused = null;
|
|
608
|
+
|
|
609
|
+
if (args.selector !== undefined && args.selector !== null && String(args.selector).length) {
|
|
610
|
+
const { node } = await resolveTarget(base, args.selector, { need: 'editable', signal: exec.signal });
|
|
611
|
+
focused = { ref: node.ref, class: node.shortClass, center: [node.bounds.cx, node.bounds.cy] };
|
|
612
|
+
await runAdb([...base, 'shell', 'input', 'tap', String(node.bounds.cx), String(node.bounds.cy)], { signal: exec.signal, timeoutMs: 30000 });
|
|
613
|
+
} else if (args.x !== undefined && args.y !== undefined) {
|
|
614
|
+
const x = Math.round(requireNumber(args.x, 'x'));
|
|
615
|
+
const y = Math.round(requireNumber(args.y, 'y'));
|
|
616
|
+
focused = { center: [x, y], source: 'raw coordinates' };
|
|
617
|
+
await runAdb([...base, 'shell', 'input', 'tap', String(x), String(y)], { signal: exec.signal, timeoutMs: 30000 });
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
await new Promise((r) => setTimeout(r, 250));
|
|
621
|
+
|
|
622
|
+
const typed = String(args.text);
|
|
623
|
+
if (!typed) throw new Error('adb_set_text requires non-empty "text"');
|
|
624
|
+
|
|
625
|
+
// ASCII without shell metacharacters goes straight through, as most
|
|
626
|
+
// `input text` implementations only accept [A-Za-z0-9._%+- ] reliably.
|
|
627
|
+
if (/^[A-Za-z0-9._%+@\- ,!?]+$/.test(typed)) {
|
|
628
|
+
if (clear && focused?.ref !== undefined) {
|
|
629
|
+
const existing = await readFieldText(base, focused.ref, exec.signal);
|
|
630
|
+
if (existing) {
|
|
631
|
+
// No clean "clear" in `input`; select-all then delete is the portable route.
|
|
632
|
+
await runAdb([...base, 'shell', 'input', 'keyevent', 'KEYCODE_MOVE_END'], { signal: exec.signal, timeoutMs: 20000 });
|
|
633
|
+
for (let i = 0; i < Math.min(existing.length + 4, 200); i += 1) {
|
|
634
|
+
await runAdb([...base, 'shell', 'input', 'keyevent', 'KEYCODE_DEL'], { signal: exec.signal, timeoutMs: 20000 });
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
await runAdb([...base, 'shell', 'input', 'text', typed.replace(/ /g, '%s')], { signal: exec.signal, timeoutMs: 30000 });
|
|
639
|
+
} else {
|
|
640
|
+
// Non-ASCII or punctuation-heavy: inject via base64 so quoting and
|
|
641
|
+
// the shell's own escaping cannot corrupt the payload.
|
|
642
|
+
const b64 = Buffer.from(typed, 'utf8').toString('base64');
|
|
643
|
+
if (clear) await runAdb([...base, 'shell', 'input', 'keyevent', 'KEYCODE_MOVE_END'], { signal: exec.signal, timeoutMs: 20000 });
|
|
644
|
+
const cmd = clear
|
|
645
|
+
? 'echo ' + b64 + ' | base64 -d > /data/local/tmp/dsh_text.txt && input text "$(cat /data/local/tmp/dsh_text.txt)"; rm -f /data/local/tmp/dsh_text.txt'
|
|
646
|
+
: 'echo ' + b64 + ' | base64 -d >> /data/local/tmp/dsh_text.txt && input text "$(cat /data/local/tmp/dsh_text.txt)"; rm -f /data/local/tmp/dsh_text.txt';
|
|
647
|
+
await runAdb([...base, 'shell', cmd], { signal: exec.signal, timeoutMs: 30000 });
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
if (args.submit) await runAdb([...base, 'shell', 'input', 'keyevent', 'KEYCODE_ENTER'], { signal: exec.signal, timeoutMs: 20000 });
|
|
651
|
+
|
|
652
|
+
return {
|
|
653
|
+
device: base[1] ?? 'auto',
|
|
654
|
+
...(focused ? { field: focused } : {}),
|
|
655
|
+
cleared: clear,
|
|
656
|
+
characters: typed.length,
|
|
657
|
+
text: 'typed ' + typed.length + ' character(s) into ' + (focused ? JSON.stringify(focused) : 'the focused field') + (clear ? ' (field cleared first)' : '') + (args.submit ? ' and pressed Enter' : '') + '.'
|
|
658
|
+
};
|
|
659
|
+
}
|
|
660
|
+
});
|
|
661
|
+
|
|
662
|
+
const adbScroll = defineTool({
|
|
663
|
+
name: 'adb_scroll',
|
|
664
|
+
description: 'Scroll the screen or a scrollable element. Prefers the UI tree: give "selector" for the list to scroll (ref or id:/class~=RecyclerView), otherwise the largest scrollable node is used, otherwise a scroll runs at screen center. direction up/down move the content by one screen (down = look further down a list); "fraction" tunes the distance. After scrolling, call adb_ui_map again — refs change as new rows appear.',
|
|
665
|
+
parameters: {
|
|
666
|
+
type: 'object',
|
|
667
|
+
properties: {
|
|
668
|
+
direction: { type: 'string', enum: ['up', 'down', 'left', 'right'], description: 'Content movement direction.', required: true },
|
|
669
|
+
selector: selectorParam,
|
|
670
|
+
fraction: { type: 'number', description: 'Portion of the scrollable area to travel, 0-1 (default 0.8).' },
|
|
671
|
+
duration_ms: { type: 'integer', description: 'Gesture duration in ms (default 300).' },
|
|
672
|
+
device: deviceParam
|
|
673
|
+
},
|
|
674
|
+
required: ['direction']
|
|
675
|
+
},
|
|
676
|
+
presentCall(args) {
|
|
677
|
+
return { card: 'terminal', title: 'adb scroll ' + args.direction };
|
|
678
|
+
},
|
|
679
|
+
async execute(args, exec) {
|
|
680
|
+
const base = await deviceArgs(args.device);
|
|
681
|
+
const fraction = Number.isFinite(Number(args.fraction)) ? Math.min(1, Math.max(0.05, Number(args.fraction))) : 0.8;
|
|
682
|
+
const duration = Number.isFinite(Number(args.duration_ms)) ? Math.max(50, Math.round(Number(args.duration_ms))) : 300;
|
|
683
|
+
|
|
684
|
+
const nodes = buildTree(await dumpUiXml(base, exec.signal));
|
|
685
|
+
let region;
|
|
686
|
+
let matched;
|
|
687
|
+
if (args.selector !== undefined && args.selector !== null && String(args.selector).length) {
|
|
688
|
+
const resolved = await resolveTarget(base, args.selector, { need: 'scrollable', signal: exec.signal });
|
|
689
|
+
matched = resolved.node;
|
|
690
|
+
region = resolved.node.bounds;
|
|
691
|
+
}
|
|
692
|
+
if (!region) {
|
|
693
|
+
const scrollable = firstScrollable(nodes);
|
|
694
|
+
if (scrollable?.bounds) {
|
|
695
|
+
region = scrollable.bounds;
|
|
696
|
+
matched = scrollable;
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
if (!region) {
|
|
700
|
+
const size = await screenSize(base, exec.signal);
|
|
701
|
+
if (!size) throw new Error('could not determine screen size to scroll; pass "selector" for a specific element.');
|
|
702
|
+
region = { x1: Math.round(size.width * 0.1), y1: Math.round(size.height * 0.15), x2: Math.round(size.width * 0.9), y2: Math.round(size.height * 0.85), cx: Math.round(size.width / 2), cy: Math.round(size.height / 2), w: Math.round(size.width * 0.8), h: Math.round(size.height * 0.7) };
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
const dx = Math.round((region.w / 2) * fraction);
|
|
706
|
+
const dy = Math.round((region.h / 2) * fraction);
|
|
707
|
+
const from = { x: region.cx, y: region.cy };
|
|
708
|
+
let to;
|
|
709
|
+
if (args.direction === 'down') to = { x: from.x, y: from.y - dy };
|
|
710
|
+
else if (args.direction === 'up') to = { x: from.x, y: from.y + dy };
|
|
711
|
+
else if (args.direction === 'right') to = { x: from.x - dx, y: from.y };
|
|
712
|
+
else to = { x: from.x + dx, y: from.y };
|
|
713
|
+
|
|
714
|
+
await runAdb([...base, 'shell', 'input', 'swipe', String(from.x), String(from.y), String(to.x), String(to.y), String(duration)], { signal: exec.signal, timeoutMs: 30000 });
|
|
715
|
+
|
|
716
|
+
return {
|
|
717
|
+
device: base[1] ?? 'auto',
|
|
718
|
+
direction: args.direction,
|
|
719
|
+
...(matched ? { scrolledElement: { ref: matched.ref, class: matched.shortClass, ...(matched.id ? { id: matched.id } : {}) } } : { scrolledElement: null }),
|
|
720
|
+
from: [from.x, from.y],
|
|
721
|
+
to: [to.x, to.y],
|
|
722
|
+
text: 'scrolled ' + args.direction + ' ' + Math.round(fraction * 100) + '% within ' + (matched ? 'element [' + matched.ref + '] ' + matched.shortClass : 'the screen') + '. Call adb_ui_map again to see the new rows.'
|
|
723
|
+
};
|
|
724
|
+
}
|
|
725
|
+
});
|
|
726
|
+
|
|
727
|
+
const adbWaitFor = defineTool({
|
|
728
|
+
name: 'adb_wait_for',
|
|
729
|
+
description: 'Wait until the UI tree reaches the expected state, polling the hierarchy instead of sleeping blindly. state "appear" (default) waits for a matching element, "disappear" waits for it to be gone, "change" waits until the tree differs from the current screen. Use it after a tap or app launch, then read the tree with adb_ui_map. Returns the matched element when it appears.',
|
|
730
|
+
parameters: {
|
|
731
|
+
type: 'object',
|
|
732
|
+
properties: {
|
|
733
|
+
selector: selectorParam,
|
|
734
|
+
state: { type: 'string', enum: ['appear', 'disappear', 'change'], description: 'Condition to wait for (default appear).' },
|
|
735
|
+
timeout_ms: { type: 'integer', description: 'Give up after this long (default 10000, max 120000).' },
|
|
736
|
+
interval_ms: { type: 'integer', description: 'Poll interval (default 700).' },
|
|
737
|
+
device: deviceParam
|
|
738
|
+
},
|
|
739
|
+
required: ['selector']
|
|
740
|
+
},
|
|
741
|
+
presentCall(args) {
|
|
742
|
+
return { card: 'terminal', title: 'adb wait for ' + args.selector };
|
|
743
|
+
},
|
|
744
|
+
async execute(args, exec) {
|
|
745
|
+
const base = await deviceArgs(args.device);
|
|
746
|
+
const state = args.state || 'appear';
|
|
747
|
+
const timeoutMs = Math.min(120000, Number(args.timeout_ms) > 0 ? Number(args.timeout_ms) : 10000);
|
|
748
|
+
const intervalMs = Math.min(5000, Number(args.interval_ms) > 0 ? Number(args.interval_ms) : 700);
|
|
749
|
+
const sel = parseSelector(args.selector);
|
|
750
|
+
const deadline = Date.now() + timeoutMs;
|
|
751
|
+
|
|
752
|
+
const snapshotOf = (nodes) => nodes.filter(isMeaningful).map((n) => n.shortClass + '|' + n.text + '|' + n.desc + '|' + n.id).join('\n');
|
|
753
|
+
const firstNodes = buildTree(await dumpUiXml(base, exec.signal));
|
|
754
|
+
const baseline = snapshotOf(firstNodes);
|
|
755
|
+
const find = (nodes) => nodes.filter(isMeaningful).filter((n) => selectorMatches(n, sel));
|
|
756
|
+
|
|
757
|
+
if (state === 'appear') {
|
|
758
|
+
const already = find(firstNodes);
|
|
759
|
+
if (already.length) return { state, matched: true, elapsedMs: 0, element: describeNode(already[0]), text: 'already present: [' + already[0].ref + '] ' + describeNode(already[0]) };
|
|
760
|
+
} else if (state === 'disappear') {
|
|
761
|
+
if (!find(firstNodes).length) return { state, matched: true, elapsedMs: 0, text: 'already gone: nothing matches "' + args.selector + '"' };
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
let polls = 0;
|
|
765
|
+
while (Date.now() < deadline) {
|
|
766
|
+
await new Promise((r) => setTimeout(r, intervalMs));
|
|
767
|
+
if (exec.signal?.aborted) throw new Error('cancelled by the caller');
|
|
768
|
+
polls += 1;
|
|
769
|
+
let nodes;
|
|
770
|
+
try {
|
|
771
|
+
nodes = buildTree(await dumpUiXml(base, exec.signal));
|
|
772
|
+
} catch {
|
|
773
|
+
continue; // transient dump failure (animating/secure window) — keep polling
|
|
774
|
+
}
|
|
775
|
+
const elapsed = timeoutMs - Math.max(0, deadline - Date.now());
|
|
776
|
+
|
|
777
|
+
if (state === 'appear') {
|
|
778
|
+
const hits = find(nodes);
|
|
779
|
+
if (hits.length) {
|
|
780
|
+
return {
|
|
781
|
+
state, matched: true, elapsedMs: elapsed, polls,
|
|
782
|
+
element: serializeTree(nodes).find((n) => n.ref === hits[0].ref),
|
|
783
|
+
text: 'appeared after ' + elapsed + 'ms: [' + hits[0].ref + '] ' + describeNode(hits[0])
|
|
784
|
+
};
|
|
785
|
+
}
|
|
786
|
+
} else if (state === 'disappear') {
|
|
787
|
+
if (!find(nodes).length) return { state, matched: true, elapsedMs: elapsed, polls, text: 'disappeared after ' + elapsed + 'ms' };
|
|
788
|
+
} else if (snapshotOf(nodes) !== baseline) {
|
|
789
|
+
return { state: 'change', matched: true, elapsedMs: elapsed, polls, text: 'the UI tree changed after ' + elapsed + 'ms. Call adb_ui_map for the new refs.' };
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
return {
|
|
794
|
+
state, matched: false, polls, elapsedMs: timeoutMs,
|
|
795
|
+
text: 'timed out after ' + timeoutMs + 'ms waiting for "' + args.selector + '" to ' + state + '. Call adb_ui_map to inspect the actual screen (or adb_screenshot if the tree is unavailable).'
|
|
796
|
+
};
|
|
797
|
+
}
|
|
798
|
+
});
|
|
799
|
+
|
|
800
|
+
//#endregion
|
|
801
|
+
|
|
802
|
+
//#region secondary tools — screenshot and raw access
|
|
803
|
+
|
|
804
|
+
const adbScreenshot = defineTool({
|
|
805
|
+
name: 'adb_screenshot',
|
|
806
|
+
description: 'SECONDARY, fallback use only. Captures the screen to a PNG on this computer and returns its absolute path (view it with read_image). Prefer adb_ui_map: the tree gives exact elements and refs, while a screenshot costs an image read and still leaves you guessing coordinates. Reach for this when the hierarchy is empty or useless — games, video, canvas/WebView surfaces, the lock screen, custom-drawn UI — or to visually confirm a result that the tree cannot express.',
|
|
807
|
+
parameters: {
|
|
808
|
+
type: 'object',
|
|
809
|
+
properties: {
|
|
810
|
+
device: deviceParam,
|
|
811
|
+
out_path: { type: 'string', description: 'Absolute PNG path to write (default ~/dsh-adb-screenshots/adb-<timestamp>.png).' }
|
|
812
|
+
}
|
|
813
|
+
},
|
|
814
|
+
concurrencySafe: true,
|
|
815
|
+
async execute(args, exec) {
|
|
816
|
+
const base = await deviceArgs(args.device);
|
|
817
|
+
const png = (await runAdb([...base, 'exec-out', 'screencap', '-p'], { signal: exec.signal, timeoutMs: 30000, encoding: 'buffer', maxBuffer: 32 * 1024 * 1024 })).stdout;
|
|
818
|
+
if (!png?.length) throw new Error('screencap returned no image data');
|
|
819
|
+
const file = args.out_path || path.join(screenshotDir(), 'adb-' + Date.now() + '.png');
|
|
820
|
+
await mkdir(path.dirname(file), { recursive: true });
|
|
821
|
+
await writeFile(file, png);
|
|
822
|
+
let width;
|
|
823
|
+
let height;
|
|
824
|
+
if (png.length > 24 && png[0] === 0x89 && png[1] === 0x50) {
|
|
825
|
+
width = png.readUInt32BE(16);
|
|
826
|
+
height = png.readUInt32BE(20);
|
|
827
|
+
}
|
|
828
|
+
return { path: file, bytes: png.length, ...(width ? { width, height } : {}), text: 'saved ' + file + ' (' + png.length + ' bytes' + (width ? ', ' + width + 'x' + height : '') + '). Use read_image on this path to view it. This is the fallback channel — adb_ui_map is cheaper and gives clickable refs.' };
|
|
829
|
+
}
|
|
830
|
+
});
|
|
831
|
+
|
|
832
|
+
/** Physical screen size, cached per call chain; used when no scrollable node exists. */
|
|
833
|
+
async function screenSize(base, signal) {
|
|
834
|
+
try {
|
|
835
|
+
const out = (await runAdb([...base, 'shell', 'wm', 'size'], { signal, timeoutMs: 10000 })).stdout;
|
|
836
|
+
const m = /(\d+)x(\d+)/.exec(out);
|
|
837
|
+
if (m) return { width: Number(m[1]), height: Number(m[2]) };
|
|
838
|
+
} catch { /* fall through */ }
|
|
839
|
+
return undefined;
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
const adbUiDump = defineTool({
|
|
843
|
+
name: 'adb_ui_dump',
|
|
844
|
+
description: 'Raw uiautomator XML of the current screen, unprocessed. Only needed when adb_ui_map is not enough — for example to inspect layout containers, view-id naming patterns, or attributes the compact tree omits. Prefer adb_ui_map for normal interaction.',
|
|
845
|
+
parameters: {
|
|
846
|
+
type: 'object',
|
|
847
|
+
properties: {
|
|
848
|
+
device: deviceParam,
|
|
849
|
+
include_hidden: { type: 'boolean', description: 'Also keep nodes with empty bounds (default false, which drops the invisible ones to save space).' }
|
|
850
|
+
}
|
|
851
|
+
},
|
|
852
|
+
concurrencySafe: true,
|
|
853
|
+
async execute(args, exec) {
|
|
854
|
+
const base = await deviceArgs(args.device);
|
|
855
|
+
const xml = (await dumpUiXml(base, exec.signal)).trim();
|
|
856
|
+
const out = args.include_hidden ? xml : xml.replace(/<node[^>]*bounds="\[\d+,\d+\]\[\d+,\d+\]"[^>]*\/>/g, (tag) => {
|
|
857
|
+
const bounds = parseBounds(readAttr(tag, 'bounds'));
|
|
858
|
+
return bounds && bounds.w > 0 && bounds.h > 0 ? tag : '';
|
|
859
|
+
});
|
|
860
|
+
return { device: base[1] ?? 'auto', bytes: out.length, text: clip(out, MAX_XML_OUTPUT) };
|
|
861
|
+
}
|
|
862
|
+
});
|
|
863
|
+
|
|
864
|
+
const adbInput = defineTool({
|
|
865
|
+
name: 'adb_input',
|
|
866
|
+
description: 'LOW-LEVEL raw input on coordinates, without consulting the UI tree: tap, long_press, swipe, text, key. Prefer adb_tap / adb_set_text / adb_scroll when the hierarchy is available, since they resolve real elements instead of guessed pixels. Use this for gestures the tree cannot express (pinch-like multi-step drags, precise swipes, coordinate work derived from a screenshot), and for key presses (home, back, recents, power, volume_up, enter, del, dpad_*, media_*) or raw KEYCODE numbers.',
|
|
867
|
+
parameters: {
|
|
868
|
+
type: 'object',
|
|
869
|
+
properties: {
|
|
870
|
+
action: { type: 'string', enum: ['tap', 'long_press', 'swipe', 'text', 'key'], description: 'Input operation.', required: true },
|
|
871
|
+
device: deviceParam,
|
|
872
|
+
x: { type: 'integer', description: 'X coordinate for tap/long_press/swipe (device pixels, origin top-left).' },
|
|
873
|
+
y: { type: 'integer', description: 'Y coordinate for tap/long_press/swipe.' },
|
|
874
|
+
x2: { type: 'integer', description: 'End X for swipe.' },
|
|
875
|
+
y2: { type: 'integer', description: 'End Y for swipe.' },
|
|
876
|
+
duration_ms: { type: 'integer', description: 'Duration in ms for long_press (default 800) or swipe (default 300).' },
|
|
877
|
+
text: { type: 'string', description: 'Text to type for action "text".' },
|
|
878
|
+
key: { type: 'string', description: 'Key name (see description) or raw KEYCODE for action "key".' }
|
|
879
|
+
},
|
|
880
|
+
required: ['action']
|
|
881
|
+
},
|
|
882
|
+
async execute(args, exec) {
|
|
883
|
+
const base = await deviceArgs(args.device);
|
|
884
|
+
const duration = Number.isFinite(Number(args.duration_ms)) ? Math.max(1, Math.round(Number(args.duration_ms))) : undefined;
|
|
885
|
+
let argv;
|
|
886
|
+
if (args.action === 'tap') {
|
|
887
|
+
argv = [...base, 'shell', 'input', 'tap', String(requireNumber(args.x, 'x')), String(requireNumber(args.y, 'y'))];
|
|
888
|
+
} else if (args.action === 'long_press') {
|
|
889
|
+
const ms = duration ?? 800;
|
|
890
|
+
const x = String(requireNumber(args.x, 'x'));
|
|
891
|
+
const y = String(requireNumber(args.y, 'y'));
|
|
892
|
+
argv = [...base, 'shell', 'input', 'swipe', x, y, x, y, String(ms)];
|
|
893
|
+
} else if (args.action === 'swipe') {
|
|
894
|
+
argv = [...base, 'shell', 'input', 'swipe', String(requireNumber(args.x, 'x')), String(requireNumber(args.y, 'y')), String(requireNumber(args.x2, 'x2')), String(requireNumber(args.y2, 'y2')), String(duration ?? 300)];
|
|
895
|
+
} else if (args.action === 'text') {
|
|
896
|
+
if (!args.text) throw new Error('action "text" requires "text"');
|
|
897
|
+
argv = [...base, 'shell', 'input', 'text', args.text.replace(/ /g, '%s')];
|
|
898
|
+
} else {
|
|
899
|
+
if (!args.key) throw new Error('action "key" requires "key"');
|
|
900
|
+
const code = /^\d+$/.test(args.key) ? args.key : KEYCODES[String(args.key).toLowerCase()];
|
|
901
|
+
if (!code) throw new Error('unknown key "' + args.key + '". Known names: ' + Object.keys(KEYCODES).join(', ') + ' (or pass a raw KEYCODE number).');
|
|
902
|
+
argv = [...base, 'shell', 'input', 'keyevent', String(code)];
|
|
903
|
+
}
|
|
904
|
+
await runAdb(argv, { signal: exec.signal, timeoutMs: 30000 });
|
|
905
|
+
return { action: args.action, device: base[1] ?? 'auto', text: args.action + ' sent' };
|
|
906
|
+
}
|
|
907
|
+
});
|
|
908
|
+
|
|
909
|
+
//#endregion
|
|
910
|
+
|
|
911
|
+
//#region device, shell, files
|
|
912
|
+
|
|
913
|
+
const adbDevices = defineTool({
|
|
914
|
+
name: 'adb_devices',
|
|
915
|
+
description: 'List Android devices visible to adb, including USB (wired) and wireless (ip:port) targets with their state and model. Call this first to learn usable device identifiers.',
|
|
916
|
+
parameters: {},
|
|
917
|
+
concurrencySafe: true,
|
|
918
|
+
async execute(_args, exec) {
|
|
919
|
+
const devices = parseDevices((await runAdb(['devices', '-l'], { signal: exec.signal })).stdout);
|
|
920
|
+
const saved = loadSettings().defaultDevice;
|
|
921
|
+
const text = devices.length
|
|
922
|
+
? devices.map((d) => d.serial + ' ' + d.state + ' ' + d.connection + (d.model ? ' ' + d.model : '')).join('\n')
|
|
923
|
+
: 'No devices found. Enable USB debugging, plug the phone in (accept the prompt on the phone), or use adb_connect for wireless.';
|
|
924
|
+
return {
|
|
925
|
+
devices: devices.map(({ serial, state, connection, model }) => ({ serial, state, connection, ...(model ? { model } : {}) })),
|
|
926
|
+
...(saved ? { defaultDevice: saved } : {}),
|
|
927
|
+
text
|
|
928
|
+
};
|
|
929
|
+
}
|
|
930
|
+
});
|
|
931
|
+
|
|
932
|
+
const adbConnect = defineTool({
|
|
933
|
+
name: 'adb_connect',
|
|
934
|
+
description: 'Manage wireless ADB. action "pair": pair with an Android 11+ device using the IP:port and 6-digit code shown under Developer options > Wireless debugging > Pair device with pairing code. action "connect": connect to a wireless target (ip:port). action "tcpip": make a USB-connected device listen on tcpip 5555 so you can then unplug and adb_connect to its IP. action "disconnect": drop a wireless target.',
|
|
935
|
+
parameters: {
|
|
936
|
+
type: 'object',
|
|
937
|
+
properties: {
|
|
938
|
+
action: { type: 'string', enum: ['pair', 'connect', 'tcpip', 'disconnect'], description: 'Wireless operation to perform.', required: true },
|
|
939
|
+
target: { type: 'string', description: 'IP:port for pair/connect/disconnect; USB serial for tcpip.', required: true },
|
|
940
|
+
port: { type: 'integer', description: 'For tcpip: the TCP port to enable on the device (default 5555).' },
|
|
941
|
+
pairing_code: { type: 'string', description: '6-digit pairing code, required for action "pair".' }
|
|
942
|
+
},
|
|
943
|
+
required: ['action', 'target']
|
|
944
|
+
},
|
|
945
|
+
presentCall(args) {
|
|
946
|
+
return { card: 'terminal', title: 'adb ' + args.action + ' ' + args.target };
|
|
947
|
+
},
|
|
948
|
+
async execute(args, exec) {
|
|
949
|
+
const port = args.port && Number.isInteger(Number(args.port)) ? Number(args.port) : 5555;
|
|
950
|
+
let argv;
|
|
951
|
+
if (args.action === 'pair') {
|
|
952
|
+
if (!args.pairing_code) throw new Error('wireless pairing needs the 6-digit "pairing_code" shown on the phone (Developer options > Wireless debugging > Pair device with pairing code).');
|
|
953
|
+
argv = ['pair', args.target, String(args.pairing_code)];
|
|
954
|
+
} else if (args.action === 'connect') {
|
|
955
|
+
argv = ['connect', args.target];
|
|
956
|
+
} else if (args.action === 'disconnect') {
|
|
957
|
+
argv = ['disconnect', args.target];
|
|
958
|
+
} else {
|
|
959
|
+
argv = ['-s', args.target, 'tcpip', String(port)];
|
|
960
|
+
}
|
|
961
|
+
const { stdout, stderr } = await runAdb(argv, { signal: exec.signal, timeoutMs: 20000 });
|
|
962
|
+
const output = (stdout + stderr).trim() || 'done';
|
|
963
|
+
return { action: args.action, target: args.target, ok: !/failed|cannot|unable/i.test(output), text: output };
|
|
964
|
+
}
|
|
965
|
+
});
|
|
966
|
+
|
|
967
|
+
const adbShell = defineTool({
|
|
968
|
+
name: 'adb_shell',
|
|
969
|
+
description: 'Run a shell command on the Android device (for example: dumpsys battery, pm list packages, logcat -d -t 200, getprop, am start -a android.intent.action.VIEW -d https://example.com, wm size). stdout and stderr are returned.',
|
|
970
|
+
parameters: {
|
|
971
|
+
type: 'object',
|
|
972
|
+
properties: {
|
|
973
|
+
command: { type: 'string', description: 'Shell command line to run on the device.', required: true },
|
|
974
|
+
device: deviceParam,
|
|
975
|
+
timeout_ms: { type: 'integer', description: 'Timeout in ms (default 30000, max 600000).' }
|
|
976
|
+
},
|
|
977
|
+
required: ['command']
|
|
978
|
+
},
|
|
979
|
+
presentCall(args) {
|
|
980
|
+
return { card: 'terminal', title: 'adb shell', description: args.command };
|
|
981
|
+
},
|
|
982
|
+
async execute(args, exec) {
|
|
983
|
+
const base = await deviceArgs(args.device);
|
|
984
|
+
const timeoutMs = Math.min(600000, Number(args.timeout_ms) > 0 ? Number(args.timeout_ms) : 30000);
|
|
985
|
+
const { stdout, stderr } = await runAdb([...base, 'shell', args.command], { signal: exec.signal, timeoutMs });
|
|
986
|
+
const output = clip((stdout + (stdout && stderr ? '\n' : '') + stderr).trim(), MAX_TEXT_OUTPUT);
|
|
987
|
+
return { device: base[1] ?? 'auto', ok: true, text: output || '(no output)' };
|
|
988
|
+
}
|
|
989
|
+
});
|
|
990
|
+
|
|
991
|
+
const adbFiles = defineTool({
|
|
992
|
+
name: 'adb_files',
|
|
993
|
+
description: 'Transfer files between this computer and the Android device. action "push" copies local_path to remote_path on the device; action "pull" copies remote_path from the device to local_path.',
|
|
994
|
+
parameters: {
|
|
995
|
+
type: 'object',
|
|
996
|
+
properties: {
|
|
997
|
+
action: { type: 'string', enum: ['push', 'pull'], description: 'Transfer direction.', required: true },
|
|
998
|
+
local_path: { type: 'string', description: 'Absolute path on this computer.', required: true },
|
|
999
|
+
remote_path: { type: 'string', description: 'Path on the device (e.g. /sdcard/Download/file.mp4).', required: true },
|
|
1000
|
+
device: deviceParam
|
|
1001
|
+
},
|
|
1002
|
+
required: ['action', 'local_path', 'remote_path']
|
|
1003
|
+
},
|
|
1004
|
+
async execute(args, exec) {
|
|
1005
|
+
const base = await deviceArgs(args.device);
|
|
1006
|
+
const argv = args.action === 'push' ? [...base, 'push', args.local_path, args.remote_path] : [...base, 'pull', args.remote_path, args.local_path];
|
|
1007
|
+
const { stdout, stderr } = await runAdb(argv, { signal: exec.signal, timeoutMs: 300000 });
|
|
1008
|
+
return { action: args.action, text: (stdout + stderr).trim() || 'done' };
|
|
1009
|
+
}
|
|
1010
|
+
});
|
|
1011
|
+
|
|
1012
|
+
const adbInstall = defineTool({
|
|
1013
|
+
name: 'adb_install',
|
|
1014
|
+
description: 'Install an APK file from this computer onto the Android device. keep_data keeps existing app data on reinstall (default true). downgrade allows installing an older version over a newer one.',
|
|
1015
|
+
parameters: {
|
|
1016
|
+
type: 'object',
|
|
1017
|
+
properties: {
|
|
1018
|
+
apk_path: { type: 'string', description: 'Absolute path to the .apk on this computer.', required: true },
|
|
1019
|
+
device: deviceParam,
|
|
1020
|
+
keep_data: { type: 'boolean', description: 'Reinstall while keeping app data (-r, default true).' },
|
|
1021
|
+
downgrade: { type: 'boolean', description: 'Allow version downgrade (-d).' }
|
|
1022
|
+
},
|
|
1023
|
+
required: ['apk_path']
|
|
1024
|
+
},
|
|
1025
|
+
async execute(args, exec) {
|
|
1026
|
+
const base = await deviceArgs(args.device);
|
|
1027
|
+
const flags = [];
|
|
1028
|
+
if (args.keep_data !== false) flags.push('-r');
|
|
1029
|
+
if (args.downgrade) flags.push('-d');
|
|
1030
|
+
const { stdout, stderr } = await runAdb([...base, 'install', ...flags, args.apk_path], { signal: exec.signal, timeoutMs: 300000 });
|
|
1031
|
+
const output = (stdout + stderr).trim();
|
|
1032
|
+
return { ok: /success/i.test(output), text: output || 'done' };
|
|
1033
|
+
}
|
|
1034
|
+
});
|
|
1035
|
+
|
|
1036
|
+
const adbConfigure = defineTool({
|
|
1037
|
+
name: 'adb_configure',
|
|
1038
|
+
description: "Show or change this plugin's persistent settings: adb_path (adb binary when not on PATH), default_device (serial or ip:port used when a tool call omits \"device\"), screenshot_dir. Omitted fields keep their values.",
|
|
1039
|
+
parameters: {
|
|
1040
|
+
type: 'object',
|
|
1041
|
+
properties: {
|
|
1042
|
+
adb_path: { type: 'string', description: 'Absolute path or command name of the adb binary.' },
|
|
1043
|
+
default_device: { type: 'string', description: 'Default device serial / ip:port for subsequent calls.' },
|
|
1044
|
+
screenshot_dir: { type: 'string', description: 'Directory for adb_screenshot output.' },
|
|
1045
|
+
clear_default_device: { type: 'boolean', description: 'Remove the saved default device.' }
|
|
1046
|
+
}
|
|
1047
|
+
},
|
|
1048
|
+
async execute(args) {
|
|
1049
|
+
const patch = {};
|
|
1050
|
+
if (args.adb_path) patch.adbPath = args.adb_path;
|
|
1051
|
+
if (args.clear_default_device) patch.defaultDevice = undefined;
|
|
1052
|
+
else if (args.default_device) patch.defaultDevice = args.default_device;
|
|
1053
|
+
if (args.screenshot_dir) patch.screenshotDir = args.screenshot_dir;
|
|
1054
|
+
const settings = Object.keys(patch).length ? saveSettings(patch) : loadSettings();
|
|
1055
|
+
const shown = { adbPath: settings.adbPath || null, defaultDevice: settings.defaultDevice || null, screenshotDir: screenshotDir() };
|
|
1056
|
+
return { settingsPath: SETTINGS_PATH, ...shown, text: 'settings at ' + SETTINGS_PATH + ': ' + JSON.stringify(shown) };
|
|
1057
|
+
}
|
|
1058
|
+
});
|
|
1059
|
+
|
|
1060
|
+
//#endregion
|
|
1061
|
+
|
|
1062
|
+
/**
|
|
1063
|
+
* Register every ADB tool. The UI-tree tools are registered first so the
|
|
1064
|
+
* hierarchy-first flow is the obvious one; screenshot and raw input stay
|
|
1065
|
+
* available as secondary channels.
|
|
1066
|
+
* @param ctx - the plugin context.
|
|
1067
|
+
*/
|
|
1068
|
+
export function apply(ctx) {
|
|
1069
|
+
for (const tool of [
|
|
1070
|
+
adbUiMap, adbTap, adbSetText, adbScroll, adbWaitFor,
|
|
1071
|
+
adbDevices, adbConnect, adbInput, adbShell, adbScreenshot, adbUiDump,
|
|
1072
|
+
adbFiles, adbInstall, adbConfigure
|
|
1073
|
+
]) {
|
|
1074
|
+
ctx.effect(() => ctx.tools.register(tool));
|
|
1075
|
+
}
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
export const inject = ['tools'];
|
|
1079
|
+
export const name = PLUGIN;
|
package/locale/en.json
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
{
|
|
2
|
+
"meta": {
|
|
3
|
+
"title": "ADB Phone Control",
|
|
4
|
+
"description": "Control Android phones with UI tree as primary, screenshot as fallback. adb_ui_map reads an indexed element tree (ref, class, id, text, desc, flags, center coords); adb_tap/adb_set_text/adb_scroll/adb_wait_for act by ref. Screenshots are only for game surfaces, WebViews, lock screens, and verification. Also: adb_devices, adb_connect, adb_input, adb_shell, adb_ui_dump, adb_files, adb_install, adb_configure."
|
|
5
|
+
}
|
|
6
|
+
}
|
package/locale/zh.json
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
{
|
|
2
|
+
"meta": {
|
|
3
|
+
"title": "ADB 手机控制",
|
|
4
|
+
"description": "以界面树为主、截图为辅控制安卓手机。adb_ui_map 读取索引化元素树(ref、类、id、文本、描述、标志、中心坐标),adb_tap/adb_set_text/adb_scroll/adb_wait_for 用 ref 精准操作。截图仅作游戏、WebView、锁屏等无树场面的兜底/验证。另有 adb_devices/adb_connect/adb_input/adb_shell/adb_ui_dump/adb_files/adb_install/adb_configure。"
|
|
5
|
+
}
|
|
6
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ppppaaaaa/adb-control",
|
|
3
|
-
"
|
|
4
|
-
"
|
|
5
|
-
|
|
6
|
-
|
|
3
|
+
"description": "Control Android phones over wired or wireless ADB from the agent. UI tree first, screenshot secondary.",
|
|
4
|
+
"version": "2.0.0",
|
|
5
|
+
|
|
6
|
+
"type": "module",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./index.js",
|
|
9
|
+
"./locale/*.json": "./locale/*.json",
|
|
10
|
+
"./icon": "./icon.svg"
|
|
11
|
+
},
|
|
12
|
+
"files": ["index.js", "cordis.patch.yml", "locale/*.json", "icon.svg"],
|
|
13
|
+
"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }
|
|
14
|
+
}
|
package/README.md
DELETED