mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-11 08:47:33 +00:00
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:
+59
@@ -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 }
|
||||
}
|
||||
+16
-2
@@ -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()
|
||||
|
||||
+105
@@ -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(
|
||||
|
||||
@@ -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")
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
+3
-3
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user