diff --git a/amethyst/plans/2026-06-19-napplet-sandbox-host.md b/amethyst/plans/2026-06-19-napplet-sandbox-host.md index 2e73dae8a8..05dd460f40 100644 --- a/amethyst/plans/2026-06-19-napplet-sandbox-host.md +++ b/amethyst/plans/2026-06-19-napplet-sandbox-host.md @@ -225,9 +225,9 @@ Deferred to v2; v1 nails the single-applet boundary first. - ✅ **Privacy:** the host routes blob fetches through the user's Tor SOCKS proxy when active; the port is passed in by the launcher (main process) so the sandbox process never touches the account-bound HTTP stack. -- **Relay discovery:** `NappletsScreen` reads only what's already in `LocalCache` — - no dedicated subscription fetches napplet manifests yet, so the list is empty - until one arrives via another feed. A `NappletsFilterAssemblerSubscription` is the - next step. +- ✅ **Relay discovery:** `NappletsFilterAssembler` (registered in + `RelaySubscriptionsCoordinator`, invoked by `NappletsScreen`) REQs kinds + 15129/35129 from the user's read relays while the screen is open, so manifests + flow into `LocalCache` for the list to render. - **Consent UX:** reuse `commons/.../ui/signing` styling; show the manifest title and a per-capability rationale; batch-grant on first run. diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt index 9a7c1b5342..2382473d74 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt @@ -53,6 +53,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.livestreams.datasource.Live import com.vitorpamplona.amethyst.ui.screen.loggedIn.longs.datasource.LongsFilterAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.music.datasource.MusicPlaylistsFilterAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.music.datasource.MusicTracksFilterAssembler +import com.vitorpamplona.amethyst.ui.screen.loggedIn.napplets.datasource.NappletsFilterAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.datasource.NestRoomFilterAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.datasource.NestRoomLivenessAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.datasource.NestsFilterAssembler @@ -139,6 +140,7 @@ class RelaySubscriptionsCoordinator( val podcasts = PodcastsFilterAssembler(client) val onePodcast = OnePodcastFilterAssembler(client) val softwareApps = SoftwareAppsFilterAssembler(client) + val napplets = NappletsFilterAssembler(client) val badges = BadgesFilterAssembler(client) val profileBadges = ProfileBadgesFilterAssembler(client) val profileAppRecommendations = ProfileAppRecommendationsFilterAssembler(client) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/NappletsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/NappletsScreen.kt index ef098d7172..031e20ddf8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/NappletsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/NappletsScreen.kt @@ -49,6 +49,7 @@ import com.vitorpamplona.amethyst.napplet.NappletLauncher import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.napplets.datasource.NappletsFilterAssemblerSubscription import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip5dNapplets.NamedNappletEvent @@ -66,6 +67,10 @@ fun NappletsScreen( nav: INav, ) { val context = LocalContext.current + + // Pull napplet manifests from the user's relays into LocalCache while this screen is open. + NappletsFilterAssemblerSubscription(accountViewModel) + val napplets by remember { Amethyst.instance.cache.observeEvents( Filter(kinds = listOf(RootNappletEvent.KIND, NamedNappletEvent.KIND)), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/datasource/NappletsFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/datasource/NappletsFilterAssembler.kt new file mode 100644 index 0000000000..2a91cab62b --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/datasource/NappletsFilterAssembler.kt @@ -0,0 +1,60 @@ +/* + * 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.amethyst.ui.screen.loggedIn.napplets.datasource + +import androidx.compose.runtime.Stable +import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +import kotlinx.coroutines.CoroutineScope + +/** + * Keyspace for the napplet-discovery subscription. Unlike a feed datasource it carries no + * `FeedContentState` — [com.vitorpamplona.amethyst.ui.screen.loggedIn.napplets.NappletsScreen] + * reads the manifests straight out of `LocalCache`, so this only needs the account (for relay + * selection) and a scope. + */ +class NappletsQueryState( + val account: Account, + val scope: CoroutineScope, +) + +/** + * Subscribes to NIP-5D napplet manifests (kinds 15129/35129) on the user's read relays while a + * napplet screen is open, dumping them into `LocalCache` for the screen to observe. Registered as + * an app-lifetime singleton in `RelaySubscriptionsCoordinator` (the underlying EOSE manager opens + * its relay subscription at construction, so it must not be created per screen). + */ +@Stable +class NappletsFilterAssembler( + client: INostrClient, +) : ComposeSubscriptionManager() { + val group = + listOf( + NappletsFilterSubAssembler(client, ::allKeys), + ) + + override fun invalidateKeys() = invalidateFilters() + + override fun invalidateFilters() = group.forEach { it.invalidateFilters() } + + override fun destroy() = group.forEach { it.destroy() } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/datasource/NappletsFilterAssemblerSubscription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/datasource/NappletsFilterAssemblerSubscription.kt new file mode 100644 index 0000000000..f0db726ca9 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/datasource/NappletsFilterAssemblerSubscription.kt @@ -0,0 +1,37 @@ +/* + * 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.amethyst.ui.screen.loggedIn.napplets.datasource + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.lifecycle.viewModelScope +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.LifecycleAwareKeyDataSourceSubscription +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel + +@Composable +fun NappletsFilterAssemblerSubscription(accountViewModel: AccountViewModel) { + val state = + remember(accountViewModel.account) { + NappletsQueryState(accountViewModel.account, accountViewModel.viewModelScope) + } + + LifecycleAwareKeyDataSourceSubscription(state, accountViewModel.dataSources().napplets) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/datasource/NappletsFilterSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/datasource/NappletsFilterSubAssembler.kt new file mode 100644 index 0000000000..27180d32ef --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/datasource/NappletsFilterSubAssembler.kt @@ -0,0 +1,64 @@ +/* + * 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.amethyst.ui.screen.loggedIn.napplets.datasource + +import com.vitorpamplona.amethyst.commons.relayClient.eoseManagers.SingleSubEoseManager +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip5dNapplets.NamedNappletEvent +import com.vitorpamplona.quartz.nip5dNapplets.RootNappletEvent + +private const val NAPPLET_PAGE_LIMIT = 200 + +/** + * Emits one REQ per read relay for both napplet manifest kinds. A single subscription per account + * (deduped by [distinct]) is enough — the screen wants "what napplets exist", not a per-author + * feed — so this stays far lighter than the follow-list feed assemblers. + */ +class NappletsFilterSubAssembler( + client: INostrClient, + allKeys: () -> Set, +) : SingleSubEoseManager(client, allKeys) { + override fun updateFilter( + keys: List, + since: SincePerRelayMap?, + ): List { + if (keys.isEmpty()) return emptyList() + + return keys.flatMap { key -> + key.account.homeRelays.flow.value.map { relay -> + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = listOf(RootNappletEvent.KIND, NamedNappletEvent.KIND), + limit = NAPPLET_PAGE_LIMIT, + since = since?.get(relay)?.time, + ), + ) + } + } + } + + override fun distinct(key: NappletsQueryState) = key.account.signer.pubKey +}