diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/favorites/FavoriteAppLauncher.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/favorites/FavoriteAppLauncher.kt index 6274bda285..fe1db25f64 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/favorites/FavoriteAppLauncher.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/favorites/FavoriteAppLauncher.kt @@ -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) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletBrokerService.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletBrokerService.kt index 2269a7bc17..0a70c9ef76 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletBrokerService.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletBrokerService.kt @@ -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() + 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 -> { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletLaunchRegistry.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletLaunchRegistry.kt index 0ba57c4697..de6992f6a6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletLaunchRegistry.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletLaunchRegistry.kt @@ -71,19 +71,27 @@ object NappletLaunchRegistry { // LinkedHashMap + @Synchronized pair provided, without JVM-only APIs. private val sessions = LruCache(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(MAX_SESSIONS) + fun register( identity: NappletIdentity, declared: Set, 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) + } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletLauncher.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletLauncher.kt index e4619c6624..dfd881e461 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletLauncher.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletLauncher.kt @@ -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()) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletLiveSubscriptions.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletLiveSubscriptions.kt index 5102996b80..943900469d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletLiveSubscriptions.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletLiveSubscriptions.kt @@ -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, ) { private data class Key( val owner: Messenger, @@ -71,6 +73,10 @@ class NappletLiveSubscriptions( val eoseSent = AtomicBoolean(false) val deliveries = Channel(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() } 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 + } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/EmbeddedWebAppController.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/EmbeddedWebAppController.kt index 4eda3795ec..57a720d395 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/EmbeddedWebAppController.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/EmbeddedWebAppController.kt @@ -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()}" } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/WebAppScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/WebAppScreen.kt index fbe5903309..5224874ca9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/WebAppScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/WebAppScreen.kt @@ -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, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedTabFactory.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedTabFactory.kt index 70ad796a72..667c222e26 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedTabFactory.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedTabFactory.kt @@ -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 /** diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedTabHost.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedTabHost.kt index 77727fd625..c9968d6de6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedTabHost.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedTabHost.kt @@ -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() + // 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>() + + 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) { warm - .filter { it.id !in keep && it.id !in parked } + .filter { it.id !in keep && !isParked(it.id) } .forEach { evict(it.id) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedTabLayer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedTabLayer.kt index 38f2571052..990204628e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedTabLayer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedTabLayer.kt @@ -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) { } 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)) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/favorites/EmbeddedNostrAppController.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/favorites/EmbeddedNostrAppController.kt index 6f80fb9b59..f9c3f0d82e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/favorites/EmbeddedNostrAppController.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/favorites/EmbeddedNostrAppController.kt @@ -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 } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/favorites/NostrAppScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/favorites/NostrAppScreen.kt index 79985191cd..5d89aef06d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/favorites/NostrAppScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/favorites/NostrAppScreen.kt @@ -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, diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/BrowserChrome.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/BrowserChrome.kt index a5f8790c71..01e4e1f800 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/BrowserChrome.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/BrowserChrome.kt @@ -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. */ diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletAttendance.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletAttendance.kt new file mode 100644 index 0000000000..fed8ea1a1c --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletAttendance.kt @@ -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 { + private val attended = MutableStateFlow>(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) +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletBridgeDocuments.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletBridgeDocuments.kt index e65622d6c8..6169de6df8 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletBridgeDocuments.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletBridgeDocuments.kt @@ -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

{ @@ -70,6 +79,40 @@ class NappletBridgeDocuments

{ 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

{ private companion object { const val SEPARATOR = ':' + const val SUB_ID = "subId" } } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletHeldRequests.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletHeldRequests.kt new file mode 100644 index 0000000000..76414f08f6 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletHeldRequests.kt @@ -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( + private val clock: () -> Long, + private val cap: Int = MAX_HELD, + private val maxAgeMs: Long = MAX_AGE_MS, +) { + private class Held( + val item: T, + val heldAt: Long, + ) + + private val items = ArrayDeque>() + + 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 { + val now = clock() + val expired = mutableListOf() + 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 { + 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 { + val all = items.map { it.item } + items.clear() + return all + } + + data class Drained( + val send: List, + val fail: List, + ) + + 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." + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletProxyClaims.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletProxyClaims.kt index 55aca57a65..00b82b6756 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletProxyClaims.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletProxyClaims.kt @@ -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/` 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 = 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, ) { 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() + // Each value is the Tor SOCKS port the owner wants, or [NO_PROXY] for the open web. + private val claims = LinkedHashMap() - /** 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) } } diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletAttendanceTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletAttendanceTest.kt new file mode 100644 index 0000000000..b84face9e6 --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletAttendanceTest.kt @@ -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() + + @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)) + } +} diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletBridgeDocumentsTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletBridgeDocumentsTest.kt index 6bdf3d0144..a686c25325 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletBridgeDocumentsTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletBridgeDocumentsTest.kt @@ -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)) + } } diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletHeldRequestsTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletHeldRequestsTest.kt new file mode 100644 index 0000000000..19986e359e --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletHeldRequestsTest.kt @@ -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(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) + } +} diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletProxyClaimsTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletProxyClaimsTest.kt index ffdf897396..1057a07ea2 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletProxyClaimsTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletProxyClaimsTest.kt @@ -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) } } diff --git a/commonsUI/src/commonMain/composeResources/values/strings.xml b/commonsUI/src/commonMain/composeResources/values/strings.xml index 5233867a0e..2e1f05b246 100644 --- a/commonsUI/src/commonMain/composeResources/values/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values/strings.xml @@ -5686,6 +5686,7 @@ Onion routing The site can't see your IP address The site can see your IP address + Off for this site, but another open page uses Tor, so this one goes through Tor too Site settings Nothing allowed yet What it can access diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/BrowserPill.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/BrowserPill.kt index b0b5d88e14..a1d450f9a6 100644 --- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/BrowserPill.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/BrowserPill.kt @@ -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) }) } } diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/PageSheets.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/PageSheets.kt index 8c28a74bc7..07d81d3244 100644 --- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/PageSheets.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/PageSheets.kt @@ -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, ) } diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/BridgeFailures.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/BridgeFailures.kt index 3c14d3f3ad..bfa0b22298 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/BridgeFailures.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/BridgeFailures.kt @@ -46,3 +46,19 @@ fun NappletBridgeDocuments.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()) } +} diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserActivity.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserActivity.kt index 36811a7cad..fef2caf1ec 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserActivity.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserActivity.kt @@ -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>() + private val heldWhileAway = NappletHeldRequests>(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) } } }, ) diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserContract.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserContract.kt index f1da8d50b7..91ceb53bef 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserContract.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserContract.kt @@ -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" diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserService.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserService.kt index bbbc6abafc..5af1fc6a88 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserService.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserService.kt @@ -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>() + // Unattended until the client says otherwise: it sends its state right after every create. + var attended = false + val heldWhileAway = NappletHeldRequests>(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. diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletEmbedContract.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletEmbedContract.kt index 4dd61f99e9..39669e7fbb 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletEmbedContract.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletEmbedContract.kt @@ -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" diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostActivity.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostActivity.kt index ff38788b9d..03cfe11266 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostActivity.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostActivity.kt @@ -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() + private val heldWhilePaused = NappletHeldRequests(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, ), diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostService.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostService.kt index 58eef54d49..c34521c6e5 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostService.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostService.kt @@ -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() + // 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(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) } } /** diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletIpc.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletIpc.kt index 198b03f604..389c7144c5 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletIpc.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletIpc.kt @@ -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" diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/WebViewProxyPolicy.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/WebViewProxyPolicy.kt index d213ed2043..201dc6672f 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/WebViewProxyPolicy.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/WebViewProxyPolicy.kt @@ -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() + + // Surfaces told which route is really in effect (an open-web page can be on Tor because another needs it). + private val routeListeners = LinkedHashMap 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 = 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 { - 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) } } } diff --git a/nappletHost/src/main/res/values/strings.xml b/nappletHost/src/main/res/values/strings.xml index d55e67ec81..1fb80fe404 100644 --- a/nappletHost/src/main/res/values/strings.xml +++ b/nappletHost/src/main/res/values/strings.xml @@ -17,5 +17,8 @@ Failed to load (%1$d): %2$s HTTP %1$d %2$s + + Not loaded: Tor isn\'t available for this page yet. Try again in a moment.