diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip46Signer/Nip46ActivityLog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip46Signer/Nip46ActivityLog.kt new file mode 100644 index 0000000000..0cbc9b2a14 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip46Signer/Nip46ActivityLog.kt @@ -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>(emptyList()) + val entries: StateFlow> = _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 = _entries.value.filter { it.clientPubKey == clientPubKey } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip46Signer/Nip46SignerState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip46Signer/Nip46SignerState.kt index 2398990996..bc33625974 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip46Signer/Nip46SignerState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip46Signer/Nip46SignerState.kt @@ -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>(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() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/nip46/Nip46SignerScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/nip46/Nip46SignerScreen.kt index 293ad1d25e..d0a4e70b84 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/nip46/Nip46SignerScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/nip46/Nip46SignerScreen.kt @@ -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) { + 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( diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 7ef6f6d57b..e96c045cc4 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -934,6 +934,18 @@ Sign for other apps Turn on signer Live + Signing as %1$s + Recent activity + No requests serviced yet. + denied + Signed an event (kind %1$d) + Encrypted a message + Decrypted a message + Shared your public key + Connected + Ping + Listed relays + %1$s Scan to connect an app to your key Scan a code Paste a link instead diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/BunkerCommand.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/BunkerCommand.kt index e276320377..d0b19cc38b 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/BunkerCommand.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/BunkerCommand.kt @@ -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") }, ) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/server/NostrConnectSignerService.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/server/NostrConnectSignerService.kt index 7a7c8d3c2e..81f394a413 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/server/NostrConnectSignerService.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/server/NostrConnectSignerService.kt @@ -63,8 +63,8 @@ class NostrConnectSignerService( val transportSigner: NostrSigner, val processor: BunkerRequestProcessor, val relays: Set, - /** 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)