Skip to content

feat(mirror): add Device Mirror host plugin - #149

Open
kitakkun wants to merge 17 commits into
mainfrom
feature/simulator-mirror-plugin
Open

feat(mirror): add Device Mirror host plugin#149
kitakkun wants to merge 17 commits into
mainfrom
feature/simulator-mirror-plugin

Conversation

@kitakkun

Copy link
Copy Markdown
Owner

Summary

Adds a new host-only plugin jetwhale-plugins/mirror/host (Device Mirror, requiresAgent: false) that mirrors Android emulator/device and booted iOS simulator screens, and lets both the user and an AI agent drive the device.

Mirroring

  • Screenshot poll loop (~350ms) via adb exec-out screencap -p (Android) / xcrun simctl io <udid> screenshot (iOS), decoded with Skia and rendered in Compose.
  • Device list auto-refreshes every 3s from adb devices -l + simctl list devices booted -j.

Interactive controls

  • Click-to-tap and drag-to-swipe directly on the mirrored frame (display coordinates are converted to device pixels).
  • Buttons: save screenshot, Home / Back / Power / Volume ± (iOS shows only its supported buttons), plus a text-send field.

MCP tools (prefix com.kitakkun.jetwhale.mirror)

listDevices, captureScreenshot (returns PNG path + pixel dimensions), tap, swipe, pressButton, inputText — all coordinates share the screenshot pixel space, so an agent can capture, inspect, and act. Implemented with the delegated-property parameter DSL, one command per file.

Notes

  • iOS input (tap/swipe/text/buttons) requires idb; without it those operations fail with an actionable install message (mirroring itself works without idb). Pixel→point conversion is taken from idb describe density.
  • adb path discovery mirrors the host's AdbUtil (the SDK does not expose host internals to plugins).

Test plan

  • :jetwhale-plugins:mirror:host:build passes (also after rebasing onto latest main)
  • Packaged plugin jar contains plugin-manifest.json, icons, and the factory class
  • Manual: run runJetWhaleLocal with a booted emulator/simulator and exercise mirror + controls

Copilot AI review requested due to automatic review settings July 18, 2026 20:46

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds a new host-only Device Mirror plugin module under jetwhale-plugins/mirror/host that mirrors Android devices/emulators and booted iOS simulators in the JetWhale host UI, and exposes MCP tools to let an agent capture screenshots and drive basic input.

Changes:

  • Introduces a new host plugin module (:jetwhale-plugins:mirror:host) with Compose UI for mirroring and interactive controls.
  • Adds MCP tools under com.kitakkun.jetwhale.mirror.* for listing devices, capturing screenshots, and sending input (tap/swipe/button/text).
  • Implements device discovery + platform-specific controllers (ADB for Android, xcrun simctl + optional idb for iOS input), plus plugin packaging resources (manifest + icons).

Reviewed changes

Copilot reviewed 14 out of 16 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
settings.gradle.kts Registers the new :jetwhale-plugins:mirror:host module in the Gradle build.
jetwhale-plugins/mirror/host/build.gradle.kts Defines plugin module build, packaging/publishing metadata, and dependencies.
jetwhale-plugins/mirror/host/src/main/resources/META-INF/jetwhale/plugin-manifest.json Declares the plugin ID, factory, version, and icons for host discovery.
jetwhale-plugins/mirror/host/src/main/resources/icons/mirror_outlined.svg Adds inactive icon asset for the plugin.
jetwhale-plugins/mirror/host/src/main/resources/icons/mirror_filled.svg Adds active icon asset for the plugin.
jetwhale-plugins/mirror/host/src/main/kotlin/com/kitakkun/jetwhale/plugins/mirror/host/Shell.kt Adds external process execution helpers + adb/idb discovery utilities.
jetwhale-plugins/mirror/host/src/main/kotlin/com/kitakkun/jetwhale/plugins/mirror/host/DeviceController.kt Implements Android/iOS device controllers and device discovery logic.
jetwhale-plugins/mirror/host/src/main/kotlin/com/kitakkun/jetwhale/plugins/mirror/host/MirrorHostPluginFactory.kt Provides the host plugin + polling loops and registers MCP commands.
jetwhale-plugins/mirror/host/src/main/kotlin/com/kitakkun/jetwhale/plugins/mirror/host/MirrorScreen.kt Compose UI for selecting devices, showing frames, and sending interactions.
jetwhale-plugins/mirror/host/src/main/kotlin/com/kitakkun/jetwhale/plugins/mirror/host/MirrorMcpCommands.kt Defines tool prefix + common helpers (OK JSON, screenshot file naming).
jetwhale-plugins/mirror/host/src/main/kotlin/com/kitakkun/jetwhale/plugins/mirror/host/ListDevicesCommand.kt MCP command to refresh and return the device list as JSON.
jetwhale-plugins/mirror/host/src/main/kotlin/com/kitakkun/jetwhale/plugins/mirror/host/CaptureScreenshotCommand.kt MCP command to capture a screenshot to disk and return path + dimensions.
jetwhale-plugins/mirror/host/src/main/kotlin/com/kitakkun/jetwhale/plugins/mirror/host/TapCommand.kt MCP command to tap on the device in screenshot pixel coordinates.
jetwhale-plugins/mirror/host/src/main/kotlin/com/kitakkun/jetwhale/plugins/mirror/host/SwipeCommand.kt MCP command to swipe in screenshot pixel coordinates with optional duration.
jetwhale-plugins/mirror/host/src/main/kotlin/com/kitakkun/jetwhale/plugins/mirror/host/PressButtonCommand.kt MCP command to press hardware buttons (platform-specific support).
jetwhale-plugins/mirror/host/src/main/kotlin/com/kitakkun/jetwhale/plugins/mirror/host/InputTextCommand.kt MCP command to send text input to the device.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +28 to +36
// Drain stderr concurrently so neither pipe can fill up and deadlock the process.
var stderr = ""
val stderrThread = Thread {
stderr = process.errorStream.bufferedReader().readText()
}.apply { start() }
val stdout = process.inputStream.readBytes()
val exitCode = process.waitFor()
stderrThread.join()
CommandResult(exitCode = exitCode, stdout = stdout, stderr = stderr)
Comment on lines +71 to +73
val escaped = text
.replace(Regex("""([\\'"`$&*()\[\]{}+|<>;?~#!])"""), """\\$1""")
.replace(" ", "%s")
Comment on lines +19 to +27
override suspend fun execute(arguments: JetWhaleMcpArguments): String {
val device = resolveDevice(arguments[deviceId])
try {
device.controller.tap(arguments[x], arguments[y])
} catch (e: DeviceControlException) {
throw JetWhaleMcpArgumentException(e.message ?: "tap failed")
}
return okJson()
}
Comment on lines +22 to +36
override suspend fun execute(arguments: JetWhaleMcpArguments): String {
val device = resolveDevice(arguments[deviceId])
try {
device.controller.swipe(
fromX = arguments[fromX],
fromY = arguments[fromY],
toX = arguments[toX],
toY = arguments[toY],
durationMillis = arguments[durationMillis] ?: 300,
)
} catch (e: DeviceControlException) {
throw JetWhaleMcpArgumentException(e.message ?: "swipe failed")
}
return okJson()
}
Comment on lines +146 to +153
override val mcpCommands: List<JetWhaleMcpCommand> = listOf(
ListDevicesCommand(refreshDevices = ::refreshDevices, selectedDeviceId = { selectedDeviceId }),
CaptureScreenshotCommand(resolveDevice = ::resolveDevice),
TapCommand(resolveDevice = ::resolveDevice),
SwipeCommand(resolveDevice = ::resolveDevice),
PressButtonCommand(resolveDevice = ::resolveDevice),
InputTextCommand(resolveDevice = ::resolveDevice),
)
@kitakkun kitakkun added feature host Indicates that it is reiated to the host(debugger application)-side implementation. labels Jul 19, 2026
@kitakkun
kitakkun force-pushed the feature/simulator-mirror-plugin branch from 9b83908 to 5db8173 Compare July 20, 2026 07:46
kitakkun added 17 commits July 28, 2026 19:51
Mirrors Android emulator/device and booted iOS simulator screens in a
host-only plugin. The mirror view supports click-to-tap and
drag-to-swipe, plus buttons for screenshots, hardware keys, and text
input. MCP tools (listDevices, captureScreenshot, tap, swipe,
pressButton, inputText) let an AI agent drive the device in the
screenshot pixel coordinate space.

Android is driven via adb; iOS screenshots use `xcrun simctl`, and iOS
input events require idb, failing with an actionable message when it is
not installed.
A fixed 350ms sleep per frame capped mirroring at ~1-2fps on top of the
capture round trip. Capture continuously with a minimal gap instead, and
back off only after a failed capture.
Text could be typed but not deleted or confirmed. Android maps them to
KEYCODE_DEL / KEYCODE_ENTER; iOS sends USB HID usage codes via
`idb ui key`. Exposed as control-row buttons and through the existing
pressButton MCP tool.
…oring

Recording: Android records on-device via `adb shell screenrecord`
(SIGINT to finalize the mp4, then pull), iOS via `xcrun simctl io
recordVideo` (SIGINT to the local process). Exposed as a Record/Stop
button and startRecording/stopRecording MCP tools; one recording at a
time.

Mirroring now runs one capture loop per connected device and shows all
devices side by side. Taps and swipes go to the frame under the cursor
(and select that device); the button/text controls target the selected,
highlighted device.
Every connected device was captured and PNG-decoded at full rate, which
scaled CPU linearly with device count even on static screens. Unselected
devices now refresh at 500ms, and a frame whose PNG bytes match the
previous capture skips decode and recomposition entirely.
Frame pixels were read during composition, so every captured frame
recomposed and re-laid-out the whole screen. Composition now depends
only on the frame dimensions (derivedStateOf, changes on rotation at
most); pixels are read inside drawBehind, so a new frame invalidates
just that pane's draw pass.
- Drain stderr with a coroutine instead of spawning a Thread per command
- Escape literal % in Android inputText (input text uses % as escape prefix)
- Validate tap/swipe coordinates (>= 0) and swipe duration (> 0) upfront
  with caller-facing errors
- Add unit tests for MCP command forwarding, defaults, validation, and
  JSON shape
…ering

The stacked toolbar rows squeezed the mirror panes into a fraction of
the window. Controls now sit in one slim glyph-icon row (with hover
tooltips) and the TopAppBar is gone.

The Send field is replaced by a direct key-input field: committed
characters are forwarded to the selected device as typed, IME
composition (e.g. Japanese conversion) stays buffered until committed
and is then sent as one string, and Backspace/Enter on an empty buffer
are sent as device keys.
Screenshot polling topped out at a few fps. Each device's mirror loop
now prefers a raw H.264 stream — `adb exec-out screenrecord` on Android
(reopened at its 180s cap), `idb video-stream` on iOS — decoded with
FFmpegFrameGrabber, falling back to screenshot polling when streaming
is unavailable or fails before producing a frame.

Unselected devices still decode but publish only every 15th frame. The
recording stop now pkills by output path so it cannot kill the mirror
stream's screenrecord. The ffmpeg natives are bundled for the build
machine's platform only.
FFmpegFrameGrabber.start() blocks in format probing for as long as the
stream process keeps stdout open without writing (e.g. idb without a
working companion), leaving the pane stuck on Waiting. A watchdog kills
the process if no frame arrives within 7s, unblocking the probe so the
loop falls back to screenshot polling.
…blish caps

The default ffmpeg input path buffers and probes for seconds, and
converting every decoded frame let the pipe back up, so the mirror
drifted ever further behind real time. Disable input buffering
(nobuffer/low_delay, small probe window) and always drain the decoder
while throttling conversion/publication by wall-clock (~30fps selected,
~2fps unselected).
Matches the decode-side publish cap; a faster stream only spends
encode/decode CPU on frames that would be dropped anyway.
Sending each committed chunk immediately and clearing the field reset
the macOS IME session, making Japanese input impossible. The field now
purely buffers (never touching an active composition), sends the whole
buffer on Enter, and forwards Enter/Backspace as device keys only when
the buffer is empty and no composition is active.
The TextFieldValue-based BasicTextField does not display in-progress
IME composition on desktop, so Japanese conversion was invisible while
typing. Migrate the key-input field to the TextFieldState overload,
which renders the composition inline; the Enter-to-send and
empty-buffer device-key behavior is unchanged.
A raw key handler fires before the IME, so it stole the Enter that
commits a Japanese conversion and sent the half-composed buffer. Enter
now goes through KeyboardOptions(imeAction = Send) + onKeyboardAction,
which only fires for an Enter the IME did not consume; only Backspace
on an empty, non-composing buffer is still intercepted as a device
key.
The SDK's JetWhaleMcpArguments now wraps a JsonObject instead of a
Map<String, String>; migrate the command tests accordingly.
@kitakkun
kitakkun force-pushed the feature/simulator-mirror-plugin branch from d746ef9 to bf57f45 Compare July 28, 2026 10:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

feature host Indicates that it is reiated to the host(debugger application)-side implementation.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants