feat(nip46): activity feed + account clarity on the signer screen (Tier 2)

Give the user visibility into what the signer is doing:

- Nip46ActivityLog: a bounded, newest-first, in-memory feed of serviced
  requests (method + kind + client + ok/denied), fed from the service's
  onServiced hook (enriched to pass the full BunkerRequest so the event kind
  is available). Survives service restarts; not persisted (it's a live feed).
- The signer screen shows a "Recent activity" card (last 8, friendly labels
  like "Signed an event (kind 1)", green/red status dot, relative time) and a
  "Signing as npub1…" line so it's clear which account is the bunker.

onServiced now hands callers the BunkerRequest instead of just the method
string (CLI updated to match).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
This commit is contained in:
Claude
2026-07-17 15:18:28 +00:00
parent 4676d176ec
commit f9d9691017
6 changed files with 197 additions and 7 deletions
@@ -0,0 +1,59 @@
/*
* 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.model.nip46Signer
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
/** One serviced NIP-46 request, for the "recent activity" feed. */
data class Nip46ActivityEntry(
val atSeconds: Long,
val clientPubKey: HexKey,
/** The NIP-46 method (`sign_event`, `nip44_encrypt`, `get_public_key`, …). */
val method: String,
/** Event kind for a `sign_event`, else `null`. */
val kind: Int? = null,
/** `null` when the request succeeded; the error string when it failed or was denied. */
val error: String? = null,
) {
val ok: Boolean get() = error == null
}
/**
* A bounded, newest-first, in-memory log of the requests this account's signer has serviced, so the
* user can see what apps are actually doing. Not persisted across app restarts (it is a live feed,
* not an audit trail); it survives service restarts because it lives on the account's signer state.
*/
class Nip46ActivityLog(
private val capacity: Int = 100,
) {
private val _entries = MutableStateFlow<List<Nip46ActivityEntry>>(emptyList())
val entries: StateFlow<List<Nip46ActivityEntry>> = _entries
fun record(entry: Nip46ActivityEntry) {
_entries.update { (listOf(entry) + it).take(capacity) }
}
/** The most recent entries for one client (newest first). */
fun forClient(clientPubKey: HexKey): List<Nip46ActivityEntry> = _entries.value.filter { it.clientPubKey == clientPubKey }
}
@@ -35,6 +35,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestSign
import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponse
import com.vitorpamplona.quartz.nip46RemoteSigner.NostrConnectEvent
import com.vitorpamplona.quartz.nip46RemoteSigner.NostrConnectURI
@@ -42,6 +43,7 @@ import com.vitorpamplona.quartz.nip46RemoteSigner.server.BunkerRequestProcessor
import com.vitorpamplona.quartz.nip46RemoteSigner.server.NostrConnectSignerService
import com.vitorpamplona.quartz.utils.Log
import com.vitorpamplona.quartz.utils.RandomInstance
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -86,6 +88,9 @@ class Nip46SignerState(
/** Relays contributed by pasted `nostrconnect://` offers this session, unioned with the inbox set. */
private val extraRelays = MutableStateFlow<Set<NormalizedRelayUrl>>(emptySet())
/** Newest-first, in-memory feed of serviced requests, so the UI can show what apps are doing. */
val activityLog = Nip46ActivityLog()
/**
* The dedicated per-account transport signer that wraps the kind-24133 envelope — a local key
* unrelated to the account identity, so the bunker address/traffic doesn't reveal who it is for,
@@ -167,8 +172,17 @@ class Nip46SignerState(
transportSigner = transportSigner(),
processor = processor,
relays = relays,
onServiced = { method, clientPubKey, error ->
Log.d("NIP46Signer") { "$method from ${clientPubKey.take(8)}… → ${error ?: "ok"}" }
onServiced = { request, clientPubKey, error ->
Log.d("NIP46Signer") { "${request.method} from ${clientPubKey.take(8)}… → ${error ?: "ok"}" }
activityLog.record(
Nip46ActivityEntry(
atSeconds = TimeUtils.now(),
clientPubKey = clientPubKey,
method = request.method,
kind = (request as? BunkerRequestSign)?.event?.kind,
error = error,
),
)
},
)
service.run()
@@ -86,13 +86,16 @@ import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.connectedApps.nip46.Nip46PermissionAuthorizer
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.model.nip46Signer.Nip46ActivityEntry
import com.vitorpamplona.amethyst.model.nip46Signer.Nip46SignerState
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton
import com.vitorpamplona.amethyst.ui.note.elements.TimeAgo
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.QrCodeDrawer
import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.SimpleQrCodeScanner
import com.vitorpamplona.quartz.nip19Bech32.entities.NPub
import kotlinx.coroutines.launch
private val LiveGreen = Color(0xFF3DDC84)
@@ -112,7 +115,9 @@ fun Nip46SignerScreen(
val enabled by account.settings.nip46SignerEnabled.collectAsStateWithLifecycle()
val secret by account.settings.nip46BunkerSecret.collectAsStateWithLifecycle()
val relays by signer.listeningRelays.collectAsStateWithLifecycle()
val activity by signer.activityLog.entries.collectAsStateWithLifecycle()
val writeable = remember { account.signer.isWriteable() }
val npub = remember { NPub.create(account.signer.pubKey) }
var connectedCount by remember { mutableIntStateOf(0) }
var refreshKey by remember { mutableIntStateOf(0) }
@@ -175,6 +180,8 @@ fun Nip46SignerScreen(
onToggleOff = { signer.setEnabled(false) },
)
SigningAsLine(npub)
if (relays.isEmpty()) {
WarningCard(stringResource(R.string.nip46_signer_status_no_relays))
}
@@ -207,6 +214,10 @@ fun Nip46SignerScreen(
)
}
if (enabled && activity.isNotEmpty()) {
ActivitySection(activity)
}
if (enabled) {
Text(
stringResource(R.string.nip46_signer_background_hint),
@@ -524,6 +535,100 @@ private fun ConnectedAppsRow(
}
}
@Composable
private fun SigningAsLine(npub: String) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
MaterialSymbols.Key,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(15.dp),
)
Text(
stringResource(R.string.nip46_signer_signing_as, npub.take(16) + ""),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontFamily = FontFamily.Monospace,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
@Composable
private fun ActivitySection(entries: List<Nip46ActivityEntry>) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
stringResource(R.string.nip46_signer_activity_title),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
)
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
) {
Column(modifier = Modifier.padding(vertical = 4.dp)) {
entries.take(8).forEach { entry ->
ActivityRow(entry)
}
}
}
}
}
@Composable
private fun ActivityRow(entry: Nip46ActivityEntry) {
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
Box(
modifier =
Modifier
.size(8.dp)
.clip(CircleShape)
.background(if (entry.ok) LiveGreen else MaterialTheme.colorScheme.error),
)
Column(modifier = Modifier.weight(1f)) {
Text(
describeNip46Activity(entry),
style = MaterialTheme.typography.bodyMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
entry.clientPubKey.take(12) + "",
style = MaterialTheme.typography.labelSmall,
fontFamily = FontFamily.Monospace,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
TimeAgo(entry.atSeconds)
}
}
@Composable
private fun describeNip46Activity(entry: Nip46ActivityEntry): String {
val base =
when (entry.method) {
"sign_event" -> stringResource(R.string.nip46_signer_act_signed_kind, entry.kind ?: 0)
"nip04_encrypt", "nip44_encrypt" -> stringResource(R.string.nip46_signer_act_encrypted)
"nip04_decrypt", "nip44_decrypt" -> stringResource(R.string.nip46_signer_act_decrypted)
"get_public_key" -> stringResource(R.string.nip46_signer_act_shared_pubkey)
"connect" -> stringResource(R.string.nip46_signer_act_connected)
"ping" -> stringResource(R.string.nip46_signer_act_ping)
"get_relays" -> stringResource(R.string.nip46_signer_act_listed_relays)
else -> stringResource(R.string.nip46_signer_act_other, entry.method)
}
return if (entry.ok) base else "$base · ${stringResource(R.string.nip46_signer_activity_denied)}"
}
@Composable
private fun WarningCard(message: String) {
Card(
+12
View File
@@ -934,6 +934,18 @@
<string name="nip46_signer_hero_title">Sign for other apps</string>
<string name="nip46_signer_turn_on">Turn on signer</string>
<string name="nip46_signer_live">Live</string>
<string name="nip46_signer_signing_as">Signing as %1$s</string>
<string name="nip46_signer_activity_title">Recent activity</string>
<string name="nip46_signer_activity_empty">No requests serviced yet.</string>
<string name="nip46_signer_activity_denied">denied</string>
<string name="nip46_signer_act_signed_kind">Signed an event (kind %1$d)</string>
<string name="nip46_signer_act_encrypted">Encrypted a message</string>
<string name="nip46_signer_act_decrypted">Decrypted a message</string>
<string name="nip46_signer_act_shared_pubkey">Shared your public key</string>
<string name="nip46_signer_act_connected">Connected</string>
<string name="nip46_signer_act_ping">Ping</string>
<string name="nip46_signer_act_listed_relays">Listed relays</string>
<string name="nip46_signer_act_other">%1$s</string>
<string name="nip46_signer_scan_caption">Scan to connect an app to your key</string>
<string name="nip46_signer_scan_connect">Scan a code</string>
<string name="nip46_signer_paste_link">Paste a link instead</string>
@@ -203,9 +203,9 @@ object BunkerCommand {
transportSigner = ctx.signer,
processor = processor,
relays = relays,
onServiced = { method, client, error ->
onServiced = { request, client, error ->
val outcome = if (error != null) "error: $error" else "ok"
System.err.println("[bunker] $method from ${client.take(8)}… → $outcome")
System.err.println("[bunker] ${request.method} from ${client.take(8)}… → $outcome")
},
)
@@ -63,8 +63,8 @@ class NostrConnectSignerService(
val transportSigner: NostrSigner,
val processor: BunkerRequestProcessor,
val relays: Set<NormalizedRelayUrl>,
/** Optional hook, invoked with each serviced request's method + client, for logging/metrics. */
val onServiced: ((method: String, clientPubKey: String, error: String?) -> Unit)? = null,
/** Optional hook, invoked with each serviced request + client, for logging/metrics/activity feeds. */
val onServiced: ((request: BunkerRequest, clientPubKey: String, error: String?) -> Unit)? = null,
/**
* Upper bound on the request-id dedup set. A long-lived signer would otherwise
* accumulate every request id it ever saw; past this many, the oldest ids are
@@ -198,7 +198,7 @@ class NostrConnectSignerService(
val response = processor.process(client, request)
val error = (response as? BunkerResponseError)?.error
onServiced?.invoke(request.method, client, error)
onServiced?.invoke(request, client, error)
try {
val reply = NostrConnectEvent.create(response, client, transportSigner)