| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208 |
- #!/usr/bin/env node
- /**
- * ui.js — adb 真机界面巡检小工具(PC 侧,不依赖 MCP)
- *
- * 用途:当 AutoX ↔ MCP 通道不可用时,用纯 adb 完成
- * 「看当前界面有哪些控件 / 点它 / 截图」这三件事,保证探索不被阻塞。
- *
- * 用法:
- * node _probe/ui.js dump [过滤词] # dump 当前界面,可选按文案子串过滤
- * node _probe/ui.js tap <x> <y> # 坐标点击
- * node _probe/ui.js tap-text <文案> # 按文案(或 desc)精确匹配后点击其中心
- * node _probe/ui.js shot <名字> # 截图到 _probe/shots/<名字>.png
- * node _probe/ui.js swipe <x1> <y1> <x2> <y2> [毫秒]
- * node _probe/ui.js fore # 当前前台窗口
- *
- * 设备序列号优先取环境变量 ADB_SERIAL,其次取 adb devices 的第一个。
- */
- 'use strict';
- const { execFileSync } = require('child_process');
- const fs = require('fs');
- const path = require('path');
- const SHOTS_DIR = path.join(__dirname, 'shots');
- const DUMP_PATH = '/sdcard/ui_probe.xml';
- let cachedSerial = null;
- /** 惰性解析设备序列号,避免模块初始化顺序问题 */
- function serial() {
- if (cachedSerial) {
- return cachedSerial;
- }
- if (process.env.ADB_SERIAL) {
- cachedSerial = process.env.ADB_SERIAL;
- return cachedSerial;
- }
- const output = execFileSync('adb', ['devices'], { encoding: 'utf8' });
- const line = output
- .split(/\r?\n/)
- .slice(1)
- .find((row) => row.includes('\tdevice'));
- if (!line) {
- throw new Error('没有可用的 adb 设备,请先 adb connect');
- }
- cachedSerial = line.split('\t')[0].trim();
- return cachedSerial;
- }
- function runAdb(args, options = {}) {
- const full = ['-s', serial(), ...args];
- return execFileSync('adb', full, {
- encoding: options.raw ? null : 'utf8',
- maxBuffer: 32 * 1024 * 1024,
- });
- }
- /** dump 当前界面并解析成扁平节点列表 */
- function readLayout() {
- runAdb(['shell', `uiautomator dump ${DUMP_PATH}`]);
- const xml = runAdb(['shell', `cat ${DUMP_PATH}`]);
- return parseNodes(xml);
- }
- function parseNodes(xml) {
- const nodes = [];
- const nodeRe = /<node[^>]*>/g;
- let match;
- while ((match = nodeRe.exec(xml)) !== null) {
- const raw = match[0];
- const bounds = toBounds(attr(raw, 'bounds'));
- if (!bounds) {
- continue;
- }
- nodes.push({
- cls: shortClass(attr(raw, 'class')),
- text: attr(raw, 'text'),
- desc: attr(raw, 'content-desc'),
- id: attr(raw, 'resource-id').split('/').pop(),
- clickable: attr(raw, 'clickable') === 'true',
- scrollable: attr(raw, 'scrollable') === 'true',
- checkable: attr(raw, 'checkable') === 'true',
- checked: attr(raw, 'checked') === 'true',
- editable: attr(raw, 'class').includes('EditText'),
- bounds,
- center: {
- x: Math.round((Math.max(bounds.l, 0) + Math.min(bounds.r, 1080)) / 2),
- y: Math.round((Math.max(bounds.t, 0) + Math.min(bounds.b, 1920)) / 2),
- },
- });
- }
- return nodes;
- }
- function attr(raw, name) {
- const match = raw.match(new RegExp(`${name}="([^"]*)"`));
- return match ? match[1] : '';
- }
- function toBounds(text) {
- const match = text.match(/\[(-?\d+),(-?\d+)\]\[(-?\d+),(-?\d+)\]/);
- if (!match) {
- return null;
- }
- const [l, t, r, b] = match.slice(1).map(Number);
- if (r <= l || b <= t) {
- return null;
- }
- return { l, t, r, b };
- }
- function shortClass(cls) {
- const parts = cls.split('.');
- return parts[parts.length - 1];
- }
- /** 只保留"可识别 ∪ 可交互"的节点:模拟 MCP get_layout 的保留规则 */
- function keepSignificant(nodes) {
- return nodes.filter(
- (node) =>
- node.text ||
- node.desc ||
- node.clickable ||
- node.scrollable ||
- node.editable
- );
- }
- function formatNode(node) {
- const label = node.text || node.desc || '';
- const flags = [
- node.clickable ? 'clk' : '-',
- node.scrollable ? 'scr' : '-',
- node.editable ? 'ed' : '-',
- node.checkable ? (node.checked ? 'ON' : 'off') : '--',
- ].join(' ');
- return [
- node.cls.padEnd(18),
- String(node.id).padEnd(22).slice(0, 22),
- flags,
- `(${node.center.x},${node.center.y})`.padEnd(12),
- `[${node.bounds.l},${node.bounds.t}][${node.bounds.r},${node.bounds.b}]`.padEnd(24),
- label,
- ].join(' ');
- }
- function printLayout(filter) {
- const nodes = keepSignificant(readLayout());
- const shown = filter
- ? nodes.filter((node) => (node.text + node.desc).includes(filter))
- : nodes;
- console.log(`# 设备 ${serial()} 节点 ${nodes.length} → 显示 ${shown.length}`);
- shown.forEach((node) => console.log(formatNode(node)));
- }
- function findOneByLabel(label) {
- const nodes = keepSignificant(readLayout()).filter(
- (node) => node.text === label || node.desc === label
- );
- if (nodes.length === 0) {
- throw new Error(`没有找到文案为 "${label}" 的节点`);
- }
- return nodes[0];
- }
- function main() {
- const [command, ...rest] = process.argv.slice(2);
- switch (command) {
- case 'dump':
- printLayout(rest[0]);
- break;
- case 'tap':
- runAdb(['shell', `input tap ${Number(rest[0])} ${Number(rest[1])}`]);
- console.log(`已点击 (${rest[0]}, ${rest[1]})`);
- break;
- case 'tap-text': {
- const node = findOneByLabel(rest[0]);
- runAdb(['shell', `input tap ${node.center.x} ${node.center.y}`]);
- console.log(`已点击 "${rest[0]}" @ ${node.cls} (${node.center.x},${node.center.y})`);
- break;
- }
- case 'swipe':
- runAdb([
- 'shell',
- `input swipe ${Number(rest[0])} ${Number(rest[1])} ${Number(rest[2])} ${Number(rest[3])} ${Number(rest[4] || 300)}`,
- ]);
- console.log('已滑动');
- break;
- case 'shot': {
- fs.mkdirSync(SHOTS_DIR, { recursive: true });
- const file = path.join(SHOTS_DIR, `${rest[0] || 'shot'}.png`);
- const png = runAdb(['exec-out', 'screencap', '-p'], { raw: true });
- fs.writeFileSync(file, png);
- console.log(file);
- break;
- }
- case 'fore': {
- const out = runAdb(['shell', 'dumpsys window']);
- const focus = out.match(/mCurrentFocus=(\S+)/);
- console.log(focus ? focus[1] : '(未知)');
- break;
- }
- default:
- console.log('用法: dump [过滤词] | tap x y | tap-text 文案 | swipe x1 y1 x2 y2 [ms] | shot 名字 | fore');
- }
- }
- main();
|