mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-09 08:04:45 +00:00
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:
@@ -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.
|
||||
Binary file not shown.
+11
-10
@@ -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)
|
||||
}
|
||||
|
||||
+1
-1
@@ -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) }
|
||||
|
||||
+4
-4
@@ -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)
|
||||
}
|
||||
|
||||
+1
-1
@@ -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 {
|
||||
|
||||
+3
-3
@@ -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()),
|
||||
)
|
||||
}
|
||||
},
|
||||
|
||||
+1
-1
@@ -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,
|
||||
+13
-12
@@ -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
|
||||
+1
-1
@@ -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>
|
||||
|
||||
+2
-2
@@ -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
|
||||
|
||||
+21
-21
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
+3
-3
@@ -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) {
|
||||
|
||||
+2
-2
@@ -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,
|
||||
+1
-1
@@ -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(
|
||||
|
||||
+13
-11
@@ -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 {
|
||||
""
|
||||
@@ -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>
|
||||
|
||||
+8
-6
@@ -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
|
||||
}
|
||||
}
|
||||
+6
-6
@@ -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>"
|
||||
|
||||
+7
-7
@@ -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() },
|
||||
)
|
||||
|
||||
|
||||
+4
-3
@@ -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
|
||||
|
||||
+4
-4
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user