dsh-mobilecode 0.11.9 → 0.12.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
@@ -374,6 +374,51 @@ say *why* instead of "something failed".
374
374
  - Tolerant internal callers (boot polling, IME checks) opt back into
375
375
  best-effort with an explicit `.catch(() => "")`.
376
376
 
377
+ ## Design workbench (0.12 — native Android UI design & verification)
378
+
379
+ Reusable capabilities for the loop *explore visual directions → approve a
380
+ design → implement native Compose → render → compare → test on Android →
381
+ refine*. App-specific decisions (learning flow, approved directions) live in
382
+ the app project, never hardcoded here.
383
+
384
+ - **Reference workspace (`preview_gallery`, `config_matrix`, `design_bridge`
385
+ tools + `/api/dsh-mobilecode/refs|preview|matrix|openpencil/*` routes)** —
386
+ import PNG/JPEG/WebP design references with metadata (project, screen,
387
+ state, provenance, approval status); associate device captures (reuses the
388
+ existing screencap path; originals stored verbatim, alignment transforms
389
+ kept separate); persisted under `~/.dsh/mobilecode/reference/` across
390
+ reloads. Header-only decompression fences (no pixel decode server-side, no
391
+ new dependency).
392
+ - **Visual diff (`POST /refs/diff`)** — two modes, never conflated:
393
+ `regression` (any changed pixel fails against a recorded native baseline)
394
+ and `design-reference` (structure comparison, always `needs_review` — the
395
+ tool ran; that is not a visual pass). Client canvas downsamples; the server
396
+ reports concrete changed-region clusters; envelopes persist as evidence
397
+ files. No quality scores anywhere.
398
+ - **Compose preview gallery** — discovers `@PreviewTest` composables of a
399
+ Gradle project and renders them through **official Compose Preview
400
+ Screenshot Testing** (`com.android.compose.screenshot`, pinned
401
+ 0.0.1-alpha16, host layoutlib — works on AGP 8.5+ without toolchain
402
+ migration; compose-ai-tools needs AGP 8.13 and is documented as a blocked
403
+ alternative). Failed/stale renders are never labeled current; host-rendered
404
+ ≠ device-verified (see `docs/design/render-adapter-decision.md`).
405
+ - **Configuration matrix (`config_matrix` tool)** — bounded (1..8) risk-based
406
+ matrices over real device settings (wm size/density, font scale): every
407
+ case reads back its ACTUAL configuration, captures evidence, and the device
408
+ is restored to its found state (including pre-existing overrides) on
409
+ success, failure and cancellation. Physical devices are refused without
410
+ explicit approval.
411
+ - **OpenPencil bridge (`design_bridge`, optional)** — file-mode CLI adapter:
412
+ inspect `.fig` docs, export a frame to PNG into the reference workspace,
413
+ read design variables. Path-confined (realpath + junction-aware); when the
414
+ CLI is absent everything degrades to honest `not_run` — the workspace stays
415
+ fully usable with manual imports. OpenPencil exports are web-oriented
416
+ (PNG/JSX/HTML); no native Compose export is claimed.
417
+ - **Panel** — the Devices drawer gains two cards: **Reference compare**
418
+ (import / capture / side-by-side / opacity overlay / swipe slider / diff)
419
+ and **Preview gallery** (discovery, thumbnails, stale badges, per-entry
420
+ render).
421
+
377
422
  ## How it works
378
423
 
379
424
  - `lib/device-build.js` — port of `mobilecode/packages/core/src/device-build.ts`:
@@ -395,6 +440,13 @@ say *why* instead of "something failed".
395
440
  Pure + injectable clock/RNG, so `test/mesh-hub.mjs` covers it offline.
396
441
  - `sdk/mesh-client.mjs` — dependency-free `fetch` client (join/link/send/
397
442
  poll/inbox/leave) for game code running inside the emulators.
443
+ - Design workbench modules: `lib/reference-workspace.js` (reference/capture
444
+ store + header-only image fences), `lib/visual-compare.js` (pure RGBA diff
445
+ core — the server never pixel-decodes), `lib/preview-gallery.js` (@PreviewTest
446
+ discovery + Gradle render driver with timeout tree-kill),
447
+ `lib/config-matrix.js` (bounded matrix runner, snapshot→apply→confirm→
448
+ capture→restore on every path), `lib/openpencil.js` (optional CLI bridge,
449
+ realpath/junction-confined).
398
450
  - `lib/setup.js` — settings store (~/.dsh/mobilecode/settings.json), the plugin
399
451
  doctor (health checks + auto-fix), and the detached PaddleOCR installer
400
452
  (writes an install script to disk and spawns it via cmd.exe, so the install
package/lib/client.js CHANGED
@@ -354,6 +354,253 @@ window.__ModuleLoader__.load({
354
354
  const readDir = () => { try { return localStorage.getItem("dsh-mobilecode-directory") ?? ""; } catch { return ""; } };
355
355
  const writeDir = (value) => { try { localStorage.setItem("dsh-mobilecode-directory", value); } catch { /* private mode */ } };
356
356
 
357
+ function RefCompareSection({ directory }) {
358
+ const [project, setProject] = useState(() => {
359
+ const base = String(directory || "").replace(/[\\/]+$/, "").split(/[\\/]/).pop();
360
+ return base || "trachtenberg";
361
+ });
362
+ const [screen, setScreen] = useState("practice");
363
+ const [stateId, setStateId] = useState("default");
364
+ const [refs, setRefs] = useState([]);
365
+ const [activeId, setActiveId] = useState(null);
366
+ const [opacity, setOpacity] = useState(55);
367
+ const [swipe, setSwipe] = useState(50);
368
+ const [error, setError] = useState("");
369
+ const [busy, setBusy] = useState("");
370
+ const [pair, setPair] = useState(null);
371
+ const [pairVersion, setPairVersion] = useState(0);
372
+
373
+ const loadRefs = async (proj) => {
374
+ try {
375
+ const data = await apiGet("/refs?project=" + encodeURIComponent(proj ?? project));
376
+ setRefs(data.records ?? []);
377
+ setActiveId(data.active?.id ?? null);
378
+ } catch (err) { setError(err instanceof Error ? err.message : String(err)); }
379
+ };
380
+ const loadPair = async () => {
381
+ try {
382
+ const data = await apiGet("/refs/compare?project=" + encodeURIComponent(project) +
383
+ "&screen=" + encodeURIComponent(screen) + "&stateId=" + encodeURIComponent(stateId));
384
+ setPair(data);
385
+ } catch (err) { setError(err instanceof Error ? err.message : String(err)); }
386
+ };
387
+ useEffect(() => { loadRefs(project); }, [project]);
388
+ useEffect(() => { loadPair(); /* eslint-disable-line react-hooks/exhaustive-deps */ }, [project, screen, stateId, activeId, pairVersion]);
389
+
390
+ const run = async (name, fn) => {
391
+ setBusy(name); setError("");
392
+ try { await fn(); } catch (err) { setError(err instanceof Error ? err.message : String(err)); }
393
+ finally { setBusy(""); }
394
+ };
395
+ const importFile = async (file) => {
396
+ if (!file) return;
397
+ const data = await new Promise((resolve, reject) => {
398
+ const reader = new FileReader();
399
+ reader.onload = () => resolve(reader.result);
400
+ reader.onerror = () => reject(reader.error);
401
+ reader.readAsDataURL(file);
402
+ });
403
+ await run("import", async () => {
404
+ await streamPost("/refs/import", { project, screen, stateId, filename: file.name, data, provenance: "manual-import", intendedUse: "compare" });
405
+ await loadRefs(project); setPairVersion((v) => v + 1);
406
+ });
407
+ };
408
+ const capture = async () => {
409
+ await run("capture", async () => {
410
+ await streamPost("/refs/capture", { project, screen, stateId });
411
+ await loadRefs(project); setPairVersion((v) => v + 1);
412
+ });
413
+ };
414
+ const setActive = async (id) => {
415
+ await run("active", async () => {
416
+ await streamPost("/refs/active", { id });
417
+ setActiveId(id); setPairVersion((v) => v + 1);
418
+ });
419
+ };
420
+ const remove = async (id) => {
421
+ await run("remove", async () => {
422
+ await streamPost("/refs/remove", { id });
423
+ await loadRefs(project); setPairVersion((v) => v + 1);
424
+ });
425
+ };
426
+ const [diff, setDiff] = useState(null);
427
+ const decodeFrame = async (src, maxDim = 480) => {
428
+ const img = new Image();
429
+ img.crossOrigin = "anonymous";
430
+ await new Promise((resolve, reject) => { img.onload = resolve; img.onerror = () => reject(new Error("image decode failed: " + src)); img.src = src; });
431
+ if (img.naturalWidth * img.naturalHeight > 24_000_000) throw new Error("image exceeds 24MP decode guard");
432
+ const scale = Math.min(1, maxDim / Math.max(img.naturalWidth, img.naturalHeight));
433
+ const w = Math.max(1, Math.round(img.naturalWidth * scale));
434
+ const hgt = Math.max(1, Math.round(img.naturalHeight * scale));
435
+ const canvas = document.createElement("canvas");
436
+ canvas.width = w; canvas.height = hgt;
437
+ const ctx2d = canvas.getContext("2d", { willReadFrequently: true });
438
+ ctx2d.drawImage(img, 0, 0, w, hgt);
439
+ return { width: w, height: hgt, naturalWidth: img.naturalWidth, naturalHeight: img.naturalHeight, rgba: ctx2d.getImageData(0, 0, w, hgt).data };
440
+ };
441
+ const runDiff = async (fullRes) => {
442
+ if (!refSrc || !capSrc) { setError("need both a reference and a capture to diff"); return; }
443
+ await run("diff", async () => {
444
+ // Regression needs NATIVE frames: decode both at full resolution and
445
+ // require equal original dimensions — the server re-checks them against
446
+ // the stored records before any "passed" is possible. Different aspects
447
+ // stay triage-only (downsampled, needs_review) — never a silent stretch.
448
+ const sameSize = fullRes === true;
449
+ const [a, b] = sameSize
450
+ ? await Promise.all([decodeFrame(refSrc, Infinity), decodeFrame(capSrc, Infinity)])
451
+ : await Promise.all([decodeFrame(refSrc, 480), decodeFrame(capSrc, 480)]);
452
+ if (a.width !== b.width || a.height !== b.height) throw new Error("frame sizes differ (" + a.width + "x" + a.height + " vs " + b.width + "x" + b.height + ") — aspects differ; only downsampled triage applies, record a transform for aligned comparison");
453
+ const toB64 = (f) => { let s = ""; const bytes = f.rgba; for (let i = 0; i < bytes.length; i++) s += String.fromCharCode(bytes[i]); return btoa(s); };
454
+ const r = await streamPost("/refs/diff", {
455
+ a: { width: a.width, height: a.height, rgba: toB64(a) },
456
+ b: { width: b.width, height: b.height, rgba: toB64(b) },
457
+ mode: sameSize ? "regression" : "design-reference", project, screen, stateId,
458
+ referenceId: pair?.reference?.id, captureId: pair?.capture?.id,
459
+ });
460
+ setDiff(r);
461
+ });
462
+ };
463
+
464
+ const imgUrl = (record) => record ? API_BASE + "/refs/image?id=" + encodeURIComponent(record.id) : null;
465
+ const refSrc = imgUrl(pair?.reference ?? refs.find((r) => r.id === activeId));
466
+ const capSrc = imgUrl(pair?.capture);
467
+ // native regression only makes sense between IDENTICAL source sizes
468
+ const sizeMatch = !!pair?.reference && !!pair?.capture &&
469
+ pair.reference.width === pair.capture.width && pair.reference.height === pair.capture.height;
470
+ const box = { position: "relative", width: "100%", aspectRatio: "9/19.5", background: "#0b0f14", borderRadius: 8, overflow: "hidden" };
471
+ const fit = { position: "absolute", inset: 0, width: "100%", height: "100%", objectFit: "contain" };
472
+
473
+ return h("div", { className: "mc-card", style: { borderTop: "1px solid var(--mc-border, #2a3340)" } },
474
+ h("div", { className: "mc-card-head" }, h("span", null, "Reference compare"),
475
+ h("span", { className: "sp" }), h("span", { style: { opacity: 0.6, fontSize: 11 } }, busy || "slice")),
476
+ h("div", { className: "mc-row" },
477
+ h("input", { className: "mc-input", value: project, placeholder: "project", onChange: (e) => setProject(e.target.value), style: { flex: 1 } }),
478
+ ),
479
+ h("div", { className: "mc-row" },
480
+ h("input", { className: "mc-input", value: screen, placeholder: "screen", onChange: (e) => setScreen(e.target.value), style: { flex: 1 } }),
481
+ h("input", { className: "mc-input", value: stateId, placeholder: "state", onChange: (e) => setStateId(e.target.value), style: { flex: 1 } }),
482
+ ),
483
+ h("div", { className: "mc-row" },
484
+ h("label", { className: "mc-btn" },
485
+ busy !== "" ? "Importing…" : "Import reference",
486
+ h("input", { type: "file", accept: "image/png,image/jpeg,image/webp", style: { display: "none" }, onChange: (e) => importFile(e.target.files?.[0]) }),
487
+ ),
488
+ h("button", { className: "mc-btn primary", disabled: busy !== "", onClick: capture }, "Capture screen"),
489
+ h("button", { className: "mc-btn", disabled: busy !== "" || !refSrc || !capSrc, title: "reduced-resolution structure triage — needs_review only, can never pass regression", onClick: () => runDiff(false) }, "Diff (triage)"),
490
+ h("button", { className: "mc-btn", disabled: busy !== "" || !refSrc || !capSrc || !sizeMatch, title: sizeMatch ? "full-resolution native comparison — regression verdict" : "source sizes differ — a native regression comparison is invalid between different configurations", onClick: () => runDiff(true) }, "Regression (full-res)"),
491
+ ),
492
+ error !== "" && h("div", { className: "mc-error" }, error),
493
+ h("div", { style: { position: "relative" }, onMouseMove: undefined },
494
+ (refSrc || capSrc)
495
+ ? h("div", { style: box },
496
+ refSrc && h("img", { src: refSrc, style: fit, alt: "reference" }),
497
+ capSrc && h("img", { src: capSrc, style: { ...fit, clipPath: "inset(0 " + (100 - swipe) + "% 0 0)", opacity: opacity / 100 } },
498
+ /* capture overlaid on reference */),
499
+ h("div", { style: { position: "absolute", left: swipe + "%", top: 0, bottom: 0, width: 1, background: "#fff" } }),
500
+ )
501
+ : h("div", { className: "mc-empty" }, "Import a reference and capture the screen to compare."),
502
+ (refSrc || capSrc) && h("div", { className: "mc-row", style: { marginTop: 6, gap: 8 } },
503
+ h("label", { style: { display: "flex", alignItems: "center", gap: 4, fontSize: 11 } },
504
+ "opacity", h("input", { type: "range", min: 0, max: 100, value: opacity, onChange: (e) => setOpacity(Number(e.target.value)) })),
505
+ h("label", { style: { display: "flex", alignItems: "center", gap: 4, fontSize: 11 } },
506
+ "swipe", h("input", { type: "range", min: 0, max: 100, value: swipe, onChange: (e) => setSwipe(Number(e.target.value)) })),
507
+ ),
508
+ ),
509
+ h("div", { className: "mc-status" }, h("span", { style: { fontSize: 11, opacity: 0.7 } },
510
+ "reference " + (refSrc ? "✓" : "—") + " · capture " + (capSrc ? "✓" : "—") +
511
+ (pair?.reference && pair?.capture ? " · sources " + pair.reference.width + "x" + pair.reference.height + " vs " + pair.capture.width + "x" + pair.capture.height + (sizeMatch ? " (full-res regression available)" : " (triage only)") : "") +
512
+ " · persisted in ~/.dsh/mobilecode/reference (survives reloads)")),
513
+ diff && h("div", { style: { border: "1px solid var(--mc-border, #2a3340)", borderRadius: 8, padding: 8, fontSize: 11 } },
514
+ h("div", null, h("b", null, "diff · " + (diff.mode ?? "—") + " · " + (diff.comparisonSpace ?? "—") + " · " + diff.checkStatus),
515
+ diff.diff ? h("span", { style: { opacity: 0.7 } }, " " + diff.diff.changePct + "% changed (" + diff.diff.changedPixels + "/" + diff.diff.totalPixels + " px @ threshold L1>" + diff.diff.threshold + "; " + diff.diff.clusterFilter.droppedPixels + " px in sub-minimum clusters counted in verdict)")
516
+ : h("span", { style: { opacity: 0.7 } }, " " + (diff.reason ?? ""))),
517
+ (diff.diff?.clusters ?? []).slice(0, 4).map((c, i) => h("div", { key: c.x + "," + c.y, style: { opacity: 0.85 } },
518
+ "region cmp " + c.x + "," + c.y + " " + c.width + "×" + c.height + " (" + c.pixels + " px)" +
519
+ (diff.regionsA?.[i] ? " · ref-original " + diff.regionsA[i].x + "," + diff.regionsA[i].y + " " + diff.regionsA[i].width + "×" + diff.regionsA[i].height : "") +
520
+ (diff.regionsB?.[i] ? " · cap-original " + diff.regionsB[i].x + "," + diff.regionsB[i].y + " " + diff.regionsB[i].width + "×" + diff.regionsB[i].height : ""))),
521
+ (diff.env?.masks ?? []).length > 0 && h("div", { style: { opacity: 0.85 } }, "masks: " + diff.env.masks.map((m) => m.reason).join(" | ")),
522
+ h("div", { style: { opacity: 0.6 } }, diff.note ?? ""),
523
+ (diff.limitations ?? []).map((l) => h("div", { key: l, style: { opacity: 0.6 } }, "· " + l)),
524
+ diff.evidenceFile && h("div", { style: { opacity: 0.6 } }, "evidence: " + diff.evidenceFile)),
525
+ refs.length > 0 && h("div", { style: { marginTop: 6, display: "flex", flexWrap: "wrap", gap: 6 } },
526
+ refs.map((record) => h("span", {
527
+ key: record.id,
528
+ title: (record.provenance ?? "") + " · " + record.importedAt,
529
+ onClick: () => setActive(record.id),
530
+ style: {
531
+ border: "1px solid " + (record.id === activeId ? "#5aa2ff" : "#2a3340"),
532
+ borderRadius: 6, padding: "2px 8px", fontSize: 11, cursor: "pointer",
533
+ background: record.id === activeId ? "rgba(90,162,255,0.15)" : "transparent",
534
+ },
535
+ },
536
+ (record.kind === "capture" ? "📷 " : "🖼 ") + record.screen + "/" + record.stateId,
537
+ record.id === activeId && h("button", {
538
+ style: { border: 0, background: "none", cursor: "pointer", marginLeft: 6, color: "#ff7a7a" },
539
+ onClick: (e) => { e.stopPropagation(); remove(record.id); },
540
+ }, "✕"))),
541
+ ),
542
+ );
543
+ }
544
+
545
+ function PreviewGallerySection({ directory }) {
546
+ const [entries, setEntries] = useState([]);
547
+ const [warnings, setWarnings] = useState([]);
548
+ const [gradle, setGradle] = useState(false);
549
+ const [busy, setBusy] = useState("");
550
+ const [error, setError] = useState("");
551
+ const [note, setNote] = useState("");
552
+
553
+ const load = async (dir) => {
554
+ const target = dir ?? directory;
555
+ if (!target) { setEntries([]); setGradle(false); return; }
556
+ try {
557
+ const data = await apiGet("/preview/list?directory=" + encodeURIComponent(target));
558
+ setGradle(!!data.gradle);
559
+ setEntries(data.entries ?? []);
560
+ setWarnings(data.warnings ?? []);
561
+ } catch (err) { setError(err instanceof Error ? err.message : String(err)); }
562
+ };
563
+ useEffect(() => { load(); }, [directory]);
564
+
565
+ const renderOne = async (entry) => {
566
+ setBusy(entry.fqClass + "." + entry.method); setError(""); setNote("");
567
+ try {
568
+ const r = await streamPost("/preview/render", { directory, class: entry.fqClass, method: entry.method });
569
+ if (r.status === "passed") setNote("rendered " + entry.method + (r.provenance?.stale ? " (still stale?)" : ""));
570
+ else setError(entry.method + ": " + (r.failureKind ?? r.status) + " — " + (r.tail ?? []).slice(-2).join(" | "));
571
+ await load();
572
+ } catch (err) { setError(err instanceof Error ? err.message : String(err)); }
573
+ finally { setBusy(""); }
574
+ };
575
+
576
+ return h("div", { className: "mc-card" },
577
+ h("div", { className: "mc-card-head" },
578
+ h("span", null, "Preview gallery"),
579
+ h("span", { className: "sp" }),
580
+ h("button", { className: "mc-btn", onClick: () => load() }, "Refresh"),
581
+ ),
582
+ !gradle && h("div", { className: "mc-empty" }, "Pick a Gradle project directory to discover @PreviewTest composables."),
583
+ gradle && entries.length === 0 && h("div", { className: "mc-empty" }, "No @PreviewTest previews yet — apply the screenshot adapter (docs/design/render-adapter-decision.md)."),
584
+ error !== "" && h("div", { className: "mc-error" }, error),
585
+ note !== "" && h("div", { className: "mc-status" }, h("span", { style: { fontSize: 11 } }, note)),
586
+ warnings.map((w) => h("div", { className: "mc-error", key: w }, "discovery: " + w)),
587
+ entries.length > 0 && h("div", { style: { display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(120px, 1fr))", gap: 8 } },
588
+ entries.map((entry) => h("div", { key: entry.fqClass + "." + entry.method, style: { border: "1px solid var(--mc-border, #2a3340)", borderRadius: 8, padding: 6, fontSize: 11 } },
589
+ entry.rendered
590
+ ? h("img", { src: API_BASE + "/preview/image?directory=" + encodeURIComponent(directory) + "&class=" + encodeURIComponent(entry.fqClass) + "&method=" + encodeURIComponent(entry.method), alt: entry.previewName, style: { width: "100%", borderRadius: 4, display: "block" } })
591
+ : h("div", { style: { height: 150, display: "flex", alignItems: "center", justifyContent: "center", opacity: 0.5 } }, "not rendered"),
592
+ h("div", { style: { marginTop: 4, fontWeight: 600 } }, entry.previewName),
593
+ entry.rendered && entry.freshness !== "fresh_tracked_inputs" && h("span", { style: { color: "#ffb020" } },
594
+ entry.freshness === "unknown" ? "freshness unknown (no manifest)" : entry.freshness === "last_run_failed" ? "last render failed" : "stale — tracked inputs changed"),
595
+ h("button", { className: "mc-btn", disabled: busy !== "", style: { width: "100%", marginTop: 4 }, onClick: () => renderOne(entry) },
596
+ busy === entry.fqClass + "." + entry.method ? "rendering…" : "Render"),
597
+ )),
598
+ ),
599
+ entries.length > 0 && h("div", { className: "mc-status" },
600
+ h("span", { style: { fontSize: 11, opacity: 0.7 } }, "host-rendered (layoutlib) — NOT device-verified · failed/stale renders never labeled current")),
601
+ );
602
+ }
603
+
357
604
  function MobileCodePanel({ controller }) {
358
605
  const [directory, setDirectory] = useState(readDir);
359
606
  const [info, setInfo] = useState(null);
@@ -533,6 +780,8 @@ window.__ModuleLoader__.load({
533
780
  ),
534
781
  info?.servers?.length === 0 && info?.builds?.length === 0 && (info?.deviceCount ?? 0) === 0 &&
535
782
  h("div", { className: "mc-empty" }, "Nothing running yet — pick a project directory and press Run app, or boot an AVD in Device 1 above."),
783
+ h(RefCompareSection, { directory: info?.directory ?? "" }),
784
+ h(PreviewGallerySection, { directory: info?.directory ?? "" }),
536
785
  ),
537
786
  h("div", { className: "mc-footer" },
538
787
  h("span", null, "dsh-mobilecode · polls every 2s"),
@@ -0,0 +1,202 @@
1
+ /**
2
+ * dsh-mobilecode — bounded configuration matrix runner (T7m/Feature F).
3
+ *
4
+ * Drives REAL Android configuration (wm size/density overrides + font scale)
5
+ * through the existing adb plumbing, captures evidence through the
6
+ * reference-workspace capture store, and RESTORES the device to the state it
7
+ * was FOUND in — including pre-existing overrides — on success, failure and
8
+ * cancellation paths.
9
+ *
10
+ * Honesty rules:
11
+ * - requested config ≠ actual config: every case queries the device
12
+ * (wm size / wm density / settings get font_scale) and reports what it
13
+ * ACTUALLY ended up with;
14
+ * - scaling the browser pane is NOT an Android configuration change and is
15
+ * never represented here;
16
+ * - physical devices are refused without an explicit approval flag;
17
+ * - font behavior is read back from the platform — no linear px↔sp formula.
18
+ */
19
+
20
+ import { tmpdir } from "node:os"
21
+ import * as DeviceBuild from "./device-build.js"
22
+
23
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
24
+
25
+ /** emulator-* serials are safe to reconfigure; physical hardware is not, without approval. */
26
+ export function isEmulatorSerial(serial) {
27
+ return typeof serial === "string" && serial.startsWith("emulator-")
28
+ }
29
+
30
+ /** Parse `wm size`/`wm density` output into effective value + whether an override was in place.
31
+ * Keys are prefixed per-kind (sizeOverridden/densityOverridden) so spreading the two parses
32
+ * cannot clobber each other's override flag (audit P4-1: both previously used `overridden`). */
33
+ export function parseDisplaySetting(out, kind) {
34
+ const text = String(out ?? "")
35
+ const flag = kind === "size" ? "sizeOverridden" : "densityOverridden"
36
+ const override = kind === "size"
37
+ ? /Override size:\s*(\d+)x(\d+)/.exec(text)
38
+ : /Override density:\s*(\d+)/.exec(text)
39
+ if (override) {
40
+ return kind === "size"
41
+ ? { [flag]: true, widthPx: Number(override[1]), heightPx: Number(override[2]) }
42
+ : { [flag]: true, densityDpi: Number(override[1]) }
43
+ }
44
+ const physical = kind === "size"
45
+ ? /Physical size:\s*(\d+)x(\d+)/.exec(text)
46
+ : /Physical density:\s*(\d+)/.exec(text)
47
+ if (!physical) return {}
48
+ return kind === "size"
49
+ ? { [flag]: false, widthPx: Number(physical[1]), heightPx: Number(physical[2]) }
50
+ : { [flag]: false, densityDpi: Number(physical[1]) }
51
+ }
52
+
53
+ export async function readConfig(serial) {
54
+ const [sizeOut, densityOut, fontOut] = await Promise.all([
55
+ DeviceBuild.adbRun(serial, ["shell", "wm", "size"]).catch(() => ""),
56
+ DeviceBuild.adbRun(serial, ["shell", "wm", "density"]).catch(() => ""),
57
+ DeviceBuild.adbRun(serial, ["shell", "settings", "get", "system", "font_scale"]).catch(() => ""),
58
+ ])
59
+ const fontRaw = String(fontOut ?? "").trim()
60
+ const fontScale = fontRaw === "" || fontRaw === "null" ? 1 : Number(fontRaw)
61
+ return {
62
+ ...parseDisplaySetting(sizeOut, "size"),
63
+ ...parseDisplaySetting(densityOut, "density"),
64
+ fontScale: Number.isFinite(fontScale) ? fontScale : 1,
65
+ }
66
+ }
67
+
68
+ /** Put one device setting and VERIFY it took effect by reading back (bounded retries). */
69
+ async function setVerified(serial, apply, read, expect, approx = false) {
70
+ for (let attempt = 0; attempt < 3; attempt++) {
71
+ await apply()
72
+ await sleep(approx ? 600 : 300)
73
+ const actual = await read()
74
+ const ok = approx ? Math.abs(actual - expect) < 0.01 : actual === expect
75
+ if (ok) return { ok: true, actual }
76
+ }
77
+ return { ok: false, actual: await read() }
78
+ }
79
+
80
+ async function applyCase(serial, c) {
81
+ if (c.dims) {
82
+ const r = await setVerified(serial,
83
+ () => DeviceBuild.adbRun(serial, ["shell", "wm", "size", `${c.dims[0]}x${c.dims[1]}`]),
84
+ async () => { const s = await readConfig(serial); return s.widthPx },
85
+ c.dims[0])
86
+ if (!r.ok) return { ok: false, step: "size", ...r }
87
+ }
88
+ if (c.density) {
89
+ const r = await setVerified(serial,
90
+ () => DeviceBuild.adbRun(serial, ["shell", "wm", "density", String(c.density)]),
91
+ async () => { const s = await readConfig(serial); return s.densityDpi },
92
+ c.density)
93
+ if (!r.ok) return { ok: false, step: "density", ...r }
94
+ }
95
+ if (c.fontScale !== undefined) {
96
+ const r = await setVerified(serial,
97
+ () => DeviceBuild.adbRun(serial, ["shell", "settings", "put", "system", "font_scale", String(c.fontScale)]),
98
+ async () => { const s = await readConfig(serial); return s.fontScale },
99
+ c.fontScale, true)
100
+ if (!r.ok) return { ok: false, step: "font_scale", ...r }
101
+ }
102
+ await sleep(700) // reflow settle before capture
103
+ return { ok: true }
104
+ }
105
+
106
+ /** Restore to the FOUND state: re-apply pre-existing overrides, else reset. */
107
+ export async function restoreConfig(serial, snapshot) {
108
+ const problems = []
109
+ if (snapshot.widthPx !== undefined) {
110
+ try {
111
+ if (snapshot.sizeOverridden) await DeviceBuild.adbRun(serial, ["shell", "wm", "size", `${snapshot.widthPx}x${snapshot.heightPx}`])
112
+ else await DeviceBuild.adbRun(serial, ["shell", "wm", "size", "reset"])
113
+ } catch (e) { problems.push(`size restore failed: ${e}`) }
114
+ }
115
+ if (snapshot.densityDpi !== undefined) {
116
+ try {
117
+ if (snapshot.densityOverridden) await DeviceBuild.adbRun(serial, ["shell", "wm", "density", String(snapshot.densityDpi)])
118
+ else await DeviceBuild.adbRun(serial, ["shell", "wm", "density", "reset"])
119
+ } catch (e) { problems.push(`density restore failed: ${e}`) }
120
+ }
121
+ try { await DeviceBuild.adbRun(serial, ["shell", "settings", "put", "system", "font_scale", String(snapshot.fontScale ?? 1)]) } catch (e) { problems.push(`font_scale restore failed: ${e}`) }
122
+ await sleep(700)
123
+ const after = await readConfig(serial)
124
+ const ok = problems.length === 0 &&
125
+ (snapshot.widthPx === undefined || (after.widthPx === snapshot.widthPx && after.heightPx === snapshot.heightPx)) &&
126
+ (snapshot.densityDpi === undefined || after.densityDpi === snapshot.densityDpi) &&
127
+ Math.abs((after.fontScale ?? 1) - (snapshot.fontScale ?? 1)) < 0.01
128
+ return { ok, problems, actualAfterRestore: after, snapshotWas: snapshot }
129
+ }
130
+
131
+ /**
132
+ * Run a bounded matrix (1..8 cases — explicit expansion is the user's call,
133
+ * no uncontrolled Cartesian product). Evidence goes through the capture store.
134
+ */
135
+ export async function runMatrix({ serial, workspace, project, screen, cases, signal }) {
136
+ if (!isEmulatorSerial(serial)) {
137
+ return { status: "blocked", reason: "physical-device reconfiguration requires explicit approval; refusing", serial, reports: [] }
138
+ }
139
+ if (!Array.isArray(cases) || cases.length === 0 || cases.length > 8) {
140
+ return { status: "blocked", reason: "matrix must declare 1..8 risk-based cases", reports: [] }
141
+ }
142
+ const before = await readConfig(serial)
143
+ // parse keys are already per-kind (sizeOverridden/densityOverridden) — no re-derivation
144
+ const snapshot = { ...before }
145
+ const reports = []
146
+ const warnings = []
147
+ let restoreResult = { ok: false, problems: ["not run"], actualAfterRestore: {} }
148
+ try {
149
+ for (const c of cases) {
150
+ if (signal?.aborted) { reports.push({ id: c.id, status: "not_run", reason: "cancelled before case" }); continue }
151
+ try {
152
+ const applied = await applyCase(serial, c)
153
+ if (!applied.ok) {
154
+ reports.push({ id: c.id, status: "needs_review", step: applied.step, requested: c, actual: await readConfig(serial), warning: "setting did not verify — device reports a different actual configuration" })
155
+ continue
156
+ }
157
+ const actual = await readConfig(serial)
158
+ const png = await DeviceBuild.screenCapture(serial, tmpdir())
159
+ const evidence = png ? workspace.capture({ project, screen, stateId: c.id, pngPath: png, device: { serial, ...actual } }) : undefined
160
+ reports.push({
161
+ id: c.id,
162
+ requested: { dims: c.dims, density: c.density, fontScale: c.fontScale },
163
+ actual,
164
+ evidenceId: evidence?.id,
165
+ evidenceFile: evidence?.file,
166
+ status: evidence ? "captured" : "failed",
167
+ ...(evidence ? {} : { error: "no screenshot produced" }),
168
+ })
169
+ } catch (error) {
170
+ reports.push({ id: c.id, status: "failed", error: String(error instanceof Error ? error.message : error) })
171
+ }
172
+ }
173
+ } finally {
174
+ restoreResult = await restoreConfig(serial, snapshot)
175
+ if (!restoreResult.ok) warnings.push(`device settings NOT fully restored — actual after restore: ${JSON.stringify(restoreResult.actualAfterRestore)} problems: ${restoreResult.problems.join("; ")}`)
176
+ }
177
+ return {
178
+ status: signal?.aborted ? "blocked" : "completed",
179
+ serial,
180
+ snapshotBefore: snapshot,
181
+ reports,
182
+ // audit P4-4: restore travels as a real envelope field, not an array property
183
+ // (reports.restore was dropped by JSON serialization)
184
+ restore: restoreResult,
185
+ warnings,
186
+ limitations: ["theme light/dark and orientation rows are app-owned capabilities — this runner covers size/density/font-scale only", "font scale read back from the platform; no px↔sp formula applied"],
187
+ }
188
+ }
189
+
190
+ /** Risk-based starter matrix for a phone app from the device's actual state. */
191
+ export function starterCases(actual) {
192
+ const density = actual.densityDpi ?? 420
193
+ const d = density / 160
194
+ const heightPx = actual.heightPx ?? 2400
195
+ const widthPx = actual.widthPx ?? Math.round(411 * d)
196
+ const narrowW = Math.round(320 * d)
197
+ const narrowH = Math.round((heightPx * narrowW) / widthPx)
198
+ return [
199
+ { id: "narrow-320dp-fs1.0", dims: [narrowW, narrowH], density, fontScale: 1 },
200
+ { id: "base-fs2.0", dims: [widthPx, heightPx], density, fontScale: 2 },
201
+ ]
202
+ }