fix(desktop): address code review findings for NIP-46 bunker login

- Add bounds check in fromBunkerUri for missing query params (P2-1)
- Preserve bunker keys on transient force-logout (P2-3)
- Replace debug printlns with DebugConfig.log() (P2-4)
- Replace unused Unstable with Disconnected state (P2-5)
- Move validateBunkerUri to account package (P3-9)
- Fix Compose state update from IO thread (P3-11)
- Add disconnectNip46Client() to onDispose cleanup (P3-12)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
nrobi144
2026-03-06 13:19:49 +02:00
parent 12c91aae3d
commit 3a1e38a2f2
24 changed files with 1101 additions and 324 deletions
@@ -72,6 +72,7 @@ import com.vitorpamplona.amethyst.desktop.account.AccountState
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
import com.vitorpamplona.amethyst.desktop.model.DesktopDmRelayState import com.vitorpamplona.amethyst.desktop.model.DesktopDmRelayState
import com.vitorpamplona.amethyst.desktop.model.DesktopIAccount import com.vitorpamplona.amethyst.desktop.model.DesktopIAccount
import com.vitorpamplona.amethyst.desktop.network.DefaultRelays
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator
import com.vitorpamplona.amethyst.desktop.ui.ComposeNoteDialog import com.vitorpamplona.amethyst.desktop.ui.ComposeNoteDialog
@@ -88,6 +89,7 @@ import com.vitorpamplona.amethyst.desktop.ui.deck.DeckState
import com.vitorpamplona.amethyst.desktop.ui.deck.SinglePaneLayout import com.vitorpamplona.amethyst.desktop.ui.deck.SinglePaneLayout
import com.vitorpamplona.amethyst.desktop.ui.profile.ProfileInfoCard import com.vitorpamplona.amethyst.desktop.ui.profile.ProfileInfoCard
import com.vitorpamplona.amethyst.desktop.ui.relay.RelayStatusCard import com.vitorpamplona.amethyst.desktop.ui.relay.RelayStatusCard
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -95,7 +97,7 @@ import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeoutOrNull import kotlinx.coroutines.runBlocking
private val isMacOS = System.getProperty("os.name").lowercase().contains("mac") private val isMacOS = System.getProperty("os.name").lowercase().contains("mac")
@@ -402,13 +404,18 @@ fun App(
val accountState by accountManager.accountState.collectAsState() val accountState by accountManager.accountState.collectAsState()
val scope = remember { CoroutineScope(SupervisorJob() + Dispatchers.Main) } val scope = remember { CoroutineScope(SupervisorJob() + Dispatchers.Main) }
// Subscriptions coordinator for metadata/reactions loading // Subscriptions coordinator — uses default relay URLs for metadata indexing.
// Feed subscriptions (inside MainContent) drive actual relay pool connections.
val subscriptionsCoordinator = val subscriptionsCoordinator =
remember(relayManager, localCache) { remember(relayManager, localCache) {
DesktopRelaySubscriptionsCoordinator( DesktopRelaySubscriptionsCoordinator(
client = relayManager.client, client = relayManager.client,
scope = scope, scope = scope,
indexRelays = relayManager.availableRelays.value, indexRelays =
DefaultRelays.RELAYS
.mapNotNull {
RelayUrlNormalizer.normalizeOrNull(it)
}.toSet(),
localCache = localCache, localCache = localCache,
) )
} }
@@ -421,31 +428,24 @@ fun App(
scope.launch(Dispatchers.IO) { scope.launch(Dispatchers.IO) {
if (accountManager.hasBunkerAccount()) { if (accountManager.hasBunkerAccount()) {
// Bunker accounts need relay connections before recreating signer // Show connecting UI while dedicated NIP-46 client connects
accountManager.setConnectingRelays() accountManager.setConnectingRelays()
val connected = }
withTimeoutOrNull(30_000L) { val result = accountManager.loadSavedAccount()
relayManager.connectedRelays.first { it.isNotEmpty() } if (result.isSuccess) {
} val current = accountManager.currentAccount()
if (connected == null) { if (current?.signerType is com.vitorpamplona.amethyst.desktop.account.SignerType.Remote) {
// No relays connected after 30s — fall back to login screen accountManager.startHeartbeat(scope)
accountManager.logout(deleteKey = true)
} else {
val result = accountManager.loadSavedAccount(relayManager.client)
if (result.isSuccess) {
accountManager.startHeartbeat(scope)
} else {
// Corrupt bunker state — fall back to login screen
accountManager.logout(deleteKey = true)
}
} }
} else { } else if (accountManager.hasBunkerAccount()) {
accountManager.loadSavedAccount() // Corrupt bunker state — fall back to login screen
accountManager.logout(deleteKey = true)
} }
} }
onDispose { onDispose {
accountManager.stopHeartbeat() accountManager.stopHeartbeat()
runBlocking { accountManager.disconnectNip46Client() }
subscriptionsCoordinator.clear() subscriptionsCoordinator.clear()
relayManager.disconnect() relayManager.disconnect()
scope.cancel() scope.cancel()
@@ -463,7 +463,6 @@ fun App(
is AccountState.LoggedOut -> { is AccountState.LoggedOut -> {
LoginScreen( LoginScreen(
accountManager = accountManager, accountManager = accountManager,
relayClient = relayManager.client,
onLoginSuccess = { onLoginSuccess = {
// Start heartbeat if bunker account // Start heartbeat if bunker account
val current = accountManager.currentAccount() val current = accountManager.currentAccount()
@@ -475,7 +474,11 @@ fun App(
} }
is AccountState.ConnectingRelays -> { is AccountState.ConnectingRelays -> {
ConnectingRelaysScreen() val relays by relayManager.relayStatuses.collectAsState()
ConnectingRelaysScreen(
subtitle = "Restoring remote signer session",
relayStatuses = relays,
)
} }
is AccountState.LoggedIn -> { is AccountState.LoggedIn -> {
@@ -23,15 +23,17 @@ package com.vitorpamplona.amethyst.desktop.account
import androidx.compose.runtime.Stable import androidx.compose.runtime.Stable
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.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper 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.INostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.req import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.req
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter 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.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions
@@ -39,12 +41,16 @@ 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.BunkerRequest
import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestConnect import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestConnect
import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponse
import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponseAck import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponseAck
import com.vitorpamplona.quartz.nip46RemoteSigner.NostrConnectEvent 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 kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
@@ -52,10 +58,15 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.isActive import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withTimeout import kotlinx.coroutines.withTimeout
import java.io.File import java.io.File
import java.nio.file.Files
import java.nio.file.attribute.PosixFilePermission
import java.security.SecureRandom import java.security.SecureRandom
sealed class SignerType { sealed class SignerType {
@@ -71,9 +82,7 @@ sealed class SignerConnectionState {
data object Connected : SignerConnectionState() data object Connected : SignerConnectionState()
data class Unstable( data object Disconnected : SignerConnectionState()
val failCount: Int,
) : SignerConnectionState()
} }
sealed class AccountState { sealed class AccountState {
@@ -106,6 +115,7 @@ class AccountManager internal constructor(
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 NOSTRCONNECT_TIMEOUT_MS = 120_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")
} }
@@ -127,16 +137,53 @@ class AccountManager internal constructor(
private var heartbeatJob: Job? = null private var heartbeatJob: Job? = null
// --- Dedicated NIP-46 client (isolated from general relay pool) ---
private val nip46ClientMutex = Mutex()
private var nip46Client: NostrClient? = null
private suspend fun getOrCreateNip46Client(): NostrClient =
nip46ClientMutex.withLock {
nip46Client ?: NostrClient(
BasicOkHttpWebSocket.Builder(DesktopHttpClient::getHttpClient),
).also {
nip46Client = it
it.connect()
}
}
suspend fun disconnectNip46Client() =
nip46ClientMutex.withLock {
nip46Client?.disconnect()
nip46Client = null
}
/**
* Waits for the NIP-46 client to connect to at least one of the target relays.
* openSubscription() triggers async relay connection via sendOrConnectAndSync,
* but we must wait for the websocket to be ready before sending requests.
*/
private suspend fun awaitNip46RelayConnection(
client: NostrClient,
targetRelays: Set<NormalizedRelayUrl>,
) {
withTimeout(NIP46_RELAY_CONNECT_TIMEOUT_MS) {
client.connectedRelaysFlow().first { connected ->
targetRelays.any { it in connected }
}
}
}
// --- Account loading --- // --- Account loading ---
suspend fun loadSavedAccount(client: INostrClient? = null): Result<AccountState.LoggedIn> = suspend fun loadSavedAccount(): Result<AccountState.LoggedIn> =
try { try {
val lastNpub = getLastNpub() ?: return Result.failure(Exception("No saved account")) val lastNpub = getLastNpub() ?: return Result.failure(Exception("No saved account"))
// Check for bunker account first // Check for bunker account first
val bunkerUri = getBunkerUri() val bunkerUri = getBunkerUri()
if (bunkerUri != null && client != null) { if (bunkerUri != null) {
loadBunkerAccount(bunkerUri, lastNpub, client) loadBunkerAccount(bunkerUri, lastNpub)
} else { } else {
loadInternalAccount(lastNpub) loadInternalAccount(lastNpub)
} }
@@ -167,7 +214,6 @@ class AccountManager internal constructor(
private suspend fun loadBunkerAccount( private suspend fun loadBunkerAccount(
bunkerUri: String, bunkerUri: String,
npub: String, npub: String,
client: INostrClient,
): Result<AccountState.LoggedIn> { ): Result<AccountState.LoggedIn> {
val ephemeralPrivKeyHex = val ephemeralPrivKeyHex =
secureStorage.getPrivateKey(BUNKER_EPHEMERAL_KEY_ALIAS) secureStorage.getPrivateKey(BUNKER_EPHEMERAL_KEY_ALIAS)
@@ -176,7 +222,8 @@ class AccountManager internal constructor(
val ephemeralKeyPair = KeyPair(privKey = ephemeralPrivKeyHex.hexToByteArray()) val ephemeralKeyPair = KeyPair(privKey = ephemeralPrivKeyHex.hexToByteArray())
val ephemeralSigner = NostrSignerInternal(ephemeralKeyPair) val ephemeralSigner = NostrSignerInternal(ephemeralKeyPair)
val remoteSigner = NostrSignerRemote.fromBunkerUri(bunkerUri, ephemeralSigner, client) val nip46Client = getOrCreateNip46Client()
val remoteSigner = NostrSignerRemote.fromBunkerUri(bunkerUri, ephemeralSigner, nip46Client)
remoteSigner.openSubscription() remoteSigner.openSubscription()
val pubKeyHex = decodePublicKeyAsHexOrNull(npub) ?: return Result.failure(Exception("Invalid saved npub")) val pubKeyHex = decodePublicKeyAsHexOrNull(npub) ?: return Result.failure(Exception("Invalid saved npub"))
@@ -197,17 +244,18 @@ class AccountManager internal constructor(
// --- Bunker login --- // --- Bunker login ---
suspend fun loginWithBunker( suspend fun loginWithBunker(bunkerUri: String): Result<AccountState.LoggedIn> =
bunkerUri: String,
client: INostrClient,
): Result<AccountState.LoggedIn> =
try { try {
val ephemeralKeyPair = KeyPair() val ephemeralKeyPair = KeyPair()
val ephemeralSigner = NostrSignerInternal(ephemeralKeyPair) val ephemeralSigner = NostrSignerInternal(ephemeralKeyPair)
val remoteSigner = NostrSignerRemote.fromBunkerUri(bunkerUri, ephemeralSigner, client) val nip46Client = getOrCreateNip46Client()
val remoteSigner = NostrSignerRemote.fromBunkerUri(bunkerUri, ephemeralSigner, nip46Client)
remoteSigner.openSubscription() remoteSigner.openSubscription()
// Wait for websocket to be ready before sending connect request
awaitNip46RelayConnection(nip46Client, remoteSigner.relays)
val remotePubkey = remoteSigner.connect() val remotePubkey = remoteSigner.connect()
val state = val state =
@@ -230,6 +278,8 @@ class AccountManager internal constructor(
) )
Result.success(state) Result.success(state)
} catch (e: kotlinx.coroutines.TimeoutCancellationException) {
Result.failure(Exception("Could not connect to NIP-46 relay. Check your network connection."))
} catch (e: SignerExceptions.TimedOutException) { } catch (e: SignerExceptions.TimedOutException) {
Result.failure(Exception("Connection timed out. Ensure remote signer is online and has approved the connection.")) Result.failure(Exception("Connection timed out. Ensure remote signer is online and has approved the connection."))
} catch (e: SignerExceptions.ManuallyUnauthorizedException) { } catch (e: SignerExceptions.ManuallyUnauthorizedException) {
@@ -242,10 +292,7 @@ class AccountManager internal constructor(
// --- Nostrconnect login --- // --- Nostrconnect login ---
suspend fun loginWithNostrConnect( suspend fun loginWithNostrConnect(onUriGenerated: (String) -> Unit): Result<AccountState.LoggedIn> =
client: INostrClient,
onUriGenerated: (String) -> Unit,
): Result<AccountState.LoggedIn> =
try { try {
val ephemeralKeyPair = KeyPair() val ephemeralKeyPair = KeyPair()
val ephemeralSigner = NostrSignerInternal(ephemeralKeyPair) val ephemeralSigner = NostrSignerInternal(ephemeralKeyPair)
@@ -257,17 +304,20 @@ class AccountManager internal constructor(
val uri = "nostrconnect://$ephemeralPubKey?$relayParams&secret=$secret&name=Amethyst%20Desktop" val uri = "nostrconnect://$ephemeralPubKey?$relayParams&secret=$secret&name=Amethyst%20Desktop"
onUriGenerated(uri) onUriGenerated(uri)
val nip46Client = getOrCreateNip46Client()
val normalizedRelays = relays.map { NormalizedRelayUrl(it) }.toSet() val normalizedRelays = relays.map { NormalizedRelayUrl(it) }.toSet()
val connectData = waitForConnectRequest(ephemeralSigner, ephemeralPubKey, normalizedRelays, secret, client) val connectData = waitForConnectRequest(ephemeralSigner, ephemeralPubKey, normalizedRelays, secret, nip46Client)
sendAckResponse(ephemeralSigner, connectData, normalizedRelays, client) if (connectData.requestId != null) {
sendAckResponse(ephemeralSigner, connectData, normalizedRelays, nip46Client)
}
val remoteSigner = val remoteSigner =
NostrSignerRemote( NostrSignerRemote(
signer = ephemeralSigner, signer = ephemeralSigner,
remotePubkey = connectData.signerPubkey, remotePubkey = connectData.signerPubkey,
relays = normalizedRelays, relays = normalizedRelays,
client = client, client = nip46Client,
) )
remoteSigner.openSubscription() remoteSigner.openSubscription()
@@ -303,7 +353,7 @@ class AccountManager internal constructor(
ephemeralPubKey: HexKey, ephemeralPubKey: HexKey,
relays: Set<NormalizedRelayUrl>, relays: Set<NormalizedRelayUrl>,
expectedSecret: String, expectedSecret: String,
client: INostrClient, client: NostrClient,
): ConnectRequestData { ): ConnectRequestData {
val deferred = CompletableDeferred<NostrConnectEvent>() val deferred = CompletableDeferred<NostrConnectEvent>()
@@ -314,6 +364,7 @@ class AccountManager internal constructor(
Filter( Filter(
kinds = listOf(NostrConnectEvent.KIND), kinds = listOf(NostrConnectEvent.KIND),
tags = mapOf("p" to listOf(ephemeralPubKey)), tags = mapOf("p" to listOf(ephemeralPubKey)),
since = TimeUtils.now() - 60,
), ),
) { event -> ) { event ->
if (event is NostrConnectEvent && !deferred.isCompleted) { if (event is NostrConnectEvent && !deferred.isCompleted) {
@@ -327,28 +378,53 @@ class AccountManager internal constructor(
deferred.await() deferred.await()
} }
val otherPubkey = event.talkingWith(ephemeralSigner.pubKey) val signerPubkey = event.talkingWith(ephemeralSigner.pubKey)
val decryptedJson = ephemeralSigner.decrypt(event.content, otherPubkey) val decryptedJson = ephemeralSigner.decrypt(event.content, signerPubkey)
val message = OptimizedJsonMapper.fromJsonTo<BunkerRequest>(decryptedJson) val message = OptimizedJsonMapper.fromJsonTo<BunkerMessage>(decryptedJson)
if (message.method != BunkerRequestConnect.METHOD_NAME) { return when (message) {
throw Exception("Expected 'connect' method, got '${message.method}'") 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")
}
} }
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")
}
return ConnectRequestData(
requestId = message.id,
signerPubkey = event.pubKey,
userPubkey = userPubkey,
)
} finally { } finally {
subscription.close() subscription.close()
} }
@@ -358,9 +434,9 @@ class AccountManager internal constructor(
ephemeralSigner: NostrSignerInternal, ephemeralSigner: NostrSignerInternal,
connectData: ConnectRequestData, connectData: ConnectRequestData,
relays: Set<NormalizedRelayUrl>, relays: Set<NormalizedRelayUrl>,
client: INostrClient, client: NostrClient,
) { ) {
val ackResponse = BunkerResponseAck(id = connectData.requestId) val ackResponse = BunkerResponseAck(id = connectData.requestId!!)
val ackEvent = val ackEvent =
NostrConnectEvent.create( NostrConnectEvent.create(
message = ackResponse, message = ackResponse,
@@ -377,7 +453,7 @@ class AccountManager internal constructor(
} }
private data class ConnectRequestData( private data class ConnectRequestData(
val requestId: String, val requestId: String?,
val signerPubkey: HexKey, val signerPubkey: HexKey,
val userPubkey: HexKey, val userPubkey: HexKey,
) )
@@ -511,6 +587,7 @@ class AccountManager internal constructor(
} }
} }
} }
disconnectNip46Client()
_signerConnectionState.value = SignerConnectionState.NotRemote _signerConnectionState.value = SignerConnectionState.NotRemote
_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
@@ -519,7 +596,7 @@ class AccountManager internal constructor(
suspend fun forceLogoutWithReason(reason: String) { suspend fun forceLogoutWithReason(reason: String) {
_forceLogoutReason.value = reason _forceLogoutReason.value = reason
logout(deleteKey = true) logout(deleteKey = false)
} }
fun clearForceLogoutReason() { fun clearForceLogoutReason() {
@@ -552,7 +629,7 @@ class AccountManager internal constructor(
) )
return@launch return@launch
} }
_signerConnectionState.value = SignerConnectionState.Unstable(consecutiveFailures) _signerConnectionState.value = SignerConnectionState.Disconnected
} }
} }
} }
@@ -601,8 +678,27 @@ class AccountManager internal constructor(
// --- File storage helpers --- // --- File storage helpers ---
private fun ensureAmethystDir() {
if (!amethystDir.exists()) {
amethystDir.mkdirs()
}
try {
Files.setPosixFilePermissions(
amethystDir.toPath(),
setOf(
PosixFilePermission.OWNER_READ,
PosixFilePermission.OWNER_WRITE,
PosixFilePermission.OWNER_EXECUTE,
),
)
} catch (_: UnsupportedOperationException) {
// Windows — file system ACLs handle this
} catch (_: Exception) {
}
}
private fun saveNwcUri(uri: String) { private fun saveNwcUri(uri: String) {
amethystDir.mkdirs() ensureAmethystDir()
getNwcFile().writeText(uri) getNwcFile().writeText(uri)
} }
@@ -614,7 +710,7 @@ class AccountManager internal constructor(
} }
private fun saveLastNpub(npub: String) { private fun saveLastNpub(npub: String) {
amethystDir.mkdirs() ensureAmethystDir()
getPrefsFile().writeText(npub) getPrefsFile().writeText(npub)
} }
@@ -630,7 +726,7 @@ class AccountManager internal constructor(
} }
private fun saveBunkerUri(uri: String) { private fun saveBunkerUri(uri: String) {
amethystDir.mkdirs() ensureAmethystDir()
getBunkerFile().writeText(uri) getBunkerFile().writeText(uri)
} }
@@ -0,0 +1,42 @@
/*
* 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.desktop.account
private val HEX_64_REGEX = Regex("^[0-9a-fA-F]{64}$")
fun validateBunkerUri(input: String): String? {
val trimmed = input.trim()
if (!trimmed.startsWith("bunker://", ignoreCase = true)) return "Not a bunker URI"
val afterScheme = trimmed.substring("bunker://".length)
val parts = afterScheme.split("?", limit = 2)
val pubkeyPart = parts[0]
if (pubkeyPart.length != 64 || !pubkeyPart.matches(HEX_64_REGEX)) {
return "Invalid bunker URI. Expected: bunker://<64-hex-chars>?relay=wss://..."
}
if (parts.size < 2 || !parts[1].contains("relay=wss://", ignoreCase = true)) {
return "Bunker URI must include at least one relay parameter (relay=wss://...)"
}
return null // valid
}
@@ -99,7 +99,7 @@ fun ChessScreen(
remember(account.pubKeyHex) { remember(account.pubKeyHex) {
DesktopChessViewModelNew(account, relayManager, scope) DesktopChessViewModelNew(account, relayManager, scope)
} }
val relayStatuses by relayManager.relayStatuses.collectAsState() val connectedRelays by relayManager.connectedRelays.collectAsState()
val broadcastStatus by viewModel.broadcastStatus.collectAsState() val broadcastStatus by viewModel.broadcastStatus.collectAsState()
val activeGames by viewModel.activeGames.collectAsState() val activeGames by viewModel.activeGames.collectAsState()
// Observe state version to force recomposition when game state changes // Observe state version to force recomposition when game state changes
@@ -147,11 +147,10 @@ fun ChessScreen(
// Subscribe to user metadata for pubkeys that need it // Subscribe to user metadata for pubkeys that need it
val pubkeysNeeded by viewModel.userMetadataCache.pubkeysNeeded.collectAsState() val pubkeysNeeded by viewModel.userMetadataCache.pubkeysNeeded.collectAsState()
rememberSubscription(relayStatuses, pubkeysNeeded, relayManager = relayManager) { rememberSubscription(connectedRelays, pubkeysNeeded, relayManager = relayManager) {
val configuredRelays = relayStatuses.keys if (connectedRelays.isNotEmpty() && pubkeysNeeded.isNotEmpty()) {
if (configuredRelays.isNotEmpty() && pubkeysNeeded.isNotEmpty()) {
createMetadataListSubscription( createMetadataListSubscription(
relays = configuredRelays, relays = connectedRelays,
pubKeys = pubkeysNeeded.toList(), pubKeys = pubkeysNeeded.toList(),
onEvent = { event, _, _, _ -> onEvent = { event, _, _, _ ->
viewModel.handleIncomingEvent(event) viewModel.handleIncomingEvent(event)
@@ -221,6 +221,6 @@ open class RelayConnectionManager(
cmd: Command, cmd: Command,
success: Boolean, success: Boolean,
) { ) {
// Command send tracking // Command send tracking — no-op for now
} }
} }
@@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.desktop.subscriptions
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import com.vitorpamplona.amethyst.desktop.DebugConfig
import com.vitorpamplona.amethyst.desktop.network.RelayConnectionManager import com.vitorpamplona.amethyst.desktop.network.RelayConnectionManager
import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.IRequestListener import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.IRequestListener
@@ -74,6 +75,7 @@ fun rememberSubscription(
DisposableEffect(*keys, subscription?.subId) { DisposableEffect(*keys, subscription?.subId) {
subscription?.let { cfg -> subscription?.let { cfg ->
if (cfg.relays.isNotEmpty()) { if (cfg.relays.isNotEmpty()) {
DebugConfig.log("SUB OPEN ${cfg.subId} relays=${cfg.relays.size}")
relayManager.subscribe( relayManager.subscribe(
subId = cfg.subId, subId = cfg.subId,
filters = cfg.filters, filters = cfg.filters,
@@ -101,7 +103,10 @@ fun rememberSubscription(
} }
onDispose { onDispose {
subscription?.let { relayManager.unsubscribe(it.subId) } subscription?.let {
DebugConfig.log("SUB CLOSE ${it.subId}")
relayManager.unsubscribe(it.subId)
}
} }
} }
@@ -76,7 +76,7 @@ fun BookmarksScreen(
onNavigateToThread: (String) -> Unit = {}, onNavigateToThread: (String) -> Unit = {},
onZapFeedback: (ZapFeedback) -> Unit = {}, onZapFeedback: (ZapFeedback) -> Unit = {},
) { ) {
val relayStatuses by relayManager.relayStatuses.collectAsState() val connectedRelays by relayManager.connectedRelays.collectAsState()
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
// Tab state // Tab state
@@ -121,9 +121,8 @@ fun BookmarksScreen(
} }
// Subscribe to user's bookmark list (kind 30001) // Subscribe to user's bookmark list (kind 30001)
rememberSubscription(relayStatuses, account.pubKeyHex, relayManager = relayManager) { rememberSubscription(connectedRelays, account.pubKeyHex, relayManager = relayManager) {
val configuredRelays = relayStatuses.keys if (connectedRelays.isNotEmpty()) {
if (configuredRelays.isNotEmpty()) {
SubscriptionConfig( SubscriptionConfig(
subId = "bookmarks-list-${account.pubKeyHex.take(8)}", subId = "bookmarks-list-${account.pubKeyHex.take(8)}",
filters = filters =
@@ -134,7 +133,7 @@ fun BookmarksScreen(
limit = 1, limit = 1,
), ),
), ),
relays = configuredRelays, relays = connectedRelays,
onEvent = { event, _, _, _ -> onEvent = { event, _, _, _ ->
if (event is BookmarkListEvent) { if (event is BookmarkListEvent) {
bookmarkList = event bookmarkList = event
@@ -179,9 +178,8 @@ fun BookmarksScreen(
} }
// Subscribe to fetch the actual public bookmarked events // Subscribe to fetch the actual public bookmarked events
rememberSubscription(relayStatuses, publicBookmarkIds, relayManager = relayManager) { rememberSubscription(connectedRelays, publicBookmarkIds, relayManager = relayManager) {
val configuredRelays = relayStatuses.keys if (connectedRelays.isNotEmpty() && publicBookmarkIds.isNotEmpty()) {
if (configuredRelays.isNotEmpty() && publicBookmarkIds.isNotEmpty()) {
publicEventState.clear() publicEventState.clear()
SubscriptionConfig( SubscriptionConfig(
subId = "public-bookmarked-events-${System.currentTimeMillis()}", subId = "public-bookmarked-events-${System.currentTimeMillis()}",
@@ -189,7 +187,7 @@ fun BookmarksScreen(
listOf( listOf(
FilterBuilders.byIds(publicBookmarkIds), FilterBuilders.byIds(publicBookmarkIds),
), ),
relays = configuredRelays, relays = connectedRelays,
onEvent = { event, _, _, _ -> onEvent = { event, _, _, _ ->
publicEventState.addItem(event) publicEventState.addItem(event)
}, },
@@ -201,9 +199,8 @@ fun BookmarksScreen(
} }
// Subscribe to fetch the actual private bookmarked events // Subscribe to fetch the actual private bookmarked events
rememberSubscription(relayStatuses, privateBookmarkIds, relayManager = relayManager) { rememberSubscription(connectedRelays, privateBookmarkIds, relayManager = relayManager) {
val configuredRelays = relayStatuses.keys if (connectedRelays.isNotEmpty() && privateBookmarkIds.isNotEmpty()) {
if (configuredRelays.isNotEmpty() && privateBookmarkIds.isNotEmpty()) {
privateEventState.clear() privateEventState.clear()
SubscriptionConfig( SubscriptionConfig(
subId = "private-bookmarked-events-${System.currentTimeMillis()}", subId = "private-bookmarked-events-${System.currentTimeMillis()}",
@@ -211,7 +208,7 @@ fun BookmarksScreen(
listOf( listOf(
FilterBuilders.byIds(privateBookmarkIds), FilterBuilders.byIds(privateBookmarkIds),
), ),
relays = configuredRelays, relays = connectedRelays,
onEvent = { event, _, _, _ -> onEvent = { event, _, _, _ ->
privateEventState.addItem(event) privateEventState.addItem(event)
}, },
@@ -177,15 +177,12 @@ private suspend fun publishNote(
replyTo: com.vitorpamplona.quartz.nip01Core.core.Event?, replyTo: com.vitorpamplona.quartz.nip01Core.core.Event?,
) { ) {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
// Check read-only mode
if (account.isReadOnly) { if (account.isReadOnly) {
throw IllegalStateException("Cannot post in read-only mode") throw IllegalStateException("Cannot post in read-only mode")
} }
// Use shared PublishAction from commons
val signedEvent = PublishAction.publishTextNote(content, account.signer, replyTo) val signedEvent = PublishAction.publishTextNote(content, account.signer, replyTo)
// Broadcast to all configured relays
relayManager.broadcastToAll(signedEvent) relayManager.broadcastToAll(signedEvent)
} }
} }
@@ -58,6 +58,7 @@ import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.state.EventCollectionState import com.vitorpamplona.amethyst.commons.state.EventCollectionState
import com.vitorpamplona.amethyst.commons.ui.components.EmptyState import com.vitorpamplona.amethyst.commons.ui.components.EmptyState
import com.vitorpamplona.amethyst.commons.ui.components.LoadingState import com.vitorpamplona.amethyst.commons.ui.components.LoadingState
import com.vitorpamplona.amethyst.desktop.DebugConfig
import com.vitorpamplona.amethyst.desktop.DesktopPreferences import com.vitorpamplona.amethyst.desktop.DesktopPreferences
import com.vitorpamplona.amethyst.desktop.account.AccountState import com.vitorpamplona.amethyst.desktop.account.AccountState
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
@@ -158,7 +159,6 @@ fun FeedScreen(
onZapFeedback: (ZapFeedback) -> Unit = {}, onZapFeedback: (ZapFeedback) -> Unit = {},
) { ) {
val connectedRelays by relayManager.connectedRelays.collectAsState() val connectedRelays by relayManager.connectedRelays.collectAsState()
val relayStatuses by relayManager.relayStatuses.collectAsState()
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val eventState = val eventState =
remember { remember {
@@ -190,15 +190,18 @@ fun FeedScreen(
val initialLoadComplete = eoseReceivedCount > 0 val initialLoadComplete = eoseReceivedCount > 0
// Load followed users for Following feed mode // Load followed users for Following feed mode
rememberSubscription(relayStatuses, account, feedMode, relayManager = relayManager) { rememberSubscription(connectedRelays, account, feedMode, relayManager = relayManager) {
val configuredRelays = relayStatuses.keys DebugConfig.log("contactList sub: relays=${connectedRelays.size}, account=${account?.pubKeyHex?.take(8)}, mode=$feedMode")
if (configuredRelays.isNotEmpty() && account != null && feedMode == FeedMode.FOLLOWING) { if (connectedRelays.isNotEmpty() && account != null && feedMode == FeedMode.FOLLOWING) {
createContactListSubscription( createContactListSubscription(
relays = configuredRelays, relays = connectedRelays,
pubKeyHex = account.pubKeyHex, pubKeyHex = account.pubKeyHex,
onEvent = { event, _, _, _ -> onEvent = { event, _, relay, _ ->
DebugConfig.log("contactList event: kind=${event.kind}, isContactList=${event is ContactListEvent}, from=$relay")
if (event is ContactListEvent) { if (event is ContactListEvent) {
followedUsers = event.verifiedFollowKeySet() val follows = event.verifiedFollowKeySet()
DebugConfig.log("followedUsers: ${follows.size} users")
followedUsers = follows
} }
}, },
) )
@@ -208,9 +211,8 @@ fun FeedScreen(
} }
// Load user's bookmark list // Load user's bookmark list
rememberSubscription(relayStatuses, account, relayManager = relayManager) { rememberSubscription(connectedRelays, account, relayManager = relayManager) {
val configuredRelays = relayStatuses.keys if (connectedRelays.isNotEmpty() && account != null) {
if (configuredRelays.isNotEmpty() && account != null) {
SubscriptionConfig( SubscriptionConfig(
subId = "bookmarks-${account.pubKeyHex.take(8)}", subId = "bookmarks-${account.pubKeyHex.take(8)}",
filters = filters =
@@ -221,7 +223,7 @@ fun FeedScreen(
limit = 1, limit = 1,
), ),
), ),
relays = configuredRelays, relays = connectedRelays,
onEvent = { event, _, _, _ -> onEvent = { event, _, _, _ ->
if (event is BookmarkListEvent) { if (event is BookmarkListEvent) {
bookmarkList = event bookmarkList = event
@@ -249,16 +251,16 @@ fun FeedScreen(
} }
// Subscribe to feed based on mode // Subscribe to feed based on mode
rememberSubscription(relayStatuses, feedMode, followedUsers, relayManager = relayManager) { rememberSubscription(connectedRelays, feedMode, followedUsers, relayManager = relayManager) {
val configuredRelays = relayStatuses.keys DebugConfig.log("feedSub: mode=$feedMode, relays=${connectedRelays.size}, followedUsers=${followedUsers.size}")
if (configuredRelays.isEmpty()) { if (connectedRelays.isEmpty()) {
return@rememberSubscription null return@rememberSubscription null
} }
when (feedMode) { when (feedMode) {
FeedMode.GLOBAL -> { FeedMode.GLOBAL -> {
createGlobalFeedSubscription( createGlobalFeedSubscription(
relays = configuredRelays, relays = connectedRelays,
onEvent = { event, _, _, _ -> onEvent = { event, _, _, _ ->
// Store metadata events in cache // Store metadata events in cache
if (event is MetadataEvent) { if (event is MetadataEvent) {
@@ -275,7 +277,7 @@ fun FeedScreen(
FeedMode.FOLLOWING -> { FeedMode.FOLLOWING -> {
if (followedUsers.isNotEmpty()) { if (followedUsers.isNotEmpty()) {
createFollowingFeedSubscription( createFollowingFeedSubscription(
relays = configuredRelays, relays = connectedRelays,
followedUsers = followedUsers.toList(), followedUsers = followedUsers.toList(),
onEvent = { event, _, _, _ -> onEvent = { event, _, _, _ ->
// Store metadata events in cache // Store metadata events in cache
@@ -297,14 +299,13 @@ fun FeedScreen(
// Subscribe to zaps for visible events // Subscribe to zaps for visible events
val eventIds = events.map { it.id } val eventIds = events.map { it.id }
rememberSubscription(relayStatuses, eventIds, relayManager = relayManager) { rememberSubscription(connectedRelays, eventIds, relayManager = relayManager) {
val configuredRelays = relayStatuses.keys if (connectedRelays.isEmpty() || eventIds.isEmpty()) {
if (configuredRelays.isEmpty() || eventIds.isEmpty()) {
return@rememberSubscription null return@rememberSubscription null
} }
createZapsSubscription( createZapsSubscription(
relays = configuredRelays, relays = connectedRelays,
eventIds = eventIds, eventIds = eventIds,
onEvent = { event, _, _, _ -> onEvent = { event, _, _, _ ->
if (event is LnZapEvent) { if (event is LnZapEvent) {
@@ -328,9 +329,8 @@ fun FeedScreen(
.flatten() .flatten()
.map { it.senderPubKey } .map { it.senderPubKey }
.distinct() .distinct()
rememberSubscription(relayStatuses, zapSenderPubkeys, relayManager = relayManager) { rememberSubscription(connectedRelays, zapSenderPubkeys, relayManager = relayManager) {
val configuredRelays = relayStatuses.keys if (connectedRelays.isEmpty() || zapSenderPubkeys.isEmpty()) {
if (configuredRelays.isEmpty() || zapSenderPubkeys.isEmpty()) {
return@rememberSubscription null return@rememberSubscription null
} }
@@ -348,7 +348,7 @@ fun FeedScreen(
} }
createBatchMetadataSubscription( createBatchMetadataSubscription(
relays = configuredRelays, relays = connectedRelays,
pubKeyHexList = missingPubkeys, pubKeyHexList = missingPubkeys,
onEvent = { event, _, _, _ -> onEvent = { event, _, _, _ ->
if (event is MetadataEvent) { if (event is MetadataEvent) {
@@ -359,14 +359,13 @@ fun FeedScreen(
} }
// Subscribe to reactions for visible events // Subscribe to reactions for visible events
rememberSubscription(relayStatuses, eventIds, relayManager = relayManager) { rememberSubscription(connectedRelays, eventIds, relayManager = relayManager) {
val configuredRelays = relayStatuses.keys if (connectedRelays.isEmpty() || eventIds.isEmpty()) {
if (configuredRelays.isEmpty() || eventIds.isEmpty()) {
return@rememberSubscription null return@rememberSubscription null
} }
createReactionsSubscription( createReactionsSubscription(
relays = configuredRelays, relays = connectedRelays,
eventIds = eventIds, eventIds = eventIds,
onEvent = { event, _, _, _ -> onEvent = { event, _, _, _ ->
if (event is ReactionEvent) { if (event is ReactionEvent) {
@@ -382,14 +381,13 @@ fun FeedScreen(
} }
// Subscribe to replies for visible events // Subscribe to replies for visible events
rememberSubscription(relayStatuses, eventIds, relayManager = relayManager) { rememberSubscription(connectedRelays, eventIds, relayManager = relayManager) {
val configuredRelays = relayStatuses.keys if (connectedRelays.isEmpty() || eventIds.isEmpty()) {
if (configuredRelays.isEmpty() || eventIds.isEmpty()) {
return@rememberSubscription null return@rememberSubscription null
} }
createRepliesSubscription( createRepliesSubscription(
relays = configuredRelays, relays = connectedRelays,
eventIds = eventIds, eventIds = eventIds,
onEvent = { event, _, _, _ -> onEvent = { event, _, _, _ ->
// Find the event this is replying to // Find the event this is replying to
@@ -410,14 +408,13 @@ fun FeedScreen(
} }
// Subscribe to reposts for visible events // Subscribe to reposts for visible events
rememberSubscription(relayStatuses, eventIds, relayManager = relayManager) { rememberSubscription(connectedRelays, eventIds, relayManager = relayManager) {
val configuredRelays = relayStatuses.keys if (connectedRelays.isEmpty() || eventIds.isEmpty()) {
if (configuredRelays.isEmpty() || eventIds.isEmpty()) {
return@rememberSubscription null return@rememberSubscription null
} }
createRepostsSubscription( createRepostsSubscription(
relays = configuredRelays, relays = connectedRelays,
eventIds = eventIds, eventIds = eventIds,
onEvent = { event, _, _, _ -> onEvent = { event, _, _, _ ->
if (event is RepostEvent) { if (event is RepostEvent) {
@@ -443,14 +440,13 @@ fun FeedScreen(
} }
// Fallback subscription if coordinator not available // Fallback subscription if coordinator not available
rememberSubscription(relayStatuses, authorPubkeys, subscriptionsCoordinator, relayManager = relayManager) { rememberSubscription(connectedRelays, authorPubkeys, subscriptionsCoordinator, relayManager = relayManager) {
// Skip if using coordinator // Skip if using coordinator
if (subscriptionsCoordinator != null) { if (subscriptionsCoordinator != null) {
return@rememberSubscription null return@rememberSubscription null
} }
val configuredRelays = relayStatuses.keys if (connectedRelays.isEmpty() || authorPubkeys.isEmpty()) {
if (configuredRelays.isEmpty() || authorPubkeys.isEmpty()) {
return@rememberSubscription null return@rememberSubscription null
} }
@@ -468,7 +464,7 @@ fun FeedScreen(
} }
createBatchMetadataSubscription( createBatchMetadataSubscription(
relays = configuredRelays, relays = connectedRelays,
pubKeyHexList = missingPubkeys, pubKeyHexList = missingPubkeys,
onEvent = { event, _, _, _ -> onEvent = { event, _, _, _ ->
if (event is MetadataEvent) { if (event is MetadataEvent) {
@@ -20,14 +20,24 @@
*/ */
package com.vitorpamplona.amethyst.desktop.ui package com.vitorpamplona.amethyst.desktop.ui
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@@ -38,15 +48,16 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.login_subtitle_desktop import com.vitorpamplona.amethyst.commons.resources.login_subtitle_desktop
import com.vitorpamplona.amethyst.commons.resources.login_title import com.vitorpamplona.amethyst.commons.resources.login_title
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
import com.vitorpamplona.amethyst.desktop.network.RelayStatus
import com.vitorpamplona.amethyst.desktop.ui.auth.LoginCard import com.vitorpamplona.amethyst.desktop.ui.auth.LoginCard
import com.vitorpamplona.amethyst.desktop.ui.auth.NewKeyWarningCard import com.vitorpamplona.amethyst.desktop.ui.auth.NewKeyWarningCard
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@@ -55,7 +66,6 @@ import org.jetbrains.compose.resources.stringResource
@Composable @Composable
fun LoginScreen( fun LoginScreen(
accountManager: AccountManager, accountManager: AccountManager,
relayClient: INostrClient?,
onLoginSuccess: () -> Unit, onLoginSuccess: () -> Unit,
) { ) {
var showNewKeyDialog by remember { mutableStateOf(false) } var showNewKeyDialog by remember { mutableStateOf(false) }
@@ -96,26 +106,16 @@ fun LoginScreen(
generatedAccount = accountManager.generateNewAccount() generatedAccount = accountManager.generateNewAccount()
showNewKeyDialog = true showNewKeyDialog = true
}, },
onLoginBunker = onLoginBunker = { bunkerUri ->
if (relayClient != null) { accountManager.loginWithBunker(bunkerUri).map {
{ bunkerUri -> onLoginSuccess()
accountManager.loginWithBunker(bunkerUri, relayClient).map { }
onLoginSuccess() },
} onLoginNostrConnect = { onUriGenerated ->
} accountManager.loginWithNostrConnect(onUriGenerated).map {
} else { onLoginSuccess()
null }
}, },
onLoginNostrConnect =
if (relayClient != null) {
{ onUriGenerated ->
accountManager.loginWithNostrConnect(relayClient, onUriGenerated).map {
onLoginSuccess()
}
}
} else {
null
},
) )
val account = generatedAccount val account = generatedAccount
@@ -137,7 +137,15 @@ fun LoginScreen(
} }
@Composable @Composable
fun ConnectingRelaysScreen() { fun ConnectingRelaysScreen(
subtitle: String = "Restoring session",
relayStatuses: Map<com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl, RelayStatus> = emptyMap(),
) {
val total = relayStatuses.size
val connected = relayStatuses.values.count { it.connected }
val failed = relayStatuses.values.count { it.error != null }
val progress = if (total > 0) connected.toFloat() / total else 0f
Column( Column(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
@@ -156,7 +164,7 @@ fun ConnectingRelaysScreen() {
Spacer(Modifier.height(16.dp)) Spacer(Modifier.height(16.dp))
Text( Text(
"Connecting to relays...", if (total > 0) "Connecting to relays ($connected/$total)" else "Connecting to relays...",
style = MaterialTheme.typography.bodyLarge, style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
@@ -164,9 +172,89 @@ fun ConnectingRelaysScreen() {
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
Text( Text(
"Restoring remote signer session", subtitle,
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
) )
// Progress bar
if (total > 0) {
Spacer(Modifier.height(16.dp))
val animatedProgress by animateFloatAsState(
targetValue = progress,
animationSpec = tween(300),
label = "relay-progress",
)
LinearProgressIndicator(
progress = { animatedProgress },
modifier = Modifier.widthIn(max = 300.dp).fillMaxWidth(),
color = MaterialTheme.colorScheme.primary,
trackColor = MaterialTheme.colorScheme.surfaceVariant,
)
Spacer(Modifier.height(16.dp))
// Per-relay status rows
Column(
modifier = Modifier.widthIn(max = 360.dp).fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
relayStatuses.values.forEach { status ->
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp),
) {
when {
status.connected -> {
Icon(
Icons.Default.Check,
contentDescription = null,
tint = Color(0xFF4CAF50),
modifier = Modifier.size(14.dp),
)
}
status.error != null -> {
Icon(
Icons.Default.Close,
contentDescription = null,
tint = MaterialTheme.colorScheme.error,
modifier = Modifier.size(14.dp),
)
}
else -> {
CircularProgressIndicator(
modifier = Modifier.size(14.dp),
strokeWidth = 1.5.dp,
)
}
}
Text(
status.url.url
.removePrefix("wss://")
.removeSuffix("/"),
style = MaterialTheme.typography.bodySmall,
color =
if (status.error != null) {
MaterialTheme.colorScheme.error.copy(alpha = 0.8f)
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
)
if (status.connected && status.pingMs != null) {
Text(
"${status.pingMs}ms",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f),
)
}
}
}
}
}
} }
} }
@@ -113,7 +113,6 @@ fun NotificationsScreen(
subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator? = null, subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator? = null,
) { ) {
val connectedRelays by relayManager.connectedRelays.collectAsState() val connectedRelays by relayManager.connectedRelays.collectAsState()
val relayStatuses by relayManager.relayStatuses.collectAsState()
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val notificationState = val notificationState =
remember { remember {
@@ -139,11 +138,10 @@ fun NotificationsScreen(
val initialLoadComplete = eoseReceivedCount > 0 val initialLoadComplete = eoseReceivedCount > 0
// Subscribe to notifications // Subscribe to notifications
rememberSubscription(relayStatuses, account.pubKeyHex, relayManager = relayManager) { rememberSubscription(connectedRelays, account.pubKeyHex, relayManager = relayManager) {
val configuredRelays = relayStatuses.keys if (connectedRelays.isNotEmpty()) {
if (configuredRelays.isNotEmpty()) {
createNotificationsSubscription( createNotificationsSubscription(
relays = configuredRelays, relays = connectedRelays,
pubKeyHex = account.pubKeyHex, pubKeyHex = account.pubKeyHex,
onEvent = { event, _, _, _ -> onEvent = { event, _, _, _ ->
// Skip events from the user themselves (except zaps) // Skip events from the user themselves (except zaps)
@@ -175,7 +175,6 @@ fun ReadsScreen(
onNavigateToArticle: (String) -> Unit = {}, onNavigateToArticle: (String) -> Unit = {},
) { ) {
val connectedRelays by relayManager.connectedRelays.collectAsState() val connectedRelays by relayManager.connectedRelays.collectAsState()
val relayStatuses by relayManager.relayStatuses.collectAsState()
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val eventState = val eventState =
@@ -195,11 +194,11 @@ fun ReadsScreen(
val initialLoadComplete = eoseReceivedCount > 0 val initialLoadComplete = eoseReceivedCount > 0
// Load followed users for Following feed mode // Load followed users for Following feed mode
rememberSubscription(relayStatuses, account, feedMode, relayManager = relayManager) { rememberSubscription(connectedRelays, account, feedMode, relayManager = relayManager) {
val configuredRelays = relayStatuses.keys val connectedRelays = connectedRelays
if (configuredRelays.isNotEmpty() && account != null && feedMode == FeedMode.FOLLOWING) { if (connectedRelays.isNotEmpty() && account != null && feedMode == FeedMode.FOLLOWING) {
createContactListSubscription( createContactListSubscription(
relays = configuredRelays, relays = connectedRelays,
pubKeyHex = account.pubKeyHex, pubKeyHex = account.pubKeyHex,
onEvent = { event, _, _, _ -> onEvent = { event, _, _, _ ->
if (event is ContactListEvent) { if (event is ContactListEvent) {
@@ -219,16 +218,16 @@ fun ReadsScreen(
} }
// Subscribe to long-form content feed // Subscribe to long-form content feed
rememberSubscription(relayStatuses, feedMode, followedUsers, relayManager = relayManager) { rememberSubscription(connectedRelays, feedMode, followedUsers, relayManager = relayManager) {
val configuredRelays = relayStatuses.keys val connectedRelays = connectedRelays
if (configuredRelays.isEmpty()) { if (connectedRelays.isEmpty()) {
return@rememberSubscription null return@rememberSubscription null
} }
when (feedMode) { when (feedMode) {
FeedMode.GLOBAL -> { FeedMode.GLOBAL -> {
createLongFormFeedSubscription( createLongFormFeedSubscription(
relays = configuredRelays, relays = connectedRelays,
onEvent = { event, _, _, _ -> onEvent = { event, _, _, _ ->
if (event is LongTextNoteEvent) { if (event is LongTextNoteEvent) {
eventState.addItem(event) eventState.addItem(event)
@@ -243,7 +242,7 @@ fun ReadsScreen(
FeedMode.FOLLOWING -> { FeedMode.FOLLOWING -> {
if (followedUsers.isNotEmpty()) { if (followedUsers.isNotEmpty()) {
createFollowingLongFormFeedSubscription( createFollowingLongFormFeedSubscription(
relays = configuredRelays, relays = connectedRelays,
followedUsers = followedUsers.toList(), followedUsers = followedUsers.toList(),
onEvent = { event, _, _, _ -> onEvent = { event, _, _, _ ->
if (event is LongTextNoteEvent) { if (event is LongTextNoteEvent) {
@@ -94,7 +94,7 @@ fun SearchScreen(
searchState.updateSearchText(initialQuery) searchState.updateSearchText(initialQuery)
} }
} }
val relayStatuses by relayManager.relayStatuses.collectAsState() val connectedRelays by relayManager.connectedRelays.collectAsState()
// Collect state from SearchBarState // Collect state from SearchBarState
val searchText by searchState.searchText.collectAsState() val searchText by searchState.searchText.collectAsState()
@@ -104,15 +104,14 @@ fun SearchScreen(
val isSearchingRelays by searchState.isSearchingRelays.collectAsState() val isSearchingRelays by searchState.isSearchingRelays.collectAsState()
// NIP-50 relay search when local cache has few/no results // NIP-50 relay search when local cache has few/no results
rememberSubscription(relayStatuses, searchText, cachedUserResults.size, relayManager = relayManager) { rememberSubscription(connectedRelays, searchText, cachedUserResults.size, relayManager = relayManager) {
val configuredRelays = relayStatuses.keys if (connectedRelays.isEmpty()) return@rememberSubscription null
if (configuredRelays.isEmpty()) return@rememberSubscription null
// Only search relays if we have a real query and limited local results // Only search relays if we have a real query and limited local results
if (searchState.shouldSearchRelays) { if (searchState.shouldSearchRelays) {
searchState.startRelaySearch() searchState.startRelaySearch()
createSearchPeopleSubscription( createSearchPeopleSubscription(
relays = configuredRelays, relays = connectedRelays,
searchQuery = searchText, searchQuery = searchText,
limit = 20, limit = 20,
onEvent = { event, _, _, _ -> onEvent = { event, _, _, _ ->
@@ -134,9 +133,8 @@ fun SearchScreen(
} }
// Subscribe to metadata for searched users (to populate cache) // Subscribe to metadata for searched users (to populate cache)
rememberSubscription(relayStatuses, searchText, relayManager = relayManager) { rememberSubscription(connectedRelays, searchText, relayManager = relayManager) {
val configuredRelays = relayStatuses.keys if (connectedRelays.isEmpty() || searchText.length < 2) {
if (configuredRelays.isEmpty() || searchText.length < 2) {
return@rememberSubscription null return@rememberSubscription null
} }
@@ -144,7 +142,7 @@ fun SearchScreen(
val pubkeyHex = decodePublicKeyAsHexOrNull(searchText) val pubkeyHex = decodePublicKeyAsHexOrNull(searchText)
if (pubkeyHex != null) { if (pubkeyHex != null) {
createMetadataSubscription( createMetadataSubscription(
relays = configuredRelays, relays = connectedRelays,
pubKeyHex = pubkeyHex, pubKeyHex = pubkeyHex,
onEvent = { event, _, _, _ -> onEvent = { event, _, _, _ ->
if (event is MetadataEvent) { if (event is MetadataEvent) {
@@ -96,7 +96,6 @@ fun ThreadScreen(
onReply: (Event) -> Unit = {}, onReply: (Event) -> Unit = {},
) { ) {
val connectedRelays by relayManager.connectedRelays.collectAsState() val connectedRelays by relayManager.connectedRelays.collectAsState()
val relayStatuses by relayManager.relayStatuses.collectAsState()
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
// State for the root note // State for the root note
@@ -149,9 +148,8 @@ fun ThreadScreen(
} }
// Subscribe to user's bookmark list // Subscribe to user's bookmark list
rememberSubscription(relayStatuses, account, relayManager = relayManager) { rememberSubscription(connectedRelays, account, relayManager = relayManager) {
val configuredRelays = relayStatuses.keys if (connectedRelays.isNotEmpty() && account != null) {
if (configuredRelays.isNotEmpty() && account != null) {
SubscriptionConfig( SubscriptionConfig(
subId = "thread-bookmarks-${account.pubKeyHex.take(8)}", subId = "thread-bookmarks-${account.pubKeyHex.take(8)}",
filters = filters =
@@ -162,7 +160,7 @@ fun ThreadScreen(
limit = 1, limit = 1,
), ),
), ),
relays = configuredRelays, relays = connectedRelays,
onEvent = { event, _, _, _ -> onEvent = { event, _, _, _ ->
if (event is BookmarkListEvent) { if (event is BookmarkListEvent) {
bookmarkList = event bookmarkList = event
@@ -183,11 +181,10 @@ fun ThreadScreen(
} }
// Subscribe to the root note // Subscribe to the root note
rememberSubscription(relayStatuses, noteId, relayManager = relayManager) { rememberSubscription(connectedRelays, noteId, relayManager = relayManager) {
val configuredRelays = relayStatuses.keys if (connectedRelays.isNotEmpty()) {
if (configuredRelays.isNotEmpty()) {
createNoteSubscription( createNoteSubscription(
relays = configuredRelays, relays = connectedRelays,
noteId = noteId, noteId = noteId,
onEvent = { event, _, _, _ -> onEvent = { event, _, _, _ ->
if (event.id == noteId) { if (event.id == noteId) {
@@ -205,11 +202,10 @@ fun ThreadScreen(
} }
// Subscribe to replies // Subscribe to replies
rememberSubscription(relayStatuses, noteId, relayManager = relayManager) { rememberSubscription(connectedRelays, noteId, relayManager = relayManager) {
val configuredRelays = relayStatuses.keys if (connectedRelays.isNotEmpty()) {
if (configuredRelays.isNotEmpty()) {
createThreadRepliesSubscription( createThreadRepliesSubscription(
relays = configuredRelays, relays = connectedRelays,
noteId = noteId, noteId = noteId,
onEvent = { event, _, _, _ -> onEvent = { event, _, _, _ ->
replyEventState.addItem(event) replyEventState.addItem(event)
@@ -225,14 +221,13 @@ fun ThreadScreen(
// Subscribe to zaps for thread events // Subscribe to zaps for thread events
val allEventIds = listOf(noteId) + replyEvents.map { it.id } val allEventIds = listOf(noteId) + replyEvents.map { it.id }
rememberSubscription(relayStatuses, allEventIds, relayManager = relayManager) { rememberSubscription(connectedRelays, allEventIds, relayManager = relayManager) {
val configuredRelays = relayStatuses.keys if (connectedRelays.isEmpty() || allEventIds.isEmpty()) {
if (configuredRelays.isEmpty() || allEventIds.isEmpty()) {
return@rememberSubscription null return@rememberSubscription null
} }
createZapsSubscription( createZapsSubscription(
relays = configuredRelays, relays = connectedRelays,
eventIds = allEventIds, eventIds = allEventIds,
onEvent = { event, _, _, _ -> onEvent = { event, _, _, _ ->
if (event is LnZapEvent) { if (event is LnZapEvent) {
@@ -251,14 +246,13 @@ fun ThreadScreen(
} }
// Subscribe to reactions for thread events // Subscribe to reactions for thread events
rememberSubscription(relayStatuses, allEventIds, relayManager = relayManager) { rememberSubscription(connectedRelays, allEventIds, relayManager = relayManager) {
val configuredRelays = relayStatuses.keys if (connectedRelays.isEmpty() || allEventIds.isEmpty()) {
if (configuredRelays.isEmpty() || allEventIds.isEmpty()) {
return@rememberSubscription null return@rememberSubscription null
} }
createReactionsSubscription( createReactionsSubscription(
relays = configuredRelays, relays = connectedRelays,
eventIds = allEventIds, eventIds = allEventIds,
onEvent = { event, _, _, _ -> onEvent = { event, _, _, _ ->
if (event is ReactionEvent) { if (event is ReactionEvent) {
@@ -274,14 +268,13 @@ fun ThreadScreen(
} }
// Subscribe to replies for thread events (for counts) // Subscribe to replies for thread events (for counts)
rememberSubscription(relayStatuses, allEventIds, relayManager = relayManager) { rememberSubscription(connectedRelays, allEventIds, relayManager = relayManager) {
val configuredRelays = relayStatuses.keys if (connectedRelays.isEmpty() || allEventIds.isEmpty()) {
if (configuredRelays.isEmpty() || allEventIds.isEmpty()) {
return@rememberSubscription null return@rememberSubscription null
} }
createRepliesSubscription( createRepliesSubscription(
relays = configuredRelays, relays = connectedRelays,
eventIds = allEventIds, eventIds = allEventIds,
onEvent = { event, _, _, _ -> onEvent = { event, _, _, _ ->
val replyToId = val replyToId =
@@ -301,14 +294,13 @@ fun ThreadScreen(
} }
// Subscribe to reposts for thread events // Subscribe to reposts for thread events
rememberSubscription(relayStatuses, allEventIds, relayManager = relayManager) { rememberSubscription(connectedRelays, allEventIds, relayManager = relayManager) {
val configuredRelays = relayStatuses.keys if (connectedRelays.isEmpty() || allEventIds.isEmpty()) {
if (configuredRelays.isEmpty() || allEventIds.isEmpty()) {
return@rememberSubscription null return@rememberSubscription null
} }
createRepostsSubscription( createRepostsSubscription(
relays = configuredRelays, relays = connectedRelays,
eventIds = allEventIds, eventIds = allEventIds,
onEvent = { event, _, _, _ -> onEvent = { event, _, _, _ ->
if (event is RepostEvent) { if (event is RepostEvent) {
@@ -109,7 +109,6 @@ fun UserProfileScreen(
onZapFeedback: (ZapFeedback) -> Unit = {}, onZapFeedback: (ZapFeedback) -> Unit = {},
) { ) {
val connectedRelays by relayManager.connectedRelays.collectAsState() val connectedRelays by relayManager.connectedRelays.collectAsState()
val relayStatuses by relayManager.relayStatuses.collectAsState()
// User metadata // User metadata
var displayName by remember { mutableStateOf<String?>(null) } var displayName by remember { mutableStateOf<String?>(null) }
@@ -154,11 +153,10 @@ fun UserProfileScreen(
var eoseReceivedCount by remember(account) { mutableStateOf(0) } var eoseReceivedCount by remember(account) { mutableStateOf(0) }
// Load current user's contact list (for follow state) // Load current user's contact list (for follow state)
rememberSubscription(relayStatuses, account, relayManager = relayManager) { rememberSubscription(connectedRelays, account, relayManager = relayManager) {
val configuredRelays = relayStatuses.keys if (connectedRelays.isNotEmpty() && account != null) {
if (configuredRelays.isNotEmpty() && account != null) {
createContactListSubscription( createContactListSubscription(
relays = configuredRelays, relays = connectedRelays,
pubKeyHex = account.pubKeyHex, pubKeyHex = account.pubKeyHex,
onEvent = { event, _, _, _ -> onEvent = { event, _, _, _ ->
if (event is ContactListEvent) { if (event is ContactListEvent) {
@@ -175,7 +173,7 @@ fun UserProfileScreen(
eoseReceivedCount++ eoseReceivedCount++
// Wait for EOSE from at least 2 relays or all relays before enabling button // Wait for EOSE from at least 2 relays or all relays before enabling button
val minEoseCount = minOf(2, configuredRelays.size) val minEoseCount = minOf(2, connectedRelays.size)
if (eoseReceivedCount >= minEoseCount && !contactListLoaded) { if (eoseReceivedCount >= minEoseCount && !contactListLoaded) {
contactListLoaded = true contactListLoaded = true
} }
@@ -194,11 +192,10 @@ fun UserProfileScreen(
} }
// Subscribe to user metadata // Subscribe to user metadata
rememberSubscription(relayStatuses, pubKeyHex, retryTrigger, relayManager = relayManager) { rememberSubscription(connectedRelays, pubKeyHex, retryTrigger, relayManager = relayManager) {
val configuredRelays = relayStatuses.keys if (connectedRelays.isNotEmpty()) {
if (configuredRelays.isNotEmpty()) {
createMetadataSubscription( createMetadataSubscription(
relays = configuredRelays, relays = connectedRelays,
pubKeyHex = pubKeyHex, pubKeyHex = pubKeyHex,
onEvent = { event, _, _, _ -> onEvent = { event, _, _, _ ->
if (event is MetadataEvent) { if (event is MetadataEvent) {
@@ -229,11 +226,10 @@ fun UserProfileScreen(
} }
// Subscribe to profile user's contact list (for following count) // Subscribe to profile user's contact list (for following count)
rememberSubscription(relayStatuses, pubKeyHex, retryTrigger, relayManager = relayManager) { rememberSubscription(connectedRelays, pubKeyHex, retryTrigger, relayManager = relayManager) {
val configuredRelays = relayStatuses.keys if (connectedRelays.isNotEmpty()) {
if (configuredRelays.isNotEmpty()) {
createContactListSubscription( createContactListSubscription(
relays = configuredRelays, relays = connectedRelays,
pubKeyHex = pubKeyHex, pubKeyHex = pubKeyHex,
onEvent = { event, _, _, _ -> onEvent = { event, _, _, _ ->
if (event is ContactListEvent) { if (event is ContactListEvent) {
@@ -252,9 +248,8 @@ fun UserProfileScreen(
val followerAuthors = remember(pubKeyHex) { mutableSetOf<String>() } val followerAuthors = remember(pubKeyHex) { mutableSetOf<String>() }
// Subscribe to followers (contact lists that tag this user) // Subscribe to followers (contact lists that tag this user)
rememberSubscription(relayStatuses, pubKeyHex, retryTrigger, relayManager = relayManager) { rememberSubscription(connectedRelays, pubKeyHex, retryTrigger, relayManager = relayManager) {
val configuredRelays = relayStatuses.keys if (connectedRelays.isNotEmpty()) {
if (configuredRelays.isNotEmpty()) {
// Clear previous followers when subscription restarts // Clear previous followers when subscription restarts
followerAuthors.clear() followerAuthors.clear()
followersCount = 0 followersCount = 0
@@ -269,7 +264,7 @@ fun UserProfileScreen(
limit = 500, limit = 500,
), ),
), ),
relays = configuredRelays, relays = connectedRelays,
onEvent = { event, _, _, _ -> onEvent = { event, _, _, _ ->
// Count unique authors who follow this user // Count unique authors who follow this user
if (followerAuthors.add(event.pubKey)) { if (followerAuthors.add(event.pubKey)) {
@@ -284,13 +279,12 @@ fun UserProfileScreen(
} }
// Subscribe to user posts // Subscribe to user posts
rememberSubscription(relayStatuses, pubKeyHex, retryTrigger, relayManager = relayManager) { rememberSubscription(connectedRelays, pubKeyHex, retryTrigger, relayManager = relayManager) {
val configuredRelays = relayStatuses.keys if (connectedRelays.isNotEmpty()) {
if (configuredRelays.isNotEmpty()) {
postsLoading = true postsLoading = true
postsError = null postsError = null
createUserPostsSubscription( createUserPostsSubscription(
relays = configuredRelays, relays = connectedRelays,
pubKeyHex = pubKeyHex, pubKeyHex = pubKeyHex,
onEvent = { event, _, _, _ -> onEvent = { event, _, _, _ ->
eventState.addItem(event) eventState.addItem(event)
@@ -61,32 +61,12 @@ import com.vitorpamplona.amethyst.commons.resources.login_button
import com.vitorpamplona.amethyst.commons.resources.login_card_subtitle import com.vitorpamplona.amethyst.commons.resources.login_card_subtitle
import com.vitorpamplona.amethyst.commons.resources.login_card_title import com.vitorpamplona.amethyst.commons.resources.login_card_title
import com.vitorpamplona.amethyst.commons.resources.login_generate_button import com.vitorpamplona.amethyst.commons.resources.login_generate_button
import com.vitorpamplona.amethyst.desktop.account.validateBunkerUri
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
private val HEX_64_REGEX = Regex("^[0-9a-fA-F]{64}$")
fun validateBunkerUri(input: String): String? {
val trimmed = input.trim()
if (!trimmed.startsWith("bunker://", ignoreCase = true)) return "Not a bunker URI"
val afterScheme = trimmed.substring("bunker://".length)
val parts = afterScheme.split("?", limit = 2)
val pubkeyPart = parts[0]
if (pubkeyPart.length != 64 || !pubkeyPart.matches(HEX_64_REGEX)) {
return "Invalid bunker URI. Expected: bunker://<64-hex-chars>?relay=wss://..."
}
if (parts.size < 2 || !parts[1].contains("relay=wss://", ignoreCase = true)) {
return "Bunker URI must include at least one relay parameter (relay=wss://...)"
}
return null // valid
}
@Composable @Composable
fun LoginCard( fun LoginCard(
onLogin: (String) -> Result<Unit>, onLogin: (String) -> Result<Unit>,
@@ -291,7 +271,7 @@ private fun NostrConnectContent(onLoginNostrConnect: suspend (onUriGenerated: (S
scope.launch(Dispatchers.IO) { scope.launch(Dispatchers.IO) {
val result = val result =
onLoginNostrConnect { uri -> onLoginNostrConnect { uri ->
nostrConnectUri = uri scope.launch(Dispatchers.Main) { nostrConnectUri = uri }
} }
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
result.onFailure { result.onFailure {
@@ -22,54 +22,65 @@ package com.vitorpamplona.amethyst.desktop.ui.auth
import androidx.compose.foundation.Canvas import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.FilterQuality
import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.toComposeImageBitmap
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.google.zxing.BarcodeFormat import com.google.zxing.BarcodeFormat
import com.google.zxing.EncodeHintType import com.google.zxing.EncodeHintType
import com.google.zxing.qrcode.QRCodeWriter import com.google.zxing.qrcode.QRCodeWriter
import java.awt.image.BufferedImage
@Composable @Composable
fun QrCodeCanvas( fun QrCodeCanvas(
data: String, data: String,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
size: Dp = 200.dp, size: Dp = 200.dp,
foreground: Color = MaterialTheme.colorScheme.onSurface,
background: Color = Color.White,
) { ) {
val matrix = val bitmap =
remember(data) { remember(data) {
QRCodeWriter().encode( createQrBitmap(data)
data,
BarcodeFormat.QR_CODE,
0,
0,
mapOf(EncodeHintType.MARGIN to 1),
)
} }
Canvas(modifier = modifier.size(size)) { Canvas(modifier = modifier.size(size)) {
val cellWidth = this.size.width / matrix.width drawImage(
val cellHeight = this.size.height / matrix.height image = bitmap,
srcOffset = IntOffset.Zero,
drawRect(background, Offset.Zero, this.size) srcSize = IntSize(bitmap.width, bitmap.height),
dstOffset = IntOffset.Zero,
for (x in 0 until matrix.width) { dstSize = IntSize(this.size.width.toInt(), this.size.height.toInt()),
for (y in 0 until matrix.height) { filterQuality = FilterQuality.None,
if (matrix.get(x, y)) { )
drawRect(
color = foreground,
topLeft = Offset(x * cellWidth, y * cellHeight),
size = Size(cellWidth, cellHeight),
)
}
}
}
} }
} }
private fun createQrBitmap(data: String): ImageBitmap {
val matrix =
QRCodeWriter().encode(
data,
BarcodeFormat.QR_CODE,
0,
0,
mapOf(EncodeHintType.MARGIN to 1),
)
val w = matrix.width
val h = matrix.height
val black = 0xFF000000.toInt()
val white = 0xFFFFFFFF.toInt()
val image = BufferedImage(w, h, BufferedImage.TYPE_INT_ARGB)
for (x in 0 until w) {
for (y in 0 until h) {
image.setRGB(x, y, if (matrix.get(x, y)) black else white)
}
}
return image.toComposeImageBitmap()
}
@@ -80,18 +80,17 @@ fun NewDmDialog(
val cachedUsers by searchState.cachedUserResults.collectAsState() val cachedUsers by searchState.cachedUserResults.collectAsState()
val relaySearchResults by searchState.relaySearchResults.collectAsState() val relaySearchResults by searchState.relaySearchResults.collectAsState()
val isSearchingRelays by searchState.isSearchingRelays.collectAsState() val isSearchingRelays by searchState.isSearchingRelays.collectAsState()
val relayStatuses by relayManager.relayStatuses.collectAsState() val connectedRelays by relayManager.connectedRelays.collectAsState()
val focusRequester = remember { FocusRequester() } val focusRequester = remember { FocusRequester() }
// NIP-50 relay search when local cache has few/no results // NIP-50 relay search when local cache has few/no results
rememberSubscription(relayStatuses, searchText, cachedUsers.size, relayManager = relayManager) { rememberSubscription(connectedRelays, searchText, cachedUsers.size, relayManager = relayManager) {
val configuredRelays = relayStatuses.keys if (connectedRelays.isEmpty()) return@rememberSubscription null
if (configuredRelays.isEmpty()) return@rememberSubscription null
if (searchState.shouldSearchRelays) { if (searchState.shouldSearchRelays) {
searchState.startRelaySearch() searchState.startRelaySearch()
createSearchPeopleSubscription( createSearchPeopleSubscription(
relays = configuredRelays, relays = connectedRelays,
searchQuery = searchText, searchQuery = searchText,
limit = 20, limit = 20,
onEvent = { event, _, _, _ -> onEvent = { event, _, _, _ ->
@@ -113,16 +112,15 @@ fun NewDmDialog(
} }
// Bech32 npub metadata loading // Bech32 npub metadata loading
rememberSubscription(relayStatuses, searchText, relayManager = relayManager) { rememberSubscription(connectedRelays, searchText, relayManager = relayManager) {
val configuredRelays = relayStatuses.keys if (connectedRelays.isEmpty() || searchText.length < 2) {
if (configuredRelays.isEmpty() || searchText.length < 2) {
return@rememberSubscription null return@rememberSubscription null
} }
val pubkeyHex = decodePublicKeyAsHexOrNull(searchText) val pubkeyHex = decodePublicKeyAsHexOrNull(searchText)
if (pubkeyHex != null) { if (pubkeyHex != null) {
createMetadataSubscription( createMetadataSubscription(
relays = configuredRelays, relays = connectedRelays,
pubKeyHex = pubkeyHex, pubKeyHex = pubkeyHex,
onEvent = { event, _, _, _ -> onEvent = { event, _, _, _ ->
if (event is MetadataEvent) { if (event is MetadataEvent) {
@@ -111,30 +111,10 @@ class AccountManagerLoadAccountTest {
storage.getPrivateKey(AccountManager.BUNKER_EPHEMERAL_KEY_ALIAS) storage.getPrivateKey(AccountManager.BUNKER_EPHEMERAL_KEY_ALIAS)
} returns null } returns null
val result = manager.loadSavedAccount(client = com.vitorpamplona.quartz.nip01Core.relay.client.EmptyNostrClient) val result = manager.loadSavedAccount()
assertTrue(result.isFailure) assertTrue(result.isFailure)
} }
@Test
fun loadSavedAccountBunkerNoClientFallsBackToInternal() =
runTest {
val validHex = "a".repeat(64)
val keyPair = KeyPair()
val npub = keyPair.pubKey.toNpub()
val privKeyHex = keyPair.privKey!!.toHexKey()
File(amethystDir, "last_account.txt").writeText(npub)
File(amethystDir, "bunker_uri.txt").writeText(
"bunker://$validHex?relay=wss://r.com",
)
coEvery { storage.getPrivateKey(npub) } returns privKeyHex
// client=null → bunkerUri is found but ignored, falls back to internal
val result = manager.loadSavedAccount(client = null)
assertTrue(result.isSuccess)
assertIs<SignerType.Internal>(result.getOrThrow().signerType)
}
@Test @Test
fun loadSavedAccountBunkerSuccess() = fun loadSavedAccountBunkerSuccess() =
runTest { runTest {
@@ -152,10 +132,7 @@ class AccountManagerLoadAccountTest {
storage.getPrivateKey(AccountManager.BUNKER_EPHEMERAL_KEY_ALIAS) storage.getPrivateKey(AccountManager.BUNKER_EPHEMERAL_KEY_ALIAS)
} returns ephemeralPrivKeyHex } returns ephemeralPrivKeyHex
val result = val result = manager.loadSavedAccount()
manager.loadSavedAccount(
client = com.vitorpamplona.quartz.nip01Core.relay.client.EmptyNostrClient,
)
assertTrue(result.isSuccess) assertTrue(result.isSuccess)
val state = result.getOrThrow() val state = result.getOrThrow()
assertIs<SignerType.Remote>(state.signerType) assertIs<SignerType.Remote>(state.signerType)
@@ -0,0 +1,195 @@
/*
* 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.desktop.account
import com.vitorpamplona.amethyst.commons.keystorage.SecureKeyStorage
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip19Bech32.toNpub
import com.vitorpamplona.quartz.nip46RemoteSigner.signer.NostrSignerRemote
import io.mockk.coEvery
import io.mockk.mockk
import kotlinx.coroutines.test.runTest
import java.io.File
import kotlin.io.path.createTempDirectory
import kotlin.test.AfterTest
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertIs
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
/**
* Tests verifying NIP-46 relay isolation the dedicated NIP-46 client
* is created internally by AccountManager, not passed from callers.
*
* These tests reproduce the root cause of Bug 2 (shared client relay
* contamination) by verifying the API no longer accepts an external client.
*/
class AccountManagerNip46IsolationTest {
private lateinit var storage: SecureKeyStorage
private lateinit var tempDir: File
private lateinit var amethystDir: File
private lateinit var manager: AccountManager
@BeforeTest
fun setup() {
storage = mockk(relaxed = true)
tempDir = createTempDirectory("acctmgr-nip46-iso-test").toFile()
amethystDir = File(tempDir, ".amethyst")
amethystDir.mkdirs()
manager = AccountManager(storage, tempDir)
}
@AfterTest
fun teardown() {
tempDir.deleteRecursively()
}
@Test
fun bunkerLoadCreatesRemoteSignerInternally() =
runTest {
val validHex = "a".repeat(64)
val ephemeralKeyPair = KeyPair()
File(amethystDir, "bunker_uri.txt").writeText(
"bunker://$validHex?relay=wss://relay.nsec.app",
)
File(amethystDir, "last_account.txt").writeText(
ephemeralKeyPair.pubKey.toNpub(),
)
coEvery {
storage.getPrivateKey(AccountManager.BUNKER_EPHEMERAL_KEY_ALIAS)
} returns ephemeralKeyPair.privKey!!.toHexKey()
val result = manager.loadSavedAccount()
assertTrue(result.isSuccess)
val state = manager.currentAccount()
assertNotNull(state)
assertIs<SignerType.Remote>(state.signerType)
assertIs<NostrSignerRemote>(state.signer)
}
@Test
fun loginWithBunkerDoesNotRequireExternalClient() =
runTest {
// loginWithBunker() compiles with just bunkerUri — no client param
try {
manager.loginWithBunker(
"bunker://${"a".repeat(64)}?relay=wss://relay.nsec.app",
)
} catch (e: Exception) {
// Expected — no real relay. Verify it's a connection error, not API error.
assertTrue(
e.message?.contains("Connection") == true ||
e.message?.contains("timed out") == true ||
e.message?.contains("failed") == true,
"Unexpected error type: ${e.message}",
)
}
}
@Test
fun loginWithNostrConnectDoesNotRequireExternalClient() =
runTest {
var generatedUri: String? = null
try {
manager.loginWithNostrConnect { uri -> generatedUri = uri }
} catch (e: Exception) {
// Expected — no signer scanning the QR
assertNotNull(e.message)
}
// URI should have been generated even if connection timed out
assertNotNull(generatedUri, "nostrconnect URI was never generated")
assertTrue(
generatedUri!!.startsWith("nostrconnect://"),
"URI format wrong: $generatedUri",
)
}
@Test
fun logoutResetsStateAfterBunkerLoad() =
runTest {
val validHex = "a".repeat(64)
val ephemeralKeyPair = KeyPair()
File(amethystDir, "bunker_uri.txt").writeText(
"bunker://$validHex?relay=wss://relay.nsec.app",
)
File(amethystDir, "last_account.txt").writeText(
ephemeralKeyPair.pubKey.toNpub(),
)
coEvery {
storage.getPrivateKey(AccountManager.BUNKER_EPHEMERAL_KEY_ALIAS)
} returns ephemeralKeyPair.privKey!!.toHexKey()
manager.loadSavedAccount()
assertIs<AccountState.LoggedIn>(manager.accountState.value)
manager.logout()
assertIs<AccountState.LoggedOut>(manager.accountState.value)
assertIs<SignerConnectionState.NotRemote>(
manager.signerConnectionState.value,
)
}
@Test
fun logoutThenReloadCreatesFreshNip46Client() =
runTest {
val validHex = "a".repeat(64)
val ephemeralKeyPair = KeyPair()
val bunkerUri = "bunker://$validHex?relay=wss://relay.nsec.app"
fun writeBunkerFiles() {
File(amethystDir, "bunker_uri.txt").writeText(bunkerUri)
File(amethystDir, "last_account.txt").writeText(
ephemeralKeyPair.pubKey.toNpub(),
)
}
coEvery {
storage.getPrivateKey(AccountManager.BUNKER_EPHEMERAL_KEY_ALIAS)
} returns ephemeralKeyPair.privKey!!.toHexKey()
// First load
writeBunkerFiles()
manager.loadSavedAccount()
val firstSigner = manager.currentAccount()?.signer
assertNotNull(firstSigner)
// Logout — disconnects NIP-46 client
manager.logout()
assertIs<AccountState.LoggedOut>(manager.accountState.value)
// Second load — should create a fresh NIP-46 client
writeBunkerFiles()
manager.loadSavedAccount()
val secondSigner = manager.currentAccount()?.signer
assertNotNull(secondSigner)
// Both should be remote signers — different instances
assertIs<NostrSignerRemote>(firstSigner)
assertIs<NostrSignerRemote>(secondSigner)
assertTrue(
firstSigner !== secondSigner,
"Second load should create a new signer instance",
)
}
}
@@ -0,0 +1,132 @@
/*
* 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.desktop.account
import com.vitorpamplona.amethyst.commons.keystorage.SecureKeyStorage
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip19Bech32.toNpub
import io.mockk.coEvery
import io.mockk.mockk
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import java.io.File
import kotlin.io.path.createTempDirectory
import kotlin.test.AfterTest
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertIs
import kotlin.test.assertTrue
/**
* Tests verifying AccountManager state transitions during NIP-46 flows.
* Reproduces Bug 1 UX issue user should see ConnectingRelays while
* the dedicated NIP-46 client is being set up.
*/
@OptIn(ExperimentalCoroutinesApi::class)
class AccountManagerStateTransitionTest {
private lateinit var storage: SecureKeyStorage
private lateinit var tempDir: File
private lateinit var amethystDir: File
private lateinit var manager: AccountManager
@BeforeTest
fun setup() {
storage = mockk(relaxed = true)
tempDir = createTempDirectory("acctmgr-state-test").toFile()
amethystDir = File(tempDir, ".amethyst")
amethystDir.mkdirs()
manager = AccountManager(storage, tempDir)
}
@AfterTest
fun teardown() {
tempDir.deleteRecursively()
}
@Test
fun connectingRelaysThenLoggedInTransition() =
runTest {
val states = mutableListOf<AccountState>()
val collector =
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
manager.accountState.toList(states)
}
// Simulate Main.kt bunker restore flow
manager.setConnectingRelays()
// Then load an internal account (simulating successful load)
val keyPair = KeyPair()
val npub = keyPair.pubKey.toNpub()
File(amethystDir, "last_account.txt").writeText(npub)
coEvery { storage.getPrivateKey(npub) } returns keyPair.privKey!!.toHexKey()
manager.loadSavedAccount()
advanceUntilIdle()
// Should see: LoggedOut → ConnectingRelays → LoggedIn
assertTrue(
states.size >= 3,
"Expected at least 3 state transitions, got ${states.size}: $states",
)
assertIs<AccountState.LoggedOut>(states[0])
assertIs<AccountState.ConnectingRelays>(states[1])
assertIs<AccountState.LoggedIn>(states[2])
collector.cancel()
}
@Test
fun connectingRelaysThenFailureFallsBackToLoggedOut() =
runTest {
val states = mutableListOf<AccountState>()
val collector =
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
manager.accountState.toList(states)
}
// Simulate bunker restore that fails
manager.setConnectingRelays()
// loadSavedAccount fails → caller does logout
val result = manager.loadSavedAccount() // no saved account
assertTrue(result.isFailure)
manager.logout()
advanceUntilIdle()
// Should see: LoggedOut → ConnectingRelays → LoggedOut
assertTrue(
states.size >= 3,
"Expected at least 3 state transitions, got ${states.size}: $states",
)
assertIs<AccountState.LoggedOut>(states[0])
assertIs<AccountState.ConnectingRelays>(states[1])
assertIs<AccountState.LoggedOut>(states[2])
collector.cancel()
}
}
@@ -20,7 +20,6 @@
*/ */
package com.vitorpamplona.amethyst.desktop.account package com.vitorpamplona.amethyst.desktop.account
import com.vitorpamplona.amethyst.desktop.ui.auth.validateBunkerUri
import kotlin.test.Test import kotlin.test.Test
import kotlin.test.assertEquals import kotlin.test.assertEquals
import kotlin.test.assertNotNull import kotlin.test.assertNotNull
@@ -42,6 +42,7 @@ import com.vitorpamplona.quartz.nip46RemoteSigner.NostrConnectEvent
import com.vitorpamplona.quartz.nip57Zaps.LnZapPrivateEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapPrivateEvent
import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent
import com.vitorpamplona.quartz.utils.Hex import com.vitorpamplona.quartz.utils.Hex
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
@@ -73,6 +74,7 @@ class NostrSignerRemote(
Filter( Filter(
kinds = listOf(NostrConnectEvent.KIND), kinds = listOf(NostrConnectEvent.KIND),
tags = mapOf("p" to listOf(signer.pubKey)), tags = mapOf("p" to listOf(signer.pubKey)),
since = TimeUtils.now() - 60,
), ),
) { event -> ) { event ->
if (event is NostrConnectEvent) { if (event is NostrConnectEvent) {
@@ -301,9 +303,10 @@ class NostrSignerRemote(
permissions: String? = null, permissions: String? = null,
): NostrSignerRemote { ): NostrSignerRemote {
if (!bunkerUri.startsWith("bunker://")) throw Exception("Invalid bunker uri") if (!bunkerUri.startsWith("bunker://")) throw Exception("Invalid bunker uri")
val splitData = bunkerUri.split("?") val splitData = bunkerUri.split("?", limit = 2)
val remotePubkey = splitData[0].removePrefix("bunker://") val remotePubkey = splitData[0].removePrefix("bunker://")
if (!Hex.isHex(remotePubkey)) throw Exception("Invalid pubkey in bunker uri") if (!Hex.isHex(remotePubkey)) throw Exception("Invalid pubkey in bunker uri")
if (splitData.size < 2) throw Exception("Missing query parameters in bunker uri")
val params = splitData[1].split("&") val params = splitData[1].split("&")
val relays = mutableSetOf<NormalizedRelayUrl>() val relays = mutableSetOf<NormalizedRelayUrl>()
var secret: String? = null var secret: String? = null
@@ -0,0 +1,278 @@
/*
* 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.quartz.nip46RemoteSigner.signer
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.IRelayClientListener
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.IRequestListener
import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import com.vitorpamplona.quartz.nip46RemoteSigner.NostrConnectEvent
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.flow.MutableStateFlow
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
/**
* INostrClient that records openReqSubscription calls for verification.
* Used instead of mockk since commonTest doesn't have mockk.
*/
private class TrackingNostrClient : INostrClient {
data class SubscriptionRecord(
val subId: String,
val filters: Map<NormalizedRelayUrl, List<Filter>>,
)
val subscriptions = mutableListOf<SubscriptionRecord>()
val sentEvents = mutableListOf<Pair<Event, Set<NormalizedRelayUrl>>>()
override fun connectedRelaysFlow() = MutableStateFlow(emptySet<NormalizedRelayUrl>())
override fun availableRelaysFlow() = MutableStateFlow(emptySet<NormalizedRelayUrl>())
override fun connect() {}
override fun disconnect() {}
override fun reconnect(
onlyIfChanged: Boolean,
ignoreRetryDelays: Boolean,
) {}
override fun isActive() = false
override fun renewFilters(relay: IRelayClient) {}
override fun openReqSubscription(
subId: String,
filters: Map<NormalizedRelayUrl, List<Filter>>,
listener: IRequestListener?,
) {
subscriptions.add(SubscriptionRecord(subId, filters))
}
override fun queryCount(
subId: String,
filters: Map<NormalizedRelayUrl, List<Filter>>,
) {}
override fun close(subId: String) {}
override fun send(
event: Event,
relayList: Set<NormalizedRelayUrl>,
) {
sentEvents.add(event to relayList)
}
override fun subscribe(listener: IRelayClientListener) {}
override fun unsubscribe(listener: IRelayClientListener) {}
override fun getReqFiltersOrNull(subId: String): Map<NormalizedRelayUrl, List<Filter>>? = null
override fun getCountFiltersOrNull(subId: String): Map<NormalizedRelayUrl, List<Filter>>? = null
}
/**
* Tests verifying NIP-46 relay isolation at the NostrSignerRemote level.
* Ensures subscription filters only target bunker relays and contain
* the correct kind + p-tag.
*/
class NostrSignerRemoteIsolationTest {
private val bunkerRelay = NormalizedRelayUrl("wss://relay.nsec.app/")
private val generalRelay = NormalizedRelayUrl("wss://relay.damus.io/")
private val validHex = "a".repeat(64)
@Test
fun subscriptionFilterTargetsOnlyBunkerRelays() {
val trackingClient = TrackingNostrClient()
val ephemeralSigner = NostrSignerInternal(KeyPair())
// Construction triggers client.req() which calls openReqSubscription
NostrSignerRemote(
signer = ephemeralSigner,
remotePubkey = validHex,
relays = setOf(bunkerRelay),
client = trackingClient,
)
// Verify subscription was opened
assertTrue(
trackingClient.subscriptions.isNotEmpty(),
"No subscription opened on construction",
)
// Verify ALL filters only target the bunker relay
trackingClient.subscriptions.forEach { record ->
assertTrue(
record.filters.keys.all { it == bunkerRelay },
"Filter contains non-bunker relay: ${record.filters.keys}",
)
assertTrue(
generalRelay !in record.filters.keys,
"General relay leaked into NIP-46 subscription",
)
}
}
@Test
fun subscriptionFilterContainsCorrectKindAndPTag() {
val trackingClient = TrackingNostrClient()
val ephemeralKeyPair = KeyPair()
val ephemeralSigner = NostrSignerInternal(ephemeralKeyPair)
NostrSignerRemote(
signer = ephemeralSigner,
remotePubkey = validHex,
relays = setOf(bunkerRelay),
client = trackingClient,
)
val allFilters =
trackingClient.subscriptions.flatMap { it.filters.values.flatten() }
assertTrue(allFilters.isNotEmpty(), "No filters captured")
allFilters.forEach { filter ->
// Must filter for NIP-46 event kind (24133)
assertTrue(
filter.kinds?.contains(NostrConnectEvent.KIND) == true,
"Filter missing kind ${NostrConnectEvent.KIND}, got: ${filter.kinds}",
)
// Must filter for our ephemeral pubkey in p-tag
val pTags = filter.tags?.get("p")
assertNotNull(pTags, "Filter missing p-tag")
assertTrue(
pTags.contains(ephemeralSigner.pubKey),
"Filter p-tag doesn't contain ephemeral pubkey",
)
}
}
@Test
fun multipleRelaysAllIncludedInFilter() {
val trackingClient = TrackingNostrClient()
val relay1 = NormalizedRelayUrl("wss://relay1.nsec.app/")
val relay2 = NormalizedRelayUrl("wss://relay2.nsec.app/")
NostrSignerRemote(
signer = NostrSignerInternal(KeyPair()),
remotePubkey = validHex,
relays = setOf(relay1, relay2),
client = trackingClient,
)
assertTrue(trackingClient.subscriptions.isNotEmpty())
val allRelayKeys =
trackingClient.subscriptions.flatMap { it.filters.keys }.toSet()
assertTrue(relay1 in allRelayKeys, "Missing relay1 in subscription")
assertTrue(relay2 in allRelayKeys, "Missing relay2 in subscription")
// General relay should NOT be present
assertTrue(
generalRelay !in allRelayKeys,
"General relay leaked into multi-relay subscription",
)
}
/**
* TDD RED TEST will fail until Step 8 adds `since` filter.
*
* Reproduces Bug 3 mitigation: stale NIP-46 responses from previous
* sessions should be filtered out via `since` timestamp.
*/
@Test
fun subscriptionFilterHasSinceTimestamp() {
val trackingClient = TrackingNostrClient()
val beforeTime = TimeUtils.now()
NostrSignerRemote(
signer = NostrSignerInternal(KeyPair()),
remotePubkey = validHex,
relays = setOf(bunkerRelay),
client = trackingClient,
)
val allFilters =
trackingClient.subscriptions.flatMap { it.filters.values.flatten() }
assertTrue(allFilters.isNotEmpty())
allFilters.forEach { filter ->
val since =
assertNotNull(
filter.since,
"Filter missing 'since' timestamp — stale responses won't be filtered",
)
// since should be roughly now - 60s (with tolerance)
val expectedMin = beforeTime - 120 // extra tolerance for test execution time
assertTrue(
since >= expectedMin,
"since too old: $since (expected >= $expectedMin)",
)
assertTrue(
since <= beforeTime,
"since in the future: $since",
)
}
}
@Test
fun fromBunkerUriPreservesRelayIsolation() {
val trackingClient = TrackingNostrClient()
val ephemeralSigner = NostrSignerInternal(KeyPair())
val remote =
NostrSignerRemote.fromBunkerUri(
"bunker://$validHex?relay=wss://relay.nsec.app",
ephemeralSigner,
trackingClient,
)
// Verify relay set matches URI
assertEquals(1, remote.relays.size)
val parsedRelay = remote.relays.first()
assertTrue(
parsedRelay.url.contains("relay.nsec.app"),
"Parsed relay doesn't match URI: ${parsedRelay.url}",
)
// Verify subscription was opened targeting ONLY the bunker relay
assertTrue(trackingClient.subscriptions.isNotEmpty())
trackingClient.subscriptions.forEach { record ->
assertTrue(
record.filters.keys.all { it in remote.relays },
"fromBunkerUri subscription targets wrong relays: ${record.filters.keys}",
)
// General relay must NOT appear
assertTrue(
generalRelay !in record.filters.keys,
"General relay leaked into fromBunkerUri subscription",
)
}
}
}