dsh-mobilecode 0.7.1 → 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
@@ -205,6 +205,54 @@ classified adb boundary (argv-based, quoted, replay-safe):
205
205
  (`engine: "anr-trace" | "logcat-crash"`). SIGQUIT refusal (system-uid or
206
206
  non-debuggable process) degrades to the crash buffer with an explanatory note
207
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.
208
256
 
209
257
  **Conversation surface (v0.7.0)** — the transcript integration ported from
210
258
  dsh-android's UI/UX:
@@ -259,7 +307,9 @@ plus the live-stream routes `GET /stream/status`, `POST /stream/grant`,
259
307
  `POST /stream/devices` (online devices **and** configured AVDs),
260
308
  `POST /stream/still` (one `screencap` as a data URL up to 4 MB),
261
309
  `POST /stream/device-action` (the `device_action` verbs over the panel fence)
262
- 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
263
313
  a machine-readable `code` next to the HTTP status — `token_invalid`,
264
314
  `stream_not_running`, `stream_start_failed`, `devices_unavailable`,
265
315
  `device_not_found`, `device_offline`, `unknown_action`, `bad_request`,
@@ -294,8 +344,14 @@ say *why* instead of "something failed".
294
344
  lifecycle (park → halt → settle → execute; one project at a time; process
295
345
  exit/signal hooks; win32 taskkill tree-kill).
296
346
  - `lib/index.js` — host half: engine, `/api/dsh-mobilecode/*` routes (run
297
- controls + welcome / doctor / ocr / settings), agent tools, system-prompt
347
+ controls + welcome / doctor / ocr / settings + mesh), agent tools, system-prompt
298
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.
299
355
  - `lib/setup.js` — settings store (~/.dsh/mobilecode/settings.json), the plugin
300
356
  doctor (health checks + auto-fix), and the detached PaddleOCR installer
301
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
+ }