mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-08 23:54:39 +00:00
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:
@@ -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) {
|
||||
|
||||
+17
@@ -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 ""
|
||||
}
|
||||
|
||||
+5
@@ -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. */
|
||||
|
||||
+6
@@ -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()
|
||||
|
||||
+5
@@ -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"))
|
||||
}
|
||||
}
|
||||
|
||||
+19
@@ -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 {
|
||||
|
||||
+11
@@ -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 {
|
||||
|
||||
+14
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user