ui.js 6.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208
  1. #!/usr/bin/env node
  2. /**
  3. * ui.js — adb 真机界面巡检小工具(PC 侧,不依赖 MCP)
  4. *
  5. * 用途:当 AutoX ↔ MCP 通道不可用时,用纯 adb 完成
  6. * 「看当前界面有哪些控件 / 点它 / 截图」这三件事,保证探索不被阻塞。
  7. *
  8. * 用法:
  9. * node _probe/ui.js dump [过滤词] # dump 当前界面,可选按文案子串过滤
  10. * node _probe/ui.js tap <x> <y> # 坐标点击
  11. * node _probe/ui.js tap-text <文案> # 按文案(或 desc)精确匹配后点击其中心
  12. * node _probe/ui.js shot <名字> # 截图到 _probe/shots/<名字>.png
  13. * node _probe/ui.js swipe <x1> <y1> <x2> <y2> [毫秒]
  14. * node _probe/ui.js fore # 当前前台窗口
  15. *
  16. * 设备序列号优先取环境变量 ADB_SERIAL,其次取 adb devices 的第一个。
  17. */
  18. 'use strict';
  19. const { execFileSync } = require('child_process');
  20. const fs = require('fs');
  21. const path = require('path');
  22. const SHOTS_DIR = path.join(__dirname, 'shots');
  23. const DUMP_PATH = '/sdcard/ui_probe.xml';
  24. let cachedSerial = null;
  25. /** 惰性解析设备序列号,避免模块初始化顺序问题 */
  26. function serial() {
  27. if (cachedSerial) {
  28. return cachedSerial;
  29. }
  30. if (process.env.ADB_SERIAL) {
  31. cachedSerial = process.env.ADB_SERIAL;
  32. return cachedSerial;
  33. }
  34. const output = execFileSync('adb', ['devices'], { encoding: 'utf8' });
  35. const line = output
  36. .split(/\r?\n/)
  37. .slice(1)
  38. .find((row) => row.includes('\tdevice'));
  39. if (!line) {
  40. throw new Error('没有可用的 adb 设备,请先 adb connect');
  41. }
  42. cachedSerial = line.split('\t')[0].trim();
  43. return cachedSerial;
  44. }
  45. function runAdb(args, options = {}) {
  46. const full = ['-s', serial(), ...args];
  47. return execFileSync('adb', full, {
  48. encoding: options.raw ? null : 'utf8',
  49. maxBuffer: 32 * 1024 * 1024,
  50. });
  51. }
  52. /** dump 当前界面并解析成扁平节点列表 */
  53. function readLayout() {
  54. runAdb(['shell', `uiautomator dump ${DUMP_PATH}`]);
  55. const xml = runAdb(['shell', `cat ${DUMP_PATH}`]);
  56. return parseNodes(xml);
  57. }
  58. function parseNodes(xml) {
  59. const nodes = [];
  60. const nodeRe = /<node[^>]*>/g;
  61. let match;
  62. while ((match = nodeRe.exec(xml)) !== null) {
  63. const raw = match[0];
  64. const bounds = toBounds(attr(raw, 'bounds'));
  65. if (!bounds) {
  66. continue;
  67. }
  68. nodes.push({
  69. cls: shortClass(attr(raw, 'class')),
  70. text: attr(raw, 'text'),
  71. desc: attr(raw, 'content-desc'),
  72. id: attr(raw, 'resource-id').split('/').pop(),
  73. clickable: attr(raw, 'clickable') === 'true',
  74. scrollable: attr(raw, 'scrollable') === 'true',
  75. checkable: attr(raw, 'checkable') === 'true',
  76. checked: attr(raw, 'checked') === 'true',
  77. editable: attr(raw, 'class').includes('EditText'),
  78. bounds,
  79. center: {
  80. x: Math.round((Math.max(bounds.l, 0) + Math.min(bounds.r, 1080)) / 2),
  81. y: Math.round((Math.max(bounds.t, 0) + Math.min(bounds.b, 1920)) / 2),
  82. },
  83. });
  84. }
  85. return nodes;
  86. }
  87. function attr(raw, name) {
  88. const match = raw.match(new RegExp(`${name}="([^"]*)"`));
  89. return match ? match[1] : '';
  90. }
  91. function toBounds(text) {
  92. const match = text.match(/\[(-?\d+),(-?\d+)\]\[(-?\d+),(-?\d+)\]/);
  93. if (!match) {
  94. return null;
  95. }
  96. const [l, t, r, b] = match.slice(1).map(Number);
  97. if (r <= l || b <= t) {
  98. return null;
  99. }
  100. return { l, t, r, b };
  101. }
  102. function shortClass(cls) {
  103. const parts = cls.split('.');
  104. return parts[parts.length - 1];
  105. }
  106. /** 只保留"可识别 ∪ 可交互"的节点:模拟 MCP get_layout 的保留规则 */
  107. function keepSignificant(nodes) {
  108. return nodes.filter(
  109. (node) =>
  110. node.text ||
  111. node.desc ||
  112. node.clickable ||
  113. node.scrollable ||
  114. node.editable
  115. );
  116. }
  117. function formatNode(node) {
  118. const label = node.text || node.desc || '';
  119. const flags = [
  120. node.clickable ? 'clk' : '-',
  121. node.scrollable ? 'scr' : '-',
  122. node.editable ? 'ed' : '-',
  123. node.checkable ? (node.checked ? 'ON' : 'off') : '--',
  124. ].join(' ');
  125. return [
  126. node.cls.padEnd(18),
  127. String(node.id).padEnd(22).slice(0, 22),
  128. flags,
  129. `(${node.center.x},${node.center.y})`.padEnd(12),
  130. `[${node.bounds.l},${node.bounds.t}][${node.bounds.r},${node.bounds.b}]`.padEnd(24),
  131. label,
  132. ].join(' ');
  133. }
  134. function printLayout(filter) {
  135. const nodes = keepSignificant(readLayout());
  136. const shown = filter
  137. ? nodes.filter((node) => (node.text + node.desc).includes(filter))
  138. : nodes;
  139. console.log(`# 设备 ${serial()} 节点 ${nodes.length} → 显示 ${shown.length}`);
  140. shown.forEach((node) => console.log(formatNode(node)));
  141. }
  142. function findOneByLabel(label) {
  143. const nodes = keepSignificant(readLayout()).filter(
  144. (node) => node.text === label || node.desc === label
  145. );
  146. if (nodes.length === 0) {
  147. throw new Error(`没有找到文案为 "${label}" 的节点`);
  148. }
  149. return nodes[0];
  150. }
  151. function main() {
  152. const [command, ...rest] = process.argv.slice(2);
  153. switch (command) {
  154. case 'dump':
  155. printLayout(rest[0]);
  156. break;
  157. case 'tap':
  158. runAdb(['shell', `input tap ${Number(rest[0])} ${Number(rest[1])}`]);
  159. console.log(`已点击 (${rest[0]}, ${rest[1]})`);
  160. break;
  161. case 'tap-text': {
  162. const node = findOneByLabel(rest[0]);
  163. runAdb(['shell', `input tap ${node.center.x} ${node.center.y}`]);
  164. console.log(`已点击 "${rest[0]}" @ ${node.cls} (${node.center.x},${node.center.y})`);
  165. break;
  166. }
  167. case 'swipe':
  168. runAdb([
  169. 'shell',
  170. `input swipe ${Number(rest[0])} ${Number(rest[1])} ${Number(rest[2])} ${Number(rest[3])} ${Number(rest[4] || 300)}`,
  171. ]);
  172. console.log('已滑动');
  173. break;
  174. case 'shot': {
  175. fs.mkdirSync(SHOTS_DIR, { recursive: true });
  176. const file = path.join(SHOTS_DIR, `${rest[0] || 'shot'}.png`);
  177. const png = runAdb(['exec-out', 'screencap', '-p'], { raw: true });
  178. fs.writeFileSync(file, png);
  179. console.log(file);
  180. break;
  181. }
  182. case 'fore': {
  183. const out = runAdb(['shell', 'dumpsys window']);
  184. const focus = out.match(/mCurrentFocus=(\S+)/);
  185. console.log(focus ? focus[1] : '(未知)');
  186. break;
  187. }
  188. default:
  189. console.log('用法: dump [过滤词] | tap x y | tap-text 文案 | swipe x1 y1 x2 y2 [ms] | shot 名字 | fore');
  190. }
  191. }
  192. main();