dsh-mobilecode
已验证dsh-mobilecode · v0.13.1 · MIT · Web 界面
MobileCode for the dsh web GUI: detect iOS/Android projects, run preview servers, and drive the simulator/emulator from the session — 41 agent tools (device_run, device_screen, device_ui_tree, device_scene, device_ui_rows, device_tap_row, device_tap_eleme
安装
dsh plugin add dsh-mobilecode 用 dsh --profile default --dump-config 确认 layer 已生效 —— 参见安装指南。
源码
标签
作者
说明文档
dsh-mobilecode
MobileCode for the dsh web GUI — a hot-pluggable DSH plugin porting hsandhu/mobilecode (the opencode fork with embedded iOS Simulator / Android Emulator support) into DeepSeek Harness.
It detects the mobile project in a directory (iOS / Android, Expo / React
Native / native), builds, installs and launches the app on the booted
simulator or emulator — and exposes the same to the agent through tools. The
device screen has exactly one home in the panel: the Device 1 live
stream (co-op adds a second pane, Device 2). (0.10.0 merged the old Android
"Start server" preview into it — serve-avd is retired for the panel;
serve-sim remains the iOS-only view.)
Installation
dsh plugin --profile web add dsh-mobilecode
(For a fresh profile or when the plugin was added while DSH was running, restart the web GUI — host modules bind at startup and the client bundle is served from the installed package.)
Support
If you find this useful, you can support development at ko-fi.com/spix18.
What the plugin provides
Device pane (web GUI) — a sidebar entry ("Devices") opening a right-hand drawer:
- project directory input (persisted in localStorage) + Detect
- platform pills — Android: attached-device status (its view is the Device 1 card); iOS: preview-server status. The iOS pill and card only render when the host OS is macOS — on Windows/Linux there is no simulator to drive, so 0.11.0 stopped showing that dead affordance.
- per platform: Run app / Stop app, build status/step/error and an expandable log tail. iOS additionally Start/Stop server with the embedded serve-sim iframe — Android's duplicated serve-avd preview was merged into the Device 1 card in 0.10.0
- a Metro (bundler) status card
- polls
GET /api/dsh-mobilecodeevery 2 s while open - keyboard-operable and resizable without a mouse (0.12.1): the drawer is a
named
complementarylandmark, its drag handle is a focusablerole="separator"carryingaria-valuenowthat answers ←/→ and resets on Home, and every scrollable log tail is reachable with Tab
Agent tools
device_run— actionrun(build+install+launch, then waits with a configurable timeout),stop(cancel/terminate), orstatus. Platformios|android|all. Returns framework, platforms, per-platform build summaries (status, step, target, appID, error, log tail — logs only when failed or incomplete), the Metro state, andcomplete.device_detect— which platforms a directory supports, the framework, and the first attached Android device.- A bundled
device-ui-automationplaybook skill (registered throughctx.skills.register(), defensively — hosts without the skill service just skip it): the observe-once → act-with-an-assertion → observe-again workflow, which observer to reach for first, how to confirm an action landed viaexpect_text/expect_gone, and the real-device safety rules. device_screen— see what is on an attached Android device: a PNG screenshot plus the uiautomator UI hierarchy (text + pixel bounds) and local PaddleOCR text recognition (text + confidence + box), so an agent can read the screen and tap by coordinates. Also returns the foreground activity and screen size.device_ui_tree— the default screen observer: the uiautomator hierarchy as a typed node tree (type/text/contentDesc/resourceId/bounds, withenabled/focused/clickable/scrollableemitted only in their interesting state), case-insensitivefilterthat keeps ancestors of matches,max_depth, and a 40 KB cap that prunes the deepest levels first. Resource-ids are the most stable tap handles.device_scene— sight of the screen for design work, and the one observer a text-only model can read (0.13.0). The tree says where things are and what they are called; the pixels say what they look like; neither answers a design question alone, so it joins them on bounds and returns one document:STRUCTURE(per node: bounds in px and dp, background, radius, border, elevation, text colour and its contrast ratio),SCALES (measured, not declared)— the real palette, corner radii, text inks, glyph heights and the spacing rhythm, tallied by usage — andISSUES: touch targets under 48dp, clickables nothing announces a label for, text below 4.5:1 (3:1 only for a clearly large glyph), clipped labels. Style is reported as a diff against the parent, because a card repeating its parent's background is not a design decision, it is the absence of one. Every number is measured from pixels, so it works on any app — no APK, no resource introspection, no debug build.device_tap_element— tap a control by identity:resource_idmatches the node's resource-id,textmatches its text or content-desc; exact match wins over substring, nested duplicates collapse to the outermost control, ambiguity lists up to 8 candidates instead of guessing, and disabled / off-screen nodes are refused with the fix.expect_text/expect_goneverify the tap in the same call — one round trip, no separate screenshot.device_wait_for— wait for text to appear or disappear: polls the UI tree every ~600 ms and falls back to local PaddleOCR on textless surfaces (WebView / Compose / canvas). A timeout is a normalmatched: falseresult, never an error — one call replaces an agent-side poll loop.device_input— act on the device: tap / swipe / type / press a key at absolute pixel coordinates (the same spacedevice_screenreturns — take the box centerx=(x1+x2)/2, y=(y1+y2)/2). The deterministic control loop isdevice_ui_tree → device_tap_element, falling back todevice_screen → device_inputwhen a surface exposes no accessibility tree. Typing is ASCII over plain adb; non-ASCII (CJK, emoji) is routed through the ADBKeyboard IME when installed and refused with the install hint otherwise.device_action— device-level verbs beyond touches:notifications,quick_settings,collapse,lock,wake,assistant,rotate(cycles 0→90→180→270 and pins auto-rotate off).device_boot/device_shutdown— boot an AVD by name and wait until it finishes booting (adopts a running emulator for the same AVD) / shut an emulator down (adb emu kill; refuses physical devices).device_apps/device_launch_app— list installed packages (third-party by default) so a package name is never guessed / launch one by package or a unique substring, withrelaunchfor a cold start.device_intent— open anything by Android intent: anaction(e.g.android.settings.WIFI_SETTINGS), a deep-linkuri(geo:,https://,file://), or an explicitcomponent(pkg/.Activity). Reaches screens no tap can address. Values are single-quoted for the device shell (apostrophes escaped, control characters refused), so metacharacters stay inert.device_stream— drive the live screen stream the panel shows:startan online device (returns a signedstreamUrland anandroid-streampresentationMetathat renders a compact conversation card and auto-opens the panel),status, orstop. Streams are per-serial and independent since 0.9.0 — start both players of a co-op game andstatuslists every live stream (serials);stopends one (withserial) or all of them. Agents that just need to see the screen should still preferdevice_screen/device_ui_tree.
Live device stream (the panel — Device 1 / Device 2)
The Devices pane shows a real-time mirror of the attached device. The primary card is Device 1; co-op docks a second, Device 2. It is produced in-process — no inner loopback port, no external helper:
- ONE persistent
adb exec-out "while :; do screencap -p; done"child per streamed device runs at ~8 fps with zero per-frame process cost (spawning adb per frame caps at ~5 fps and 100% churn). A quote-safe PNG splitter cuts the concatenated output into frames by walking chunk headers (no marker scanning, no false positives). - The browser
<img>reads amultipart/x-mixed-replacebody served straight from the latest-frame buffer. Backpressure is latest-wins: a slow tab skips frames instead of building an unbounded queue or watching a growing delay. - Each stream owns its consumer refcount + idle timeout (nobody watching that device → its child stops) and crash keep-alive. Since 0.9.0 starting device B never retires device A's child — co-op streams are fully independent, and a frame from one device can never leak into the other viewer's pipe.
- Tap or drag directly on the screen to drive the device: a short press is a tap, a long drag becomes a swipe carrying the real press duration (clamped 0–5000 ms), coalesced into one control call. A floating pill toolbar of SVG icons (Back / Home / Recents / Screenshot / Rotate / Refresh) sits over the stage, and a ☰ device menu adds notifications / quick settings / collapse / lock / wake / assistant.
- The header device picker groups online devices (🖥 emulator / 📱 physical,
streaming badge) — each online emulator row carries a ⏻ off power button
(
POST /off→adb emu kill; physical devices are refused) — and lists stopped configured AVDs with a one-click ⏻ boot button (POST /boot, the merged device-start action from the retired Start-server flow: validate → spawn detached → adopt if already running → wait boot complete → the card streams it immediately); a running AVD appears only as its online row, never a duplicate boot entry. A Live badge shows the streamed serial. Quick sizes (Fit / 100% / S·240 / M·320 presets + a select) and frame styles (none / bezel / device) reshape the stage — each stream pane carries its own copy of these controls (0.11.2). In Fit the stage hugs the device's real aspect — a poller reads the MJPEG<img>'snaturalWidth/naturalHeight, sets--mc-ar-non the stage, and the stage box becomeswidth: min(100%, 60vh · ar); aspect-ratio: ar— no letterbox bars, and two devices with different aspect ratios keep equal visible height (0.11.5). - Screenshot captures a still via
POST /stream/still(a realscreencap, embedded as a data URL up to 4 MB) and flips the stage to a still view with a "back to Live" link. - Co-op split view (⧉, v0.9.0): with two or more online devices the card
header grows a ⧉ toggle that docks a second Device 2 pane beside it —
its own device select, live
<img>, tap/drag control, its own Size/Frame row (0.11.2), and since 0.11.5 the same nav toolbar (Back/Home/Recents/ Rotate/Refresh) and ☰ device-actions menu as Device 1. The section is a responsive CSS gridrepeat(auto-fit, minmax(min(250px, 100%), 1fr)), so dragging the drawer narrow stacks the panes vertically and widening it puts them side by side (0.11.5). Each pane grants its own HMAC capability; closing one pane never stalls or disturbs the other stream. A presence watcher polls device liveness while streaming: if a device is powered off (⏻ off, crash, unplug) its pane drops the frozen frame and the Live badge within ~5 s, and stays idle instead of silently grabbing another device — both panes guard against the re-grab (0.11.3–4). Both stream captions carry the running client version (· v0.11.x) so a page refresh is visibly proven. - Security: every stream route sits behind a loopback + trusted-browser
transport fence (peer address, loopback
Host,Sec-Fetch-Site/Origin— so a LAN client cannot spoof localhost and a DNS-rebindingHostis rejected), and the stream URL is an HMAC-SHA256 capability signed with a per-install key (~/.dsh/mobilecode/stream-access.key,0600), expiring within 10 minutes and re-minted automatically. Coordinates are normalized 0..1 of the streamed frame, so one mapping serves every rotation.
Multimodal screenshots
When the routed model declares image input, device_screen delivers the
screenshot as an image block — the model literally sees the screen instead of
reading a file path. This mirrors the in-tree read_image tool: the PNG is
committed to DSH's durable attachment store (ctx.get('attachments').saveImage)
and returned as a {type:'image', attachment} content block, gated on
llm.resolveModelInfo(...).inputModalities. It degrades, never refuses: a
text-only route, a headless profile, or a host without the attachment store keeps
the plain JSON summary (path + UI tree + OCR) with no new error.
device_log— device logs: logcatmain/crash/events/kernelbuffers (kernel = dmesg, needs adb root — works on emulators) with an optional case-insensitive substring filter, capped line count.device_status— one normalized snapshot: attached devices, configured AVDs, emulator binary, and what the plugin currently runs (preview servers, Metro, builds per directory).
Device-ultimate port (v0.6.0) — tools ported from dsh-adb-ultimate, rebuilt on the classified adb boundary (argv-based, quoted, replay-safe):
device_scroll_to— scroll until an element (resource_idortext/ content-desc, exact then substring) comes into view, then report it with its center tap point. Completes the control loop for long lists:device_wait_forwaits without scrolling, this brings the element on-screen so a follow-updevice_tap_elementcan hit it. Defaults to 8 swipes, swipe directionup/left.device_connect— attach a Wi-Fi device:adb connect <host>:<port>(default port 5555); passpairing_code+pairing_port(default 37000) for the first-time Android 11+ "Pair device" flow. Reattaching a known device needs no code.device_pair_qr— the full pairing journey in one call: generates aWIFI:T:ADB;S:...;P:...;;QR string (render it with any QR generator and scan with Settings → Connected devices → Pair by QR), pollsadb mdns services(adb ≥ 31) for_adb-tls-pairing._tcp, then auto-pairs and connects.device_perf— one-shot RAM / battery / CPU snapshot (meminfo +dumpsys battery+ cpuinfo → used %, level/temp/status/health, cores).device_app_info— per-app detail fromdumpsys package: versionName/ versionCode, minSdk/targetSdk, requested permissions, exported activities.device_install/device_uninstall— sideload a local APK (-r -g: replace + grant runtime permissions) / remove an app (optionally-kkeep data). Uninstall is destructive.device_reboot— reboot intonormal/recovery/bootloader(the device drops offline and comes back in a minute or two).
Row & memory tools (v0.7.0) — ported from ZSeven-W/dsh-android:
device_ui_rows— read a list as rows, not a tree: clusters repeated sibling nodes of similar height (≥3 rows; page-sized containers and off-screen nodes excluded), aggregates each row's label, and parses counters (3万,1.2k,42 items) into{key, value, raw}. Returns{rows: [{index, group, frame, label, counters}], omittedOffscreen}— the compact handle for "tap row 7 of this list" without reading a 40 KB UI tree. Optionalfilterkeeps rows whose label contains a substring.device_tap_row— tap rowindex(fromdevice_ui_rows) at row-relative fractionsx/y(default 0.5,0.5 = center).expect_count {key, delta}turns the tap into one verified round trip: the counter must already be visible in the row before the tap (refused otherwise — never probe an unknown control), and after an 800 ms settle the same row must show the count moved by exactlydelta(default +1) with the row not having scrolled.device_meminfo— a running app's memory profile fromdumpsys meminfo: TOTAL PSS/RSS/Swap-PSS, the App Summary heap breakdown (Java/Native/Code/Stack/Graphics) and the top PSS categories. Throws when the package has no running process.device_backtrace— a thread/crash dump without a debugger: sends SIGQUIT (kill -3), waits for ART to write the trace, reads the newest/data/anrentry, and falls back to the logcat crash buffer when/data/anris unreadable (engine: "anr-trace" | "logcat-crash"). SIGQUIT refusal (system-uid or non-debuggable process) degrades to the crash buffer with an explanatory note instead of failing. Passpackage_nameorpid.device_display— read or change a device's DPI and resolution (wm density/wm size):getreports physical + override values,setoverrides density and/orwidthxheight,resetrestores. Layouts reflow instantly — re-observe withdevice_screenafter a change.device_avd_create— create a second virtual device viaavdmanager;clone_fromcopies an existing AVD's full hardware config (same image, DPI, RAM) so both players behave identically. Boot the new AVD withdevice_boot(a running emulator holds 5554, so device #2 lands onemulator-5556).device_batch— fire 1..16 input actions concurrently across devices (each step carries its own serial + the same fieldsdevice_inputtakes). The co-op primitive for "both players press attack on the same frame"; a failing step never blocks the others, and per-step results say what landed.device_pair_capture— capture both devices at the same instant: one call, parallel screenshots + UI digests, both attached as real image blocks so a vision model watches both players at once.ocr: trueadds PaddleOCR.
Co-op mesh (v0.8.0) — two emulators cannot multicast-discover each other
(each sits behind its own slirp NAT), but every one of them reaches the host at
10.0.2.2. So the plugin hosts a LocalSend-style JSON pub/sub hub: games
join it and talk through it, and the AI model reads and steers the same wire.
- Identity:
POST /mesh/join {serial?, name?, role?}— the serial pins a stable, random, LocalSend-style callsign (amber-fox,brisk-owl…); the join returns a per-process token that authorizes every later call. - Sessions:
POST /mesh/link {with: [names…]}(2–8 members), thenPOST /mesh/send {session, body}andGET /mesh/poll?id&token&after&wait(long-polls up to 30 s).GET /mesh/peersshows the roster;POST /mesh/leavedeparts. Everything is JSON, 64 KiB per message. - Network conditions: each session carries a policy —
latencyMs,jitterMs,dropPct,dupPct,throttleKbps(size-proportional delay) — enforced per recipient by the hub. This is desync testing without touching app code: make player B lag 400 ms with 100 ms jitter and watch the pair cope. - Agent side:
mesh_status(who joined, sessions, policies, undelivered mail),mesh_send(ghost a message as any peer — the other game receives it as if its partner sent it),mesh_log(every join/link/send/drop/dup/tune event, timestamped),mesh_tune(set the policy),mesh_reset. - Security: the routes are loopback-only (emulators reach the host loopback
via slirp); a request presenting browser headers (
Origin/Sec-Fetch-*) must additionally pass the trusted-browser stream fence, so a random web page can never join a game or read its messages. - Client SDK:
sdk/mesh-client.mjs— a ~90-line dependency-freefetchclient (join/link/send/inbox) for Node/Deno/Bun/WebView/React-Native game code; plain HTTP works from any other engine (Unity, Godot, Kotlin).
Typical loop: device_avd_create + device_boot a clone → both apps
join → mesh_link → device_batch inputs at both → device_pair_capture
to watch → mesh_log to see the traffic → mesh_tune to inject real-world
network pain. For the human at the keyboard: open the panel and hit ⧉ — the
Devices pane mirrors both devices live, side by side, tappable (v0.9.0).
Conversation surface (v0.7.0) — the transcript integration ported from dsh-android's UI/UX:
- A settled
device_stream/device_bootcall renders a compact card in the conversation (kind, serial, streaming/failed badge, "⤢ open in panel") instead of a raw JSON blob, and auto-opens the Devices panel once when a stream first settles. The card hydrates from the host-projectedpresentationMetaon top-level calls and falls back to parsing the serial out of the durable result text for nested calls; it never crashes on a shape it does not recognize. - A composer capsule — a green
● <serial>pill in the input dock — shows while a device streams and the panel is closed; clicking it opens the panel.
First-run experience & settings
- On first start after installation, a welcome window explains how to use the
plugin, shows a copy-paste prompt for any AI model (tells it about
device_detect/device_run/device_screen/device_input/device_log/device_statusand the recommended control loop), and offers a one-click PaddleOCR install —device_screenreads on-screen text with fully local OCR. The plugin works without it, just without OCR text. - A ⚙ Settings button in the Devices pane header opens the settings dialog
with three tabs:
- Doctor — runs every health check (node, Android SDK, emulator binary, AVDs, attached device, PaddleOCR, OCR script) with ✓/✗ + details, and a Fix button for auto-fixable checks (PaddleOCR install).
- PaddleOCR — install status, progress log, and the install button.
- AI Prompt — the copyable agent prompt.
- Connection — the resolved adb binary path and every device
adbsees (serial, state dot, model, USB / Wi-Fi badge), straight fromGET /connection. A connect form (device IP + port, optional pairing code + pair port →POST /connect) and a Pair by QR button (POST /pair-qr, one blocking call that generates theWIFI:T:ADBQR, waits for the mDNS scan and auto-pairs + connects) plus an in-line refresh. Explains the Wi-Fi reconnect policy.
- The same settings appear as a
MobileCodepage in the DSH Settings (registered as asettings.sectionslot, like the other installed plugins), so they are reachable from Settings even when the Devices pane is closed. - State lives in
~/.dsh/mobilecode/(settings.json, ocr-venv, install log, OCR-INSTALL.md).
HTTP API (loopback-only, consumed by the pane) — GET /api/dsh-mobilecode
(info) and POST /api/dsh-mobilecode/{start,stop,run,run/stop,focus} with a
directory + platform body, mirroring mobilecode's server.devicePreview
group — plus setup endpoints: GET /welcome, POST /welcome/dismiss,
GET /doctor, POST /doctor/fix {id}, GET /ocr, POST /ocr/install,
GET/POST /settings, GET /connection — plus the Wi-Fi actions
POST /connect (host/port/pairing code) and POST /pair-qr (mDNS QR flow) —
plus the live-stream routes GET /stream/status, POST /stream/grant,
POST /stream/control (tap / swipe / key / long-press, coalesced),
POST /stream/devices (online devices and configured AVDs),
POST /boot {avd} (the panel's merged one-click AVD boot — validated name,
adopt-if-running, detached spawn, resolves once boot-completed),
POST /stream/still (one screencap as a data URL up to 4 MB),
POST /stream/device-action (the device_action verbs over the panel fence)
and GET /stream/{token} (the multipart frame body) — plus the co-op mesh
routes POST /mesh/join, GET /mesh/peers, POST /mesh/{link,send,leave},
GET /mesh/poll (see Co-op mesh above). Every stream error carries
a machine-readable code next to the HTTP status — token_invalid,
stream_not_running, stream_start_failed, devices_unavailable,
device_not_found, device_offline, unknown_action, bad_request,
control_failed, capture_failed, device_action_failed — so the panel can
say why instead of "something failed".
One classified adb boundary — every serial-targeted adb command runs through
adbRun() in lib/device-build.js:
- A transport failure (
device not found/offline/unauthorized/closed/no devices) on a Wi-Fi serial (ip:port) gets exactly ONEadb connectretry. Read-only commands (thereplaySafeAdballowlist:exec-out/screencap/uiautomator/dumpsys/getprop/logcat/cat/…) then replay automatically; side-effectful ones never do — a replayed tap could double-tap — they raise "reconnected — call again" instead. - USB serials and non-transport failures (a real
am starterror, aSecurityException) surface as classified, actionable errors — the old silentcapture()→""swallowing is gone on agent-facing paths. - Tolerant internal callers (boot polling, IME checks) opt back into
best-effort with an explicit
.catch(() => "").
Design workbench (0.12–0.13 — native Android UI design & verification)
Reusable capabilities for the loop explore visual directions → approve a design → implement native Compose → render → compare → test on Android → refine. App-specific decisions (learning flow, approved directions) live in the app project, never hardcoded here.
- Reference workspace (
preview_gallery,config_matrix,design_bridgetools +/api/dsh-mobilecode/refs|preview|matrix|openpencil/*routes) — import PNG/JPEG/WebP design references with metadata (project, screen, state, provenance, approval status); associate device captures (reuses the existing screencap path; originals stored verbatim, alignment transforms kept separate); persisted under~/.dsh/mobilecode/reference/across reloads. Header-only decompression fences (no pixel decode server-side, no new dependency). - Visual diff (
POST /refs/diff) — two modes, never conflated:regression(any changed pixel fails against a recorded native baseline) anddesign-reference(structure comparison, alwaysneeds_review— the tool ran; that is not a visual pass). Client canvas downsamples; the server reports concrete changed-region clusters; envelopes persist as evidence files. No quality scores anywhere. - Compose preview gallery — discovers
@PreviewTestcomposables of a Gradle project and renders them through official Compose Preview Screenshot Testing (com.android.compose.screenshot, pinned 0.0.1-alpha16, host layoutlib — works on AGP 8.5+ without toolchain migration; compose-ai-tools needs AGP 8.13 and is documented as a blocked alternative). Failed/stale renders are never labeled current; host-rendered ≠ device-verified (seedocs/design/render-adapter-decision.md). - Configuration matrix (
config_matrixtool) — bounded (1..8) risk-based matrices over real device settings (wm size/density, font scale): every case reads back its ACTUAL configuration, captures evidence, and the device is restored to its found state (including pre-existing overrides) on success, failure and cancellation. Physical devices are refused without explicit approval. - OpenPencil bridge (
design_bridge, optional) — file-mode CLI adapter: inspect.figdocs, export a frame to PNG into the reference workspace, read design variables. Path-confined (realpath + junction-aware); when the CLI is absent everything degrades to honestnot_run— the workspace stays fully usable with manual imports. OpenPencil exports are web-oriented (PNG/JSX/HTML); no native Compose export is claimed. - Deep perception (
device_scene, 0.13.0) — the design loop needs sight of a real screen, and a text-only model cannot look at a screenshot. This joins the uiautomator tree (where things are, what they are called) to the pixels (what they look like) on bounds, and returns one readable document: measured backgrounds, radii, borders, elevations, text colours with their contrast ratios, the palette / radius / glyph / spacing rhythm tallied by usage, and the problems worth fixing. Every number is measured from pixels — no APK, no resource introspection, no debug build — so it works on any app, including Compose. Colours are WCAG 2.x exactly. Two limits are documented rather than hidden: a gradient fill is indistinguishable from a shadow, and a hard-edged corner resolves to about ±2px (flagged?in the output). - Panel — the Devices drawer gains two cards: Reference compare (import / capture / side-by-side / opacity overlay / swipe slider / diff) and Preview gallery (discovery, thumbnails, stale badges, per-entry render).
How it works
lib/device-build.js— port ofmobilecode/packages/core/src/device-build.ts: bounded project discovery (2-level walk, SKIP set), framework detection, preflight (xcodebuild / pod / Android SDK / Java / gradle wrapper), Expoapp.jsonid injection andprebuild, Metro port/status helpers, iOS target/build-arg resolution, Android SDK/adb/aapt2/gradle helpers.lib/device-preview.js— port ofmobilecode/packages/core/src/device-preview.tswith the Effect runtime dropped: a plain asyncDevicePreviewEngineclass holding the sameservers/builds/bundlersmaps and the same lifecycle (park → halt → settle → execute; one project at a time; process exit/signal hooks; win32 taskkill tree-kill).lib/index.js— host half: engine,/api/dsh-mobilecode/*routes (run controls + welcome / doctor / ocr / settings + mesh), agent tools, system-prompt guidance section,ctx.provide('mobilecode', handle).lib/mesh-hub.js— the co-op mesh core: peer identity (serial-pinned callsigns + HMAC tokens), sessions, per-session network policy (latency/jitter/drop/dup/throttle), a long-poll inbox, and an event log. Pure + injectable clock/RNG, sotest/mesh-hub.mjscovers it offline.sdk/mesh-client.mjs— dependency-freefetchclient (join/link/send/ poll/inbox/leave) for game code running inside the emulators.- Design workbench modules:
lib/reference-workspace.js(reference/capture store + header-only image fences),lib/visual-compare.js(pure RGBA diff core — the server never pixel-decodes),lib/preview-gallery.js(@PreviewTest discovery + Gradle render driver with timeout tree-kill),lib/config-matrix.js(bounded matrix runner, snapshot→apply→confirm→ capture→restore on every path),lib/openpencil.js(optional CLI bridge, realpath/junction-confined). lib/setup.js— settings store (~/.dsh/mobilecode/settings.json), the plugin doctor (health checks + auto-fix), and the detached PaddleOCR installer (writes an install script to disk and spawns it via cmd.exe, so the install survives GUI restarts and its progress is pollable through/ocr).lib/client.js— browser half:window.__ModuleLoader__.load({id, factory})bundle (the only client bundle format the web shell materializes) mounting the sidebar entry, the drawer with DOM-level self-healing, the first-run welcome modal, and the settings dialog (doctor / PaddleOCR / AI prompt), like dsh-logcat.cordis.patch.yml+package.json(dsh.bundle.patch,dsh.client.inject) make it a hot-pluggable profile bundle.
No runtime dependencies — the port replaces cross-spawn (not resolvable
from the profile node_modules tree) with a ~40-line launch() helper that does
PATHEXT lookup and spawns .cmd/.bat through cmd.exe /d /s /c with
double-wrapped quoting, and replaces the Effect/Schema types with plain JS.
Install / mount
dsh plugin --profile web add dsh-mobilecode
The package ships with cordis.patch.yml + package.json (dsh.bundle.patch,
dsh.client.inject) so it mounts as a hot-pluggable profile bundle with no
manual file copying. Restart the GUI to load the host half; refresh the browser
to load the client bundle (/plugins/dsh-mobilecode/client.js — the URL id is
the package name, not the patch row id).
Verify
node test/all.mjs # the whole offline suite (21 files, 369 checks)
node test/smoke.mjs # engine unit smoke test (15 checks)
node test/e2e-android.mjs <dir> # full build+install+launch on a real device
test/all.mjs runs an explicit allowlist of the files that need no device; the
device-dependent ones (smoke, e2e-android, observability, agent-tools,
tool-run) hang without an emulator, which is why the list is explicit rather
than "everything except these". It forces DSH_MOBILECODE_DIR to this repo —
without that, selfcheck silently verifies the installed copy instead of the
working tree. test/mutate.mjs is the sharpest of them: it copies the source,
breaks one guard at a time, and requires the test to go red on the right
assertion — a guard that is green on the real source proves nothing.
The e2e run performs a Gradle assembleDebug, reads the app id with aapt2,
adb install -r -g, and am start on the first attached device, then quits
the app.
Notes
PaddleOCR (optional, powers
device_screen's OCR) — fully local, no network at inference time. One-time install into the shared venv the plugin looks for (DSH_MOBILECODE_OCR_PYenv override wins, then~/.dsh/mobilecode/ocr-venv):py -3.12 -m venv $env:USERPROFILE\.dsh\mobilecode\ocr-venv & $env:USERPROFILE\.dsh\mobilecode\ocr-venv\Scripts\python.exe -m pip install setuptools wheel "numpy<2" "paddleocr==3.7.0" "paddlepaddle==3.3.1"Paddle 3.x on Windows crashes in the oneDNN PIR executor, so
ocr.pyalways constructs PaddleOCR withenable_mkldnn=False— with that, 3.7.0 works and its PP-OCRv6 models read text more accurately than the old 2.7.3 pin. Without the venv,device_screenstill returns the UI hierarchy and screenshot; only OCR is skipped with a clear note.iOS support (xcodebuild / simctl / serve-sim) is darwin-gated exactly like mobilecode:
findProjectsonly walks for iOS on macOS, and the run pipeline resolves Xcode targets on demand. On Windows the pane shows Android only.The engine keeps one project at a time: switching to another directory parks the previous project's live apps (and its Metro), and switching back revives them (
focus).Agent tools take an explicit
directory(default: plugindefaultDirectoryconfig or the host cwd) because DSH tools have no session-location concept.Preview URLs are embedded in an iframe; serve-* must not send an
X-Frame-Options: DENYheader for the stream to render inside the pane.