feat(desktop): add bunker heartbeat indicator to sidebar navigation
Pulsating green heart icon shows NIP-46 signer connection status in both DeckSidebar and SinglePaneLayout NavigationRail. Tooltip displays last ping time. SignerConnectionState moved to commons for KMP sharing. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
+29
@@ -0,0 +1,29 @@
|
||||
/*
|
||||
* 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.commons.domain.nip46
|
||||
|
||||
sealed class SignerConnectionState {
|
||||
data object NotRemote : SignerConnectionState()
|
||||
|
||||
data object Connected : SignerConnectionState()
|
||||
|
||||
data object Disconnected : SignerConnectionState()
|
||||
}
|
||||
+117
@@ -0,0 +1,117 @@
|
||||
/*
|
||||
* 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.commons.ui.components
|
||||
|
||||
import androidx.compose.animation.core.RepeatMode
|
||||
import androidx.compose.animation.core.animateFloat
|
||||
import androidx.compose.animation.core.infiniteRepeatable
|
||||
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Favorite
|
||||
import androidx.compose.material.icons.filled.FavoriteBorder
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.PlainTooltip
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TooltipBox
|
||||
import androidx.compose.material3.TooltipDefaults
|
||||
import androidx.compose.material3.rememberTooltipState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.scale
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.commons.domain.nip46.SignerConnectionState
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun BunkerHeartbeatIndicator(
|
||||
signerConnectionState: SignerConnectionState,
|
||||
lastPingTimeSec: Long?,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
if (signerConnectionState is SignerConnectionState.NotRemote) return
|
||||
|
||||
val tooltipState = rememberTooltipState()
|
||||
val tooltipText =
|
||||
when (signerConnectionState) {
|
||||
is SignerConnectionState.Connected -> {
|
||||
if (lastPingTimeSec != null) {
|
||||
val agoSeconds = TimeUtils.now() - lastPingTimeSec
|
||||
"Bunker connected \u2014 last pinged ${agoSeconds}s ago"
|
||||
} else {
|
||||
"Bunker connected"
|
||||
}
|
||||
}
|
||||
|
||||
is SignerConnectionState.Disconnected -> {
|
||||
"Bunker disconnected"
|
||||
}
|
||||
|
||||
else -> {
|
||||
""
|
||||
}
|
||||
}
|
||||
|
||||
TooltipBox(
|
||||
positionProvider = TooltipDefaults.rememberTooltipPositionProvider(),
|
||||
tooltip = { PlainTooltip { Text(tooltipText) } },
|
||||
state = tooltipState,
|
||||
modifier = modifier,
|
||||
) {
|
||||
when (signerConnectionState) {
|
||||
is SignerConnectionState.Connected -> {
|
||||
val infiniteTransition = rememberInfiniteTransition(label = "heartbeat")
|
||||
val scale by infiniteTransition.animateFloat(
|
||||
initialValue = 0.85f,
|
||||
targetValue = 1.15f,
|
||||
animationSpec =
|
||||
infiniteRepeatable(
|
||||
animation = tween(800),
|
||||
repeatMode = RepeatMode.Reverse,
|
||||
),
|
||||
label = "heartbeatScale",
|
||||
)
|
||||
Icon(
|
||||
Icons.Default.Favorite,
|
||||
contentDescription = "Bunker connected",
|
||||
tint = Color(0xFF4CAF50),
|
||||
modifier = Modifier.size(20.dp).scale(scale),
|
||||
)
|
||||
}
|
||||
|
||||
is SignerConnectionState.Disconnected -> {
|
||||
Icon(
|
||||
Icons.Default.FavoriteBorder,
|
||||
contentDescription = "Bunker disconnected",
|
||||
tint = Color(0xFFF44336),
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
}
|
||||
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -557,6 +557,8 @@ fun MainContent(
|
||||
) {
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
val scope = rememberCoroutineScope()
|
||||
val signerConnectionState by accountManager.signerConnectionState.collectAsState()
|
||||
val lastPingTimeSec by accountManager.lastPingTimeSec.collectAsState()
|
||||
|
||||
// DM infrastructure — hoisted here so it survives screen navigation
|
||||
val dmSendTracker =
|
||||
@@ -678,6 +680,8 @@ fun MainContent(
|
||||
onShowComposeDialog = onShowComposeDialog,
|
||||
onShowReplyDialog = onShowReplyDialog,
|
||||
onZapFeedback = onZapFeedback,
|
||||
signerConnectionState = signerConnectionState,
|
||||
lastPingTimeSec = lastPingTimeSec,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
@@ -692,6 +696,8 @@ fun MainContent(
|
||||
deckState.addColumn(DeckColumnType.Settings)
|
||||
}
|
||||
},
|
||||
signerConnectionState = signerConnectionState,
|
||||
lastPingTimeSec = lastPingTimeSec,
|
||||
)
|
||||
|
||||
VerticalDivider()
|
||||
|
||||
+35
-181
@@ -21,22 +21,21 @@
|
||||
package com.vitorpamplona.amethyst.desktop.account
|
||||
|
||||
import androidx.compose.runtime.Stable
|
||||
import com.vitorpamplona.amethyst.commons.domain.nip46.BunkerLoginUseCase
|
||||
import com.vitorpamplona.amethyst.commons.domain.nip46.NostrConnectLoginUseCase
|
||||
import com.vitorpamplona.amethyst.commons.domain.nip46.SignerConnectionState
|
||||
import com.vitorpamplona.amethyst.commons.keystorage.SecureKeyStorage
|
||||
import com.vitorpamplona.amethyst.commons.keystorage.SecureStorageException
|
||||
import com.vitorpamplona.amethyst.desktop.network.DesktopHttpClient
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper
|
||||
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
|
||||
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.IRelayClientListener
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.req
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.Command
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.EventCmd
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.sockets.okhttp.BasicOkHttpWebSocket
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||
@@ -46,17 +45,9 @@ import com.vitorpamplona.quartz.nip19Bech32.decodePrivateKeyAsHexOrNull
|
||||
import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull
|
||||
import com.vitorpamplona.quartz.nip19Bech32.toNpub
|
||||
import com.vitorpamplona.quartz.nip19Bech32.toNsec
|
||||
import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerMessage
|
||||
import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequest
|
||||
import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestConnect
|
||||
import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponse
|
||||
import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponseAck
|
||||
import com.vitorpamplona.quartz.nip46RemoteSigner.NostrConnectEvent
|
||||
import com.vitorpamplona.quartz.nip46RemoteSigner.signer.NostrSignerRemote
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect
|
||||
import com.vitorpamplona.quartz.utils.Hex
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
@@ -72,7 +63,6 @@ import kotlinx.coroutines.withTimeout
|
||||
import java.io.File
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.attribute.PosixFilePermission
|
||||
import java.security.SecureRandom
|
||||
|
||||
sealed class SignerType {
|
||||
data object Internal : SignerType()
|
||||
@@ -82,14 +72,6 @@ sealed class SignerType {
|
||||
) : SignerType()
|
||||
}
|
||||
|
||||
sealed class SignerConnectionState {
|
||||
data object NotRemote : SignerConnectionState()
|
||||
|
||||
data object Connected : SignerConnectionState()
|
||||
|
||||
data object Disconnected : SignerConnectionState()
|
||||
}
|
||||
|
||||
sealed class AccountState {
|
||||
data object LoggedOut : AccountState()
|
||||
|
||||
@@ -119,7 +101,6 @@ class AccountManager internal constructor(
|
||||
internal const val HEARTBEAT_INTERVAL_MS = 60_000L
|
||||
internal const val MAX_CONSECUTIVE_FAILURES = 3
|
||||
internal const val BUNKER_EPHEMERAL_KEY_ALIAS = "bunker_ephemeral"
|
||||
internal const val NOSTRCONNECT_TIMEOUT_MS = 120_000L
|
||||
internal const val NIP46_RELAY_CONNECT_TIMEOUT_MS = 15_000L
|
||||
internal val NIP46_RELAYS = listOf("wss://relay.nsec.app")
|
||||
}
|
||||
@@ -137,6 +118,9 @@ class AccountManager internal constructor(
|
||||
private val _signerConnectionState = MutableStateFlow<SignerConnectionState>(SignerConnectionState.NotRemote)
|
||||
val signerConnectionState: StateFlow<SignerConnectionState> = _signerConnectionState.asStateFlow()
|
||||
|
||||
private val _lastPingTimeSec = MutableStateFlow<Long?>(null)
|
||||
val lastPingTimeSec: StateFlow<Long?> = _lastPingTimeSec.asStateFlow()
|
||||
|
||||
private val _forceLogoutReason = MutableStateFlow<String?>(null)
|
||||
val forceLogoutReason: StateFlow<String?> = _forceLogoutReason.asStateFlow()
|
||||
|
||||
@@ -326,31 +310,26 @@ class AccountManager internal constructor(
|
||||
|
||||
val nip46Client = getOrCreateNip46Client()
|
||||
client = nip46Client
|
||||
val remoteSigner = NostrSignerRemote.fromBunkerUri(bunkerUri, ephemeralSigner, nip46Client)
|
||||
|
||||
// Emit connecting with initial relay statuses
|
||||
val relaysFromUri = parseBunkerRelays(bunkerUri)
|
||||
_loginProgress.value =
|
||||
LoginProgress.ConnectingToRelays(
|
||||
remoteSigner.relays.associateWith { RelayLoginStatus.CONNECTING },
|
||||
relaysFromUri.associateWith { RelayLoginStatus.CONNECTING },
|
||||
)
|
||||
nip46Client.subscribe(listener)
|
||||
|
||||
remoteSigner.openSubscription()
|
||||
|
||||
// Wait for websocket to be ready before sending connect request
|
||||
awaitNip46RelayConnection(nip46Client, remoteSigner.relays)
|
||||
|
||||
_loginProgress.value =
|
||||
LoginProgress.WaitingForSigner(
|
||||
relayStatuses = _loginProgress.value?.relayStatuses.orEmpty(),
|
||||
)
|
||||
val remotePubkey = remoteSigner.connect()
|
||||
|
||||
val result = BunkerLoginUseCase.execute(bunkerUri, ephemeralSigner, nip46Client)
|
||||
|
||||
val state =
|
||||
AccountState.LoggedIn(
|
||||
signer = remoteSigner,
|
||||
pubKeyHex = remotePubkey,
|
||||
npub = remotePubkey.hexToByteArray().toNpub(),
|
||||
signer = result.signer,
|
||||
pubKeyHex = result.pubKeyHex,
|
||||
npub = result.pubKeyHex.hexToByteArray().toNpub(),
|
||||
nsec = null,
|
||||
isReadOnly = false,
|
||||
signerType = SignerType.Remote(bunkerUri),
|
||||
@@ -358,7 +337,6 @@ class AccountManager internal constructor(
|
||||
_accountState.value = state
|
||||
_signerConnectionState.value = SignerConnectionState.Connected
|
||||
|
||||
// Save bunker account — strip secret param (no longer needed after connect)
|
||||
saveBunkerAccount(
|
||||
bunkerUri = stripBunkerSecret(bunkerUri),
|
||||
ephemeralPrivKeyHex = ephemeralKeyPair.privKey!!.toHexKey(),
|
||||
@@ -389,61 +367,34 @@ class AccountManager internal constructor(
|
||||
var client: NostrClient? = null
|
||||
try {
|
||||
val ephemeralKeyPair = KeyPair()
|
||||
val ephemeralSigner = NostrSignerInternal(ephemeralKeyPair)
|
||||
val secret = generateNostrConnectSecret()
|
||||
val ephemeralPubKey = ephemeralKeyPair.pubKey.toHexKey()
|
||||
|
||||
val relays = NIP46_RELAYS
|
||||
val relayParams = relays.joinToString("&") { "relay=$it" }
|
||||
val uri = "nostrconnect://$ephemeralPubKey?$relayParams&secret=$secret&name=Amethyst%20Desktop"
|
||||
val uriData = NostrConnectLoginUseCase.generateUri(ephemeralKeyPair, NIP46_RELAYS, "Amethyst%20Desktop")
|
||||
|
||||
val nip46Client = getOrCreateNip46Client()
|
||||
client = nip46Client
|
||||
val normalizedRelays = relays.map { NormalizedRelayUrl(it) }.toSet()
|
||||
|
||||
// Emit connecting with initial relay statuses
|
||||
_loginProgress.value =
|
||||
LoginProgress.ConnectingToRelays(
|
||||
normalizedRelays.associateWith { RelayLoginStatus.CONNECTING },
|
||||
uriData.relays.associateWith { RelayLoginStatus.CONNECTING },
|
||||
)
|
||||
nip46Client.subscribe(listener)
|
||||
|
||||
onUriGenerated(uri)
|
||||
onUriGenerated(uriData.uri)
|
||||
|
||||
_loginProgress.value =
|
||||
LoginProgress.WaitingForSigner(
|
||||
relayStatuses = _loginProgress.value?.relayStatuses.orEmpty(),
|
||||
)
|
||||
val connectData = waitForConnectRequest(ephemeralSigner, ephemeralPubKey, normalizedRelays, secret, nip46Client)
|
||||
|
||||
if (connectData.requestId != null) {
|
||||
_loginProgress.value =
|
||||
LoginProgress.SendingAck(
|
||||
relayStatuses = _loginProgress.value?.relayStatuses.orEmpty(),
|
||||
)
|
||||
sendAckResponse(ephemeralSigner, connectData, normalizedRelays, nip46Client)
|
||||
}
|
||||
val result = NostrConnectLoginUseCase.awaitAndLogin(uriData, nip46Client)
|
||||
|
||||
val remoteSigner =
|
||||
NostrSignerRemote(
|
||||
signer = ephemeralSigner,
|
||||
remotePubkey = connectData.signerPubkey,
|
||||
relays = normalizedRelays,
|
||||
client = nip46Client,
|
||||
)
|
||||
remoteSigner.openSubscription()
|
||||
|
||||
// Verify user pubkey via get_public_key — connect params may contain
|
||||
// the wrong pubkey (e.g. ephemeral key echoed back by some signers)
|
||||
val verifiedPubkey = remoteSigner.getPublicKey()
|
||||
|
||||
val syntheticBunkerUri = "bunker://${connectData.signerPubkey}?$relayParams"
|
||||
val relayParams = NIP46_RELAYS.joinToString("&") { "relay=$it" }
|
||||
val syntheticBunkerUri = "bunker://${result.signer.remotePubkey}?$relayParams"
|
||||
|
||||
val state =
|
||||
AccountState.LoggedIn(
|
||||
signer = remoteSigner,
|
||||
pubKeyHex = verifiedPubkey,
|
||||
npub = verifiedPubkey.hexToByteArray().toNpub(),
|
||||
signer = result.signer,
|
||||
pubKeyHex = result.pubKeyHex,
|
||||
npub = result.pubKeyHex.hexToByteArray().toNpub(),
|
||||
nsec = null,
|
||||
isReadOnly = false,
|
||||
signerType = SignerType.Remote(syntheticBunkerUri),
|
||||
@@ -468,116 +419,6 @@ class AccountManager internal constructor(
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun waitForConnectRequest(
|
||||
ephemeralSigner: NostrSignerInternal,
|
||||
ephemeralPubKey: HexKey,
|
||||
relays: Set<NormalizedRelayUrl>,
|
||||
expectedSecret: String,
|
||||
client: NostrClient,
|
||||
): ConnectRequestData {
|
||||
val deferred = CompletableDeferred<NostrConnectEvent>()
|
||||
|
||||
val subscription =
|
||||
client.req(
|
||||
relays = relays.toList(),
|
||||
filter =
|
||||
Filter(
|
||||
kinds = listOf(NostrConnectEvent.KIND),
|
||||
tags = mapOf("p" to listOf(ephemeralPubKey)),
|
||||
since = TimeUtils.now() - 60,
|
||||
),
|
||||
) { event ->
|
||||
if (event is NostrConnectEvent && !deferred.isCompleted) {
|
||||
deferred.complete(event)
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
val event =
|
||||
withTimeout(NOSTRCONNECT_TIMEOUT_MS) {
|
||||
deferred.await()
|
||||
}
|
||||
|
||||
val signerPubkey = event.talkingWith(ephemeralSigner.pubKey)
|
||||
val decryptedJson = ephemeralSigner.decrypt(event.content, signerPubkey)
|
||||
val message = OptimizedJsonMapper.fromJsonTo<BunkerMessage>(decryptedJson)
|
||||
|
||||
return when (message) {
|
||||
is BunkerRequest -> {
|
||||
if (message.method != BunkerRequestConnect.METHOD_NAME) {
|
||||
throw Exception("Expected 'connect' method, got '${message.method}'")
|
||||
}
|
||||
|
||||
val userPubkey =
|
||||
message.params.getOrNull(0)
|
||||
?: throw Exception("Missing user pubkey in connect request")
|
||||
val receivedSecret = message.params.getOrNull(1)
|
||||
|
||||
if (receivedSecret != expectedSecret) {
|
||||
throw Exception("Secret mismatch in connect request")
|
||||
}
|
||||
|
||||
ConnectRequestData(
|
||||
requestId = message.id,
|
||||
signerPubkey = signerPubkey,
|
||||
userPubkey = userPubkey,
|
||||
)
|
||||
}
|
||||
|
||||
is BunkerResponse -> {
|
||||
if (message.error != null) {
|
||||
throw Exception("Signer rejected connection: ${message.error}")
|
||||
}
|
||||
|
||||
val userPubkey =
|
||||
message.result
|
||||
?.takeIf { it.length == 64 && Hex.isHex(it) }
|
||||
?: signerPubkey
|
||||
|
||||
ConnectRequestData(
|
||||
requestId = null,
|
||||
signerPubkey = signerPubkey,
|
||||
userPubkey = userPubkey,
|
||||
)
|
||||
}
|
||||
|
||||
else -> {
|
||||
throw Exception("Unexpected NIP-46 message format")
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
subscription.close()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun sendAckResponse(
|
||||
ephemeralSigner: NostrSignerInternal,
|
||||
connectData: ConnectRequestData,
|
||||
relays: Set<NormalizedRelayUrl>,
|
||||
client: NostrClient,
|
||||
) {
|
||||
val ackResponse = BunkerResponseAck(id = connectData.requestId!!)
|
||||
val ackEvent =
|
||||
NostrConnectEvent.create(
|
||||
message = ackResponse,
|
||||
remoteKey = connectData.signerPubkey,
|
||||
signer = ephemeralSigner,
|
||||
)
|
||||
client.send(ackEvent, relays)
|
||||
}
|
||||
|
||||
private fun generateNostrConnectSecret(): String {
|
||||
val bytes = ByteArray(32)
|
||||
SecureRandom().nextBytes(bytes)
|
||||
return bytes.joinToString("") { "%02x".format(it) }
|
||||
}
|
||||
|
||||
private data class ConnectRequestData(
|
||||
val requestId: String?,
|
||||
val signerPubkey: HexKey,
|
||||
val userPubkey: HexKey,
|
||||
)
|
||||
|
||||
private suspend fun saveBunkerAccount(
|
||||
bunkerUri: String,
|
||||
ephemeralPrivKeyHex: String,
|
||||
@@ -709,6 +550,7 @@ class AccountManager internal constructor(
|
||||
}
|
||||
disconnectNip46Client()
|
||||
_signerConnectionState.value = SignerConnectionState.NotRemote
|
||||
_lastPingTimeSec.value = null
|
||||
_accountState.value = AccountState.LoggedOut
|
||||
// Cancel heartbeat LAST — may be called from within the heartbeat coroutine
|
||||
stopHeartbeat()
|
||||
@@ -738,6 +580,7 @@ class AccountManager internal constructor(
|
||||
remoteSigner.ping()
|
||||
consecutiveFailures = 0
|
||||
_signerConnectionState.value = SignerConnectionState.Connected
|
||||
_lastPingTimeSec.value = TimeUtils.now()
|
||||
} catch (_: SignerExceptions.ManuallyUnauthorizedException) {
|
||||
forceLogoutWithReason("Remote signer revoked access.")
|
||||
return@launch
|
||||
@@ -853,6 +696,17 @@ class AccountManager internal constructor(
|
||||
private fun getBunkerFile(): File = File(amethystDir, "bunker_uri.txt")
|
||||
}
|
||||
|
||||
internal fun parseBunkerRelays(uri: String): Set<NormalizedRelayUrl> {
|
||||
val idx = uri.indexOf('?')
|
||||
if (idx < 0) return emptySet()
|
||||
return uri
|
||||
.substring(idx + 1)
|
||||
.split("&")
|
||||
.filter { it.startsWith("relay=", ignoreCase = true) }
|
||||
.map { NormalizedRelayUrl(it.removePrefix("relay=")) }
|
||||
.toSet()
|
||||
}
|
||||
|
||||
internal fun stripBunkerSecret(uri: String): String {
|
||||
val idx = uri.indexOf('?')
|
||||
if (idx < 0) return uri
|
||||
|
||||
+11
@@ -41,11 +41,15 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.vitorpamplona.amethyst.commons.domain.nip46.SignerConnectionState
|
||||
import com.vitorpamplona.amethyst.commons.ui.components.BunkerHeartbeatIndicator
|
||||
|
||||
@Composable
|
||||
fun DeckSidebar(
|
||||
onAddColumn: () -> Unit,
|
||||
onOpenSettings: () -> Unit,
|
||||
signerConnectionState: SignerConnectionState,
|
||||
lastPingTimeSec: Long?,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Column(
|
||||
@@ -78,6 +82,13 @@ fun DeckSidebar(
|
||||
|
||||
Spacer(Modifier.weight(1f))
|
||||
|
||||
BunkerHeartbeatIndicator(
|
||||
signerConnectionState = signerConnectionState,
|
||||
lastPingTimeSec = lastPingTimeSec,
|
||||
)
|
||||
|
||||
Spacer(Modifier.size(8.dp))
|
||||
|
||||
IconButton(onClick = onOpenSettings) {
|
||||
Icon(
|
||||
Icons.Default.Settings,
|
||||
|
||||
+12
@@ -62,6 +62,8 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.commons.domain.nip46.SignerConnectionState
|
||||
import com.vitorpamplona.amethyst.commons.ui.components.BunkerHeartbeatIndicator
|
||||
import com.vitorpamplona.amethyst.desktop.DesktopScreen
|
||||
import com.vitorpamplona.amethyst.desktop.account.AccountManager
|
||||
import com.vitorpamplona.amethyst.desktop.account.AccountState
|
||||
@@ -103,6 +105,8 @@ fun SinglePaneLayout(
|
||||
onShowComposeDialog: () -> Unit,
|
||||
onShowReplyDialog: (com.vitorpamplona.quartz.nip01Core.core.Event) -> Unit,
|
||||
onZapFeedback: (ZapFeedback) -> Unit,
|
||||
signerConnectionState: SignerConnectionState,
|
||||
lastPingTimeSec: Long?,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
var currentColumnType by remember { mutableStateOf<DeckColumnType>(DeckColumnType.HomeFeed) }
|
||||
@@ -139,6 +143,14 @@ fun SinglePaneLayout(
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(Modifier.weight(1f))
|
||||
|
||||
BunkerHeartbeatIndicator(
|
||||
signerConnectionState = signerConnectionState,
|
||||
lastPingTimeSec = lastPingTimeSec,
|
||||
modifier = Modifier.padding(bottom = 12.dp),
|
||||
)
|
||||
}
|
||||
|
||||
VerticalDivider()
|
||||
|
||||
+1
@@ -20,6 +20,7 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.desktop.account
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.domain.nip46.SignerConnectionState
|
||||
import com.vitorpamplona.amethyst.commons.keystorage.SecureKeyStorage
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||
import com.vitorpamplona.quartz.nip19Bech32.toNsec
|
||||
|
||||
+1
@@ -20,6 +20,7 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.desktop.account
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.domain.nip46.SignerConnectionState
|
||||
import com.vitorpamplona.amethyst.commons.keystorage.SecureKeyStorage
|
||||
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||
|
||||
Reference in New Issue
Block a user