mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-09 16:14:40 +00:00
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:
@@ -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.
|
||||
|
||||
Binary file not shown.
@@ -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) }
|
||||
|
||||
+9
@@ -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,
|
||||
|
||||
+3
@@ -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)
|
||||
|
||||
+136
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user