dsh-mobilecode 0.7.0 → 0.8.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 +61 -3
- package/lib/device-build.js +121 -0
- package/lib/index.js +780 -19
- package/lib/list-rows.js +4 -0
- package/lib/mesh-hub.js +343 -0
- package/lib/vision.js +9 -6
- package/package.json +3 -2
- package/sdk/mesh-client.mjs +78 -0
package/lib/index.js
CHANGED
|
@@ -24,6 +24,7 @@ import * as StreamAccess from './stream-access.js'
|
|
|
24
24
|
import { AndroidStreamHost, ROTATION_CYCLE } from './android-stream.js'
|
|
25
25
|
import * as Vision from './vision.js'
|
|
26
26
|
import { DevicePreviewEngine } from './device-preview.js'
|
|
27
|
+
import { MeshHub } from './mesh-hub.js'
|
|
27
28
|
import * as Setup from './setup.js'
|
|
28
29
|
import { registerMobileSkill } from './skill.js'
|
|
29
30
|
import { existsSync, readFileSync } from 'node:fs'
|
|
@@ -92,6 +93,38 @@ function makeRoutes(engine, config, stream) {
|
|
|
92
93
|
if ((req.method ?? 'GET') !== 'POST') { writeJson(res, 405, { error: 'method not allowed' }); return false }
|
|
93
94
|
return true
|
|
94
95
|
}
|
|
96
|
+
// Mesh fence: game clients are plain HTTP stacks (OkHttp/Unity/curl) that
|
|
97
|
+
// never send browser headers — they must only prove a loopback peer plus a
|
|
98
|
+
// Host the emulator NAT actually presents (10.0.2.2) or loopback. Anything
|
|
99
|
+
// that DOES look like a browser request (Origin / Sec-Fetch-*) additionally
|
|
100
|
+
// passes the trusted-browser stream fence, so a random web page can never
|
|
101
|
+
// join a game session or read its messages.
|
|
102
|
+
const meshGuard = (req, res) => {
|
|
103
|
+
if (!StreamAccess.isLoopbackRemoteAddress(req.socket?.remoteAddress)) {
|
|
104
|
+
writeJson(res, 403, { code: 'forbidden', error: 'the mesh is loopback-only (emulators reach it via 10.0.2.2)' })
|
|
105
|
+
return false
|
|
106
|
+
}
|
|
107
|
+
if (req.headers.origin !== undefined || req.headers['sec-fetch-site'] !== undefined || req.headers['sec-fetch-mode'] !== undefined) {
|
|
108
|
+
return fence(req, res, false)
|
|
109
|
+
}
|
|
110
|
+
let hostname = ''
|
|
111
|
+
try { hostname = new URL('http://' + String(req.headers.host ?? '')).hostname } catch { hostname = '' }
|
|
112
|
+
const trusted = hostname === 'localhost' || hostname === '::1' || hostname === '[::1]'
|
|
113
|
+
|| hostname.startsWith('127.') || hostname === '10.0.2.2' || hostname === '10.0.3.2'
|
|
114
|
+
if (!trusted) { writeJson(res, 403, { code: 'forbidden', error: `unexpected Host "${hostname}" for a mesh client` }); return false }
|
|
115
|
+
return true
|
|
116
|
+
}
|
|
117
|
+
const meshAuth = (req, url, body) => {
|
|
118
|
+
const id = body?.id ?? url.searchParams.get('id') ?? req.headers['x-mesh-peer']
|
|
119
|
+
const token = body?.token ?? url.searchParams.get('token') ?? req.headers['x-mesh-token']
|
|
120
|
+
if (typeof id !== 'string' || !stream.mesh.verify(id, typeof token === 'string' ? token : '')) return undefined
|
|
121
|
+
return stream.mesh.peers.get(id)
|
|
122
|
+
}
|
|
123
|
+
const meshUnauthorized = (res) => writeJson(res, 401, { code: 'token_invalid', error: 'POST /mesh/join first, then pass your id + token' })
|
|
124
|
+
const meshFail = (res, error) => {
|
|
125
|
+
const status = ({ unknown_peer: 404, unknown_session: 409, not_member: 403, too_large: 413 })[error?.code] ?? 400
|
|
126
|
+
writeJson(res, status, { code: error?.code ?? 'bad_request', error: error instanceof Error ? error.message : String(error) })
|
|
127
|
+
}
|
|
95
128
|
const platformOf = (value) => (value === 'ios' || value === 'android' ? value : undefined)
|
|
96
129
|
const routes = [
|
|
97
130
|
// GET /api/dsh-mobilecode?directory=... → current info (platforms, servers, builds, bundler).
|
|
@@ -531,6 +564,10 @@ function makeRoutes(engine, config, stream) {
|
|
|
531
564
|
const argv = DEVICE_ACTIONS[action]
|
|
532
565
|
if (argv === undefined) { writeJson(res, 400, { code: 'unknown_action', error: `unknown device action "${action}"` }); return }
|
|
533
566
|
if (!StreamAccess.SERIAL_PATTERN.test(serial)) { writeJson(res, 400, { code: 'bad_request', error: 'device must be an adb device serial' }); return }
|
|
567
|
+
if (serial !== stream.host.streamedSerial) {
|
|
568
|
+
const online = await stream.host.listDevices()
|
|
569
|
+
if (!online.some((device) => device.serial === serial)) { writeJson(res, 409, { code: 'device_not_found', error: `device ${serial} is not online` }); return }
|
|
570
|
+
}
|
|
534
571
|
try {
|
|
535
572
|
await DeviceBuild.adbRun(serial, ['shell', ...argv])
|
|
536
573
|
writeJson(res, 200, { ok: true, action, device: serial })
|
|
@@ -586,6 +623,118 @@ function makeRoutes(engine, config, stream) {
|
|
|
586
623
|
}
|
|
587
624
|
},
|
|
588
625
|
},
|
|
626
|
+
// ── mesh (v0.8.0) ─ LocalSend-style rendezvous for co-op testing. Emulators
|
|
627
|
+
// cannot multicast through their isolated slirp NATs, but every one reaches
|
|
628
|
+
// the host at 10.0.2.2, so the hub lives on these routes. A game joins once
|
|
629
|
+
// (its serial pins a stable random callsign), links with a peer, then sends
|
|
630
|
+
// and polls JSON through the hub — which doubles as the agent's observation
|
|
631
|
+
// window (mesh_log) and network-condition injector (mesh_tune).
|
|
632
|
+
{
|
|
633
|
+
kind: 'exact',
|
|
634
|
+
path: API_BASE + '/mesh/join',
|
|
635
|
+
handler: async (req, res) => {
|
|
636
|
+
if (!meshGuard(req, res)) return
|
|
637
|
+
if (!isPost(req, res)) return
|
|
638
|
+
const body = await readBody(req, res)
|
|
639
|
+
if (body === undefined) return
|
|
640
|
+
try {
|
|
641
|
+
const { peer, token } = stream.mesh.join({ serial: body.serial, name: body.name, role: body.role })
|
|
642
|
+
writeJson(res, 200, { ok: true, peer, token })
|
|
643
|
+
} catch (error) {
|
|
644
|
+
meshFail(res, error)
|
|
645
|
+
}
|
|
646
|
+
},
|
|
647
|
+
},
|
|
648
|
+
{
|
|
649
|
+
kind: 'exact',
|
|
650
|
+
path: API_BASE + '/mesh/peers',
|
|
651
|
+
handler: async (req, res) => {
|
|
652
|
+
if (!meshGuard(req, res)) return
|
|
653
|
+
const url = new URL(req.url ?? '/', 'http://localhost')
|
|
654
|
+
const me = meshAuth(req, url, undefined)
|
|
655
|
+
if (!me) { meshUnauthorized(res); return }
|
|
656
|
+
writeJson(res, 200, { ok: true, me, ...stream.mesh.status() })
|
|
657
|
+
},
|
|
658
|
+
},
|
|
659
|
+
{
|
|
660
|
+
kind: 'exact',
|
|
661
|
+
path: API_BASE + '/mesh/link',
|
|
662
|
+
handler: async (req, res) => {
|
|
663
|
+
if (!meshGuard(req, res)) return
|
|
664
|
+
if (!isPost(req, res)) return
|
|
665
|
+
const body = await readBody(req, res)
|
|
666
|
+
if (body === undefined) return
|
|
667
|
+
const url = new URL(req.url ?? '/', 'http://localhost')
|
|
668
|
+
const me = meshAuth(req, url, body)
|
|
669
|
+
if (!me) { meshUnauthorized(res); return }
|
|
670
|
+
try {
|
|
671
|
+
const queries = Array.isArray(body.with) ? body.with : []
|
|
672
|
+
const session = stream.mesh.link([me.name, ...queries])
|
|
673
|
+
writeJson(res, 200, {
|
|
674
|
+
ok: true,
|
|
675
|
+
session: {
|
|
676
|
+
id: session.id,
|
|
677
|
+
members: session.members.map((id) => stream.mesh.peers.get(id)?.name ?? id),
|
|
678
|
+
policy: session.policy,
|
|
679
|
+
},
|
|
680
|
+
})
|
|
681
|
+
} catch (error) {
|
|
682
|
+
meshFail(res, error)
|
|
683
|
+
}
|
|
684
|
+
},
|
|
685
|
+
},
|
|
686
|
+
{
|
|
687
|
+
kind: 'exact',
|
|
688
|
+
path: API_BASE + '/mesh/send',
|
|
689
|
+
handler: async (req, res) => {
|
|
690
|
+
if (!meshGuard(req, res)) return
|
|
691
|
+
if (!isPost(req, res)) return
|
|
692
|
+
const body = await readBody(req, res)
|
|
693
|
+
if (body === undefined) return
|
|
694
|
+
const url = new URL(req.url ?? '/', 'http://localhost')
|
|
695
|
+
const me = meshAuth(req, url, body)
|
|
696
|
+
if (!me) { meshUnauthorized(res); return }
|
|
697
|
+
try {
|
|
698
|
+
const result = stream.mesh.send({ session: body.session, from: me.id, body: body.body })
|
|
699
|
+
writeJson(res, 200, { ok: true, ...result })
|
|
700
|
+
} catch (error) {
|
|
701
|
+
meshFail(res, error)
|
|
702
|
+
}
|
|
703
|
+
},
|
|
704
|
+
},
|
|
705
|
+
{
|
|
706
|
+
kind: 'exact',
|
|
707
|
+
path: API_BASE + '/mesh/poll',
|
|
708
|
+
handler: async (req, res) => {
|
|
709
|
+
if (!meshGuard(req, res)) return
|
|
710
|
+
const url = new URL(req.url ?? '/', 'http://localhost')
|
|
711
|
+
const me = meshAuth(req, url, undefined)
|
|
712
|
+
if (!me) { meshUnauthorized(res); return }
|
|
713
|
+
const after = Number(url.searchParams.get('after')) || 0
|
|
714
|
+
const wait = Math.min(Number(url.searchParams.get('wait')) || 0, 30_000)
|
|
715
|
+
let result = stream.mesh.poll(me.id, after)
|
|
716
|
+
if (result.messages.length === 0 && wait > 0) {
|
|
717
|
+
await stream.mesh.waitFor(me.id, wait)
|
|
718
|
+
result = stream.mesh.poll(me.id, after)
|
|
719
|
+
}
|
|
720
|
+
writeJson(res, 200, { ok: true, ...result })
|
|
721
|
+
},
|
|
722
|
+
},
|
|
723
|
+
{
|
|
724
|
+
kind: 'exact',
|
|
725
|
+
path: API_BASE + '/mesh/leave',
|
|
726
|
+
handler: async (req, res) => {
|
|
727
|
+
if (!meshGuard(req, res)) return
|
|
728
|
+
if (!isPost(req, res)) return
|
|
729
|
+
const body = await readBody(req, res)
|
|
730
|
+
if (body === undefined) return
|
|
731
|
+
const url = new URL(req.url ?? '/', 'http://localhost')
|
|
732
|
+
const me = meshAuth(req, url, body)
|
|
733
|
+
if (!me) { meshUnauthorized(res); return }
|
|
734
|
+
stream.mesh.leave(me.id)
|
|
735
|
+
writeJson(res, 200, { ok: true })
|
|
736
|
+
},
|
|
737
|
+
},
|
|
589
738
|
]
|
|
590
739
|
return routes
|
|
591
740
|
}
|
|
@@ -1647,7 +1796,8 @@ function deviceBacktraceTool() {
|
|
|
1647
1796
|
name: 'device_backtrace',
|
|
1648
1797
|
description: 'Capture a native/ANR thread backtrace or crash log for an app on the device. Sends SIGQUIT (kill -3) to the process, ' +
|
|
1649
1798
|
'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").
|
|
1799
|
+
'back to the logcat crash buffer and says so (engine field: "anr-trace" | "logcat-crash"). SIGQUIT refusal (system-uid or ' +
|
|
1800
|
+
'non-debuggable process) degrades to the crash buffer with an explanatory note instead of failing. Pass package_name or pid.',
|
|
1651
1801
|
parameters: {
|
|
1652
1802
|
serial: { type: 'string', description: 'Android device serial. Omit to use the first attached device.' },
|
|
1653
1803
|
package_name: { type: 'string', description: 'Package of the process to trace (e.g. com.simspoof.app).' },
|
|
@@ -1695,22 +1845,32 @@ function deviceBacktraceTool() {
|
|
|
1695
1845
|
if (pid === undefined) {
|
|
1696
1846
|
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
1847
|
}
|
|
1698
|
-
// SIGQUIT: the ART runtime writes the thread dump to /data/anr/.
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
})
|
|
1848
|
+
// SIGQUIT: the ART runtime writes the thread dump to /data/anr/. The adb
|
|
1849
|
+
// shell user cannot signal system-uid / non-debuggable processes on
|
|
1850
|
+
// enforcing builds (EPERM) — degrade honestly instead of throwing.
|
|
1851
|
+
let sigquit = true
|
|
1852
|
+
try {
|
|
1853
|
+
await DeviceBuild.adbRun(serial, ['shell', 'kill', '-3', String(pid)])
|
|
1854
|
+
} catch {
|
|
1855
|
+
sigquit = false
|
|
1856
|
+
}
|
|
1857
|
+
let newest
|
|
1858
|
+
if (sigquit) {
|
|
1859
|
+
await new Promise((resolve) => setTimeout(resolve, 1200))
|
|
1860
|
+
const listing = await DeviceBuild.adbRun(serial, ['shell', 'ls', '-t', '/data/anr']).catch(() => undefined)
|
|
1861
|
+
newest = (listing ?? '').split(/\r?\n/).map((line) => line.trim()).filter((line) => line !== '' && !line.includes(' '))[0]
|
|
1862
|
+
if (newest !== undefined) {
|
|
1863
|
+
const content = await DeviceBuild.adbRun(serial, ['shell', 'cat', DeviceBuild.shQuoteDevice(`/data/anr/${newest}`)], { timeoutMs: 30_000 }).catch(() => undefined)
|
|
1864
|
+
if (content !== undefined && content.trim() !== '') {
|
|
1865
|
+
return DeviceBuild.jsonSafe({
|
|
1866
|
+
serial,
|
|
1867
|
+
engine: 'anr-trace',
|
|
1868
|
+
...(packageName ? { package_name: packageName } : {}),
|
|
1869
|
+
pid,
|
|
1870
|
+
trace_path: `/data/anr/${newest}`,
|
|
1871
|
+
lines: content.split(/\r?\n/).slice(-250),
|
|
1872
|
+
})
|
|
1873
|
+
}
|
|
1714
1874
|
}
|
|
1715
1875
|
}
|
|
1716
1876
|
// Fallback: the logcat crash buffer (FATAL/AndroidRuntime lines), filtered by package when known.
|
|
@@ -1724,7 +1884,9 @@ function deviceBacktraceTool() {
|
|
|
1724
1884
|
engine: 'logcat-crash',
|
|
1725
1885
|
...(packageName ? { package_name: packageName } : {}),
|
|
1726
1886
|
pid,
|
|
1727
|
-
note:
|
|
1887
|
+
note: sigquit
|
|
1888
|
+
? `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.`
|
|
1889
|
+
: `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.`,
|
|
1728
1890
|
lines: [],
|
|
1729
1891
|
})
|
|
1730
1892
|
}
|
|
@@ -1810,6 +1972,572 @@ function deviceMeminfoTool() {
|
|
|
1810
1972
|
})
|
|
1811
1973
|
}
|
|
1812
1974
|
|
|
1975
|
+
// ── display, fleet, co-op observation + mesh tools (v0.8.0) ─────────────────
|
|
1976
|
+
|
|
1977
|
+
/** Drop null/undefined fields so strict output schemas stay satisfiable. */
|
|
1978
|
+
function clean(object) {
|
|
1979
|
+
return Object.fromEntries(Object.entries(object).filter(([, value]) => value !== null && value !== undefined))
|
|
1980
|
+
}
|
|
1981
|
+
|
|
1982
|
+
function deviceDisplayTool() {
|
|
1983
|
+
return defineTool({
|
|
1984
|
+
name: 'device_display',
|
|
1985
|
+
description: 'Read or change a device display metrics: DPI (wm density) and pixel resolution (wm size). ' +
|
|
1986
|
+
'action=get reports physical + current override; action=set applies overrides (density and/or width+height); ' +
|
|
1987
|
+
'action=reset restores physical values for both. Layouts reflow instantly — re-observe with device_screen ' +
|
|
1988
|
+
'after a change, and remember input coordinates follow the new resolution.',
|
|
1989
|
+
parameters: {
|
|
1990
|
+
serial: { type: 'string', description: 'Android device serial. Omit to use the first attached device.' },
|
|
1991
|
+
action: { type: 'string', enum: ['get', 'set', 'reset'], description: 'Defaults to get.' },
|
|
1992
|
+
density: { type: 'integer', description: 'DPI to set (action=set), 80..800 — e.g. 420 for a phone, 260 for a tablet-ish look.' },
|
|
1993
|
+
width: { type: 'integer', description: 'Resolution width px to set (action=set, requires height).' },
|
|
1994
|
+
height: { type: 'integer', description: 'Resolution height px to set (action=set, requires width).' },
|
|
1995
|
+
},
|
|
1996
|
+
output: {
|
|
1997
|
+
schema: {
|
|
1998
|
+
type: 'object',
|
|
1999
|
+
additionalProperties: false,
|
|
2000
|
+
properties: {
|
|
2001
|
+
serial: { type: 'string', required: true },
|
|
2002
|
+
action: { type: 'string', required: true },
|
|
2003
|
+
density: {
|
|
2004
|
+
type: 'object',
|
|
2005
|
+
additionalProperties: false,
|
|
2006
|
+
properties: { physical: { type: 'integer' }, override: { type: 'integer' } },
|
|
2007
|
+
},
|
|
2008
|
+
size: {
|
|
2009
|
+
type: 'object',
|
|
2010
|
+
additionalProperties: false,
|
|
2011
|
+
properties: {
|
|
2012
|
+
physical: { type: 'object', additionalProperties: false, properties: { width: { type: 'integer', required: true }, height: { type: 'integer', required: true } } },
|
|
2013
|
+
override: { type: 'object', additionalProperties: false, properties: { width: { type: 'integer', required: true }, height: { type: 'integer', required: true } } },
|
|
2014
|
+
},
|
|
2015
|
+
},
|
|
2016
|
+
applied: { type: 'array', items: { type: 'string' } },
|
|
2017
|
+
},
|
|
2018
|
+
},
|
|
2019
|
+
render: (_args, value) => {
|
|
2020
|
+
const v = value ?? { serial: '', action: 'get' }
|
|
2021
|
+
const d = v.density ?? {}
|
|
2022
|
+
const s = v.size ?? {}
|
|
2023
|
+
const dpi = `DPI ${d.override ?? d.physical ?? '?'}${d.override ? ` (physical ${d.physical}, overridden)` : ''}`
|
|
2024
|
+
const px = s.physical ? `${s.override?.width ?? s.physical.width}x${s.override?.height ?? s.physical.height}${s.override ? ` (physical ${s.physical.width}x${s.physical.height})` : ''}` : 'size unknown'
|
|
2025
|
+
const applied = v.applied?.length ? ` → applied: ${v.applied.join(', ')}` : ''
|
|
2026
|
+
return [{ type: 'text', text: `${v.serial} [${v.action}]: ${dpi} · ${px}${applied}` }]
|
|
2027
|
+
},
|
|
2028
|
+
},
|
|
2029
|
+
async execute(args) {
|
|
2030
|
+
const serial = await requireAndroidDevice(args.serial)
|
|
2031
|
+
const action = args.action ?? 'get'
|
|
2032
|
+
const applied = []
|
|
2033
|
+
if (action === 'set') {
|
|
2034
|
+
const { density, width, height } = args
|
|
2035
|
+
if (density == null && width == null && height == null) throw new Error('action=set needs density and/or width+height (or use get/reset).')
|
|
2036
|
+
if ((width == null) !== (height == null)) throw new Error('setting resolution needs BOTH width and height.')
|
|
2037
|
+
if (density != null && (density < 80 || density > 800)) throw new Error('density must be within 80..800.')
|
|
2038
|
+
if (width != null && (width < 100 || width > 10_000 || height < 100 || height > 10_000)) throw new Error('resolution sides must each be 100..10000 px.')
|
|
2039
|
+
if (width != null) {
|
|
2040
|
+
await DeviceBuild.adbRun(serial, ['shell', 'wm', 'size', `${width}x${height}`])
|
|
2041
|
+
applied.push(`size ${width}x${height}`)
|
|
2042
|
+
}
|
|
2043
|
+
if (density != null) {
|
|
2044
|
+
await DeviceBuild.adbRun(serial, ['shell', 'wm', 'density', String(density)])
|
|
2045
|
+
applied.push(`density ${density}`)
|
|
2046
|
+
}
|
|
2047
|
+
} else if (action === 'reset') {
|
|
2048
|
+
await DeviceBuild.adbRun(serial, ['shell', 'wm', 'density', 'reset'])
|
|
2049
|
+
await DeviceBuild.adbRun(serial, ['shell', 'wm', 'size', 'reset'])
|
|
2050
|
+
applied.push('density reset', 'size reset')
|
|
2051
|
+
}
|
|
2052
|
+
const [densityText, sizeText] = await Promise.all([
|
|
2053
|
+
DeviceBuild.adbRun(serial, ['shell', 'wm', 'density']),
|
|
2054
|
+
DeviceBuild.adbRun(serial, ['shell', 'wm', 'size']),
|
|
2055
|
+
])
|
|
2056
|
+
return {
|
|
2057
|
+
serial,
|
|
2058
|
+
action,
|
|
2059
|
+
density: clean(DeviceBuild.parseWmDensity(densityText)),
|
|
2060
|
+
size: clean(DeviceBuild.parseWmSize(sizeText)),
|
|
2061
|
+
...(applied.length ? { applied } : {}),
|
|
2062
|
+
}
|
|
2063
|
+
},
|
|
2064
|
+
})
|
|
2065
|
+
}
|
|
2066
|
+
|
|
2067
|
+
/** Prefer the newest API level when picking a default system image. */
|
|
2068
|
+
function newestImage(images) {
|
|
2069
|
+
const apiOf = (id) => Number(/android-(\d+)/.exec(id)?.[1] ?? 0)
|
|
2070
|
+
return [...images].sort((a, b) => apiOf(a) - apiOf(b)).at(-1)
|
|
2071
|
+
}
|
|
2072
|
+
|
|
2073
|
+
function deviceAvdCreateTool() {
|
|
2074
|
+
return defineTool({
|
|
2075
|
+
name: 'device_avd_create',
|
|
2076
|
+
description: 'Create a second virtual device (the co-op partner) via avdmanager. clone_from copies an existing ' +
|
|
2077
|
+
'AVD\'s full hardware config (same image, DPI, RAM, SoC) so both players behave identically; otherwise pass an ' +
|
|
2078
|
+
'explicit image id + device profile. This only creates the AVD — boot it with device_boot (a running emulator ' +
|
|
2079
|
+
'holds 5554, so device #2 lands on emulator-5556 automatically), then wire the two together with the mesh.',
|
|
2080
|
+
parameters: {
|
|
2081
|
+
name: { type: 'string', description: 'New AVD name (letters/digits/._-, up to 64).' },
|
|
2082
|
+
clone_from: { type: 'string', description: 'Existing AVD whose config.ini to clone (recommended for parity).' },
|
|
2083
|
+
image: { type: 'string', description: 'System-image package id (e.g. system-images;android-35;google_apis;x86_64). Defaults to the clone source\'s image, else the newest installed.' },
|
|
2084
|
+
device: { type: 'string', description: 'avdmanager hardware profile when not cloning (default pixel_7).' },
|
|
2085
|
+
},
|
|
2086
|
+
output: {
|
|
2087
|
+
schema: {
|
|
2088
|
+
type: 'object',
|
|
2089
|
+
additionalProperties: false,
|
|
2090
|
+
properties: {
|
|
2091
|
+
name: { type: 'string', required: true },
|
|
2092
|
+
imageId: { type: 'string', required: true },
|
|
2093
|
+
configPath: { type: 'string', required: true },
|
|
2094
|
+
clonedFrom: { type: 'string' },
|
|
2095
|
+
},
|
|
2096
|
+
},
|
|
2097
|
+
render: (_args, value) => {
|
|
2098
|
+
const v = value ?? { name: '', imageId: '', configPath: '' }
|
|
2099
|
+
return [{ type: 'text', text: `Created AVD "${v.name}" (${v.imageId}${v.clonedFrom ? `, cloned hardware from ${v.clonedFrom}` : ''}) — boot it with device_boot.` }]
|
|
2100
|
+
},
|
|
2101
|
+
},
|
|
2102
|
+
async execute(args) {
|
|
2103
|
+
const name = String(args.name ?? '').trim()
|
|
2104
|
+
if (!/^[A-Za-z0-9._-]{1,64}$/.test(name)) throw new Error('AVD name must match [A-Za-z0-9._-]{1,64}.')
|
|
2105
|
+
if (!DeviceBuild.avdmanagerBinary()) throw new Error('avdmanager not found — install SDK cmdline-tools (sdkmanager --install "cmdline-tools;latest").')
|
|
2106
|
+
const avds = await DeviceBuild.androidAvds()
|
|
2107
|
+
if (avds.includes(name)) throw new Error(`AVD "${name}" already exists — boot it with device_boot or choose another name.`)
|
|
2108
|
+
let cloneConfig
|
|
2109
|
+
let imageId = typeof args.image === 'string' && args.image.includes(';') ? args.image.trim() : undefined
|
|
2110
|
+
if (args.clone_from) {
|
|
2111
|
+
if (!avds.includes(args.clone_from)) throw new Error(`clone_from "${args.clone_from}" is not a known AVD (see device_status.avds).`)
|
|
2112
|
+
try { cloneConfig = readFileSync(DeviceBuild.avdConfigPath(args.clone_from), 'utf8') } catch { throw new Error(`could not read config.ini for "${args.clone_from}".`) }
|
|
2113
|
+
if (!imageId) {
|
|
2114
|
+
const sysdir = /^\s*image\.sysdir\.1\s*=\s*(.+)$/m.exec(cloneConfig)?.[1]?.trim()
|
|
2115
|
+
if (sysdir) imageId = sysdir.replace(/\\/g, '/').replace(/\/+$/, '').split('/').join(';')
|
|
2116
|
+
}
|
|
2117
|
+
}
|
|
2118
|
+
if (!imageId) {
|
|
2119
|
+
const images = await DeviceBuild.installedSystemImages()
|
|
2120
|
+
if (images.length === 0) throw new Error('no system images installed — install one first: sdkmanager --install "system-images;android-35;google_apis;x86_64".')
|
|
2121
|
+
imageId = newestImage(images)
|
|
2122
|
+
}
|
|
2123
|
+
const created = await DeviceBuild.createAvd({ name, imageId, deviceProfile: imageId && !cloneConfig ? (args.device ?? 'pixel_7') : undefined, cloneConfigText: cloneConfig })
|
|
2124
|
+
return { name, imageId, configPath: created.configPath, ...(args.clone_from ? { clonedFrom: args.clone_from } : {}) }
|
|
2125
|
+
},
|
|
2126
|
+
})
|
|
2127
|
+
}
|
|
2128
|
+
|
|
2129
|
+
/**
|
|
2130
|
+
* Pure adb-argv builder for one device_batch step (exported for offline tests).
|
|
2131
|
+
* Throws with the offending step named when the parameters do not fit the action.
|
|
2132
|
+
*/
|
|
2133
|
+
export function buildInputArgv(step) {
|
|
2134
|
+
const action = step?.action ?? 'tap'
|
|
2135
|
+
const int = (value) => (typeof value === 'number' && Number.isFinite(value) ? Math.round(value) : undefined)
|
|
2136
|
+
switch (action) {
|
|
2137
|
+
case 'tap': {
|
|
2138
|
+
const x = int(step.x)
|
|
2139
|
+
const y = int(step.y)
|
|
2140
|
+
if (x === undefined || y === undefined) throw new Error('step action=tap needs numeric x and y.')
|
|
2141
|
+
return { argv: ['shell', 'input', 'tap', String(x), String(y)], sent: `tap ${x},${y}` }
|
|
2142
|
+
}
|
|
2143
|
+
case 'swipe': {
|
|
2144
|
+
const x = int(step.x)
|
|
2145
|
+
const y = int(step.y)
|
|
2146
|
+
const x2 = int(step.x2)
|
|
2147
|
+
const y2 = int(step.y2)
|
|
2148
|
+
if ([x, y, x2, y2].some((value) => value === undefined)) throw new Error('step action=swipe needs numeric x, y, x2, y2.')
|
|
2149
|
+
const duration = int(step.duration) ?? 200
|
|
2150
|
+
return { argv: ['shell', 'input', 'swipe', String(x), String(y), String(x2), String(y2), String(duration)], sent: `swipe ${x},${y}→${x2},${y2}` }
|
|
2151
|
+
}
|
|
2152
|
+
case 'text': {
|
|
2153
|
+
const text = step.text
|
|
2154
|
+
if (typeof text !== 'string' || text === '') throw new Error('step action=text needs a non-empty text string.')
|
|
2155
|
+
if (!DeviceBuild.isAsciiInput(text)) throw new Error(`batch action=text is ASCII-only (non-ASCII needs the ADBKeyboard path — use device_input): "${text}".`)
|
|
2156
|
+
return { argv: ['shell', 'input', 'text', DeviceBuild.escapeInputText(text)], sent: `text "${text}"` }
|
|
2157
|
+
}
|
|
2158
|
+
case 'key': {
|
|
2159
|
+
const raw = String(step.key ?? '')
|
|
2160
|
+
const code = /^\d+$/.test(raw) ? Number(raw) : KEYCODES[raw.toLowerCase()]
|
|
2161
|
+
if (!code) throw new Error(`unknown key "${raw}" in batch step.`)
|
|
2162
|
+
return { argv: ['shell', 'input', 'keyevent', String(code)], sent: `key ${raw}` }
|
|
2163
|
+
}
|
|
2164
|
+
default:
|
|
2165
|
+
throw new Error(`unknown step action "${action}" — use tap, swipe, text or key.`)
|
|
2166
|
+
}
|
|
2167
|
+
}
|
|
2168
|
+
|
|
2169
|
+
function deviceBatchTool() {
|
|
2170
|
+
return defineTool({
|
|
2171
|
+
name: 'device_batch',
|
|
2172
|
+
description: 'Fire several input actions at once across devices — the co-op primitive for "both players press ' +
|
|
2173
|
+
'attack on the same frame". All steps dispatch concurrently (separate adb connections, spawned in parallel, so ' +
|
|
2174
|
+
'cross-device skew is a few milliseconds), each step carries the same fields device_input uses plus its own ' +
|
|
2175
|
+
'serial (omit serial to target the first device). A failing step never blocks the others; the per-step results ' +
|
|
2176
|
+
'say what landed. Follow with device_pair_capture to watch what both screens did.',
|
|
2177
|
+
parameters: {
|
|
2178
|
+
steps: {
|
|
2179
|
+
type: 'array',
|
|
2180
|
+
items: {
|
|
2181
|
+
type: 'object',
|
|
2182
|
+
additionalProperties: true,
|
|
2183
|
+
properties: {
|
|
2184
|
+
serial: { type: 'string' },
|
|
2185
|
+
action: { type: 'string', enum: ['tap', 'swipe', 'text', 'key'] },
|
|
2186
|
+
x: { type: 'integer' }, y: { type: 'integer' }, x2: { type: 'integer' }, y2: { type: 'integer' },
|
|
2187
|
+
duration: { type: 'integer' }, text: { type: 'string' }, key: { type: 'string' },
|
|
2188
|
+
},
|
|
2189
|
+
},
|
|
2190
|
+
description: '1..16 input steps, e.g. [{serial:"emulator-5554",action:"tap",x:540,y:1200},{serial:"emulator-5556",action:"key",key:"space"}].',
|
|
2191
|
+
},
|
|
2192
|
+
settle_ms: { type: 'integer', description: 'After the last dispatch, wait this long (0..10000, default 0) so the game can react before you observe.' },
|
|
2193
|
+
},
|
|
2194
|
+
output: {
|
|
2195
|
+
schema: {
|
|
2196
|
+
type: 'object',
|
|
2197
|
+
additionalProperties: false,
|
|
2198
|
+
properties: {
|
|
2199
|
+
ok: { type: 'boolean', required: true },
|
|
2200
|
+
results: {
|
|
2201
|
+
type: 'array',
|
|
2202
|
+
required: true,
|
|
2203
|
+
items: {
|
|
2204
|
+
type: 'object',
|
|
2205
|
+
additionalProperties: false,
|
|
2206
|
+
properties: {
|
|
2207
|
+
index: { type: 'integer', required: true },
|
|
2208
|
+
serial: { type: 'string', required: true },
|
|
2209
|
+
ok: { type: 'boolean', required: true },
|
|
2210
|
+
sent: { type: 'string' },
|
|
2211
|
+
error: { type: 'string' },
|
|
2212
|
+
},
|
|
2213
|
+
},
|
|
2214
|
+
},
|
|
2215
|
+
},
|
|
2216
|
+
},
|
|
2217
|
+
render: (_args, value) => {
|
|
2218
|
+
const v = value ?? { ok: false, results: [] }
|
|
2219
|
+
const lines = v.results.map((r) => ` #${r.index} ${r.serial}: ${r.ok ? r.sent : `FAILED — ${r.error}`}`)
|
|
2220
|
+
return [{ type: 'text', text: `device_batch ${v.ok ? 'all landed' : 'had failures'}:\n${lines.join('\n')}` }]
|
|
2221
|
+
},
|
|
2222
|
+
},
|
|
2223
|
+
async execute(args) {
|
|
2224
|
+
const steps = Array.isArray(args.steps) ? args.steps : []
|
|
2225
|
+
if (steps.length === 0 || steps.length > 16) throw new Error('steps must hold 1..16 input actions.')
|
|
2226
|
+
const planned = steps.map((step, index) => ({ index, step, ...buildInputArgv(step) }))
|
|
2227
|
+
let fallback
|
|
2228
|
+
for (const item of planned) {
|
|
2229
|
+
if (typeof item.step.serial === 'string' && item.step.serial !== '') item.serial = item.step.serial
|
|
2230
|
+
else { fallback ??= await requireAndroidDevice(undefined); item.serial = fallback }
|
|
2231
|
+
}
|
|
2232
|
+
const results = await Promise.all(planned.map(async (item) => {
|
|
2233
|
+
try {
|
|
2234
|
+
await DeviceBuild.adbRun(item.serial, item.argv)
|
|
2235
|
+
return { index: item.index, serial: item.serial, ok: true, sent: item.sent }
|
|
2236
|
+
} catch (error) {
|
|
2237
|
+
return { index: item.index, serial: item.serial, ok: false, error: error instanceof Error ? error.message : String(error) }
|
|
2238
|
+
}
|
|
2239
|
+
}))
|
|
2240
|
+
const settle = Math.min(Math.max(args.settle_ms ?? 0, 0), 10_000)
|
|
2241
|
+
if (settle > 0) await new Promise((resolve) => setTimeout(resolve, settle))
|
|
2242
|
+
return { ok: results.every((r) => r.ok), results }
|
|
2243
|
+
},
|
|
2244
|
+
})
|
|
2245
|
+
}
|
|
2246
|
+
|
|
2247
|
+
/** One device's screen state for device_pair_capture (mirrors device_screen's parts). */
|
|
2248
|
+
async function captureForPair(serial, directory, wantOcr) {
|
|
2249
|
+
const [png, ui, foreground, size] = await Promise.all([
|
|
2250
|
+
DeviceBuild.screenCapture(serial, directory).catch(() => undefined),
|
|
2251
|
+
DeviceBuild.uiDump(serial).catch(() => []),
|
|
2252
|
+
DeviceBuild.foregroundActivity(serial).catch(() => undefined),
|
|
2253
|
+
captureScreenSize(serial).catch(() => undefined),
|
|
2254
|
+
])
|
|
2255
|
+
const out = { serial, ui, ...(foreground ? { foreground } : {}), ...(png ? { screenshot: png } : {}) }
|
|
2256
|
+
if (png && size) { out.width = size.width; out.height = size.height }
|
|
2257
|
+
if (wantOcr) {
|
|
2258
|
+
if (!png) out.ocrError = 'screenshot failed'
|
|
2259
|
+
else {
|
|
2260
|
+
const ocr = await DeviceBuild.ocrImage(png).catch(() => [])
|
|
2261
|
+
if (ocr.length > 0) out.ocr = ocr
|
|
2262
|
+
else out.ocrError = 'PaddleOCR returned no text'
|
|
2263
|
+
}
|
|
2264
|
+
}
|
|
2265
|
+
return out
|
|
2266
|
+
}
|
|
2267
|
+
|
|
2268
|
+
function devicePairCaptureTool(vision) {
|
|
2269
|
+
return defineTool({
|
|
2270
|
+
name: 'device_pair_capture',
|
|
2271
|
+
description: 'Watch two devices at the same instant: one call captures BOTH screens (PNG + uiautomator digest, ' +
|
|
2272
|
+
'optional OCR) in parallel and attaches both screenshots as real image blocks, so a vision model sees both ' +
|
|
2273
|
+
'players in a single glance. This is the co-op observation primitive — call it right after device_batch (or any ' +
|
|
2274
|
+
'mesh_send) to watch the reaction on both sides.',
|
|
2275
|
+
parameters: {
|
|
2276
|
+
serials: { type: 'array', items: { type: 'string' }, description: 'Exactly two device serials. Defaults to the first two attached devices.' },
|
|
2277
|
+
ocr: { type: 'boolean', description: 'Run PaddleOCR on both screenshots (default false — slower). UI trees usually suffice.' },
|
|
2278
|
+
directory: { type: 'string', description: 'Where to store the PNGs (default: temp).' },
|
|
2279
|
+
},
|
|
2280
|
+
output: {
|
|
2281
|
+
schema: {
|
|
2282
|
+
type: 'object',
|
|
2283
|
+
additionalProperties: false,
|
|
2284
|
+
properties: {
|
|
2285
|
+
devices: {
|
|
2286
|
+
type: 'array',
|
|
2287
|
+
required: true,
|
|
2288
|
+
items: {
|
|
2289
|
+
type: 'object',
|
|
2290
|
+
additionalProperties: false,
|
|
2291
|
+
properties: {
|
|
2292
|
+
serial: { type: 'string', required: true },
|
|
2293
|
+
screenshot: { type: 'string' },
|
|
2294
|
+
width: { type: 'integer' },
|
|
2295
|
+
height: { type: 'integer' },
|
|
2296
|
+
foreground: { type: 'string' },
|
|
2297
|
+
ui: { type: 'array', items: { type: 'object', additionalProperties: false, properties: { text: { type: 'string', required: true }, resourceId: { type: 'string' }, bounds: { type: 'array', items: { type: 'integer' } } } } },
|
|
2298
|
+
ocr: { type: 'array', items: { type: 'object', additionalProperties: false, properties: { text: { type: 'string', required: true }, confidence: { type: 'number' }, box: { type: 'array', items: { type: 'integer' } } } } },
|
|
2299
|
+
ocrError: { type: 'string' },
|
|
2300
|
+
},
|
|
2301
|
+
},
|
|
2302
|
+
},
|
|
2303
|
+
images: { type: 'array', items: Vision.IMAGE_REF_SCHEMA },
|
|
2304
|
+
},
|
|
2305
|
+
},
|
|
2306
|
+
render: (_args, value) => {
|
|
2307
|
+
const v = value ?? { devices: [] }
|
|
2308
|
+
const blocks = [{ type: 'text', text: 'Pair capture (both devices, same instant):' }]
|
|
2309
|
+
for (const device of v.devices) {
|
|
2310
|
+
blocks.push({
|
|
2311
|
+
type: 'text',
|
|
2312
|
+
text: [
|
|
2313
|
+
`▶ ${device.serial}${device.foreground ? ` — foreground: ${device.foreground}` : ''}${device.screenshot ? ` [${device.screenshot}${device.width ? ` ${device.width}x${device.height}` : ''}]` : ''}`,
|
|
2314
|
+
...(device.ui ?? []).slice(0, 25).map((item) => ` - ${item.text}${item.resourceId ? ` [${item.resourceId}]` : ''}${item.bounds ? ` @${item.bounds.join(',')}` : ''}`),
|
|
2315
|
+
...((device.ui?.length ?? 0) > 25 ? [` … and ${device.ui.length - 25} more`] : []),
|
|
2316
|
+
...(device.ocr ?? []).slice(0, 15).map((item) => ` “${item.text}” (${Math.round(item.confidence * 100)}%)`),
|
|
2317
|
+
...(device.ocrError ? [` OCR unavailable: ${device.ocrError}`] : []),
|
|
2318
|
+
].join('\n'),
|
|
2319
|
+
})
|
|
2320
|
+
}
|
|
2321
|
+
return Vision.appendImageBlock(blocks, v)
|
|
2322
|
+
},
|
|
2323
|
+
},
|
|
2324
|
+
async execute(args, exec) {
|
|
2325
|
+
let serials = (Array.isArray(args.serials) ? args.serials : []).filter((s) => typeof s === 'string' && s !== '').slice(0, 2)
|
|
2326
|
+
if (serials.length < 2) {
|
|
2327
|
+
const rows = await DeviceBuild.devices()
|
|
2328
|
+
const online = [...new Set([...serials, ...rows.filter((r) => r.state === 'device').map((r) => r.serial)])]
|
|
2329
|
+
serials = online.slice(0, 2)
|
|
2330
|
+
}
|
|
2331
|
+
if (serials.length < 2) throw new Error(`device_pair_capture needs two devices — found ${serials.length}. Boot a second with device_boot, then mesh-link them.`)
|
|
2332
|
+
const wantOcr = args.ocr === true
|
|
2333
|
+
const devices = await Promise.all(serials.map((serial) => captureForPair(serial, args.directory, wantOcr)))
|
|
2334
|
+
const images = (await Promise.all(devices.map((device) => Vision.maybeAttachScreenshot(vision, device.screenshot, exec)))).filter((image) => image !== undefined)
|
|
2335
|
+
return { devices, ...(images.length > 0 ? { images } : {}) }
|
|
2336
|
+
},
|
|
2337
|
+
})
|
|
2338
|
+
}
|
|
2339
|
+
|
|
2340
|
+
const MESH_POLICY_SCHEMA = {
|
|
2341
|
+
type: 'object',
|
|
2342
|
+
additionalProperties: false,
|
|
2343
|
+
properties: {
|
|
2344
|
+
latencyMs: { type: 'integer', required: true },
|
|
2345
|
+
jitterMs: { type: 'integer', required: true },
|
|
2346
|
+
dropPct: { type: 'number', required: true },
|
|
2347
|
+
dupPct: { type: 'number', required: true },
|
|
2348
|
+
throttleKbps: { type: 'integer', required: true },
|
|
2349
|
+
},
|
|
2350
|
+
}
|
|
2351
|
+
const MESH_PEER_SCHEMA = {
|
|
2352
|
+
type: 'object',
|
|
2353
|
+
additionalProperties: false,
|
|
2354
|
+
properties: {
|
|
2355
|
+
id: { type: 'string', required: true },
|
|
2356
|
+
name: { type: 'string', required: true },
|
|
2357
|
+
role: { type: 'string', required: true },
|
|
2358
|
+
joinedAt: { type: 'integer', required: true },
|
|
2359
|
+
lastSeen: { type: 'integer', required: true },
|
|
2360
|
+
serial: { type: 'string' },
|
|
2361
|
+
},
|
|
2362
|
+
}
|
|
2363
|
+
const MESH_SESSION_SCHEMA = {
|
|
2364
|
+
type: 'object',
|
|
2365
|
+
additionalProperties: false,
|
|
2366
|
+
properties: {
|
|
2367
|
+
id: { type: 'string', required: true },
|
|
2368
|
+
members: { type: 'array', items: { type: 'string' }, required: true },
|
|
2369
|
+
policy: MESH_POLICY_SCHEMA,
|
|
2370
|
+
pending: { type: 'integer', required: true },
|
|
2371
|
+
},
|
|
2372
|
+
}
|
|
2373
|
+
|
|
2374
|
+
function meshStatusTool(mesh) {
|
|
2375
|
+
return defineTool({
|
|
2376
|
+
name: 'mesh_status',
|
|
2377
|
+
description: 'Inspect the host mesh hub: every peer that joined from a device (a random callsign pinned to its ' +
|
|
2378
|
+
'serial, LocalSend-style), every session with its members, live network policy and undelivered mail. Devices ' +
|
|
2379
|
+
'reach this hub at http://10.0.2.2:<dsh-port>/api/dsh-mobilecode/mesh/* — games POST join {serial?,name?}, ' +
|
|
2380
|
+
'then link/send/poll with the returned id+token.',
|
|
2381
|
+
parameters: {},
|
|
2382
|
+
output: {
|
|
2383
|
+
schema: {
|
|
2384
|
+
type: 'object',
|
|
2385
|
+
additionalProperties: false,
|
|
2386
|
+
properties: {
|
|
2387
|
+
peers: { type: 'array', items: MESH_PEER_SCHEMA, required: true },
|
|
2388
|
+
sessions: { type: 'array', items: MESH_SESSION_SCHEMA, required: true },
|
|
2389
|
+
messageSeq: { type: 'integer', required: true },
|
|
2390
|
+
},
|
|
2391
|
+
},
|
|
2392
|
+
render: (_args, value) => {
|
|
2393
|
+
const v = value ?? { peers: [], sessions: [], messageSeq: 0 }
|
|
2394
|
+
const lines = [
|
|
2395
|
+
`mesh: ${v.peers.length} peer(s), ${v.sessions.length} session(s), ${v.messageSeq} message(s) so far`,
|
|
2396
|
+
...v.peers.map((peer) => ` ● ${peer.name}${peer.serial ? ` (${peer.serial})` : ''} [${peer.role}] last-seen ${new Date(peer.lastSeen).toISOString().slice(11, 19)}`),
|
|
2397
|
+
...v.sessions.map((s) => ` ◈ ${s.id}: ${s.members.join(' ↔')} — ${JSON.stringify(s.policy)}${s.pending ? ` (${s.pending} undelivered)` : ''}`),
|
|
2398
|
+
]
|
|
2399
|
+
return [{ type: 'text', text: lines.join('\n') }]
|
|
2400
|
+
},
|
|
2401
|
+
},
|
|
2402
|
+
async execute() {
|
|
2403
|
+
const status = mesh.status()
|
|
2404
|
+
return { peers: status.peers.map(clean), sessions: status.sessions, messageSeq: status.messageSeq }
|
|
2405
|
+
},
|
|
2406
|
+
})
|
|
2407
|
+
}
|
|
2408
|
+
|
|
2409
|
+
function meshSendTool(mesh) {
|
|
2410
|
+
return defineTool({
|
|
2411
|
+
name: 'mesh_send',
|
|
2412
|
+
description: 'Inject a JSON message into a mesh session AS an existing peer ("ghost" the other player) — the ' +
|
|
2413
|
+
'fastest way to drive a co-op test without touching the app: the receiving game gets it from /mesh/poll ' +
|
|
2414
|
+
'exactly as if its partner sent it, and the hub applies the session latency/drop/dup policy on delivery. ' +
|
|
2415
|
+
'Every injection is logged (via: agent) so you can see how each side reacts.',
|
|
2416
|
+
parameters: {
|
|
2417
|
+
session: { type: 'string', description: 'Session id from mesh_status.' },
|
|
2418
|
+
from: { type: 'string', description: 'Peer name / id to speak as (must be a session member).' },
|
|
2419
|
+
body: { type: 'object', additionalProperties: true, description: 'JSON payload delivered verbatim to the other members.' },
|
|
2420
|
+
},
|
|
2421
|
+
output: {
|
|
2422
|
+
schema: {
|
|
2423
|
+
type: 'object',
|
|
2424
|
+
additionalProperties: false,
|
|
2425
|
+
properties: {
|
|
2426
|
+
seq: { type: 'integer', required: true },
|
|
2427
|
+
delivered: { type: 'integer', required: true },
|
|
2428
|
+
dropped: { type: 'integer', required: true },
|
|
2429
|
+
duplicated: { type: 'integer', required: true },
|
|
2430
|
+
readyAt: { type: 'integer' },
|
|
2431
|
+
},
|
|
2432
|
+
},
|
|
2433
|
+
render: (_args, value) => {
|
|
2434
|
+
const v = value ?? { seq: 0, delivered: 0, dropped: 0, duplicated: 0 }
|
|
2435
|
+
return [{ type: 'text', text: `mesh message #${v.seq}: delivered ${v.delivered}, dropped ${v.dropped}, duplicated ${v.duplicated}${v.readyAt ? ` (due ${new Date(v.readyAt).toISOString().slice(11, 23)})` : ''}` }]
|
|
2436
|
+
},
|
|
2437
|
+
},
|
|
2438
|
+
async execute(args) {
|
|
2439
|
+
const result = mesh.send({ session: args.session, from: args.from, body: args.body ?? null, via: 'agent' })
|
|
2440
|
+
return clean(result)
|
|
2441
|
+
},
|
|
2442
|
+
})
|
|
2443
|
+
}
|
|
2444
|
+
|
|
2445
|
+
function meshLogTool(mesh) {
|
|
2446
|
+
return defineTool({
|
|
2447
|
+
name: 'mesh_log',
|
|
2448
|
+
description: 'Read the mesh message log — every join/link/send/drop/dup/tune event with timestamps, sequence ' +
|
|
2449
|
+
'numbers, byte sizes and the acting peer. This is the wire between the players: watch the game talk, spot ' +
|
|
2450
|
+
'policy drops, and correlate traffic bursts with what device_pair_capture shows on both screens.',
|
|
2451
|
+
parameters: {
|
|
2452
|
+
session: { type: 'string', description: 'Only entries for this session id (default: all).' },
|
|
2453
|
+
limit: { type: 'integer', description: 'Most recent N entries (default 50, max 500).' },
|
|
2454
|
+
},
|
|
2455
|
+
output: {
|
|
2456
|
+
schema: {
|
|
2457
|
+
type: 'object',
|
|
2458
|
+
additionalProperties: false,
|
|
2459
|
+
properties: { entries: { type: 'array', items: { type: 'object', additionalProperties: true }, required: true } },
|
|
2460
|
+
},
|
|
2461
|
+
render: (_args, value) => {
|
|
2462
|
+
const entries = value?.entries ?? []
|
|
2463
|
+
const text = entries.length === 0
|
|
2464
|
+
? 'mesh log is empty'
|
|
2465
|
+
: entries.map((e) => ` ${new Date(e.ts).toISOString().slice(11, 23)} ${e.kind} ${JSON.stringify(Object.fromEntries(Object.entries(e).filter(([k]) => !['seq', 'ts', 'kind'].includes(k))))}`).join('\n')
|
|
2466
|
+
return [{ type: 'text', text: `mesh log (${entries.length} entries):\n${text}` }]
|
|
2467
|
+
},
|
|
2468
|
+
},
|
|
2469
|
+
async execute(args) {
|
|
2470
|
+
return { entries: mesh.log({ session: args.session, limit: args.limit }) }
|
|
2471
|
+
},
|
|
2472
|
+
})
|
|
2473
|
+
}
|
|
2474
|
+
|
|
2475
|
+
function meshTuneTool(mesh) {
|
|
2476
|
+
return defineTool({
|
|
2477
|
+
name: 'mesh_tune',
|
|
2478
|
+
description: 'Set the network conditions a mesh session delivers under: latencyMs delay, jitterMs random extra ' +
|
|
2479
|
+
'delay, dropPct random message loss, dupPct random duplicates, throttleKbps size-proportional delay (8000 bytes ' +
|
|
2480
|
+
'at 8 Kbps = +1000 ms). Applied per send to every recipient, zero means OFF. This is the desync-testing lever: ' +
|
|
2481
|
+
'make player B lag 400 ms with 100 ms jitter and watch the game handle it — no app code changes.',
|
|
2482
|
+
parameters: {
|
|
2483
|
+
session: { type: 'string', description: 'Session id from mesh_status.' },
|
|
2484
|
+
latencyMs: { type: 'integer', description: 'Fixed per-message delay, 0..5000.' },
|
|
2485
|
+
jitterMs: { type: 'integer', description: 'Random extra delay 0..jitterMs, 0..5000.' },
|
|
2486
|
+
dropPct: { type: 'number', description: 'Chance each recipient-copy is dropped, 0..100.' },
|
|
2487
|
+
dupPct: { type: 'number', description: 'Chance each delivered copy is duplicated, 0..100.' },
|
|
2488
|
+
throttleKbps: { type: 'integer', description: 'Bandwidth cap: adds bytes*8/kbps ms per message. 0 = unlimited.' },
|
|
2489
|
+
},
|
|
2490
|
+
output: {
|
|
2491
|
+
schema: {
|
|
2492
|
+
type: 'object',
|
|
2493
|
+
additionalProperties: false,
|
|
2494
|
+
properties: {
|
|
2495
|
+
session: { type: 'string', required: true },
|
|
2496
|
+
policy: MESH_POLICY_SCHEMA,
|
|
2497
|
+
},
|
|
2498
|
+
},
|
|
2499
|
+
render: (_args, value) => {
|
|
2500
|
+
const v = value ?? { session: '', policy: {} }
|
|
2501
|
+
return [{ type: 'text', text: `session ${v.session} policy → ${JSON.stringify(v.policy)}` }]
|
|
2502
|
+
},
|
|
2503
|
+
},
|
|
2504
|
+
async execute(args) {
|
|
2505
|
+
const policy = mesh.tune(args.session, {
|
|
2506
|
+
latencyMs: args.latencyMs, jitterMs: args.jitterMs, dropPct: args.dropPct, dupPct: args.dupPct, throttleKbps: args.throttleKbps,
|
|
2507
|
+
})
|
|
2508
|
+
return { session: args.session, policy }
|
|
2509
|
+
},
|
|
2510
|
+
})
|
|
2511
|
+
}
|
|
2512
|
+
|
|
2513
|
+
function meshResetTool(mesh) {
|
|
2514
|
+
return defineTool({
|
|
2515
|
+
name: 'mesh_reset',
|
|
2516
|
+
description: 'Clear mesh state: pass a session id to dissolve that one session, or nothing to wipe ALL peers and ' +
|
|
2517
|
+
'sessions and the log. Devices re-join with /mesh/join afterwards (their callsigns stay pinned to serial), so a ' +
|
|
2518
|
+
'reset between test runs is cheap.',
|
|
2519
|
+
parameters: {
|
|
2520
|
+
session: { type: 'string', description: 'Dissolve only this session (default: full wipe).' },
|
|
2521
|
+
},
|
|
2522
|
+
output: {
|
|
2523
|
+
schema: {
|
|
2524
|
+
type: 'object',
|
|
2525
|
+
additionalProperties: false,
|
|
2526
|
+
properties: { cleared: { type: 'string', required: true } },
|
|
2527
|
+
},
|
|
2528
|
+
render: (_args, value) => [{ type: 'text', text: `mesh cleared: ${value?.cleared ?? '?'}` }],
|
|
2529
|
+
},
|
|
2530
|
+
async execute(args) {
|
|
2531
|
+
if (typeof args.session === 'string' && args.session !== '') {
|
|
2532
|
+
mesh.unlink(args.session)
|
|
2533
|
+
return { cleared: `session ${args.session}` }
|
|
2534
|
+
}
|
|
2535
|
+
mesh.reset()
|
|
2536
|
+
return { cleared: 'everything (peers, sessions, log)' }
|
|
2537
|
+
},
|
|
2538
|
+
})
|
|
2539
|
+
}
|
|
2540
|
+
|
|
1813
2541
|
function deviceBootTool() {
|
|
1814
2542
|
return defineTool({
|
|
1815
2543
|
name: 'device_boot',
|
|
@@ -2602,6 +3330,28 @@ function guidance() {
|
|
|
2602
3330
|
' check a running app\'s footprint.',
|
|
2603
3331
|
'- device_backtrace: SIGQUIT an app process and read its newest /data/anr thread dump; falls back to the logcat crash buffer',
|
|
2604
3332
|
' when /data/anr is unreadable (engine field says which). Deterministic crash/ANR capture for debugging.',
|
|
3333
|
+
'- device_display: read or set a device\'s DPI (wm density) and pixel resolution (wm size) — action get/set/reset.',
|
|
3334
|
+
' Use it to test layout scaling, or to make two devices share a viewport. Re-observe with device_screen after a change.',
|
|
3335
|
+
'- device_avd_create: create a second virtual device (clone_from copies an existing AVD\'s full hardware config so both',
|
|
3336
|
+
' players behave identically). Boot it with device_boot; a running emulator holds 5554 so device #2 lands on 5556.',
|
|
3337
|
+
'- device_batch: fire 1..16 input actions concurrently across devices (each step carries its own serial) — the co-op',
|
|
3338
|
+
' primitive for "both players press attack on the same frame". Per-step results; a failing step never blocks others.',
|
|
3339
|
+
'- device_pair_capture: capture BOTH devices\' screens in one call (parallel PNG + UI digests, both attached as real',
|
|
3340
|
+
' image blocks) — the way to watch two players at the same instant. Add ocr:true only when UI trees are not enough.',
|
|
3341
|
+
'',
|
|
3342
|
+
'Co-op mesh (v0.8.0) — two emulators cannot multicast-discover each other through their isolated NATs, so the plugin',
|
|
3343
|
+
'hosts a LocalSend-style JSON pub/sub hub. Any app inside an emulator reaches it at http://10.0.2.2:<dsh-port>' +
|
|
3344
|
+
'/api/dsh-mobilecode/mesh/join — POST {serial?,name?} to join (the hub assigns a random callsign like "amber-fox",',
|
|
3345
|
+
'stable per serial, and a token), POST /mesh/link {with:[names]} to form a session, then POST /mesh/send and GET',
|
|
3346
|
+
'/mesh/poll?id&token&after&wait to exchange JSON. The hub is also your observation window and network simulator:',
|
|
3347
|
+
'- mesh_status: who has joined, which sessions exist, live per-session policy and undelivered mail.',
|
|
3348
|
+
'- mesh_send: inject a message as any peer (the other game receives it from /mesh/poll as if its partner sent it).',
|
|
3349
|
+
'- mesh_log: every join/link/send/drop/dup/tune event with timestamps, sizes and latency — the wire between players.',
|
|
3350
|
+
'- mesh_tune: set a session\'s latencyMs / jitterMs / dropPct / dupPct / throttleKbps to desync-test the pair',
|
|
3351
|
+
' (e.g. latency 400 + jitter 100 on player B) without touching app code.',
|
|
3352
|
+
'- mesh_reset: dissolve one session or wipe everything between test runs.',
|
|
3353
|
+
'Typical co-op loop: device_avd_create + device_boot a clone → both apps join → mesh_link → device_batch inputs at',
|
|
3354
|
+
'both, device_pair_capture to watch, mesh_log to see the traffic, mesh_tune to inject real-world network pain.',
|
|
2605
3355
|
'',
|
|
2606
3356
|
'Expo and React Native projects are handled automatically: expo prebuild runs when needed, Metro starts for you,',
|
|
2607
3357
|
'and the app is installed and launched on the booted simulator/emulator. Failed builds report the error and a log tail.',
|
|
@@ -2630,10 +3380,12 @@ export function apply(ctx, config) {
|
|
|
2630
3380
|
const engine = new DevicePreviewEngine()
|
|
2631
3381
|
const streamHost = new AndroidStreamHost()
|
|
2632
3382
|
const streamAccess = new StreamAccess.StreamAccessController()
|
|
3383
|
+
const mesh = new MeshHub()
|
|
2633
3384
|
const vision = Vision.resolveVisionServices(ctx)
|
|
2634
3385
|
const handle = {
|
|
2635
3386
|
engine,
|
|
2636
3387
|
stream: streamHost,
|
|
3388
|
+
mesh,
|
|
2637
3389
|
status: () => ({
|
|
2638
3390
|
directories: [...new Set([...engine.builds.keys()].map((key) => key.split('\0')[0]))],
|
|
2639
3391
|
servers: [...engine.servers.keys()],
|
|
@@ -2644,7 +3396,7 @@ export function apply(ctx, config) {
|
|
|
2644
3396
|
if (typeof ctx.provide === 'function') ctx.provide('mobilecode', handle)
|
|
2645
3397
|
else ctx.mobilecode = handle
|
|
2646
3398
|
|
|
2647
|
-
const routes = makeRoutes(engine, config, { host: streamHost, access: streamAccess })
|
|
3399
|
+
const routes = makeRoutes(engine, config, { host: streamHost, access: streamAccess, mesh })
|
|
2648
3400
|
let disposeRoutes
|
|
2649
3401
|
let disposeTools
|
|
2650
3402
|
let disposeSection
|
|
@@ -2697,6 +3449,15 @@ export function apply(ctx, config) {
|
|
|
2697
3449
|
deviceTapRowTool(),
|
|
2698
3450
|
deviceBacktraceTool(),
|
|
2699
3451
|
deviceMeminfoTool(),
|
|
3452
|
+
deviceDisplayTool(),
|
|
3453
|
+
deviceAvdCreateTool(),
|
|
3454
|
+
deviceBatchTool(),
|
|
3455
|
+
devicePairCaptureTool(vision),
|
|
3456
|
+
meshStatusTool(mesh),
|
|
3457
|
+
meshSendTool(mesh),
|
|
3458
|
+
meshLogTool(mesh),
|
|
3459
|
+
meshTuneTool(mesh),
|
|
3460
|
+
meshResetTool(mesh),
|
|
2700
3461
|
].map((tool) => ctx.tools.register(tool))
|
|
2701
3462
|
return () => { for (const dispose of disposers) dispose() }
|
|
2702
3463
|
}, 'dsh-mobilecode: tools')
|