From 69950823340278ed3e605fa133244668db41efb4 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Thu, 25 Jun 2026 20:11:27 -0400 Subject: [PATCH] fix(embed): full-screen round-trip no longer corrupts embedded WebViews MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Opening an embedded page full-screen and returning left every embedded WebView in the :napplet process broken: dead DNS (ERR_NAME_NOT_RESOLVED), DOM reads returning empty (a field that visibly shows text reports value==""), dead text-selection highlight, and broken IME (caret stuck at 0, can't delete). Two process-global defects in the full-screen hosts were corrupting the shared WebView state the embedded surfaces rely on: - pauseTimers()/resumeTimers() are PROCESS-GLOBAL — they pause/resume JS, layout and parsing timers for every WebView in the process. The full-screen activities (and the napplet embed pause/resume path) called them on their own lifecycle, so returning from full-screen froze the embedded surfaces, which have no resume of their own. Replaced with per-WebView onPause()/onResume() (which pause only that surface's JS/DOM — still satisfies the napplet background-security goal). No process-global timer calls remain. - WebView.destroy() was called while the WebView was still attached to the window, which corrupts the shared multiprocess renderer. Detach (removeView) and stopLoading() before destroy() in both full-screen activities. This also resolves the long-standing "selection highlight dead after a full-screen excursion" blocker — same root cause. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../napplethost/NappletBrowserActivity.kt | 19 ++++++++++++++++--- .../napplethost/NappletHostActivity.kt | 10 ++++++++-- .../napplethost/NappletHostService.kt | 15 +++++---------- 3 files changed, 29 insertions(+), 15 deletions(-) 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 a14a77b7c3..199b8d7961 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserActivity.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserActivity.kt @@ -35,6 +35,7 @@ import android.os.Messenger import android.util.Log import android.view.Gravity import android.view.View +import android.view.ViewGroup import android.webkit.ConsoleMessage import android.webkit.WebChromeClient import android.webkit.WebResourceError @@ -231,7 +232,6 @@ class NappletBrowserActivity : ComponentActivity() { super.onResume() if (this::webView.isInitialized) { webView.onResume() - webView.resumeTimers() } resumed = true heartbeatHandler.removeCallbacks(heartbeat) @@ -240,8 +240,11 @@ class NappletBrowserActivity : ComponentActivity() { override fun onPause() { if (this::webView.isInitialized) { + // 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() - webView.pauseTimers() } resumed = false heartbeatHandler.removeCallbacks(heartbeat) @@ -251,7 +254,17 @@ class NappletBrowserActivity : ComponentActivity() { override fun onDestroy() { runCatching { unbindService(brokerConnection) } - if (this::webView.isInitialized) webView.destroy() + if (this::webView.isInitialized) { + // Detach from the view tree BEFORE destroy(). Destroying a WebView while it is still attached to + // the window corrupts the SHARED multiprocess renderer/network state, which then breaks the OTHER + // (embedded) WebViews living in this `:napplet` process: dead DNS (ERR_NAME_NOT_RESOLVED), DOM reads + // returning empty (`value == ""` on a field that visibly shows text), dead selection-highlight paint, + // and broken IME — all after a full-screen excursion returns to an embed. (`destroy()` requires the + // view to be removed from the hierarchy first; see WebView.destroy() docs.) + webView.stopLoading() + (webView.parent as? ViewGroup)?.removeView(webView) + webView.destroy() + } super.onDestroy() } 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 82f71a4c19..771ff3c470 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostActivity.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostActivity.kt @@ -321,7 +321,6 @@ class NappletHostActivity : ComponentActivity() { super.onResume() if (this::webView.isInitialized) { webView.onResume() - webView.resumeTimers() } // Launching this :napplet-process surface backgrounded the main process; tell the broker to // hold the main process resumed (Tor/relays/AUTH) while this napplet/nSite is in front, and @@ -335,8 +334,10 @@ class NappletHostActivity : ComponentActivity() { // 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) { + // 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() - webView.pauseTimers() } // No longer foreground: stop renewing and let the main process resume normal background scaling. resumed = false @@ -384,6 +385,11 @@ class NappletHostActivity : ComponentActivity() { runCatching { unbindService(brokerConnection) } keyActions.clear() if (this::webView.isInitialized) { + // Detach before destroy(): destroying an attached WebView corrupts the shared multiprocess + // renderer/network state and breaks the other (embedded) WebViews in this `:napplet` process + // (dead DNS, empty DOM reads, dead selection paint, broken IME). See NappletBrowserActivity. + webView.stopLoading() + (webView.parent as? ViewGroup)?.removeView(webView) webView.destroy() } super.onDestroy() 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 1e57f3af1e..edde38c068 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostService.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostService.kt @@ -172,16 +172,11 @@ class NappletHostService : Service() { } NappletEmbedContract.MSG_BACK -> tabFor(msg)?.webView?.let { if (it.canGoBack()) it.goBack() } NappletEmbedContract.MSG_RELOAD -> tabFor(msg)?.webView?.reload() - NappletEmbedContract.MSG_PAUSE -> - tabFor(msg)?.webView?.let { - it.onPause() - it.pauseTimers() - } - NappletEmbedContract.MSG_RESUME -> - tabFor(msg)?.webView?.let { - it.onResume() - it.resumeTimers() - } + // 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_IME_OP -> { val tab = tabFor(msg) ?: return true val payload = msg.data?.getString(NappletEmbedContract.KEY_IME_PAYLOAD) ?: return true