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 8a2b6f1bd2..9357ac9e4d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletBrokerService.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletBrokerService.kt @@ -102,6 +102,16 @@ class NappletBrokerService : Service() { } private fun handleMessage(msg: Message): Boolean { + // The sandbox relays the user's per-site network choice; persist it against the trusted + // coordinate the launch token resolves to (the sandbox can't state its own coordinate). + if (msg.what == NappletIpc.MSG_SET_NETWORK_MODE) { + val data = msg.data ?: return true + val session = NappletLaunchRegistry.resolve(data.getString(NappletIpc.KEY_LAUNCH_TOKEN)) ?: return true + NappletNetworkRegistry.init(applicationContext) + NappletNetworkRegistry.set(session.identity.coordinate, data.getBoolean(NappletIpc.KEY_NETWORK_USE_TOR, true)) + return true + } + if (msg.what != NappletIpc.MSG_REQUEST) return false val data = msg.data ?: return true 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 2215519138..c8b88598a5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletLauncher.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletLauncher.kt @@ -98,6 +98,11 @@ object NappletLauncher { } val launchToken = NappletLaunchRegistry.register(identity, declared) + // Resolve the per-site network choice (Tor default; a site can be opted out to the open web). + // Locked napplets always keep Tor for their blob fetches — only nSites expose the toggle. + NappletNetworkRegistry.init(context.applicationContext) + val useTor = if (websiteMode) NappletNetworkRegistry.useTor(identity.coordinate) else true + // Resolve capability labels here (the app has the resources) so the sandbox module needs none. val capLabels = declared.map { context.getString(it.labelRes()) } @@ -115,6 +120,7 @@ object NappletLauncher { putExtra(NappletHostContract.EXTRA_LAUNCH_TOKEN, launchToken) putExtra(NappletHostContract.EXTRA_PROXY_PORT, proxyPort) putExtra(NappletHostContract.EXTRA_WEBSITE_MODE, websiteMode) + putExtra(NappletHostContract.EXTRA_USE_TOR, useTor) 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/NappletNetworkRegistry.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletNetworkRegistry.kt new file mode 100644 index 0000000000..2a33ec4191 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletNetworkRegistry.kt @@ -0,0 +1,89 @@ +/* + * 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 androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import java.util.concurrent.ConcurrentHashMap + +private val Context.nappletNetworkDataStore by preferencesDataStore(name = "napplet_network") + +/** + * Per-nSite network-routing preference: whether a site's traffic goes through **Tor** (the default) + * or over the **open web**. Keyed by the site's coordinate (`author:identifier`), like the permission + * ledger, so the choice survives across launches and routine code updates. + * + * When Tor is active, nSites route through it by default; a user can opt a specific site out (e.g. a + * site that breaks or is unusably slow over Tor) from the sandbox top bar. "Open web" makes + * *everything* for that site direct — both its live web traffic and its blob fetches. + * + * An in-memory map is authoritative for the session ([NappletLauncher] reads it synchronously while + * building the launch intent) with write-through persistence. Absent = Tor (the safe default), so a + * site never silently starts on the open web before the on-disk preference has hydrated. + * + * Lives only in the **main process**: the launcher reads it, and [NappletBrokerService] writes it + * when the key-free `:napplet` sandbox relays a toggle over IPC. The sandbox never touches it. + */ +object NappletNetworkRegistry { + private const val OPEN_WEB = "OPEN" + private const val TOR = "TOR" + + // coordinate -> useTor. Absent means "never chosen" -> Tor. + private val modes = ConcurrentHashMap() + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + + @Volatile private var appContext: Context? = null + + /** Binds the app context and hydrates the on-disk preferences into memory. Idempotent. */ + fun init(context: Context) { + if (appContext != null) return + val ctx = context.applicationContext + appContext = ctx + scope.launch { + ctx.nappletNetworkDataStore.data.first().asMap().forEach { (key, value) -> + // putIfAbsent: never clobber a choice made in this session before hydration finished. + modes.putIfAbsent(key.name, value != OPEN_WEB) + } + } + } + + /** Whether [coordinate] routes through Tor. Defaults to true (Tor) for any site never set. */ + fun useTor(coordinate: String): Boolean = modes[coordinate] ?: true + + /** Records [useTor] for [coordinate] in memory and persists it. */ + fun set( + coordinate: String, + useTor: Boolean, + ) { + modes[coordinate] = useTor + val ctx = appContext ?: return + scope.launch { + ctx.nappletNetworkDataStore.edit { it[stringPreferencesKey(coordinate)] = if (useTor) TOR else OPEN_WEB } + } + } +} diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostActivity.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostActivity.kt index 1489bb987d..e72cc6a720 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostActivity.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostActivity.kt @@ -55,6 +55,8 @@ import androidx.core.graphics.Insets import androidx.core.view.ViewCompat import androidx.core.view.WindowInsetsCompat import androidx.webkit.JavaScriptReplyProxy +import androidx.webkit.ProxyConfig +import androidx.webkit.ProxyController import androidx.webkit.WebMessageCompat import androidx.webkit.WebViewCompat import androidx.webkit.WebViewFeature @@ -73,6 +75,7 @@ import kotlinx.coroutines.cancel import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.json.JSONObject +import java.util.concurrent.Executor /** * Hosts a napplet/nsite WebView in the isolated `:napplet` process — a process that holds **no** @@ -107,6 +110,10 @@ class NappletHostActivity : ComponentActivity() { // nSite "website mode": a normal web app (NIP-07 window.nostr + normal network), vs a locked napplet. private var websiteMode: Boolean = false + // Per-site network choice: route this site's traffic through Tor (default) or over the open web. + // Applied to both the WebView proxy and the blob-fetch client; toggled from the top-bar onion. + private var useTor: Boolean = true + // NAP domain strings the shell advertises to the applet in the shell.init handshake. private var declaredDomains: List = emptyList() @@ -196,13 +203,20 @@ class NappletHostActivity : ComponentActivity() { val shellHtml = readContractAsset(NappletWebContract.SHELL_HTML_PATH) val shim = readContractAsset(NappletWebContract.SHIM_JS_PATH).decodeToString() val appOrigin = NappletWebContract.appOrigin(deriveAppId(author, identifier)) - contentServer = NappletContentServer(paths, servers, proxyPort, cacheDir, shellHtml, shim, appOrigin, websiteMode) + // "Open web" for a site makes everything direct — both its blob fetches (here) and its live + // web traffic (the WebView proxy, below). Tor (the default) routes both through the SOCKS port. + val effectiveProxy = if (useTor) proxyPort else -1 + contentServer = NappletContentServer(paths, servers, effectiveProxy, cacheDir, shellHtml, shim, appOrigin, websiteMode) // Create + warm the WebView NOW so its (slow, first-in-process) Chromium init runs on the main // thread concurrently with the index probe below (which runs on IO) — instead of serially after // it. Binding the broker early overlaps too. The WebView is attached once the probe succeeds. webView = WebView(this) hardenWebView(webView) + // Route the WebView's own (off-origin) traffic through Tor for an nSite, unless this site was + // opted out to the open web. Set process-wide before any page navigation; the shell + blobs are + // served from cache via shouldInterceptRequest, so only the site's external requests hit this. + if (websiteMode) applyWebViewProxy(effectiveProxy) // Origin-restricted bridge: only the trusted shell page (main frame) can reach native. WebViewCompat.addWebMessageListener( webView, @@ -328,6 +342,7 @@ class NappletHostActivity : ComponentActivity() { author = intent.getStringExtra(NappletHostContract.EXTRA_AUTHOR).orEmpty() identifier = intent.getStringExtra(NappletHostContract.EXTRA_IDENTIFIER).orEmpty() websiteMode = intent.getBooleanExtra(NappletHostContract.EXTRA_WEBSITE_MODE, false) + useTor = intent.getBooleanExtra(NappletHostContract.EXTRA_USE_TOR, true) title = intent.getStringExtra(NappletHostContract.EXTRA_TITLE).orEmpty() proxyPort = intent.getIntExtra(NappletHostContract.EXTRA_PROXY_PORT, -1) launchToken = intent.getStringExtra(NappletHostContract.EXTRA_LAUNCH_TOKEN).orEmpty() @@ -390,6 +405,25 @@ class NappletHostActivity : ComponentActivity() { webView.webViewClient = NappletWebViewClient() } + /** + * Routes this process's WebView traffic through the Tor SOCKS proxy when [port] > 0, else clears any + * override so the site loads over the open web. Process-global (this `:napplet` process hosts only + * applet/site WebViews) and best-effort: a device whose WebView can't honor a SOCKS proxy falls back + * to direct — verified on-device, since SOCKS-over-WebView support varies by WebView version. + */ + private fun applyWebViewProxy(port: Int) { + if (!WebViewFeature.isFeatureSupported(WebViewFeature.PROXY_OVERRIDE)) return + val executor = Executor { it.run() } + runCatching { + if (port > 0) { + val config = ProxyConfig.Builder().addProxyRule("socks5://127.0.0.1:$port").build() + ProxyController.getInstance().setProxyOverride(config, executor) {} + } else { + ProxyController.getInstance().clearProxyOverride(executor) {} + } + }.onFailure { Log.w(TAG, "Failed to apply WebView proxy override", it) } + } + /** Serves only the trusted shell and the manifest's verified blobs; everything else 404s. */ private inner class NappletWebViewClient : WebViewClient() { override fun shouldInterceptRequest( @@ -638,6 +672,21 @@ class NappletHostActivity : ComponentActivity() { layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f) }, ) + // nSite network indicator + toggle: onion = Tor (private), globe = open web (IP visible). Only + // shown when this is a website-mode nSite AND Tor is active — otherwise there is nothing to + // route through. Its own click handler opens the network dialog without firing the access sheet. + if (websiteMode && proxyPort > 0) { + bar.addView( + TextView(this).apply { + text = if (useTor) "🧅" else "🌐" + textSize = 18f + setPadding(0, 0, dp(12), 0) + isClickable = true + setOnClickListener { showNetworkDialog() } + contentDescription = getString(if (useTor) R.string.napplet_net_tor_desc else R.string.napplet_net_open_desc) + }, + ) + } bar.addView( TextView(this).apply { text = "ⓘ" // circled info @@ -648,6 +697,40 @@ class NappletHostActivity : ComponentActivity() { return bar } + /** + * Explains the site's current network routing and offers to switch it. Switching persists the + * per-site choice (via the broker, which owns the preference) and relaunches this screen so the new + * routing applies cleanly from [onCreate] — the proxy and content server are rebuilt for the new mode. + */ + private fun showNetworkDialog() { + val titleRes = if (useTor) R.string.napplet_net_tor_title else R.string.napplet_net_open_title + val messageRes = if (useTor) R.string.napplet_net_tor_message else R.string.napplet_net_open_message + val switchRes = if (useTor) R.string.napplet_net_switch_open else R.string.napplet_net_switch_tor + AlertDialog + .Builder(this) + .setTitle(getString(titleRes, barTitle())) + .setMessage(getString(messageRes)) + .setPositiveButton(getString(switchRes)) { _, _ -> setNetworkMode(!useTor) } + .setNegativeButton(android.R.string.cancel, null) + .show() + } + + /** Persists the new routing choice in the main process, then relaunches this screen to apply it. */ + private fun setNetworkMode(newUseTor: Boolean) { + val msg = + Message.obtain(null, NappletIpc.MSG_SET_NETWORK_MODE).apply { + data = + Bundle().apply { + putString(NappletIpc.KEY_LAUNCH_TOKEN, launchToken) + putBoolean(NappletIpc.KEY_NETWORK_USE_TOR, newUseTor) + } + } + sendToBroker(msg) + useTor = newUseTor + intent.putExtra(NappletHostContract.EXTRA_USE_TOR, newUseTor) + recreate() + } + private fun buildDivider(): View = View(this).apply { layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, dp(1)) diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostContract.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostContract.kt index e4a5d34a61..c34eaeffcd 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostContract.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostContract.kt @@ -59,6 +59,13 @@ object NappletHostContract { */ const val EXTRA_WEBSITE_MODE = "napplet_website_mode" + /** + * Whether this site's traffic routes through Tor (true, the default when Tor is active) or over the + * open web (false). The main process resolves the per-site preference; the sandbox applies it to + * both the WebView proxy and the blob-fetch client. Always true for locked napplets. + */ + const val EXTRA_USE_TOR = "napplet_use_tor" + /** * FQN of the main-process broker service (in `:amethyst`). The sandbox binds it by name so it * needs no compile-time reference to `:amethyst`. Must match the manifest `` declaration. diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletIpc.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletIpc.kt index f4e4e98439..acf1e115bb 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletIpc.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletIpc.kt @@ -40,9 +40,19 @@ object NappletIpc { */ const val MSG_PUSH = 3 + /** + * Host → broker: persist this nSite's network-routing choice (Tor vs open web). Carries + * [KEY_LAUNCH_TOKEN] (so the broker resolves the trusted coordinate) and [KEY_NETWORK_USE_TOR]. + * The sandbox never persists anything itself; the main process owns the preference. + */ + const val MSG_SET_NETWORK_MODE = 4 + const val KEY_REQUEST_ID = "requestId" const val KEY_PAYLOAD = "payload" + /** Boolean: route this site through Tor (true) or over the open web (false). */ + const val KEY_NETWORK_USE_TOR = "networkUseTor" + /** * Opaque per-launch token. The sandbox cannot be trusted to state its own identity, so it sends * only this token; the broker resolves it through [NappletLaunchRegistry] (main process) back to diff --git a/nappletHost/src/main/res/values/strings.xml b/nappletHost/src/main/res/values/strings.xml index efe528789d..fb51769ca8 100644 --- a/nappletHost/src/main/res/values/strings.xml +++ b/nappletHost/src/main/res/values/strings.xml @@ -14,6 +14,16 @@ “%1$s” uploaded a file “%1$s” made a payment + + This site loads over Tor. Tap to change. + This site loads over the open web. Tap to change. + “%1$s” loads over Tor + This site\'s traffic is routed through Tor, so it can\'t see your IP address. Some sites are slow or broken over Tor — you can switch this site to the open web. Your choice is remembered for this site. + “%1$s” loads over the open web + This site loads directly, so it (and the servers it contacts) can see your IP address. Switch it back to Tor to keep your IP private. Your choice is remembered for this site. + Use open web + Use Tor + Couldn\'t load “%1$s” The publisher\'s servers may be offline, or you\'re not connected. You can try again.