From c06104699c934cd962014aebf597ac4e6612c388 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 04:05:58 +0000 Subject: [PATCH 01/13] fix(browser): bring back embedded tabs that went black after their WebView died Bottom-bar browser/nsite/napplet tabs render a WebView living in the `:napplet` process. All those WebViews share one renderer process, and after long use the OS reclaims it (or it crashes). Every tab then lost its WebView at once and stayed a black rectangle until the app restarted: - the tab had already loaded, so the load overlay (spinner / Retry) never covered it; - the browser's reload was a no-op on a tab with no WebView; - a surface whose remote session errored out (e.g. `:napplet` died) keeps a dead client that never reopens until it gets a NEW adapter, and nobody listened for that error. Now the sandbox flags renderer deaths in its load-state message, the browser's MSG_RELOAD rebuilds a missing WebView on the page it was showing, and both controllers watch their SandboxedSdkView for session errors. A new EmbeddedAutoRecovery gate rebuilds the visible tab right away and a parked one when it is next shown (so a renderer death doesn't rebuild every warm tab at once), with a spinner over the surface meanwhile. A page that kills its renderer again within 30 s of an automatic rebuild falls back to the error overlay's Retry instead of looping. When `:napplet` itself restarts, the reconnect's session re-create is treated as the recovery. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01G3bteStHvaf18TdABSkb8h --- .../browser/EmbeddedWebAppController.kt | 125 +++++++++++++++--- .../loggedIn/embed/EmbeddedAutoRecovery.kt | 93 +++++++++++++ .../favorites/EmbeddedNostrAppController.kt | 124 +++++++++++++++-- .../embed/EmbeddedAutoRecoveryTest.kt | 78 +++++++++++ .../napplethost/NappletBrowserContract.kt | 5 +- .../napplethost/NappletBrowserService.kt | 17 ++- .../napplethost/NappletEmbedContract.kt | 3 + .../napplethost/NappletHostService.kt | 8 +- 8 files changed, 417 insertions(+), 36 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedAutoRecovery.kt create mode 100644 amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedAutoRecoveryTest.kt 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 8df5a2000f..144fe8213d 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 @@ -40,6 +40,7 @@ import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf import androidx.privacysandbox.ui.client.SandboxedUiAdapterFactory import androidx.privacysandbox.ui.client.view.SandboxedSdkView +import androidx.privacysandbox.ui.client.view.SandboxedSdkViewEventListener import androidx.privacysandbox.ui.core.SandboxedUiAdapter import com.vitorpamplona.amethyst.commons.browser.BrowserSitePermission import com.vitorpamplona.amethyst.commons.browser.ui.pill.CertificateInfo @@ -49,6 +50,7 @@ import com.vitorpamplona.amethyst.napplet.NappletWebViewProfiles import com.vitorpamplona.amethyst.napplet.WebFileChooserCoordinator import com.vitorpamplona.amethyst.napplethost.NappletBrowserContract import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.ConsoleBridge +import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedAutoRecovery 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 @@ -97,6 +99,15 @@ class EmbeddedWebAppController( private var hasLoadedReal = false private var blankRecovered = false + // Brings the tab back when its sandbox-side surface dies (see [onSurfaceLost]). + private val recovery = EmbeddedAutoRecovery(SystemClock::elapsedRealtime) + + // The remote session behind the current view errored out: only a brand-new session can repaint it. + private var sessionDead = false + + // Set after the first connection, so a later onServiceConnected is recognised as `:napplet` coming back. + private var everConnected = false + /** Last known main-frame load state, so the tab layer renders the right overlay immediately. */ override var loadStatus: EmbeddedLoadStatus = EmbeddedLoadStatus() private set @@ -150,11 +161,23 @@ class EmbeddedWebAppController( service: IBinder?, ) { serviceMessenger = Messenger(service) + if (everConnected) { + // `:napplet` died and was restarted. The create below IS the recovery (a fresh process + // has no session under any id), so nothing is left pending; cover the surface until the + // new page paints. + recovery.clearPending() + sessionDead = false + showRecovering() + } + everConnected = true sendCreateSession() } override fun onServiceDisconnected(name: ComponentName?) { + // `:napplet` died (the OS reclaimed it, or it crashed). Its WebViews went with it; the + // system restarts the bound service and [onServiceConnected] re-creates the session. serviceMessenger = null + showRecovering() } } @@ -171,6 +194,7 @@ class EmbeddedWebAppController( } // Drop refs so an evicted controller doesn't pin the surface view or the remote messenger. serviceMessenger = null + sandboxedSdkView?.setEventListener(null) sandboxedSdkView = null pendingAdapter = null adapterDelivered = false @@ -186,6 +210,12 @@ class EmbeddedWebAppController( override fun teardown() = unbind() + override fun onShown() { + if (recovery.onShown()) recover() + } + + override fun onHidden() = recovery.onHidden() + /** * Hands the surface view to the controller; applies the adapter if it already arrived, and re-arms the * remote session when this controller is being re-used by a *second* view. @@ -208,6 +238,7 @@ class EmbeddedWebAppController( // Paint the surface placeholder in the app's theme background so there's no white flash before // the remote WebView delivers its first frame. view.setBackgroundColor(backgroundColor) + view.setEventListener(surfaceListener(view)) val adapter = pendingAdapter when { adapter != null -> { @@ -217,20 +248,81 @@ class EmbeddedWebAppController( } // No adapter in hand and one was already spent on a previous (now disposed) view: the session // behind it is gone, so this view would stay blank forever. Re-create it. - adapterDelivered -> { - // Mint a FRESH session id. The disposed view's Session.close() reaches the sandbox - // asynchronously (it posts to the sandbox's main thread) and was measured landing ~1 s - // AFTER this create: reusing the id let that late close reap the session we had just asked - // for — a new WebView was built, destroyed, and the surface stayed black. A new id makes - // the stale close target only the corpse it belongs to. - sessionId = "browser-${SESSION_SEQ.incrementAndGet()}" - adapterDelivered = false - sendCreateSession() - } + adapterDelivered -> rearmSession() // else: the first session is still in flight; MSG_SESSION_READY will arm this view. } } + /** + * Asks the sandbox for a brand-new session; the [NappletBrowserContract.MSG_SESSION_READY] reply arms + * the current view with its adapter. + * + * Mints a FRESH session id. A disposed view's Session.close() reaches the sandbox asynchronously (it + * posts to the sandbox's main thread) and was measured landing ~1 s AFTER the create: reusing the id let + * that late close reap the session we had just asked for — a new WebView was built, destroyed, and the + * surface stayed black. A new id makes the stale close target only the corpse it belongs to. + */ + private fun rearmSession() { + sessionId = "browser-${SESSION_SEQ.incrementAndGet()}" + adapterDelivered = false + sessionDead = false + sendCreateSession() + } + + /** + * Watches [view]'s remote session. A session that errors out (its provider failed, or `:napplet` died) + * leaves the view holding a dead client that never reopens: it paints nothing, forever, until it is + * handed a NEW adapter. + */ + private fun surfaceListener(view: SandboxedSdkView) = + object : SandboxedSdkViewEventListener { + override fun onUiDisplayed() { + // Nothing to do: the load state reports when the page itself paints. + } + + 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) + } + + override fun onUiClosed() { + // Nothing to do: closes are ours (a view disposed, or an adapter replaced on purpose). + } + } + + /** + * The tab's page is gone: its WebView's renderer died ([sessionDead] false — the session lives on, and a + * MSG_RELOAD rebuilds the WebView inside it), or the whole remote session errored out ([sessionDead] + * true — only a new session can repaint the view). Either way the surface is a black rectangle that would + * stay that way, so rebuild it, as [EmbeddedAutoRecovery] allows. + */ + private fun onSurfaceLost(sessionDead: Boolean) { + if (sessionDead) this.sessionDead = true + hasLoadedReal = false + // `:napplet` itself is down: its restart re-creates the session (see [onServiceConnected]). + if (serviceMessenger?.binder?.isBinderAlive != true) { + showRecovering() + return + } + when (recovery.onLost()) { + EmbeddedAutoRecovery.Decision.RECOVER_NOW -> recover() + EmbeddedAutoRecovery.Decision.DEFERRED -> showRecovering() + EmbeddedAutoRecovery.Decision.GIVE_UP -> publishLoadStatus(EmbeddedLoadStatus(failed = true)) + } + } + + private fun recover() { + showRecovering() + if (sessionDead) rearmSession() else reload() + } + + /** Covers the surface with the loading spinner until the rebuilt page paints. */ + private fun showRecovering() { + hasLoadedReal = false + blankRecovered = false + publishLoadStatus(EmbeddedLoadStatus(isLoading = true)) + } + private fun sendCreateSession() { val msg = Message.obtain(null, NappletBrowserContract.MSG_CREATE_SESSION).apply { @@ -279,7 +371,11 @@ class EmbeddedWebAppController( val isLoading = msg.data?.getBoolean(NappletBrowserContract.KEY_IS_LOADING, false) ?: false val failed = msg.data?.getBoolean(NappletBrowserContract.KEY_LOAD_FAILED, false) ?: false val loadedUrl = msg.data?.getString(NappletBrowserContract.KEY_URL).orEmpty() - onLoadState(isLoading, failed, loadedUrl) + if (msg.data?.getBoolean(NappletBrowserContract.KEY_RENDERER_GONE, false) == true) { + onSurfaceLost(sessionDead = false) + } else { + onLoadState(isLoading, failed, loadedUrl) + } } NappletBrowserContract.MSG_CONSOLE_LOG -> { val level = msg.data?.getString(NappletBrowserContract.KEY_CONSOLE_LEVEL) ?: "LOG" @@ -396,10 +492,9 @@ class EmbeddedWebAppController( * session that never got its URL), this re-navigates to the favorite's real URL. */ override fun retry() { - blankRecovered = false - hasLoadedReal = false - publishLoadStatus(EmbeddedLoadStatus(isLoading = true)) - navigate(startUrl) + recovery.clearPending() + showRecovering() + if (sessionDead) rearmSession() else navigate(startUrl) } private fun onLoadState( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedAutoRecovery.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedAutoRecovery.kt new file mode 100644 index 0000000000..c56b05b9b7 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedAutoRecovery.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.ui.screen.loggedIn.embed + +/** + * Decides when an embedded tab rebuilds itself after its sandbox-side surface died underneath it. + * + * Every WebView in `:napplet` shares one renderer process. When the OS reclaims it (memory pressure after + * a long session, the app sitting in the background) or it crashes, EVERY warm tab loses its WebView at + * once, and a surface whose remote session errored out paints nothing — both leave a black rectangle that + * never comes back on its own. The tab was already loaded, so no load overlay covers it either. + * + * Recovery is automatic but lazy: the visible tab rebuilds right away, a parked one only when it is next + * shown, so a renderer death doesn't rebuild every warm tab at once and push memory straight back up. A + * page that kills its renderer again right after an automatic rebuild is not rebuilt in a loop — the tab + * falls back to the error overlay and its Retry. + * + * Main-thread only. [now] is injectable for tests. + */ +class EmbeddedAutoRecovery( + private val now: () -> Long, +) { + enum class Decision { + /** Rebuild now: the tab is on screen. */ + RECOVER_NOW, + + /** The tab is parked; [onShown] will ask for the rebuild. */ + DEFERRED, + + /** It died again right after an automatic rebuild: stop and let the user retry. */ + GIVE_UP, + } + + private var shown = false + private var pending = false + private var lastAutoRecoveryAt: Long? = null + + /** The tab's surface died. */ + fun onLost(): Decision { + if (!shown) { + pending = true + return Decision.DEFERRED + } + val last = lastAutoRecoveryAt + if (last != null && now() - last < LOOP_WINDOW_MS) { + pending = false + return Decision.GIVE_UP + } + lastAutoRecoveryAt = now() + return Decision.RECOVER_NOW + } + + /** The tab became visible. Returns true when a deferred rebuild must run now. */ + fun onShown(): Boolean { + shown = true + if (!pending) return false + pending = false + lastAutoRecoveryAt = now() + return true + } + + fun onHidden() { + shown = false + } + + /** The surface came back by other means (a fresh session, a user retry): nothing left to rebuild. */ + fun clearPending() { + pending = false + } + + companion object { + /** A second death this soon after an automatic rebuild means the page itself is killing it. */ + const val LOOP_WINDOW_MS = 30_000L + } +} 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 79a7ed1dd5..274be17d31 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 @@ -40,6 +40,7 @@ import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf import androidx.privacysandbox.ui.client.SandboxedUiAdapterFactory import androidx.privacysandbox.ui.client.view.SandboxedSdkView +import androidx.privacysandbox.ui.client.view.SandboxedSdkViewEventListener import androidx.privacysandbox.ui.core.SandboxedUiAdapter import com.vitorpamplona.amethyst.commons.browser.ui.pill.ConsoleLine import com.vitorpamplona.amethyst.napplet.NappletWebViewProfiles @@ -47,6 +48,7 @@ import com.vitorpamplona.amethyst.napplet.WebFileChooserCoordinator import com.vitorpamplona.amethyst.napplethost.NappletEmbedContract import com.vitorpamplona.amethyst.napplethost.NappletHostContract import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.ConsoleBridge +import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedAutoRecovery 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 @@ -111,6 +113,15 @@ class EmbeddedNostrAppController( private var hasLoadedReal = false + // Brings the tab back when its sandbox-side surface dies (see [onSurfaceLost]). + private val recovery = EmbeddedAutoRecovery(SystemClock::elapsedRealtime) + + // The remote session behind the current view errored out: only a brand-new session can repaint it. + private var sessionDead = false + + // Set after the first connection, so a later onServiceConnected is recognised as `:napplet` coming back. + private var everConnected = false + /** Last known main-frame load state, so the tab layer renders the right overlay immediately. */ override var loadStatus: EmbeddedLoadStatus = EmbeddedLoadStatus() private set @@ -137,11 +148,23 @@ class EmbeddedNostrAppController( service: IBinder?, ) { serviceMessenger = Messenger(service) + if (everConnected) { + // `:napplet` died and was restarted. The create below IS the recovery (a fresh process + // has no session under any id), so nothing is left pending; cover the surface until the + // new page paints. + recovery.clearPending() + sessionDead = false + showRecovering() + } + everConnected = true sendCreateSession() } override fun onServiceDisconnected(name: ComponentName?) { + // `:napplet` died (the OS reclaimed it, or it crashed). Its WebViews went with it; the + // system restarts the bound service and [onServiceConnected] re-creates the session. serviceMessenger = null + showRecovering() } } @@ -157,6 +180,7 @@ class EmbeddedNostrAppController( } // Drop refs so an evicted controller doesn't pin the surface view or the remote messenger. serviceMessenger = null + sandboxedSdkView?.setEventListener(null) sandboxedSdkView = null pendingAdapter = null adapterDelivered = false @@ -187,6 +211,7 @@ class EmbeddedNostrAppController( // Paint the surface placeholder in the app's theme background so there's no white flash before // the remote WebView delivers its first frame. view.setBackgroundColor(params.getInt(NappletHostContract.EXTRA_BG_COLOR, android.graphics.Color.WHITE)) + view.setEventListener(surfaceListener(view)) val adapter = pendingAdapter when { adapter != null -> { @@ -196,21 +221,88 @@ class EmbeddedNostrAppController( } // No adapter in hand and one was already spent on a previous (now disposed) view: the session // behind it is gone, so this view would stay blank forever. Re-create it. - adapterDelivered -> { - // Mint a FRESH session id: the disposed view's Session.close() reaches the sandbox - // asynchronously and can land AFTER this create. Reusing the id would let that late close - // reap the session we just asked for, leaving the surface black. - sessionId = "napplet-${SESSION_SEQ.incrementAndGet()}" - adapterDelivered = false - sendCreateSession() - } + adapterDelivered -> rearmSession() // else: the first session is still in flight; MSG_SESSION_READY will arm this view. } } - override fun onShown() = resume() + /** + * Asks the sandbox for a brand-new session; the [NappletEmbedContract.MSG_SESSION_READY] reply arms the + * current view with its adapter. + * + * Mints a FRESH session id: a disposed view's Session.close() reaches the sandbox asynchronously and can + * land AFTER the create. Reusing the id would let that late close reap the session we just asked for, + * leaving the surface black. + */ + private fun rearmSession() { + sessionId = "napplet-${SESSION_SEQ.incrementAndGet()}" + adapterDelivered = false + sessionDead = false + sendCreateSession() + } - override fun onHidden() = pause() + /** + * Watches [view]'s remote session. A session that errors out (its provider failed, or `:napplet` died) + * leaves the view holding a dead client that never reopens: it paints nothing, forever, until it is + * handed a NEW adapter. + */ + private fun surfaceListener(view: SandboxedSdkView) = + object : SandboxedSdkViewEventListener { + override fun onUiDisplayed() { + // Nothing to do: the load state reports when the page itself paints. + } + + 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) + } + + override fun onUiClosed() { + // Nothing to do: closes are ours (a view disposed, or an adapter replaced on purpose). + } + } + + /** + * The tab's page is gone: its WebView's renderer died ([sessionDead] false — the session lives on, and a + * MSG_RELOAD rebuilds the WebView inside it), or the whole remote session errored out ([sessionDead] + * true — only a new session can repaint the view). Either way the surface is a black rectangle that would + * stay that way, so rebuild it, as [EmbeddedAutoRecovery] allows. + */ + private fun onSurfaceLost(sessionDead: Boolean) { + if (sessionDead) this.sessionDead = true + hasLoadedReal = false + // `:napplet` itself is down: its restart re-creates the session (see [onServiceConnected]). + if (serviceMessenger?.binder?.isBinderAlive != true) { + showRecovering() + return + } + when (recovery.onLost()) { + EmbeddedAutoRecovery.Decision.RECOVER_NOW -> recover() + EmbeddedAutoRecovery.Decision.DEFERRED -> showRecovering() + EmbeddedAutoRecovery.Decision.GIVE_UP -> publishLoadStatus(EmbeddedLoadStatus(failed = true)) + } + } + + private fun recover() { + showRecovering() + if (sessionDead) rearmSession() else reload() + } + + /** Covers the surface with the loading spinner until the rebuilt page paints. */ + private fun showRecovering() { + hasLoadedReal = false + publishLoadStatus(EmbeddedLoadStatus(isLoading = true)) + } + + override fun onShown() { + resume() + if (recovery.onShown()) recover() + } + + override fun onHidden() { + pause() + recovery.onHidden() + } override fun teardown() = unbind() @@ -263,7 +355,11 @@ class EmbeddedNostrAppController( NappletEmbedContract.MSG_LOAD_STATE -> { val isLoading = msg.data?.getBoolean(NappletEmbedContract.KEY_IS_LOADING, false) ?: false val failed = msg.data?.getBoolean(NappletEmbedContract.KEY_LOAD_FAILED, false) ?: false - onLoadState(isLoading, failed) + if (msg.data?.getBoolean(NappletEmbedContract.KEY_RENDERER_GONE, false) == true) { + onSurfaceLost(sessionDead = false) + } else { + onLoadState(isLoading, failed) + } } NappletEmbedContract.MSG_FILE_CHOOSER_REQUEST -> { val data = msg.data ?: return true @@ -350,9 +446,9 @@ class EmbeddedNostrAppController( /** User-triggered recovery for a stuck or failed session: reload the verified content from scratch. */ override fun retry() { - hasLoadedReal = false - publishLoadStatus(EmbeddedLoadStatus(isLoading = true)) - reload() + recovery.clearPending() + showRecovering() + if (sessionDead) rearmSession() else reload() } private fun onLoadState( diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedAutoRecoveryTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedAutoRecoveryTest.kt new file mode 100644 index 0000000000..8f1fdf1d9a --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedAutoRecoveryTest.kt @@ -0,0 +1,78 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.embed + +import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedAutoRecovery.Decision +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class EmbeddedAutoRecoveryTest { + private var clock = 1_000L + private val recovery = EmbeddedAutoRecovery { clock } + + @Test + fun visibleTabRebuildsRightAway() { + recovery.onShown() + assertEquals(Decision.RECOVER_NOW, recovery.onLost()) + } + + @Test + fun parkedTabWaitsUntilShown() { + assertEquals(Decision.DEFERRED, recovery.onLost()) + assertTrue(recovery.onShown()) + // Only once: coming back again doesn't rebuild a live tab. + recovery.onHidden() + assertFalse(recovery.onShown()) + } + + @Test + fun parkedTabRecoveredByOtherMeansDoesNotRebuild() { + assertEquals(Decision.DEFERRED, recovery.onLost()) + recovery.clearPending() + assertFalse(recovery.onShown()) + } + + @Test + fun secondDeathRightAfterAnAutoRebuildGivesUp() { + recovery.onShown() + assertEquals(Decision.RECOVER_NOW, recovery.onLost()) + clock += 5_000 + assertEquals(Decision.GIVE_UP, recovery.onLost()) + } + + @Test + fun deathAfterADeferredRebuildAlsoGivesUp() { + assertEquals(Decision.DEFERRED, recovery.onLost()) + assertTrue(recovery.onShown()) + clock += 1_000 + assertEquals(Decision.GIVE_UP, recovery.onLost()) + } + + @Test + fun laterDeathsRecoverAgain() { + recovery.onShown() + assertEquals(Decision.RECOVER_NOW, recovery.onLost()) + clock += EmbeddedAutoRecovery.LOOP_WINDOW_MS + assertEquals(Decision.RECOVER_NOW, recovery.onLost()) + } +} 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 f814830628..ccf8c634da 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserContract.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserContract.kt @@ -70,7 +70,9 @@ object NappletBrowserContract { * Provider → client: the main-frame load state changed. Carries [KEY_IS_LOADING] (a navigation is in * flight), [KEY_LOAD_FAILED] (the main frame errored), and [KEY_URL] (the page it settled on). Lets * the main process draw a loading spinner / error overlay over the embedded surface, and recover a - * favorite whose session came up on a blank page (re-navigate to its real URL). + * favorite whose session came up on a blank page (re-navigate to its real URL). [KEY_RENDERER_GONE] + * marks a failure caused by the WebView renderer dying: the tab has no WebView left, so the client + * must [MSG_RELOAD] (which rebuilds it on the page it was showing). */ const val MSG_LOAD_STATE = 10 @@ -232,6 +234,7 @@ object NappletBrowserContract { const val KEY_IS_LOADING = "isLoading" const val KEY_LOAD_FAILED = "loadFailed" + const val KEY_RENDERER_GONE = "rendererGone" const val KEY_CONSOLE_LEVEL = "consoleLevel" const val KEY_CONSOLE_MESSAGE = "consoleMessage" 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 e58e8a861e..b50bbc6005 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserService.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserService.kt @@ -111,6 +111,9 @@ class NappletBrowserService : Service() { ) { var webView: WebView? = null + // The page the renderer was showing when it died, so the rebuild lands back where the user was. + var recoverUrl: String? = null + // The session's root view (holds the WebView, and the page's fullscreen view when it has one). var container: FrameLayout? = null var customView: View? = null @@ -296,7 +299,11 @@ class NappletBrowserService : Service() { ) } NappletBrowserContract.MSG_EXIT_FULLSCREEN -> tabFor(msg)?.let { exitFullscreen(it) } - NappletBrowserContract.MSG_RELOAD -> tabFor(msg)?.webView?.reload() + NappletBrowserContract.MSG_RELOAD -> { + val tab = tabFor(msg) ?: return true + // 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() + } NappletBrowserContract.MSG_BACK -> tabFor(msg)?.webView?.let { if (it.canGoBack()) it.goBack() } NappletBrowserContract.MSG_IME_OP -> { val tab = tabFor(msg) ?: return true @@ -841,19 +848,22 @@ class NappletBrowserService : Service() { /** * The renderer died. It is shared by every WebView in `:napplet`, and an unhandled crash kills the - * whole process — every other tab included. Drop just this tab's WebView and report the load as - * failed; the tab's retry (MSG_NAVIGATE) builds a fresh WebView in the same surface. + * whole process — every other tab included. Drop just this tab's WebView and report it gone + * ([NappletBrowserContract.KEY_RENDERER_GONE]); the client's MSG_RELOAD (or a MSG_NAVIGATE) builds + * a fresh WebView in the same surface. */ override fun onRenderProcessGone( view: WebView, detail: RenderProcessGoneDetail, ): Boolean { Log.w(TAG) { "Renderer gone (crashed=${detail.didCrash()}) for an embedded tab" } + val lastUrl = view.url (view.parent as? ViewGroup)?.removeView(view) view.destroy() val tab = tab ?: return true if (tab.webView === view) { tab.webView = null + tab.recoverUrl = lastUrl?.takeIf { it.isNotBlank() && it != ABOUT_BLANK } ?: tab.recoverUrl tab.customView?.let { tab.container?.removeView(it) } tab.customView = null tab.customViewCallback = null @@ -862,6 +872,7 @@ class NappletBrowserService : Service() { sendToClient(tab, NappletBrowserContract.MSG_LOAD_STATE) { putBoolean(NappletBrowserContract.KEY_IS_LOADING, false) putBoolean(NappletBrowserContract.KEY_LOAD_FAILED, true) + putBoolean(NappletBrowserContract.KEY_RENDERER_GONE, true) putString(NappletBrowserContract.KEY_URL, tab.url) } } 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 38df1171f7..1dce20bda9 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletEmbedContract.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletEmbedContract.kt @@ -81,6 +81,8 @@ object NappletEmbedContract { * Provider → client: the main-frame load state changed. Carries [KEY_IS_LOADING] (a load is in * flight) and [KEY_LOAD_FAILED] (the main frame errored). Lets the main process draw a loading * spinner / error+retry overlay over the embedded surface instead of a bare black/white void. + * [KEY_RENDERER_GONE] marks a failure caused by the WebView renderer dying: the tab has no WebView + * left, so the client must [MSG_RELOAD] (which rebuilds it) — the page on screen is gone, not failed. */ const val MSG_LOAD_STATE = 15 @@ -171,6 +173,7 @@ object NappletEmbedContract { const val KEY_CAN_GO_BACK = "canGoBack" const val KEY_IS_LOADING = "isLoading" const val KEY_LOAD_FAILED = "loadFailed" + const val KEY_RENDERER_GONE = "rendererGone" const val KEY_NOTICE = "notice" const val KEY_IME_PAYLOAD = "imePayload" 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 998117aad8..45b615be8d 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostService.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostService.kt @@ -588,8 +588,8 @@ class NappletHostService : Service() { /** * The renderer died. It is shared by every WebView in `:napplet`, and an unhandled crash kills the - * whole process — every other tab included. Drop just this tab's WebView and report the load as - * failed; the tab's retry (MSG_RELOAD) rebuilds it in the same surface. + * whole process — every other tab included. Drop just this tab's WebView and report it gone + * ([NappletEmbedContract.KEY_RENDERER_GONE]); the client's MSG_RELOAD rebuilds it in the same surface. */ override fun onRenderProcessGone( view: WebView, @@ -602,7 +602,7 @@ class NappletHostService : Service() { tab.webView = null tab.bridgeReplyProxy = null tab.loadFailed = true - pushLoadState(tab, isLoading = false) + pushLoadState(tab, isLoading = false, rendererGone = true) } return true } @@ -671,6 +671,7 @@ class NappletHostService : Service() { private fun pushLoadState( tab: NappletTab, isLoading: Boolean, + rendererGone: Boolean = false, ) { val message = Message.obtain(null, NappletEmbedContract.MSG_LOAD_STATE).apply { @@ -678,6 +679,7 @@ class NappletHostService : Service() { Bundle().apply { putBoolean(NappletEmbedContract.KEY_IS_LOADING, isLoading) putBoolean(NappletEmbedContract.KEY_LOAD_FAILED, tab.loadFailed) + putBoolean(NappletEmbedContract.KEY_RENDERER_GONE, rendererGone) } } runCatching { tab.clientMessenger?.send(message) } From 12b22c0721578bfcd497c9141940dd87fb0c380c Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 06:33:45 +0000 Subject: [PATCH 02/13] fix(napplet): keep NIP-07 replies and relay subscriptions with the page that asked Browser tabs kept one bridge reply proxy that every main-frame message overwrote. After a.com navigated to b.com, a pending NIP-07 answer for a.com (a signature, a nip04/nip44 decryption) was posted into b.com, where it even resolved b.com's own `r0`; a.com's live relay events kept streaming into b.com too. - NappletBridgeDocuments (commons) numbers each document a surface hosts, stamps it on request ids sent to the broker, and only resolves a reply for the document still on screen. Used by NappletBrowserService and NappletBrowserActivity. - On a new document, a renderer death, or a closed session the host releases the old page: queued requests and in-flight mints are dropped and the broker is told to close what the page had open. - The broker keyed live relay subscriptions by the page's own subId only, so two surfaces both using `s0` replaced or closed each other's feeds. They are now keyed by (client Messenger, subId), and MSG_RELEASE_CLIENT closes a client's remaining subscriptions. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01G3bteStHvaf18TdABSkb8h --- .../amethyst/napplet/NappletBrokerService.kt | 11 ++- .../napplet/NappletLiveSubscriptions.kt | 58 +++++++---- .../commons/napplet/NappletBridgeDocuments.kt | 82 ++++++++++++++++ .../napplet/NappletBridgeDocumentsTest.kt | 97 +++++++++++++++++++ .../napplethost/NappletBrowserActivity.kt | 42 ++++++-- .../napplethost/NappletBrowserService.kt | 47 +++++++-- 6 files changed, 298 insertions(+), 39 deletions(-) create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletBridgeDocuments.kt create mode 100644 commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletBridgeDocumentsTest.kt 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 d5b822e42c..55c0778244 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletBrokerService.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletBrokerService.kt @@ -93,7 +93,7 @@ class NappletBrokerService : Service() { private val incoming by lazy { Messenger(Handler(Looper.getMainLooper(), ::handleMessage)) } - // Live relay subscriptions, keyed by the applet's subId. The account comes per-open from the + // 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) @@ -158,7 +158,10 @@ class NappletBrokerService : Service() { // client's Messenger keeps a binder alive, which pins that surface's whole Activity (and its // WebView) in the `:napplet` process past onDestroy — reclaimable only by killing the process. if (msg.what == NappletIpc.MSG_RELEASE_CLIENT) { - msg.replyTo?.let { incBus.removeAll(it) } + msg.replyTo?.let { + incBus.removeAll(it) + liveSubscriptions.closeAllFor(it) + } // Release its foreground lease too; otherwise a destroyed surface keeps the main process // pinned resumed until the lease watchdog expires it. msg.data?.getString(NappletIpc.KEY_LAUNCH_TOKEN)?.let { token -> @@ -401,8 +404,8 @@ class NappletBrokerService : Service() { } } is NappletRequestRouter.Outcome.OpenSubscription -> - liveSubscriptions.open(outcome.subId, outcome.filters, accountFor(session.accountPubKey)) { push(replyTo, it) } - is NappletRequestRouter.Outcome.CloseSubscription -> liveSubscriptions.close(outcome.subId) + liveSubscriptions.open(replyTo, outcome.subId, outcome.filters, accountFor(session.accountPubKey)) { push(replyTo, it) } + is NappletRequestRouter.Outcome.CloseSubscription -> liveSubscriptions.close(replyTo, outcome.subId) is NappletRequestRouter.Outcome.Push -> outcome.payloads.forEach { push(replyTo, it) } is NappletRequestRouter.Outcome.SubscribeInc -> incBus.subscribe(replyTo, outcome.topic) is NappletRequestRouter.Outcome.UnsubscribeInc -> incBus.unsubscribe(replyTo, outcome.topic) 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 089e83d431..5102996b80 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletLiveSubscriptions.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletLiveSubscriptions.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.napplet +import android.os.Messenger import com.vitorpamplona.amethyst.commons.model.Account import com.vitorpamplona.amethyst.commons.napplet.NappletRelayCleartext import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletProtocolJson @@ -37,7 +38,10 @@ import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicInteger /** - * The registry of live relay subscriptions an applet has open, keyed by its `subId`. Each entry + * The registry of live relay subscriptions applets have open, keyed by the requesting surface's reply + * [Messenger] plus the applet's own `subId`. One broker serves every surface, and each page numbers its + * subs from scratch (`s0`, `s1`, …), so the `subId` alone would let one tab's REQ replace — or its + * `relay.close` kill — another tab's feed. Each entry * holds the exact [INostrClient] that opened it, so teardown unsubscribes from the right account * even after an account switch, plus an EOSE latch so a multi-relay subscription emits a single * `relay.eose`. Encodes the `relay.event`/`relay.eose`/`relay.closed` pushes and hands them to the @@ -52,7 +56,12 @@ import java.util.concurrent.atomic.AtomicInteger class NappletLiveSubscriptions( private val scope: CoroutineScope, ) { - private val liveSubs = ConcurrentHashMap() + private data class Key( + val owner: Messenger, + val subId: String, + ) + + private val liveSubs = ConcurrentHashMap() private val liveSeq = AtomicInteger(0) private class LiveSub( @@ -77,11 +86,12 @@ class NappletLiveSubscriptions( } /** - * Opens a live relay subscription for [nappletSubId], streaming `relay.event`/`relay.eose`/ - * `relay.closed` envelopes to [push] as events arrive. Replaces any existing subscription for - * the same id. With no account/relays/filters it pushes a single empty EOSE to close it. + * Opens [owner]'s live relay subscription [nappletSubId], streaming `relay.event`/`relay.eose`/ + * `relay.closed` envelopes to [push] as events arrive. Replaces any existing subscription [owner] has + * under the same id. With no account/relays/filters it pushes a single empty EOSE to close it. */ fun open( + owner: Messenger, nappletSubId: String, filters: List, account: Account?, @@ -93,15 +103,16 @@ class NappletLiveSubscriptions( return } - close(nappletSubId) + val key = Key(owner, nappletSubId) + close(owner, nappletSubId) // liveSeq guarantees a unique client subId, so a rapid re-open of the same applet subId // can't collide with the subscription it's replacing. val sub = LiveSub("napplet-$nappletSubId-${liveSeq.incrementAndGet()}", account.client) - liveSubs[nappletSubId] = sub + liveSubs[key] = sub sub.deliveryJob = scope.launch { for (delivery in sub.deliveries) { - if (liveSubs[nappletSubId] !== sub) break + if (liveSubs[key] !== sub) break when (delivery) { is Delivery.RelayEvent -> NappletRelayCleartext.forDelivery(delivery.event, account.signer)?.let { @@ -145,21 +156,30 @@ class NappletLiveSubscriptions( runCatching { sub.client.subscribe(sub.clientSubId, relays.associateWith { filters }, listener) } } - /** Stops the live subscription for [nappletSubId], unsubscribing from the client that opened it. */ - fun close(nappletSubId: String) { - val sub = liveSubs.remove(nappletSubId) ?: return - sub.deliveries.close() - sub.deliveryJob?.cancel() - runCatching { sub.client.unsubscribe(sub.clientSubId) } + /** Stops [owner]'s live subscription [nappletSubId], unsubscribing from the client that opened it. */ + fun close( + owner: Messenger, + nappletSubId: String, + ) { + liveSubs.remove(Key(owner, nappletSubId))?.let(::stop) + } + + /** Stops every subscription [owner] still has open (its surface went away without closing them). */ + fun closeAllFor(owner: Messenger) { + liveSubs.keys + .filter { it.owner == owner } + .forEach { key -> liveSubs.remove(key)?.let(::stop) } } /** Tears down every open subscription (service teardown). */ fun closeAll() { - liveSubs.values.forEach { sub -> - sub.deliveries.close() - sub.deliveryJob?.cancel() - runCatching { sub.client.unsubscribe(sub.clientSubId) } - } + liveSubs.values.forEach(::stop) liveSubs.clear() } + + private fun stop(sub: LiveSub) { + sub.deliveries.close() + sub.deliveryJob?.cancel() + runCatching { sub.client.unsubscribe(sub.clientSubId) } + } } 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 new file mode 100644 index 0000000000..e65622d6c8 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletBridgeDocuments.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 + +/** + * Keeps a browser surface's NIP-07 traffic with the document that started it. + * + * A browser tab has one broker channel but hosts a sequence of documents: every navigation brings a new + * page, with a new bridge reply proxy [P], and each page numbers its requests from scratch (`r0`, `r1`, …). + * Delivering broker replies to "whichever page posted last" would hand a.com's signature or decrypted + * message to b.com once the user navigates while a consent sheet is up (b.com's own `r0` would even + * resolve with it). + * + * So every page gets a document sequence number. [brokerIdFor] stamps it on the page's request id before + * the request leaves for the broker, and [resolve] only turns a reply back into the page's id — and hands + * back the proxy to post it on — when it belongs to the document on screen now. Replies for a replaced + * 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. + * + * Single-threaded: call from the WebView's (main) thread. + */ +class NappletBridgeDocuments

{ + private var current: P? = null + private var document = 0L + + /** The bridge reply proxy of the document on screen, or null before the first message. */ + val currentProxy: P? get() = current + + /** + * Records that a main-frame bridge message arrived through [proxy]. Returns true when it came from a + * NEW document replacing an earlier one — the caller then drops the old page's broker state. + */ + fun onMessage(proxy: P): Boolean { + if (current === proxy) return false + val replaced = current != null + current = proxy + document++ + return replaced + } + + /** The id to send to the broker for the current document's request [pageId]. */ + fun brokerIdFor(pageId: String): String = "$document$SEPARATOR$pageId" + + /** + * Resolves a broker reply's [brokerId] into the page's own id and the proxy to post it on, or null when + * the reply belongs to a document that is gone (or was never stamped by [brokerIdFor]). + */ + fun resolve(brokerId: String): Pair? { + val proxy = current ?: return null + val cut = brokerId.indexOf(SEPARATOR) + if (cut <= 0 || brokerId.substring(0, cut).toLongOrNull() != document) return null + return brokerId.substring(cut + 1) to proxy + } + + /** The surface went away (session closed, renderer died): nothing on screen can receive a reply. */ + fun clear() { + current = null + document++ + } + + private companion object { + const val SEPARATOR = ':' + } +} 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 new file mode 100644 index 0000000000..6bdf3d0144 --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletBridgeDocumentsTest.kt @@ -0,0 +1,97 @@ +/* + * 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.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertSame +import kotlin.test.assertTrue + +class NappletBridgeDocumentsTest { + private class Proxy( + val name: String, + ) + + private val docs = NappletBridgeDocuments() + private val a = Proxy("a.com") + private val b = Proxy("b.com") + + @Test + fun replyReachesTheDocumentThatAsked() { + assertFalse(docs.onMessage(a)) + val id = docs.brokerIdFor("r0") + val (pageId, proxy) = docs.resolve(id)!! + assertEquals("r0", pageId) + assertSame(a, proxy) + } + + @Test + fun sameDocumentKeepsItsRequests() { + docs.onMessage(a) + val id = docs.brokerIdFor("r0") + assertFalse(docs.onMessage(a)) + assertSame(a, docs.resolve(id)!!.second) + } + + @Test + fun replyForANavigatedAwayDocumentIsDropped() { + docs.onMessage(a) + val aRequest = docs.brokerIdFor("r0") + assertTrue(docs.onMessage(b)) + // b.com numbers its own requests from r0 too; a.com's late reply must not resolve it. + val bRequest = docs.brokerIdFor("r0") + assertNull(docs.resolve(aRequest)) + assertSame(b, docs.resolve(bRequest)!!.second) + } + + @Test + fun returningToAnEarlierProxyIsStillANewDocument() { + docs.onMessage(a) + val first = docs.brokerIdFor("r0") + docs.onMessage(b) + docs.onMessage(a) + assertNull(docs.resolve(first)) + } + + @Test + fun pageIdsMayContainTheSeparator() { + docs.onMessage(a) + assertEquals("fire:7", docs.resolve(docs.brokerIdFor("fire:7"))!!.first) + } + + @Test + fun clearedSurfaceResolvesNothing() { + docs.onMessage(a) + val id = docs.brokerIdFor("r0") + docs.clear() + assertNull(docs.resolve(id)) + assertNull(docs.currentProxy) + } + + @Test + fun unstampedIdsAreRejected() { + docs.onMessage(a) + assertNull(docs.resolve("r0")) + assertNull(docs.resolve(":r0")) + } +} 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 4525300d1b..be0a27296c 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserActivity.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserActivity.kt @@ -89,6 +89,7 @@ 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.PageDialogType +import com.vitorpamplona.amethyst.commons.napplet.NappletBridgeDocuments import com.vitorpamplona.amethyst.commons.napplet.NappletWebContract import com.vitorpamplona.amethyst.commons.util.parseJsonObjectOrNull import com.vitorpamplona.amethyst.commons.util.stringOrNull @@ -220,7 +221,10 @@ class NappletBrowserActivity : ComponentActivity() { } private val pendingBrokerRequests = mutableListOf() - private var bridgeReplyProxy: JavaScriptReplyProxy? = null + + // The page on screen's bridge reply proxy, and which document each broker reply belongs to: a reply + // for a page the user has navigated away from must never land in the next one. + private val bridge = NappletBridgeDocuments() private var fireSeq = 0 private val originTokens = mutableMapOf() private val pendingByOrigin = mutableMapOf>() @@ -749,6 +753,9 @@ class NappletBrowserActivity : ComponentActivity() { Log.w(TAG) { "Renderer gone (crashed=${detail.didCrash()}); offering a reload of $lastUrl" } exitFullscreen() destroyWebView() + // The page died with its renderer: nothing is left to receive its replies or pushes. + releasePage() + bridge.clear() showCrashView(lastUrl) return true } @@ -888,7 +895,8 @@ class NappletBrowserActivity : ComponentActivity() { replyProxy: JavaScriptReplyProxy, ) { if (!isMainFrame) return - bridgeReplyProxy = replyProxy + // A new document replaced the page: whatever the old one had open with the broker is dead. + if (bridge.onMessage(replyProxy)) releasePage() val raw = message.data ?: return val envelope = parseJsonObjectOrNull(raw) ?: return @@ -902,7 +910,8 @@ class NappletBrowserActivity : ComponentActivity() { val host = sourceOrigin.host ?: return val origin = "$scheme://$host" + if (sourceOrigin.port > 0) ":${sourceOrigin.port}" else "" - val id = envelope.stringOrNull("id").orEmpty().ifEmpty { "fire-${fireSeq++}" } + val pageId = envelope.stringOrNull("id").orEmpty().ifEmpty { "fire-${fireSeq++}" } + val id = bridge.brokerIdFor(pageId) val msg = Message.obtain(null, NappletIpc.MSG_REQUEST).apply { replyTo = replyMessenger @@ -954,6 +963,22 @@ class NappletBrowserActivity : ComponentActivity() { queueToBroker(msg) } + /** + * The page is gone (navigated away, or its renderer died): drop its requests still waiting for a token + * or the broker, and have the broker close the live relay / inc subscriptions it opened — otherwise + * their events would keep streaming into whatever page comes next. Unlike [releaseFromBroker] this keeps + * the surface's foreground lease: the activity itself is still up. + */ + private fun releasePage() { + pendingByOrigin.clear() + // A mint the broker never answered (none is sent while logged out) would otherwise block the + // origin for good; the next page asks again. + mintInFlight.clear() + pendingBrokerRequests.removeAll { it.what == NappletIpc.MSG_REQUEST } + val broker = brokerMessenger ?: return + runCatching { broker.send(Message.obtain(null, NappletIpc.MSG_RELEASE_CLIENT).apply { replyTo = replyMessenger }) } + } + /** Sends now when the broker is bound, else queues until it is. */ private fun queueToBroker(msg: Message) { if (brokerMessenger != null) sendToBroker(msg) else pendingBrokerRequests.add(msg) @@ -971,14 +996,17 @@ class NappletBrowserActivity : ComponentActivity() { val data = msg.data ?: return true when (msg.what) { NappletIpc.MSG_RESPONSE -> { - val id = data.getString(NappletIpc.KEY_REQUEST_ID) ?: return true + val brokerId = data.getString(NappletIpc.KEY_REQUEST_ID) ?: return true val payload = data.getString(NappletIpc.KEY_PAYLOAD) ?: return true - val result = (parseJsonObjectOrNull(payload) ?: JsonObject(emptyMap())).withString("id", id) - bridgeReplyProxy?.postMessage(result.toString()) + // Null when the page that asked has been navigated away from: drop it rather than hand + // one site's answer (a signature, a decryption) to the next. + val (pageId, proxy) = bridge.resolve(brokerId) ?: return true + val result = (parseJsonObjectOrNull(payload) ?: JsonObject(emptyMap())).withString("id", pageId) + runCatching { proxy.postMessage(result.toString()) } } NappletIpc.MSG_PUSH -> { val payload = data.getString(NappletIpc.KEY_PAYLOAD) ?: return true - bridgeReplyProxy?.postMessage(payload) + runCatching { bridge.currentProxy?.postMessage(payload) } } NappletIpc.MSG_WEB_FAVORITE_STATE -> { val url = data.getString(NappletIpc.KEY_FAVORITE_URL) ?: return true 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 b50bbc6005..7bb73b6968 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserService.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserService.kt @@ -66,6 +66,7 @@ import androidx.webkit.WebViewFeature import com.vitorpamplona.amethyst.commons.browser.BrowserChrome import com.vitorpamplona.amethyst.commons.browser.BrowserSitePermission import com.vitorpamplona.amethyst.commons.browser.OmniboxInput +import com.vitorpamplona.amethyst.commons.napplet.NappletBridgeDocuments import com.vitorpamplona.amethyst.commons.napplet.NappletWebContract import com.vitorpamplona.amethyst.commons.util.parseJsonObjectOrNull import com.vitorpamplona.amethyst.commons.util.stringOrNull @@ -127,7 +128,9 @@ class NappletBrowserService : Service() { var textZoom = BrowserChrome.DEFAULT_TEXT_ZOOM var desktopSite = false - var bridgeReplyProxy: JavaScriptReplyProxy? = null + // The page on screen's bridge reply proxy, and which document each broker reply belongs to: a + // reply for a page the tab has navigated away from must never land in the next one. + val bridge = NappletBridgeDocuments() var fireSeq = 0 // The in-flight `` pick for this surface. The picker itself runs in the main @@ -191,6 +194,7 @@ class NappletBrowserService : Service() { tabs.values.forEach { it.fileChooser.cancel() cancelPending(it) + releasePage(it) it.webView?.destroy() } tabs.clear() @@ -308,7 +312,7 @@ class NappletBrowserService : Service() { NappletBrowserContract.MSG_IME_OP -> { val tab = tabFor(msg) ?: return true val payload = msg.data?.getString(NappletBrowserContract.KEY_IME_PAYLOAD) ?: return true - tab.bridgeReplyProxy?.postMessage(payload) + runCatching { tab.bridge.currentProxy?.postMessage(payload) } } NappletBrowserContract.MSG_SET_TOR -> { val tab = tabFor(msg) ?: return true @@ -449,7 +453,8 @@ class NappletBrowserService : Service() { /** A session closed: drop the tab and destroy its own WebView (never a sibling's). */ fun onSessionClosed(sessionId: String) { val tab = tabs.remove(sessionId) ?: return - tab.bridgeReplyProxy = null + releasePage(tab) + tab.bridge.clear() // Release a picker still waiting on this surface before its WebView goes away. tab.fileChooser.cancel() cancelPending(tab) @@ -864,6 +869,10 @@ class NappletBrowserService : Service() { if (tab.webView === view) { tab.webView = null tab.recoverUrl = lastUrl?.takeIf { it.isNotBlank() && it != ABOUT_BLANK } ?: tab.recoverUrl + // The page died with its renderer: its broker subscriptions and pick have no one to serve. + releasePage(tab) + tab.bridge.clear() + tab.fileChooser.cancel() tab.customView?.let { tab.container?.removeView(it) } tab.customView = null tab.customViewCallback = null @@ -960,7 +969,8 @@ class NappletBrowserService : Service() { replyProxy: JavaScriptReplyProxy, ) { if (!isMainFrame) return - tab.bridgeReplyProxy = replyProxy + // A new document replaced the page: whatever the old one had open with the broker is dead. + if (tab.bridge.onMessage(replyProxy)) releasePage(tab) val raw = message.data ?: return val envelope = parseJsonObjectOrNull(raw) ?: return @@ -995,7 +1005,8 @@ class NappletBrowserService : Service() { val host = sourceOrigin.host ?: return val origin = "$scheme://$host" + if (sourceOrigin.port > 0) ":${sourceOrigin.port}" else "" - val id = envelope.stringOrNull("id").orEmpty().ifEmpty { "fire-${tab.fireSeq++}" } + val pageId = envelope.stringOrNull("id").orEmpty().ifEmpty { "fire-${tab.fireSeq++}" } + val id = tab.bridge.brokerIdFor(pageId) val msg = Message.obtain(null, NappletIpc.MSG_REQUEST).apply { replyTo = tab.replyMessenger @@ -1016,6 +1027,21 @@ class NappletBrowserService : Service() { } } + /** + * The page on [tab] is gone (navigated away, renderer died, session closed): drop its requests still + * waiting for a token or the broker, and have the broker close the live relay / inc subscriptions it + * opened — otherwise their events would keep streaming into whatever page comes next. + */ + private fun releasePage(tab: BrowserTab) { + tab.pendingByOrigin.clear() + // A mint the broker never answered (none is sent while logged out) would otherwise block the + // origin for the tab's life; the next page asks again. + tab.mintInFlight.clear() + pendingBrokerRequests.removeAll { it.what == NappletIpc.MSG_REQUEST && it.replyTo == tab.replyMessenger } + val release = Message.obtain(null, NappletIpc.MSG_RELEASE_CLIENT).apply { replyTo = tab.replyMessenger } + if (brokerMessenger != null) sendToBroker(release) + } + private fun requestBrowserToken( tab: BrowserTab, origin: String, @@ -1102,14 +1128,17 @@ class NappletBrowserService : Service() { val data = msg.data ?: return true when (msg.what) { NappletIpc.MSG_RESPONSE -> { - val id = data.getString(NappletIpc.KEY_REQUEST_ID) ?: return true + val brokerId = data.getString(NappletIpc.KEY_REQUEST_ID) ?: return true val payload = data.getString(NappletIpc.KEY_PAYLOAD) ?: return true - val result = (parseJsonObjectOrNull(payload) ?: JsonObject(emptyMap())).withString("id", id) - runCatching { tab.bridgeReplyProxy?.postMessage(result.toString()) } + // Null when the page that asked has been navigated away from: drop it rather than hand + // one site's answer (a signature, a decryption) to the next. + val (pageId, proxy) = tab.bridge.resolve(brokerId) ?: return true + val result = (parseJsonObjectOrNull(payload) ?: JsonObject(emptyMap())).withString("id", pageId) + runCatching { proxy.postMessage(result.toString()) } } NappletIpc.MSG_PUSH -> { val payload = data.getString(NappletIpc.KEY_PAYLOAD) ?: return true - runCatching { tab.bridgeReplyProxy?.postMessage(payload) } + runCatching { tab.bridge.currentProxy?.postMessage(payload) } } NappletIpc.MSG_BROWSER_TOKEN -> { val origin = data.getString(NappletIpc.KEY_BROWSER_ORIGIN) ?: return true From 1e994dec9393508ab814b781a2991789276d6777 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 06:33:48 +0000 Subject: [PATCH 03/13] fix(browser): embedded tab lifecycle bugs and layer recomposition Found in an audit of the embedded browser/napplet tabs: - Toggling Tor on an nSite evicted its session, which also cleared the active tab, so the rebuilt session stayed parked off-screen (blank). EmbeddedTabHost.rebuild(id) replaces the session but keeps it active. - A low-memory trim tore every session down without bumping the rebuild epoch, leaving the visible screen with a dead controller on return. It now uses rebuildAll. - Re-navigating to the same non-bar tab let the outgoing screen evict the controller the incoming one had just acquired; screens now count holders per id. - Controllers reset fullscreen / dialog / permission / find state when their page is lost (a stale fullscreen flag swallowed every Back), and re-send Tor, text zoom and desktop mode with every session re-create (a Tor site came back over clearnet after a `:napplet` restart). - A napplet paused before its WebView existed started running anyway; the provider now remembers the pause and applies it on build. - The tab layer counted console lines in composition, recomposing on every log line; controllers now keep an error counter (ConsoleBuffer). - A selection handle removed mid-drag left the loupe up, the toolbar hidden and the drawer's edge swipe disabled; handles end the drag on dispose and read changing inputs through rememberUpdatedState (the toolbar's Copy could copy a previous selection). - The load safety-net called a still-loading page stuck after 12 s; a load in flight now gets 45 s. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01G3bteStHvaf18TdABSkb8h --- .../com/vitorpamplona/amethyst/Amethyst.kt | 8 ++- .../browser/EmbeddedWebAppController.kt | 54 ++++++++++++--- .../screen/loggedIn/browser/WebAppScreen.kt | 7 +- .../ui/screen/loggedIn/embed/ConsoleBridge.kt | 33 +++++++++ .../screen/loggedIn/embed/EmbeddedTabHost.kt | 48 +++++++++---- .../screen/loggedIn/embed/EmbeddedTabLayer.kt | 56 +++++++++++++--- .../favorites/EmbeddedNostrAppController.kt | 23 +++++-- .../loggedIn/favorites/NostrAppScreen.kt | 9 ++- .../loggedIn/embed/ConsoleBufferTest.kt | 67 +++++++++++++++++++ .../napplethost/NappletHostService.kt | 17 ++++- 10 files changed, 277 insertions(+), 45 deletions(-) create mode 100644 amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/ConsoleBufferTest.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/Amethyst.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/Amethyst.kt index 67254b7b40..171905009d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/Amethyst.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/Amethyst.kt @@ -240,9 +240,15 @@ class Amethyst : Application() { // BACKGROUND means the process is on the system LRU list (real reclaim pressure), while UI_HIDDEN // fires on every app switch — so evict only at BACKGROUND and above, letting a pinned tab survive // a plain backgrounding. R+ only. + // + // rebuildAll, not a plain eviction: the tab screen the user left stays composed, holding its + // controller, and it only re-acquires when the rebuild epoch moves. Tearing the sessions down without + // bumping it left that screen with a dead controller and no active tab — blank on return. The epoch + // bump is picked up by the next recomposition, which doesn't run while the app is backgrounded, so the + // memory stays freed until the user comes back. val pressure = level >= ComponentCallbacks2.TRIM_MEMORY_BACKGROUND if (pressure && Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - EmbeddedTabHost.evictAll() + EmbeddedTabHost.rebuildAll() } } } 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 144fe8213d..20b8dec4fc 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 @@ -36,12 +36,12 @@ import android.os.Messenger import android.os.SystemClock import androidx.annotation.RequiresApi import androidx.compose.runtime.State -import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf import androidx.privacysandbox.ui.client.SandboxedUiAdapterFactory import androidx.privacysandbox.ui.client.view.SandboxedSdkView import androidx.privacysandbox.ui.client.view.SandboxedSdkViewEventListener import androidx.privacysandbox.ui.core.SandboxedUiAdapter +import com.vitorpamplona.amethyst.commons.browser.BrowserChrome import com.vitorpamplona.amethyst.commons.browser.BrowserSitePermission import com.vitorpamplona.amethyst.commons.browser.ui.pill.CertificateInfo import com.vitorpamplona.amethyst.commons.browser.ui.pill.ConsoleLine @@ -50,6 +50,7 @@ import com.vitorpamplona.amethyst.napplet.NappletWebViewProfiles import com.vitorpamplona.amethyst.napplet.WebFileChooserCoordinator import com.vitorpamplona.amethyst.napplethost.NappletBrowserContract import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.ConsoleBridge +import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.ConsoleBuffer import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedAutoRecovery import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedImeBridge import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedLoadStatus @@ -116,9 +117,18 @@ class EmbeddedWebAppController( override var onLoadStatusChanged: ((EmbeddedLoadStatus) -> Unit)? = null /** JavaScript console output received from the embedded WebView, capped at [MAX_CONSOLE_LOGS] entries. */ - override val consoleLogs = mutableStateListOf() + private val console = ConsoleBuffer(MAX_CONSOLE_LOGS) + override val consoleLogs get() = console.lines + override val consoleErrorCount get() = console.errorCount - override fun clearConsoleLogs() = consoleLogs.clear() + override fun clearConsoleLogs() = console.clear() + + // The user's per-tab page settings. The provider forgets them whenever the session is re-created (a + // `:napplet` restart, a rearm), so they are re-sent with every create — otherwise a site the user + // switched onto Tor would silently come back over clearnet while the pill still said Tor. + private var useTor = initialUseTor + private var textZoom = BrowserChrome.DEFAULT_TEXT_ZOOM + private var desktopSite = false // A single NappletBrowserService instance serves every embedded browser tab, so each controller // stamps its own id on every message; the provider uses it to route controls/updates to this tab. @@ -177,6 +187,7 @@ class EmbeddedWebAppController( // `:napplet` died (the OS reclaimed it, or it crashed). Its WebViews went with it; the // system restarts the bound service and [onServiceConnected] re-creates the session. serviceMessenger = null + resetPageState() showRecovering() } } @@ -202,10 +213,20 @@ class EmbeddedWebAppController( onImeEvent = null onMagnifierFrame = null onLoadStatusChanged = null - consoleLogs.clear() + console.clear() + resetPageState() + } + + /** + * Drops UI state that belongs to the page on screen: once that page is gone (renderer death, session + * lost, `:napplet` restart) nothing will ever close it. A stale fullscreen flag swallowed every Back + * press, and a stale dialog or permission prompt auto-refused every new one from the rebuilt page. + */ + private fun resetPageState() { pendingDialog.value = null pendingPermission.value = null isFullscreen.value = false + _findResult.value = null } override fun teardown() = unbind() @@ -266,6 +287,7 @@ class EmbeddedWebAppController( sessionId = "browser-${SESSION_SEQ.incrementAndGet()}" adapterDelivered = false sessionDead = false + resetPageState() sendCreateSession() } @@ -299,6 +321,7 @@ class EmbeddedWebAppController( private fun onSurfaceLost(sessionDead: Boolean) { if (sessionDead) this.sessionDead = true hasLoadedReal = false + resetPageState() // `:napplet` itself is down: its restart re-creates the session (see [onServiceConnected]). if (serviceMessenger?.binder?.isBinderAlive != true) { showRecovering() @@ -332,7 +355,7 @@ class EmbeddedWebAppController( putString(NappletBrowserContract.KEY_SESSION_ID, sessionId) putString(NappletBrowserContract.KEY_URL, startUrl) putInt(NappletBrowserContract.KEY_PROXY_PORT, proxyPort) - putBoolean(NappletBrowserContract.KEY_USE_TOR, initialUseTor) + putBoolean(NappletBrowserContract.KEY_USE_TOR, useTor) putInt(NappletBrowserContract.KEY_BG_COLOR, backgroundColor) putString(NappletBrowserContract.KEY_THEME, themeType) // Opaque per-account storage partition, so an embedded site can't carry one @@ -341,6 +364,9 @@ class EmbeddedWebAppController( } } runCatching { serviceMessenger?.send(msg) } + // Messenger keeps order, so these land after the CREATE and are stored on the new tab. + if (textZoom != BrowserChrome.DEFAULT_TEXT_ZOOM) setTextZoom(textZoom) + if (desktopSite) setDesktopSite(true) } private fun onServiceMessage(msg: Message): Boolean { @@ -382,8 +408,7 @@ class EmbeddedWebAppController( val message = msg.data?.getString(NappletBrowserContract.KEY_CONSOLE_MESSAGE).orEmpty() val source = msg.data?.getString(NappletBrowserContract.KEY_CONSOLE_SOURCE).orEmpty() val line = msg.data?.getInt(NappletBrowserContract.KEY_CONSOLE_LINE, 0) ?: 0 - if (consoleLogs.size >= MAX_CONSOLE_LOGS) consoleLogs.removeAt(0) - consoleLogs.add(ConsoleLine(consoleLevelOf(level), message, source, line)) + console.add(ConsoleLine(consoleLevelOf(level), message, source, line)) } NappletBrowserContract.MSG_FILE_CHOOSER_REQUEST -> { val data = msg.data ?: return true @@ -537,9 +562,15 @@ class EmbeddedWebAppController( override fun findNext(forward: Boolean) = send(NappletBrowserContract.MSG_FIND_NEXT) { putBoolean(NappletBrowserContract.KEY_FIND_FORWARD, forward) } - fun setDesktopSite(enabled: Boolean) = send(NappletBrowserContract.MSG_SET_DESKTOP) { putBoolean(NappletBrowserContract.KEY_ENABLED, enabled) } + fun setDesktopSite(enabled: Boolean) { + desktopSite = enabled + send(NappletBrowserContract.MSG_SET_DESKTOP) { putBoolean(NappletBrowserContract.KEY_ENABLED, enabled) } + } - fun setTextZoom(percent: Int) = send(NappletBrowserContract.MSG_SET_TEXT_ZOOM) { putInt(NappletBrowserContract.KEY_TEXT_ZOOM, percent) } + fun setTextZoom(percent: Int) { + textZoom = percent + send(NappletBrowserContract.MSG_SET_TEXT_ZOOM) { putInt(NappletBrowserContract.KEY_TEXT_ZOOM, percent) } + } /** Back to the app's home origin ([homeUrl]), Chrome's out-of-scope ✕. */ fun backToScope(homeUrl: String) = send(NappletBrowserContract.MSG_BACK_TO_SCOPE) { putString(NappletBrowserContract.KEY_URL, homeUrl) } @@ -581,7 +612,10 @@ class EmbeddedWebAppController( } } - fun setTor(useTor: Boolean) = send(NappletBrowserContract.MSG_SET_TOR) { putBoolean(NappletBrowserContract.KEY_USE_TOR, useTor) } + fun setTor(useTor: Boolean) { + this.useTor = useTor + send(NappletBrowserContract.MSG_SET_TOR) { putBoolean(NappletBrowserContract.KEY_USE_TOR, useTor) } + } override fun sendImeOp(json: String) = send(NappletBrowserContract.MSG_IME_OP) { putString(NappletBrowserContract.KEY_IME_PAYLOAD, json) } 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 7a50ebb6f3..692a141adf 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 @@ -290,11 +290,14 @@ private fun EmbeddedWebAppTab( val bottomBarFlow = accountViewModel.account.settings.syncedSettings.navigation.bottomBarItems DisposableEffect(id) { val token = EmbeddedTabHost.setActive(id) + EmbeddedTabHost.hold(id) onDispose { EmbeddedTabHost.clearActiveIfOwner(token) EmbeddedTabHost.clearActiveChrome(id) - // Only bottom-row apps stay warm; anything else restarts when it leaves. - if (id !in bottomBarFlow.value.favoriteIds()) EmbeddedTabHost.evict(id) + // Only bottom-row apps stay warm; anything else restarts when it leaves — unless a re-navigation + // to this same tab already composed a new screen on the same session. + val lastHolder = EmbeddedTabHost.release(id) + if (lastHolder && id !in bottomBarFlow.value.favoriteIds()) EmbeddedTabHost.evict(id) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/ConsoleBridge.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/ConsoleBridge.kt index faa8cf7204..8761799e7c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/ConsoleBridge.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/ConsoleBridge.kt @@ -20,6 +20,9 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.embed +import androidx.compose.runtime.IntState +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.snapshots.SnapshotStateList import com.vitorpamplona.amethyst.commons.browser.ui.pill.ConsoleLine @@ -31,9 +34,39 @@ import com.vitorpamplona.amethyst.commons.browser.ui.pill.ConsoleLine interface ConsoleBridge { val consoleLogs: SnapshotStateList + /** + * How many of [consoleLogs] are errors — the pill's badge. Kept as its own state so the tab layer + * reads a single int instead of counting the list, which subscribed it to EVERY log line: a page + * logging each frame recomposed the whole layer each frame. + */ + val consoleErrorCount: IntState + fun clearConsoleLogs() } +/** + * A capped JavaScript console buffer that keeps its error count as separate state (see + * [ConsoleBridge.consoleErrorCount]). Main-thread only. + */ +class ConsoleBuffer( + private val max: Int, +) { + val lines = mutableStateListOf() + private val errors = mutableIntStateOf(0) + val errorCount: IntState get() = errors + + fun add(line: ConsoleLine) { + if (lines.size >= max && lines.removeAt(0).level == ConsoleLine.Level.ERROR) errors.intValue-- + lines.add(line) + if (line.level == ConsoleLine.Level.ERROR) errors.intValue++ + } + + fun clear() { + lines.clear() + errors.intValue = 0 + } +} + /** Maps a provider's console level (WebView's `ConsoleMessage.MessageLevel` name) onto the chrome's. */ fun consoleLevelOf(level: String): ConsoleLine.Level = when (level) { 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 9f4bf544d5..8c2dac1700 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 @@ -37,7 +37,7 @@ import androidx.compose.ui.geometry.Rect * * Warm-keep is scoped to **bottom-row apps**: a session is retained only while its app is a bottom-bar * favorite (see [retainOnly], driven by the bottom-bar settings). A favorite opened outside the bottom - * row restarts when it leaves, and a low-memory trim ([evictAll]) drops everything. + * row restarts when it leaves, and a low-memory trim ([rebuildAll]) drops everything. * * State is Compose snapshot state so [EmbeddedTabLayer] recomposes as sessions / the active id / the * content bounds change. Main-thread only. @@ -174,6 +174,35 @@ object EmbeddedTabHost { w.controller.teardown() } + /** + * Tears down [id]'s warm session so its screen re-acquires a freshly built one (e.g. an nSite switched + * between Tor and the open web). Unlike [evict] this keeps [activeId]: the screen stays composed and + * never re-runs its `setActive`, so clearing it would park the new session off-screen — a blank tab. + */ + fun rebuild(id: String) { + val w = warm.firstOrNull { it.id == id } ?: return + keyboardUpOnLeave.remove(id) + warm.remove(w) + w.controller.teardown() + } + + // How many composed screens currently show each id. Re-navigating to the same route composes the new + // screen (which acquires the SAME warm controller) before the old one disposes; counting lets the old + // one's disposal see that the tab is still in use instead of evicting the controller under the new one. + private val holders = mutableMapOf() + + /** A screen showing [id] entered composition. Pair with [release]. */ + fun hold(id: String) { + holders[id] = (holders[id] ?: 0) + 1 + } + + /** A screen showing [id] left composition. Returns true when no other screen still shows it. */ + fun release(id: String): Boolean { + val left = (holders[id] ?: 1) - 1 + if (left <= 0) holders.remove(id) else holders[id] = left + return left <= 0 + } + /** Drops every warm session whose id isn't in [keep] (bottom-row membership + the active tab). */ fun retainOnly(keep: Set) { warm @@ -181,19 +210,12 @@ object EmbeddedTabHost { .forEach { evict(it.id) } } - fun evictAll() { - activeId = null - keyboardUpOnLeave.clear() - val copy = warm.toList() - warm.clear() - copy.forEach { it.controller.teardown() } - } - /** - * Something a WebView can only pick up at construction changed (the theme, or the account): tear down - * every warm session and bump [rebuildEpoch] so the visible screen and the preloader re-acquire freshly - * built sessions. Unlike [evictAll] this keeps [activeId], so the visible tab re-activates the instant - * its screen re-acquires — the user just sees the current tab reload, not a blanked-out surface. + * Something a WebView can only pick up at construction changed (the theme, or the account), or the + * system asked for memory back: tear down every warm session and bump [rebuildEpoch] so the visible + * screen and the preloader re-acquire freshly built sessions. This keeps [activeId], so the visible tab + * re-activates the instant its screen re-acquires — the user just sees the current tab reload, not a + * blanked-out surface. */ fun rebuildAll() { // Every page is about to be rebuilt from scratch, so no field survives to restore a keyboard onto. 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 ba49a8480c..244d2ddf67 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 @@ -244,11 +244,13 @@ fun EmbeddedTabLayer(barFavoriteIds: List) { activeController?.onLoadStatusChanged = { loadStatus = it } onDispose { activeController?.onLoadStatusChanged = null } } - // Safety net: nothing painted and nothing actively loading after a grace period → offer a retry. + // Safety net: nothing painted after a grace period → offer a retry. A load still in flight (a slow + // site, one over Tor) gets a longer budget before it's called stuck, instead of flipping to an error + // while it's still progressing. LaunchedEffect(activeId, loadStatus) { timedOut = false if (!loadStatus.hasLoadedReal && !loadStatus.failed) { - delay(12_000) + delay(if (loadStatus.isLoading) 45_000 else 12_000) timedOut = true } } @@ -322,7 +324,7 @@ fun EmbeddedTabLayer(barFavoriteIds: List) { chrome.ui.copy( chrome = chrome.ui.chrome.copy(hasFind = chrome.ui.chrome.hasFind && findBridge != null), consoleShowing = consoleShowing, - consoleErrors = consoleLogs?.count { it.level == ConsoleLine.Level.ERROR } ?: 0, + consoleErrors = consoleBridge?.consoleErrorCount?.intValue ?: 0, ) Box(tabModifier) { @@ -783,8 +785,17 @@ private fun SelectionHandle( val color = MaterialTheme.colorScheme.primary val currentTip by rememberUpdatedState(tipPx) val currentMagnify by rememberUpdatedState(onMagnify) + // The drag gesture is installed once (pointerInput(Unit)) and outlives recompositions, so everything it + // reads that can change mid-life (a rotation or resize moves the origin, the page's zoom changes the + // scale, the drag target captures the current bridge) is read through updated state. + val currentOriginX by rememberUpdatedState(surfaceOriginX) + val currentOriginY by rememberUpdatedState(surfaceOriginY) + val currentScale by rememberUpdatedState(scale) + val currentLineHalf by rememberUpdatedState(lineHalfPx) + val currentDragTo by rememberUpdatedState(onDragTo) var dragTip by remember { mutableStateOf(null) } val tip = dragTip ?: tipPx + EndDragOnDispose(isDragging = { dragTip != null }, onMagnify = { currentMagnify }) // Loupe capture: X follows the finger, Y is locked to the authoritative endpoint's line ([currentTip] is // the foot, so lift by half the line height) — so the bubble shows the line being edited, not wherever the @@ -792,7 +803,7 @@ private fun SelectionHandle( fun magnify( fingerPx: Offset, active: Boolean = true, - ) = currentMagnify?.invoke(active, fingerPx, fingerPx.x - surfaceOriginX, currentTip.y - surfaceOriginY - lineHalfPx) + ) = currentMagnify?.invoke(active, fingerPx, fingerPx.x - currentOriginX, currentTip.y - currentOriginY - currentLineHalf) // Place the box so its pointed corner lands on the tip: start = top-right corner, end = top-left corner. val boxLeft = if (isStart) tip.x - sizePx else tip.x Box( @@ -809,7 +820,7 @@ private fun SelectionHandle( change.consume() val np = (dragTip ?: currentTip) + delta dragTip = np - onDragTo((np.x - surfaceOriginX) / scale, (np.y - surfaceOriginY) / scale) + currentDragTo((np.x - currentOriginX) / currentScale, (np.y - currentOriginY) / currentScale) magnify(np) }, onDragEnd = { @@ -870,6 +881,12 @@ private fun InsertionHandle( val currentTip by rememberUpdatedState(tipPx) val currentMagnify by rememberUpdatedState(onMagnify) val currentTap by rememberUpdatedState(onTap) + // Read through updated state for the same reason as SelectionHandle: the gesture outlives recompositions. + val currentOriginX by rememberUpdatedState(surfaceOriginX) + val currentOriginY by rememberUpdatedState(surfaceOriginY) + val currentScale by rememberUpdatedState(scale) + val currentLineHalf by rememberUpdatedState(lineHalfPx) + val currentDragTo by rememberUpdatedState(onDragTo) // Loupe capture: X follows the finger, Y is locked to the authoritative caret's line ([currentTip] is the // caret foot, so lift by half the line height) — so the bubble shows the edited line, not wherever the @@ -877,11 +894,12 @@ private fun InsertionHandle( fun magnify( fingerPx: Offset, active: Boolean = true, - ) = currentMagnify?.invoke(active, fingerPx, fingerPx.x - surfaceOriginX, currentTip.y - surfaceOriginY - lineHalfPx) + ) = currentMagnify?.invoke(active, fingerPx, fingerPx.x - currentOriginX, currentTip.y - currentOriginY - currentLineHalf) // Track the finger separately from what we draw: the handle renders at the AUTHORITATIVE caret (tipPx, // which snaps to a character position as the move round-trips through the shim), while the finger drives // the move. So the handle stays glued to the line/text and clamps to the field instead of trailing off. var fingerPx by remember { mutableStateOf(null) } + EndDragOnDispose(isDragging = { fingerPx != null }, onMagnify = { currentMagnify }) Box( Modifier .absoluteOffset { IntOffset((tipPx.x - wPx / 2f).roundToInt(), tipPx.y.roundToInt()) } @@ -909,6 +927,7 @@ private fun InsertionHandle( if (!dragging && (change.position - down.position).getDistance() > viewConfiguration.touchSlop) { dragging = true fp = currentTip + fingerPx = fp magnify(currentTip) } if (dragging) { @@ -919,7 +938,7 @@ private fun InsertionHandle( fp += change.positionChangeIgnoreConsumed() change.consume() fingerPx = fp - onDragTo((fp.x - surfaceOriginX) / scale, (fp.y - surfaceOriginY) / scale) + currentDragTo((fp.x - currentOriginX) / currentScale, (fp.y - currentOriginY) / currentScale) magnify(fp) } } @@ -999,6 +1018,9 @@ private fun SelectionToolbarItem( label: String, onClick: () -> Unit, ) { + // The gesture is keyed on the label only, so read the action through updated state: "Copy" captures the + // selected text, and a stale first lambda copied the previous selection. + val currentOnClick by rememberUpdatedState(onClick) Text( text = label, color = MaterialTheme.colorScheme.onSurfaceVariant, @@ -1012,13 +1034,31 @@ private fun SelectionToolbarItem( val up = waitForUpOrCancellation() if (up != null) { up.consume() - onClick() + currentOnClick() } } }.padding(horizontal = 12.dp, vertical = 10.dp), ) } +/** + * Ends a handle drag that is still in progress when the handle leaves composition — the selection collapsed + * or the page blurred under the finger. The gesture's own end/cancel callbacks never run then (its coroutine + * is just cancelled), which left the loupe on screen, the toolbar hidden and the drawer's edge swipe disabled + * app-wide ([EmbeddedSelectionDrag]). + */ +@Composable +private fun EndDragOnDispose( + isDragging: () -> Boolean, + onMagnify: () -> OnMagnify?, +) { + DisposableEffect(Unit) { + onDispose { + if (isDragging()) onMagnify()?.invoke(false, Offset.Zero, 0f, 0f) + } + } +} + /** One console line as plain text, for copying. */ private fun formatConsoleLine(line: ConsoleLine): String = buildString { 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 274be17d31..11bf48ea99 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 @@ -36,18 +36,19 @@ import android.os.Messenger import android.os.SystemClock import androidx.annotation.RequiresApi import androidx.compose.runtime.State -import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf import androidx.privacysandbox.ui.client.SandboxedUiAdapterFactory import androidx.privacysandbox.ui.client.view.SandboxedSdkView import androidx.privacysandbox.ui.client.view.SandboxedSdkViewEventListener import androidx.privacysandbox.ui.core.SandboxedUiAdapter +import com.vitorpamplona.amethyst.commons.browser.BrowserChrome import com.vitorpamplona.amethyst.commons.browser.ui.pill.ConsoleLine import com.vitorpamplona.amethyst.napplet.NappletWebViewProfiles import com.vitorpamplona.amethyst.napplet.WebFileChooserCoordinator import com.vitorpamplona.amethyst.napplethost.NappletEmbedContract import com.vitorpamplona.amethyst.napplethost.NappletHostContract import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.ConsoleBridge +import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.ConsoleBuffer import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedAutoRecovery import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedImeBridge import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedLoadStatus @@ -134,9 +135,15 @@ class EmbeddedNostrAppController( override var onMagnifierFrame: ((MagnifierFrame) -> Unit)? = null /** The app's console output, capped at [MAX_CONSOLE_LOGS] entries. */ - override val consoleLogs = mutableStateListOf() + private val console = ConsoleBuffer(MAX_CONSOLE_LOGS) + override val consoleLogs get() = console.lines + override val consoleErrorCount get() = console.errorCount - override fun clearConsoleLogs() = consoleLogs.clear() + override fun clearConsoleLogs() = console.clear() + + // The user's text zoom. The provider forgets it whenever the session is re-created (a `:napplet` + // restart, a rearm), so it is re-sent with every create. + private var textZoom = BrowserChrome.DEFAULT_TEXT_ZOOM private val _findResult = mutableStateOf(null) override val findResult: State = _findResult @@ -238,6 +245,7 @@ class EmbeddedNostrAppController( sessionId = "napplet-${SESSION_SEQ.incrementAndGet()}" adapterDelivered = false sessionDead = false + _findResult.value = null sendCreateSession() } @@ -325,6 +333,7 @@ class EmbeddedNostrAppController( // so a never-shown applet doesn't start running. Messenger preserves order, so PAUSE lands after // CREATE in the host. if (wantPaused) send(NappletEmbedContract.MSG_PAUSE) + if (textZoom != BrowserChrome.DEFAULT_TEXT_ZOOM) setTextZoom(textZoom) } private fun onServiceMessage(msg: Message): Boolean { @@ -386,8 +395,7 @@ class EmbeddedNostrAppController( } NappletEmbedContract.MSG_CONSOLE_LOG -> { val data = msg.data ?: return true - if (consoleLogs.size >= MAX_CONSOLE_LOGS) consoleLogs.removeAt(0) - consoleLogs.add( + console.add( ConsoleLine( consoleLevelOf(data.getString(NappletEmbedContract.KEY_CONSOLE_LEVEL).orEmpty()), data.getString(NappletEmbedContract.KEY_CONSOLE_MESSAGE).orEmpty(), @@ -442,7 +450,10 @@ class EmbeddedNostrAppController( override fun findNext(forward: Boolean) = send(NappletEmbedContract.MSG_FIND_NEXT) { putBoolean(NappletEmbedContract.KEY_FIND_FORWARD, forward) } - fun setTextZoom(percent: Int) = send(NappletEmbedContract.MSG_SET_TEXT_ZOOM) { putInt(NappletEmbedContract.KEY_TEXT_ZOOM, percent) } + fun setTextZoom(percent: Int) { + textZoom = percent + send(NappletEmbedContract.MSG_SET_TEXT_ZOOM) { putInt(NappletEmbedContract.KEY_TEXT_ZOOM, percent) } + } /** User-triggered recovery for a stuck or failed session: reload the verified content from scratch. */ override fun retry() { 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 5dccbbf0dc..c237c85d30 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 @@ -212,7 +212,7 @@ private fun EmbeddedNostrAppTab( BrowserChrome.Action.TOR -> { // Persist the new route, then rebuild the session so it loads that way. NappletNetworkRegistry.set(permissionCoordinate, !useTor) - EmbeddedTabHost.evict(id) + EmbeddedTabHost.rebuild(id) networkEpoch++ } BrowserChrome.Action.SITE_SETTINGS -> nav.nav(Route.ConnectedAppDetail(permissionCoordinate)) @@ -237,11 +237,14 @@ private fun EmbeddedNostrAppTab( val bottomBarFlow = accountViewModel.account.settings.syncedSettings.navigation.bottomBarItems DisposableEffect(id) { val token = EmbeddedTabHost.setActive(id) + EmbeddedTabHost.hold(id) onDispose { EmbeddedTabHost.clearActiveIfOwner(token) EmbeddedTabHost.clearActiveChrome(id) - // Only bottom-row apps stay warm; anything else restarts when it leaves. - if (id !in bottomBarFlow.value.favoriteIds()) EmbeddedTabHost.evict(id) + // Only bottom-row apps stay warm; anything else restarts when it leaves — unless a re-navigation + // to this same tab already composed a new screen on the same session. + val lastHolder = EmbeddedTabHost.release(id) + if (lastHolder && id !in bottomBarFlow.value.favoriteIds()) EmbeddedTabHost.evict(id) } } diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/ConsoleBufferTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/ConsoleBufferTest.kt new file mode 100644 index 0000000000..129983c365 --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/ConsoleBufferTest.kt @@ -0,0 +1,67 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.embed + +import com.vitorpamplona.amethyst.commons.browser.ui.pill.ConsoleLine +import org.junit.Assert.assertEquals +import org.junit.Test + +class ConsoleBufferTest { + private fun line(level: ConsoleLine.Level) = ConsoleLine(level, "m", "s", 1) + + @Test + fun countsErrors() { + val buffer = ConsoleBuffer(max = 10) + buffer.add(line(ConsoleLine.Level.LOG)) + buffer.add(line(ConsoleLine.Level.ERROR)) + buffer.add(line(ConsoleLine.Level.ERROR)) + assertEquals(2, buffer.errorCount.intValue) + assertEquals(3, buffer.lines.size) + } + + @Test + fun evictingAnErrorLowersTheCount() { + val buffer = ConsoleBuffer(max = 2) + buffer.add(line(ConsoleLine.Level.ERROR)) + buffer.add(line(ConsoleLine.Level.LOG)) + buffer.add(line(ConsoleLine.Level.LOG)) + assertEquals(0, buffer.errorCount.intValue) + assertEquals(2, buffer.lines.size) + } + + @Test + fun evictingANonErrorKeepsTheCount() { + val buffer = ConsoleBuffer(max = 2) + buffer.add(line(ConsoleLine.Level.LOG)) + buffer.add(line(ConsoleLine.Level.ERROR)) + buffer.add(line(ConsoleLine.Level.ERROR)) + assertEquals(2, buffer.errorCount.intValue) + } + + @Test + fun clearResets() { + val buffer = ConsoleBuffer(max = 2) + buffer.add(line(ConsoleLine.Level.ERROR)) + buffer.clear() + assertEquals(0, buffer.errorCount.intValue) + assertEquals(0, buffer.lines.size) + } +} 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 45b615be8d..1a1086aae0 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostService.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostService.kt @@ -121,6 +121,10 @@ class NappletHostService : Service() { // The session's root view; a WebView lost to a renderer crash is rebuilt inside it on retry. var container: FrameLayout? = null + + // The client's last pause/resume. A parked tab is paused before its WebView exists (the WebView is + // only built when the surface opens), so the flag is applied to every WebView built for the tab. + var paused = false var bridgeReplyProxy: JavaScriptReplyProxy? = null var fireSeq = 0 @@ -210,8 +214,16 @@ class NappletHostService : Service() { // onPause()/onResume() are per-WebView (pause/resume THIS surface's JS/DOM). Do NOT call // pauseTimers()/resumeTimers(): they are process-global and would freeze/thaw every WebView in // `:napplet` (the browser embed + other napplets), whose lifecycles are independent of this one. - NappletEmbedContract.MSG_PAUSE -> tabFor(msg)?.webView?.onPause() - NappletEmbedContract.MSG_RESUME -> tabFor(msg)?.webView?.onResume() + NappletEmbedContract.MSG_PAUSE -> + tabFor(msg)?.let { + it.paused = true + it.webView?.onPause() + } + NappletEmbedContract.MSG_RESUME -> + tabFor(msg)?.let { + it.paused = false + it.webView?.onResume() + } NappletEmbedContract.MSG_IME_OP -> { val tab = tabFor(msg) ?: return true val payload = msg.data?.getString(NappletEmbedContract.KEY_IME_PAYLOAD) ?: return true @@ -381,6 +393,7 @@ class NappletHostService : Service() { wv.setFindListener { active, total, _ -> pushFindResult(tab, active, total) } if (tab.textZoom != BrowserChrome.DEFAULT_TEXT_ZOOM) BrowserWebTools.setTextZoom(wv, tab.textZoom) tab.webView = wv + if (tab.paused) wv.onPause() wv.loadUrl(NappletWebContract.SHELL_URL) return wv } From 8db7819344cf240e60e40224c980da135ca39e69 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 12:16:11 +0000 Subject: [PATCH 04/13] fix(napplet): one owner for the Tor proxy; closed surfaces give back broker state Tor proxy (privacy): Android's WebView proxy override is process-global, but every :napplet surface (embedded browser tab, embedded nSite, full-screen browser, full-screen host) set or cleared it for its own site. The last one won for everyone, so opening an open-web page silently moved an already-open Tor page onto the open web. - NappletProxyClaims (commons, tested) derives the one shared route from every live surface's claim: Tor while any surface wants it (the latest Tor port); an open-web surface exempts only its own site's host from that route, so a pinned Tor favorite doesn't force every opted-out site onto Tor for good; no proxy once no surface wants Tor. - WebViewProxyPolicy (nappletHost) is the only code that touches ProxyController. It applies the route and holds a surface's first page load (and a navigation right after a Tor toggle) until the route is in effect, so a Tor page's first request can't leave over the open web. Broker state: closed surfaces kept their launch tokens, relay/inc subscriptions and reply Messenger alive in the main process. The token registry is a 128-entry LRU, so dead browser tokens pushed live tabs' tokens out, after which every NIP-07 call from that origin failed. - MSG_RELEASE_CLIENT can carry KEY_RELEASED_TOKENS; the broker unregisters them and stops their identity watches and resource requests. Browser tabs/activities give back their per-origin tokens on close. - The embedded napplet host releases a closed session's subscriptions; the main-process controller unregisters its launch token on teardown, and a screen visit that mints params for an already-warm tab frees the unused token. - A request with an unknown token now also gets MSG_TOKEN_UNKNOWN, and browser hosts drop that token so the origin re-mints instead of failing forever. The registry cap is raised to 512 as a backstop. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01G3bteStHvaf18TdABSkb8h --- .../amethyst/napplet/NappletBrokerService.kt | 17 +++ .../amethyst/napplet/NappletLaunchRegistry.kt | 7 +- .../loggedIn/embed/EmbeddedTabFactory.kt | 6 + .../favorites/EmbeddedNostrAppController.kt | 4 + .../commons/napplet/NappletIdentityWatch.kt | 5 + .../commons/napplet/NappletProxyClaims.kt | 90 +++++++++++ .../commons/napplet/NappletProxyClaimsTest.kt | 82 ++++++++++ .../napplethost/NappletBrowserActivity.kt | 56 ++++--- .../napplethost/NappletBrowserService.kt | 113 +++++++------- .../napplethost/NappletHostActivity.kt | 28 +--- .../napplethost/NappletHostService.kt | 50 ++++--- .../amethyst/napplethost/NappletIpc.kt | 16 +- .../napplethost/WebViewProxyPolicy.kt | 140 ++++++++++++++++++ 13 files changed, 488 insertions(+), 126 deletions(-) create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletProxyClaims.kt create mode 100644 commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletProxyClaimsTest.kt create mode 100644 nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/WebViewProxyPolicy.kt 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 55c0778244..1b630cf8ec 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletBrokerService.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletBrokerService.kt @@ -162,6 +162,15 @@ class NappletBrokerService : Service() { incBus.removeAll(it) liveSubscriptions.closeAllFor(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 + // give back its own. + msg.data?.getStringArray(NappletIpc.KEY_RELEASED_TOKENS)?.forEach { token -> + NappletLaunchRegistry.unregister(token) + identityWatch.stop(token) + val prefix = "$token\u0000" + resourceRequests.keys.filter { it.startsWith(prefix) }.forEach { key -> resourceRequests.remove(key)?.cancel() } + } // Release its foreground lease too; otherwise a destroyed surface keeps the main process // pinned resumed until the lease watchdog expires it. msg.data?.getString(NappletIpc.KEY_LAUNCH_TOKEN)?.let { token -> @@ -369,6 +378,14 @@ class NappletBrokerService : Service() { val session = NappletLaunchRegistry.resolve(launchToken) if (session == null) { reply(replyTo, requestId, NappletProtocolJson.encodeResponse(requestType, NappletResponse.Failed("Unknown napplet session."))) + // Tell the surface its token is gone, so a browser tab re-mints instead of failing every call. + if (launchToken != null) { + val unknown = + Message.obtain(null, NappletIpc.MSG_TOKEN_UNKNOWN).apply { + this.data = Bundle().apply { putString(NappletIpc.KEY_LAUNCH_TOKEN, launchToken) } + } + runCatching { replyTo.send(unknown) } + } return true } val identity = session.identity 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 fa1da7bae5..0ba57c4697 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletLaunchRegistry.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletLaunchRegistry.kt @@ -61,9 +61,10 @@ object NappletLaunchRegistry { val accountPubKey: HexKey, ) - // Access-ordered + capped so tokens from long-closed napplets can't accumulate without bound. The - // active napplet always re-touches its token, so only stale sessions are ever evicted. - private const val MAX_SESSIONS = 128 + // Access-ordered + capped so tokens from long-closed napplets can't accumulate without bound. Closed + // surfaces give their tokens back ([unregister]), so the cap is only a backstop for ones that never + // could (a crashed process); an idle live surface whose token is evicted anyway is told so and re-mints. + private const val MAX_SESSIONS = 512 // LruCache is internally synchronized and access-ordered — the same // touch-on-resolve + evict-eldest-beyond-cap semantics the old access-ordered 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 8422c85477..70ad796a72 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 @@ -30,6 +30,7 @@ import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp import com.vitorpamplona.amethyst.commons.model.ThemeType import com.vitorpamplona.amethyst.commons.tor.TorType import com.vitorpamplona.amethyst.favorites.FavoriteAppLauncher +import com.vitorpamplona.amethyst.napplet.NappletLaunchRegistry import com.vitorpamplona.amethyst.napplet.WebAppNetworkRegistry import com.vitorpamplona.amethyst.napplethost.NappletHostContract import com.vitorpamplona.amethyst.ui.screen.loggedIn.browser.EmbeddedWebAppController @@ -84,6 +85,11 @@ object EmbeddedTabFactory { backgroundColor: Int, ): EmbeddedNostrAppController { params.putInt(NappletHostContract.EXTRA_BG_COLOR, backgroundColor) + // The screen mints fresh params on every visit, but a warm tab keeps the session (and token) it was + // built with — give the unused fresh token back instead of leaving it registered. + if (EmbeddedTabHost.isWarm(nostrAppId(coordinate))) { + NappletLaunchRegistry.unregister(params.getString(NappletHostContract.EXTRA_LAUNCH_TOKEN)) + } return EmbeddedTabHost.acquire(nostrAppId(coordinate)) { EmbeddedNostrAppController(context.applicationContext, params).also { it.bind() } } as EmbeddedNostrAppController 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 11bf48ea99..6a4ea90a60 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 @@ -43,6 +43,7 @@ import androidx.privacysandbox.ui.client.view.SandboxedSdkViewEventListener import androidx.privacysandbox.ui.core.SandboxedUiAdapter import com.vitorpamplona.amethyst.commons.browser.BrowserChrome import com.vitorpamplona.amethyst.commons.browser.ui.pill.ConsoleLine +import com.vitorpamplona.amethyst.napplet.NappletLaunchRegistry import com.vitorpamplona.amethyst.napplet.NappletWebViewProfiles import com.vitorpamplona.amethyst.napplet.WebFileChooserCoordinator import com.vitorpamplona.amethyst.napplethost.NappletEmbedContract @@ -185,6 +186,9 @@ class EmbeddedNostrAppController( runCatching { appContext.unbindService(connection) } bound = false } + // This controller's launch token dies with it (every session it re-creates reuses the token, so it + // can't be given back any earlier). Left registered, dead tokens crowd live ones out of the registry. + NappletLaunchRegistry.unregister(params.getString(NappletHostContract.EXTRA_LAUNCH_TOKEN)) // Drop refs so an evicted controller doesn't pin the surface view or the remote messenger. serviceMessenger = null sandboxedSdkView?.setEventListener(null) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletIdentityWatch.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletIdentityWatch.kt index 97848ca09e..3e81e57a05 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletIdentityWatch.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletIdentityWatch.kt @@ -63,6 +63,11 @@ class NappletIdentityWatch( } } + /** Stops the watch started under [watchId], if any. */ + fun stop(watchId: String) { + jobs.remove(watchId)?.cancel() + } + fun stopAll() { jobs.snapshot().values.forEach { it.cancel() } jobs.clear() 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 new file mode 100644 index 0000000000..55aca57a65 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletProxyClaims.kt @@ -0,0 +1,90 @@ +/* + * 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 + +/** + * Decides the ONE proxy route shared by every WebView in the sandbox process. + * + * Android's WebView proxy override is process-global, yet Tor is chosen per surface (per site): a + * browser tab, an nSite, a full-screen page. When each surface set or cleared the override for itself, + * 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. + * + * 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. */ + 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() + + /** Files (or replaces) [owner]'s claim and returns the resulting route. */ + fun claim( + owner: Any, + claim: Claim, + ): Route { + claims.remove(owner) + claims[owner] = claim + return route() + } + + /** Withdraws [owner]'s claim (the surface is gone) and returns the resulting route. */ + fun release(owner: Any): Route { + claims.remove(owner) + 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) + } + + companion object { + const val NO_PROXY = -1 + val DIRECT = Route(NO_PROXY, emptySet()) + } +} 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 new file mode 100644 index 0000000000..ffdf897396 --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletProxyClaimsTest.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 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 +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class NappletProxyClaimsTest { + private val claims = NappletProxyClaims() + private val torTab = Any() + private val openTab = Any() + + @Test + fun noClaimsMeansNoProxy() { + assertEquals(DIRECT, claims.route()) + } + + @Test + fun anOpenWebSurfaceDoesNotDowngradeATorOne() { + claims.claim(torTab, Claim(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) + } + + @Test + fun orderDoesNotMatter() { + claims.claim(openTab, Claim(NO_PROXY, setOf("example.com"))) + assertTrue(claims.claim(torTab, Claim(9050)).usesTor) + } + + @Test + fun releasingTheLastTorSurfaceGoesDirect() { + claims.claim(torTab, Claim(9050)) + claims.claim(openTab, Claim(NO_PROXY, setOf("example.com"))) + assertEquals(DIRECT, claims.release(torTab)) + } + + @Test + fun switchingASurfaceOffTorReleasesTheProxy() { + claims.claim(torTab, Claim(9050)) + assertEquals(DIRECT, claims.claim(torTab, Claim(NO_PROXY))) + } + + @Test + fun theLatestTorPortWins() { + claims.claim(torTab, Claim(9050)) + val other = Any() + assertEquals(9150, claims.claim(other, Claim(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) + } +} 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 be0a27296c..dcf9da4487 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserActivity.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserActivity.kt @@ -75,8 +75,6 @@ import androidx.core.view.WindowCompat import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsControllerCompat import androidx.webkit.JavaScriptReplyProxy -import androidx.webkit.ProxyConfig -import androidx.webkit.ProxyController import androidx.webkit.WebMessageCompat import androidx.webkit.WebViewCompat import androidx.webkit.WebViewFeature @@ -90,6 +88,7 @@ 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.PageDialogType import com.vitorpamplona.amethyst.commons.napplet.NappletBridgeDocuments +import com.vitorpamplona.amethyst.commons.napplet.NappletProxyClaims import com.vitorpamplona.amethyst.commons.napplet.NappletWebContract import com.vitorpamplona.amethyst.commons.util.parseJsonObjectOrNull import com.vitorpamplona.amethyst.commons.util.stringOrNull @@ -98,7 +97,6 @@ import com.vitorpamplona.quartz.utils.Log import kotlinx.serialization.json.JsonObject import java.io.ByteArrayOutputStream import java.lang.ref.WeakReference -import java.util.concurrent.Executor import com.vitorpamplona.amethyst.commons.R as CommonsR /** @@ -311,7 +309,7 @@ class NappletBrowserActivity : ComponentActivity() { } shimJs = readContractAsset(NappletWebContract.SHIM_JS_PATH).decodeToString() - applyWebViewProxy(if (useTor) proxyPort else -1) + claimRoute() bindService(Intent().setClassName(this, NappletHostContract.BROKER_SERVICE_CLASS), brokerConnection, BIND_AUTO_CREATE) onBackPressedDispatcher.addCallback(this, backCallback) @@ -336,7 +334,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) } - wv.loadUrl(startUrl) + // 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) } } else { wv.url?.let { if (it.isNotBlank() && it != "about:blank") startUrl = it } } @@ -420,6 +419,7 @@ class NappletBrowserActivity : ComponentActivity() { // 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]. releaseFromBroker() + WebViewProxyPolicy.release(this) runCatching { unbindService(brokerConnection) } // A picker still up when the browser is torn down would otherwise leave its callback unanswered. pendingFileChooser.cancel() @@ -471,7 +471,12 @@ class NappletBrowserActivity : ComponentActivity() { val msg = Message.obtain(null, NappletIpc.MSG_RELEASE_CLIENT).apply { replyTo = replyMessenger - data = Bundle().apply { putString(NappletIpc.KEY_LAUNCH_TOKEN, leaseKey) } + data = + Bundle().apply { + putString(NappletIpc.KEY_LAUNCH_TOKEN, leaseKey) + // This activity's per-origin tokens die with it (a recreated activity mints its own). + if (originTokens.isNotEmpty()) putStringArray(NappletIpc.KEY_RELEASED_TOKENS, originTokens.values.toTypedArray()) + } } runCatching { broker.send(msg) } } @@ -879,10 +884,11 @@ class NappletBrowserActivity : ComponentActivity() { val resolved = OmniboxInput.resolve(text) ?: return if (resolved.forceTor && proxyPort > 0 && !useTor) { useTor = true - applyWebViewProxy(proxyPort) + claimRoute() updateChromeState { copy(torOn = true) } } - webView?.loadUrl(resolved.url) + // An onion must not leave before the Tor route it just claimed is in place. + WebViewProxyPolicy.whenApplied { webView?.loadUrl(resolved.url) } } // ---- bridge: page <-> native (mirror of NappletBrowserService.onBridgeMessage) ---- @@ -995,6 +1001,11 @@ class NappletBrowserActivity : ComponentActivity() { private fun onBrokerReply(msg: Message): Boolean { val data = msg.data ?: return true when (msg.what) { + NappletIpc.MSG_TOKEN_UNKNOWN -> { + // The broker no longer knows this token (evicted): forget it so the origin re-mints. + val token = data.getString(NappletIpc.KEY_LAUNCH_TOKEN) ?: return true + originTokens.values.removeAll { it == token } + } NappletIpc.MSG_RESPONSE -> { val brokerId = data.getString(NappletIpc.KEY_REQUEST_ID) ?: return true val payload = data.getString(NappletIpc.KEY_PAYLOAD) ?: return true @@ -1320,27 +1331,24 @@ class NappletBrowserActivity : ComponentActivity() { // ---- network ---- /** - * Routes WebView traffic through the Tor SOCKS proxy when [port] > 0, else clears the override. - * Process-global (this `:napplet` process hosts only sandbox WebViews) and best-effort. + * 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. */ - private fun applyWebViewProxy(port: Int) { - if (!WebViewFeature.isFeatureSupported(WebViewFeature.PROXY_OVERRIDE)) return - val executor = Executor { it.run() } - runCatching { - if (port > 0) { - val config = ProxyConfig.Builder().addProxyRule("socks5://127.0.0.1:$port").build() - ProxyController.getInstance().setProxyOverride(config, executor) {} - } else { - ProxyController.getInstance().clearProxyOverride(executor) {} - } - }.onFailure { Log.w(TAG, "Failed to apply WebView proxy override", it) } + 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) + } } /** Persists the per-host Tor choice in the main process and re-applies it to the live WebView. */ private fun setNetworkMode(newUseTor: Boolean) { useTor = newUseTor - applyWebViewProxy(if (useTor) proxyPort else -1) - webView?.reload() + // 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) } // 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. @@ -1627,7 +1635,7 @@ class NappletBrowserActivity : ComponentActivity() { crashView = null val wv = buildWebView() contentFrame.addView(wv, 0, FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT)) - wv.loadUrl(url) + WebViewProxyPolicy.whenApplied { if (webView === wv) wv.loadUrl(url) } } }, ) 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 7bb73b6968..ef4f84864e 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserService.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserService.kt @@ -58,15 +58,13 @@ import androidx.core.graphics.scale import androidx.core.net.toUri import androidx.privacysandbox.ui.provider.toCoreLibInfo import androidx.webkit.JavaScriptReplyProxy -import androidx.webkit.ProxyConfig -import androidx.webkit.ProxyController import androidx.webkit.WebMessageCompat import androidx.webkit.WebViewCompat -import androidx.webkit.WebViewFeature import com.vitorpamplona.amethyst.commons.browser.BrowserChrome import com.vitorpamplona.amethyst.commons.browser.BrowserSitePermission import com.vitorpamplona.amethyst.commons.browser.OmniboxInput import com.vitorpamplona.amethyst.commons.napplet.NappletBridgeDocuments +import com.vitorpamplona.amethyst.commons.napplet.NappletProxyClaims import com.vitorpamplona.amethyst.commons.napplet.NappletWebContract import com.vitorpamplona.amethyst.commons.util.parseJsonObjectOrNull import com.vitorpamplona.amethyst.commons.util.stringOrNull @@ -74,7 +72,6 @@ import com.vitorpamplona.amethyst.commons.util.withString import com.vitorpamplona.quartz.utils.Log import kotlinx.serialization.json.JsonObject import java.io.ByteArrayOutputStream -import java.util.concurrent.Executor /** * Provider for the **embedded** in-app browser. Runs in the keyless `:napplet` process: it hosts the @@ -187,17 +184,19 @@ class NappletBrowserService : Service() { override fun onBind(intent: Intent?): IBinder = incoming.binder override fun onDestroy() { + // Release before unbinding, while the broker can still hear it. + tabs.values.forEach { + it.fileChooser.cancel() + cancelPending(it) + releasePage(it, closing = true) + WebViewProxyPolicy.release(it) + it.webView?.destroy() + } + tabs.clear() if (brokerBound) { runCatching { unbindService(brokerConnection) } brokerBound = false } - tabs.values.forEach { - it.fileChooser.cancel() - cancelPending(it) - releasePage(it) - it.webView?.destroy() - } - tabs.clear() super.onDestroy() } @@ -231,7 +230,13 @@ class NappletBrowserService : Service() { val tab = tabFor(msg) ?: return true 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. - if (tab.webView == null) rebuildWebView(tab, url) else tab.webView?.loadUrl(url) + 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) } + } } NappletBrowserContract.MSG_FORWARD -> tabFor(msg)?.webView?.let { if (it.canGoForward()) it.goForward() } NappletBrowserContract.MSG_STOP -> tabFor(msg)?.webView?.stopLoading() @@ -317,11 +322,9 @@ class NappletBrowserService : Service() { NappletBrowserContract.MSG_SET_TOR -> { val tab = tabFor(msg) ?: return true tab.useTor = msg.data?.getBoolean(NappletBrowserContract.KEY_USE_TOR, false) ?: false - // Reload only after the proxy override actually applies — setProxyOverride is async, so - // reloading immediately would re-fetch through the old route. NB: the override is - // process-global (Android has no per-WebView proxy), so it affects every tab; we only - // reload the one the user toggled. - applyWebViewProxy(if (tab.useTor) tab.proxyPort else -1) { tab.webView?.reload() } + // 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() } } NappletBrowserContract.MSG_MAGNIFIER_REQUEST -> onMagnifierRequest(msg) NappletBrowserContract.MSG_FILE_CHOOSER_RESULT -> { @@ -408,7 +411,24 @@ class NappletBrowserService : Service() { // than build a WebView that no tab tracks (it would leak). val tab = tabs[sessionId] ?: error("No browser tab for session $sessionId") tab.container = container - return buildTabWebView(context, tab).also { it.loadUrl(tab.url) } + val wv = buildTabWebView(context, tab) + claimRoute(tab) { if (tab.webView === wv) wv.loadUrl(tab.url) } + return wv + } + + /** + * 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. + */ + 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) + } } /** Builds [tab]'s WebView with every client, bridge and script wired, without loading anything. */ @@ -425,7 +445,6 @@ class NappletBrowserService : Service() { // Theme the pre-load background so a blank/loading page shows Amethyst's background, not white. wv.setBackgroundColor(tab.bgColor) wv.dropSystemBarInsets() - applyWebViewProxy(if (tab.useTor) tab.proxyPort else -1) WebViewCompat.addWebMessageListener(wv, NappletWebContract.BRIDGE_NAME, setOf("*")) { view, message, sourceOrigin, isMainFrame, replyProxy -> onBridgeMessage(tab, view, message, sourceOrigin, isMainFrame, replyProxy) } @@ -447,13 +466,14 @@ class NappletBrowserService : Service() { val container = tab.container ?: return val wv = buildTabWebView(container.context, tab) container.addView(wv, 0, FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT)) - wv.loadUrl(url) + claimRoute(tab) { if (tab.webView === wv) wv.loadUrl(url) } } /** A session closed: drop the tab and destroy its own WebView (never a sibling's). */ fun onSessionClosed(sessionId: String) { val tab = tabs.remove(sessionId) ?: return - releasePage(tab) + WebViewProxyPolicy.release(tab) + releasePage(tab, closing = true) tab.bridge.clear() // Release a picker still waiting on this surface before its WebView goes away. tab.fileChooser.cancel() @@ -928,34 +948,6 @@ class NappletBrowserService : Service() { runCatching { tab?.clientMessenger?.send(message) } } - /** - * Routes WebView traffic through the Tor SOCKS proxy when [port] > 0, else clears the override. - * [onApplied] runs on the main thread once the override is in effect (the WebKit callback is async, - * so callers that reload must wait for it). Process-global (this `:napplet` process hosts only - * sandbox WebViews) and best-effort. - */ - private fun applyWebViewProxy( - port: Int, - onApplied: () -> Unit = {}, - ) { - if (!WebViewFeature.isFeatureSupported(WebViewFeature.PROXY_OVERRIDE)) { - onApplied() - return - } - val executor = Executor { it.run() } - runCatching { - if (port > 0) { - val config = ProxyConfig.Builder().addProxyRule("socks5://127.0.0.1:$port").build() - ProxyController.getInstance().setProxyOverride(config, executor) { onApplied() } - } else { - ProxyController.getInstance().clearProxyOverride(executor) { onApplied() } - } - }.onFailure { - Log.w(TAG, "Failed to apply WebView proxy override", it) - onApplied() - } - } - /** * A top-frame NIP-07 call from [sourceOrigin]. The origin is the trusted value the WebView reports * (the page can't forge it), so it keys consent; each origin uses its own broker-minted launch token. @@ -1031,14 +1023,28 @@ class NappletBrowserService : Service() { * The page on [tab] is gone (navigated away, renderer died, session closed): drop its requests still * waiting for a token or the broker, and have the broker close the live relay / inc subscriptions it * opened — otherwise their events would keep streaming into whatever page comes next. + * + * [closing]: the whole tab is going away, so its per-origin launch tokens are given back too (a closed + * tab never re-uses them; a new session mints its own). Otherwise they'd sit in the broker's registry + * until evicted, pushing live tabs' tokens out first. */ - private fun releasePage(tab: BrowserTab) { + private fun releasePage( + tab: BrowserTab, + closing: Boolean = false, + ) { tab.pendingByOrigin.clear() // A mint the broker never answered (none is sent while logged out) would otherwise block the // origin for the tab's life; the next page asks again. tab.mintInFlight.clear() pendingBrokerRequests.removeAll { it.what == NappletIpc.MSG_REQUEST && it.replyTo == tab.replyMessenger } - val release = Message.obtain(null, NappletIpc.MSG_RELEASE_CLIENT).apply { replyTo = tab.replyMessenger } + val release = + Message.obtain(null, NappletIpc.MSG_RELEASE_CLIENT).apply { + replyTo = tab.replyMessenger + if (closing && tab.originTokens.isNotEmpty()) { + data = Bundle().apply { putStringArray(NappletIpc.KEY_RELEASED_TOKENS, tab.originTokens.values.toTypedArray()) } + } + } + if (closing) tab.originTokens.clear() if (brokerMessenger != null) sendToBroker(release) } @@ -1140,6 +1146,11 @@ class NappletBrowserService : Service() { val payload = data.getString(NappletIpc.KEY_PAYLOAD) ?: return true runCatching { tab.bridge.currentProxy?.postMessage(payload) } } + NappletIpc.MSG_TOKEN_UNKNOWN -> { + // The broker no longer knows this token (evicted): forget it so the origin re-mints. + val token = data.getString(NappletIpc.KEY_LAUNCH_TOKEN) ?: return true + tab.originTokens.values.removeAll { it == token } + } NappletIpc.MSG_BROWSER_TOKEN -> { val origin = data.getString(NappletIpc.KEY_BROWSER_ORIGIN) ?: return true val token = data.getString(NappletIpc.KEY_LAUNCH_TOKEN) ?: return true 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 d1eb8f8e4f..a75c0c2f6c 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostActivity.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostActivity.kt @@ -58,8 +58,6 @@ import androidx.activity.ComponentActivity import androidx.activity.OnBackPressedCallback import androidx.core.content.ContextCompat import androidx.webkit.JavaScriptReplyProxy -import androidx.webkit.ProxyConfig -import androidx.webkit.ProxyController import androidx.webkit.WebMessageCompat import androidx.webkit.WebViewCompat import androidx.webkit.WebViewFeature @@ -89,7 +87,6 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import kotlinx.serialization.json.JsonObject import java.lang.ref.WeakReference -import java.util.concurrent.Executor import com.vitorpamplona.amethyst.commons.R as CommonsR /** @@ -303,7 +300,7 @@ class NappletHostActivity : ComponentActivity() { // Route the WebView's own (off-origin) traffic through Tor for an nSite, unless this site was // opted out to the open web. Set process-wide before any page navigation; the shell + blobs are // served from cache via shouldInterceptRequest, so only the site's external requests hit this. - if (profile.exposesNetwork) applyWebViewProxy(effectiveProxy) + if (profile.exposesNetwork) WebViewProxyPolicy.claim(this, effectiveProxy) // Origin-restricted bridge: only the trusted shell page (main frame) can reach native. WebViewCompat.addWebMessageListener( webView, @@ -368,7 +365,8 @@ class NappletHostActivity : ComponentActivity() { contentFrame.addView(webView, 0, FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT)) if (!started) { started = true - webView.loadUrl(NappletWebContract.SHELL_URL) + // 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) } } } @@ -455,6 +453,7 @@ class NappletHostActivity : ComponentActivity() { // 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. releaseFromBroker() + WebViewProxyPolicy.release(this) // unbind is in runCatching: if the index never resolved we never bound the broker. runCatching { unbindService(brokerConnection) } keyActions.clear() @@ -569,25 +568,6 @@ class NappletHostActivity : ComponentActivity() { webView.webChromeClient = NappletWebChromeClient() } - /** - * Routes this process's WebView traffic through the Tor SOCKS proxy when [port] > 0, else clears any - * override so the site loads over the open web. Process-global (this `:napplet` process hosts only - * applet/site WebViews) and best-effort: a device whose WebView can't honor a SOCKS proxy falls back - * to direct — verified on-device, since SOCKS-over-WebView support varies by WebView version. - */ - private fun applyWebViewProxy(port: Int) { - if (!WebViewFeature.isFeatureSupported(WebViewFeature.PROXY_OVERRIDE)) return - val executor = Executor { it.run() } - runCatching { - if (port > 0) { - val config = ProxyConfig.Builder().addProxyRule("socks5://127.0.0.1:$port").build() - ProxyController.getInstance().setProxyOverride(config, executor) {} - } else { - ProxyController.getInstance().clearProxyOverride(executor) {} - } - }.onFailure { Log.w(TAG, "Failed to apply WebView proxy override", it) } - } - /** Serves only the trusted shell and the manifest's verified blobs; everything else 404s. */ private inner class NappletWebViewClient : WebViewClient() { override fun shouldInterceptRequest( 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 1a1086aae0..9887649bcd 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostService.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostService.kt @@ -56,8 +56,6 @@ import androidx.core.graphics.createBitmap import androidx.core.net.toUri import androidx.privacysandbox.ui.provider.toCoreLibInfo import androidx.webkit.JavaScriptReplyProxy -import androidx.webkit.ProxyConfig -import androidx.webkit.ProxyController import androidx.webkit.WebMessageCompat import androidx.webkit.WebViewCompat import androidx.webkit.WebViewFeature @@ -73,7 +71,6 @@ import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.sha256.sha256 import kotlinx.serialization.json.JsonObject import java.io.ByteArrayOutputStream -import java.util.concurrent.Executor /** * Provider for an **embedded** nsite/napplet tab — the in-app-tab counterpart of [NappletHostActivity]. @@ -171,16 +168,19 @@ class NappletHostService : Service() { override fun onBind(intent: Intent?): IBinder = incoming.binder override fun onDestroy() { - if (brokerBound) { - runCatching { unbindService(brokerConnection) } - brokerBound = false - } + // Release before unbinding, while the broker can still hear it. tabs.values.forEach { + WebViewProxyPolicy.release(it) + releaseFromBroker(it) it.fileChooser.cancel() it.contentServer?.close() it.webView?.destroy() } tabs.clear() + if (brokerBound) { + runCatching { unbindService(brokerConnection) } + brokerBound = false + } super.onDestroy() } @@ -388,13 +388,18 @@ class NappletHostService : Service() { // Theme the pre-load background so the shell/app loading shows Amethyst's background, not white. wv.setBackgroundColor(tab.bgColor) wv.dropSystemBarInsets() - if (tab.profile.exposesNetwork) applyWebViewProxy(effectiveProxy) WebViewCompat.addWebMessageListener(wv, NappletWebContract.BRIDGE_NAME, setOf(NappletWebContract.ORIGIN), ::onShellMessage) wv.setFindListener { active, total, _ -> pushFindResult(tab, active, total) } if (tab.textZoom != BrowserChrome.DEFAULT_TEXT_ZOOM) BrowserWebTools.setTextZoom(wv, tab.textZoom) tab.webView = wv if (tab.paused) wv.onPause() - wv.loadUrl(NappletWebContract.SHELL_URL) + 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) } + } else { + wv.loadUrl(NappletWebContract.SHELL_URL) + } return wv } @@ -402,6 +407,8 @@ class NappletHostService : Service() { fun onSessionClosed(sessionId: String) { val tab = tabs.remove(sessionId) ?: return tab.bridgeReplyProxy = null + WebViewProxyPolicy.release(tab) + releaseFromBroker(tab) // Release a picker still waiting on this surface before its WebView goes away. tab.fileChooser.cancel() tab.contentServer?.close() @@ -532,19 +539,6 @@ class NappletHostService : Service() { return true } - private fun applyWebViewProxy(port: Int) { - if (!WebViewFeature.isFeatureSupported(WebViewFeature.PROXY_OVERRIDE)) return - val executor = Executor { it.run() } - runCatching { - if (port > 0) { - val config = ProxyConfig.Builder().addProxyRule("socks5://127.0.0.1:$port").build() - ProxyController.getInstance().setProxyOverride(config, executor) {} - } else { - ProxyController.getInstance().clearProxyOverride(executor) {} - } - }.onFailure { Log.w(TAG, "Failed to apply WebView proxy override", it) } - } - /** Serves only the trusted shell and the manifest's verified blobs; external links go to the system. */ private inner class HostClient( private val tab: NappletTab, @@ -738,6 +732,18 @@ class NappletHostService : Service() { if (brokerMessenger == null) pendingBrokerRequests.add(msg) else sendToBroker(msg) } + /** + * [tab] is gone: have the broker close the live relay / inc subscriptions it opened and drop its reply + * Messenger (a binder the main process would otherwise hold, keeping the tab alive). The launch token is + * NOT given back: the main-process controller re-creates sessions with the same token, and gives it + * back itself when it is torn down. + */ + private fun releaseFromBroker(tab: NappletTab) { + pendingBrokerRequests.removeAll { it.replyTo == tab.replyMessenger } + if (brokerMessenger == null) return + sendToBroker(Message.obtain(null, NappletIpc.MSG_RELEASE_CLIENT).apply { replyTo = tab.replyMessenger }) + } + private fun sendToBroker(msg: Message) { try { brokerMessenger?.send(msg) 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 3aeafdf70b..4066474a23 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletIpc.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletIpc.kt @@ -109,8 +109,10 @@ object NappletIpc { /** * Host → broker: this surface is being destroyed — drop every reference the broker holds to its - * `replyTo` [android.os.Messenger] (inc-bus topic subscriptions, and its foreground lease when - * [KEY_LAUNCH_TOKEN] is supplied). + * `replyTo` [android.os.Messenger] (inc-bus topic subscriptions, live relay subscriptions, and its + * foreground lease when [KEY_LAUNCH_TOKEN] is supplied). [KEY_RELEASED_TOKENS], when present, lists + * launch tokens the surface minted and will never use again (a closed browser tab's per-origin + * tokens); the broker unregisters them so dead sessions stop crowding live ones out of the registry. * * A `Messenger` handed to the broker is a **binder**, so the main process holding it keeps a JNI * global reference alive in `:napplet`. Because the sandbox's reply handler is a bound method @@ -157,6 +159,13 @@ object NappletIpc { */ const val MSG_ADD_TO_HOME_SCREEN = 19 + /** + * Broker → host: a request carried [KEY_LAUNCH_TOKEN] the registry no longer knows (it was evicted, or + * released). The request itself was answered with a failure; a browser host drops that token so the + * origin's next call mints a fresh one instead of failing forever. + */ + const val MSG_TOKEN_UNKNOWN = 20 + const val KEY_REQUEST_ID = "requestId" const val KEY_PAYLOAD = "payload" @@ -208,4 +217,7 @@ object NappletIpc { * the trusted identity + declared capability set the launch was registered with. */ const val KEY_LAUNCH_TOKEN = "launchToken" + + /** Launch tokens a [MSG_RELEASE_CLIENT] gives back (a string array). */ + const val KEY_RELEASED_TOKENS = "releasedTokens" } diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/WebViewProxyPolicy.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/WebViewProxyPolicy.kt new file mode 100644 index 0000000000..d213ed2043 --- /dev/null +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/WebViewProxyPolicy.kt @@ -0,0 +1,140 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.napplethost + +import 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 + +/** + * 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). + * + * 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. + * + * Main thread only. + */ +object WebViewProxyPolicy { + private const val TAG = "WebViewProxyPolicy" + + 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 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. + */ + fun claim( + owner: Any, + torPort: Int, + directHosts: Set = emptySet(), + onReady: () -> Unit = {}, + ) { + claims.claim(owner, Claim(torPort, directHosts)) + sync(onReady) + } + + /** Withdraws [owner]'s claim; the route relaxes once no remaining surface needs it. */ + fun release(owner: Any) { + claims.release(owner) + sync {} + } + + /** + * 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) + } + + /** 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 + } + val target = claims.route() + if (target == applied && applying == null) { + onReady() + return + } + waiting += onReady + 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() } + } + } + } + + private fun apply( + route: Route, + onApplied: () -> 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) + } else { + ProxyController.getInstance().clearProxyOverride(executor, onApplied) + } + }.onFailure { + Log.w(TAG, "Failed to apply WebView proxy override", it) + onApplied() + } + } +} From 8ac8ca32c310fea7f73a01462769a90142526a46 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 12:29:15 +0000 Subject: [PATCH 05/13] fix(browser): close the remaining embedded-tab audit items - Session close race: a session's close now names its own container, and the provider ignores a close from a session that was already replaced by a re-open (it used to reap the live one, leaving the tab black for good). Opening a session destroys any WebView left from the previous one, and close no longer posts to the main thread when it is already on it. - A parked popup (before an Activity adopts it) handles its renderer's death itself; the default killed :napplet and every tab in it. - Magnifier: the frame is capped at 512 px a side and encoded as lossy WebP on a background thread (was a quality-100 PNG on the main thread every drag frame, which all sandboxed surfaces render on); the client decodes off the main thread and drops out-of-order frames. - Hidden browser tabs are paused (MSG_PAUSE / MSG_RESUME), like napplets; a pause sent before the WebView exists is applied when it is built. - A non-bar tab covered by a pushed screen (even its own Site settings) stays warm until its back-stack entry is destroyed, instead of restarting when the user comes back. - After a :napplet restart only the visible tab re-creates its session; hidden ones re-create when next shown, instead of every warm tab reloading at once right after the OS reclaimed that memory. - Controllers keep the page's URL, title, back/forward, text zoom, desktop mode and Tor state; screens seed from them, so returning to a bar tab no longer resets the address (share / favorite / site settings acted on the start URL) or makes Back leave the tab instead of going back. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01G3bteStHvaf18TdABSkb8h --- .../browser/EmbeddedWebAppController.kt | 60 +++++++++++- .../screen/loggedIn/browser/WebAppScreen.kt | 43 ++++---- .../loggedIn/embed/EmbeddedAutoRecovery.kt | 3 + .../loggedIn/embed/EmbeddedMagnifier.kt | 5 + .../screen/loggedIn/embed/EmbeddedTabHost.kt | 48 ++++++++- .../screen/loggedIn/embed/EmbeddedTabLayer.kt | 14 ++- .../favorites/EmbeddedNostrAppController.kt | 31 +++++- .../loggedIn/favorites/NostrAppScreen.kt | 17 ++-- .../amethyst/napplethost/BrowserPopups.kt | 16 +++ .../amethyst/napplethost/MagnifierCapture.kt | 90 +++++++++++++++++ .../napplethost/NappletBrowserContract.kt | 10 ++ .../napplethost/NappletBrowserService.kt | 97 +++++++++++-------- .../napplethost/NappletBrowserUiAdapter.kt | 11 ++- .../napplethost/NappletHostService.kt | 82 ++++++++-------- .../napplethost/NappletHostUiAdapter.kt | 11 ++- 15 files changed, 414 insertions(+), 124 deletions(-) create mode 100644 nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/MagnifierCapture.kt 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 20b8dec4fc..0fb65d155c 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 @@ -109,6 +109,13 @@ class EmbeddedWebAppController( // Set after the first connection, so a later onServiceConnected is recognised as `:napplet` coming back. private var everConnected = false + // A `:napplet` restart found this tab hidden: its session is re-created when it is next shown. + private var createOnShow = false + + // A parked tab can be hidden (paused) before the service even binds, so the pause is remembered and + // replayed right after each session is created. + private var wantPaused = false + /** Last known main-frame load state, so the tab layer renders the right overlay immediately. */ override var loadStatus: EmbeddedLoadStatus = EmbeddedLoadStatus() private set @@ -130,6 +137,23 @@ class EmbeddedWebAppController( private var textZoom = BrowserChrome.DEFAULT_TEXT_ZOOM private var desktopSite = false + // The page on screen, kept here rather than in the tab's screen: the screen leaves composition whenever + // the user switches bottom-bar tabs, and coming back must show where they were (the right address for + // share / favorite / site settings, and a Back that goes back in the page instead of leaving the tab). + var lastUrl: String? = null + private set + var lastTitle: String? = null + private set + var lastCanGoBack = false + private set + var lastCanGoForward = false + private set + + /** The user's per-tab settings as last set, for a screen coming back to this tab. */ + val isTorOn: Boolean get() = useTor + val isDesktopSite: Boolean get() = desktopSite + val currentTextZoom: Int get() = textZoom + // A single NappletBrowserService instance serves every embedded browser tab, so each controller // 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 @@ -172,12 +196,17 @@ class EmbeddedWebAppController( ) { serviceMessenger = Messenger(service) if (everConnected) { - // `:napplet` died and was restarted. The create below IS the recovery (a fresh process - // has no session under any id), so nothing is left pending; cover the surface until the - // new page paints. + // `:napplet` died and was restarted. Re-creating the session IS the recovery (a fresh + // process has no session under any id), so nothing else is left pending; cover the + // surface until the new page paints. Only the visible tab rebuilds now: every warm tab + // reconnects at once, and rebuilding them all right after the OS reclaimed that memory + // would just push it back up. The rest re-create when next shown. recovery.clearPending() sessionDead = false showRecovering() + everConnected = true + if (recovery.isShown) sendCreateSession() else createOnShow = true + return } everConnected = true sendCreateSession() @@ -232,10 +261,24 @@ class EmbeddedWebAppController( override fun teardown() = unbind() override fun onShown() { - if (recovery.onShown()) recover() + wantPaused = false + send(NappletBrowserContract.MSG_RESUME) {} + val deferredRecovery = recovery.onShown() + if (createOnShow) { + createOnShow = false + sendCreateSession() + } else if (deferredRecovery) { + recover() + } } - override fun onHidden() = recovery.onHidden() + override fun onHidden() { + // A warm tab parked off-screen keeps no animations, media or geolocation running (napplets are + // paused the same way). + wantPaused = true + send(NappletBrowserContract.MSG_PAUSE) {} + recovery.onHidden() + } /** * Hands the surface view to the controller; applies the adapter if it already arrived, and re-arms the @@ -367,6 +410,7 @@ class EmbeddedWebAppController( // Messenger keeps order, so these land after the CREATE and are stored on the new tab. if (textZoom != BrowserChrome.DEFAULT_TEXT_ZOOM) setTextZoom(textZoom) if (desktopSite) setDesktopSite(true) + if (wantPaused) send(NappletBrowserContract.MSG_PAUSE) {} } private fun onServiceMessage(msg: Message): Boolean { @@ -387,6 +431,12 @@ class EmbeddedWebAppController( val canGoBack = msg.data?.getBoolean(NappletBrowserContract.KEY_CAN_GO_BACK, false) ?: false val canGoForward = msg.data?.getBoolean(NappletBrowserContract.KEY_CAN_GO_FORWARD, false) ?: false val title = msg.data?.getString(NappletBrowserContract.KEY_TITLE) + if (url != "about:blank") { + lastUrl = url + lastTitle = title + } + lastCanGoBack = canGoBack + lastCanGoForward = canGoForward onUrlChanged?.invoke(url, title, canGoBack, canGoForward) } NappletBrowserContract.MSG_IME_EVENT -> { 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 692a141adf..617ea5a1f2 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 @@ -56,6 +56,7 @@ import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties import androidx.core.content.ContextCompat import androidx.core.net.toUri +import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.R @@ -128,23 +129,9 @@ private fun EmbeddedWebAppTab( // Matches FavoriteApp.WebApp.id, so warm-keep membership lines up with the bottom-bar favorites. val id = "url:$url" - var currentUrl by remember { mutableStateOf(url) } - // The page's own ; null until the current document reports one (the sheet shows the host). - var pageTitle by remember { mutableStateOf<String?>(null) } - var canGoBack by remember { mutableStateOf(false) } - var canGoForward by remember { mutableStateOf(false) } - var desktopSite by remember { mutableStateOf(false) } - var textZoom by remember { mutableIntStateOf(BrowserChrome.DEFAULT_TEXT_ZOOM) } var showPageInfo by remember { mutableStateOf(false) } val proxyAvailable = remember { Amethyst.instance.torManager.activePortOrNull.value != null } - // Start from this site's remembered Tor choice (some sites' servers reject Tor exits, so the user - // can opt one out and it must stick). Only meaningful when Tor is actually available. - var torOn by remember { mutableStateOf(proxyAvailable && WebAppNetworkRegistry.useTor(url)) } - - val apps by Amethyst.instance.favoriteApps.favorites - .collectAsStateWithLifecycle() - val isFavorite = remember(apps, currentUrl) { apps.any { it is FavoriteApp.WebApp && it.url == currentUrl } } val backgroundColor = MaterialTheme.colorScheme.background.toArgb() @@ -154,6 +141,23 @@ private fun EmbeddedWebAppTab( remember(id, EmbeddedTabHost.rebuildEpoch) { EmbeddedTabFactory.acquireWebApp(context, url, backgroundColor) } + + // Seeded from the controller, which outlives this screen: switching bottom-bar tabs disposes the screen + // but keeps the page, so coming back must show where the page is — not the start URL with no history. + var currentUrl by remember(controller) { mutableStateOf(controller.lastUrl ?: url) } + // The page's own <title>; null until the current document reports one (the sheet shows the host). + var pageTitle by remember(controller) { mutableStateOf(controller.lastTitle) } + var canGoBack by remember(controller) { mutableStateOf(controller.lastCanGoBack) } + var canGoForward by remember(controller) { mutableStateOf(controller.lastCanGoForward) } + var desktopSite by remember(controller) { mutableStateOf(controller.isDesktopSite) } + var textZoom by remember(controller) { mutableIntStateOf(controller.currentTextZoom) } + // The controller starts from this site's remembered Tor choice (some sites' servers reject Tor exits, so + // the user can opt one out and it must stick). Only meaningful when Tor is actually available. + var torOn by remember(controller) { mutableStateOf(proxyAvailable && controller.isTorOn) } + + val apps by Amethyst.instance.favoriteApps.favorites + .collectAsStateWithLifecycle() + val isFavorite = remember(apps, currentUrl) { apps.any { it is FavoriteApp.WebApp && it.url == currentUrl } } val isLoading by controller.isLoading // Keep the URL/back callback fresh (cheap, needs the latest closure). @@ -288,16 +292,19 @@ private fun EmbeddedWebAppTab( SideEffect { EmbeddedTabHost.setActiveChrome(id, chrome) } val bottomBarFlow = accountViewModel.account.settings.syncedSettings.navigation.bottomBarItems + val entryLifecycle = LocalLifecycleOwner.current.lifecycle DisposableEffect(id) { val token = EmbeddedTabHost.setActive(id) EmbeddedTabHost.hold(id) onDispose { EmbeddedTabHost.clearActiveIfOwner(token) EmbeddedTabHost.clearActiveChrome(id) - // Only bottom-row apps stay warm; anything else restarts when it leaves — unless a re-navigation - // to this same tab already composed a new screen on the same session. - val lastHolder = EmbeddedTabHost.release(id) - if (lastHolder && id !in bottomBarFlow.value.favoriteIds()) EmbeddedTabHost.evict(id) + // Only bottom-row apps stay warm; anything else restarts once the user actually leaves it — not + // when a screen is merely pushed on top, and not when a re-navigation to this same tab already + // composed a new screen on the same session. + if (EmbeddedTabHost.release(id)) { + EmbeddedTabHost.releaseWhenGone(id, entryLifecycle) { id in bottomBarFlow.value.favoriteIds() } + } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedAutoRecovery.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedAutoRecovery.kt index c56b05b9b7..22480b2fe1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedAutoRecovery.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedAutoRecovery.kt @@ -50,6 +50,9 @@ class EmbeddedAutoRecovery( } private var shown = false + + /** Whether the tab is the visible one (per the last [onShown] / [onHidden]). */ + val isShown: Boolean get() = shown private var pending = false private var lastAutoRecoveryAt: Long? = null diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedMagnifier.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedMagnifier.kt index f4762f77ef..f19eea700a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedMagnifier.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedMagnifier.kt @@ -62,10 +62,15 @@ class MagnifierUiState { var lastRequestUptimeMs: Long = 0L var awaitingFrame: Boolean = false + // Request stamp of the frame on screen. Frames decode off the main thread and can finish out of order, + // so an older one must not replace a newer one. + var shownFrameStamp: Long = Long.MIN_VALUE + fun hide() { visible = false image = null awaitingFrame = false + shownFrameStamp = Long.MIN_VALUE } } 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 8c2dac1700..9fb2ef3468 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 @@ -27,6 +27,9 @@ import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.compose.ui.geometry.Rect +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.LifecycleOwner /** * Process-level holder of **warm embedded sessions** — the persistent-surface-layer half of keep-warm. @@ -167,6 +170,7 @@ object EmbeddedTabHost { fun takeKeyboardRestore(id: String): Boolean = keyboardUpOnLeave.remove(id) fun evict(id: String) { + parked.remove(id) val w = warm.firstOrNull { it.id == id } ?: return if (activeId == id) activeId = null keyboardUpOnLeave.remove(id) @@ -194,6 +198,48 @@ object EmbeddedTabHost { /** A screen showing [id] entered composition. Pair with [release]. */ fun hold(id: String) { holders[id] = (holders[id] ?: 0) + 1 + parked.remove(id) + } + + // Non-bar tabs whose screen left composition while their back-stack entry lives on — another screen + // was pushed on top (even the tab's own Site settings). They stay warm until that entry is destroyed. + private val parked = mutableSetOf<String>() + + /** + * The last screen showing [id] left composition. A bottom-bar tab ([keepWarm]) stays warm. Any other tab + * goes once its back-stack entry ([entry]'s lifecycle) is destroyed — right away when it already is (the + * user left it), or later when merely covered by a pushed screen, so coming back doesn't restart the + * page. + */ + fun releaseWhenGone( + id: String, + entry: Lifecycle, + keepWarm: () -> Boolean, + ) { + 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) + } + if (entry.currentState == Lifecycle.State.DESTROYED) { + gone() + return + } + parked.add(id) + entry.addObserver( + object : LifecycleEventObserver { + override fun onStateChanged( + source: LifecycleOwner, + event: Lifecycle.Event, + ) { + if (event != Lifecycle.Event.ON_DESTROY) return + entry.removeObserver(this) + gone() + } + }, + ) } /** A screen showing [id] left composition. Returns true when no other screen still shows it. */ @@ -206,7 +252,7 @@ object EmbeddedTabHost { /** Drops every warm session whose id isn't in [keep] (bottom-row membership + the active tab). */ fun retainOnly(keep: Set<String>) { warm - .filter { it.id !in keep } + .filter { it.id !in keep && it.id !in parked } .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 244d2ddf67..e72032277e 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 @@ -62,6 +62,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.key import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment @@ -91,7 +92,10 @@ 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 import com.vitorpamplona.amethyst.napplethost.BrowserWebTools +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.put import kotlin.math.roundToInt @@ -534,11 +538,19 @@ fun EmbeddedTabLayer(barFavoriteIds: List<String>) { // Source rect (surface px) = bubble px / zoom, so the provider-scaled frame lands ≈ bubble-sized. val magSrcW = with(density) { (magBubble.width.toPx() / magZoom).roundToInt() } val magSrcH = with(density) { (magBubble.height.toPx() / magZoom).roundToInt() } + val magScope = rememberCoroutineScope() DisposableEffect(magProbe) { magProbe?.onMagnifierFrame = { frame -> if (magnifier.visible) { magnifier.awaitingFrame = false - BitmapFactory.decodeByteArray(frame.bytes, 0, frame.bytes.size)?.let { magnifier.image = it.asImageBitmap() } + // Decode off the main thread: this runs for every frame of a handle drag. + magScope.launch { + val image = withContext(Dispatchers.Default) { BitmapFactory.decodeByteArray(frame.bytes, 0, frame.bytes.size)?.asImageBitmap() } + if (image != null && magnifier.visible && frame.requestStampNanos > magnifier.shownFrameStamp) { + magnifier.shownFrameStamp = frame.requestStampNanos + magnifier.image = image + } + } } } onDispose { 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 6a4ea90a60..77c6385691 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 @@ -124,6 +124,9 @@ class EmbeddedNostrAppController( // Set after the first connection, so a later onServiceConnected is recognised as `:napplet` coming back. private var everConnected = false + // A `:napplet` restart found this tab hidden: its session is re-created when it is next shown. + private var createOnShow = false + /** Last known main-frame load state, so the tab layer renders the right overlay immediately. */ override var loadStatus: EmbeddedLoadStatus = EmbeddedLoadStatus() private set @@ -146,6 +149,14 @@ class EmbeddedNostrAppController( // restart, a rearm), so it is re-sent with every create. private var textZoom = BrowserChrome.DEFAULT_TEXT_ZOOM + /** The user's text zoom as last set, for a screen coming back to this tab. */ + val currentTextZoom: Int get() = textZoom + + // Whether the app can go back, kept here rather than in the tab's screen (which leaves composition on + // every bottom-bar switch), so coming back keeps Back working inside the app. + var lastCanGoBack = false + private set + private val _findResult = mutableStateOf<FindResult?>(null) override val findResult: State<FindResult?> = _findResult @@ -157,12 +168,17 @@ class EmbeddedNostrAppController( ) { serviceMessenger = Messenger(service) if (everConnected) { - // `:napplet` died and was restarted. The create below IS the recovery (a fresh process - // has no session under any id), so nothing is left pending; cover the surface until the - // new page paints. + // `:napplet` died and was restarted. Re-creating the session IS the recovery (a fresh + // process has no session under any id), so nothing else is left pending; cover the + // surface until the new page paints. Only the visible tab rebuilds now: every warm tab + // reconnects at once, and rebuilding them all right after the OS reclaimed that memory + // would just push it back up. The rest re-create when next shown. recovery.clearPending() sessionDead = false showRecovering() + everConnected = true + if (recovery.isShown) sendCreateSession() else createOnShow = true + return } everConnected = true sendCreateSession() @@ -308,7 +324,13 @@ class EmbeddedNostrAppController( override fun onShown() { resume() - if (recovery.onShown()) recover() + val deferredRecovery = recovery.onShown() + if (createOnShow) { + createOnShow = false + sendCreateSession() + } else if (deferredRecovery) { + recover() + } } override fun onHidden() { @@ -355,6 +377,7 @@ class EmbeddedNostrAppController( } NappletEmbedContract.MSG_STATE -> { val canGoBack = msg.data?.getBoolean(NappletEmbedContract.KEY_CAN_GO_BACK, false) ?: false + lastCanGoBack = canGoBack onStateChanged?.invoke(canGoBack) } NappletEmbedContract.MSG_NOTICE -> { 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 c237c85d30..357427f359 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 @@ -153,9 +153,7 @@ private fun EmbeddedNostrAppTab( val torOn = if (profile.exposesNetwork && params.getInt(NappletHostContract.EXTRA_PROXY_PORT, -1) > 0) useTor else null val scope = rememberCoroutineScope() - var canGoBack by remember { mutableStateOf(false) } var showAccess by remember { mutableStateOf(false) } - var textZoom by remember(coordinate) { mutableIntStateOf(BrowserChrome.DEFAULT_TEXT_ZOOM) } val apps by Amethyst.instance.favoriteApps.favorites .collectAsStateWithLifecycle() @@ -165,6 +163,10 @@ private fun EmbeddedNostrAppTab( remember(id, EmbeddedTabHost.rebuildEpoch, networkEpoch) { EmbeddedTabFactory.acquireNostrApp(context, coordinate, params, backgroundColor) } + // Seeded from the controller, which outlives this screen (it leaves composition on every bottom-bar + // switch), so coming back keeps Back working inside the app and the pill showing the zoom in effect. + var canGoBack by remember(controller) { mutableStateOf(controller.lastCanGoBack) } + var textZoom by remember(controller) { mutableIntStateOf(controller.currentTextZoom) } // Keep the controller callbacks fresh (cheap, need the latest closures). SideEffect { @@ -235,16 +237,19 @@ private fun EmbeddedNostrAppTab( SideEffect { EmbeddedTabHost.setActiveChrome(id, chrome) } val bottomBarFlow = accountViewModel.account.settings.syncedSettings.navigation.bottomBarItems + val entryLifecycle = LocalLifecycleOwner.current.lifecycle DisposableEffect(id) { val token = EmbeddedTabHost.setActive(id) EmbeddedTabHost.hold(id) onDispose { EmbeddedTabHost.clearActiveIfOwner(token) EmbeddedTabHost.clearActiveChrome(id) - // Only bottom-row apps stay warm; anything else restarts when it leaves — unless a re-navigation - // to this same tab already composed a new screen on the same session. - val lastHolder = EmbeddedTabHost.release(id) - if (lastHolder && id !in bottomBarFlow.value.favoriteIds()) EmbeddedTabHost.evict(id) + // Only bottom-row apps stay warm; anything else restarts once the user actually leaves it — not + // when a screen is merely pushed on top, and not when a re-navigation to this same tab already + // composed a new screen on the same session. + if (EmbeddedTabHost.release(id)) { + EmbeddedTabHost.releaseWhenGone(id, entryLifecycle) { id in bottomBarFlow.value.favoriteIds() } + } } } diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/BrowserPopups.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/BrowserPopups.kt index 3a2bbd1c07..f052b8caf7 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/BrowserPopups.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/BrowserPopups.kt @@ -25,7 +25,9 @@ import android.content.MutableContextWrapper import android.net.Uri import android.os.Handler import android.os.Looper +import android.webkit.RenderProcessGoneDetail import android.webkit.WebView +import android.webkit.WebViewClient import androidx.webkit.JavaScriptReplyProxy import androidx.webkit.WebMessageCompat import androidx.webkit.WebViewCompat @@ -122,6 +124,20 @@ object BrowserPopups { } WebViewCompat.addDocumentStartJavaScript(webView, BrowserWebTools.browserStartScript(shimJs, imeProxy = false), setOf("*")) val token = UUID.randomUUID().toString() + // Until an Activity adopts it (and installs its own client), a parked popup still shares the one + // `:napplet` renderer. With no client its renderer death falls to the default — returning false, which + // kills the whole process and every embedded tab in it. Drop just the popup instead. + webView.webViewClient = + object : WebViewClient() { + override fun onRenderProcessGone( + view: WebView, + detail: RenderProcessGoneDetail, + ): Boolean { + pending.remove(token) + view.destroy() + return true + } + } pending[token] = entry main.postDelayed({ pending.remove(token)?.let { orphan -> diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/MagnifierCapture.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/MagnifierCapture.kt new file mode 100644 index 0000000000..13434c605c --- /dev/null +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/MagnifierCapture.kt @@ -0,0 +1,90 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.napplethost + +import android.graphics.Bitmap +import android.graphics.Canvas +import android.os.Build +import android.os.Handler +import android.os.Looper +import android.os.SystemClock +import android.webkit.WebView +import androidx.annotation.RequiresApi +import androidx.core.graphics.createBitmap +import java.io.ByteArrayOutputStream +import java.util.concurrent.Executors + +/** + * Captures the selection loupe's magnified slice of an embedded page for the main process. + * + * Host-side `PixelCopy` can't read the sandbox surface, so the page is drawn here. That draw has to happen + * on the main thread — the one thread every sandboxed surface in `:napplet` renders on — so everything else + * is kept off it: the frame is capped at [MAX_SIDE_PX] a side (a large box at high zoom used to allocate + * tens of MB and produce a frame too big for the binder, which was silently dropped), and the encode runs on + * a background thread as lossy WebP (a quality-100 PNG, per drag frame, on the main thread, was the cost). + */ +@RequiresApi(Build.VERSION_CODES.R) +internal object MagnifierCapture { + private const val MAX_SIDE_PX = 512 + private const val QUALITY = 85 + + private val encoder = Executors.newSingleThreadExecutor { Thread(it, "napplet-magnifier").apply { isDaemon = true } } + private val main = Handler(Looper.getMainLooper()) + + /** + * Draws the [boxW]×[boxH] source rect centered on ([cx], [cy]) (view px) of [webView] at [zoom] over + * [bgColor], then — on the main thread, once encoded — hands [deliver] the image bytes, their size, and + * the capture time in ms. Main thread only. + */ + fun capture( + webView: WebView, + bgColor: Int, + cx: Float, + cy: Float, + boxW: Int, + boxH: Int, + zoom: Float, + deliver: (bytes: ByteArray, width: Int, height: Int, captureMs: Double) -> Unit, + ) { + val t0 = SystemClock.elapsedRealtimeNanos() + val scale = minOf(zoom, MAX_SIDE_PX.toFloat() / boxW, MAX_SIDE_PX.toFloat() / boxH) + val outW = (boxW * scale).toInt().coerceAtLeast(1) + val outH = (boxH * scale).toInt().coerceAtLeast(1) + val bitmap = createBitmap(outW, outH) + val canvas = Canvas(bitmap) + canvas.drawColor(bgColor) + // Map the source rect (centered on cx,cy in view px) into the scaled output bitmap. + canvas.scale(scale, scale) + canvas.translate(-(cx - boxW / 2f), -(cy - boxH / 2f)) + webView.draw(canvas) + + encoder.execute { + val bytes = + ByteArrayOutputStream().use { out -> + bitmap.compress(Bitmap.CompressFormat.WEBP_LOSSY, QUALITY, out) + out.toByteArray() + } + bitmap.recycle() + val captureMs = (SystemClock.elapsedRealtimeNanos() - t0) / 1_000_000.0 + main.post { deliver(bytes, outW, outH, captureMs) } + } + } +} 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 ccf8c634da..5c2d82cac7 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserContract.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserContract.kt @@ -186,6 +186,16 @@ object NappletBrowserContract { /** Leave HTML fullscreen (the user pressed back). */ const val MSG_EXIT_FULLSCREEN = 33 + /** + * Client → provider: the tab left the screen (parked off-screen by the tab layer). The page's WebView is + * paused — animations, media and geolocation stop — so warm tabs in the background don't keep burning + * CPU and battery. Mirrors [NappletEmbedContract.MSG_PAUSE] for napplets. + */ + const val MSG_PAUSE = 34 + + /** Client → provider: the tab is the visible one again; resume its WebView. */ + const val MSG_RESUME = 35 + 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 ef4f84864e..cffadc9bbe 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserService.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserService.kt @@ -26,7 +26,6 @@ import android.content.Context import android.content.Intent import android.content.ServiceConnection import android.graphics.Bitmap -import android.graphics.Canvas import android.graphics.Color import android.net.Uri import android.os.Build @@ -36,7 +35,6 @@ 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 @@ -53,7 +51,6 @@ import android.webkit.WebView import android.webkit.WebViewClient import android.widget.FrameLayout import androidx.annotation.RequiresApi -import androidx.core.graphics.createBitmap import androidx.core.graphics.scale import androidx.core.net.toUri import androidx.privacysandbox.ui.provider.toCoreLibInfo @@ -112,6 +109,10 @@ class NappletBrowserService : Service() { // The page the renderer was showing when it died, so the rebuild lands back where the user was. var recoverUrl: String? = null + // The client's last pause/resume. A parked tab can be paused before its WebView exists (the WebView + // is only built when the surface opens), so the flag is applied to every WebView built for the tab. + var paused = false + // The session's root view (holds the WebView, and the page's fullscreen view when it has one). var container: FrameLayout? = null var customView: View? = null @@ -238,6 +239,16 @@ class NappletBrowserService : Service() { WebViewProxyPolicy.whenApplied { if (tab.webView === wv) wv.loadUrl(url) } } } + NappletBrowserContract.MSG_PAUSE -> + tabFor(msg)?.let { + it.paused = true + it.webView?.onPause() + } + NappletBrowserContract.MSG_RESUME -> + tabFor(msg)?.let { + it.paused = false + it.webView?.onResume() + } NappletBrowserContract.MSG_FORWARD -> tabFor(msg)?.webView?.let { if (it.canGoForward()) it.goForward() } NappletBrowserContract.MSG_STOP -> tabFor(msg)?.webView?.stopLoading() NappletBrowserContract.MSG_FIND -> { @@ -341,48 +352,34 @@ class NappletBrowserService : Service() { return true } - // One reusable output bitmap per tab would be ideal, but loupe size is fixed per drag; createBitmap each - // frame is cheap next to the draw. Source rect is in view px (== surface px, the SCVH is 1:1). + // Source rect is in view px (== surface px, the SCVH is 1:1). See MagnifierCapture for the threading. private fun onMagnifierRequest(msg: Message) { val tab = tabFor(msg) ?: return val wv = tab.webView ?: return val data = msg.data ?: return - val cx = data.getFloat(NappletBrowserContract.KEY_MAG_X) - val cy = data.getFloat(NappletBrowserContract.KEY_MAG_Y) - val boxW = data.getInt(NappletBrowserContract.KEY_MAG_BOX_W, 150).coerceIn(16, 1024) - val boxH = data.getInt(NappletBrowserContract.KEY_MAG_BOX_H, 84).coerceIn(16, 1024) - val zoom = data.getFloat(NappletBrowserContract.KEY_MAG_ZOOM, 1.6f).coerceIn(1f, 4f) val reqT = data.getLong(NappletBrowserContract.KEY_MAG_REQ_T) - - val outW = (boxW * zoom).toInt().coerceAtLeast(1) - val outH = (boxH * zoom).toInt().coerceAtLeast(1) - val t0 = SystemClock.elapsedRealtimeNanos() - val bitmap = createBitmap(outW, outH) - val canvas = Canvas(bitmap) - canvas.drawColor(tab.bgColor) - // Map the source rect (centered on cx,cy in view px) into the zoomed output bitmap. - canvas.scale(zoom, zoom) - canvas.translate(-(cx - boxW / 2f), -(cy - boxH / 2f)) - wv.draw(canvas) - - val baos = ByteArrayOutputStream() - bitmap.compress(Bitmap.CompressFormat.PNG, 100, baos) - val bytes = baos.toByteArray() - bitmap.recycle() - val captureMs = (SystemClock.elapsedRealtimeNanos() - t0) / 1_000_000.0 - - val reply = - Message.obtain(null, NappletBrowserContract.MSG_MAGNIFIER_FRAME).apply { - this.data = - Bundle().apply { - putByteArray(NappletBrowserContract.KEY_MAG_BYTES, bytes) - putInt(NappletBrowserContract.KEY_MAG_W, outW) - putInt(NappletBrowserContract.KEY_MAG_H, outH) - putDouble(NappletBrowserContract.KEY_MAG_CAPTURE_MS, captureMs) - putLong(NappletBrowserContract.KEY_MAG_REQ_T, reqT) - } - } - runCatching { tab.clientMessenger?.send(reply) } + MagnifierCapture.capture( + webView = wv, + bgColor = tab.bgColor, + cx = data.getFloat(NappletBrowserContract.KEY_MAG_X), + cy = data.getFloat(NappletBrowserContract.KEY_MAG_Y), + boxW = data.getInt(NappletBrowserContract.KEY_MAG_BOX_W, 150).coerceIn(16, 1024), + boxH = data.getInt(NappletBrowserContract.KEY_MAG_BOX_H, 84).coerceIn(16, 1024), + zoom = data.getFloat(NappletBrowserContract.KEY_MAG_ZOOM, 1.6f).coerceIn(1f, 4f), + ) { bytes, outW, outH, captureMs -> + val reply = + Message.obtain(null, NappletBrowserContract.MSG_MAGNIFIER_FRAME).apply { + this.data = + Bundle().apply { + putByteArray(NappletBrowserContract.KEY_MAG_BYTES, bytes) + putInt(NappletBrowserContract.KEY_MAG_W, outW) + putInt(NappletBrowserContract.KEY_MAG_H, outH) + putDouble(NappletBrowserContract.KEY_MAG_CAPTURE_MS, captureMs) + putLong(NappletBrowserContract.KEY_MAG_REQ_T, reqT) + } + } + runCatching { tab.clientMessenger?.send(reply) } + } } /** Builds the SandboxedUiAdapter for [tab] and ships its cross-process handle (coreLibInfo) to the client. */ @@ -410,6 +407,14 @@ class NappletBrowserService : Service() { // The session may have been closed between MSG_CREATE_SESSION and this posted call — fail rather // than build a WebView that no tab tracks (it would leak). val tab = tabs[sessionId] ?: error("No browser tab for session $sessionId") + // A session re-opened on this tab (the client's view detached and re-attached) before the old one's + // close landed: that session's WebView is still here. Destroy it now — its close will be ignored + // (see onSessionClosed), and overwriting it would leak it. + tab.webView?.let { stale -> + (stale.parent as? ViewGroup)?.removeView(stale) + stale.destroy() + tab.webView = null + } tab.container = container val wv = buildTabWebView(context, tab) claimRoute(tab) { if (tab.webView === wv) wv.loadUrl(tab.url) } @@ -455,6 +460,7 @@ class NappletBrowserService : Service() { BrowserWebTools.setTextZoom(wv, tab.textZoom) if (tab.desktopSite) BrowserWebTools.setDesktopMode(wv, true) tab.webView = wv + if (tab.paused) wv.onPause() return wv } @@ -470,8 +476,15 @@ class NappletBrowserService : Service() { } /** A session closed: drop the tab and destroy its own WebView (never a sibling's). */ - fun onSessionClosed(sessionId: String) { - val tab = tabs.remove(sessionId) ?: return + fun onSessionClosed( + sessionId: String, + container: FrameLayout, + ) { + // Only the session that currently owns the tab may close it. A late close from a session that was + // already replaced by a re-open would otherwise reap the live one — its WebView destroyed under a + // client that had just been told the session opened, leaving the surface black for good. + val tab = tabs[sessionId]?.takeIf { it.container === container } ?: return + tabs.remove(sessionId) WebViewProxyPolicy.release(tab) releasePage(tab, closing = true) tab.bridge.clear() diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserUiAdapter.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserUiAdapter.kt index 82e0315008..32bb9bebdc 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserUiAdapter.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserUiAdapter.kt @@ -111,8 +111,13 @@ private class BrowserSession( } override fun close() { - // The library may call close() off the main thread; WebView.destroy() (and the tabs mutation) - // must run on the main thread. - Handler(Looper.getMainLooper()).post { service.onSessionClosed(sessionId) } + // WebView.destroy() (and the tabs mutation) must run on the main thread. The library usually calls + // this there already; post only when it doesn't. The container identifies THIS session, so a close + // that lands after the tab was re-opened can't tear down its successor. + if (Looper.myLooper() == Looper.getMainLooper()) { + service.onSessionClosed(sessionId, container) + } else { + Handler(Looper.getMainLooper()).post { service.onSessionClosed(sessionId, container) } + } } } 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 9887649bcd..43b8400b57 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostService.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostService.kt @@ -26,7 +26,6 @@ import android.content.Context import android.content.Intent import android.content.ServiceConnection import android.graphics.Bitmap -import android.graphics.Canvas import android.net.Uri import android.os.Build import android.os.Bundle @@ -35,7 +34,6 @@ 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 @@ -52,7 +50,6 @@ import android.webkit.WebView import android.webkit.WebViewClient import android.widget.FrameLayout import androidx.annotation.RequiresApi -import androidx.core.graphics.createBitmap import androidx.core.net.toUri import androidx.privacysandbox.ui.provider.toCoreLibInfo import androidx.webkit.JavaScriptReplyProxy @@ -70,7 +67,6 @@ import com.vitorpamplona.quartz.nip5aStaticWebsites.tags.PathTag import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.sha256.sha256 import kotlinx.serialization.json.JsonObject -import java.io.ByteArrayOutputStream /** * Provider for an **embedded** nsite/napplet tab — the in-app-tab counterpart of [NappletHostActivity]. @@ -298,41 +294,29 @@ class NappletHostService : Service() { val tab = tabFor(msg) ?: return val wv = tab.webView ?: return val data = msg.data ?: return - val cx = data.getFloat(NappletEmbedContract.KEY_MAG_X) - val cy = data.getFloat(NappletEmbedContract.KEY_MAG_Y) - val boxW = data.getInt(NappletEmbedContract.KEY_MAG_BOX_W, 150).coerceIn(16, 1024) - val boxH = data.getInt(NappletEmbedContract.KEY_MAG_BOX_H, 84).coerceIn(16, 1024) - val zoom = data.getFloat(NappletEmbedContract.KEY_MAG_ZOOM, 1.6f).coerceIn(1f, 4f) val reqT = data.getLong(NappletEmbedContract.KEY_MAG_REQ_T) - - val outW = (boxW * zoom).toInt().coerceAtLeast(1) - val outH = (boxH * zoom).toInt().coerceAtLeast(1) - val t0 = SystemClock.elapsedRealtimeNanos() - val bitmap = createBitmap(outW, outH) - val canvas = Canvas(bitmap) - canvas.drawColor(tab.bgColor) - canvas.scale(zoom, zoom) - canvas.translate(-(cx - boxW / 2f), -(cy - boxH / 2f)) - wv.draw(canvas) - - val baos = ByteArrayOutputStream() - bitmap.compress(Bitmap.CompressFormat.PNG, 100, baos) - val bytes = baos.toByteArray() - bitmap.recycle() - val captureMs = (SystemClock.elapsedRealtimeNanos() - t0) / 1_000_000.0 - - val reply = - Message.obtain(null, NappletEmbedContract.MSG_MAGNIFIER_FRAME).apply { - this.data = - Bundle().apply { - putByteArray(NappletEmbedContract.KEY_MAG_BYTES, bytes) - putInt(NappletEmbedContract.KEY_MAG_W, outW) - putInt(NappletEmbedContract.KEY_MAG_H, outH) - putDouble(NappletEmbedContract.KEY_MAG_CAPTURE_MS, captureMs) - putLong(NappletEmbedContract.KEY_MAG_REQ_T, reqT) - } - } - runCatching { tab.clientMessenger?.send(reply) } + MagnifierCapture.capture( + webView = wv, + bgColor = tab.bgColor, + cx = data.getFloat(NappletEmbedContract.KEY_MAG_X), + cy = data.getFloat(NappletEmbedContract.KEY_MAG_Y), + boxW = data.getInt(NappletEmbedContract.KEY_MAG_BOX_W, 150).coerceIn(16, 1024), + boxH = data.getInt(NappletEmbedContract.KEY_MAG_BOX_H, 84).coerceIn(16, 1024), + zoom = data.getFloat(NappletEmbedContract.KEY_MAG_ZOOM, 1.6f).coerceIn(1f, 4f), + ) { bytes, outW, outH, captureMs -> + val reply = + Message.obtain(null, NappletEmbedContract.MSG_MAGNIFIER_FRAME).apply { + this.data = + Bundle().apply { + putByteArray(NappletEmbedContract.KEY_MAG_BYTES, bytes) + putInt(NappletEmbedContract.KEY_MAG_W, outW) + putInt(NappletEmbedContract.KEY_MAG_H, outH) + putDouble(NappletEmbedContract.KEY_MAG_CAPTURE_MS, captureMs) + putLong(NappletEmbedContract.KEY_MAG_REQ_T, reqT) + } + } + runCatching { tab.clientMessenger?.send(reply) } + } } /** Builds the SandboxedUiAdapter for [tab] and ships its cross-process handle (coreLibInfo) to the client. */ @@ -360,6 +344,15 @@ class NappletHostService : Service() { // The session may have been closed between MSG_CREATE_SESSION and this posted call — fail rather // than build a WebView that no tab tracks (it would leak). val tab = tabs[sessionId] ?: error("No napplet tab for session $sessionId") + // A session re-opened on this tab (the client's view detached and re-attached) before the old one's + // close landed: that session's WebView is still here. Destroy it now — its close will be ignored + // (see onSessionClosed), and overwriting it would leak it. + tab.webView?.let { stale -> + (stale.parent as? ViewGroup)?.removeView(stale) + stale.destroy() + tab.webView = null + tab.bridgeReplyProxy = null + } tab.container = container // A rebuild after a renderer crash: release the previous content server first. tab.contentServer?.close() @@ -404,8 +397,15 @@ class NappletHostService : Service() { } /** A session closed: drop the tab, release its resources, and destroy its own WebView (never a sibling's). */ - fun onSessionClosed(sessionId: String) { - val tab = tabs.remove(sessionId) ?: return + fun onSessionClosed( + sessionId: String, + container: FrameLayout, + ) { + // Only the session that currently owns the tab may close it. A late close from a session that was + // already replaced by a re-open would otherwise reap the live one — its WebView destroyed under a + // client that had just been told the session opened, leaving the surface black for good. + val tab = tabs[sessionId]?.takeIf { it.container === container } ?: return + tabs.remove(sessionId) tab.bridgeReplyProxy = null WebViewProxyPolicy.release(tab) releaseFromBroker(tab) @@ -551,7 +551,7 @@ class NappletHostService : Service() { override fun onPageStarted( view: WebView, url: String, - favicon: android.graphics.Bitmap?, + favicon: Bitmap?, ) { // A new main-frame navigation cleared any prior error. tab.loadFailed = false diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostUiAdapter.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostUiAdapter.kt index 5bdf866d02..90e838bd6a 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostUiAdapter.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostUiAdapter.kt @@ -112,8 +112,13 @@ private class HostSession( } override fun close() { - // The library may call close() off the main thread; WebView.destroy() (and the tabs mutation) - // must run on the main thread. - Handler(Looper.getMainLooper()).post { service.onSessionClosed(sessionId) } + // WebView.destroy() (and the tabs mutation) must run on the main thread. The library usually calls + // this there already; post only when it doesn't. The container identifies THIS session, so a close + // that lands after the tab was re-opened can't tear down its successor. + if (Looper.myLooper() == Looper.getMainLooper()) { + service.onSessionClosed(sessionId, container) + } else { + Handler(Looper.getMainLooper()).post { service.onSessionClosed(sessionId, container) } + } } } From 53397a35d6aa6f14bba9ce0ba396a71293245fc5 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Tue, 29 Sep 2026 12:59:34 +0000 Subject: [PATCH 06/13] fix(napplet): finish the embedded-tab follow-ups Safety - Hosts hold a napplet's acting requests (publish, pay, upload, notify, inc.emit) while it is off-screen and send them when the user returns: pausing a WebView doesn't stop JavaScript, so an "allow always" napplet parked in the bar could otherwise keep acting. Reads keep flowing, so preloading still fills the app in. (NappletActingRequests, tested.) - "Allow always" notices are shown by the controller on the app scope, so one that lands after the user left the tab is no longer dropped. - Provider replies carry their session id; controllers drop anything from a session they replaced (a stale SESSION_READY re-armed the view with a dead adapter) and anything after teardown (a late file-chooser request still opened a picker). - Teardown and re-arm send MSG_CLOSE_SESSION, so a session created for a view disposed before it attached (every warm tab on an account switch) no longer stays in :napplet forever. - A controller lets go of its view when the AndroidView is released, so a warm controller can't keep an Activity the user backed out of alive. - The broker answers a browser-token request it can't serve (no account), and hosts fail a page's queued NIP-07 calls on that answer or after 20 s, instead of leaving window.nostr hanging. Performance - The tab layer reads the keyboard inset inside its effect instead of in composition (every keyboard show/hide recomposed the whole layer) and only queries the clipboard while the pill is open. - The browser launcher builds its discover list off the main thread and ranks each typed text once instead of twice per keystroke. - Data-URL downloads decode on the downloads thread, not the main one. Small fixes - Find-in-page highlights are cleared on the tab being left. - Keyboard-restore marks are only recorded for the session still warm. - The theme the sessions were built in lives in EmbeddedTabHost, so a dark-mode flip while the Activity was gone still rebuilds them. - The preload sweep retries favorites not loaded from disk yet. - Browser permission-request entries are dropped once answered. - The napplet host's IME relay can't crash on a dead reply proxy. - An identity watch re-started under the same token replaces the old stream instead of pushing to the previous surface's Messenger. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G3bteStHvaf18TdABSkb8h --- .../amethyst/napplet/NappletBrokerService.kt | 12 +++- .../screen/loggedIn/browser/BrowserScreen.kt | 32 ++++++--- .../browser/EmbeddedWebAppController.kt | 22 ++++++ .../embed/EmbeddedSurfaceController.kt | 7 ++ .../screen/loggedIn/embed/EmbeddedTabHost.kt | 22 ++++++ .../screen/loggedIn/embed/EmbeddedTabLayer.kt | 36 ++++++---- .../embed/EmbeddedTabPreloadSweeper.kt | 8 ++- .../loggedIn/embed/EmbeddedTabThemeWatcher.kt | 11 +-- .../favorites/EmbeddedNostrAppController.kt | 58 ++++++++++++++-- .../loggedIn/favorites/NostrAppScreen.kt | 23 ------- .../commons/napplet/NappletActingRequests.kt | 42 +++++++++++ .../commons/napplet/NappletIdentityWatch.kt | 3 + .../napplet/NappletActingRequestsTest.kt | 39 +++++++++++ .../amethyst/napplethost/BridgeFailures.kt | 48 +++++++++++++ .../amethyst/napplethost/BrowserDownloads.kt | 28 ++++---- .../napplethost/NappletBrowserActivity.kt | 23 ++++++- .../napplethost/NappletBrowserContract.kt | 7 ++ .../napplethost/NappletBrowserService.kt | 69 +++++++++++++++---- .../napplethost/NappletEmbedContract.kt | 7 ++ .../napplethost/NappletHostActivity.kt | 13 ++++ .../napplethost/NappletHostService.kt | 56 +++++++++++---- .../amethyst/napplethost/NappletIpc.kt | 3 + 22 files changed, 469 insertions(+), 100 deletions(-) create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletActingRequests.kt create mode 100644 commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletActingRequestsTest.kt create mode 100644 nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/BridgeFailures.kt 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 1b630cf8ec..0975a66ca8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletBrokerService.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletBrokerService.kt @@ -348,7 +348,17 @@ class NappletBrokerService : Service() { val identity = NappletIdentity(authorPubKey = BROWSER_IDENTITY_AUTHOR, identifier = origin) // 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() ?: return true + val mintAccount = Amethyst.instance.sessionManager.loggedInAccount() + if (mintAccount == null) { + // 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 = + Message.obtain(null, NappletIpc.MSG_BROWSER_TOKEN).apply { + this.data = Bundle().apply { putString(NappletIpc.KEY_BROWSER_ORIGIN, origin) } + } + runCatching { replyTo.send(refusal) } + return true + } val token = NappletLaunchRegistry.register(identity, NappletCapability.WEBSITE_CAPABILITIES, mintAccount.pubKey) val response = Message.obtain(null, NappletIpc.MSG_BROWSER_TOKEN).apply { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/BrowserScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/BrowserScreen.kt index 725fd312a9..50d55d8ab5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/BrowserScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/BrowserScreen.kt @@ -57,6 +57,7 @@ import androidx.compose.material3.TextFieldDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment @@ -117,6 +118,8 @@ import com.vitorpamplona.quartz.nip5aStaticWebsites.NamedSiteEvent import com.vitorpamplona.quartz.nip5aStaticWebsites.RootSiteEvent import com.vitorpamplona.quartz.nip5dNapplets.NamedNappletEvent import com.vitorpamplona.quartz.nip5dNapplets.RootNappletEvent +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import org.jetbrains.compose.resources.StringResource import com.vitorpamplona.amethyst.commons.R as CommonsR @@ -211,19 +214,27 @@ private fun BrowserLauncher( // Drop ones already pinned — they show under Favorites, not twice. val favoriteCoordinates = remember(apps) { apps.filterIsInstance<FavoriteApp.NostrApp>().mapTo(HashSet()) { it.coordinate } } - val followedNsites = - remember(nsiteNotes, nsiteFollows, favoriteCoordinates) { - nsiteNotes.toDiscoverApps(nsiteFollows::matchAuthor, favoriteCoordinates) - } - val followedNapplets = - remember(nappletNotes, nappletFollows, favoriteCoordinates) { - nappletNotes.toDiscoverApps(nappletFollows::matchAuthor, favoriteCoordinates) - } + // Built off the main thread: these walk every cached nsite/napplet note, and the note lists re-emit as + // relays deliver. + val followedNsites by produceState(emptyList<DiscoverNostrApp>(), nsiteNotes, nsiteFollows, favoriteCoordinates) { + value = withContext(Dispatchers.Default) { nsiteNotes.toDiscoverApps(nsiteFollows::matchAuthor, favoriteCoordinates) } + } + val followedNapplets by produceState(emptyList<DiscoverNostrApp>(), nappletNotes, nappletFollows, favoriteCoordinates) { + value = withContext(Dispatchers.Default) { nappletNotes.toDiscoverApps(nappletFollows::matchAuthor, favoriteCoordinates) } + } // What the user actually typed, excluding any selected ghost-completion suffix (selection.min is the // caret when collapsed, or the start of the highlighted suffix when a completion is showing). val typed = field.text.take(field.selection.min.coerceIn(0, field.text.length)) - val suggestions = remember(typed, candidates) { OmniboxSuggestions.rank(typed, candidates, limit = 12) } + // One ranking per typed text: an appended character is ranked in onValueChange (for the inline + // completion) and then again for this list on the recomposition that follows — keep the last one. + val lastRanking = remember(candidates) { arrayOfNulls<Pair<String, List<OmniboxSuggestions.Suggestion>>>(1) } + + fun ranked(text: String): List<OmniboxSuggestions.Suggestion> = + lastRanking[0]?.takeIf { it.first == text }?.second + ?: OmniboxSuggestions.rank(text, candidates, limit = 12).also { lastRanking[0] = text to it } + + val suggestions = remember(typed, candidates) { ranked(typed) } fun open(text: String) { val target = OmniboxInput.resolve(text) ?: return @@ -254,7 +265,8 @@ private fun BrowserLauncher( newText.length > prevTyped.length && newText.startsWith(prevTyped) if (appended) { - val completion = OmniboxSuggestions.completion(newText, OmniboxSuggestions.rank(newText, candidates)) + // The completion has always looked at the top 8 (rank's default limit). + val completion = OmniboxSuggestions.completion(newText, ranked(newText).take(8)) if (completion != null) { // Keep the user's own casing for the typed prefix; append only the remaining suffix. val full = newText + completion.substring(newText.length) 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 0fb65d155c..c962d6e124 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 @@ -109,6 +109,9 @@ class EmbeddedWebAppController( // Set after the first connection, so a later onServiceConnected is recognised as `:napplet` coming back. private var everConnected = false + // Set by [unbind]: nothing that arrives afterwards may act. + private var tornDown = false + // A `:napplet` restart found this tab hidden: its session is re-created when it is next shown. private var createOnShow = false @@ -228,6 +231,10 @@ class EmbeddedWebAppController( } fun unbind() { + // Tell the provider to drop this tab's session now: one created for a view that was disposed before + // it attached never gets the surface close that would otherwise clean it up. + send(NappletBrowserContract.MSG_CLOSE_SESSION) {} + tornDown = true if (bound) { runCatching { appContext.unbindService(connection) } bound = false @@ -317,6 +324,12 @@ class EmbeddedWebAppController( } } + override fun detachView(view: SandboxedSdkView) { + if (sandboxedSdkView !== view) return + view.setEventListener(null) + sandboxedSdkView = null + } + /** * Asks the sandbox for a brand-new session; the [NappletBrowserContract.MSG_SESSION_READY] reply arms * the current view with its adapter. @@ -327,6 +340,9 @@ class EmbeddedWebAppController( * surface stayed black. A new id makes the stale close target only the corpse it belongs to. */ private fun rearmSession() { + // 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()}" adapterDelivered = false sessionDead = false @@ -414,6 +430,12 @@ class EmbeddedWebAppController( } private fun onServiceMessage(msg: Message): Boolean { + // Nothing may act on a torn-down tab (a late file-chooser request would still open a picker), nor on + // what a session this controller has since replaced still had in flight — a stale SESSION_READY + // would re-arm the view with that dead session's adapter. + if (tornDown) return true + val from = msg.data?.getString(NappletBrowserContract.KEY_SESSION_ID) + if (from != null && from != sessionId) return true when (msg.what) { NappletBrowserContract.MSG_SESSION_READY -> { val coreLibInfo = msg.data?.getBundle(NappletBrowserContract.KEY_CORE_LIB_INFO) ?: return true diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedSurfaceController.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedSurfaceController.kt index 8f38e10ac9..b3add42f99 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedSurfaceController.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedSurfaceController.kt @@ -40,6 +40,13 @@ import androidx.privacysandbox.ui.client.view.SandboxedSdkView interface EmbeddedSurfaceController { fun attachView(view: SandboxedSdkView) + /** + * [view] left the composition. The controller outlives it (it lives in the process-scoped host), so it + * must let go of it — a view holds its Activity, and a warm controller still pointing at the view of an + * Activity the user backed out of would keep that whole Activity alive. + */ + fun detachView(view: SandboxedSdkView) + /** The session became the visible tab. */ fun onShown() { // Optional hook: default no-op. Controllers that don't pause/resume applet JS need no action. 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 9fb2ef3468..ed06b11204 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 @@ -119,6 +119,28 @@ object EmbeddedTabHost { /** True if a warm session already exists for [id] (used by the preloader to skip re-acquiring). */ fun isWarm(id: String): Boolean = warm.any { it.id == id } + /** True if [controller] is still the warm session for [id] (not torn down or replaced by a rebuild). */ + fun isWarm( + id: String, + controller: Any?, + ): Boolean = warm.any { it.id == id && it.controller === controller } + + /** + * The theme (dark or not) the warm sessions were built in. Kept here, next to the sessions, rather than in + * the watcher's `remember`: an Activity recreated while the process lives re-seeds a remembered value to + * the CURRENT theme, so a system dark-mode flip that happened meanwhile was never noticed and the warm + * pages stayed in the old theme. + */ + private var builtDark: Boolean? = null + + /** Rebuilds every warm session when the resolved theme differs from the one they were built in. */ + fun rebuildIfThemeChanged(dark: Boolean) { + val previous = builtDark + builtDark = dark + // The first report only records what the sessions (built from the same preference) already use. + if (previous != null && previous != dark) rebuildAll() + } + /** * Seeds [contentBounds] with an approximate full-content rect when no tab has reported real bounds * yet, so surfaces preloaded before the user visits any tab lay out at a realistic viewport (and so 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 e72032277e..c7b3e085a1 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 @@ -65,6 +65,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset @@ -161,13 +162,10 @@ fun EmbeddedTabLayer(barFavoriteIds: List<String>) { var layerSize by remember { mutableStateOf(IntSize.Zero) } val density = LocalDensity.current - // While the soft keyboard is up (hosted by [RemoteImeView] in this window), shrink the active - // surface so its bottom clears the keyboard — the embedded WebView then reflows and scrolls the - // focused field into view. Only the portion of the keyboard that overlaps the surface counts. - // Use the *snapped* animation target rather than the animated `ime` inset: the cross-process surface - // resize is expensive (a SurfaceControlViewHost reconfigure each frame), so we resize once to the - // final height instead of on every frame of the keyboard slide-in/out. - val imeBottomPx = WindowInsets.imeAnimationTarget.getBottom(density) + // The keyboard's *snapped* target inset (not the animated one). Only the insets object is taken here; + // its value is read inside the effect below, so a keyboard showing or hiding doesn't recompose this whole + // layer (every surface, the pill, the selection overlay). + val imeTarget = WindowInsets.imeAnimationTarget Box( Modifier @@ -199,8 +197,6 @@ fun EmbeddedTabLayer(barFavoriteIds: List<String>) { // SurfaceControlViewHost surface, and the first frame presented after that reconfigure // stalls ~1s (the per-focus "freeze"). Keep the surface full-size and let the page bring // the focused field above the keyboard via the shim's scrollIntoView on focus. - @Suppress("UNUSED_EXPRESSION") - imeBottomPx Modifier .absoluteOffset(left, (bounds.top - layerOrigin.y).toDp()) .size(bounds.width.toDp(), bounds.height.toDp()) @@ -223,6 +219,9 @@ fun EmbeddedTabLayer(barFavoriteIds: List<String>) { } }, modifier = placement, + onRelease = { holder -> + (holder.getChildAt(0) as? SandboxedSdkView)?.let { session.controller.detachView(it) } + }, ) } } @@ -302,6 +301,12 @@ fun EmbeddedTabLayer(barFavoriteIds: List<String>) { findQuery = "" } + // Switching tabs drops the find bar (its state is per tab), but the page it searched keeps its + // highlights until told otherwise — clear them on the tab being left. + DisposableEffect(findBridge) { + onDispose { if (findShowing) findBridge?.find("") } + } + val tabModifier = with(density) { Modifier @@ -353,8 +358,9 @@ fun EmbeddedTabLayer(barFavoriteIds: List<String>) { }, 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 (BrowserWebTools.clipboardHasText(context)) { + if (pillExpanded && BrowserWebTools.clipboardHasText(context)) { { pillExpanded = false BrowserWebTools.clipboardText(context)?.let { chrome.onEvent(BrowserPillEvent.Navigate(it)) } @@ -413,8 +419,10 @@ fun EmbeddedTabLayer(barFavoriteIds: List<String>) { // doesn't pop the keyboard back over the page. A tab switch also collapses the insets but does NOT // look like this: measured on device, the switch takes focus off the view in the same frame, so // isMirroringPageField() is already false there and the mark this tab was owed survives. - LaunchedEffect(activeId, imeBottomPx) { - if (imeBottomPx == 0 && imeView.isMirroringPageField()) imeView.noteKeyboardDismissed() + LaunchedEffect(activeId) { + snapshotFlow { imeTarget.getBottom(density) }.collect { imeBottomPx -> + if (imeBottomPx == 0 && imeView.isMirroringPageField()) imeView.noteKeyboardDismissed() + } } DisposableEffect(imeBridge) { val boundId = activeId @@ -495,7 +503,9 @@ fun EmbeddedTabLayer(barFavoriteIds: List<String>) { // mid-typing — which is exactly the case this restore exists for. [wantsKeyboardForPageField] // also answers the other half: only a keyboard THIS mirror holds counts, so typing in the // browser's own address bar never arms a restore for a page field. - if (boundId != null) { + // Only for the session that is still warm under this id: after a rebuild (theme, account) or an + // eviction this disposal runs late, and a mark recorded now would be restored onto a fresh page. + if (boundId != null && EmbeddedTabHost.isWarm(boundId, imeBridge)) { EmbeddedTabHost.noteKeyboardOnLeave(boundId, imeView.wantsKeyboardForPageField()) } imeView.onPageBlur() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedTabPreloadSweeper.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedTabPreloadSweeper.kt index 8c68f970f1..4fd6ee0575 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedTabPreloadSweeper.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedTabPreloadSweeper.kt @@ -118,7 +118,13 @@ object EmbeddedTabPreloadSweeper { .associateBy { it.id } var stillPending = false for (id in favoriteIds) { - val app = byId[id] ?: continue + // Not loaded from disk yet (the favorites store hydrates asynchronously): retry, rather than + // letting the sweep end having warmed nothing. + val app = byId[id] + if (app == null) { + stillPending = true + continue + } if (!EmbeddedTabFactory.preload(context, app, backgroundColor)) stillPending = true // Each preload may build + attach a WebView on this (main) thread; yield between favorites // so the sweep doesn't monopolize the frame and jank the paint that follows. diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedTabThemeWatcher.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedTabThemeWatcher.kt index d3e8c770f6..5290dee5d8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedTabThemeWatcher.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedTabThemeWatcher.kt @@ -26,8 +26,6 @@ import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.commons.model.ThemeType @@ -55,13 +53,8 @@ fun EmbeddedTabThemeWatcher() { ThemeType.SYSTEM -> systemDark } - // Holds the theme the warm surfaces were last built in; a mismatch (only after a real flip — the - // first composition seeds it equal) triggers exactly one rebuild. - val applied = remember { mutableStateOf(resolvedDark) } + // The host remembers the theme the warm surfaces were built in; a real flip triggers exactly one rebuild. LaunchedEffect(resolvedDark) { - if (applied.value != resolvedDark) { - applied.value = resolvedDark - EmbeddedTabHost.rebuildAll() - } + EmbeddedTabHost.rebuildIfThemeChanged(resolvedDark) } } 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 77c6385691..908595756b 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 @@ -34,6 +34,7 @@ import android.os.Looper import android.os.Message import android.os.Messenger import android.os.SystemClock +import android.widget.Toast import androidx.annotation.RequiresApi import androidx.compose.runtime.State import androidx.compose.runtime.mutableStateOf @@ -41,8 +42,14 @@ import androidx.privacysandbox.ui.client.SandboxedUiAdapterFactory import androidx.privacysandbox.ui.client.view.SandboxedSdkView import androidx.privacysandbox.ui.client.view.SandboxedSdkViewEventListener import androidx.privacysandbox.ui.core.SandboxedUiAdapter +import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.commons.browser.BrowserChrome import com.vitorpamplona.amethyst.commons.browser.ui.pill.ConsoleLine +import com.vitorpamplona.amethyst.commons.resources.Res +import com.vitorpamplona.amethyst.commons.resources.favorite_notice_paid +import com.vitorpamplona.amethyst.commons.resources.favorite_notice_published +import com.vitorpamplona.amethyst.commons.resources.favorite_notice_uploaded +import com.vitorpamplona.amethyst.commons.ui.loadStringRes import com.vitorpamplona.amethyst.napplet.NappletLaunchRegistry import com.vitorpamplona.amethyst.napplet.NappletWebViewProfiles import com.vitorpamplona.amethyst.napplet.WebFileChooserCoordinator @@ -61,6 +68,9 @@ 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 kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import java.util.concurrent.atomic.AtomicLong /** @@ -110,9 +120,6 @@ class EmbeddedNostrAppController( /** (canGoBack) — drives the in-tab back gesture. */ var onStateChanged: ((Boolean) -> Unit)? = null - /** A granted "allow always" sensitive op just ran (one of NappletEmbedContract.NOTICE_*). */ - var onNotice: ((String) -> Unit)? = null - private var hasLoadedReal = false // Brings the tab back when its sandbox-side surface dies (see [onSurfaceLost]). @@ -124,6 +131,9 @@ class EmbeddedNostrAppController( // Set after the first connection, so a later onServiceConnected is recognised as `:napplet` coming back. private var everConnected = false + // Set by [unbind]: nothing that arrives afterwards may act. + private var tornDown = false + // A `:napplet` restart found this tab hidden: its session is re-created when it is next shown. private var createOnShow = false @@ -198,6 +208,10 @@ class EmbeddedNostrAppController( } fun unbind() { + // Tell the provider to drop this tab's session now: one created for a view that was disposed before + // it attached never gets the surface close that would otherwise clean it up. + send(NappletEmbedContract.MSG_CLOSE_SESSION) + tornDown = true if (bound) { runCatching { appContext.unbindService(connection) } bound = false @@ -212,7 +226,6 @@ class EmbeddedNostrAppController( pendingAdapter = null adapterDelivered = false onStateChanged = null - onNotice = null onImeEvent = null onMagnifierFrame = null onLoadStatusChanged = null @@ -253,6 +266,12 @@ class EmbeddedNostrAppController( } } + override fun detachView(view: SandboxedSdkView) { + if (sandboxedSdkView !== view) return + view.setEventListener(null) + sandboxedSdkView = null + } + /** * Asks the sandbox for a brand-new session; the [NappletEmbedContract.MSG_SESSION_READY] reply arms the * current view with its adapter. @@ -262,6 +281,9 @@ class EmbeddedNostrAppController( * leaving the surface black. */ private fun rearmSession() { + // 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()}" adapterDelivered = false sessionDead = false @@ -363,6 +385,12 @@ class EmbeddedNostrAppController( } private fun onServiceMessage(msg: Message): Boolean { + // Nothing may act on a torn-down tab (a late file-chooser request would still open a picker), nor on + // what a session this controller has since replaced still had in flight — a stale SESSION_READY + // would re-arm the view with that dead session's adapter. + if (tornDown) return true + val from = msg.data?.getString(NappletEmbedContract.KEY_SESSION_ID) + if (from != null && from != sessionId) return true when (msg.what) { NappletEmbedContract.MSG_SESSION_READY -> { val coreLibInfo = msg.data?.getBundle(NappletEmbedContract.KEY_CORE_LIB_INFO) ?: return true @@ -382,7 +410,7 @@ class EmbeddedNostrAppController( } NappletEmbedContract.MSG_NOTICE -> { val notice = msg.data?.getString(NappletEmbedContract.KEY_NOTICE) ?: return true - onNotice?.invoke(notice) + showNotice(notice) } NappletEmbedContract.MSG_IME_EVENT -> { val payload = msg.data?.getString(NappletEmbedContract.KEY_IME_PAYLOAD) ?: return true @@ -502,6 +530,26 @@ class EmbeddedNostrAppController( onLoadStatusChanged?.invoke(status) } + /** + * A granted "allow always" sensitive op just ran (one of NappletEmbedContract.NOTICE_*): tell the user. + * Shown from here, on the app's own scope, rather than by the tab's screen: the op can complete after + * the user has left the tab, when that screen — and the coroutine scope it would have toasted from — is + * already gone, and the notice was silently dropped. + */ + private fun showNotice(notice: String) { + val res = + when (notice) { + NappletEmbedContract.NOTICE_PUBLISHED -> Res.string.favorite_notice_published + NappletEmbedContract.NOTICE_UPLOADED -> Res.string.favorite_notice_uploaded + NappletEmbedContract.NOTICE_PAID -> Res.string.favorite_notice_paid + else -> return + } + Amethyst.instance.applicationIOScope.launch { + val text = loadStringRes(res) + withContext(Dispatchers.Main) { Toast.makeText(appContext, text, Toast.LENGTH_SHORT).show() } + } + } + /** Pause/resume the applet's JS when the tab leaves/returns to the foreground (background gating). */ fun pause() { wantPaused = true 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 357427f359..cd5c45f753 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 @@ -21,7 +21,6 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.favorites import android.os.Build -import android.widget.Toast import androidx.activity.compose.BackHandler import androidx.annotation.RequiresApi import androidx.compose.foundation.layout.Box @@ -41,7 +40,6 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -70,10 +68,6 @@ 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.resources.favorite_notice_paid -import com.vitorpamplona.amethyst.commons.resources.favorite_notice_published -import com.vitorpamplona.amethyst.commons.resources.favorite_notice_uploaded -import com.vitorpamplona.amethyst.commons.ui.loadStringRes import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav import com.vitorpamplona.amethyst.commons.ui.platform.AppBottomBar import com.vitorpamplona.amethyst.commons.ui.stringRes @@ -81,13 +75,10 @@ import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel import com.vitorpamplona.amethyst.favorites.FavoriteAppLauncher import com.vitorpamplona.amethyst.napplet.NappletNetworkRegistry import com.vitorpamplona.amethyst.napplethost.HostProfile -import com.vitorpamplona.amethyst.napplethost.NappletEmbedContract import com.vitorpamplona.amethyst.napplethost.NappletHostContract import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedTabChrome import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedTabFactory import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedTabHost -import kotlinx.coroutines.launch -import org.jetbrains.compose.resources.StringResource /** * A **Nostr app** — an nSite or nApplet, reached by [coordinate] (favorited or not) — rendered as an @@ -152,7 +143,6 @@ private fun EmbeddedNostrAppTab( // 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 - val scope = rememberCoroutineScope() var showAccess by remember { mutableStateOf(false) } val apps by Amethyst.instance.favoriteApps.favorites @@ -171,11 +161,6 @@ private fun EmbeddedNostrAppTab( // Keep the controller callbacks fresh (cheap, need the latest closures). SideEffect { controller.onStateChanged = { canGoBack = it } - controller.onNotice = { notice -> - noticeResId(notice)?.let { res -> - scope.launch { Toast.makeText(context, loadStringRes(res), Toast.LENGTH_SHORT).show() } - } - } } // The permission-ledger key is the addressable coordinate without its kind prefix (`pubkey:dtag`), @@ -335,11 +320,3 @@ private fun UnavailableTab( } } } - -private fun noticeResId(notice: String): StringResource? = - when (notice) { - NappletEmbedContract.NOTICE_PUBLISHED -> Res.string.favorite_notice_published - NappletEmbedContract.NOTICE_UPLOADED -> Res.string.favorite_notice_uploaded - NappletEmbedContract.NOTICE_PAID -> Res.string.favorite_notice_paid - else -> null - } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletActingRequests.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletActingRequests.kt new file mode 100644 index 0000000000..133086c027 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletActingRequests.kt @@ -0,0 +1,42 @@ +/* + * 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 napplet requests that ACT for the user — publish, pay, upload, notify, broadcast to other + * napplets — as opposed to reading. A host holds these while its napplet is off-screen: pausing the + * WebView stops animations and media but not JavaScript, so without this an "allow always" napplet + * parked in the bottom bar could keep publishing or paying while the user looks elsewhere. Reads keep + * flowing, so a preloaded napplet still fills itself in. + */ +object NappletActingRequests { + private val ACTING = + setOf( + "relay.publish", + "relay.publishEncrypted", + "value.payInvoice", + "upload.upload", + "notify.create", + "inc.emit", + ) + + fun actsForUser(requestType: String?): Boolean = requestType in ACTING +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletIdentityWatch.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletIdentityWatch.kt index 3e81e57a05..34d856ffb7 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletIdentityWatch.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletIdentityWatch.kt @@ -49,6 +49,9 @@ class NappletIdentityWatch( boundPubKey: String, push: (String) -> Unit, ) { + // A surface re-created under the same launch token (a tab re-arming its session) starts a new watch + // with its own sink; replace the old stream rather than keep pushing to the gone surface's Messenger. + jobs.remove(watchId)?.cancel() jobs.getOrPut(watchId) { val id = watchId scope diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletActingRequestsTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletActingRequestsTest.kt new file mode 100644 index 0000000000..45252e7d42 --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletActingRequestsTest.kt @@ -0,0 +1,39 @@ +/* + * 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.assertFalse +import kotlin.test.assertTrue + +class NappletActingRequestsTest { + @Test + fun actingRequestsAreHeld() { + listOf("relay.publish", "relay.publishEncrypted", "value.payInvoice", "upload.upload", "notify.create", "inc.emit") + .forEach { assertTrue(NappletActingRequests.actsForUser(it), it) } + } + + @Test + fun readsFlow() { + listOf("identity.getPublicKey", "relay.query", "relay.subscribe", "relay.close", "storage.get", "resource.bytes", "theme.get", null) + .forEach { assertFalse(NappletActingRequests.actsForUser(it), it.toString()) } + } +} diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/BridgeFailures.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/BridgeFailures.kt new file mode 100644 index 0000000000..3c14d3f3ad --- /dev/null +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/BridgeFailures.kt @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.napplethost + +import android.os.Message +import androidx.webkit.JavaScriptReplyProxy +import com.vitorpamplona.amethyst.commons.napplet.NappletBridgeDocuments +import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletProtocolJson +import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletResponse +import com.vitorpamplona.amethyst.commons.util.parseJsonObjectOrNull +import com.vitorpamplona.amethyst.commons.util.withString +import kotlinx.serialization.json.JsonObject + +/** + * Answers a queued broker [request] (a [NappletIpc.MSG_REQUEST] that never left) with a failure, delivered + * to the page that made it — the same reply shape the broker uses, so the page's promise rejects with + * [reason] instead of waiting forever. Dropped if that page has since been navigated away from. + */ +fun NappletBridgeDocuments<JavaScriptReplyProxy>.failRequest( + request: Message, + reason: String, +) { + val data = request.data ?: return + val brokerId = data.getString(NappletIpc.KEY_REQUEST_ID) ?: return + val raw = data.getString(NappletIpc.KEY_PAYLOAD) ?: return + val (pageId, proxy) = resolve(brokerId) ?: return + val type = runCatching { NappletProtocolJson.readType(raw) }.getOrNull() ?: "napplet" + val reply = parseJsonObjectOrNull(NappletProtocolJson.encodeResponse(type, NappletResponse.Failed(reason))) ?: JsonObject(emptyMap()) + runCatching { proxy.postMessage(reply.withString("id", pageId).toString()) } +} diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/BrowserDownloads.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/BrowserDownloads.kt index 6e97344b45..276a7fbd00 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/BrowserDownloads.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/BrowserDownloads.kt @@ -117,18 +117,22 @@ object BrowserDownloads { suggestedName: String?, ) { val app = context.applicationContext - val header = dataUrl.substringBefore(',', "") - val payload = dataUrl.substringAfter(',', "") - val mime = header.removePrefix("data:").substringBefore(';').ifBlank { "application/octet-stream" } - val bytes = - runCatching { - if (header.endsWith(";base64", ignoreCase = true)) { - Base64.decode(payload, Base64.DEFAULT) - } else { - URLDecoder.decode(payload, "UTF-8").toByteArray() - } - }.getOrNull() ?: return - saveBytes(app, suggestedName, mime, bytes) + // Decoding up to MAX_INLINE_BYTES of base64 stalls whatever thread does it; callers are on the main + // thread every sandboxed surface renders on, so decode on the io thread with the write. + io.execute { + val header = dataUrl.substringBefore(',', "") + val payload = dataUrl.substringAfter(',', "") + val mime = header.removePrefix("data:").substringBefore(';').ifBlank { "application/octet-stream" } + val bytes = + runCatching { + if (header.endsWith(";base64", ignoreCase = true)) { + Base64.decode(payload, Base64.DEFAULT) + } else { + URLDecoder.decode(payload, "UTF-8").toByteArray() + } + }.getOrNull() ?: return@execute + saveBytes(app, suggestedName, mime, bytes) + } } /** Saves bytes a page handed over (a `blob:` download, via the browser-extras script). */ 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 dcf9da4487..7aeb811511 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserActivity.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserActivity.kt @@ -961,6 +961,11 @@ class NappletBrowserActivity : ComponentActivity() { private fun requestBrowserToken(origin: String) { if (!mintInFlight.add(origin)) return + // The broker may never answer (it died mid-mint): fail the origin's queued calls rather than let the + // page wait forever. + Handler(Looper.getMainLooper()).postDelayed({ + if (!isDestroyed && origin in mintInFlight) failMint(origin, MINT_TIMED_OUT) + }, NappletIpc.MINT_TIMEOUT_MS) val msg = Message.obtain(null, NappletIpc.MSG_MINT_BROWSER_TOKEN).apply { replyTo = replyMessenger @@ -985,6 +990,15 @@ class NappletBrowserActivity : ComponentActivity() { runCatching { broker.send(Message.obtain(null, NappletIpc.MSG_RELEASE_CLIENT).apply { replyTo = replyMessenger }) } } + /** A token for [origin] won't come: answer each call queued behind it with a failure, and allow a retry. */ + private fun failMint( + origin: String, + reason: String, + ) { + mintInFlight.remove(origin) + pendingByOrigin.remove(origin)?.forEach { queued -> bridge.failRequest(queued, reason) } + } + /** Sends now when the broker is bound, else queues until it is. */ private fun queueToBroker(msg: Message) { if (brokerMessenger != null) sendToBroker(msg) else pendingBrokerRequests.add(msg) @@ -1034,7 +1048,12 @@ class NappletBrowserActivity : ComponentActivity() { } NappletIpc.MSG_BROWSER_TOKEN -> { val origin = data.getString(NappletIpc.KEY_BROWSER_ORIGIN) ?: return true - val token = data.getString(NappletIpc.KEY_LAUNCH_TOKEN) ?: return true + val token = data.getString(NappletIpc.KEY_LAUNCH_TOKEN) + if (token == null) { + // Refused (no account signed in): the page's calls fail now instead of hanging. + failMint(origin, NOT_SIGNED_IN) + return true + } originTokens[origin] = token mintInFlight.remove(origin) pendingByOrigin.remove(origin)?.forEach { queued -> @@ -1688,6 +1707,8 @@ class NappletBrowserActivity : ComponentActivity() { companion object { private const val TAG = "NappletBrowserActivity" + private const val NOT_SIGNED_IN = "Sign in to Amethyst to use this site's Nostr features." + private const val MINT_TIMED_OUT = "Amethyst didn't answer. Reload the page to try again." private const val ACTIVITY_CLASS = "com.vitorpamplona.amethyst.napplethost.NappletBrowserActivity" /** How often a resumed browser renews its foreground lease (well under the broker's 90s TTL). */ 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 5c2d82cac7..715ace5e6c 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserContract.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserContract.kt @@ -196,6 +196,13 @@ object NappletBrowserContract { /** Client → provider: the tab is the visible one again; resume its WebView. */ const val MSG_RESUME = 35 + /** + * Client → provider: the tab was torn down (evicted, or rebuilt for a theme/account change). Drops the + * session and its WebView even if the surface never opened — a session created for a view that was + * disposed before it attached would otherwise sit in the provider forever, pinning its client. + */ + const val MSG_CLOSE_SESSION = 36 + 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 cffadc9bbe..d5d10715c1 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserService.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserService.kt @@ -151,6 +151,17 @@ class NappletBrowserService : Service() { val mintInFlight = mutableSetOf<String>() val replyMessenger = Messenger(Handler(Looper.getMainLooper()) { onBrokerReply(this, it) }) + + /** + * Sends [msg] to this tab's client stamped with the session it belongs to, so the client can drop what + * a session it has since replaced still had in flight (a late file-chooser request, a stale + * SESSION_READY that would re-arm its view with a dead adapter). Returns whether it was delivered. + */ + fun toClient(msg: Message): Boolean { + val client = clientMessenger ?: return false + msg.data.putString(NappletBrowserContract.KEY_SESSION_ID, sessionId) + return runCatching { client.send(msg) }.isSuccess + } } private val tabs = mutableMapOf<String, BrowserTab>() @@ -239,6 +250,7 @@ class NappletBrowserService : Service() { WebViewProxyPolicy.whenApplied { if (tab.webView === wv) wv.loadUrl(url) } } } + NappletBrowserContract.MSG_CLOSE_SESSION -> tabFor(msg)?.let(::closeTab) NappletBrowserContract.MSG_PAUSE -> tabFor(msg)?.let { it.paused = true @@ -378,7 +390,7 @@ class NappletBrowserService : Service() { putLong(NappletBrowserContract.KEY_MAG_REQ_T, reqT) } } - runCatching { tab.clientMessenger?.send(reply) } + tab.toClient(reply) } } @@ -391,7 +403,7 @@ class NappletBrowserService : Service() { Message.obtain(null, NappletBrowserContract.MSG_SESSION_READY).apply { data = Bundle().apply { putBundle(NappletBrowserContract.KEY_CORE_LIB_INFO, coreLibInfo) } } - runCatching { tab.clientMessenger?.send(reply) } + tab.toClient(reply) } /** @@ -483,8 +495,12 @@ class NappletBrowserService : Service() { // Only the session that currently owns the tab may close it. A late close from a session that was // already replaced by a re-open would otherwise reap the live one — its WebView destroyed under a // client that had just been told the session opened, leaving the surface black for good. - val tab = tabs[sessionId]?.takeIf { it.container === container } ?: return - tabs.remove(sessionId) + tabs[sessionId]?.takeIf { it.container === container }?.let(::closeTab) + } + + /** Drops [tab] and everything it holds (its WebView, broker state, proxy claim). */ + private fun closeTab(tab: BrowserTab) { + tabs.remove(tab.sessionId) WebViewProxyPolicy.release(tab) releasePage(tab, closing = true) tab.bridge.clear() @@ -526,9 +542,8 @@ class NappletBrowserService : Service() { what: Int, crossinline block: Bundle.() -> Unit, ): Boolean { - val client = tab.clientMessenger ?: return false val message = Message.obtain(null, what).apply { data = Bundle().apply(block) } - return runCatching { client.send(message) }.isSuccess + return tab.toClient(message) } private fun pushFindResult( @@ -730,10 +745,14 @@ class NappletBrowserService : Service() { val wanted = request.resources.mapNotNull(::sitePermissionFor).toSet() val id = relayPermissionRequest(tab, BrowserChrome.originOf(request.origin.toString()), wanted) { granted -> + // Answered: nothing left for a cancellation to withdraw. + pendingWebPermissions.remove(request) val resources = request.resources.filter { sitePermissionFor(it) in granted }.toTypedArray() if (resources.isEmpty()) request.deny() else request.grant(resources) } - if (id != null) pendingWebPermissions[request] = id + // Only track it while it is still waiting on the user — it may already have been answered inline + // (nothing to ask, or the client unreachable), and an entry kept after that would never be removed. + if (id != null && tab?.permissionRequests?.containsKey(id) == true) pendingWebPermissions[request] = id } override fun onPermissionRequestCanceled(request: PermissionRequest) { @@ -803,7 +822,7 @@ class NappletBrowserService : Service() { putString(NappletBrowserContract.KEY_FILE_CHOOSER_TITLE, params.title?.toString()) } } - if (runCatching { client.send(msg) }.isFailure) tab.fileChooser.cancel() + if (!tab.toClient(msg)) tab.fileChooser.cancel() return true } @@ -824,7 +843,7 @@ class NappletBrowserService : Service() { putInt(NappletBrowserContract.KEY_CONSOLE_LINE, line) } } - runCatching { tab.clientMessenger?.send(msg) } + tab.toClient(msg) } /** Loads live web pages in-WebView (http/https) and hands other schemes to the system on a user tap. */ @@ -937,7 +956,7 @@ class NappletBrowserService : Service() { putString(NappletBrowserContract.KEY_URL, view.url.orEmpty()) } } - runCatching { tab?.clientMessenger?.send(message) } + tab?.toClient(message) } private fun pushUrl( @@ -958,7 +977,7 @@ class NappletBrowserService : Service() { title?.let { putString(NappletBrowserContract.KEY_TITLE, it) } } } - runCatching { tab?.clientMessenger?.send(message) } + tab?.toClient(message) } /** @@ -1002,7 +1021,7 @@ class NappletBrowserService : Service() { Message.obtain(null, NappletBrowserContract.MSG_IME_EVENT).apply { data = Bundle().apply { putString(NappletBrowserContract.KEY_IME_PAYLOAD, raw) } } - runCatching { tab.clientMessenger?.send(reply) } + tab.toClient(reply) return } @@ -1032,6 +1051,18 @@ class NappletBrowserService : Service() { } } + private val mintTimeouts = Handler(Looper.getMainLooper()) + + /** A token for [origin] won't come: answer each call queued behind it with a failure, and allow a retry. */ + private fun failMint( + tab: BrowserTab, + origin: String, + reason: String, + ) { + tab.mintInFlight.remove(origin) + tab.pendingByOrigin.remove(origin)?.forEach { queued -> tab.bridge.failRequest(queued, reason) } + } + /** * The page on [tab] is gone (navigated away, renderer died, session closed): drop its requests still * waiting for a token or the broker, and have the broker close the live relay / inc subscriptions it @@ -1066,6 +1097,11 @@ class NappletBrowserService : Service() { origin: String, ) { if (!tab.mintInFlight.add(origin)) return + // The broker may never answer (it died mid-mint): fail the origin's queued calls rather than let the + // page wait forever. + mintTimeouts.postDelayed({ + if (tabs[tab.sessionId] === tab && origin in tab.mintInFlight) failMint(tab, origin, MINT_TIMED_OUT) + }, NappletIpc.MINT_TIMEOUT_MS) val msg = Message.obtain(null, NappletIpc.MSG_MINT_BROWSER_TOKEN).apply { replyTo = tab.replyMessenger @@ -1166,7 +1202,12 @@ class NappletBrowserService : Service() { } NappletIpc.MSG_BROWSER_TOKEN -> { val origin = data.getString(NappletIpc.KEY_BROWSER_ORIGIN) ?: return true - val token = data.getString(NappletIpc.KEY_LAUNCH_TOKEN) ?: return true + val token = data.getString(NappletIpc.KEY_LAUNCH_TOKEN) + if (token == null) { + // Refused (no account signed in): the page's calls fail now instead of hanging. + failMint(tab, origin, NOT_SIGNED_IN) + return true + } tab.originTokens[origin] = token tab.mintInFlight.remove(origin) tab.pendingByOrigin.remove(origin)?.forEach { queued -> @@ -1186,6 +1227,8 @@ class NappletBrowserService : Service() { private companion object { private const val TAG = "NappletBrowserService" + private const val NOT_SIGNED_IN = "Sign in to Amethyst to use this site's Nostr features." + private const val MINT_TIMED_OUT = "Amethyst didn't answer. Reload the page to try again." private const val ABOUT_BLANK = "about:blank" /** Max favicon edge (px) before sending over IPC — keeps the PNG tiny, well under the Binder limit. */ 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 1dce20bda9..1b52ee3dba 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletEmbedContract.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletEmbedContract.kt @@ -135,6 +135,13 @@ object NappletEmbedContract { */ const val MSG_CONSOLE_LOG = 24 + /** + * Client → provider: the tab was torn down (evicted, or rebuilt for a theme/account change). Drops the + * session and its WebView even if the surface never opened — a session created for a view that was + * disposed before it attached would otherwise sit in the provider forever, pinning its client. + */ + const val MSG_CLOSE_SESSION = 25 + const val KEY_FIND_QUERY = "findQuery" const val KEY_FIND_FORWARD = "findForward" const val KEY_FIND_ACTIVE = "findActive" 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 a75c0c2f6c..db9c8a24e8 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostActivity.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostActivity.kt @@ -66,6 +66,7 @@ import com.vitorpamplona.amethyst.commons.browser.BrowserSitePermission 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.NappletWebContract import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletProtocolJson import com.vitorpamplona.amethyst.commons.util.booleanOrNull @@ -230,6 +231,10 @@ class NappletHostActivity : ComponentActivity() { // the broker binds after this surface is already resumed (bindService is async). private var resumed = false + // Requests that act for the user (publish, pay, upload…) made while this napplet was in the background. + // Pausing the WebView doesn't stop JavaScript, so they are held here and sent on the next resume. + private val heldWhilePaused = mutableListOf<Message>() + // 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. private var foregroundHeartbeat: Job? = null @@ -380,6 +385,9 @@ class NappletHostActivity : ComponentActivity() { // keep renewing that lease so a crash here can't pin the network up forever. resumed = true startForegroundHeartbeat() + val held = heldWhilePaused.toList() + heldWhilePaused.clear() + held.forEach { if (brokerMessenger == null) pendingRequests.add(it) else sendToBroker(it) } } override fun onPause() { @@ -767,6 +775,11 @@ 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 + return + } val messenger = brokerMessenger if (messenger == null) { pendingRequests.add(msg) 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 43b8400b57..2a40a14e91 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostService.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostService.kt @@ -57,7 +57,9 @@ import androidx.webkit.WebMessageCompat 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.NappletWebContract +import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletProtocolJson import com.vitorpamplona.amethyst.commons.util.booleanOrNull import com.vitorpamplona.amethyst.commons.util.parseJsonObjectOrNull import com.vitorpamplona.amethyst.commons.util.stringOrNull @@ -118,6 +120,10 @@ class NappletHostService : Service() { // The client's last pause/resume. A parked tab is paused before its WebView exists (the WebView is // only built when the surface opens), so the flag is applied to every WebView built for the tab. var paused = false + + // Requests that act for the user (publish, pay, upload…) sent while the tab was off-screen. Pausing + // the WebView doesn't stop JavaScript, so they are held here and sent when the user comes back. + val heldWhilePaused = mutableListOf<Message>() var bridgeReplyProxy: JavaScriptReplyProxy? = null var fireSeq = 0 @@ -132,6 +138,17 @@ class NappletHostService : Service() { // The user's text size, re-applied when a renderer crash forces a fresh WebView. var textZoom = BrowserChrome.DEFAULT_TEXT_ZOOM val replyMessenger = Messenger(Handler(Looper.getMainLooper()) { onBrokerReply(this, it) }) + + /** + * Sends [msg] to this tab's client stamped with the session it belongs to, so the client can drop what + * a session it has since replaced still had in flight (a late file-chooser request, a stale + * SESSION_READY that would re-arm its view with a dead adapter). Returns whether it was delivered. + */ + fun toClient(msg: Message): Boolean { + val client = clientMessenger ?: return false + msg.data.putString(NappletEmbedContract.KEY_SESSION_ID, sessionId) + return runCatching { client.send(msg) }.isSuccess + } } private val tabs = mutableMapOf<String, NappletTab>() @@ -210,6 +227,7 @@ class NappletHostService : Service() { // onPause()/onResume() are per-WebView (pause/resume THIS surface's JS/DOM). Do NOT call // pauseTimers()/resumeTimers(): they are process-global and would freeze/thaw every WebView in // `:napplet` (the browser embed + other napplets), whose lifecycles are independent of this one. + NappletEmbedContract.MSG_CLOSE_SESSION -> tabFor(msg)?.let(::closeTab) NappletEmbedContract.MSG_PAUSE -> tabFor(msg)?.let { it.paused = true @@ -219,11 +237,15 @@ class NappletHostService : Service() { tabFor(msg)?.let { it.paused = false it.webView?.onResume() + val held = it.heldWhilePaused.toList() + it.heldWhilePaused.clear() + held.forEach { request -> if (brokerMessenger == null) pendingBrokerRequests.add(request) else sendToBroker(request) } } NappletEmbedContract.MSG_IME_OP -> { val tab = tabFor(msg) ?: return true val payload = msg.data?.getString(NappletEmbedContract.KEY_IME_PAYLOAD) ?: return true - tab.bridgeReplyProxy?.postMessage(payload) + // The proxy can belong to a page that has already gone away; that must not crash the sandbox. + runCatching { tab.bridgeReplyProxy?.postMessage(payload) } } NappletEmbedContract.MSG_MAGNIFIER_REQUEST -> onMagnifierRequest(msg) NappletEmbedContract.MSG_FIND -> { @@ -315,7 +337,7 @@ class NappletHostService : Service() { putLong(NappletEmbedContract.KEY_MAG_REQ_T, reqT) } } - runCatching { tab.clientMessenger?.send(reply) } + tab.toClient(reply) } } @@ -328,7 +350,7 @@ class NappletHostService : Service() { Message.obtain(null, NappletEmbedContract.MSG_SESSION_READY).apply { data = Bundle().apply { putBundle(NappletEmbedContract.KEY_CORE_LIB_INFO, coreLibInfo) } } - runCatching { tab.clientMessenger?.send(reply) } + tab.toClient(reply) } /** @@ -404,8 +426,12 @@ class NappletHostService : Service() { // Only the session that currently owns the tab may close it. A late close from a session that was // already replaced by a re-open would otherwise reap the live one — its WebView destroyed under a // client that had just been told the session opened, leaving the surface black for good. - val tab = tabs[sessionId]?.takeIf { it.container === container } ?: return - tabs.remove(sessionId) + tabs[sessionId]?.takeIf { it.container === container }?.let(::closeTab) + } + + /** Drops [tab] and everything it holds (its WebView, content server, broker state, proxy claim). */ + private fun closeTab(tab: NappletTab) { + tabs.remove(tab.sessionId) tab.bridgeReplyProxy = null WebViewProxyPolicy.release(tab) releaseFromBroker(tab) @@ -535,7 +561,7 @@ class NappletHostService : Service() { putString(NappletEmbedContract.KEY_FILE_CHOOSER_TITLE, params.title?.toString()) } } - if (runCatching { client.send(msg) }.isFailure) tab.fileChooser.cancel() + if (!tab.toClient(msg)) tab.fileChooser.cancel() return true } @@ -635,7 +661,7 @@ class NappletHostService : Service() { Message.obtain(null, NappletEmbedContract.MSG_STATE).apply { data = Bundle().apply { putBoolean(NappletEmbedContract.KEY_CAN_GO_BACK, view.canGoBack()) } } - runCatching { tab.clientMessenger?.send(message) } + tab.toClient(message) } private fun pushFindResult( @@ -651,7 +677,7 @@ class NappletHostService : Service() { putInt(NappletEmbedContract.KEY_FIND_TOTAL, total) } } - runCatching { tab.clientMessenger?.send(message) } + tab.toClient(message) } private fun pushConsoleLog( @@ -671,7 +697,7 @@ class NappletHostService : Service() { putInt(NappletEmbedContract.KEY_CONSOLE_LINE, line) } } - runCatching { tab.clientMessenger?.send(message) } + tab.toClient(message) } /** Tells the client whether a main-frame load is in flight and whether it failed, so it can overlay a spinner/retry. */ @@ -689,7 +715,7 @@ class NappletHostService : Service() { putBoolean(NappletEmbedContract.KEY_RENDERER_GONE, rendererGone) } } - runCatching { tab.clientMessenger?.send(message) } + tab.toClient(message) } // ---- bridge: shell <-> native (mirror of NappletHostActivity.onShellMessage) ---- @@ -714,7 +740,7 @@ class NappletHostService : Service() { Message.obtain(null, NappletEmbedContract.MSG_IME_EVENT).apply { data = Bundle().apply { putString(NappletEmbedContract.KEY_IME_PAYLOAD, raw) } } - runCatching { tab.clientMessenger?.send(reply) } + tab.toClient(reply) return } @@ -729,6 +755,11 @@ class NappletHostService : Service() { putString(NappletIpc.KEY_LAUNCH_TOKEN, tab.launchToken) } } + // Parked off-screen: an act on the user's behalf waits until they're looking at this napplet again. + if (tab.paused && NappletActingRequests.actsForUser(runCatching { NappletProtocolJson.readType(raw) }.getOrNull())) { + tab.heldWhilePaused += msg + return + } if (brokerMessenger == null) pendingBrokerRequests.add(msg) else sendToBroker(msg) } @@ -739,6 +770,7 @@ class NappletHostService : Service() { * back itself when it is torn down. */ private fun releaseFromBroker(tab: NappletTab) { + tab.heldWhilePaused.clear() pendingBrokerRequests.removeAll { it.replyTo == tab.replyMessenger } if (brokerMessenger == null) return sendToBroker(Message.obtain(null, NappletIpc.MSG_RELEASE_CLIENT).apply { replyTo = tab.replyMessenger }) @@ -795,7 +827,7 @@ class NappletHostService : Service() { Message.obtain(null, NappletEmbedContract.MSG_NOTICE).apply { data = Bundle().apply { putString(NappletEmbedContract.KEY_NOTICE, notice) } } - runCatching { tab.clientMessenger?.send(message) } + tab.toClient(message) } private fun readContractAsset(path: String): ByteArray = assets.open(NappletWebContract.RESOURCE_ASSET_ROOT + path).use { it.readBytes() } 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 4066474a23..198b03f604 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletIpc.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletIpc.kt @@ -57,6 +57,9 @@ object NappletIpc { /** Broker → host: the [KEY_LAUNCH_TOKEN] minted for [KEY_BROWSER_ORIGIN]. */ const val MSG_BROWSER_TOKEN = 6 + /** How long a host waits for [MSG_BROWSER_TOKEN] before failing the origin's queued calls. */ + const val MINT_TIMEOUT_MS = 20_000L + /** * Host → broker: this sandbox surface entered ([KEY_FOREGROUND] true) or left ([KEY_FOREGROUND] * false) the foreground. The `:napplet` host runs in its own process and so can't touch the main From b2801ee7d750c1eaf3e129013f742eb0a5c3043c Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Tue, 29 Sep 2026 14:03:34 +0000 Subject: [PATCH 07/13] fix(browser): website tabs stop acting when nobody is looking, and pause in the background MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Website tabs in the bottom bar had two gaps: - A backgrounded app never paused the tab on screen: the pause followed which tab was active, not whether the app was visible. EmbeddedTabHost now watches the app's UI (the tab layer's lifecycle) and, after the same 30 s the relays get before they disconnect, pauses even the visible tab's page; returning resumes it. A quick trip to another app doesn't interrupt a page. - WebView.onPause doesn't stop JavaScript, so a parked or backgrounded site could keep calling NIP-07 — and sign silently under "allow always". Controllers now tell the provider whether the user is looking at the tab (MSG_SET_ATTENDED: visible tab AND app on screen); while not, the provider holds nostr.signEvent / nip44Encrypt / nip44Decrypt and sends them once the user is back. Reads (getPublicKey, getRelays) still flow. The full-screen browser holds them while it isn't resumed. NappletActingRequests gains the three NIP-07 types. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G3bteStHvaf18TdABSkb8h --- .../browser/EmbeddedWebAppController.kt | 50 ++++++++++++++++--- .../embed/EmbeddedSurfaceController.kt | 16 ++++++ .../screen/loggedIn/embed/EmbeddedTabHost.kt | 44 ++++++++++++++++ .../screen/loggedIn/embed/EmbeddedTabLayer.kt | 19 +++++++ .../commons/napplet/NappletActingRequests.kt | 15 ++++-- .../napplet/NappletActingRequestsTest.kt | 15 ++++-- .../napplethost/NappletBrowserActivity.kt | 24 +++++++++ .../napplethost/NappletBrowserContract.kt | 8 +++ .../napplethost/NappletBrowserService.kt | 32 ++++++++++++ 9 files changed, 207 insertions(+), 16 deletions(-) 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 c962d6e124..4eda3795ec 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 @@ -115,9 +115,14 @@ class EmbeddedWebAppController( // A `:napplet` restart found this tab hidden: its session is re-created when it is next shown. private var createOnShow = false - // A parked tab can be hidden (paused) before the service even binds, so the pause is remembered and - // replayed right after each session is created. + // 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 + + // The app is on screen / has been in the background long enough to pause even the visible tab. + private var appVisible = true + private var backgroundIdle = false /** Last known main-frame load state, so the tab layer renders the right overlay immediately. */ override var loadStatus: EmbeddedLoadStatus = EmbeddedLoadStatus() @@ -268,9 +273,8 @@ class EmbeddedWebAppController( override fun teardown() = unbind() override fun onShown() { - wantPaused = false - send(NappletBrowserContract.MSG_RESUME) {} val deferredRecovery = recovery.onShown() + syncPageState() if (createOnShow) { createOnShow = false sendCreateSession() @@ -280,11 +284,40 @@ class EmbeddedWebAppController( } override fun onHidden() { - // A warm tab parked off-screen keeps no animations, media or geolocation running (napplets are - // paused the same way). - wantPaused = true - send(NappletBrowserContract.MSG_PAUSE) {} recovery.onHidden() + syncPageState() + } + + override fun onAppVisibility(visible: Boolean) { + appVisible = visible + syncPageState() + } + + override fun onBackgroundIdle(idle: Boolean) { + backgroundIdle = idle + syncPageState() + } + + /** + * Tells the provider what the page may do now: + * - paused while parked off-screen, or once the app has sat in the background as long as the relays get + * (EmbeddedTabHost.BACKGROUND_PAUSE_MS) — no animations, media or geolocation keep running; + * - attended only while it's the visible tab AND the app is on screen. The provider holds the page's + * NIP-07 sign / encrypt / decrypt while it isn't, so a parked or backgrounded site can't sign (even with + * "allow always") while nobody is looking. That one applies at once: it's about who is watching, not + * about saving work. + */ + private fun syncPageState() { + val pause = !recovery.isShown || backgroundIdle + if (pause != wantPaused) { + wantPaused = pause + send(if (pause) NappletBrowserContract.MSG_PAUSE else NappletBrowserContract.MSG_RESUME) {} + } + val attended = recovery.isShown && appVisible + if (attended != wantAttended) { + wantAttended = attended + send(NappletBrowserContract.MSG_SET_ATTENDED) { putBoolean(NappletBrowserContract.KEY_ENABLED, attended) } + } } /** @@ -427,6 +460,7 @@ 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) } } private fun onServiceMessage(msg: Message): Boolean { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedSurfaceController.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedSurfaceController.kt index b3add42f99..bf3bda6e13 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedSurfaceController.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedSurfaceController.kt @@ -57,6 +57,22 @@ interface EmbeddedSurfaceController { // Optional hook: default no-op. Controllers that don't pause/resume applet JS need no action. } + /** + * The app left the screen ([visible] false) or came back. Even the visible tab has nobody looking at it + * while the app is in the background, so a controller stops anything that acts for the user right away. + */ + fun onAppVisibility(visible: Boolean) { + // Optional hook: default no-op (napplet screens pause on their own lifecycle). + } + + /** + * The app has been in the background long enough that the rest of it winds down too (relays disconnect + * at the same point): [idle] true pauses even the visible tab's page; false when the app returns. + */ + fun onBackgroundIdle(idle: Boolean) { + // Optional hook: default no-op (napplet screens pause on their own lifecycle). + } + /** Permanently close the session (unbind the service); used on eviction. */ fun teardown() 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 ed06b11204..5530f2fabb 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 @@ -21,6 +21,8 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.embed import android.os.Build +import android.os.Handler +import android.os.Looper import androidx.annotation.RequiresApi import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateListOf @@ -112,10 +114,52 @@ object EmbeddedTabHost { ): EmbeddedSurfaceController { warm.firstOrNull { it.id == id }?.let { return it.controller } val controller = factory() + // A session built while the app is in the background (a rebuild, a preload) starts in that state. + if (!appVisible) controller.onAppVisibility(false) + if (backgroundIdle) controller.onBackgroundIdle(true) warm.add(Warm(id, controller)) return controller } + // ---- the app in the background ---- + + /** + * How long the app sits in the background before even the visible tab's page is paused. The same grace + * the rest of the app gets: relays disconnect 30 s after the UI stops (RelayProxyClientConnector's + * `WhileSubscribed(30000)`), so a quick trip to another app (a 2FA code, a password manager) doesn't + * interrupt a page, while one left behind stops running. + */ + const val BACKGROUND_PAUSE_MS = 30_000L + + private var appVisible = true + private var backgroundIdle = false + private val backgroundTimer = Handler(Looper.getMainLooper()) + private val goIdle = + Runnable { + backgroundIdle = true + warm.forEach { it.controller.onBackgroundIdle(true) } + } + + /** The app's UI stopped (went to the background). */ + fun onAppStopped() { + if (!appVisible) return + appVisible = false + warm.forEach { it.controller.onAppVisibility(false) } + backgroundTimer.postDelayed(goIdle, BACKGROUND_PAUSE_MS) + } + + /** The app's UI started again. */ + fun onAppStarted() { + backgroundTimer.removeCallbacks(goIdle) + if (appVisible) return + appVisible = true + warm.forEach { it.controller.onAppVisibility(true) } + if (backgroundIdle) { + backgroundIdle = false + warm.forEach { it.controller.onBackgroundIdle(false) } + } + } + /** True if a warm session already exists for [id] (used by the preloader to skip re-acquiring). */ fun isWarm(id: String): Boolean = warm.any { it.id == 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 c7b3e085a1..c9d05ea8ce 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 @@ -84,6 +84,9 @@ import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.privacysandbox.ui.client.view.SandboxedSdkView import com.vitorpamplona.amethyst.commons.browser.BrowserChrome import com.vitorpamplona.amethyst.commons.browser.ui.EmbeddedLoadOverlay @@ -151,6 +154,22 @@ private fun EmbeddedImeBridge.sendFieldOp( fun EmbeddedTabLayer(barFavoriteIds: List<String>) { val activeId = EmbeddedTabHost.activeId + // Tell the warm tabs when the app leaves the screen and when it comes back. The host stops them acting for + // the user right away and pauses their pages on the same schedule the relays wind down on. + val lifecycleOwner = LocalLifecycleOwner.current + DisposableEffect(lifecycleOwner) { + val observer = + LifecycleEventObserver { _, event -> + when (event) { + Lifecycle.Event.ON_STOP -> EmbeddedTabHost.onAppStopped() + Lifecycle.Event.ON_START -> EmbeddedTabHost.onAppStarted() + else -> Unit + } + } + lifecycleOwner.lifecycle.addObserver(observer) + onDispose { lifecycleOwner.lifecycle.removeObserver(observer) } + } + // Keep only bottom-row apps warm (plus the active tab, even mid-removal). A favorite removed from // the bar drops its warm session here. LaunchedEffect(barFavoriteIds, activeId) { diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletActingRequests.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletActingRequests.kt index 133086c027..3df0b284d1 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletActingRequests.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletActingRequests.kt @@ -21,11 +21,12 @@ package com.vitorpamplona.amethyst.commons.napplet /** - * The napplet requests that ACT for the user — publish, pay, upload, notify, broadcast to other - * napplets — as opposed to reading. A host holds these while its napplet is off-screen: pausing the - * WebView stops animations and media but not JavaScript, so without this an "allow always" napplet - * parked in the bottom bar could keep publishing or paying while the user looks elsewhere. Reads keep - * flowing, so a preloaded napplet still fills itself in. + * The napplet / website requests that ACT for the user or use their key — publish, pay, upload, notify, + * broadcast to other napplets, and a website's NIP-07 sign / encrypt / decrypt — as opposed to reading. + * A host holds these while the user isn't looking at the surface (it's parked off-screen, or the app is in + * the background): pausing the WebView stops animations and media but not JavaScript, so without this an + * "allow always" napplet or site could keep publishing, paying, signing or decrypting while the user looks + * elsewhere. Reads (`getPublicKey`, relay queries, …) keep flowing, so a preloaded surface still fills in. */ object NappletActingRequests { private val ACTING = @@ -36,6 +37,10 @@ object NappletActingRequests { "upload.upload", "notify.create", "inc.emit", + // NIP-07 (website posture). Decrypt counts too: it hands the page plaintext it couldn't read. + "nostr.signEvent", + "nostr.nip44Encrypt", + "nostr.nip44Decrypt", ) fun actsForUser(requestType: String?): Boolean = requestType in ACTING diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletActingRequestsTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletActingRequestsTest.kt index 45252e7d42..ef7d4d2395 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletActingRequestsTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletActingRequestsTest.kt @@ -27,13 +27,22 @@ import kotlin.test.assertTrue class NappletActingRequestsTest { @Test fun actingRequestsAreHeld() { - listOf("relay.publish", "relay.publishEncrypted", "value.payInvoice", "upload.upload", "notify.create", "inc.emit") - .forEach { assertTrue(NappletActingRequests.actsForUser(it), it) } + listOf( + "relay.publish", + "relay.publishEncrypted", + "value.payInvoice", + "upload.upload", + "notify.create", + "inc.emit", + "nostr.signEvent", + "nostr.nip44Encrypt", + "nostr.nip44Decrypt", + ).forEach { assertTrue(NappletActingRequests.actsForUser(it), it) } } @Test fun readsFlow() { - listOf("identity.getPublicKey", "relay.query", "relay.subscribe", "relay.close", "storage.get", "resource.bytes", "theme.get", null) + listOf("identity.getPublicKey", "identity.getRelays", "relay.query", "relay.subscribe", "relay.close", "storage.get", "resource.bytes", "theme.get", null) .forEach { assertFalse(NappletActingRequests.actsForUser(it), it.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 7aeb811511..471083d70f 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserActivity.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserActivity.kt @@ -87,9 +87,11 @@ 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.PageDialogType +import com.vitorpamplona.amethyst.commons.napplet.NappletActingRequests import com.vitorpamplona.amethyst.commons.napplet.NappletBridgeDocuments 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.parseJsonObjectOrNull import com.vitorpamplona.amethyst.commons.util.stringOrNull import com.vitorpamplona.amethyst.commons.util.withString @@ -228,6 +230,11 @@ class NappletBrowserActivity : ComponentActivity() { private val pendingByOrigin = mutableMapOf<String, MutableList<Message>>() private val mintInFlight = mutableSetOf<String>() + // The page's requests that act for the user (NIP-07 sign / encrypt / decrypt) made while this window was in + // the background, as (origin, request): sent on the next resume, so a site can't sign — even with + // "allow always" — while nobody is looking at it. + private val heldWhileAway = mutableListOf<Pair<String, Message>>() + /** * Back walks out of fullscreen video, then the find bar, then the page's history, then leaves. Enabled * only while one of those applies, so the system back (and its predictive animation) otherwise acts @@ -398,6 +405,9 @@ class NappletBrowserActivity : ComponentActivity() { super.onResume() webView?.onResume() resumed = true + val held = heldWhileAway.toList() + heldWhileAway.clear() + held.forEach { (origin, request) -> dispatchToBroker(origin, request) } heartbeatHandler.removeCallbacks(heartbeat) heartbeat.run() } @@ -928,6 +938,19 @@ class NappletBrowserActivity : ComponentActivity() { } } + // 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 + return + } + dispatchToBroker(origin, msg) + } + + /** Sends [msg] with [origin]'s launch token, minting the token first if the origin has none yet. */ + private fun dispatchToBroker( + origin: String, + msg: Message, + ) { val token = originTokens[origin] if (token != null) { msg.data.putString(NappletIpc.KEY_LAUNCH_TOKEN, token) @@ -982,6 +1005,7 @@ class NappletBrowserActivity : ComponentActivity() { */ private fun releasePage() { pendingByOrigin.clear() + heldWhileAway.clear() // A mint the broker never answered (none is sent while logged out) would otherwise block the // origin for good; the next page asks again. mintInFlight.clear() 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 715ace5e6c..f1da8d50b7 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserContract.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserContract.kt @@ -203,6 +203,14 @@ object NappletBrowserContract { */ const val MSG_CLOSE_SESSION = 36 + /** + * Client → provider: whether the user is looking at this tab ([KEY_ENABLED]) — it's the visible tab AND + * the app is on screen. While not, the provider holds the page's requests that act for the user or use + * their key (NIP-07 sign / encrypt / decrypt) and sends them once the user is back, so a parked or + * backgrounded site can't sign — even with "allow always" — while nobody is watching. Reads still flow. + */ + const val MSG_SET_ATTENDED = 37 + 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 d5d10715c1..bbbc6abafc 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserService.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserService.kt @@ -60,9 +60,11 @@ import androidx.webkit.WebViewCompat import com.vitorpamplona.amethyst.commons.browser.BrowserChrome 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.NappletProxyClaims import com.vitorpamplona.amethyst.commons.napplet.NappletWebContract +import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletProtocolJson import com.vitorpamplona.amethyst.commons.util.parseJsonObjectOrNull import com.vitorpamplona.amethyst.commons.util.stringOrNull import com.vitorpamplona.amethyst.commons.util.withString @@ -113,6 +115,11 @@ class NappletBrowserService : Service() { // is only built when the surface opens), so the flag is applied to every WebView built for the tab. var paused = false + // Whether the user is looking at this tab (see NappletBrowserContract.MSG_SET_ATTENDED), and the page's + // requests that act for the user held while they aren't: (origin, request), sent when they're back. + var attended = true + val heldWhileAway = mutableListOf<Pair<String, Message>>() + // The session's root view (holds the WebView, and the page's fullscreen view when it has one). var container: FrameLayout? = null var customView: View? = null @@ -251,6 +258,15 @@ class NappletBrowserService : Service() { } } 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 + if (tab.attended) { + val held = tab.heldWhileAway.toList() + tab.heldWhileAway.clear() + held.forEach { (origin, request) -> dispatchToBroker(tab, origin, request) } + } + } NappletBrowserContract.MSG_PAUSE -> tabFor(msg)?.let { it.paused = true @@ -1041,6 +1057,21 @@ class NappletBrowserService : Service() { } } + // 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 + return + } + dispatchToBroker(tab, origin, msg) + } + + /** Sends [msg] with [origin]'s launch token, minting the token first if the origin has none yet. */ + private fun dispatchToBroker( + tab: BrowserTab, + origin: String, + msg: Message, + ) { val token = tab.originTokens[origin] if (token != null) { msg.data.putString(NappletIpc.KEY_LAUNCH_TOKEN, token) @@ -1077,6 +1108,7 @@ class NappletBrowserService : Service() { closing: Boolean = false, ) { tab.pendingByOrigin.clear() + tab.heldWhileAway.clear() // A mint the broker never answered (none is sent while logged out) would otherwise block the // origin for the tab's life; the next page asks again. tab.mintInFlight.clear() From ec8613a8bc77644d836cc78334aff5511eac460d Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Tue, 29 Sep 2026 14:10:39 +0000 Subject: [PATCH 08/13] fix(napplet): embedded napplets follow the websites' background schedule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Embedded napplets paused the moment the app left the screen (the tab screen's own ON_STOP), while website tabs now wait the relays' 30 s. Napplets now follow EmbeddedTabHost like website tabs do: - their acting requests (publish, pay, upload, notify, inc.emit) are held the moment the user stops watching — the tab is parked or the app is in the background (new NappletEmbedContract.MSG_SET_ATTENDED; the host holds while not attended or paused); - their page is paused while parked, and 30 s after the app goes to the background, so a quick trip to another app doesn't interrupt them. NostrAppScreen's own lifecycle pause goes away. The full-screen napplet host is unchanged (it still pauses on onPause). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G3bteStHvaf18TdABSkb8h --- .../favorites/EmbeddedNostrAppController.kt | 65 +++++++++++++------ .../loggedIn/favorites/NostrAppScreen.kt | 21 +----- .../napplethost/NappletEmbedContract.kt | 10 +++ .../napplethost/NappletHostService.kt | 30 +++++++-- 4 files changed, 82 insertions(+), 44 deletions(-) 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 908595756b..6f80fb9b59 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 @@ -112,10 +112,15 @@ class EmbeddedNostrAppController( // previous view can never reap the replacement. private var sessionId: String = "napplet-${SESSION_SEQ.incrementAndGet()}" - // A parked tab can be hidden (paused) before the service even binds, so the pause message is - // dropped (no messenger yet). Remember the intent and replay it right after the session is created, - // otherwise an applet that was never shown comes up running in the background. + // 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 + + // The app is on screen / has been in the background long enough to pause even the visible tab. + private var appVisible = true + private var backgroundIdle = false /** (canGoBack) — drives the in-tab back gesture. */ var onStateChanged: ((Boolean) -> Unit)? = null @@ -345,8 +350,8 @@ class EmbeddedNostrAppController( } override fun onShown() { - resume() val deferredRecovery = recovery.onShown() + syncPageState() if (createOnShow) { createOnShow = false sendCreateSession() @@ -356,8 +361,40 @@ class EmbeddedNostrAppController( } override fun onHidden() { - pause() recovery.onHidden() + syncPageState() + } + + override fun onAppVisibility(visible: Boolean) { + appVisible = visible + syncPageState() + } + + override fun onBackgroundIdle(idle: Boolean) { + backgroundIdle = idle + syncPageState() + } + + /** + * Tells the provider what the applet may do now — the same schedule as a website tab: + * - paused (JS-driven animations, media, geolocation) while parked off-screen, or once the app has sat in + * the background as long as the relays get (EmbeddedTabHost.BACKGROUND_PAUSE_MS), so a quick trip to + * another app doesn't interrupt it; + * - attended only while it's the visible tab AND the app is on screen. The provider holds its requests + * that act for the user (publish, pay, upload…) while it isn't — at once, not after the grace: even an + * "allow always" napplet can't act on the user's behalf while they aren't looking. + */ + private fun syncPageState() { + val pause = !recovery.isShown || backgroundIdle + if (pause != wantPaused) { + wantPaused = pause + send(if (pause) NappletEmbedContract.MSG_PAUSE else NappletEmbedContract.MSG_RESUME) + } + val attended = recovery.isShown && appVisible + if (attended != wantAttended) { + wantAttended = attended + send(NappletEmbedContract.MSG_SET_ATTENDED) { putBoolean(NappletEmbedContract.KEY_ATTENDED, attended) } + } } override fun teardown() = unbind() @@ -377,10 +414,11 @@ class EmbeddedNostrAppController( } } runCatching { serviceMessenger?.send(msg) } - // Replay a pause that was requested before we had a messenger to send it on (parked-before-bound), - // so a never-shown applet doesn't start running. Messenger preserves order, so PAUSE lands after - // CREATE in the host. + // Replay a pause / not-attended that was 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) } if (textZoom != BrowserChrome.DEFAULT_TEXT_ZOOM) setTextZoom(textZoom) } @@ -550,17 +588,6 @@ class EmbeddedNostrAppController( } } - /** Pause/resume the applet's JS when the tab leaves/returns to the foreground (background gating). */ - fun pause() { - wantPaused = true - send(NappletEmbedContract.MSG_PAUSE) - } - - fun resume() { - wantPaused = false - send(NappletEmbedContract.MSG_RESUME) - } - private inline fun send( what: Int, crossinline block: Bundle.() -> Unit = {}, 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 cd5c45f753..72c84bb38a 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 @@ -51,8 +51,6 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.Amethyst @@ -238,22 +236,9 @@ private fun EmbeddedNostrAppTab( } } - // Pause the applet's JS while the app is backgrounded (parity with NappletHostActivity's onPause): - // an "allow always" napplet can't act on the user's behalf when they aren't looking. (The tab layer - // separately pauses it whenever it isn't the visible tab.) - val lifecycleOwner = LocalLifecycleOwner.current - DisposableEffect(lifecycleOwner, controller) { - val observer = - LifecycleEventObserver { _, event -> - when (event) { - Lifecycle.Event.ON_STOP -> controller.pause() - Lifecycle.Event.ON_START -> controller.resume() - else -> Unit - } - } - lifecycleOwner.lifecycle.addObserver(observer) - onDispose { lifecycleOwner.lifecycle.removeObserver(observer) } - } + // No lifecycle handling here: the tab layer tells every warm tab when the app leaves the screen + // (EmbeddedTabHost.onAppStopped), which holds the applet's acting requests at once and pauses its page on + // the relays' 30 s schedule. BackHandler(enabled = canGoBack) { controller.back() } 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 1b52ee3dba..4dd61f99e9 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletEmbedContract.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletEmbedContract.kt @@ -142,6 +142,15 @@ object NappletEmbedContract { */ const val MSG_CLOSE_SESSION = 25 + /** + * Client → provider: whether the user is looking at this napplet ([KEY_ATTENDED]) — it's the visible tab + * AND the app is on screen. While not, the provider holds the napplet's requests that act for the user + * (publish, pay, upload, notify, `inc.emit`) and sends them once the user is back. Separate from + * [MSG_PAUSE]: the page itself is only paused a while after the app leaves the screen, but nothing may + * act on the user's behalf the moment they stop watching. + */ + const val MSG_SET_ATTENDED = 26 + const val KEY_FIND_QUERY = "findQuery" const val KEY_FIND_FORWARD = "findForward" const val KEY_FIND_ACTIVE = "findActive" @@ -190,6 +199,7 @@ object NappletEmbedContract { * so this scopes a control to the right surface and routes state/notices/IME back to the right tab. */ const val KEY_SESSION_ID = "sessionId" + const val KEY_ATTENDED = "attended" const val NOTICE_PUBLISHED = "published" const val NOTICE_UPLOADED = "uploaded" 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 2a40a14e91..58eef54d49 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostService.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostService.kt @@ -121,9 +121,13 @@ class NappletHostService : Service() { // only built when the surface opens), so the flag is applied to every WebView built for the tab. var paused = false - // Requests that act for the user (publish, pay, upload…) sent while the tab was off-screen. Pausing - // the WebView doesn't stop JavaScript, so they are held here and sent when the user comes back. + // Whether the user is looking at this napplet (NappletEmbedContract.MSG_SET_ATTENDED). Requests that act + // for the user (publish, pay, upload…) made while they aren't — or while the page is paused — are held + // here and sent when they're back: pausing the WebView doesn't stop JavaScript. + var attended = true val heldWhilePaused = mutableListOf<Message>() + + val mayAct: Boolean get() = attended && !paused var bridgeReplyProxy: JavaScriptReplyProxy? = null var fireSeq = 0 @@ -237,9 +241,12 @@ class NappletHostService : Service() { tabFor(msg)?.let { it.paused = false it.webView?.onResume() - val held = it.heldWhilePaused.toList() - it.heldWhilePaused.clear() - held.forEach { request -> if (brokerMessenger == null) pendingBrokerRequests.add(request) else sendToBroker(request) } + releaseHeld(it) + } + NappletEmbedContract.MSG_SET_ATTENDED -> + tabFor(msg)?.let { + it.attended = msg.data?.getBoolean(NappletEmbedContract.KEY_ATTENDED, true) ?: true + releaseHeld(it) } NappletEmbedContract.MSG_IME_OP -> { val tab = tabFor(msg) ?: return true @@ -755,14 +762,23 @@ class NappletHostService : Service() { putString(NappletIpc.KEY_LAUNCH_TOKEN, tab.launchToken) } } - // Parked off-screen: an act on the user's behalf waits until they're looking at this napplet again. - if (tab.paused && NappletActingRequests.actsForUser(runCatching { NappletProtocolJson.readType(raw) }.getOrNull())) { + // 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 return } if (brokerMessenger == null) pendingBrokerRequests.add(msg) else sendToBroker(msg) } + /** 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) } + } + /** * [tab] is gone: have the broker close the live relay / inc subscriptions it opened and drop its reply * Messenger (a binder the main process would otherwise hold, keeping the tab alive). The launch token is From 506c583a2d055cf9d1946285dea3ad5398dc0838 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Tue, 29 Sep 2026 14:14:46 +0000 Subject: [PATCH 09/13] fix(napplet): full-screen hosts follow the same background schedule The full-screen napplet host and browser paused their WebView on onPause, while embedded tabs now wait the relays' 30 s. Both activities now pause the page 30 s after onStop (cancelled if the user is back within it), and keep holding requests that act for the user from the moment they stop being resumed, as before. The 30 s lives in NappletHostContract.BACKGROUND_PAUSE_MS, shared with EmbeddedTabHost. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G3bteStHvaf18TdABSkb8h --- .../screen/loggedIn/embed/EmbeddedTabHost.kt | 10 ++--- .../napplethost/NappletBrowserActivity.kt | 26 ++++++++++--- .../napplethost/NappletHostActivity.kt | 39 ++++++++++++++----- .../napplethost/NappletHostContract.kt | 9 +++++ 4 files changed, 62 insertions(+), 22 deletions(-) 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 5530f2fabb..77727fd625 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 @@ -32,6 +32,7 @@ import androidx.compose.ui.geometry.Rect import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.LifecycleOwner +import com.vitorpamplona.amethyst.napplethost.NappletHostContract /** * Process-level holder of **warm embedded sessions** — the persistent-surface-layer half of keep-warm. @@ -123,13 +124,8 @@ object EmbeddedTabHost { // ---- the app in the background ---- - /** - * How long the app sits in the background before even the visible tab's page is paused. The same grace - * the rest of the app gets: relays disconnect 30 s after the UI stops (RelayProxyClientConnector's - * `WhileSubscribed(30000)`), so a quick trip to another app (a 2FA code, a password manager) doesn't - * interrupt a page, while one left behind stops running. - */ - const val BACKGROUND_PAUSE_MS = 30_000L + /** How long the app sits in the background before even the visible tab's page is paused (see there). */ + const val BACKGROUND_PAUSE_MS = NappletHostContract.BACKGROUND_PAUSE_MS private var appVisible = true private var backgroundIdle = false 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 471083d70f..36811a7cad 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserActivity.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserActivity.kt @@ -412,12 +412,27 @@ class NappletBrowserActivity : ComponentActivity() { heartbeat.run() } + override fun onStart() { + super.onStart() + // Back within the grace: the page was never paused. + heartbeatHandler.removeCallbacks(backgroundPause) + } + + override fun onStop() { + // Out of sight: pause the page after the same grace the rest of the app gets + // (NappletHostContract.BACKGROUND_PAUSE_MS), so a quick trip to another app doesn't interrupt it. + // NIP-07 sign / encrypt / decrypt is already held while not resumed (see [heldWhileAway]). + heartbeatHandler.postDelayed(backgroundPause, NappletHostContract.BACKGROUND_PAUSE_MS) + super.onStop() + } + + // Only pause THIS activity's WebView (onPause is per-WebView). Do NOT call pauseTimers(): it is + // process-global — it freezes JS/layout/parsing timers for EVERY WebView in `:napplet`, including the + // embedded ones in NappletBrowserService, which have no resume of their own. That left the embed frozen + // (dead page/connection) after returning from a full-screen excursion. + private val backgroundPause = Runnable { if (!isDestroyed) webView?.onPause() } + override fun onPause() { - // Only pause THIS activity's WebView (onPause is per-WebView). Do NOT call pauseTimers(): it is - // process-global — it freezes JS/layout/parsing timers for EVERY WebView in `:napplet`, including - // the embedded ones in NappletBrowserService, which have no resume of their own. That left the - // embed frozen (dead page/connection) after returning from a full-screen excursion. - webView?.onPause() resumed = false heartbeatHandler.removeCallbacks(heartbeat) setBrokerForeground(false) @@ -425,6 +440,7 @@ class NappletBrowserActivity : ComponentActivity() { } override fun onDestroy() { + heartbeatHandler.removeCallbacks(backgroundPause) // 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]. 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 db9c8a24e8..ff38788b9d 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostActivity.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostActivity.kt @@ -375,6 +375,16 @@ class NappletHostActivity : ComponentActivity() { } } + private val backgroundPauseHandler = Handler(Looper.getMainLooper()) + + // webView.onPause() pauses THIS WebView (animations, media, geolocation). Do NOT call pauseTimers(): it's + // process-global and freezes EVERY WebView in `:napplet`, including the embedded browser/napplet surfaces, + // which never resume. + private val backgroundPause = + Runnable { + if (!isDestroyed && this::webView.isInitialized && !webViewGone) webView.onPause() + } + override fun onResume() { super.onResume() if (this::webView.isInitialized && !webViewGone) { @@ -390,17 +400,25 @@ class NappletHostActivity : ComponentActivity() { held.forEach { if (brokerMessenger == null) pendingRequests.add(it) else sendToBroker(it) } } + override fun onStart() { + super.onStart() + // Back within the grace: the page was never paused. + backgroundPauseHandler.removeCallbacks(backgroundPause) + } + + override fun onStop() { + // Out of sight: pause the page after the same grace the rest of the app gets + // (NappletHostContract.BACKGROUND_PAUSE_MS), so a quick trip to another app doesn't interrupt it. + // Anything that acts for the user is already held (see [resumed]). + backgroundPauseHandler.postDelayed(backgroundPause, NappletHostContract.BACKGROUND_PAUSE_MS) + super.onStop() + } + override fun onPause() { - // Foreground-only: stop the applet's JS/timers in the background so it cannot fire a - // sign/decrypt/pay request whose consent prompt would surface over (and be confused with) - // Amethyst's own UI. Requests only happen while the user is looking at this napplet. - if (this::webView.isInitialized && !webViewGone) { - // webView.onPause() pauses THIS WebView's JS/DOM (the security goal — a backgrounded napplet can't - // fire a sign/decrypt/pay request). Do NOT call pauseTimers(): it's process-global and freezes - // EVERY WebView in `:napplet`, including the embedded browser/napplet surfaces, which never resume. - webView.onPause() - } - // No longer foreground: stop renewing and let the main process resume normal background scaling. + // Foreground-only for requests: while not resumed, the applet's requests that act for the user + // (sign/publish/pay…) are held until the user is back, so their consent prompt can't surface over + // (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 stopForegroundHeartbeat() setBrokerForeground(false) @@ -457,6 +475,7 @@ class NappletHostActivity : ComponentActivity() { } override fun onDestroy() { + backgroundPauseHandler.removeCallbacks(backgroundPause) 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. diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostContract.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostContract.kt index 2dc7f2b6af..568350b7c3 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostContract.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostContract.kt @@ -26,6 +26,15 @@ package com.vitorpamplona.amethyst.napplethost * `:amethyst` depends on `:nappletHost`, never the other way around. */ object NappletHostContract { + /** + * How long a napplet or website page keeps running after the app leaves the screen before its WebView is + * paused — embedded tabs and the full-screen hosts alike. The same grace the rest of the app gets: relays + * disconnect 30 s after the UI stops (RelayProxyClientConnector's `WhileSubscribed(30000)`), so a quick + * trip to another app (a 2FA code, a password manager) doesn't interrupt a page, while one left behind + * stops running. Requests that act for the user are held from the first moment regardless. + */ + const val BACKGROUND_PAUSE_MS = 30_000L + const val EXTRA_PATHS = "napplet_paths" const val EXTRA_HASHES = "napplet_hashes" const val EXTRA_SERVERS = "napplet_servers" From 3a78cd21c402ceffb42429812fdafc6cbe24b253 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Wed, 30 Sep 2026 20:38:30 +0000 Subject: [PATCH 10/13] fix(browser): Tor always wins and fails closed; per-document subscriptions; bounded held requests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tor / proxy - NappletProxyClaims: no host exemptions — any surface that wants Tor puts all of :napplet on Tor. Pages set to the open web show why they're on Tor anyway (BrowserChrome.State.torForced, fed by MSG_ROUTE/observeRoute). - WebViewProxyPolicy fails closed: a load waits for the route to apply, and gets onFailed (never a direct load) when applying fails or the WebView can't proxy while Tor is wanted. Callbacks run on the main executor. - Launchers pass useTor = "Tor is on", not "port known": a Tor surface with no port yet blocks (embedded: Retry re-reads the port; full screen: refuses with a toast) instead of going out on the open web. Sessions - Provider closes a live tab before accepting a duplicate create; closeTab removes by identity. Session ids carry a per-process nonce. rearmSession clears createOnShow. Late onUiError while a create is pending is ignored; Retry re-creates a surface that never opened. - Sessions start unattended; controllers always send their state. NIP-07 / relay reads - Relay subIds are stamped per document; pushes for a replaced document are dropped. A main-frame onPageStarted ends the document. - Held requests are capped (32) and expire (2 min) with failure replies. - Browser token mints carry the surface's storage profile; the broker refuses one that isn't the signed-in account's. Browser tokens get their own LRU so subdomain cycling can't evict napplet tokens. - Broker tracks attendance per client: encrypted subscription events are held undecrypted until the page is attended; relay.query waits for it. Also: releaseWhenGone tracks every parked back-stack entry; the console error count is read in its own composable scope. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G3bteStHvaf18TdABSkb8h --- .../amethyst/favorites/FavoriteAppLauncher.kt | 6 +- .../amethyst/napplet/NappletBrokerService.kt | 30 +++- .../amethyst/napplet/NappletLaunchRegistry.kt | 14 +- .../amethyst/napplet/NappletLauncher.kt | 6 +- .../napplet/NappletLiveSubscriptions.kt | 39 ++++- .../browser/EmbeddedWebAppController.kt | 65 +++++++-- .../screen/loggedIn/browser/WebAppScreen.kt | 10 +- .../loggedIn/embed/EmbeddedTabFactory.kt | 9 +- .../screen/loggedIn/embed/EmbeddedTabHost.kt | 24 ++-- .../screen/loggedIn/embed/EmbeddedTabLayer.kt | 82 ++++++----- .../favorites/EmbeddedNostrAppController.kt | 53 +++++-- .../loggedIn/favorites/NostrAppScreen.kt | 11 +- .../amethyst/commons/browser/BrowserChrome.kt | 5 + .../commons/napplet/NappletAttendance.kt | 56 ++++++++ .../commons/napplet/NappletBridgeDocuments.kt | 44 ++++++ .../commons/napplet/NappletHeldRequests.kt | 93 ++++++++++++ .../commons/napplet/NappletProxyClaims.kt | 47 +++---- .../commons/napplet/NappletAttendanceTest.kt | 57 ++++++++ .../napplet/NappletBridgeDocumentsTest.kt | 58 ++++++++ .../napplet/NappletHeldRequestsTest.kt | 82 +++++++++++ .../commons/napplet/NappletProxyClaimsTest.kt | 31 ++-- .../composeResources/values/strings.xml | 1 + .../commons/browser/ui/pill/BrowserPill.kt | 10 +- .../commons/browser/ui/pill/PageSheets.kt | 10 +- .../amethyst/napplethost/BridgeFailures.kt | 16 +++ .../napplethost/NappletBrowserActivity.kt | 113 ++++++++++++--- .../napplethost/NappletBrowserContract.kt | 14 ++ .../napplethost/NappletBrowserService.kt | 127 ++++++++++++++--- .../napplethost/NappletEmbedContract.kt | 16 +++ .../napplethost/NappletHostActivity.kt | 71 ++++++++-- .../napplethost/NappletHostService.kt | 101 +++++++++++-- .../amethyst/napplethost/NappletIpc.kt | 14 ++ .../napplethost/WebViewProxyPolicy.kt | 133 ++++++++++-------- nappletHost/src/main/res/values/strings.xml | 3 + 34 files changed, 1193 insertions(+), 258 deletions(-) create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletAttendance.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletHeldRequests.kt create mode 100644 commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletAttendanceTest.kt create mode 100644 commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletHeldRequestsTest.kt 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<Messenger>() + private val liveSubscriptions = NappletLiveSubscriptions(scope, attendance) // NAP-RESOURCE cancellation is keyed by the trusted launch token plus the caller's request id. // Cancelling removes the job before it can emit a late terminal envelope to the sandbox. @@ -161,6 +165,7 @@ class NappletBrokerService : Service() { msg.replyTo?.let { incBus.removeAll(it) liveSubscriptions.closeAllFor(it) + attendance.forget(it) } // Tokens the surface will never use again: drop their sessions and whatever runs under them. // Only the surface that minted a token holds it (tokens are unguessable), so it can only ever @@ -183,6 +188,15 @@ class NappletBrokerService : Service() { // A sandbox surface (full-screen :napplet host) entered, renewed, or left the foreground. Hold the // main process resumed while at least one is foreground, so opening it doesn't tear down Tor/relays. + if (msg.what == NappletIpc.MSG_SET_ATTENDED) { + val owner = msg.replyTo ?: return true + val attended = msg.data?.getBoolean(NappletIpc.KEY_ATTENDED, false) ?: false + attendance.set(owner, attended) + // Encrypted events its subscriptions received meanwhile can be decrypted and delivered now. + if (attended) liveSubscriptions.onAttended(owner) + return true + } + if (msg.what == NappletIpc.MSG_SET_FOREGROUND) { val data = msg.data ?: return true val token = data.getString(NappletIpc.KEY_LAUNCH_TOKEN) ?: return true @@ -349,7 +363,11 @@ class NappletBrokerService : Service() { // Bind to the account active at mint time: a browser token minted for one account must // never sign as another if the user switches while the page is still open. val mintAccount = Amethyst.instance.sessionManager.loggedInAccount() - if (mintAccount == null) { + // The surface names the storage jar it runs in: a page left open across an account switch (its + // cookies, its session, belong to the previous account) must not be re-minted a token that acts + // as the new one — its token is evicted or released, and it asks again from the old jar. + val surfaceProfile = data.getString(NappletIpc.KEY_WEBVIEW_PROFILE) + if (mintAccount == null || surfaceProfile != NappletWebViewProfiles.forPubKey(mintAccount.pubKey)) { // No one to act as: answer anyway (with no token), so the page's queued calls fail right away // instead of waiting forever for a token that will never come. val refusal = @@ -359,7 +377,7 @@ class NappletBrokerService : Service() { runCatching { replyTo.send(refusal) } return true } - val token = NappletLaunchRegistry.register(identity, NappletCapability.WEBSITE_CAPABILITIES, mintAccount.pubKey) + val token = NappletLaunchRegistry.register(identity, NappletCapability.WEBSITE_CAPABILITIES, mintAccount.pubKey, browserOrigin = true) val response = Message.obtain(null, NappletIpc.MSG_BROWSER_TOKEN).apply { this.data = @@ -419,6 +437,12 @@ class NappletBrokerService : Service() { reply(replyTo, requestId, NappletProtocolJson.encodeResponse(requestType, NappletResponse.Failed("That account is no longer signed in."))) return@launch } + // A query's results are decrypted with the user's key: while nobody is looking at the page it + // waits (as its sign / decrypt requests do), and gives up like them. + if (requestType == "relay.query" && !attendance.awaitAttended(replyTo, NappletHeldRequests.MAX_AGE_MS)) { + reply(replyTo, requestId, NappletProtocolJson.encodeResponse(requestType, NappletResponse.Failed(NappletHeldRequests.EXPIRED))) + return@launch + } when (val outcome = NappletRequestRouter.route(broker, identity, declared, payload)) { is NappletRequestRouter.Outcome.Ignore -> {} is NappletRequestRouter.Outcome.Reply -> { 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<String, Session>(MAX_SESSIONS) + // Browser tokens live apart: a page mints one per origin it touches, so a site cycling through + // subdomains (a.x.com, b.x.com, …) could otherwise push every open napplet's token out of the cache. + private val browserSessions = LruCache<String, Session>(MAX_SESSIONS) + fun register( identity: NappletIdentity, declared: Set<NappletCapability>, accountPubKey: HexKey, + browserOrigin: Boolean = false, ): String { val token = RandomInstance.bytes(32).toHexKey() - sessions.put(token, Session(identity.copy(instanceId = token), declared, accountPubKey)) + (if (browserOrigin) browserSessions else sessions).put(token, Session(identity.copy(instanceId = token), declared, accountPubKey)) return token } - fun resolve(token: String?): Session? = token?.let { sessions[it] } + fun resolve(token: String?): Session? = token?.let { sessions[it] ?: browserSessions[it] } fun unregister(token: String?) { - token?.let { sessions.remove(it) } + token?.let { + sessions.remove(it) + browserSessions.remove(it) + } } } 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<Messenger>, ) { private data class Key( val owner: Messenger, @@ -71,6 +73,10 @@ class NappletLiveSubscriptions( val eoseSent = AtomicBoolean(false) val deliveries = Channel<Delivery>(Channel.UNLIMITED) var deliveryJob: Job? = null + + // Encrypted events that arrived while nobody was looking at the page, still encrypted: they are + // decrypted and delivered when it is attended again. Touched only by the delivery coroutine. + val heldEncrypted = ArrayDeque<Event>() } private sealed interface Delivery { @@ -80,6 +86,9 @@ class NappletLiveSubscriptions( data object Eose : Delivery + // The page is being looked at again: deliver what was held. + data object Attended : Delivery + data class Closed( val reason: String, ) : Delivery @@ -114,9 +123,23 @@ class NappletLiveSubscriptions( for (delivery in sub.deliveries) { if (liveSubs[key] !== sub) break when (delivery) { - is Delivery.RelayEvent -> - NappletRelayCleartext.forDelivery(delivery.event, account.signer)?.let { - push(NappletProtocolJson.encodeRelayEvent(nappletSubId, it)) + is Delivery.RelayEvent -> { + val event = delivery.event + if (NappletRelayCleartext.isEncrypted(event) && !attendance.isAttended(owner)) { + // Don't decrypt for a page nobody is watching: keep it (bounded) for later. + if (sub.heldEncrypted.size >= MAX_HELD_ENCRYPTED) sub.heldEncrypted.removeFirst() + sub.heldEncrypted.addLast(event) + } else { + NappletRelayCleartext.forDelivery(event, account.signer)?.let { + push(NappletProtocolJson.encodeRelayEvent(nappletSubId, it)) + } + } + } + Delivery.Attended -> + while (sub.heldEncrypted.isNotEmpty() && attendance.isAttended(owner)) { + NappletRelayCleartext.forDelivery(sub.heldEncrypted.removeFirst(), account.signer)?.let { + push(NappletProtocolJson.encodeRelayEvent(nappletSubId, it)) + } } Delivery.Eose -> push(NappletProtocolJson.encodeRelayEose(nappletSubId)) is Delivery.Closed -> push(NappletProtocolJson.encodeRelayClosed(nappletSubId, delivery.reason)) @@ -156,6 +179,11 @@ class NappletLiveSubscriptions( runCatching { sub.client.subscribe(sub.clientSubId, relays.associateWith { filters }, listener) } } + /** [owner] is being looked at again: its subscriptions deliver the encrypted events they held. */ + fun onAttended(owner: Messenger) { + liveSubs.forEach { (key, sub) -> if (key.owner == owner) sub.deliveries.trySend(Delivery.Attended) } + } + /** Stops [owner]'s live subscription [nappletSubId], unsubscribing from the client that opened it. */ fun close( owner: Messenger, @@ -182,4 +210,9 @@ class NappletLiveSubscriptions( sub.deliveryJob?.cancel() runCatching { sub.client.unsubscribe(sub.clientSubId) } } + + private companion object { + // Per subscription: past this, the oldest held encrypted event is dropped. + const val MAX_HELD_ENCRYPTED = 500 + } } 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<String>() + // was pushed on top (even the tab's own Site settings). They stay warm until EVERY such entry is + // destroyed: the same tab can sit in the back stack twice (opened again from inside itself), and popping + // the top one must not take the session from under the one still waiting below. + private val parked = mutableMapOf<String, MutableSet<Lifecycle>>() + + private fun isParked(id: String) = !parked[id].isNullOrEmpty() /** * The last screen showing [id] left composition. A bottom-bar tab ([keepWarm]) stays warm. Any other tab @@ -281,15 +284,20 @@ object EmbeddedTabHost { if (keepWarm()) return fun gone() { - parked.remove(id) - // A screen may have come back to this tab meanwhile, or it may have joined the bottom bar. - if ((holders[id] ?: 0) == 0 && !keepWarm()) evict(id) + parked[id]?.let { + it.remove(entry) + if (it.isEmpty()) parked.remove(id) + } + // A screen may have come back to this tab meanwhile, another entry may still hold it, or it may + // have joined the bottom bar. + if ((holders[id] ?: 0) == 0 && !isParked(id) && !keepWarm()) evict(id) } if (entry.currentState == Lifecycle.State.DESTROYED) { gone() return } - parked.add(id) + // Already watched from an earlier time this entry was covered. + if (!parked.getOrPut(id) { mutableSetOf() }.add(entry)) return entry.addObserver( object : LifecycleEventObserver { override fun onStateChanged( @@ -314,7 +322,7 @@ object EmbeddedTabHost { /** Drops every warm session whose id isn't in [keep] (bottom-row membership + the active tab). */ fun retainOnly(keep: Set<String>) { warm - .filter { it.id !in keep && it.id !in parked } + .filter { it.id !in keep && !isParked(it.id) } .forEach { evict(it.id) } } 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<String>) { } val consoleLogs = consoleBridge?.consoleLogs - val ui = + val baseUi = chrome.ui.copy( chrome = chrome.ui.chrome.copy(hasFind = chrome.ui.chrome.hasFind && findBridge != null), consoleShowing = consoleShowing, - consoleErrors = consoleBridge?.consoleErrorCount?.intValue ?: 0, ) Box(tabModifier) { - BrowserPill( - ui = ui, - expanded = pillExpanded, - onExpandedChange = { pillExpanded = it }, - onEvent = { event -> - val action = (event as? BrowserPillEvent.Action)?.action - when { - action == BrowserChrome.Action.FIND_IN_PAGE && findBridge != null -> { - // One bottom panel at a time: find replaces the console. - consoleShowing = false - findShowing = true + WithConsoleErrors(baseUi, consoleBridge?.consoleErrorCount) { ui -> + BrowserPill( + ui = ui, + expanded = pillExpanded, + onExpandedChange = { pillExpanded = it }, + onEvent = { event -> + val action = (event as? BrowserPillEvent.Action)?.action + when { + action == BrowserChrome.Action.FIND_IN_PAGE && findBridge != null -> { + // One bottom panel at a time: find replaces the console. + consoleShowing = false + findShowing = true + } + action == BrowserChrome.Action.CONSOLE && consoleBridge != null -> { + if (!consoleShowing) closeFind() + consoleShowing = !consoleShowing + } + else -> chrome.onEvent(event) } - action == BrowserChrome.Action.CONSOLE && consoleBridge != null -> { - if (!consoleShowing) closeFind() - consoleShowing = !consoleShowing - } - else -> chrome.onEvent(event) - } - }, - showClose = false, - suggestionsFor = chrome.suggestionsFor, - // A clipboard query is a binder call: only make it while the pill is open to use it. - onPasteAndGo = - if (pillExpanded && BrowserWebTools.clipboardHasText(context)) { - { - pillExpanded = false - BrowserWebTools.clipboardText(context)?.let { chrome.onEvent(BrowserPillEvent.Navigate(it)) } - } - } else { - null }, - modifier = Modifier.align(Alignment.TopCenter), - ) + showClose = false, + suggestionsFor = chrome.suggestionsFor, + // A clipboard query is a binder call: only make it while the pill is open to use it. + onPasteAndGo = + if (pillExpanded && BrowserWebTools.clipboardHasText(context)) { + { + pillExpanded = false + BrowserWebTools.clipboardText(context)?.let { chrome.onEvent(BrowserPillEvent.Navigate(it)) } + } + } else { + null + }, + modifier = Modifier.align(Alignment.TopCenter), + ) + } // Find in page: opened from the pill's Find tile. if (findShowing && findBridge != null) { @@ -1112,3 +1115,16 @@ private fun formatConsoleLine(line: ConsoleLine): String = .append(')') } } + +/** + * Reads the page's console error count in a scope of its own: a page that keeps logging errors then + * recomposes just the pill, not the whole tab layer around it. + */ +@Composable +private fun WithConsoleErrors( + ui: BrowserPillUi, + errors: IntState?, + content: @Composable (BrowserPillUi) -> Unit, +) { + content(ui.copy(consoleErrors = errors?.intValue ?: 0)) +} 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<K : Any> { + private val attended = MutableStateFlow<Set<K>>(emptySet()) + + fun set( + owner: K, + isAttended: Boolean, + ) = attended.update { if (isAttended) it + owner else it - owner } + + fun isAttended(owner: K): Boolean = owner in attended.value + + /** Waits up to [timeoutMs] for [owner] to be attended; false when it wasn't in time. */ + suspend fun awaitAttended( + owner: K, + timeoutMs: Long, + ): Boolean = withTimeoutOrNull(timeoutMs) { attended.first { owner in it } } != null + + /** [owner] went away. */ + fun forget(owner: K) = set(owner, false) +} 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<P : Any> { @@ -70,6 +79,40 @@ class NappletBridgeDocuments<P : Any> { return brokerId.substring(cut + 1) to proxy } + /** + * [envelope] with the current document stamped on its `subId` (`relay.subscribe`, `relay.close`), or + * null when it carries none and goes to the broker unchanged. + */ + fun stampSubscription(envelope: JsonObject): JsonObject? { + val subId = envelope.quotedString(SUB_ID) ?: return null + return envelope.withString(SUB_ID, brokerIdFor(subId)) + } + + /** + * A broker push to hand to the page on screen: unchanged when it isn't for a subscription, with the page's + * own `subId` back when it is for one this document opened, or null — drop it — when it is for a + * subscription of a document that is gone (or when nothing is on screen). + */ + fun resolvePush(push: JsonObject): JsonObject? { + if (current == null) return null + val brokerSubId = push.quotedString(SUB_ID) ?: return push + val cut = brokerSubId.indexOf(SEPARATOR) + if (cut <= 0 || brokerSubId.substring(0, cut).toLongOrNull() != document) return null + return push.withString(SUB_ID, brokerSubId.substring(cut + 1)) + } + + private fun JsonObject.quotedString(key: String): String? = (this[key] as? JsonPrimitive)?.takeIf { it.isString }?.content + + /** + * A main-frame navigation began: whatever document was on screen is on its way out, even if the new one + * never talks to the bridge. Returns true when there was one — the caller then drops its broker state. + */ + fun onNavigation(): Boolean { + val had = current != null + clear() + return had + } + /** The surface went away (session closed, renderer died): nothing on screen can receive a reply. */ fun clear() { current = null @@ -78,5 +121,6 @@ class NappletBridgeDocuments<P : Any> { private companion object { const val SEPARATOR = ':' + const val SUB_ID = "subId" } } 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<T>( + private val clock: () -> Long, + private val cap: Int = MAX_HELD, + private val maxAgeMs: Long = MAX_AGE_MS, +) { + private class Held<T>( + val item: T, + val heldAt: Long, + ) + + private val items = ArrayDeque<Held<T>>() + + val size: Int get() = items.size + + /** Holds [item], or hands it back (for the caller to fail) when [cap] requests are already waiting. */ + fun hold(item: T): T? { + if (items.size >= cap) return item + items.addLast(Held(item, clock())) + return null + } + + /** Removes and returns the requests held longer than [maxAgeMs] (oldest first). */ + fun expire(): List<T> { + val now = clock() + val expired = mutableListOf<T>() + while (items.isNotEmpty() && now - items.first().heldAt >= maxAgeMs) expired += items.removeFirst().item + return expired + } + + /** Removes everything held: the requests still fresh enough to send, and the ones to fail instead. */ + fun drain(): Drained<T> { + val expired = expire() + val fresh = items.map { it.item } + items.clear() + return Drained(fresh, expired) + } + + /** Drops everything held (the page or surface is gone) and returns it. */ + fun clear(): List<T> { + val all = items.map { it.item } + items.clear() + return all + } + + data class Drained<T>( + val send: List<T>, + val fail: List<T>, + ) + + companion object { + /** At most this many requests wait for the user at once. */ + const val MAX_HELD = 32 + + /** A held request older than this is failed rather than sent: the moment it was made for has passed. */ + const val MAX_AGE_MS = 120_000L + + const val TOO_MANY = "Too many requests are waiting for the user." + const val EXPIRED = "The request timed out while the user was away." + } +} 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/<id>` and tie the user's real IP to + * the Tor session. The cost is that an open-web page goes through Tor while another open page needs it; + * surfaces show why (see [Route.usesTor]). * * Not thread-safe: the caller serializes access (the sandbox touches it only on its main thread). */ class NappletProxyClaims { - data class Claim( - /** The Tor SOCKS port this surface wants, or [NO_PROXY] for the open web. */ - val torPort: Int, - /** For an open-web claim: hosts that must go direct even while Tor is on for others. */ - val directHosts: Set<String> = emptySet(), - ) - - /** The route to apply: [torPort] > 0 routes through Tor except [bypassHosts]; else no proxy. */ + /** The route to apply: through Tor on [torPort] when [usesTor], else no proxy. */ data class Route( val torPort: Int, - val bypassHosts: Set<String>, ) { val usesTor: Boolean get() = torPort > 0 } // Insertion-ordered; a re-claim moves the owner to the end, so the last entry is the latest claim. - private val claims = LinkedHashMap<Any, Claim>() + // Each value is the Tor SOCKS port the owner wants, or [NO_PROXY] for the open web. + private val claims = LinkedHashMap<Any, Int>() - /** Files (or replaces) [owner]'s claim and returns the resulting route. */ + /** Files (or replaces) [owner]'s claim — Tor on [torPort] (> 0), or [NO_PROXY] — and returns the route. */ fun claim( owner: Any, - claim: Claim, + torPort: Int, ): Route { claims.remove(owner) - claims[owner] = claim + claims[owner] = torPort return route() } @@ -74,17 +68,10 @@ class NappletProxyClaims { return route() } - fun route(): Route { - val torPort = claims.values.lastOrNull { it.torPort > 0 }?.torPort ?: return DIRECT - val bypass = - claims.values - .filter { it.torPort <= 0 } - .flatMapTo(HashSet()) { it.directHosts } - return Route(torPort, bypass) - } + fun route(): Route = Route(claims.values.lastOrNull { it > 0 } ?: NO_PROXY) companion object { const val NO_PROXY = -1 - val DIRECT = Route(NO_PROXY, emptySet()) + val DIRECT = Route(NO_PROXY) } } 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<String>() + + @Test + fun unattendedUntilTold() { + assertFalse(attendance.isAttended("tab")) + attendance.set("tab", true) + assertTrue(attendance.isAttended("tab")) + attendance.forget("tab") + assertFalse(attendance.isAttended("tab")) + } + + @Test + fun awaitReturnsOnceTheUserIsBack() = + runTest { + val waiting = async { attendance.awaitAttended("tab", 10_000) } + testScheduler.advanceTimeBy(1_000) + attendance.set("other", true) + testScheduler.advanceTimeBy(1_000) + attendance.set("tab", true) + assertTrue(waiting.await()) + } + + @Test + fun awaitGivesUpAfterTheTimeout() = + runTest { + assertFalse(attendance.awaitAttended("tab", 5_000)) + } +} 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<String>(clock = { now }, cap = 3, maxAgeMs = 1_000) + + @Test + fun heldRequestsAreSentWhenTheUserIsBack() { + assertNull(held.hold("a")) + assertNull(held.hold("b")) + val drained = held.drain() + assertEquals(listOf("a", "b"), drained.send) + assertTrue(drained.fail.isEmpty()) + assertEquals(0, held.size) + } + + @Test + fun pastTheCapANewRequestIsHandedBack() { + held.hold("a") + held.hold("b") + held.hold("c") + assertEquals("d", held.hold("d")) + assertEquals(3, held.size) + } + + @Test + fun staleRequestsAreFailedNotSent() { + held.hold("old") + now = 600 + held.hold("new") + now = 1_200 + val drained = held.drain() + assertEquals(listOf("new"), drained.send) + assertEquals(listOf("old"), drained.fail) + } + + @Test + fun expireRemovesOnlyTheStaleOnes() { + held.hold("old") + now = 600 + held.hold("new") + now = 1_000 + assertEquals(listOf("old"), held.expire()) + assertEquals(1, held.size) + now = 1_600 + assertEquals(listOf("new"), held.expire()) + assertEquals(0, held.size) + } + + @Test + fun clearReturnsEverything() { + held.hold("a") + held.hold("b") + assertEquals(listOf("a", "b"), held.clear()) + assertEquals(0, held.size) + } +} 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 @@ <string name="browser_pill_tor_title">Onion routing</string> <string name="browser_pill_tor_on">The site can't see your IP address</string> <string name="browser_pill_tor_off">The site can see your IP address</string> + <string name="browser_pill_tor_forced">Off for this site, but another open page uses Tor, so this one goes through Tor too</string> <string name="browser_pill_site_settings">Site settings</string> <string name="browser_pill_site_settings_none">Nothing allowed yet</string> <string name="browser_pill_access">What it can access</string> 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<JavaScriptReplyProxy>.failRequest( val reply = parseJsonObjectOrNull(NappletProtocolJson.encodeResponse(type, NappletResponse.Failed(reason))) ?: JsonObject(emptyMap()) runCatching { proxy.postMessage(reply.withString("id", pageId).toString()) } } + +/** + * Answers a napplet's queued broker [request] with a failure on [this] proxy. A napplet's request ids aren't + * stamped per document (its shell never navigates), so the id goes back as the page sent it. + */ +fun JavaScriptReplyProxy.failRequest( + request: Message, + reason: String, +) { + val data = request.data ?: return + val id = data.getString(NappletIpc.KEY_REQUEST_ID) ?: return + val raw = data.getString(NappletIpc.KEY_PAYLOAD) ?: return + val type = runCatching { NappletProtocolJson.readType(raw) }.getOrNull() ?: "napplet" + val reply = parseJsonObjectOrNull(NappletProtocolJson.encodeResponse(type, NappletResponse.Failed(reason))) ?: JsonObject(emptyMap()) + runCatching { postMessage(reply.withString("id", id).toString()) } +} 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<Pair<String, Message>>() + private val heldWhileAway = NappletHeldRequests<Pair<String, Message>>(SystemClock::elapsedRealtime) /** * Back walks out of fullscreen video, then the find bar, then the page's history, then leaves. Enabled @@ -271,6 +273,7 @@ class NappletBrowserActivity : ComponentActivity() { pendingBrokerRequests.forEach { sendToBroker(it) } pendingBrokerRequests.clear() if (resumed) setBrokerForeground(true) + reportAttended() } override fun onServiceDisconnected(name: ComponentName?) { @@ -309,6 +312,14 @@ class NappletBrowserActivity : ComponentActivity() { } title = intent.getStringExtra(EXTRA_TITLE).orEmpty() + // Fail closed: Tor is on but its port isn't known yet (still starting). This window can't learn it + // later, so refuse now rather than sit blank — or go out on the open web. + if (popup == null && useTor && proxyPort <= 0) { + Toast.makeText(this, R.string.napplet_route_blocked, Toast.LENGTH_LONG).show() + finish() + return + } + if (!WebViewFeature.isFeatureSupported(WebViewFeature.WEB_MESSAGE_LISTENER)) { Toast.makeText(this, getString(R.string.napplet_webview_too_old), Toast.LENGTH_LONG).show() finish() @@ -316,7 +327,13 @@ class NappletBrowserActivity : ComponentActivity() { } shimJs = readContractAsset(NappletWebContract.SHIM_JS_PATH).decodeToString() - claimRoute() + // Which route is really in effect: an open-web page goes through Tor while another page needs it. + WebViewProxyPolicy.observeRoute(this) { + routedOverTor = it + updateChromeState { copy(torForced = !useTor && it) } + } + // A popup's WebView is already loading: its route is claimed now, not before a load. + if (popup != null) claimRoute() bindService(Intent().setClassName(this, NappletHostContract.BROKER_SERVICE_CLASS), brokerConnection, BIND_AUTO_CREATE) onBackPressedDispatcher.addCallback(this, backCallback) @@ -341,8 +358,8 @@ class NappletBrowserActivity : ComponentActivity() { contentFrame.addView(wv, FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT)) if (popup == null) { loadingView = buildLoadingView().also { contentFrame.addView(it) } - // Wait for this page's route (claimed above) to be in effect before the first request leaves. - WebViewProxyPolicy.whenApplied { if (webView === wv) wv.loadUrl(startUrl) } + // Wait for this page's route to be in effect before the first request leaves. + claimRoute { if (webView === wv) wv.loadUrl(startUrl) } } else { wv.url?.let { if (it.isNotBlank() && it != "about:blank") startUrl = it } } @@ -401,13 +418,19 @@ class NappletBrowserActivity : ComponentActivity() { } } + private val expireHeld = + Runnable { + if (!isDestroyed) heldWhileAway.expire().forEach { (_, request) -> bridge.failRequest(request, NappletHeldRequests.EXPIRED) } + } + override fun onResume() { super.onResume() webView?.onResume() resumed = true - val held = heldWhileAway.toList() - heldWhileAway.clear() - held.forEach { (origin, request) -> dispatchToBroker(origin, request) } + reportAttended() + val held = heldWhileAway.drain() + held.fail.forEach { (_, request) -> bridge.failRequest(request, NappletHeldRequests.EXPIRED) } + held.send.forEach { (origin, request) -> dispatchToBroker(origin, request) } heartbeatHandler.removeCallbacks(heartbeat) heartbeat.run() } @@ -434,6 +457,7 @@ class NappletBrowserActivity : ComponentActivity() { override fun onPause() { resumed = false + reportAttended() heartbeatHandler.removeCallbacks(heartbeat) setBrokerForeground(false) super.onPause() @@ -441,6 +465,7 @@ class NappletBrowserActivity : ComponentActivity() { override fun onDestroy() { heartbeatHandler.removeCallbacks(backgroundPause) + heartbeatHandler.removeCallbacks(expireHeld) // Tell the broker to drop every reference to our reply Messenger BEFORE unbinding — a retained // Messenger is a binder, and it would pin this Activity (and its WebView) in `:napplet` for the // life of the process. `unbindService` alone does not release it. See [replyMessenger]. @@ -697,6 +722,9 @@ class NappletBrowserActivity : ComponentActivity() { // A fresh main-frame navigation: arm history gating and show the new address. pendingMainFrameUrl = url mainFrameLoadFailed = false + // The page is being replaced: nothing it asked for (replies, subscription pushes) may reach the next + // one, even a next one that never talks to the bridge. + if (bridge.onNavigation()) releasePage() // A window a page opened takes its first real page as its home ("scope"). if (startUrl == "about:blank" && url.startsWith("http")) startUrl = url // Re-arm favicon capture when the host changes, so a same-host in-page nav doesn't re-send. @@ -908,13 +936,12 @@ class NappletBrowserActivity : ComponentActivity() { /** Loads a user-typed address from "Edit address", forcing Tor for `.onion` when available. */ private fun loadAddress(text: String) { val resolved = OmniboxInput.resolve(text) ?: return - if (resolved.forceTor && proxyPort > 0 && !useTor) { + if (resolved.forceTor && !useTor) { useTor = true - claimRoute() - updateChromeState { copy(torOn = true) } + updateChromeState { copy(torOn = true, torForced = false) } } // An onion must not leave before the Tor route it just claimed is in place. - WebViewProxyPolicy.whenApplied { webView?.loadUrl(resolved.url) } + claimRoute { webView?.loadUrl(resolved.url) } } // ---- bridge: page <-> native (mirror of NappletBrowserService.onBridgeMessage) ---- @@ -944,19 +971,28 @@ class NappletBrowserActivity : ComponentActivity() { val pageId = envelope.stringOrNull("id").orEmpty().ifEmpty { "fire-${fireSeq++}" } val id = bridge.brokerIdFor(pageId) + // A relay subscription is named by the page, and its pushes (decrypted events included) come back + // under that name: stamp this document on it so the next page can never receive them. + val outgoing = bridge.stampSubscription(envelope)?.toString() ?: raw val msg = Message.obtain(null, NappletIpc.MSG_REQUEST).apply { replyTo = replyMessenger data = Bundle().apply { putString(NappletIpc.KEY_REQUEST_ID, id) - putString(NappletIpc.KEY_PAYLOAD, raw) + putString(NappletIpc.KEY_PAYLOAD, outgoing) } } // In the background: a sign / encrypt / decrypt waits until the user is back on this window. if (!resumed && NappletActingRequests.actsForUser(runCatching { NappletProtocolJson.readType(raw) }.getOrNull())) { - heldWhileAway += origin to msg + val refused = heldWhileAway.hold(origin to msg) + if (refused != null) { + bridge.failRequest(refused.second, NappletHeldRequests.TOO_MANY) + } else { + // Settle it with an error if nobody comes back for it, so the page isn't left waiting forever. + heartbeatHandler.postDelayed(expireHeld, NappletHeldRequests.MAX_AGE_MS) + } return } dispatchToBroker(origin, msg) @@ -1008,7 +1044,11 @@ class NappletBrowserActivity : ComponentActivity() { val msg = Message.obtain(null, NappletIpc.MSG_MINT_BROWSER_TOKEN).apply { replyTo = replyMessenger - data = Bundle().apply { putString(NappletIpc.KEY_BROWSER_ORIGIN, origin) } + data = + Bundle().apply { + putString(NappletIpc.KEY_BROWSER_ORIGIN, origin) + putString(NappletIpc.KEY_WEBVIEW_PROFILE, webViewProfile) + } } queueToBroker(msg) } @@ -1028,6 +1068,18 @@ class NappletBrowserActivity : ComponentActivity() { pendingBrokerRequests.removeAll { it.what == NappletIpc.MSG_REQUEST } val broker = brokerMessenger ?: return runCatching { broker.send(Message.obtain(null, NappletIpc.MSG_RELEASE_CLIENT).apply { replyTo = replyMessenger }) } + // The release forgets this window's attendance along with the rest; the next page is watched the same. + if (resumed) reportAttended() + } + + /** Tells the broker whether this window is being looked at, which gates decrypting its relay reads. */ + private fun reportAttended() { + queueToBroker( + Message.obtain(null, NappletIpc.MSG_SET_ATTENDED).apply { + replyTo = replyMessenger + data = Bundle().apply { putBoolean(NappletIpc.KEY_ATTENDED, resumed) } + }, + ) } /** A token for [origin] won't come: answer each call queued behind it with a failure, and allow a retry. */ @@ -1071,7 +1123,9 @@ class NappletBrowserActivity : ComponentActivity() { } NappletIpc.MSG_PUSH -> { val payload = data.getString(NappletIpc.KEY_PAYLOAD) ?: return true - runCatching { bridge.currentProxy?.postMessage(payload) } + // Dropped when it is for a subscription a replaced document opened. + val push = parseJsonObjectOrNull(payload)?.let { bridge.resolvePush(it) } ?: return true + runCatching { bridge.currentProxy?.postMessage(push.toString()) } } NappletIpc.MSG_WEB_FAVORITE_STATE -> { val url = data.getString(NappletIpc.KEY_FAVORITE_URL) ?: return true @@ -1389,18 +1443,30 @@ class NappletBrowserActivity : ComponentActivity() { // ---- network ---- + /** Whether the process route is Tor right now, whatever this page asked for. */ + private var routedOverTor = false + /** * Files this page's Tor / open-web choice with the process-wide [WebViewProxyPolicy] (the override is * shared by every WebView in `:napplet`, so no surface sets it directly); [onReady] runs once the shared - * route is in effect. An open-web page exempts its own site from other surfaces' Tor route. + * route is in effect. Fails closed: a page that wants Tor loads nothing until Tor's port is known and the + * route is really applied. */ private fun claimRoute(onReady: () -> Unit = {}) { - if (useTor && proxyPort > 0) { - WebViewProxyPolicy.claim(this, proxyPort, onReady = onReady) - } else { - val shown = webView?.url?.takeIf { it.startsWith("http") } ?: startUrl - WebViewProxyPolicy.claim(this, NappletProxyClaims.NO_PROXY, WebViewProxyPolicy.directHostsOf(shown), onReady) + if (useTor && proxyPort <= 0) { + showRouteBlocked() + return } + WebViewProxyPolicy.claim( + owner = this, + torPort = if (useTor) proxyPort else NappletProxyClaims.NO_PROXY, + onFailed = { showRouteBlocked() }, + onReady = onReady, + ) + } + + private fun showRouteBlocked() { + if (!isDestroyed) Toast.makeText(this, R.string.napplet_route_blocked, Toast.LENGTH_LONG).show() } /** Persists the per-host Tor choice in the main process and re-applies it to the live WebView. */ @@ -1408,7 +1474,7 @@ class NappletBrowserActivity : ComponentActivity() { useTor = newUseTor // Reload only once the new route is in effect, or the reload would go out the old way. claimRoute { webView?.reload() } - updateChromeState { copy(torOn = useTor) } + updateChromeState { copy(torOn = useTor, torForced = !useTor && routedOverTor) } // Key the persisted choice on the host actually displayed (which may differ from startUrl after // in-page navigation), so the preference sticks to the right site. val host = runCatching { currentUrl().toUri().host }.getOrNull()?.takeIf { it.isNotBlank() } ?: return @@ -1447,6 +1513,7 @@ class NappletBrowserActivity : ComponentActivity() { url = startUrl, startUrl = startUrl, torOn = if (proxyPort > 0) useTor else null, + torForced = !useTor && routedOverTor, ), isFavorite = intent.getBooleanExtra(EXTRA_IS_FAVORITE, false), defaultBrowserName = DefaultBrowser.label(this), @@ -1694,7 +1761,7 @@ class NappletBrowserActivity : ComponentActivity() { crashView = null val wv = buildWebView() contentFrame.addView(wv, 0, FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT)) - WebViewProxyPolicy.whenApplied { if (webView === wv) wv.loadUrl(url) } + claimRoute { if (webView === wv) wv.loadUrl(url) } } }, ) 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<Pair<String, Message>>() + // Unattended until the client says otherwise: it sends its state right after every create. + var attended = false + val heldWhileAway = NappletHeldRequests<Pair<String, Message>>(SystemClock::elapsedRealtime) // The session's root view (holds the WebView, and the page's fullscreen view when it has one). var container: FrameLayout? = null @@ -193,6 +196,8 @@ class NappletBrowserService : Service() { brokerMessenger = Messenger(service) pendingBrokerRequests.forEach { sendToBroker(it) } pendingBrokerRequests.clear() + // A broker that restarted knows nothing about who is watching. + tabs.values.forEach { if (it.attended) reportAttended(it) } } override fun onServiceDisconnected(name: ComponentName?) { @@ -219,6 +224,17 @@ class NappletBrowserService : Service() { super.onDestroy() } + /** The client re-reads Tor's port on every load it asks for: Tor may have come up (or moved) since the tab was made. */ + private fun refreshProxyPort( + tab: BrowserTab, + msg: Message, + ) { + msg.data + ?.getInt(NappletBrowserContract.KEY_PROXY_PORT, 0) + ?.takeIf { it != 0 } + ?.let { tab.proxyPort = it } + } + private fun tabFor(msg: Message): BrowserTab? = msg.data?.getString(NappletBrowserContract.KEY_SESSION_ID)?.let { tabs[it] } private fun onClientMessage(msg: Message): Boolean { @@ -226,6 +242,9 @@ class NappletBrowserService : Service() { NappletBrowserContract.MSG_CREATE_SESSION -> { val data = msg.data ?: return true val sessionId = data.getString(NappletBrowserContract.KEY_SESSION_ID) ?: return true + // A re-sent create for an id that is still live (a client that lost track of it) must not + // strand the old tab's WebView, broker state and proxy claim with nothing left to close them. + tabs[sessionId]?.let(::closeTab) val tab = BrowserTab( sessionId = sessionId, @@ -247,24 +266,27 @@ class NappletBrowserService : Service() { } NappletBrowserContract.MSG_NAVIGATE -> { val tab = tabFor(msg) ?: return true + refreshProxyPort(tab, msg) val url = normalizeUrl(msg.data?.getString(NappletBrowserContract.KEY_URL).orEmpty()) // A renderer crash destroyed this tab's WebView; the user's retry builds a fresh one. val wv = tab.webView if (wv == null) { rebuildWebView(tab, url) } else { - // Right after a Tor toggle the new route may still be applying; don't let this load race it. - WebViewProxyPolicy.whenApplied { if (tab.webView === wv) wv.loadUrl(url) } + // Right after a Tor toggle the new route may still be applying; don't let this load race it + // (nor go out at all when Tor is wanted but unavailable). + claimRoute(tab) { if (tab.webView === wv) wv.loadUrl(url) } } } NappletBrowserContract.MSG_CLOSE_SESSION -> tabFor(msg)?.let(::closeTab) NappletBrowserContract.MSG_SET_ATTENDED -> { val tab = tabFor(msg) ?: return true - tab.attended = msg.data?.getBoolean(NappletBrowserContract.KEY_ENABLED, true) ?: true + tab.attended = msg.data?.getBoolean(NappletBrowserContract.KEY_ENABLED, false) ?: false + reportAttended(tab) if (tab.attended) { - val held = tab.heldWhileAway.toList() - tab.heldWhileAway.clear() - held.forEach { (origin, request) -> dispatchToBroker(tab, origin, request) } + val held = tab.heldWhileAway.drain() + held.fail.forEach { (_, request) -> tab.bridge.failRequest(request, NappletHeldRequests.EXPIRED) } + held.send.forEach { (origin, request) -> dispatchToBroker(tab, origin, request) } } } NappletBrowserContract.MSG_PAUSE -> @@ -349,8 +371,9 @@ class NappletBrowserService : Service() { NappletBrowserContract.MSG_EXIT_FULLSCREEN -> tabFor(msg)?.let { exitFullscreen(it) } NappletBrowserContract.MSG_RELOAD -> { val tab = tabFor(msg) ?: return true + refreshProxyPort(tab, msg) // A renderer death destroyed this tab's WebView: rebuild it on the page it was showing. - if (tab.webView == null) rebuildWebView(tab, tab.recoverUrl ?: tab.url) else tab.webView?.reload() + if (tab.webView == null) rebuildWebView(tab, tab.recoverUrl ?: tab.url) else claimRoute(tab) { tab.webView?.reload() } } NappletBrowserContract.MSG_BACK -> tabFor(msg)?.webView?.let { if (it.canGoBack()) it.goBack() } NappletBrowserContract.MSG_IME_OP -> { @@ -361,6 +384,7 @@ class NappletBrowserService : Service() { NappletBrowserContract.MSG_SET_TOR -> { val tab = tabFor(msg) ?: return true tab.useTor = msg.data?.getBoolean(NappletBrowserContract.KEY_USE_TOR, false) ?: false + refreshProxyPort(tab, msg) // Reload only after the route actually applies — the override is async, so reloading // immediately would re-fetch through the old route. claimRoute(tab) { tab.webView?.reload() } @@ -450,17 +474,40 @@ class NappletBrowserService : Service() { } /** - * Files [tab]'s Tor / open-web choice with the process-wide [WebViewProxyPolicy]; [onReady] runs once the - * shared route is in effect. An open-web tab exempts its own site from other tabs' Tor route. + * Files [tab]'s Tor / open-web choice with the process-wide [WebViewProxyPolicy] and runs [onReady] (the + * load) once the shared route is in effect. Fails closed: a tab that wants Tor while Tor has no port yet + * doesn't claim or load at all, and a route that can't be applied blocks the load too — either way the + * client hears [NappletBrowserContract.KEY_ROUTE_BLOCKED] and offers Retry. Also keeps the client told + * which route is really in effect ([NappletBrowserContract.MSG_ROUTE]). */ private fun claimRoute( tab: BrowserTab, onReady: () -> Unit = {}, ) { - if (tab.useTor && tab.proxyPort > 0) { - WebViewProxyPolicy.claim(tab, tab.proxyPort, onReady = onReady) - } else { - WebViewProxyPolicy.claim(tab, NappletProxyClaims.NO_PROXY, WebViewProxyPolicy.directHostsOf(tab.webView?.url ?: tab.url), onReady) + WebViewProxyPolicy.observeRoute(tab) { usesTor -> + if (tabs[tab.sessionId] === tab) sendToClient(tab, NappletBrowserContract.MSG_ROUTE) { putBoolean(NappletBrowserContract.KEY_USE_TOR, usesTor) } + } + if (tab.useTor && tab.proxyPort <= 0) { + reportRouteBlocked(tab) + return + } + WebViewProxyPolicy.claim( + owner = tab, + torPort = if (tab.useTor) tab.proxyPort else NappletProxyClaims.NO_PROXY, + onFailed = { reportRouteBlocked(tab) }, + onReady = onReady, + ) + } + + /** The page wasn't loaded because its route can't be honored: tell the client, which shows the error. */ + private fun reportRouteBlocked(tab: BrowserTab) { + if (tabs[tab.sessionId] !== tab) return + tab.loadFailed = true + sendToClient(tab, NappletBrowserContract.MSG_LOAD_STATE) { + putBoolean(NappletBrowserContract.KEY_IS_LOADING, false) + putBoolean(NappletBrowserContract.KEY_LOAD_FAILED, true) + putBoolean(NappletBrowserContract.KEY_ROUTE_BLOCKED, true) + putString(NappletBrowserContract.KEY_URL, tab.webView?.url ?: tab.url) } } @@ -516,7 +563,8 @@ class NappletBrowserService : Service() { /** Drops [tab] and everything it holds (its WebView, broker state, proxy claim). */ private fun closeTab(tab: BrowserTab) { - tabs.remove(tab.sessionId) + // By identity: a replaced tab closing late must not take its replacement (same id) with it. + tabs.remove(tab.sessionId, tab) WebViewProxyPolicy.release(tab) releasePage(tab, closing = true) tab.bridge.clear() @@ -883,6 +931,9 @@ class NappletBrowserService : Service() { ) { // A new main-frame navigation cleared any prior error, and lifts "block this page's dialogs". tab?.loadFailed = false + // The page is being replaced: nothing it asked for (replies, subscription pushes) may reach the next + // one, even a next one that never talks to the bridge. + if (tab != null && tab.bridge.onNavigation()) releasePage(tab) tab?.jsDialogsOnPage = 0 tab?.jsDialogsBlocked = false // Re-arm favicon capture when the host changes, so a same-host in-page nav doesn't re-send. @@ -1047,25 +1098,41 @@ class NappletBrowserService : Service() { val pageId = envelope.stringOrNull("id").orEmpty().ifEmpty { "fire-${tab.fireSeq++}" } val id = tab.bridge.brokerIdFor(pageId) + // A relay subscription is named by the page, and its pushes (decrypted events included) come back + // under that name: stamp this document on it so the next page can never receive them. + val outgoing = tab.bridge.stampSubscription(envelope)?.toString() ?: raw val msg = Message.obtain(null, NappletIpc.MSG_REQUEST).apply { replyTo = tab.replyMessenger data = Bundle().apply { putString(NappletIpc.KEY_REQUEST_ID, id) - putString(NappletIpc.KEY_PAYLOAD, raw) + putString(NappletIpc.KEY_PAYLOAD, outgoing) } } // Nobody is looking at this tab (it's parked, or the app is in the background): a sign / encrypt / // decrypt waits until they are, even when "allow always" would let it through without a prompt. if (!tab.attended && NappletActingRequests.actsForUser(runCatching { NappletProtocolJson.readType(raw) }.getOrNull())) { - tab.heldWhileAway += origin to msg + val refused = tab.heldWhileAway.hold(origin to msg) + if (refused != null) { + tab.bridge.failRequest(refused.second, NappletHeldRequests.TOO_MANY) + } else { + // Settle it with an error if nobody comes back for it, so the page isn't left waiting forever. + heldExpiry.postDelayed({ expireHeld(tab) }, NappletHeldRequests.MAX_AGE_MS) + } return } dispatchToBroker(tab, origin, msg) } + private val heldExpiry = Handler(Looper.getMainLooper()) + + private fun expireHeld(tab: BrowserTab) { + if (tabs[tab.sessionId] !== tab) return + tab.heldWhileAway.expire().forEach { (_, request) -> tab.bridge.failRequest(request, NappletHeldRequests.EXPIRED) } + } + /** Sends [msg] with [origin]'s launch token, minting the token first if the origin has none yet. */ private fun dispatchToBroker( tab: BrowserTab, @@ -1122,6 +1189,18 @@ class NappletBrowserService : Service() { } if (closing) tab.originTokens.clear() if (brokerMessenger != null) sendToBroker(release) + // The release forgets the tab's attendance along with the rest; the next page is watched just the same. + if (!closing && tab.attended) reportAttended(tab) + } + + /** Tells the broker whether [tab] is being looked at, which gates decrypting its relay reads. */ + private fun reportAttended(tab: BrowserTab) { + val msg = + Message.obtain(null, NappletIpc.MSG_SET_ATTENDED).apply { + replyTo = tab.replyMessenger + data = Bundle().apply { putBoolean(NappletIpc.KEY_ATTENDED, tab.attended) } + } + if (brokerMessenger == null) pendingBrokerRequests.add(msg) else sendToBroker(msg) } private fun requestBrowserToken( @@ -1137,7 +1216,11 @@ class NappletBrowserService : Service() { val msg = Message.obtain(null, NappletIpc.MSG_MINT_BROWSER_TOKEN).apply { replyTo = tab.replyMessenger - data = Bundle().apply { putString(NappletIpc.KEY_BROWSER_ORIGIN, origin) } + data = + Bundle().apply { + putString(NappletIpc.KEY_BROWSER_ORIGIN, origin) + putString(NappletIpc.KEY_WEBVIEW_PROFILE, tab.webViewProfile) + } } if (brokerMessenger == null) pendingBrokerRequests.add(msg) else sendToBroker(msg) } @@ -1225,7 +1308,9 @@ class NappletBrowserService : Service() { } NappletIpc.MSG_PUSH -> { val payload = data.getString(NappletIpc.KEY_PAYLOAD) ?: return true - runCatching { tab.bridge.currentProxy?.postMessage(payload) } + // Dropped when it is for a subscription a replaced document opened. + val push = parseJsonObjectOrNull(payload)?.let { tab.bridge.resolvePush(it) } ?: return true + runCatching { tab.bridge.currentProxy?.postMessage(push.toString()) } } NappletIpc.MSG_TOKEN_UNKNOWN -> { // The broker no longer knows this token (evicted): forget it so the origin re-mints. 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<Message>() + private val heldWhilePaused = NappletHeldRequests<Message>(SystemClock::elapsedRealtime) + + private val expireHeld = + Runnable { + if (!isDestroyed) heldWhilePaused.expire().forEach { bridgeReplyProxy?.failRequest(it, NappletHeldRequests.EXPIRED) } + } // Renews the broker's foreground lease while resumed. If this process dies, the heartbeat stops and // the broker reaps the stale lease, so a crash can't pin the main process's network up forever. @@ -251,6 +262,7 @@ class NappletHostActivity : ComponentActivity() { // If we're already foreground by the time the broker binds, report it now so the // main-process resource hold is acquired for this session. if (resumed) setBrokerForeground(true) + reportAttended() } override fun onServiceDisconnected(name: ComponentName?) { @@ -267,6 +279,12 @@ class NappletHostActivity : ComponentActivity() { finish() return } + // Fail closed: Tor is on but its port isn't known yet, so nothing (blobs or web traffic) may go out. + if (useTor && proxyPort <= 0) { + Toast.makeText(this, R.string.napplet_route_blocked, Toast.LENGTH_LONG).show() + finish() + return + } if (!WebViewFeature.isFeatureSupported(WebViewFeature.WEB_MESSAGE_LISTENER)) { Toast.makeText(this, getString(R.string.napplet_webview_too_old), Toast.LENGTH_LONG).show() @@ -305,7 +323,15 @@ class NappletHostActivity : ComponentActivity() { // Route the WebView's own (off-origin) traffic through Tor for an nSite, unless this site was // opted out to the open web. Set process-wide before any page navigation; the shell + blobs are // served from cache via shouldInterceptRequest, so only the site's external requests hit this. - if (profile.exposesNetwork) WebViewProxyPolicy.claim(this, effectiveProxy) + if (profile.exposesNetwork) { + // An open-web nSite still goes through Tor while another surface needs it: say so in the chrome. + WebViewProxyPolicy.observeRoute(this) { + routedOverTor = it + chrome?.let { c -> c.ui = c.ui.copy(chrome = c.ui.chrome.copy(torForced = !useTor && it)) } + } + // Start applying now, overlapping the index probe; the load itself waits in mountWebView. + WebViewProxyPolicy.claim(this, effectiveProxy) + } // Origin-restricted bridge: only the trusted shell page (main frame) can reach native. WebViewCompat.addWebMessageListener( webView, @@ -370,8 +396,17 @@ class NappletHostActivity : ComponentActivity() { contentFrame.addView(webView, 0, FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT)) if (!started) { started = true - // Wait for the route claimed above: a Tor nSite's first off-origin request must not leave early. - WebViewProxyPolicy.whenApplied { if (!isDestroyed) webView.loadUrl(NappletWebContract.SHELL_URL) } + // Wait for the route to be in effect: a Tor nSite's first off-origin request must not leave early, + // and must not leave at all if the route can't be applied. + if (profile.exposesNetwork) { + WebViewProxyPolicy.claim( + owner = this, + torPort = if (useTor) proxyPort else NappletProxyClaims.NO_PROXY, + onFailed = { if (!isDestroyed) Toast.makeText(this, R.string.napplet_route_blocked, Toast.LENGTH_LONG).show() }, + ) { if (!isDestroyed) webView.loadUrl(NappletWebContract.SHELL_URL) } + } else { + webView.loadUrl(NappletWebContract.SHELL_URL) + } } } @@ -394,10 +429,11 @@ class NappletHostActivity : ComponentActivity() { // hold the main process resumed (Tor/relays/AUTH) while this napplet/nSite is in front, and // keep renewing that lease so a crash here can't pin the network up forever. resumed = true + reportAttended() startForegroundHeartbeat() - val held = heldWhilePaused.toList() - heldWhilePaused.clear() - held.forEach { if (brokerMessenger == null) pendingRequests.add(it) else sendToBroker(it) } + val held = heldWhilePaused.drain() + held.fail.forEach { bridgeReplyProxy?.failRequest(it, NappletHeldRequests.EXPIRED) } + held.send.forEach { if (brokerMessenger == null) pendingRequests.add(it) else sendToBroker(it) } } override fun onStart() { @@ -420,6 +456,7 @@ class NappletHostActivity : ComponentActivity() { // (and be confused with) Amethyst's own UI and an "allow always" napplet can't act unwatched. The page // itself keeps running until onStop's grace runs out. resumed = false + reportAttended() stopForegroundHeartbeat() setBrokerForeground(false) super.onPause() @@ -457,6 +494,16 @@ class NappletHostActivity : ComponentActivity() { runCatching { broker.send(msg) } } + /** Tells the broker whether this napplet is being looked at, which gates decrypting its relay reads. */ + private fun reportAttended() { + val msg = + Message.obtain(null, NappletIpc.MSG_SET_ATTENDED).apply { + replyTo = replyMessenger + data = Bundle().apply { putBoolean(NappletIpc.KEY_ATTENDED, resumed) } + } + if (brokerMessenger == null) pendingRequests.add(msg) else sendToBroker(msg) + } + /** Reports this surface's foreground state to the broker so it can hold the main process resumed. */ private fun setBrokerForeground(foreground: Boolean) { @@ -476,6 +523,7 @@ class NappletHostActivity : ComponentActivity() { override fun onDestroy() { backgroundPauseHandler.removeCallbacks(backgroundPause) + backgroundPauseHandler.removeCallbacks(expireHeld) uiScope.cancel() // Drop the broker's references to our reply Messenger BEFORE unbinding — a retained Messenger is a // binder and would pin this Activity (and its WebView) for the life of the `:napplet` process. @@ -796,7 +844,13 @@ class NappletHostActivity : ComponentActivity() { // In the background: an act on the user's behalf waits until they're looking at this napplet again. if (!resumed && NappletActingRequests.actsForUser(runCatching { NappletProtocolJson.readType(raw) }.getOrNull())) { - heldWhilePaused += msg + val refused = heldWhilePaused.hold(msg) + if (refused != null) { + bridgeReplyProxy?.failRequest(refused, NappletHeldRequests.TOO_MANY) + } else { + // Settle it with an error if nobody comes back for it, so the applet isn't left waiting forever. + backgroundPauseHandler.postDelayed(expireHeld, NappletHeldRequests.MAX_AGE_MS) + } return } val messenger = brokerMessenger @@ -952,6 +1006,7 @@ class NappletHostActivity : ComponentActivity() { // Website-mode nSites can re-route over Tor; switching rebuilds the session, so the // row taps through to a full relaunch rather than toggling inline. torOn = if (profile.exposesNetwork && proxyPort > 0) useTor else null, + torForced = !useTor && routedOverTor, canFavorite = false, hasAccessInfo = true, ), 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<Message>() + // here and sent when they're back: pausing the WebView doesn't stop JavaScript. Unattended until the + // client says otherwise: it sends its state right after every create. + var attended = false + val heldWhilePaused = NappletHeldRequests<Message>(SystemClock::elapsedRealtime) val mayAct: Boolean get() = attended && !paused var bridgeReplyProxy: JavaScriptReplyProxy? = null @@ -175,6 +181,8 @@ class NappletHostService : Service() { brokerMessenger = Messenger(service) pendingBrokerRequests.forEach { sendToBroker(it) } pendingBrokerRequests.clear() + // A broker that restarted knows nothing about who is watching. + tabs.values.forEach { if (it.attended) reportAttended(it) } } override fun onServiceDisconnected(name: ComponentName?) { @@ -209,6 +217,9 @@ class NappletHostService : Service() { when (msg.what) { NappletEmbedContract.MSG_CREATE_SESSION -> { val tab = buildTab(msg) ?: return true + // A re-sent create for an id that is still live must not strand the old tab's WebView, content + // server and broker state with nothing left to close them. + tabs[tab.sessionId]?.let(::closeTab) tabs[tab.sessionId] = tab // Bind the broker once; a re-sent MSG_CREATE_SESSION must not leak a second binding. if (!brokerBound) { @@ -219,9 +230,15 @@ class NappletHostService : Service() { NappletEmbedContract.MSG_BACK -> tabFor(msg)?.webView?.let { if (it.canGoBack()) it.goBack() } NappletEmbedContract.MSG_RELOAD -> { val tab = tabFor(msg) ?: return true + // The client re-reads Tor's port on a retry: it may have come up (or moved) since the tab was made. + msg.data + ?.getInt(NappletHostContract.EXTRA_PROXY_PORT, 0) + ?.takeIf { it != 0 } + ?.let { tab.proxyPort = it } val container = tab.container - // After a renderer crash the tab has no WebView: the retry builds a fresh one. - if (tab.webView == null && container != null) { + // After a renderer crash the tab has no WebView, and a tab blocked on its route has only a blank + // placeholder: the retry builds a fresh one. + if ((tab.webView == null || tab.routeBlocked) && container != null) { val wv = createHostWebView(container.context, tab.sessionId, container) container.addView(wv, 0, FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT)) } else { @@ -245,7 +262,8 @@ class NappletHostService : Service() { } NappletEmbedContract.MSG_SET_ATTENDED -> tabFor(msg)?.let { - it.attended = msg.data?.getBoolean(NappletEmbedContract.KEY_ATTENDED, true) ?: true + it.attended = msg.data?.getBoolean(NappletEmbedContract.KEY_ATTENDED, false) ?: false + reportAttended(it) releaseHeld(it) } NappletEmbedContract.MSG_IME_OP -> { @@ -385,6 +403,17 @@ class NappletHostService : Service() { tab.container = container // A rebuild after a renderer crash: release the previous content server first. tab.contentServer?.close() + tab.contentServer = null + // Fail closed: Tor is wanted but has no port yet. Nothing may be fetched — not the applet's blobs (the + // content server would fetch them directly) and not its own traffic — so leave a blank placeholder and + // tell the client, whose Retry sends the port once Tor is up. + if (tab.useTor && tab.proxyPort <= 0) { + val blank = WebView(nightThemedContext(context, tab.themeType)).apply { setBackgroundColor(tab.bgColor) } + tab.webView = blank + reportRouteBlocked(tab) + return blank + } + tab.routeBlocked = false val wv = WebView(nightThemedContext(context, tab.themeType)) // FIRST touch after construction: setProfile throws once the WebView has loaded content (or its // profile has otherwise been used), so the storage partition must be chosen before the @@ -417,8 +446,17 @@ class NappletHostService : Service() { if (tab.paused) wv.onPause() if (tab.profile.exposesNetwork) { // The site's own off-origin traffic follows the process-wide route; load once it's in place so - // a Tor nSite's first request can't leave over the open web. - WebViewProxyPolicy.claim(tab, effectiveProxy) { if (tab.webView === wv) wv.loadUrl(NappletWebContract.SHELL_URL) } + // a Tor nSite's first request can't leave over the open web — and not at all if it can't be. + WebViewProxyPolicy.observeRoute(tab) { usesTor -> + if (tabs[tab.sessionId] === tab) { + tab.toClient(Message.obtain(null, NappletEmbedContract.MSG_ROUTE).apply { data = Bundle().apply { putBoolean(NappletEmbedContract.KEY_ROUTE_TOR, usesTor) } }) + } + } + WebViewProxyPolicy.claim( + owner = tab, + torPort = effectiveProxy, + onFailed = { reportRouteBlocked(tab) }, + ) { if (tab.webView === wv) wv.loadUrl(NappletWebContract.SHELL_URL) } } else { wv.loadUrl(NappletWebContract.SHELL_URL) } @@ -438,7 +476,8 @@ class NappletHostService : Service() { /** Drops [tab] and everything it holds (its WebView, content server, broker state, proxy claim). */ private fun closeTab(tab: NappletTab) { - tabs.remove(tab.sessionId) + // By identity: a replaced tab closing late must not take its replacement (same id) with it. + tabs.remove(tab.sessionId, tab) tab.bridgeReplyProxy = null WebViewProxyPolicy.release(tab) releaseFromBroker(tab) @@ -712,6 +751,7 @@ class NappletHostService : Service() { tab: NappletTab, isLoading: Boolean, rendererGone: Boolean = false, + routeBlocked: Boolean = false, ) { val message = Message.obtain(null, NappletEmbedContract.MSG_LOAD_STATE).apply { @@ -720,11 +760,21 @@ class NappletHostService : Service() { putBoolean(NappletEmbedContract.KEY_IS_LOADING, isLoading) putBoolean(NappletEmbedContract.KEY_LOAD_FAILED, tab.loadFailed) putBoolean(NappletEmbedContract.KEY_RENDERER_GONE, rendererGone) + putBoolean(NappletEmbedContract.KEY_ROUTE_BLOCKED, routeBlocked) } } tab.toClient(message) } + /** The applet wasn't loaded because its route can't be honored: tell the client, which shows the error. */ + private fun reportRouteBlocked(tab: NappletTab) { + if (tabs[tab.sessionId] !== tab) return + // Whatever WebView the tab has never loaded the applet: the retry must rebuild it, not reload it. + tab.routeBlocked = true + tab.loadFailed = true + pushLoadState(tab, isLoading = false, routeBlocked = true) + } + // ---- bridge: shell <-> native (mirror of NappletHostActivity.onShellMessage) ---- private fun onShellMessage( @@ -765,7 +815,13 @@ class NappletHostService : Service() { // Nobody is looking (parked off-screen, or the app is in the background): an act on the user's behalf // waits until they're looking at this napplet again. if (!tab.mayAct && NappletActingRequests.actsForUser(runCatching { NappletProtocolJson.readType(raw) }.getOrNull())) { - tab.heldWhilePaused += msg + val refused = tab.heldWhilePaused.hold(msg) + if (refused != null) { + tab.bridgeReplyProxy?.failRequest(refused, NappletHeldRequests.TOO_MANY) + } else { + // Settle it with an error if nobody comes back for it, so the applet isn't left waiting forever. + heldExpiry.postDelayed({ expireHeld(tab) }, NappletHeldRequests.MAX_AGE_MS) + } return } if (brokerMessenger == null) pendingBrokerRequests.add(msg) else sendToBroker(msg) @@ -774,9 +830,26 @@ class NappletHostService : Service() { /** Sends [tab]'s held acting requests once it may act again (attended and not paused). */ private fun releaseHeld(tab: NappletTab) { if (!tab.mayAct) return - val held = tab.heldWhilePaused.toList() - tab.heldWhilePaused.clear() - held.forEach { request -> if (brokerMessenger == null) pendingBrokerRequests.add(request) else sendToBroker(request) } + val held = tab.heldWhilePaused.drain() + held.fail.forEach { tab.bridgeReplyProxy?.failRequest(it, NappletHeldRequests.EXPIRED) } + held.send.forEach { request -> if (brokerMessenger == null) pendingBrokerRequests.add(request) else sendToBroker(request) } + } + + private val heldExpiry = Handler(Looper.getMainLooper()) + + /** Tells the broker whether [tab] is being looked at, which gates decrypting its relay reads. */ + private fun reportAttended(tab: NappletTab) { + val msg = + Message.obtain(null, NappletIpc.MSG_SET_ATTENDED).apply { + replyTo = tab.replyMessenger + data = Bundle().apply { putBoolean(NappletIpc.KEY_ATTENDED, tab.attended) } + } + if (brokerMessenger == null) pendingBrokerRequests.add(msg) else sendToBroker(msg) + } + + private fun expireHeld(tab: NappletTab) { + if (tabs[tab.sessionId] !== tab) return + tab.heldWhilePaused.expire().forEach { tab.bridgeReplyProxy?.failRequest(it, NappletHeldRequests.EXPIRED) } } /** 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<Waiter>() + + // Surfaces told which route is really in effect (an open-web page can be on Tor because another needs it). + private val routeListeners = LinkedHashMap<Any, (Boolean) -> Unit>() + + private val main = Handler(Looper.getMainLooper()) + private val mainExecutor = Executor { if (Looper.myLooper() == Looper.getMainLooper()) it.run() else main.post(it) } private val supported by lazy { WebViewFeature.isFeatureSupported(WebViewFeature.PROXY_OVERRIDE) } /** - * Files [owner]'s route: through Tor on [torPort] (> 0), or the open web ([NappletProxyClaims.NO_PROXY]) - * with [directHosts] exempt from any Tor route other surfaces need. [onReady] runs on the main thread - * once the resulting process route is in effect — immediately when nothing had to change. + * Files [owner]'s route: through Tor on [torPort] (> 0), or the open web ([NappletProxyClaims.NO_PROXY]). + * [onReady] runs on the main thread once the resulting process route is in effect — immediately when + * nothing had to change; [onFailed] instead when it can't be. */ fun claim( owner: Any, torPort: Int, - directHosts: Set<String> = emptySet(), + onFailed: (Failure) -> Unit = {}, onReady: () -> Unit = {}, ) { - claims.claim(owner, Claim(torPort, directHosts)) - sync(onReady) + claims.claim(owner, torPort) + sync(Waiter(torPort > 0, onReady, onFailed)) } - /** Withdraws [owner]'s claim; the route relaxes once no remaining surface needs it. */ + /** Withdraws [owner]'s claim and route listener; the route relaxes once no remaining surface needs it. */ fun release(owner: Any) { claims.release(owner) - sync {} + routeListeners.remove(owner) + sync(null) } - /** - * The hosts an open-web surface showing [url] exempts from other surfaces' Tor route: its site, and its - * subdomains through the bypass rule. Only for web pages, and never an onion (those only resolve via Tor). - */ - fun directHostsOf(url: String?): Set<String> { - if (url == null || !(url.startsWith("https://") || url.startsWith("http://"))) return emptySet() - val host = OmniboxInput.hostOf(url)?.lowercase()?.removePrefix("www.") ?: return emptySet() - return if (host.endsWith(".onion")) emptySet() else setOf(host) + /** Tells [listener] (now, and on every change) whether the process currently routes through Tor. */ + fun observeRoute( + owner: Any, + listener: (usesTor: Boolean) -> Unit, + ) { + routeListeners[owner] = listener + listener(applied.usesTor) } - /** Runs [onReady] once no route change is in flight (e.g. a navigation right after a Tor toggle). */ - fun whenApplied(onReady: () -> Unit) = sync(onReady) - - private fun sync(onReady: () -> Unit) { - if (!supported) { - onReady() - return - } + private fun sync(waiter: Waiter?) { val target = claims.route() - if (target == applied && applying == null) { - onReady() + if (!supported) { + // Nothing can be proxied. The open web still works; a surface that wants Tor fails closed. + if (waiter?.wantsTor == true) waiter.onFailed(Failure.TOR_UNSUPPORTED) else waiter?.onReady?.invoke() return } - waiting += onReady + if (target == applied && applying == null) { + waiter?.onReady?.invoke() + return + } + waiter?.let { waiting += it } if (target == applying) return applying = target - apply(target) { - applied = target - // A newer route superseded this one while it applied: its own callback releases the waiters. - if (applying == target) { - applying = null - val ready = waiting.toList() - waiting.clear() - ready.forEach { it() } + apply(target) { ok -> + // A newer route superseded this one while it applied: its own callback settles the waiters. + if (applying != target) { + if (ok) applied = target + return@apply + } + applying = null + val settled = waiting.toList() + waiting.clear() + if (ok) { + applied = target + routeListeners.values.toList().forEach { it(target.usesTor) } + settled.forEach { it.onReady() } + } else { + // Keep `applied` as it was, so the next claim tries again; nothing waiting goes out unrouted. + settled.forEach { it.onFailed(Failure.APPLY_FAILED) } } } } private fun apply( route: Route, - onApplied: () -> Unit, + done: (ok: Boolean) -> Unit, ) { - val executor = Executor { it.run() } runCatching { if (route.usesTor) { - val config = - ProxyConfig - .Builder() - .addProxyRule("socks5://127.0.0.1:${route.torPort}") - .apply { - route.bypassHosts.forEach { host -> - addBypassRule(host) - addBypassRule("*.$host") - } - }.build() - ProxyController.getInstance().setProxyOverride(config, executor, onApplied) + val config = ProxyConfig.Builder().addProxyRule("socks5://127.0.0.1:${route.torPort}").build() + ProxyController.getInstance().setProxyOverride(config, mainExecutor) { done(true) } } else { - ProxyController.getInstance().clearProxyOverride(executor, onApplied) + ProxyController.getInstance().clearProxyOverride(mainExecutor) { done(true) } } }.onFailure { Log.w(TAG, "Failed to apply WebView proxy override", it) - onApplied() + done(false) } } } 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 @@ <!-- Developer console: page-load failures surfaced as console errors --> <string name="napplet_console_load_error">Failed to load (%1$d): %2$s</string> <string name="napplet_console_http_error">HTTP %1$d %2$s</string> + <!-- Shown by the full-screen browser / napplet (in the sandbox process, where compose resources can't be + read outside composition) when a page is not loaded because its Tor route can't be honored. --> + <string name="napplet_route_blocked">Not loaded: Tor isn\'t available for this page yet. Try again in a moment.</string> </resources> From 38526fe810a1e44cb61420f189bb3c424203066c Mon Sep 17 00:00:00 2001 From: Vitor Pamplona <vitor@vitorpamplona.com> Date: Wed, 30 Sep 2026 19:11:31 -0400 Subject: [PATCH 11/13] fix(browser): a rebuilt tab resumes the page it showed Two ways a browser tab came back on its start URL instead of where the user was, both seen on the emulator: - Every re-created session (after a renderer or `:napplet` death, or a Tor toggle) opened `startUrl`, although the controller tracks `lastUrl`. New sessions now resume `lastUrl`. Only the user's Retry starts over at `startUrl`, which is what it is for. - The memory trim Android sends about a minute into the background (TRIM_MEMORY_BACKGROUND) runs rebuildAll(), which built new controllers and lost the page, history and desktop/zoom/Tor choices of every pinned site. rebuildAll(keepPages = true) now hands each browser tab's page and settings to its rebuilt controller. The theme rebuild keeps them too; an account switch never does, so the next account can't open the previous one's pages. Verified on the emulator with a pinned tab moved off its start URL (P1, desktop mode, start URL P2): - am send-trim-memory BACKGROUND: the WebView was destroyed and rebuilt, and the tab came back on P1 in desktop mode (before: P2, mobile). - Force-stopping the WebView provider (renderer and `:napplet` died): it recovered to P1 in desktop mode (before: P2). - Tor toggle: stays on P1 (before: the start URL). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- .../com/vitorpamplona/amethyst/Amethyst.kt | 2 +- .../browser/EmbeddedWebAppController.kt | 38 ++++++++++++++++++- .../loggedIn/embed/EmbeddedTabFactory.kt | 5 ++- .../screen/loggedIn/embed/EmbeddedTabHost.kt | 22 +++++++++-- 4 files changed, 60 insertions(+), 7 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/Amethyst.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/Amethyst.kt index 171905009d..f9756c9b54 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/Amethyst.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/Amethyst.kt @@ -248,7 +248,7 @@ class Amethyst : Application() { // memory stays freed until the user comes back. val pressure = level >= ComponentCallbacks2.TRIM_MEMORY_BACKGROUND if (pressure && Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - EmbeddedTabHost.rebuildAll() + EmbeddedTabHost.rebuildAll(keepPages = true) } } } 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 a893e8ba30..e521f7a1c9 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 @@ -166,6 +166,24 @@ class EmbeddedWebAppController( var lastCanGoForward = false private set + /** A tab's page and per-tab settings, carried to the controller that replaces this one on a rebuild. */ + class PageSnapshot( + val url: String?, + val useTor: Boolean, + val textZoom: Int, + val desktopSite: Boolean, + ) + + fun snapshot() = PageSnapshot(lastUrl, useTor, textZoom, desktopSite) + + /** Takes over a torn-down predecessor's page and settings; call before [bind], which creates the session. */ + fun restore(snapshot: PageSnapshot) { + lastUrl = snapshot.url + useTor = snapshot.useTor + textZoom = snapshot.textZoom + desktopSite = snapshot.desktopSite + } + /** The user's per-tab settings as last set, for a screen coming back to this tab. */ val isTorOn: Boolean get() = useTor @@ -462,6 +480,17 @@ class EmbeddedWebAppController( publishLoadStatus(EmbeddedLoadStatus(isLoading = true)) } + // Set by the user's Retry: the next session starts over at [startUrl]. Every other re-creation (a crashed + // renderer, a `:napplet` restart, a memory-trim rebuild) resumes the page the user was on. + private var restartAtStart = false + + /** Where a new session opens: the page on screen before it was lost, else the tab's own [startUrl]. */ + private fun sessionUrl(): String { + val resume = lastUrl?.takeUnless { restartAtStart || it.isBlankPage() } + restartAtStart = false + return resume ?: startUrl + } + private fun sendCreateSession() { awaitingReady = true uiDisplayed = false @@ -471,7 +500,7 @@ class EmbeddedWebAppController( data = Bundle().apply { putString(NappletBrowserContract.KEY_SESSION_ID, sessionId) - putString(NappletBrowserContract.KEY_URL, startUrl) + putString(NappletBrowserContract.KEY_URL, sessionUrl()) putInt(NappletBrowserContract.KEY_PROXY_PORT, proxyPort()) putBoolean(NappletBrowserContract.KEY_USE_TOR, useTor) putInt(NappletBrowserContract.KEY_BG_COLOR, backgroundColor) @@ -683,7 +712,12 @@ class EmbeddedWebAppController( recovery.clearPending() showRecovering() // 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) + if (sessionDead || (sandboxedSdkView != null && !uiDisplayed)) { + restartAtStart = true + rearmSession() + } else { + navigate(startUrl) + } } private fun onLoadState( 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 667c222e26..b761283ace 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 @@ -73,7 +73,10 @@ object EmbeddedTabFactory { if (nightMask == Configuration.UI_MODE_NIGHT_YES) "DARK" else "LIGHT" } } - EmbeddedWebAppController(context.applicationContext, ::currentTorPort, initialUseTor, backgroundColor, theme).also { it.bind(url) } + EmbeddedWebAppController(context.applicationContext, ::currentTorPort, initialUseTor, backgroundColor, theme).also { + EmbeddedTabHost.takePageSnapshot(webAppId(url))?.let(it::restore) + 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 c9968d6de6..2d7c1a1080 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 @@ -33,6 +33,7 @@ import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.LifecycleOwner import com.vitorpamplona.amethyst.napplethost.NappletHostContract +import com.vitorpamplona.amethyst.ui.screen.loggedIn.browser.EmbeddedWebAppController /** * Process-level holder of **warm embedded sessions** — the persistent-surface-layer half of keep-warm. @@ -178,7 +179,7 @@ object EmbeddedTabHost { val previous = builtDark builtDark = dark // The first report only records what the sessions (built from the same preference) already use. - if (previous != null && previous != dark) rebuildAll() + if (previous != null && previous != dark) rebuildAll(keepPages = true) } /** @@ -332,16 +333,31 @@ object EmbeddedTabHost { * screen and the preloader re-acquire freshly built sessions. This keeps [activeId], so the visible tab * re-activates the instant its screen re-acquires — the user just sees the current tab reload, not a * blanked-out surface. + * + * [keepPages]: each browser tab's rebuilt controller resumes the page it showed, with the user's Tor, zoom + * and desktop choices ([EmbeddedWebAppController.PageSnapshot]). Android sends the memory trim routinely, + * about a minute into the background, so without this every pinned site came back on its start URL. Never + * across an account switch: the next account must not open the previous one's pages. */ - fun rebuildAll() { + fun rebuildAll(keepPages: Boolean) { // Every page is about to be rebuilt from scratch, so no field survives to restore a keyboard onto. keyboardUpOnLeave.clear() val copy = warm.toList() warm.clear() + pageSnapshots.clear() + if (keepPages) { + copy.forEach { w -> (w.controller as? EmbeddedWebAppController)?.let { pageSnapshots[w.id] = it.snapshot() } } + } copy.forEach { it.controller.teardown() } rebuildEpoch += 1 } + // Page state carried from a torn-down browser tab to its rebuilt controller (see [rebuildAll]). + private val pageSnapshots = mutableMapOf<String, EmbeddedWebAppController.PageSnapshot>() + + /** The page state [rebuildAll] saved for tab [id], handed over once. */ + fun takePageSnapshot(id: String): EmbeddedWebAppController.PageSnapshot? = pageSnapshots.remove(id) + /** * Account the warm sessions were built for, as the opaque WebView storage-profile name (null while * logged out). Kept HERE, next to the sessions it describes, rather than in a composable's `remember`: @@ -372,6 +388,6 @@ object EmbeddedTabHost { builtForProfile = profileName // Seeding on the first call (app start) must not bump the epoch: nothing is stale yet, and a // needless bump would restart the preload sweep that is just getting going. - if (!isFirstCall) rebuildAll() + if (!isFirstCall) rebuildAll(keepPages = false) } } From ccf1c1c75f0f6e6d146a0c224064fee881fafffb Mon Sep 17 00:00:00 2001 From: Vitor Pamplona <vitor@vitorpamplona.com> Date: Wed, 30 Sep 2026 19:11:33 -0400 Subject: [PATCH 12/13] docs(napplet): the 30 s background pause is an upper bound on Android 14+ The cached-app freezer suspends `:napplet` and its renderers about 10 s after the app leaves the screen (12 s measured on an API 36 emulator), so media and scripts stop well before BACKGROUND_PAUSE_MS. Say so where the constant is defined, instead of promising 30 s of playback. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- .../amethyst/napplethost/NappletHostContract.kt | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostContract.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostContract.kt index 568350b7c3..b60aff9b20 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostContract.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostContract.kt @@ -32,6 +32,12 @@ object NappletHostContract { * disconnect 30 s after the UI stops (RelayProxyClientConnector's `WhileSubscribed(30000)`), so a quick * trip to another app (a 2FA code, a password manager) doesn't interrupt a page, while one left behind * stops running. Requests that act for the user are held from the first moment regardless. + * + * An upper bound, not a promise: on Android 14+ the cached-app freezer suspends `:napplet` (and its + * renderers) about 10 s after the app leaves the screen, since nothing keeps that process in the + * foreground. Measured on an API 36 emulator: frozen 12 s after Home, media stopped with it. So on those + * devices a page stops within seconds, and this pause lands (on the next unfreeze) on a page that + * already stopped. */ const val BACKGROUND_PAUSE_MS = 30_000L From 100eee0f65202993b243615e368ed27d1f5d2246 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona <vitor@vitorpamplona.com> Date: Wed, 30 Sep 2026 19:58:09 -0400 Subject: [PATCH 13/13] fix(napplet): observe the Tor setting on the nSite screen instead of reading it once NostrAppScreen read `torPrefs.torType.value` during composition, which Android lint rejects (StateFlowValueCalledInComposition) and which failed CI's lint-android job. It is also a real bug: the screen never recomposed when Tor was switched on or off, so the pill's route toggle could show or hide on stale state. Collect it as state. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- .../amethyst/ui/screen/loggedIn/favorites/NostrAppScreen.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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 5d89aef06d..1521d24459 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 @@ -140,7 +140,9 @@ private fun EmbeddedNostrAppTab( 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 on. - val torOn = if (profile.exposesNetwork && Amethyst.instance.torPrefs.torType.value != TorType.OFF) useTor else null + val torType by Amethyst.instance.torPrefs.torType + .collectAsStateWithLifecycle() + val torOn = if (profile.exposesNetwork && torType != TorType.OFF) useTor else null var showAccess by remember { mutableStateOf(false) }