fix(browser): Tor always wins and fails closed; per-document subscriptions; bounded held requests

Tor / proxy
- NappletProxyClaims: no host exemptions — any surface that wants Tor puts
  all of :napplet on Tor. Pages set to the open web show why they're on
  Tor anyway (BrowserChrome.State.torForced, fed by MSG_ROUTE/observeRoute).
- WebViewProxyPolicy fails closed: a load waits for the route to apply, and
  gets onFailed (never a direct load) when applying fails or the WebView
  can't proxy while Tor is wanted. Callbacks run on the main executor.
- Launchers pass useTor = "Tor is on", not "port known": a Tor surface with
  no port yet blocks (embedded: Retry re-reads the port; full screen:
  refuses with a toast) instead of going out on the open web.

Sessions
- Provider closes a live tab before accepting a duplicate create; closeTab
  removes by identity. Session ids carry a per-process nonce. rearmSession
  clears createOnShow. Late onUiError while a create is pending is ignored;
  Retry re-creates a surface that never opened.
- Sessions start unattended; controllers always send their state.

NIP-07 / relay reads
- Relay subIds are stamped per document; pushes for a replaced document
  are dropped. A main-frame onPageStarted ends the document.
- Held requests are capped (32) and expire (2 min) with failure replies.
- Browser token mints carry the surface's storage profile; the broker
  refuses one that isn't the signed-in account's. Browser tokens get their
  own LRU so subdomain cycling can't evict napplet tokens.
- Broker tracks attendance per client: encrypted subscription events are
  held undecrypted until the page is attended; relay.query waits for it.

Also: releaseWhenGone tracks every parked back-stack entry; the console
error count is read in its own composable scope.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G3bteStHvaf18TdABSkb8h
This commit is contained in:
Claude
2026-09-30 20:38:30 +00:00
parent a374747276
commit 3a78cd21c4
34 changed files with 1193 additions and 258 deletions
@@ -30,6 +30,7 @@ import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp
import com.vitorpamplona.amethyst.commons.favorites.favoriteCoordinateOf
import com.vitorpamplona.amethyst.commons.model.ThemeType
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
import com.vitorpamplona.amethyst.commons.tor.TorType
import com.vitorpamplona.amethyst.napplet.NappletLauncher
import com.vitorpamplona.amethyst.napplet.NappletWebViewProfiles
import com.vitorpamplona.amethyst.napplet.WebAppNetworkRegistry
@@ -87,7 +88,10 @@ object FavoriteAppLauncher {
preferTor: Boolean = false,
) {
val proxyPort = Amethyst.instance.torManager.activePortOrNull.value ?: -1
val useTor = proxyPort > 0 && (preferTor || WebAppNetworkRegistry.useTor(url))
// Whether Tor is ON, not whether its port is known yet: a surface that wants Tor with no port refuses
// to load (fails closed) rather than quietly going out on the open web while Tor is still starting.
val torEnabled = Amethyst.instance.torPrefs.torType.value != TorType.OFF
val useTor = torEnabled && (preferTor || WebAppNetworkRegistry.useTor(url))
val themeType = Amethyst.instance.uiPrefs.value.theme.value
val theme =
when (themeType) {
@@ -37,8 +37,10 @@ import com.vitorpamplona.amethyst.commons.browser.BrowserSitePermission
import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerPermissionLedger
import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp
import com.vitorpamplona.amethyst.commons.model.Account
import com.vitorpamplona.amethyst.commons.napplet.NappletAttendance
import com.vitorpamplona.amethyst.commons.napplet.NappletBroker
import com.vitorpamplona.amethyst.commons.napplet.NappletCapability
import com.vitorpamplona.amethyst.commons.napplet.NappletHeldRequests
import com.vitorpamplona.amethyst.commons.napplet.NappletIdentity
import com.vitorpamplona.amethyst.commons.napplet.NappletIdentityWatch
import com.vitorpamplona.amethyst.commons.napplet.NappletRequestRouter
@@ -95,7 +97,9 @@ class NappletBrokerService : Service() {
// Live relay subscriptions, keyed by the requesting surface plus the applet's subId. The account comes per-open from the
// requesting surface's launch token, so a surface's REQs always target the account it acts as.
private val liveSubscriptions = NappletLiveSubscriptions(scope)
// Which surfaces the user is looking at: relay reads are decrypted for a page only while it is.
private val attendance = NappletAttendance<Messenger>()
private val liveSubscriptions = NappletLiveSubscriptions(scope, attendance)
// NAP-RESOURCE cancellation is keyed by the trusted launch token plus the caller's request id.
// Cancelling removes the job before it can emit a late terminal envelope to the sandbox.
@@ -161,6 +165,7 @@ class NappletBrokerService : Service() {
msg.replyTo?.let {
incBus.removeAll(it)
liveSubscriptions.closeAllFor(it)
attendance.forget(it)
}
// Tokens the surface will never use again: drop their sessions and whatever runs under them.
// Only the surface that minted a token holds it (tokens are unguessable), so it can only ever
@@ -183,6 +188,15 @@ class NappletBrokerService : Service() {
// A sandbox surface (full-screen :napplet host) entered, renewed, or left the foreground. Hold the
// main process resumed while at least one is foreground, so opening it doesn't tear down Tor/relays.
if (msg.what == NappletIpc.MSG_SET_ATTENDED) {
val owner = msg.replyTo ?: return true
val attended = msg.data?.getBoolean(NappletIpc.KEY_ATTENDED, false) ?: false
attendance.set(owner, attended)
// Encrypted events its subscriptions received meanwhile can be decrypted and delivered now.
if (attended) liveSubscriptions.onAttended(owner)
return true
}
if (msg.what == NappletIpc.MSG_SET_FOREGROUND) {
val data = msg.data ?: return true
val token = data.getString(NappletIpc.KEY_LAUNCH_TOKEN) ?: return true
@@ -349,7 +363,11 @@ class NappletBrokerService : Service() {
// Bind to the account active at mint time: a browser token minted for one account must
// never sign as another if the user switches while the page is still open.
val mintAccount = Amethyst.instance.sessionManager.loggedInAccount()
if (mintAccount == null) {
// The surface names the storage jar it runs in: a page left open across an account switch (its
// cookies, its session, belong to the previous account) must not be re-minted a token that acts
// as the new one — its token is evicted or released, and it asks again from the old jar.
val surfaceProfile = data.getString(NappletIpc.KEY_WEBVIEW_PROFILE)
if (mintAccount == null || surfaceProfile != NappletWebViewProfiles.forPubKey(mintAccount.pubKey)) {
// No one to act as: answer anyway (with no token), so the page's queued calls fail right away
// instead of waiting forever for a token that will never come.
val refusal =
@@ -359,7 +377,7 @@ class NappletBrokerService : Service() {
runCatching { replyTo.send(refusal) }
return true
}
val token = NappletLaunchRegistry.register(identity, NappletCapability.WEBSITE_CAPABILITIES, mintAccount.pubKey)
val token = NappletLaunchRegistry.register(identity, NappletCapability.WEBSITE_CAPABILITIES, mintAccount.pubKey, browserOrigin = true)
val response =
Message.obtain(null, NappletIpc.MSG_BROWSER_TOKEN).apply {
this.data =
@@ -419,6 +437,12 @@ class NappletBrokerService : Service() {
reply(replyTo, requestId, NappletProtocolJson.encodeResponse(requestType, NappletResponse.Failed("That account is no longer signed in.")))
return@launch
}
// A query's results are decrypted with the user's key: while nobody is looking at the page it
// waits (as its sign / decrypt requests do), and gives up like them.
if (requestType == "relay.query" && !attendance.awaitAttended(replyTo, NappletHeldRequests.MAX_AGE_MS)) {
reply(replyTo, requestId, NappletProtocolJson.encodeResponse(requestType, NappletResponse.Failed(NappletHeldRequests.EXPIRED)))
return@launch
}
when (val outcome = NappletRequestRouter.route(broker, identity, declared, payload)) {
is NappletRequestRouter.Outcome.Ignore -> {}
is NappletRequestRouter.Outcome.Reply -> {
@@ -71,19 +71,27 @@ object NappletLaunchRegistry {
// LinkedHashMap + @Synchronized pair provided, without JVM-only APIs.
private val sessions = LruCache<String, Session>(MAX_SESSIONS)
// Browser tokens live apart: a page mints one per origin it touches, so a site cycling through
// subdomains (a.x.com, b.x.com, …) could otherwise push every open napplet's token out of the cache.
private val browserSessions = LruCache<String, Session>(MAX_SESSIONS)
fun register(
identity: NappletIdentity,
declared: Set<NappletCapability>,
accountPubKey: HexKey,
browserOrigin: Boolean = false,
): String {
val token = RandomInstance.bytes(32).toHexKey()
sessions.put(token, Session(identity.copy(instanceId = token), declared, accountPubKey))
(if (browserOrigin) browserSessions else sessions).put(token, Session(identity.copy(instanceId = token), declared, accountPubKey))
return token
}
fun resolve(token: String?): Session? = token?.let { sessions[it] }
fun resolve(token: String?): Session? = token?.let { sessions[it] ?: browserSessions[it] }
fun unregister(token: String?) {
token?.let { sessions.remove(it) }
token?.let {
sessions.remove(it)
browserSessions.remove(it)
}
}
}
@@ -29,6 +29,7 @@ import com.vitorpamplona.amethyst.commons.model.ThemeType
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
import com.vitorpamplona.amethyst.commons.napplet.NappletArtifactPolicy
import com.vitorpamplona.amethyst.commons.napplet.NappletIdentity
import com.vitorpamplona.amethyst.commons.tor.TorType
import com.vitorpamplona.amethyst.napplethost.HostProfile
import com.vitorpamplona.amethyst.napplethost.NappletHostActivity
import com.vitorpamplona.amethyst.napplethost.NappletHostContract
@@ -174,7 +175,10 @@ object NappletLauncher {
// 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 (profile.exposesNetwork) NappletNetworkRegistry.useTor(identity.coordinate) else true
// Whether Tor is ON, not whether its port is known yet: with Tor on and no port the host refuses to
// fetch anything (fails closed) instead of going out directly while Tor is still starting.
val torEnabled = Amethyst.instance.torPrefs.torType.value != TorType.OFF
val useTor = torEnabled && (!profile.exposesNetwork || NappletNetworkRegistry.useTor(identity.coordinate))
// Resolve capability labels here (the app has the resources) so the sandbox module needs none.
val capLabels = declared.map { stringRes(context, it.labelResId()) }
@@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.napplet
import android.os.Messenger
import com.vitorpamplona.amethyst.commons.model.Account
import com.vitorpamplona.amethyst.commons.napplet.NappletAttendance
import com.vitorpamplona.amethyst.commons.napplet.NappletRelayCleartext
import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletProtocolJson
import com.vitorpamplona.quartz.nip01Core.core.Event
@@ -55,6 +56,7 @@ import java.util.concurrent.atomic.AtomicInteger
*/
class NappletLiveSubscriptions(
private val scope: CoroutineScope,
private val attendance: NappletAttendance<Messenger>,
) {
private data class Key(
val owner: Messenger,
@@ -71,6 +73,10 @@ class NappletLiveSubscriptions(
val eoseSent = AtomicBoolean(false)
val deliveries = Channel<Delivery>(Channel.UNLIMITED)
var deliveryJob: Job? = null
// Encrypted events that arrived while nobody was looking at the page, still encrypted: they are
// decrypted and delivered when it is attended again. Touched only by the delivery coroutine.
val heldEncrypted = ArrayDeque<Event>()
}
private sealed interface Delivery {
@@ -80,6 +86,9 @@ class NappletLiveSubscriptions(
data object Eose : Delivery
// The page is being looked at again: deliver what was held.
data object Attended : Delivery
data class Closed(
val reason: String,
) : Delivery
@@ -114,9 +123,23 @@ class NappletLiveSubscriptions(
for (delivery in sub.deliveries) {
if (liveSubs[key] !== sub) break
when (delivery) {
is Delivery.RelayEvent ->
NappletRelayCleartext.forDelivery(delivery.event, account.signer)?.let {
push(NappletProtocolJson.encodeRelayEvent(nappletSubId, it))
is Delivery.RelayEvent -> {
val event = delivery.event
if (NappletRelayCleartext.isEncrypted(event) && !attendance.isAttended(owner)) {
// Don't decrypt for a page nobody is watching: keep it (bounded) for later.
if (sub.heldEncrypted.size >= MAX_HELD_ENCRYPTED) sub.heldEncrypted.removeFirst()
sub.heldEncrypted.addLast(event)
} else {
NappletRelayCleartext.forDelivery(event, account.signer)?.let {
push(NappletProtocolJson.encodeRelayEvent(nappletSubId, it))
}
}
}
Delivery.Attended ->
while (sub.heldEncrypted.isNotEmpty() && attendance.isAttended(owner)) {
NappletRelayCleartext.forDelivery(sub.heldEncrypted.removeFirst(), account.signer)?.let {
push(NappletProtocolJson.encodeRelayEvent(nappletSubId, it))
}
}
Delivery.Eose -> push(NappletProtocolJson.encodeRelayEose(nappletSubId))
is Delivery.Closed -> push(NappletProtocolJson.encodeRelayClosed(nappletSubId, delivery.reason))
@@ -156,6 +179,11 @@ class NappletLiveSubscriptions(
runCatching { sub.client.subscribe(sub.clientSubId, relays.associateWith { filters }, listener) }
}
/** [owner] is being looked at again: its subscriptions deliver the encrypted events they held. */
fun onAttended(owner: Messenger) {
liveSubs.forEach { (key, sub) -> if (key.owner == owner) sub.deliveries.trySend(Delivery.Attended) }
}
/** Stops [owner]'s live subscription [nappletSubId], unsubscribing from the client that opened it. */
fun close(
owner: Messenger,
@@ -182,4 +210,9 @@ class NappletLiveSubscriptions(
sub.deliveryJob?.cancel()
runCatching { sub.client.unsubscribe(sub.clientSubId) }
}
private companion object {
// Per subscription: past this, the oldest held encrypted event is dropped.
const val MAX_HELD_ENCRYPTED = 500
}
}
@@ -62,6 +62,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.ImeEvent
import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.MagnifierFrame
import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.consoleLevelOf
import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.parseImeEvent
import java.util.UUID
import java.util.concurrent.atomic.AtomicLong
/**
@@ -73,7 +74,9 @@ import java.util.concurrent.atomic.AtomicLong
@RequiresApi(Build.VERSION_CODES.R)
class EmbeddedWebAppController(
private val appContext: Context,
private val proxyPort: Int,
// Read on every create and load rather than once: Tor may still be starting when the tab is made, and a
// Tor page loads nothing (fails closed) until its port is known.
private val proxyPort: () -> Int,
private val initialUseTor: Boolean,
private val backgroundColor: Int,
private val themeType: String = "SYSTEM",
@@ -115,10 +118,16 @@ class EmbeddedWebAppController(
// A `:napplet` restart found this tab hidden: its session is re-created when it is next shown.
private var createOnShow = false
// A create is in flight: the view's old session erroring out now is the one being replaced, not news.
private var awaitingReady = false
// The current session's surface has shown in the view at least once (see [retry]).
private var uiDisplayed = false
// What the provider was last told (see [syncPageState]). Remembered so both are replayed right after each
// session is created: a parked tab can be hidden before the service even binds.
private var wantPaused = false
private var wantAttended = true
private var wantAttended = false
// The app is on screen / has been in the background long enough to pause even the visible tab.
private var appVisible = true
@@ -159,6 +168,12 @@ class EmbeddedWebAppController(
/** The user's per-tab settings as last set, for a screen coming back to this tab. */
val isTorOn: Boolean get() = useTor
// Whether `:napplet` routes through Tor right now: another surface that needs Tor puts every page on it.
private val routedOverTor = mutableStateOf(false)
/** This page is set to the open web but goes through Tor anyway, because another open page needs Tor. */
val isTorForced: Boolean get() = !useTor && routedOverTor.value
val isDesktopSite: Boolean get() = desktopSite
val currentTextZoom: Int get() = textZoom
@@ -166,7 +181,7 @@ class EmbeddedWebAppController(
// stamps its own id on every message; the provider uses it to route controls/updates to this tab.
// Re-minted whenever the remote session is re-created (see [attachView]), so a late close() from the
// previous view can never reap the replacement.
private var sessionId: String = "browser-${SESSION_SEQ.incrementAndGet()}"
private var sessionId: String = newSessionId()
/** Invoked on the main thread when the page navigates or retitles: (url, title or null, canGoBack, canGoForward). */
var onUrlChanged: ((String, String?, Boolean, Boolean) -> Unit)? = null
@@ -376,7 +391,9 @@ class EmbeddedWebAppController(
// The session being replaced may never have opened a surface (its view went away first), in which
// case no surface close will ever reach the provider for it.
send(NappletBrowserContract.MSG_CLOSE_SESSION) {}
sessionId = "browser-${SESSION_SEQ.incrementAndGet()}"
sessionId = newSessionId()
// This create IS the re-creation a `:napplet` restart deferred to the next show.
createOnShow = false
adapterDelivered = false
sessionDead = false
resetPageState()
@@ -391,12 +408,15 @@ class EmbeddedWebAppController(
private fun surfaceListener(view: SandboxedSdkView) =
object : SandboxedSdkViewEventListener {
override fun onUiDisplayed() {
// Nothing to do: the load state reports when the page itself paints.
// The load state reports when the page itself paints; this only says the surface opened.
if (sandboxedSdkView === view) uiDisplayed = true
}
override fun onUiError(error: Throwable) {
// A view this controller has since moved past (disposed, replaced) is not ours to revive.
if (sandboxedSdkView === view) onSurfaceLost(sessionDead = true)
// A view this controller has since moved past (disposed, replaced) is not ours to revive, and an
// error landing while a new session is on its way is the old one dying: that create already
// is the rebuild.
if (sandboxedSdkView === view && !awaitingReady) onSurfaceLost(sessionDead = true)
}
override fun onUiClosed() {
@@ -439,6 +459,8 @@ class EmbeddedWebAppController(
}
private fun sendCreateSession() {
awaitingReady = true
uiDisplayed = false
val msg =
Message.obtain(null, NappletBrowserContract.MSG_CREATE_SESSION).apply {
replyTo = incoming
@@ -446,7 +468,7 @@ class EmbeddedWebAppController(
Bundle().apply {
putString(NappletBrowserContract.KEY_SESSION_ID, sessionId)
putString(NappletBrowserContract.KEY_URL, startUrl)
putInt(NappletBrowserContract.KEY_PROXY_PORT, proxyPort)
putInt(NappletBrowserContract.KEY_PROXY_PORT, proxyPort())
putBoolean(NappletBrowserContract.KEY_USE_TOR, useTor)
putInt(NappletBrowserContract.KEY_BG_COLOR, backgroundColor)
putString(NappletBrowserContract.KEY_THEME, themeType)
@@ -460,7 +482,8 @@ class EmbeddedWebAppController(
if (textZoom != BrowserChrome.DEFAULT_TEXT_ZOOM) setTextZoom(textZoom)
if (desktopSite) setDesktopSite(true)
if (wantPaused) send(NappletBrowserContract.MSG_PAUSE) {}
if (!wantAttended) send(NappletBrowserContract.MSG_SET_ATTENDED) { putBoolean(NappletBrowserContract.KEY_ENABLED, false) }
// Always: a new session starts unattended, so a tab created in view must say it is being watched.
send(NappletBrowserContract.MSG_SET_ATTENDED) { putBoolean(NappletBrowserContract.KEY_ENABLED, wantAttended) }
}
private fun onServiceMessage(msg: Message): Boolean {
@@ -473,6 +496,7 @@ class EmbeddedWebAppController(
when (msg.what) {
NappletBrowserContract.MSG_SESSION_READY -> {
val coreLibInfo = msg.data?.getBundle(NappletBrowserContract.KEY_CORE_LIB_INFO) ?: return true
awaitingReady = false
val adapter = SandboxedUiAdapterFactory.createFromCoreLibInfo(coreLibInfo)
val view = sandboxedSdkView
if (view != null) {
@@ -594,6 +618,7 @@ class EmbeddedWebAppController(
val id = msg.data?.getLong(NappletBrowserContract.KEY_PERMISSION_ID)
if (pendingPermission.value?.id == id) pendingPermission.value = null
}
NappletBrowserContract.MSG_ROUTE -> routedOverTor.value = msg.data?.getBoolean(NappletBrowserContract.KEY_USE_TOR, false) ?: false
NappletBrowserContract.MSG_FULLSCREEN -> isFullscreen.value = msg.data?.getBoolean(NappletBrowserContract.KEY_ENABLED, false) ?: false
NappletBrowserContract.MSG_MAGNIFIER_FRAME -> {
val data = msg.data ?: return true
@@ -613,9 +638,13 @@ class EmbeddedWebAppController(
return true
}
fun navigate(url: String) = send(NappletBrowserContract.MSG_NAVIGATE) { putString(NappletBrowserContract.KEY_URL, url) }
fun navigate(url: String) =
send(NappletBrowserContract.MSG_NAVIGATE) {
putString(NappletBrowserContract.KEY_URL, url)
putInt(NappletBrowserContract.KEY_PROXY_PORT, proxyPort())
}
fun reload() = send(NappletBrowserContract.MSG_RELOAD) {}
fun reload() = send(NappletBrowserContract.MSG_RELOAD) { putInt(NappletBrowserContract.KEY_PROXY_PORT, proxyPort()) }
/**
* User-triggered recovery for a stuck, blank, or failed session: reload the canonical [startUrl] from
@@ -625,7 +654,8 @@ class EmbeddedWebAppController(
override fun retry() {
recovery.clearPending()
showRecovering()
if (sessionDead) rearmSession() else navigate(startUrl)
// A surface that never opened has nothing to navigate: only a new session can paint it.
if (sessionDead || (sandboxedSdkView != null && !uiDisplayed)) rearmSession() else navigate(startUrl)
}
private fun onLoadState(
@@ -720,7 +750,10 @@ class EmbeddedWebAppController(
fun setTor(useTor: Boolean) {
this.useTor = useTor
send(NappletBrowserContract.MSG_SET_TOR) { putBoolean(NappletBrowserContract.KEY_USE_TOR, useTor) }
send(NappletBrowserContract.MSG_SET_TOR) {
putBoolean(NappletBrowserContract.KEY_USE_TOR, useTor)
putInt(NappletBrowserContract.KEY_PROXY_PORT, proxyPort())
}
}
override fun sendImeOp(json: String) = send(NappletBrowserContract.MSG_IME_OP) { putString(NappletBrowserContract.KEY_IME_PAYLOAD, json) }
@@ -758,6 +791,12 @@ class EmbeddedWebAppController(
private companion object {
private val SESSION_SEQ = AtomicLong()
// The provider outlives this process's restarts (and this counter with them): without a per-process
// nonce a fresh main process would hand out ids a still-running `:napplet` already holds.
private val PROCESS_NONCE = UUID.randomUUID().toString().take(8)
private const val MAX_CONSOLE_LOGS = 200
private fun newSessionId() = "browser-$PROCESS_NONCE-${SESSION_SEQ.incrementAndGet()}"
}
}
@@ -74,6 +74,7 @@ import com.vitorpamplona.amethyst.commons.model.navigation.Route
import com.vitorpamplona.amethyst.commons.model.navigation.favoriteIds
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.browser_unsupported
import com.vitorpamplona.amethyst.commons.tor.TorType
import com.vitorpamplona.amethyst.commons.ui.components.PlatformBackHandler
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.platform.AppBottomBar
@@ -131,7 +132,8 @@ private fun EmbeddedWebAppTab(
var showPageInfo by remember { mutableStateOf(false) }
val proxyAvailable = remember { Amethyst.instance.torManager.activePortOrNull.value != null }
// Tor is ON (its port may still be coming up: a Tor page then waits, it never falls back to the open web).
val proxyAvailable = remember { Amethyst.instance.torPrefs.torType.value != TorType.OFF }
val backgroundColor = MaterialTheme.colorScheme.background.toArgb()
@@ -242,9 +244,12 @@ private fun EmbeddedWebAppTab(
val siteDecisions by WebSitePermissionRegistry.decisions.collectAsStateWithLifecycle()
val sitePermissions = remember(siteDecisions, currentUrl) { browserOrigin(currentUrl)?.let { siteDecisions[it] }.orEmpty() }
// Off for this site, yet on Tor because another open page needs it (Tor always wins in `:napplet`).
val torForced = controller.isTorForced
// Rebuilt only when a displayed value changes, so the tab layer isn't recomposed every frame.
val chrome =
remember(currentUrl, pageTitle, canGoBack, canGoForward, isLoading, torOn, proxyAvailable, isFavorite, desktopSite, textZoom, sitePermissions, candidates, controller) {
remember(currentUrl, pageTitle, canGoBack, canGoForward, isLoading, torOn, torForced, proxyAvailable, isFavorite, desktopSite, textZoom, sitePermissions, candidates, controller) {
EmbeddedTabChrome(
ui =
BrowserPillUi(
@@ -259,6 +264,7 @@ private fun EmbeddedWebAppTab(
canGoForward = canGoForward,
isLoading = isLoading,
torOn = if (proxyAvailable) torOn else null,
torForced = torForced,
hasSiteSettings = browserOrigin(currentUrl) != null,
),
isFavorite = isFavorite,
@@ -51,6 +51,9 @@ object EmbeddedTabFactory {
fun nostrAppId(coordinate: String) = "nostr:$coordinate"
/** Tor's SOCKS port right now, or -1 while it is off or still starting. */
fun currentTorPort(): Int = Amethyst.instance.torManager.activePortOrNull.value ?: -1
/** Acquires (or returns) the warm browser controller for [url], routing over Tor per the site's choice. */
fun acquireWebApp(
context: Context,
@@ -58,8 +61,8 @@ object EmbeddedTabFactory {
backgroundColor: Int,
): EmbeddedWebAppController =
EmbeddedTabHost.acquire(webAppId(url)) {
val proxyPort = Amethyst.instance.torManager.activePortOrNull.value ?: -1
val initialUseTor = proxyPort > 0 && WebAppNetworkRegistry.useTor(url)
// Tor ON, not "port known": the provider blocks a Tor page until the port is there (fails closed).
val initialUseTor = Amethyst.instance.torPrefs.torType.value != TorType.OFF && WebAppNetworkRegistry.useTor(url)
val themeType = Amethyst.instance.uiPrefs.value.theme.value
val theme =
when (themeType) {
@@ -70,7 +73,7 @@ object EmbeddedTabFactory {
if (nightMask == Configuration.UI_MODE_NIGHT_YES) "DARK" else "LIGHT"
}
}
EmbeddedWebAppController(context.applicationContext, proxyPort, initialUseTor, backgroundColor, theme).also { it.bind(url) }
EmbeddedWebAppController(context.applicationContext, ::currentTorPort, initialUseTor, backgroundColor, theme).also { it.bind(url) }
} as EmbeddedWebAppController
/**
@@ -260,12 +260,15 @@ object EmbeddedTabHost {
/** A screen showing [id] entered composition. Pair with [release]. */
fun hold(id: String) {
holders[id] = (holders[id] ?: 0) + 1
parked.remove(id)
}
// Non-bar tabs whose screen left composition while their back-stack entry lives on — another screen
// was pushed on top (even the tab's own Site settings). They stay warm until that entry is destroyed.
private val parked = mutableSetOf<String>()
// was pushed on top (even the tab's own Site settings). They stay warm until EVERY such entry is
// destroyed: the same tab can sit in the back stack twice (opened again from inside itself), and popping
// the top one must not take the session from under the one still waiting below.
private val parked = mutableMapOf<String, MutableSet<Lifecycle>>()
private fun isParked(id: String) = !parked[id].isNullOrEmpty()
/**
* The last screen showing [id] left composition. A bottom-bar tab ([keepWarm]) stays warm. Any other tab
@@ -281,15 +284,20 @@ object EmbeddedTabHost {
if (keepWarm()) return
fun gone() {
parked.remove(id)
// A screen may have come back to this tab meanwhile, or it may have joined the bottom bar.
if ((holders[id] ?: 0) == 0 && !keepWarm()) evict(id)
parked[id]?.let {
it.remove(entry)
if (it.isEmpty()) parked.remove(id)
}
// A screen may have come back to this tab meanwhile, another entry may still hold it, or it may
// have joined the bottom bar.
if ((holders[id] ?: 0) == 0 && !isParked(id) && !keepWarm()) evict(id)
}
if (entry.currentState == Lifecycle.State.DESTROYED) {
gone()
return
}
parked.add(id)
// Already watched from an earlier time this entry was covered.
if (!parked.getOrPut(id) { mutableSetOf() }.add(entry)) return
entry.addObserver(
object : LifecycleEventObserver {
override fun onStateChanged(
@@ -314,7 +322,7 @@ object EmbeddedTabHost {
/** Drops every warm session whose id isn't in [keep] (bottom-row membership + the active tab). */
fun retainOnly(keep: Set<String>) {
warm
.filter { it.id !in keep && it.id !in parked }
.filter { it.id !in keep && !isParked(it.id) }
.forEach { evict(it.id) }
}
@@ -56,6 +56,7 @@ import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.IntState
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
@@ -91,6 +92,7 @@ import com.vitorpamplona.amethyst.commons.browser.BrowserChrome
import com.vitorpamplona.amethyst.commons.browser.ui.EmbeddedLoadOverlay
import com.vitorpamplona.amethyst.commons.browser.ui.pill.BrowserPill
import com.vitorpamplona.amethyst.commons.browser.ui.pill.BrowserPillEvent
import com.vitorpamplona.amethyst.commons.browser.ui.pill.BrowserPillUi
import com.vitorpamplona.amethyst.commons.browser.ui.pill.ConsoleLine
import com.vitorpamplona.amethyst.commons.browser.ui.pill.ConsoleSheet
import com.vitorpamplona.amethyst.commons.browser.ui.pill.FindInPagePill
@@ -348,47 +350,48 @@ fun EmbeddedTabLayer(barFavoriteIds: List<String>) {
}
val consoleLogs = consoleBridge?.consoleLogs
val ui =
val baseUi =
chrome.ui.copy(
chrome = chrome.ui.chrome.copy(hasFind = chrome.ui.chrome.hasFind && findBridge != null),
consoleShowing = consoleShowing,
consoleErrors = consoleBridge?.consoleErrorCount?.intValue ?: 0,
)
Box(tabModifier) {
BrowserPill(
ui = ui,
expanded = pillExpanded,
onExpandedChange = { pillExpanded = it },
onEvent = { event ->
val action = (event as? BrowserPillEvent.Action)?.action
when {
action == BrowserChrome.Action.FIND_IN_PAGE && findBridge != null -> {
// One bottom panel at a time: find replaces the console.
consoleShowing = false
findShowing = true
WithConsoleErrors(baseUi, consoleBridge?.consoleErrorCount) { ui ->
BrowserPill(
ui = ui,
expanded = pillExpanded,
onExpandedChange = { pillExpanded = it },
onEvent = { event ->
val action = (event as? BrowserPillEvent.Action)?.action
when {
action == BrowserChrome.Action.FIND_IN_PAGE && findBridge != null -> {
// One bottom panel at a time: find replaces the console.
consoleShowing = false
findShowing = true
}
action == BrowserChrome.Action.CONSOLE && consoleBridge != null -> {
if (!consoleShowing) closeFind()
consoleShowing = !consoleShowing
}
else -> chrome.onEvent(event)
}
action == BrowserChrome.Action.CONSOLE && consoleBridge != null -> {
if (!consoleShowing) closeFind()
consoleShowing = !consoleShowing
}
else -> chrome.onEvent(event)
}
},
showClose = false,
suggestionsFor = chrome.suggestionsFor,
// A clipboard query is a binder call: only make it while the pill is open to use it.
onPasteAndGo =
if (pillExpanded && BrowserWebTools.clipboardHasText(context)) {
{
pillExpanded = false
BrowserWebTools.clipboardText(context)?.let { chrome.onEvent(BrowserPillEvent.Navigate(it)) }
}
} else {
null
},
modifier = Modifier.align(Alignment.TopCenter),
)
showClose = false,
suggestionsFor = chrome.suggestionsFor,
// A clipboard query is a binder call: only make it while the pill is open to use it.
onPasteAndGo =
if (pillExpanded && BrowserWebTools.clipboardHasText(context)) {
{
pillExpanded = false
BrowserWebTools.clipboardText(context)?.let { chrome.onEvent(BrowserPillEvent.Navigate(it)) }
}
} else {
null
},
modifier = Modifier.align(Alignment.TopCenter),
)
}
// Find in page: opened from the pill's Find tile.
if (findShowing && findBridge != null) {
@@ -1112,3 +1115,16 @@ private fun formatConsoleLine(line: ConsoleLine): String =
.append(')')
}
}
/**
* Reads the page's console error count in a scope of its own: a page that keeps logging errors then
* recomposes just the pill, not the whole tab layer around it.
*/
@Composable
private fun WithConsoleErrors(
ui: BrowserPillUi,
errors: IntState?,
content: @Composable (BrowserPillUi) -> Unit,
) {
content(ui.copy(consoleErrors = errors?.intValue ?: 0))
}
@@ -62,6 +62,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedImeBridge
import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedLoadStatus
import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedMagnifierProbe
import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedSurfaceController
import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedTabFactory
import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.FindBridge
import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.FindResult
import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.ImeEvent
@@ -71,6 +72,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.parseImeEvent
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.util.UUID
import java.util.concurrent.atomic.AtomicLong
/**
@@ -110,13 +112,13 @@ class EmbeddedNostrAppController(
// its own id on every message; the provider uses it to route controls/state/IME to this tab.
// Re-minted whenever the remote session is re-created (see [attachView]), so a late close() from the
// previous view can never reap the replacement.
private var sessionId: String = "napplet-${SESSION_SEQ.incrementAndGet()}"
private var sessionId: String = newSessionId()
// What the provider was last told (see [syncPageState]). A parked tab can be hidden before the service
// even binds, when the message is dropped (no messenger yet), so both are replayed right after each
// session is created — otherwise an applet that was never shown comes up running, and acting, unwatched.
private var wantPaused = false
private var wantAttended = true
private var wantAttended = false
// The app is on screen / has been in the background long enough to pause even the visible tab.
private var appVisible = true
@@ -142,6 +144,18 @@ class EmbeddedNostrAppController(
// A `:napplet` restart found this tab hidden: its session is re-created when it is next shown.
private var createOnShow = false
// A create is in flight: the view's old session erroring out now is the one being replaced, not news.
private var awaitingReady = false
// The current session's surface has shown in the view at least once (see [retry]).
private var uiDisplayed = false
// Whether `:napplet` routes through Tor right now: another surface that needs Tor puts every page on it.
private val routedOverTor = mutableStateOf(false)
/** This nSite is set to the open web but goes through Tor anyway, because another open page needs Tor. */
val isTorForced: Boolean get() = !params.getBoolean(NappletHostContract.EXTRA_USE_TOR, true) && routedOverTor.value
/** Last known main-frame load state, so the tab layer renders the right overlay immediately. */
override var loadStatus: EmbeddedLoadStatus = EmbeddedLoadStatus()
private set
@@ -289,7 +303,9 @@ class EmbeddedNostrAppController(
// The session being replaced may never have opened a surface (its view went away first), in which
// case no surface close will ever reach the provider for it.
send(NappletEmbedContract.MSG_CLOSE_SESSION)
sessionId = "napplet-${SESSION_SEQ.incrementAndGet()}"
sessionId = newSessionId()
// This create IS the re-creation a `:napplet` restart deferred to the next show.
createOnShow = false
adapterDelivered = false
sessionDead = false
_findResult.value = null
@@ -304,12 +320,15 @@ class EmbeddedNostrAppController(
private fun surfaceListener(view: SandboxedSdkView) =
object : SandboxedSdkViewEventListener {
override fun onUiDisplayed() {
// Nothing to do: the load state reports when the page itself paints.
// The load state reports when the page itself paints; this only says the surface opened.
if (sandboxedSdkView === view) uiDisplayed = true
}
override fun onUiError(error: Throwable) {
// A view this controller has since moved past (disposed, replaced) is not ours to revive.
if (sandboxedSdkView === view) onSurfaceLost(sessionDead = true)
// A view this controller has since moved past (disposed, replaced) is not ours to revive, and an
// error landing while a new session is on its way is the old one dying: that create already
// is the rebuild.
if (sandboxedSdkView === view && !awaitingReady) onSurfaceLost(sessionDead = true)
}
override fun onUiClosed() {
@@ -400,6 +419,8 @@ class EmbeddedNostrAppController(
override fun teardown() = unbind()
private fun sendCreateSession() {
awaitingReady = true
uiDisplayed = false
val msg =
Message.obtain(null, NappletEmbedContract.MSG_CREATE_SESSION).apply {
replyTo = incoming
@@ -411,14 +432,17 @@ class EmbeddedNostrAppController(
// [attachView]) must land in the CURRENT account's jar, never the one this
// controller was originally built for.
putString(NappletHostContract.EXTRA_WEBVIEW_PROFILE, NappletWebViewProfiles.current())
// Likewise Tor's port: it may have come up (or moved) since [params] were minted.
putInt(NappletHostContract.EXTRA_PROXY_PORT, EmbeddedTabFactory.currentTorPort())
}
}
runCatching { serviceMessenger?.send(msg) }
// Replay a pause / not-attended that was decided before we had a messenger to send it on
// Replay a pause / the attended state decided before we had a messenger to send it on
// (parked-before-bound), so a never-shown applet doesn't start running or acting. Messenger preserves
// order, so these land after CREATE in the host.
if (wantPaused) send(NappletEmbedContract.MSG_PAUSE)
if (!wantAttended) send(NappletEmbedContract.MSG_SET_ATTENDED) { putBoolean(NappletEmbedContract.KEY_ATTENDED, false) }
// Always: a new session starts unattended, so a tab created in view must say it is being watched.
send(NappletEmbedContract.MSG_SET_ATTENDED) { putBoolean(NappletEmbedContract.KEY_ATTENDED, wantAttended) }
if (textZoom != BrowserChrome.DEFAULT_TEXT_ZOOM) setTextZoom(textZoom)
}
@@ -432,6 +456,7 @@ class EmbeddedNostrAppController(
when (msg.what) {
NappletEmbedContract.MSG_SESSION_READY -> {
val coreLibInfo = msg.data?.getBundle(NappletEmbedContract.KEY_CORE_LIB_INFO) ?: return true
awaitingReady = false
val adapter = SandboxedUiAdapterFactory.createFromCoreLibInfo(coreLibInfo)
val view = sandboxedSdkView
if (view != null) {
@@ -482,6 +507,7 @@ class EmbeddedNostrAppController(
}
}
}
NappletEmbedContract.MSG_ROUTE -> routedOverTor.value = msg.data?.getBoolean(NappletEmbedContract.KEY_ROUTE_TOR, false) ?: false
NappletEmbedContract.MSG_FIND_RESULT -> {
val data = msg.data ?: return true
_findResult.value = FindResult(data.getInt(NappletEmbedContract.KEY_FIND_ACTIVE), data.getInt(NappletEmbedContract.KEY_FIND_TOTAL))
@@ -534,7 +560,7 @@ class EmbeddedNostrAppController(
fun back() = send(NappletEmbedContract.MSG_BACK)
fun reload() = send(NappletEmbedContract.MSG_RELOAD)
fun reload() = send(NappletEmbedContract.MSG_RELOAD) { putInt(NappletHostContract.EXTRA_PROXY_PORT, EmbeddedTabFactory.currentTorPort()) }
override fun find(query: String) {
if (query.isEmpty()) _findResult.value = null
@@ -552,7 +578,8 @@ class EmbeddedNostrAppController(
override fun retry() {
recovery.clearPending()
showRecovering()
if (sessionDead) rearmSession() else reload()
// A surface that never opened has nothing to reload: only a new session can paint it.
if (sessionDead || (sandboxedSdkView != null && !uiDisplayed)) rearmSession() else reload()
}
private fun onLoadState(
@@ -606,6 +633,12 @@ class EmbeddedNostrAppController(
private companion object {
private val SESSION_SEQ = AtomicLong()
// The provider outlives this process's restarts (and this counter with them): without a per-process
// nonce a fresh main process would hand out ids a still-running `:napplet` already holds.
private val PROCESS_NONCE = UUID.randomUUID().toString().take(8)
private fun newSessionId() = "napplet-$PROCESS_NONCE-${SESSION_SEQ.incrementAndGet()}"
private const val MAX_CONSOLE_LOGS = 200
}
}
@@ -65,6 +65,7 @@ import com.vitorpamplona.amethyst.commons.resources.browser_unsupported
import com.vitorpamplona.amethyst.commons.resources.favorite_app_still_loading
import com.vitorpamplona.amethyst.commons.resources.favorite_app_unavailable
import com.vitorpamplona.amethyst.commons.resources.favorite_apps
import com.vitorpamplona.amethyst.commons.tor.TorType
import com.vitorpamplona.amethyst.commons.ui.components.PlatformBackHandler
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.platform.AppBottomBar
@@ -138,8 +139,8 @@ private fun EmbeddedNostrAppTab(
val capLabels = params.getStringArrayList(NappletHostContract.EXTRA_CAP_LABELS).orEmpty()
val profile = HostProfile.fromName(params.getString(NappletHostContract.EXTRA_HOST_PROFILE))
val useTor = params.getBoolean(NappletHostContract.EXTRA_USE_TOR, true)
// Only nSites have a route of their own to choose, and only when Tor is running.
val torOn = if (profile.exposesNetwork && params.getInt(NappletHostContract.EXTRA_PROXY_PORT, -1) > 0) useTor else null
// Only nSites have a route of their own to choose, and only when Tor is on.
val torOn = if (profile.exposesNetwork && Amethyst.instance.torPrefs.torType.value != TorType.OFF) useTor else null
var showAccess by remember { mutableStateOf(false) }
@@ -165,9 +166,12 @@ private fun EmbeddedNostrAppTab(
// matching how the Connected Apps screen keys napplet/nsite grants (see NappletIdentity.coordinate).
val permissionCoordinate = remember(coordinate) { coordinate.substringAfter(':') }
// Off for this site, yet on Tor because another open page needs it (Tor always wins in `:napplet`).
val torForced = controller.isTorForced
// Stable per app (title/coordinate/isFavorite don't change often), so the tab layer isn't recomposed every frame.
val chrome =
remember(title, coordinate, isFavorite, torOn, textZoom, controller) {
remember(title, coordinate, isFavorite, torOn, torForced, textZoom, controller) {
EmbeddedTabChrome(
ui =
BrowserPillUi(
@@ -179,6 +183,7 @@ private fun EmbeddedNostrAppTab(
url = "",
startUrl = "",
torOn = torOn,
torForced = torForced,
hasAccessInfo = true,
),
isFavorite = isFavorite,
@@ -91,6 +91,11 @@ object BrowserChrome {
val isLoading: Boolean = false,
/** Tor routing state, or null when this surface offers no Tor choice. */
val torOn: Boolean? = null,
/**
* The site is set to the open web ([torOn] false) but still goes through Tor, because another open page
* needs Tor and the app's pages share one route (Tor always wins). Shown so the user knows why.
*/
val torForced: Boolean = false,
/** Whether the star is offered at all. */
val canFavorite: Boolean = true,
/** Whether an editable permissions screen exists for this surface. */
@@ -0,0 +1,56 @@
/*
* 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.commons.napplet
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.withTimeoutOrNull
/**
* The broker's view of which surfaces the user is looking at right now, as each surface reports it.
*
* The surfaces hold a page's acting requests (sign, encrypt, decrypt…) themselves while nobody is looking,
* but relay reads decrypt on the broker side: an encrypted event a relay pushes to a parked page's
* subscription, or returns for its query, would be decrypted with the user's key and handed over unwatched.
* The broker checks here first and waits until the page is attended again.
*
* Unattended until a surface says otherwise, so one that never reports can't read unwatched.
*/
class NappletAttendance<K : Any> {
private val attended = MutableStateFlow<Set<K>>(emptySet())
fun set(
owner: K,
isAttended: Boolean,
) = attended.update { if (isAttended) it + owner else it - owner }
fun isAttended(owner: K): Boolean = owner in attended.value
/** Waits up to [timeoutMs] for [owner] to be attended; false when it wasn't in time. */
suspend fun awaitAttended(
owner: K,
timeoutMs: Long,
): Boolean = withTimeoutOrNull(timeoutMs) { attended.first { owner in it } } != null
/** [owner] went away. */
fun forget(owner: K) = set(owner, false)
}
@@ -20,6 +20,10 @@
*/
package com.vitorpamplona.amethyst.commons.napplet
import com.vitorpamplona.amethyst.commons.util.withString
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
/**
* Keeps a browser surface's NIP-07 traffic with the document that started it.
*
@@ -35,6 +39,11 @@ package com.vitorpamplona.amethyst.commons.napplet
* document are dropped. The stamp is deterministic per (document, page id), so a later message that
* reuses a request's id (a cancel) still reaches the same broker-side request.
*
* Relay subscriptions get the same treatment: a page names its own (`s0`, …), and the broker pushes their
* events — decrypted DMs included — keyed by that name. [stampSubscription] stamps the document on the
* `subId` of what the page sends, and [resolvePush] only lets a push through, with the page's own name
* back, when it is for the document on screen now.
*
* Single-threaded: call from the WebView's (main) thread.
*/
class NappletBridgeDocuments<P : Any> {
@@ -70,6 +79,40 @@ class NappletBridgeDocuments<P : Any> {
return brokerId.substring(cut + 1) to proxy
}
/**
* [envelope] with the current document stamped on its `subId` (`relay.subscribe`, `relay.close`), or
* null when it carries none and goes to the broker unchanged.
*/
fun stampSubscription(envelope: JsonObject): JsonObject? {
val subId = envelope.quotedString(SUB_ID) ?: return null
return envelope.withString(SUB_ID, brokerIdFor(subId))
}
/**
* A broker push to hand to the page on screen: unchanged when it isn't for a subscription, with the page's
* own `subId` back when it is for one this document opened, or null — drop it — when it is for a
* subscription of a document that is gone (or when nothing is on screen).
*/
fun resolvePush(push: JsonObject): JsonObject? {
if (current == null) return null
val brokerSubId = push.quotedString(SUB_ID) ?: return push
val cut = brokerSubId.indexOf(SEPARATOR)
if (cut <= 0 || brokerSubId.substring(0, cut).toLongOrNull() != document) return null
return push.withString(SUB_ID, brokerSubId.substring(cut + 1))
}
private fun JsonObject.quotedString(key: String): String? = (this[key] as? JsonPrimitive)?.takeIf { it.isString }?.content
/**
* A main-frame navigation began: whatever document was on screen is on its way out, even if the new one
* never talks to the bridge. Returns true when there was one — the caller then drops its broker state.
*/
fun onNavigation(): Boolean {
val had = current != null
clear()
return had
}
/** The surface went away (session closed, renderer died): nothing on screen can receive a reply. */
fun clear() {
current = null
@@ -78,5 +121,6 @@ class NappletBridgeDocuments<P : Any> {
private companion object {
const val SEPARATOR = ':'
const val SUB_ID = "subId"
}
}
@@ -0,0 +1,93 @@
/*
* 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.commons.napplet
/**
* The requests a page made to act for the user (sign, encrypt, decrypt, publish, pay…) while nobody was
* looking at it, held until someone is (see [NappletActingRequests]).
*
* Bounded both ways, so an unattended page can neither grow the queue without end nor have the user come
* back to a pile of stale prompts: past [cap] a new request is handed straight back to be failed, and one
* held longer than [maxAgeMs] is failed instead of sent — by [expire], or when [drain] finds it on the user's
* return. Either way the page's promise settles with an error rather than hanging.
*
* Single-threaded: call from the main thread.
*/
class NappletHeldRequests<T>(
private val clock: () -> Long,
private val cap: Int = MAX_HELD,
private val maxAgeMs: Long = MAX_AGE_MS,
) {
private class Held<T>(
val item: T,
val heldAt: Long,
)
private val items = ArrayDeque<Held<T>>()
val size: Int get() = items.size
/** Holds [item], or hands it back (for the caller to fail) when [cap] requests are already waiting. */
fun hold(item: T): T? {
if (items.size >= cap) return item
items.addLast(Held(item, clock()))
return null
}
/** Removes and returns the requests held longer than [maxAgeMs] (oldest first). */
fun expire(): List<T> {
val now = clock()
val expired = mutableListOf<T>()
while (items.isNotEmpty() && now - items.first().heldAt >= maxAgeMs) expired += items.removeFirst().item
return expired
}
/** Removes everything held: the requests still fresh enough to send, and the ones to fail instead. */
fun drain(): Drained<T> {
val expired = expire()
val fresh = items.map { it.item }
items.clear()
return Drained(fresh, expired)
}
/** Drops everything held (the page or surface is gone) and returns it. */
fun clear(): List<T> {
val all = items.map { it.item }
items.clear()
return all
}
data class Drained<T>(
val send: List<T>,
val fail: List<T>,
)
companion object {
/** At most this many requests wait for the user at once. */
const val MAX_HELD = 32
/** A held request older than this is failed rather than sent: the moment it was made for has passed. */
const val MAX_AGE_MS = 120_000L
const val TOO_MANY = "Too many requests are waiting for the user."
const val EXPIRED = "The request timed out while the user was away."
}
}
@@ -28,43 +28,37 @@ package com.vitorpamplona.amethyst.commons.napplet
* the last one to do so won for everyone — opening an open-web page silently moved an already-open Tor
* page onto the open web, with no reload and nothing on screen to say so.
*
* So every live surface files a claim, and the route is derived from all of them:
* - while ANY claim wants Tor, the whole process goes through Tor (the most recent Tor claim's port — a
* Tor restart can move it). A Tor page is never downgraded because another surface opened.
* - an open-web claim can name the hosts it was opted out for ([Claim.directHosts]); those, and only those,
* bypass the proxy. Without that, one Tor favorite pinned to the bottom bar would force every site the
* user took off Tor back onto it for good. The cost: a Tor page requesting one of those exact hosts
* reaches it directly too — only hosts the user explicitly put on the open web.
* - with no Tor claim at all, there is no proxy.
* So every live surface files a claim, and the route is derived from all of them: **Tor always wins.**
* While ANY claim wants Tor, the whole process goes through Tor (the most recent Tor claim's port — a Tor
* restart can move it), open-web surfaces included; with no Tor claim at all there is no proxy.
*
* There are deliberately no per-host exemptions. Exempting an open-web page's host would let a Tor page
* reach that host directly — and an attacker who got one page onto the open web (a site opened before Tor
* was up, say) could then have a Tor page load `https://x.attacker.com/<id>` and tie the user's real IP to
* the Tor session. The cost is that an open-web page goes through Tor while another open page needs it;
* surfaces show why (see [Route.usesTor]).
*
* Not thread-safe: the caller serializes access (the sandbox touches it only on its main thread).
*/
class NappletProxyClaims {
data class Claim(
/** The Tor SOCKS port this surface wants, or [NO_PROXY] for the open web. */
val torPort: Int,
/** For an open-web claim: hosts that must go direct even while Tor is on for others. */
val directHosts: Set<String> = emptySet(),
)
/** The route to apply: [torPort] > 0 routes through Tor except [bypassHosts]; else no proxy. */
/** The route to apply: through Tor on [torPort] when [usesTor], else no proxy. */
data class Route(
val torPort: Int,
val bypassHosts: Set<String>,
) {
val usesTor: Boolean get() = torPort > 0
}
// Insertion-ordered; a re-claim moves the owner to the end, so the last entry is the latest claim.
private val claims = LinkedHashMap<Any, Claim>()
// Each value is the Tor SOCKS port the owner wants, or [NO_PROXY] for the open web.
private val claims = LinkedHashMap<Any, Int>()
/** Files (or replaces) [owner]'s claim and returns the resulting route. */
/** Files (or replaces) [owner]'s claim — Tor on [torPort] (> 0), or [NO_PROXY] — and returns the route. */
fun claim(
owner: Any,
claim: Claim,
torPort: Int,
): Route {
claims.remove(owner)
claims[owner] = claim
claims[owner] = torPort
return route()
}
@@ -74,17 +68,10 @@ class NappletProxyClaims {
return route()
}
fun route(): Route {
val torPort = claims.values.lastOrNull { it.torPort > 0 }?.torPort ?: return DIRECT
val bypass =
claims.values
.filter { it.torPort <= 0 }
.flatMapTo(HashSet()) { it.directHosts }
return Route(torPort, bypass)
}
fun route(): Route = Route(claims.values.lastOrNull { it > 0 } ?: NO_PROXY)
companion object {
const val NO_PROXY = -1
val DIRECT = Route(NO_PROXY, emptySet())
val DIRECT = Route(NO_PROXY)
}
}
@@ -0,0 +1,57 @@
/*
* 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.commons.napplet
import kotlinx.coroutines.async
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class NappletAttendanceTest {
private val attendance = NappletAttendance<String>()
@Test
fun unattendedUntilTold() {
assertFalse(attendance.isAttended("tab"))
attendance.set("tab", true)
assertTrue(attendance.isAttended("tab"))
attendance.forget("tab")
assertFalse(attendance.isAttended("tab"))
}
@Test
fun awaitReturnsOnceTheUserIsBack() =
runTest {
val waiting = async { attendance.awaitAttended("tab", 10_000) }
testScheduler.advanceTimeBy(1_000)
attendance.set("other", true)
testScheduler.advanceTimeBy(1_000)
attendance.set("tab", true)
assertTrue(waiting.await())
}
@Test
fun awaitGivesUpAfterTheTimeout() =
runTest {
assertFalse(attendance.awaitAttended("tab", 5_000))
}
}
@@ -20,6 +20,8 @@
*/
package com.vitorpamplona.amethyst.commons.napplet
import com.vitorpamplona.amethyst.commons.util.parseJsonObjectOrNull
import com.vitorpamplona.amethyst.commons.util.stringOrNull
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
@@ -94,4 +96,60 @@ class NappletBridgeDocumentsTest {
assertNull(docs.resolve("r0"))
assertNull(docs.resolve(":r0"))
}
private fun json(raw: String) = parseJsonObjectOrNull(raw)!!
@Test
fun subscriptionPushesReachTheDocumentThatSubscribed() {
docs.onMessage(a)
val stamped = docs.stampSubscription(json("""{"type":"relay.subscribe","id":"r0","subId":"s0"}"""))!!
val brokerSubId = stamped.stringOrNull("subId")!!
val push = docs.resolvePush(json("""{"type":"relay.event","subId":"$brokerSubId"}"""))!!
assertEquals("s0", push.stringOrNull("subId"))
}
@Test
fun subscriptionPushesForANavigatedAwayDocumentAreDropped() {
docs.onMessage(a)
val brokerSubId = docs.stampSubscription(json("""{"type":"relay.subscribe","subId":"s0"}"""))!!.stringOrNull("subId")!!
docs.onMessage(b)
// b.com names its own subscription s0 too: a.com's decrypted events must not reach it.
docs.stampSubscription(json("""{"type":"relay.subscribe","subId":"s0"}"""))
assertNull(docs.resolvePush(json("""{"type":"relay.event","subId":"$brokerSubId"}""")))
}
@Test
fun closeIsStampedLikeTheSubscribeItEnds() {
docs.onMessage(a)
val open = docs.stampSubscription(json("""{"type":"relay.subscribe","subId":"s0"}"""))!!
val close = docs.stampSubscription(json("""{"type":"relay.close","subId":"s0"}"""))!!
assertEquals(open.stringOrNull("subId"), close.stringOrNull("subId"))
}
@Test
fun pushesWithoutASubscriptionGoToThePageOnScreen() {
assertNull(docs.resolvePush(json("""{"type":"identity.changed"}""")))
docs.onMessage(a)
assertEquals("identity.changed", docs.resolvePush(json("""{"type":"identity.changed"}"""))!!.stringOrNull("type"))
assertNull(docs.stampSubscription(json("""{"type":"nostr.signEvent","id":"r0"}""")))
}
@Test
fun unstampedSubscriptionPushesAreDropped() {
docs.onMessage(a)
assertNull(docs.resolvePush(json("""{"type":"relay.event","subId":"s0"}""")))
}
@Test
fun navigationEndsTheDocumentEvenIfTheNextNeverTalks() {
docs.onMessage(a)
val request = docs.brokerIdFor("r0")
assertTrue(docs.onNavigation())
assertNull(docs.resolve(request))
assertNull(docs.resolvePush(json("""{"type":"identity.changed"}""")))
// Nothing on screen talked yet: a second navigation has nothing to release.
assertFalse(docs.onNavigation())
// The next page's first message is not a "replacement": its predecessor was already released.
assertFalse(docs.onMessage(b))
}
}
@@ -0,0 +1,82 @@
/*
* 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.commons.napplet
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
import kotlin.test.assertTrue
class NappletHeldRequestsTest {
private var now = 0L
private val held = NappletHeldRequests<String>(clock = { now }, cap = 3, maxAgeMs = 1_000)
@Test
fun heldRequestsAreSentWhenTheUserIsBack() {
assertNull(held.hold("a"))
assertNull(held.hold("b"))
val drained = held.drain()
assertEquals(listOf("a", "b"), drained.send)
assertTrue(drained.fail.isEmpty())
assertEquals(0, held.size)
}
@Test
fun pastTheCapANewRequestIsHandedBack() {
held.hold("a")
held.hold("b")
held.hold("c")
assertEquals("d", held.hold("d"))
assertEquals(3, held.size)
}
@Test
fun staleRequestsAreFailedNotSent() {
held.hold("old")
now = 600
held.hold("new")
now = 1_200
val drained = held.drain()
assertEquals(listOf("new"), drained.send)
assertEquals(listOf("old"), drained.fail)
}
@Test
fun expireRemovesOnlyTheStaleOnes() {
held.hold("old")
now = 600
held.hold("new")
now = 1_000
assertEquals(listOf("old"), held.expire())
assertEquals(1, held.size)
now = 1_600
assertEquals(listOf("new"), held.expire())
assertEquals(0, held.size)
}
@Test
fun clearReturnsEverything() {
held.hold("a")
held.hold("b")
assertEquals(listOf("a", "b"), held.clear())
assertEquals(0, held.size)
}
}
@@ -20,7 +20,6 @@
*/
package com.vitorpamplona.amethyst.commons.napplet
import com.vitorpamplona.amethyst.commons.napplet.NappletProxyClaims.Claim
import com.vitorpamplona.amethyst.commons.napplet.NappletProxyClaims.Companion.DIRECT
import com.vitorpamplona.amethyst.commons.napplet.NappletProxyClaims.Companion.NO_PROXY
import kotlin.test.Test
@@ -39,44 +38,36 @@ class NappletProxyClaimsTest {
@Test
fun anOpenWebSurfaceDoesNotDowngradeATorOne() {
claims.claim(torTab, Claim(9050))
claims.claim(torTab, 9050)
// The open-web page opening later must not clear Tor for the page already on it.
val route = claims.claim(openTab, Claim(NO_PROXY, setOf("example.com")))
assertEquals(9050, route.torPort)
assertEquals(setOf("example.com"), route.bypassHosts)
assertEquals(9050, claims.claim(openTab, NO_PROXY).torPort)
}
@Test
fun orderDoesNotMatter() {
claims.claim(openTab, Claim(NO_PROXY, setOf("example.com")))
assertTrue(claims.claim(torTab, Claim(9050)).usesTor)
claims.claim(openTab, NO_PROXY)
assertTrue(claims.claim(torTab, 9050).usesTor)
}
@Test
fun releasingTheLastTorSurfaceGoesDirect() {
claims.claim(torTab, Claim(9050))
claims.claim(openTab, Claim(NO_PROXY, setOf("example.com")))
claims.claim(torTab, 9050)
claims.claim(openTab, NO_PROXY)
assertEquals(DIRECT, claims.release(torTab))
}
@Test
fun switchingASurfaceOffTorReleasesTheProxy() {
claims.claim(torTab, Claim(9050))
assertEquals(DIRECT, claims.claim(torTab, Claim(NO_PROXY)))
claims.claim(torTab, 9050)
assertEquals(DIRECT, claims.claim(torTab, NO_PROXY))
}
@Test
fun theLatestTorPortWins() {
claims.claim(torTab, Claim(9050))
claims.claim(torTab, 9050)
val other = Any()
assertEquals(9150, claims.claim(other, Claim(9150)).torPort)
assertEquals(9150, claims.claim(other, 9150).torPort)
// Re-claiming moves an owner to the end, making its port the latest.
assertEquals(9050, claims.claim(torTab, Claim(9050)).torPort)
}
@Test
fun directHostsOnlyComeFromOpenWebClaims() {
claims.claim(torTab, Claim(9050, setOf("tor-only.example")))
assertEquals(emptySet(), claims.route().bypassHosts)
assertEquals(9050, claims.claim(torTab, 9050).torPort)
}
}
@@ -5686,6 +5686,7 @@
<string name="browser_pill_tor_title">Onion routing</string>
<string name="browser_pill_tor_on">The site can't see your IP address</string>
<string name="browser_pill_tor_off">The site can see your IP address</string>
<string name="browser_pill_tor_forced">Off for this site, but another open page uses Tor, so this one goes through Tor too</string>
<string name="browser_pill_site_settings">Site settings</string>
<string name="browser_pill_site_settings_none">Nothing allowed yet</string>
<string name="browser_pill_access">What it can access</string>
@@ -90,6 +90,7 @@ import com.vitorpamplona.amethyst.commons.resources.browser_pill_text_larger
import com.vitorpamplona.amethyst.commons.resources.browser_pill_text_reset
import com.vitorpamplona.amethyst.commons.resources.browser_pill_text_smaller
import com.vitorpamplona.amethyst.commons.resources.browser_pill_text_value
import com.vitorpamplona.amethyst.commons.resources.browser_pill_tor_forced
import com.vitorpamplona.amethyst.commons.resources.browser_pill_tor_off
import com.vitorpamplona.amethyst.commons.resources.browser_pill_tor_on
import com.vitorpamplona.amethyst.commons.ui.stringRes
@@ -487,7 +488,14 @@ private fun PrivacyCard(
icon = { PillActionIcon(Action.TOR, tint = if (on) MaterialTheme.colorScheme.onTertiaryContainer else MaterialTheme.colorScheme.onSurfaceVariant, size = 22.dp) },
iconContainer = if (on) MaterialTheme.colorScheme.tertiaryContainer else MaterialTheme.colorScheme.surfaceContainerHighest,
title = stringRes(pillLabelFor(Action.TOR)),
supporting = stringRes(if (on) Res.string.browser_pill_tor_on else Res.string.browser_pill_tor_off),
supporting =
stringRes(
when {
on -> Res.string.browser_pill_tor_on
ui.chrome.torForced -> Res.string.browser_pill_tor_forced
else -> Res.string.browser_pill_tor_off
},
),
onClick = { onAction(Action.TOR) },
) { Switch(checked = on, onCheckedChange = { onAction(Action.TOR) }) }
}
@@ -111,6 +111,7 @@ import com.vitorpamplona.amethyst.commons.resources.browser_pill_perm_microphone
import com.vitorpamplona.amethyst.commons.resources.browser_pill_perm_once
import com.vitorpamplona.amethyst.commons.resources.browser_pill_perm_title
import com.vitorpamplona.amethyst.commons.resources.browser_pill_perm_tor_note
import com.vitorpamplona.amethyst.commons.resources.browser_pill_tor_forced
import com.vitorpamplona.amethyst.commons.resources.browser_pill_tor_off
import com.vitorpamplona.amethyst.commons.resources.browser_pill_tor_on
import com.vitorpamplona.amethyst.commons.ui.stringRes
@@ -372,7 +373,14 @@ fun PageInfoSheet(
icon = { PillActionIcon(BrowserChrome.Action.TOR, tint = if (tor) MaterialTheme.colorScheme.onTertiaryContainer else MaterialTheme.colorScheme.onSurfaceVariant, size = 22.dp) },
iconContainer = if (tor) MaterialTheme.colorScheme.tertiaryContainer else MaterialTheme.colorScheme.surfaceContainerHighest,
title = stringRes(if (tor) Res.string.browser_pill_info_tor else Res.string.browser_pill_info_open),
supporting = stringRes(if (tor) Res.string.browser_pill_tor_on else Res.string.browser_pill_tor_off),
supporting =
stringRes(
when {
tor -> Res.string.browser_pill_tor_on
ui.chrome.torForced -> Res.string.browser_pill_tor_forced
else -> Res.string.browser_pill_tor_off
},
),
onClick = null,
)
}
@@ -46,3 +46,19 @@ fun NappletBridgeDocuments<JavaScriptReplyProxy>.failRequest(
val reply = parseJsonObjectOrNull(NappletProtocolJson.encodeResponse(type, NappletResponse.Failed(reason))) ?: JsonObject(emptyMap())
runCatching { proxy.postMessage(reply.withString("id", pageId).toString()) }
}
/**
* Answers a napplet's queued broker [request] with a failure on [this] proxy. A napplet's request ids aren't
* stamped per document (its shell never navigates), so the id goes back as the page sent it.
*/
fun JavaScriptReplyProxy.failRequest(
request: Message,
reason: String,
) {
val data = request.data ?: return
val id = data.getString(NappletIpc.KEY_REQUEST_ID) ?: return
val raw = data.getString(NappletIpc.KEY_PAYLOAD) ?: return
val type = runCatching { NappletProtocolJson.readType(raw) }.getOrNull() ?: "napplet"
val reply = parseJsonObjectOrNull(NappletProtocolJson.encodeResponse(type, NappletResponse.Failed(reason))) ?: JsonObject(emptyMap())
runCatching { postMessage(reply.withString("id", id).toString()) }
}
@@ -40,6 +40,7 @@ import android.os.IBinder
import android.os.Looper
import android.os.Message
import android.os.Messenger
import android.os.SystemClock
import android.util.TypedValue
import android.view.ContextMenu
import android.view.Gravity
@@ -89,6 +90,7 @@ import com.vitorpamplona.amethyst.commons.browser.ui.pill.ConsoleLine
import com.vitorpamplona.amethyst.commons.browser.ui.pill.PageDialogType
import com.vitorpamplona.amethyst.commons.napplet.NappletActingRequests
import com.vitorpamplona.amethyst.commons.napplet.NappletBridgeDocuments
import com.vitorpamplona.amethyst.commons.napplet.NappletHeldRequests
import com.vitorpamplona.amethyst.commons.napplet.NappletProxyClaims
import com.vitorpamplona.amethyst.commons.napplet.NappletWebContract
import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletProtocolJson
@@ -233,7 +235,7 @@ class NappletBrowserActivity : ComponentActivity() {
// The page's requests that act for the user (NIP-07 sign / encrypt / decrypt) made while this window was in
// the background, as (origin, request): sent on the next resume, so a site can't sign — even with
// "allow always" — while nobody is looking at it.
private val heldWhileAway = mutableListOf<Pair<String, Message>>()
private val heldWhileAway = NappletHeldRequests<Pair<String, Message>>(SystemClock::elapsedRealtime)
/**
* Back walks out of fullscreen video, then the find bar, then the page's history, then leaves. Enabled
@@ -271,6 +273,7 @@ class NappletBrowserActivity : ComponentActivity() {
pendingBrokerRequests.forEach { sendToBroker(it) }
pendingBrokerRequests.clear()
if (resumed) setBrokerForeground(true)
reportAttended()
}
override fun onServiceDisconnected(name: ComponentName?) {
@@ -309,6 +312,14 @@ class NappletBrowserActivity : ComponentActivity() {
}
title = intent.getStringExtra(EXTRA_TITLE).orEmpty()
// Fail closed: Tor is on but its port isn't known yet (still starting). This window can't learn it
// later, so refuse now rather than sit blank — or go out on the open web.
if (popup == null && useTor && proxyPort <= 0) {
Toast.makeText(this, R.string.napplet_route_blocked, Toast.LENGTH_LONG).show()
finish()
return
}
if (!WebViewFeature.isFeatureSupported(WebViewFeature.WEB_MESSAGE_LISTENER)) {
Toast.makeText(this, getString(R.string.napplet_webview_too_old), Toast.LENGTH_LONG).show()
finish()
@@ -316,7 +327,13 @@ class NappletBrowserActivity : ComponentActivity() {
}
shimJs = readContractAsset(NappletWebContract.SHIM_JS_PATH).decodeToString()
claimRoute()
// Which route is really in effect: an open-web page goes through Tor while another page needs it.
WebViewProxyPolicy.observeRoute(this) {
routedOverTor = it
updateChromeState { copy(torForced = !useTor && it) }
}
// A popup's WebView is already loading: its route is claimed now, not before a load.
if (popup != null) claimRoute()
bindService(Intent().setClassName(this, NappletHostContract.BROKER_SERVICE_CLASS), brokerConnection, BIND_AUTO_CREATE)
onBackPressedDispatcher.addCallback(this, backCallback)
@@ -341,8 +358,8 @@ class NappletBrowserActivity : ComponentActivity() {
contentFrame.addView(wv, FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT))
if (popup == null) {
loadingView = buildLoadingView().also { contentFrame.addView(it) }
// Wait for this page's route (claimed above) to be in effect before the first request leaves.
WebViewProxyPolicy.whenApplied { if (webView === wv) wv.loadUrl(startUrl) }
// Wait for this page's route to be in effect before the first request leaves.
claimRoute { if (webView === wv) wv.loadUrl(startUrl) }
} else {
wv.url?.let { if (it.isNotBlank() && it != "about:blank") startUrl = it }
}
@@ -401,13 +418,19 @@ class NappletBrowserActivity : ComponentActivity() {
}
}
private val expireHeld =
Runnable {
if (!isDestroyed) heldWhileAway.expire().forEach { (_, request) -> bridge.failRequest(request, NappletHeldRequests.EXPIRED) }
}
override fun onResume() {
super.onResume()
webView?.onResume()
resumed = true
val held = heldWhileAway.toList()
heldWhileAway.clear()
held.forEach { (origin, request) -> dispatchToBroker(origin, request) }
reportAttended()
val held = heldWhileAway.drain()
held.fail.forEach { (_, request) -> bridge.failRequest(request, NappletHeldRequests.EXPIRED) }
held.send.forEach { (origin, request) -> dispatchToBroker(origin, request) }
heartbeatHandler.removeCallbacks(heartbeat)
heartbeat.run()
}
@@ -434,6 +457,7 @@ class NappletBrowserActivity : ComponentActivity() {
override fun onPause() {
resumed = false
reportAttended()
heartbeatHandler.removeCallbacks(heartbeat)
setBrokerForeground(false)
super.onPause()
@@ -441,6 +465,7 @@ class NappletBrowserActivity : ComponentActivity() {
override fun onDestroy() {
heartbeatHandler.removeCallbacks(backgroundPause)
heartbeatHandler.removeCallbacks(expireHeld)
// Tell the broker to drop every reference to our reply Messenger BEFORE unbinding — a retained
// Messenger is a binder, and it would pin this Activity (and its WebView) in `:napplet` for the
// life of the process. `unbindService` alone does not release it. See [replyMessenger].
@@ -697,6 +722,9 @@ class NappletBrowserActivity : ComponentActivity() {
// A fresh main-frame navigation: arm history gating and show the new address.
pendingMainFrameUrl = url
mainFrameLoadFailed = false
// The page is being replaced: nothing it asked for (replies, subscription pushes) may reach the next
// one, even a next one that never talks to the bridge.
if (bridge.onNavigation()) releasePage()
// A window a page opened takes its first real page as its home ("scope").
if (startUrl == "about:blank" && url.startsWith("http")) startUrl = url
// Re-arm favicon capture when the host changes, so a same-host in-page nav doesn't re-send.
@@ -908,13 +936,12 @@ class NappletBrowserActivity : ComponentActivity() {
/** Loads a user-typed address from "Edit address", forcing Tor for `.onion` when available. */
private fun loadAddress(text: String) {
val resolved = OmniboxInput.resolve(text) ?: return
if (resolved.forceTor && proxyPort > 0 && !useTor) {
if (resolved.forceTor && !useTor) {
useTor = true
claimRoute()
updateChromeState { copy(torOn = true) }
updateChromeState { copy(torOn = true, torForced = false) }
}
// An onion must not leave before the Tor route it just claimed is in place.
WebViewProxyPolicy.whenApplied { webView?.loadUrl(resolved.url) }
claimRoute { webView?.loadUrl(resolved.url) }
}
// ---- bridge: page <-> native (mirror of NappletBrowserService.onBridgeMessage) ----
@@ -944,19 +971,28 @@ class NappletBrowserActivity : ComponentActivity() {
val pageId = envelope.stringOrNull("id").orEmpty().ifEmpty { "fire-${fireSeq++}" }
val id = bridge.brokerIdFor(pageId)
// A relay subscription is named by the page, and its pushes (decrypted events included) come back
// under that name: stamp this document on it so the next page can never receive them.
val outgoing = bridge.stampSubscription(envelope)?.toString() ?: raw
val msg =
Message.obtain(null, NappletIpc.MSG_REQUEST).apply {
replyTo = replyMessenger
data =
Bundle().apply {
putString(NappletIpc.KEY_REQUEST_ID, id)
putString(NappletIpc.KEY_PAYLOAD, raw)
putString(NappletIpc.KEY_PAYLOAD, outgoing)
}
}
// In the background: a sign / encrypt / decrypt waits until the user is back on this window.
if (!resumed && NappletActingRequests.actsForUser(runCatching { NappletProtocolJson.readType(raw) }.getOrNull())) {
heldWhileAway += origin to msg
val refused = heldWhileAway.hold(origin to msg)
if (refused != null) {
bridge.failRequest(refused.second, NappletHeldRequests.TOO_MANY)
} else {
// Settle it with an error if nobody comes back for it, so the page isn't left waiting forever.
heartbeatHandler.postDelayed(expireHeld, NappletHeldRequests.MAX_AGE_MS)
}
return
}
dispatchToBroker(origin, msg)
@@ -1008,7 +1044,11 @@ class NappletBrowserActivity : ComponentActivity() {
val msg =
Message.obtain(null, NappletIpc.MSG_MINT_BROWSER_TOKEN).apply {
replyTo = replyMessenger
data = Bundle().apply { putString(NappletIpc.KEY_BROWSER_ORIGIN, origin) }
data =
Bundle().apply {
putString(NappletIpc.KEY_BROWSER_ORIGIN, origin)
putString(NappletIpc.KEY_WEBVIEW_PROFILE, webViewProfile)
}
}
queueToBroker(msg)
}
@@ -1028,6 +1068,18 @@ class NappletBrowserActivity : ComponentActivity() {
pendingBrokerRequests.removeAll { it.what == NappletIpc.MSG_REQUEST }
val broker = brokerMessenger ?: return
runCatching { broker.send(Message.obtain(null, NappletIpc.MSG_RELEASE_CLIENT).apply { replyTo = replyMessenger }) }
// The release forgets this window's attendance along with the rest; the next page is watched the same.
if (resumed) reportAttended()
}
/** Tells the broker whether this window is being looked at, which gates decrypting its relay reads. */
private fun reportAttended() {
queueToBroker(
Message.obtain(null, NappletIpc.MSG_SET_ATTENDED).apply {
replyTo = replyMessenger
data = Bundle().apply { putBoolean(NappletIpc.KEY_ATTENDED, resumed) }
},
)
}
/** A token for [origin] won't come: answer each call queued behind it with a failure, and allow a retry. */
@@ -1071,7 +1123,9 @@ class NappletBrowserActivity : ComponentActivity() {
}
NappletIpc.MSG_PUSH -> {
val payload = data.getString(NappletIpc.KEY_PAYLOAD) ?: return true
runCatching { bridge.currentProxy?.postMessage(payload) }
// Dropped when it is for a subscription a replaced document opened.
val push = parseJsonObjectOrNull(payload)?.let { bridge.resolvePush(it) } ?: return true
runCatching { bridge.currentProxy?.postMessage(push.toString()) }
}
NappletIpc.MSG_WEB_FAVORITE_STATE -> {
val url = data.getString(NappletIpc.KEY_FAVORITE_URL) ?: return true
@@ -1389,18 +1443,30 @@ class NappletBrowserActivity : ComponentActivity() {
// ---- network ----
/** Whether the process route is Tor right now, whatever this page asked for. */
private var routedOverTor = false
/**
* Files this page's Tor / open-web choice with the process-wide [WebViewProxyPolicy] (the override is
* shared by every WebView in `:napplet`, so no surface sets it directly); [onReady] runs once the shared
* route is in effect. An open-web page exempts its own site from other surfaces' Tor route.
* route is in effect. Fails closed: a page that wants Tor loads nothing until Tor's port is known and the
* route is really applied.
*/
private fun claimRoute(onReady: () -> Unit = {}) {
if (useTor && proxyPort > 0) {
WebViewProxyPolicy.claim(this, proxyPort, onReady = onReady)
} else {
val shown = webView?.url?.takeIf { it.startsWith("http") } ?: startUrl
WebViewProxyPolicy.claim(this, NappletProxyClaims.NO_PROXY, WebViewProxyPolicy.directHostsOf(shown), onReady)
if (useTor && proxyPort <= 0) {
showRouteBlocked()
return
}
WebViewProxyPolicy.claim(
owner = this,
torPort = if (useTor) proxyPort else NappletProxyClaims.NO_PROXY,
onFailed = { showRouteBlocked() },
onReady = onReady,
)
}
private fun showRouteBlocked() {
if (!isDestroyed) Toast.makeText(this, R.string.napplet_route_blocked, Toast.LENGTH_LONG).show()
}
/** Persists the per-host Tor choice in the main process and re-applies it to the live WebView. */
@@ -1408,7 +1474,7 @@ class NappletBrowserActivity : ComponentActivity() {
useTor = newUseTor
// Reload only once the new route is in effect, or the reload would go out the old way.
claimRoute { webView?.reload() }
updateChromeState { copy(torOn = useTor) }
updateChromeState { copy(torOn = useTor, torForced = !useTor && routedOverTor) }
// Key the persisted choice on the host actually displayed (which may differ from startUrl after
// in-page navigation), so the preference sticks to the right site.
val host = runCatching { currentUrl().toUri().host }.getOrNull()?.takeIf { it.isNotBlank() } ?: return
@@ -1447,6 +1513,7 @@ class NappletBrowserActivity : ComponentActivity() {
url = startUrl,
startUrl = startUrl,
torOn = if (proxyPort > 0) useTor else null,
torForced = !useTor && routedOverTor,
),
isFavorite = intent.getBooleanExtra(EXTRA_IS_FAVORITE, false),
defaultBrowserName = DefaultBrowser.label(this),
@@ -1694,7 +1761,7 @@ class NappletBrowserActivity : ComponentActivity() {
crashView = null
val wv = buildWebView()
contentFrame.addView(wv, 0, FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT))
WebViewProxyPolicy.whenApplied { if (webView === wv) wv.loadUrl(url) }
claimRoute { if (webView === wv) wv.loadUrl(url) }
}
},
)
@@ -211,6 +211,20 @@ object NappletBrowserContract {
*/
const val MSG_SET_ATTENDED = 37
/**
* Provider → client: whether the process's pages currently go through Tor ([KEY_USE_TOR]). Sent on every
* change. Tor always wins process-wide, so a tab set to the open web can still be on Tor because another
* open page needs it — the client shows why.
*/
const val MSG_ROUTE = 38
/**
* On [MSG_LOAD_STATE]: the page was not loaded because its network route can't be honored (Tor wanted but
* not running yet, this WebView can't proxy, or applying the proxy failed). Nothing went out; the client
* shows the error + Retry even over a page that loaded before.
*/
const val KEY_ROUTE_BLOCKED = "routeBlocked"
const val KEY_CAN_GO_FORWARD = "canGoForward"
const val KEY_FIND_QUERY = "findQuery"
const val KEY_FIND_FORWARD = "findForward"
@@ -35,6 +35,7 @@ import android.os.IBinder
import android.os.Looper
import android.os.Message
import android.os.Messenger
import android.os.SystemClock
import android.view.View
import android.view.ViewGroup
import android.webkit.ConsoleMessage
@@ -62,6 +63,7 @@ import com.vitorpamplona.amethyst.commons.browser.BrowserSitePermission
import com.vitorpamplona.amethyst.commons.browser.OmniboxInput
import com.vitorpamplona.amethyst.commons.napplet.NappletActingRequests
import com.vitorpamplona.amethyst.commons.napplet.NappletBridgeDocuments
import com.vitorpamplona.amethyst.commons.napplet.NappletHeldRequests
import com.vitorpamplona.amethyst.commons.napplet.NappletProxyClaims
import com.vitorpamplona.amethyst.commons.napplet.NappletWebContract
import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletProtocolJson
@@ -99,7 +101,7 @@ class NappletBrowserService : Service() {
val sessionId: String,
var clientMessenger: Messenger?,
val url: String,
val proxyPort: Int,
var proxyPort: Int,
var useTor: Boolean,
val bgColor: Int,
val themeType: String,
@@ -117,8 +119,9 @@ class NappletBrowserService : Service() {
// Whether the user is looking at this tab (see NappletBrowserContract.MSG_SET_ATTENDED), and the page's
// requests that act for the user held while they aren't: (origin, request), sent when they're back.
var attended = true
val heldWhileAway = mutableListOf<Pair<String, Message>>()
// Unattended until the client says otherwise: it sends its state right after every create.
var attended = false
val heldWhileAway = NappletHeldRequests<Pair<String, Message>>(SystemClock::elapsedRealtime)
// The session's root view (holds the WebView, and the page's fullscreen view when it has one).
var container: FrameLayout? = null
@@ -193,6 +196,8 @@ class NappletBrowserService : Service() {
brokerMessenger = Messenger(service)
pendingBrokerRequests.forEach { sendToBroker(it) }
pendingBrokerRequests.clear()
// A broker that restarted knows nothing about who is watching.
tabs.values.forEach { if (it.attended) reportAttended(it) }
}
override fun onServiceDisconnected(name: ComponentName?) {
@@ -219,6 +224,17 @@ class NappletBrowserService : Service() {
super.onDestroy()
}
/** The client re-reads Tor's port on every load it asks for: Tor may have come up (or moved) since the tab was made. */
private fun refreshProxyPort(
tab: BrowserTab,
msg: Message,
) {
msg.data
?.getInt(NappletBrowserContract.KEY_PROXY_PORT, 0)
?.takeIf { it != 0 }
?.let { tab.proxyPort = it }
}
private fun tabFor(msg: Message): BrowserTab? = msg.data?.getString(NappletBrowserContract.KEY_SESSION_ID)?.let { tabs[it] }
private fun onClientMessage(msg: Message): Boolean {
@@ -226,6 +242,9 @@ class NappletBrowserService : Service() {
NappletBrowserContract.MSG_CREATE_SESSION -> {
val data = msg.data ?: return true
val sessionId = data.getString(NappletBrowserContract.KEY_SESSION_ID) ?: return true
// A re-sent create for an id that is still live (a client that lost track of it) must not
// strand the old tab's WebView, broker state and proxy claim with nothing left to close them.
tabs[sessionId]?.let(::closeTab)
val tab =
BrowserTab(
sessionId = sessionId,
@@ -247,24 +266,27 @@ class NappletBrowserService : Service() {
}
NappletBrowserContract.MSG_NAVIGATE -> {
val tab = tabFor(msg) ?: return true
refreshProxyPort(tab, msg)
val url = normalizeUrl(msg.data?.getString(NappletBrowserContract.KEY_URL).orEmpty())
// A renderer crash destroyed this tab's WebView; the user's retry builds a fresh one.
val wv = tab.webView
if (wv == null) {
rebuildWebView(tab, url)
} else {
// Right after a Tor toggle the new route may still be applying; don't let this load race it.
WebViewProxyPolicy.whenApplied { if (tab.webView === wv) wv.loadUrl(url) }
// Right after a Tor toggle the new route may still be applying; don't let this load race it
// (nor go out at all when Tor is wanted but unavailable).
claimRoute(tab) { if (tab.webView === wv) wv.loadUrl(url) }
}
}
NappletBrowserContract.MSG_CLOSE_SESSION -> tabFor(msg)?.let(::closeTab)
NappletBrowserContract.MSG_SET_ATTENDED -> {
val tab = tabFor(msg) ?: return true
tab.attended = msg.data?.getBoolean(NappletBrowserContract.KEY_ENABLED, true) ?: true
tab.attended = msg.data?.getBoolean(NappletBrowserContract.KEY_ENABLED, false) ?: false
reportAttended(tab)
if (tab.attended) {
val held = tab.heldWhileAway.toList()
tab.heldWhileAway.clear()
held.forEach { (origin, request) -> dispatchToBroker(tab, origin, request) }
val held = tab.heldWhileAway.drain()
held.fail.forEach { (_, request) -> tab.bridge.failRequest(request, NappletHeldRequests.EXPIRED) }
held.send.forEach { (origin, request) -> dispatchToBroker(tab, origin, request) }
}
}
NappletBrowserContract.MSG_PAUSE ->
@@ -349,8 +371,9 @@ class NappletBrowserService : Service() {
NappletBrowserContract.MSG_EXIT_FULLSCREEN -> tabFor(msg)?.let { exitFullscreen(it) }
NappletBrowserContract.MSG_RELOAD -> {
val tab = tabFor(msg) ?: return true
refreshProxyPort(tab, msg)
// A renderer death destroyed this tab's WebView: rebuild it on the page it was showing.
if (tab.webView == null) rebuildWebView(tab, tab.recoverUrl ?: tab.url) else tab.webView?.reload()
if (tab.webView == null) rebuildWebView(tab, tab.recoverUrl ?: tab.url) else claimRoute(tab) { tab.webView?.reload() }
}
NappletBrowserContract.MSG_BACK -> tabFor(msg)?.webView?.let { if (it.canGoBack()) it.goBack() }
NappletBrowserContract.MSG_IME_OP -> {
@@ -361,6 +384,7 @@ class NappletBrowserService : Service() {
NappletBrowserContract.MSG_SET_TOR -> {
val tab = tabFor(msg) ?: return true
tab.useTor = msg.data?.getBoolean(NappletBrowserContract.KEY_USE_TOR, false) ?: false
refreshProxyPort(tab, msg)
// Reload only after the route actually applies — the override is async, so reloading
// immediately would re-fetch through the old route.
claimRoute(tab) { tab.webView?.reload() }
@@ -450,17 +474,40 @@ class NappletBrowserService : Service() {
}
/**
* Files [tab]'s Tor / open-web choice with the process-wide [WebViewProxyPolicy]; [onReady] runs once the
* shared route is in effect. An open-web tab exempts its own site from other tabs' Tor route.
* Files [tab]'s Tor / open-web choice with the process-wide [WebViewProxyPolicy] and runs [onReady] (the
* load) once the shared route is in effect. Fails closed: a tab that wants Tor while Tor has no port yet
* doesn't claim or load at all, and a route that can't be applied blocks the load too — either way the
* client hears [NappletBrowserContract.KEY_ROUTE_BLOCKED] and offers Retry. Also keeps the client told
* which route is really in effect ([NappletBrowserContract.MSG_ROUTE]).
*/
private fun claimRoute(
tab: BrowserTab,
onReady: () -> Unit = {},
) {
if (tab.useTor && tab.proxyPort > 0) {
WebViewProxyPolicy.claim(tab, tab.proxyPort, onReady = onReady)
} else {
WebViewProxyPolicy.claim(tab, NappletProxyClaims.NO_PROXY, WebViewProxyPolicy.directHostsOf(tab.webView?.url ?: tab.url), onReady)
WebViewProxyPolicy.observeRoute(tab) { usesTor ->
if (tabs[tab.sessionId] === tab) sendToClient(tab, NappletBrowserContract.MSG_ROUTE) { putBoolean(NappletBrowserContract.KEY_USE_TOR, usesTor) }
}
if (tab.useTor && tab.proxyPort <= 0) {
reportRouteBlocked(tab)
return
}
WebViewProxyPolicy.claim(
owner = tab,
torPort = if (tab.useTor) tab.proxyPort else NappletProxyClaims.NO_PROXY,
onFailed = { reportRouteBlocked(tab) },
onReady = onReady,
)
}
/** The page wasn't loaded because its route can't be honored: tell the client, which shows the error. */
private fun reportRouteBlocked(tab: BrowserTab) {
if (tabs[tab.sessionId] !== tab) return
tab.loadFailed = true
sendToClient(tab, NappletBrowserContract.MSG_LOAD_STATE) {
putBoolean(NappletBrowserContract.KEY_IS_LOADING, false)
putBoolean(NappletBrowserContract.KEY_LOAD_FAILED, true)
putBoolean(NappletBrowserContract.KEY_ROUTE_BLOCKED, true)
putString(NappletBrowserContract.KEY_URL, tab.webView?.url ?: tab.url)
}
}
@@ -516,7 +563,8 @@ class NappletBrowserService : Service() {
/** Drops [tab] and everything it holds (its WebView, broker state, proxy claim). */
private fun closeTab(tab: BrowserTab) {
tabs.remove(tab.sessionId)
// By identity: a replaced tab closing late must not take its replacement (same id) with it.
tabs.remove(tab.sessionId, tab)
WebViewProxyPolicy.release(tab)
releasePage(tab, closing = true)
tab.bridge.clear()
@@ -883,6 +931,9 @@ class NappletBrowserService : Service() {
) {
// A new main-frame navigation cleared any prior error, and lifts "block this page's dialogs".
tab?.loadFailed = false
// The page is being replaced: nothing it asked for (replies, subscription pushes) may reach the next
// one, even a next one that never talks to the bridge.
if (tab != null && tab.bridge.onNavigation()) releasePage(tab)
tab?.jsDialogsOnPage = 0
tab?.jsDialogsBlocked = false
// Re-arm favicon capture when the host changes, so a same-host in-page nav doesn't re-send.
@@ -1047,25 +1098,41 @@ class NappletBrowserService : Service() {
val pageId = envelope.stringOrNull("id").orEmpty().ifEmpty { "fire-${tab.fireSeq++}" }
val id = tab.bridge.brokerIdFor(pageId)
// A relay subscription is named by the page, and its pushes (decrypted events included) come back
// under that name: stamp this document on it so the next page can never receive them.
val outgoing = tab.bridge.stampSubscription(envelope)?.toString() ?: raw
val msg =
Message.obtain(null, NappletIpc.MSG_REQUEST).apply {
replyTo = tab.replyMessenger
data =
Bundle().apply {
putString(NappletIpc.KEY_REQUEST_ID, id)
putString(NappletIpc.KEY_PAYLOAD, raw)
putString(NappletIpc.KEY_PAYLOAD, outgoing)
}
}
// Nobody is looking at this tab (it's parked, or the app is in the background): a sign / encrypt /
// decrypt waits until they are, even when "allow always" would let it through without a prompt.
if (!tab.attended && NappletActingRequests.actsForUser(runCatching { NappletProtocolJson.readType(raw) }.getOrNull())) {
tab.heldWhileAway += origin to msg
val refused = tab.heldWhileAway.hold(origin to msg)
if (refused != null) {
tab.bridge.failRequest(refused.second, NappletHeldRequests.TOO_MANY)
} else {
// Settle it with an error if nobody comes back for it, so the page isn't left waiting forever.
heldExpiry.postDelayed({ expireHeld(tab) }, NappletHeldRequests.MAX_AGE_MS)
}
return
}
dispatchToBroker(tab, origin, msg)
}
private val heldExpiry = Handler(Looper.getMainLooper())
private fun expireHeld(tab: BrowserTab) {
if (tabs[tab.sessionId] !== tab) return
tab.heldWhileAway.expire().forEach { (_, request) -> tab.bridge.failRequest(request, NappletHeldRequests.EXPIRED) }
}
/** Sends [msg] with [origin]'s launch token, minting the token first if the origin has none yet. */
private fun dispatchToBroker(
tab: BrowserTab,
@@ -1122,6 +1189,18 @@ class NappletBrowserService : Service() {
}
if (closing) tab.originTokens.clear()
if (brokerMessenger != null) sendToBroker(release)
// The release forgets the tab's attendance along with the rest; the next page is watched just the same.
if (!closing && tab.attended) reportAttended(tab)
}
/** Tells the broker whether [tab] is being looked at, which gates decrypting its relay reads. */
private fun reportAttended(tab: BrowserTab) {
val msg =
Message.obtain(null, NappletIpc.MSG_SET_ATTENDED).apply {
replyTo = tab.replyMessenger
data = Bundle().apply { putBoolean(NappletIpc.KEY_ATTENDED, tab.attended) }
}
if (brokerMessenger == null) pendingBrokerRequests.add(msg) else sendToBroker(msg)
}
private fun requestBrowserToken(
@@ -1137,7 +1216,11 @@ class NappletBrowserService : Service() {
val msg =
Message.obtain(null, NappletIpc.MSG_MINT_BROWSER_TOKEN).apply {
replyTo = tab.replyMessenger
data = Bundle().apply { putString(NappletIpc.KEY_BROWSER_ORIGIN, origin) }
data =
Bundle().apply {
putString(NappletIpc.KEY_BROWSER_ORIGIN, origin)
putString(NappletIpc.KEY_WEBVIEW_PROFILE, tab.webViewProfile)
}
}
if (brokerMessenger == null) pendingBrokerRequests.add(msg) else sendToBroker(msg)
}
@@ -1225,7 +1308,9 @@ class NappletBrowserService : Service() {
}
NappletIpc.MSG_PUSH -> {
val payload = data.getString(NappletIpc.KEY_PAYLOAD) ?: return true
runCatching { tab.bridge.currentProxy?.postMessage(payload) }
// Dropped when it is for a subscription a replaced document opened.
val push = parseJsonObjectOrNull(payload)?.let { tab.bridge.resolvePush(it) } ?: return true
runCatching { tab.bridge.currentProxy?.postMessage(push.toString()) }
}
NappletIpc.MSG_TOKEN_UNKNOWN -> {
// The broker no longer knows this token (evicted): forget it so the origin re-mints.
@@ -151,6 +151,13 @@ object NappletEmbedContract {
*/
const val MSG_SET_ATTENDED = 26
/**
* Provider → client: whether the process's pages currently go through Tor ([KEY_ROUTE_TOR]). Sent on every
* change for an nSite (it has off-origin traffic of its own). Tor always wins process-wide, so an nSite
* set to the open web can still be on Tor because another open page needs it — the client shows why.
*/
const val MSG_ROUTE = 27
const val KEY_FIND_QUERY = "findQuery"
const val KEY_FIND_FORWARD = "findForward"
const val KEY_FIND_ACTIVE = "findActive"
@@ -190,6 +197,15 @@ object NappletEmbedContract {
const val KEY_IS_LOADING = "isLoading"
const val KEY_LOAD_FAILED = "loadFailed"
const val KEY_RENDERER_GONE = "rendererGone"
const val KEY_ROUTE_TOR = "routeTor"
/**
* On [MSG_LOAD_STATE]: the applet was not loaded because its network route can't be honored (Tor wanted
* but not running yet, this WebView can't proxy, or applying the proxy failed). Nothing went out; the
* client offers Retry, whose [MSG_RELOAD] carries the current Tor port
* ([NappletHostContract.EXTRA_PROXY_PORT]).
*/
const val KEY_ROUTE_BLOCKED = "routeBlocked"
const val KEY_NOTICE = "notice"
const val KEY_IME_PAYLOAD = "imePayload"
@@ -33,6 +33,7 @@ import android.os.IBinder
import android.os.Looper
import android.os.Message
import android.os.Messenger
import android.os.SystemClock
import android.util.TypedValue
import android.view.Gravity
import android.view.KeyEvent
@@ -67,6 +68,8 @@ import com.vitorpamplona.amethyst.commons.browser.ui.pill.BrowserPillEvent
import com.vitorpamplona.amethyst.commons.browser.ui.pill.BrowserPillUi
import com.vitorpamplona.amethyst.commons.browser.ui.pill.ConsoleLine
import com.vitorpamplona.amethyst.commons.napplet.NappletActingRequests
import com.vitorpamplona.amethyst.commons.napplet.NappletHeldRequests
import com.vitorpamplona.amethyst.commons.napplet.NappletProxyClaims
import com.vitorpamplona.amethyst.commons.napplet.NappletWebContract
import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletProtocolJson
import com.vitorpamplona.amethyst.commons.util.booleanOrNull
@@ -153,6 +156,9 @@ class NappletHostActivity : ComponentActivity() {
private var proxyPort: Int = -1
/** Whether the process route is Tor right now, whatever this nSite asked for. */
private var routedOverTor = false
// The resource edge (shell + verified blobs); built in onCreate once the manifest is parsed.
private lateinit var contentServer: NappletContentServer
@@ -233,7 +239,12 @@ class NappletHostActivity : ComponentActivity() {
// Requests that act for the user (publish, pay, upload…) made while this napplet was in the background.
// Pausing the WebView doesn't stop JavaScript, so they are held here and sent on the next resume.
private val heldWhilePaused = mutableListOf<Message>()
private val heldWhilePaused = NappletHeldRequests<Message>(SystemClock::elapsedRealtime)
private val expireHeld =
Runnable {
if (!isDestroyed) heldWhilePaused.expire().forEach { bridgeReplyProxy?.failRequest(it, NappletHeldRequests.EXPIRED) }
}
// Renews the broker's foreground lease while resumed. If this process dies, the heartbeat stops and
// the broker reaps the stale lease, so a crash can't pin the main process's network up forever.
@@ -251,6 +262,7 @@ class NappletHostActivity : ComponentActivity() {
// If we're already foreground by the time the broker binds, report it now so the
// main-process resource hold is acquired for this session.
if (resumed) setBrokerForeground(true)
reportAttended()
}
override fun onServiceDisconnected(name: ComponentName?) {
@@ -267,6 +279,12 @@ class NappletHostActivity : ComponentActivity() {
finish()
return
}
// Fail closed: Tor is on but its port isn't known yet, so nothing (blobs or web traffic) may go out.
if (useTor && proxyPort <= 0) {
Toast.makeText(this, R.string.napplet_route_blocked, Toast.LENGTH_LONG).show()
finish()
return
}
if (!WebViewFeature.isFeatureSupported(WebViewFeature.WEB_MESSAGE_LISTENER)) {
Toast.makeText(this, getString(R.string.napplet_webview_too_old), Toast.LENGTH_LONG).show()
@@ -305,7 +323,15 @@ 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 (profile.exposesNetwork) WebViewProxyPolicy.claim(this, effectiveProxy)
if (profile.exposesNetwork) {
// An open-web nSite still goes through Tor while another surface needs it: say so in the chrome.
WebViewProxyPolicy.observeRoute(this) {
routedOverTor = it
chrome?.let { c -> c.ui = c.ui.copy(chrome = c.ui.chrome.copy(torForced = !useTor && it)) }
}
// Start applying now, overlapping the index probe; the load itself waits in mountWebView.
WebViewProxyPolicy.claim(this, effectiveProxy)
}
// Origin-restricted bridge: only the trusted shell page (main frame) can reach native.
WebViewCompat.addWebMessageListener(
webView,
@@ -370,8 +396,17 @@ class NappletHostActivity : ComponentActivity() {
contentFrame.addView(webView, 0, FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT))
if (!started) {
started = true
// Wait for the route claimed above: a Tor nSite's first off-origin request must not leave early.
WebViewProxyPolicy.whenApplied { if (!isDestroyed) webView.loadUrl(NappletWebContract.SHELL_URL) }
// Wait for the route to be in effect: a Tor nSite's first off-origin request must not leave early,
// and must not leave at all if the route can't be applied.
if (profile.exposesNetwork) {
WebViewProxyPolicy.claim(
owner = this,
torPort = if (useTor) proxyPort else NappletProxyClaims.NO_PROXY,
onFailed = { if (!isDestroyed) Toast.makeText(this, R.string.napplet_route_blocked, Toast.LENGTH_LONG).show() },
) { if (!isDestroyed) webView.loadUrl(NappletWebContract.SHELL_URL) }
} else {
webView.loadUrl(NappletWebContract.SHELL_URL)
}
}
}
@@ -394,10 +429,11 @@ class NappletHostActivity : ComponentActivity() {
// hold the main process resumed (Tor/relays/AUTH) while this napplet/nSite is in front, and
// keep renewing that lease so a crash here can't pin the network up forever.
resumed = true
reportAttended()
startForegroundHeartbeat()
val held = heldWhilePaused.toList()
heldWhilePaused.clear()
held.forEach { if (brokerMessenger == null) pendingRequests.add(it) else sendToBroker(it) }
val held = heldWhilePaused.drain()
held.fail.forEach { bridgeReplyProxy?.failRequest(it, NappletHeldRequests.EXPIRED) }
held.send.forEach { if (brokerMessenger == null) pendingRequests.add(it) else sendToBroker(it) }
}
override fun onStart() {
@@ -420,6 +456,7 @@ class NappletHostActivity : ComponentActivity() {
// (and be confused with) Amethyst's own UI and an "allow always" napplet can't act unwatched. The page
// itself keeps running until onStop's grace runs out.
resumed = false
reportAttended()
stopForegroundHeartbeat()
setBrokerForeground(false)
super.onPause()
@@ -457,6 +494,16 @@ class NappletHostActivity : ComponentActivity() {
runCatching { broker.send(msg) }
}
/** Tells the broker whether this napplet is being looked at, which gates decrypting its relay reads. */
private fun reportAttended() {
val msg =
Message.obtain(null, NappletIpc.MSG_SET_ATTENDED).apply {
replyTo = replyMessenger
data = Bundle().apply { putBoolean(NappletIpc.KEY_ATTENDED, resumed) }
}
if (brokerMessenger == null) pendingRequests.add(msg) else sendToBroker(msg)
}
/** Reports this surface's foreground state to the broker so it can hold the main process resumed. */
private fun setBrokerForeground(foreground: Boolean) {
@@ -476,6 +523,7 @@ class NappletHostActivity : ComponentActivity() {
override fun onDestroy() {
backgroundPauseHandler.removeCallbacks(backgroundPause)
backgroundPauseHandler.removeCallbacks(expireHeld)
uiScope.cancel()
// Drop the broker's references to our reply Messenger BEFORE unbinding — a retained Messenger is a
// binder and would pin this Activity (and its WebView) for the life of the `:napplet` process.
@@ -796,7 +844,13 @@ class NappletHostActivity : ComponentActivity() {
// In the background: an act on the user's behalf waits until they're looking at this napplet again.
if (!resumed && NappletActingRequests.actsForUser(runCatching { NappletProtocolJson.readType(raw) }.getOrNull())) {
heldWhilePaused += msg
val refused = heldWhilePaused.hold(msg)
if (refused != null) {
bridgeReplyProxy?.failRequest(refused, NappletHeldRequests.TOO_MANY)
} else {
// Settle it with an error if nobody comes back for it, so the applet isn't left waiting forever.
backgroundPauseHandler.postDelayed(expireHeld, NappletHeldRequests.MAX_AGE_MS)
}
return
}
val messenger = brokerMessenger
@@ -952,6 +1006,7 @@ class NappletHostActivity : ComponentActivity() {
// Website-mode nSites can re-route over Tor; switching rebuilds the session, so the
// row taps through to a full relaunch rather than toggling inline.
torOn = if (profile.exposesNetwork && proxyPort > 0) useTor else null,
torForced = !useTor && routedOverTor,
canFavorite = false,
hasAccessInfo = true,
),
@@ -34,6 +34,7 @@ import android.os.IBinder
import android.os.Looper
import android.os.Message
import android.os.Messenger
import android.os.SystemClock
import android.view.View
import android.view.ViewGroup
import android.webkit.ConsoleMessage
@@ -58,6 +59,7 @@ import androidx.webkit.WebViewCompat
import androidx.webkit.WebViewFeature
import com.vitorpamplona.amethyst.commons.browser.BrowserChrome
import com.vitorpamplona.amethyst.commons.napplet.NappletActingRequests
import com.vitorpamplona.amethyst.commons.napplet.NappletHeldRequests
import com.vitorpamplona.amethyst.commons.napplet.NappletWebContract
import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletProtocolJson
import com.vitorpamplona.amethyst.commons.util.booleanOrNull
@@ -102,7 +104,7 @@ class NappletHostService : Service() {
val launchToken: String,
val profile: HostProfile,
val useTor: Boolean,
val proxyPort: Int,
var proxyPort: Int,
val bgColor: Int,
val themeType: String,
// Opaque per-account WebView storage-profile name (see NappletWebViewProfile).
@@ -121,11 +123,15 @@ class NappletHostService : Service() {
// only built when the surface opens), so the flag is applied to every WebView built for the tab.
var paused = false
// The applet wasn't built because its route can't be honored (Tor wanted, no port): a retry rebuilds it.
var routeBlocked = false
// Whether the user is looking at this napplet (NappletEmbedContract.MSG_SET_ATTENDED). Requests that act
// for the user (publish, pay, upload…) made while they aren't — or while the page is paused — are held
// here and sent when they're back: pausing the WebView doesn't stop JavaScript.
var attended = true
val heldWhilePaused = mutableListOf<Message>()
// here and sent when they're back: pausing the WebView doesn't stop JavaScript. Unattended until the
// client says otherwise: it sends its state right after every create.
var attended = false
val heldWhilePaused = NappletHeldRequests<Message>(SystemClock::elapsedRealtime)
val mayAct: Boolean get() = attended && !paused
var bridgeReplyProxy: JavaScriptReplyProxy? = null
@@ -175,6 +181,8 @@ class NappletHostService : Service() {
brokerMessenger = Messenger(service)
pendingBrokerRequests.forEach { sendToBroker(it) }
pendingBrokerRequests.clear()
// A broker that restarted knows nothing about who is watching.
tabs.values.forEach { if (it.attended) reportAttended(it) }
}
override fun onServiceDisconnected(name: ComponentName?) {
@@ -209,6 +217,9 @@ class NappletHostService : Service() {
when (msg.what) {
NappletEmbedContract.MSG_CREATE_SESSION -> {
val tab = buildTab(msg) ?: return true
// A re-sent create for an id that is still live must not strand the old tab's WebView, content
// server and broker state with nothing left to close them.
tabs[tab.sessionId]?.let(::closeTab)
tabs[tab.sessionId] = tab
// Bind the broker once; a re-sent MSG_CREATE_SESSION must not leak a second binding.
if (!brokerBound) {
@@ -219,9 +230,15 @@ class NappletHostService : Service() {
NappletEmbedContract.MSG_BACK -> tabFor(msg)?.webView?.let { if (it.canGoBack()) it.goBack() }
NappletEmbedContract.MSG_RELOAD -> {
val tab = tabFor(msg) ?: return true
// The client re-reads Tor's port on a retry: it may have come up (or moved) since the tab was made.
msg.data
?.getInt(NappletHostContract.EXTRA_PROXY_PORT, 0)
?.takeIf { it != 0 }
?.let { tab.proxyPort = it }
val container = tab.container
// After a renderer crash the tab has no WebView: the retry builds a fresh one.
if (tab.webView == null && container != null) {
// After a renderer crash the tab has no WebView, and a tab blocked on its route has only a blank
// placeholder: the retry builds a fresh one.
if ((tab.webView == null || tab.routeBlocked) && container != null) {
val wv = createHostWebView(container.context, tab.sessionId, container)
container.addView(wv, 0, FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT))
} else {
@@ -245,7 +262,8 @@ class NappletHostService : Service() {
}
NappletEmbedContract.MSG_SET_ATTENDED ->
tabFor(msg)?.let {
it.attended = msg.data?.getBoolean(NappletEmbedContract.KEY_ATTENDED, true) ?: true
it.attended = msg.data?.getBoolean(NappletEmbedContract.KEY_ATTENDED, false) ?: false
reportAttended(it)
releaseHeld(it)
}
NappletEmbedContract.MSG_IME_OP -> {
@@ -385,6 +403,17 @@ class NappletHostService : Service() {
tab.container = container
// A rebuild after a renderer crash: release the previous content server first.
tab.contentServer?.close()
tab.contentServer = null
// Fail closed: Tor is wanted but has no port yet. Nothing may be fetched — not the applet's blobs (the
// content server would fetch them directly) and not its own traffic — so leave a blank placeholder and
// tell the client, whose Retry sends the port once Tor is up.
if (tab.useTor && tab.proxyPort <= 0) {
val blank = WebView(nightThemedContext(context, tab.themeType)).apply { setBackgroundColor(tab.bgColor) }
tab.webView = blank
reportRouteBlocked(tab)
return blank
}
tab.routeBlocked = false
val wv = WebView(nightThemedContext(context, tab.themeType))
// FIRST touch after construction: setProfile throws once the WebView has loaded content (or its
// profile has otherwise been used), so the storage partition must be chosen before the
@@ -417,8 +446,17 @@ class NappletHostService : Service() {
if (tab.paused) wv.onPause()
if (tab.profile.exposesNetwork) {
// The site's own off-origin traffic follows the process-wide route; load once it's in place so
// a Tor nSite's first request can't leave over the open web.
WebViewProxyPolicy.claim(tab, effectiveProxy) { if (tab.webView === wv) wv.loadUrl(NappletWebContract.SHELL_URL) }
// a Tor nSite's first request can't leave over the open web — and not at all if it can't be.
WebViewProxyPolicy.observeRoute(tab) { usesTor ->
if (tabs[tab.sessionId] === tab) {
tab.toClient(Message.obtain(null, NappletEmbedContract.MSG_ROUTE).apply { data = Bundle().apply { putBoolean(NappletEmbedContract.KEY_ROUTE_TOR, usesTor) } })
}
}
WebViewProxyPolicy.claim(
owner = tab,
torPort = effectiveProxy,
onFailed = { reportRouteBlocked(tab) },
) { if (tab.webView === wv) wv.loadUrl(NappletWebContract.SHELL_URL) }
} else {
wv.loadUrl(NappletWebContract.SHELL_URL)
}
@@ -438,7 +476,8 @@ class NappletHostService : Service() {
/** Drops [tab] and everything it holds (its WebView, content server, broker state, proxy claim). */
private fun closeTab(tab: NappletTab) {
tabs.remove(tab.sessionId)
// By identity: a replaced tab closing late must not take its replacement (same id) with it.
tabs.remove(tab.sessionId, tab)
tab.bridgeReplyProxy = null
WebViewProxyPolicy.release(tab)
releaseFromBroker(tab)
@@ -712,6 +751,7 @@ class NappletHostService : Service() {
tab: NappletTab,
isLoading: Boolean,
rendererGone: Boolean = false,
routeBlocked: Boolean = false,
) {
val message =
Message.obtain(null, NappletEmbedContract.MSG_LOAD_STATE).apply {
@@ -720,11 +760,21 @@ class NappletHostService : Service() {
putBoolean(NappletEmbedContract.KEY_IS_LOADING, isLoading)
putBoolean(NappletEmbedContract.KEY_LOAD_FAILED, tab.loadFailed)
putBoolean(NappletEmbedContract.KEY_RENDERER_GONE, rendererGone)
putBoolean(NappletEmbedContract.KEY_ROUTE_BLOCKED, routeBlocked)
}
}
tab.toClient(message)
}
/** The applet wasn't loaded because its route can't be honored: tell the client, which shows the error. */
private fun reportRouteBlocked(tab: NappletTab) {
if (tabs[tab.sessionId] !== tab) return
// Whatever WebView the tab has never loaded the applet: the retry must rebuild it, not reload it.
tab.routeBlocked = true
tab.loadFailed = true
pushLoadState(tab, isLoading = false, routeBlocked = true)
}
// ---- bridge: shell <-> native (mirror of NappletHostActivity.onShellMessage) ----
private fun onShellMessage(
@@ -765,7 +815,13 @@ class NappletHostService : Service() {
// Nobody is looking (parked off-screen, or the app is in the background): an act on the user's behalf
// waits until they're looking at this napplet again.
if (!tab.mayAct && NappletActingRequests.actsForUser(runCatching { NappletProtocolJson.readType(raw) }.getOrNull())) {
tab.heldWhilePaused += msg
val refused = tab.heldWhilePaused.hold(msg)
if (refused != null) {
tab.bridgeReplyProxy?.failRequest(refused, NappletHeldRequests.TOO_MANY)
} else {
// Settle it with an error if nobody comes back for it, so the applet isn't left waiting forever.
heldExpiry.postDelayed({ expireHeld(tab) }, NappletHeldRequests.MAX_AGE_MS)
}
return
}
if (brokerMessenger == null) pendingBrokerRequests.add(msg) else sendToBroker(msg)
@@ -774,9 +830,26 @@ class NappletHostService : Service() {
/** Sends [tab]'s held acting requests once it may act again (attended and not paused). */
private fun releaseHeld(tab: NappletTab) {
if (!tab.mayAct) return
val held = tab.heldWhilePaused.toList()
tab.heldWhilePaused.clear()
held.forEach { request -> if (brokerMessenger == null) pendingBrokerRequests.add(request) else sendToBroker(request) }
val held = tab.heldWhilePaused.drain()
held.fail.forEach { tab.bridgeReplyProxy?.failRequest(it, NappletHeldRequests.EXPIRED) }
held.send.forEach { request -> if (brokerMessenger == null) pendingBrokerRequests.add(request) else sendToBroker(request) }
}
private val heldExpiry = Handler(Looper.getMainLooper())
/** Tells the broker whether [tab] is being looked at, which gates decrypting its relay reads. */
private fun reportAttended(tab: NappletTab) {
val msg =
Message.obtain(null, NappletIpc.MSG_SET_ATTENDED).apply {
replyTo = tab.replyMessenger
data = Bundle().apply { putBoolean(NappletIpc.KEY_ATTENDED, tab.attended) }
}
if (brokerMessenger == null) pendingBrokerRequests.add(msg) else sendToBroker(msg)
}
private fun expireHeld(tab: NappletTab) {
if (tabs[tab.sessionId] !== tab) return
tab.heldWhilePaused.expire().forEach { tab.bridgeReplyProxy?.failRequest(it, NappletHeldRequests.EXPIRED) }
}
/**
@@ -169,6 +169,17 @@ object NappletIpc {
*/
const val MSG_TOKEN_UNKNOWN = 20
/**
* Host → broker: whether the user is looking at the surface behind [android.os.Message.replyTo]
* ([KEY_ATTENDED]). The broker decrypts relay reads for a page — events pushed to its subscriptions, and
* `relay.query` results — only while it is; until then encrypted events wait. A surface is unattended
* until it says otherwise, and after each [MSG_RELEASE_CLIENT].
*/
const val MSG_SET_ATTENDED = 21
/** Boolean for [MSG_SET_ATTENDED]. */
const val KEY_ATTENDED = "attended"
const val KEY_REQUEST_ID = "requestId"
const val KEY_PAYLOAD = "payload"
@@ -211,6 +222,9 @@ object NappletIpc {
/** The visited web origin (e.g. `https://example.com`) a browser-mode request belongs to. */
const val KEY_BROWSER_ORIGIN = "browserOrigin"
/** [MSG_MINT_BROWSER_TOKEN]: the opaque storage profile the asking surface runs in (its account's jar). */
const val KEY_WEBVIEW_PROFILE = "webViewProfile"
/** Boolean: route this site through Tor (true) or over the open web (false). */
const val KEY_NETWORK_USE_TOR = "networkUseTor"
@@ -20,12 +20,12 @@
*/
package com.vitorpamplona.amethyst.napplethost
import android.os.Handler
import android.os.Looper
import androidx.webkit.ProxyConfig
import androidx.webkit.ProxyController
import androidx.webkit.WebViewFeature
import com.vitorpamplona.amethyst.commons.browser.OmniboxInput
import com.vitorpamplona.amethyst.commons.napplet.NappletProxyClaims
import com.vitorpamplona.amethyst.commons.napplet.NappletProxyClaims.Claim
import com.vitorpamplona.amethyst.commons.napplet.NappletProxyClaims.Route
import com.vitorpamplona.quartz.utils.Log
import java.util.concurrent.Executor
@@ -33,108 +33,127 @@ import java.util.concurrent.Executor
/**
* The single owner of the `:napplet` process's WebView proxy override. Every surface — embedded browser
* tab, embedded nSite, full-screen browser or host — files a claim here instead of setting the override
* itself; [NappletProxyClaims] derives the one route they all share (see there for the policy).
* itself; [NappletProxyClaims] derives the one route they all share (Tor always wins, see there).
*
* The override applies asynchronously, so a surface's page load waits for [claim]'s `onReady`: a Tor
* page's first request can no longer leave before the Tor route is in place.
* It fails CLOSED. A surface's page load waits for [claim]'s `onReady`, which only runs once the route is
* actually in effect: if applying it fails, or this WebView can't take a proxy override at all while Tor is
* wanted, the waiting loads get `onFailed` instead of going out directly, and the next claim tries again.
* (A surface that wants Tor but has no Tor port yet must not claim at all — it blocks its own load.)
*
* Main thread only.
* Main thread only; the WebKit callback is delivered back to the main thread too.
*/
object WebViewProxyPolicy {
private const val TAG = "WebViewProxyPolicy"
/** Why a surface's load can't go ahead. */
enum class Failure {
/** This device's WebView can't route through a proxy, so Tor can't be honored. */
TOR_UNSUPPORTED,
/** Setting the proxy override failed. */
APPLY_FAILED,
}
private class Waiter(
val wantsTor: Boolean,
val onReady: () -> Unit,
val onFailed: (Failure) -> Unit,
)
private val claims = NappletProxyClaims()
// What the WebView currently runs with (a fresh process has no override), and what is being applied.
private var applied: Route = NappletProxyClaims.DIRECT
private var applying: Route? = null
private val waiting = mutableListOf<() -> Unit>()
private val waiting = mutableListOf<Waiter>()
// Surfaces told which route is really in effect (an open-web page can be on Tor because another needs it).
private val routeListeners = LinkedHashMap<Any, (Boolean) -> Unit>()
private val main = Handler(Looper.getMainLooper())
private val mainExecutor = Executor { if (Looper.myLooper() == Looper.getMainLooper()) it.run() else main.post(it) }
private val supported by lazy { WebViewFeature.isFeatureSupported(WebViewFeature.PROXY_OVERRIDE) }
/**
* Files [owner]'s route: through Tor on [torPort] (> 0), or the open web ([NappletProxyClaims.NO_PROXY])
* with [directHosts] exempt from any Tor route other surfaces need. [onReady] runs on the main thread
* once the resulting process route is in effect — immediately when nothing had to change.
* Files [owner]'s route: through Tor on [torPort] (> 0), or the open web ([NappletProxyClaims.NO_PROXY]).
* [onReady] runs on the main thread once the resulting process route is in effect — immediately when
* nothing had to change; [onFailed] instead when it can't be.
*/
fun claim(
owner: Any,
torPort: Int,
directHosts: Set<String> = emptySet(),
onFailed: (Failure) -> Unit = {},
onReady: () -> Unit = {},
) {
claims.claim(owner, Claim(torPort, directHosts))
sync(onReady)
claims.claim(owner, torPort)
sync(Waiter(torPort > 0, onReady, onFailed))
}
/** Withdraws [owner]'s claim; the route relaxes once no remaining surface needs it. */
/** Withdraws [owner]'s claim and route listener; the route relaxes once no remaining surface needs it. */
fun release(owner: Any) {
claims.release(owner)
sync {}
routeListeners.remove(owner)
sync(null)
}
/**
* The hosts an open-web surface showing [url] exempts from other surfaces' Tor route: its site, and its
* subdomains through the bypass rule. Only for web pages, and never an onion (those only resolve via Tor).
*/
fun directHostsOf(url: String?): Set<String> {
if (url == null || !(url.startsWith("https://") || url.startsWith("http://"))) return emptySet()
val host = OmniboxInput.hostOf(url)?.lowercase()?.removePrefix("www.") ?: return emptySet()
return if (host.endsWith(".onion")) emptySet() else setOf(host)
/** Tells [listener] (now, and on every change) whether the process currently routes through Tor. */
fun observeRoute(
owner: Any,
listener: (usesTor: Boolean) -> Unit,
) {
routeListeners[owner] = listener
listener(applied.usesTor)
}
/** Runs [onReady] once no route change is in flight (e.g. a navigation right after a Tor toggle). */
fun whenApplied(onReady: () -> Unit) = sync(onReady)
private fun sync(onReady: () -> Unit) {
if (!supported) {
onReady()
return
}
private fun sync(waiter: Waiter?) {
val target = claims.route()
if (target == applied && applying == null) {
onReady()
if (!supported) {
// Nothing can be proxied. The open web still works; a surface that wants Tor fails closed.
if (waiter?.wantsTor == true) waiter.onFailed(Failure.TOR_UNSUPPORTED) else waiter?.onReady?.invoke()
return
}
waiting += onReady
if (target == applied && applying == null) {
waiter?.onReady?.invoke()
return
}
waiter?.let { waiting += it }
if (target == applying) return
applying = target
apply(target) {
applied = target
// A newer route superseded this one while it applied: its own callback releases the waiters.
if (applying == target) {
applying = null
val ready = waiting.toList()
waiting.clear()
ready.forEach { it() }
apply(target) { ok ->
// A newer route superseded this one while it applied: its own callback settles the waiters.
if (applying != target) {
if (ok) applied = target
return@apply
}
applying = null
val settled = waiting.toList()
waiting.clear()
if (ok) {
applied = target
routeListeners.values.toList().forEach { it(target.usesTor) }
settled.forEach { it.onReady() }
} else {
// Keep `applied` as it was, so the next claim tries again; nothing waiting goes out unrouted.
settled.forEach { it.onFailed(Failure.APPLY_FAILED) }
}
}
}
private fun apply(
route: Route,
onApplied: () -> Unit,
done: (ok: Boolean) -> Unit,
) {
val executor = Executor { it.run() }
runCatching {
if (route.usesTor) {
val config =
ProxyConfig
.Builder()
.addProxyRule("socks5://127.0.0.1:${route.torPort}")
.apply {
route.bypassHosts.forEach { host ->
addBypassRule(host)
addBypassRule("*.$host")
}
}.build()
ProxyController.getInstance().setProxyOverride(config, executor, onApplied)
val config = ProxyConfig.Builder().addProxyRule("socks5://127.0.0.1:${route.torPort}").build()
ProxyController.getInstance().setProxyOverride(config, mainExecutor) { done(true) }
} else {
ProxyController.getInstance().clearProxyOverride(executor, onApplied)
ProxyController.getInstance().clearProxyOverride(mainExecutor) { done(true) }
}
}.onFailure {
Log.w(TAG, "Failed to apply WebView proxy override", it)
onApplied()
done(false)
}
}
}
@@ -17,5 +17,8 @@
<!-- Developer console: page-load failures surfaced as console errors -->
<string name="napplet_console_load_error">Failed to load (%1$d): %2$s</string>
<string name="napplet_console_http_error">HTTP %1$d %2$s</string>
<!-- Shown by the full-screen browser / napplet (in the sandbox process, where compose resources can't be
read outside composition) when a page is not loaded because its Tor route can't be honored. -->
<string name="napplet_route_blocked">Not loaded: Tor isn\'t available for this page yet. Try again in a moment.</string>
</resources>