dsh-mobilecode 0.6.0 → 0.7.0
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/README.md +68 -6
- package/lib/client.js +286 -27
- package/lib/device-build.js +77 -0
- package/lib/index.js +514 -23
- package/lib/list-rows.js +295 -0
- package/package.json +1 -1
package/lib/index.js
CHANGED
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
19
19
|
import * as DeviceBuild from './device-build.js'
|
|
20
20
|
import * as UiTree from './uitree.js'
|
|
21
|
+
import * as RowList from './list-rows.js'
|
|
21
22
|
import * as FrameSource from './frame-source.js'
|
|
22
23
|
import * as StreamAccess from './stream-access.js'
|
|
23
24
|
import { AndroidStreamHost, ROTATION_CYCLE } from './android-stream.js'
|
|
@@ -25,7 +26,8 @@ import * as Vision from './vision.js'
|
|
|
25
26
|
import { DevicePreviewEngine } from './device-preview.js'
|
|
26
27
|
import * as Setup from './setup.js'
|
|
27
28
|
import { registerMobileSkill } from './skill.js'
|
|
28
|
-
import { existsSync } from 'node:fs'
|
|
29
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
30
|
+
import { tmpdir } from 'node:os'
|
|
29
31
|
|
|
30
32
|
export const name = 'mobilecode'
|
|
31
33
|
export const inject = ['webServer', 'tools', 'systemPrompt']
|
|
@@ -385,14 +387,14 @@ function makeRoutes(engine, config, stream) {
|
|
|
385
387
|
if ((req.method ?? 'GET') !== 'GET') { writeJson(res, 405, { error: 'method not allowed' }); return }
|
|
386
388
|
const token = new URL(req.url ?? '/', 'http://localhost').searchParams.get('token') ?? ''
|
|
387
389
|
const payload = await stream.access.verifyStreamToken(token)
|
|
388
|
-
if (payload === undefined) { writeJson(res, 403, { error: 'the stream token is invalid or expired' }); return }
|
|
389
|
-
if (stream.host.streamedSerial !== payload.serial) { writeJson(res, 503, { error: 'the device stream is not running; request a fresh grant' }); return }
|
|
390
|
+
if (payload === undefined) { writeJson(res, 403, { code: 'token_invalid', error: 'the stream token is invalid or expired' }); return }
|
|
391
|
+
if (stream.host.streamedSerial !== payload.serial) { writeJson(res, 503, { code: 'stream_not_running', error: 'the device stream is not running; request a fresh grant' }); return }
|
|
390
392
|
const release = stream.host.acquire()
|
|
391
393
|
try {
|
|
392
394
|
await stream.host.ensureStreaming({ serial: payload.serial })
|
|
393
395
|
} catch (error) {
|
|
394
396
|
release()
|
|
395
|
-
writeJson(res, 502, { error: `the device stream failed to start: ${error instanceof Error ? error.message : String(error)}` })
|
|
397
|
+
writeJson(res, 502, { code: 'stream_start_failed', error: `the device stream failed to start: ${error instanceof Error ? error.message : String(error)}` })
|
|
396
398
|
return
|
|
397
399
|
}
|
|
398
400
|
const writer = new FrameSource.MultipartFrameWriter(res)
|
|
@@ -458,7 +460,8 @@ function makeRoutes(engine, config, stream) {
|
|
|
458
460
|
writeJson(res, 200, { ok: true, running: true, serial: status.serial, width: status.width, height: status.height })
|
|
459
461
|
},
|
|
460
462
|
},
|
|
461
|
-
// POST /api/dsh-mobilecode/stream/devices — online device list for the picker
|
|
463
|
+
// POST /api/dsh-mobilecode/stream/devices — online device list for the picker
|
|
464
|
+
// + bootable AVDs (for the "start with device_boot" hint rows).
|
|
462
465
|
{
|
|
463
466
|
kind: 'exact',
|
|
464
467
|
path: API_BASE + '/stream/devices',
|
|
@@ -466,11 +469,73 @@ function makeRoutes(engine, config, stream) {
|
|
|
466
469
|
if (!fence(req, res, true) || !isPost(req, res)) return
|
|
467
470
|
await readBody(req, res)
|
|
468
471
|
try {
|
|
469
|
-
const devices = await
|
|
472
|
+
const [devices, avds] = await Promise.all([
|
|
473
|
+
stream.host.listDevices(),
|
|
474
|
+
DeviceBuild.androidAvds().catch(() => []),
|
|
475
|
+
])
|
|
470
476
|
const streamed = stream.host.streamedSerial
|
|
471
|
-
writeJson(res, 200, {
|
|
477
|
+
writeJson(res, 200, {
|
|
478
|
+
ok: true,
|
|
479
|
+
avds,
|
|
480
|
+
devices: devices.map((device) => ({ ...device, ...(device.serial === streamed ? { streaming: true } : {}) })),
|
|
481
|
+
})
|
|
482
|
+
} catch (error) {
|
|
483
|
+
writeJson(res, 503, { code: 'devices_unavailable', error: error instanceof Error ? error.message : String(error) })
|
|
484
|
+
}
|
|
485
|
+
},
|
|
486
|
+
},
|
|
487
|
+
// POST /api/dsh-mobilecode/stream/still {device?} — capture ONE fresh
|
|
488
|
+
// screencap PNG and serve it; returns {path, width, height, dataUrl}. The
|
|
489
|
+
// panel's Screenshot button uses this (a still is the current pixels, unlike
|
|
490
|
+
// the live multipart loop). Falls back to the streamed device when device is
|
|
491
|
+
// omitted. The PNG is written to a temp dir; dataUrl embeds it so the
|
|
492
|
+
// browser can show it immediately (device_screen writes the same files).
|
|
493
|
+
{
|
|
494
|
+
kind: 'exact',
|
|
495
|
+
path: API_BASE + '/stream/still',
|
|
496
|
+
handler: async (req, res) => {
|
|
497
|
+
if (!fence(req, res, true) || !isPost(req, res)) return
|
|
498
|
+
const body = await readBody(req, res)
|
|
499
|
+
if (body === undefined) return
|
|
500
|
+
try {
|
|
501
|
+
const serial = typeof body.device === 'string' && body.device !== '' ? body.device : stream.host.streamedSerial
|
|
502
|
+
if (!serial) { writeJson(res, 409, { code: 'device_not_found', error: 'no device is streaming; pass a serial' }); return }
|
|
503
|
+
if (!StreamAccess.SERIAL_PATTERN.test(serial)) { writeJson(res, 400, { code: 'bad_request', error: 'device must be an adb device serial' }); return }
|
|
504
|
+
const local = await DeviceBuild.screenCapture(serial, tmpdir())
|
|
505
|
+
if (local === undefined) { writeJson(res, 502, { code: 'capture_failed', error: `could not capture a still of ${serial}` }); return }
|
|
506
|
+
const size = pngSize(local)
|
|
507
|
+
const out = { ok: true, path: local, ...(size ? { width: size.width, height: size.height } : {}) }
|
|
508
|
+
const bytes = readFileSync(local)
|
|
509
|
+
if (bytes.length > 0 && bytes.length < 4 * 1024 * 1024) {
|
|
510
|
+
out.dataUrl = 'data:image/png;base64,' + bytes.toString('base64')
|
|
511
|
+
}
|
|
512
|
+
writeJson(res, 200, out)
|
|
472
513
|
} catch (error) {
|
|
473
|
-
writeJson(res,
|
|
514
|
+
writeJson(res, 502, { code: 'capture_failed', error: `the still capture failed: ${error instanceof Error ? error.message : String(error)}` })
|
|
515
|
+
}
|
|
516
|
+
},
|
|
517
|
+
},
|
|
518
|
+
// POST /api/dsh-mobilecode/stream/device-action {device, action} — the
|
|
519
|
+
// panel device menu: notifications / quick_settings / collapse / lock /
|
|
520
|
+
// wake / assistant (the DEVICE_ACTIONS map device_action uses as a tool).
|
|
521
|
+
{
|
|
522
|
+
kind: 'exact',
|
|
523
|
+
path: API_BASE + '/stream/device-action',
|
|
524
|
+
handler: async (req, res) => {
|
|
525
|
+
if (!fence(req, res, true) || !isPost(req, res)) return
|
|
526
|
+
const body = await readBody(req, res)
|
|
527
|
+
if (body === undefined) return
|
|
528
|
+
const serial = typeof body.device === 'string' && body.device !== '' ? body.device : stream.host.streamedSerial
|
|
529
|
+
if (!serial) { writeJson(res, 409, { code: 'device_not_found', error: 'no device is streaming; pass a device' }); return }
|
|
530
|
+
const action = typeof body.action === 'string' ? body.action : ''
|
|
531
|
+
const argv = DEVICE_ACTIONS[action]
|
|
532
|
+
if (argv === undefined) { writeJson(res, 400, { code: 'unknown_action', error: `unknown device action "${action}"` }); return }
|
|
533
|
+
if (!StreamAccess.SERIAL_PATTERN.test(serial)) { writeJson(res, 400, { code: 'bad_request', error: 'device must be an adb device serial' }); return }
|
|
534
|
+
try {
|
|
535
|
+
await DeviceBuild.adbRun(serial, ['shell', ...argv])
|
|
536
|
+
writeJson(res, 200, { ok: true, action, device: serial })
|
|
537
|
+
} catch (error) {
|
|
538
|
+
writeJson(res, 502, { code: 'device_action_failed', error: `device action "${action}" failed: ${error instanceof Error ? error.message : String(error)}` })
|
|
474
539
|
}
|
|
475
540
|
},
|
|
476
541
|
},
|
|
@@ -484,17 +549,17 @@ function makeRoutes(engine, config, stream) {
|
|
|
484
549
|
const body = await readBody(req, res)
|
|
485
550
|
if (body === undefined) return
|
|
486
551
|
const serial = body.device
|
|
487
|
-
if (typeof serial !== 'string' || !StreamAccess.SERIAL_PATTERN.test(serial)) { writeJson(res, 400, { error: 'device must be an adb device serial' }); return }
|
|
552
|
+
if (typeof serial !== 'string' || !StreamAccess.SERIAL_PATTERN.test(serial)) { writeJson(res, 400, { code: 'bad_request', error: 'device must be an adb device serial' }); return }
|
|
488
553
|
const action = body.action
|
|
489
|
-
if (typeof action !== 'object' || action === null || typeof action.kind !== 'string') { writeJson(res, 400, { error: 'action must be an object with a kind' }); return }
|
|
554
|
+
if (typeof action !== 'object' || action === null || typeof action.kind !== 'string') { writeJson(res, 400, { code: 'bad_request', error: 'action must be an object with a kind' }); return }
|
|
490
555
|
const point = (x, y) => typeof x === 'number' && typeof y === 'number' && x >= 0 && x <= 1 && y >= 0 && y <= 1
|
|
491
|
-
if (action.kind === 'tap' && !point(action.x, action.y)) { writeJson(res, 400, { error: 'tap needs normalized x,y in 0..1' }); return }
|
|
492
|
-
if (action.kind === 'drag' && !(point(action.fromX, action.fromY) && point(action.toX, action.toY))) { writeJson(res, 400, { error: 'drag needs normalized fromX,fromY,toX,toY in 0..1' }); return }
|
|
493
|
-
if (action.kind === 'button' && (typeof action.name !== 'string' || action.name === '')) { writeJson(res, 400, { error: 'button requires a non-empty name' }); return }
|
|
494
|
-
if (action.kind === 'type' && (typeof action.text !== 'string' || action.text === '')) { writeJson(res, 400, { error: 'type requires a non-empty text' }); return }
|
|
556
|
+
if (action.kind === 'tap' && !point(action.x, action.y)) { writeJson(res, 400, { code: 'bad_request', error: 'tap needs normalized x,y in 0..1' }); return }
|
|
557
|
+
if (action.kind === 'drag' && !(point(action.fromX, action.fromY) && point(action.toX, action.toY))) { writeJson(res, 400, { code: 'bad_request', error: 'drag needs normalized fromX,fromY,toX,toY in 0..1' }); return }
|
|
558
|
+
if (action.kind === 'button' && (typeof action.name !== 'string' || action.name === '')) { writeJson(res, 400, { code: 'bad_request', error: 'button requires a non-empty name' }); return }
|
|
559
|
+
if (action.kind === 'type' && (typeof action.text !== 'string' || action.text === '')) { writeJson(res, 400, { code: 'bad_request', error: 'type requires a non-empty text' }); return }
|
|
495
560
|
if (stream.host.streamedSerial !== serial) {
|
|
496
561
|
const online = await stream.host.listDevices()
|
|
497
|
-
if (!online.some((device) => device.serial === serial)) { writeJson(res, 409, { error: `device ${serial} is not online` }); return }
|
|
562
|
+
if (!online.some((device) => device.serial === serial)) { writeJson(res, 409, { code: 'device_offline', error: `device ${serial} is not online` }); return }
|
|
498
563
|
}
|
|
499
564
|
const release = stream.host.acquire()
|
|
500
565
|
try {
|
|
@@ -511,11 +576,11 @@ function makeRoutes(engine, config, stream) {
|
|
|
511
576
|
result = { ok: true, rotation: next }
|
|
512
577
|
break
|
|
513
578
|
}
|
|
514
|
-
default: writeJson(res, 400, { error: `unknown control action ${JSON.stringify(action.kind)}` }); return
|
|
579
|
+
default: writeJson(res, 400, { code: 'unknown_action', error: `unknown control action ${JSON.stringify(action.kind)}` }); return
|
|
515
580
|
}
|
|
516
581
|
writeJson(res, 200, result)
|
|
517
582
|
} catch (error) {
|
|
518
|
-
writeJson(res, 502, { error: `the device control failed: ${error instanceof Error ? error.message : String(error)}` })
|
|
583
|
+
writeJson(res, 502, { code: 'control_failed', error: `the device control failed: ${error instanceof Error ? error.message : String(error)}` })
|
|
519
584
|
} finally {
|
|
520
585
|
release()
|
|
521
586
|
}
|
|
@@ -747,6 +812,24 @@ async function requireAndroidDevice(serial) {
|
|
|
747
812
|
return target
|
|
748
813
|
}
|
|
749
814
|
|
|
815
|
+
/**
|
|
816
|
+
* PNG IHDR hand-parse: read the 8-byte signature + 24-byte IHDR chunk and pull
|
|
817
|
+
* width/height (big-endian at offsets 16/20). Returns undefined for non-PNGs —
|
|
818
|
+
* the PNG the screencap writes is always 8-bit RGBA non-interlaced, so no
|
|
819
|
+
* deeper parsing is needed.
|
|
820
|
+
*/
|
|
821
|
+
function pngSize(filePath) {
|
|
822
|
+
try {
|
|
823
|
+
const bytes = readFileSync(filePath)
|
|
824
|
+
if (bytes.length < 24 || bytes[0] !== 0x89 || bytes[1] !== 0x50 || bytes[2] !== 0x4e || bytes[3] !== 0x47) return undefined
|
|
825
|
+
const width = bytes.readUInt32BE(16)
|
|
826
|
+
const height = bytes.readUInt32BE(20)
|
|
827
|
+
return width > 0 && height > 0 ? { width, height } : undefined
|
|
828
|
+
} catch {
|
|
829
|
+
return undefined
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
|
|
750
833
|
/** Common key names → Android keycode. Anything else can be passed as a raw keycode integer. */
|
|
751
834
|
const KEYCODES = {
|
|
752
835
|
back: 4, home: 3, menu: 82, recents: 187, app_switch: 187, enter: 66, tab: 61, space: 62,
|
|
@@ -1199,12 +1282,12 @@ function devicePerfTool() {
|
|
|
1199
1282
|
DeviceBuild.adbRun(serial, ['shell', 'dumpsys', 'battery']).catch(() => ''),
|
|
1200
1283
|
DeviceBuild.adbRun(serial, ['shell', 'cat', '/proc/cpuinfo']).catch(() => ''),
|
|
1201
1284
|
])
|
|
1202
|
-
return {
|
|
1285
|
+
return DeviceBuild.jsonSafe({
|
|
1203
1286
|
serial,
|
|
1204
1287
|
memory: DeviceBuild.parseMeminfo(meminfo),
|
|
1205
1288
|
battery: parseBatteryShaped(battery),
|
|
1206
1289
|
cpu: DeviceBuild.parseCpuinfo(cpuinfo),
|
|
1207
|
-
}
|
|
1290
|
+
})
|
|
1208
1291
|
},
|
|
1209
1292
|
})
|
|
1210
1293
|
}
|
|
@@ -1257,7 +1340,7 @@ function deviceAppInfoTool() {
|
|
|
1257
1340
|
const output = await DeviceBuild.adbRun(serial, ['shell', 'dumpsys', 'package', pkg])
|
|
1258
1341
|
const info = DeviceBuild.parseAppInfo(output, pkg)
|
|
1259
1342
|
if (info.versionName === undefined) throw new Error(`package ${pkg} is not installed (dumpsys package returned nothing for it) — run device_apps to list what is.`)
|
|
1260
|
-
return info
|
|
1343
|
+
return DeviceBuild.jsonSafe(info)
|
|
1261
1344
|
},
|
|
1262
1345
|
})
|
|
1263
1346
|
}
|
|
@@ -1368,6 +1451,365 @@ function deviceRebootTool() {
|
|
|
1368
1451
|
})
|
|
1369
1452
|
}
|
|
1370
1453
|
|
|
1454
|
+
function deviceUiRowsTool() {
|
|
1455
|
+
return defineTool({
|
|
1456
|
+
name: 'device_ui_rows',
|
|
1457
|
+
description: 'Detect the list/feed ROWS on the attached Android screen (Settings pages, inboxes, feeds): every row reports ' +
|
|
1458
|
+
'its index, an isomorphic group id, its pixel frame, the aggregated visible label, and any parsed counters (e.g. "3万 粉丝" ' +
|
|
1459
|
+
'→ {key:"粉丝", value:30000}, "1.2k likes" → {key:"likes", value:1200}). Rows are the unit the user sees — use this instead of ' +
|
|
1460
|
+
'device_ui_tree when the screen is a list, then tap a row by index with device_tap_row. Row detection is repetition-based: ' +
|
|
1461
|
+
'>=3 sibling subtrees of one parent sharing a class and near-equal height (tolerance max(8px, 15%)).',
|
|
1462
|
+
parameters: {
|
|
1463
|
+
serial: { type: 'string', description: 'Android device serial. Omit to use the first attached device.' },
|
|
1464
|
+
filter: { type: 'string', description: 'Optional case-insensitive substring; keep only rows whose label contains it.' },
|
|
1465
|
+
},
|
|
1466
|
+
output: {
|
|
1467
|
+
schema: {
|
|
1468
|
+
type: 'object',
|
|
1469
|
+
additionalProperties: false,
|
|
1470
|
+
properties: {
|
|
1471
|
+
serial: { type: 'string', required: true },
|
|
1472
|
+
screen: {
|
|
1473
|
+
type: 'object',
|
|
1474
|
+
additionalProperties: false,
|
|
1475
|
+
properties: {
|
|
1476
|
+
width: { type: 'integer', required: true },
|
|
1477
|
+
height: { type: 'integer', required: true },
|
|
1478
|
+
},
|
|
1479
|
+
},
|
|
1480
|
+
omittedOffscreen: { type: 'integer' },
|
|
1481
|
+
rows: {
|
|
1482
|
+
type: 'array',
|
|
1483
|
+
required: true,
|
|
1484
|
+
items: {
|
|
1485
|
+
type: 'object',
|
|
1486
|
+
additionalProperties: false,
|
|
1487
|
+
properties: {
|
|
1488
|
+
index: { type: 'integer', required: true },
|
|
1489
|
+
group: { type: 'integer', required: true },
|
|
1490
|
+
frame: {
|
|
1491
|
+
type: 'object',
|
|
1492
|
+
additionalProperties: false,
|
|
1493
|
+
properties: {
|
|
1494
|
+
x: { type: 'integer', required: true },
|
|
1495
|
+
y: { type: 'integer', required: true },
|
|
1496
|
+
w: { type: 'integer', required: true },
|
|
1497
|
+
h: { type: 'integer', required: true },
|
|
1498
|
+
},
|
|
1499
|
+
},
|
|
1500
|
+
label: { type: 'string', required: true },
|
|
1501
|
+
counters: {
|
|
1502
|
+
type: 'array',
|
|
1503
|
+
required: true,
|
|
1504
|
+
items: {
|
|
1505
|
+
type: 'object',
|
|
1506
|
+
additionalProperties: false,
|
|
1507
|
+
properties: {
|
|
1508
|
+
key: { type: 'string', required: true },
|
|
1509
|
+
value: { type: 'number', required: true },
|
|
1510
|
+
raw: { type: 'string', required: true },
|
|
1511
|
+
},
|
|
1512
|
+
},
|
|
1513
|
+
},
|
|
1514
|
+
},
|
|
1515
|
+
},
|
|
1516
|
+
},
|
|
1517
|
+
},
|
|
1518
|
+
},
|
|
1519
|
+
render: (_args, value) => {
|
|
1520
|
+
const v = value ?? { serial: '', rows: [] }
|
|
1521
|
+
const lines = [`Device: ${v.serial} — ${v.rows.length} row(s)${v.omittedOffscreen ? ` (${v.omittedOffscreen} off-screen omitted)` : ''}`]
|
|
1522
|
+
for (const row of v.rows.slice(0, 20)) {
|
|
1523
|
+
const counterText = row.counters.length > 0 ? ` [${row.counters.map((c) => `${c.key}=${c.value}`).join(', ')}]` : ''
|
|
1524
|
+
lines.push(` #${row.index} g${row.group} @${row.frame.x},${row.frame.y} ${row.frame.w}x${row.frame.h} — ${row.label || '(no text)'}${counterText}`)
|
|
1525
|
+
}
|
|
1526
|
+
if (v.rows.length > 20) lines.push(` ... and ${v.rows.length - 20} more`)
|
|
1527
|
+
if (v.rows.length === 0) lines.push('No repeated rows detected — the screen may not be a list, or it may need a scroll (see device_scroll_to).')
|
|
1528
|
+
return [{ type: 'text', text: lines.join('\n') }]
|
|
1529
|
+
},
|
|
1530
|
+
},
|
|
1531
|
+
async execute(args) {
|
|
1532
|
+
const serial = await requireAndroidDevice(args.serial)
|
|
1533
|
+
const parsed = await UiTree.readUiTree(serial)
|
|
1534
|
+
const screen = UiTree.screenBoundsOf(parsed.roots)
|
|
1535
|
+
const { rows, omittedOffscreen } = RowList.detectRows(parsed.roots, screen)
|
|
1536
|
+
const filter = args.filter !== undefined && String(args.filter).trim() !== '' ? String(args.filter).trim().toLowerCase() : undefined
|
|
1537
|
+
const out = {
|
|
1538
|
+
serial,
|
|
1539
|
+
screen: { width: screen.width, height: screen.height },
|
|
1540
|
+
omittedOffscreen,
|
|
1541
|
+
rows: rows.filter((row) => filter === undefined || row.label.toLowerCase().includes(filter)),
|
|
1542
|
+
}
|
|
1543
|
+
return DeviceBuild.jsonSafe(out)
|
|
1544
|
+
},
|
|
1545
|
+
})
|
|
1546
|
+
}
|
|
1547
|
+
|
|
1548
|
+
function deviceTapRowTool() {
|
|
1549
|
+
return defineTool({
|
|
1550
|
+
name: 'device_tap_row',
|
|
1551
|
+
description: 'Tap inside a list/feed row by index (from device_ui_rows) at a row-relative position: x,y are fractions of the ' +
|
|
1552
|
+
'row frame (default 0.5,0.5 = the row center). Optional expect_count {key, delta} turns the tap into one verified round trip: ' +
|
|
1553
|
+
'the counter must ALREADY be visible in the row before the tap (refused otherwise — never probe an unknown control), and after ' +
|
|
1554
|
+
'an 800ms settle the row at the same index + position must show the count changed by EXACTLY delta (default +1).',
|
|
1555
|
+
parameters: {
|
|
1556
|
+
serial: { type: 'string', description: 'Android device serial. Omit to use the first attached device.' },
|
|
1557
|
+
row: { type: 'integer', description: 'Zero-based row index from device_ui_rows.', required: true },
|
|
1558
|
+
x: { type: 'number', description: 'Row-relative X fraction 0..1 (0 = left edge, 1 = right edge). Default 0.5.' },
|
|
1559
|
+
y: { type: 'number', description: 'Row-relative Y fraction 0..1. Default 0.5.' },
|
|
1560
|
+
expect_count: {
|
|
1561
|
+
type: 'object',
|
|
1562
|
+
additionalProperties: false,
|
|
1563
|
+
description: 'Verify a counter changed by exactly delta after the tap.',
|
|
1564
|
+
properties: {
|
|
1565
|
+
key: { type: 'string', description: 'Counter key as reported by device_ui_rows (e.g. "粉丝", "likes").', required: true },
|
|
1566
|
+
delta: { type: 'integer', description: 'Expected change: 1 (default) or -1.' },
|
|
1567
|
+
},
|
|
1568
|
+
},
|
|
1569
|
+
},
|
|
1570
|
+
output: {
|
|
1571
|
+
schema: {
|
|
1572
|
+
type: 'object',
|
|
1573
|
+
additionalProperties: false,
|
|
1574
|
+
properties: {
|
|
1575
|
+
serial: { type: 'string', required: true },
|
|
1576
|
+
row: { type: 'integer', required: true },
|
|
1577
|
+
x: { type: 'integer', required: true },
|
|
1578
|
+
y: { type: 'integer', required: true },
|
|
1579
|
+
expect: {
|
|
1580
|
+
type: 'object',
|
|
1581
|
+
additionalProperties: false,
|
|
1582
|
+
properties: {
|
|
1583
|
+
verified: { type: 'boolean', required: true },
|
|
1584
|
+
reason: { type: 'string' },
|
|
1585
|
+
before: { type: 'number' },
|
|
1586
|
+
after: { type: 'number' },
|
|
1587
|
+
},
|
|
1588
|
+
},
|
|
1589
|
+
},
|
|
1590
|
+
},
|
|
1591
|
+
render: (_args, value) => {
|
|
1592
|
+
const v = value ?? { serial: '', row: 0, x: 0, y: 0 }
|
|
1593
|
+
const lines = [`Tapped row #${v.row} at ${v.x},${v.y} on ${v.serial}`]
|
|
1594
|
+
if (v.expect) {
|
|
1595
|
+
lines.push(`Expect_count: ${v.expect.verified ? 'VERIFIED' : 'NOT verified'}${v.expect.reason ? ` — ${v.expect.reason}` : ''}${v.expect.before !== undefined ? ` (${v.expect.before} → ${v.expect.after})` : ''}`)
|
|
1596
|
+
}
|
|
1597
|
+
return [{ type: 'text', text: lines.join('\n') }]
|
|
1598
|
+
},
|
|
1599
|
+
},
|
|
1600
|
+
async execute(args) {
|
|
1601
|
+
const serial = await requireAndroidDevice(args.serial)
|
|
1602
|
+
if (!Number.isInteger(args.row) || args.row < 0) throw new Error('device_tap_row requires a non-negative integer row index (see device_ui_rows).')
|
|
1603
|
+
const parsed = await UiTree.readUiTree(serial)
|
|
1604
|
+
const screen = UiTree.screenBoundsOf(parsed.roots)
|
|
1605
|
+
const { rows } = RowList.detectRows(parsed.roots, screen)
|
|
1606
|
+
const row = rows[args.row]
|
|
1607
|
+
if (row === undefined) throw new Error(`row ${args.row} does not exist — device_ui_rows reported ${rows.length} row(s). Re-run it for fresh indices.`)
|
|
1608
|
+
const fractionX = args.x === undefined ? 0.5 : args.x
|
|
1609
|
+
const fractionY = args.y === undefined ? 0.5 : args.y
|
|
1610
|
+
const expect = args.expect_count
|
|
1611
|
+
if (expect) {
|
|
1612
|
+
const key = RowList.normalizeCountKey(String(expect.key))
|
|
1613
|
+
if (!row.counters.some((c) => c.key === key)) {
|
|
1614
|
+
throw new Error(`refusing to tap: counter "${expect.key}" is not visible in row ${args.row} ("${row.label}") — pass a key device_ui_rows reported.`)
|
|
1615
|
+
}
|
|
1616
|
+
}
|
|
1617
|
+
const point = RowList.planRowTap(rows, args.row, fractionX, fractionY)
|
|
1618
|
+
await DeviceBuild.adbRun(serial, ['shell', 'input', 'tap', String(point.x), String(point.y)])
|
|
1619
|
+
const out = { serial, row: args.row, x: point.x, y: point.y }
|
|
1620
|
+
if (expect) {
|
|
1621
|
+
await new Promise((resolve) => setTimeout(resolve, 800))
|
|
1622
|
+
const fresh = await UiTree.readUiTree(serial).catch(() => undefined)
|
|
1623
|
+
if (fresh === undefined) {
|
|
1624
|
+
out.expect = { verified: false, reason: 'could not re-read the screen after the tap' }
|
|
1625
|
+
} else {
|
|
1626
|
+
const freshRows = RowList.detectRows(fresh.roots, UiTree.screenBoundsOf(fresh.roots)).rows
|
|
1627
|
+
const afterRow = freshRows[args.row]
|
|
1628
|
+
const key = RowList.normalizeCountKey(String(expect.key))
|
|
1629
|
+
const delta = expect.delta === undefined ? 1 : Number(expect.delta)
|
|
1630
|
+
const verification = afterRow !== undefined && RowList.rowsStayedPut(row, afterRow)
|
|
1631
|
+
? RowList.verifyCountChange(row, afterRow, key, delta)
|
|
1632
|
+
: { verified: false, reason: 'the list moved after the tap (row at this index changed position) — the count could not be verified' }
|
|
1633
|
+
out.expect = {
|
|
1634
|
+
verified: verification.verified,
|
|
1635
|
+
...(verification.reason ? { reason: verification.reason } : {}),
|
|
1636
|
+
...(verification.before !== undefined ? { before: verification.before, after: verification.after } : {}),
|
|
1637
|
+
}
|
|
1638
|
+
}
|
|
1639
|
+
}
|
|
1640
|
+
return DeviceBuild.jsonSafe(out)
|
|
1641
|
+
},
|
|
1642
|
+
})
|
|
1643
|
+
}
|
|
1644
|
+
|
|
1645
|
+
function deviceBacktraceTool() {
|
|
1646
|
+
return defineTool({
|
|
1647
|
+
name: 'device_backtrace',
|
|
1648
|
+
description: 'Capture a native/ANR thread backtrace or crash log for an app on the device. Sends SIGQUIT (kill -3) to the process, ' +
|
|
1649
|
+
'waits for the ART runtime to write the thread dump, then reads the newest /data/anr entry. When /data/anr is unrunnable it falls ' +
|
|
1650
|
+
'back to the logcat crash buffer and says so (engine field: "anr-trace" | "logcat-crash"). Pass package_name or pid.',
|
|
1651
|
+
parameters: {
|
|
1652
|
+
serial: { type: 'string', description: 'Android device serial. Omit to use the first attached device.' },
|
|
1653
|
+
package_name: { type: 'string', description: 'Package of the process to trace (e.g. com.simspoof.app).' },
|
|
1654
|
+
pid: { type: 'integer', description: 'Explicit pid; resolved from package_name when omitted.' },
|
|
1655
|
+
},
|
|
1656
|
+
output: {
|
|
1657
|
+
schema: {
|
|
1658
|
+
type: 'object',
|
|
1659
|
+
additionalProperties: false,
|
|
1660
|
+
properties: {
|
|
1661
|
+
serial: { type: 'string', required: true },
|
|
1662
|
+
engine: { type: 'string', required: true },
|
|
1663
|
+
package_name: { type: 'string' },
|
|
1664
|
+
pid: { type: 'integer', required: true },
|
|
1665
|
+
trace_path: { type: 'string' },
|
|
1666
|
+
note: { type: 'string' },
|
|
1667
|
+
lines: { type: 'array', items: { type: 'string' }, required: true },
|
|
1668
|
+
},
|
|
1669
|
+
},
|
|
1670
|
+
render: (_args, value) => {
|
|
1671
|
+
const v = value ?? { serial: '', engine: '', pid: 0, lines: [] }
|
|
1672
|
+
const lines = [`Backtrace of pid ${v.pid} on ${v.serial} (engine: ${v.engine})${v.trace_path ? ` — ${v.trace_path}` : ''}`]
|
|
1673
|
+
if (v.note) lines.push(v.note)
|
|
1674
|
+
for (const line of v.lines.slice(-40)) lines.push(` ${line}`)
|
|
1675
|
+
if (v.lines.length > 40) lines.push(` ... ${v.lines.length - 40} more lines`)
|
|
1676
|
+
return [{ type: 'text', text: lines.join('\n') }]
|
|
1677
|
+
},
|
|
1678
|
+
},
|
|
1679
|
+
async execute(args) {
|
|
1680
|
+
const serial = await requireAndroidDevice(args.serial)
|
|
1681
|
+
const packageName = args.package_name !== undefined && args.package_name !== '' ? String(args.package_name) : undefined
|
|
1682
|
+
let pid
|
|
1683
|
+
if (args.pid !== undefined && Number.isInteger(args.pid) && args.pid > 0) {
|
|
1684
|
+
pid = args.pid
|
|
1685
|
+
} else if (packageName) {
|
|
1686
|
+
const pidout = await DeviceBuild.adbRun(serial, ['shell', 'pidof', '-s', DeviceBuild.shQuoteDevice(packageName)]).catch(() => undefined)
|
|
1687
|
+
const parsedPid = Number((pidout ?? '').trim())
|
|
1688
|
+
if (Number.isInteger(parsedPid) && parsedPid > 0) pid = parsedPid
|
|
1689
|
+
if (pid === undefined) {
|
|
1690
|
+
const ps = await DeviceBuild.adbRun(serial, ['shell', 'ps', '-A']).catch(() => '')
|
|
1691
|
+
const match = ps.split(/\r?\n/).find((line) => /\s+\d+\s/.test(line) && line.split(/\s+/).pop() === packageName)
|
|
1692
|
+
pid = match ? Number(/\s+(\d+)\s+/.exec(match)?.[1]) : undefined
|
|
1693
|
+
}
|
|
1694
|
+
}
|
|
1695
|
+
if (pid === undefined) {
|
|
1696
|
+
throw new Error(`device_backtrace could not resolve a running process for ${packageName ? `"${packageName}"` : 'the request'} on ${serial} — pass a package_name of a running app, or an explicit pid.`)
|
|
1697
|
+
}
|
|
1698
|
+
// SIGQUIT: the ART runtime writes the thread dump to /data/anr/.
|
|
1699
|
+
await DeviceBuild.adbRun(serial, ['shell', 'kill', '-3', String(pid)])
|
|
1700
|
+
await new Promise((resolve) => setTimeout(resolve, 1200))
|
|
1701
|
+
const listing = await DeviceBuild.adbRun(serial, ['shell', 'ls', '-t', '/data/anr']).catch(() => undefined)
|
|
1702
|
+
const newest = (listing ?? '').split(/\r?\n/).map((line) => line.trim()).filter((line) => line !== '' && !line.includes(' '))[0]
|
|
1703
|
+
if (newest !== undefined) {
|
|
1704
|
+
const content = await DeviceBuild.adbRun(serial, ['shell', 'cat', DeviceBuild.shQuoteDevice(`/data/anr/${newest}`)], { timeoutMs: 30_000 }).catch(() => undefined)
|
|
1705
|
+
if (content !== undefined && content.trim() !== '') {
|
|
1706
|
+
return DeviceBuild.jsonSafe({
|
|
1707
|
+
serial,
|
|
1708
|
+
engine: 'anr-trace',
|
|
1709
|
+
...(packageName ? { package_name: packageName } : {}),
|
|
1710
|
+
pid,
|
|
1711
|
+
trace_path: `/data/anr/${newest}`,
|
|
1712
|
+
lines: content.split(/\r?\n/).slice(-250),
|
|
1713
|
+
})
|
|
1714
|
+
}
|
|
1715
|
+
}
|
|
1716
|
+
// Fallback: the logcat crash buffer (FATAL/AndroidRuntime lines), filtered by package when known.
|
|
1717
|
+
const log = await DeviceBuild.adbRun(serial, ['logcat', '-b', 'crash', '-d', '-v', 'time']).catch(() => '')
|
|
1718
|
+
const all = log.split(/\r?\n/).filter((line) => /FATAL EXCEPTION|AndroidRuntime|DEBUG|Abort message/.test(line))
|
|
1719
|
+
const lines = (packageName ? all.filter((line) => line.includes(packageName)) : all)
|
|
1720
|
+
if (lines.length === 0) {
|
|
1721
|
+
// Keep the honest fallback note: anr-trace failed; crash buffer silent.
|
|
1722
|
+
return DeviceBuild.jsonSafe({
|
|
1723
|
+
serial,
|
|
1724
|
+
engine: 'logcat-crash',
|
|
1725
|
+
...(packageName ? { package_name: packageName } : {}),
|
|
1726
|
+
pid,
|
|
1727
|
+
note: `SIGQUIT sent to pid ${pid}; /data/anr was ${newest === undefined ? 'empty/unreadable' : `read but produced no content for ${newest}`}; the logcat crash buffer has no matching FATAL lines now. The app may not have crashed — check device_log for the main buffer.`,
|
|
1728
|
+
lines: [],
|
|
1729
|
+
})
|
|
1730
|
+
}
|
|
1731
|
+
return DeviceBuild.jsonSafe({
|
|
1732
|
+
serial,
|
|
1733
|
+
engine: 'logcat-crash',
|
|
1734
|
+
...(packageName ? { package_name: packageName } : {}),
|
|
1735
|
+
pid,
|
|
1736
|
+
note: '/data/anr unreadable; fell back to the logcat crash buffer.',
|
|
1737
|
+
lines: lines.slice(-200),
|
|
1738
|
+
})
|
|
1739
|
+
},
|
|
1740
|
+
})
|
|
1741
|
+
}
|
|
1742
|
+
|
|
1743
|
+
function deviceMeminfoTool() {
|
|
1744
|
+
return defineTool({
|
|
1745
|
+
name: 'device_meminfo',
|
|
1746
|
+
description: 'Read a running app\'s memory profile from `dumpsys meminfo <package>`: TOTAL PSS/RSS/Swap-PSS plus the App Summary heap ' +
|
|
1747
|
+
'breakdown (Java/Native/Code/Stack/Graphics) and the top PSS categories (mmap regions etc.). Throws when the package has no running ' +
|
|
1748
|
+
'process. Use to check an app\'s footprint after a run, or to compare builds.',
|
|
1749
|
+
parameters: {
|
|
1750
|
+
serial: { type: 'string', description: 'Android device serial. Omit to use the first attached device.' },
|
|
1751
|
+
package_name: { type: 'string', description: 'Package to profile (see device_apps).', required: true },
|
|
1752
|
+
},
|
|
1753
|
+
output: {
|
|
1754
|
+
schema: {
|
|
1755
|
+
type: 'object',
|
|
1756
|
+
additionalProperties: false,
|
|
1757
|
+
properties: {
|
|
1758
|
+
serial: { type: 'string', required: true },
|
|
1759
|
+
package_name: { type: 'string', required: true },
|
|
1760
|
+
totalPssKb: { type: 'number' },
|
|
1761
|
+
totalRssKb: { type: 'number' },
|
|
1762
|
+
totalSwapPssKb: { type: 'number' },
|
|
1763
|
+
appSummary: {
|
|
1764
|
+
type: 'object',
|
|
1765
|
+
additionalProperties: true,
|
|
1766
|
+
description: 'PSS KB per heap bucket: {javaHeapKb, nativeHeapKb, codeKb, stackKb, graphicsKb}.',
|
|
1767
|
+
},
|
|
1768
|
+
topCategories: {
|
|
1769
|
+
type: 'array',
|
|
1770
|
+
items: {
|
|
1771
|
+
type: 'object',
|
|
1772
|
+
additionalProperties: false,
|
|
1773
|
+
properties: {
|
|
1774
|
+
name: { type: 'string', required: true },
|
|
1775
|
+
pssKb: { type: 'number', required: true },
|
|
1776
|
+
},
|
|
1777
|
+
},
|
|
1778
|
+
},
|
|
1779
|
+
},
|
|
1780
|
+
},
|
|
1781
|
+
render: (_args, value) => {
|
|
1782
|
+
const v = value ?? { serial: '', package_name: '' }
|
|
1783
|
+
const mb = (kb) => (kb === undefined ? '?' : `${Math.round(kb / 1024)} MB`)
|
|
1784
|
+
const lines = [`Memory of ${v.package_name} on ${v.serial}`]
|
|
1785
|
+
lines.push(` TOTAL PSS: ${mb(v.totalPssKb)} RSS: ${mb(v.totalRssKb)} SwapPSS: ${mb(v.totalSwapPssKb)}`)
|
|
1786
|
+
if (v.appSummary && Object.keys(v.appSummary).length > 0) {
|
|
1787
|
+
const labels = { javaHeapKb: 'Java heap', nativeHeapKb: 'Native heap', codeKb: 'Code', stackKb: 'Stack', graphicsKb: 'Graphics' }
|
|
1788
|
+
const parts = []
|
|
1789
|
+
for (const [key, label] of Object.entries(labels)) {
|
|
1790
|
+
if (v.appSummary[key] !== undefined) parts.push(`${label}: ${mb(v.appSummary[key])}`)
|
|
1791
|
+
}
|
|
1792
|
+
if (parts.length > 0) lines.push(` App Summary — ${parts.join(', ')}`)
|
|
1793
|
+
}
|
|
1794
|
+
if (v.topCategories && v.topCategories.length > 0) {
|
|
1795
|
+
lines.push(' Top categories:')
|
|
1796
|
+
for (const category of v.topCategories.slice(0, 6)) lines.push(` - ${category.name}: ${mb(category.pssKb)}`)
|
|
1797
|
+
}
|
|
1798
|
+
return [{ type: 'text', text: lines.join('\n') }]
|
|
1799
|
+
},
|
|
1800
|
+
},
|
|
1801
|
+
async execute(args) {
|
|
1802
|
+
const serial = await requireAndroidDevice(args.serial)
|
|
1803
|
+
const packageName = String(args.package_name ?? '').trim()
|
|
1804
|
+
if (packageName === '') throw new Error('device_meminfo requires a package_name (see device_apps).')
|
|
1805
|
+
const output = await DeviceBuild.adbRun(serial, ['shell', 'dumpsys', 'meminfo', packageName], { timeoutMs: 30_000 })
|
|
1806
|
+
const parsed = DeviceBuild.parsePackageMeminfo(output)
|
|
1807
|
+
if (parsed === undefined) throw new Error(`\`dumpsys meminfo ${packageName}\` produced no process block on ${serial} — is ${packageName} running?`)
|
|
1808
|
+
return DeviceBuild.jsonSafe({ serial, package_name: packageName, ...parsed })
|
|
1809
|
+
},
|
|
1810
|
+
})
|
|
1811
|
+
}
|
|
1812
|
+
|
|
1371
1813
|
function deviceBootTool() {
|
|
1372
1814
|
return defineTool({
|
|
1373
1815
|
name: 'device_boot',
|
|
@@ -1387,6 +1829,11 @@ function deviceBootTool() {
|
|
|
1387
1829
|
avd: { type: 'string', required: true },
|
|
1388
1830
|
booted: { type: 'boolean', required: true },
|
|
1389
1831
|
alreadyRunning: { type: 'boolean', required: true },
|
|
1832
|
+
presentationMeta: {
|
|
1833
|
+
type: 'object',
|
|
1834
|
+
additionalProperties: true,
|
|
1835
|
+
description: 'Projected into ToolResultNode.meta for the conversation stream card.',
|
|
1836
|
+
},
|
|
1390
1837
|
},
|
|
1391
1838
|
},
|
|
1392
1839
|
render: (_args, value) => {
|
|
@@ -1403,7 +1850,10 @@ function deviceBootTool() {
|
|
|
1403
1850
|
const name = await DeviceBuild.avdName(row.serial).catch(() => undefined)
|
|
1404
1851
|
if (name === avd) {
|
|
1405
1852
|
const booted = await DeviceBuild.waitForBoot(row.serial, timeout)
|
|
1406
|
-
return {
|
|
1853
|
+
return {
|
|
1854
|
+
serial: row.serial, avd, booted, alreadyRunning: true,
|
|
1855
|
+
presentationMeta: { kind: 'android-stream', device: { serial: row.serial, name: avd } },
|
|
1856
|
+
}
|
|
1407
1857
|
}
|
|
1408
1858
|
}
|
|
1409
1859
|
if (!DeviceBuild.emulatorBinary()) throw new Error('No SDK emulator binary found; cannot boot an AVD.')
|
|
@@ -1420,7 +1870,10 @@ function deviceBootTool() {
|
|
|
1420
1870
|
if (!serial) throw new Error(`No emulator serial appeared for "${avd}" within ${timeout} ms.`)
|
|
1421
1871
|
const booted = await DeviceBuild.waitForBoot(serial, Math.max(1000, deadline - Date.now()))
|
|
1422
1872
|
if (!booted) throw new Error(`Emulator ${serial} ("${avd}") did not finish booting within ${timeout} ms.`)
|
|
1423
|
-
return {
|
|
1873
|
+
return {
|
|
1874
|
+
serial, avd, booted: true, alreadyRunning: false,
|
|
1875
|
+
presentationMeta: { kind: 'android-stream', device: { serial, name: avd } },
|
|
1876
|
+
}
|
|
1424
1877
|
},
|
|
1425
1878
|
})
|
|
1426
1879
|
}
|
|
@@ -1609,6 +2062,11 @@ function deviceStreamTool(host, access) {
|
|
|
1609
2062
|
streamUrl: { type: 'string' },
|
|
1610
2063
|
width: { type: 'integer' },
|
|
1611
2064
|
height: { type: 'integer' },
|
|
2065
|
+
presentationMeta: {
|
|
2066
|
+
type: 'object',
|
|
2067
|
+
additionalProperties: true,
|
|
2068
|
+
description: 'Projected into ToolResultNode.meta for the conversation stream card.',
|
|
2069
|
+
},
|
|
1612
2070
|
},
|
|
1613
2071
|
},
|
|
1614
2072
|
render: (_args, value) => {
|
|
@@ -1634,7 +2092,14 @@ function deviceStreamTool(host, access) {
|
|
|
1634
2092
|
if (!online.some((device) => device.serial === serial)) throw new Error(`device ${serial} is not online; cannot stream it`)
|
|
1635
2093
|
const info = await host.ensureStreaming({ serial })
|
|
1636
2094
|
const signed = await access.signStreamToken(serial)
|
|
1637
|
-
return {
|
|
2095
|
+
return {
|
|
2096
|
+
action, running: true, serial: info.serial, width: info.width, height: info.height, streamUrl: `${API_BASE}/stream?token=${encodeURIComponent(signed.token)}`,
|
|
2097
|
+
presentationMeta: {
|
|
2098
|
+
kind: 'android-stream',
|
|
2099
|
+
device: { serial: info.serial },
|
|
2100
|
+
streamRouteId: `dsh-mobilecode/stream/${info.serial}`,
|
|
2101
|
+
},
|
|
2102
|
+
}
|
|
1638
2103
|
},
|
|
1639
2104
|
})
|
|
1640
2105
|
}
|
|
@@ -1697,6 +2162,11 @@ function deviceScreenTool(engine, vision) {
|
|
|
1697
2162
|
},
|
|
1698
2163
|
ocrError: { type: 'string' },
|
|
1699
2164
|
image: Vision.IMAGE_REF_SCHEMA,
|
|
2165
|
+
presentationMeta: {
|
|
2166
|
+
type: 'object',
|
|
2167
|
+
additionalProperties: true,
|
|
2168
|
+
description: 'Projected into ToolResultNode.meta for the conversation screenshot card.',
|
|
2169
|
+
},
|
|
1700
2170
|
},
|
|
1701
2171
|
},
|
|
1702
2172
|
render: (_args, value) => {
|
|
@@ -1752,6 +2222,15 @@ function deviceScreenTool(engine, vision) {
|
|
|
1752
2222
|
}
|
|
1753
2223
|
const image = await Vision.maybeAttachScreenshot(vision, png, exec)
|
|
1754
2224
|
if (image !== undefined) out.image = image
|
|
2225
|
+
if (png) {
|
|
2226
|
+
out.presentationMeta = {
|
|
2227
|
+
kind: 'android-screenshot',
|
|
2228
|
+
device: { serial },
|
|
2229
|
+
path: png,
|
|
2230
|
+
}
|
|
2231
|
+
} else {
|
|
2232
|
+
out.presentationMeta = { kind: 'android-screenshot', device: { serial } }
|
|
2233
|
+
}
|
|
1755
2234
|
return out
|
|
1756
2235
|
},
|
|
1757
2236
|
})
|
|
@@ -2115,6 +2594,14 @@ function guidance() {
|
|
|
2115
2594
|
' that just need to see the screen should prefer device_screen or device_ui_tree.',
|
|
2116
2595
|
'- device_log: read device logs (logcat main/crash/events, kernel dmesg). Call it when a run fails or an app misbehaves.',
|
|
2117
2596
|
'- device_status: one normalized snapshot of attached devices, AVDs, running/parked projects, Metro and preview servers.',
|
|
2597
|
+
'- device_ui_rows: detect list/feed ROWS on the attached screen (Settings pages, feeds, inboxes) with per-row index, group,',
|
|
2598
|
+
' frame, aggregated label and parsed counters (e.g. "3万 粉丝" → 粉丝=30000). Use it before tapping a row.',
|
|
2599
|
+
'- device_tap_row: tap inside a row by index at a row-relative position; optional expect_count {key, delta} verifies the',
|
|
2600
|
+
' counter changed by exactly delta after the tap (refused when the key is not visible first).',
|
|
2601
|
+
'- device_meminfo: `dumpsys meminfo <pkg>` → TOTAL PSS/RSS/Swap-PSS + App Summary heap buckets + top categories. Use it to',
|
|
2602
|
+
' check a running app\'s footprint.',
|
|
2603
|
+
'- device_backtrace: SIGQUIT an app process and read its newest /data/anr thread dump; falls back to the logcat crash buffer',
|
|
2604
|
+
' when /data/anr is unreadable (engine field says which). Deterministic crash/ANR capture for debugging.',
|
|
2118
2605
|
'',
|
|
2119
2606
|
'Expo and React Native projects are handled automatically: expo prebuild runs when needed, Metro starts for you,',
|
|
2120
2607
|
'and the app is installed and launched on the booted simulator/emulator. Failed builds report the error and a log tail.',
|
|
@@ -2206,6 +2693,10 @@ export function apply(ctx, config) {
|
|
|
2206
2693
|
deviceLogTool(engine),
|
|
2207
2694
|
deviceStatusTool(engine),
|
|
2208
2695
|
deviceInputTool(),
|
|
2696
|
+
deviceUiRowsTool(),
|
|
2697
|
+
deviceTapRowTool(),
|
|
2698
|
+
deviceBacktraceTool(),
|
|
2699
|
+
deviceMeminfoTool(),
|
|
2209
2700
|
].map((tool) => ctx.tools.register(tool))
|
|
2210
2701
|
return () => { for (const dispose of disposers) dispose() }
|
|
2211
2702
|
}, 'dsh-mobilecode: tools')
|