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 snackbarHostState = remember { SnackbarHostState() }
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
val signerConnectionState by accountManager.signerConnectionState.collectAsState()
|
||||||
|
val lastPingTimeSec by accountManager.lastPingTimeSec.collectAsState()
|
||||||
|
|
||||||
// DM infrastructure — hoisted here so it survives screen navigation
|
// DM infrastructure — hoisted here so it survives screen navigation
|
||||||
val dmSendTracker =
|
val dmSendTracker =
|
||||||
@@ -678,6 +680,8 @@ fun MainContent(
|
|||||||
onShowComposeDialog = onShowComposeDialog,
|
onShowComposeDialog = onShowComposeDialog,
|
||||||
onShowReplyDialog = onShowReplyDialog,
|
onShowReplyDialog = onShowReplyDialog,
|
||||||
onZapFeedback = onZapFeedback,
|
onZapFeedback = onZapFeedback,
|
||||||
|
signerConnectionState = signerConnectionState,
|
||||||
|
lastPingTimeSec = lastPingTimeSec,
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -692,6 +696,8 @@ fun MainContent(
|
|||||||
deckState.addColumn(DeckColumnType.Settings)
|
deckState.addColumn(DeckColumnType.Settings)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
signerConnectionState = signerConnectionState,
|
||||||
|
lastPingTimeSec = lastPingTimeSec,
|
||||||
)
|
)
|
||||||
|
|
||||||
VerticalDivider()
|
VerticalDivider()
|
||||||
|
|||||||
+35
-181
@@ -21,22 +21,21 @@
|
|||||||
package com.vitorpamplona.amethyst.desktop.account
|
package com.vitorpamplona.amethyst.desktop.account
|
||||||
|
|
||||||
import androidx.compose.runtime.Stable
|
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.SecureKeyStorage
|
||||||
import com.vitorpamplona.amethyst.commons.keystorage.SecureStorageException
|
import com.vitorpamplona.amethyst.commons.keystorage.SecureStorageException
|
||||||
import com.vitorpamplona.amethyst.desktop.network.DesktopHttpClient
|
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.hexToByteArray
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||||
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||||
import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient
|
import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient
|
||||||
import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.IRelayClientListener
|
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.client.single.IRelayClient
|
||||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message
|
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.Command
|
||||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.EventCmd
|
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.normalizer.NormalizedRelayUrl
|
||||||
import com.vitorpamplona.quartz.nip01Core.relay.sockets.okhttp.BasicOkHttpWebSocket
|
import com.vitorpamplona.quartz.nip01Core.relay.sockets.okhttp.BasicOkHttpWebSocket
|
||||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
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.decodePublicKeyAsHexOrNull
|
||||||
import com.vitorpamplona.quartz.nip19Bech32.toNpub
|
import com.vitorpamplona.quartz.nip19Bech32.toNpub
|
||||||
import com.vitorpamplona.quartz.nip19Bech32.toNsec
|
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.nip46RemoteSigner.signer.NostrSignerRemote
|
||||||
import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect
|
import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect
|
||||||
import com.vitorpamplona.quartz.utils.Hex
|
|
||||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||||
import kotlinx.coroutines.CompletableDeferred
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
@@ -72,7 +63,6 @@ import kotlinx.coroutines.withTimeout
|
|||||||
import java.io.File
|
import java.io.File
|
||||||
import java.nio.file.Files
|
import java.nio.file.Files
|
||||||
import java.nio.file.attribute.PosixFilePermission
|
import java.nio.file.attribute.PosixFilePermission
|
||||||
import java.security.SecureRandom
|
|
||||||
|
|
||||||
sealed class SignerType {
|
sealed class SignerType {
|
||||||
data object Internal : SignerType()
|
data object Internal : SignerType()
|
||||||
@@ -82,14 +72,6 @@ sealed class SignerType {
|
|||||||
) : SignerType()
|
) : SignerType()
|
||||||
}
|
}
|
||||||
|
|
||||||
sealed class SignerConnectionState {
|
|
||||||
data object NotRemote : SignerConnectionState()
|
|
||||||
|
|
||||||
data object Connected : SignerConnectionState()
|
|
||||||
|
|
||||||
data object Disconnected : SignerConnectionState()
|
|
||||||
}
|
|
||||||
|
|
||||||
sealed class AccountState {
|
sealed class AccountState {
|
||||||
data object LoggedOut : AccountState()
|
data object LoggedOut : AccountState()
|
||||||
|
|
||||||
@@ -119,7 +101,6 @@ class AccountManager internal constructor(
|
|||||||
internal const val HEARTBEAT_INTERVAL_MS = 60_000L
|
internal const val HEARTBEAT_INTERVAL_MS = 60_000L
|
||||||
internal const val MAX_CONSECUTIVE_FAILURES = 3
|
internal const val MAX_CONSECUTIVE_FAILURES = 3
|
||||||
internal const val BUNKER_EPHEMERAL_KEY_ALIAS = "bunker_ephemeral"
|
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 const val NIP46_RELAY_CONNECT_TIMEOUT_MS = 15_000L
|
||||||
internal val NIP46_RELAYS = listOf("wss://relay.nsec.app")
|
internal val NIP46_RELAYS = listOf("wss://relay.nsec.app")
|
||||||
}
|
}
|
||||||
@@ -137,6 +118,9 @@ class AccountManager internal constructor(
|
|||||||
private val _signerConnectionState = MutableStateFlow<SignerConnectionState>(SignerConnectionState.NotRemote)
|
private val _signerConnectionState = MutableStateFlow<SignerConnectionState>(SignerConnectionState.NotRemote)
|
||||||
val signerConnectionState: StateFlow<SignerConnectionState> = _signerConnectionState.asStateFlow()
|
val signerConnectionState: StateFlow<SignerConnectionState> = _signerConnectionState.asStateFlow()
|
||||||
|
|
||||||
|
private val _lastPingTimeSec = MutableStateFlow<Long?>(null)
|
||||||
|
val lastPingTimeSec: StateFlow<Long?> = _lastPingTimeSec.asStateFlow()
|
||||||
|
|
||||||
private val _forceLogoutReason = MutableStateFlow<String?>(null)
|
private val _forceLogoutReason = MutableStateFlow<String?>(null)
|
||||||
val forceLogoutReason: StateFlow<String?> = _forceLogoutReason.asStateFlow()
|
val forceLogoutReason: StateFlow<String?> = _forceLogoutReason.asStateFlow()
|
||||||
|
|
||||||
@@ -326,31 +310,26 @@ class AccountManager internal constructor(
|
|||||||
|
|
||||||
val nip46Client = getOrCreateNip46Client()
|
val nip46Client = getOrCreateNip46Client()
|
||||||
client = nip46Client
|
client = nip46Client
|
||||||
val remoteSigner = NostrSignerRemote.fromBunkerUri(bunkerUri, ephemeralSigner, nip46Client)
|
|
||||||
|
|
||||||
// Emit connecting with initial relay statuses
|
val relaysFromUri = parseBunkerRelays(bunkerUri)
|
||||||
_loginProgress.value =
|
_loginProgress.value =
|
||||||
LoginProgress.ConnectingToRelays(
|
LoginProgress.ConnectingToRelays(
|
||||||
remoteSigner.relays.associateWith { RelayLoginStatus.CONNECTING },
|
relaysFromUri.associateWith { RelayLoginStatus.CONNECTING },
|
||||||
)
|
)
|
||||||
nip46Client.subscribe(listener)
|
nip46Client.subscribe(listener)
|
||||||
|
|
||||||
remoteSigner.openSubscription()
|
|
||||||
|
|
||||||
// Wait for websocket to be ready before sending connect request
|
|
||||||
awaitNip46RelayConnection(nip46Client, remoteSigner.relays)
|
|
||||||
|
|
||||||
_loginProgress.value =
|
_loginProgress.value =
|
||||||
LoginProgress.WaitingForSigner(
|
LoginProgress.WaitingForSigner(
|
||||||
relayStatuses = _loginProgress.value?.relayStatuses.orEmpty(),
|
relayStatuses = _loginProgress.value?.relayStatuses.orEmpty(),
|
||||||
)
|
)
|
||||||
val remotePubkey = remoteSigner.connect()
|
|
||||||
|
val result = BunkerLoginUseCase.execute(bunkerUri, ephemeralSigner, nip46Client)
|
||||||
|
|
||||||
val state =
|
val state =
|
||||||
AccountState.LoggedIn(
|
AccountState.LoggedIn(
|
||||||
signer = remoteSigner,
|
signer = result.signer,
|
||||||
pubKeyHex = remotePubkey,
|
pubKeyHex = result.pubKeyHex,
|
||||||
npub = remotePubkey.hexToByteArray().toNpub(),
|
npub = result.pubKeyHex.hexToByteArray().toNpub(),
|
||||||
nsec = null,
|
nsec = null,
|
||||||
isReadOnly = false,
|
isReadOnly = false,
|
||||||
signerType = SignerType.Remote(bunkerUri),
|
signerType = SignerType.Remote(bunkerUri),
|
||||||
@@ -358,7 +337,6 @@ class AccountManager internal constructor(
|
|||||||
_accountState.value = state
|
_accountState.value = state
|
||||||
_signerConnectionState.value = SignerConnectionState.Connected
|
_signerConnectionState.value = SignerConnectionState.Connected
|
||||||
|
|
||||||
// Save bunker account — strip secret param (no longer needed after connect)
|
|
||||||
saveBunkerAccount(
|
saveBunkerAccount(
|
||||||
bunkerUri = stripBunkerSecret(bunkerUri),
|
bunkerUri = stripBunkerSecret(bunkerUri),
|
||||||
ephemeralPrivKeyHex = ephemeralKeyPair.privKey!!.toHexKey(),
|
ephemeralPrivKeyHex = ephemeralKeyPair.privKey!!.toHexKey(),
|
||||||
@@ -389,61 +367,34 @@ class AccountManager internal constructor(
|
|||||||
var client: NostrClient? = null
|
var client: NostrClient? = null
|
||||||
try {
|
try {
|
||||||
val ephemeralKeyPair = KeyPair()
|
val ephemeralKeyPair = KeyPair()
|
||||||
val ephemeralSigner = NostrSignerInternal(ephemeralKeyPair)
|
val uriData = NostrConnectLoginUseCase.generateUri(ephemeralKeyPair, NIP46_RELAYS, "Amethyst%20Desktop")
|
||||||
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 nip46Client = getOrCreateNip46Client()
|
val nip46Client = getOrCreateNip46Client()
|
||||||
client = nip46Client
|
client = nip46Client
|
||||||
val normalizedRelays = relays.map { NormalizedRelayUrl(it) }.toSet()
|
|
||||||
|
|
||||||
// Emit connecting with initial relay statuses
|
|
||||||
_loginProgress.value =
|
_loginProgress.value =
|
||||||
LoginProgress.ConnectingToRelays(
|
LoginProgress.ConnectingToRelays(
|
||||||
normalizedRelays.associateWith { RelayLoginStatus.CONNECTING },
|
uriData.relays.associateWith { RelayLoginStatus.CONNECTING },
|
||||||
)
|
)
|
||||||
nip46Client.subscribe(listener)
|
nip46Client.subscribe(listener)
|
||||||
|
|
||||||
onUriGenerated(uri)
|
onUriGenerated(uriData.uri)
|
||||||
|
|
||||||
_loginProgress.value =
|
_loginProgress.value =
|
||||||
LoginProgress.WaitingForSigner(
|
LoginProgress.WaitingForSigner(
|
||||||
relayStatuses = _loginProgress.value?.relayStatuses.orEmpty(),
|
relayStatuses = _loginProgress.value?.relayStatuses.orEmpty(),
|
||||||
)
|
)
|
||||||
val connectData = waitForConnectRequest(ephemeralSigner, ephemeralPubKey, normalizedRelays, secret, nip46Client)
|
|
||||||
|
|
||||||
if (connectData.requestId != null) {
|
val result = NostrConnectLoginUseCase.awaitAndLogin(uriData, nip46Client)
|
||||||
_loginProgress.value =
|
|
||||||
LoginProgress.SendingAck(
|
|
||||||
relayStatuses = _loginProgress.value?.relayStatuses.orEmpty(),
|
|
||||||
)
|
|
||||||
sendAckResponse(ephemeralSigner, connectData, normalizedRelays, nip46Client)
|
|
||||||
}
|
|
||||||
|
|
||||||
val remoteSigner =
|
val relayParams = NIP46_RELAYS.joinToString("&") { "relay=$it" }
|
||||||
NostrSignerRemote(
|
val syntheticBunkerUri = "bunker://${result.signer.remotePubkey}?$relayParams"
|
||||||
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 state =
|
val state =
|
||||||
AccountState.LoggedIn(
|
AccountState.LoggedIn(
|
||||||
signer = remoteSigner,
|
signer = result.signer,
|
||||||
pubKeyHex = verifiedPubkey,
|
pubKeyHex = result.pubKeyHex,
|
||||||
npub = verifiedPubkey.hexToByteArray().toNpub(),
|
npub = result.pubKeyHex.hexToByteArray().toNpub(),
|
||||||
nsec = null,
|
nsec = null,
|
||||||
isReadOnly = false,
|
isReadOnly = false,
|
||||||
signerType = SignerType.Remote(syntheticBunkerUri),
|
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(
|
private suspend fun saveBunkerAccount(
|
||||||
bunkerUri: String,
|
bunkerUri: String,
|
||||||
ephemeralPrivKeyHex: String,
|
ephemeralPrivKeyHex: String,
|
||||||
@@ -709,6 +550,7 @@ class AccountManager internal constructor(
|
|||||||
}
|
}
|
||||||
disconnectNip46Client()
|
disconnectNip46Client()
|
||||||
_signerConnectionState.value = SignerConnectionState.NotRemote
|
_signerConnectionState.value = SignerConnectionState.NotRemote
|
||||||
|
_lastPingTimeSec.value = null
|
||||||
_accountState.value = AccountState.LoggedOut
|
_accountState.value = AccountState.LoggedOut
|
||||||
// Cancel heartbeat LAST — may be called from within the heartbeat coroutine
|
// Cancel heartbeat LAST — may be called from within the heartbeat coroutine
|
||||||
stopHeartbeat()
|
stopHeartbeat()
|
||||||
@@ -738,6 +580,7 @@ class AccountManager internal constructor(
|
|||||||
remoteSigner.ping()
|
remoteSigner.ping()
|
||||||
consecutiveFailures = 0
|
consecutiveFailures = 0
|
||||||
_signerConnectionState.value = SignerConnectionState.Connected
|
_signerConnectionState.value = SignerConnectionState.Connected
|
||||||
|
_lastPingTimeSec.value = TimeUtils.now()
|
||||||
} catch (_: SignerExceptions.ManuallyUnauthorizedException) {
|
} catch (_: SignerExceptions.ManuallyUnauthorizedException) {
|
||||||
forceLogoutWithReason("Remote signer revoked access.")
|
forceLogoutWithReason("Remote signer revoked access.")
|
||||||
return@launch
|
return@launch
|
||||||
@@ -853,6 +696,17 @@ class AccountManager internal constructor(
|
|||||||
private fun getBunkerFile(): File = File(amethystDir, "bunker_uri.txt")
|
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 {
|
internal fun stripBunkerSecret(uri: String): String {
|
||||||
val idx = uri.indexOf('?')
|
val idx = uri.indexOf('?')
|
||||||
if (idx < 0) return uri
|
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.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
|
import com.vitorpamplona.amethyst.commons.domain.nip46.SignerConnectionState
|
||||||
|
import com.vitorpamplona.amethyst.commons.ui.components.BunkerHeartbeatIndicator
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun DeckSidebar(
|
fun DeckSidebar(
|
||||||
onAddColumn: () -> Unit,
|
onAddColumn: () -> Unit,
|
||||||
onOpenSettings: () -> Unit,
|
onOpenSettings: () -> Unit,
|
||||||
|
signerConnectionState: SignerConnectionState,
|
||||||
|
lastPingTimeSec: Long?,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
@@ -78,6 +82,13 @@ fun DeckSidebar(
|
|||||||
|
|
||||||
Spacer(Modifier.weight(1f))
|
Spacer(Modifier.weight(1f))
|
||||||
|
|
||||||
|
BunkerHeartbeatIndicator(
|
||||||
|
signerConnectionState = signerConnectionState,
|
||||||
|
lastPingTimeSec = lastPingTimeSec,
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(Modifier.size(8.dp))
|
||||||
|
|
||||||
IconButton(onClick = onOpenSettings) {
|
IconButton(onClick = onOpenSettings) {
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Default.Settings,
|
Icons.Default.Settings,
|
||||||
|
|||||||
+12
@@ -62,6 +62,8 @@ import androidx.compose.ui.Modifier
|
|||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
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.DesktopScreen
|
||||||
import com.vitorpamplona.amethyst.desktop.account.AccountManager
|
import com.vitorpamplona.amethyst.desktop.account.AccountManager
|
||||||
import com.vitorpamplona.amethyst.desktop.account.AccountState
|
import com.vitorpamplona.amethyst.desktop.account.AccountState
|
||||||
@@ -103,6 +105,8 @@ fun SinglePaneLayout(
|
|||||||
onShowComposeDialog: () -> Unit,
|
onShowComposeDialog: () -> Unit,
|
||||||
onShowReplyDialog: (com.vitorpamplona.quartz.nip01Core.core.Event) -> Unit,
|
onShowReplyDialog: (com.vitorpamplona.quartz.nip01Core.core.Event) -> Unit,
|
||||||
onZapFeedback: (ZapFeedback) -> Unit,
|
onZapFeedback: (ZapFeedback) -> Unit,
|
||||||
|
signerConnectionState: SignerConnectionState,
|
||||||
|
lastPingTimeSec: Long?,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
var currentColumnType by remember { mutableStateOf<DeckColumnType>(DeckColumnType.HomeFeed) }
|
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()
|
VerticalDivider()
|
||||||
|
|||||||
+1
@@ -20,6 +20,7 @@
|
|||||||
*/
|
*/
|
||||||
package com.vitorpamplona.amethyst.desktop.account
|
package com.vitorpamplona.amethyst.desktop.account
|
||||||
|
|
||||||
|
import com.vitorpamplona.amethyst.commons.domain.nip46.SignerConnectionState
|
||||||
import com.vitorpamplona.amethyst.commons.keystorage.SecureKeyStorage
|
import com.vitorpamplona.amethyst.commons.keystorage.SecureKeyStorage
|
||||||
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||||
import com.vitorpamplona.quartz.nip19Bech32.toNsec
|
import com.vitorpamplona.quartz.nip19Bech32.toNsec
|
||||||
|
|||||||
+1
@@ -20,6 +20,7 @@
|
|||||||
*/
|
*/
|
||||||
package com.vitorpamplona.amethyst.desktop.account
|
package com.vitorpamplona.amethyst.desktop.account
|
||||||
|
|
||||||
|
import com.vitorpamplona.amethyst.commons.domain.nip46.SignerConnectionState
|
||||||
import com.vitorpamplona.amethyst.commons.keystorage.SecureKeyStorage
|
import com.vitorpamplona.amethyst.commons.keystorage.SecureKeyStorage
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||||
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||||
|
|||||||
Reference in New Issue
Block a user