dsh-mobile-use 1.0.0 → 1.0.3

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/index.js CHANGED
@@ -22,8 +22,8 @@ import { homedir, tmpdir } from 'node:os'
22
22
  import { dirname, join } from 'node:path'
23
23
  import { fileURLToPath } from 'node:url'
24
24
 
25
- import { setupMirror, frameSize as jpegSize } from './mirror.js?v=164'
26
- import { ocrAvailable, ocrImage, stopOcr, toRects } from './ocr.js?v=115'
25
+ import { setupMirror, frameSize as jpegSize } from './mirror.js?v=169'
26
+ import { ocrAvailable, ocrImage, stopOcr, toRects } from './ocr.js?v=116'
27
27
 
28
28
  /** Plugin name,与 bundle patch 里的行 id 对应。 */
29
29
  export const name = 'adb'
@@ -86,12 +86,13 @@ function adb(args, options = {}) {
86
86
  const cmd = args.slice(shellAt + 1).join(' ')
87
87
  return sh(serial, cmd, timeout).then((result) => ({
88
88
  ok: result.ok,
89
- code: result.ok ? 0 : 1,
89
+ code: result.code ?? (result.ok ? 0 : 1),
90
90
  out: result.out,
91
91
  raw: Buffer.from(result.out, 'utf8'),
92
92
  err: result.err ?? '',
93
93
  ms: result.ms,
94
- timedOut: false,
94
+ /* 超时/通道断:命令可能已经执行了一半,调用方不能当「确定没做」处理。 */
95
+ timedOut: result.uncertain === true,
95
96
  }))
96
97
  }
97
98
  }
@@ -221,6 +222,62 @@ function dshSleep(ms) {
221
222
  /** WebView 调试口转发缓存:`serial/pid` → 本机端口。 */
222
223
  const webPorts = new Map()
223
224
 
225
+ /** 自己建的转发:`serial|本机端` → { serial, local, remote },退出时只删自己建的。 @type {Map<string, {serial: string, local: string, remote: string}>} */
226
+ const ownedForwards = new Map()
227
+
228
+ /**
229
+ * 解析 `adb forward --list`,每行形如 `序列号 tcp:19108 tcp:9008`。
230
+ * `-s` 只决定新建的转发挂在哪台设备上,列表本身是所有设备的,所以必须自己按序列号过滤。
231
+ * @param {string} text - 命令输出。
232
+ * @returns {Array<{ serial: string, local: string, remote: string }>} 转发条目。
233
+ */
234
+ function parseForwards(text) {
235
+ const rows = []
236
+ for (const line of String(text).split('\n')) {
237
+ const matched = /^(\S+)\s+(tcp:\d+|localabstract:\S+|localreserved:\S+|local:[\w.-]+)\s+(\S+)\s*$/.exec(line.trim())
238
+ if (matched === null) continue
239
+ rows.push({ serial: matched[1], local: matched[2], remote: matched[3] })
240
+ }
241
+ return rows
242
+ }
243
+
244
+ /**
245
+ * 在转发列表里找指定设备的某条转发。
246
+ * @param {string} text - `adb forward --list` 输出。
247
+ * @param {string} serial - 设备序列号。
248
+ * @param {string} remote - 设备端目标(如 `tcp:9008`、`localabstract:webview_devtools_remote_123`)。
249
+ * @returns {{ serial: string, local: string, remote: string } | null} 命中条目。
250
+ */
251
+ function forwardFor(text, serial, remote) {
252
+ return parseForwards(text).find((row) => row.serial === serial && row.remote === remote) ?? null
253
+ }
254
+
255
+ /**
256
+ * 记下自己建的转发,退出时只删这些。
257
+ * @param {string} serial - 设备。
258
+ * @param {string} local - 本机端(`tcp:19108`)。
259
+ * @param {string} remote - 设备端。
260
+ * @returns {void}
261
+ */
262
+ function ownForward(serial, local, remote) {
263
+ ownedForwards.set(`${serial}|${local}`, { serial, local, remote })
264
+ }
265
+
266
+ /**
267
+ * 删掉自己建的转发:只删记过的,别人的(或别的设备的)不动。
268
+ * @param {string} serial - 设备。
269
+ * @param {string} [local] - 本机端;不写就删这台设备所有自己建的。
270
+ * @returns {Promise<void>} 删完返回。
271
+ */
272
+ async function releaseForward(serial, local) {
273
+ for (const [key, entry] of [...ownedForwards]) {
274
+ if (entry.serial !== serial) continue
275
+ if (local !== undefined && entry.local !== local) continue
276
+ ownedForwards.delete(key)
277
+ await adb(['-s', serial, 'forward', '--remove', entry.local], { timeout: 4000 })
278
+ }
279
+ }
280
+
224
281
  /**
225
282
  * 找 App 的 WebView 调试口,必要时建 adb forward。
226
283
  * @param {string} device - 设备序列号。
@@ -239,13 +296,25 @@ async function webTarget(device, packageName) {
239
296
  }
240
297
  const socketPid = socketName.slice('webview_devtools_remote_'.length)
241
298
  const key = `${device}/${socketPid}`
299
+ const remote = `localabstract:${socketName}`
242
300
  const cachedPort = webPorts.get(key)
243
- if (cachedPort) return { pid, port: cachedPort, socketPid }
244
- const forward = await adb(['-s', device, 'forward', 'tcp:0', `localabstract:${socketName}`], { timeout: 6000 })
301
+ if (cachedPort) {
302
+ /* 缓存端口只在「仍是这台设备、仍指向这个 socket」时复用,否则别的设备的转发会被拿来用。 */
303
+ const rows = await adb(['-s', device, 'forward', '--list'], { timeout: 6000 })
304
+ const hit = forwardFor(rows.out, device, remote)
305
+ const port = hit === null ? 0 : Number(hit.local.replace(/^tcp:/, ''))
306
+ if (Number.isFinite(port) && port > 0) {
307
+ webPorts.set(key, port)
308
+ return { pid, port, socketPid }
309
+ }
310
+ webPorts.delete(key)
311
+ }
312
+ const forward = await adb(['-s', device, 'forward', 'tcp:0', remote], { timeout: 6000 })
245
313
  const port = Number((forward.out || '').trim())
246
314
  if (!Number.isFinite(port) || port <= 0) {
247
315
  throw new Error(`WebView 调试口转发失败:${oneLine(forward.err || forward.out, 140)}`)
248
316
  }
317
+ ownForward(device, `tcp:${port}`, remote)
249
318
  webPorts.set(key, port)
250
319
  return { pid, port, socketPid }
251
320
  }
@@ -331,58 +400,121 @@ function openShell(serial) {
331
400
  return channel
332
401
  }
333
402
 
403
+ /** 只读命令的首词:只读设备状态,失败后重跑一遍不会改设备,可以安全重试。 */
404
+ const READ_ONLY_HEADS = new Set([
405
+ 'cat', 'ls', 'getprop', 'dumpsys', 'pidof', 'ps', 'top', 'stat', 'wc', 'head', 'tail',
406
+ 'grep', 'sed', 'awk', 'echo', 'printf', 'date', 'id', 'whoami', 'uname', 'uptime',
407
+ 'df', 'du', 'logcat', 'uiautomator', 'screencap', 'test', '[', 'true', 'false',
408
+ 'sleep', 'which', 'find', 'getevent', 'netstat', 'ip', 'lsusb', 'dmesg',
409
+ ])
410
+
411
+ /** 明确会改设备状态的首词:超时后一律不重放(点一次再点一次就是两下)。 */
412
+ const WRITE_HEADS = new Set([
413
+ 'input', 'am', 'pm', 'svc', 'settings', 'cmd', 'rm', 'rmdir', 'mkdir', 'mv', 'cp',
414
+ 'chmod', 'chown', 'kill', 'killall', 'monkey', 'screenrecord', 'nohup', 'app_process',
415
+ 'setprop', 'reboot', 'shutdown', 'wm', 'su', 'sh', 'bash', 'touch', 'dd', 'tee',
416
+ 'install', 'uninstall', 'ln',
417
+ ])
418
+
419
+ /**
420
+ * 判断一条命令是不是「写状态」的:写命令超时后设备可能已经执行,重放会重复操作。
421
+ * 按 `; & |` 和换行拆成简单命令逐段判断;不认识的命令按写命令算(宁可不重试)。
422
+ * @param {string} cmd - shell 命令。
423
+ * @returns {boolean} 是否写了设备状态。
424
+ */
425
+ function isWriteCommand(cmd) {
426
+ for (const raw of String(cmd).split(/[;&|\n]+/)) {
427
+ const segment = raw.trim().replace(/^\(+\s*/, '')
428
+ if (segment === '') continue
429
+ const [head, second] = segment.split(/\s+/)
430
+ if (WRITE_HEADS.has(head)) {
431
+ /* `wm size` 只是读分辨率,其余 wm 子命令都会改状态。 */
432
+ if (head === 'wm' && second === 'size') continue
433
+ return true
434
+ }
435
+ if (READ_ONLY_HEADS.has(head)) continue
436
+ return true
437
+ }
438
+ return false
439
+ }
440
+
441
+ /**
442
+ * 失败结果的给用户看的说法:超时和明确报错要分开,超时不能说成「没做成」。
443
+ * @param {string} what - 动作名。
444
+ * @param {{ out: string, uncertain?: boolean }} result - sh 的结果。
445
+ * @returns {string} 错误信息。
446
+ */
447
+ function failureNote(what, result) {
448
+ if (result.uncertain) return `${what}超时:设备可能已经执行,先用 adb_ui 看界面再决定要不要重试(不会自动重放)。`
449
+ return `${what}失败:${oneLine(result.out || '命令超时', 160)}`
450
+ }
451
+
334
452
  /**
335
453
  * 在常驻 shell 里跑一条命令。
454
+ * 命令包一层 `( … ) 2>&1` 让 stderr 也进输出,再用唯一标记夹住并在标记后写 `$?`:
455
+ * 读到标记和退出码才算跑完;没读到就是超时或通道断了,返回 code:null(不确定)。
336
456
  * @param {string} serial - 设备。
337
457
  * @param {string} cmd - shell 命令。
338
458
  * @param {number} timeout - 毫秒。
339
- * @returns {Promise<{ ok: boolean, out: string, ms: number }>} 结果。
459
+ * @returns {Promise<{ ok: boolean, code: number | null, out: string, ms: number, uncertain?: boolean }>} 结果。
340
460
  */
341
461
  function shellOnce(serial, cmd, timeout) {
342
462
  return new Promise((resolve) => {
343
463
  let channel = shellChannels.get(serial)
344
464
  if (channel === undefined || channel.dead) channel = openShell(serial)
345
- const tag = `@S${(Math.random() * 1e9) | 0}@`
465
+ /* 标记只用字母数字:不进 printf 格式串,也不会被引号、百分号弄坏。 */
466
+ const token = `dsh${Date.now().toString(36)}${(Math.random() * 1e9) | 0}`
467
+ const marker = `@${token}:`
346
468
  const started = performance.now()
347
469
  let done = false
348
- /** 收尾:只认第一次,顺手摘掉监听。 @param {string} out - 输出。 @param {boolean} ok - 是否成功。 @returns {void} */
349
- const finish = (out, ok) => {
470
+ /**
471
+ * 收尾:只认第一次,顺手摘掉监听。
472
+ * @param {number | null} code - 退出码;null 表示没读到(超时或通道断了)。
473
+ * @param {string} out - 命令输出(stdout + stderr)。
474
+ * @returns {void}
475
+ */
476
+ const finish = (code, out) => {
350
477
  if (done) return
351
478
  done = true
352
479
  clearTimeout(timer)
353
480
  channel.onChunk = null
354
481
  channel.child.removeListener('exit', onExit)
355
- resolve({ ok, out, ms: Math.round(performance.now() - started) })
482
+ const ms = Math.round(performance.now() - started)
483
+ if (code === null) resolve({ ok: false, code: null, out, ms, uncertain: true })
484
+ else resolve({ ok: code === 0, code, out, ms })
356
485
  }
357
486
  /** 子进程退出:这次命令等不到了。 @returns {void} */
358
- const onExit = () => finish('', false)
487
+ const onExit = () => finish(null, '')
359
488
  channel.buf = ''
360
489
  channel.onChunk = (text) => {
361
- const cut = text.indexOf(tag)
490
+ const cut = text.indexOf(marker)
362
491
  if (cut < 0) return
363
- finish(text.slice(0, cut), true)
492
+ /* 标记可能被拆在两个 chunk 里,退出码还没收全就继续等。 */
493
+ const matched = /^(\d+)@/.exec(text.slice(cut + marker.length))
494
+ if (matched === null) return
495
+ finish(Number(matched[1]), text.slice(0, cut))
364
496
  }
365
497
  const timer = setTimeout(() => {
366
498
  killShell(serial)
367
- finish('', false)
499
+ finish(null, '')
368
500
  }, timeout)
369
501
  timer.unref?.()
370
502
  channel.child.once('exit', onExit)
371
503
  try {
372
- channel.child.stdin.write(`${cmd}; echo ${tag}\n`)
504
+ channel.child.stdin.write(`( ${cmd} ) 2>&1; printf '@${token}:%s@\\n' "$?"\n`)
373
505
  } catch {
374
506
  killShell(serial)
375
- finish('', false)
507
+ finish(null, '')
376
508
  }
377
509
  })
378
510
  }
379
511
 
380
512
  /**
381
- * 跑一条 shell 命令:优先走常驻通道,失败退回一次性 `adb shell`。
513
+ * 跑一条 shell 命令:优先走常驻通道,只有「确定没执行」的读命令才退回一次性 `adb shell`。
382
514
  * @param {string} serial - 设备。
383
515
  * @param {string} cmd - shell 命令。
384
516
  * @param {number} timeout - 毫秒。
385
- * @returns {Promise<{ ok: boolean, out: string, ms: number }>} 结果。
517
+ * @returns {Promise<{ ok: boolean, code: number | null, out: string, ms: number, uncertain?: boolean, reason?: string }>} 结果。
386
518
  */
387
519
  async function sh(serial, cmd, timeout = 15_000) {
388
520
  const previous = shellQueues.get(serial) ?? Promise.resolve()
@@ -394,10 +526,20 @@ async function sh(serial, cmd, timeout = 15_000) {
394
526
  await previous
395
527
  try {
396
528
  const fast = await shellOnce(serial, cmd, timeout)
397
- if (fast.ok) return fast
529
+ if (fast.code !== null) return fast
530
+ /* 没读到退出码:命令可能已经执行了一半。写命令重放就是重复点击/重复发送,不重试。 */
531
+ if (isWriteCommand(cmd)) {
532
+ return { ok: false, code: null, out: fast.out, ms: fast.ms, uncertain: true, reason: '超时,设备可能已执行' }
533
+ }
398
534
  const started = performance.now()
399
535
  const slow = await adb(['-s', serial, 'shell', cmd], { timeout, direct: true })
400
- return { ok: slow.ok, out: slow.out, ms: Math.round(performance.now() - started) }
536
+ return {
537
+ ok: slow.ok,
538
+ code: slow.ok ? 0 : (slow.timedOut ? null : 1),
539
+ out: `${slow.out}${slow.err ?? ''}`,
540
+ ms: Math.round(performance.now() - started),
541
+ ...(slow.ok ? {} : { uncertain: slow.timedOut }),
542
+ }
401
543
  } finally {
402
544
  release()
403
545
  }
@@ -592,12 +734,18 @@ async function u2Start(serial) {
592
734
  */
593
735
  async function u2Port(serial) {
594
736
  const cached = u2Cache.get(serial)
595
- if (cached && Date.now() - cached.at < 120_000 && await u2Alive(cached.port)) return cached.port
737
+ if (cached && Date.now() - cached.at < 120_000 && await u2Alive(cached.port)) {
738
+ /* 缓存端口也要先确认转发还在、而且属于这台设备:别的设备的转发会被误当成自己的。 */
739
+ const rows = await adb(['-s', serial, 'forward', '--list'], { timeout: 8000 })
740
+ if (forwardFor(rows.out, serial, `tcp:${U2_PORT}`) !== null) return cached.port
741
+ u2Cache.delete(serial)
742
+ }
596
743
  const list = await adb(['-s', serial, 'forward', '--list'], { timeout: 8000 })
597
- const reused = new RegExp(`tcp:(\\d+) tcp:${U2_PORT}\\b`).exec(list.out)
598
- if (reused && await u2Alive(Number(reused[1]))) {
599
- u2Cache.set(serial, { port: Number(reused[1]), at: Date.now() })
600
- return Number(reused[1])
744
+ const reused = forwardFor(list.out, serial, `tcp:${U2_PORT}`)
745
+ const reusedPort = reused === null ? 0 : Number(reused.local.replace(/^tcp:/, ''))
746
+ if (Number.isFinite(reusedPort) && reusedPort > 0 && await u2Alive(reusedPort)) {
747
+ u2Cache.set(serial, { port: reusedPort, at: Date.now() })
748
+ return reusedPort
601
749
  }
602
750
  /* `adb forward tcp:0` 在部分 adb 版本上直接失败(实测 37.0.1 返回非 0),
603
751
  所以自己挑几个端口试,能 ping 通即用。 */
@@ -608,6 +756,7 @@ async function u2Port(serial) {
608
756
  lastError = oneLine(created.err || created.out || `adb forward 退出码 ${created.code}`, 160)
609
757
  continue
610
758
  }
759
+ ownForward(serial, `tcp:${port}`, `tcp:${U2_PORT}`)
611
760
  if (!await u2Alive(port)) {
612
761
  try {
613
762
  await u2Start(serial)
@@ -781,10 +930,24 @@ async function foregroundActivity(serial) {
781
930
  }
782
931
 
783
932
  /**
784
- * 当前设备上最近一次文本树,用于把 ref 还原成坐标。
785
- * @type {Map<string, {refs: Array<{ref: number, node: any}>, at: number}>}
933
+ * 当前设备上最近一次界面快照,用于把 ref 还原成坐标。
934
+ * 快照带编号:ref 只在生成它的那次快照里有效,跨快照用同一个 ref 会点到别的地方。
935
+ * @type {Map<string, {snapshotId: string, serial: string, activity: string, at: number, refs: Array<{ref: number, node: any}>, nodes: any[], signature: string}>}
786
936
  */
787
937
  const lastTrees = new Map()
938
+
939
+ /** 快照序号:只增不减,和时间戳一起组成快照编号。 */
940
+ let snapshotSeq = 0
941
+
942
+ /**
943
+ * 生成一次快照的编号。
944
+ * @param {string} serial - 设备。
945
+ * @returns {string} 形如 `R58C12A3:7:1730000000000`。
946
+ */
947
+ function nextSnapshotId(serial) {
948
+ snapshotSeq += 1
949
+ return `${serial}:${snapshotSeq}:${Date.now()}`
950
+ }
788
951
  /** 上一次 adb_ui 的行,changed_only 用来做差。 */
789
952
  const lastUiLines = new Map()
790
953
 
@@ -963,34 +1126,70 @@ async function dumpState(serial, options) {
963
1126
  const signature = tree.lines.join('\n')
964
1127
  const previous = lastTrees.get(serial)
965
1128
  const changed = !previous || previous.signature !== signature
966
- lastTrees.set(serial, { refs: tree.refs, nodes, at: Date.now(), signature, activity })
967
- return { serial, activity, nodes, tree, changed, ms: Date.now() - started, source, ocrNote }
1129
+ const at = Date.now()
1130
+ const snapshotId = nextSnapshotId(serial)
1131
+ lastTrees.set(serial, { snapshotId, serial, activity, at, refs: tree.refs, nodes, signature })
1132
+ return { serial, activity, nodes, tree, changed, ms: at - started, source, ocrNote, snapshotId, generated_at: new Date(at).toISOString() }
968
1133
  }
969
1134
 
970
1135
  /**
971
- * 找 ref 对应的节点。
1136
+ * 校验手上这次快照还能用:没有快照、过期、或编号对不上都直接说清楚,不拿旧 ref 乱点。
1137
+ * @param {string} serial - 设备。
1138
+ * @param {string | undefined} snapshotId - 调用方带的快照编号(可省略)。
1139
+ * @param {string} what - 用于错误信息的名词(ref / 文字)。
1140
+ * @returns {any} 快照条目。
1141
+ */
1142
+ function checkSnapshot(serial, snapshotId, what) {
1143
+ const cached = lastTrees.get(serial)
1144
+ if (!cached) throw new Error(`还没有界面快照:先调用 adb_ui 读一次界面,再用它给的 ${what}。`)
1145
+ if (Date.now() - cached.at > REF_TTL_MS) throw new Error('界面快照已过期,请重新 adb_ui。')
1146
+ if (snapshotId !== undefined && snapshotId !== null && String(snapshotId) !== '' && String(snapshotId) !== cached.snapshotId) {
1147
+ throw new Error(`快照已变:你手上的是 ${snapshotId},当前是 ${cached.snapshotId}(之后又 dump 过界面),先 adb_ui 看最新界面再操作。`)
1148
+ }
1149
+ return cached
1150
+ }
1151
+
1152
+ /**
1153
+ * 找 ref 对应的节点。ref 只在生成它的那次快照里有效,调用方应带上 snapshot_id。
972
1154
  * @param {string} serial - 设备。
973
1155
  * @param {number} ref - 引用号。
1156
+ * @param {string} [snapshotId] - 调用方手上的快照编号。
974
1157
  * @returns {any} 节点。
975
1158
  */
976
- function nodeForRef(serial, ref) {
977
- const cached = lastTrees.get(serial)
978
- if (!cached) throw new Error('还没有界面树:先调用 adb_ui 读一次界面,再用它给的 ref 点击。')
979
- if (Date.now() - cached.at > REF_TTL_MS) throw new Error('ref 已过期(超过 2 分钟或界面已变):重新调用 adb_ui,用新的 ref。')
1159
+ function nodeForRef(serial, ref, snapshotId) {
1160
+ const cached = checkSnapshot(serial, snapshotId, 'ref')
980
1161
  const found = cached.refs.find((entry) => entry.ref === ref)
981
1162
  if (!found) throw new Error(`没有 ref=${ref}:ref 编号每次 adb_ui 都会重新分配,用最新一次返回的编号。`)
982
1163
  return found.node
983
1164
  }
984
1165
 
1166
+ /**
1167
+ * 按文字/id 找控件前先拿到可用快照:缺失、过期、或当前页面已经换了一页就重新 dump 一次。
1168
+ * 这比拿两分钟前的坐标去点安全,也比直接报错少一个模型回合。
1169
+ * @param {string} serial - 设备。
1170
+ * @param {string} [snapshotId] - 调用方带的快照编号;带了且对不上就直接报错,不偷偷换快照。
1171
+ * @returns {Promise<any>} 快照条目。
1172
+ */
1173
+ async function snapshotForSelector(serial, snapshotId) {
1174
+ const cached = lastTrees.get(serial)
1175
+ if (cached !== undefined && snapshotId !== undefined && snapshotId !== null && String(snapshotId) !== ''
1176
+ && String(snapshotId) !== cached.snapshotId) {
1177
+ throw new Error(`快照已变:你手上的是 ${snapshotId},当前是 ${cached.snapshotId}(之后又 dump 过界面),先 adb_ui 看最新界面再操作。`)
1178
+ }
1179
+ if (cached === undefined || Date.now() - cached.at > REF_TTL_MS) return dumpState(serial, {})
1180
+ const live = await foregroundActivity(serial).catch(() => '')
1181
+ if (live !== '' && cached.activity !== '' && live !== cached.activity) return dumpState(serial, {})
1182
+ return cached
1183
+ }
1184
+
985
1185
  /**
986
1186
  * 按文字/资源 id/描述找节点。文字优先精确匹配,再退化为包含匹配。
987
1187
  * @param {string} serial - 设备。
988
- * @param {{text?: string, id?: string, desc?: string}} want - 选择器。
1188
+ * @param {{text?: string, id?: string, desc?: string, snapshotId?: string}} want - 选择器。
989
1189
  * @returns {any} 节点。
990
1190
  */
991
1191
  function nodeForSelector(serial, want) {
992
- const cached = lastTrees.get(serial)
993
- if (!cached) throw new Error('还没有界面树:先调用 adb_ui 读一次界面,再按文字/id 点击。')
1192
+ const cached = checkSnapshot(serial, want.snapshotId, '文字/id')
994
1193
  const refs = cached.refs.map((entry) => entry.node)
995
1194
  if (want.text) {
996
1195
  const exact = refs.find((node) => node.text === want.text || node.desc === want.text)
@@ -1076,6 +1275,33 @@ function shellInputText(text) {
1076
1275
  return { ok: true, value: `'${escaped}'` }
1077
1276
  }
1078
1277
 
1278
+ /**
1279
+ * 往当前焦点输入框输入文字:能走 `input text` 就走(快),中文/emoji 走剪贴板粘贴。
1280
+ * adb_input 与 adb_do 的 text 步骤共用这一条路径,避免两处各自只支持一半。
1281
+ * @param {string} serial - 设备。
1282
+ * @param {string} text - 要输入的文字。
1283
+ * @returns {Promise<{how: 'input' | 'paste', steps: string[], field: string, note: string}>} 结果。
1284
+ */
1285
+ async function inputTextVia(serial, text) {
1286
+ const wanted = String(text)
1287
+ const count = [...wanted].length
1288
+ if (wanted === '') throw new Error('要输入的文字是空的。')
1289
+ const encoded = shellInputText(wanted)
1290
+ if (encoded.ok) {
1291
+ const result = await sh(serial, `input text ${encoded.value}`, 15_000)
1292
+ if (!result.ok) throw new Error(failureNote('输入', result))
1293
+ return { how: 'input', steps: ['input text'], field: '', note: `已输入 ${count} 个字符` }
1294
+ }
1295
+ /* 中文没有 input text 可用:写剪贴板再长按粘贴,走的是系统输入法自己的通道。 */
1296
+ const pasted = await typeViaClipboard(serial, wanted)
1297
+ return {
1298
+ how: 'paste',
1299
+ steps: pasted.steps,
1300
+ field: pasted.field,
1301
+ note: `已粘贴 ${count} 个字符(${pasted.steps.join(' → ')})`,
1302
+ }
1303
+ }
1304
+
1079
1305
  /**
1080
1306
  * 读 PNG 的 IHDR 尺寸(字节 16..24)。缩图后以文件为准,避免手算误差。
1081
1307
  * @param {string} path - PNG 路径。
@@ -1250,6 +1476,9 @@ export function apply(ctx, config) {
1250
1476
  mirror.stopAll()
1251
1477
  stopOcr()
1252
1478
  for (const device of [...shellChannels.keys()]) killShell(device)
1479
+ /* 只拆本插件自己建的转发(按 serial 区分),别的设备/别的工具留的不要动。 */
1480
+ const created = new Set([...ownedForwards.values()].map((item) => item.serial))
1481
+ for (const device of created) void releaseForward(device).catch(() => {})
1253
1482
  }, 'adb: 实时镜像退出')
1254
1483
 
1255
1484
  ctx.effect(() => ctx.tools.register({
@@ -1286,9 +1515,11 @@ export function apply(ctx, config) {
1286
1515
  tree: { type: 'string' },
1287
1516
  changed: { type: 'boolean' },
1288
1517
  matched: { type: 'boolean' },
1518
+ snapshot_id: { type: 'string' },
1519
+ generated_at: { type: 'string' },
1289
1520
  elapsed_ms: { type: 'integer' },
1290
1521
  },
1291
- required: ['serial', 'activity', 'summary', 'tree', 'changed', 'matched', 'elapsed_ms'],
1522
+ required: ['serial', 'activity', 'summary', 'tree', 'changed', 'matched', 'snapshot_id', 'generated_at', 'elapsed_ms'],
1292
1523
  additionalProperties: false,
1293
1524
  },
1294
1525
  render: (_args, value) => textBlock(`${value.summary}\n${value.tree}`),
@@ -1351,6 +1582,8 @@ export function apply(ctx, config) {
1351
1582
  tree: shown.join('\n') || '(没有匹配的控件)',
1352
1583
  changed: state.changed,
1353
1584
  matched,
1585
+ snapshot_id: state.snapshotId,
1586
+ generated_at: state.generated_at,
1354
1587
  elapsed_ms: Date.now() - started,
1355
1588
  }
1356
1589
  },
@@ -1362,7 +1595,8 @@ export function apply(ctx, config) {
1362
1595
  parameters: {
1363
1596
  type: 'object',
1364
1597
  properties: {
1365
- ref: { type: 'integer', description: 'adb_ui 返回的 ref 编号(只对最近一次 adb_ui 有效)' },
1598
+ ref: { type: 'integer', description: 'adb_ui 返回的 ref 编号(只对生成它的那次快照有效,界面重新 dump 过就作废)' },
1599
+ snapshot_id: { type: 'string', description: '可选:adb_ui 返回的 snapshot_id。带上就校验快照还是不是那次,对不上直接报错,不拿旧 ref 点。' },
1366
1600
  text: { type: 'string', description: '按钮/文字内容,精确匹配优先,其次包含匹配' },
1367
1601
  id: { type: 'string', description: '资源 id,可写全名或只写后半段,如 login_btn' },
1368
1602
  desc: { type: 'string', description: 'content-desc 内容' },
@@ -1404,11 +1638,15 @@ export function apply(ctx, config) {
1404
1638
  ;[x, y] = parts
1405
1639
  target = `坐标 ${x},${y}`
1406
1640
  } else {
1407
- /* 这次会话还没读过树就自动读一次:少一个来回,也不用先让模型调 adb_ui。 */
1408
- if (!lastTrees.has(device)) await dumpState(device, {})
1409
- const node = args.ref !== undefined
1410
- ? nodeForRef(device, Number(args.ref))
1411
- : nodeForSelector(device, { text: args.text, id: args.id, desc: args.desc })
1641
+ /* ref 严格按快照校验:快照过期或之后又 dump 过就报错,不拿旧 ref 乱点。
1642
+ 按文字/id 点则允许自动重读:页面换了就 dump 一次再找,比拿旧坐标点安全。 */
1643
+ let node
1644
+ if (args.ref !== undefined) {
1645
+ node = nodeForRef(device, Number(args.ref), args.snapshot_id)
1646
+ } else {
1647
+ await snapshotForSelector(device, args.snapshot_id)
1648
+ node = nodeForSelector(device, { text: args.text, id: args.id, desc: args.desc, snapshotId: args.snapshot_id })
1649
+ }
1412
1650
  const hit = clickTarget(nodesOf(device), node)
1413
1651
  x = Math.round((hit.x1 + hit.x2) / 2)
1414
1652
  y = Math.round((hit.y1 + hit.y2) / 2)
@@ -1418,12 +1656,20 @@ export function apply(ctx, config) {
1418
1656
  }
1419
1657
  /* 轻点用 input tap(一条命令 ~35ms);长按只能靠 swipe 原地按住 600ms。 */
1420
1658
  if (args.dry_run) {
1421
- return { ok: true, target, message: `预演:会${args.long ? '长按' : '点击'} ${target}(没有真点)。确认无误去掉 dry_run 再点。`, elapsed_ms: Date.now() - started }
1659
+ const activity = lastTrees.get(device)?.activity ?? await foregroundActivity(device).catch(() => '')
1660
+ return {
1661
+ ok: true,
1662
+ target,
1663
+ activity,
1664
+ matched: true,
1665
+ message: `预演:会${args.long ? '长按' : '点击'} ${target}(没有真点)。确认无误去掉 dry_run 再点。`,
1666
+ elapsed_ms: Date.now() - started,
1667
+ }
1422
1668
  }
1423
1669
  const r = args.long
1424
1670
  ? await sh(device, `input swipe ${x} ${y} ${x} ${y} 600`, 10_000)
1425
1671
  : await sh(device, `input tap ${x} ${y}`, 10_000)
1426
- if (!r.ok) throw new Error(`点击失败:${oneLine(r.out || '命令超时', 200)}`)
1672
+ if (!r.ok) throw new Error(failureNote(args.long ? '长按' : '点击', r))
1427
1673
 
1428
1674
  lastActions.push({ at: Date.now(), what: `${args.long ? '长按' : '点击'} ${target}` })
1429
1675
  /* 点完顺手等结果:一次工具调用顶「点一下 + 再读一次树」,省一个模型回合。 */
@@ -1513,7 +1759,7 @@ export function apply(ctx, config) {
1513
1759
  for (const point of path.slice(1)) script.push(`sleep ${pause}`, `input motionevent MOVE ${point.join(' ')}`)
1514
1760
  script.push(`input motionevent UP ${path[path.length - 1].join(' ')}`)
1515
1761
  const r = await sh(device, script.join('; '), 30_000)
1516
- if (!r.ok) throw new Error(`手势失败:${oneLine(r.out || '命令超时', 200)}`)
1762
+ if (!r.ok) throw new Error(failureNote('手势', r))
1517
1763
  lastActions.push({ at: Date.now(), what: `手势 ${points[0].join(',')}→${points[points.length - 1].join(',')}(${duration}ms)` })
1518
1764
  return {
1519
1765
  ok: true,
@@ -1529,7 +1775,7 @@ export function apply(ctx, config) {
1529
1775
  parameters: {
1530
1776
  type: 'object',
1531
1777
  properties: {
1532
- text: { type: 'string', description: '要输入的文字(ASCII);先点中输入框再调用' },
1778
+ text: { type: 'string', description: '要输入的文字(ASCII 直发,中文走剪贴板粘贴);先点中输入框再调用' },
1533
1779
  key: { type: 'string', description: '按键名,如 ENTER、BACK、HOME、DEL、TAB、APP_SWITCH,或数字 keycode' },
1534
1780
  swipe: {
1535
1781
  type: 'array',
@@ -1558,30 +1804,22 @@ export function apply(ctx, config) {
1558
1804
  const started = Date.now()
1559
1805
  const messages = []
1560
1806
  if (args.clear) {
1561
- await sh(device, 'input keyevent KEYCODE_MOVE_END; for i in $(seq 40); do input keyevent KEYCODE_DEL; done', 20_000)
1807
+ const cleared = await sh(device, 'input keyevent KEYCODE_MOVE_END; for i in $(seq 40); do input keyevent KEYCODE_DEL; done', 20_000)
1808
+ if (!cleared.ok) throw new Error(failureNote('清空输入框', cleared))
1562
1809
  messages.push('已清空输入框')
1563
1810
  }
1564
1811
  if (args.text !== undefined) {
1565
1812
  const wanted = String(args.text)
1566
- const encoded = shellInputText(wanted)
1567
- if (!encoded.ok) {
1568
- /* 中文没有 input text 可用:写剪贴板再长按粘贴,走的是系统输入法自己的通道。 */
1569
- const pasted = await typeViaClipboard(device, wanted)
1570
- lastActions.push({ at: Date.now(), what: `粘贴文字 ${wanted.length} 字` })
1571
- messages.push(`已粘贴 ${wanted.length} 个字符(${pasted.steps.join(' → ')})`)
1572
- if (pasted.field !== '') messages.push(`输入框现在是「${oneLine(pasted.field, 40)}」`)
1573
- } else {
1574
- const r = await sh(device, `input text ${encoded.value}`, 15_000)
1575
- if (!r.ok) throw new Error(`输入失败:${oneLine(r.out || '命令超时', 200)}`)
1576
- lastActions.push({ at: Date.now(), what: `输入文字 ${wanted.length} 字` })
1577
- messages.push(`已输入 ${wanted.length} 个字符`)
1578
- }
1813
+ const typed = await inputTextVia(device, wanted)
1814
+ lastActions.push({ at: Date.now(), what: typed.how === 'paste' ? `粘贴文字 ${wanted.length} 字` : `输入文字 ${wanted.length} 字` })
1815
+ messages.push(typed.note)
1816
+ if (typed.field !== '') messages.push(`输入框现在是「${oneLine(typed.field, 40)}」`)
1579
1817
  }
1580
1818
  if (args.key !== undefined) {
1581
1819
  const key = String(args.key).trim()
1582
1820
  const name = /^\d+$/.test(key) ? key : key.toUpperCase()
1583
1821
  const r = await sh(device, `input keyevent ${name}`, 8000)
1584
- if (!r.ok) throw new Error(`按键失败:${oneLine(r.out || '命令超时', 200)}`)
1822
+ if (!r.ok) throw new Error(failureNote(`按键 ${name}`, r))
1585
1823
  lastActions.push({ at: Date.now(), what: `按键 ${name}` })
1586
1824
  messages.push(`已按 ${name}`)
1587
1825
  }
@@ -1590,7 +1828,7 @@ export function apply(ctx, config) {
1590
1828
  if (values.length < 4) throw new Error('swipe 至少给 4 个数:x1,y1,x2,y2')
1591
1829
  const duration = values[4] ?? 300
1592
1830
  const r = await sh(device, `input swipe ${values.slice(0, 4).join(' ')} ${duration}`, 15_000)
1593
- if (!r.ok) throw new Error(`滑动失败:${oneLine(r.out || '命令超时', 200)}`)
1831
+ if (!r.ok) throw new Error(failureNote('滑动', r))
1594
1832
  lastActions.push({ at: Date.now(), what: `滑动 ${values.slice(0, 4).join(',')}` })
1595
1833
  messages.push(`已滑动 ${values.slice(0, 4).join(',')}(${duration}ms)`)
1596
1834
  }
@@ -1695,10 +1933,12 @@ export function apply(ctx, config) {
1695
1933
  type: 'object',
1696
1934
  properties: {
1697
1935
  ok: { type: 'boolean' },
1936
+ code: { type: 'integer' },
1937
+ uncertain: { type: 'boolean' },
1698
1938
  output: { type: 'string' },
1699
1939
  elapsed_ms: { type: 'integer' },
1700
1940
  },
1701
- required: ['ok', 'output', 'elapsed_ms'],
1941
+ required: ['ok', 'code', 'uncertain', 'output', 'elapsed_ms'],
1702
1942
  additionalProperties: false,
1703
1943
  },
1704
1944
  render: (_args, value) => textBlock(value.output),
@@ -1712,8 +1952,13 @@ export function apply(ctx, config) {
1712
1952
  ? await adb(['-s', device, 'shell', cmd], { timeout: wait, buffer: 16 << 20 })
1713
1953
  : await sh(device, cmd, wait)
1714
1954
  const body = `${r.out}${r.err ?? ''}`.trim()
1715
- const output = body.length > OUTPUT_LIMIT ? `${body.slice(0, OUTPUT_LIMIT)}\n…(截断,原长 ${body.length} 字符)` : body
1716
- return { ok: r.ok, output: output || '(无输出)', elapsed_ms: r.ms }
1955
+ const cut = body.length > OUTPUT_LIMIT ? `${body.slice(0, OUTPUT_LIMIT)}\n…(截断,原长 ${body.length} 字符)` : body
1956
+ /* 退出码和「超时但可能已执行」都要写出来:只看 ok 会把失败的命令当成成功。 */
1957
+ const notes = []
1958
+ if (typeof r.code === 'number' && r.code !== 0) notes.push(`退出码 ${r.code}`)
1959
+ if (r.uncertain) notes.push('命令超时:设备可能已经执行,先看界面或日志确认结果,再决定要不要重试(不会自动重放)。')
1960
+ const output = `${cut || '(无输出)'}${notes.length > 0 ? `\n${notes.join('\n')}` : ''}`
1961
+ return { ok: r.ok, code: typeof r.code === 'number' ? r.code : -1, uncertain: r.uncertain === true, output, elapsed_ms: r.ms }
1717
1962
  },
1718
1963
  }), 'adb: adb_sh')
1719
1964
 
@@ -1738,7 +1983,7 @@ export function apply(ctx, config) {
1738
1983
  id: { type: 'string', description: '按资源 id 点' },
1739
1984
  swipe: { type: 'array', items: { type: 'integer' }, description: '滑动 [x1,y1,x2,y2,毫秒]' },
1740
1985
  key: { type: 'string', description: '按键名:BACK / HOME / ENTER,或 keycode 数字' },
1741
- text: { type: 'string', description: '往焦点输入框输入(仅 ASCII)' },
1986
+ text: { type: 'string', description: '往焦点输入框输入文字(ASCII 直发,中文走剪贴板粘贴;先点中输入框让它获得焦点)' },
1742
1987
  action: { type: 'string', description: '系统动作:home / back / recents / lock / wake / notifications / quick_settings' },
1743
1988
  wait_text: { type: 'string', description: '等这段文字出现再做下一步' },
1744
1989
  wait_gone: { type: 'string', description: '等这段文字消失再做下一步' },
@@ -1782,7 +2027,8 @@ export function apply(ctx, config) {
1782
2027
  const parts = String(step.tap).split(',').map((value) => Number(value.trim()))
1783
2028
  point = { x: parts[0], y: parts[1] }
1784
2029
  } else {
1785
- if (!lastTrees.has(device)) await dumpState(device, {})
2030
+ /* 按文字/id 点之前先确认快照还对应现在的界面,页面换了就重新 dump,不拿旧坐标点。 */
2031
+ await snapshotForSelector(device)
1786
2032
  node = nodeForSelector(device, { text: step.tap, id: step.id })
1787
2033
  }
1788
2034
  let target
@@ -1795,7 +2041,7 @@ export function apply(ctx, config) {
1795
2041
  target = { x, y, label: `「${oneLine(node.text || node.desc || shortId(node.id) || shortClass(node.cls), 20)}」@${x},${y}` }
1796
2042
  }
1797
2043
  const r = await sh(device, `input tap ${target.x} ${target.y}`, 10_000)
1798
- if (!r.ok) throw new Error(`第 ${at} 步点击失败:${oneLine(r.out || '命令超时', 160)}`)
2044
+ if (!r.ok) throw new Error(`第 ${at} 步${failureNote('点击', r)}`)
1799
2045
  lastActions.push({ at: Date.now(), what: `点击 ${target.label}` })
1800
2046
  done.push(`点 ${target.label}`)
1801
2047
  } else if (step.swipe !== undefined) {
@@ -1803,24 +2049,23 @@ export function apply(ctx, config) {
1803
2049
  if (!Array.isArray(s4) || s4.length < 4) throw new Error(`第 ${at} 步 swipe 要写 [x1,y1,x2,y2,毫秒]。`)
1804
2050
  const ms = s4[4] ?? 300
1805
2051
  const r = await sh(device, `input swipe ${s4[0]} ${s4[1]} ${s4[2]} ${s4[3]} ${ms}`, 15_000)
1806
- if (!r.ok) throw new Error(`第 ${at} 步滑动失败:${oneLine(r.out || '命令超时', 160)}`)
2052
+ if (!r.ok) throw new Error(`第 ${at} 步${failureNote('滑动', r)}`)
1807
2053
  done.push(`滑 ${s4[0]},${s4[1]}→${s4[2]},${s4[3]}`)
1808
2054
  } else if (step.key !== undefined) {
1809
2055
  const code = /^\d+$/.test(String(step.key)) ? String(step.key) : String(step.key).toUpperCase()
1810
2056
  const r = await sh(device, `input keyevent ${code}`, 10_000)
1811
- if (!r.ok) throw new Error(`第 ${at} 步按键失败:${oneLine(r.out || '命令超时', 160)}`)
2057
+ if (!r.ok) throw new Error(`第 ${at} 步${failureNote(`按键 ${code}`, r)}`)
1812
2058
  done.push(`键 ${code}`)
1813
2059
  } else if (step.text !== undefined) {
1814
- const bad = [...String(step.text)].filter((char) => char.charCodeAt(0) > 127)
1815
- if (bad.length > 0) throw new Error(`第 ${at} 步含非 ASCII 字符(${[...new Set(bad)].slice(0, 5).join('')}),adb input text 不支持中文。`)
1816
- const r = await sh(device, `input text '${String(step.text).replace(/'/g, "'\\''")}'`, 15_000)
1817
- if (!r.ok) throw new Error(`第 ${at} 步输入失败:${oneLine(r.out || '命令超时', 160)}`)
2060
+ /* 与 adb_input 共用一条路径:ASCII 走 input text,中文走剪贴板粘贴。 */
2061
+ const typed = await inputTextVia(device, String(step.text))
2062
+ lastActions.push({ at: Date.now(), what: typed.how === 'paste' ? `粘贴文字 ${String(step.text).length} 字` : `输入文字 ${String(step.text).length} 字` })
1818
2063
  done.push(`输入 ${oneLine(String(step.text), 20)}`)
1819
2064
  } else if (step.action !== undefined) {
1820
2065
  const cmd = ACTION_MAP[step.action]
1821
2066
  if (cmd === undefined) throw new Error(`第 ${at} 步 action 不认识:${step.action}`)
1822
2067
  const r = await sh(device, cmd, 12_000)
1823
- if (!r.ok) throw new Error(`第 ${at} 步系统动作失败:${oneLine(r.out || '命令超时', 160)}`)
2068
+ if (!r.ok) throw new Error(`第 ${at} 步${failureNote('系统动作', r)}`)
1824
2069
  done.push(step.action)
1825
2070
  }
1826
2071
 
@@ -1852,6 +2097,7 @@ export function apply(ctx, config) {
1852
2097
  type: 'object',
1853
2098
  properties: {
1854
2099
  ref: { type: 'integer', description: '目标控件 ref,来自最近一次 adb_ui(和 text/id 三选一)' },
2100
+ snapshot_id: { type: 'string', description: '可选:adb_ui 返回的 snapshot_id;带上就校验 ref 属于那次快照,对不上直接报错' },
1855
2101
  text: { type: 'string', description: '目标控件文字' },
1856
2102
  id: { type: 'string', description: '目标控件资源 id' },
1857
2103
  duration_ms: { type: 'integer', description: '采样时长,默认 2000,上限 20000' },
@@ -1866,7 +2112,7 @@ export function apply(ctx, config) {
1866
2112
  tap: { type: 'string', description: '文字内容,或 "x,y" 坐标' },
1867
2113
  swipe: { type: 'array', description: '滑动 [x1,y1,x2,y2,毫秒]', items: { type: 'integer' } },
1868
2114
  id: { type: 'string', description: '资源 id' },
1869
- text: { type: 'string', description: '要输入的文字(ASCII)' },
2115
+ text: { type: 'string', description: '要输入的文字(ASCII 直发,中文走剪贴板粘贴;先点中输入框)' },
1870
2116
  action: { type: 'string', description: '系统动作,同 adb_action' },
1871
2117
  },
1872
2118
  additionalProperties: false,
@@ -1898,8 +2144,14 @@ export function apply(ctx, config) {
1898
2144
  const began = Date.now()
1899
2145
  const duration = Math.min(Math.max(args.duration_ms ?? 2000, 300), 20_000)
1900
2146
  const wantedFps = Math.min(Math.max(args.fps ?? 6, 2), 10)
1901
- if (!lastTrees.has(device)) await dumpState(device, {})
1902
- const seed = args.ref !== undefined ? nodeForRef(device, args.ref) : nodeForSelector(device, { text: args.text, id: args.id })
2147
+ /* 起始对象:ref 严格按快照校验,text/id 允许自动重读一次再找。 */
2148
+ let seed
2149
+ if (args.ref !== undefined) {
2150
+ seed = nodeForRef(device, args.ref, args.snapshot_id)
2151
+ } else {
2152
+ await snapshotForSelector(device, args.snapshot_id)
2153
+ seed = nodeForSelector(device, { text: args.text, id: args.id, snapshotId: args.snapshot_id })
2154
+ }
1903
2155
  const key = { text: seed.text, id: seed.id, desc: seed.desc }
1904
2156
  const seedX = (seed.x1 + seed.x2) / 2
1905
2157
  const seedY = (seed.y1 + seed.y2) / 2
@@ -1958,7 +2210,7 @@ export function apply(ctx, config) {
1958
2210
  continue
1959
2211
  }
1960
2212
  if (step.text !== undefined) {
1961
- await adb(['-s', device, 'shell', 'input', 'text', String(step.text).replace(/ /g, '%s')], { timeout: 8000 })
2213
+ await inputTextVia(device, String(step.text))
1962
2214
  continue
1963
2215
  }
1964
2216
  if (Array.isArray(step.swipe)) {
@@ -2588,7 +2840,7 @@ ctx.effect(() => ctx.tools.register({
2588
2840
  throw new Error('action 只能是 home / back / recents / lock / wake / rotate / notifications / quick_settings / night_mode。')
2589
2841
  }
2590
2842
  const r = await sh(device, cmd, 12_000)
2591
- if (!r.ok) throw new Error(`${label}失败:${oneLine(r.out || '命令超时', 200)}`)
2843
+ if (!r.ok) throw new Error(failureNote(label, r))
2592
2844
  lastActions.push({ at: Date.now(), what: label })
2593
2845
  if (lastActions.length > 400) lastActions.splice(0, lastActions.length - 400)
2594
2846
  return { ok: true, summary: `${label}完成(${r.ms}ms)。界面变化用 adb_ui 确认。`, elapsed_ms: Date.now() - began }