diff --git a/amethyst/plans/2026-06-19-napplet-sandbox-host.md b/amethyst/plans/2026-06-19-napplet-sandbox-host.md index 836d9e850e..2e73dae8a8 100644 --- a/amethyst/plans/2026-06-19-napplet-sandbox-host.md +++ b/amethyst/plans/2026-06-19-napplet-sandbox-host.md @@ -217,9 +217,17 @@ Deferred to v2; v1 nails the single-applet boundary first. - **On-device verification (needs emulator/device):** opaque-origin iframe really excludes the bridge; CSP `connect-src 'none'` blocks fetch/XHR/WebSocket; a real napplet renders and round-trips a `getPublicKey` / `signEvent` through consent. -- **UI entry point:** wire `NappletLauncher.launch(...)` into navigation (a napplet - list/detail screen). Today the host is reachable only programmatically. -- **Privacy:** the host's `OkHttpClient` for blob fetches ignores the user's - Tor/proxy settings — route it through the app's configured client. +- ✅ **UI entry point:** a "Napplets" drawer item → `NappletsScreen` that lists + cached napplet manifests (kinds 15129/35129) and launches the host. +- ✅ **Process isolation:** `Amethyst.onCreate` now skips `AppModules` entirely in + the `:napplet` process, so the account/signer are never loaded there. (Previously + `initiate()` loaded the account in every process — a real hole, now closed.) +- ✅ **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. - **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/Amethyst.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/Amethyst.kt index 4ac9fefc0e..5159a09592 100644 Binary files a/amethyst/src/main/java/com/vitorpamplona/amethyst/Amethyst.kt and b/amethyst/src/main/java/com/vitorpamplona/amethyst/Amethyst.kt differ diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletHostActivity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletHostActivity.kt index d0dc428835..83544d94bd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletHostActivity.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletHostActivity.kt @@ -51,6 +51,8 @@ import okhttp3.OkHttpClient import okhttp3.Request import org.json.JSONObject import java.io.ByteArrayInputStream +import java.net.InetSocketAddress +import java.net.Proxy /** * Hosts a napplet/nsite WebView in the isolated `:napplet` process — a process that holds **no** @@ -75,7 +77,8 @@ class NappletHostActivity : ComponentActivity() { private var identifier: String = "" private var aggregateHash: String? = null - private val http = OkHttpClient() + private var proxyPort: Int = -1 + private val http by lazy { buildHttpClient(proxyPort) } private val fetch: BlobFetcher = { url -> try { http @@ -168,6 +171,7 @@ class NappletHostActivity : ComponentActivity() { identifier = intent.getStringExtra(NappletLauncher.EXTRA_IDENTIFIER).orEmpty() aggregateHash = intent.getStringExtra(NappletLauncher.EXTRA_AGGREGATE_HASH) title = intent.getStringExtra(NappletLauncher.EXTRA_TITLE).orEmpty() + proxyPort = intent.getIntExtra(NappletLauncher.EXTRA_PROXY_PORT, -1) return author.isNotEmpty() } @@ -337,6 +341,17 @@ class NappletHostActivity : ComponentActivity() { return true } + /** Routes blob fetches through the user's Tor SOCKS proxy when one is active (port > 0). */ + private fun buildHttpClient(port: Int): OkHttpClient = + if (port > 0) { + OkHttpClient + .Builder() + .proxy(Proxy(Proxy.Type.SOCKS, InetSocketAddress("127.0.0.1", port))) + .build() + } else { + OkHttpClient() + } + private fun notFound(): WebResourceResponse = WebResourceResponse("text/plain", "utf-8", 404, "Not Found", emptyMap(), ByteArrayInputStream(ByteArray(0))) private fun splitContentType(contentType: String): Pair { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletLauncher.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletLauncher.kt index 1251b75b1f..b2200a9ebd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletLauncher.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletLauncher.kt @@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.napplet import android.content.Context import android.content.Intent +import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip5dNapplets.NappletManifest @@ -40,6 +41,9 @@ object NappletLauncher { const val EXTRA_AGGREGATE_HASH = "napplet_aggregate_hash" const val EXTRA_TITLE = "napplet_title" + /** SOCKS proxy port to route blob fetches through, or -1 for a direct connection. */ + const val EXTRA_PROXY_PORT = "napplet_proxy_port" + fun launch( context: Context, manifest: NappletManifest, @@ -47,6 +51,9 @@ object NappletLauncher { identifier: String, ) { val pathTags = manifest.paths() + // Resolved here in the main process (which knows the user's Tor settings) and passed in, + // so the sandbox process never needs the app's account-bound HTTP stack. + val proxyPort = Amethyst.instance.torManager.activePortOrNull.value ?: -1 val intent = Intent(context, NappletHostActivity::class.java).apply { putExtra(EXTRA_PATHS, ArrayList(pathTags.map { it.path })) @@ -56,6 +63,7 @@ object NappletLauncher { putExtra(EXTRA_IDENTIFIER, identifier) putExtra(EXTRA_AGGREGATE_HASH, manifest.declaredAggregateHash() ?: manifest.computeAggregateHash()) putExtra(EXTRA_TITLE, manifest.title() ?: identifier.ifBlank { "Napplet" }) + putExtra(EXTRA_PROXY_PORT, proxyPort) if (context !is android.app.Activity) addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) } context.startActivity(intent) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index da1f4590e3..66e2eb0761 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -144,6 +144,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.music.MusicPlaylistsScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.music.MusicTracksScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.music.NewMusicPlaylistScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.music.NewMusicTrackScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.napplets.NappletsScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.NestsScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.room.lobby.NestLobbyScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.newUser.ImportFollowListPickFollowsScreen @@ -288,6 +289,7 @@ fun BuildNavigation( composableFromEnd { PicturesScreen(accountViewModel, nav) } composableFromEnd { WorkoutsScreen(accountViewModel, nav) } composableFromEnd { SoftwareAppsScreen(accountViewModel, nav) } + composableFromEnd { NappletsScreen(accountViewModel, nav) } composableFromEndArgs { SoftwareAppDetailScreen(Address(it.kind, it.pubKeyHex, it.dTag), accountViewModel, nav) } composableFromEnd { CalendarsScreen(accountViewModel, nav) } composableFromEnd { CalendarCollectionsScreen(accountViewModel, nav) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/NavBarItem.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/NavBarItem.kt index b9bfc64888..38aba166fd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/NavBarItem.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/NavBarItem.kt @@ -52,6 +52,7 @@ enum class NavBarItem { PICTURES, WORKOUTS, SOFTWARE_APPS, + NAPPLETS, CALENDARS, CALENDAR_COLLECTIONS, SHORTS, @@ -221,6 +222,13 @@ val NavBarCatalog: Map = icon = MaterialSymbols.Apps, resolveRoute = { Route.SoftwareApps }, ), + NavBarItem.NAPPLETS to + NavBarItemDef( + id = NavBarItem.NAPPLETS, + labelRes = R.string.napplets, + icon = MaterialSymbols.Apps, + resolveRoute = { Route.Napplets }, + ), NavBarItem.CALENDARS to NavBarItemDef( id = NavBarItem.CALENDARS, @@ -384,6 +392,7 @@ val DrawerFeedsItems: List = NavBarItem.PICTURES, NavBarItem.WORKOUTS, NavBarItem.SOFTWARE_APPS, + NavBarItem.NAPPLETS, NavBarItem.CALENDARS, NavBarItem.CALENDAR_COLLECTIONS, NavBarItem.SHORTS, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt index 58d41472cd..3a9efb17ff 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt @@ -87,6 +87,8 @@ sealed class Route { @Serializable object SoftwareApps : Route() + @Serializable object Napplets : Route() + @Serializable data class SoftwareAppDetail( val kind: Int, val pubKeyHex: HexKey, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/BottomBarFeedPreloaders.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/BottomBarFeedPreloaders.kt index 844deed5ba..b82d500629 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/BottomBarFeedPreloaders.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/BottomBarFeedPreloaders.kt @@ -95,6 +95,9 @@ private fun PreloadFor( NavBarItem.SOFTWARE_APPS -> SoftwareAppsFilterAssemblerSubscription(accountViewModel) + // Napplets read directly from the local cache; no dedicated relay subscription yet. + NavBarItem.NAPPLETS -> {} + NavBarItem.CALENDARS, NavBarItem.CALENDAR_COLLECTIONS, -> CalendarsFilterAssemblerSubscription(accountViewModel) 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 new file mode 100644 index 0000000000..ef098d7172 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/NappletsScreen.kt @@ -0,0 +1,136 @@ +/* + * 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 + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.Amethyst +import com.vitorpamplona.amethyst.R +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.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip5dNapplets.NamedNappletEvent +import com.vitorpamplona.quartz.nip5dNapplets.NappletManifest +import com.vitorpamplona.quartz.nip5dNapplets.RootNappletEvent + +/** + * Lists the napplet manifests currently in the local cache (NIP-5D kinds 15129/35129) and opens + * the selected one in the sandboxed [NappletLauncher] host. Reads the cache directly rather than + * standing up a full relay-backed feed — discovery/subscription is a later step. + */ +@Composable +fun NappletsScreen( + accountViewModel: AccountViewModel, + nav: INav, +) { + val context = LocalContext.current + val napplets by remember { + Amethyst.instance.cache.observeEvents( + Filter(kinds = listOf(RootNappletEvent.KIND, NamedNappletEvent.KIND)), + ) + }.collectAsStateWithLifecycle(emptyList()) + + Scaffold( + topBar = { TopBarWithBackButton(stringResource(R.string.napplets), nav) }, + ) { padding -> + if (napplets.isEmpty()) { + Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) { + Text( + "No napplets found yet.", + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } else { + LazyColumn(Modifier.fillMaxSize().padding(padding)) { + items(napplets, key = { it.id }) { event -> + val manifest = event as? NappletManifest ?: return@items + NappletRow( + manifest = manifest, + onClick = { + NappletLauncher.launch( + context = context, + manifest = manifest, + authorPubKey = event.pubKey, + identifier = (event as? NamedNappletEvent)?.identifier() ?: "", + ) + }, + ) + HorizontalDivider() + } + } + } + } +} + +@Composable +private fun NappletRow( + manifest: NappletManifest, + onClick: () -> Unit, +) { + Column( + modifier = + Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + Text( + text = manifest.title()?.ifBlank { null } ?: "Untitled napplet", + style = MaterialTheme.typography.titleMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + manifest.description()?.takeIf { it.isNotBlank() }?.let { + Text( + text = it, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + } +} diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 453bcf2bae..65a2d646f4 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -733,6 +733,7 @@ Fasting Apps Apps + Napplets Source: %1$s v%1$s Download