diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 6cfee42..1b65d77 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -67,6 +67,7 @@ dependencies { implementation(libs.androidx.navigation.compose) implementation(libs.okhttp) implementation(libs.kotlinx.serialization.json) + implementation(libs.kotlinx.coroutines.android) testImplementation(libs.junit) diff --git a/android/app/src/main/java/pt/naps/maestro/api/MaestroApiException.kt b/android/app/src/main/java/pt/naps/maestro/api/MaestroApiException.kt new file mode 100644 index 0000000..59212c6 --- /dev/null +++ b/android/app/src/main/java/pt/naps/maestro/api/MaestroApiException.kt @@ -0,0 +1,21 @@ +package pt.naps.maestro.api + +import pt.naps.maestro.data.InTheWay + +/** A `/api` failure with the daemon's own error envelope kept intact, so a + * caller can branch on [code] and render [unsavedWork] — the kill guard — + * rather than re-parsing [detail]. Mirrors the web client's + * `ApiRequestError`. */ +class MaestroApiException( + val status: Int, + val code: String, + val detail: String, + val unsavedWork: InTheWay? = null, +) : Exception(buildMessage(status, code, detail)) { + private companion object { + fun buildMessage(status: Int, code: String, detail: String): String { + val info = if (code.isNotEmpty()) "$code: $detail" else detail + return if (info.isNotEmpty()) "$status — $info" else "$status" + } + } +} diff --git a/android/app/src/main/java/pt/naps/maestro/api/MaestroClient.kt b/android/app/src/main/java/pt/naps/maestro/api/MaestroClient.kt new file mode 100644 index 0000000..3f82333 --- /dev/null +++ b/android/app/src/main/java/pt/naps/maestro/api/MaestroClient.kt @@ -0,0 +1,252 @@ +package pt.naps.maestro.api + +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement +import okhttp3.Call +import okhttp3.Callback +import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import okhttp3.Response +import pt.naps.maestro.data.ApiError +import pt.naps.maestro.data.BranchInfo +import pt.naps.maestro.data.CreateSessionRequest +import pt.naps.maestro.data.DaemonInfo +import pt.naps.maestro.data.DirListing +import pt.naps.maestro.data.GitRoot +import pt.naps.maestro.data.NameBatch +import pt.naps.maestro.data.Project +import pt.naps.maestro.data.RecentDirs +import pt.naps.maestro.data.RenameSessionRequest +import pt.naps.maestro.data.Session +import pt.naps.maestro.data.SetFocusRequest +import pt.naps.maestro.data.StructuredAttachmentResponse +import pt.naps.maestro.data.StructuredApprovalDecision +import pt.naps.maestro.data.StructuredConfigRequest +import pt.naps.maestro.data.StructuredPromptRequest +import pt.naps.maestro.data.Uuid +import java.io.IOException +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException + +private val JSON_MEDIA_TYPE = "application/json; charset=utf-8".toMediaType() +private val OCTET_STREAM_MEDIA_TYPE = "application/octet-stream".toMediaType() + +/** + * Typed client for the daemon's `/api` surface (REST half). Mirrors + * `maestro_protocol::client::DaemonClient` / `web/src/api.ts`. + * + * There is no authentication layer: reaching the daemon's bind address is + * the authorization, same trust model as tmux itself. + */ +class MaestroClient( + private val baseUrl: String, + private val httpClient: OkHttpClient = OkHttpClient(), +) { + private val json = Json { + ignoreUnknownKeys = true + isLenient = true + } + + suspend fun listSessions(): List = get(Routes.SESSIONS) + + suspend fun createSession(request: CreateSessionRequest): Session = + post(Routes.SESSIONS, request) + + /** `PATCH /api/sessions/:id`: rename a live session. An empty [name] + * clears the custom one, so the card falls back to the command. */ + suspend fun renameSession(id: Uuid, name: String): Session = + patch(Routes.session(id), RenameSessionRequest(name)) + + /** `DELETE /api/sessions/:id`. With [force], the daemon overrides the + * kill refusal over a worktree holding unsaved work and leaves that + * worktree on disk. */ + suspend fun killSession(id: Uuid, force: Boolean = false) { + val query = if (force) listOf(Routes.FORCE_QUERY to "true") else emptyList() + executeNoContent(newRequest("DELETE", Routes.session(id), query = query)) + } + + /** `POST /api/sessions/:id/focus`: report whether this client's pane is + * the one currently showing [id] — not merely highlighted in a + * sidebar. */ + suspend fun setSessionFocus(id: Uuid, focused: Boolean) { + executeNoContent( + newRequest( + "POST", + Routes.sessionFocus(id), + body = json.encodeToString(SetFocusRequest(focused)), + ), + ) + } + + /** `GET /api/dirs?path=`: one directory's immediate subdirectories. + * [path] may be empty, `~`, relative, or absolute — the daemon resolves + * it against its home dir. */ + suspend fun listDir(path: String): DirListing = + get(Routes.DIRS, listOf(Routes.PATH_QUERY to path)) + + /** `GET /api/info`: identity + spawn presets of the connected daemon. */ + suspend fun getDaemonInfo(): DaemonInfo = get(Routes.INFO) + + /** `GET /api/recent-dirs`: recently-used working dirs, newest-first and + * deduped by dir. */ + suspend fun recentDirs(): RecentDirs = get(Routes.RECENT_DIRS) + + /** `GET /api/projects`: every discovered git repository/worktree, + * newest-activity-first. */ + suspend fun listProjects(): List = get(Routes.PROJECTS) + + /** `GET /api/gitroot?path=`: the enclosing git repo of [path]; absent + * when [path] isn't inside a repo. */ + suspend fun gitRoot(path: String): GitRoot = + get(Routes.GITROOT, listOf(Routes.PATH_QUERY to path)) + + /** `GET /api/names?count=`: a batch of fresh session handles. */ + suspend fun fetchNames(count: Int): NameBatch = + get(Routes.NAMES, listOf(Routes.COUNT_QUERY to count.toString())) + + /** `GET /api/branches?path=`: one repository's local branches, with + * worktree occupancy. */ + suspend fun listBranches(path: String): List = + get(Routes.BRANCHES, listOf(Routes.PATH_QUERY to path)) + + /** Send a prompt to a structured session's ACP turn. `409 + * turn_in_flight` when one is already active. */ + suspend fun sendStructuredPrompt(id: Uuid, prompt: String) { + executeNoContent( + newRequest( + "POST", + Routes.structuredPrompt(id), + body = json.encodeToString(StructuredPromptRequest(prompt)), + ), + ) + } + + /** Upload a file attachment for a structured session's next prompt. */ + suspend fun uploadStructuredAttachment( + id: Uuid, + fileName: String, + contents: ByteArray, + ): StructuredAttachmentResponse { + val url = Routes.httpUrl(baseUrl, Routes.structuredAttachment(id)).toHttpUrl() + .newBuilder() + .addQueryParameter("name", fileName) + .build() + val request = Request.Builder() + .url(url) + .post(contents.toRequestBody(OCTET_STREAM_MEDIA_TYPE)) + .build() + return decode(execute(request)) + } + + /** Cancel a structured session's active ACP turn. */ + suspend fun cancelStructuredTurn(id: Uuid) { + executeNoContent(newRequest("POST", Routes.structuredCancel(id))) + } + + /** Change one option advertised by a live ACP session. */ + suspend fun setStructuredConfig(id: Uuid, configId: String, value: JsonElement) { + executeNoContent( + newRequest( + "POST", + Routes.structuredConfig(id), + body = json.encodeToString(StructuredConfigRequest(configId, value)), + ), + ) + } + + /** Answer a pending ACP permission request. Always resolves once the + * session itself is a live structured session — a nonce that is unknown + * or already consumed still answers success. */ + suspend fun resolveStructuredApproval( + id: Uuid, + nonce: String, + decision: StructuredApprovalDecision, + ) { + executeNoContent( + newRequest( + "POST", + Routes.structuredApproval(id, nonce), + body = json.encodeToString(decision), + ), + ) + } + + // -- plumbing ------------------------------------------------------ + + private suspend inline fun get( + path: String, + query: List> = emptyList(), + ): T = decode(execute(newRequest("GET", path, query = query))) + + private suspend inline fun post(path: String, body: B): T = + decode(execute(newRequest("POST", path, body = json.encodeToString(body)))) + + private suspend inline fun patch(path: String, body: B): T = + decode(execute(newRequest("PATCH", path, body = json.encodeToString(body)))) + + private fun newRequest( + method: String, + path: String, + query: List> = emptyList(), + body: String? = null, + ): Request { + val urlBuilder = Routes.httpUrl(baseUrl, path).toHttpUrl().newBuilder() + for ((key, value) in query) urlBuilder.addQueryParameter(key, value) + // OkHttp refuses a null body on POST/PUT/PATCH even when the daemon + // expects none (e.g. the structured-turn cancel) — an empty body + // satisfies it without implying a JSON payload. + val requestBody = when { + body != null -> body.toRequestBody(JSON_MEDIA_TYPE) + method == "POST" || method == "PUT" || method == "PATCH" -> ByteArray(0).toRequestBody(null) + else -> null + } + return Request.Builder() + .url(urlBuilder.build()) + .method(method, requestBody) + .build() + } + + private suspend fun execute(request: Request): Response = + suspendCancellableCoroutine { cont -> + val call = httpClient.newCall(request) + cont.invokeOnCancellation { call.cancel() } + call.enqueue( + object : Callback { + override fun onFailure(call: Call, e: IOException) { + if (!cont.isCancelled) cont.resumeWithException(e) + } + + override fun onResponse(call: Call, response: Response) { + cont.resume(response) + } + }, + ) + } + + private suspend fun executeNoContent(request: Request) { + execute(request).use { if (!it.isSuccessful) throw toApiException(it) } + } + + private inline fun decode(response: Response): T = + response.use { + if (!it.isSuccessful) throw toApiException(it) + json.decodeFromString(it.body?.string().orEmpty()) + } + + private fun toApiException(response: Response): MaestroApiException { + val text = response.body?.string().orEmpty() + val info = runCatching { json.decodeFromString(text) }.getOrNull()?.error + return MaestroApiException( + status = response.code, + code = info?.code ?: "", + detail = info?.message ?: text, + unsavedWork = info?.unsaved_work, + ) + } +} diff --git a/android/app/src/main/java/pt/naps/maestro/api/MaestroWebSocket.kt b/android/app/src/main/java/pt/naps/maestro/api/MaestroWebSocket.kt new file mode 100644 index 0000000..6bf87b8 --- /dev/null +++ b/android/app/src/main/java/pt/naps/maestro/api/MaestroWebSocket.kt @@ -0,0 +1,175 @@ +package pt.naps.maestro.api + +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.Response +import okhttp3.WebSocket +import okhttp3.WebSocketListener +import okio.ByteString +import pt.naps.maestro.data.CatalogEvent +import pt.naps.maestro.data.ClientControl +import pt.naps.maestro.data.StructuredEvent +import pt.naps.maestro.data.Uuid + +private val wsJson = Json { + ignoreUnknownKeys = true + isLenient = true +} + +/** One frame received on `WS /api/attach/:id`: raw PTY bytes, or a decoded + * [ClientControl] text message. */ +sealed class AttachFrame { + data class Pty(val bytes: ByteArray) : AttachFrame() + data class Control(val message: ClientControl) : AttachFrame() +} + +/** A live `WS /api/attach/:id` connection. PTY bytes travel in binary frames + * both directions; control messages ([ClientControl]) travel in text + * frames. */ +class AttachConnection internal constructor( + private val socket: WebSocket, + val frames: Flow, +) { + /** Type raw bytes into the session's pane, as if typed on an attached + * terminal. */ + fun sendInput(bytes: ByteArray) { + socket.send(ByteString.of(*bytes)) + } + + fun resize(cols: Int, rows: Int) { + sendControl(ClientControl.Resize(cols, rows)) + } + + fun ping() { + sendControl(ClientControl.Ping) + } + + private fun sendControl(message: ClientControl) { + socket.send(wsJson.encodeToString(message)) + } + + fun close() { + socket.close(1000, null) + } +} + +/** A live `WS /api/structured/:id` connection: transcript/state snapshots + * and live deltas for one structured session. */ +class StructuredConnection internal constructor( + private val socket: WebSocket, + val events: Flow, +) { + fun close() { + socket.close(1000, null) + } +} + +/** A live `WS /api/events` connection: catalog snapshot + session deltas. */ +class EventsConnection internal constructor( + private val socket: WebSocket, + val events: Flow, +) { + fun close() { + socket.close(1000, null) + } +} + +/** + * WebSocket half of the daemon's `/api` surface. Mirrors the `*_url` + * helpers in `crates/maestro-protocol/src/routes.rs`. + * + * Each `connect*` call opens the socket immediately and returns a handle + * whose `Flow` starts buffering frames right away — a slow collector never + * misses a frame, it only sees it later. Call `close()` on the returned + * connection when done; there is no automatic reconnect. + */ +class MaestroWebSocket( + private val baseUrl: String, + private val httpClient: OkHttpClient = OkHttpClient(), +) { + /** `WS /api/events`: catalog snapshot + session deltas. */ + fun connectEvents(): EventsConnection { + val channel = Channel(Channel.BUFFERED) + val socket = open( + Routes.eventsWsUrl(baseUrl), + object : WebSocketListener() { + override fun onMessage(webSocket: WebSocket, text: String) { + runCatching { wsJson.decodeFromString(text) } + .onSuccess { channel.trySend(it) } + } + + override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) { + channel.close(t) + } + + override fun onClosed(webSocket: WebSocket, code: Int, reason: String) { + channel.close() + } + }, + ) + return EventsConnection(socket, channel.receiveAsFlow()) + } + + /** `WS /api/attach/:id`: raw PTY bytes both directions, control messages + * as text JSON. */ + fun connectAttach(id: Uuid): AttachConnection { + val channel = Channel(Channel.BUFFERED) + val socket = open( + Routes.attachWsUrl(baseUrl, id), + object : WebSocketListener() { + override fun onMessage(webSocket: WebSocket, text: String) { + runCatching { wsJson.decodeFromString(text) } + .onSuccess { channel.trySend(AttachFrame.Control(it)) } + } + + override fun onMessage(webSocket: WebSocket, bytes: ByteString) { + channel.trySend(AttachFrame.Pty(bytes.toByteArray())) + } + + override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) { + channel.close(t) + } + + override fun onClosed(webSocket: WebSocket, code: Int, reason: String) { + channel.close() + } + }, + ) + return AttachConnection(socket, channel.receiveAsFlow()) + } + + /** `WS /api/structured/:id`: transcript/state snapshots and live deltas + * for one structured session. */ + fun connectStructured(id: Uuid): StructuredConnection { + val channel = Channel(Channel.BUFFERED) + val socket = open( + Routes.structuredWsUrl(baseUrl, id), + object : WebSocketListener() { + override fun onMessage(webSocket: WebSocket, text: String) { + runCatching { wsJson.decodeFromString(text) } + .onSuccess { channel.trySend(it) } + } + + override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) { + channel.close(t) + } + + override fun onClosed(webSocket: WebSocket, code: Int, reason: String) { + channel.close() + } + }, + ) + return StructuredConnection(socket, channel.receiveAsFlow()) + } + + private fun open(url: String, listener: WebSocketListener): WebSocket { + val request = Request.Builder().url(url).build() + return httpClient.newWebSocket(request, listener) + } +} diff --git a/android/app/src/main/java/pt/naps/maestro/api/Routes.kt b/android/app/src/main/java/pt/naps/maestro/api/Routes.kt new file mode 100644 index 0000000..8b229d7 --- /dev/null +++ b/android/app/src/main/java/pt/naps/maestro/api/Routes.kt @@ -0,0 +1,84 @@ +package pt.naps.maestro.api + +import pt.naps.maestro.data.Uuid + +/** The daemon's `/api` surface: paths, and the scheme swap needed to reach + * the WebSocket endpoints. Mirrors `maestro-protocol::routes` / + * `web/src/routes.ts`. */ +object Routes { + const val API_PREFIX = "/api" + + const val SESSIONS = "/api/sessions" + const val INFO = "/api/info" + const val DIRS = "/api/dirs" + const val RECENT_DIRS = "/api/recent-dirs" + const val PROJECTS = "/api/projects" + const val GITROOT = "/api/gitroot" + const val NAMES = "/api/names" + const val BRANCHES = "/api/branches" + const val EVENTS = "/api/events" + + /** Name of the query parameter [DIRS], [GITROOT] and [BRANCHES] take. */ + const val PATH_QUERY = "path" + + /** Name of the query parameter `DELETE /api/sessions/:id` takes: + * `force=true` overrides the kill refusal over a guarded worktree. */ + const val FORCE_QUERY = "force" + + /** Name of the query parameter [NAMES] takes. */ + const val COUNT_QUERY = "count" + + fun session(id: Uuid): String = "$SESSIONS/$id" + + fun sessionFocus(id: Uuid): String = "${session(id)}/focus" + + fun attach(id: Uuid): String = "$API_PREFIX/attach/$id" + + /** `WS /api/structured/:id` — transcript/state snapshots and live + * deltas. */ + fun structured(id: Uuid): String = "$API_PREFIX/structured/$id" + + /** `POST /api/sessions/:id/structured/prompt` — send an ACP prompt. */ + fun structuredPrompt(id: Uuid): String = "${session(id)}/structured/prompt" + + fun structuredAttachment(id: Uuid): String = "${session(id)}/structured/attachments" + + /** `POST /api/sessions/:id/structured/cancel` — cancel an ACP turn. */ + fun structuredCancel(id: Uuid): String = "${session(id)}/structured/cancel" + + /** `POST /api/sessions/:id/structured/config` — change an ACP session + * option. */ + fun structuredConfig(id: Uuid): String = "${session(id)}/structured/config" + + /** `POST /api/sessions/:id/structured/approvals/:nonce` — answer a + * permission request. */ + fun structuredApproval(id: Uuid, nonce: String): String = + "${session(id)}/structured/approvals/$nonce" + + /** Strip a trailing slash so `{base}{path}` never doubles up. */ + fun normalizeBase(base: String): String = base.trimEnd('/') + + /** Join a daemon base URL and one of this object's paths. */ + fun httpUrl(base: String, path: String): String = "${normalizeBase(base)}$path" + + /** Swap an HTTP base URL for its WebSocket equivalent: `http://` becomes + * `ws://`, `https://` becomes `wss://`; a base already `ws(s)://` passes + * through. */ + fun wsBase(httpBase: String): String { + val base = normalizeBase(httpBase) + return when { + base.startsWith("https://") -> "wss://${base.removePrefix("https://")}" + base.startsWith("http://") -> "ws://${base.removePrefix("http://")}" + base.startsWith("wss://") || base.startsWith("ws://") -> base + else -> throw IllegalArgumentException( + "daemon url has no supported scheme (want http/https/ws/wss): $httpBase", + ) + } + } + + fun eventsWsUrl(base: String): String = "${wsBase(base)}$EVENTS" + + fun attachWsUrl(base: String, id: Uuid): String = "${wsBase(base)}${attach(id)}" + + fun structuredWsUrl(base: String, id: Uuid): String = "${wsBase(base)}${structured(id)}" +} diff --git a/android/gradle/libs.versions.toml b/android/gradle/libs.versions.toml index a893892..881e243 100644 --- a/android/gradle/libs.versions.toml +++ b/android/gradle/libs.versions.toml @@ -8,6 +8,7 @@ composeBom = "2024.09.03" navigationCompose = "2.8.2" okhttp = "4.12.0" kotlinxSerializationJson = "1.7.3" +kotlinxCoroutines = "1.9.0" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } @@ -24,6 +25,7 @@ androidx-material3 = { group = "androidx.compose.material3", name = "material3" androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" } okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" } kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" } +kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "kotlinxCoroutines" } junit = { group = "junit", name = "junit", version = "4.13.2" } androidx-test-ext-junit = { group = "androidx.test.ext", name = "junit", version = "1.2.1" } androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version = "3.6.1" }