mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-09 08:04:45 +00:00
feat(napplet): live subscription tail, multi-filters, resource.cancel
Implements the remaining tractable conformance follow-ups: - Live subscription tail: relay.subscribe now opens a real client.subscribe whose SubscriptionListener streams relay.event (stored + live), relay.eose, and relay.closed pushes keyed by subId, instead of a one-shot snapshot. relay.close unsubscribes (tracked in liveSubs, torn down in onDestroy). The broker only authorizes the subscription (RELAY consent) and returns Subscribed; the host owns the live stream. Shim dispatches relay.closed too. - Multi-filters: relay.query/subscribe honor every filter in filters[], not just the first (decodeFilterList; gateway query(List<Filter>); queryEvents unions across filters; max limit applied). - resource.cancel: accepted at the host edge as a no-op Done. Conformance tests extended (multi-filter decode, relay.closed push); commons:jvmTest and the amethyst napplet suite pass. Still open: identity getList/getZaps/getBadges + onChanged (shapes underspecified), the keys.action push (needs a host trigger UI), the resource nostr: scheme, inc + the niche domains, and on-device verification. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
This commit is contained in:
@@ -26,9 +26,18 @@ All four conformance breakers below are fixed (codec pinned by `NappletSdkConfor
|
||||
request `Blob` as base64 so it survives the bridge; the gateway uploads via the app's
|
||||
`BlossomUploader` to the user's kind:10063 server with a signed auth event.
|
||||
|
||||
Still open (◐): multi-`filters` queries, identity `getList`/`getZaps`/`getBadges` + `onChanged`,
|
||||
`resource` `nostr:` + `cancel`, `relay.closed` + live subscription tail, and `inc`/`intent`/the
|
||||
niche domains. Plus **on-device verification** of the host/shell behavior.
|
||||
Also now implemented (the ◐ follow-ups):
|
||||
- **Live subscription tail** — `relay.subscribe` opens a real `client.subscribe` whose listener
|
||||
streams `relay.event` (stored + live), `relay.eose`, and `relay.closed` pushes by `subId`;
|
||||
`relay.close` unsubscribes (tracked in `liveSubs`, torn down in `onDestroy`).
|
||||
- **Multi-`filters`** — `relay.query`/`subscribe` honor every filter in the `filters[]` array,
|
||||
not just the first (`decodeFilterList`, gateway `query(List<Filter>)`).
|
||||
- **`resource.cancel`** — accepted at the host edge as a no-op `Done`.
|
||||
|
||||
Still open: identity `getList`/`getZaps`/`getBadges` + `onChanged` (object shapes / list-type
|
||||
semantics underspecified), the `keys.action` push (needs a host command-palette UI to *trigger*
|
||||
actions — registration already conforms), the `resource` `nostr:` scheme (unspecified bytes),
|
||||
`inc`/`intent`/the niche domains, and **on-device verification** of the host/shell behavior.
|
||||
|
||||
## Base envelope & error convention (verified)
|
||||
|
||||
|
||||
@@ -56,7 +56,9 @@ import com.vitorpamplona.quartz.lightning.LnInvoiceUtil
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAll
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcErrorResponse
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceErrorResponse
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceSuccessResponse
|
||||
@@ -83,6 +85,8 @@ import java.io.ByteArrayInputStream
|
||||
import java.net.InetSocketAddress
|
||||
import java.net.Proxy
|
||||
import java.net.URLDecoder
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
|
||||
/**
|
||||
* The trust boundary's main-process endpoint. The untrusted `:napplet` process binds this
|
||||
@@ -106,6 +110,10 @@ class NappletBrokerService : Service() {
|
||||
|
||||
private val incoming by lazy { Messenger(Handler(Looper.getMainLooper(), ::handleMessage)) }
|
||||
|
||||
// Live relay subscriptions: applet subId -> client subId, so relay.close (and teardown) can stop them.
|
||||
private val liveSubs = ConcurrentHashMap<String, String>()
|
||||
private val liveSeq = AtomicInteger(0)
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? {
|
||||
// Defense in depth on top of exported=false: only our own UID may bind.
|
||||
if (Binder.getCallingUid() != Process.myUid()) return null
|
||||
@@ -113,6 +121,12 @@ class NappletBrokerService : Service() {
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
val client =
|
||||
Amethyst.instance.sessionManager
|
||||
.loggedInAccount()
|
||||
?.client
|
||||
liveSubs.values.forEach { runCatching { client?.unsubscribe(it) } }
|
||||
liveSubs.clear()
|
||||
scope.cancel()
|
||||
super.onDestroy()
|
||||
}
|
||||
@@ -135,23 +149,31 @@ class NappletBrokerService : Service() {
|
||||
|
||||
val requestType = runCatching { NappletProtocolJson.readType(payload) }.getOrNull() ?: "napplet"
|
||||
scope.launch {
|
||||
// Unsubscribe is fire-and-forget: snapshot subscriptions have no live tail to cancel.
|
||||
if (requestType == "relay.close") {
|
||||
reply(replyTo, requestId, NappletProtocolJson.encodeResponse(requestType, NappletResponse.Done))
|
||||
return@launch
|
||||
// Fire-and-forget edge ops that don't go through the broker.
|
||||
when (requestType) {
|
||||
"relay.close" -> {
|
||||
runCatching { NappletProtocolJson.readSubId(payload) }.getOrNull()?.let { closeLiveSubscription(it) }
|
||||
reply(replyTo, requestId, NappletProtocolJson.encodeResponse(requestType, NappletResponse.Done))
|
||||
return@launch
|
||||
}
|
||||
"resource.cancel" -> {
|
||||
reply(replyTo, requestId, NappletProtocolJson.encodeResponse(requestType, NappletResponse.Done))
|
||||
return@launch
|
||||
}
|
||||
}
|
||||
|
||||
val response = process(identity, declared, payload)
|
||||
|
||||
// A subscription is answered with relay.event/relay.eose pushes (keyed by subId), not a
|
||||
// .result — matching @napplet/shim. Today it delivers the initial matches then EOSE; a
|
||||
// live tail is a follow-up.
|
||||
// A subscription is answered with relay.event/relay.eose/relay.closed pushes keyed by
|
||||
// subId (matching @napplet/shim) — the host opens a live relay subscription once the
|
||||
// broker authorizes it. A non-authorized subscription closes immediately with an EOSE.
|
||||
val subId = if (requestType == "relay.subscribe") runCatching { NappletProtocolJson.readSubId(payload) }.getOrNull() else null
|
||||
if (subId != null) {
|
||||
if (response is NappletResponse.Events) {
|
||||
response.events.forEach { push(replyTo, NappletProtocolJson.encodeRelayEvent(subId, it)) }
|
||||
if (response is NappletResponse.Subscribed) {
|
||||
openLiveSubscription(subId, payload, replyTo)
|
||||
} else {
|
||||
push(replyTo, NappletProtocolJson.encodeRelayEose(subId))
|
||||
}
|
||||
push(replyTo, NappletProtocolJson.encodeRelayEose(subId))
|
||||
} else {
|
||||
reply(replyTo, requestId, NappletProtocolJson.encodeResponse(requestType, response))
|
||||
}
|
||||
@@ -191,7 +213,7 @@ class NappletBrokerService : Service() {
|
||||
return relays.map { it.url }
|
||||
}
|
||||
|
||||
override suspend fun query(filter: Filter): List<Event> = queryEvents(account, filter)
|
||||
override suspend fun query(filters: List<Filter>): List<Event> = queryEvents(account, filters)
|
||||
}
|
||||
|
||||
val consent =
|
||||
@@ -416,27 +438,86 @@ class NappletBrokerService : Service() {
|
||||
return NappletResource(bytes, contentType)
|
||||
}
|
||||
|
||||
/** Bounded live relay fetch (EOSE/timeout) merged with the local cache, newest-first. */
|
||||
/** Bounded live relay fetch (EOSE/timeout) for all [filters], merged with the local cache, newest-first. */
|
||||
private suspend fun queryEvents(
|
||||
account: Account,
|
||||
filter: Filter,
|
||||
filters: List<Filter>,
|
||||
): List<Event> {
|
||||
if (filters.isEmpty()) return emptyList()
|
||||
val relays = account.homeRelays.flow.value
|
||||
val fromRelays =
|
||||
if (relays.isEmpty()) {
|
||||
emptyList()
|
||||
} else {
|
||||
runCatching {
|
||||
account.client.fetchAll(filters = relays.associateWith { listOf(filter) }, timeoutMs = QUERY_TIMEOUT_MS)
|
||||
account.client.fetchAll(filters = relays.associateWith { filters }, timeoutMs = QUERY_TIMEOUT_MS)
|
||||
}.getOrDefault(emptyList())
|
||||
}
|
||||
val fromCache = account.cache.filter(filter).mapNotNull { it.event }
|
||||
val fromCache = filters.flatMap { filter -> account.cache.filter(filter).mapNotNull { it.event } }
|
||||
|
||||
val merged =
|
||||
(fromRelays + fromCache)
|
||||
.distinctBy { it.id }
|
||||
.sortedByDescending { it.createdAt }
|
||||
return filter.limit?.let { merged.take(it) } ?: merged
|
||||
val limit = filters.mapNotNull { it.limit }.maxOrNull()
|
||||
return limit?.let { merged.take(it) } ?: merged
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens a live relay subscription for [nappletSubId], streaming `relay.event`/`relay.eose`/
|
||||
* `relay.closed` pushes to the applet as events arrive. Replaces any existing subscription for
|
||||
* the same id. Reached only after the broker authorized the subscription (RELAY consent).
|
||||
*/
|
||||
private fun openLiveSubscription(
|
||||
nappletSubId: String,
|
||||
payload: String,
|
||||
replyTo: Messenger,
|
||||
) {
|
||||
val account = Amethyst.instance.sessionManager.loggedInAccount()
|
||||
val filters = runCatching { NappletProtocolJson.decodeFilterList(payload) }.getOrDefault(emptyList())
|
||||
val relays = account?.homeRelays?.flow?.value ?: emptySet()
|
||||
if (account == null || filters.isEmpty() || relays.isEmpty()) {
|
||||
push(replyTo, NappletProtocolJson.encodeRelayEose(nappletSubId))
|
||||
return
|
||||
}
|
||||
|
||||
closeLiveSubscription(nappletSubId)
|
||||
val clientSubId = "napplet-$nappletSubId-${liveSeq.incrementAndGet()}"
|
||||
liveSubs[nappletSubId] = clientSubId
|
||||
|
||||
val listener =
|
||||
object : SubscriptionListener {
|
||||
override fun onEvent(
|
||||
event: Event,
|
||||
isLive: Boolean,
|
||||
relay: NormalizedRelayUrl,
|
||||
forFilters: List<Filter>?,
|
||||
) = push(replyTo, NappletProtocolJson.encodeRelayEvent(nappletSubId, event))
|
||||
|
||||
override fun onEose(
|
||||
relay: NormalizedRelayUrl,
|
||||
forFilters: List<Filter>?,
|
||||
) = push(replyTo, NappletProtocolJson.encodeRelayEose(nappletSubId))
|
||||
|
||||
override fun onClosed(
|
||||
message: String,
|
||||
relay: NormalizedRelayUrl,
|
||||
forFilters: List<Filter>?,
|
||||
) = push(replyTo, NappletProtocolJson.encodeRelayClosed(nappletSubId, message))
|
||||
}
|
||||
|
||||
runCatching { account.client.subscribe(clientSubId, relays.associateWith { filters }, listener) }
|
||||
}
|
||||
|
||||
/** Stops the live subscription for [nappletSubId], if any. */
|
||||
private fun closeLiveSubscription(nappletSubId: String) {
|
||||
val clientSubId = liveSubs.remove(nappletSubId) ?: return
|
||||
runCatching {
|
||||
Amethyst.instance.sessionManager
|
||||
.loggedInAccount()
|
||||
?.client
|
||||
?.unsubscribe(clientSubId)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -511,10 +511,11 @@ class NappletHostActivity : ComponentActivity() {
|
||||
var msg; if (typeof e.data === 'string') { try { msg = JSON.parse(e.data); } catch (_) { return; } } else { msg = e.data; }
|
||||
if (!msg) return;
|
||||
// Subscription pushes are keyed by subId, not a request id.
|
||||
if (msg.type === 'relay.event' || msg.type === 'relay.eose') {
|
||||
if (msg.type === 'relay.event' || msg.type === 'relay.eose' || msg.type === 'relay.closed') {
|
||||
var sub = subs[msg.subId]; if (!sub) return;
|
||||
if (msg.type === 'relay.event') { if (sub.onEvent) sub.onEvent(msg.event); }
|
||||
else { if (sub.onEose) sub.onEose(); }
|
||||
else if (msg.type === 'relay.eose') { if (sub.onEose) sub.onEose(); }
|
||||
else { delete subs[msg.subId]; if (sub.onClosed) sub.onClosed(msg.reason); }
|
||||
return;
|
||||
}
|
||||
// keys.action push: the shell triggers a registered keyboard/command action.
|
||||
|
||||
@@ -77,6 +77,17 @@ object NappletProtocolJson {
|
||||
put("subId", subId)
|
||||
}.toString()
|
||||
|
||||
/** A `relay.closed` push: a relay (or the shell) ended the subscription [subId]. */
|
||||
fun encodeRelayClosed(
|
||||
subId: String,
|
||||
reason: String,
|
||||
): String =
|
||||
buildJsonObject {
|
||||
put("type", "relay.closed")
|
||||
put("subId", subId)
|
||||
put("reason", reason)
|
||||
}.toString()
|
||||
|
||||
/**
|
||||
* The `shell.init` handshake reply (`@napplet/core`): the capability environment the napplet
|
||||
* caches and answers `shell.supports()` from. [domains] is the set of NAP domains this shell
|
||||
@@ -115,8 +126,8 @@ object NappletProtocolJson {
|
||||
encryption = o.str("encryption") ?: "nip44",
|
||||
)
|
||||
}
|
||||
"relay.query" -> NappletRequest.QueryEvents(decodeFilter(o))
|
||||
"relay.subscribe" -> NappletRequest.Subscribe(decodeFilter(o))
|
||||
"relay.query" -> NappletRequest.QueryEvents(decodeFilterList(o))
|
||||
"relay.subscribe" -> NappletRequest.Subscribe(decodeFilterList(o))
|
||||
"storage.get" -> NappletRequest.StorageGet(o.req("key"))
|
||||
"storage.set" -> NappletRequest.StorageSet(o.req("key"), o.req("value"))
|
||||
"storage.remove" -> NappletRequest.StorageRemove(o.req("key"))
|
||||
@@ -220,6 +231,10 @@ object NappletProtocolJson {
|
||||
is NappletResponse.Done -> {
|
||||
put("ok", true)
|
||||
}
|
||||
// Subscribe is acknowledged here, but the host streams pushes instead of sending this.
|
||||
is NappletResponse.Subscribed -> {
|
||||
put("ok", true)
|
||||
}
|
||||
is NappletResponse.Denied -> {
|
||||
put("ok", false)
|
||||
put("error", "denied")
|
||||
@@ -239,10 +254,16 @@ object NappletProtocolJson {
|
||||
}
|
||||
}.toString()
|
||||
|
||||
/** Parses a Nostr filter from `filter` (object) or the first of `filters` (array). */
|
||||
private fun decodeFilter(o: JsonObject): Filter {
|
||||
val f = o["filter"]?.jsonObject ?: o["filters"]?.jsonArray?.firstOrNull()?.jsonObject ?: JsonObject(emptyMap())
|
||||
/** Parses every Nostr filter from a `filters` array (each entry) or a single `filter` object. */
|
||||
fun decodeFilterList(envelopeJson: String): List<Filter> = decodeFilterList(json.parseToJsonElement(envelopeJson).jsonObject)
|
||||
|
||||
private fun decodeFilterList(o: JsonObject): List<Filter> {
|
||||
o["filters"]?.jsonArray?.let { arr -> return arr.map { decodeFilterObject(it.jsonObject) } }
|
||||
o["filter"]?.jsonObject?.let { return listOf(decodeFilterObject(it)) }
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
private fun decodeFilterObject(f: JsonObject): Filter {
|
||||
val tags = mutableMapOf<String, List<String>>()
|
||||
for ((key, value) in f) {
|
||||
if (key.startsWith("#") && key.length == 2) {
|
||||
|
||||
+15
-6
@@ -96,15 +96,24 @@ class NappletProtocolJsonTest {
|
||||
@Test
|
||||
fun decodesQueryAndSubscribeFromFilterObjectOrFiltersArray() {
|
||||
val single = NappletProtocolJson.decodeRequest("""{"type":"relay.query","filter":{"kinds":[1],"#t":["nostr"],"limit":5}}""") as NappletRequest.QueryEvents
|
||||
assertEquals(listOf(1), single.filter.kinds)
|
||||
assertEquals(listOf("nostr"), single.filter.tags?.get("t"))
|
||||
assertEquals(5, single.filter.limit)
|
||||
assertEquals(listOf(1), single.filters.first().kinds)
|
||||
assertEquals(
|
||||
listOf("nostr"),
|
||||
single.filters
|
||||
.first()
|
||||
.tags
|
||||
?.get("t"),
|
||||
)
|
||||
assertEquals(5, single.filters.first().limit)
|
||||
|
||||
val array = NappletProtocolJson.decodeRequest("""{"type":"relay.query","filters":[{"authors":["aa"]}]}""") as NappletRequest.QueryEvents
|
||||
assertEquals(listOf("aa"), array.filter.authors)
|
||||
// Multiple filters are all honored, not just the first.
|
||||
val array = NappletProtocolJson.decodeRequest("""{"type":"relay.query","filters":[{"authors":["aa"]},{"kinds":[7]}]}""") as NappletRequest.QueryEvents
|
||||
assertEquals(2, array.filters.size)
|
||||
assertEquals(listOf("aa"), array.filters[0].authors)
|
||||
assertEquals(listOf(7), array.filters[1].kinds)
|
||||
|
||||
val sub = NappletProtocolJson.decodeRequest("""{"type":"relay.subscribe","filter":{"kinds":[1]}}""") as NappletRequest.Subscribe
|
||||
assertEquals(listOf(1), sub.filter.kinds)
|
||||
assertEquals(listOf(1), sub.filters.first().kinds)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
+12
-5
@@ -79,14 +79,15 @@ class NappletSdkConformanceTest {
|
||||
|
||||
@Test
|
||||
fun relayQueryAndSubscribeReadTheFiltersArray() {
|
||||
// RelayQueryMessage: { type:'relay.query', id, filters: NostrFilter[] }
|
||||
val q = NappletProtocolJson.decodeRequest("""{"type":"relay.query","id":"1","filters":[{"kinds":[1],"authors":["aa"]}]}""") as NappletRequest.QueryEvents
|
||||
assertEquals(listOf(1), q.filter.kinds)
|
||||
assertEquals(listOf("aa"), q.filter.authors)
|
||||
// RelayQueryMessage: { type:'relay.query', id, filters: NostrFilter[] } — all filters honored.
|
||||
val q = NappletProtocolJson.decodeRequest("""{"type":"relay.query","id":"1","filters":[{"kinds":[1]},{"authors":["aa"]}]}""") as NappletRequest.QueryEvents
|
||||
assertEquals(2, q.filters.size)
|
||||
assertEquals(listOf(1), q.filters[0].kinds)
|
||||
assertEquals(listOf("aa"), q.filters[1].authors)
|
||||
|
||||
// RelaySubscribeMessage: { type:'relay.subscribe', id, subId, filters, relay? }
|
||||
val s = NappletProtocolJson.decodeRequest("""{"type":"relay.subscribe","id":"1","subId":"s1","filters":[{"kinds":[7]}]}""") as NappletRequest.Subscribe
|
||||
assertEquals(listOf(7), s.filter.kinds)
|
||||
assertEquals(listOf(7), s.filters.first().kinds)
|
||||
// The subId the pushes are keyed by is read from the raw envelope by the service.
|
||||
assertEquals("s1", NappletProtocolJson.readSubId("""{"type":"relay.subscribe","id":"1","subId":"s1","filters":[{}]}"""))
|
||||
}
|
||||
@@ -133,6 +134,12 @@ class NappletSdkConformanceTest {
|
||||
val eose = json.parseToJsonElement(NappletProtocolJson.encodeRelayEose("s1")).jsonObject
|
||||
assertEquals("relay.eose", eose["type"]?.jsonPrimitive?.content)
|
||||
assertEquals("s1", eose["subId"]?.jsonPrimitive?.content)
|
||||
|
||||
// RelayClosedMessage (PUSH): { type:'relay.closed', subId, reason }
|
||||
val closed = json.parseToJsonElement(NappletProtocolJson.encodeRelayClosed("s1", "done")).jsonObject
|
||||
assertEquals("relay.closed", closed["type"]?.jsonPrimitive?.content)
|
||||
assertEquals("s1", closed["subId"]?.jsonPrimitive?.content)
|
||||
assertEquals("done", closed["reason"]?.jsonPrimitive?.content)
|
||||
}
|
||||
|
||||
// ---------- identity (Identity*Message) ----------
|
||||
|
||||
+5
-4
@@ -168,13 +168,14 @@ class NappletBroker(
|
||||
|
||||
is NappletRequest.QueryEvents -> {
|
||||
val gateway = relay ?: return NappletResponse.Unsupported("relay.query")
|
||||
NappletResponse.Events(gateway.query(request.filter))
|
||||
NappletResponse.Events(gateway.query(request.filters))
|
||||
}
|
||||
|
||||
// Live tailing is a follow-up; for now subscribe returns the initial matches.
|
||||
// The broker only authorizes the subscription (consent + declaration); the host opens the
|
||||
// live relay subscription and streams relay.event/relay.eose/relay.closed by subId.
|
||||
is NappletRequest.Subscribe -> {
|
||||
val gateway = relay ?: return NappletResponse.Unsupported("relay.subscribe")
|
||||
NappletResponse.Events(gateway.query(request.filter))
|
||||
relay ?: return NappletResponse.Unsupported("relay.subscribe")
|
||||
NappletResponse.Subscribed
|
||||
}
|
||||
|
||||
is NappletRequest.StorageGet -> {
|
||||
|
||||
+2
-2
@@ -50,8 +50,8 @@ interface NappletRelayGateway {
|
||||
/** Publishes [event] and returns the relay URLs that accepted it (empty = nowhere reached). */
|
||||
suspend fun publish(event: Event): List<String>
|
||||
|
||||
/** Returns events matching [filter] (e.g. from the local cache and/or a bounded relay fetch). */
|
||||
suspend fun query(filter: Filter): List<Event>
|
||||
/** Returns events matching any of [filters] (e.g. from the local cache and/or a bounded relay fetch). */
|
||||
suspend fun query(filters: List<Filter>): List<Event>
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+5
-5
@@ -140,19 +140,19 @@ sealed interface NappletRequest {
|
||||
}
|
||||
}
|
||||
|
||||
/** Read events matching [filter] (from the cache and/or a bounded relay fetch). */
|
||||
/** Read events matching [filters] (from the cache and/or a bounded relay fetch). */
|
||||
data class QueryEvents(
|
||||
val filter: Filter,
|
||||
val filters: List<Filter>,
|
||||
) : NappletRequest {
|
||||
override val capability get() = NappletCapability.RELAY
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to events matching [filter]. The shell currently answers with the initial matches
|
||||
* (like a query); a live tail over the existing reply channel is a follow-up.
|
||||
* Subscribe to events matching [filters]. The shell opens a live relay subscription and pushes
|
||||
* `relay.event`/`relay.eose`/`relay.closed` keyed by the applet's `subId`.
|
||||
*/
|
||||
data class Subscribe(
|
||||
val filter: Filter,
|
||||
val filters: List<Filter>,
|
||||
) : NappletRequest {
|
||||
override val capability get() = NappletCapability.RELAY
|
||||
}
|
||||
|
||||
+3
@@ -60,6 +60,9 @@ sealed interface NappletResponse {
|
||||
val actionId: String,
|
||||
) : NappletResponse
|
||||
|
||||
/** `relay.subscribe` was authorized; the host now streams `relay.event`/`relay.eose` pushes. */
|
||||
data object Subscribed : NappletResponse
|
||||
|
||||
/** Result of a storage read; [value] is null when the key is absent. */
|
||||
data class StorageValue(
|
||||
val value: String?,
|
||||
|
||||
+2
-2
@@ -77,7 +77,7 @@ class NappletBrokerTest {
|
||||
return listOf("wss://relay.example")
|
||||
}
|
||||
|
||||
override suspend fun query(filter: Filter): List<Event> = queryResult
|
||||
override suspend fun query(filters: List<Filter>): List<Event> = queryResult
|
||||
}
|
||||
|
||||
private class RecordingWallet(
|
||||
@@ -276,7 +276,7 @@ class NappletBrokerTest {
|
||||
|
||||
val response =
|
||||
broker(ScriptedPrompt(GrantState.ALLOW_ONCE), relay = relay)
|
||||
.handle(applet, NappletRequest.QueryEvents(Filter(kinds = listOf(1))), allDeclared)
|
||||
.handle(applet, NappletRequest.QueryEvents(listOf(Filter(kinds = listOf(1)))), allDeclared)
|
||||
|
||||
assertEquals(NappletResponse.Events(listOf(cached)), response)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user