Merge pull request #3426 from vitorpamplona/claude/connected-apps-permissions-bqion9

Add "Manage permissions" UI for Connected Apps detail screen
This commit is contained in:
Vitor Pamplona
2026-06-30 08:49:04 -04:00
committed by GitHub
12 changed files with 170 additions and 0 deletions
@@ -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:<origin>`). 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,
@@ -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:<origin>` 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) {
@@ -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:<origin>`
// (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:<origin>` 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 ""
}
@@ -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. */
@@ -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()
@@ -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"
@@ -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"))
}
}
@@ -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:<origin>`), 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 {
@@ -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 {
@@ -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) }
@@ -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:<origin>`). 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"
@@ -10,6 +10,7 @@
<string name="napplet_chrome_keys_safe">It can never read your keys, and every sign, publish, upload, or payment was approved by you. Manage access in Settings ▸ nApplets.</string>
<string name="napplet_chrome_static_site">Static site — it has no special access to your account.</string>
<string name="napplet_chrome_permissions_desc">What this app can access</string>
<string name="napplet_chrome_manage_permissions">Manage permissions</string>
<string name="napplet_chrome_reload">Reload</string>
<!-- Browser address bar and developer console strings are in :commons -->
<string name="napplet_action_published">“%1$s” published a note as you</string>