From 3a1e38a2f21463790b3109fdff72e84e3c96acdf Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Fri, 6 Mar 2026 13:19:49 +0200 Subject: [PATCH] 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 --- .../vitorpamplona/amethyst/desktop/Main.kt | 49 +-- .../desktop/account/AccountManager.kt | 196 ++++++++---- .../desktop/account/BunkerUriUtils.kt | 42 +++ .../amethyst/desktop/chess/ChessScreen.kt | 9 +- .../desktop/network/RelayConnectionManager.kt | 2 +- .../subscriptions/SubscriptionUtils.kt | 7 +- .../amethyst/desktop/ui/BookmarksScreen.kt | 23 +- .../amethyst/desktop/ui/ComposeNoteDialog.kt | 3 - .../amethyst/desktop/ui/FeedScreen.kt | 76 +++-- .../amethyst/desktop/ui/LoginScreen.kt | 138 +++++++-- .../desktop/ui/NotificationsScreen.kt | 8 +- .../amethyst/desktop/ui/ReadsScreen.kt | 19 +- .../amethyst/desktop/ui/SearchScreen.kt | 16 +- .../amethyst/desktop/ui/ThreadScreen.kt | 50 ++-- .../amethyst/desktop/ui/UserProfileScreen.kt | 38 +-- .../amethyst/desktop/ui/auth/LoginCard.kt | 24 +- .../amethyst/desktop/ui/auth/QrCodeCanvas.kt | 71 +++-- .../amethyst/desktop/ui/chats/NewDmDialog.kt | 16 +- .../account/AccountManagerLoadAccountTest.kt | 27 +- .../AccountManagerNip46IsolationTest.kt | 195 ++++++++++++ .../AccountManagerStateTransitionTest.kt | 132 +++++++++ .../desktop/account/BunkerUriUtilsTest.kt | 1 - .../signer/NostrSignerRemote.kt | 5 +- .../signer/NostrSignerRemoteIsolationTest.kt | 278 ++++++++++++++++++ 24 files changed, 1101 insertions(+), 324 deletions(-) create mode 100644 desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/account/BunkerUriUtils.kt create mode 100644 desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManagerNip46IsolationTest.kt create mode 100644 desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManagerStateTransitionTest.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/NostrSignerRemoteIsolationTest.kt diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt index bc8fb669e..d353393af 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt @@ -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 -> { diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManager.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManager.kt index bd0ca588a..76e3d7bed 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManager.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManager.kt @@ -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, + ) { + withTimeout(NIP46_RELAY_CONNECT_TIMEOUT_MS) { + client.connectedRelaysFlow().first { connected -> + targetRelays.any { it in connected } + } + } + } + // --- Account loading --- - suspend fun loadSavedAccount(client: INostrClient? = null): Result = + suspend fun loadSavedAccount(): Result = 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 { 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 = + suspend fun loginWithBunker(bunkerUri: String): Result = 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 = + suspend fun loginWithNostrConnect(onUriGenerated: (String) -> Unit): Result = 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, expectedSecret: String, - client: INostrClient, + client: NostrClient, ): ConnectRequestData { val deferred = CompletableDeferred() @@ -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(decryptedJson) + val signerPubkey = event.talkingWith(ephemeralSigner.pubKey) + val decryptedJson = ephemeralSigner.decrypt(event.content, signerPubkey) + val message = OptimizedJsonMapper.fromJsonTo(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, - 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) } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/account/BunkerUriUtils.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/account/BunkerUriUtils.kt new file mode 100644 index 000000000..c476d3c97 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/account/BunkerUriUtils.kt @@ -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 +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/chess/ChessScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/chess/ChessScreen.kt index 432fd7c47..daf10b553 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/chess/ChessScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/chess/ChessScreen.kt @@ -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) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/network/RelayConnectionManager.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/network/RelayConnectionManager.kt index 8a42cad81..1f98c52ef 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/network/RelayConnectionManager.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/network/RelayConnectionManager.kt @@ -221,6 +221,6 @@ open class RelayConnectionManager( cmd: Command, success: Boolean, ) { - // Command send tracking + // Command send tracking — no-op for now } } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/SubscriptionUtils.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/SubscriptionUtils.kt index 3ecac1b05..4bf7062e5 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/SubscriptionUtils.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/SubscriptionUtils.kt @@ -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) + } } } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/BookmarksScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/BookmarksScreen.kt index d8e915825..c4f1570c6 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/BookmarksScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/BookmarksScreen.kt @@ -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) }, diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ComposeNoteDialog.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ComposeNoteDialog.kt index 9c6a805a8..dcf903fa7 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ComposeNoteDialog.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ComposeNoteDialog.kt @@ -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) } } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt index e79aa3cfa..38ce7b121 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt @@ -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) { diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/LoginScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/LoginScreen.kt index 880214190..050d829ed 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/LoginScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/LoginScreen.kt @@ -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 = 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), + ) + } + } + } + } + } } } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NotificationsScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NotificationsScreen.kt index 0fc6ba44a..e1be49646 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NotificationsScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NotificationsScreen.kt @@ -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) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ReadsScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ReadsScreen.kt index 11121055b..3853e4b6e 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ReadsScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ReadsScreen.kt @@ -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) { diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/SearchScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/SearchScreen.kt index cc4cee93b..1caa34d94 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/SearchScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/SearchScreen.kt @@ -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) { diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ThreadScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ThreadScreen.kt index d72b914ef..ca79ca553 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ThreadScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ThreadScreen.kt @@ -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) { diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt index 704a8f03a..eed9c760f 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt @@ -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(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() } // 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) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/auth/LoginCard.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/auth/LoginCard.kt index a90fe81f9..04e86c919 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/auth/LoginCard.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/auth/LoginCard.kt @@ -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, @@ -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 { diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/auth/QrCodeCanvas.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/auth/QrCodeCanvas.kt index 68320b18b..cb8ea6442 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/auth/QrCodeCanvas.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/auth/QrCodeCanvas.kt @@ -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() +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/NewDmDialog.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/NewDmDialog.kt index a01593013..dd220a06b 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/NewDmDialog.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/NewDmDialog.kt @@ -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) { diff --git a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManagerLoadAccountTest.kt b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManagerLoadAccountTest.kt index 9515ed26e..005029e24 100644 --- a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManagerLoadAccountTest.kt +++ b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManagerLoadAccountTest.kt @@ -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(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(state.signerType) diff --git a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManagerNip46IsolationTest.kt b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManagerNip46IsolationTest.kt new file mode 100644 index 000000000..ef5bce652 --- /dev/null +++ b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManagerNip46IsolationTest.kt @@ -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(state.signerType) + assertIs(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(manager.accountState.value) + + manager.logout() + assertIs(manager.accountState.value) + assertIs( + 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(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(firstSigner) + assertIs(secondSigner) + assertTrue( + firstSigner !== secondSigner, + "Second load should create a new signer instance", + ) + } +} diff --git a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManagerStateTransitionTest.kt b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManagerStateTransitionTest.kt new file mode 100644 index 000000000..988cbf96a --- /dev/null +++ b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManagerStateTransitionTest.kt @@ -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() + 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(states[0]) + assertIs(states[1]) + assertIs(states[2]) + + collector.cancel() + } + + @Test + fun connectingRelaysThenFailureFallsBackToLoggedOut() = + runTest { + val states = mutableListOf() + 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(states[0]) + assertIs(states[1]) + assertIs(states[2]) + + collector.cancel() + } +} diff --git a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/account/BunkerUriUtilsTest.kt b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/account/BunkerUriUtilsTest.kt index 09ac52c3b..10149a71b 100644 --- a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/account/BunkerUriUtilsTest.kt +++ b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/account/BunkerUriUtilsTest.kt @@ -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 diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/NostrSignerRemote.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/NostrSignerRemote.kt index da0c3b63e..c54995156 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/NostrSignerRemote.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/NostrSignerRemote.kt @@ -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() var secret: String? = null diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/NostrSignerRemoteIsolationTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/NostrSignerRemoteIsolationTest.kt new file mode 100644 index 000000000..3bad658c7 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/NostrSignerRemoteIsolationTest.kt @@ -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>, + ) + + val subscriptions = mutableListOf() + val sentEvents = mutableListOf>>() + + override fun connectedRelaysFlow() = MutableStateFlow(emptySet()) + + override fun availableRelaysFlow() = MutableStateFlow(emptySet()) + + 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>, + listener: IRequestListener?, + ) { + subscriptions.add(SubscriptionRecord(subId, filters)) + } + + override fun queryCount( + subId: String, + filters: Map>, + ) {} + + override fun close(subId: String) {} + + override fun send( + event: Event, + relayList: Set, + ) { + sentEvents.add(event to relayList) + } + + override fun subscribe(listener: IRelayClientListener) {} + + override fun unsubscribe(listener: IRelayClientListener) {} + + override fun getReqFiltersOrNull(subId: String): Map>? = null + + override fun getCountFiltersOrNull(subId: String): Map>? = 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", + ) + } + } +}