#!/usr/bin/env node /** * ui.js — adb 真机界面巡检小工具(PC 侧,不依赖 MCP) * * 用途:当 AutoX ↔ MCP 通道不可用时,用纯 adb 完成 * 「看当前界面有哪些控件 / 点它 / 截图」这三件事,保证探索不被阻塞。 * * 用法: * node _probe/ui.js dump [过滤词] # dump 当前界面,可选按文案子串过滤 * node _probe/ui.js tap # 坐标点击 * node _probe/ui.js tap-text <文案> # 按文案(或 desc)精确匹配后点击其中心 * node _probe/ui.js shot <名字> # 截图到 _probe/shots/<名字>.png * node _probe/ui.js swipe [毫秒] * 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 = /]*>/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();