dsh-mobilecode 0.6.1 → 0.7.1
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 +70 -6
- package/lib/client.js +286 -27
- package/lib/device-build.js +52 -0
- package/lib/index.js +528 -20
- package/lib/list-rows.js +299 -0
- package/package.json +2 -2
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,77 @@ 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
|
+
if (serial !== stream.host.streamedSerial) {
|
|
535
|
+
const online = await stream.host.listDevices()
|
|
536
|
+
if (!online.some((device) => device.serial === serial)) { writeJson(res, 409, { code: 'device_not_found', error: `device ${serial} is not online` }); return }
|
|
537
|
+
}
|
|
538
|
+
try {
|
|
539
|
+
await DeviceBuild.adbRun(serial, ['shell', ...argv])
|
|
540
|
+
writeJson(res, 200, { ok: true, action, device: serial })
|
|
541
|
+
} catch (error) {
|
|
542
|
+
writeJson(res, 502, { code: 'device_action_failed', error: `device action "${action}" failed: ${error instanceof Error ? error.message : String(error)}` })
|
|
474
543
|
}
|
|
475
544
|
},
|
|
476
545
|
},
|
|
@@ -484,17 +553,17 @@ function makeRoutes(engine, config, stream) {
|
|
|
484
553
|
const body = await readBody(req, res)
|
|
485
554
|
if (body === undefined) return
|
|
486
555
|
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 }
|
|
556
|
+
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
557
|
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 }
|
|
558
|
+
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
559
|
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 }
|
|
560
|
+
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 }
|
|
561
|
+
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 }
|
|
562
|
+
if (action.kind === 'button' && (typeof action.name !== 'string' || action.name === '')) { writeJson(res, 400, { code: 'bad_request', error: 'button requires a non-empty name' }); return }
|
|
563
|
+
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
564
|
if (stream.host.streamedSerial !== serial) {
|
|
496
565
|
const online = await stream.host.listDevices()
|
|
497
|
-
if (!online.some((device) => device.serial === serial)) { writeJson(res, 409, { error: `device ${serial} is not online` }); return }
|
|
566
|
+
if (!online.some((device) => device.serial === serial)) { writeJson(res, 409, { code: 'device_offline', error: `device ${serial} is not online` }); return }
|
|
498
567
|
}
|
|
499
568
|
const release = stream.host.acquire()
|
|
500
569
|
try {
|
|
@@ -511,11 +580,11 @@ function makeRoutes(engine, config, stream) {
|
|
|
511
580
|
result = { ok: true, rotation: next }
|
|
512
581
|
break
|
|
513
582
|
}
|
|
514
|
-
default: writeJson(res, 400, { error: `unknown control action ${JSON.stringify(action.kind)}` }); return
|
|
583
|
+
default: writeJson(res, 400, { code: 'unknown_action', error: `unknown control action ${JSON.stringify(action.kind)}` }); return
|
|
515
584
|
}
|
|
516
585
|
writeJson(res, 200, result)
|
|
517
586
|
} catch (error) {
|
|
518
|
-
writeJson(res, 502, { error: `the device control failed: ${error instanceof Error ? error.message : String(error)}` })
|
|
587
|
+
writeJson(res, 502, { code: 'control_failed', error: `the device control failed: ${error instanceof Error ? error.message : String(error)}` })
|
|
519
588
|
} finally {
|
|
520
589
|
release()
|
|
521
590
|
}
|
|
@@ -747,6 +816,24 @@ async function requireAndroidDevice(serial) {
|
|
|
747
816
|
return target
|
|
748
817
|
}
|
|
749
818
|
|
|
819
|
+
/**
|
|
820
|
+
* PNG IHDR hand-parse: read the 8-byte signature + 24-byte IHDR chunk and pull
|
|
821
|
+
* width/height (big-endian at offsets 16/20). Returns undefined for non-PNGs —
|
|
822
|
+
* the PNG the screencap writes is always 8-bit RGBA non-interlaced, so no
|
|
823
|
+
* deeper parsing is needed.
|
|
824
|
+
*/
|
|
825
|
+
function pngSize(filePath) {
|
|
826
|
+
try {
|
|
827
|
+
const bytes = readFileSync(filePath)
|
|
828
|
+
if (bytes.length < 24 || bytes[0] !== 0x89 || bytes[1] !== 0x50 || bytes[2] !== 0x4e || bytes[3] !== 0x47) return undefined
|
|
829
|
+
const width = bytes.readUInt32BE(16)
|
|
830
|
+
const height = bytes.readUInt32BE(20)
|
|
831
|
+
return width > 0 && height > 0 ? { width, height } : undefined
|
|
832
|
+
} catch {
|
|
833
|
+
return undefined
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
|
|
750
837
|
/** Common key names → Android keycode. Anything else can be passed as a raw keycode integer. */
|
|
751
838
|
const KEYCODES = {
|
|
752
839
|
back: 4, home: 3, menu: 82, recents: 187, app_switch: 187, enter: 66, tab: 61, space: 62,
|
|
@@ -1368,6 +1455,378 @@ function deviceRebootTool() {
|
|
|
1368
1455
|
})
|
|
1369
1456
|
}
|
|
1370
1457
|
|
|
1458
|
+
function deviceUiRowsTool() {
|
|
1459
|
+
return defineTool({
|
|
1460
|
+
name: 'device_ui_rows',
|
|
1461
|
+
description: 'Detect the list/feed ROWS on the attached Android screen (Settings pages, inboxes, feeds): every row reports ' +
|
|
1462
|
+
'its index, an isomorphic group id, its pixel frame, the aggregated visible label, and any parsed counters (e.g. "3万 粉丝" ' +
|
|
1463
|
+
'→ {key:"粉丝", value:30000}, "1.2k likes" → {key:"likes", value:1200}). Rows are the unit the user sees — use this instead of ' +
|
|
1464
|
+
'device_ui_tree when the screen is a list, then tap a row by index with device_tap_row. Row detection is repetition-based: ' +
|
|
1465
|
+
'>=3 sibling subtrees of one parent sharing a class and near-equal height (tolerance max(8px, 15%)).',
|
|
1466
|
+
parameters: {
|
|
1467
|
+
serial: { type: 'string', description: 'Android device serial. Omit to use the first attached device.' },
|
|
1468
|
+
filter: { type: 'string', description: 'Optional case-insensitive substring; keep only rows whose label contains it.' },
|
|
1469
|
+
},
|
|
1470
|
+
output: {
|
|
1471
|
+
schema: {
|
|
1472
|
+
type: 'object',
|
|
1473
|
+
additionalProperties: false,
|
|
1474
|
+
properties: {
|
|
1475
|
+
serial: { type: 'string', required: true },
|
|
1476
|
+
screen: {
|
|
1477
|
+
type: 'object',
|
|
1478
|
+
additionalProperties: false,
|
|
1479
|
+
properties: {
|
|
1480
|
+
width: { type: 'integer', required: true },
|
|
1481
|
+
height: { type: 'integer', required: true },
|
|
1482
|
+
},
|
|
1483
|
+
},
|
|
1484
|
+
omittedOffscreen: { type: 'integer' },
|
|
1485
|
+
rows: {
|
|
1486
|
+
type: 'array',
|
|
1487
|
+
required: true,
|
|
1488
|
+
items: {
|
|
1489
|
+
type: 'object',
|
|
1490
|
+
additionalProperties: false,
|
|
1491
|
+
properties: {
|
|
1492
|
+
index: { type: 'integer', required: true },
|
|
1493
|
+
group: { type: 'integer', required: true },
|
|
1494
|
+
frame: {
|
|
1495
|
+
type: 'object',
|
|
1496
|
+
additionalProperties: false,
|
|
1497
|
+
properties: {
|
|
1498
|
+
x: { type: 'integer', required: true },
|
|
1499
|
+
y: { type: 'integer', required: true },
|
|
1500
|
+
w: { type: 'integer', required: true },
|
|
1501
|
+
h: { type: 'integer', required: true },
|
|
1502
|
+
},
|
|
1503
|
+
},
|
|
1504
|
+
label: { type: 'string', required: true },
|
|
1505
|
+
counters: {
|
|
1506
|
+
type: 'array',
|
|
1507
|
+
required: true,
|
|
1508
|
+
items: {
|
|
1509
|
+
type: 'object',
|
|
1510
|
+
additionalProperties: false,
|
|
1511
|
+
properties: {
|
|
1512
|
+
key: { type: 'string', required: true },
|
|
1513
|
+
value: { type: 'number', required: true },
|
|
1514
|
+
raw: { type: 'string', required: true },
|
|
1515
|
+
},
|
|
1516
|
+
},
|
|
1517
|
+
},
|
|
1518
|
+
},
|
|
1519
|
+
},
|
|
1520
|
+
},
|
|
1521
|
+
},
|
|
1522
|
+
},
|
|
1523
|
+
render: (_args, value) => {
|
|
1524
|
+
const v = value ?? { serial: '', rows: [] }
|
|
1525
|
+
const lines = [`Device: ${v.serial} — ${v.rows.length} row(s)${v.omittedOffscreen ? ` (${v.omittedOffscreen} off-screen omitted)` : ''}`]
|
|
1526
|
+
for (const row of v.rows.slice(0, 20)) {
|
|
1527
|
+
const counterText = row.counters.length > 0 ? ` [${row.counters.map((c) => `${c.key}=${c.value}`).join(', ')}]` : ''
|
|
1528
|
+
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}`)
|
|
1529
|
+
}
|
|
1530
|
+
if (v.rows.length > 20) lines.push(` ... and ${v.rows.length - 20} more`)
|
|
1531
|
+
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).')
|
|
1532
|
+
return [{ type: 'text', text: lines.join('\n') }]
|
|
1533
|
+
},
|
|
1534
|
+
},
|
|
1535
|
+
async execute(args) {
|
|
1536
|
+
const serial = await requireAndroidDevice(args.serial)
|
|
1537
|
+
const parsed = await UiTree.readUiTree(serial)
|
|
1538
|
+
const screen = UiTree.screenBoundsOf(parsed.roots)
|
|
1539
|
+
const { rows, omittedOffscreen } = RowList.detectRows(parsed.roots, screen)
|
|
1540
|
+
const filter = args.filter !== undefined && String(args.filter).trim() !== '' ? String(args.filter).trim().toLowerCase() : undefined
|
|
1541
|
+
const out = {
|
|
1542
|
+
serial,
|
|
1543
|
+
screen: { width: screen.width, height: screen.height },
|
|
1544
|
+
omittedOffscreen,
|
|
1545
|
+
rows: rows.filter((row) => filter === undefined || row.label.toLowerCase().includes(filter)),
|
|
1546
|
+
}
|
|
1547
|
+
return DeviceBuild.jsonSafe(out)
|
|
1548
|
+
},
|
|
1549
|
+
})
|
|
1550
|
+
}
|
|
1551
|
+
|
|
1552
|
+
function deviceTapRowTool() {
|
|
1553
|
+
return defineTool({
|
|
1554
|
+
name: 'device_tap_row',
|
|
1555
|
+
description: 'Tap inside a list/feed row by index (from device_ui_rows) at a row-relative position: x,y are fractions of the ' +
|
|
1556
|
+
'row frame (default 0.5,0.5 = the row center). Optional expect_count {key, delta} turns the tap into one verified round trip: ' +
|
|
1557
|
+
'the counter must ALREADY be visible in the row before the tap (refused otherwise — never probe an unknown control), and after ' +
|
|
1558
|
+
'an 800ms settle the row at the same index + position must show the count changed by EXACTLY delta (default +1).',
|
|
1559
|
+
parameters: {
|
|
1560
|
+
serial: { type: 'string', description: 'Android device serial. Omit to use the first attached device.' },
|
|
1561
|
+
row: { type: 'integer', description: 'Zero-based row index from device_ui_rows.', required: true },
|
|
1562
|
+
x: { type: 'number', description: 'Row-relative X fraction 0..1 (0 = left edge, 1 = right edge). Default 0.5.' },
|
|
1563
|
+
y: { type: 'number', description: 'Row-relative Y fraction 0..1. Default 0.5.' },
|
|
1564
|
+
expect_count: {
|
|
1565
|
+
type: 'object',
|
|
1566
|
+
additionalProperties: false,
|
|
1567
|
+
description: 'Verify a counter changed by exactly delta after the tap.',
|
|
1568
|
+
properties: {
|
|
1569
|
+
key: { type: 'string', description: 'Counter key as reported by device_ui_rows (e.g. "粉丝", "likes").', required: true },
|
|
1570
|
+
delta: { type: 'integer', description: 'Expected change: 1 (default) or -1.' },
|
|
1571
|
+
},
|
|
1572
|
+
},
|
|
1573
|
+
},
|
|
1574
|
+
output: {
|
|
1575
|
+
schema: {
|
|
1576
|
+
type: 'object',
|
|
1577
|
+
additionalProperties: false,
|
|
1578
|
+
properties: {
|
|
1579
|
+
serial: { type: 'string', required: true },
|
|
1580
|
+
row: { type: 'integer', required: true },
|
|
1581
|
+
x: { type: 'integer', required: true },
|
|
1582
|
+
y: { type: 'integer', required: true },
|
|
1583
|
+
expect: {
|
|
1584
|
+
type: 'object',
|
|
1585
|
+
additionalProperties: false,
|
|
1586
|
+
properties: {
|
|
1587
|
+
verified: { type: 'boolean', required: true },
|
|
1588
|
+
reason: { type: 'string' },
|
|
1589
|
+
before: { type: 'number' },
|
|
1590
|
+
after: { type: 'number' },
|
|
1591
|
+
},
|
|
1592
|
+
},
|
|
1593
|
+
},
|
|
1594
|
+
},
|
|
1595
|
+
render: (_args, value) => {
|
|
1596
|
+
const v = value ?? { serial: '', row: 0, x: 0, y: 0 }
|
|
1597
|
+
const lines = [`Tapped row #${v.row} at ${v.x},${v.y} on ${v.serial}`]
|
|
1598
|
+
if (v.expect) {
|
|
1599
|
+
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})` : ''}`)
|
|
1600
|
+
}
|
|
1601
|
+
return [{ type: 'text', text: lines.join('\n') }]
|
|
1602
|
+
},
|
|
1603
|
+
},
|
|
1604
|
+
async execute(args) {
|
|
1605
|
+
const serial = await requireAndroidDevice(args.serial)
|
|
1606
|
+
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).')
|
|
1607
|
+
const parsed = await UiTree.readUiTree(serial)
|
|
1608
|
+
const screen = UiTree.screenBoundsOf(parsed.roots)
|
|
1609
|
+
const { rows } = RowList.detectRows(parsed.roots, screen)
|
|
1610
|
+
const row = rows[args.row]
|
|
1611
|
+
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.`)
|
|
1612
|
+
const fractionX = args.x === undefined ? 0.5 : args.x
|
|
1613
|
+
const fractionY = args.y === undefined ? 0.5 : args.y
|
|
1614
|
+
const expect = args.expect_count
|
|
1615
|
+
if (expect) {
|
|
1616
|
+
const key = RowList.normalizeCountKey(String(expect.key))
|
|
1617
|
+
if (!row.counters.some((c) => c.key === key)) {
|
|
1618
|
+
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.`)
|
|
1619
|
+
}
|
|
1620
|
+
}
|
|
1621
|
+
const point = RowList.planRowTap(rows, args.row, fractionX, fractionY)
|
|
1622
|
+
await DeviceBuild.adbRun(serial, ['shell', 'input', 'tap', String(point.x), String(point.y)])
|
|
1623
|
+
const out = { serial, row: args.row, x: point.x, y: point.y }
|
|
1624
|
+
if (expect) {
|
|
1625
|
+
await new Promise((resolve) => setTimeout(resolve, 800))
|
|
1626
|
+
const fresh = await UiTree.readUiTree(serial).catch(() => undefined)
|
|
1627
|
+
if (fresh === undefined) {
|
|
1628
|
+
out.expect = { verified: false, reason: 'could not re-read the screen after the tap' }
|
|
1629
|
+
} else {
|
|
1630
|
+
const freshRows = RowList.detectRows(fresh.roots, UiTree.screenBoundsOf(fresh.roots)).rows
|
|
1631
|
+
const afterRow = freshRows[args.row]
|
|
1632
|
+
const key = RowList.normalizeCountKey(String(expect.key))
|
|
1633
|
+
const delta = expect.delta === undefined ? 1 : Number(expect.delta)
|
|
1634
|
+
const verification = afterRow !== undefined && RowList.rowsStayedPut(row, afterRow)
|
|
1635
|
+
? RowList.verifyCountChange(row, afterRow, key, delta)
|
|
1636
|
+
: { verified: false, reason: 'the list moved after the tap (row at this index changed position) — the count could not be verified' }
|
|
1637
|
+
out.expect = {
|
|
1638
|
+
verified: verification.verified,
|
|
1639
|
+
...(verification.reason ? { reason: verification.reason } : {}),
|
|
1640
|
+
...(verification.before !== undefined ? { before: verification.before, after: verification.after } : {}),
|
|
1641
|
+
}
|
|
1642
|
+
}
|
|
1643
|
+
}
|
|
1644
|
+
return DeviceBuild.jsonSafe(out)
|
|
1645
|
+
},
|
|
1646
|
+
})
|
|
1647
|
+
}
|
|
1648
|
+
|
|
1649
|
+
function deviceBacktraceTool() {
|
|
1650
|
+
return defineTool({
|
|
1651
|
+
name: 'device_backtrace',
|
|
1652
|
+
description: 'Capture a native/ANR thread backtrace or crash log for an app on the device. Sends SIGQUIT (kill -3) to the process, ' +
|
|
1653
|
+
'waits for the ART runtime to write the thread dump, then reads the newest /data/anr entry. When /data/anr is unrunnable it falls ' +
|
|
1654
|
+
'back to the logcat crash buffer and says so (engine field: "anr-trace" | "logcat-crash"). SIGQUIT refusal (system-uid or ' +
|
|
1655
|
+
'non-debuggable process) degrades to the crash buffer with an explanatory note instead of failing. Pass package_name or pid.',
|
|
1656
|
+
parameters: {
|
|
1657
|
+
serial: { type: 'string', description: 'Android device serial. Omit to use the first attached device.' },
|
|
1658
|
+
package_name: { type: 'string', description: 'Package of the process to trace (e.g. com.simspoof.app).' },
|
|
1659
|
+
pid: { type: 'integer', description: 'Explicit pid; resolved from package_name when omitted.' },
|
|
1660
|
+
},
|
|
1661
|
+
output: {
|
|
1662
|
+
schema: {
|
|
1663
|
+
type: 'object',
|
|
1664
|
+
additionalProperties: false,
|
|
1665
|
+
properties: {
|
|
1666
|
+
serial: { type: 'string', required: true },
|
|
1667
|
+
engine: { type: 'string', required: true },
|
|
1668
|
+
package_name: { type: 'string' },
|
|
1669
|
+
pid: { type: 'integer', required: true },
|
|
1670
|
+
trace_path: { type: 'string' },
|
|
1671
|
+
note: { type: 'string' },
|
|
1672
|
+
lines: { type: 'array', items: { type: 'string' }, required: true },
|
|
1673
|
+
},
|
|
1674
|
+
},
|
|
1675
|
+
render: (_args, value) => {
|
|
1676
|
+
const v = value ?? { serial: '', engine: '', pid: 0, lines: [] }
|
|
1677
|
+
const lines = [`Backtrace of pid ${v.pid} on ${v.serial} (engine: ${v.engine})${v.trace_path ? ` — ${v.trace_path}` : ''}`]
|
|
1678
|
+
if (v.note) lines.push(v.note)
|
|
1679
|
+
for (const line of v.lines.slice(-40)) lines.push(` ${line}`)
|
|
1680
|
+
if (v.lines.length > 40) lines.push(` ... ${v.lines.length - 40} more lines`)
|
|
1681
|
+
return [{ type: 'text', text: lines.join('\n') }]
|
|
1682
|
+
},
|
|
1683
|
+
},
|
|
1684
|
+
async execute(args) {
|
|
1685
|
+
const serial = await requireAndroidDevice(args.serial)
|
|
1686
|
+
const packageName = args.package_name !== undefined && args.package_name !== '' ? String(args.package_name) : undefined
|
|
1687
|
+
let pid
|
|
1688
|
+
if (args.pid !== undefined && Number.isInteger(args.pid) && args.pid > 0) {
|
|
1689
|
+
pid = args.pid
|
|
1690
|
+
} else if (packageName) {
|
|
1691
|
+
const pidout = await DeviceBuild.adbRun(serial, ['shell', 'pidof', '-s', DeviceBuild.shQuoteDevice(packageName)]).catch(() => undefined)
|
|
1692
|
+
const parsedPid = Number((pidout ?? '').trim())
|
|
1693
|
+
if (Number.isInteger(parsedPid) && parsedPid > 0) pid = parsedPid
|
|
1694
|
+
if (pid === undefined) {
|
|
1695
|
+
const ps = await DeviceBuild.adbRun(serial, ['shell', 'ps', '-A']).catch(() => '')
|
|
1696
|
+
const match = ps.split(/\r?\n/).find((line) => /\s+\d+\s/.test(line) && line.split(/\s+/).pop() === packageName)
|
|
1697
|
+
pid = match ? Number(/\s+(\d+)\s+/.exec(match)?.[1]) : undefined
|
|
1698
|
+
}
|
|
1699
|
+
}
|
|
1700
|
+
if (pid === undefined) {
|
|
1701
|
+
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.`)
|
|
1702
|
+
}
|
|
1703
|
+
// SIGQUIT: the ART runtime writes the thread dump to /data/anr/. The adb
|
|
1704
|
+
// shell user cannot signal system-uid / non-debuggable processes on
|
|
1705
|
+
// enforcing builds (EPERM) — degrade honestly instead of throwing.
|
|
1706
|
+
let sigquit = true
|
|
1707
|
+
try {
|
|
1708
|
+
await DeviceBuild.adbRun(serial, ['shell', 'kill', '-3', String(pid)])
|
|
1709
|
+
} catch {
|
|
1710
|
+
sigquit = false
|
|
1711
|
+
}
|
|
1712
|
+
let newest
|
|
1713
|
+
if (sigquit) {
|
|
1714
|
+
await new Promise((resolve) => setTimeout(resolve, 1200))
|
|
1715
|
+
const listing = await DeviceBuild.adbRun(serial, ['shell', 'ls', '-t', '/data/anr']).catch(() => undefined)
|
|
1716
|
+
newest = (listing ?? '').split(/\r?\n/).map((line) => line.trim()).filter((line) => line !== '' && !line.includes(' '))[0]
|
|
1717
|
+
if (newest !== undefined) {
|
|
1718
|
+
const content = await DeviceBuild.adbRun(serial, ['shell', 'cat', DeviceBuild.shQuoteDevice(`/data/anr/${newest}`)], { timeoutMs: 30_000 }).catch(() => undefined)
|
|
1719
|
+
if (content !== undefined && content.trim() !== '') {
|
|
1720
|
+
return DeviceBuild.jsonSafe({
|
|
1721
|
+
serial,
|
|
1722
|
+
engine: 'anr-trace',
|
|
1723
|
+
...(packageName ? { package_name: packageName } : {}),
|
|
1724
|
+
pid,
|
|
1725
|
+
trace_path: `/data/anr/${newest}`,
|
|
1726
|
+
lines: content.split(/\r?\n/).slice(-250),
|
|
1727
|
+
})
|
|
1728
|
+
}
|
|
1729
|
+
}
|
|
1730
|
+
}
|
|
1731
|
+
// Fallback: the logcat crash buffer (FATAL/AndroidRuntime lines), filtered by package when known.
|
|
1732
|
+
const log = await DeviceBuild.adbRun(serial, ['logcat', '-b', 'crash', '-d', '-v', 'time']).catch(() => '')
|
|
1733
|
+
const all = log.split(/\r?\n/).filter((line) => /FATAL EXCEPTION|AndroidRuntime|DEBUG|Abort message/.test(line))
|
|
1734
|
+
const lines = (packageName ? all.filter((line) => line.includes(packageName)) : all)
|
|
1735
|
+
if (lines.length === 0) {
|
|
1736
|
+
// Keep the honest fallback note: anr-trace failed; crash buffer silent.
|
|
1737
|
+
return DeviceBuild.jsonSafe({
|
|
1738
|
+
serial,
|
|
1739
|
+
engine: 'logcat-crash',
|
|
1740
|
+
...(packageName ? { package_name: packageName } : {}),
|
|
1741
|
+
pid,
|
|
1742
|
+
note: sigquit
|
|
1743
|
+
? `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.`
|
|
1744
|
+
: `SIGQUIT refused for pid ${pid} — the adb shell user cannot signal this process (system uid or non-debuggable app on an enforcing build), so no fresh thread dump exists; the logcat crash buffer has no matching FATAL lines either. Target a debuggable app for a live dump, or use an adb-rooted device.`,
|
|
1745
|
+
lines: [],
|
|
1746
|
+
})
|
|
1747
|
+
}
|
|
1748
|
+
return DeviceBuild.jsonSafe({
|
|
1749
|
+
serial,
|
|
1750
|
+
engine: 'logcat-crash',
|
|
1751
|
+
...(packageName ? { package_name: packageName } : {}),
|
|
1752
|
+
pid,
|
|
1753
|
+
note: '/data/anr unreadable; fell back to the logcat crash buffer.',
|
|
1754
|
+
lines: lines.slice(-200),
|
|
1755
|
+
})
|
|
1756
|
+
},
|
|
1757
|
+
})
|
|
1758
|
+
}
|
|
1759
|
+
|
|
1760
|
+
function deviceMeminfoTool() {
|
|
1761
|
+
return defineTool({
|
|
1762
|
+
name: 'device_meminfo',
|
|
1763
|
+
description: 'Read a running app\'s memory profile from `dumpsys meminfo <package>`: TOTAL PSS/RSS/Swap-PSS plus the App Summary heap ' +
|
|
1764
|
+
'breakdown (Java/Native/Code/Stack/Graphics) and the top PSS categories (mmap regions etc.). Throws when the package has no running ' +
|
|
1765
|
+
'process. Use to check an app\'s footprint after a run, or to compare builds.',
|
|
1766
|
+
parameters: {
|
|
1767
|
+
serial: { type: 'string', description: 'Android device serial. Omit to use the first attached device.' },
|
|
1768
|
+
package_name: { type: 'string', description: 'Package to profile (see device_apps).', required: true },
|
|
1769
|
+
},
|
|
1770
|
+
output: {
|
|
1771
|
+
schema: {
|
|
1772
|
+
type: 'object',
|
|
1773
|
+
additionalProperties: false,
|
|
1774
|
+
properties: {
|
|
1775
|
+
serial: { type: 'string', required: true },
|
|
1776
|
+
package_name: { type: 'string', required: true },
|
|
1777
|
+
totalPssKb: { type: 'number' },
|
|
1778
|
+
totalRssKb: { type: 'number' },
|
|
1779
|
+
totalSwapPssKb: { type: 'number' },
|
|
1780
|
+
appSummary: {
|
|
1781
|
+
type: 'object',
|
|
1782
|
+
additionalProperties: true,
|
|
1783
|
+
description: 'PSS KB per heap bucket: {javaHeapKb, nativeHeapKb, codeKb, stackKb, graphicsKb}.',
|
|
1784
|
+
},
|
|
1785
|
+
topCategories: {
|
|
1786
|
+
type: 'array',
|
|
1787
|
+
items: {
|
|
1788
|
+
type: 'object',
|
|
1789
|
+
additionalProperties: false,
|
|
1790
|
+
properties: {
|
|
1791
|
+
name: { type: 'string', required: true },
|
|
1792
|
+
pssKb: { type: 'number', required: true },
|
|
1793
|
+
},
|
|
1794
|
+
},
|
|
1795
|
+
},
|
|
1796
|
+
},
|
|
1797
|
+
},
|
|
1798
|
+
render: (_args, value) => {
|
|
1799
|
+
const v = value ?? { serial: '', package_name: '' }
|
|
1800
|
+
const mb = (kb) => (kb === undefined ? '?' : `${Math.round(kb / 1024)} MB`)
|
|
1801
|
+
const lines = [`Memory of ${v.package_name} on ${v.serial}`]
|
|
1802
|
+
lines.push(` TOTAL PSS: ${mb(v.totalPssKb)} RSS: ${mb(v.totalRssKb)} SwapPSS: ${mb(v.totalSwapPssKb)}`)
|
|
1803
|
+
if (v.appSummary && Object.keys(v.appSummary).length > 0) {
|
|
1804
|
+
const labels = { javaHeapKb: 'Java heap', nativeHeapKb: 'Native heap', codeKb: 'Code', stackKb: 'Stack', graphicsKb: 'Graphics' }
|
|
1805
|
+
const parts = []
|
|
1806
|
+
for (const [key, label] of Object.entries(labels)) {
|
|
1807
|
+
if (v.appSummary[key] !== undefined) parts.push(`${label}: ${mb(v.appSummary[key])}`)
|
|
1808
|
+
}
|
|
1809
|
+
if (parts.length > 0) lines.push(` App Summary — ${parts.join(', ')}`)
|
|
1810
|
+
}
|
|
1811
|
+
if (v.topCategories && v.topCategories.length > 0) {
|
|
1812
|
+
lines.push(' Top categories:')
|
|
1813
|
+
for (const category of v.topCategories.slice(0, 6)) lines.push(` - ${category.name}: ${mb(category.pssKb)}`)
|
|
1814
|
+
}
|
|
1815
|
+
return [{ type: 'text', text: lines.join('\n') }]
|
|
1816
|
+
},
|
|
1817
|
+
},
|
|
1818
|
+
async execute(args) {
|
|
1819
|
+
const serial = await requireAndroidDevice(args.serial)
|
|
1820
|
+
const packageName = String(args.package_name ?? '').trim()
|
|
1821
|
+
if (packageName === '') throw new Error('device_meminfo requires a package_name (see device_apps).')
|
|
1822
|
+
const output = await DeviceBuild.adbRun(serial, ['shell', 'dumpsys', 'meminfo', packageName], { timeoutMs: 30_000 })
|
|
1823
|
+
const parsed = DeviceBuild.parsePackageMeminfo(output)
|
|
1824
|
+
if (parsed === undefined) throw new Error(`\`dumpsys meminfo ${packageName}\` produced no process block on ${serial} — is ${packageName} running?`)
|
|
1825
|
+
return DeviceBuild.jsonSafe({ serial, package_name: packageName, ...parsed })
|
|
1826
|
+
},
|
|
1827
|
+
})
|
|
1828
|
+
}
|
|
1829
|
+
|
|
1371
1830
|
function deviceBootTool() {
|
|
1372
1831
|
return defineTool({
|
|
1373
1832
|
name: 'device_boot',
|
|
@@ -1387,6 +1846,11 @@ function deviceBootTool() {
|
|
|
1387
1846
|
avd: { type: 'string', required: true },
|
|
1388
1847
|
booted: { type: 'boolean', required: true },
|
|
1389
1848
|
alreadyRunning: { type: 'boolean', required: true },
|
|
1849
|
+
presentationMeta: {
|
|
1850
|
+
type: 'object',
|
|
1851
|
+
additionalProperties: true,
|
|
1852
|
+
description: 'Projected into ToolResultNode.meta for the conversation stream card.',
|
|
1853
|
+
},
|
|
1390
1854
|
},
|
|
1391
1855
|
},
|
|
1392
1856
|
render: (_args, value) => {
|
|
@@ -1403,7 +1867,10 @@ function deviceBootTool() {
|
|
|
1403
1867
|
const name = await DeviceBuild.avdName(row.serial).catch(() => undefined)
|
|
1404
1868
|
if (name === avd) {
|
|
1405
1869
|
const booted = await DeviceBuild.waitForBoot(row.serial, timeout)
|
|
1406
|
-
return {
|
|
1870
|
+
return {
|
|
1871
|
+
serial: row.serial, avd, booted, alreadyRunning: true,
|
|
1872
|
+
presentationMeta: { kind: 'android-stream', device: { serial: row.serial, name: avd } },
|
|
1873
|
+
}
|
|
1407
1874
|
}
|
|
1408
1875
|
}
|
|
1409
1876
|
if (!DeviceBuild.emulatorBinary()) throw new Error('No SDK emulator binary found; cannot boot an AVD.')
|
|
@@ -1420,7 +1887,10 @@ function deviceBootTool() {
|
|
|
1420
1887
|
if (!serial) throw new Error(`No emulator serial appeared for "${avd}" within ${timeout} ms.`)
|
|
1421
1888
|
const booted = await DeviceBuild.waitForBoot(serial, Math.max(1000, deadline - Date.now()))
|
|
1422
1889
|
if (!booted) throw new Error(`Emulator ${serial} ("${avd}") did not finish booting within ${timeout} ms.`)
|
|
1423
|
-
return {
|
|
1890
|
+
return {
|
|
1891
|
+
serial, avd, booted: true, alreadyRunning: false,
|
|
1892
|
+
presentationMeta: { kind: 'android-stream', device: { serial, name: avd } },
|
|
1893
|
+
}
|
|
1424
1894
|
},
|
|
1425
1895
|
})
|
|
1426
1896
|
}
|
|
@@ -1609,6 +2079,11 @@ function deviceStreamTool(host, access) {
|
|
|
1609
2079
|
streamUrl: { type: 'string' },
|
|
1610
2080
|
width: { type: 'integer' },
|
|
1611
2081
|
height: { type: 'integer' },
|
|
2082
|
+
presentationMeta: {
|
|
2083
|
+
type: 'object',
|
|
2084
|
+
additionalProperties: true,
|
|
2085
|
+
description: 'Projected into ToolResultNode.meta for the conversation stream card.',
|
|
2086
|
+
},
|
|
1612
2087
|
},
|
|
1613
2088
|
},
|
|
1614
2089
|
render: (_args, value) => {
|
|
@@ -1634,7 +2109,14 @@ function deviceStreamTool(host, access) {
|
|
|
1634
2109
|
if (!online.some((device) => device.serial === serial)) throw new Error(`device ${serial} is not online; cannot stream it`)
|
|
1635
2110
|
const info = await host.ensureStreaming({ serial })
|
|
1636
2111
|
const signed = await access.signStreamToken(serial)
|
|
1637
|
-
return {
|
|
2112
|
+
return {
|
|
2113
|
+
action, running: true, serial: info.serial, width: info.width, height: info.height, streamUrl: `${API_BASE}/stream?token=${encodeURIComponent(signed.token)}`,
|
|
2114
|
+
presentationMeta: {
|
|
2115
|
+
kind: 'android-stream',
|
|
2116
|
+
device: { serial: info.serial },
|
|
2117
|
+
streamRouteId: `dsh-mobilecode/stream/${info.serial}`,
|
|
2118
|
+
},
|
|
2119
|
+
}
|
|
1638
2120
|
},
|
|
1639
2121
|
})
|
|
1640
2122
|
}
|
|
@@ -1697,6 +2179,11 @@ function deviceScreenTool(engine, vision) {
|
|
|
1697
2179
|
},
|
|
1698
2180
|
ocrError: { type: 'string' },
|
|
1699
2181
|
image: Vision.IMAGE_REF_SCHEMA,
|
|
2182
|
+
presentationMeta: {
|
|
2183
|
+
type: 'object',
|
|
2184
|
+
additionalProperties: true,
|
|
2185
|
+
description: 'Projected into ToolResultNode.meta for the conversation screenshot card.',
|
|
2186
|
+
},
|
|
1700
2187
|
},
|
|
1701
2188
|
},
|
|
1702
2189
|
render: (_args, value) => {
|
|
@@ -1752,6 +2239,15 @@ function deviceScreenTool(engine, vision) {
|
|
|
1752
2239
|
}
|
|
1753
2240
|
const image = await Vision.maybeAttachScreenshot(vision, png, exec)
|
|
1754
2241
|
if (image !== undefined) out.image = image
|
|
2242
|
+
if (png) {
|
|
2243
|
+
out.presentationMeta = {
|
|
2244
|
+
kind: 'android-screenshot',
|
|
2245
|
+
device: { serial },
|
|
2246
|
+
path: png,
|
|
2247
|
+
}
|
|
2248
|
+
} else {
|
|
2249
|
+
out.presentationMeta = { kind: 'android-screenshot', device: { serial } }
|
|
2250
|
+
}
|
|
1755
2251
|
return out
|
|
1756
2252
|
},
|
|
1757
2253
|
})
|
|
@@ -2115,6 +2611,14 @@ function guidance() {
|
|
|
2115
2611
|
' that just need to see the screen should prefer device_screen or device_ui_tree.',
|
|
2116
2612
|
'- device_log: read device logs (logcat main/crash/events, kernel dmesg). Call it when a run fails or an app misbehaves.',
|
|
2117
2613
|
'- device_status: one normalized snapshot of attached devices, AVDs, running/parked projects, Metro and preview servers.',
|
|
2614
|
+
'- device_ui_rows: detect list/feed ROWS on the attached screen (Settings pages, feeds, inboxes) with per-row index, group,',
|
|
2615
|
+
' frame, aggregated label and parsed counters (e.g. "3万 粉丝" → 粉丝=30000). Use it before tapping a row.',
|
|
2616
|
+
'- device_tap_row: tap inside a row by index at a row-relative position; optional expect_count {key, delta} verifies the',
|
|
2617
|
+
' counter changed by exactly delta after the tap (refused when the key is not visible first).',
|
|
2618
|
+
'- device_meminfo: `dumpsys meminfo <pkg>` → TOTAL PSS/RSS/Swap-PSS + App Summary heap buckets + top categories. Use it to',
|
|
2619
|
+
' check a running app\'s footprint.',
|
|
2620
|
+
'- device_backtrace: SIGQUIT an app process and read its newest /data/anr thread dump; falls back to the logcat crash buffer',
|
|
2621
|
+
' when /data/anr is unreadable (engine field says which). Deterministic crash/ANR capture for debugging.',
|
|
2118
2622
|
'',
|
|
2119
2623
|
'Expo and React Native projects are handled automatically: expo prebuild runs when needed, Metro starts for you,',
|
|
2120
2624
|
'and the app is installed and launched on the booted simulator/emulator. Failed builds report the error and a log tail.',
|
|
@@ -2206,6 +2710,10 @@ export function apply(ctx, config) {
|
|
|
2206
2710
|
deviceLogTool(engine),
|
|
2207
2711
|
deviceStatusTool(engine),
|
|
2208
2712
|
deviceInputTool(),
|
|
2713
|
+
deviceUiRowsTool(),
|
|
2714
|
+
deviceTapRowTool(),
|
|
2715
|
+
deviceBacktraceTool(),
|
|
2716
|
+
deviceMeminfoTool(),
|
|
2209
2717
|
].map((tool) => ctx.tools.register(tool))
|
|
2210
2718
|
return () => { for (const dispose of disposers) dispose() }
|
|
2211
2719
|
}, 'dsh-mobilecode: tools')
|