Merge pull request #3366 from vitorpamplona/claude/favorite-web-apps-review-cn82yb

Rename web/napplet screens and controllers; introduce HostProfile
This commit is contained in:
Vitor Pamplona
2026-06-25 14:28:59 -04:00
committed by GitHub
29 changed files with 270 additions and 132 deletions
@@ -0,0 +1,63 @@
# Web-app naming overhaul (favorites / browser / app surfaces)
## Problem
The in-app "app" surfaces had colliding, sometimes inaccurate names:
- `software_apps` (NIP-89 native Android apps, an install-from-a-store flow) showed as
**"Apps"** — colliding with the in-app favorites, which showed as **"Favorite apps"**.
- The **host screens** that render a single web client / nSite / nApplet were named
`FavoriteWebAppScreen` / `FavoriteNappletScreen`. But they open *any* url/coordinate,
favorited or not — "Favorite" described how they happened to be reached (a pinned
bottom-bar tab), not what they are. And `FavoriteNappletScreen` also renders **nSites**
(website-mode), so "Napplet" was narrower than reality.
- Three vocabularies for two model cases: model `WebUrl`/`NostrApp`, route
`FavoriteWebApp`/`FavoriteNostrApp`, screen `FavoriteWebApp`/`FavoriteNapplet`.
## Taxonomy (decided with maintainer)
User-facing terms, now distinct:
| Concept | User-facing | What it is |
|---|---|---|
| Native app store | **App Store** | NIP-89 native Android apps you install off-device |
| Nostr web client | **Web app** | an `https://` client that runs in-app (WebView) |
| nApplet | **nApplet** | NIP-5D sandboxed JS app |
| nSite | **nSite** | NIP-5A static website |
| Pinned set | **Favorite** | a cross-cutting attribute (the star), *not* a screen |
Code axis (favorites / route / screen / embedded-controller layer): **`WebApp`** (url-based,
no nostr identity) and **`NostrApp`** (coordinate-based nSite *or* nApplet). The cross-process
sandbox infra (`napplet/`, `nappletHost/`, `NappletHostService`, `NappletEmbedContract`)
keeps **"Napplet"** — that process genuinely is the napplet host (it serves nSites in
website-mode too, but the host *is* the napplet runtime).
"Favorite" is reserved for the **grid of pinned apps** (`FavoriteAppsScreen` /
`Route.FavoriteApps`) and the star toggle — the only things that are actually about favorites.
## Renames
Routes: `FavoriteWebApp(url)``WebApp(url)`; `FavoriteNostrApp(coordinate)``NostrApp(coordinate)`.
Screens: `FavoriteWebAppScreen``WebAppScreen`; `FavoriteNappletScreen``NostrAppScreen`.
Controllers: `EmbeddedBrowserController``EmbeddedWebAppController`;
`EmbeddedNappletController``EmbeddedNostrAppController`.
Factory: `acquireBrowser`/`browserId``acquireWebApp`/`webAppId`;
`acquireNapplet`/`nappletId``acquireNostrApp`/`nostrAppId`.
Model: `FavoriteApp.WebUrl``FavoriteApp.WebApp`. Registry: `WebUrlNetworkRegistry`
`WebAppNetworkRegistry`.
Strings: `software_apps` "Apps" → "App Store"; `favorite_apps_empty` reworded to name
nApplet/nSite.
## Stable (do NOT change — persistence / wire compat)
- Favorite `id` prefixes `"url:"` / `"nostr:"` (persisted dedup + bottom-bar keys).
- DataStore names `"favorite_apps"`, `"weburl_network"`; serialized type tags `"url"` / `"nostr"`.
- `napplet/` + `nappletHost/` sandbox infra names and IPC contracts.
## Follow-ups (not in this pass)
- Move `NostrAppScreen` + `EmbeddedNostrAppController` out of the `ui/...favorites/`
package (they are no longer favorites-specific) into a host package alongside the web side.
- Recent nApplets / nSites (parallel to the browser's recent web apps), surfaced on the
discovery screens.
@@ -32,7 +32,8 @@ import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.ThemeType
import com.vitorpamplona.amethyst.napplet.NappletLauncher
import com.vitorpamplona.amethyst.napplet.WebUrlNetworkRegistry
import com.vitorpamplona.amethyst.napplet.WebAppNetworkRegistry
import com.vitorpamplona.amethyst.napplethost.HostProfile
import com.vitorpamplona.amethyst.napplethost.NappletBrowserActivity
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip5aStaticWebsites.NamedSiteEvent
@@ -44,7 +45,7 @@ import com.vitorpamplona.quartz.nip5dNapplets.RootNappletEvent
* Turns a [FavoriteApp] back into a running app. The two cases map to the two launch paths in the
* codebase, nothing more:
*
* - [FavoriteApp.WebUrl] → a full-screen direct-WebView
* - [FavoriteApp.WebApp] → a full-screen direct-WebView
* [NappletBrowserActivity][com.vitorpamplona.amethyst.napplethost.NappletBrowserActivity] (its own
* task/recents entry), so the web client owns the whole screen and scrolls/zooms natively.
* - [FavoriteApp.NostrApp] → re-resolve the live event from [LocalCache] by coordinate, read its
@@ -60,7 +61,7 @@ object FavoriteAppLauncher {
app: FavoriteApp,
) {
when (app) {
is FavoriteApp.WebUrl -> launchUrl(context, app.url)
is FavoriteApp.WebApp -> launchUrl(context, app.url)
is FavoriteApp.NostrApp -> launchNostrApp(context, app.coordinate)
}
}
@@ -78,7 +79,7 @@ object FavoriteAppLauncher {
preferTor: Boolean = false,
) {
val proxyPort = Amethyst.instance.torManager.activePortOrNull.value ?: -1
val useTor = proxyPort > 0 && (preferTor || WebUrlNetworkRegistry.useTor(url))
val useTor = proxyPort > 0 && (preferTor || WebAppNetworkRegistry.useTor(url))
val themeType = Amethyst.instance.uiPrefs.value.theme.value
val theme =
when (themeType) {
@@ -117,7 +118,7 @@ object FavoriteAppLauncher {
aggregateHash = null,
title = event.title() ?: "nsite",
requires = emptyList(),
websiteMode = true,
profile = HostProfile.WEBSITE,
)
is NamedSiteEvent ->
NappletLauncher.launch(
@@ -129,7 +130,7 @@ object FavoriteAppLauncher {
aggregateHash = null,
title = event.title() ?: event.identifier(),
requires = emptyList(),
websiteMode = true,
profile = HostProfile.WEBSITE,
)
else -> {
Log.w("FavoriteAppLauncher", "Favorited app not resolvable yet: $coordinate")
@@ -160,7 +161,7 @@ object FavoriteAppLauncher {
event.declaredAggregateHash() ?: event.computeAggregateHash(),
event.title() ?: "Napplet",
event.requires(),
false,
HostProfile.NAPPLET,
)
is NamedNappletEvent ->
NappletLauncher.buildLaunchParams(
@@ -172,7 +173,7 @@ object FavoriteAppLauncher {
event.declaredAggregateHash() ?: event.computeAggregateHash(),
event.title() ?: event.identifier(),
event.requires(),
false,
HostProfile.NAPPLET,
)
is RootSiteEvent ->
NappletLauncher.buildLaunchParams(
@@ -184,7 +185,7 @@ object FavoriteAppLauncher {
null,
event.title() ?: "nsite",
emptyList(),
true,
HostProfile.WEBSITE,
)
is NamedSiteEvent ->
NappletLauncher.buildLaunchParams(
@@ -196,7 +197,7 @@ object FavoriteAppLauncher {
null,
event.title() ?: event.identifier(),
emptyList(),
true,
HostProfile.WEBSITE,
)
else -> null
}
@@ -128,7 +128,7 @@ object FavoriteAppsRegistry {
list.map {
when (it) {
is FavoriteApp.NostrApp -> Entry(TYPE_NOSTR, it.coordinate, it.label, it.addedAt, it.iconUrl)
is FavoriteApp.WebUrl -> Entry(TYPE_URL, it.url, it.label, it.addedAt, it.iconUrl)
is FavoriteApp.WebApp -> Entry(TYPE_URL, it.url, it.label, it.addedAt, it.iconUrl)
}
},
)
@@ -138,7 +138,7 @@ object FavoriteAppsRegistry {
JsonMapper.fromJson<List<Entry>>(json).mapNotNull { entry ->
when (entry.type) {
TYPE_NOSTR -> FavoriteApp.NostrApp(entry.ref, entry.label, entry.addedAt, entry.iconUrl)
TYPE_URL -> FavoriteApp.WebUrl(entry.ref, entry.label, entry.addedAt, entry.iconUrl)
TYPE_URL -> FavoriteApp.WebApp(entry.ref, entry.label, entry.addedAt, entry.iconUrl)
else -> null
}
}
@@ -187,7 +187,7 @@ class NappletBrokerService : Service() {
if (FavoriteAppsRegistry.isFavorite(id)) {
FavoriteAppsRegistry.remove(id)
} else {
FavoriteAppsRegistry.add(FavoriteApp.WebUrl(url, label, System.currentTimeMillis()))
FavoriteAppsRegistry.add(FavoriteApp.WebApp(url, label, System.currentTimeMillis()))
}
return true
}
@@ -196,8 +196,8 @@ class NappletBrokerService : Service() {
if (msg.what == NappletIpc.MSG_SET_WEB_TOR) {
val data = msg.data ?: return true
val host = data.getString(NappletIpc.KEY_WEB_HOST)?.takeIf { it.isNotBlank() } ?: return true
WebUrlNetworkRegistry.init(applicationContext)
WebUrlNetworkRegistry.set(host, data.getBoolean(NappletIpc.KEY_NETWORK_USE_TOR, true))
WebAppNetworkRegistry.init(applicationContext)
WebAppNetworkRegistry.set(host, data.getBoolean(NappletIpc.KEY_NETWORK_USE_TOR, true))
return true
}
@@ -25,11 +25,10 @@ import android.content.Intent
import android.content.res.Configuration
import android.os.Bundle
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.commons.napplet.NappletCapability
import com.vitorpamplona.amethyst.commons.napplet.NappletIdentity
import com.vitorpamplona.amethyst.commons.napplet.resolveRequiredCapabilities
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.ThemeType
import com.vitorpamplona.amethyst.napplethost.HostProfile
import com.vitorpamplona.amethyst.napplethost.NappletHostActivity
import com.vitorpamplona.amethyst.napplethost.NappletHostContract
import com.vitorpamplona.quartz.nip01Core.core.HexKey
@@ -74,12 +73,12 @@ object NappletLauncher {
aggregateHash: HexKey?,
title: String,
requires: List<String>,
// nSites open in "website mode": a NIP-07 window.nostr provider + normal network. The broker
// then grants the IDENTITY + RELAY capabilities NIP-07 needs (consent-gated), regardless of the
// (empty) manifest `requires`. Napplets pass false and keep their declared-only, locked sandbox.
websiteMode: Boolean = false,
// nSites open as [HostProfile.WEBSITE]: a NIP-07 window.nostr provider + normal network. The
// broker then grants the IDENTITY + RELAY capabilities NIP-07 needs (consent-gated), regardless
// of the (empty) manifest `requires`. Napplets keep the default locked [HostProfile.NAPPLET].
profile: HostProfile = HostProfile.NAPPLET,
) {
val params = buildLaunchParams(context, paths, servers, authorPubKey, identifier, aggregateHash, title, requires, websiteMode)
val params = buildLaunchParams(context, paths, servers, authorPubKey, identifier, aggregateHash, title, requires, profile)
val intent =
Intent(context, NappletHostActivity::class.java).apply {
putExtras(params)
@@ -104,7 +103,7 @@ object NappletLauncher {
aggregateHash: HexKey?,
title: String,
requires: List<String>,
websiteMode: Boolean,
profile: HostProfile,
): Bundle {
val proxyPort = Amethyst.instance.torManager.activePortOrNull.value ?: -1
@@ -120,18 +119,13 @@ object NappletLauncher {
// Mint the launch token in the (trusted) main process: the broker resolves the sandbox's
// requests back to THIS identity + declared set, regardless of anything the sandbox sends.
val identity = NappletIdentity(authorPubKey = authorPubKey, identifier = identifier, aggregateHash = aggregateHash)
val declared =
if (websiteMode) {
setOf(NappletCapability.IDENTITY, NappletCapability.RELAY)
} else {
resolveRequiredCapabilities(requires).capabilities.toSet()
}
val declared = profile.declaredCapabilities(requires)
val launchToken = NappletLaunchRegistry.register(identity, declared)
// Resolve the per-site network choice (Tor default; a site can be opted out to the open web).
// Locked napplets always keep Tor for their blob fetches — only nSites expose the toggle.
NappletNetworkRegistry.init(context.applicationContext)
val useTor = if (websiteMode) NappletNetworkRegistry.useTor(identity.coordinate) else true
val useTor = if (profile.exposesNetwork) NappletNetworkRegistry.useTor(identity.coordinate) else true
// Resolve capability labels here (the app has the resources) so the sandbox module needs none.
val capLabels = declared.map { context.getString(it.labelRes()) }
@@ -159,7 +153,7 @@ object NappletLauncher {
putStringArrayList(NappletHostContract.EXTRA_CAP_LABELS, ArrayList(capLabels))
putString(NappletHostContract.EXTRA_LAUNCH_TOKEN, launchToken)
putInt(NappletHostContract.EXTRA_PROXY_PORT, proxyPort)
putBoolean(NappletHostContract.EXTRA_WEBSITE_MODE, websiteMode)
putString(NappletHostContract.EXTRA_HOST_PROFILE, profile.name)
putBoolean(NappletHostContract.EXTRA_USE_TOR, useTor)
putString(NappletHostContract.EXTRA_THEME, theme)
}
@@ -48,7 +48,7 @@ private val Context.webUrlNetworkDataStore by preferencesDataStore(name = "webur
* Lives only in the **main process** (where the browser chrome runs); an in-memory map is authoritative
* for the session with write-through persistence.
*/
object WebUrlNetworkRegistry {
object WebAppNetworkRegistry {
private const val OPEN_WEB = "OPEN"
private const val TOR = "TOR"
@@ -83,7 +83,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.membershipMa
import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.membershipManagement.PostBookmarkListManagementScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.old.OldBookmarkListScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.browser.BrowserScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.browser.FavoriteWebAppScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.browser.WebAppScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.CalendarCollectionsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.CalendarReminderSettingsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.calendars.CalendarsScreen
@@ -127,7 +127,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.emojipacks.list.metadata.Em
import com.vitorpamplona.amethyst.ui.screen.loggedIn.emojipacks.membershipManagement.EmojiPackSelectionScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.emojipacks.membershipManagement.MyEmojiListScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.favorites.FavoriteAppsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.favorites.FavoriteNappletScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.favorites.NostrAppScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.followPacks.feed.FollowPackFeedScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.followPacks.list.FollowPacksScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.geohash.GeoHashPostScreen
@@ -321,8 +321,8 @@ fun BuildNavigation(
composableFromEnd<Route.Nsites> { NsitesScreen(accountViewModel, nav) }
composableFromEnd<Route.Browser> { BrowserScreen(accountViewModel, nav) }
composableFromEnd<Route.FavoriteApps> { FavoriteAppsScreen(accountViewModel, nav) }
composableFromEndArgs<Route.FavoriteWebApp> { FavoriteWebAppScreen(it.url, accountViewModel, nav) }
composableFromEndArgs<Route.FavoriteNostrApp> { FavoriteNappletScreen(it.coordinate, accountViewModel, nav) }
composableFromEndArgs<Route.WebApp> { WebAppScreen(it.url, accountViewModel, nav) }
composableFromEndArgs<Route.NostrApp> { NostrAppScreen(it.coordinate, accountViewModel, nav) }
composableFromEnd<Route.NappletPermissions> { NappletPermissionsScreen(accountViewModel, nav) }
composableFromEndArgs<Route.SoftwareAppDetail> { SoftwareAppDetailScreen(Address(it.kind, it.pubKeyHex, it.dTag), accountViewModel, nav) }
composableFromEnd<Route.Calendars> { CalendarsScreen(accountViewModel, nav) }
@@ -90,7 +90,7 @@ fun AppBottomBar(
}
// Favorite entries in the unified list resolve to a live favorite for their icon/label and to an
// embedded-tab route. Both kinds embed in-process (WebUrl → browser surface, NostrApp → napplet
// embedded-tab route. Both kinds embed in-process (WebApp → browser surface, NostrApp → napplet
// surface), so such a tab swaps in place rather than launching an activity from the bottom row.
val favorites by FavoriteAppsRegistry.favorites.collectAsStateWithLifecycle()
@@ -143,12 +143,12 @@ private fun RenderBottomMenu(
val fav = favoritesById[entry.favoriteId] ?: return@forEach
val destination =
when (fav) {
is FavoriteApp.WebUrl -> Route.FavoriteWebApp(fav.url)
is FavoriteApp.NostrApp -> Route.FavoriteNostrApp(fav.coordinate)
is FavoriteApp.WebApp -> Route.WebApp(fav.url)
is FavoriteApp.NostrApp -> Route.NostrApp(fav.coordinate)
}
val iconModel =
remember(fav, iconKeys) {
(fav as? FavoriteApp.WebUrl)?.let { OmniboxInput.hostOf(it.url)?.let(BrowserIconRegistry::iconModelFor) }
(fav as? FavoriteApp.WebApp)?.let { OmniboxInput.hostOf(it.url)?.let(BrowserIconRegistry::iconModelFor) }
}
FavoriteNavItem(destination == selectedRoute, fav, iconModel, destination, nav)
}
@@ -33,7 +33,7 @@ import kotlinx.serialization.Serializable
* - [Favorite] points at a [FavoriteApp][com.vitorpamplona.amethyst.commons.favorites.FavoriteApp] by
* its stable id (which already encodes the route's parameters — the `url` or addressable
* `coordinate`); the bar resolves it to a live favorite for its icon/label and to
* `Route.FavoriteWebApp` / `Route.FavoriteNostrApp` for navigation.
* `Route.WebApp` / `Route.NostrApp` for navigation.
*/
@Serializable
sealed interface BottomBarEntry {
@@ -95,11 +95,11 @@ sealed class Route {
@Serializable object FavoriteApps : Route()
@Serializable data class FavoriteWebApp(
@Serializable data class WebApp(
val url: String,
) : Route()
@Serializable data class FavoriteNostrApp(
@Serializable data class NostrApp(
val coordinate: String,
) : Route()
@@ -28,6 +28,7 @@ import com.vitorpamplona.amethyst.commons.ui.note.StaticWebsiteCard
import com.vitorpamplona.amethyst.favorites.FavoriteAppLauncher
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.napplet.NappletLauncher
import com.vitorpamplona.amethyst.napplethost.HostProfile
import com.vitorpamplona.amethyst.napplethost.NappletBlobPrefetcher
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -132,7 +133,7 @@ fun RenderRootSiteEvent(
aggregateHash = null,
title = event.title() ?: "nsite",
requires = emptyList(),
websiteMode = true,
profile = HostProfile.WEBSITE,
)
}
} else {
@@ -173,7 +174,7 @@ fun RenderNamedSiteEvent(
aggregateHash = null,
title = event.title() ?: event.identifier(),
requires = emptyList(),
websiteMode = true,
profile = HostProfile.WEBSITE,
)
}
} else {
@@ -130,7 +130,7 @@ private fun BrowserLauncher(
val candidates =
remember(apps, history) {
buildList {
apps.forEach { if (it is FavoriteApp.WebUrl) add(OmniboxSuggestions.Candidate(it.url, it.label, isFavorite = true)) }
apps.forEach { if (it is FavoriteApp.WebApp) add(OmniboxSuggestions.Candidate(it.url, it.label, isFavorite = true)) }
history.forEach {
add(
OmniboxSuggestions.Candidate(
@@ -214,7 +214,7 @@ private fun BrowserLauncher(
)
}
else -> {
val favoriteUrls = remember(apps) { apps.filterIsInstance<FavoriteApp.WebUrl>().mapTo(HashSet()) { it.url } }
val favoriteUrls = remember(apps) { apps.filterIsInstance<FavoriteApp.WebApp>().mapTo(HashSet()) { it.url } }
BrowserHome(
apps = apps,
history = history,
@@ -229,7 +229,7 @@ private fun BrowserLauncher(
FavoriteAppsRegistry.remove(id)
} else {
FavoriteAppsRegistry.add(
FavoriteApp.WebUrl(entry.url, entry.title.ifBlank { entry.host }, System.currentTimeMillis()),
FavoriteApp.WebApp(entry.url, entry.title.ifBlank { entry.host }, System.currentTimeMillis()),
)
}
},
@@ -53,7 +53,7 @@ import java.util.concurrent.atomic.AtomicLong
* that drive the trusted, main-process address bar.
*/
@RequiresApi(Build.VERSION_CODES.R)
class EmbeddedBrowserController(
class EmbeddedWebAppController(
private val appContext: Context,
private val proxyPort: Int,
private val initialUseTor: Boolean,
@@ -50,7 +50,7 @@ import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp
import com.vitorpamplona.amethyst.favorites.FavoriteAppLauncher
import com.vitorpamplona.amethyst.favorites.FavoriteAppsRegistry
import com.vitorpamplona.amethyst.napplet.WebUrlNetworkRegistry
import com.vitorpamplona.amethyst.napplet.WebAppNetworkRegistry
import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar
import com.vitorpamplona.amethyst.ui.navigation.bottombars.favoriteIds
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
@@ -61,7 +61,8 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedTabFactory
import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedTabHost
/**
* A pinned web client rendered as an **in-app tab**. The embedded `:napplet` browser surface is drawn
* A **Web app** (a Nostr web client, reached by [url]) rendered as an **in-app tab** for *any* URL,
* favorited or not; favoriting is just the star toggle in the chrome. The embedded `:napplet` browser surface is drawn
* by the persistent [EmbeddedTabHost]/[com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedTabLayer]
* layer, which keeps the session warm across tab swaps. This screen owns only the chrome it publishes
* the controls as an [EmbeddedTabChrome] and the layer draws a top pull-down sheet over the (z-below)
@@ -71,13 +72,13 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedTabHost
* (restarted) when the screen leaves. Requires API 30+ for the cross-process surface.
*/
@Composable
fun FavoriteWebAppScreen(
fun WebAppScreen(
url: String,
accountViewModel: AccountViewModel,
nav: INav,
) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
EmbeddedFavoriteTab(url, accountViewModel, nav)
EmbeddedWebAppTab(url, accountViewModel, nav)
} else {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text(
@@ -90,13 +91,13 @@ fun FavoriteWebAppScreen(
@RequiresApi(Build.VERSION_CODES.R)
@Composable
private fun EmbeddedFavoriteTab(
private fun EmbeddedWebAppTab(
url: String,
accountViewModel: AccountViewModel,
nav: INav,
) {
val context = LocalContext.current
// Matches FavoriteApp.WebUrl.id, so warm-keep membership lines up with the bottom-bar favorites.
// Matches FavoriteApp.WebApp.id, so warm-keep membership lines up with the bottom-bar favorites.
val id = "url:$url"
var currentUrl by remember { mutableStateOf(url) }
@@ -105,16 +106,16 @@ private fun EmbeddedFavoriteTab(
val proxyAvailable = remember { Amethyst.instance.torManager.activePortOrNull.value != null }
// Start from this site's remembered Tor choice (some sites' servers reject Tor exits, so the user
// can opt one out and it must stick). Only meaningful when Tor is actually available.
var torOn by remember { mutableStateOf(proxyAvailable && WebUrlNetworkRegistry.useTor(url)) }
var torOn by remember { mutableStateOf(proxyAvailable && WebAppNetworkRegistry.useTor(url)) }
val apps by FavoriteAppsRegistry.favorites.collectAsStateWithLifecycle()
val isFavorite = remember(apps, currentUrl) { apps.any { it is FavoriteApp.WebUrl && it.url == currentUrl } }
val isFavorite = remember(apps, currentUrl) { apps.any { it is FavoriteApp.WebApp && it.url == currentUrl } }
val backgroundColor = MaterialTheme.colorScheme.background.toArgb()
val controller =
remember(id) {
EmbeddedTabFactory.acquireBrowser(context, url, backgroundColor)
EmbeddedTabFactory.acquireWebApp(context, url, backgroundColor)
}
// Keep the URL/back callback fresh (cheap, needs the latest closure).
@@ -137,7 +138,7 @@ private fun EmbeddedFavoriteTab(
onToggleTor = {
torOn = !torOn
controller.setTor(torOn)
WebUrlNetworkRegistry.set(url, torOn)
WebAppNetworkRegistry.set(url, torOn)
},
isFavorite = isFavorite,
onFavorite = {
@@ -145,7 +146,7 @@ private fun EmbeddedFavoriteTab(
if (FavoriteAppsRegistry.isFavorite(favId)) {
FavoriteAppsRegistry.remove(favId)
} else {
FavoriteAppsRegistry.add(FavoriteApp.WebUrl(currentUrl, hostLabel(currentUrl), System.currentTimeMillis()))
FavoriteAppsRegistry.add(FavoriteApp.WebApp(currentUrl, hostLabel(currentUrl), System.currentTimeMillis()))
}
},
)
@@ -169,7 +170,7 @@ private fun EmbeddedFavoriteTab(
Scaffold(
bottomBar = {
AppBottomBar(Route.FavoriteWebApp(url), nav, accountViewModel) { route -> nav.navBottomBar(route) }
AppBottomBar(Route.WebApp(url), nav, accountViewModel) { route -> nav.navBottomBar(route) }
},
) { padding ->
// Reserve the full content area; the warm surface, its top sheet, and the loading/error overlay
@@ -25,7 +25,7 @@ import androidx.compose.runtime.snapshots.SnapshotStateList
/**
* A surface controller that exposes JavaScript console output captured from the embedded WebView.
* The [consoleLogs] list is Compose snapshot state so [BottomConsoleSheet] recomposes as messages
* arrive. Implemented by [com.vitorpamplona.amethyst.ui.screen.loggedIn.browser.EmbeddedBrowserController].
* arrive. Implemented by [com.vitorpamplona.amethyst.ui.screen.loggedIn.browser.EmbeddedWebAppController].
*/
interface ConsoleBridge {
val consoleLogs: SnapshotStateList<ConsoleLogEntry>
@@ -26,8 +26,8 @@ import androidx.privacysandbox.ui.client.view.SandboxedSdkView
/**
* The minimal surface controls [EmbeddedTabHost] needs to keep a session warm regardless of whether
* it's a browser ([com.vitorpamplona.amethyst.ui.screen.loggedIn.browser.EmbeddedBrowserController])
* or an nsite/napplet ([com.vitorpamplona.amethyst.ui.screen.loggedIn.favorites.EmbeddedNappletController]).
* it's a browser ([com.vitorpamplona.amethyst.ui.screen.loggedIn.browser.EmbeddedWebAppController])
* or an nsite/napplet ([com.vitorpamplona.amethyst.ui.screen.loggedIn.favorites.EmbeddedNostrAppController]).
*
* Warm-keep works by keeping the session's [SandboxedSdkView] **attached** to the window the whole
* time (just moved off-screen when not active) the privacy-sandbox view only closes its session on
@@ -30,10 +30,10 @@ import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp
import com.vitorpamplona.amethyst.commons.tor.TorType
import com.vitorpamplona.amethyst.favorites.FavoriteAppLauncher
import com.vitorpamplona.amethyst.model.ThemeType
import com.vitorpamplona.amethyst.napplet.WebUrlNetworkRegistry
import com.vitorpamplona.amethyst.napplet.WebAppNetworkRegistry
import com.vitorpamplona.amethyst.napplethost.NappletHostContract
import com.vitorpamplona.amethyst.ui.screen.loggedIn.browser.EmbeddedBrowserController
import com.vitorpamplona.amethyst.ui.screen.loggedIn.favorites.EmbeddedNappletController
import com.vitorpamplona.amethyst.ui.screen.loggedIn.browser.EmbeddedWebAppController
import com.vitorpamplona.amethyst.ui.screen.loggedIn.favorites.EmbeddedNostrAppController
/**
* The single place that builds a warm embedded-tab controller, so the favorite screens and the
@@ -46,19 +46,19 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.favorites.EmbeddedNappletCo
*/
@RequiresApi(Build.VERSION_CODES.R)
object EmbeddedTabFactory {
fun browserId(url: String) = "url:$url"
fun webAppId(url: String) = "url:$url"
fun nappletId(coordinate: String) = "nostr:$coordinate"
fun nostrAppId(coordinate: String) = "nostr:$coordinate"
/** Acquires (or returns) the warm browser controller for [url], routing over Tor per the site's choice. */
fun acquireBrowser(
fun acquireWebApp(
context: Context,
url: String,
backgroundColor: Int,
): EmbeddedBrowserController =
EmbeddedTabHost.acquire(browserId(url)) {
): EmbeddedWebAppController =
EmbeddedTabHost.acquire(webAppId(url)) {
val proxyPort = Amethyst.instance.torManager.activePortOrNull.value ?: -1
val initialUseTor = proxyPort > 0 && WebUrlNetworkRegistry.useTor(url)
val initialUseTor = proxyPort > 0 && WebAppNetworkRegistry.useTor(url)
val themeType = Amethyst.instance.uiPrefs.value.theme.value
val theme =
when (themeType) {
@@ -69,24 +69,24 @@ object EmbeddedTabFactory {
if (nightMask == Configuration.UI_MODE_NIGHT_YES) "DARK" else "LIGHT"
}
}
EmbeddedBrowserController(context.applicationContext, proxyPort, initialUseTor, backgroundColor, theme).also { it.bind(url) }
} as EmbeddedBrowserController
EmbeddedWebAppController(context.applicationContext, proxyPort, initialUseTor, backgroundColor, theme).also { it.bind(url) }
} as EmbeddedWebAppController
/**
* Acquires (or returns) the warm nsite/napplet controller for [coordinate] using already-resolved
* launch [params] (the caller has the metadata anyway). [backgroundColor] is stamped into the params
* so the sandbox WebView doesn't flash white before paint.
*/
fun acquireNapplet(
fun acquireNostrApp(
context: Context,
coordinate: String,
params: Bundle,
backgroundColor: Int,
): EmbeddedNappletController {
): EmbeddedNostrAppController {
params.putInt(NappletHostContract.EXTRA_BG_COLOR, backgroundColor)
return EmbeddedTabHost.acquire(nappletId(coordinate)) {
EmbeddedNappletController(context.applicationContext, params).also { it.bind() }
} as EmbeddedNappletController
return EmbeddedTabHost.acquire(nostrAppId(coordinate)) {
EmbeddedNostrAppController(context.applicationContext, params).also { it.bind() }
} as EmbeddedNostrAppController
}
/**
@@ -100,30 +100,30 @@ object EmbeddedTabFactory {
backgroundColor: Int,
): Boolean =
when (app) {
is FavoriteApp.WebUrl -> {
is FavoriteApp.WebApp -> {
// Privacy: never preload a Tor-routed site over clearnet just because Tor hasn't finished
// connecting yet. A site only "wants Tor" when Tor is enabled AND not opted out for it; in
// that case wait until the proxy port is actually up (the preloader retries). When Tor is
// off, or the user opted this site out, clearnet IS the real route — preload immediately.
val torEnabled = Amethyst.instance.torPrefs.torType.value != TorType.OFF
val wantsTor = torEnabled && WebUrlNetworkRegistry.useTor(app.url)
val wantsTor = torEnabled && WebAppNetworkRegistry.useTor(app.url)
val torReady = Amethyst.instance.torManager.activePortOrNull.value != null
if (wantsTor && !torReady) {
false
} else {
acquireBrowser(context, app.url, backgroundColor)
acquireWebApp(context, app.url, backgroundColor)
true
}
}
is FavoriteApp.NostrApp -> {
if (EmbeddedTabHost.isWarm(nappletId(app.coordinate))) {
if (EmbeddedTabHost.isWarm(nostrAppId(app.coordinate))) {
true
} else {
val params = FavoriteAppLauncher.embedParams(context, app.coordinate)
if (params == null) {
false
} else {
acquireNapplet(context, app.coordinate, params, backgroundColor)
acquireNostrApp(context, app.coordinate, params, backgroundColor)
true
}
}
@@ -35,7 +35,7 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.favorites.FavoriteAppsRegistry
import com.vitorpamplona.amethyst.napplet.NappletNetworkRegistry
import com.vitorpamplona.amethyst.napplet.WebUrlNetworkRegistry
import com.vitorpamplona.amethyst.napplet.WebAppNetworkRegistry
import com.vitorpamplona.amethyst.ui.navigation.bottombars.favoriteIds
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import kotlinx.coroutines.delay
@@ -81,9 +81,9 @@ fun EmbeddedTabPreloader(accountViewModel: AccountViewModel) {
// Hydrate the per-site Tor/open-web choices BEFORE the first preload: a cold start otherwise reads
// the bare Tor default and would route a site the user pinned to the open web through Tor (or stall
// it waiting for Tor), which is exactly what breaks Tor-incompatible servers.
WebUrlNetworkRegistry.init(context)
WebAppNetworkRegistry.init(context)
NappletNetworkRegistry.init(context)
WebUrlNetworkRegistry.awaitReady()
WebAppNetworkRegistry.awaitReady()
NappletNetworkRegistry.awaitReady()
var attempt = 0
while (isActive) {
@@ -52,10 +52,10 @@ import java.util.concurrent.atomic.AtomicLong
*
* [params] is the bundle minted in the main process by
* [NappletLauncher.buildLaunchParams][com.vitorpamplona.amethyst.napplet.NappletLauncher.buildLaunchParams]
* the verified manifest, identity, and launch token. The mirror of `EmbeddedBrowserController`.
* the verified manifest, identity, and launch token. The mirror of `EmbeddedWebAppController`.
*/
@RequiresApi(Build.VERSION_CODES.R)
class EmbeddedNappletController(
class EmbeddedNostrAppController(
private val appContext: Context,
private val params: Bundle,
) : EmbeddedSurfaceController,
@@ -180,7 +180,7 @@ internal fun FavoriteAppCell(
val iconKeys by BrowserIconRegistry.keys.collectAsStateWithLifecycle()
val faviconModel =
remember(app, iconKeys) {
(app as? FavoriteApp.WebUrl)?.let { OmniboxInput.hostOf(it.url)?.let(BrowserIconRegistry::iconModelFor) }
(app as? FavoriteApp.WebApp)?.let { OmniboxInput.hostOf(it.url)?.let(BrowserIconRegistry::iconModelFor) }
}
Column(
@@ -58,6 +58,7 @@ import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp
import com.vitorpamplona.amethyst.favorites.FavoriteAppLauncher
import com.vitorpamplona.amethyst.favorites.FavoriteAppsRegistry
import com.vitorpamplona.amethyst.napplethost.HostProfile
import com.vitorpamplona.amethyst.napplethost.NappletEmbedContract
import com.vitorpamplona.amethyst.napplethost.NappletHostContract
import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar
@@ -70,7 +71,8 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedTabFactory
import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedTabHost
/**
* A favorited nsite/napplet rendered as an **in-app tab**. The verified-blob sandbox surface (hosted in
* A **Nostr app** an nSite or nApplet, reached by [coordinate] (favorited or not) rendered as an
* **in-app tab**. The verified-blob sandbox surface (hosted in
* the keyless `:napplet` process by `NappletHostService`) is drawn by the persistent [EmbeddedTabHost]
* layer, which keeps the session warm across tab swaps the surface stays attached and just moves over
* the area this screen reserves. The **trusted chrome** (sandbox shield, app name, "what it can access")
@@ -81,13 +83,13 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedTabHost
* leaves. Requires API 30+ for the cross-process surface.
*/
@Composable
fun FavoriteNappletScreen(
fun NostrAppScreen(
coordinate: String,
accountViewModel: AccountViewModel,
nav: INav,
) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
EmbeddedNappletTab(coordinate, accountViewModel, nav)
EmbeddedNostrAppTab(coordinate, accountViewModel, nav)
} else {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text(
@@ -101,7 +103,7 @@ fun FavoriteNappletScreen(
@RequiresApi(Build.VERSION_CODES.R)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun EmbeddedNappletTab(
private fun EmbeddedNostrAppTab(
coordinate: String,
accountViewModel: AccountViewModel,
nav: INav,
@@ -121,7 +123,7 @@ private fun EmbeddedNappletTab(
val title = params.getString(NappletHostContract.EXTRA_TITLE).orEmpty()
val capLabels = params.getStringArrayList(NappletHostContract.EXTRA_CAP_LABELS).orEmpty()
val websiteMode = params.getBoolean(NappletHostContract.EXTRA_WEBSITE_MODE, false)
val profile = HostProfile.fromName(params.getString(NappletHostContract.EXTRA_HOST_PROFILE))
val useTor = params.getBoolean(NappletHostContract.EXTRA_USE_TOR, true)
var canGoBack by remember { mutableStateOf(false) }
@@ -132,7 +134,7 @@ private fun EmbeddedNappletTab(
val controller =
remember(id) {
EmbeddedTabFactory.acquireNapplet(context, coordinate, params, backgroundColor)
EmbeddedTabFactory.acquireNostrApp(context, coordinate, params, backgroundColor)
}
// Keep the controller callbacks fresh (cheap, need the latest closures).
@@ -198,12 +200,12 @@ private fun EmbeddedNappletTab(
BackHandler(enabled = canGoBack) { controller.back() }
if (showAccess) {
AccessDialog(title, capLabels, websiteMode, useTor) { showAccess = false }
AccessDialog(title, capLabels, profile.exposesNetwork, useTor) { showAccess = false }
}
Scaffold(
bottomBar = {
AppBottomBar(Route.FavoriteNostrApp(coordinate), nav, accountViewModel) { route -> nav.navBottomBar(route) }
AppBottomBar(Route.NostrApp(coordinate), nav, accountViewModel) { route -> nav.navBottomBar(route) }
},
) { padding ->
// Reserve the full content area; the warm surface, its top sheet, and the loading/error overlay
@@ -227,7 +229,7 @@ private fun UnavailableTab(
Scaffold(
topBar = { TopAppBar(title = { Text(stringResource(R.string.favorite_apps)) }) },
bottomBar = {
AppBottomBar(Route.FavoriteNostrApp(coordinate), nav, accountViewModel) { route -> nav.navBottomBar(route) }
AppBottomBar(Route.NostrApp(coordinate), nav, accountViewModel) { route -> nav.navBottomBar(route) }
},
) { padding ->
Box(
@@ -250,7 +252,7 @@ private fun UnavailableTab(
private fun AccessDialog(
title: String,
capLabels: List<String>,
websiteMode: Boolean,
showsNetwork: Boolean,
useTor: Boolean,
onDismiss: () -> Unit,
) {
@@ -261,7 +263,7 @@ private fun AccessDialog(
capLabels.joinToString("\n") { "$it" }
}
val networkBody =
if (websiteMode) {
if (showsNetwork) {
"\n\n" + stringResource(if (useTor) R.string.favorite_app_network_tor else R.string.favorite_app_network_open)
} else {
""
+4 -4
View File
@@ -661,8 +661,8 @@
<string name="exercise_circuit">Circuit</string>
<string name="exercise_emom">EMOM</string>
<string name="exercise_amrap">AMRAP</string>
<string name="software_apps">Apps</string>
<string name="route_software_apps">Apps</string>
<string name="software_apps">App Store</string>
<string name="route_software_apps">App Store</string>
<string name="napplets">nApplets</string>
<string name="nsites">nSites</string>
<string name="nsite_none_found">No nSites found yet.</string>
@@ -681,8 +681,8 @@
<string name="browser_favorites">Favorites</string>
<string name="browser_recent_options">Options</string>
<string name="browser_recent_remove">Remove from history</string>
<string name="favorite_apps">Favorite apps</string>
<string name="favorite_apps_empty">No favorite apps yet. Open a web client or nsite and tap the star to pin it here.</string>
<string name="favorite_apps">Web apps</string>
<string name="favorite_apps_empty">Nothing here yet. Open a web app, nApplet, or nSite and tap the star to keep it here.</string>
<string name="favorite_app_add">Add to favorites</string>
<string name="favorite_app_remove">Remove from favorites</string>
<string name="favorite_app_open_window">Open in its own window</string>
@@ -29,14 +29,15 @@ import androidx.compose.runtime.Immutable
*
* Two shapes, because the launch layer has exactly two shapes (see `FavoriteAppLauncher`):
*
* - [NostrApp] a NIP-5A nsite **or** NIP-5D napplet. Both resolve to the same launch path
* - [NostrApp] a NIP-5A nSite **or** NIP-5D nApplet. Both resolve to the same launch path
* (`NappletLauncher` sandboxed `:napplet` host), so they share one case here. Whether the
* resolved event is an nsite (website mode) or a locked napplet is recomputed from the live event
* resolved event is an nSite (website mode) or a locked nApplet is recomputed from the live event
* at launch time, never stored: the addressable [coordinate] survives code/manifest updates, but
* the manifest's `requires`/website-mode flags do not.
* - [WebUrl] an arbitrary `https://` nostr client. No identity, no coordinate; the URL is the key.
* - [WebApp] a Nostr web client reached by an arbitrary `https://` URL. No identity, no
* coordinate; the URL is the key.
*
* A [NostrApp] is only launchable while its event is resolvable in `LocalCache`; a [WebUrl] is always
* A [NostrApp] is only launchable while its event is resolvable in `LocalCache`; a [WebApp] is always
* launchable. That is the one robustness difference between the two cases.
*/
@Immutable
@@ -68,14 +69,15 @@ sealed interface FavoriteApp {
override val id: String get() = "nostr:$coordinate"
}
/** An arbitrary web client reached by URL. */
/** A Nostr web client reached by URL — an arbitrary `https://` app. */
@Immutable
data class WebUrl(
data class WebApp(
val url: String,
override val label: String,
override val addedAt: Long,
override val iconUrl: String? = null,
) : FavoriteApp {
// The "url:" prefix is persisted (favorites de-dup + bottom-bar keys); keep it stable.
override val id: String get() = "url:$url"
}
}
@@ -0,0 +1,73 @@
/*
* 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.napplethost
import com.vitorpamplona.amethyst.commons.napplet.NappletCapability
import com.vitorpamplona.amethyst.commons.napplet.NappletWebContract
import com.vitorpamplona.amethyst.commons.napplet.resolveRequiredCapabilities
/**
* The security posture a sandbox host renders under the single decision that "locked nApplet vs
* open nSite" actually *is*. Replaces the old scattered `websiteMode` boolean: resolved once in the
* trusted main process, carried over the Intent/Messenger boundary as [name], and every coupled
* consequence (capabilities, CSP, NIP-07, off-origin, network UI) reads from here so they can't
* drift out of sync, and a third posture is one new constant instead of several edited call sites.
*/
enum class HostProfile {
/** NIP-5D napplet: locked sandbox — declared-only capabilities, strict app CSP, no off-origin, pinned to Tor. */
NAPPLET,
/** NIP-5A nSite ("website mode"): a normal web app — NIP-07 provider, no app CSP, off-origin + per-site Tor toggle. */
WEBSITE,
;
/**
* What this posture is allowed to ask the broker for THE security decision, minted into the
* launch token in the trusted main process. A website gets the IDENTITY + RELAY pair NIP-07 needs
* (consent-gated); a locked napplet gets only what its manifest `requires` declares.
*/
fun declaredCapabilities(requires: List<String>): Set<NappletCapability> =
when (this) {
WEBSITE -> setOf(NappletCapability.IDENTITY, NappletCapability.RELAY)
NAPPLET -> resolveRequiredCapabilities(requires).capabilities.toSet()
}
/** Strict app CSP for a locked applet (connect-src 'none', etc.); null for a website, which sets its own. */
val appCsp: String?
get() = if (this == NAPPLET) NappletWebContract.APP_CSP else null
/** Install the NIP-07 `window.nostr` provider before the shim runs. */
val injectsNip07: Boolean get() = this == WEBSITE
/** Let off-origin requests reach the network (a locked applet 404s them — its CSP is connect-src 'none'). */
val allowsOffOrigin: Boolean get() = this == WEBSITE
/**
* Apply the WebView SOCKS proxy and expose the Tor/network toggle. A website can re-route over Tor
* or the open web; a locked napplet is pinned to Tor with no toggle.
*/
val exposesNetwork: Boolean get() = this == WEBSITE
companion object {
/** Reconstruct from the wire [name], defaulting to the locked [NAPPLET] posture on anything unknown. */
fun fromName(name: String?): HostProfile = entries.firstOrNull { it.name == name } ?: NAPPLET
}
}
@@ -57,9 +57,9 @@ class NappletContentServer(
// The applet's own per-applet origin (a distinct napplet.local subdomain). The shell is on
// NappletWebContract.ORIGIN; app blobs are served here so the applet has a real, isolated origin.
private val appOrigin: String,
// nSite "website mode": the applet is a normal web app — it gets a NIP-07 window.nostr provider,
// normal network (no app CSP; off-origin requests defer to the WebView), unlike a locked napplet.
private val websiteMode: Boolean = false,
// The host posture: a WEBSITE nSite is a normal web app — NIP-07 window.nostr provider, normal
// network (no app CSP; off-origin requests defer to the WebView), unlike a locked NAPPLET.
private val profile: HostProfile = HostProfile.NAPPLET,
// Embedded surfaces (a windowless Service) can't host the soft keyboard, so the shim installs the
// IME proxy agent that relays the focused field to the host's keyboard. The full-screen Activity
// host has a native keyboard and leaves this false.
@@ -126,7 +126,7 @@ class NappletContentServer(
}
// Off-origin: a locked napplet 404s (connect-src 'none' means it shouldn't ask). An nSite in
// website mode is a normal web app — defer to the WebView so it can load external resources.
return if (websiteMode) null else notFound()
return if (profile.allowsOffOrigin) null else notFound()
}
private fun serveShell(): WebResourceResponse {
@@ -172,7 +172,7 @@ class NappletContentServer(
// Locked napplets get the strict app CSP (connect-src 'none', etc.). An nSite in website mode
// is a normal web app: no app CSP, so it can talk to relays (wss) and load external resources.
val headers = if (websiteMode) emptyMap() else mapOf("Content-Security-Policy" to NappletWebContract.APP_CSP)
val headers = profile.appCsp?.let { mapOf("Content-Security-Policy" to it) } ?: emptyMap()
return WebResourceResponse(
mime,
charset,
@@ -193,7 +193,7 @@ class NappletContentServer(
// theme-independently. Allowed by the app CSP's `style-src 'unsafe-inline'`.
val style = "<style>html,body{overscroll-behavior:none !important}</style>"
// In website mode, set the NIP-07 flag synchronously *before* the shim so window.nostr installs.
val nip07Flag = if (websiteMode) "<script>window.__nappletNip07=true;</script>" else ""
val nip07Flag = if (profile.injectsNip07) "<script>window.__nappletNip07=true;</script>" else ""
// Embedded surface: turn on the IME proxy agent (set before the shim runs).
val imeFlag = if (imeProxy) "<script>window.__nappletImeProxy=true;</script>" else ""
val script = "$style$nip07Flag$imeFlag<script>$shimJs</script>"
@@ -110,8 +110,8 @@ class NappletHostActivity : ComponentActivity() {
// The sandbox never carries its own coordinate, so a compromised :napplet process can't forge one.
private var launchToken: String = ""
// nSite "website mode": a normal web app (NIP-07 window.nostr + normal network), vs a locked napplet.
private var websiteMode: Boolean = false
// The host posture: a WEBSITE nSite (NIP-07 window.nostr + normal network) vs a locked NAPPLET.
private var profile: HostProfile = HostProfile.NAPPLET
// Per-site network choice: route this site's traffic through Tor (default) or over the open web.
// Applied to both the WebView proxy and the blob-fetch client; toggled from the top-bar onion.
@@ -224,7 +224,7 @@ class NappletHostActivity : ComponentActivity() {
// "Open web" for a site makes everything direct — both its blob fetches (here) and its live
// web traffic (the WebView proxy, below). Tor (the default) routes both through the SOCKS port.
val effectiveProxy = if (useTor) proxyPort else -1
contentServer = NappletContentServer(paths, servers, effectiveProxy, cacheDir, shellHtml, shim, appOrigin, websiteMode)
contentServer = NappletContentServer(paths, servers, effectiveProxy, cacheDir, shellHtml, shim, appOrigin, profile)
// Create + warm the WebView NOW so its (slow, first-in-process) Chromium init runs on the main
// thread concurrently with the index probe below (which runs on IO) — instead of serially after
@@ -237,7 +237,7 @@ class NappletHostActivity : ComponentActivity() {
// Route the WebView's own (off-origin) traffic through Tor for an nSite, unless this site was
// opted out to the open web. Set process-wide before any page navigation; the shell + blobs are
// served from cache via shouldInterceptRequest, so only the site's external requests hit this.
if (websiteMode) applyWebViewProxy(effectiveProxy)
if (profile.exposesNetwork) applyWebViewProxy(effectiveProxy)
// Origin-restricted bridge: only the trusted shell page (main frame) can reach native.
WebViewCompat.addWebMessageListener(
webView,
@@ -411,7 +411,7 @@ class NappletHostActivity : ComponentActivity() {
servers.addAll(intent.getStringArrayListExtra(NappletHostContract.EXTRA_SERVERS) ?: emptyList())
author = intent.getStringExtra(NappletHostContract.EXTRA_AUTHOR).orEmpty()
identifier = intent.getStringExtra(NappletHostContract.EXTRA_IDENTIFIER).orEmpty()
websiteMode = intent.getBooleanExtra(NappletHostContract.EXTRA_WEBSITE_MODE, false)
profile = HostProfile.fromName(intent.getStringExtra(NappletHostContract.EXTRA_HOST_PROFILE))
useTor = intent.getBooleanExtra(NappletHostContract.EXTRA_USE_TOR, true)
title = intent.getStringExtra(NappletHostContract.EXTRA_TITLE).orEmpty()
proxyPort = intent.getIntExtra(NappletHostContract.EXTRA_PROXY_PORT, -1)
@@ -731,8 +731,8 @@ class NappletHostActivity : ComponentActivity() {
onReload = { if (this::webView.isInitialized) webView.reload() },
// Website-mode nSites can re-route over Tor; switching rebuilds the session via a confirm
// dialog, so the row taps through rather than toggling inline.
torInitiallyOn = if (websiteMode && proxyPort > 0) useTor else null,
onNetworkTap = if (websiteMode && proxyPort > 0) ({ showNetworkDialog() }) else null,
torInitiallyOn = if (profile.exposesNetwork && proxyPort > 0) useTor else null,
onNetworkTap = if (profile.exposesNetwork && proxyPort > 0) ({ showNetworkDialog() }) else null,
onInfo = { showAccessDialog() },
)
@@ -61,10 +61,11 @@ object NappletHostContract {
const val EXTRA_BG_COLOR = "napplet_bg_color"
/**
* nSite "website mode": treat the content as a normal web app install the NIP-07 `window.nostr`
* provider and allow normal network (no app CSP). Off for locked napplets.
* The host posture this launch renders under a [HostProfile] name. WEBSITE treats the content as
* a normal web app (NIP-07 `window.nostr` provider, normal network, no app CSP); NAPPLET is the
* locked sandbox. Resolved in the trusted main process; the sandbox derives all coupled policy from it.
*/
const val EXTRA_WEBSITE_MODE = "napplet_website_mode"
const val EXTRA_HOST_PROFILE = "napplet_host_profile"
/**
* Whether this site's traffic routes through Tor (true, the default when Tor is active) or over the
@@ -89,7 +89,7 @@ class NappletHostService : Service() {
val author: String,
val identifier: String,
val launchToken: String,
val websiteMode: Boolean,
val profile: HostProfile,
val useTor: Boolean,
val proxyPort: Int,
val bgColor: Int,
@@ -218,7 +218,7 @@ class NappletHostService : Service() {
author = author,
identifier = data.getString(NappletHostContract.EXTRA_IDENTIFIER).orEmpty(),
launchToken = launchToken,
websiteMode = data.getBoolean(NappletHostContract.EXTRA_WEBSITE_MODE, false),
profile = HostProfile.fromName(data.getString(NappletHostContract.EXTRA_HOST_PROFILE)),
useTor = data.getBoolean(NappletHostContract.EXTRA_USE_TOR, true),
proxyPort = data.getInt(NappletHostContract.EXTRA_PROXY_PORT, -1),
bgColor = data.getInt(NappletHostContract.EXTRA_BG_COLOR, android.graphics.Color.WHITE),
@@ -255,13 +255,13 @@ class NappletHostService : Service() {
val wv = WebView(context)
val appOrigin = NappletWebContract.appOrigin(deriveAppId(tab.author, tab.identifier))
val effectiveProxy = if (tab.useTor) tab.proxyPort else -1
tab.contentServer = NappletContentServer(tab.paths, tab.servers, effectiveProxy, cacheDir, shellHtml, shimJs, appOrigin, tab.websiteMode, imeProxy = true)
tab.contentServer = NappletContentServer(tab.paths, tab.servers, effectiveProxy, cacheDir, shellHtml, shimJs, appOrigin, tab.profile, imeProxy = true)
hardenWebView(wv, tab)
// Theme the pre-load background so the shell/app loading shows Amethyst's background, not white.
wv.setBackgroundColor(tab.bgColor)
wv.dropSystemBarInsets()
if (tab.websiteMode) applyWebViewProxy(effectiveProxy)
if (tab.profile.exposesNetwork) applyWebViewProxy(effectiveProxy)
WebViewCompat.addWebMessageListener(wv, NappletWebContract.BRIDGE_NAME, setOf(NappletWebContract.ORIGIN), ::onShellMessage)
tab.webView = wv
wv.loadUrl(NappletWebContract.SHELL_URL)