From 58b3e8418440859ae48535eaff69f2c0316e1202 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 30 Jun 2026 01:59:00 +0000 Subject: [PATCH] feat(napplet): add "Manage permissions" to app pull-down sheets Add a direct link to each running app's editable Connected Apps permission-detail screen from its top pull-down sheet, so users can change the trust level and per-capability grants as they navigate. Covers every top pull-down rendering: - Embedded napplet/nsite and web-app tabs (Compose TopControlSheet), keyed by the napplet `pubkey:dtag` coordinate or the web client's `browser:`. - Full-screen sandbox host and direct-browser activities (native NappletControlSheet), via a new MSG_OPEN_PERMISSIONS IPC: the host (which can't state its own coordinate) sends its launch token or visited origin, and the main-process broker resolves the trusted coordinate and opens MainActivity through a `connectedapp?coordinate=` deep link added to uriToRoute. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_019dXhUL8To3qVXJVBZGCmhU --- .../amethyst/napplet/NappletBrokerService.kt | 31 ++++++++++++++++++ .../vitorpamplona/amethyst/ui/MainActivity.kt | 20 ++++++++++++ .../screen/loggedIn/browser/WebAppScreen.kt | 17 ++++++++++ .../loggedIn/embed/EmbeddedTabChrome.kt | 5 +++ .../screen/loggedIn/embed/TopControlSheet.kt | 6 ++++ .../loggedIn/favorites/NostrAppScreen.kt | 5 +++ .../vitorpamplona/amethyst/URIParserTest.kt | 32 +++++++++++++++++++ .../napplethost/NappletBrowserActivity.kt | 19 +++++++++++ .../napplethost/NappletControlSheet.kt | 11 +++++++ .../napplethost/NappletHostActivity.kt | 14 ++++++++ .../amethyst/napplethost/NappletIpc.kt | 9 ++++++ nappletHost/src/main/res/values/strings.xml | 1 + 12 files changed, 170 insertions(+) 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 fc282ec8e8..59f276401d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletBrokerService.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletBrokerService.kt @@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.napplet import android.app.Service import android.content.Intent +import android.net.Uri import android.os.Bundle import android.os.Handler import android.os.IBinder @@ -47,6 +48,7 @@ import com.vitorpamplona.amethyst.favorites.FavoriteAppsRegistry import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.napplet.gateways.AccountNappletGateways import com.vitorpamplona.amethyst.napplethost.NappletIpc +import com.vitorpamplona.amethyst.ui.MainActivity import com.vitorpamplona.amethyst.ui.screen.AccountState import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -216,6 +218,20 @@ class NappletBrokerService : Service() { return true } + // A running full-screen sandbox surface asks to open its editable permission screen. The sandbox + // can't state its own coordinate, so a napplet/nsite sends its launch token (resolved here to the + // trusted coordinate) and a browser sends its visited origin (keyed as `browser:`). Open + // the main activity at that Connected Apps detail. + if (msg.what == NappletIpc.MSG_OPEN_PERMISSIONS) { + val data = msg.data ?: return true + val coordinate = + data.getString(NappletIpc.KEY_LAUNCH_TOKEN)?.let { NappletLaunchRegistry.resolve(it)?.identity?.coordinate } + ?: data.getString(NappletIpc.KEY_BROWSER_ORIGIN)?.takeIf { it.isNotBlank() }?.let { "browser:$it" } + ?: return true + openConnectedAppDetail(coordinate) + return true + } + // Browser mode mints a fresh launch token per visited origin, so NIP-07 consent is scoped to the // one site the request came from. The origin is the trusted source origin the WebView reported // (the sandbox can't forge it), and the synthetic identity keys the permission ledger per host. @@ -334,6 +350,21 @@ class NappletBrokerService : Service() { return broker } + /** + * Brings the main activity (a `singleInstance`) forward at the Connected Apps detail for [coordinate], + * via the in-process `connectedapp?coordinate=` deep link that [com.vitorpamplona.amethyst.ui.uriToRoute] + * resolves. Used when a full-screen sandbox surface taps "Manage permissions". + */ + private fun openConnectedAppDetail(coordinate: String) { + val intent = + Intent(applicationContext, MainActivity::class.java).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + data = Uri.parse("nostr:connectedapp?coordinate=" + Uri.encode(coordinate)) + } + runCatching { applicationContext.startActivity(intent) } + .onFailure { Log.w("NappletBrokerService", "Could not open Connected Apps detail", it) } + } + private fun reply( replyTo: Messenger, requestId: String, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt index edf9d17fbf..eeef549f91 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt @@ -151,6 +151,23 @@ fun isHashtagRoute(uri: String) = uri.startsWith("hashtag?id=") || uri.startsWit fun isUrlRoute(uri: String) = uri.startsWith("url?id=") || uri.startsWith("nostr:url?id=") +fun isConnectedAppRoute(uri: String) = uri.startsWith("connectedapp?coordinate=") || uri.startsWith("nostr:connectedapp?coordinate=") + +/** + * The Connected Apps permission-detail route for an app coordinate (`pubkey:dtag` for a napplet/nsite, + * `browser:` for a web client). Fired by the sandbox host so a running full-screen surface can + * jump straight to its editable permissions; the coordinate is URL-encoded into the [coordinate] param. + */ +fun connectedAppRoute(uri: String): Route.ConnectedAppDetail? { + val coordinate = + runCatching { + val raw = java.net.URI(uri.removePrefix("nostr:")).findParameterValue("coordinate") ?: return null + URLDecoder.decode(raw, Charsets.UTF_8.name()) + }.getOrNull()?.takeIf { it.isNotBlank() } ?: return null + + return Route.ConnectedAppDetail(coordinate) +} + fun urlRoute(uri: String): Route.Url? { val url = runCatching { @@ -181,6 +198,9 @@ fun uriToRoute( if (isUrlRoute(uri)) { return urlRoute(uri) } + if (isConnectedAppRoute(uri)) { + return connectedAppRoute(uri) + } val nip19 = Nip19Parser.uriToRoute(uri)?.entity if (nip19 != null) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/WebAppScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/WebAppScreen.kt index 0699770886..9b903b4b33 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/WebAppScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/WebAppScreen.kt @@ -151,6 +151,12 @@ private fun EmbeddedWebAppTab( FavoriteAppsRegistry.add(FavoriteApp.WebApp(currentUrl, hostLabel(currentUrl), System.currentTimeMillis())) } }, + // NIP-07 grants for a plain web client are keyed per visited origin as `browser:` + // (see NappletBrokerService.BROWSER_IDENTITY_AUTHOR); jump straight to that detail screen. + onPermissions = + browserOrigin(currentUrl)?.let { origin -> + { nav.nav(Route.ConnectedAppDetail("browser:$origin")) } + }, ) } // Publish the top-sheet controls to the tab layer (which draws them over the z-below surface). In a @@ -188,3 +194,14 @@ private fun EmbeddedWebAppTab( /** The host of [url] for the tab title, falling back to the raw string. */ internal fun hostLabel(url: String): String = runCatching { Uri.parse(url).host }.getOrNull()?.takeIf { it.isNotBlank() } ?: url + +/** + * The `scheme://host[:port]` origin of [url] — the exact form the sandbox reports for NIP-07 consent, so + * it matches the `browser:` permission-ledger key. Null when [url] has no usable scheme/host. + */ +internal fun browserOrigin(url: String): String? { + val uri = runCatching { Uri.parse(url) }.getOrNull() ?: return null + val scheme = uri.scheme?.takeIf { it.isNotBlank() } ?: return null + val host = uri.host?.takeIf { it.isNotBlank() } ?: return null + return "$scheme://$host" + if (uri.port > 0) ":${uri.port}" else "" +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedTabChrome.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedTabChrome.kt index 42ef553923..fc28e0cf7d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedTabChrome.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedTabChrome.kt @@ -37,6 +37,11 @@ data class EmbeddedTabChrome( val onToggleTor: () -> Unit = {}, /** The "what it can access" sheet, for sandboxed napplets/nsites; null for a plain web client. */ val onInfo: (() -> Unit)? = null, + /** + * Opens this app's editable permission screen (the "Connected Apps" detail) so the user can change + * trust level and per-capability grants as they browse; null when the surface has no managed identity. + */ + val onPermissions: (() -> Unit)? = null, /** Whether the current URL/app is already saved as a favorite. */ val isFavorite: Boolean = false, /** Toggles the current site/app in the favorites registry; null when not applicable. */ diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/TopControlSheet.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/TopControlSheet.kt index aff8d5fedb..a683b6abdd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/TopControlSheet.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/TopControlSheet.kt @@ -129,6 +129,12 @@ fun TopControlSheet( info() } } + chrome.onPermissions?.let { openPermissions -> + SheetItem(MaterialSymbols.Tune, stringResource(R.string.napplet_manage_permissions)) { + onExpandedChange(false) + openPermissions() + } + } SheetItem(MaterialSymbols.OpenInFull, stringResource(R.string.favorite_app_open_window)) { onExpandedChange(false) chrome.onOpenFull() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/favorites/NostrAppScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/favorites/NostrAppScreen.kt index 9fe92ee7a9..ff44b1b874 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/favorites/NostrAppScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/favorites/NostrAppScreen.kt @@ -146,6 +146,10 @@ private fun EmbeddedNostrAppTab( } } + // The permission-ledger key is the addressable coordinate without its kind prefix (`pubkey:dtag`), + // matching how the Connected Apps screen keys napplet/nsite grants (see NappletIdentity.coordinate). + val permissionCoordinate = remember(coordinate) { coordinate.substringAfter(':') } + // Stable per app (title/coordinate/isFavorite don't change often), so the tab layer isn't recomposed every frame. val chrome = remember(title, coordinate, isFavorite, controller) { @@ -155,6 +159,7 @@ private fun EmbeddedNostrAppTab( onReload = { controller.reload() }, onOpenFull = { FavoriteAppLauncher.launch(context, FavoriteApp.NostrApp(coordinate, title, System.currentTimeMillis())) }, onInfo = { showAccess = true }, + onPermissions = { nav.nav(Route.ConnectedAppDetail(permissionCoordinate)) }, isFavorite = isFavorite, onFavorite = { val favId = "nostr:$coordinate" diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/URIParserTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/URIParserTest.kt index 3a7efcf7e9..3fa498a868 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/URIParserTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/URIParserTest.kt @@ -20,10 +20,14 @@ */ package com.vitorpamplona.amethyst +import com.vitorpamplona.amethyst.ui.connectedAppRoute +import com.vitorpamplona.amethyst.ui.isConnectedAppRoute import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.urlRoute import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue import org.junit.Test import java.net.URLEncoder @@ -40,4 +44,32 @@ class URIParserTest { fun ignoresMalformedEncodedUrlRoutes() { assertNull(urlRoute("nostr:url?id=%")) } + + @Test + fun parsesNappletConnectedAppRoute() { + val coordinate = "abc123:my-app" + val route = connectedAppRoute("nostr:connectedapp?coordinate=${URLEncoder.encode(coordinate, Charsets.UTF_8.name())}") + + assertEquals(Route.ConnectedAppDetail(coordinate), route) + } + + @Test + fun parsesBrowserConnectedAppRoute() { + val coordinate = "browser:https://example.com" + val route = connectedAppRoute("nostr:connectedapp?coordinate=${URLEncoder.encode(coordinate, Charsets.UTF_8.name())}") + + assertEquals(Route.ConnectedAppDetail(coordinate), route) + } + + @Test + fun ignoresBlankConnectedAppCoordinate() { + assertNull(connectedAppRoute("nostr:connectedapp?coordinate=")) + } + + @Test + fun recognizesConnectedAppRoutePrefixes() { + assertTrue(isConnectedAppRoute("connectedapp?coordinate=abc")) + assertTrue(isConnectedAppRoute("nostr:connectedapp?coordinate=abc")) + assertFalse(isConnectedAppRoute("nostr:url?id=abc")) + } } diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserActivity.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserActivity.kt index c79b04f2b7..9088b7f8b7 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserActivity.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserActivity.kt @@ -629,6 +629,7 @@ class NappletBrowserActivity : ComponentActivity() { torInitiallyOn = if (proxyPort > 0) useTor else null, onToggleTor = { setNetworkMode(it) }, onInfo = null, + onPermissions = { openPermissions() }, liveUrl = startUrl, onNavigate = { loadAddress(it) }, onConsole = { show -> consolePanel?.setShowing(show) }, @@ -636,6 +637,24 @@ class NappletBrowserActivity : ComponentActivity() { onFavoriteToggle = { url, _ -> sendFavoriteToggle(url) }, ).also { controlSheet = it } + /** + * Ask the broker to open the editable permission screen for the site currently displayed. NIP-07 grants + * for a plain browser are keyed per visited origin (`browser:`), so we send the live origin and + * the broker launches the main activity at that Connected Apps detail. + */ + private fun openPermissions() { + val liveUrl = if (this::webView.isInitialized) webView.url ?: startUrl else startUrl + val uri = runCatching { Uri.parse(liveUrl) }.getOrNull() ?: return + val scheme = uri.scheme?.takeIf { it.isNotBlank() } ?: return + val host = uri.host?.takeIf { it.isNotBlank() } ?: return + val origin = "$scheme://$host" + if (uri.port > 0) ":${uri.port}" else "" + val msg = + Message.obtain(null, NappletIpc.MSG_OPEN_PERMISSIONS).apply { + data = Bundle().apply { putString(NappletIpc.KEY_BROWSER_ORIGIN, origin) } + } + if (brokerMessenger != null) sendToBroker(msg) else pendingBrokerRequests.add(msg) + } + private fun sendFavoriteToggle(url: String) { val host = runCatching { diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletControlSheet.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletControlSheet.kt index 605909c7a6..28539b2af2 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletControlSheet.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletControlSheet.kt @@ -61,6 +61,9 @@ class NappletControlSheet( // toggling inline — used by the nSite host, where switching routing rebuilds the whole session. private val onNetworkTap: (() -> Unit)? = null, private val onInfo: (() -> Unit)? = null, + // When non-null, a "Manage permissions" row is added that taps through to this — used to open the + // main process's editable Connected Apps detail screen for this surface. + private val onPermissions: (() -> Unit)? = null, // The live URL of a plain-website browser. Non-null only for the direct-WebView browser (never an // nsite/napplet), where it renders an editable address row; [onNavigate] loads what the user types. liveUrl: String? = null, @@ -131,6 +134,14 @@ class NappletControlSheet( }, ) } + onPermissions?.let { manage -> + addView( + actionRow("⚙", context.getString(R.string.napplet_chrome_manage_permissions)) { + collapse() + manage() + }, + ) + } onConsole?.let { val label = TextView(context).apply { 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 3a486e9407..fd8eda6617 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostActivity.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostActivity.kt @@ -838,9 +838,23 @@ class NappletHostActivity : ComponentActivity() { torInitiallyOn = if (profile.exposesNetwork && proxyPort > 0) useTor else null, onNetworkTap = if (profile.exposesNetwork && proxyPort > 0) ({ setNetworkMode(!useTor) }) else null, onInfo = { showAccessDialog() }, + onPermissions = { openPermissions() }, onConsole = { show -> consolePanel?.setShowing(show) }, ).also { controlSheet = it } + /** + * Ask the broker to open this napplet's editable permission screen. The sandbox can't state its own + * coordinate, so we send only the launch token; the broker resolves it to the trusted coordinate and + * launches the main activity at the Connected Apps detail. + */ + private fun openPermissions() { + val msg = + Message.obtain(null, NappletIpc.MSG_OPEN_PERMISSIONS).apply { + data = Bundle().apply { putString(NappletIpc.KEY_LAUNCH_TOKEN, launchToken) } + } + if (brokerMessenger != null) sendToBroker(msg) + } + private fun buildConsolePanel(): View = NappletConsolePanel(this).also { it.onClearCallback = { controlSheet?.updateConsoleCount(0) } 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 d740e5a03a..9f72f7c471 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletIpc.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletIpc.kt @@ -97,6 +97,15 @@ object NappletIpc { */ const val MSG_TOGGLE_WEB_FAVORITE = 11 + /** + * Host → broker: open this surface's editable permission screen (the main-process "Connected Apps" + * detail) so the user can change its trust level / grants while it's running. A sandbox napplet/nsite + * carries [KEY_LAUNCH_TOKEN] (the broker resolves it to the trusted coordinate, since the sandbox + * can't state its own); a browser carries [KEY_BROWSER_ORIGIN] (keyed as `browser:`). The + * broker launches the main activity at that coordinate. Fire-and-forget; no reply needed. + */ + const val MSG_OPEN_PERMISSIONS = 12 + const val KEY_REQUEST_ID = "requestId" const val KEY_PAYLOAD = "payload" diff --git a/nappletHost/src/main/res/values/strings.xml b/nappletHost/src/main/res/values/strings.xml index c5f8ed7576..34c48f60bf 100644 --- a/nappletHost/src/main/res/values/strings.xml +++ b/nappletHost/src/main/res/values/strings.xml @@ -10,6 +10,7 @@ It can never read your keys, and every sign, publish, upload, or payment was approved by you. Manage access in Settings ▸ nApplets. Static site — it has no special access to your account. What this app can access + Manage permissions Reload “%1$s” published a note as you