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 58453f479b..f49b497538 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletBrokerService.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletBrokerService.kt @@ -320,7 +320,7 @@ class NappletBrokerService : Service() { // NAP-IDENTITY has no watch/unwatch request. Once the consent-gated startup // snapshot succeeds, the runtime owns identity.changed delivery for this // trusted launch token until the broker service closes. - if (requestType == "identity.getPublicKey" && outcome.payload.contains("\"ok\":true") && launchToken != null) { + if (requestType == "identity.getPublicKey" && outcome.response is NappletResponse.PublicKey && launchToken != null) { identityWatch.start(launchToken, session.accountPubKey) { push(replyTo, it) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletIdentityWatch.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletIdentityWatch.kt index bbb6a3aaa2..819c55b4a2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletIdentityWatch.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletIdentityWatch.kt @@ -27,6 +27,7 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.drop import kotlinx.coroutines.launch +import java.util.concurrent.ConcurrentHashMap /** * Streams `identity.changed` pushes to an applet that registered `napplet.identity.onChanged`. It @@ -41,21 +42,24 @@ class NappletIdentityWatch( private val scope: CoroutineScope, private val pubKey: (boundPubKey: String) -> Flow, ) { - private val jobs = mutableMapOf() + private val jobs = ConcurrentHashMap() fun start( watchId: String, boundPubKey: String, push: (String) -> Unit, ) { - if (jobs.containsKey(watchId)) return - jobs[watchId] = - scope.launch { - pubKey(boundPubKey) - .distinctUntilChanged() - .drop(1) - .collect { push(NappletProtocolJson.encodeIdentityChanged(it)) } - } + jobs.computeIfAbsent(watchId) { id -> + scope + .launch { + pubKey(boundPubKey) + .distinctUntilChanged() + .drop(1) + .collect { push(NappletProtocolJson.encodeIdentityChanged(it)) } + }.also { job -> + job.invokeOnCompletion { jobs.remove(id, job) } + } + } } fun stopAll() { 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 29c0a2f091..c923b799d7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletLauncher.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletLauncher.kt @@ -24,7 +24,6 @@ import android.content.Context import android.content.Intent import android.content.res.Configuration import android.os.Bundle -import android.util.Log import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.commons.napplet.NappletArtifactPolicy import com.vitorpamplona.amethyst.commons.napplet.NappletIdentity @@ -39,6 +38,7 @@ import com.vitorpamplona.quartz.nip01Core.crypto.verify import com.vitorpamplona.quartz.nip5aStaticWebsites.tags.PathTag import com.vitorpamplona.quartz.nip5dNapplets.NappletManifest import com.vitorpamplona.quartz.nipB7Blossom.BlossomServersEvent +import com.vitorpamplona.quartz.utils.Log /** * Opens a napplet/nsite in the sandboxed [NappletHostActivity] (the `:napplet` process). Only @@ -56,7 +56,7 @@ object NappletLauncher { ) { val event = manifest as? Event if (event?.verify() != true || event.pubKey != authorPubKey) { - Log.w(TAG, "Refusing NIP-5D manifest that failed signature/author verification") + Log.w(TAG) { "Refusing NIP-5D manifest that failed signature/author verification" } return } buildLaunchParams(context, manifest, authorPubKey, identifier)?.let { openHost(context, it) } @@ -80,7 +80,7 @@ object NappletLauncher { profile: HostProfile, ) { if (profile != HostProfile.WEBSITE) { - Log.w(TAG, "Refusing raw NIP-5D launch without a verified manifest") + Log.w(TAG) { "Refusing raw NIP-5D launch without a verified manifest" } return } val params = @@ -219,7 +219,7 @@ object NappletLauncher { ): Bundle? { val event = manifest as? Event if (event?.verify() != true || event.pubKey != authorPubKey) { - Log.w(TAG, "Refusing embedded NIP-5D manifest that failed signature/author verification") + Log.w(TAG) { "Refusing embedded NIP-5D manifest that failed signature/author verification" } return null } return runCatching { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/gateways/NappletResourceFetcher.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/gateways/NappletResourceFetcher.kt index 9acdf7b690..24bf6e655c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/gateways/NappletResourceFetcher.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/gateways/NappletResourceFetcher.kt @@ -39,16 +39,21 @@ import com.vitorpamplona.quartz.nip19Bech32.entities.NPub import com.vitorpamplona.quartz.nip5aStaticWebsites.resolver.StaticSiteResolver import com.vitorpamplona.quartz.nip5aStaticWebsites.resolver.sniffContentType import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.withContext import kotlinx.serialization.json.Json import okhttp3.Authenticator +import okhttp3.Call +import okhttp3.Callback import okhttp3.CookieJar import okhttp3.Dns import okhttp3.HttpUrl import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import okhttp3.OkHttpClient import okhttp3.Request +import okhttp3.Response import java.io.ByteArrayOutputStream +import java.io.IOException import java.io.InterruptedIOException import java.net.InetAddress import java.net.URLDecoder @@ -127,7 +132,7 @@ class NappletResourceFetcher( ) }.build() - private fun fetchHttps( + private suspend fun fetchHttps( url: String, client: OkHttpClient, ): NappletResourceResult { @@ -141,7 +146,7 @@ class NappletResourceFetcher( .url(current) .get() .build(), - ).execute() + ).await() .use { response -> if (response.isRedirect) { if (hop >= MAX_REDIRECTS) return failure(ERROR_BLOCKED, "Redirect limit exceeded.") @@ -227,7 +232,7 @@ class NappletResourceFetcher( * wrong server can never substitute the blob. Returns null for a malformed hash or if no server * serves it. */ - private fun fetchBlossom( + private suspend fun fetchBlossom( url: String, client: OkHttpClient, ): NappletResourceResult { @@ -257,6 +262,32 @@ class NappletResourceFetcher( return failure(ERROR_NOT_FOUND, "No Blossom server returned the verified blob.") } + private suspend fun Call.await(): Response = + suspendCancellableCoroutine { continuation -> + continuation.invokeOnCancellation { cancel() } + enqueue( + object : Callback { + override fun onFailure( + call: Call, + e: IOException, + ) { + if (continuation.isActive) continuation.resumeWith(Result.failure(e)) + } + + override fun onResponse( + call: Call, + response: Response, + ) { + if (continuation.isActive) { + continuation.resumeWith(Result.success(response)) + } else { + response.close() + } + } + }, + ) + } + /** Parses a `data:[][;base64],` URL into bytes + content type. */ private fun decodeDataUrl(url: String): NappletResourceResult { val comma = url.indexOf(',') diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/napplet/NappletLauncherTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/napplet/NappletLauncherTest.kt new file mode 100644 index 0000000000..6145418d88 --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/napplet/NappletLauncherTest.kt @@ -0,0 +1,68 @@ +/* + * 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.napplethost.HostProfile +import com.vitorpamplona.quartz.nip5dNapplets.NappletManifest +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import org.junit.Assert.assertNull +import org.junit.Test + +class NappletLauncherTest { + private val context = mockk(relaxed = true) + private val manifest = mockk() + private val author = "aa".repeat(32) + + @Test + fun manifestLaunchRejectsNonEventManifest() { + NappletLauncher.launch(context, manifest, author, "demo") + + verify(exactly = 0) { context.startActivity(any()) } + } + + @Test + fun embeddedBuildLaunchParamsRejectsNonEventManifest() { + assertNull(NappletLauncher.buildLaunchParams(context, manifest, author, "demo")) + } + + @Test + fun rawLaunchRejectsNappletProfile() { + every { context.startActivity(any()) } returns Unit + + NappletLauncher.launch( + context = context, + paths = emptyList(), + servers = emptyList(), + authorPubKey = author, + identifier = "demo", + aggregateHash = null, + title = "Demo", + requires = emptyList(), + profile = HostProfile.NAPPLET, + ) + + verify(exactly = 0) { context.startActivity(any()) } + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletBroker.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletBroker.kt index 0a9a923848..b7f9adf73d 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletBroker.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletBroker.kt @@ -33,6 +33,7 @@ import com.vitorpamplona.amethyst.commons.napplet.permissions.NappletPermissionL import com.vitorpamplona.amethyst.commons.napplet.permissions.PermissionDecision import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletRequest import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletResponse +import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletStorageScope import com.vitorpamplona.amethyst.commons.napplet.protocol.toSignerOp import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner @@ -376,11 +377,11 @@ class NappletBroker( private fun storageCoordinate( identity: NappletIdentity, - scope: com.vitorpamplona.amethyst.commons.napplet.protocol.NappletStorageScope, + scope: NappletStorageScope, ): String = when (scope) { - com.vitorpamplona.amethyst.commons.napplet.protocol.NappletStorageScope.SHARED -> identity.storageCoordinate - com.vitorpamplona.amethyst.commons.napplet.protocol.NappletStorageScope.INSTANCE -> identity.instanceStorageCoordinate + NappletStorageScope.SHARED -> identity.storageCoordinate + NappletStorageScope.INSTANCE -> identity.instanceStorageCoordinate } /** diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletRequestRouter.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletRequestRouter.kt index b1e7d9440f..fb27dd611e 100644 --- a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletRequestRouter.kt +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletRequestRouter.kt @@ -42,6 +42,7 @@ object NappletRequestRouter { /** Send this `.result` payload back, correlated to the request's id. */ data class Reply( val payload: String, + val response: NappletResponse? = null, ) : Outcome /** Open a live relay subscription; the host streams `relay.event`/`relay.eose`/`relay.closed` by [subId]. */ @@ -114,7 +115,12 @@ object NappletRequestRouter { val request = runCatching { NappletProtocolJson.decodeRequest(payload) }.getOrNull() - ?: return Outcome.Ignore + ?: return if (runCatching { NappletProtocolJson.readId(payload) }.getOrNull() != null) { + val failure = NappletResponse.Failed("Malformed or unsupported request.") + Outcome.Reply(NappletProtocolJson.encodeResponse(requestType, failure), failure) + } else { + Outcome.Ignore + } val response = broker.handle(identity, request, declared) @@ -131,6 +137,6 @@ object NappletRequestRouter { } } - return Outcome.Reply(NappletProtocolJson.encodeResponse(requestType, response)) + return Outcome.Reply(NappletProtocolJson.encodeResponse(requestType, response), response) } } diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/napplet/protocol/NappletProtocolJson.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/napplet/protocol/NappletProtocolJson.kt index 1df933a9e7..7cfb2a95e0 100644 --- a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/napplet/protocol/NappletProtocolJson.kt +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/napplet/protocol/NappletProtocolJson.kt @@ -61,6 +61,9 @@ object NappletProtocolJson { /** The `type` discriminant of a request envelope, used to build the matching `.result` type. */ fun readType(envelopeJson: String): String? = json.parseToJsonElement(envelopeJson).jsonObject.str("type") + /** The request `id`, when the envelope expects a correlated reply. */ + fun readId(envelopeJson: String): String? = json.parseToJsonElement(envelopeJson).jsonObject.str("id") + /** The `subId` of a subscription request, used to key the `relay.event`/`relay.eose` pushes back to it. */ fun readSubId(envelopeJson: String): String? = json.parseToJsonElement(envelopeJson).jsonObject.str("subId") diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletRequestRouterTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletRequestRouterTest.kt index eca9000437..6269b971ea 100644 --- a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletRequestRouterTest.kt +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletRequestRouterTest.kt @@ -125,6 +125,20 @@ class NappletRequestRouterTest { assertEquals(NappletRequestRouter.Outcome.Ignore, route("""{"type":"relay.publish"}""")) } + @Test + fun keyedUnknownAndMalformedRequestsReplyWithFailure() = + runTest { + val unknown = route("""{"type":"totally.unknown","id":"r1"}""") + assertIs(unknown) + assertTrue(unknown.payload.contains("totally.unknown.result")) + assertTrue(unknown.payload.contains("Malformed or unsupported request.")) + + val malformed = route("""{"type":"relay.publish","id":"r2"}""") + assertIs(malformed) + assertTrue(malformed.payload.contains("relay.publish.result")) + assertTrue(malformed.payload.contains("Malformed or unsupported request.")) + } + @Test fun queryRepliesWithItsResult() = runTest {