Skip to content

Commit 6cba126

Browse files
fix(mcp-visualizer): support physical Android devices (#3297)
* fix(mcp-visualizer): support physical Android devices The visualizer always launched simulator-server with the `android` subcommand (emulator-only), which panicked on physical Android serials with "emulator with id <serial> should be running". And even with the right subcommand it could not find its screen-sharing agent resources because installSimulatorServer only unpacked the binary, not its sibling `resources/` tree. - Plumb a typed StreamDeviceType (android | android_device | ios) through DeviceStreamTarget, MaestroConnected, and McpMaestroSession so the launch site picks the correct subcommand based on Device.DeviceType at the source. Browser carries the field opaquely in /api/device/start. Invalid combinations (BROWSER, real iOS) are unrepresentable. - Unpack the entire deps/simulator-server/<platform>/ classpath subtree into ~/.maestro/deps/ via a new Unpacker.unpackTree, so screen-sharing-agent.jar + per-ABI .so files land next to the binary. Uses NIO FileSystems.newFileSystem to share one walker across jar and filesystem resources. Verifies all declared executableEntries were present so missing-binary packaging bugs fail loudly. * chore(deps): bump simulator-server to f4b4f058 Required by the visualizer fix above: this release adds the `android_device` subcommand for physical Android and ships the screen-sharing-agent.jar + per-ABI .so files alongside the binary. * fix(mcp-visualizer): flush SSE headers on stream open Ktor 2.3.x's respondBytesWriter buffers the response until the first write. /api/events/stream has no initial payload, so the browser sees ERR_EMPTY_RESPONSE on the EventSource and never reconnects, hiding the entire command log and tap-animation pipeline. The bug has been latent since the visualizer was introduced in #3288 — exposed now that physical Android lets us exercise the events path. Write an SSE comment line on stream open — the standard pattern to flush headers without producing a data event the client will misinterpret. * refactor(mcp): consolidate stream-device-type mapping in createSession Per pedro18x review: the EMULATOR/REAL→ANDROID/ANDROID_DEVICE mapping lived in two places (createAndroidSession and StreamDeviceType.forConnected) and could drift if a new DeviceType lands in one without the other. Resolve the stream type once at createSession's entry, bail with a clear unsupported message if forConnected returns null, and pass the resolved value into the per-platform builders. forConnected becomes the sole authority. The "real iOS unsupported" check is now subsumed by the same null branch.
1 parent 7cd8817 commit 6cba126

7 files changed

Lines changed: 205 additions & 26 deletions

File tree

gradle/libs.versions.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ picocli = "4.6.3"
5050
posthog = "1.0.3"
5151
selenium = "4.43.0"
5252
selenium-devtools = "4.43.0"
53-
simulatorServer = "b8534f42"
53+
simulatorServer = "f4b4f058"
5454
skiko = "0.8.18"
5555
squareOkhttp = "4.12.0"
5656
squareOkio = "3.16.2"

maestro-cli/mcp-visualizer/src/main.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,11 @@ type DeviceState = {
3434
message?: string;
3535
};
3636

37+
type StreamDeviceType = "android" | "android_device" | "ios";
38+
3739
type DeviceTarget = {
3840
platform: string;
41+
deviceType: StreamDeviceType;
3942
deviceId: string;
4043
};
4144

@@ -704,6 +707,7 @@ function App() {
704707
headers: { "Content-Type": "application/json" },
705708
body: JSON.stringify({
706709
platform: target.platform,
710+
deviceType: target.deviceType,
707711
deviceId: target.deviceId,
708712
}),
709713
});

maestro-cli/src/main/java/maestro/cli/Dependencies.kt

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package maestro.cli
22

33
import maestro.cli.util.Unpacker.binaryDependency
44
import maestro.cli.util.Unpacker.unpack
5+
import maestro.cli.util.Unpacker.unpackTree
56
import maestro.cli.util.EnvUtils
67

78
object Dependencies {
@@ -16,9 +17,14 @@ object Dependencies {
1617
}
1718

1819
fun installSimulatorServer() {
19-
unpack(
20-
jarPath = "deps/simulator-server/${simulatorServerPlatformDir()}/${simulatorServerBinaryName()}",
21-
target = simulatorServer,
20+
// simulator-server expects a sibling `resources/` tree (screen-sharing-agent.jar
21+
// + per-ABI .so files for physical Android; ffmpeg .so's for linux), so unpack
22+
// the whole platform subtree rather than just the binary.
23+
val binaryName = simulatorServerBinaryName()
24+
unpackTree(
25+
classpathPrefix = "deps/simulator-server/${simulatorServerPlatformDir()}",
26+
targetDir = simulatorServer.parentFile,
27+
executableEntries = setOf(binaryName),
2228
)
2329
}
2430

maestro-cli/src/main/java/maestro/cli/mcp/McpMaestroSessionManager.kt

Lines changed: 34 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import maestro.Maestro
77
import maestro.cli.CliError
88
import maestro.cli.mcp.visualizer.McpVisualizerDriver
99
import maestro.cli.mcp.visualizer.McpVisualizerEvents
10+
import maestro.cli.mcp.visualizer.StreamDeviceType
1011
import maestro.cli.mcp.visualizer.VisualizerEvent
1112
import maestro.cli.report.TestDebugReporter
1213
import maestro.device.DeviceService
@@ -41,7 +42,11 @@ internal class McpMaestroSessionManager : AutoCloseable {
4142

4243
private fun publishConnected(session: McpMaestroSession) {
4344
McpVisualizerEvents.publish(
44-
VisualizerEvent.MaestroConnected(platform = session.platform, deviceId = session.deviceId)
45+
VisualizerEvent.MaestroConnected(
46+
platform = session.platform,
47+
deviceType = session.streamDeviceType,
48+
deviceId = session.deviceId,
49+
)
4550
)
4651
}
4752

@@ -60,33 +65,49 @@ internal class McpMaestroSessionManager : AutoCloseable {
6065
val device = DeviceService.listConnectedDevices()
6166
.find { it.instanceId.equals(deviceId, ignoreCase = true) }
6267
?: throw CliError("Device with id $deviceId is not connected")
63-
if (device.platform == Platform.IOS && device.deviceType == Device.DeviceType.REAL) {
64-
throw UnsupportedOperationException("Real iOS devices are not yet supported by the MCP server")
65-
}
68+
val streamDeviceType = StreamDeviceType.forConnected(device)
69+
?: throw UnsupportedOperationException(
70+
"Device ${device.instanceId} (${device.platform}/${device.deviceType}) is not supported by the MCP server"
71+
)
6672

6773
return when (device.platform) {
68-
Platform.ANDROID -> createAndroidSession(device)
69-
Platform.IOS -> createIosSession(device)
74+
Platform.ANDROID -> createAndroidSession(device, streamDeviceType)
75+
Platform.IOS -> createIosSession(device, streamDeviceType)
7076
Platform.WEB -> createWebSession()
7177
}
7278
}
7379

74-
private fun createAndroidSession(device: Device.Connected): McpMaestroSession {
80+
private fun createAndroidSession(device: Device.Connected, streamDeviceType: StreamDeviceType): McpMaestroSession {
7581
val dadb = Dadb.list().find { it.toString() == device.instanceId }
7682
?: error("Unable to find device with id ${device.instanceId}")
7783
val driver = McpVisualizerDriver(AndroidDriver(dadb, null, device.instanceId, true), "android")
78-
return McpMaestroSession(Maestro.android(driver), platform = "android", deviceId = device.instanceId)
84+
return McpMaestroSession(
85+
maestro = Maestro.android(driver),
86+
platform = "android",
87+
streamDeviceType = streamDeviceType,
88+
deviceId = device.instanceId,
89+
)
7990
}
8091

81-
private fun createIosSession(device: Device.Connected): McpMaestroSession {
92+
private fun createIosSession(device: Device.Connected, streamDeviceType: StreamDeviceType): McpMaestroSession {
8293
val driver = McpVisualizerDriver(createIOSDriver(device.instanceId, device.deviceType), "ios")
83-
return McpMaestroSession(Maestro.ios(driver, openDriver = true), platform = "ios", deviceId = device.instanceId)
94+
return McpMaestroSession(
95+
maestro = Maestro.ios(driver, openDriver = true),
96+
platform = "ios",
97+
streamDeviceType = streamDeviceType,
98+
deviceId = device.instanceId,
99+
)
84100
}
85101

86102
private fun createWebSession(): McpMaestroSession {
87103
val driver = McpVisualizerDriver(CdpWebDriver(isStudio = false, isHeadless = false, screenSize = null), "web")
88104
driver.open()
89-
return McpMaestroSession(Maestro(driver), platform = "web", deviceId = WEB_DEVICE_ID)
105+
return McpMaestroSession(
106+
maestro = Maestro(driver),
107+
platform = "web",
108+
streamDeviceType = null,
109+
deviceId = WEB_DEVICE_ID,
110+
)
90111
}
91112

92113
private fun createIOSDriver(
@@ -146,6 +167,8 @@ internal class McpMaestroSessionManager : AutoCloseable {
146167
data class McpMaestroSession(
147168
val maestro: Maestro,
148169
val platform: String,
170+
// null for web sessions, which the visualizer doesn't stream.
171+
val streamDeviceType: StreamDeviceType?,
149172
val deviceId: String,
150173
) {
151174
fun close() {

maestro-cli/src/main/java/maestro/cli/mcp/visualizer/McpVisualizerEvents.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ internal sealed interface VisualizerEvent {
1717

1818
data class MaestroConnected(
1919
val platform: String,
20+
// null for web sessions, which the visualizer doesn't stream.
21+
val deviceType: StreamDeviceType?,
2022
val deviceId: String,
2123
) : VisualizerEvent
2224

maestro-cli/src/main/java/maestro/cli/mcp/visualizer/McpVisualizerServer.kt

Lines changed: 59 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
package maestro.cli.mcp.visualizer
22

3+
import com.fasterxml.jackson.annotation.JsonCreator
4+
import com.fasterxml.jackson.annotation.JsonValue
35
import com.fasterxml.jackson.databind.ObjectMapper
46
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
57
import com.fasterxml.jackson.module.kotlin.readValue
@@ -33,6 +35,7 @@ import kotlinx.coroutines.sync.Mutex
3335
import kotlinx.coroutines.sync.withLock
3436
import maestro.cli.Dependencies
3537
import maestro.cli.util.getFreePort
38+
import maestro.device.Device
3639
import maestro.device.DeviceService
3740
import maestro.device.Platform
3841
import java.io.BufferedReader
@@ -49,7 +52,36 @@ internal data class DeviceStreamState(
4952
val message: String? = null,
5053
)
5154

52-
private data class DeviceStreamTarget(val platform: String, val deviceId: String)
55+
// The subset of devices simulator-server can stream, plus the wire/CLI name for each.
56+
// Mirrors simulator-server's subcommands; using an enum keeps invalid combinations
57+
// (BROWSER, real iOS, etc.) unrepresentable at the call sites.
58+
internal enum class StreamDeviceType(@JsonValue val wire: String) {
59+
ANDROID("android"),
60+
ANDROID_DEVICE("android_device"),
61+
IOS("ios"),
62+
;
63+
64+
companion object {
65+
@JvmStatic
66+
@JsonCreator
67+
fun fromWire(value: String): StreamDeviceType =
68+
entries.firstOrNull { it.wire == value }
69+
?: throw IllegalArgumentException("Unknown StreamDeviceType: '$value'")
70+
71+
fun forConnected(device: Device.Connected): StreamDeviceType? = when {
72+
device.platform == Platform.ANDROID && device.deviceType == Device.DeviceType.EMULATOR -> ANDROID
73+
device.platform == Platform.ANDROID && device.deviceType == Device.DeviceType.REAL -> ANDROID_DEVICE
74+
device.platform == Platform.IOS && device.deviceType == Device.DeviceType.SIMULATOR -> IOS
75+
else -> null
76+
}
77+
}
78+
}
79+
80+
private data class DeviceStreamTarget(
81+
val platform: String,
82+
val deviceType: StreamDeviceType,
83+
val deviceId: String,
84+
)
5385

5486
internal class McpVisualizerServer private constructor(
5587
val port: Int,
@@ -88,15 +120,21 @@ internal class McpVisualizerServer private constructor(
88120
suspend fun deviceStreamTargets(): List<DeviceStreamTarget> =
89121
withContext(Dispatchers.IO) {
90122
DeviceService.listConnectedDevices()
91-
.filter { it.platform != Platform.WEB }
92-
.map { DeviceStreamTarget(it.platform.name.lowercase(), it.instanceId) }
123+
.mapNotNull { device ->
124+
val type = StreamDeviceType.forConnected(device) ?: return@mapNotNull null
125+
DeviceStreamTarget(
126+
platform = device.platform.name.lowercase(),
127+
deviceType = type,
128+
deviceId = device.instanceId,
129+
)
130+
}
93131
}
94132

95133
val eventRegistration = McpVisualizerEvents.register { event ->
96134
scope.launch {
97135
events.publish(event)
98-
if (event is VisualizerEvent.MaestroConnected && event.platform != "web") {
99-
deviceStream.start(event.platform, event.deviceId)
136+
if (event is VisualizerEvent.MaestroConnected && event.deviceType != null) {
137+
deviceStream.start(event.platform, event.deviceType, event.deviceId)
100138
}
101139
}
102140
}
@@ -126,15 +164,20 @@ internal class McpVisualizerServer private constructor(
126164
get("/api/device/state") { deviceStates.stream(call, deviceStream.state) }
127165
get("/api/device/targets") { call.respondJson(mapOf("devices" to deviceStreamTargets())) }
128166
post("/api/device/start") {
129-
data class Request(val platform: String? = null, val deviceId: String? = null)
167+
data class Request(
168+
val platform: String? = null,
169+
val deviceType: StreamDeviceType? = null,
170+
val deviceId: String? = null,
171+
)
130172
val request = runCatching { mapper.readValue<Request>(call.receiveText()) }.getOrNull()
131173
val platform = request?.platform
174+
val deviceType = request?.deviceType
132175
val deviceId = request?.deviceId
133-
if (platform.isNullOrBlank() || deviceId.isNullOrBlank()) {
134-
call.respondJson(mapOf("error" to "platform and deviceId are required"), HttpStatusCode.BadRequest)
176+
if (platform.isNullOrBlank() || deviceType == null || deviceId.isNullOrBlank()) {
177+
call.respondJson(mapOf("error" to "platform, deviceType, and deviceId are required"), HttpStatusCode.BadRequest)
135178
return@post
136179
}
137-
call.respondJson(deviceStream.start(platform, deviceId))
180+
call.respondJson(deviceStream.start(platform, deviceType, deviceId))
138181
}
139182
post("/api/device/input") {
140183
// Opaque proxy to simulator-server's stdin: the browser owns the protocol
@@ -186,7 +229,7 @@ private class DeviceStream(
186229
Runtime.getRuntime().addShutdownHook(Thread { process?.destroyForcibly() })
187230
}
188231

189-
suspend fun start(platform: String, deviceId: String): DeviceStreamState = startLock.withLock {
232+
suspend fun start(platform: String, deviceType: StreamDeviceType, deviceId: String): DeviceStreamState = startLock.withLock {
190233
val current = state
191234
if (current.platform == platform && current.deviceId == deviceId &&
192235
(current.status == "starting" || current.status == "streaming")) {
@@ -200,7 +243,7 @@ private class DeviceStream(
200243
Dependencies.installSimulatorServer()
201244
val p = ProcessBuilder(
202245
Dependencies.simulatorServerBinary().absolutePath,
203-
platform, "--id", deviceId,
246+
deviceType.wire, "--id", deviceId,
204247
).redirectErrorStream(false).start()
205248
process = p
206249
stdinWriter = OutputStreamWriter(p.outputStream)
@@ -296,6 +339,11 @@ private class SseBroadcaster(private val mapper: ObjectMapper) {
296339
val client = SseClient(this)
297340
clients.add(client)
298341
try {
342+
// Flush headers immediately with an SSE comment. Ktor 2.3.x buffers the
343+
// response until the first write, so a stream with no initial payload
344+
// (e.g. /api/events/stream before any event fires) looks like an empty
345+
// response to the browser and EventSource errors out with ERR_EMPTY_RESPONSE.
346+
client.write(": ready\n\n")
299347
if (initialValue != null) {
300348
client.write("data: ${mapper.writeValueAsString(initialValue)}\n\n")
301349
}

0 commit comments

Comments
 (0)