diff --git a/amethyst/build.gradle.kts b/amethyst/build.gradle.kts
index c11ed91602..b0104042ba 100644
--- a/amethyst/build.gradle.kts
+++ b/amethyst/build.gradle.kts
@@ -337,6 +337,9 @@ dependencies {
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.activity.compose)
+ // Hardened WebView host for sandboxed napplet/nsite rendering (origin-restricted message bridge).
+ implementation(libs.androidx.webkit)
+
implementation(libs.androidx.ui)
implementation(libs.androidx.ui.graphics)
implementation(libs.androidx.ui.tooling.preview)
diff --git a/amethyst/plans/2026-06-19-napplet-sandbox-host.md b/amethyst/plans/2026-06-19-napplet-sandbox-host.md
index c39fcf98f1..836d9e850e 100644
--- a/amethyst/plans/2026-06-19-napplet-sandbox-host.md
+++ b/amethyst/plans/2026-06-19-napplet-sandbox-host.md
@@ -1,7 +1,7 @@
# Napplet / nsite sandbox host — design
**Date:** 2026-06-19
-**Status:** Draft — core (commons) implemented + tested; Android host specified, not yet built
+**Status:** Core (commons) implemented + tested; Android host (`:napplet` process, WebView, broker IPC, consent, DataStore) implemented and compiling — needs on-device verification
**Companion:** `quartz/plans/2026-06-19-napplet-nip5a-resolver.md` (the bottom half — manifest parsing + verified Blossom resolution — already landed in `quartz`).
## Goal
@@ -190,9 +190,36 @@ Deferred to v2; v1 nails the single-applet boundary first.
## Phasing (within the "full napplet" milestone)
-1. **Core (commons, tested)** — protocol + capability model + ledger + broker. ✅ verifiable now.
-2. **Android host** — `:napplet` process + WebView + asset loader rendering a
- static, no-capability applet (proves sandbox + verified blob serving).
-3. **Broker IPC + `identity`** — getPublicKey/signEvent + consent UI.
-4. **`relay` + `value` + `storage`** capabilities.
-5. **Inter-applet** (v2).
+1. **Core (commons, tested)** — protocol + capability model + ledger + broker. ✅ done.
+2. **Android host** — `:napplet` process + WebView + verified-blob serving via
+ `shouldInterceptRequest` + CSP. ✅ implemented (`NappletHostActivity`).
+3. **Broker IPC + `identity` + `relay`** — Messenger broker
+ (`NappletBrokerService`), `window.napplet.*` shim, consent UI
+ (`NappletConsentActivity`), DataStore ledger. ✅ implemented.
+4. **`value` + `storage` + `net`** capabilities — protocol-defined; broker
+ currently answers `Unsupported`. ⏳ next.
+5. **Inter-applet** (v2). ⏳
+
+### Implemented Android components (amethyst `…/napplet/`)
+
+| File | Process | Role |
+|---|---|---|
+| `NappletHostActivity` | `:napplet` | WebView host: hardened settings, opaque-origin iframe, verified-blob `shouldInterceptRequest`, CSP, `window.napplet` shim, Messenger client |
+| `NappletBrokerService` | main | Bound Messenger service; runs the commons `NappletBroker` against the live account; `exported=false` + UID check |
+| `NappletConsentActivity` / `NappletConsentCoordinator` | main | Capability-consent dialog + suspend bridge to the broker |
+| `DataStoreNappletPermissionStore` | main | Persistent grant store (`NappletPermissionStore` actual) |
+| `NappletProtocolJson` / `NappletIpc` | both | JSON codec + Messenger wire contract |
+| `NappletLauncher` | caller | Packs a verified manifest into the host Intent |
+| `assets/napplet/shell.html` | `:napplet` | Trusted shell page that sandboxes the applet iframe and relays messages |
+
+### Remaining before user-facing ship
+
+- **On-device verification (needs emulator/device):** opaque-origin iframe really
+ excludes the bridge; CSP `connect-src 'none'` blocks fetch/XHR/WebSocket; a real
+ napplet renders and round-trips a `getPublicKey` / `signEvent` through consent.
+- **UI entry point:** wire `NappletLauncher.launch(...)` into navigation (a napplet
+ list/detail screen). Today the host is reachable only programmatically.
+- **Privacy:** the host's `OkHttpClient` for blob fetches ignores the user's
+ Tor/proxy settings — route it through the app's configured client.
+- **Consent UX:** reuse `commons/.../ui/signing` styling; show the manifest title
+ and a per-capability rationale; batch-grant on first run.
diff --git a/amethyst/src/main/AndroidManifest.xml b/amethyst/src/main/AndroidManifest.xml
index b243df378b..d346d9868c 100644
--- a/amethyst/src/main/AndroidManifest.xml
+++ b/amethyst/src/main/AndroidManifest.xml
@@ -402,6 +402,29 @@
android:name=".service.call.CallNotificationReceiver"
android:exported="false" />
+
+
+
+
+
+
+
+
+
diff --git a/amethyst/src/main/assets/napplet/shell.html b/amethyst/src/main/assets/napplet/shell.html
new file mode 100644
index 0000000000..cabb9a1a1b
--- /dev/null
+++ b/amethyst/src/main/assets/napplet/shell.html
@@ -0,0 +1,42 @@
+
+
+
+
+
+
+ Napplet
+
+
+
+
+
+
+
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/DataStoreNappletPermissionStore.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/DataStoreNappletPermissionStore.kt
new file mode 100644
index 0000000000..dc462c1202
Binary files /dev/null and b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/DataStoreNappletPermissionStore.kt differ
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletBrokerService.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletBrokerService.kt
new file mode 100644
index 0000000000..dd584edcd8
--- /dev/null
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletBrokerService.kt
@@ -0,0 +1,179 @@
+/*
+ * 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.napplet
+
+import android.app.Service
+import android.content.Intent
+import android.os.Binder
+import android.os.Bundle
+import android.os.Handler
+import android.os.IBinder
+import android.os.Looper
+import android.os.Message
+import android.os.Messenger
+import android.os.Process
+import android.os.RemoteException
+import android.util.Log
+import com.vitorpamplona.amethyst.Amethyst
+import com.vitorpamplona.amethyst.commons.napplet.NappletBroker
+import com.vitorpamplona.amethyst.commons.napplet.NappletCapability
+import com.vitorpamplona.amethyst.commons.napplet.NappletConsentPrompt
+import com.vitorpamplona.amethyst.commons.napplet.NappletIdentity
+import com.vitorpamplona.amethyst.commons.napplet.NappletRelayGateway
+import com.vitorpamplona.amethyst.commons.napplet.permissions.NappletPermissionLedger
+import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletRequest
+import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletResponse
+import com.vitorpamplona.quartz.nip01Core.core.Event
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.launch
+
+/**
+ * The trust boundary's main-process endpoint. The untrusted `:napplet` process binds this
+ * service and sends [NappletRequest]s as JSON over a [Messenger]; this is the only side that
+ * holds the signer, the relays, and the permission ledger. It runs each request through the
+ * shared [NappletBroker], which gates everything on consent and never returns key material.
+ *
+ * `exported=false` in the manifest restricts binding to this app's own UID, so no other
+ * installed app can reach the broker. A renderer escape that reaches the `:napplet` process
+ * could still bind here — but it gains only what the user explicitly consents to, per
+ * capability, and never the private key (the broker's contract).
+ */
+class NappletBrokerService : Service() {
+ private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
+
+ // One ledger for the whole service lifetime: persistent grants on disk, session grants in RAM.
+ private val ledger by lazy { NappletPermissionLedger(DataStoreNappletPermissionStore(applicationContext)) }
+
+ private val incoming by lazy { Messenger(Handler(Looper.getMainLooper(), ::handleMessage)) }
+
+ override fun onBind(intent: Intent?): IBinder? {
+ // Defense in depth on top of exported=false: only our own UID may bind.
+ if (Binder.getCallingUid() != Process.myUid()) return null
+ return incoming.binder
+ }
+
+ override fun onDestroy() {
+ scope.cancel()
+ super.onDestroy()
+ }
+
+ private fun handleMessage(msg: Message): Boolean {
+ if (msg.what != NappletIpc.MSG_REQUEST) return false
+
+ val data = msg.data ?: return true
+ val replyTo = msg.replyTo ?: return true
+ val requestId = data.getString(NappletIpc.KEY_REQUEST_ID) ?: return true
+ val payload = data.getString(NappletIpc.KEY_PAYLOAD) ?: return true
+
+ val identity =
+ NappletIdentity(
+ authorPubKey = data.getString(NappletIpc.KEY_AUTHOR).orEmpty(),
+ identifier = data.getString(NappletIpc.KEY_IDENTIFIER).orEmpty(),
+ aggregateHash = data.getString(NappletIpc.KEY_AGGREGATE_HASH),
+ )
+
+ scope.launch {
+ val response = process(identity, payload)
+ reply(replyTo, requestId, NappletProtocolJson.encodeResponse(response))
+ }
+ return true
+ }
+
+ private suspend fun process(
+ identity: NappletIdentity,
+ payload: String,
+ ): NappletResponse {
+ val request =
+ runCatching { NappletProtocolJson.decodeRequest(payload) }.getOrNull()
+ ?: return NappletResponse.Failed("Malformed or unsupported request.")
+
+ val broker = buildBroker() ?: return NappletResponse.Failed("No account is signed in.")
+ return broker.handle(identity, request)
+ }
+
+ /** Builds a broker bound to the *currently* signed-in account, so account switches are honored. */
+ private fun buildBroker(): NappletBroker? {
+ val account = Amethyst.instance.sessionManager.loggedInAccount() ?: return null
+
+ val relay =
+ NappletRelayGateway { event: Event ->
+ val relays = account.computeRelayListToBroadcast(event)
+ account.client.publish(event, relays)
+ relays.map { it.url }
+ }
+
+ val consent =
+ NappletConsentPrompt { id, capability, request ->
+ NappletConsentCoordinator.requestConsent(
+ context = applicationContext,
+ info = consentInfo(id, capability, request),
+ )
+ }
+
+ return NappletBroker(account.signer, ledger, consent, relay)
+ }
+
+ private fun consentInfo(
+ identity: NappletIdentity,
+ capability: NappletCapability,
+ request: NappletRequest,
+ ): NappletConsentInfo {
+ val title = identity.identifier.ifBlank { "Napplet ${identity.authorPubKey.take(8)}…" }
+ return NappletConsentInfo(
+ appletTitle = title,
+ coordinate = identity.coordinate,
+ capabilityLabel = capability.name.lowercase(),
+ operationSummary = summaryFor(request),
+ )
+ }
+
+ private fun summaryFor(request: NappletRequest): String =
+ when (request) {
+ is NappletRequest.GetPublicKey -> "This napplet wants to read your public key."
+ is NappletRequest.SignEvent -> "This napplet wants to sign an event (kind ${request.kind}) as you."
+ is NappletRequest.Nip04Encrypt, is NappletRequest.Nip44Encrypt -> "This napplet wants to encrypt a message as you."
+ is NappletRequest.Nip04Decrypt, is NappletRequest.Nip44Decrypt -> "This napplet wants to decrypt a message addressed to you."
+ is NappletRequest.Publish -> "This napplet wants to publish an event to your relays."
+ }
+
+ private fun reply(
+ replyTo: Messenger,
+ requestId: String,
+ payload: String,
+ ) {
+ val response =
+ Message.obtain(null, NappletIpc.MSG_RESPONSE).apply {
+ data =
+ Bundle().apply {
+ putString(NappletIpc.KEY_REQUEST_ID, requestId)
+ putString(NappletIpc.KEY_PAYLOAD, payload)
+ }
+ }
+ try {
+ replyTo.send(response)
+ } catch (e: RemoteException) {
+ Log.w("NappletBrokerService", "Applet host went away before reply could be delivered", e)
+ }
+ }
+}
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletConsentActivity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletConsentActivity.kt
new file mode 100644
index 0000000000..0179e9f1e8
--- /dev/null
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletConsentActivity.kt
@@ -0,0 +1,137 @@
+/*
+ * 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.napplet
+
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import com.vitorpamplona.amethyst.commons.napplet.permissions.GrantState
+import com.vitorpamplona.amethyst.ui.theme.AmethystTheme
+
+/**
+ * The capability-consent dialog, shown in the **main** process (the only place trusted to
+ * make a grant). It renders the [NappletConsentInfo] for a pending request and reports the
+ * user's [GrantState] back through [NappletConsentCoordinator]. The untrusted applet never
+ * sees or drives this UI.
+ */
+class NappletConsentActivity : ComponentActivity() {
+ private var token: String? = null
+ private var decided = false
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ val token = intent.getStringExtra(NappletConsentCoordinator.EXTRA_TOKEN)
+ this.token = token
+ val info = token?.let { NappletConsentCoordinator.infoFor(it) }
+
+ if (token == null || info == null) {
+ finish()
+ return
+ }
+
+ setContent {
+ AmethystTheme {
+ NappletConsentDialog(
+ info = info,
+ onDecision = { grant ->
+ decided = true
+ NappletConsentCoordinator.complete(token, grant)
+ finish()
+ },
+ onDismiss = {
+ decided = true
+ NappletConsentCoordinator.cancel(token)
+ finish()
+ },
+ )
+ }
+ }
+ }
+
+ override fun finish() {
+ // If the system tears us down before the user chose, fail closed.
+ if (!decided) token?.let { NappletConsentCoordinator.cancel(it) }
+ super.finish()
+ }
+}
+
+@Composable
+private fun NappletConsentDialog(
+ info: NappletConsentInfo,
+ onDecision: (GrantState) -> Unit,
+ onDismiss: () -> Unit,
+) {
+ AlertDialog(
+ onDismissRequest = onDismiss,
+ title = { Text(info.appletTitle) },
+ text = {
+ Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
+ Text(info.operationSummary)
+ Text(
+ "Capability: ${info.capabilityLabel}",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ Text(
+ info.coordinate,
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ },
+ confirmButton = {
+ Column(modifier = Modifier.fillMaxWidth()) {
+ TextButton(
+ onClick = { onDecision(GrantState.ALLOW_ALWAYS) },
+ modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp),
+ ) { Text("Always allow") }
+ TextButton(
+ onClick = { onDecision(GrantState.ALLOW_ONCE) },
+ modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp),
+ ) { Text("Allow once") }
+ }
+ },
+ dismissButton = {
+ Column(modifier = Modifier.fillMaxWidth()) {
+ TextButton(
+ onClick = { onDecision(GrantState.DENY) },
+ modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp),
+ ) { Text("Never allow") }
+ TextButton(
+ onClick = { onDecision(GrantState.ASK) },
+ modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp),
+ ) { Text("Not now") }
+ }
+ },
+ )
+}
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletConsentCoordinator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletConsentCoordinator.kt
new file mode 100644
index 0000000000..524c3976e9
--- /dev/null
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletConsentCoordinator.kt
@@ -0,0 +1,92 @@
+/*
+ * 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.napplet
+
+import android.content.Context
+import android.content.Intent
+import com.vitorpamplona.amethyst.commons.napplet.permissions.GrantState
+import kotlinx.coroutines.CompletableDeferred
+import java.util.UUID
+import java.util.concurrent.ConcurrentHashMap
+
+/** Everything the consent dialog needs to describe what an applet is asking permission to do. */
+data class NappletConsentInfo(
+ val appletTitle: String,
+ val coordinate: String,
+ val capabilityLabel: String,
+ val operationSummary: String,
+)
+
+/**
+ * Bridges the main-process broker to the consent UI. The broker suspends in
+ * [requestConsent]; this launches [NappletConsentActivity], holds the pending decision keyed
+ * by a one-time token, and resolves it when the activity reports the user's choice.
+ *
+ * A dismissed dialog resolves to [GrantState.ASK] — i.e. "not authorized, ask again next
+ * time" — so closing the prompt never silently grants nor permanently blocks.
+ */
+object NappletConsentCoordinator {
+ private class Pending(
+ val info: NappletConsentInfo,
+ val deferred: CompletableDeferred,
+ )
+
+ private val pending = ConcurrentHashMap()
+
+ suspend fun requestConsent(
+ context: Context,
+ info: NappletConsentInfo,
+ ): GrantState {
+ val token = UUID.randomUUID().toString()
+ val deferred = CompletableDeferred()
+ pending[token] = Pending(info, deferred)
+
+ val intent =
+ Intent(context, NappletConsentActivity::class.java)
+ .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ .putExtra(EXTRA_TOKEN, token)
+ context.startActivity(intent)
+
+ return try {
+ deferred.await()
+ } finally {
+ pending.remove(token)
+ }
+ }
+
+ /** Called by [NappletConsentActivity] to render the prompt. */
+ fun infoFor(token: String): NappletConsentInfo? = pending[token]?.info
+
+ /** Called by [NappletConsentActivity] with the user's decision. */
+ fun complete(
+ token: String,
+ grant: GrantState,
+ ) {
+ pending[token]?.deferred?.complete(grant)
+ }
+
+ /** Called when the dialog is dismissed without a choice — fails closed (no grant). */
+ fun cancel(token: String) {
+ pending[token]?.deferred?.complete(GrantState.ASK)
+ }
+
+ const val EXTRA_TOKEN = "napplet_consent_token"
+}
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletHostActivity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletHostActivity.kt
new file mode 100644
index 0000000000..d0dc428835
--- /dev/null
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletHostActivity.kt
@@ -0,0 +1,410 @@
+/*
+ * 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.napplet
+
+import android.content.ComponentName
+import android.content.Intent
+import android.content.ServiceConnection
+import android.net.Uri
+import android.os.Bundle
+import android.os.Handler
+import android.os.IBinder
+import android.os.Looper
+import android.os.Message
+import android.os.Messenger
+import android.util.Log
+import android.webkit.WebResourceRequest
+import android.webkit.WebResourceResponse
+import android.webkit.WebSettings
+import android.webkit.WebView
+import android.webkit.WebViewClient
+import android.widget.Toast
+import androidx.activity.ComponentActivity
+import androidx.webkit.JavaScriptReplyProxy
+import androidx.webkit.WebMessageCompat
+import androidx.webkit.WebViewCompat
+import androidx.webkit.WebViewFeature
+import com.vitorpamplona.quartz.nip5aStaticWebsites.resolver.BlobFetcher
+import com.vitorpamplona.quartz.nip5aStaticWebsites.resolver.StaticSiteResolution
+import com.vitorpamplona.quartz.nip5aStaticWebsites.resolver.StaticSiteResolver
+import com.vitorpamplona.quartz.nip5aStaticWebsites.tags.PathTag
+import kotlinx.coroutines.runBlocking
+import okhttp3.OkHttpClient
+import okhttp3.Request
+import org.json.JSONObject
+import java.io.ByteArrayInputStream
+
+/**
+ * Hosts a napplet/nsite WebView in the isolated `:napplet` process — a process that holds **no**
+ * account state, signer, or keys. It:
+ *
+ * 1. serves the trusted shell page and the manifest's **already-verified** blobs through
+ * [WebViewClient.shouldInterceptRequest] (no `file://`/`content://`, default-deny CSP with
+ * `connect-src 'none'` so the applet has no direct network), and
+ * 2. relays the applet's `window.napplet.*` capability calls — applet → shell (postMessage) →
+ * native (origin-restricted [WebViewCompat.addWebMessageListener]) → main-process broker
+ * (Messenger) → back — without ever interpreting them itself.
+ *
+ * Even a full WebView/renderer escape into this process yields no secret: the keys live only in
+ * the main process, and every brokered operation is still gated by user consent there.
+ */
+class NappletHostActivity : ComponentActivity() {
+ private lateinit var webView: WebView
+
+ private val paths = mutableListOf()
+ private val servers = mutableListOf()
+ private var author: String = ""
+ private var identifier: String = ""
+ private var aggregateHash: String? = null
+
+ private val http = OkHttpClient()
+ private val fetch: BlobFetcher = { url ->
+ try {
+ http
+ .newCall(
+ Request
+ .Builder()
+ .url(url)
+ .get()
+ .build(),
+ ).execute()
+ .use { r ->
+ if (r.isSuccessful) r.body.bytes() else null
+ }
+ } catch (e: Exception) {
+ Log.w(TAG, "Blob fetch failed for $url", e)
+ null
+ }
+ }
+
+ // Messenger to the main-process broker, bound lazily; requests queue until connected.
+ private var brokerMessenger: Messenger? = null
+ private val replyMessenger = Messenger(Handler(Looper.getMainLooper(), ::onBrokerReply))
+ private val pendingRequests = mutableListOf()
+ private var bridgeReplyProxy: JavaScriptReplyProxy? = null
+
+ private val brokerConnection =
+ object : ServiceConnection {
+ override fun onServiceConnected(
+ name: ComponentName?,
+ service: IBinder?,
+ ) {
+ brokerMessenger = Messenger(service)
+ pendingRequests.forEach { sendToBroker(it) }
+ pendingRequests.clear()
+ }
+
+ override fun onServiceDisconnected(name: ComponentName?) {
+ brokerMessenger = null
+ }
+ }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ if (!readManifestExtras()) {
+ Toast.makeText(this, "Invalid napplet.", Toast.LENGTH_SHORT).show()
+ finish()
+ return
+ }
+
+ if (!WebViewFeature.isFeatureSupported(WebViewFeature.WEB_MESSAGE_LISTENER)) {
+ Toast.makeText(this, "This device's WebView is too old to run napplets safely.", Toast.LENGTH_LONG).show()
+ finish()
+ return
+ }
+
+ webView = WebView(this)
+ setContentView(webView)
+ hardenWebView(webView)
+
+ // Origin-restricted bridge: only the trusted shell page (main frame) can reach native.
+ WebViewCompat.addWebMessageListener(
+ webView,
+ BRIDGE_NAME,
+ setOf(ORIGIN),
+ ::onShellMessage,
+ )
+
+ bindService(Intent(this, NappletBrokerService::class.java), brokerConnection, BIND_AUTO_CREATE)
+
+ webView.loadUrl(SHELL_URL)
+ }
+
+ override fun onDestroy() {
+ runCatching { unbindService(brokerConnection) }
+ if (this::webView.isInitialized) {
+ webView.destroy()
+ }
+ super.onDestroy()
+ }
+
+ private fun readManifestExtras(): Boolean {
+ val pathList = intent.getStringArrayListExtra(NappletLauncher.EXTRA_PATHS) ?: return false
+ val hashList = intent.getStringArrayListExtra(NappletLauncher.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(intent.getStringArrayListExtra(NappletLauncher.EXTRA_SERVERS) ?: emptyList())
+ author = intent.getStringExtra(NappletLauncher.EXTRA_AUTHOR).orEmpty()
+ identifier = intent.getStringExtra(NappletLauncher.EXTRA_IDENTIFIER).orEmpty()
+ aggregateHash = intent.getStringExtra(NappletLauncher.EXTRA_AGGREGATE_HASH)
+ title = intent.getStringExtra(NappletLauncher.EXTRA_TITLE).orEmpty()
+ return author.isNotEmpty()
+ }
+
+ private var title: String = ""
+
+ @Suppress("SetJavaScriptEnabled")
+ private fun hardenWebView(webView: WebView) {
+ webView.settings.apply {
+ javaScriptEnabled = true // the applet needs JS; isolation comes from process + CSP + sandbox
+ domStorageEnabled = false
+ databaseEnabled = false
+ allowFileAccess = false
+ allowContentAccess = false
+ @Suppress("DEPRECATION")
+ allowFileAccessFromFileURLs = false
+ @Suppress("DEPRECATION")
+ allowUniversalAccessFromFileURLs = false
+ javaScriptCanOpenWindowsAutomatically = false
+ setSupportMultipleWindows(false)
+ setGeolocationEnabled(false)
+ mediaPlaybackRequiresUserGesture = true
+ cacheMode = WebSettings.LOAD_NO_CACHE
+ mixedContentMode = WebSettings.MIXED_CONTENT_NEVER_ALLOW
+ if (WebViewFeature.isFeatureSupported(WebViewFeature.SAFE_BROWSING_ENABLE)) {
+ safeBrowsingEnabled = true
+ }
+ }
+ WebView.setWebContentsDebuggingEnabled(false)
+ webView.webViewClient = NappletWebViewClient()
+ }
+
+ /** Serves only the trusted shell and the manifest's verified blobs; everything else 404s. */
+ private inner class NappletWebViewClient : WebViewClient() {
+ override fun shouldInterceptRequest(
+ view: WebView,
+ request: WebResourceRequest,
+ ): WebResourceResponse? {
+ val url = request.url.toString()
+ if (!request.method.equals("GET", ignoreCase = true)) return null
+ if (!url.startsWith(ORIGIN)) return notFound()
+
+ if (url == SHELL_URL) return serveShell()
+ if (url == APP_BASE || url.startsWith(APP_BASE)) return serveAppResource(url)
+ return notFound()
+ }
+
+ override fun shouldOverrideUrlLoading(
+ view: WebView,
+ request: WebResourceRequest,
+ ): Boolean {
+ // Block any navigation away from our internal origin (e.g. applet link clicks).
+ return request.url.host != HOST
+ }
+ }
+
+ private fun serveShell(): WebResourceResponse {
+ val bytes = assets.open("napplet/shell.html").use { it.readBytes() }
+ return WebResourceResponse(
+ "text/html",
+ "utf-8",
+ 200,
+ "OK",
+ mapOf("Content-Security-Policy" to SHELL_CSP),
+ ByteArrayInputStream(bytes),
+ )
+ }
+
+ private fun serveAppResource(url: String): WebResourceResponse {
+ val requestPath =
+ url
+ .removePrefix(APP_BASE)
+ .substringBefore('?')
+ .substringBefore('#')
+ .let { if (it.isEmpty()) "/" else "/$it" }
+
+ val resolution = runBlocking { StaticSiteResolver.resolve(requestPath, paths, servers, fetch) }
+ if (resolution !is StaticSiteResolution.Resolved) return notFound()
+
+ val (mime, charset) = splitContentType(resolution.contentType)
+ val isHtml = mime.equals("text/html", ignoreCase = true)
+ val bytes = if (isHtml) injectShim(resolution.bytes) else resolution.bytes
+
+ return WebResourceResponse(
+ mime,
+ charset,
+ 200,
+ "OK",
+ mapOf("Content-Security-Policy" to APP_CSP),
+ ByteArrayInputStream(bytes),
+ )
+ }
+
+ /** Inserts the `window.napplet` client shim into the applet's HTML document. */
+ private fun injectShim(html: ByteArray): ByteArray {
+ val text = html.decodeToString()
+ val script = ""
+ val headIdx = text.indexOf("= 0 -> {
+ val close = text.indexOf('>', headIdx)
+ if (close >= 0) text.substring(0, close + 1) + script + text.substring(close + 1) else script + text
+ }
+ else -> script + text
+ }
+ return injected.encodeToByteArray()
+ }
+
+ // ---- bridge: shell <-> native ----
+
+ private fun onShellMessage(
+ view: WebView,
+ message: WebMessageCompat,
+ sourceOrigin: Uri,
+ isMainFrame: Boolean,
+ replyProxy: JavaScriptReplyProxy,
+ ) {
+ if (!isMainFrame) return // only the trusted shell, never a sub-frame
+ bridgeReplyProxy = replyProxy
+
+ val raw = message.data ?: return
+ val envelope = runCatching { JSONObject(raw) }.getOrNull() ?: return
+ val id = envelope.optString("id").ifEmpty { return }
+ val payload = envelope.optString("payload").ifEmpty { return }
+
+ val msg =
+ Message.obtain(null, NappletIpc.MSG_REQUEST).apply {
+ replyTo = replyMessenger
+ data =
+ Bundle().apply {
+ putString(NappletIpc.KEY_REQUEST_ID, id)
+ putString(NappletIpc.KEY_PAYLOAD, payload)
+ putString(NappletIpc.KEY_AUTHOR, author)
+ putString(NappletIpc.KEY_IDENTIFIER, identifier)
+ putString(NappletIpc.KEY_AGGREGATE_HASH, aggregateHash)
+ }
+ }
+
+ val messenger = brokerMessenger
+ if (messenger == null) {
+ pendingRequests.add(msg)
+ } else {
+ sendToBroker(msg)
+ }
+ }
+
+ private fun sendToBroker(msg: Message) {
+ try {
+ brokerMessenger?.send(msg)
+ } catch (e: Exception) {
+ Log.w(TAG, "Failed to deliver request to broker", e)
+ }
+ }
+
+ private fun onBrokerReply(msg: Message): Boolean {
+ if (msg.what != NappletIpc.MSG_RESPONSE) return false
+ val data = msg.data ?: return true
+ val id = data.getString(NappletIpc.KEY_REQUEST_ID) ?: return true
+ val payload = data.getString(NappletIpc.KEY_PAYLOAD) ?: return true
+
+ val envelope =
+ JSONObject().apply {
+ put("id", id)
+ put("response", payload)
+ }
+ bridgeReplyProxy?.postMessage(envelope.toString())
+ return true
+ }
+
+ private fun notFound(): WebResourceResponse = WebResourceResponse("text/plain", "utf-8", 404, "Not Found", emptyMap(), ByteArrayInputStream(ByteArray(0)))
+
+ private fun splitContentType(contentType: String): Pair {
+ val mime = contentType.substringBefore(';').trim().ifEmpty { "application/octet-stream" }
+ val charset =
+ contentType.substringAfter("charset=", "").trim().ifEmpty { null }
+ ?: if (mime.startsWith("text/") || mime.endsWith("javascript") || mime.endsWith("json")) "utf-8" else ""
+ return mime to charset
+ }
+
+ companion object {
+ private const val TAG = "NappletHostActivity"
+ private const val HOST = "napplet.local"
+ private const val ORIGIN = "https://napplet.local"
+ private const val SHELL_URL = "$ORIGIN/__shell__"
+ private const val APP_BASE = "$ORIGIN/app/"
+ private const val BRIDGE_NAME = "__nappletBridge"
+
+ private const val SHELL_CSP =
+ "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; " +
+ "frame-src https://napplet.local; base-uri 'none'; form-action 'none'"
+
+ // 'self' does not match an opaque (sandboxed) origin, so the host is listed explicitly.
+ // connect-src 'none' is the key lever: the applet gets no direct network.
+ private const val APP_CSP =
+ "default-src 'self' https://napplet.local; " +
+ "script-src 'self' https://napplet.local 'unsafe-inline'; " +
+ "style-src 'self' https://napplet.local 'unsafe-inline'; " +
+ "img-src 'self' https://napplet.local data: blob:; " +
+ "font-src 'self' https://napplet.local data:; " +
+ "media-src 'self' https://napplet.local blob: data:; " +
+ "connect-src 'none'; frame-src 'none'; object-src 'none'; base-uri 'self'; form-action 'none'"
+
+ private const val SHIM_JS = """
+(function(){
+ if (window.__nappletShimInstalled) return; window.__nappletShimInstalled = true;
+ var seq = 0, pending = {};
+ function call(payload){
+ return new Promise(function(resolve){
+ var id = 'r' + (seq++);
+ pending[id] = resolve;
+ parent.postMessage(JSON.stringify({ id: id, payload: JSON.stringify(payload) }), '*');
+ });
+ }
+ window.addEventListener('message', function(e){
+ if (e.source !== parent) return;
+ if (typeof e.data !== 'string') return;
+ var msg; try { msg = JSON.parse(e.data); } catch (_) { return; }
+ if (!msg || !msg.id) return;
+ var cb = pending[msg.id]; if (!cb) return; delete pending[msg.id];
+ var resp; try { resp = JSON.parse(msg.response); } catch (_) { resp = { type: 'failed', reason: 'bad response' }; }
+ cb(resp);
+ });
+ function fail(r){ var e = new Error((r && r.reason) || (r && r.type) || 'napplet error'); e.napplet = r; throw e; }
+ function pubkey(r){ if (r.type === 'publicKey') return r.pubkey; fail(r); }
+ function evt(r){ if (r.type === 'signedEvent') return r.event; fail(r); }
+ function text(r){ if (r.type === 'text') return r.value; fail(r); }
+ function published(r){ if (r.type === 'published') return r.relays; fail(r); }
+ window.napplet = Object.freeze({
+ getPublicKey: function(){ return call({ op: 'getPublicKey' }).then(pubkey); },
+ signEvent: function(t){ return call({ op: 'signEvent', kind: t.kind, tags: t.tags || [], content: t.content || '' }).then(evt); },
+ nip04Encrypt: function(peer, plaintext){ return call({ op: 'nip04Encrypt', peer: peer, plaintext: plaintext }).then(text); },
+ nip04Decrypt: function(peer, ciphertext){ return call({ op: 'nip04Decrypt', peer: peer, ciphertext: ciphertext }).then(text); },
+ nip44Encrypt: function(peer, plaintext){ return call({ op: 'nip44Encrypt', peer: peer, plaintext: plaintext }).then(text); },
+ nip44Decrypt: function(peer, ciphertext){ return call({ op: 'nip44Decrypt', peer: peer, ciphertext: ciphertext }).then(text); },
+ publish: function(ev){ return call({ op: 'publish', event: ev }).then(published); }
+ });
+})();
+"""
+ }
+}
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletIpc.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletIpc.kt
new file mode 100644
index 0000000000..2906b97531
--- /dev/null
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletIpc.kt
@@ -0,0 +1,44 @@
+/*
+ * 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.napplet
+
+/**
+ * The Messenger wire contract between the untrusted `:napplet` process (the WebView host) and
+ * the main-process [NappletBrokerService]. Kept tiny and string-only on purpose: nothing the
+ * applet controls is ever interpreted as a Binder object, and the only payloads are JSON
+ * strings ([NappletProtocolJson]) plus the applet's identity coordinate.
+ */
+object NappletIpc {
+ /** Host → broker: a capability request. Carries [KEY_REQUEST_ID], the identity keys, and [KEY_PAYLOAD]. */
+ const val MSG_REQUEST = 1
+
+ /** Broker → host: the matching reply. Carries [KEY_REQUEST_ID] and [KEY_PAYLOAD]. */
+ const val MSG_RESPONSE = 2
+
+ const val KEY_REQUEST_ID = "requestId"
+ const val KEY_PAYLOAD = "payload"
+
+ // Applet identity (the ledger coordinate) travels with every request — the host cannot be
+ // trusted to have applied any policy, so the broker re-derives everything from these.
+ const val KEY_AUTHOR = "author"
+ const val KEY_IDENTIFIER = "identifier"
+ const val KEY_AGGREGATE_HASH = "aggregateHash"
+}
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletLauncher.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletLauncher.kt
new file mode 100644
index 0000000000..1251b75b1f
--- /dev/null
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletLauncher.kt
@@ -0,0 +1,63 @@
+/*
+ * 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.napplet
+
+import android.content.Context
+import android.content.Intent
+import com.vitorpamplona.quartz.nip01Core.core.HexKey
+import com.vitorpamplona.quartz.nip5dNapplets.NappletManifest
+
+/**
+ * Opens a napplet/nsite in the sandboxed [NappletHostActivity] (the `:napplet` process). Only
+ * the verified manifest data the host needs to render and broker for the applet is passed —
+ * the declared `path → hash` map, the Blossom servers, the applet's identity coordinate, and a
+ * display title. No account state crosses into the sandbox process.
+ */
+object NappletLauncher {
+ const val EXTRA_PATHS = "napplet_paths"
+ const val EXTRA_HASHES = "napplet_hashes"
+ const val EXTRA_SERVERS = "napplet_servers"
+ const val EXTRA_AUTHOR = "napplet_author"
+ const val EXTRA_IDENTIFIER = "napplet_identifier"
+ const val EXTRA_AGGREGATE_HASH = "napplet_aggregate_hash"
+ const val EXTRA_TITLE = "napplet_title"
+
+ fun launch(
+ context: Context,
+ manifest: NappletManifest,
+ authorPubKey: HexKey,
+ identifier: String,
+ ) {
+ val pathTags = manifest.paths()
+ val intent =
+ Intent(context, NappletHostActivity::class.java).apply {
+ putExtra(EXTRA_PATHS, ArrayList(pathTags.map { it.path }))
+ putExtra(EXTRA_HASHES, ArrayList(pathTags.map { it.hash }))
+ putExtra(EXTRA_SERVERS, ArrayList(manifest.servers()))
+ putExtra(EXTRA_AUTHOR, authorPubKey)
+ putExtra(EXTRA_IDENTIFIER, identifier)
+ putExtra(EXTRA_AGGREGATE_HASH, manifest.declaredAggregateHash() ?: manifest.computeAggregateHash())
+ putExtra(EXTRA_TITLE, manifest.title() ?: identifier.ifBlank { "Napplet" })
+ if (context !is android.app.Activity) addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ }
+ context.startActivity(intent)
+ }
+}
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletProtocolJson.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletProtocolJson.kt
new file mode 100644
index 0000000000..d3dff5f120
--- /dev/null
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletProtocolJson.kt
@@ -0,0 +1,102 @@
+/*
+ * 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.napplet
+
+import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletRequest
+import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletResponse
+import com.vitorpamplona.quartz.nip01Core.core.Event
+import org.json.JSONArray
+import org.json.JSONObject
+
+/**
+ * Marshals the KMP-pure [NappletRequest] / [NappletResponse] types to and from the JSON the
+ * applet exchanges over `window.napplet.*`. This is the only place the boundary parses applet
+ * input, so it is deliberately strict: unknown ops decode to `null` (the broker rejects them)
+ * and a malformed body throws rather than guessing.
+ *
+ * The host process only ever *shuttles* these strings; decoding/encoding happens in the
+ * main-process broker so a compromised host cannot fabricate a typed request that skips a field.
+ */
+object NappletProtocolJson {
+ /** Parses an applet request. Returns `null` for an unrecognized `op` so the broker can deny it. */
+ fun decodeRequest(json: String): NappletRequest? {
+ val o = JSONObject(json)
+ return when (o.getString("op")) {
+ "getPublicKey" -> NappletRequest.GetPublicKey
+ "signEvent" ->
+ NappletRequest.SignEvent(
+ kind = o.getInt("kind"),
+ tags = decodeTags(o.optJSONArray("tags")),
+ content = o.optString("content", ""),
+ )
+ "nip04Encrypt" -> NappletRequest.Nip04Encrypt(o.getString("peer"), o.getString("plaintext"))
+ "nip04Decrypt" -> NappletRequest.Nip04Decrypt(o.getString("peer"), o.getString("ciphertext"))
+ "nip44Encrypt" -> NappletRequest.Nip44Encrypt(o.getString("peer"), o.getString("plaintext"))
+ "nip44Decrypt" -> NappletRequest.Nip44Decrypt(o.getString("peer"), o.getString("ciphertext"))
+ "publish" -> NappletRequest.Publish(Event.fromJson(o.getJSONObject("event").toString()))
+ else -> null
+ }
+ }
+
+ fun encodeResponse(response: NappletResponse): String {
+ val o = JSONObject()
+ when (response) {
+ is NappletResponse.PublicKey -> {
+ o.put("type", "publicKey")
+ o.put("pubkey", response.pubkey)
+ }
+ is NappletResponse.SignedEvent -> {
+ o.put("type", "signedEvent")
+ o.put("event", JSONObject(response.event.toJson()))
+ }
+ is NappletResponse.Text -> {
+ o.put("type", "text")
+ o.put("value", response.value)
+ }
+ is NappletResponse.Published -> {
+ o.put("type", "published")
+ o.put("relays", JSONArray(response.relays))
+ }
+ is NappletResponse.Denied -> {
+ o.put("type", "denied")
+ o.put("capability", response.capability.name)
+ o.put("reason", response.reason)
+ }
+ is NappletResponse.Unsupported -> {
+ o.put("type", "unsupported")
+ o.put("operation", response.operation)
+ }
+ is NappletResponse.Failed -> {
+ o.put("type", "failed")
+ o.put("reason", response.reason)
+ }
+ }
+ return o.toString()
+ }
+
+ private fun decodeTags(array: JSONArray?): Array> {
+ if (array == null) return emptyArray()
+ return Array(array.length()) { i ->
+ val inner = array.getJSONArray(i)
+ Array(inner.length()) { j -> inner.getString(j) }
+ }
+ }
+}
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 34b5e7b6f8..a1c932f65e 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -76,6 +76,7 @@ commonsImaging = "1.0.0-alpha6"
thumbnailator = "0.4.21"
zxing = "3.5.4"
zxingAndroidEmbedded = "4.3.0"
+webkit = "1.12.1"
windowCoreAndroid = "1.5.1"
workRuntime = "2.11.2"
androidxCamera = "1.6.1"
@@ -207,6 +208,7 @@ stream-webrtc-android = { group = "io.getstream", name = "stream-webrtc-android"
unifiedpush = { group = "com.github.UnifiedPush", name = "android-connector", version.ref = "unifiedpush" }
play-services-cast-framework = { group = "com.google.android.gms", name = "play-services-cast-framework", version.ref = "playServicesCast" }
vico-charts-compose = { group = "com.patrykandpatrick.vico", name = "compose", version.ref = "vico-charts-compose" }
+androidx-webkit = { group = "androidx.webkit", name = "webkit", version.ref = "webkit" }
vico-charts-m3 = { group = "com.patrykandpatrick.vico", name = "compose-m3", version.ref = "vico-charts-compose" }
zelory-image-compressor = { group = "id.zelory", name = "compressor", version.ref = "zelory" }
zoomable = { group = "net.engawapg.lib", name = "zoomable", version.ref = "zoomable" }