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 CHANGED
@@ -202,7 +202,57 @@ classified adb boundary (argv-based, quoted, replay-safe):
202
202
  - `device_backtrace` — a thread/crash dump without a debugger: sends SIGQUIT
203
203
  (`kill -3`), waits for ART to write the trace, reads the newest `/data/anr`
204
204
  entry, and falls back to the logcat crash buffer when `/data/anr` is unreadable
205
- (`engine: "anr-trace" | "logcat-crash"`). Pass `package_name` or `pid`.
205
+ (`engine: "anr-trace" | "logcat-crash"`). SIGQUIT refusal (system-uid or
206
+ non-debuggable process) degrades to the crash buffer with an explanatory note
207
+ instead of failing. Pass `package_name` or `pid`.
208
+ - `device_display` — read or change a device's **DPI and resolution**
209
+ (`wm density` / `wm size`): `get` reports physical + override values, `set`
210
+ overrides density and/or `width`x`height`, `reset` restores. Layouts reflow
211
+ instantly — re-observe with `device_screen` after a change.
212
+ - `device_avd_create` — create a **second virtual device** via `avdmanager`;
213
+ `clone_from` copies an existing AVD's full hardware config (same image, DPI,
214
+ RAM) so both players behave identically. Boot the new AVD with `device_boot`
215
+ (a running emulator holds 5554, so device #2 lands on `emulator-5556`).
216
+ - `device_batch` — fire 1..16 input actions **concurrently across devices**
217
+ (each step carries its own serial + the same fields `device_input` takes).
218
+ The co-op primitive for "both players press attack on the same frame"; a
219
+ failing step never blocks the others, and per-step results say what landed.
220
+ - `device_pair_capture` — capture **both devices at the same instant**: one
221
+ call, parallel screenshots + UI digests, both attached as real image blocks
222
+ so a vision model watches both players at once. `ocr: true` adds PaddleOCR.
223
+
224
+ **Co-op mesh (v0.8.0)** — two emulators cannot multicast-discover each other
225
+ (each sits behind its own slirp NAT), but every one of them reaches the host at
226
+ `10.0.2.2`. So the plugin hosts a LocalSend-style JSON pub/sub **hub**: games
227
+ join it and talk through it, and the AI model reads and steers the same wire.
228
+
229
+ - **Identity**: `POST /mesh/join {serial?, name?, role?}` — the serial pins a
230
+ stable, random, LocalSend-style callsign (`amber-fox`, `brisk-owl`…); the
231
+ join returns a per-process token that authorizes every later call.
232
+ - **Sessions**: `POST /mesh/link {with: [names…]}` (2–8 members), then
233
+ `POST /mesh/send {session, body}` and `GET /mesh/poll?id&token&after&wait`
234
+ (long-polls up to 30 s). `GET /mesh/peers` shows the roster; `POST /mesh/leave`
235
+ departs. Everything is JSON, 64 KiB per message.
236
+ - **Network conditions**: each session carries a policy — `latencyMs`,
237
+ `jitterMs`, `dropPct`, `dupPct`, `throttleKbps` (size-proportional delay) —
238
+ enforced per recipient by the hub. This is desync testing without touching
239
+ app code: make player B lag 400 ms with 100 ms jitter and watch the pair cope.
240
+ - **Agent side**: `mesh_status` (who joined, sessions, policies, undelivered
241
+ mail), `mesh_send` (ghost a message as any peer — the other game receives it
242
+ as if its partner sent it), `mesh_log` (every join/link/send/drop/dup/tune
243
+ event, timestamped), `mesh_tune` (set the policy), `mesh_reset`.
244
+ - **Security**: the routes are loopback-only (emulators reach the host loopback
245
+ via slirp); a request presenting browser headers (`Origin`/`Sec-Fetch-*`)
246
+ must additionally pass the trusted-browser stream fence, so a random web page
247
+ can never join a game or read its messages.
248
+ - **Client SDK**: `sdk/mesh-client.mjs` — a ~90-line dependency-free `fetch`
249
+ client (`join/link/send/inbox`) for Node/Deno/Bun/WebView/React-Native game
250
+ code; plain HTTP works from any other engine (Unity, Godot, Kotlin).
251
+
252
+ Typical loop: `device_avd_create` + `device_boot` a clone → both apps
253
+ `join` → `mesh_link` → `device_batch` inputs at both → `device_pair_capture`
254
+ to watch → `mesh_log` to see the traffic → `mesh_tune` to inject real-world
255
+ network pain.
206
256
 
207
257
  **Conversation surface (v0.7.0)** — the transcript integration ported from
208
258
  dsh-android's UI/UX:
@@ -257,7 +307,9 @@ plus the live-stream routes `GET /stream/status`, `POST /stream/grant`,
257
307
  `POST /stream/devices` (online devices **and** configured AVDs),
258
308
  `POST /stream/still` (one `screencap` as a data URL up to 4 MB),
259
309
  `POST /stream/device-action` (the `device_action` verbs over the panel fence)
260
- and `GET /stream/{token}` (the multipart frame body). Every stream error carries
310
+ and `GET /stream/{token}` (the multipart frame body) plus the co-op mesh
311
+ routes `POST /mesh/join`, `GET /mesh/peers`, `POST /mesh/{link,send,leave}`,
312
+ `GET /mesh/poll` (see **Co-op mesh** above). Every stream error carries
261
313
  a machine-readable `code` next to the HTTP status — `token_invalid`,
262
314
  `stream_not_running`, `stream_start_failed`, `devices_unavailable`,
263
315
  `device_not_found`, `device_offline`, `unknown_action`, `bad_request`,
@@ -292,8 +344,14 @@ say *why* instead of "something failed".
292
344
  lifecycle (park → halt → settle → execute; one project at a time; process
293
345
  exit/signal hooks; win32 taskkill tree-kill).
294
346
  - `lib/index.js` — host half: engine, `/api/dsh-mobilecode/*` routes (run
295
- controls + welcome / doctor / ocr / settings), agent tools, system-prompt
347
+ controls + welcome / doctor / ocr / settings + mesh), agent tools, system-prompt
296
348
  guidance section, `ctx.provide('mobilecode', handle)`.
349
+ - `lib/mesh-hub.js` — the co-op mesh core: peer identity (serial-pinned
350
+ callsigns + HMAC tokens), sessions, per-session network policy
351
+ (latency/jitter/drop/dup/throttle), a long-poll inbox, and an event log.
352
+ Pure + injectable clock/RNG, so `test/mesh-hub.mjs` covers it offline.
353
+ - `sdk/mesh-client.mjs` — dependency-free `fetch` client (join/link/send/
354
+ poll/inbox/leave) for game code running inside the emulators.
297
355
  - `lib/setup.js` — settings store (~/.dsh/mobilecode/settings.json), the plugin
298
356
  doctor (health checks + auto-fix), and the detached PaddleOCR installer
299
357
  (writes an install script to disk and spawns it via cmd.exe, so the install
@@ -1335,3 +1335,124 @@ export async function ocrImage(pngPath, lang = "ch") {
1335
1335
  }
1336
1336
  return []
1337
1337
  }
1338
+
1339
+ // ── AVD creation + display overrides (v0.8.0) ────────────────────────────────
1340
+
1341
+ /** avdmanager.bat from the SDK cmdline-tools, or undefined. */
1342
+ export function avdmanagerBinary() {
1343
+ const sdk = androidSdk()
1344
+ if (!sdk) return undefined
1345
+ const exe = process.platform === "win32" ? "avdmanager.bat" : "avdmanager"
1346
+ const roots = [path.join(sdk, "cmdline-tools", "latest"), path.join(sdk, "cmdline-tools")]
1347
+ for (const root of roots) {
1348
+ const direct = path.join(root, "bin", exe)
1349
+ if (existsSync(direct)) return direct
1350
+ if (!existsSync(path.join(root, "bin"))) {
1351
+ try {
1352
+ for (const entry of readdirSync(root)) {
1353
+ const candidate = path.join(root, entry, "bin", exe)
1354
+ if (existsSync(candidate)) return candidate
1355
+ }
1356
+ } catch { /* nothing to enumerate */ }
1357
+ }
1358
+ }
1359
+ return undefined
1360
+ }
1361
+
1362
+ /** Installed system-image packages as `system-images;<api>;<tag>;<abi>` ids. */
1363
+ export async function installedSystemImages() {
1364
+ const sdk = androidSdk()
1365
+ if (!sdk) return []
1366
+ const ids = []
1367
+ const base = path.join(sdk, "system-images")
1368
+ for (const api of readdirSafe(base)) {
1369
+ for (const tag of readdirSafe(path.join(base, api))) {
1370
+ for (const abi of readdirSafe(path.join(base, api, tag))) {
1371
+ ids.push(`system-images;${api};${tag};${abi}`)
1372
+ }
1373
+ }
1374
+ }
1375
+ return ids.sort()
1376
+ }
1377
+
1378
+ function readdirSafe(dir) {
1379
+ try { return readdirSync(dir, { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => e.name) } catch { return [] }
1380
+ }
1381
+
1382
+ /** config.ini path of a created AVD. */
1383
+ export function avdConfigPath(name) {
1384
+ return path.join(os.homedir(), ".android", "avd", `${name}.avd`, "config.ini")
1385
+ }
1386
+
1387
+ function parseIni(text) {
1388
+ const entries = []
1389
+ for (const line of String(text).split(/\r?\n/)) {
1390
+ const match = /^\s*([\w.]+)\s*=\s*(.*)$/.exec(line)
1391
+ if (match) entries.push([match[1], match[2].trim()])
1392
+ }
1393
+ return entries
1394
+ }
1395
+
1396
+ /**
1397
+ * Merge an AVD config for hardware-parity cloning: identity/path keys of the
1398
+ * NEW avd stay theirs; every other key (hw.*, image.sysdir, runtime perms)
1399
+ * comes from the source. Pure so it can be unit-tested.
1400
+ */
1401
+ export function mergeAvdConfig(newText, sourceText) {
1402
+ const KEEP = new Set(["avd.id", "avd.name", "path", "path.rel", "basedir", "displayname"])
1403
+ const base = parseIni(newText)
1404
+ const source = new Map(parseIni(sourceText).filter(([key]) => !KEEP.has(key)))
1405
+ const seen = new Set()
1406
+ const lines = base.map(([key, value]) => {
1407
+ seen.add(key)
1408
+ return source.has(key) ? `${key}=${source.get(key)}` : `${key}=${value}`
1409
+ })
1410
+ for (const [key, value] of source) if (!seen.has(key)) lines.push(`${key}=${value}`)
1411
+ return lines.join("\n") + "\n"
1412
+ }
1413
+
1414
+ /**
1415
+ * Create an AVD via avdmanager. `cloneConfigText` (optional) is the source
1416
+ * AVD's config.ini — merged in afterwards for hardware parity. Throws with
1417
+ * avdmanager's own error text on failure.
1418
+ */
1419
+ export async function createAvd({ name, imageId, deviceProfile, cloneConfigText }) {
1420
+ const binary = avdmanagerBinary()
1421
+ if (!binary) throw new Error("avdmanager not found in the SDK cmdline-tools — install it via sdkmanager.")
1422
+ const args = ["create", "avd", "-n", name, "-k", imageId]
1423
+ if (deviceProfile) args.push("-d", deviceProfile)
1424
+ args.push("--force")
1425
+ const result = await captureFull(binary, args, { env: javaEnv() })
1426
+ if (result.code !== 0) {
1427
+ const text = [result.out, result.err].filter(Boolean).join("\n")
1428
+ throw new Error(`avdmanager failed (exit ${result.code}):\n${text.slice(-600)}`)
1429
+ }
1430
+ const configPath = avdConfigPath(name)
1431
+ if (!existsSync(configPath)) throw new Error(`AVD "${name}" was created but config.ini is missing at ${configPath}.`)
1432
+ if (cloneConfigText) {
1433
+ const merged = mergeAvdConfig(readFileSync(configPath, "utf8"), cloneConfigText)
1434
+ writeFileSync(configPath, merged)
1435
+ }
1436
+ return { name, configPath }
1437
+ }
1438
+
1439
+ /** avdmanager/`sdkmanager` are Java tools: make sure JAVA_HOME-less shells still find java. */
1440
+ function javaEnv() {
1441
+ return { ANDROID_HOME: androidSdk(), ANDROID_SDK_ROOT: androidSdk() }
1442
+ }
1443
+
1444
+ /** Parse `wm density` output → {physical, override|null}. */
1445
+ export function parseWmDensity(text) {
1446
+ const physical = Number(/Physical density:\s*(\d+)/.exec(text)?.[1]) || undefined
1447
+ const override = Number(/Override density:\s*(\d+)/.exec(text)?.[1]) || undefined
1448
+ return { physical, override: override && override !== physical ? override : null }
1449
+ }
1450
+
1451
+ /** Parse `wm size` output → {physical:{width,height}, override|null}. */
1452
+ export function parseWmSize(text) {
1453
+ const toSize = (m) => (m ? { width: Number(m[1]), height: Number(m[2]) } : undefined)
1454
+ const physical = toSize(/Physical size:\s*(\d+)x(\d+)/.exec(text))
1455
+ const override = toSize(/Override size:\s*(\d+)x(\d+)/.exec(text))
1456
+ const same = override && physical && override.width === physical.width && override.height === physical.height
1457
+ return { physical, override: same ? null : (override ?? null) }
1458
+ }