From d4899c2afc12130eecec12d755652ce264e47b0e Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 20 Jun 2026 03:10:12 +0000 Subject: [PATCH] feat(napplet): UI entry point, Tor-routed blob fetch, sandbox process isolation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UI entry point: - NappletsScreen: a "Napplets" drawer item lists napplet manifests in the local cache (NIP-5D kinds 15129/35129) and opens the selected one in the sandboxed host. Wired as Route.Napplets with a NavBarItem + drawer entry. Sandbox process isolation (security fix): - Application.onCreate runs in every process, so the :napplet process was building AppModules and initiate() was loading the account + constructing the signer there — defeating the "no keys in the sandbox" guarantee. Amethyst.onCreate now detects the :napplet process and skips AppModules entirely, leaving `instance` unset so any accidental use fails fast. Tor/proxy-aware blob fetch: - The host's OkHttpClient now routes Blossom blob fetches through the user's Tor SOCKS proxy when active. The port is resolved in the main process by the launcher and passed via the Intent, so the sandbox process never needs the account-bound HTTP stack. :amethyst:compileFdroidDebugKotlin passes; spotless clean. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde --- .../plans/2026-06-19-napplet-sandbox-host.md | 16 ++- .../com/vitorpamplona/amethyst/Amethyst.kt | Bin 3440 -> 4505 bytes .../amethyst/napplet/NappletHostActivity.kt | 17 ++- .../amethyst/napplet/NappletLauncher.kt | 8 ++ .../amethyst/ui/navigation/AppNavigation.kt | 2 + .../ui/navigation/bottombars/NavBarItem.kt | 9 ++ .../amethyst/ui/navigation/routes/Routes.kt | 2 + .../loggedIn/BottomBarFeedPreloaders.kt | 3 + .../loggedIn/napplets/NappletsScreen.kt | 136 ++++++++++++++++++ amethyst/src/main/res/values/strings.xml | 1 + 10 files changed, 189 insertions(+), 5 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/NappletsScreen.kt 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 4ac9fefc0e3c81e26c87a776b43a0a051f4af19b..5159a095922144a730a37338450a26d2870fb0d5 100644 GIT binary patch delta 1056 zcmZ9Ly>1gh5XXs>qzDZXU8=zpxeH{k4>Blmf@DE#3tLGUukYsKCF|YO?w*|x8Pm|w znT8jjMoL3Z%}ekEhzCH--uX3T`^7z}=)dyT9MvH`L%l302llh~y z&H4A$9}mSS)5gJDKITkl_EJc^ez*2ZibDuvd-kOD~zl-Q)s+K?{ZTnOc2fyGq4yNbUUd* zETvaMXGQf?yRc*=(;W)!xKLD6aDszlffINF%}yhH3Re4g_FQDZr35OOa*9HOA*r_| zO5|gNz96Ao#n=bJi0&DgUHYm-f|N%>J+}xc7t+$(I<~A*q!0+(D!Fz`(47)~5QcD7 z7|wsL!|N^y%7i*AftEZAT?Ay&p{i{)m@X6e!ih<4)beP$as7Aw)9NhR-iDsZ5hg?Q zP>Ic=?;ewfEUcC^90~mIFI2cZS_mv6?3L4a#Fa?Vx{$$kR3yuc82i~ub48)<7C9?M zEC_dDUu)v17S(#iV`8TG2%)}I?TVdU$WK%hj}l1( zRSOv{RMNu>7lh2_1Das+*vC{G47ToVg^UhHK?p}vMoQYIl}mY7!%|V%x`M?EhAOcq X!VQC3wQjW#>UE$U!Rz<;|E&B2nWs#l delta 34 scmV+-0NwwYBk&rqrUaAt1S6B-1hTWI1%v^!QVEL$lg0)Qv%(M80SBB8NB{r; 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