refactor: session-scope NappletHostService so multiple napplet/nsite tabs are correct

Mirror the browser host's per-session refactor for the napplet/nsite embed
provider. A single NappletHostService instance is shared by every embedded tab
(same bound Intent), but it kept one set of fields (client messenger, config,
content server, WebView, broker bridge), so with >1 napplet tab open the
controls (reload/back/pause/resume), navigation state, "allow always" notices,
NIP-07 traffic, and IME all routed to whichever tab was created last.

Collect all per-surface state into a NappletTab keyed by a client-stamped
session id (KEY_SESSION_ID on MSG_CREATE_SESSION and every control message):

- Controls/pause/resume resolve the target tab by id and act on its own WebView.
- Content server, shell handshake (declaredDomains), launch token, and page
  state/notices are per tab; onShellMessage resolves the tab by its WebView.
- Each tab gets its OWN reply Messenger, so broker responses AND unsolicited
  relay pushes come back tagged to the right tab — no id rewriting, per-tab
  origin/fire-seq state.
- onSessionClosed drops the tab and destroys only its own WebView; the broker is
  bound once for the whole service.

EmbeddedNappletController generates a unique id and stamps it on all messages.
Both embed hosts (browser + napplet) are now fully per-tab correct, including the
keyboard for multiple simultaneous tabs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgMpRcWj6y82LxLiwcuzmN
This commit is contained in:
Claude
2026-06-24 04:00:54 +00:00
parent 09cd69ba7c
commit 5eb39b141a
4 changed files with 167 additions and 98 deletions
@@ -41,6 +41,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedImeBridge
import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedSurfaceController
import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.ImeEvent
import org.json.JSONObject
import java.util.concurrent.atomic.AtomicLong
/**
* Client-side handle to an embedded nsite/napplet. Binds [NappletHostService][com.vitorpamplona.amethyst.napplethost.NappletHostService]
@@ -65,6 +66,10 @@ class EmbeddedNappletController(
private var sandboxedSdkView: SandboxedSdkView? = null
private var pendingAdapter: SandboxedUiAdapter? = null
// A single NappletHostService instance serves every embedded napplet tab, so each controller stamps
// its own id on every message; the provider uses it to route controls/state/IME to this tab.
private val 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.
@@ -133,7 +138,7 @@ class EmbeddedNappletController(
val msg =
Message.obtain(null, NappletEmbedContract.MSG_CREATE_SESSION).apply {
replyTo = incoming
data = Bundle(params)
data = Bundle(params).apply { putString(NappletEmbedContract.KEY_SESSION_ID, sessionId) }
}
runCatching { serviceMessenger?.send(msg) }
// Replay a pause that was requested before we had a messenger to send it on (parked-before-bound),
@@ -167,10 +172,7 @@ class EmbeddedNappletController(
return true
}
override fun sendImeOp(json: String) {
val msg = Message.obtain(null, NappletEmbedContract.MSG_IME_OP).apply { data = Bundle().apply { putString(NappletEmbedContract.KEY_IME_PAYLOAD, json) } }
runCatching { serviceMessenger?.send(msg) }
}
override fun sendImeOp(json: String) = send(NappletEmbedContract.MSG_IME_OP) { putString(NappletEmbedContract.KEY_IME_PAYLOAD, json) }
private fun parseImeEvent(payload: String): ImeEvent? {
val o = runCatching { JSONObject(payload) }.getOrNull() ?: return null
@@ -210,8 +212,22 @@ class EmbeddedNappletController(
send(NappletEmbedContract.MSG_RESUME)
}
private fun send(what: Int) {
val msg = Message.obtain(null, what)
private inline fun send(
what: Int,
crossinline block: Bundle.() -> Unit = {},
) {
val msg =
Message.obtain(null, what).apply {
data =
Bundle().apply {
putString(NappletEmbedContract.KEY_SESSION_ID, sessionId)
block()
}
}
runCatching { serviceMessenger?.send(msg) }
}
private companion object {
private val SESSION_SEQ = AtomicLong()
}
}
@@ -82,6 +82,13 @@ object NappletEmbedContract {
const val KEY_NOTICE = "notice"
const val KEY_IME_PAYLOAD = "imePayload"
/**
* Opaque per-tab session id the client stamps on [MSG_CREATE_SESSION] and every control message. A
* single provider instance is shared by all embedded napplet/nSite tabs (they bind the same Intent),
* 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 NOTICE_PUBLISHED = "published"
const val NOTICE_UPLOADED = "uploaded"
const val NOTICE_PAID = "paid"
@@ -72,29 +72,40 @@ import java.util.concurrent.Executor
@RequiresApi(Build.VERSION_CODES.R)
class NappletHostService : Service() {
private val incoming = Messenger(Handler(Looper.getMainLooper(), ::onClientMessage))
private var clientMessenger: Messenger? = null
private val paths = mutableListOf<PathTag>()
private val servers = mutableListOf<String>()
private var author = ""
private var identifier = ""
private var launchToken = ""
private var websiteMode = false
private var useTor = true
private var proxyPort = -1
private var bgColor = android.graphics.Color.WHITE
private var declaredDomains: List<String> = emptyList()
/**
* Per-embedded-tab state. A single NappletHostService instance is shared by every embedded
* napplet/nSite tab (they bind the same Intent), so each tab's config, content server, WebView, and
* broker bridge live here, keyed by [sessionId]. Each tab has its OWN [replyMessenger] so broker
* responses AND unsolicited pushes come back tagged to the right tab (the broker echoes replyTo).
*/
private inner class NappletTab(
val sessionId: String,
var clientMessenger: Messenger?,
val paths: List<PathTag>,
val servers: List<String>,
val author: String,
val identifier: String,
val launchToken: String,
val websiteMode: Boolean,
val useTor: Boolean,
val proxyPort: Int,
val bgColor: Int,
val declaredDomains: List<String>,
) {
var contentServer: NappletContentServer? = null
var webView: WebView? = null
var bridgeReplyProxy: JavaScriptReplyProxy? = null
var fireSeq = 0
val replyMessenger = Messenger(Handler(Looper.getMainLooper()) { onBrokerReply(this, it) })
}
private lateinit var contentServer: NappletContentServer
private val tabs = mutableMapOf<String, NappletTab>()
private var webView: WebView? = null
// ---- broker bridge (identical trust model to NappletHostActivity: one launch token) ----
// ---- broker bridge: ONE binding shared by all tabs; per-message replyTo scopes the routing ----
private var brokerMessenger: Messenger? = null
private val replyMessenger = Messenger(Handler(Looper.getMainLooper(), ::onBrokerReply))
private var brokerBound = false
private val pendingBrokerRequests = mutableListOf<Message>()
private var bridgeReplyProxy: JavaScriptReplyProxy? = null
private var fireSeq = 0
private val brokerConnection =
object : ServiceConnection {
@@ -115,71 +126,90 @@ class NappletHostService : Service() {
override fun onBind(intent: Intent?): IBinder = incoming.binder
override fun onDestroy() {
runCatching { unbindService(brokerConnection) }
webView?.destroy()
webView = null
if (brokerBound) {
runCatching { unbindService(brokerConnection) }
brokerBound = false
}
tabs.values.forEach { it.webView?.destroy() }
tabs.clear()
super.onDestroy()
}
private fun tabFor(msg: Message): NappletTab? = msg.data?.getString(NappletEmbedContract.KEY_SESSION_ID)?.let { tabs[it] }
private fun tabFor(view: WebView): NappletTab? = tabs.values.firstOrNull { it.webView === view }
private fun onClientMessage(msg: Message): Boolean {
when (msg.what) {
NappletEmbedContract.MSG_CREATE_SESSION -> {
if (!readParams(msg)) return true
clientMessenger = msg.replyTo
bindService(Intent().setClassName(this, NappletHostContract.BROKER_SERVICE_CLASS), brokerConnection, BIND_AUTO_CREATE)
replyWithAdapter()
val tab = buildTab(msg) ?: return true
tabs[tab.sessionId] = tab
// Bind the broker once; a re-sent MSG_CREATE_SESSION must not leak a second binding.
if (!brokerBound) {
brokerBound = bindService(Intent().setClassName(this, NappletHostContract.BROKER_SERVICE_CLASS), brokerConnection, BIND_AUTO_CREATE)
}
replyWithAdapter(tab)
}
NappletEmbedContract.MSG_BACK -> webView?.let { if (it.canGoBack()) it.goBack() }
NappletEmbedContract.MSG_RELOAD -> webView?.reload()
NappletEmbedContract.MSG_BACK -> tabFor(msg)?.webView?.let { if (it.canGoBack()) it.goBack() }
NappletEmbedContract.MSG_RELOAD -> tabFor(msg)?.webView?.reload()
NappletEmbedContract.MSG_PAUSE ->
webView?.let {
tabFor(msg)?.webView?.let {
it.onPause()
it.pauseTimers()
}
NappletEmbedContract.MSG_RESUME ->
webView?.let {
tabFor(msg)?.webView?.let {
it.onResume()
it.resumeTimers()
}
NappletEmbedContract.MSG_IME_OP -> {
val tab = tabFor(msg) ?: return true
val payload = msg.data?.getString(NappletEmbedContract.KEY_IME_PAYLOAD) ?: return true
bridgeReplyProxy?.postMessage(payload)
tab.bridgeReplyProxy?.postMessage(payload)
}
else -> return false
}
return true
}
private fun readParams(msg: Message): Boolean {
val data = msg.data ?: return false
val pathList = data.getStringArrayList(NappletHostContract.EXTRA_PATHS) ?: return false
val hashList = data.getStringArrayList(NappletHostContract.EXTRA_HASHES) ?: return false
if (pathList.size != hashList.size || pathList.isEmpty()) return false
for (i in pathList.indices) paths.add(PathTag(pathList[i], hashList[i]))
servers.addAll(data.getStringArrayList(NappletHostContract.EXTRA_SERVERS) ?: emptyList())
author = data.getString(NappletHostContract.EXTRA_AUTHOR).orEmpty()
identifier = data.getString(NappletHostContract.EXTRA_IDENTIFIER).orEmpty()
websiteMode = data.getBoolean(NappletHostContract.EXTRA_WEBSITE_MODE, false)
useTor = data.getBoolean(NappletHostContract.EXTRA_USE_TOR, true)
proxyPort = data.getInt(NappletHostContract.EXTRA_PROXY_PORT, -1)
bgColor = data.getInt(NappletHostContract.EXTRA_BG_COLOR, android.graphics.Color.WHITE)
launchToken = data.getString(NappletHostContract.EXTRA_LAUNCH_TOKEN).orEmpty()
private fun buildTab(msg: Message): NappletTab? {
val data = msg.data ?: return null
val sessionId = data.getString(NappletEmbedContract.KEY_SESSION_ID) ?: return null
val pathList = data.getStringArrayList(NappletHostContract.EXTRA_PATHS) ?: return null
val hashList = data.getStringArrayList(NappletHostContract.EXTRA_HASHES) ?: return null
if (pathList.size != hashList.size || pathList.isEmpty()) return null
val author = data.getString(NappletHostContract.EXTRA_AUTHOR).orEmpty()
val launchToken = data.getString(NappletHostContract.EXTRA_LAUNCH_TOKEN).orEmpty()
if (author.isEmpty() || launchToken.isEmpty()) return null
val requires = data.getStringArrayList(NappletHostContract.EXTRA_REQUIRES) ?: emptyList()
declaredDomains = (listOf("shell") + resolveRequiredCapabilities(requires).capabilities.map { it.name.lowercase() }).distinct()
val declaredDomains = (listOf("shell") + resolveRequiredCapabilities(requires).capabilities.map { it.name.lowercase() }).distinct()
return author.isNotEmpty() && launchToken.isNotEmpty()
return NappletTab(
sessionId = sessionId,
clientMessenger = msg.replyTo,
paths = pathList.indices.map { PathTag(pathList[it], hashList[it]) },
servers = data.getStringArrayList(NappletHostContract.EXTRA_SERVERS) ?: emptyList(),
author = author,
identifier = data.getString(NappletHostContract.EXTRA_IDENTIFIER).orEmpty(),
launchToken = launchToken,
websiteMode = data.getBoolean(NappletHostContract.EXTRA_WEBSITE_MODE, false),
useTor = data.getBoolean(NappletHostContract.EXTRA_USE_TOR, true),
proxyPort = data.getInt(NappletHostContract.EXTRA_PROXY_PORT, -1),
bgColor = data.getInt(NappletHostContract.EXTRA_BG_COLOR, android.graphics.Color.WHITE),
declaredDomains = declaredDomains,
)
}
/** Builds the SandboxedUiAdapter and ships its cross-process handle (coreLibInfo) to the client. */
private fun replyWithAdapter() {
val adapter = NappletHostUiAdapter(this)
/** Builds the SandboxedUiAdapter for [tab] and ships its cross-process handle (coreLibInfo) to the client. */
private fun replyWithAdapter(tab: NappletTab) {
val adapter = NappletHostUiAdapter(this, tab.sessionId)
val coreLibInfo = adapter.toCoreLibInfo(this)
val reply =
Message.obtain(null, NappletEmbedContract.MSG_SESSION_READY).apply {
data = Bundle().apply { putBundle(NappletEmbedContract.KEY_CORE_LIB_INFO, coreLibInfo) }
}
runCatching { clientMessenger?.send(reply) }
runCatching { tab.clientMessenger?.send(reply) }
}
/**
@@ -187,38 +217,39 @@ class NappletHostService : Service() {
* surface). Mirrors [NappletHostActivity]: serves the shell + verified blobs through the content
* server, installs the origin-restricted shell bridge, loads the trusted shell URL.
*/
fun createHostWebView(context: Context): WebView {
// NOTE: a single NappletHostService instance is shared by every embedded napplet tab (they bind
// the same Intent), but each tab opens its own session with its own WebView; [webView] is just a
// "latest" pointer. Do NOT destroy it here — that would tear down a *sibling* tab's live WebView
// and black it out. Each session owns its WebView until it closes.
fun createHostWebView(
context: Context,
sessionId: String,
): WebView {
val wv = WebView(context)
val tab = tabs[sessionId] ?: return wv
val shellHtml = readContractAsset(NappletWebContract.SHELL_HTML_PATH)
val shim = readContractAsset(NappletWebContract.SHIM_JS_PATH).decodeToString()
val appOrigin = NappletWebContract.appOrigin(deriveAppId(author, identifier))
val effectiveProxy = if (useTor) proxyPort else -1
contentServer = NappletContentServer(paths, servers, effectiveProxy, cacheDir, shellHtml, shim, appOrigin, websiteMode, imeProxy = true)
val appOrigin = NappletWebContract.appOrigin(deriveAppId(tab.author, tab.identifier))
val effectiveProxy = if (tab.useTor) tab.proxyPort else -1
tab.contentServer = NappletContentServer(tab.paths, tab.servers, effectiveProxy, cacheDir, shellHtml, shim, appOrigin, tab.websiteMode, imeProxy = true)
val wv = WebView(context)
hardenWebView(wv)
hardenWebView(wv, tab)
// Theme the pre-load background so the shell/app loading shows Amethyst's background, not white.
wv.setBackgroundColor(bgColor)
wv.setBackgroundColor(tab.bgColor)
wv.dropSystemBarInsets()
if (websiteMode) applyWebViewProxy(effectiveProxy)
if (tab.websiteMode) applyWebViewProxy(effectiveProxy)
WebViewCompat.addWebMessageListener(wv, NappletWebContract.BRIDGE_NAME, setOf(NappletWebContract.ORIGIN), ::onShellMessage)
webView = wv
tab.webView = wv
wv.loadUrl(NappletWebContract.SHELL_URL)
return wv
}
/** A session closed: destroy that session's own WebView and clear the shared pointer only if it
* still referenced it (a sibling tab may have become the latest). */
fun onSessionClosed(closed: WebView) {
if (webView === closed) webView = null
closed.destroy()
/** A session closed: drop the tab and destroy its own WebView (never a sibling's). */
fun onSessionClosed(sessionId: String) {
tabs.remove(sessionId)?.webView?.destroy()
}
@Suppress("SetJavaScriptEnabled")
private fun hardenWebView(wv: WebView) {
private fun hardenWebView(
wv: WebView,
tab: NappletTab,
) {
wv.settings.apply {
javaScriptEnabled = true
domStorageEnabled = true
@@ -241,7 +272,7 @@ class NappletHostService : Service() {
}
wv.overScrollMode = View.OVER_SCROLL_NEVER
WebView.setWebContentsDebuggingEnabled(false)
wv.webViewClient = HostClient()
wv.webViewClient = HostClient(tab)
}
private fun applyWebViewProxy(port: Int) {
@@ -258,22 +289,24 @@ class NappletHostService : Service() {
}
/** Serves only the trusted shell and the manifest's verified blobs; external links go to the system. */
private inner class HostClient : WebViewClient() {
private inner class HostClient(
private val tab: NappletTab,
) : WebViewClient() {
override fun shouldInterceptRequest(
view: WebView,
request: WebResourceRequest,
): WebResourceResponse? = contentServer.serve(request)
): WebResourceResponse? = tab.contentServer?.serve(request)
override fun doUpdateVisitedHistory(
view: WebView,
url: String,
isReload: Boolean,
) = pushState(view)
) = pushState(tab, view)
override fun onPageFinished(
view: WebView,
url: String,
) = pushState(view)
) = pushState(tab, view)
override fun shouldOverrideUrlLoading(
view: WebView,
@@ -288,12 +321,15 @@ class NappletHostService : Service() {
}
}
private fun pushState(view: WebView) {
private fun pushState(
tab: NappletTab,
view: WebView,
) {
val message =
Message.obtain(null, NappletEmbedContract.MSG_STATE).apply {
data = Bundle().apply { putBoolean(NappletEmbedContract.KEY_CAN_GO_BACK, view.canGoBack()) }
}
runCatching { clientMessenger?.send(message) }
runCatching { tab.clientMessenger?.send(message) }
}
// ---- bridge: shell <-> native (mirror of NappletHostActivity.onShellMessage) ----
@@ -306,13 +342,14 @@ class NappletHostService : Service() {
replyProxy: JavaScriptReplyProxy,
) {
if (!isMainFrame) return
bridgeReplyProxy = replyProxy
val tab = tabFor(view) ?: return
tab.bridgeReplyProxy = replyProxy
val raw = message.data ?: return
val envelope = runCatching { JSONObject(raw) }.getOrNull() ?: return
if (envelope.optString("type") == "shell.ready") {
runCatching { replyProxy.postMessage(NappletProtocolJson.encodeShellInit(declaredDomains, declaredDomains)) }
runCatching { replyProxy.postMessage(NappletProtocolJson.encodeShellInit(tab.declaredDomains, tab.declaredDomains)) }
return
}
@@ -322,19 +359,19 @@ class NappletHostService : Service() {
Message.obtain(null, NappletEmbedContract.MSG_IME_EVENT).apply {
data = Bundle().apply { putString(NappletEmbedContract.KEY_IME_PAYLOAD, raw) }
}
runCatching { clientMessenger?.send(reply) }
runCatching { tab.clientMessenger?.send(reply) }
return
}
val id = envelope.optString("id").ifEmpty { "fire-${fireSeq++}" }
val id = envelope.optString("id").ifEmpty { "fire-${tab.fireSeq++}" }
val msg =
Message.obtain(null, NappletIpc.MSG_REQUEST).apply {
replyTo = replyMessenger
replyTo = tab.replyMessenger
data =
Bundle().apply {
putString(NappletIpc.KEY_REQUEST_ID, id)
putString(NappletIpc.KEY_PAYLOAD, raw)
putString(NappletIpc.KEY_LAUNCH_TOKEN, launchToken)
putString(NappletIpc.KEY_LAUNCH_TOKEN, tab.launchToken)
}
}
if (brokerMessenger == null) pendingBrokerRequests.add(msg) else sendToBroker(msg)
@@ -348,7 +385,11 @@ class NappletHostService : Service() {
}
}
private fun onBrokerReply(msg: Message): Boolean {
/** Broker reply for [tab] — delivered to the tab's own reply Messenger, so it's already scoped. */
private fun onBrokerReply(
tab: NappletTab,
msg: Message,
): Boolean {
val data = msg.data ?: return true
when (msg.what) {
NappletIpc.MSG_RESPONSE -> {
@@ -356,12 +397,12 @@ class NappletHostService : Service() {
val payload = data.getString(NappletIpc.KEY_PAYLOAD) ?: return true
val result = runCatching { JSONObject(payload) }.getOrNull() ?: JSONObject()
result.put("id", id)
notifyIfSensitive(result)
bridgeReplyProxy?.postMessage(result.toString())
notifyIfSensitive(tab, result)
tab.bridgeReplyProxy?.postMessage(result.toString())
}
NappletIpc.MSG_PUSH -> {
val payload = data.getString(NappletIpc.KEY_PAYLOAD) ?: return true
bridgeReplyProxy?.postMessage(payload)
tab.bridgeReplyProxy?.postMessage(payload)
}
else -> return false
}
@@ -369,7 +410,10 @@ class NappletHostService : Service() {
}
/** Pushes a notice to the main process for a granted "allow always" sensitive op, so it can toast. */
private fun notifyIfSensitive(result: JSONObject) {
private fun notifyIfSensitive(
tab: NappletTab,
result: JSONObject,
) {
if (!result.optBoolean("ok")) return
val notice =
when (result.optString("type")) {
@@ -382,7 +426,7 @@ class NappletHostService : Service() {
Message.obtain(null, NappletEmbedContract.MSG_NOTICE).apply {
data = Bundle().apply { putString(NappletEmbedContract.KEY_NOTICE, notice) }
}
runCatching { clientMessenger?.send(message) }
runCatching { tab.clientMessenger?.send(message) }
}
private fun readContractAsset(path: String): ByteArray = assets.open(NappletWebContract.RESOURCE_ASSET_ROOT + path).use { it.readBytes() }
@@ -44,6 +44,7 @@ import java.util.concurrent.Executor
@RequiresApi(Build.VERSION_CODES.R)
class NappletHostUiAdapter(
private val service: NappletHostService,
private val sessionId: String,
) : AbstractSandboxedUiAdapter() {
private val mainHandler = Handler(Looper.getMainLooper())
@@ -59,11 +60,11 @@ class NappletHostUiAdapter(
// WebView creation must run on the main thread; openSession is called on a binder thread.
mainHandler.post {
runCatching {
val webView = service.createHostWebView(context)
val webView = service.createHostWebView(context, sessionId)
// FrameLayout.LayoutParams (a MarginLayoutParams) — the SurfaceControlViewHost container
// measures children with measureChildWithMargins, which casts to MarginLayoutParams.
webView.layoutParams = FrameLayout.LayoutParams(initialWidth, initialHeight)
HostSession(webView, service)
HostSession(sessionId, webView, service)
}.onSuccess { session -> clientExecutor.execute { client.onSessionOpened(session) } }
.onFailure { t -> clientExecutor.execute { client.onSessionError(t) } }
}
@@ -73,6 +74,7 @@ class NappletHostUiAdapter(
/** A single embedded napplet/nsite session: the WebView is the rendered view; close tears it down. */
@RequiresApi(Build.VERSION_CODES.R)
private class HostSession(
private val sessionId: String,
private val webView: WebView,
private val service: NappletHostService,
) : SandboxedUiAdapter.Session {
@@ -97,6 +99,6 @@ private class HostSession(
override fun notifyUiChanged(uiContainerInfo: Bundle) {}
override fun close() {
service.onSessionClosed(webView)
service.onSessionClosed(sessionId)
}
}