diff --git a/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/NestsConnect.kt b/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/NestsConnect.kt new file mode 100644 index 000000000..9a732e13b --- /dev/null +++ b/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/NestsConnect.kt @@ -0,0 +1,194 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.nestsclient + +import com.vitorpamplona.nestsclient.moq.MoqSession +import com.vitorpamplona.nestsclient.moq.MoqVersion +import com.vitorpamplona.nestsclient.moq.SubscribeHandle +import com.vitorpamplona.nestsclient.moq.TrackNamespace +import com.vitorpamplona.nestsclient.transport.WebTransportException +import com.vitorpamplona.nestsclient.transport.WebTransportFactory +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableStateFlow + +/** + * Walk the full join-as-listener handshake against a nests-compatible audio + * server: + * + * 1. Resolve the room — POST/GET `/` with NIP-98 auth, + * returning [NestsRoomInfo] (the MoQ endpoint + bearer token). + * 2. Open a [com.vitorpamplona.nestsclient.transport.WebTransportSession] + * against the endpoint via [transport]. + * 3. Run the MoQ SETUP handshake. + * + * The returned [NestsListener] is in state [NestsListenerState.Connected]; + * if any step fails, the listener is returned in + * [NestsListenerState.Failed] with the underlying cause attached and the + * transport torn down. + * + * @param signer NIP-98 signer for the resolveRoom HTTP call. + * @param scope where the [MoqSession] pumps live (typically the caller's + * ViewModel scope so they cancel when the screen leaves). + * @param supportedMoqVersions in preference order; defaults to draft-17. + */ +suspend fun connectNestsListener( + httpClient: NestsClient, + transport: WebTransportFactory, + scope: CoroutineScope, + serviceBase: String, + roomId: String, + signer: NostrSigner, + supportedMoqVersions: List = listOf(MoqVersion.DRAFT_17), +): NestsListener { + val state = + MutableStateFlow( + NestsListenerState.Connecting(NestsListenerState.Connecting.ConnectStep.ResolvingRoom), + ) + + val roomInfo = + try { + httpClient.resolveRoom(serviceBase = serviceBase, roomId = roomId, signer = signer) + } catch (t: NestsException) { + state.value = NestsListenerState.Failed("Room resolution failed: ${t.message}", t) + return failedListener(state) + } + + state.value = NestsListenerState.Connecting(NestsListenerState.Connecting.ConnectStep.OpeningTransport) + + val (authority, path) = + try { + parseEndpoint(roomInfo.endpoint) + } catch (t: Throwable) { + state.value = + NestsListenerState.Failed( + "Malformed MoQ endpoint URL '${roomInfo.endpoint}': ${t.message}", + t, + ) + return failedListener(state) + } + + val webTransport = + try { + transport.connect(authority = authority, path = path, bearerToken = roomInfo.token) + } catch (t: WebTransportException) { + state.value = + NestsListenerState.Failed( + "WebTransport ${t.kind.name}: ${t.message}", + t, + ) + return failedListener(state) + } + + state.value = NestsListenerState.Connecting(NestsListenerState.Connecting.ConnectStep.MoqHandshake) + + val moq = + try { + MoqSession.client(webTransport, scope).also { it.setup(supportedMoqVersions) } + } catch (t: Throwable) { + runCatching { webTransport.close(0, "moq setup failed") } + state.value = NestsListenerState.Failed("MoQ handshake failed: ${t.message}", t) + return failedListener(state) + } + + val negotiatedVersion = + moq.selectedVersion ?: run { + runCatching { moq.close() } + state.value = NestsListenerState.Failed("MoQ session reported no negotiated version") + return failedListener(state) + } + + state.value = NestsListenerState.Connected(roomInfo, negotiatedVersion) + return DefaultNestsListener( + session = moq, + roomNamespace = TrackNamespace.of("nests", roomId), + mutableState = state, + ) +} + +/** + * Build a no-op [NestsListener] in a Failed state for callers that want a + * uniform return type even on early-failure paths. + */ +private fun failedListener(state: MutableStateFlow): NestsListener = + object : NestsListener { + override val state = state + + override suspend fun subscribeSpeaker(speakerPubkeyHex: String): SubscribeHandle = error("listener never connected: ${state.value}") + + override suspend fun close() { + if (state.value !is NestsListenerState.Closed) { + state.value = NestsListenerState.Closed + } + } + } + +/** + * Split a typical nests endpoint URL such as `https://relay.example.com/moq` + * or `https://relay.example.com:4443/api/v1/moq?room=abc` into the + * WebTransport `(authority, path)` pair. WebTransport authority is + * `host[:port]` (port omitted when it's the protocol default); path is + * everything after the authority including any query string. Defaults to + * `/` when the URL has no path. + * + * Hand-rolled rather than pulling in a URL library so this stays in + * commonMain with no extra dependency. + */ +internal fun parseEndpoint(endpoint: String): Pair { + val schemeEnd = endpoint.indexOf("://") + require(schemeEnd > 0) { "endpoint must include a scheme (got '$endpoint')" } + val scheme = endpoint.substring(0, schemeEnd).lowercase() + val rest = endpoint.substring(schemeEnd + 3) + + val pathSep = rest.indexOf('/') + val (authorityRaw, pathRaw) = + if (pathSep < 0) { + rest to "/" + } else { + rest.substring(0, pathSep) to rest.substring(pathSep) + } + + require(authorityRaw.isNotEmpty()) { "endpoint must include an authority (got '$endpoint')" } + + val portSep = authorityRaw.lastIndexOf(':') + val hasUserInfo = authorityRaw.contains('@') + require(!hasUserInfo) { "endpoint must not include userinfo (got '$endpoint')" } + + // Strip the port if it's the scheme default so the on-the-wire authority is + // canonical. + val authority = + if (portSep >= 0) { + val host = authorityRaw.substring(0, portSep) + val portStr = authorityRaw.substring(portSep + 1) + val port = portStr.toIntOrNull() ?: error("malformed port '$portStr' in '$endpoint'") + val defaultPort = + when (scheme) { + "https", "wss" -> 443 + "http", "ws" -> 80 + else -> -1 + } + if (port == defaultPort) host else "$host:$port" + } else { + authorityRaw + } + + return authority to pathRaw +} diff --git a/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/NestsListener.kt b/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/NestsListener.kt new file mode 100644 index 000000000..946c0e0ff --- /dev/null +++ b/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/NestsListener.kt @@ -0,0 +1,132 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.nestsclient + +import com.vitorpamplona.nestsclient.moq.MoqSession +import com.vitorpamplona.nestsclient.moq.SubscribeFilter +import com.vitorpamplona.nestsclient.moq.SubscribeHandle +import com.vitorpamplona.nestsclient.moq.TrackNamespace +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +/** + * High-level listener handle for an audio-room. Hides the layered HTTP + + * WebTransport + MoQ wiring under one observable state machine so UI code + * (and tests) can reason about the room as a single resource. + * + * Open one [NestsListener] per audio-room screen. To listen to multiple + * speakers in the room, call [subscribeSpeaker] once per speaker pubkey. + */ +interface NestsListener { + /** Live connection state — the UI typically shows a chip / spinner derived from this. */ + val state: StateFlow + + /** + * Subscribe to one speaker's audio track. nests publishes each speaker's + * Opus stream under the namespace `["nests", ]` with the + * speaker's pubkey hex as the track name. + * + * @throws com.vitorpamplona.nestsclient.moq.MoqProtocolException if the + * publisher rejects the subscription. + * @throws IllegalStateException if the listener is not in [NestsListenerState.Connected]. + */ + suspend fun subscribeSpeaker(speakerPubkeyHex: String): SubscribeHandle + + /** Tear down the MoQ session + underlying transport. Idempotent. */ + suspend fun close() +} + +/** + * Lifecycle states of a [NestsListener]. + * + * Resolved/connected information (room metadata, MoQ version) is carried on + * [NestsListenerState.Connected] so the UI can render details without + * threading them separately. + */ +sealed class NestsListenerState { + /** No connection attempt has started yet. */ + data object Idle : NestsListenerState() + + /** A connect call is in flight. [step] indicates which substage. */ + data class Connecting( + val step: ConnectStep, + ) : NestsListenerState() { + enum class ConnectStep { + /** Calling `/` to obtain the MoQ endpoint + token. */ + ResolvingRoom, + + /** Opening the WebTransport (Kwik QUIC + Extended CONNECT). */ + OpeningTransport, + + /** Running the MoQ CLIENT_SETUP / SERVER_SETUP exchange. */ + MoqHandshake, + } + } + + /** Connection is live. [roomInfo] reflects the resolved server metadata. */ + data class Connected( + val roomInfo: NestsRoomInfo, + val negotiatedMoqVersion: Long, + ) : NestsListenerState() + + /** A connect attempt or live session failed. UI shows [reason] to the user. */ + data class Failed( + val reason: String, + val cause: Throwable? = null, + ) : NestsListenerState() + + /** [close] has been called. Terminal. */ + data object Closed : NestsListenerState() +} + +/** + * Default [NestsListener] implementation that delegates to a [MoqSession] + * already set up on a [com.vitorpamplona.nestsclient.transport.WebTransportSession]. + * + * Construction does NOT open the transport — call [connectNestsListener] to + * walk the full HTTP + transport + MoQ handshake; this class just owns the + * resulting session and exposes the listener API on top. + */ +class DefaultNestsListener internal constructor( + private val session: MoqSession, + private val roomNamespace: TrackNamespace, + private val mutableState: MutableStateFlow, +) : NestsListener { + override val state: StateFlow = mutableState.asStateFlow() + + override suspend fun subscribeSpeaker(speakerPubkeyHex: String): SubscribeHandle { + check(state.value is NestsListenerState.Connected) { + "NestsListener.subscribeSpeaker requires Connected state, was ${state.value}" + } + return session.subscribe( + namespace = roomNamespace, + trackName = speakerPubkeyHex.encodeToByteArray(), + filter = SubscribeFilter.LatestGroup, + ) + } + + override suspend fun close() { + if (state.value is NestsListenerState.Closed) return + runCatching { session.close() } + mutableState.value = NestsListenerState.Closed + } +} diff --git a/nestsClient/src/commonTest/kotlin/com/vitorpamplona/nestsclient/NestsConnectTest.kt b/nestsClient/src/commonTest/kotlin/com/vitorpamplona/nestsclient/NestsConnectTest.kt new file mode 100644 index 000000000..17bd0b280 --- /dev/null +++ b/nestsClient/src/commonTest/kotlin/com/vitorpamplona/nestsclient/NestsConnectTest.kt @@ -0,0 +1,247 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.nestsclient + +import com.vitorpamplona.nestsclient.moq.ClientSetup +import com.vitorpamplona.nestsclient.moq.MoqCodec +import com.vitorpamplona.nestsclient.moq.MoqVersion +import com.vitorpamplona.nestsclient.moq.ServerSetup +import com.vitorpamplona.nestsclient.transport.FakeWebTransport +import com.vitorpamplona.nestsclient.transport.WebTransportException +import com.vitorpamplona.nestsclient.transport.WebTransportFactory +import com.vitorpamplona.nestsclient.transport.WebTransportSession +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertTrue +import kotlin.test.fail + +class NestsConnectTest { + @Test + fun connect_walks_resolveRoom_then_transport_then_moq_handshake() = + runTest { + val (clientSide, serverSide) = FakeWebTransport.pair() + val httpClient = + FakeNestsClient( + NestsRoomInfo( + endpoint = "https://relay.example.com/moq", + token = "tok-abc", + ), + ) + val transport = ConstantWebTransportFactory(clientSide) + + // Server-side raw peer answers SETUP. + val server = + async { + val control = serverSide.peerOpenedBidiStreams().first() + val cs = MoqCodec.decode(control.incoming().first())!!.message as ClientSetup + control.write(MoqCodec.encode(ServerSetup(cs.supportedVersions.first()))) + } + + val listener = + connectNestsListener( + httpClient = httpClient, + transport = transport, + scope = this, + serviceBase = "https://relay.example.com/api/v1/nests", + roomId = "abc", + signer = NostrSignerInternal(KeyPair()), + ) + server.await() + + val connected = assertIs(listener.state.value) + assertEquals("https://relay.example.com/moq", connected.roomInfo.endpoint) + assertEquals(MoqVersion.DRAFT_17, connected.negotiatedMoqVersion) + + assertEquals("relay.example.com", transport.lastConnectedAuthority) + assertEquals("/moq", transport.lastConnectedPath) + assertEquals("tok-abc", transport.lastBearer) + + listener.close() + assertIs(listener.state.value) + } + + @Test + fun resolveRoom_failure_short_circuits_to_Failed() = + runTest { + val httpClient = + ThrowingNestsClient( + NestsException("server returned 500", status = 500), + ) + val transport = NeverConnectFactory() + + val listener = + connectNestsListener( + httpClient = httpClient, + transport = transport, + scope = this, + serviceBase = "https://relay.example.com/api/v1/nests", + roomId = "abc", + signer = NostrSignerInternal(KeyPair()), + ) + + val failed = assertIs(listener.state.value) + assertTrue("Room resolution failed" in failed.reason) + assertTrue("500" in failed.reason || (failed.cause as? NestsException)?.status == 500) + assertEquals(0, transport.connectCallCount, "transport must not be reached") + } + + @Test + fun transport_handshake_failure_short_circuits_to_Failed() = + runTest { + val httpClient = + FakeNestsClient(NestsRoomInfo(endpoint = "https://relay.example.com/moq")) + val transport = + ThrowingTransportFactory( + WebTransportException( + WebTransportException.Kind.HandshakeFailed, + "QUIC handshake refused", + ), + ) + + val listener = + connectNestsListener( + httpClient = httpClient, + transport = transport, + scope = this, + serviceBase = "https://relay.example.com/api/v1/nests", + roomId = "abc", + signer = NostrSignerInternal(KeyPair()), + ) + + val failed = assertIs(listener.state.value) + assertTrue("HandshakeFailed" in failed.reason, "got: ${failed.reason}") + } + + @Test + fun malformed_endpoint_url_short_circuits_to_Failed() = + runTest { + val httpClient = + FakeNestsClient(NestsRoomInfo(endpoint = "not-a-url")) + + val listener = + connectNestsListener( + httpClient = httpClient, + transport = NeverConnectFactory(), + scope = this, + serviceBase = "https://relay.example.com/api/v1/nests", + roomId = "abc", + signer = NostrSignerInternal(KeyPair()), + ) + + val failed = assertIs(listener.state.value) + assertTrue("Malformed MoQ endpoint" in failed.reason, "got: ${failed.reason}") + } + + @Test + fun parseEndpoint_drops_default_port_keeps_explicit_port_and_path() { + assertEquals( + "relay.example.com" to "/moq", + parseEndpoint("https://relay.example.com/moq"), + ) + assertEquals( + "relay.example.com:4443" to "/moq", + parseEndpoint("https://relay.example.com:4443/moq"), + ) + assertEquals( + "relay.example.com" to "/", + parseEndpoint("https://relay.example.com"), + ) + assertEquals( + "relay.example.com" to "/api/v1/moq?room=abc", + parseEndpoint("https://relay.example.com/api/v1/moq?room=abc"), + ) + } + + // ---------------------------------------------------------- fakes + + private class FakeNestsClient( + private val info: NestsRoomInfo, + ) : NestsClient { + override suspend fun resolveRoom( + serviceBase: String, + roomId: String, + signer: NostrSigner, + ): NestsRoomInfo = info + } + + private class ThrowingNestsClient( + private val toThrow: NestsException, + ) : NestsClient { + override suspend fun resolveRoom( + serviceBase: String, + roomId: String, + signer: NostrSigner, + ): NestsRoomInfo = throw toThrow + } + + private class ConstantWebTransportFactory( + private val session: WebTransportSession, + ) : WebTransportFactory { + var lastConnectedAuthority: String? = null + private set + var lastConnectedPath: String? = null + private set + var lastBearer: String? = null + private set + + override suspend fun connect( + authority: String, + path: String, + bearerToken: String?, + ): WebTransportSession { + lastConnectedAuthority = authority + lastConnectedPath = path + lastBearer = bearerToken + return session + } + } + + private class ThrowingTransportFactory( + private val toThrow: WebTransportException, + ) : WebTransportFactory { + override suspend fun connect( + authority: String, + path: String, + bearerToken: String?, + ): WebTransportSession = throw toThrow + } + + private class NeverConnectFactory : WebTransportFactory { + var connectCallCount = 0 + private set + + override suspend fun connect( + authority: String, + path: String, + bearerToken: String?, + ): WebTransportSession { + connectCallCount++ + fail("transport.connect must not be called") + } + } +}