dsh-mobilecode 0.7.1 → 0.8.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 +58 -2
- package/lib/device-build.js +121 -0
- package/lib/index.js +749 -1
- 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).
|
|
@@ -590,6 +623,118 @@ function makeRoutes(engine, config, stream) {
|
|
|
590
623
|
}
|
|
591
624
|
},
|
|
592
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
|
+
},
|
|
593
738
|
]
|
|
594
739
|
return routes
|
|
595
740
|
}
|
|
@@ -1827,6 +1972,576 @@ function deviceMeminfoTool() {
|
|
|
1827
1972
|
})
|
|
1828
1973
|
}
|
|
1829
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
|
+
// Always pass -d: without a device profile avdmanager prompts "Do you
|
|
2124
|
+
// wish to create a custom hardware profile?" and crashes on EOF stdin
|
|
2125
|
+
// (Range [0, 0 + -1) out of bounds). Clone parity comes from the
|
|
2126
|
+
// config.ini merge below, not from the base profile.
|
|
2127
|
+
const created = await DeviceBuild.createAvd({ name, imageId, deviceProfile: args.device ?? 'pixel_7', cloneConfigText: cloneConfig })
|
|
2128
|
+
return { name, imageId, configPath: created.configPath, ...(args.clone_from ? { clonedFrom: args.clone_from } : {}) }
|
|
2129
|
+
},
|
|
2130
|
+
})
|
|
2131
|
+
}
|
|
2132
|
+
|
|
2133
|
+
/**
|
|
2134
|
+
* Pure adb-argv builder for one device_batch step (exported for offline tests).
|
|
2135
|
+
* Throws with the offending step named when the parameters do not fit the action.
|
|
2136
|
+
*/
|
|
2137
|
+
export function buildInputArgv(step) {
|
|
2138
|
+
const action = step?.action ?? 'tap'
|
|
2139
|
+
const int = (value) => (typeof value === 'number' && Number.isFinite(value) ? Math.round(value) : undefined)
|
|
2140
|
+
switch (action) {
|
|
2141
|
+
case 'tap': {
|
|
2142
|
+
const x = int(step.x)
|
|
2143
|
+
const y = int(step.y)
|
|
2144
|
+
if (x === undefined || y === undefined) throw new Error('step action=tap needs numeric x and y.')
|
|
2145
|
+
return { argv: ['shell', 'input', 'tap', String(x), String(y)], sent: `tap ${x},${y}` }
|
|
2146
|
+
}
|
|
2147
|
+
case 'swipe': {
|
|
2148
|
+
const x = int(step.x)
|
|
2149
|
+
const y = int(step.y)
|
|
2150
|
+
const x2 = int(step.x2)
|
|
2151
|
+
const y2 = int(step.y2)
|
|
2152
|
+
if ([x, y, x2, y2].some((value) => value === undefined)) throw new Error('step action=swipe needs numeric x, y, x2, y2.')
|
|
2153
|
+
const duration = int(step.duration) ?? 200
|
|
2154
|
+
return { argv: ['shell', 'input', 'swipe', String(x), String(y), String(x2), String(y2), String(duration)], sent: `swipe ${x},${y}→${x2},${y2}` }
|
|
2155
|
+
}
|
|
2156
|
+
case 'text': {
|
|
2157
|
+
const text = step.text
|
|
2158
|
+
if (typeof text !== 'string' || text === '') throw new Error('step action=text needs a non-empty text string.')
|
|
2159
|
+
if (!DeviceBuild.isAsciiInput(text)) throw new Error(`batch action=text is ASCII-only (non-ASCII needs the ADBKeyboard path — use device_input): "${text}".`)
|
|
2160
|
+
return { argv: ['shell', 'input', 'text', DeviceBuild.escapeInputText(text)], sent: `text "${text}"` }
|
|
2161
|
+
}
|
|
2162
|
+
case 'key': {
|
|
2163
|
+
const raw = String(step.key ?? '')
|
|
2164
|
+
const code = /^\d+$/.test(raw) ? Number(raw) : KEYCODES[raw.toLowerCase()]
|
|
2165
|
+
if (!code) throw new Error(`unknown key "${raw}" in batch step.`)
|
|
2166
|
+
return { argv: ['shell', 'input', 'keyevent', String(code)], sent: `key ${raw}` }
|
|
2167
|
+
}
|
|
2168
|
+
default:
|
|
2169
|
+
throw new Error(`unknown step action "${action}" — use tap, swipe, text or key.`)
|
|
2170
|
+
}
|
|
2171
|
+
}
|
|
2172
|
+
|
|
2173
|
+
function deviceBatchTool() {
|
|
2174
|
+
return defineTool({
|
|
2175
|
+
name: 'device_batch',
|
|
2176
|
+
description: 'Fire several input actions at once across devices — the co-op primitive for "both players press ' +
|
|
2177
|
+
'attack on the same frame". All steps dispatch concurrently (separate adb connections, spawned in parallel, so ' +
|
|
2178
|
+
'cross-device skew is a few milliseconds), each step carries the same fields device_input uses plus its own ' +
|
|
2179
|
+
'serial (omit serial to target the first device). A failing step never blocks the others; the per-step results ' +
|
|
2180
|
+
'say what landed. Follow with device_pair_capture to watch what both screens did.',
|
|
2181
|
+
parameters: {
|
|
2182
|
+
steps: {
|
|
2183
|
+
type: 'array',
|
|
2184
|
+
items: {
|
|
2185
|
+
type: 'object',
|
|
2186
|
+
additionalProperties: true,
|
|
2187
|
+
properties: {
|
|
2188
|
+
serial: { type: 'string' },
|
|
2189
|
+
action: { type: 'string', enum: ['tap', 'swipe', 'text', 'key'] },
|
|
2190
|
+
x: { type: 'integer' }, y: { type: 'integer' }, x2: { type: 'integer' }, y2: { type: 'integer' },
|
|
2191
|
+
duration: { type: 'integer' }, text: { type: 'string' }, key: { type: 'string' },
|
|
2192
|
+
},
|
|
2193
|
+
},
|
|
2194
|
+
description: '1..16 input steps, e.g. [{serial:"emulator-5554",action:"tap",x:540,y:1200},{serial:"emulator-5556",action:"key",key:"space"}].',
|
|
2195
|
+
},
|
|
2196
|
+
settle_ms: { type: 'integer', description: 'After the last dispatch, wait this long (0..10000, default 0) so the game can react before you observe.' },
|
|
2197
|
+
},
|
|
2198
|
+
output: {
|
|
2199
|
+
schema: {
|
|
2200
|
+
type: 'object',
|
|
2201
|
+
additionalProperties: false,
|
|
2202
|
+
properties: {
|
|
2203
|
+
ok: { type: 'boolean', required: true },
|
|
2204
|
+
results: {
|
|
2205
|
+
type: 'array',
|
|
2206
|
+
required: true,
|
|
2207
|
+
items: {
|
|
2208
|
+
type: 'object',
|
|
2209
|
+
additionalProperties: false,
|
|
2210
|
+
properties: {
|
|
2211
|
+
index: { type: 'integer', required: true },
|
|
2212
|
+
serial: { type: 'string', required: true },
|
|
2213
|
+
ok: { type: 'boolean', required: true },
|
|
2214
|
+
sent: { type: 'string' },
|
|
2215
|
+
error: { type: 'string' },
|
|
2216
|
+
},
|
|
2217
|
+
},
|
|
2218
|
+
},
|
|
2219
|
+
},
|
|
2220
|
+
},
|
|
2221
|
+
render: (_args, value) => {
|
|
2222
|
+
const v = value ?? { ok: false, results: [] }
|
|
2223
|
+
const lines = v.results.map((r) => ` #${r.index} ${r.serial}: ${r.ok ? r.sent : `FAILED — ${r.error}`}`)
|
|
2224
|
+
return [{ type: 'text', text: `device_batch ${v.ok ? 'all landed' : 'had failures'}:\n${lines.join('\n')}` }]
|
|
2225
|
+
},
|
|
2226
|
+
},
|
|
2227
|
+
async execute(args) {
|
|
2228
|
+
const steps = Array.isArray(args.steps) ? args.steps : []
|
|
2229
|
+
if (steps.length === 0 || steps.length > 16) throw new Error('steps must hold 1..16 input actions.')
|
|
2230
|
+
const planned = steps.map((step, index) => ({ index, step, ...buildInputArgv(step) }))
|
|
2231
|
+
let fallback
|
|
2232
|
+
for (const item of planned) {
|
|
2233
|
+
if (typeof item.step.serial === 'string' && item.step.serial !== '') item.serial = item.step.serial
|
|
2234
|
+
else { fallback ??= await requireAndroidDevice(undefined); item.serial = fallback }
|
|
2235
|
+
}
|
|
2236
|
+
const results = await Promise.all(planned.map(async (item) => {
|
|
2237
|
+
try {
|
|
2238
|
+
await DeviceBuild.adbRun(item.serial, item.argv)
|
|
2239
|
+
return { index: item.index, serial: item.serial, ok: true, sent: item.sent }
|
|
2240
|
+
} catch (error) {
|
|
2241
|
+
return { index: item.index, serial: item.serial, ok: false, error: error instanceof Error ? error.message : String(error) }
|
|
2242
|
+
}
|
|
2243
|
+
}))
|
|
2244
|
+
const settle = Math.min(Math.max(args.settle_ms ?? 0, 0), 10_000)
|
|
2245
|
+
if (settle > 0) await new Promise((resolve) => setTimeout(resolve, settle))
|
|
2246
|
+
return { ok: results.every((r) => r.ok), results }
|
|
2247
|
+
},
|
|
2248
|
+
})
|
|
2249
|
+
}
|
|
2250
|
+
|
|
2251
|
+
/** One device's screen state for device_pair_capture (mirrors device_screen's parts). */
|
|
2252
|
+
async function captureForPair(serial, directory, wantOcr) {
|
|
2253
|
+
const [png, ui, foreground, size] = await Promise.all([
|
|
2254
|
+
DeviceBuild.screenCapture(serial, directory).catch(() => undefined),
|
|
2255
|
+
DeviceBuild.uiDump(serial).catch(() => []),
|
|
2256
|
+
DeviceBuild.foregroundActivity(serial).catch(() => undefined),
|
|
2257
|
+
captureScreenSize(serial).catch(() => undefined),
|
|
2258
|
+
])
|
|
2259
|
+
const out = { serial, ui, ...(foreground ? { foreground } : {}), ...(png ? { screenshot: png } : {}) }
|
|
2260
|
+
if (png && size) { out.width = size.width; out.height = size.height }
|
|
2261
|
+
if (wantOcr) {
|
|
2262
|
+
if (!png) out.ocrError = 'screenshot failed'
|
|
2263
|
+
else {
|
|
2264
|
+
const ocr = await DeviceBuild.ocrImage(png).catch(() => [])
|
|
2265
|
+
if (ocr.length > 0) out.ocr = ocr
|
|
2266
|
+
else out.ocrError = 'PaddleOCR returned no text'
|
|
2267
|
+
}
|
|
2268
|
+
}
|
|
2269
|
+
return out
|
|
2270
|
+
}
|
|
2271
|
+
|
|
2272
|
+
function devicePairCaptureTool(vision) {
|
|
2273
|
+
return defineTool({
|
|
2274
|
+
name: 'device_pair_capture',
|
|
2275
|
+
description: 'Watch two devices at the same instant: one call captures BOTH screens (PNG + uiautomator digest, ' +
|
|
2276
|
+
'optional OCR) in parallel and attaches both screenshots as real image blocks, so a vision model sees both ' +
|
|
2277
|
+
'players in a single glance. This is the co-op observation primitive — call it right after device_batch (or any ' +
|
|
2278
|
+
'mesh_send) to watch the reaction on both sides.',
|
|
2279
|
+
parameters: {
|
|
2280
|
+
serials: { type: 'array', items: { type: 'string' }, description: 'Exactly two device serials. Defaults to the first two attached devices.' },
|
|
2281
|
+
ocr: { type: 'boolean', description: 'Run PaddleOCR on both screenshots (default false — slower). UI trees usually suffice.' },
|
|
2282
|
+
directory: { type: 'string', description: 'Where to store the PNGs (default: temp).' },
|
|
2283
|
+
},
|
|
2284
|
+
output: {
|
|
2285
|
+
schema: {
|
|
2286
|
+
type: 'object',
|
|
2287
|
+
additionalProperties: false,
|
|
2288
|
+
properties: {
|
|
2289
|
+
devices: {
|
|
2290
|
+
type: 'array',
|
|
2291
|
+
required: true,
|
|
2292
|
+
items: {
|
|
2293
|
+
type: 'object',
|
|
2294
|
+
additionalProperties: false,
|
|
2295
|
+
properties: {
|
|
2296
|
+
serial: { type: 'string', required: true },
|
|
2297
|
+
screenshot: { type: 'string' },
|
|
2298
|
+
width: { type: 'integer' },
|
|
2299
|
+
height: { type: 'integer' },
|
|
2300
|
+
foreground: { type: 'string' },
|
|
2301
|
+
ui: { type: 'array', items: { type: 'object', additionalProperties: false, properties: { text: { type: 'string', required: true }, resourceId: { type: 'string' }, bounds: { type: 'array', items: { type: 'integer' } } } } },
|
|
2302
|
+
ocr: { type: 'array', items: { type: 'object', additionalProperties: false, properties: { text: { type: 'string', required: true }, confidence: { type: 'number' }, box: { type: 'array', items: { type: 'integer' } } } } },
|
|
2303
|
+
ocrError: { type: 'string' },
|
|
2304
|
+
},
|
|
2305
|
+
},
|
|
2306
|
+
},
|
|
2307
|
+
images: { type: 'array', items: Vision.IMAGE_REF_SCHEMA },
|
|
2308
|
+
},
|
|
2309
|
+
},
|
|
2310
|
+
render: (_args, value) => {
|
|
2311
|
+
const v = value ?? { devices: [] }
|
|
2312
|
+
const blocks = [{ type: 'text', text: 'Pair capture (both devices, same instant):' }]
|
|
2313
|
+
for (const device of v.devices) {
|
|
2314
|
+
blocks.push({
|
|
2315
|
+
type: 'text',
|
|
2316
|
+
text: [
|
|
2317
|
+
`▶ ${device.serial}${device.foreground ? ` — foreground: ${device.foreground}` : ''}${device.screenshot ? ` [${device.screenshot}${device.width ? ` ${device.width}x${device.height}` : ''}]` : ''}`,
|
|
2318
|
+
...(device.ui ?? []).slice(0, 25).map((item) => ` - ${item.text}${item.resourceId ? ` [${item.resourceId}]` : ''}${item.bounds ? ` @${item.bounds.join(',')}` : ''}`),
|
|
2319
|
+
...((device.ui?.length ?? 0) > 25 ? [` … and ${device.ui.length - 25} more`] : []),
|
|
2320
|
+
...(device.ocr ?? []).slice(0, 15).map((item) => ` “${item.text}” (${Math.round(item.confidence * 100)}%)`),
|
|
2321
|
+
...(device.ocrError ? [` OCR unavailable: ${device.ocrError}`] : []),
|
|
2322
|
+
].join('\n'),
|
|
2323
|
+
})
|
|
2324
|
+
}
|
|
2325
|
+
return Vision.appendImageBlock(blocks, v)
|
|
2326
|
+
},
|
|
2327
|
+
},
|
|
2328
|
+
async execute(args, exec) {
|
|
2329
|
+
let serials = (Array.isArray(args.serials) ? args.serials : []).filter((s) => typeof s === 'string' && s !== '').slice(0, 2)
|
|
2330
|
+
if (serials.length < 2) {
|
|
2331
|
+
const rows = await DeviceBuild.devices()
|
|
2332
|
+
const online = [...new Set([...serials, ...rows.filter((r) => r.state === 'device').map((r) => r.serial)])]
|
|
2333
|
+
serials = online.slice(0, 2)
|
|
2334
|
+
}
|
|
2335
|
+
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.`)
|
|
2336
|
+
const wantOcr = args.ocr === true
|
|
2337
|
+
const devices = await Promise.all(serials.map((serial) => captureForPair(serial, args.directory, wantOcr)))
|
|
2338
|
+
const images = (await Promise.all(devices.map((device) => Vision.maybeAttachScreenshot(vision, device.screenshot, exec)))).filter((image) => image !== undefined)
|
|
2339
|
+
return { devices, ...(images.length > 0 ? { images } : {}) }
|
|
2340
|
+
},
|
|
2341
|
+
})
|
|
2342
|
+
}
|
|
2343
|
+
|
|
2344
|
+
const MESH_POLICY_SCHEMA = {
|
|
2345
|
+
type: 'object',
|
|
2346
|
+
additionalProperties: false,
|
|
2347
|
+
properties: {
|
|
2348
|
+
latencyMs: { type: 'integer', required: true },
|
|
2349
|
+
jitterMs: { type: 'integer', required: true },
|
|
2350
|
+
dropPct: { type: 'number', required: true },
|
|
2351
|
+
dupPct: { type: 'number', required: true },
|
|
2352
|
+
throttleKbps: { type: 'integer', required: true },
|
|
2353
|
+
},
|
|
2354
|
+
}
|
|
2355
|
+
const MESH_PEER_SCHEMA = {
|
|
2356
|
+
type: 'object',
|
|
2357
|
+
additionalProperties: false,
|
|
2358
|
+
properties: {
|
|
2359
|
+
id: { type: 'string', required: true },
|
|
2360
|
+
name: { type: 'string', required: true },
|
|
2361
|
+
role: { type: 'string', required: true },
|
|
2362
|
+
joinedAt: { type: 'integer', required: true },
|
|
2363
|
+
lastSeen: { type: 'integer', required: true },
|
|
2364
|
+
serial: { type: 'string' },
|
|
2365
|
+
},
|
|
2366
|
+
}
|
|
2367
|
+
const MESH_SESSION_SCHEMA = {
|
|
2368
|
+
type: 'object',
|
|
2369
|
+
additionalProperties: false,
|
|
2370
|
+
properties: {
|
|
2371
|
+
id: { type: 'string', required: true },
|
|
2372
|
+
members: { type: 'array', items: { type: 'string' }, required: true },
|
|
2373
|
+
policy: MESH_POLICY_SCHEMA,
|
|
2374
|
+
pending: { type: 'integer', required: true },
|
|
2375
|
+
},
|
|
2376
|
+
}
|
|
2377
|
+
|
|
2378
|
+
function meshStatusTool(mesh) {
|
|
2379
|
+
return defineTool({
|
|
2380
|
+
name: 'mesh_status',
|
|
2381
|
+
description: 'Inspect the host mesh hub: every peer that joined from a device (a random callsign pinned to its ' +
|
|
2382
|
+
'serial, LocalSend-style), every session with its members, live network policy and undelivered mail. Devices ' +
|
|
2383
|
+
'reach this hub at http://10.0.2.2:<dsh-port>/api/dsh-mobilecode/mesh/* — games POST join {serial?,name?}, ' +
|
|
2384
|
+
'then link/send/poll with the returned id+token.',
|
|
2385
|
+
parameters: {},
|
|
2386
|
+
output: {
|
|
2387
|
+
schema: {
|
|
2388
|
+
type: 'object',
|
|
2389
|
+
additionalProperties: false,
|
|
2390
|
+
properties: {
|
|
2391
|
+
peers: { type: 'array', items: MESH_PEER_SCHEMA, required: true },
|
|
2392
|
+
sessions: { type: 'array', items: MESH_SESSION_SCHEMA, required: true },
|
|
2393
|
+
messageSeq: { type: 'integer', required: true },
|
|
2394
|
+
},
|
|
2395
|
+
},
|
|
2396
|
+
render: (_args, value) => {
|
|
2397
|
+
const v = value ?? { peers: [], sessions: [], messageSeq: 0 }
|
|
2398
|
+
const lines = [
|
|
2399
|
+
`mesh: ${v.peers.length} peer(s), ${v.sessions.length} session(s), ${v.messageSeq} message(s) so far`,
|
|
2400
|
+
...v.peers.map((peer) => ` ● ${peer.name}${peer.serial ? ` (${peer.serial})` : ''} [${peer.role}] last-seen ${new Date(peer.lastSeen).toISOString().slice(11, 19)}`),
|
|
2401
|
+
...v.sessions.map((s) => ` ◈ ${s.id}: ${s.members.join(' ↔')} — ${JSON.stringify(s.policy)}${s.pending ? ` (${s.pending} undelivered)` : ''}`),
|
|
2402
|
+
]
|
|
2403
|
+
return [{ type: 'text', text: lines.join('\n') }]
|
|
2404
|
+
},
|
|
2405
|
+
},
|
|
2406
|
+
async execute() {
|
|
2407
|
+
const status = mesh.status()
|
|
2408
|
+
return { peers: status.peers.map(clean), sessions: status.sessions, messageSeq: status.messageSeq }
|
|
2409
|
+
},
|
|
2410
|
+
})
|
|
2411
|
+
}
|
|
2412
|
+
|
|
2413
|
+
function meshSendTool(mesh) {
|
|
2414
|
+
return defineTool({
|
|
2415
|
+
name: 'mesh_send',
|
|
2416
|
+
description: 'Inject a JSON message into a mesh session AS an existing peer ("ghost" the other player) — the ' +
|
|
2417
|
+
'fastest way to drive a co-op test without touching the app: the receiving game gets it from /mesh/poll ' +
|
|
2418
|
+
'exactly as if its partner sent it, and the hub applies the session latency/drop/dup policy on delivery. ' +
|
|
2419
|
+
'Every injection is logged (via: agent) so you can see how each side reacts.',
|
|
2420
|
+
parameters: {
|
|
2421
|
+
session: { type: 'string', description: 'Session id from mesh_status.' },
|
|
2422
|
+
from: { type: 'string', description: 'Peer name / id to speak as (must be a session member).' },
|
|
2423
|
+
body: { type: 'object', additionalProperties: true, description: 'JSON payload delivered verbatim to the other members.' },
|
|
2424
|
+
},
|
|
2425
|
+
output: {
|
|
2426
|
+
schema: {
|
|
2427
|
+
type: 'object',
|
|
2428
|
+
additionalProperties: false,
|
|
2429
|
+
properties: {
|
|
2430
|
+
seq: { type: 'integer', required: true },
|
|
2431
|
+
delivered: { type: 'integer', required: true },
|
|
2432
|
+
dropped: { type: 'integer', required: true },
|
|
2433
|
+
duplicated: { type: 'integer', required: true },
|
|
2434
|
+
readyAt: { type: 'integer' },
|
|
2435
|
+
},
|
|
2436
|
+
},
|
|
2437
|
+
render: (_args, value) => {
|
|
2438
|
+
const v = value ?? { seq: 0, delivered: 0, dropped: 0, duplicated: 0 }
|
|
2439
|
+
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)})` : ''}` }]
|
|
2440
|
+
},
|
|
2441
|
+
},
|
|
2442
|
+
async execute(args) {
|
|
2443
|
+
const result = mesh.send({ session: args.session, from: args.from, body: args.body ?? null, via: 'agent' })
|
|
2444
|
+
return clean(result)
|
|
2445
|
+
},
|
|
2446
|
+
})
|
|
2447
|
+
}
|
|
2448
|
+
|
|
2449
|
+
function meshLogTool(mesh) {
|
|
2450
|
+
return defineTool({
|
|
2451
|
+
name: 'mesh_log',
|
|
2452
|
+
description: 'Read the mesh message log — every join/link/send/drop/dup/tune event with timestamps, sequence ' +
|
|
2453
|
+
'numbers, byte sizes and the acting peer. This is the wire between the players: watch the game talk, spot ' +
|
|
2454
|
+
'policy drops, and correlate traffic bursts with what device_pair_capture shows on both screens.',
|
|
2455
|
+
parameters: {
|
|
2456
|
+
session: { type: 'string', description: 'Only entries for this session id (default: all).' },
|
|
2457
|
+
limit: { type: 'integer', description: 'Most recent N entries (default 50, max 500).' },
|
|
2458
|
+
},
|
|
2459
|
+
output: {
|
|
2460
|
+
schema: {
|
|
2461
|
+
type: 'object',
|
|
2462
|
+
additionalProperties: false,
|
|
2463
|
+
properties: { entries: { type: 'array', items: { type: 'object', additionalProperties: true }, required: true } },
|
|
2464
|
+
},
|
|
2465
|
+
render: (_args, value) => {
|
|
2466
|
+
const entries = value?.entries ?? []
|
|
2467
|
+
const text = entries.length === 0
|
|
2468
|
+
? 'mesh log is empty'
|
|
2469
|
+
: 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')
|
|
2470
|
+
return [{ type: 'text', text: `mesh log (${entries.length} entries):\n${text}` }]
|
|
2471
|
+
},
|
|
2472
|
+
},
|
|
2473
|
+
async execute(args) {
|
|
2474
|
+
return { entries: mesh.log({ session: args.session, limit: args.limit }) }
|
|
2475
|
+
},
|
|
2476
|
+
})
|
|
2477
|
+
}
|
|
2478
|
+
|
|
2479
|
+
function meshTuneTool(mesh) {
|
|
2480
|
+
return defineTool({
|
|
2481
|
+
name: 'mesh_tune',
|
|
2482
|
+
description: 'Set the network conditions a mesh session delivers under: latencyMs delay, jitterMs random extra ' +
|
|
2483
|
+
'delay, dropPct random message loss, dupPct random duplicates, throttleKbps size-proportional delay (8000 bytes ' +
|
|
2484
|
+
'at 8 Kbps = +1000 ms). Applied per send to every recipient, zero means OFF. This is the desync-testing lever: ' +
|
|
2485
|
+
'make player B lag 400 ms with 100 ms jitter and watch the game handle it — no app code changes.',
|
|
2486
|
+
parameters: {
|
|
2487
|
+
session: { type: 'string', description: 'Session id from mesh_status.' },
|
|
2488
|
+
latencyMs: { type: 'integer', description: 'Fixed per-message delay, 0..5000.' },
|
|
2489
|
+
jitterMs: { type: 'integer', description: 'Random extra delay 0..jitterMs, 0..5000.' },
|
|
2490
|
+
dropPct: { type: 'number', description: 'Chance each recipient-copy is dropped, 0..100.' },
|
|
2491
|
+
dupPct: { type: 'number', description: 'Chance each delivered copy is duplicated, 0..100.' },
|
|
2492
|
+
throttleKbps: { type: 'integer', description: 'Bandwidth cap: adds bytes*8/kbps ms per message. 0 = unlimited.' },
|
|
2493
|
+
},
|
|
2494
|
+
output: {
|
|
2495
|
+
schema: {
|
|
2496
|
+
type: 'object',
|
|
2497
|
+
additionalProperties: false,
|
|
2498
|
+
properties: {
|
|
2499
|
+
session: { type: 'string', required: true },
|
|
2500
|
+
policy: MESH_POLICY_SCHEMA,
|
|
2501
|
+
},
|
|
2502
|
+
},
|
|
2503
|
+
render: (_args, value) => {
|
|
2504
|
+
const v = value ?? { session: '', policy: {} }
|
|
2505
|
+
return [{ type: 'text', text: `session ${v.session} policy → ${JSON.stringify(v.policy)}` }]
|
|
2506
|
+
},
|
|
2507
|
+
},
|
|
2508
|
+
async execute(args) {
|
|
2509
|
+
const policy = mesh.tune(args.session, {
|
|
2510
|
+
latencyMs: args.latencyMs, jitterMs: args.jitterMs, dropPct: args.dropPct, dupPct: args.dupPct, throttleKbps: args.throttleKbps,
|
|
2511
|
+
})
|
|
2512
|
+
return { session: args.session, policy }
|
|
2513
|
+
},
|
|
2514
|
+
})
|
|
2515
|
+
}
|
|
2516
|
+
|
|
2517
|
+
function meshResetTool(mesh) {
|
|
2518
|
+
return defineTool({
|
|
2519
|
+
name: 'mesh_reset',
|
|
2520
|
+
description: 'Clear mesh state: pass a session id to dissolve that one session, or nothing to wipe ALL peers and ' +
|
|
2521
|
+
'sessions and the log. Devices re-join with /mesh/join afterwards (their callsigns stay pinned to serial), so a ' +
|
|
2522
|
+
'reset between test runs is cheap.',
|
|
2523
|
+
parameters: {
|
|
2524
|
+
session: { type: 'string', description: 'Dissolve only this session (default: full wipe).' },
|
|
2525
|
+
},
|
|
2526
|
+
output: {
|
|
2527
|
+
schema: {
|
|
2528
|
+
type: 'object',
|
|
2529
|
+
additionalProperties: false,
|
|
2530
|
+
properties: { cleared: { type: 'string', required: true } },
|
|
2531
|
+
},
|
|
2532
|
+
render: (_args, value) => [{ type: 'text', text: `mesh cleared: ${value?.cleared ?? '?'}` }],
|
|
2533
|
+
},
|
|
2534
|
+
async execute(args) {
|
|
2535
|
+
if (typeof args.session === 'string' && args.session !== '') {
|
|
2536
|
+
mesh.unlink(args.session)
|
|
2537
|
+
return { cleared: `session ${args.session}` }
|
|
2538
|
+
}
|
|
2539
|
+
mesh.reset()
|
|
2540
|
+
return { cleared: 'everything (peers, sessions, log)' }
|
|
2541
|
+
},
|
|
2542
|
+
})
|
|
2543
|
+
}
|
|
2544
|
+
|
|
1830
2545
|
function deviceBootTool() {
|
|
1831
2546
|
return defineTool({
|
|
1832
2547
|
name: 'device_boot',
|
|
@@ -2619,6 +3334,28 @@ function guidance() {
|
|
|
2619
3334
|
' check a running app\'s footprint.',
|
|
2620
3335
|
'- device_backtrace: SIGQUIT an app process and read its newest /data/anr thread dump; falls back to the logcat crash buffer',
|
|
2621
3336
|
' when /data/anr is unreadable (engine field says which). Deterministic crash/ANR capture for debugging.',
|
|
3337
|
+
'- device_display: read or set a device\'s DPI (wm density) and pixel resolution (wm size) — action get/set/reset.',
|
|
3338
|
+
' Use it to test layout scaling, or to make two devices share a viewport. Re-observe with device_screen after a change.',
|
|
3339
|
+
'- device_avd_create: create a second virtual device (clone_from copies an existing AVD\'s full hardware config so both',
|
|
3340
|
+
' players behave identically). Boot it with device_boot; a running emulator holds 5554 so device #2 lands on 5556.',
|
|
3341
|
+
'- device_batch: fire 1..16 input actions concurrently across devices (each step carries its own serial) — the co-op',
|
|
3342
|
+
' primitive for "both players press attack on the same frame". Per-step results; a failing step never blocks others.',
|
|
3343
|
+
'- device_pair_capture: capture BOTH devices\' screens in one call (parallel PNG + UI digests, both attached as real',
|
|
3344
|
+
' image blocks) — the way to watch two players at the same instant. Add ocr:true only when UI trees are not enough.',
|
|
3345
|
+
'',
|
|
3346
|
+
'Co-op mesh (v0.8.0) — two emulators cannot multicast-discover each other through their isolated NATs, so the plugin',
|
|
3347
|
+
'hosts a LocalSend-style JSON pub/sub hub. Any app inside an emulator reaches it at http://10.0.2.2:<dsh-port>' +
|
|
3348
|
+
'/api/dsh-mobilecode/mesh/join — POST {serial?,name?} to join (the hub assigns a random callsign like "amber-fox",',
|
|
3349
|
+
'stable per serial, and a token), POST /mesh/link {with:[names]} to form a session, then POST /mesh/send and GET',
|
|
3350
|
+
'/mesh/poll?id&token&after&wait to exchange JSON. The hub is also your observation window and network simulator:',
|
|
3351
|
+
'- mesh_status: who has joined, which sessions exist, live per-session policy and undelivered mail.',
|
|
3352
|
+
'- mesh_send: inject a message as any peer (the other game receives it from /mesh/poll as if its partner sent it).',
|
|
3353
|
+
'- mesh_log: every join/link/send/drop/dup/tune event with timestamps, sizes and latency — the wire between players.',
|
|
3354
|
+
'- mesh_tune: set a session\'s latencyMs / jitterMs / dropPct / dupPct / throttleKbps to desync-test the pair',
|
|
3355
|
+
' (e.g. latency 400 + jitter 100 on player B) without touching app code.',
|
|
3356
|
+
'- mesh_reset: dissolve one session or wipe everything between test runs.',
|
|
3357
|
+
'Typical co-op loop: device_avd_create + device_boot a clone → both apps join → mesh_link → device_batch inputs at',
|
|
3358
|
+
'both, device_pair_capture to watch, mesh_log to see the traffic, mesh_tune to inject real-world network pain.',
|
|
2622
3359
|
'',
|
|
2623
3360
|
'Expo and React Native projects are handled automatically: expo prebuild runs when needed, Metro starts for you,',
|
|
2624
3361
|
'and the app is installed and launched on the booted simulator/emulator. Failed builds report the error and a log tail.',
|
|
@@ -2647,10 +3384,12 @@ export function apply(ctx, config) {
|
|
|
2647
3384
|
const engine = new DevicePreviewEngine()
|
|
2648
3385
|
const streamHost = new AndroidStreamHost()
|
|
2649
3386
|
const streamAccess = new StreamAccess.StreamAccessController()
|
|
3387
|
+
const mesh = new MeshHub()
|
|
2650
3388
|
const vision = Vision.resolveVisionServices(ctx)
|
|
2651
3389
|
const handle = {
|
|
2652
3390
|
engine,
|
|
2653
3391
|
stream: streamHost,
|
|
3392
|
+
mesh,
|
|
2654
3393
|
status: () => ({
|
|
2655
3394
|
directories: [...new Set([...engine.builds.keys()].map((key) => key.split('\0')[0]))],
|
|
2656
3395
|
servers: [...engine.servers.keys()],
|
|
@@ -2661,7 +3400,7 @@ export function apply(ctx, config) {
|
|
|
2661
3400
|
if (typeof ctx.provide === 'function') ctx.provide('mobilecode', handle)
|
|
2662
3401
|
else ctx.mobilecode = handle
|
|
2663
3402
|
|
|
2664
|
-
const routes = makeRoutes(engine, config, { host: streamHost, access: streamAccess })
|
|
3403
|
+
const routes = makeRoutes(engine, config, { host: streamHost, access: streamAccess, mesh })
|
|
2665
3404
|
let disposeRoutes
|
|
2666
3405
|
let disposeTools
|
|
2667
3406
|
let disposeSection
|
|
@@ -2714,6 +3453,15 @@ export function apply(ctx, config) {
|
|
|
2714
3453
|
deviceTapRowTool(),
|
|
2715
3454
|
deviceBacktraceTool(),
|
|
2716
3455
|
deviceMeminfoTool(),
|
|
3456
|
+
deviceDisplayTool(),
|
|
3457
|
+
deviceAvdCreateTool(),
|
|
3458
|
+
deviceBatchTool(),
|
|
3459
|
+
devicePairCaptureTool(vision),
|
|
3460
|
+
meshStatusTool(mesh),
|
|
3461
|
+
meshSendTool(mesh),
|
|
3462
|
+
meshLogTool(mesh),
|
|
3463
|
+
meshTuneTool(mesh),
|
|
3464
|
+
meshResetTool(mesh),
|
|
2717
3465
|
].map((tool) => ctx.tools.register(tool))
|
|
2718
3466
|
return () => { for (const dispose of disposers) dispose() }
|
|
2719
3467
|
}, 'dsh-mobilecode: tools')
|