From 125a060a697cc1d0eee78265b1436f49f873d11a Mon Sep 17 00:00:00 2001 From: PJ Date: Sat, 18 Apr 2026 00:02:08 +0700 Subject: [PATCH] feat(sidecar): DriverService implementing v0.1 RPCs DriverService delegates each RPC to a pluggable DriverBackend so real Maestro integration can land in a follow-up without changing the gRPC layer. StubDriverBackend satisfies the contract and lets the Go side talk to a running sidecar today. --- .../kotlin/dev/uatu/sidecar/DriverBackend.kt | 56 ++++++++++ .../kotlin/dev/uatu/sidecar/DriverService.kt | 100 ++++++++++++++++++ 2 files changed, 156 insertions(+) create mode 100644 sidecar/src/main/kotlin/dev/uatu/sidecar/DriverBackend.kt create mode 100644 sidecar/src/main/kotlin/dev/uatu/sidecar/DriverService.kt diff --git a/sidecar/src/main/kotlin/dev/uatu/sidecar/DriverBackend.kt b/sidecar/src/main/kotlin/dev/uatu/sidecar/DriverBackend.kt new file mode 100644 index 0000000..454840f --- /dev/null +++ b/sidecar/src/main/kotlin/dev/uatu/sidecar/DriverBackend.kt @@ -0,0 +1,56 @@ +package dev.uatu.sidecar + +interface DriverBackend { + fun launch(bundleId: String, clearState: Boolean) + fun terminate(bundleId: String) + fun tap(x: Int, y: Int) + fun inputText(text: String) + fun screenshot(): Triple + fun hierarchy(): String + fun waitForIdle(durationMillis: Long) + fun healthy(): Boolean +} + +/** + * StubDriverBackend records calls but takes no real device action. Real + * Maestro integration arrives in a follow-up; v0.1 wires the gRPC plumbing + * end-to-end so the Go side can be exercised against a running sidecar + * even before Maestro is plugged in. + */ +class StubDriverBackend(private val platform: String) : DriverBackend { + @Volatile var launchCount: Int = 0 + private set + @Volatile var lastBundleId: String? = null + private set + @Volatile var lastTap: Pair? = null + private set + @Volatile var lastInputText: String? = null + private set + + override fun launch(bundleId: String, clearState: Boolean) { + launchCount++ + lastBundleId = bundleId + } + + override fun terminate(bundleId: String) { + lastBundleId = null + } + + override fun tap(x: Int, y: Int) { + lastTap = x to y + } + + override fun inputText(text: String) { + lastInputText = text + } + + override fun screenshot(): Triple = Triple(ByteArray(0), 0, 0) + + override fun hierarchy(): String = "{\"children\":[],\"platform\":\"$platform\"}" + + override fun waitForIdle(durationMillis: Long) { + if (durationMillis > 0) Thread.sleep(durationMillis) + } + + override fun healthy(): Boolean = true +} diff --git a/sidecar/src/main/kotlin/dev/uatu/sidecar/DriverService.kt b/sidecar/src/main/kotlin/dev/uatu/sidecar/DriverService.kt new file mode 100644 index 0000000..5599141 --- /dev/null +++ b/sidecar/src/main/kotlin/dev/uatu/sidecar/DriverService.kt @@ -0,0 +1,100 @@ +package dev.uatu.sidecar + +import com.google.protobuf.ByteString +import dev.uatu.driver.v1.DriverGrpc +import dev.uatu.driver.v1.Duration +import dev.uatu.driver.v1.Empty +import dev.uatu.driver.v1.HealthStatus +import dev.uatu.driver.v1.HierarchyJSON +import dev.uatu.driver.v1.Image +import dev.uatu.driver.v1.LaunchRequest +import dev.uatu.driver.v1.Point +import dev.uatu.driver.v1.Text +import io.grpc.stub.StreamObserver +import java.util.concurrent.atomic.AtomicReference + +class DriverService( + private val platform: String = "android", + private val serial: String? = null, + private val backend: DriverBackend = StubDriverBackend(platform), +) : DriverGrpc.DriverImplBase() { + + private val launchedBundleId = AtomicReference(null) + + override fun launch(request: LaunchRequest, responseObserver: StreamObserver) { + runRpc(responseObserver) { + backend.launch(request.bundleId, request.clearState) + launchedBundleId.set(request.bundleId) + Empty.getDefaultInstance() + } + } + + override fun terminate(request: Empty, responseObserver: StreamObserver) { + runRpc(responseObserver) { + launchedBundleId.get()?.let { backend.terminate(it) } + launchedBundleId.set(null) + Empty.getDefaultInstance() + } + } + + override fun tap(request: Point, responseObserver: StreamObserver) { + runRpc(responseObserver) { + backend.tap(request.x, request.y) + Empty.getDefaultInstance() + } + } + + override fun inputText(request: Text, responseObserver: StreamObserver) { + runRpc(responseObserver) { + backend.inputText(request.value) + Empty.getDefaultInstance() + } + } + + override fun screenshot(request: Empty, responseObserver: StreamObserver) { + runRpc(responseObserver) { + val (png, width, height) = backend.screenshot() + Image.newBuilder() + .setPng(ByteString.copyFrom(png)) + .setWidth(width) + .setHeight(height) + .build() + } + } + + override fun hierarchy(request: Empty, responseObserver: StreamObserver) { + runRpc(responseObserver) { + HierarchyJSON.newBuilder().setJson(backend.hierarchy()).build() + } + } + + override fun waitForIdle(request: Duration, responseObserver: StreamObserver) { + runRpc(responseObserver) { + backend.waitForIdle(request.millis) + Empty.getDefaultInstance() + } + } + + override fun health(request: Empty, responseObserver: StreamObserver) { + runRpc(responseObserver) { + HealthStatus.newBuilder() + .setReady(backend.healthy()) + .setVersion(VERSION) + .setPlatform(platform) + .build() + } + } + + private inline fun runRpc(observer: StreamObserver, block: () -> T) { + try { + observer.onNext(block()) + observer.onCompleted() + } catch (cause: Exception) { + observer.onError(cause) + } + } + + companion object { + const val VERSION: String = "0.0.1" + } +}