input.js 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296
  1. /**
  2. * adb method: input — 通过 ADB Keyboard 输入文本(先安装 static/ADBKeyboard.apk,切换 IME,发送后切回原输入法)
  3. */
  4. const path = require('path')
  5. const fs = require('fs')
  6. const { execSync, spawnSync } = require('child_process')
  7. const ADB_KEYBOARD_IME_ID = 'com.android.adbkeyboard/.AdbKeyboard'
  8. const ADB_KEYBOARD_PACKAGE = 'com.android.adbkeyboard'
  9. const B64_CHUNK_CHARS = 200
  10. function getProjectRoot(ctx) {
  11. const root = ctx.compilerConfig?.projectRoot
  12. if (root && fs.existsSync(root)) return root
  13. // 本文件在 .../nodejs/ef-compiler/actions/fun/adb:上溯 **5** 层才到仓库根(4 层只会停在 nodejs/)
  14. return path.resolve(__dirname, '..', '..', '..', '..', '..')
  15. }
  16. function getAdbPath(projectRoot) {
  17. try {
  18. const configPath = process.env.STATIC_ROOT
  19. ? path.join(path.dirname(path.resolve(process.env.STATIC_ROOT)), 'config.js')
  20. : path.join(projectRoot, 'config.js')
  21. const config = fs.existsSync(configPath) ? require(configPath) : {}
  22. const cfgRoot =
  23. (config.projectRoot && fs.existsSync(config.projectRoot)) ? config.projectRoot : projectRoot
  24. const p = config.adbPath?.path
  25. if (p) return path.isAbsolute(p) ? p : path.resolve(cfgRoot, p)
  26. } catch (e) {}
  27. return path.join(projectRoot, 'lib', 'scrcpy-adb', process.platform === 'win32' ? 'adb.exe' : 'adb')
  28. }
  29. function getAdbKeyboardApkPath(projectRoot) {
  30. const p = path.join(projectRoot, 'static', 'ADBKeyboard.apk')
  31. return fs.existsSync(p) ? p : null
  32. }
  33. function deviceArgs(device) {
  34. return device && String(device).includes(':') ? ['-s', device] : []
  35. }
  36. function runShell(adbPath, device, shellArgs, timeout = 3000) {
  37. const args = [...deviceArgs(device), 'shell', shellArgs]
  38. const r = spawnSync(adbPath, args, { encoding: 'utf-8', timeout })
  39. if (r.status !== 0) throw new Error(r.stderr || r.stdout || `adb shell failed: ${r.status}`)
  40. return (r.stdout || '').trim()
  41. }
  42. function runShellQuiet(adbPath, device, shellArgs, timeout = 3000) {
  43. const args = [...deviceArgs(device), 'shell', shellArgs]
  44. const r = spawnSync(adbPath, args, { encoding: 'utf-8', timeout })
  45. return r.status === 0
  46. }
  47. function isPackageInstalled(adbPath, device, packageId) {
  48. try {
  49. const out = runShell(adbPath, device, `pm list packages ${packageId}`, 3000)
  50. return (out || '').indexOf('package:' + packageId) >= 0
  51. } catch (e) {
  52. return false
  53. }
  54. }
  55. function installApk(adbPath, device, apkPath) {
  56. const args = [...deviceArgs(device), 'install', '-r', apkPath]
  57. const r = spawnSync(adbPath, args, { encoding: 'utf-8', timeout: 60000 })
  58. if (r.status !== 0) throw new Error(r.stderr || r.stdout || `adb install failed: ${r.status}`)
  59. }
  60. function getCurrentIme(adbPath, device) {
  61. try {
  62. return runShell(adbPath, device, 'settings get secure default_input_method', 3000)
  63. } catch (e) {
  64. return ''
  65. }
  66. }
  67. function listEnabledImes(adbPath, device) {
  68. try {
  69. const out = runShell(adbPath, device, 'ime list -s', 3000)
  70. return (out || '').split(/\r?\n/).map(s => s.trim()).filter(Boolean)
  71. } catch (e) {
  72. return []
  73. }
  74. }
  75. function listAllImes(adbPath, device) {
  76. try {
  77. const out = runShell(adbPath, device, 'ime list -a', 3000)
  78. return (out || '').split(/\r?\n/).map(s => s.trim()).filter(Boolean)
  79. } catch (e) {
  80. return []
  81. }
  82. }
  83. /** 从 ime list -a 输出解析 IME id 列表(行格式多为 "com.pkg/.Class:") */
  84. function parseImeIdsFromListA(out) {
  85. const ids = []
  86. const lines = (out || '').split(/\r?\n/)
  87. for (const line of lines) {
  88. const t = line.trim()
  89. if (t.includes('/') && (t.endsWith(':') || !t.includes(' '))) {
  90. ids.push(t.replace(/:$/, ''))
  91. }
  92. }
  93. return ids
  94. }
  95. function findAdbKeyboardImeId(adbPath, device) {
  96. try {
  97. const out = runShell(adbPath, device, 'ime list -a', 3000)
  98. const ids = parseImeIdsFromListA(out)
  99. const lower = s => String(s).toLowerCase()
  100. const found = ids.find(id => lower(id).includes('adbkeyboard') || lower(id).includes('adb.keyboard'))
  101. return found || ADB_KEYBOARD_IME_ID
  102. } catch (e) {
  103. return ADB_KEYBOARD_IME_ID
  104. }
  105. }
  106. /** 从 dumpsys package 解析包内 BroadcastReceiver 组件名,用于 -n 显式发送 */
  107. function getPackageReceivers(adbPath, device, packageId) {
  108. try {
  109. const out = runShell(adbPath, device, `dumpsys package ${packageId}`, 8000)
  110. const receivers = []
  111. const re = /ComponentInfo\{([^}]+)\}/g
  112. let m
  113. while ((m = re.exec(out)) !== null) {
  114. const comp = m[1].trim()
  115. if (comp.startsWith(packageId + '/')) receivers.push(comp)
  116. }
  117. return [...new Set(receivers)]
  118. } catch (e) {
  119. return []
  120. }
  121. }
  122. function enableIme(adbPath, device, imeId) {
  123. if (!imeId) return
  124. const args = [...deviceArgs(device), 'shell', 'ime', 'enable', imeId]
  125. const r = spawnSync(adbPath, args, { encoding: 'utf-8', timeout: 5000 })
  126. if (r.status !== 0 && r.stderr) {
  127. throw new Error(`ime enable 失败: ${(r.stderr || r.stdout || '').trim() || r.status}`)
  128. }
  129. }
  130. function setIme(adbPath, device, imeId) {
  131. if (!imeId) return
  132. const args = [...deviceArgs(device), 'shell', 'ime', 'set', imeId]
  133. const r = spawnSync(adbPath, args, { encoding: 'utf-8', timeout: 5000 })
  134. if (r.status !== 0) {
  135. throw new Error(`ime set 失败: ${(r.stderr || r.stdout || '').trim() || r.status}`)
  136. }
  137. }
  138. function ensureAdbKeyboardInstalled(adbPath, device, projectRoot) {
  139. const apkPath = getAdbKeyboardApkPath(projectRoot)
  140. if (!apkPath) throw new Error('未找到 static/ADBKeyboard.apk,请将 ADB Keyboard 的 apk 放在该路径')
  141. if (!isPackageInstalled(adbPath, device, ADB_KEYBOARD_PACKAGE)) installApk(adbPath, device, apkPath)
  142. }
  143. /** 单次 broadcast(--es 字符串),返回是否成功 */
  144. function tryBroadcast(adbPath, device, action, key, value, component) {
  145. const args = [...deviceArgs(device), 'shell', 'am', 'broadcast', '-a', action, '--es', key, value]
  146. if (component) args.splice(args.indexOf(action) + 1, 0, '-n', component)
  147. const r = spawnSync(adbPath, args, { encoding: 'utf-8', timeout: 15000 })
  148. return r.status === 0
  149. }
  150. /** ADB_INPUT_CHARS:--eia chars 码点1 码点2 ...(部分设备 B64 异常时可用) */
  151. function tryBroadcastChars(adbPath, device, codePoints, component) {
  152. const args = [...deviceArgs(device), 'shell', 'am', 'broadcast', '-a', 'ADB_INPUT_CHARS', '--eia', 'chars', ...codePoints.map(String)]
  153. if (component) args.splice(args.indexOf('ADB_INPUT_CHARS') + 1, 0, '-n', component)
  154. const r = spawnSync(adbPath, args, { encoding: 'utf-8', timeout: 15000 })
  155. return r.status === 0
  156. }
  157. /** 依次尝试多种方式发送,直到一种成功。先试隐式(系统派发到注册的 Receiver),再试 -n */
  158. async function sendTextViaBroadcast(adbPath, device, str, adbKeyboardImeId, logMessage, folderPath) {
  159. const fromDumpsys = getPackageReceivers(adbPath, device, ADB_KEYBOARD_PACKAGE)
  160. const components = [
  161. null,
  162. adbKeyboardImeId,
  163. ...fromDumpsys,
  164. `${ADB_KEYBOARD_PACKAGE}/.AdbReceiver`,
  165. `${ADB_KEYBOARD_PACKAGE}/.Receiver`
  166. ]
  167. const dedup = [...new Set(components)]
  168. for (let i = 0; i < str.length; i += B64_CHUNK_CHARS) {
  169. const chunk = str.slice(i, i + B64_CHUNK_CHARS)
  170. const b64 = Buffer.from(chunk, 'utf8').toString('base64')
  171. let sent = false
  172. let used = null
  173. for (const comp of dedup) {
  174. if (tryBroadcast(adbPath, device, 'ADB_INPUT_B64', 'msg', b64, comp)) {
  175. sent = true
  176. used = comp ? comp : 'B64隐式'
  177. break
  178. }
  179. }
  180. if (!sent && chunk.length <= 100) {
  181. const codePoints = [...chunk].map(c => c.codePointAt(0))
  182. for (const comp of [adbKeyboardImeId, null]) {
  183. if (tryBroadcastChars(adbPath, device, codePoints, comp)) {
  184. sent = true
  185. used = 'ADB_INPUT_CHARS'
  186. break
  187. }
  188. }
  189. }
  190. if (!sent && tryBroadcast(adbPath, device, 'ADB_INPUT_TEXT', 'msg', chunk, null)) {
  191. sent = true
  192. used = 'ADB_INPUT_TEXT'
  193. }
  194. if (!sent) throw new Error('broadcast 所有方式均失败')
  195. if (i + B64_CHUNK_CHARS < str.length) await new Promise(r => setTimeout(r, 150))
  196. }
  197. }
  198. async function run(action, ctx) {
  199. const { device, folderPath, variableContext, api, extractVarName, resolveValue } = ctx
  200. let inputValue = null
  201. const inVars = action.inVars || []
  202. if (inVars.length > 0) {
  203. const raw = inVars[0]
  204. if (raw != null && typeof raw === 'string' && !(raw.startsWith('{') && raw.endsWith('}'))) {
  205. inputValue = raw
  206. } else {
  207. inputValue = variableContext[extractVarName(raw)]
  208. }
  209. }
  210. if (!inputValue && action.value) inputValue = resolveValue(action.value, variableContext)
  211. if (!inputValue) return { success: false, error: 'input 操作缺少输入内容' }
  212. if (action.clear) {
  213. if (!api?.sendKeyEvent) return { success: false, error: '清空需要 sendKeyEvent API' }
  214. for (let i = 0; i < 200; i++) {
  215. const clearResult = await api.sendKeyEvent(device, '67')
  216. if (!clearResult.success) break
  217. await new Promise((r) => setTimeout(r, 10))
  218. }
  219. await new Promise((r) => setTimeout(r, 200))
  220. }
  221. const projectRoot = getProjectRoot(ctx)
  222. const adbPath = getAdbPath(projectRoot)
  223. if (!fs.existsSync(adbPath)) return { success: false, error: `未找到 adb: ${adbPath}` }
  224. const logMessage = ctx.logMessage
  225. const needInstall = !isPackageInstalled(adbPath, device, ADB_KEYBOARD_PACKAGE)
  226. if (needInstall) {
  227. ensureAdbKeyboardInstalled(adbPath, device, projectRoot)
  228. }
  229. const adbKeyboardImeId = findAdbKeyboardImeId(adbPath, device)
  230. try {
  231. const prevIme = getCurrentIme(adbPath, device)
  232. try {
  233. try {
  234. enableIme(adbPath, device, adbKeyboardImeId)
  235. } catch (enableErr) {}
  236. await new Promise(r => setTimeout(r, 400))
  237. try {
  238. const sizeOut = runShell(adbPath, device, 'wm size', 3000)
  239. const m = (sizeOut || '').match(/(\d+)x(\d+)/)
  240. if (m) {
  241. const w = parseInt(m[1], 10)
  242. const h = parseInt(m[2], 10)
  243. const tapX = Math.floor(w / 2)
  244. const tapY = Math.floor(h * 0.28)
  245. runShellQuiet(adbPath, device, `input tap ${tapX} ${tapY}`, 2000)
  246. await new Promise(r => setTimeout(r, 500))
  247. }
  248. } catch (e) {}
  249. setIme(adbPath, device, adbKeyboardImeId)
  250. await new Promise(r => setTimeout(r, 1000))
  251. await sendTextViaBroadcast(adbPath, device, String(inputValue), adbKeyboardImeId, logMessage, folderPath)
  252. await new Promise(r => setTimeout(r, 1000))
  253. } finally {
  254. if (prevIme && prevIme !== adbKeyboardImeId) {
  255. setIme(adbPath, device, prevIme)
  256. } else {
  257. const enabled = listEnabledImes(adbPath, device)
  258. const other = enabled.find(id => id !== adbKeyboardImeId)
  259. if (other) setIme(adbPath, device, other)
  260. }
  261. }
  262. } catch (e) {
  263. const msg = e && (e.message || String(e)) || 'unknown'
  264. return { success: false, error: `输入失败: ${msg}` }
  265. }
  266. return { success: true }
  267. }
  268. module.exports = { run }