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.model.DesktopDmRelayState
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.subscriptions.DesktopRelaySubscriptionsCoordinator
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.profile.ProfileInfoCard
import com.vitorpamplona.amethyst.desktop.ui.relay.RelayStatusCard
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -95,7 +97,7 @@ import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeoutOrNull
import kotlinx.coroutines.runBlocking
private val isMacOS = System.getProperty("os.name").lowercase().contains("mac")
@@ -402,13 +404,18 @@ fun App(
val accountState by accountManager.accountState.collectAsState()
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 =
remember(relayManager, localCache) {
DesktopRelaySubscriptionsCoordinator(
client = relayManager.client,
scope = scope,
indexRelays = relayManager.availableRelays.value,
indexRelays =
DefaultRelays.RELAYS
.mapNotNull {
RelayUrlNormalizer.normalizeOrNull(it)
}.toSet(),
localCache = localCache,
)
}
@@ -421,31 +428,24 @@ fun App(
scope.launch(Dispatchers.IO) {
if (accountManager.hasBunkerAccount()) {
// Bunker accounts need relay connections before recreating signer
// Show connecting UI while dedicated NIP-46 client connects
accountManager.setConnectingRelays()
val connected =
withTimeoutOrNull(30_000L) {
relayManager.connectedRelays.first { it.isNotEmpty() }
}
if (connected == null) {
// No relays connected after 30s — fall back to login screen
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)
}
}
val result = accountManager.loadSavedAccount()
if (result.isSuccess) {
val current = accountManager.currentAccount()
if (current?.signerType is com.vitorpamplona.amethyst.desktop.account.SignerType.Remote) {
accountManager.startHeartbeat(scope)
}
} else {
accountManager.loadSavedAccount()
} else if (accountManager.hasBunkerAccount()) {
// Corrupt bunker state — fall back to login screen
accountManager.logout(deleteKey = true)
}
}
onDispose {
accountManager.stopHeartbeat()
runBlocking { accountManager.disconnectNip46Client() }
subscriptionsCoordinator.clear()
relayManager.disconnect()
scope.cancel()
@@ -463,7 +463,6 @@ fun App(
is AccountState.LoggedOut -> {
LoginScreen(
accountManager = accountManager,
relayClient = relayManager.client,
onLoginSuccess = {
// Start heartbeat if bunker account
val current = accountManager.currentAccount()
@@ -475,7 +474,11 @@ fun App(
}
is AccountState.ConnectingRelays -> {
ConnectingRelaysScreen()
val relays by relayManager.relayStatuses.collectAsState()
ConnectingRelaysScreen(
subtitle = "Restoring remote signer session",
relayStatuses = relays,
)
}
is AccountState.LoggedIn -> {
@@ -23,15 +23,17 @@ package com.vitorpamplona.amethyst.desktop.account
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.commons.keystorage.SecureKeyStorage
import com.vitorpamplona.amethyst.commons.keystorage.SecureStorageException
import com.vitorpamplona.amethyst.desktop.network.DesktopHttpClient
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.req
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.sockets.okhttp.BasicOkHttpWebSocket
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
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.toNpub
import com.vitorpamplona.quartz.nip19Bech32.toNsec
import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerMessage
import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequest
import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestConnect
import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponse
import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponseAck
import com.vitorpamplona.quartz.nip46RemoteSigner.NostrConnectEvent
import com.vitorpamplona.quartz.nip46RemoteSigner.signer.NostrSignerRemote
import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect
import com.vitorpamplona.quartz.utils.Hex
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
@@ -52,10 +58,15 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withTimeout
import java.io.File
import java.nio.file.Files
import java.nio.file.attribute.PosixFilePermission
import java.security.SecureRandom
sealed class SignerType {
@@ -71,9 +82,7 @@ sealed class SignerConnectionState {
data object Connected : SignerConnectionState()
data class Unstable(
val failCount: Int,
) : SignerConnectionState()
data object Disconnected : SignerConnectionState()
}
sealed class AccountState {
@@ -106,6 +115,7 @@ class AccountManager internal constructor(
internal const val MAX_CONSECUTIVE_FAILURES = 3
internal const val BUNKER_EPHEMERAL_KEY_ALIAS = "bunker_ephemeral"
internal const val NOSTRCONNECT_TIMEOUT_MS = 120_000L
internal const val NIP46_RELAY_CONNECT_TIMEOUT_MS = 15_000L
internal val NIP46_RELAYS = listOf("wss://relay.nsec.app")
}
@@ -127,16 +137,53 @@ class AccountManager internal constructor(
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 ---
suspend fun loadSavedAccount(client: INostrClient? = null): Result<AccountState.LoggedIn> =
suspend fun loadSavedAccount(): Result<AccountState.LoggedIn> =
try {
val lastNpub = getLastNpub() ?: return Result.failure(Exception("No saved account"))
// Check for bunker account first
val bunkerUri = getBunkerUri()
if (bunkerUri != null && client != null) {
loadBunkerAccount(bunkerUri, lastNpub, client)
if (bunkerUri != null) {
loadBunkerAccount(bunkerUri, lastNpub)
} else {
loadInternalAccount(lastNpub)
}
@@ -167,7 +214,6 @@ class AccountManager internal constructor(
private suspend fun loadBunkerAccount(
bunkerUri: String,
npub: String,
client: INostrClient,
): Result<AccountState.LoggedIn> {
val ephemeralPrivKeyHex =
secureStorage.getPrivateKey(BUNKER_EPHEMERAL_KEY_ALIAS)
@@ -176,7 +222,8 @@ class AccountManager internal constructor(
val ephemeralKeyPair = KeyPair(privKey = ephemeralPrivKeyHex.hexToByteArray())
val ephemeralSigner = NostrSignerInternal(ephemeralKeyPair)
val remoteSigner = NostrSignerRemote.fromBunkerUri(bunkerUri, ephemeralSigner, client)
val nip46Client = getOrCreateNip46Client()
val remoteSigner = NostrSignerRemote.fromBunkerUri(bunkerUri, ephemeralSigner, nip46Client)
remoteSigner.openSubscription()
val pubKeyHex = decodePublicKeyAsHexOrNull(npub) ?: return Result.failure(Exception("Invalid saved npub"))
@@ -197,17 +244,18 @@ class AccountManager internal constructor(
// --- Bunker login ---
suspend fun loginWithBunker(
bunkerUri: String,
client: INostrClient,
): Result<AccountState.LoggedIn> =
suspend fun loginWithBunker(bunkerUri: String): Result<AccountState.LoggedIn> =
try {
val ephemeralKeyPair = KeyPair()
val ephemeralSigner = NostrSignerInternal(ephemeralKeyPair)
val remoteSigner = NostrSignerRemote.fromBunkerUri(bunkerUri, ephemeralSigner, client)
val nip46Client = getOrCreateNip46Client()
val remoteSigner = NostrSignerRemote.fromBunkerUri(bunkerUri, ephemeralSigner, nip46Client)
remoteSigner.openSubscription()
// Wait for websocket to be ready before sending connect request
awaitNip46RelayConnection(nip46Client, remoteSigner.relays)
val remotePubkey = remoteSigner.connect()
val state =
@@ -230,6 +278,8 @@ class AccountManager internal constructor(
)
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) {
Result.failure(Exception("Connection timed out. Ensure remote signer is online and has approved the connection."))
} catch (e: SignerExceptions.ManuallyUnauthorizedException) {
@@ -242,10 +292,7 @@ class AccountManager internal constructor(
// --- Nostrconnect login ---
suspend fun loginWithNostrConnect(
client: INostrClient,
onUriGenerated: (String) -> Unit,
): Result<AccountState.LoggedIn> =
suspend fun loginWithNostrConnect(onUriGenerated: (String) -> Unit): Result<AccountState.LoggedIn> =
try {
val ephemeralKeyPair = KeyPair()
val ephemeralSigner = NostrSignerInternal(ephemeralKeyPair)
@@ -257,17 +304,20 @@ class AccountManager internal constructor(
val uri = "nostrconnect://$ephemeralPubKey?$relayParams&secret=$secret&name=Amethyst%20Desktop"
onUriGenerated(uri)
val nip46Client = getOrCreateNip46Client()
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 =
NostrSignerRemote(
signer = ephemeralSigner,
remotePubkey = connectData.signerPubkey,
relays = normalizedRelays,
client = client,
client = nip46Client,
)
remoteSigner.openSubscription()
@@ -303,7 +353,7 @@ class AccountManager internal constructor(
ephemeralPubKey: HexKey,
relays: Set<NormalizedRelayUrl>,
expectedSecret: String,
client: INostrClient,
client: NostrClient,
): ConnectRequestData {
val deferred = CompletableDeferred<NostrConnectEvent>()
@@ -314,6 +364,7 @@ class AccountManager internal constructor(
Filter(
kinds = listOf(NostrConnectEvent.KIND),
tags = mapOf("p" to listOf(ephemeralPubKey)),
since = TimeUtils.now() - 60,
),
) { event ->
if (event is NostrConnectEvent && !deferred.isCompleted) {
@@ -327,28 +378,53 @@ class AccountManager internal constructor(
deferred.await()
}
val otherPubkey = event.talkingWith(ephemeralSigner.pubKey)
val decryptedJson = ephemeralSigner.decrypt(event.content, otherPubkey)
val message = OptimizedJsonMapper.fromJsonTo<BunkerRequest>(decryptedJson)
val signerPubkey = event.talkingWith(ephemeralSigner.pubKey)
val decryptedJson = ephemeralSigner.decrypt(event.content, signerPubkey)
val message = OptimizedJsonMapper.fromJsonTo<BunkerMessage>(decryptedJson)
if (message.method != BunkerRequestConnect.METHOD_NAME) {
throw Exception("Expected 'connect' method, got '${message.method}'")
return when (message) {
is BunkerRequest -> {
if (message.method != BunkerRequestConnect.METHOD_NAME) {
throw Exception("Expected 'connect' method, got '${message.method}'")
}
val userPubkey =
message.params.getOrNull(0)
?: throw Exception("Missing user pubkey in connect request")
val receivedSecret = message.params.getOrNull(1)
if (receivedSecret != expectedSecret) {
throw Exception("Secret mismatch in connect request")
}
ConnectRequestData(
requestId = message.id,
signerPubkey = signerPubkey,
userPubkey = userPubkey,
)
}
is BunkerResponse -> {
if (message.error != null) {
throw Exception("Signer rejected connection: ${message.error}")
}
val userPubkey =
message.result
?.takeIf { it.length == 64 && Hex.isHex(it) }
?: signerPubkey
ConnectRequestData(
requestId = null,
signerPubkey = signerPubkey,
userPubkey = userPubkey,
)
}
else -> {
throw Exception("Unexpected NIP-46 message format")
}
}
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 {
subscription.close()
}
@@ -358,9 +434,9 @@ class AccountManager internal constructor(
ephemeralSigner: NostrSignerInternal,
connectData: ConnectRequestData,
relays: Set<NormalizedRelayUrl>,
client: INostrClient,
client: NostrClient,
) {
val ackResponse = BunkerResponseAck(id = connectData.requestId)
val ackResponse = BunkerResponseAck(id = connectData.requestId!!)
val ackEvent =
NostrConnectEvent.create(
message = ackResponse,
@@ -377,7 +453,7 @@ class AccountManager internal constructor(
}
private data class ConnectRequestData(
val requestId: String,
val requestId: String?,
val signerPubkey: HexKey,
val userPubkey: HexKey,
)
@@ -511,6 +587,7 @@ class AccountManager internal constructor(
}
}
}
disconnectNip46Client()
_signerConnectionState.value = SignerConnectionState.NotRemote
_accountState.value = AccountState.LoggedOut
// Cancel heartbeat LAST — may be called from within the heartbeat coroutine
@@ -519,7 +596,7 @@ class AccountManager internal constructor(
suspend fun forceLogoutWithReason(reason: String) {
_forceLogoutReason.value = reason
logout(deleteKey = true)
logout(deleteKey = false)
}
fun clearForceLogoutReason() {
@@ -552,7 +629,7 @@ class AccountManager internal constructor(
)
return@launch
}
_signerConnectionState.value = SignerConnectionState.Unstable(consecutiveFailures)
_signerConnectionState.value = SignerConnectionState.Disconnected
}
}
}
@@ -601,8 +678,27 @@ class AccountManager internal constructor(
// --- 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) {
amethystDir.mkdirs()
ensureAmethystDir()
getNwcFile().writeText(uri)
}
@@ -614,7 +710,7 @@ class AccountManager internal constructor(
}
private fun saveLastNpub(npub: String) {
amethystDir.mkdirs()
ensureAmethystDir()
getPrefsFile().writeText(npub)
}
@@ -630,7 +726,7 @@ class AccountManager internal constructor(
}
private fun saveBunkerUri(uri: String) {
amethystDir.mkdirs()
ensureAmethystDir()
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) {
DesktopChessViewModelNew(account, relayManager, scope)
}
val relayStatuses by relayManager.relayStatuses.collectAsState()
val connectedRelays by relayManager.connectedRelays.collectAsState()
val broadcastStatus by viewModel.broadcastStatus.collectAsState()
val activeGames by viewModel.activeGames.collectAsState()
// 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
val pubkeysNeeded by viewModel.userMetadataCache.pubkeysNeeded.collectAsState()
rememberSubscription(relayStatuses, pubkeysNeeded, relayManager = relayManager) {
val configuredRelays = relayStatuses.keys
if (configuredRelays.isNotEmpty() && pubkeysNeeded.isNotEmpty()) {
rememberSubscription(connectedRelays, pubkeysNeeded, relayManager = relayManager) {
if (connectedRelays.isNotEmpty() && pubkeysNeeded.isNotEmpty()) {
createMetadataListSubscription(
relays = configuredRelays,
relays = connectedRelays,
pubKeys = pubkeysNeeded.toList(),
onEvent = { event, _, _, _ ->
viewModel.handleIncomingEvent(event)
@@ -221,6 +221,6 @@ open class RelayConnectionManager(
cmd: Command,
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.DisposableEffect
import androidx.compose.runtime.remember
import com.vitorpamplona.amethyst.desktop.DebugConfig
import com.vitorpamplona.amethyst.desktop.network.RelayConnectionManager
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.IRequestListener
@@ -74,6 +75,7 @@ fun rememberSubscription(
DisposableEffect(*keys, subscription?.subId) {
subscription?.let { cfg ->
if (cfg.relays.isNotEmpty()) {
DebugConfig.log("SUB OPEN ${cfg.subId} relays=${cfg.relays.size}")
relayManager.subscribe(
subId = cfg.subId,
filters = cfg.filters,
@@ -101,7 +103,10 @@ fun rememberSubscription(
}
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 = {},
onZapFeedback: (ZapFeedback) -> Unit = {},
) {
val relayStatuses by relayManager.relayStatuses.collectAsState()
val connectedRelays by relayManager.connectedRelays.collectAsState()
val scope = rememberCoroutineScope()
// Tab state
@@ -121,9 +121,8 @@ fun BookmarksScreen(
}
// Subscribe to user's bookmark list (kind 30001)
rememberSubscription(relayStatuses, account.pubKeyHex, relayManager = relayManager) {
val configuredRelays = relayStatuses.keys
if (configuredRelays.isNotEmpty()) {
rememberSubscription(connectedRelays, account.pubKeyHex, relayManager = relayManager) {
if (connectedRelays.isNotEmpty()) {
SubscriptionConfig(
subId = "bookmarks-list-${account.pubKeyHex.take(8)}",
filters =
@@ -134,7 +133,7 @@ fun BookmarksScreen(
limit = 1,
),
),
relays = configuredRelays,
relays = connectedRelays,
onEvent = { event, _, _, _ ->
if (event is BookmarkListEvent) {
bookmarkList = event
@@ -179,9 +178,8 @@ fun BookmarksScreen(
}
// Subscribe to fetch the actual public bookmarked events
rememberSubscription(relayStatuses, publicBookmarkIds, relayManager = relayManager) {
val configuredRelays = relayStatuses.keys
if (configuredRelays.isNotEmpty() && publicBookmarkIds.isNotEmpty()) {
rememberSubscription(connectedRelays, publicBookmarkIds, relayManager = relayManager) {
if (connectedRelays.isNotEmpty() && publicBookmarkIds.isNotEmpty()) {
publicEventState.clear()
SubscriptionConfig(
subId = "public-bookmarked-events-${System.currentTimeMillis()}",
@@ -189,7 +187,7 @@ fun BookmarksScreen(
listOf(
FilterBuilders.byIds(publicBookmarkIds),
),
relays = configuredRelays,
relays = connectedRelays,
onEvent = { event, _, _, _ ->
publicEventState.addItem(event)
},
@@ -201,9 +199,8 @@ fun BookmarksScreen(
}
// Subscribe to fetch the actual private bookmarked events
rememberSubscription(relayStatuses, privateBookmarkIds, relayManager = relayManager) {
val configuredRelays = relayStatuses.keys
if (configuredRelays.isNotEmpty() && privateBookmarkIds.isNotEmpty()) {
rememberSubscription(connectedRelays, privateBookmarkIds, relayManager = relayManager) {
if (connectedRelays.isNotEmpty() && privateBookmarkIds.isNotEmpty()) {
privateEventState.clear()
SubscriptionConfig(
subId = "private-bookmarked-events-${System.currentTimeMillis()}",
@@ -211,7 +208,7 @@ fun BookmarksScreen(
listOf(
FilterBuilders.byIds(privateBookmarkIds),
),
relays = configuredRelays,
relays = connectedRelays,
onEvent = { event, _, _, _ ->
privateEventState.addItem(event)
},
@@ -177,15 +177,12 @@ private suspend fun publishNote(
replyTo: com.vitorpamplona.quartz.nip01Core.core.Event?,
) {
withContext(Dispatchers.IO) {
// Check read-only mode
if (account.isReadOnly) {
throw IllegalStateException("Cannot post in read-only mode")
}
// Use shared PublishAction from commons
val signedEvent = PublishAction.publishTextNote(content, account.signer, replyTo)
// Broadcast to all configured relays
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.ui.components.EmptyState
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.account.AccountState
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
@@ -158,7 +159,6 @@ fun FeedScreen(
onZapFeedback: (ZapFeedback) -> Unit = {},
) {
val connectedRelays by relayManager.connectedRelays.collectAsState()
val relayStatuses by relayManager.relayStatuses.collectAsState()
val scope = rememberCoroutineScope()
val eventState =
remember {
@@ -190,15 +190,18 @@ fun FeedScreen(
val initialLoadComplete = eoseReceivedCount > 0
// Load followed users for Following feed mode
rememberSubscription(relayStatuses, account, feedMode, relayManager = relayManager) {
val configuredRelays = relayStatuses.keys
if (configuredRelays.isNotEmpty() && account != null && feedMode == FeedMode.FOLLOWING) {
rememberSubscription(connectedRelays, account, feedMode, relayManager = relayManager) {
DebugConfig.log("contactList sub: relays=${connectedRelays.size}, account=${account?.pubKeyHex?.take(8)}, mode=$feedMode")
if (connectedRelays.isNotEmpty() && account != null && feedMode == FeedMode.FOLLOWING) {
createContactListSubscription(
relays = configuredRelays,
relays = connectedRelays,
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) {
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
rememberSubscription(relayStatuses, account, relayManager = relayManager) {
val configuredRelays = relayStatuses.keys
if (configuredRelays.isNotEmpty() && account != null) {
rememberSubscription(connectedRelays, account, relayManager = relayManager) {
if (connectedRelays.isNotEmpty() && account != null) {
SubscriptionConfig(
subId = "bookmarks-${account.pubKeyHex.take(8)}",
filters =
@@ -221,7 +223,7 @@ fun FeedScreen(
limit = 1,
),
),
relays = configuredRelays,
relays = connectedRelays,
onEvent = { event, _, _, _ ->
if (event is BookmarkListEvent) {
bookmarkList = event
@@ -249,16 +251,16 @@ fun FeedScreen(
}
// Subscribe to feed based on mode
rememberSubscription(relayStatuses, feedMode, followedUsers, relayManager = relayManager) {
val configuredRelays = relayStatuses.keys
if (configuredRelays.isEmpty()) {
rememberSubscription(connectedRelays, feedMode, followedUsers, relayManager = relayManager) {
DebugConfig.log("feedSub: mode=$feedMode, relays=${connectedRelays.size}, followedUsers=${followedUsers.size}")
if (connectedRelays.isEmpty()) {
return@rememberSubscription null
}
when (feedMode) {
FeedMode.GLOBAL -> {
createGlobalFeedSubscription(
relays = configuredRelays,
relays = connectedRelays,
onEvent = { event, _, _, _ ->
// Store metadata events in cache
if (event is MetadataEvent) {
@@ -275,7 +277,7 @@ fun FeedScreen(
FeedMode.FOLLOWING -> {
if (followedUsers.isNotEmpty()) {
createFollowingFeedSubscription(
relays = configuredRelays,
relays = connectedRelays,
followedUsers = followedUsers.toList(),
onEvent = { event, _, _, _ ->
// Store metadata events in cache
@@ -297,14 +299,13 @@ fun FeedScreen(
// Subscribe to zaps for visible events
val eventIds = events.map { it.id }
rememberSubscription(relayStatuses, eventIds, relayManager = relayManager) {
val configuredRelays = relayStatuses.keys
if (configuredRelays.isEmpty() || eventIds.isEmpty()) {
rememberSubscription(connectedRelays, eventIds, relayManager = relayManager) {
if (connectedRelays.isEmpty() || eventIds.isEmpty()) {
return@rememberSubscription null
}
createZapsSubscription(
relays = configuredRelays,
relays = connectedRelays,
eventIds = eventIds,
onEvent = { event, _, _, _ ->
if (event is LnZapEvent) {
@@ -328,9 +329,8 @@ fun FeedScreen(
.flatten()
.map { it.senderPubKey }
.distinct()
rememberSubscription(relayStatuses, zapSenderPubkeys, relayManager = relayManager) {
val configuredRelays = relayStatuses.keys
if (configuredRelays.isEmpty() || zapSenderPubkeys.isEmpty()) {
rememberSubscription(connectedRelays, zapSenderPubkeys, relayManager = relayManager) {
if (connectedRelays.isEmpty() || zapSenderPubkeys.isEmpty()) {
return@rememberSubscription null
}
@@ -348,7 +348,7 @@ fun FeedScreen(
}
createBatchMetadataSubscription(
relays = configuredRelays,
relays = connectedRelays,
pubKeyHexList = missingPubkeys,
onEvent = { event, _, _, _ ->
if (event is MetadataEvent) {
@@ -359,14 +359,13 @@ fun FeedScreen(
}
// Subscribe to reactions for visible events
rememberSubscription(relayStatuses, eventIds, relayManager = relayManager) {
val configuredRelays = relayStatuses.keys
if (configuredRelays.isEmpty() || eventIds.isEmpty()) {
rememberSubscription(connectedRelays, eventIds, relayManager = relayManager) {
if (connectedRelays.isEmpty() || eventIds.isEmpty()) {
return@rememberSubscription null
}
createReactionsSubscription(
relays = configuredRelays,
relays = connectedRelays,
eventIds = eventIds,
onEvent = { event, _, _, _ ->
if (event is ReactionEvent) {
@@ -382,14 +381,13 @@ fun FeedScreen(
}
// Subscribe to replies for visible events
rememberSubscription(relayStatuses, eventIds, relayManager = relayManager) {
val configuredRelays = relayStatuses.keys
if (configuredRelays.isEmpty() || eventIds.isEmpty()) {
rememberSubscription(connectedRelays, eventIds, relayManager = relayManager) {
if (connectedRelays.isEmpty() || eventIds.isEmpty()) {
return@rememberSubscription null
}
createRepliesSubscription(
relays = configuredRelays,
relays = connectedRelays,
eventIds = eventIds,
onEvent = { event, _, _, _ ->
// Find the event this is replying to
@@ -410,14 +408,13 @@ fun FeedScreen(
}
// Subscribe to reposts for visible events
rememberSubscription(relayStatuses, eventIds, relayManager = relayManager) {
val configuredRelays = relayStatuses.keys
if (configuredRelays.isEmpty() || eventIds.isEmpty()) {
rememberSubscription(connectedRelays, eventIds, relayManager = relayManager) {
if (connectedRelays.isEmpty() || eventIds.isEmpty()) {
return@rememberSubscription null
}
createRepostsSubscription(
relays = configuredRelays,
relays = connectedRelays,
eventIds = eventIds,
onEvent = { event, _, _, _ ->
if (event is RepostEvent) {
@@ -443,14 +440,13 @@ fun FeedScreen(
}
// Fallback subscription if coordinator not available
rememberSubscription(relayStatuses, authorPubkeys, subscriptionsCoordinator, relayManager = relayManager) {
rememberSubscription(connectedRelays, authorPubkeys, subscriptionsCoordinator, relayManager = relayManager) {
// Skip if using coordinator
if (subscriptionsCoordinator != null) {
return@rememberSubscription null
}
val configuredRelays = relayStatuses.keys
if (configuredRelays.isEmpty() || authorPubkeys.isEmpty()) {
if (connectedRelays.isEmpty() || authorPubkeys.isEmpty()) {
return@rememberSubscription null
}
@@ -468,7 +464,7 @@ fun FeedScreen(
}
createBatchMetadataSubscription(
relays = configuredRelays,
relays = connectedRelays,
pubKeyHexList = missingPubkeys,
onEvent = { event, _, _, _ ->
if (event is MetadataEvent) {
@@ -20,14 +20,24 @@
*/
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.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
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.Icon
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -38,15 +48,16 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.login_subtitle_desktop
import com.vitorpamplona.amethyst.commons.resources.login_title
import com.vitorpamplona.amethyst.desktop.account.AccountManager
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.NewKeyWarningCard
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@@ -55,7 +66,6 @@ import org.jetbrains.compose.resources.stringResource
@Composable
fun LoginScreen(
accountManager: AccountManager,
relayClient: INostrClient?,
onLoginSuccess: () -> Unit,
) {
var showNewKeyDialog by remember { mutableStateOf(false) }
@@ -96,26 +106,16 @@ fun LoginScreen(
generatedAccount = accountManager.generateNewAccount()
showNewKeyDialog = true
},
onLoginBunker =
if (relayClient != null) {
{ bunkerUri ->
accountManager.loginWithBunker(bunkerUri, relayClient).map {
onLoginSuccess()
}
}
} else {
null
},
onLoginNostrConnect =
if (relayClient != null) {
{ onUriGenerated ->
accountManager.loginWithNostrConnect(relayClient, onUriGenerated).map {
onLoginSuccess()
}
}
} else {
null
},
onLoginBunker = { bunkerUri ->
accountManager.loginWithBunker(bunkerUri).map {
onLoginSuccess()
}
},
onLoginNostrConnect = { onUriGenerated ->
accountManager.loginWithNostrConnect(onUriGenerated).map {
onLoginSuccess()
}
},
)
val account = generatedAccount
@@ -137,7 +137,15 @@ fun LoginScreen(
}
@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(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
@@ -156,7 +164,7 @@ fun ConnectingRelaysScreen() {
Spacer(Modifier.height(16.dp))
Text(
"Connecting to relays...",
if (total > 0) "Connecting to relays ($connected/$total)" else "Connecting to relays...",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@@ -164,9 +172,89 @@ fun ConnectingRelaysScreen() {
Spacer(Modifier.height(8.dp))
Text(
"Restoring remote signer session",
subtitle,
style = MaterialTheme.typography.bodyMedium,
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,
) {
val connectedRelays by relayManager.connectedRelays.collectAsState()
val relayStatuses by relayManager.relayStatuses.collectAsState()
val scope = rememberCoroutineScope()
val notificationState =
remember {
@@ -139,11 +138,10 @@ fun NotificationsScreen(
val initialLoadComplete = eoseReceivedCount > 0
// Subscribe to notifications
rememberSubscription(relayStatuses, account.pubKeyHex, relayManager = relayManager) {
val configuredRelays = relayStatuses.keys
if (configuredRelays.isNotEmpty()) {
rememberSubscription(connectedRelays, account.pubKeyHex, relayManager = relayManager) {
if (connectedRelays.isNotEmpty()) {
createNotificationsSubscription(
relays = configuredRelays,
relays = connectedRelays,
pubKeyHex = account.pubKeyHex,
onEvent = { event, _, _, _ ->
// Skip events from the user themselves (except zaps)
@@ -175,7 +175,6 @@ fun ReadsScreen(
onNavigateToArticle: (String) -> Unit = {},
) {
val connectedRelays by relayManager.connectedRelays.collectAsState()
val relayStatuses by relayManager.relayStatuses.collectAsState()
val scope = rememberCoroutineScope()
val eventState =
@@ -195,11 +194,11 @@ fun ReadsScreen(
val initialLoadComplete = eoseReceivedCount > 0
// Load followed users for Following feed mode
rememberSubscription(relayStatuses, account, feedMode, relayManager = relayManager) {
val configuredRelays = relayStatuses.keys
if (configuredRelays.isNotEmpty() && account != null && feedMode == FeedMode.FOLLOWING) {
rememberSubscription(connectedRelays, account, feedMode, relayManager = relayManager) {
val connectedRelays = connectedRelays
if (connectedRelays.isNotEmpty() && account != null && feedMode == FeedMode.FOLLOWING) {
createContactListSubscription(
relays = configuredRelays,
relays = connectedRelays,
pubKeyHex = account.pubKeyHex,
onEvent = { event, _, _, _ ->
if (event is ContactListEvent) {
@@ -219,16 +218,16 @@ fun ReadsScreen(
}
// Subscribe to long-form content feed
rememberSubscription(relayStatuses, feedMode, followedUsers, relayManager = relayManager) {
val configuredRelays = relayStatuses.keys
if (configuredRelays.isEmpty()) {
rememberSubscription(connectedRelays, feedMode, followedUsers, relayManager = relayManager) {
val connectedRelays = connectedRelays
if (connectedRelays.isEmpty()) {
return@rememberSubscription null
}
when (feedMode) {
FeedMode.GLOBAL -> {
createLongFormFeedSubscription(
relays = configuredRelays,
relays = connectedRelays,
onEvent = { event, _, _, _ ->
if (event is LongTextNoteEvent) {
eventState.addItem(event)
@@ -243,7 +242,7 @@ fun ReadsScreen(
FeedMode.FOLLOWING -> {
if (followedUsers.isNotEmpty()) {
createFollowingLongFormFeedSubscription(
relays = configuredRelays,
relays = connectedRelays,
followedUsers = followedUsers.toList(),
onEvent = { event, _, _, _ ->
if (event is LongTextNoteEvent) {
@@ -94,7 +94,7 @@ fun SearchScreen(
searchState.updateSearchText(initialQuery)
}
}
val relayStatuses by relayManager.relayStatuses.collectAsState()
val connectedRelays by relayManager.connectedRelays.collectAsState()
// Collect state from SearchBarState
val searchText by searchState.searchText.collectAsState()
@@ -104,15 +104,14 @@ fun SearchScreen(
val isSearchingRelays by searchState.isSearchingRelays.collectAsState()
// NIP-50 relay search when local cache has few/no results
rememberSubscription(relayStatuses, searchText, cachedUserResults.size, relayManager = relayManager) {
val configuredRelays = relayStatuses.keys
if (configuredRelays.isEmpty()) return@rememberSubscription null
rememberSubscription(connectedRelays, searchText, cachedUserResults.size, relayManager = relayManager) {
if (connectedRelays.isEmpty()) return@rememberSubscription null
// Only search relays if we have a real query and limited local results
if (searchState.shouldSearchRelays) {
searchState.startRelaySearch()
createSearchPeopleSubscription(
relays = configuredRelays,
relays = connectedRelays,
searchQuery = searchText,
limit = 20,
onEvent = { event, _, _, _ ->
@@ -134,9 +133,8 @@ fun SearchScreen(
}
// Subscribe to metadata for searched users (to populate cache)
rememberSubscription(relayStatuses, searchText, relayManager = relayManager) {
val configuredRelays = relayStatuses.keys
if (configuredRelays.isEmpty() || searchText.length < 2) {
rememberSubscription(connectedRelays, searchText, relayManager = relayManager) {
if (connectedRelays.isEmpty() || searchText.length < 2) {
return@rememberSubscription null
}
@@ -144,7 +142,7 @@ fun SearchScreen(
val pubkeyHex = decodePublicKeyAsHexOrNull(searchText)
if (pubkeyHex != null) {
createMetadataSubscription(
relays = configuredRelays,
relays = connectedRelays,
pubKeyHex = pubkeyHex,
onEvent = { event, _, _, _ ->
if (event is MetadataEvent) {
@@ -96,7 +96,6 @@ fun ThreadScreen(
onReply: (Event) -> Unit = {},
) {
val connectedRelays by relayManager.connectedRelays.collectAsState()
val relayStatuses by relayManager.relayStatuses.collectAsState()
val scope = rememberCoroutineScope()
// State for the root note
@@ -149,9 +148,8 @@ fun ThreadScreen(
}
// Subscribe to user's bookmark list
rememberSubscription(relayStatuses, account, relayManager = relayManager) {
val configuredRelays = relayStatuses.keys
if (configuredRelays.isNotEmpty() && account != null) {
rememberSubscription(connectedRelays, account, relayManager = relayManager) {
if (connectedRelays.isNotEmpty() && account != null) {
SubscriptionConfig(
subId = "thread-bookmarks-${account.pubKeyHex.take(8)}",
filters =
@@ -162,7 +160,7 @@ fun ThreadScreen(
limit = 1,
),
),
relays = configuredRelays,
relays = connectedRelays,
onEvent = { event, _, _, _ ->
if (event is BookmarkListEvent) {
bookmarkList = event
@@ -183,11 +181,10 @@ fun ThreadScreen(
}
// Subscribe to the root note
rememberSubscription(relayStatuses, noteId, relayManager = relayManager) {
val configuredRelays = relayStatuses.keys
if (configuredRelays.isNotEmpty()) {
rememberSubscription(connectedRelays, noteId, relayManager = relayManager) {
if (connectedRelays.isNotEmpty()) {
createNoteSubscription(
relays = configuredRelays,
relays = connectedRelays,
noteId = noteId,
onEvent = { event, _, _, _ ->
if (event.id == noteId) {
@@ -205,11 +202,10 @@ fun ThreadScreen(
}
// Subscribe to replies
rememberSubscription(relayStatuses, noteId, relayManager = relayManager) {
val configuredRelays = relayStatuses.keys
if (configuredRelays.isNotEmpty()) {
rememberSubscription(connectedRelays, noteId, relayManager = relayManager) {
if (connectedRelays.isNotEmpty()) {
createThreadRepliesSubscription(
relays = configuredRelays,
relays = connectedRelays,
noteId = noteId,
onEvent = { event, _, _, _ ->
replyEventState.addItem(event)
@@ -225,14 +221,13 @@ fun ThreadScreen(
// Subscribe to zaps for thread events
val allEventIds = listOf(noteId) + replyEvents.map { it.id }
rememberSubscription(relayStatuses, allEventIds, relayManager = relayManager) {
val configuredRelays = relayStatuses.keys
if (configuredRelays.isEmpty() || allEventIds.isEmpty()) {
rememberSubscription(connectedRelays, allEventIds, relayManager = relayManager) {
if (connectedRelays.isEmpty() || allEventIds.isEmpty()) {
return@rememberSubscription null
}
createZapsSubscription(
relays = configuredRelays,
relays = connectedRelays,
eventIds = allEventIds,
onEvent = { event, _, _, _ ->
if (event is LnZapEvent) {
@@ -251,14 +246,13 @@ fun ThreadScreen(
}
// Subscribe to reactions for thread events
rememberSubscription(relayStatuses, allEventIds, relayManager = relayManager) {
val configuredRelays = relayStatuses.keys
if (configuredRelays.isEmpty() || allEventIds.isEmpty()) {
rememberSubscription(connectedRelays, allEventIds, relayManager = relayManager) {
if (connectedRelays.isEmpty() || allEventIds.isEmpty()) {
return@rememberSubscription null
}
createReactionsSubscription(
relays = configuredRelays,
relays = connectedRelays,
eventIds = allEventIds,
onEvent = { event, _, _, _ ->
if (event is ReactionEvent) {
@@ -274,14 +268,13 @@ fun ThreadScreen(
}
// Subscribe to replies for thread events (for counts)
rememberSubscription(relayStatuses, allEventIds, relayManager = relayManager) {
val configuredRelays = relayStatuses.keys
if (configuredRelays.isEmpty() || allEventIds.isEmpty()) {
rememberSubscription(connectedRelays, allEventIds, relayManager = relayManager) {
if (connectedRelays.isEmpty() || allEventIds.isEmpty()) {
return@rememberSubscription null
}
createRepliesSubscription(
relays = configuredRelays,
relays = connectedRelays,
eventIds = allEventIds,
onEvent = { event, _, _, _ ->
val replyToId =
@@ -301,14 +294,13 @@ fun ThreadScreen(
}
// Subscribe to reposts for thread events
rememberSubscription(relayStatuses, allEventIds, relayManager = relayManager) {
val configuredRelays = relayStatuses.keys
if (configuredRelays.isEmpty() || allEventIds.isEmpty()) {
rememberSubscription(connectedRelays, allEventIds, relayManager = relayManager) {
if (connectedRelays.isEmpty() || allEventIds.isEmpty()) {
return@rememberSubscription null
}
createRepostsSubscription(
relays = configuredRelays,
relays = connectedRelays,
eventIds = allEventIds,
onEvent = { event, _, _, _ ->
if (event is RepostEvent) {
@@ -109,7 +109,6 @@ fun UserProfileScreen(
onZapFeedback: (ZapFeedback) -> Unit = {},
) {
val connectedRelays by relayManager.connectedRelays.collectAsState()
val relayStatuses by relayManager.relayStatuses.collectAsState()
// User metadata
var displayName by remember { mutableStateOf<String?>(null) }
@@ -154,11 +153,10 @@ fun UserProfileScreen(
var eoseReceivedCount by remember(account) { mutableStateOf(0) }
// Load current user's contact list (for follow state)
rememberSubscription(relayStatuses, account, relayManager = relayManager) {
val configuredRelays = relayStatuses.keys
if (configuredRelays.isNotEmpty() && account != null) {
rememberSubscription(connectedRelays, account, relayManager = relayManager) {
if (connectedRelays.isNotEmpty() && account != null) {
createContactListSubscription(
relays = configuredRelays,
relays = connectedRelays,
pubKeyHex = account.pubKeyHex,
onEvent = { event, _, _, _ ->
if (event is ContactListEvent) {
@@ -175,7 +173,7 @@ fun UserProfileScreen(
eoseReceivedCount++
// 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) {
contactListLoaded = true
}
@@ -194,11 +192,10 @@ fun UserProfileScreen(
}
// Subscribe to user metadata
rememberSubscription(relayStatuses, pubKeyHex, retryTrigger, relayManager = relayManager) {
val configuredRelays = relayStatuses.keys
if (configuredRelays.isNotEmpty()) {
rememberSubscription(connectedRelays, pubKeyHex, retryTrigger, relayManager = relayManager) {
if (connectedRelays.isNotEmpty()) {
createMetadataSubscription(
relays = configuredRelays,
relays = connectedRelays,
pubKeyHex = pubKeyHex,
onEvent = { event, _, _, _ ->
if (event is MetadataEvent) {
@@ -229,11 +226,10 @@ fun UserProfileScreen(
}
// Subscribe to profile user's contact list (for following count)
rememberSubscription(relayStatuses, pubKeyHex, retryTrigger, relayManager = relayManager) {
val configuredRelays = relayStatuses.keys
if (configuredRelays.isNotEmpty()) {
rememberSubscription(connectedRelays, pubKeyHex, retryTrigger, relayManager = relayManager) {
if (connectedRelays.isNotEmpty()) {
createContactListSubscription(
relays = configuredRelays,
relays = connectedRelays,
pubKeyHex = pubKeyHex,
onEvent = { event, _, _, _ ->
if (event is ContactListEvent) {
@@ -252,9 +248,8 @@ fun UserProfileScreen(
val followerAuthors = remember(pubKeyHex) { mutableSetOf<String>() }
// Subscribe to followers (contact lists that tag this user)
rememberSubscription(relayStatuses, pubKeyHex, retryTrigger, relayManager = relayManager) {
val configuredRelays = relayStatuses.keys
if (configuredRelays.isNotEmpty()) {
rememberSubscription(connectedRelays, pubKeyHex, retryTrigger, relayManager = relayManager) {
if (connectedRelays.isNotEmpty()) {
// Clear previous followers when subscription restarts
followerAuthors.clear()
followersCount = 0
@@ -269,7 +264,7 @@ fun UserProfileScreen(
limit = 500,
),
),
relays = configuredRelays,
relays = connectedRelays,
onEvent = { event, _, _, _ ->
// Count unique authors who follow this user
if (followerAuthors.add(event.pubKey)) {
@@ -284,13 +279,12 @@ fun UserProfileScreen(
}
// Subscribe to user posts
rememberSubscription(relayStatuses, pubKeyHex, retryTrigger, relayManager = relayManager) {
val configuredRelays = relayStatuses.keys
if (configuredRelays.isNotEmpty()) {
rememberSubscription(connectedRelays, pubKeyHex, retryTrigger, relayManager = relayManager) {
if (connectedRelays.isNotEmpty()) {
postsLoading = true
postsError = null
createUserPostsSubscription(
relays = configuredRelays,
relays = connectedRelays,
pubKeyHex = pubKeyHex,
onEvent = { 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_title
import com.vitorpamplona.amethyst.commons.resources.login_generate_button
import com.vitorpamplona.amethyst.desktop.account.validateBunkerUri
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
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
fun LoginCard(
onLogin: (String) -> Result<Unit>,
@@ -291,7 +271,7 @@ private fun NostrConnectContent(onLoginNostrConnect: suspend (onUriGenerated: (S
scope.launch(Dispatchers.IO) {
val result =
onLoginNostrConnect { uri ->
nostrConnectUri = uri
scope.launch(Dispatchers.Main) { nostrConnectUri = uri }
}
withContext(Dispatchers.Main) {
result.onFailure {
@@ -22,54 +22,65 @@ package com.vitorpamplona.amethyst.desktop.ui.auth
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.size
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.FilterQuality
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.toComposeImageBitmap
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 com.google.zxing.BarcodeFormat
import com.google.zxing.EncodeHintType
import com.google.zxing.qrcode.QRCodeWriter
import java.awt.image.BufferedImage
@Composable
fun QrCodeCanvas(
data: String,
modifier: Modifier = Modifier,
size: Dp = 200.dp,
foreground: Color = MaterialTheme.colorScheme.onSurface,
background: Color = Color.White,
) {
val matrix =
val bitmap =
remember(data) {
QRCodeWriter().encode(
data,
BarcodeFormat.QR_CODE,
0,
0,
mapOf(EncodeHintType.MARGIN to 1),
)
createQrBitmap(data)
}
Canvas(modifier = modifier.size(size)) {
val cellWidth = this.size.width / matrix.width
val cellHeight = this.size.height / matrix.height
drawRect(background, Offset.Zero, this.size)
for (x in 0 until matrix.width) {
for (y in 0 until matrix.height) {
if (matrix.get(x, y)) {
drawRect(
color = foreground,
topLeft = Offset(x * cellWidth, y * cellHeight),
size = Size(cellWidth, cellHeight),
)
}
}
}
drawImage(
image = bitmap,
srcOffset = IntOffset.Zero,
srcSize = IntSize(bitmap.width, bitmap.height),
dstOffset = IntOffset.Zero,
dstSize = IntSize(this.size.width.toInt(), this.size.height.toInt()),
filterQuality = FilterQuality.None,
)
}
}
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 relaySearchResults by searchState.relaySearchResults.collectAsState()
val isSearchingRelays by searchState.isSearchingRelays.collectAsState()
val relayStatuses by relayManager.relayStatuses.collectAsState()
val connectedRelays by relayManager.connectedRelays.collectAsState()
val focusRequester = remember { FocusRequester() }
// NIP-50 relay search when local cache has few/no results
rememberSubscription(relayStatuses, searchText, cachedUsers.size, relayManager = relayManager) {
val configuredRelays = relayStatuses.keys
if (configuredRelays.isEmpty()) return@rememberSubscription null
rememberSubscription(connectedRelays, searchText, cachedUsers.size, relayManager = relayManager) {
if (connectedRelays.isEmpty()) return@rememberSubscription null
if (searchState.shouldSearchRelays) {
searchState.startRelaySearch()
createSearchPeopleSubscription(
relays = configuredRelays,
relays = connectedRelays,
searchQuery = searchText,
limit = 20,
onEvent = { event, _, _, _ ->
@@ -113,16 +112,15 @@ fun NewDmDialog(
}
// Bech32 npub metadata loading
rememberSubscription(relayStatuses, searchText, relayManager = relayManager) {
val configuredRelays = relayStatuses.keys
if (configuredRelays.isEmpty() || searchText.length < 2) {
rememberSubscription(connectedRelays, searchText, relayManager = relayManager) {
if (connectedRelays.isEmpty() || searchText.length < 2) {
return@rememberSubscription null
}
val pubkeyHex = decodePublicKeyAsHexOrNull(searchText)
if (pubkeyHex != null) {
createMetadataSubscription(
relays = configuredRelays,
relays = connectedRelays,
pubKeyHex = pubkeyHex,
onEvent = { event, _, _, _ ->
if (event is MetadataEvent) {
@@ -111,30 +111,10 @@ class AccountManagerLoadAccountTest {
storage.getPrivateKey(AccountManager.BUNKER_EPHEMERAL_KEY_ALIAS)
} returns null
val result = manager.loadSavedAccount(client = com.vitorpamplona.quartz.nip01Core.relay.client.EmptyNostrClient)
val result = manager.loadSavedAccount()
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
fun loadSavedAccountBunkerSuccess() =
runTest {
@@ -152,10 +132,7 @@ class AccountManagerLoadAccountTest {
storage.getPrivateKey(AccountManager.BUNKER_EPHEMERAL_KEY_ALIAS)
} returns ephemeralPrivKeyHex
val result =
manager.loadSavedAccount(
client = com.vitorpamplona.quartz.nip01Core.relay.client.EmptyNostrClient,
)
val result = manager.loadSavedAccount()
assertTrue(result.isSuccess)
val state = result.getOrThrow()
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
import com.vitorpamplona.amethyst.desktop.ui.auth.validateBunkerUri
import kotlin.test.Test
import kotlin.test.assertEquals
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.LnZapRequestEvent
import com.vitorpamplona.quartz.utils.Hex
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
@@ -73,6 +74,7 @@ class NostrSignerRemote(
Filter(
kinds = listOf(NostrConnectEvent.KIND),
tags = mapOf("p" to listOf(signer.pubKey)),
since = TimeUtils.now() - 60,
),
) { event ->
if (event is NostrConnectEvent) {
@@ -301,9 +303,10 @@ class NostrSignerRemote(
permissions: String? = null,
): NostrSignerRemote {
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://")
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 relays = mutableSetOf<NormalizedRelayUrl>()
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",
)
}
}
}