feat(sidecar): add IosDriverBackend using Maestro IOSDriver + env pass-through

This commit is contained in:
pj committed 2026-04-23 17:33:26 +07:00
1 parent f446439e33
commit 03fad58860
4 files changed
+90 -10

No files matched your search

+2
View File
@@ -26,6 +26,8 @@ val maestroVersion = "1.40.0"
dependencies { dependencies {
implementation("dev.mobile:maestro-client:$maestroVersion") implementation("dev.mobile:maestro-client:$maestroVersion")
implementation("dev.mobile:maestro-utils:$maestroVersion") implementation("dev.mobile:maestro-utils:$maestroVersion")
implementation("dev.mobile:maestro-ios:$maestroVersion")
implementation("dev.mobile:maestro-ios-driver:$maestroVersion")
implementation("io.grpc:grpc-netty-shaded:$grpcVersion") implementation("io.grpc:grpc-netty-shaded:$grpcVersion")
implementation("io.grpc:grpc-protobuf:$grpcVersion") implementation("io.grpc:grpc-protobuf:$grpcVersion")
@@ -1,7 +1,7 @@
package dev.sanderling.sidecar package dev.sanderling.sidecar
interface DriverBackend { interface DriverBackend {
fun launch(bundleId: String, clearState: Boolean) fun launch(bundleId: String, clearState: Boolean, env: Map<String, String> = emptyMap())
fun terminate(bundleId: String) fun terminate(bundleId: String)
fun tap(x: Int, y: Int) fun tap(x: Int, y: Int)
fun tapSelector(selector: String) fun tapSelector(selector: String)
@@ -131,7 +131,7 @@ class StubDriverBackend(private val platform: String) : DriverBackend {
@Volatile var lastInputText: String? = null @Volatile var lastInputText: String? = null
private set private set
override fun launch(bundleId: String, clearState: Boolean) { override fun launch(bundleId: String, clearState: Boolean, env: Map<String, String>) {
launchCount++ launchCount++
lastBundleId = bundleId lastBundleId = bundleId
if (clearState) { if (clearState) {
@@ -386,9 +386,9 @@ class MaestroDriverBackend(private val serial: String?) : DriverBackend {
driver.open() driver.open()
} }
override fun launch(bundleId: String, clearState: Boolean) { override fun launch(bundleId: String, clearState: Boolean, env: Map<String, String>) {
if (clearState) driver.clearAppState(bundleId) if (clearState) driver.clearAppState(bundleId)
driver.launchApp(bundleId, emptyMap(), java.util.UUID.randomUUID()) driver.launchApp(bundleId, env, java.util.UUID.randomUUID())
} }
override fun terminate(bundleId: String) = driver.stopApp(bundleId) override fun terminate(bundleId: String) = driver.stopApp(bundleId)
@@ -486,6 +486,80 @@ private fun pngHeight(bytes: ByteArray): Int {
(bytes[22].toInt() and 0xFF shl 8) or (bytes[23].toInt() and 0xFF) (bytes[22].toInt() and 0xFF shl 8) or (bytes[23].toInt() and 0xFF)
} }
class IosDriverBackend(private val udid: String) : DriverBackend {
private val driver: maestro.drivers.IOSDriver
init {
val httpClient = xcuitest.api.OkHttpClientInstance.get()
val metrics = maestro.utils.NoOpMetrics()
val wdaPort = maestro.utils.SocketUtils.nextFreePort(22000, 23000)
val installer = xcuitest.installer.LocalXCTestInstaller(
udid,
"localhost",
false,
wdaPort,
metrics,
httpClient,
false,
false,
)
val xcTestDriverClient = xcuitest.XCTestDriverClient(installer, httpClient, false)
val xcTestDevice = ios.xctest.XCTestIOSDevice(udid, xcTestDriverClient) { emptySet() }
val simctlDevice = ios.simctl.SimctlIOSDevice(udid)
val device = ios.LocalIOSDevice(udid, xcTestDevice, simctlDevice, maestro.utils.NoopInsights)
driver = maestro.drivers.IOSDriver(device, maestro.utils.NoopInsights, metrics)
driver.open()
}
override fun launch(bundleId: String, clearState: Boolean, env: Map<String, String>) {
runCatching { driver.stopApp(bundleId) }
if (clearState) driver.clearAppState(bundleId)
driver.launchApp(bundleId, env, java.util.UUID.randomUUID())
}
override fun terminate(bundleId: String) = driver.stopApp(bundleId)
override fun tap(x: Int, y: Int) = driver.tap(maestro.Point(x, y))
override fun tapSelector(selector: String) {
val root = driver.contentDescriptor(false)
val bounds = findBoundsBySelector(root, selector) ?: return
driver.tap(maestro.Point((bounds[0] + bounds[2]) / 2, (bounds[1] + bounds[3]) / 2))
}
override fun inputText(text: String) = driver.inputText(text)
override fun swipe(fromX: Int, fromY: Int, toX: Int, toY: Int, durationMillis: Long) =
driver.swipe(maestro.Point(fromX, fromY), maestro.Point(toX, toY), maxOf(durationMillis, 250L))
override fun pressKey(key: String) {
StubDriverBackend.KEY_MAP[key]?.let { keyCode ->
keyCodeToMaestro(keyCode)?.let { driver.pressKey(it) }
}
}
override fun screenshot(): Triple<ByteArray, Int, Int> {
val buf = okio.Buffer()
driver.takeScreenshot(buf, false)
val bytes = buf.readByteArray()
return Triple(bytes, pngWidth(bytes), pngHeight(bytes))
}
override fun hierarchy(): String =
com.fasterxml.jackson.module.kotlin.jacksonObjectMapper()
.writeValueAsString(driver.contentDescriptor(false))
override fun recentLogs(sinceUnixMillis: Long, minLevel: String): List<LogLine> = emptyList()
override fun waitForIdle(durationMillis: Long) {
driver.waitForAppToSettle(null, null, durationMillis.toInt())
}
override fun healthy() = runCatching { driver.contentDescriptor(false); true }.getOrElse { false }
override fun metrics(bundleId: String) = MetricsSample(0.0, 0L, 0L)
}
private fun keyCodeToMaestro(adbKeyCode: String): maestro.KeyCode? { private fun keyCodeToMaestro(adbKeyCode: String): maestro.KeyCode? {
return when (adbKeyCode) { return when (adbKeyCode) {
"KEYCODE_BACK" -> maestro.KeyCode.BACK "KEYCODE_BACK" -> maestro.KeyCode.BACK
@@ -30,7 +30,7 @@ class DriverService(
override fun launch(request: LaunchRequest, responseObserver: StreamObserver<Empty>) { override fun launch(request: LaunchRequest, responseObserver: StreamObserver<Empty>) {
runRpc(responseObserver) { runRpc(responseObserver) {
backend.launch(request.bundleId, request.clearState) backend.launch(request.bundleId, request.clearState, request.envMap)
launchedBundleId.set(request.bundleId) launchedBundleId.set(request.bundleId)
Empty.getDefaultInstance() Empty.getDefaultInstance()
} }
@@ -150,7 +150,8 @@ class DriverService(
observer.onNext(block()) observer.onNext(block())
observer.onCompleted() observer.onCompleted()
} catch (cause: Exception) { } catch (cause: Exception) {
observer.onError(cause) observer.onError(io.grpc.Status.INTERNAL.withDescription(cause.toString())
.withCause(cause).asRuntimeException())
} }
} }
@@ -44,11 +44,14 @@ fun main(arguments: Array<String>) {
val serial = arguments.indexOf("--serial").let { index -> val serial = arguments.indexOf("--serial").let { index ->
if (index >= 0 && index + 1 < arguments.size) arguments[index + 1] else null if (index >= 0 && index + 1 < arguments.size) arguments[index + 1] else null
} }
val udid = arguments.indexOf("--udid").let { index ->
if (index >= 0 && index + 1 < arguments.size) arguments[index + 1] else null
}
val backend: DriverBackend = if (platform == "android") { val backend: DriverBackend = when (platform) {
MaestroDriverBackend(serial) "android" -> MaestroDriverBackend(serial)
} else { "ios" -> IosDriverBackend(udid ?: error("--udid required for ios platform"))
StubDriverBackend(platform) else -> StubDriverBackend(platform)
} }
val service = DriverService(platform = platform, backend = backend) val service = DriverService(platform = platform, backend = backend)
val server = SidecarServer(port, service) val server = SidecarServer(port, service)