feat(napplet): UI entry point, Tor-routed blob fetch, sandbox process isolation

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
This commit is contained in:
Claude
2026-06-20 03:10:12 +00:00
parent daae12b53d
commit d4899c2afc
10 changed files with 189 additions and 5 deletions
@@ -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.
@@ -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<String, String> {
@@ -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)
@@ -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<Route.Pictures> { PicturesScreen(accountViewModel, nav) }
composableFromEnd<Route.Workouts> { WorkoutsScreen(accountViewModel, nav) }
composableFromEnd<Route.SoftwareApps> { SoftwareAppsScreen(accountViewModel, nav) }
composableFromEnd<Route.Napplets> { NappletsScreen(accountViewModel, nav) }
composableFromEndArgs<Route.SoftwareAppDetail> { SoftwareAppDetailScreen(Address(it.kind, it.pubKeyHex, it.dTag), accountViewModel, nav) }
composableFromEnd<Route.Calendars> { CalendarsScreen(accountViewModel, nav) }
composableFromEnd<Route.CalendarCollections> { CalendarCollectionsScreen(accountViewModel, nav) }
@@ -52,6 +52,7 @@ enum class NavBarItem {
PICTURES,
WORKOUTS,
SOFTWARE_APPS,
NAPPLETS,
CALENDARS,
CALENDAR_COLLECTIONS,
SHORTS,
@@ -221,6 +222,13 @@ val NavBarCatalog: Map<NavBarItem, NavBarItemDef> =
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> =
NavBarItem.PICTURES,
NavBarItem.WORKOUTS,
NavBarItem.SOFTWARE_APPS,
NavBarItem.NAPPLETS,
NavBarItem.CALENDARS,
NavBarItem.CALENDAR_COLLECTIONS,
NavBarItem.SHORTS,
@@ -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,
@@ -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)
@@ -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<Event>(
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,
)
}
}
}
+1
View File
@@ -733,6 +733,7 @@
<string name="exercise_fasting">Fasting</string>
<string name="software_apps">Apps</string>
<string name="route_software_apps">Apps</string>
<string name="napplets">Napplets</string>
<string name="nip82_repository_label">Source: %1$s</string>
<string name="nip82_version_label">v%1$s</string>
<string name="nip82_download">Download</string>