diff --git a/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/MoqLiteNestsListener.kt b/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/MoqLiteNestsListener.kt new file mode 100644 index 000000000..48c0ea1dd --- /dev/null +++ b/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/MoqLiteNestsListener.kt @@ -0,0 +1,137 @@ +/* + * 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.MoqObject +import com.vitorpamplona.nestsclient.moq.SubscribeHandle +import com.vitorpamplona.nestsclient.moq.SubscribeOk +import com.vitorpamplona.nestsclient.moq.lite.MoqLiteSession +import com.vitorpamplona.nestsclient.moq.lite.MoqLiteSubscribeException +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.map +import java.util.concurrent.atomic.AtomicLong + +/** + * Moq-lite-backed [NestsListener]. Wraps a connected [MoqLiteSession] + * and exposes the same listener API the IETF [DefaultNestsListener] + * does, so [connectNestsListener] can swap the framing layer without + * changing the public surface that [com.vitorpamplona.amethyst.commons.viewmodels.AudioRoomViewModel] + * and downstream UI consume. + * + * Subscription mapping per the audio-rooms NIP draft + nests JS + * reference (`@moq/watch/broadcast-Djx16jPC.js:6237`, + * `@moq/publish/screen-B680RFft.js:5641`): + * - subscribeSpeaker(pubkey) → MoqLiteSession.subscribe( + * broadcast = pubkey, track = "audio/data") + * - the catalog (`"catalog.json"`) is not required for audio-only + * listening; we'll add a parallel subscribe in a follow-up if + * speaker-state metadata becomes useful. + * + * Frame adaptation: each [com.vitorpamplona.nestsclient.moq.lite.MoqLiteFrame] + * is wrapped in a [MoqObject] so existing decoder / player code keeps + * working unchanged. moq-lite frames carry no per-frame envelope, so + * [MoqObject.objectId] is synthesised as a per-subscription monotonic + * counter and [MoqObject.publisherPriority] uses the IETF default. + */ +class MoqLiteNestsListener internal constructor( + private val session: MoqLiteSession, + 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}" + } + val handle = + try { + session.subscribe( + broadcast = speakerPubkeyHex, + track = AUDIO_TRACK, + ) + } catch (e: MoqLiteSubscribeException) { + // The IETF SubscribeHandle path conventionally surfaces + // protocol-level rejections through the same exception + // shape MoqProtocolException uses, so wrapping here + // avoids leaking moq-lite types into UI consumers. + throw com.vitorpamplona.nestsclient.moq.MoqProtocolException( + "moq-lite subscribe rejected: ${e.message}", + e, + ) + } + + val objectIdSeq = AtomicLong(0L) + val mapped = + handle.frames.map { frame -> + MoqObject( + trackAlias = handle.id, + groupId = frame.groupSequence, + objectId = objectIdSeq.getAndIncrement(), + publisherPriority = MoqLiteSession.DEFAULT_PRIORITY, + payload = frame.payload, + ) + } + + return SubscribeHandle( + subscribeId = handle.id, + trackAlias = handle.id, // moq-lite has no separate alias; reuse id + ok = synthesizedOk(handle.ok), + objects = mapped, + unsubscribeAction = { handle.unsubscribe() }, + ) + } + + override suspend fun close() { + if (state.value is NestsListenerState.Closed) return + runCatching { session.close() } + mutableState.value = NestsListenerState.Closed + } + + private fun synthesizedOk(ok: com.vitorpamplona.nestsclient.moq.lite.MoqLiteSubscribeOk): SubscribeOk = + SubscribeOk( + subscribeId = 0L, // not used by downstream consumers + expiresMs = ok.maxLatencyMillis, + groupOrder = 0x01, + // moq-lite has no "content exists" signal — assume no history. + contentExists = false, + largestGroupId = null, + largestObjectId = null, + ) + + companion object { + /** + * Track name nests publishers use for the Opus audio stream. + * Source: `@moq/publish/screen-B680RFft.js:5641` + * (`static TRACK = "audio/data"`). + */ + const val AUDIO_TRACK: String = "audio/data" + + /** + * Catalog track name moq-lite watchers subscribe to first to + * receive a JSON description of the broadcast. Audio-only + * listening doesn't need it; reserved here for the nests + * speaker-metadata follow-up. + */ + const val CATALOG_TRACK: String = "catalog.json" + } +} diff --git a/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/NestsConnect.kt b/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/NestsConnect.kt index 2c1c9b947..773656e8d 100644 --- a/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/NestsConnect.kt +++ b/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/NestsConnect.kt @@ -39,16 +39,13 @@ import kotlinx.coroutines.flow.MutableStateFlow * 1. Mint a JWT — POST `/auth` with NIP-98 + namespace body * (see [NestsClient.mintToken]). * 2. Open a [com.vitorpamplona.nestsclient.transport.WebTransportSession] - * against the [room.endpoint] via [transport], passing the JWT as the - * bearer token. - * 3. Run the MoQ SETUP handshake. - * - * Note: step 3 runs the IETF `draft-ietf-moq-transport-17` SETUP, which is - * NOT what nostrnests's relay (moq-lite) expects. Steps 1 and 2 are - * already on the moq-rs wire shape (path = `/`, JWT in - * `?jwt=`). The MoQ framing layer needs a moq-lite codec swap before - * end-to-end interop works — see - * `nestsClient/plans/2026-04-26-moq-lite-gap.md`. + * against the [room.endpoint] via [transport]. The path is + * `/?jwt=` — moq-rs treats the URL path as + * `claims.root` and reads the JWT from `?jwt=`. + * 3. Wrap the WT session in a [com.vitorpamplona.nestsclient.moq.lite.MoqLiteSession]. + * moq-lite Lite-03 has NO in-band SETUP message — the WT handshake + * itself is the handshake, version is selected by the ALPN + * `moq-lite-03`. * * The returned [NestsListener] is in state [NestsListenerState.Connected]; * if any step fails, the listener is returned in @@ -58,9 +55,12 @@ import kotlinx.coroutines.flow.MutableStateFlow * @param room per-room config built from the NIP-53 kind 30312 event by * the caller (UI / VM). * @param signer NIP-98 signer for the mintToken HTTP call. - * @param scope where the [MoqSession] pumps live (typically the caller's + * @param scope where the moq-lite pumps live (typically the caller's * ViewModel scope so they cancel when the screen leaves). - * @param supportedMoqVersions in preference order; defaults to draft-17. + * @param supportedMoqVersions retained for source-compatible callers but + * currently a no-op — moq-lite negotiates via ALPN, not an in-band + * SETUP message. Will be repurposed to drive ALPN preference once a + * second moq-lite version ships. */ suspend fun connectNestsListener( httpClient: NestsClient, @@ -113,35 +113,37 @@ suspend fun connectNestsListener( state.value = NestsListenerState.Connecting(NestsListenerState.Connecting.ConnectStep.MoqHandshake) + // moq-lite Lite-03 has NO setup message — the WebTransport handshake + // itself is the handshake, version is selected by ALPN. The + // `supportedMoqVersions` parameter is retained for backward compat + // with callers that may want to gate on a version mismatch in + // future, but it is currently a no-op. val moq = try { - MoqSession.client(webTransport, scope).also { it.setup(supportedMoqVersions) } + com.vitorpamplona.nestsclient.moq.lite.MoqLiteSession + .client(webTransport, scope) } catch (t: Throwable) { - runCatching { webTransport.close(0, "moq setup failed") } - state.value = NestsListenerState.Failed("MoQ handshake failed: ${t.message}", t) + runCatching { webTransport.close(0, "moq-lite session init failed") } + state.value = NestsListenerState.Failed("moq-lite session init 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(room, negotiatedVersion) - return DefaultNestsListener( + state.value = NestsListenerState.Connected(room, MOQ_LITE_03_VERSION) + return MoqLiteNestsListener( session = moq, - // moq-auth's JWT claim is `root: ""` — the simplest - // wire-level mapping is a 1-segment TrackNamespace whose only - // element is that exact string. Phase-3 interop test will - // confirm; if the real relay expects a multi-segment tuple - // (e.g. split on `/` or `:`), adjust here. - roomNamespace = TrackNamespace.of(room.moqNamespace()), mutableState = state, ) } +/** + * Synthetic version code reported on [NestsListenerState.Connected.negotiatedMoqVersion] + * for moq-lite Lite-03 sessions. moq-lite negotiates the wire version + * via ALPN rather than an in-band SETUP exchange, so there is no real + * "version" the peer reports back. The constant carries the ALPN suffix + * `-03` in the low bytes for diagnostic display. + */ +const val MOQ_LITE_03_VERSION: Long = 0x6D71_6C03L + /** * Build a no-op [NestsListener] in a Failed state for callers that want a * uniform return type even on early-failure paths. diff --git a/nestsClient/src/commonTest/kotlin/com/vitorpamplona/nestsclient/NestsConnectTest.kt b/nestsClient/src/commonTest/kotlin/com/vitorpamplona/nestsclient/NestsConnectTest.kt index 274dc37e0..c81bbc5ce 100644 --- a/nestsClient/src/commonTest/kotlin/com/vitorpamplona/nestsclient/NestsConnectTest.kt +++ b/nestsClient/src/commonTest/kotlin/com/vitorpamplona/nestsclient/NestsConnectTest.kt @@ -20,10 +20,6 @@ */ 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 @@ -31,8 +27,6 @@ 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 @@ -50,20 +44,15 @@ class NestsConnectTest { ) @Test - fun connect_walks_mintToken_then_transport_then_moq_handshake() = + fun connect_walks_mintToken_then_transport_then_moq_lite_session() = runTest { - val (clientSide, serverSide) = FakeWebTransport.pair() + val (clientSide, _) = FakeWebTransport.pair() val httpClient = FakeNestsClient(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()))) - } - + // moq-lite Lite-03 has NO setup message — the WT handshake + // itself is the handshake. As soon as connect returns, the + // listener is in Connected and ready to subscribe. val listener = connectNestsListener( httpClient = httpClient, @@ -72,11 +61,14 @@ class NestsConnectTest { room = room, signer = NostrSignerInternal(KeyPair()), ) - server.await() val connected = assertIs(listener.state.value) assertEquals(room, connected.room) - assertEquals(MoqVersion.DRAFT_17, connected.negotiatedMoqVersion) + assertEquals( + MOQ_LITE_03_VERSION, + connected.negotiatedMoqVersion, + "synthesised version code identifies the moq-lite-03 ALPN", + ) assertEquals("relay.example.com", transport.lastConnectedAuthority) assertEquals(