fix(napplet): route brokered resource fetches by the applet's own Tor mode

The consolidation passed `useProxy = true` for every brokered `resource.bytes`
fetch, forcing them through Tor whenever Tor was active — regardless of the
napplet/nSite's actual network mode. That overrides the user's explicit choice:
an nSite running in "open web" mode would still have its blob fetches tunneled,
inconsistent with how its own WebView page loads.

The authoritative per-applet preference already exists main-side in
NappletNetworkRegistry.useTor(coordinate) (locked napplets pinned to Tor;
nSites follow the persisted per-site toggle, which relaunches on change) — the
same source NappletLauncher reads to set the WebView proxy. Thread the calling
applet's coordinate through NappletResourceGateway.fetch so the broker can
resolve it, and pick the shared client with
getHttpClient(useProxy = NappletNetworkRegistry.useTor(coordinate)). This
mirrors the host's own `effectiveProxy = if (useTor) proxyPort else -1` exactly,
so a brokered fetch now routes like the applet's page.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Vitor Pamplona
2026-06-26 17:32:57 -04:00
co-authored by Claude Opus 4.8
parent c797033ba5
commit 1dbfd78b44
5 changed files with 40 additions and 27 deletions
@@ -320,8 +320,9 @@ class NappletBrokerService : Service() {
context = applicationContext,
ledger = ledger,
storage = storage,
// Prefer Tor when active; the shared manager falls back to clearnet when it isn't.
httpClient = { Amethyst.instance.okHttpClients.getHttpClient(useProxy = true) },
// Per-applet Tor decision (see NappletResourceFetcher): the shared manager routes
// through Tor when asked + active, and falls back to clearnet otherwise.
httpClient = { useProxy -> Amethyst.instance.okHttpClients.getHttpClient(useProxy) },
).broker()
cachedBroker = account to broker
return broker
@@ -71,7 +71,7 @@ class AccountNappletGateways(
private val context: Context,
private val ledger: NappletPermissionLedger,
private val storage: NappletStorage,
private val httpClient: () -> OkHttpClient,
private val httpClient: (useProxy: Boolean) -> OkHttpClient,
) {
private val consentSummary = NappletConsentSummary(context)
@@ -102,7 +102,7 @@ class AccountNappletGateways(
}
val wallet = NappletWalletGateway { invoice -> payInvoiceViaNwc(invoice) }
val resource = NappletResourceGateway { url -> resourceFetcher.fetch(url) }
val resource = NappletResourceGateway { url, coordinate -> resourceFetcher.fetch(url, coordinate) }
val identityReads = NappletIdentityGateway { method, argument -> identityReader.read(method, argument) }
val upload = NappletUploadGateway { bytes, contentType, filename -> uploadBlob(bytes, contentType, filename) }
val theme = NappletThemeGateway { currentThemeColors() }
@@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.napplet.gateways
import android.util.Base64
import com.vitorpamplona.amethyst.commons.napplet.NappletResource
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.napplet.NappletNetworkRegistry
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAll
@@ -50,23 +51,32 @@ import java.net.URLDecoder
*
* Network goes through the app-wide [OkHttpClient] supplied by [httpClient] (the shared
* [com.vitorpamplona.amethyst.service.okhttp.DualHttpClientManager]). Reusing it — rather than
* standing up a private client — means napplet blob fetches inherit the same Tor routing,
* passive `Onion-Location` discovery + `.onion` rewriting, local Blossom cache redirect,
* connection pool and DNS as every other HTTP role. Built per account (so it reads the right
* Blossom server list); consent is enforced by the broker before [fetch] ever runs.
* standing up a private client — means napplet blob fetches inherit the same passive
* `Onion-Location` discovery + `.onion` rewriting, local Blossom cache redirect, connection pool
* and DNS as every other HTTP role. Tor-or-clearnet is chosen per request from the calling
* applet's [NappletNetworkRegistry] mode (locked napplets are pinned to Tor; nSites follow the
* user's per-site toggle), so a brokered fetch routes exactly like that applet's own page loads.
* Built per account (so it reads the right Blossom server list); consent is enforced by the
* broker before [fetch] ever runs.
*/
class NappletResourceFetcher(
private val account: Account,
private val httpClient: () -> OkHttpClient,
private val httpClient: (useProxy: Boolean) -> OkHttpClient,
) {
/** Fetches an https/data/blossom resource, or null if unsupported/unavailable. */
suspend fun fetch(url: String): NappletResource? =
/** Fetches an https/data/blossom resource for the applet at [coordinate], or null if unsupported/unavailable. */
suspend fun fetch(
url: String,
coordinate: String,
): NappletResource? =
withContext(Dispatchers.IO) {
// Route like the applet's own page: Tor when its network mode is Tor, clearnet otherwise.
NappletNetworkRegistry.awaitReady()
val client = httpClient(NappletNetworkRegistry.useTor(coordinate))
when {
url.startsWith("data:") -> decodeDataUrl(url)
url.startsWith("https://") -> {
runCatching {
client()
client
.newCall(
Request
.Builder()
@@ -82,7 +92,7 @@ class NappletResourceFetcher(
}
}.getOrNull()
}
url.startsWith("blossom:") -> fetchBlossom(url)
url.startsWith("blossom:") -> fetchBlossom(url, client)
url.startsWith("nostr:") -> resolveNostr(url)
else -> null
}
@@ -139,20 +149,16 @@ class NappletResourceFetcher(
.maxByOrNull { it.createdAt }
}
/**
* The app-wide OkHttp client for host-side blob fetches (the applet has no direct network).
* The shared manager already routes through Tor when active, captures `Onion-Location` and
* rewrites `.onion`s, bridges the local Blossom cache, and pools connections — so there is no
* private client to build or cache here.
*/
private fun client(): OkHttpClient = httpClient()
/**
* Fetches a `blossom:<sha256>` (or `blossom://<sha256>`) blob from the user's Blossom servers
* (kind:10063), verifying the sha256 before returning — content-addressed, so a wrong server
* can never substitute the blob. Returns null for a malformed hash or if no server serves it.
* (kind:10063) over [client], verifying the sha256 before returning — content-addressed, so a
* wrong server can never substitute the blob. Returns null for a malformed hash or if no server
* serves it.
*/
private fun fetchBlossom(url: String): NappletResource? {
private fun fetchBlossom(
url: String,
client: OkHttpClient,
): NappletResource? {
val hash =
url
.removePrefix("blossom://")
@@ -168,7 +174,6 @@ class NappletResourceFetcher(
.getBlossomServersList()
?.servers()
.orEmpty()
val client = client()
for (candidate in StaticSiteResolver.candidateUrls(servers, hash)) {
val bytes =
runCatching {
@@ -274,7 +274,7 @@ class NappletBroker(
is NappletRequest.ResourceBytes -> {
val gateway = resource ?: return NappletResponse.Unsupported("resource.bytes")
val fetched = gateway.fetch(request.url) ?: return NappletResponse.Failed("Could not fetch the resource.")
val fetched = gateway.fetch(request.url, identity.coordinate) ?: return NappletResponse.Failed("Could not fetch the resource.")
NappletResponse.Bytes(fetched.bytes, fetched.contentType)
}
@@ -161,9 +161,16 @@ class NappletResource(
* (`resource.bytes`). The host fetches https/blossom/nostr/data URLs on the applet's behalf —
* the applet itself has no direct network (CSP `connect-src 'none'`). Returns `null` for an
* unsupported scheme or a failed fetch.
*
* [coordinate] is the calling applet's identity coordinate (`author:identifier`), so the host can
* route the fetch the same way the applet's own page loads — through Tor or the open web — per that
* applet's/site's network mode.
*/
fun interface NappletResourceGateway {
suspend fun fetch(url: String): NappletResource?
suspend fun fetch(
url: String,
coordinate: String,
): NappletResource?
}
/** A completed upload: where the blob lives plus NIP-94-ish metadata. */