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/client.js +68 -12
- package/client.rev +1 -1
- package/index.js +335 -83
- package/mirror.js +122 -21
- package/ocr.js +88 -17
- package/package.json +7 -3
- package/selftest.mjs +64 -12
- package/stream.js +103 -32
- package/test/coords.test.mjs +129 -0
- package/test/symbols.test.mjs +73 -0
- package/test/timers.test.mjs +60 -0
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=
|
|
26
|
-
import { ocrAvailable, ocrImage, stopOcr, toRects } from './ocr.js?v=
|
|
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
|
-
|
|
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)
|
|
244
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
349
|
-
|
|
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
|
-
|
|
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(''
|
|
487
|
+
const onExit = () => finish(null, '')
|
|
359
488
|
channel.buf = ''
|
|
360
489
|
channel.onChunk = (text) => {
|
|
361
|
-
const cut = text.indexOf(
|
|
490
|
+
const cut = text.indexOf(marker)
|
|
362
491
|
if (cut < 0) return
|
|
363
|
-
|
|
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(''
|
|
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(
|
|
504
|
+
channel.child.stdin.write(`( ${cmd} ) 2>&1; printf '@${token}:%s@\\n' "$?"\n`)
|
|
373
505
|
} catch {
|
|
374
506
|
killShell(serial)
|
|
375
|
-
finish(''
|
|
507
|
+
finish(null, '')
|
|
376
508
|
}
|
|
377
509
|
})
|
|
378
510
|
}
|
|
379
511
|
|
|
380
512
|
/**
|
|
381
|
-
* 跑一条 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.
|
|
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 {
|
|
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))
|
|
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 =
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
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
|
-
*
|
|
785
|
-
*
|
|
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
|
-
|
|
967
|
-
|
|
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
|
-
*
|
|
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 =
|
|
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 =
|
|
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
|
|
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
|
-
/*
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
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(
|
|
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(
|
|
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
|
|
1716
|
-
|
|
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: '
|
|
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
|
-
|
|
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}
|
|
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}
|
|
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}
|
|
2057
|
+
if (!r.ok) throw new Error(`第 ${at} 步${failureNote(`按键 ${code}`, r)}`)
|
|
1812
2058
|
done.push(`键 ${code}`)
|
|
1813
2059
|
} else if (step.text !== undefined) {
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
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}
|
|
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
|
-
|
|
1902
|
-
|
|
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
|
|
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(
|
|
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 }
|