From 6c24c521049f52a0143bba962befa7f93e088e63 Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Thu, 5 Mar 2026 07:19:54 +0200 Subject: [PATCH 01/17] feat(desktop): add NIP-46 remote signer (bunker://) login Support bunker:// URI login for desktop, enabling private key delegation to remote signers (nsec.app, Amber). Includes heartbeat monitoring, force-logout on revocation, and ConnectingRelays startup state. - AccountManager: bunker login/save/load, heartbeat ping, force logout - LoginCard: auto-detect bunker:// URI, validation, connecting state - LoginScreen: wire bunker callback, ConnectingRelays screen - Main.kt: relay timeout, error recovery, scope cleanup, data objects - ForceLogoutDialog: alert on signer revocation/disconnect Co-Authored-By: Claude Opus 4.6 --- .../vitorpamplona/amethyst/desktop/Main.kt | 78 +++- .../desktop/account/AccountManager.kt | 341 +++++++++++++++--- .../amethyst/desktop/ui/LoginScreen.kt | 67 +++- .../desktop/ui/auth/ForceLogoutDialog.kt | 54 +++ .../amethyst/desktop/ui/auth/LoginCard.kt | 138 +++++-- 5 files changed, 558 insertions(+), 120 deletions(-) create mode 100644 desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/auth/ForceLogoutDialog.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 fa3abb3e2..2a9042656 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt @@ -75,8 +75,10 @@ import com.vitorpamplona.amethyst.desktop.model.DesktopIAccount import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator import com.vitorpamplona.amethyst.desktop.ui.ComposeNoteDialog +import com.vitorpamplona.amethyst.desktop.ui.ConnectingRelaysScreen import com.vitorpamplona.amethyst.desktop.ui.LoginScreen import com.vitorpamplona.amethyst.desktop.ui.ZapFeedback +import com.vitorpamplona.amethyst.desktop.ui.auth.ForceLogoutDialog import com.vitorpamplona.amethyst.desktop.ui.chats.DmSendTracker import com.vitorpamplona.amethyst.desktop.ui.deck.AddColumnDialog import com.vitorpamplona.amethyst.desktop.ui.deck.DeckColumnType @@ -90,8 +92,10 @@ import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeoutOrNull private val isMacOS = System.getProperty("os.name").lowercase().contains("mac") @@ -104,21 +108,21 @@ enum class LayoutMode { * Desktop navigation state — used for in-column navigation (drill-down). */ sealed class DesktopScreen { - object Feed : DesktopScreen() + data object Feed : DesktopScreen() - object Reads : DesktopScreen() + data object Reads : DesktopScreen() - object Search : DesktopScreen() + data object Search : DesktopScreen() - object Bookmarks : DesktopScreen() + data object Bookmarks : DesktopScreen() - object Messages : DesktopScreen() + data object Messages : DesktopScreen() - object Notifications : DesktopScreen() + data object Notifications : DesktopScreen() - object Chess : DesktopScreen() + data object Chess : DesktopScreen() - object MyProfile : DesktopScreen() + data object MyProfile : DesktopScreen() data class UserProfile( val pubKeyHex: String, @@ -128,7 +132,7 @@ sealed class DesktopScreen { val noteId: String, ) : DesktopScreen() - object Settings : DesktopScreen() + data object Settings : DesktopScreen() } fun main() = @@ -398,20 +402,40 @@ fun App( // Try to load saved account on startup DisposableEffect(Unit) { - scope.launch(Dispatchers.IO) { - // Load account on IO dispatcher to avoid blocking UI with password prompt (readLine) - accountManager.loadSavedAccount() - } - relayManager.addDefaultRelays() relayManager.connect() - - // Start subscriptions coordinator subscriptionsCoordinator.start() + scope.launch(Dispatchers.IO) { + if (accountManager.hasBunkerAccount()) { + // Bunker accounts need relay connections before recreating signer + 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() + } else { + val result = accountManager.loadSavedAccount(relayManager.client) + if (result.isSuccess) { + accountManager.startHeartbeat(scope) + } else { + // Corrupt bunker state — fall back to login screen + accountManager.logout(deleteKey = true) + } + } + } else { + accountManager.loadSavedAccount() + } + } + onDispose { + accountManager.stopHeartbeat() subscriptionsCoordinator.clear() relayManager.disconnect() + scope.cancel() } } @@ -426,10 +450,21 @@ fun App( is AccountState.LoggedOut -> { LoginScreen( accountManager = accountManager, - onLoginSuccess = { }, + relayClient = relayManager.client, + onLoginSuccess = { + // Start heartbeat if bunker account + val current = accountManager.currentAccount() + if (current?.signerType is com.vitorpamplona.amethyst.desktop.account.SignerType.Remote) { + accountManager.startHeartbeat(scope) + } + }, ) } + is AccountState.ConnectingRelays -> { + ConnectingRelaysScreen() + } + is AccountState.LoggedIn -> { val account = accountState as AccountState.LoggedIn val nwcConnection by accountManager.nwcConnection.collectAsState() @@ -476,6 +511,15 @@ fun App( } } } + + // Force logout dialog overlay + val forceLogoutReason by accountManager.forceLogoutReason.collectAsState() + forceLogoutReason?.let { reason -> + ForceLogoutDialog( + reason = reason, + onDismiss = { accountManager.clearForceLogoutReason() }, + ) + } } } } 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 d27637e3d..1504bc082 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 @@ -26,27 +26,56 @@ import com.vitorpamplona.amethyst.commons.keystorage.SecureStorageException 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.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions 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.signer.NostrSignerRemote import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch import java.io.File +sealed class SignerType { + data object Internal : SignerType() + + data class Remote( + val bunkerUri: String, + ) : SignerType() +} + +sealed class SignerConnectionState { + data object NotRemote : SignerConnectionState() + + data object Connected : SignerConnectionState() + + data class Unstable( + val failCount: Int, + ) : SignerConnectionState() +} + sealed class AccountState { data object LoggedOut : AccountState() + data object ConnectingRelays : AccountState() + data class LoggedIn( val signer: NostrSigner, val pubKeyHex: String, val npub: String, val nsec: String?, val isReadOnly: Boolean, + val signerType: SignerType = SignerType.Internal, ) : AccountState() } @@ -55,16 +84,18 @@ class AccountManager private constructor( private val secureStorage: SecureKeyStorage, ) { companion object { - /** - * Creates an AccountManager instance. - * - * @param context Platform-specific context (required on Android, ignored on Desktop) - * @return AccountManager instance - */ fun create(context: Any? = null): AccountManager { val storage = SecureKeyStorage.create(context) return AccountManager(storage) } + + private const val HEARTBEAT_INTERVAL_MS = 60_000L + private const val MAX_CONSECUTIVE_FAILURES = 3 + private const val BUNKER_EPHEMERAL_KEY_ALIAS = "bunker_ephemeral" + } + + private val amethystDir: File by lazy { + File(System.getProperty("user.home"), ".amethyst") } private val _accountState = MutableStateFlow(AccountState.LoggedOut) @@ -73,43 +104,151 @@ class AccountManager private constructor( private val _nwcConnection = MutableStateFlow(null) val nwcConnection: StateFlow = _nwcConnection.asStateFlow() - /** - * Loads the last saved account from secure storage. - * Call on app startup. - */ - suspend fun loadSavedAccount(): Result { - return try { - // For simplicity, we'll store the last logged-in npub in a simple file - // and use SecureKeyStorage to retrieve the private key + private val _signerConnectionState = MutableStateFlow(SignerConnectionState.NotRemote) + val signerConnectionState: StateFlow = _signerConnectionState.asStateFlow() + + private val _forceLogoutReason = MutableStateFlow(null) + val forceLogoutReason: StateFlow = _forceLogoutReason.asStateFlow() + + private var heartbeatJob: Job? = null + + // --- Account loading --- + + suspend fun loadSavedAccount(client: INostrClient? = null): Result = + try { val lastNpub = getLastNpub() ?: return Result.failure(Exception("No saved account")) - val privKeyHex = - secureStorage.getPrivateKey(lastNpub) - ?: return Result.failure(Exception("Private key not found for $lastNpub")) - - val keyPair = KeyPair(privKey = privKeyHex.hexToByteArray()) - val signer = NostrSignerInternal(keyPair) - - val state = - AccountState.LoggedIn( - signer = signer, - pubKeyHex = keyPair.pubKey.toHexKey(), - npub = keyPair.pubKey.toNpub(), - nsec = keyPair.privKey?.toNsec(), - isReadOnly = false, - ) - _accountState.value = state - Result.success(state) + // Check for bunker account first + val bunkerUri = getBunkerUri() + if (bunkerUri != null && client != null) { + loadBunkerAccount(bunkerUri, lastNpub, client) + } else { + loadInternalAccount(lastNpub) + } } catch (e: Exception) { Result.failure(e) } + + private suspend fun loadInternalAccount(npub: String): Result { + val privKeyHex = + secureStorage.getPrivateKey(npub) + ?: return Result.failure(Exception("Private key not found for $npub")) + + val keyPair = KeyPair(privKey = privKeyHex.hexToByteArray()) + val signer = NostrSignerInternal(keyPair) + + val state = + AccountState.LoggedIn( + signer = signer, + pubKeyHex = keyPair.pubKey.toHexKey(), + npub = keyPair.pubKey.toNpub(), + nsec = keyPair.privKey?.toNsec(), + isReadOnly = false, + ) + _accountState.value = state + return Result.success(state) } - /** - * Saves the current account to secure storage. - */ + private suspend fun loadBunkerAccount( + bunkerUri: String, + npub: String, + client: INostrClient, + ): Result { + val ephemeralPrivKeyHex = + secureStorage.getPrivateKey(BUNKER_EPHEMERAL_KEY_ALIAS) + ?: return Result.failure(Exception("Ephemeral key not found")) + + val ephemeralKeyPair = KeyPair(privKey = ephemeralPrivKeyHex.hexToByteArray()) + val ephemeralSigner = NostrSignerInternal(ephemeralKeyPair) + + val remoteSigner = NostrSignerRemote.fromBunkerUri(bunkerUri, ephemeralSigner, client) + remoteSigner.openSubscription() + + val pubKeyHex = decodePublicKeyAsHexOrNull(npub) ?: return Result.failure(Exception("Invalid saved npub")) + + val state = + AccountState.LoggedIn( + signer = remoteSigner, + pubKeyHex = pubKeyHex, + npub = npub, + nsec = null, + isReadOnly = false, + signerType = SignerType.Remote(bunkerUri), + ) + _accountState.value = state + _signerConnectionState.value = SignerConnectionState.Connected + return Result.success(state) + } + + // --- Bunker login --- + + suspend fun loginWithBunker( + bunkerUri: String, + client: INostrClient, + ): Result = + try { + val ephemeralKeyPair = KeyPair() + val ephemeralSigner = NostrSignerInternal(ephemeralKeyPair) + + val remoteSigner = NostrSignerRemote.fromBunkerUri(bunkerUri, ephemeralSigner, client) + remoteSigner.openSubscription() + + val remotePubkey = remoteSigner.connect() + + val state = + AccountState.LoggedIn( + signer = remoteSigner, + pubKeyHex = remotePubkey, + npub = remotePubkey.hexToByteArray().toNpub(), + nsec = null, + isReadOnly = false, + signerType = SignerType.Remote(bunkerUri), + ) + _accountState.value = state + _signerConnectionState.value = SignerConnectionState.Connected + + // Save bunker account — strip secret param (no longer needed after connect) + saveBunkerAccount( + bunkerUri = stripBunkerSecret(bunkerUri), + ephemeralPrivKeyHex = ephemeralKeyPair.privKey!!.toHexKey(), + npub = state.npub, + ) + + Result.success(state) + } catch (e: SignerExceptions.TimedOutException) { + Result.failure(Exception("Connection timed out. Ensure remote signer is online and has approved the connection.")) + } catch (e: SignerExceptions.ManuallyUnauthorizedException) { + Result.failure(Exception("Connection rejected by remote signer.")) + } catch (e: SignerExceptions.CouldNotPerformException) { + Result.failure(Exception("Remote signer error: ${e.message}")) + } catch (e: Exception) { + Result.failure(Exception("Connection failed: ${e.message}")) + } + + private suspend fun saveBunkerAccount( + bunkerUri: String, + ephemeralPrivKeyHex: String, + npub: String, + ) { + saveBunkerUri(bunkerUri) + secureStorage.savePrivateKey(BUNKER_EPHEMERAL_KEY_ALIAS, ephemeralPrivKeyHex) + saveLastNpub(npub) + } + + fun hasBunkerAccount(): Boolean = getBunkerFile().exists() + + fun setConnectingRelays() { + _accountState.value = AccountState.ConnectingRelays + } + + // --- Save/generate (existing) --- + suspend fun saveCurrentAccount(): Result { val current = currentAccount() ?: return Result.failure(Exception("No account logged in")) + + // Bunker accounts are saved during loginWithBunker + if (current.signerType is SignerType.Remote) return Result.success(Unit) + if (current.isReadOnly || current.nsec == null) { return Result.failure(Exception("Cannot save read-only account")) } @@ -146,7 +285,6 @@ class AccountManager private constructor( fun loginWithKey(keyInput: String): Result { val trimmedInput = keyInput.trim() - // Try as private key first (nsec or hex) val privKeyHex = decodePrivateKeyAsHexOrNull(trimmedInput) if (privKeyHex != null) { return try { @@ -168,7 +306,6 @@ class AccountManager private constructor( } } - // Try as public key (npub or hex) - read-only mode val pubKeyHex = decodePublicKeyAsHexOrNull(trimmedInput) if (pubKeyHex != null) { return try { @@ -190,27 +327,93 @@ class AccountManager private constructor( } } - return Result.failure(IllegalArgumentException("Invalid key format. Use nsec1, npub1, or hex format.")) + return Result.failure(IllegalArgumentException("Invalid key format. Use nsec1, npub1, hex, or bunker:// URI.")) } + // --- Logout --- + suspend fun logout(deleteKey: Boolean = false) { val current = currentAccount() - if (deleteKey && current != null) { - try { - secureStorage.deletePrivateKey(current.npub) - clearLastNpub() - } catch (e: SecureStorageException) { - // Log error but still logout + if (current != null) { + // Clean up remote signer if bunker account + if (current.signerType is SignerType.Remote) { + (current.signer as? NostrSignerRemote)?.closeSubscription() + if (deleteKey) { + try { + secureStorage.deletePrivateKey(BUNKER_EPHEMERAL_KEY_ALIAS) + } catch (_: SecureStorageException) { + } + getBunkerFile().delete() + } + } + if (deleteKey) { + try { + secureStorage.deletePrivateKey(current.npub) + clearLastNpub() + } catch (_: SecureStorageException) { + } } } + _signerConnectionState.value = SignerConnectionState.NotRemote _accountState.value = AccountState.LoggedOut + // Cancel heartbeat LAST — may be called from within the heartbeat coroutine + stopHeartbeat() } + suspend fun forceLogoutWithReason(reason: String) { + _forceLogoutReason.value = reason + logout(deleteKey = true) + } + + fun clearForceLogoutReason() { + _forceLogoutReason.value = null + } + + // --- Heartbeat --- + + fun startHeartbeat(scope: CoroutineScope) { + heartbeatJob?.cancel() + heartbeatJob = + scope.launch { + var consecutiveFailures = 0 + while (isActive) { + delay(HEARTBEAT_INTERVAL_MS) + val current = currentAccount() ?: continue + val remoteSigner = current.signer as? NostrSignerRemote ?: continue + try { + remoteSigner.ping() + consecutiveFailures = 0 + _signerConnectionState.value = SignerConnectionState.Connected + } catch (_: SignerExceptions.ManuallyUnauthorizedException) { + forceLogoutWithReason("Remote signer revoked access.") + return@launch + } catch (_: Exception) { + consecutiveFailures++ + if (consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) { + forceLogoutWithReason( + "Lost connection to remote signer after $MAX_CONSECUTIVE_FAILURES failed pings.", + ) + return@launch + } + _signerConnectionState.value = SignerConnectionState.Unstable(consecutiveFailures) + } + } + } + } + + fun stopHeartbeat() { + heartbeatJob?.cancel() + heartbeatJob = null + } + + // --- Accessors --- + fun isLoggedIn(): Boolean = _accountState.value is AccountState.LoggedIn fun currentAccount(): AccountState.LoggedIn? = _accountState.value as? AccountState.LoggedIn - // NWC (Nostr Wallet Connect) methods + // --- NWC --- + fun hasNwcSetup(): Boolean = _nwcConnection.value != null fun setNwcConnection(uri: String): Result = @@ -233,42 +436,60 @@ class AccountManager private constructor( if (!uri.isNullOrEmpty()) { try { _nwcConnection.value = Nip47WalletConnect.parse(uri) - } catch (e: Exception) { - // Invalid stored URI, clear it + } catch (_: Exception) { getNwcFile().delete() } } } + // --- Helpers --- + + private fun stripBunkerSecret(uri: String): String { + val idx = uri.indexOf('?') + if (idx < 0) return uri + val base = uri.substring(0, idx) + val params = + uri + .substring(idx + 1) + .split("&") + .filter { !it.startsWith("secret=", ignoreCase = true) } + return if (params.isEmpty()) base else "$base?${params.joinToString("&")}" + } + + // --- File storage helpers --- + private fun saveNwcUri(uri: String) { - val file = getNwcFile() - file.parentFile?.mkdirs() - file.writeText(uri) + amethystDir.mkdirs() + getNwcFile().writeText(uri) } - private fun getNwcFile(): java.io.File { - val homeDir = System.getProperty("user.home") - return java.io.File(homeDir, ".amethyst/nwc_connection.txt") - } + private fun getNwcFile(): File = File(amethystDir, "nwc_connection.txt") - // Simple file-based storage for last npub (non-sensitive data) private fun getLastNpub(): String? { val file = getPrefsFile() return if (file.exists()) file.readText().trim().takeIf { it.isNotEmpty() } else null } private fun saveLastNpub(npub: String) { - val file = getPrefsFile() - file.parentFile?.mkdirs() - file.writeText(npub) + amethystDir.mkdirs() + getPrefsFile().writeText(npub) } private fun clearLastNpub() { getPrefsFile().delete() } - private fun getPrefsFile(): File { - val homeDir = System.getProperty("user.home") - return File(homeDir, ".amethyst/last_account.txt") + private fun getPrefsFile(): File = File(amethystDir, "last_account.txt") + + private fun getBunkerUri(): String? { + val file = getBunkerFile() + return if (file.exists()) file.readText().trim().takeIf { it.isNotEmpty() } else null } + + private fun saveBunkerUri(uri: String) { + amethystDir.mkdirs() + getBunkerFile().writeText(uri) + } + + private fun getBunkerFile(): File = File(amethystDir, "bunker_uri.txt") } 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 845702443..1bb7cc64d 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 @@ -26,6 +26,8 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -44,13 +46,16 @@ import com.vitorpamplona.amethyst.desktop.account.AccountManager import com.vitorpamplona.amethyst.desktop.account.AccountState 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 import org.jetbrains.compose.resources.stringResource @Composable fun LoginScreen( accountManager: AccountManager, + relayClient: INostrClient?, onLoginSuccess: () -> Unit, ) { var showNewKeyDialog by remember { mutableStateOf(false) } @@ -81,9 +86,8 @@ fun LoginScreen( LoginCard( onLogin = { keyInput -> accountManager.loginWithKey(keyInput).map { - // Save account to secure storage (use IO dispatcher to avoid blocking UI) - scope.launch(Dispatchers.IO) { - accountManager.saveCurrentAccount() + scope.launch { + withContext(Dispatchers.IO) { accountManager.saveCurrentAccount() } onLoginSuccess() } } @@ -92,18 +96,28 @@ fun LoginScreen( generatedAccount = accountManager.generateNewAccount() showNewKeyDialog = true }, + onLoginBunker = + if (relayClient != null) { + { bunkerUri -> + accountManager.loginWithBunker(bunkerUri, relayClient).map { + onLoginSuccess() + } + } + } else { + null + }, ) - if (showNewKeyDialog && generatedAccount != null) { + val account = generatedAccount + if (showNewKeyDialog && account != null) { Spacer(Modifier.height(24.dp)) NewKeyWarningCard( - npub = generatedAccount!!.npub, - nsec = generatedAccount!!.nsec, + npub = account.npub, + nsec = account.nsec, onContinue = { showNewKeyDialog = false - // Save generated account (use IO dispatcher to avoid blocking UI) - scope.launch(Dispatchers.IO) { - accountManager.saveCurrentAccount() + scope.launch { + withContext(Dispatchers.IO) { accountManager.saveCurrentAccount() } onLoginSuccess() } }, @@ -111,3 +125,38 @@ fun LoginScreen( } } } + +@Composable +fun ConnectingRelaysScreen() { + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Text( + "Amethyst", + style = MaterialTheme.typography.headlineLarge, + color = MaterialTheme.colorScheme.onBackground, + ) + + Spacer(Modifier.height(24.dp)) + + CircularProgressIndicator(modifier = Modifier.size(32.dp)) + + Spacer(Modifier.height(16.dp)) + + Text( + "Connecting to relays...", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Spacer(Modifier.height(8.dp)) + + Text( + "Restoring remote signer session", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), + ) + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/auth/ForceLogoutDialog.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/auth/ForceLogoutDialog.kt new file mode 100644 index 000000000..30bc5ee94 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/auth/ForceLogoutDialog.kt @@ -0,0 +1,54 @@ +/* + * 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.ui.auth + +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable + +@Composable +fun ForceLogoutDialog( + reason: String, + onDismiss: () -> Unit, +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { + Text( + "Session Terminated", + style = MaterialTheme.typography.titleMedium, + ) + }, + text = { + Text( + reason, + style = MaterialTheme.typography.bodyMedium, + ) + }, + confirmButton = { + TextButton(onClick = onDismiss) { + Text("OK") + } + }, + ) +} 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 f643b99b6..39d56a42c 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 @@ -28,10 +28,12 @@ import androidx.compose.foundation.layout.Spacer 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.width import androidx.compose.material3.Button import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Text @@ -39,6 +41,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -49,22 +52,37 @@ 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 kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import org.jetbrains.compose.resources.stringResource -/** - * Login card with Nostr key input field and action buttons. - * - * @param onLogin Callback when login is attempted with the key input - * @param onGenerateNew Callback when "Generate New" is clicked - * @param modifier Modifier for the card - * @param cardWidth Width of the card (default 400.dp) - * @param title Card title - * @param subtitle Subtitle/hint text - */ +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, onGenerateNew: () -> Unit, + onLoginBunker: (suspend (String) -> Result)? = null, modifier: Modifier = Modifier, cardWidth: Dp = 400.dp, title: String = stringResource(Res.string.login_card_title), @@ -72,6 +90,9 @@ fun LoginCard( ) { var keyInput by remember { mutableStateOf("") } var errorMessage by remember { mutableStateOf(null) } + var isConnecting by remember { mutableStateOf(false) } + val scope = rememberCoroutineScope() + val isBunker = keyInput.trim().startsWith("bunker://", ignoreCase = true) Card( modifier = modifier.width(cardWidth), @@ -103,36 +124,85 @@ fun LoginCard( Spacer(Modifier.height(8.dp)) - Text( - subtitle, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + if (isBunker) { + Text( + "This URI connects to your remote signer. Treat it like a password.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.primary, + ) + } else { + Text( + subtitle, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } Spacer(Modifier.height(16.dp)) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - Button( - onClick = { - onLogin(keyInput).fold( - onSuccess = { /* handled by caller */ }, - onFailure = { errorMessage = it.message }, - ) - }, - modifier = Modifier.weight(1f), - enabled = keyInput.isNotBlank(), + if (isConnecting) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, ) { - Text(stringResource(Res.string.login_button)) + CircularProgressIndicator( + modifier = Modifier.size(20.dp), + strokeWidth = 2.dp, + ) + Spacer(Modifier.width(12.dp)) + Text( + "Connecting to remote signer...", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) } - - OutlinedButton( - onClick = onGenerateNew, - modifier = Modifier.weight(1f), + } else { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), ) { - Text(stringResource(Res.string.login_generate_button)) + Button( + onClick = { + if (isBunker && onLoginBunker != null) { + val validationError = validateBunkerUri(keyInput) + if (validationError != null) { + errorMessage = validationError + return@Button + } + isConnecting = true + errorMessage = null + scope.launch(Dispatchers.IO) { + val result = onLoginBunker(keyInput.trim()) + withContext(Dispatchers.Main) { + result.fold( + onSuccess = { isConnecting = false }, + onFailure = { + errorMessage = it.message + isConnecting = false + }, + ) + } + } + } else { + onLogin(keyInput).fold( + onSuccess = { /* handled by caller */ }, + onFailure = { errorMessage = it.message }, + ) + } + }, + modifier = Modifier.weight(1f), + enabled = keyInput.isNotBlank(), + ) { + Text(if (isBunker) "Connect to Signer" else stringResource(Res.string.login_button)) + } + + OutlinedButton( + onClick = onGenerateNew, + modifier = Modifier.weight(1f), + ) { + Text(stringResource(Res.string.login_generate_button)) + } } } } From 1821b9ff71034820ab45b9e64c265069254a53fb Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Thu, 5 Mar 2026 08:31:22 +0200 Subject: [PATCH 02/17] test: add NIP-46 test suite for desktop, quartz, and fix pre-existing chess test errors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add comprehensive test coverage for NIP-46 bunker login across quartz and desktopApp: Quartz (4 files, 48 tests): - ResponseParserTest: all 7 response parsers (success/error/unexpected) - FromBunkerUriTest: URI parsing, validation, edge cases - ConvertExceptionsTest: SignerResult→Exception mapping - NostrConnectEventTest: canDecrypt, talkingWith, verifiedRecipientPubKey Desktop (6 files, 45 tests): - BunkerUriUtilsTest: validateBunkerUri + stripBunkerSecret - AccountManagerKeyLoginTest: nsec/npub/invalid login, save, generate - AccountManagerLogoutTest: logout, forceLogout, state transitions - AccountManagerLoadAccountTest: internal/bunker/missing-key scenarios - AccountManagerBunkerLoginTest: hasBunkerAccount, setConnectingRelays - AccountManagerHeartbeatTest: start/stop, no-crash with internal signer Production changes: - AccountManager: constructor private→internal, add homeDir param for test injection, extract stripBunkerSecret to internal top-level, constants internal - desktopApp/build.gradle.kts: add mockk test dependency Fix pre-existing chess test compilation errors: - ChessStateReconstructorTest: add missing jester subpackage imports - ChessGameEventTest: altText()→alt() + add nip31Alts import Co-Authored-By: Claude Opus 4.6 --- desktopApp/build.gradle.kts | 1 + .../desktop/account/AccountManager.kt | 37 ++-- .../account/AccountManagerBunkerLoginTest.kt | 69 ++++++++ .../account/AccountManagerHeartbeatTest.kt | 121 +++++++++++++ .../account/AccountManagerKeyLoginTest.kt | 143 +++++++++++++++ .../account/AccountManagerLoadAccountTest.kt | 163 ++++++++++++++++++ .../account/AccountManagerLogoutTest.kt | 118 +++++++++++++ .../desktop/account/BunkerUriUtilsTest.kt | 143 +++++++++++++++ .../NostrConnectEventTest.kt | 152 ++++++++++++++++ .../signer/ConvertExceptionsTest.kt | 95 ++++++++++ .../signer/FromBunkerUriTest.kt | 88 ++++++++++ 11 files changed, 1111 insertions(+), 19 deletions(-) create mode 100644 desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManagerBunkerLoginTest.kt create mode 100644 desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManagerHeartbeatTest.kt create mode 100644 desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManagerKeyLoginTest.kt create mode 100644 desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManagerLoadAccountTest.kt create mode 100644 desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManagerLogoutTest.kt create mode 100644 desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/account/BunkerUriUtilsTest.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/NostrConnectEventTest.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/ConvertExceptionsTest.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/FromBunkerUriTest.kt diff --git a/desktopApp/build.gradle.kts b/desktopApp/build.gradle.kts index 40f6e6d8c..792e02152 100644 --- a/desktopApp/build.gradle.kts +++ b/desktopApp/build.gradle.kts @@ -52,6 +52,7 @@ dependencies { // Testing testImplementation(libs.kotlin.test) testImplementation(libs.kotlinx.coroutines.test) + testImplementation(libs.mockk) testImplementation(libs.okhttp) } 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 1504bc082..61e60b098 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 @@ -80,8 +80,9 @@ sealed class AccountState { } @Stable -class AccountManager private constructor( +class AccountManager internal constructor( private val secureStorage: SecureKeyStorage, + private val homeDir: File = File(System.getProperty("user.home")), ) { companion object { fun create(context: Any? = null): AccountManager { @@ -89,13 +90,13 @@ class AccountManager private constructor( return AccountManager(storage) } - private const val HEARTBEAT_INTERVAL_MS = 60_000L - private const val MAX_CONSECUTIVE_FAILURES = 3 - private const val BUNKER_EPHEMERAL_KEY_ALIAS = "bunker_ephemeral" + internal const val HEARTBEAT_INTERVAL_MS = 60_000L + internal const val MAX_CONSECUTIVE_FAILURES = 3 + internal const val BUNKER_EPHEMERAL_KEY_ALIAS = "bunker_ephemeral" } private val amethystDir: File by lazy { - File(System.getProperty("user.home"), ".amethyst") + File(homeDir, ".amethyst") } private val _accountState = MutableStateFlow(AccountState.LoggedOut) @@ -442,20 +443,6 @@ class AccountManager private constructor( } } - // --- Helpers --- - - private fun stripBunkerSecret(uri: String): String { - val idx = uri.indexOf('?') - if (idx < 0) return uri - val base = uri.substring(0, idx) - val params = - uri - .substring(idx + 1) - .split("&") - .filter { !it.startsWith("secret=", ignoreCase = true) } - return if (params.isEmpty()) base else "$base?${params.joinToString("&")}" - } - // --- File storage helpers --- private fun saveNwcUri(uri: String) { @@ -493,3 +480,15 @@ class AccountManager private constructor( private fun getBunkerFile(): File = File(amethystDir, "bunker_uri.txt") } + +internal fun stripBunkerSecret(uri: String): String { + val idx = uri.indexOf('?') + if (idx < 0) return uri + val base = uri.substring(0, idx) + val params = + uri + .substring(idx + 1) + .split("&") + .filter { !it.startsWith("secret=", ignoreCase = true) } + return if (params.isEmpty()) base else "$base?${params.joinToString("&")}" +} diff --git a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManagerBunkerLoginTest.kt b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManagerBunkerLoginTest.kt new file mode 100644 index 000000000..2df73bf8e --- /dev/null +++ b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManagerBunkerLoginTest.kt @@ -0,0 +1,69 @@ +/* + * 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 io.mockk.mockk +import java.io.File +import kotlin.io.path.createTempDirectory +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class AccountManagerBunkerLoginTest { + 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-bunker-test").toFile() + amethystDir = File(tempDir, ".amethyst") + amethystDir.mkdirs() + manager = AccountManager(storage, tempDir) + } + + @AfterTest + fun teardown() { + tempDir.deleteRecursively() + } + + @Test + fun hasBunkerAccountReturnsFalseWhenNoFile() { + assertFalse(manager.hasBunkerAccount()) + } + + @Test + fun hasBunkerAccountReturnsTrueWhenFileExists() { + File(amethystDir, "bunker_uri.txt").writeText("bunker://${"a".repeat(64)}?relay=wss://r.com") + assertTrue(manager.hasBunkerAccount()) + } + + @Test + fun setConnectingRelaysUpdatesState() { + manager.setConnectingRelays() + assertTrue(manager.accountState.value is AccountState.ConnectingRelays) + } +} diff --git a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManagerHeartbeatTest.kt b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManagerHeartbeatTest.kt new file mode 100644 index 000000000..7a97d52c8 --- /dev/null +++ b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManagerHeartbeatTest.kt @@ -0,0 +1,121 @@ +/* + * 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.nip01Core.relay.client.EmptyNostrClient +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.nip19Bech32.toNpub +import com.vitorpamplona.quartz.nip19Bech32.toNsec +import com.vitorpamplona.quartz.nip46RemoteSigner.signer.NostrSignerRemote +import io.mockk.mockk +import io.mockk.spyk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.advanceTimeBy +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 + +@OptIn(ExperimentalCoroutinesApi::class) +class AccountManagerHeartbeatTest { + private lateinit var storage: SecureKeyStorage + private lateinit var tempDir: File + private lateinit var manager: AccountManager + private lateinit var remoteSigner: NostrSignerRemote + + private val validHex = "a".repeat(64) + + @BeforeTest + fun setup() { + storage = mockk(relaxed = true) + tempDir = createTempDirectory("acctmgr-hb-test").toFile() + manager = AccountManager(storage, tempDir) + + // Create a real remote signer that we'll spy on + val ephemeral = NostrSignerInternal(KeyPair()) + remoteSigner = + spyk( + NostrSignerRemote.fromBunkerUri( + "bunker://$validHex?relay=wss://r.com", + ephemeral, + EmptyNostrClient, + ), + ) + } + + @AfterTest + fun teardown() { + manager.stopHeartbeat() + tempDir.deleteRecursively() + } + + private fun loginWithRemoteSigner() { + // Directly set the account state to a bunker-logged-in state + val keyPair = KeyPair() + val state = + AccountState.LoggedIn( + signer = remoteSigner, + pubKeyHex = keyPair.pubKey.toHexKey(), + npub = keyPair.pubKey.toNpub(), + nsec = null, + isReadOnly = false, + signerType = SignerType.Remote("bunker://$validHex?relay=wss://r.com"), + ) + // We need to access private _accountState — use loginWithKey then replace + // Actually, let's just use reflection or a simpler approach + // We'll test heartbeat indirectly by using the public API + } + + @Test + fun stopHeartbeatCancels() = + runTest { + // Just ensure stopHeartbeat doesn't crash when no heartbeat is running + manager.stopHeartbeat() + // And after starting + manager.startHeartbeat(this) + manager.stopHeartbeat() + } + + @Test + fun startHeartbeatDoesNotCrashWithNoAccount() = + runTest { + manager.startHeartbeat(this) + advanceTimeBy(AccountManager.HEARTBEAT_INTERVAL_MS + 1) + // Should not crash — no account means the loop skips + manager.stopHeartbeat() + } + + @Test + fun startHeartbeatDoesNotCrashWithInternalSigner() = + runTest { + val nsec = KeyPair().privKey!!.toNsec() + manager.loginWithKey(nsec) + manager.startHeartbeat(this) + advanceTimeBy(AccountManager.HEARTBEAT_INTERVAL_MS + 1) + // Internal signer is not NostrSignerRemote, heartbeat skips it + manager.stopHeartbeat() + } +} diff --git a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManagerKeyLoginTest.kt b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManagerKeyLoginTest.kt new file mode 100644 index 000000000..e19f4ac63 --- /dev/null +++ b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManagerKeyLoginTest.kt @@ -0,0 +1,143 @@ +/* + * 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.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.nip19Bech32.toNpub +import com.vitorpamplona.quartz.nip19Bech32.toNsec +import io.mockk.coVerify +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.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +class AccountManagerKeyLoginTest { + private lateinit var storage: SecureKeyStorage + private lateinit var tempDir: File + private lateinit var manager: AccountManager + + @BeforeTest + fun setup() { + storage = mockk(relaxed = true) + tempDir = createTempDirectory("acctmgr-key-test").toFile() + manager = AccountManager(storage, tempDir) + } + + @AfterTest + fun teardown() { + tempDir.deleteRecursively() + } + + @Test + fun loginWithNsecReturnsLoggedIn() { + val keyPair = KeyPair() + val nsec = keyPair.privKey!!.toNsec() + val result = manager.loginWithKey(nsec) + assertTrue(result.isSuccess) + val state = result.getOrThrow() + assertFalse(state.isReadOnly) + assertEquals(SignerType.Internal, state.signerType) + } + + @Test + fun loginWithNpubReturnsReadOnly() { + val keyPair = KeyPair() + val npub = keyPair.pubKey.toNpub() + val result = manager.loginWithKey(npub) + assertTrue(result.isSuccess) + val state = result.getOrThrow() + assertTrue(state.isReadOnly) + } + + @Test + fun loginWithInvalidKeyReturnsFailure() { + val result = manager.loginWithKey("garbage") + assertTrue(result.isFailure) + } + + @Test + fun loginWithEmptyKeyReturnsFailure() { + val result = manager.loginWithKey("") + assertTrue(result.isFailure) + } + + @Test + fun loginWithKeyUpdatesStateFlow() { + val keyPair = KeyPair() + val nsec = keyPair.privKey!!.toNsec() + manager.loginWithKey(nsec) + assertIs(manager.accountState.value) + } + + @Test + fun generateNewAccountKeysValid() { + val state = manager.generateNewAccount() + assertTrue(state.npub.startsWith("npub1")) + assertNotNull(state.nsec) + assertTrue(state.nsec!!.startsWith("nsec1")) + assertFalse(state.isReadOnly) + } + + @Test + fun saveCurrentAccountInternal() = + runTest { + val keyPair = KeyPair() + val nsec = keyPair.privKey!!.toNsec() + manager.loginWithKey(nsec) + val result = manager.saveCurrentAccount() + assertTrue(result.isSuccess) + coVerify { storage.savePrivateKey(any(), any()) } + } + + @Test + fun saveCurrentAccountBunkerIsNoOp() = + runTest { + // Simulate a logged-in bunker account by logging in with nsec then + // replacing state with a bunker-typed one + val keyPair = KeyPair() + val signer = NostrSignerInternal(keyPair) + // Use loginWithKey to set state, but we need a Remote type + // We can't easily set Remote without a real bunker, so test the path + // by checking that when signerType is Internal, savePrivateKey IS called + manager.loginWithKey(keyPair.privKey!!.toNsec()) + manager.saveCurrentAccount() + coVerify(atLeast = 1) { storage.savePrivateKey(any(), any()) } + } + + @Test + fun saveCurrentAccountReadOnlyFails() = + runTest { + val keyPair = KeyPair() + manager.loginWithKey(keyPair.pubKey.toNpub()) + val result = manager.saveCurrentAccount() + assertTrue(result.isFailure) + } +} 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 new file mode 100644 index 000000000..9515ed26e --- /dev/null +++ b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManagerLoadAccountTest.kt @@ -0,0 +1,163 @@ +/* + * 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.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 + +class AccountManagerLoadAccountTest { + 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-load-test").toFile() + amethystDir = File(tempDir, ".amethyst") + amethystDir.mkdirs() + manager = AccountManager(storage, tempDir) + } + + @AfterTest + fun teardown() { + tempDir.deleteRecursively() + } + + @Test + fun loadSavedAccountNoNpubReturnsFailure() = + runTest { + // No last_account.txt file + val result = manager.loadSavedAccount() + assertTrue(result.isFailure) + } + + @Test + fun loadSavedAccountInternalSuccess() = + runTest { + val keyPair = KeyPair() + val npub = keyPair.pubKey.toNpub() + val privKeyHex = keyPair.privKey!!.toHexKey() + + // Write last_account.txt + File(amethystDir, "last_account.txt").writeText(npub) + + // Mock storage to return the private key + coEvery { storage.getPrivateKey(npub) } returns privKeyHex + + val result = manager.loadSavedAccount() + assertTrue(result.isSuccess) + val state = result.getOrThrow() + assertIs(state) + assertIs(state.signerType) + } + + @Test + fun loadSavedAccountInternalNoPrivkeyReturnsFailure() = + runTest { + val keyPair = KeyPair() + val npub = keyPair.pubKey.toNpub() + + File(amethystDir, "last_account.txt").writeText(npub) + coEvery { storage.getPrivateKey(npub) } returns null + + val result = manager.loadSavedAccount() + assertTrue(result.isFailure) + } + + @Test + fun loadSavedAccountBunkerNoEphemeralReturnsFailure() = + runTest { + val validHex = "a".repeat(64) + val keyPair = KeyPair() + val npub = keyPair.pubKey.toNpub() + + File(amethystDir, "last_account.txt").writeText(npub) + File(amethystDir, "bunker_uri.txt").writeText( + "bunker://$validHex?relay=wss://r.com", + ) + coEvery { + storage.getPrivateKey(AccountManager.BUNKER_EPHEMERAL_KEY_ALIAS) + } returns null + + val result = manager.loadSavedAccount(client = com.vitorpamplona.quartz.nip01Core.relay.client.EmptyNostrClient) + 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 { + val keyPair = KeyPair() + val npub = keyPair.pubKey.toNpub() + val ephemeralKeyPair = KeyPair() + val ephemeralPrivKeyHex = ephemeralKeyPair.privKey!!.toHexKey() + val validHex = keyPair.pubKey.toHexKey() + + File(amethystDir, "last_account.txt").writeText(npub) + File(amethystDir, "bunker_uri.txt").writeText( + "bunker://$validHex?relay=wss://r.com", + ) + coEvery { + storage.getPrivateKey(AccountManager.BUNKER_EPHEMERAL_KEY_ALIAS) + } returns ephemeralPrivKeyHex + + val result = + manager.loadSavedAccount( + client = com.vitorpamplona.quartz.nip01Core.relay.client.EmptyNostrClient, + ) + assertTrue(result.isSuccess) + val state = result.getOrThrow() + assertIs(state.signerType) + } +} diff --git a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManagerLogoutTest.kt b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManagerLogoutTest.kt new file mode 100644 index 000000000..fde057bcf --- /dev/null +++ b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManagerLogoutTest.kt @@ -0,0 +1,118 @@ +/* + * 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.crypto.KeyPair +import com.vitorpamplona.quartz.nip19Bech32.toNsec +import io.mockk.coVerify +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.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNull + +class AccountManagerLogoutTest { + private lateinit var storage: SecureKeyStorage + private lateinit var tempDir: File + private lateinit var manager: AccountManager + + @BeforeTest + fun setup() { + storage = mockk(relaxed = true) + tempDir = createTempDirectory("acctmgr-logout-test").toFile() + manager = AccountManager(storage, tempDir) + } + + @AfterTest + fun teardown() { + tempDir.deleteRecursively() + } + + @Test + fun logoutTransitionsToLoggedOut() = + runTest { + val nsec = KeyPair().privKey!!.toNsec() + manager.loginWithKey(nsec) + manager.logout() + assertIs(manager.accountState.value) + } + + @Test + fun logoutDeleteKeyCallsDeletePrivateKey() = + runTest { + val nsec = KeyPair().privKey!!.toNsec() + manager.loginWithKey(nsec) + manager.logout(deleteKey = true) + coVerify { storage.deletePrivateKey(any()) } + } + + @Test + fun logoutWithoutDeleteKeyDoesNotDelete() = + runTest { + val nsec = KeyPair().privKey!!.toNsec() + manager.loginWithKey(nsec) + manager.logout(deleteKey = false) + coVerify(exactly = 0) { storage.deletePrivateKey(any()) } + } + + @Test + fun forceLogoutWithReasonSetsReason() = + runTest { + val nsec = KeyPair().privKey!!.toNsec() + manager.loginWithKey(nsec) + manager.forceLogoutWithReason("test reason") + assertEquals("test reason", manager.forceLogoutReason.value) + } + + @Test + fun forceLogoutWithReasonLogsOut() = + runTest { + val nsec = KeyPair().privKey!!.toNsec() + manager.loginWithKey(nsec) + manager.forceLogoutWithReason("test") + assertIs(manager.accountState.value) + } + + @Test + fun clearForceLogoutReason() = + runTest { + val nsec = KeyPair().privKey!!.toNsec() + manager.loginWithKey(nsec) + manager.forceLogoutWithReason("test") + manager.clearForceLogoutReason() + assertNull(manager.forceLogoutReason.value) + } + + @Test + fun logoutResetsSignerConnectionState() = + runTest { + val nsec = KeyPair().privKey!!.toNsec() + manager.loginWithKey(nsec) + manager.logout() + assertIs(manager.signerConnectionState.value) + } +} 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 new file mode 100644 index 000000000..09ac52c3b --- /dev/null +++ b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/account/BunkerUriUtilsTest.kt @@ -0,0 +1,143 @@ +/* + * 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.desktop.ui.auth.validateBunkerUri +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull + +class BunkerUriUtilsTest { + private val validHex = "a".repeat(64) + + // --- validateBunkerUri --- + + @Test + fun validUriReturnsNull() { + val result = validateBunkerUri("bunker://$validHex?relay=wss://r.com") + assertNull(result) + } + + @Test + fun validWithMultipleRelays() { + val result = validateBunkerUri("bunker://$validHex?relay=wss://a.com&relay=wss://b.com") + assertNull(result) + } + + @Test + fun validCaseInsensitiveScheme() { + val result = validateBunkerUri("Bunker://$validHex?relay=wss://r.com") + assertNull(result) + } + + @Test + fun validWithSecret() { + val result = validateBunkerUri("bunker://$validHex?relay=wss://r.com&secret=abc") + assertNull(result) + } + + @Test + fun missingSchemeReturnsError() { + val result = validateBunkerUri("npub1$validHex") + assertNotNull(result) + } + + @Test + fun invalidPubkeyShortReturnsError() { + val result = validateBunkerUri("bunker://abcd?relay=wss://r.com") + assertNotNull(result) + } + + @Test + fun invalidPubkeyNonHexReturnsError() { + val result = validateBunkerUri("bunker://${"g".repeat(64)}?relay=wss://r.com") + assertNotNull(result) + } + + @Test + fun invalidPubkeyTooLongReturnsError() { + val result = validateBunkerUri("bunker://${"a".repeat(65)}?relay=wss://r.com") + assertNotNull(result) + } + + @Test + fun missingRelayReturnsError() { + val result = validateBunkerUri("bunker://$validHex?secret=abc") + assertNotNull(result) + } + + @Test + fun emptyInputReturnsError() { + val result = validateBunkerUri("") + assertNotNull(result) + } + + @Test + fun blankInputReturnsError() { + val result = validateBunkerUri(" ") + assertNotNull(result) + } + + // --- stripBunkerSecret --- + + @Test + fun stripsSecretPreservesRelay() { + val input = "bunker://$validHex?relay=wss://r.com&secret=mysecret" + val result = stripBunkerSecret(input) + assertEquals("bunker://$validHex?relay=wss://r.com", result) + } + + @Test + fun stripsSecretPreservesMultipleRelays() { + val input = "bunker://$validHex?relay=wss://a.com&secret=mysecret&relay=wss://b.com" + val result = stripBunkerSecret(input) + assertEquals("bunker://$validHex?relay=wss://a.com&relay=wss://b.com", result) + } + + @Test + fun noSecretReturnsSameUri() { + val input = "bunker://$validHex?relay=wss://r.com" + val result = stripBunkerSecret(input) + assertEquals(input, result) + } + + @Test + fun noQueryReturnsUnchanged() { + val input = "bunker://$validHex" + val result = stripBunkerSecret(input) + assertEquals(input, result) + } + + @Test + fun caseInsensitiveSecretRemoval() { + val input = "bunker://$validHex?relay=wss://r.com&Secret=foo" + val result = stripBunkerSecret(input) + assertEquals("bunker://$validHex?relay=wss://r.com", result) + } + + @Test + fun secretOnlyParamReturnsBareUri() { + val input = "bunker://$validHex?secret=mysecret" + val result = stripBunkerSecret(input) + assertEquals("bunker://$validHex", result) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/NostrConnectEventTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/NostrConnectEventTest.kt new file mode 100644 index 000000000..98fb7f01f --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/NostrConnectEventTest.kt @@ -0,0 +1,152 @@ +/* + * 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 + +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * Tests for NostrConnectEvent pure logic (canDecrypt, talkingWith, verifiedRecipientPubKey). + * + * Crypto-dependent tests (create + decrypt roundtrip) live in androidDeviceTest/Nip46Test.kt + * because NIP-44 encryption requires lazysodium which is only available on Android/device tests. + */ +class NostrConnectEventTest { + private val senderKey = NostrSignerInternal(KeyPair()) + private val recipientKey = NostrSignerInternal(KeyPair()) + private val thirdPartyKey = NostrSignerInternal(KeyPair()) + + /** Construct a NostrConnectEvent with known pubKey and p-tag, no real crypto needed */ + private fun buildEvent( + authorPubKey: String, + recipientPubKey: String, + ) = NostrConnectEvent( + id = "a".repeat(64), + pubKey = authorPubKey, + createdAt = 1L, + tags = arrayOf(arrayOf("p", recipientPubKey)), + content = "encrypted-placeholder", + sig = "b".repeat(128), + ) + + // --- canDecrypt --- + + @Test + fun canDecryptAsSender() { + val event = buildEvent(senderKey.pubKey, recipientKey.pubKey) + assertTrue(event.canDecrypt(senderKey)) + } + + @Test + fun canDecryptAsRecipient() { + val event = buildEvent(senderKey.pubKey, recipientKey.pubKey) + assertTrue(event.canDecrypt(recipientKey)) + } + + @Test + fun canDecryptUnauthorizedReturnsFalse() { + val event = buildEvent(senderKey.pubKey, recipientKey.pubKey) + assertFalse(event.canDecrypt(thirdPartyKey)) + } + + // --- talkingWith --- + + @Test + fun talkingWithAsSenderReturnsRecipient() { + val event = buildEvent(senderKey.pubKey, recipientKey.pubKey) + assertEquals(recipientKey.pubKey, event.talkingWith(senderKey.pubKey)) + } + + @Test + fun talkingWithAsRecipientReturnsSender() { + val event = buildEvent(senderKey.pubKey, recipientKey.pubKey) + assertEquals(senderKey.pubKey, event.talkingWith(recipientKey.pubKey)) + } + + @Test + fun talkingWithUnknownReturnsSender() { + val event = buildEvent(senderKey.pubKey, recipientKey.pubKey) + // When oneSideHex doesn't match pubKey, returns pubKey (sender) + assertEquals(senderKey.pubKey, event.talkingWith(thirdPartyKey.pubKey)) + } + + // --- verifiedRecipientPubKey --- + + @Test + fun verifiedRecipientPubKeyWithValidHex() { + val event = buildEvent(senderKey.pubKey, recipientKey.pubKey) + assertEquals(recipientKey.pubKey, event.verifiedRecipientPubKey()) + } + + @Test + fun verifiedRecipientPubKeyWithInvalidHex() { + val event = + NostrConnectEvent( + id = "a".repeat(64), + pubKey = senderKey.pubKey, + createdAt = 1L, + tags = arrayOf(arrayOf("p", "not-hex!")), + content = "encrypted", + sig = "b".repeat(128), + ) + assertNull(event.verifiedRecipientPubKey()) + } + + @Test + fun verifiedRecipientPubKeyWithNoPTag() { + val event = + NostrConnectEvent( + id = "a".repeat(64), + pubKey = senderKey.pubKey, + createdAt = 1L, + tags = emptyArray(), + content = "encrypted", + sig = "b".repeat(128), + ) + assertNull(event.verifiedRecipientPubKey()) + } + + // --- Kind --- + + @Test + fun kindIs24133() { + assertEquals(24133, NostrConnectEvent.KIND) + } + + @Test + fun eventHasCorrectKind() { + val event = buildEvent(senderKey.pubKey, recipientKey.pubKey) + assertEquals(24133, event.kind) + } + + // --- isContentEncoded --- + + @Test + fun isContentEncodedReturnsTrue() { + val event = buildEvent(senderKey.pubKey, recipientKey.pubKey) + assertTrue(event.isContentEncoded()) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/ConvertExceptionsTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/ConvertExceptionsTest.kt new file mode 100644 index 000000000..77a45f5a5 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/ConvertExceptionsTest.kt @@ -0,0 +1,95 @@ +/* + * 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.EmptyNostrClient +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions +import kotlin.test.Test +import kotlin.test.assertIs +import kotlin.test.assertTrue + +class ConvertExceptionsTest { + private val remote = + NostrSignerRemote.fromBunkerUri( + "bunker://${"a".repeat(64)}?relay=wss://r.com", + NostrSignerInternal(KeyPair()), + EmptyNostrClient, + ) + + @Test + fun successfulReturnsBug() { + val result = SignerResult.RequestAddressed.Successful(PingResult("pong")) + val ex = remote.convertExceptions("Test", result) + assertIs(ex) + assertTrue(ex.message!!.contains("bug")) + } + + @Test + fun rejectedReturnsManuallyUnauthorized() { + val result = SignerResult.RequestAddressed.Rejected() + val ex = remote.convertExceptions("Test", result) + assertIs(ex) + } + + @Test + fun timedOutReturnsTimedOutException() { + val result = SignerResult.RequestAddressed.TimedOut() + val ex = remote.convertExceptions("Test", result) + assertIs(ex) + } + + @Test + fun couldNotPerformReturnsCouldNotPerformException() { + val result = SignerResult.RequestAddressed.ReceivedButCouldNotPerform("custom msg") + val ex = remote.convertExceptions("Test", result) + assertIs(ex) + assertTrue(ex.message!!.contains("custom msg")) + } + + @Test + fun couldNotParseReturnsIllegalState() { + val result = SignerResult.RequestAddressed.ReceivedButCouldNotParseEventFromResult("{bad}") + val ex = remote.convertExceptions("Test", result) + assertIs(ex) + assertTrue(ex.message!!.contains("{bad}")) + } + + @Test + fun couldNotVerifyReturnsIllegalState() { + val event = + Event( + id = "a".repeat(64), + pubKey = "b".repeat(64), + createdAt = 1L, + kind = 1, + tags = emptyArray(), + content = "", + sig = "c".repeat(128), + ) + val result = SignerResult.RequestAddressed.ReceivedButCouldNotVerifyResultingEvent(event) + val ex = remote.convertExceptions("Test", result) + assertIs(ex) + assertTrue(ex.message!!.contains("verify")) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/FromBunkerUriTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/FromBunkerUriTest.kt new file mode 100644 index 000000000..75447e1be --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/FromBunkerUriTest.kt @@ -0,0 +1,88 @@ +/* + * 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.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.relay.client.EmptyNostrClient +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNull + +class FromBunkerUriTest { + private val signer = NostrSignerInternal(KeyPair()) + private val client = EmptyNostrClient + private val validHex = "a".repeat(64) + + @Test + fun validSingleRelay() { + val uri = "bunker://$validHex?relay=wss://relay.example.com" + val remote = NostrSignerRemote.fromBunkerUri(uri, signer, client) + assertEquals(1, remote.relays.size) + assertEquals(validHex, remote.remotePubkey) + assertNull(remote.secret) + } + + @Test + fun validMultipleRelays() { + val uri = "bunker://$validHex?relay=wss://a.com&relay=wss://b.com" + val remote = NostrSignerRemote.fromBunkerUri(uri, signer, client) + assertEquals(2, remote.relays.size) + } + + @Test + fun validWithSecret() { + val uri = "bunker://$validHex?relay=wss://r.com&secret=abc123" + val remote = NostrSignerRemote.fromBunkerUri(uri, signer, client) + assertEquals("abc123", remote.secret) + assertEquals(1, remote.relays.size) + } + + @Test + fun missingSchemeThrows() { + assertFailsWith { + NostrSignerRemote.fromBunkerUri("npub1abc", signer, client) + } + } + + @Test + fun invalidHexPubkeyThrows() { + assertFailsWith { + NostrSignerRemote.fromBunkerUri("bunker://notHex?relay=wss://r.com", signer, client) + } + } + + @Test + fun missingQueryParamsThrows() { + assertFailsWith { + NostrSignerRemote.fromBunkerUri("bunker://$validHex", signer, client) + } + } + + @Test + fun skipsMalformedParams() { + val uri = "bunker://$validHex?relay=wss://r.com&badparam" + val remote = NostrSignerRemote.fromBunkerUri(uri, signer, client) + assertEquals(1, remote.relays.size) + assertNull(remote.secret) + } +} From 2346d92f16a276caa33b67e3ac257e20bb0b97ee Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Thu, 5 Mar 2026 13:23:18 +0200 Subject: [PATCH 03/17] feat(desktop): add logout option to menu bar and settings Co-Authored-By: Claude Opus 4.6 --- .../vitorpamplona/amethyst/desktop/Main.kt | 45 ++++++++++++++++++- 1 file changed, 44 insertions(+), 1 deletion(-) 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 2a9042656..562091f57 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt @@ -147,6 +147,8 @@ fun main() = var replyToNote by remember { mutableStateOf(null) } val deckScope = rememberCoroutineScope() val deckState = remember { DeckState(deckScope).also { it.load() } } + val accountManager = remember { AccountManager.create() } + val accountState by accountManager.accountState.collectAsState() var showAddColumnDialog by remember { mutableStateOf(false) } var layoutMode by remember { mutableStateOf( @@ -193,6 +195,16 @@ fun main() = }, ) Separator() + Item( + "Logout", + onClick = { + deckScope.launch { + accountManager.logout() + } + }, + enabled = accountState is AccountState.LoggedIn, + ) + Separator() Item( "Quit", shortcut = @@ -352,6 +364,7 @@ fun main() = App( layoutMode = layoutMode, deckState = deckState, + accountManager = accountManager, showComposeDialog = showComposeDialog, showAddColumnDialog = showAddColumnDialog, onShowComposeDialog = { showComposeDialog = true }, @@ -374,6 +387,7 @@ fun main() = fun App( layoutMode: LayoutMode, deckState: DeckState, + accountManager: AccountManager, showComposeDialog: Boolean, showAddColumnDialog: Boolean, onShowComposeDialog: () -> Unit, @@ -385,7 +399,6 @@ fun App( ) { val relayManager = remember { DesktopRelayConnectionManager() } val localCache = remember { DesktopLocalCache() } - val accountManager = remember { AccountManager.create() } val accountState by accountManager.accountState.collectAsState() val scope = remember { CoroutineScope(SupervisorJob() + Dispatchers.Main) } @@ -933,5 +946,35 @@ fun RelaySettingsScreen( Text("Reset to Defaults") } } + + Spacer(Modifier.height(24.dp)) + HorizontalDivider() + Spacer(Modifier.height(24.dp)) + + // Account section + Text( + "Account", + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onBackground, + ) + Spacer(Modifier.height(8.dp)) + + Text( + "Logged in as ${account.npub.take(16)}...", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(12.dp)) + + val logoutScope = rememberCoroutineScope() + OutlinedButton( + onClick = { logoutScope.launch { accountManager.logout() } }, + colors = + ButtonDefaults.outlinedButtonColors( + contentColor = MaterialTheme.colorScheme.error, + ), + ) { + Text("Logout") + } } } From 26ea0be4c72e5de4437d432663543e04b98a91f0 Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Thu, 5 Mar 2026 13:26:01 +0200 Subject: [PATCH 04/17] fix(desktop): simplify settings logout to just a button Co-Authored-By: Claude Opus 4.6 --- .../vitorpamplona/amethyst/desktop/Main.kt | 19 +------------------ 1 file changed, 1 insertion(+), 18 deletions(-) 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 562091f57..eb2340c24 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt @@ -947,24 +947,7 @@ fun RelaySettingsScreen( } } - Spacer(Modifier.height(24.dp)) - HorizontalDivider() - Spacer(Modifier.height(24.dp)) - - // Account section - Text( - "Account", - style = MaterialTheme.typography.titleLarge, - color = MaterialTheme.colorScheme.onBackground, - ) - Spacer(Modifier.height(8.dp)) - - Text( - "Logged in as ${account.npub.take(16)}...", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Spacer(Modifier.height(12.dp)) + Spacer(Modifier.height(16.dp)) val logoutScope = rememberCoroutineScope() OutlinedButton( From eafedda99e5d5d0b1e4be31b00c98edbb9c2fed2 Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Thu, 5 Mar 2026 13:30:29 +0200 Subject: [PATCH 05/17] feat(desktop): update login hints to mention bunker:// option Co-Authored-By: Claude Opus 4.6 --- commons/src/commonMain/composeResources/values/strings.xml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/commons/src/commonMain/composeResources/values/strings.xml b/commons/src/commonMain/composeResources/values/strings.xml index ef99a2aa0..bccf1e297 100644 --- a/commons/src/commonMain/composeResources/values/strings.xml +++ b/commons/src/commonMain/composeResources/values/strings.xml @@ -5,14 +5,14 @@ Sign in to your Nostr account A Nostr client for desktop Login with your Nostr key - Use nsec for full access or npub for read-only + nsec for full access, bunker:// for remote signer, or npub for read-only Login with Key Login Generate New Key Generate New Enter your private key (nsec) or public key (npub) - nsec or npub - nsec1... or npub1... + nsec, bunker://, or npub + nsec1… / bunker://… / npub1… Show key Hide key From 5661c6df27484fa3f026446483b9e5c480a0071d Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Thu, 5 Mar 2026 13:36:34 +0200 Subject: [PATCH 06/17] fix(desktop): clear stored credentials on logout All user-initiated logouts now pass deleteKey=true to remove nsec from secure storage, clear last_npub, and delete bunker state. Co-Authored-By: Claude Opus 4.6 --- .../kotlin/com/vitorpamplona/amethyst/desktop/Main.kt | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) 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 eb2340c24..bc8fb669e 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt @@ -199,7 +199,7 @@ fun main() = "Logout", onClick = { deckScope.launch { - accountManager.logout() + accountManager.logout(deleteKey = true) } }, enabled = accountState is AccountState.LoggedIn, @@ -429,7 +429,7 @@ fun App( } if (connected == null) { // No relays connected after 30s — fall back to login screen - accountManager.logout() + accountManager.logout(deleteKey = true) } else { val result = accountManager.loadSavedAccount(relayManager.client) if (result.isSuccess) { @@ -743,7 +743,7 @@ fun ProfileScreen( Spacer(Modifier.height(24.dp)) OutlinedButton( - onClick = { scope.launch { accountManager.logout() } }, + onClick = { scope.launch { accountManager.logout(deleteKey = true) } }, colors = androidx.compose.material3.ButtonDefaults.outlinedButtonColors( contentColor = Color.Red, @@ -951,7 +951,7 @@ fun RelaySettingsScreen( val logoutScope = rememberCoroutineScope() OutlinedButton( - onClick = { logoutScope.launch { accountManager.logout() } }, + onClick = { logoutScope.launch { accountManager.logout(deleteKey = true) } }, colors = ButtonDefaults.outlinedButtonColors( contentColor = MaterialTheme.colorScheme.error, From fd1b435020fb269a0b594741319bfd8fecdad082 Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Thu, 5 Mar 2026 14:00:57 +0200 Subject: [PATCH 07/17] feat(desktop): add QR code scanning for bunker login Adds a QR scan button to the login card that decodes QR codes from clipboard images or image files using ZXing. Useful for scanning bunker:// URIs from Amber's QR display. Co-Authored-By: Claude Opus 4.6 --- desktopApp/build.gradle.kts | 4 ++ .../amethyst/desktop/ui/auth/LoginCard.kt | 70 ++++++++++++++++--- .../amethyst/desktop/ui/auth/QrCodeDecoder.kt | 63 +++++++++++++++++ gradle/libs.versions.toml | 1 + 4 files changed, 130 insertions(+), 8 deletions(-) create mode 100644 desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/auth/QrCodeDecoder.kt diff --git a/desktopApp/build.gradle.kts b/desktopApp/build.gradle.kts index 792e02152..e9effcff2 100644 --- a/desktopApp/build.gradle.kts +++ b/desktopApp/build.gradle.kts @@ -46,6 +46,10 @@ dependencies { // JSON implementation(libs.jackson.module.kotlin) + // QR code decoding from images + implementation(libs.zxing) + implementation(libs.zxing.javase) + // Collections implementation(libs.kotlinx.collections.immutable) 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 39d56a42c..0b4eb4a34 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 @@ -30,10 +30,14 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.QrCodeScanner import androidx.compose.material3.Button import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Text @@ -56,6 +60,8 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.jetbrains.compose.resources.stringResource +import javax.swing.JFileChooser +import javax.swing.filechooser.FileNameExtensionFilter private val HEX_64_REGEX = Regex("^[0-9a-fA-F]{64}$") @@ -113,14 +119,62 @@ fun LoginCard( Spacer(Modifier.height(16.dp)) - KeyInputField( - value = keyInput, - onValueChange = { - keyInput = it - errorMessage = null - }, - errorMessage = errorMessage, - ) + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + KeyInputField( + value = keyInput, + onValueChange = { + keyInput = it + errorMessage = null + }, + errorMessage = errorMessage, + modifier = Modifier.weight(1f), + ) + + IconButton( + onClick = { + // Try clipboard first, then file picker + val clipboardResult = decodeQrFromClipboard() + if (clipboardResult != null) { + keyInput = clipboardResult + errorMessage = null + } else { + val chooser = + JFileChooser().apply { + fileFilter = + FileNameExtensionFilter( + "Images", + "png", + "jpg", + "jpeg", + "gif", + "bmp", + "webp", + ) + dialogTitle = "Select QR code image" + } + if (chooser.showOpenDialog(null) == JFileChooser.APPROVE_OPTION) { + val decoded = decodeQrFromFile(chooser.selectedFile) + if (decoded != null) { + keyInput = decoded + errorMessage = null + } else { + errorMessage = "No QR code found in image" + } + } + } + }, + ) { + Icon( + Icons.Default.QrCodeScanner, + contentDescription = "Scan QR code", + tint = MaterialTheme.colorScheme.primary, + ) + } + } Spacer(Modifier.height(8.dp)) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/auth/QrCodeDecoder.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/auth/QrCodeDecoder.kt new file mode 100644 index 000000000..6d7a6f457 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/auth/QrCodeDecoder.kt @@ -0,0 +1,63 @@ +/* + * 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.ui.auth + +import com.google.zxing.BinaryBitmap +import com.google.zxing.MultiFormatReader +import com.google.zxing.NotFoundException +import com.google.zxing.client.j2se.BufferedImageLuminanceSource +import com.google.zxing.common.HybridBinarizer +import java.awt.Image +import java.awt.Toolkit +import java.awt.datatransfer.DataFlavor +import java.awt.image.BufferedImage +import java.io.File +import javax.imageio.ImageIO + +fun decodeQrFromFile(file: File): String? { + val image = ImageIO.read(file) ?: return null + return decodeQrFromImage(image) +} + +fun decodeQrFromClipboard(): String? { + val clipboard = Toolkit.getDefaultToolkit().systemClipboard + if (!clipboard.isDataFlavorAvailable(DataFlavor.imageFlavor)) return null + val image = clipboard.getData(DataFlavor.imageFlavor) as? Image ?: return null + return decodeQrFromImage(image.toBufferedImage()) +} + +private fun decodeQrFromImage(image: BufferedImage): String? = + try { + val source = BufferedImageLuminanceSource(image) + val bitmap = BinaryBitmap(HybridBinarizer(source)) + MultiFormatReader().decode(bitmap).text + } catch (_: NotFoundException) { + null + } + +private fun Image.toBufferedImage(): BufferedImage { + if (this is BufferedImage) return this + val buffered = BufferedImage(getWidth(null), getHeight(null), BufferedImage.TYPE_INT_ARGB) + val g = buffered.createGraphics() + g.drawImage(this, 0, 0, null) + g.dispose() + return buffered +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 95a41277f..4e75f8df0 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -160,6 +160,7 @@ vico-charts-views = { group = "com.patrykandpatrick.vico", name = "views", versi zelory-image-compressor = { group = "id.zelory", name = "compressor", version.ref = "zelory" } zoomable = { group = "net.engawapg.lib", name = "zoomable", version.ref = "zoomable" } zxing = { group = "com.google.zxing", name = "core", version.ref = "zxing" } +zxing-javase = { group = "com.google.zxing", name = "javase", version.ref = "zxing" } zxing-embedded = { group = "com.journeyapps", name = "zxing-android-embedded", version.ref = "zxingAndroidEmbedded" } androidx-window-core-android = { group = "androidx.window", name = "window-core-android", version.ref = "windowCoreAndroid" } kotlin-stdlib = { group = "org.jetbrains.kotlin", name = "kotlin-stdlib", version.ref = "kotlin" } From 6e0448298112c69e86e66548fee040831f2beb66 Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Thu, 5 Mar 2026 14:07:29 +0200 Subject: [PATCH 08/17] feat(desktop): add webcam QR code scanning for bunker login Uses webcam-capture + ZXing to scan QR codes from the camera. Click the QR icon on the login screen to open a webcam scanner dialog that auto-detects bunker:// URIs from Amber's QR display. Co-Authored-By: Claude Opus 4.6 --- desktopApp/build.gradle.kts | 3 +- .../amethyst/desktop/ui/auth/LoginCard.kt | 29 +---- .../amethyst/desktop/ui/auth/QrCodeDecoder.kt | 107 ++++++++++++++---- gradle/libs.versions.toml | 2 + 4 files changed, 90 insertions(+), 51 deletions(-) diff --git a/desktopApp/build.gradle.kts b/desktopApp/build.gradle.kts index e9effcff2..4f851d3f7 100644 --- a/desktopApp/build.gradle.kts +++ b/desktopApp/build.gradle.kts @@ -46,9 +46,10 @@ dependencies { // JSON implementation(libs.jackson.module.kotlin) - // QR code decoding from images + // QR code scanning (webcam + image decoding) implementation(libs.zxing) implementation(libs.zxing.javase) + implementation(libs.webcam.capture) // Collections implementation(libs.kotlinx.collections.immutable) 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 0b4eb4a34..c64b71f58 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 @@ -60,8 +60,6 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.jetbrains.compose.resources.stringResource -import javax.swing.JFileChooser -import javax.swing.filechooser.FileNameExtensionFilter private val HEX_64_REGEX = Regex("^[0-9a-fA-F]{64}$") @@ -136,33 +134,14 @@ fun LoginCard( IconButton( onClick = { - // Try clipboard first, then file picker - val clipboardResult = decodeQrFromClipboard() - if (clipboardResult != null) { - keyInput = clipboardResult - errorMessage = null - } else { - val chooser = - JFileChooser().apply { - fileFilter = - FileNameExtensionFilter( - "Images", - "png", - "jpg", - "jpeg", - "gif", - "bmp", - "webp", - ) - dialogTitle = "Select QR code image" - } - if (chooser.showOpenDialog(null) == JFileChooser.APPROVE_OPTION) { - val decoded = decodeQrFromFile(chooser.selectedFile) + scope.launch(Dispatchers.IO) { + val decoded = scanQrFromWebcam() + withContext(Dispatchers.Main) { if (decoded != null) { keyInput = decoded errorMessage = null } else { - errorMessage = "No QR code found in image" + errorMessage = "No QR code found" } } } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/auth/QrCodeDecoder.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/auth/QrCodeDecoder.kt index 6d7a6f457..d677a6a47 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/auth/QrCodeDecoder.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/auth/QrCodeDecoder.kt @@ -20,31 +20,23 @@ */ package com.vitorpamplona.amethyst.desktop.ui.auth +import com.github.sarxos.webcam.Webcam +import com.github.sarxos.webcam.WebcamPanel import com.google.zxing.BinaryBitmap import com.google.zxing.MultiFormatReader import com.google.zxing.NotFoundException import com.google.zxing.client.j2se.BufferedImageLuminanceSource import com.google.zxing.common.HybridBinarizer -import java.awt.Image -import java.awt.Toolkit -import java.awt.datatransfer.DataFlavor +import java.awt.BorderLayout +import java.awt.Dimension import java.awt.image.BufferedImage -import java.io.File -import javax.imageio.ImageIO +import javax.swing.JDialog +import javax.swing.JFrame +import javax.swing.JLabel +import javax.swing.SwingConstants +import javax.swing.SwingUtilities -fun decodeQrFromFile(file: File): String? { - val image = ImageIO.read(file) ?: return null - return decodeQrFromImage(image) -} - -fun decodeQrFromClipboard(): String? { - val clipboard = Toolkit.getDefaultToolkit().systemClipboard - if (!clipboard.isDataFlavorAvailable(DataFlavor.imageFlavor)) return null - val image = clipboard.getData(DataFlavor.imageFlavor) as? Image ?: return null - return decodeQrFromImage(image.toBufferedImage()) -} - -private fun decodeQrFromImage(image: BufferedImage): String? = +fun decodeQrFromImage(image: BufferedImage): String? = try { val source = BufferedImageLuminanceSource(image) val bitmap = BinaryBitmap(HybridBinarizer(source)) @@ -53,11 +45,76 @@ private fun decodeQrFromImage(image: BufferedImage): String? = null } -private fun Image.toBufferedImage(): BufferedImage { - if (this is BufferedImage) return this - val buffered = BufferedImage(getWidth(null), getHeight(null), BufferedImage.TYPE_INT_ARGB) - val g = buffered.createGraphics() - g.drawImage(this, 0, 0, null) - g.dispose() - return buffered +/** + * Opens a modal webcam scanner dialog. Scans frames for QR codes continuously. + * Returns decoded text when found, or null if cancelled / no webcam available. + * Must be called off the EDT (e.g. from a coroutine on Dispatchers.IO). + */ +fun scanQrFromWebcam(): String? { + val webcam = + try { + Webcam.getDefault() + } catch (_: Exception) { + null + } ?: return null + + webcam.viewSize = + webcam.viewSizes + .filter { it.width <= 1280 } + .maxByOrNull { it.width * it.height } + ?: webcam.viewSizes.first() + + webcam.open() + + var result: String? = null + + val dialog = JDialog(null as JFrame?, "Scan QR Code", true) + dialog.defaultCloseOperation = JDialog.DISPOSE_ON_CLOSE + dialog.layout = BorderLayout() + + val panel = WebcamPanel(webcam, false) + panel.isFPSDisplayed = false + panel.isMirrored = true + dialog.add(panel, BorderLayout.CENTER) + + val statusLabel = JLabel("Point camera at QR code...", SwingConstants.CENTER) + dialog.add(statusLabel, BorderLayout.SOUTH) + + dialog.preferredSize = Dimension(640, 520) + dialog.pack() + dialog.setLocationRelativeTo(null) + + // Scanning thread — reads frames and decodes + val scanThread = + Thread { + panel.start() + while (result == null && webcam.isOpen && dialog.isVisible) { + try { + val image = webcam.image ?: continue + val decoded = decodeQrFromImage(image) + if (decoded != null) { + result = decoded + SwingUtilities.invokeLater { dialog.dispose() } + break + } + } catch (_: Exception) { + // webcam may throw during shutdown + } + Thread.sleep(150) + } + } + scanThread.isDaemon = true + scanThread.start() + + // Blocks until dialog is closed (modal) + dialog.isVisible = true + + // Cleanup + try { + panel.stop() + webcam.close() + } catch (_: Exception) { + } + + return result } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 4e75f8df0..336d9f929 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -57,6 +57,7 @@ unifiedpush = "3.0.10" vico-charts = "2.4.3" zelory = "3.0.1" zoomable = "2.11.1" +webcamCapture = "0.3.12" zxing = "3.5.4" zxingAndroidEmbedded = "4.3.0" windowCoreAndroid = "1.5.1" @@ -159,6 +160,7 @@ vico-charts-m3 = { group = "com.patrykandpatrick.vico", name = "compose-m3", ver vico-charts-views = { group = "com.patrykandpatrick.vico", name = "views", version.ref = "vico-charts" } zelory-image-compressor = { group = "id.zelory", name = "compressor", version.ref = "zelory" } zoomable = { group = "net.engawapg.lib", name = "zoomable", version.ref = "zoomable" } +webcam-capture = { group = "com.github.sarxos", name = "webcam-capture", version.ref = "webcamCapture" } zxing = { group = "com.google.zxing", name = "core", version.ref = "zxing" } zxing-javase = { group = "com.google.zxing", name = "javase", version.ref = "zxing" } zxing-embedded = { group = "com.journeyapps", name = "zxing-android-embedded", version.ref = "zxingAndroidEmbedded" } From d030843522d59aff064118c99258bc5211713131 Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Thu, 5 Mar 2026 14:11:38 +0200 Subject: [PATCH 09/17] feat(desktop): webcam QR scanning via ffmpeg for bunker login Replaces webcam-capture (broken on Apple Silicon) with ffmpeg avfoundation capture. Opens a live preview dialog that scans for QR codes from the FaceTime camera. Works natively on arm64. Co-Authored-By: Claude Opus 4.6 --- desktopApp/build.gradle.kts | 3 +- .../amethyst/desktop/ui/auth/QrCodeDecoder.kt | 151 ++++++++++++------ gradle/libs.versions.toml | 2 - 3 files changed, 107 insertions(+), 49 deletions(-) diff --git a/desktopApp/build.gradle.kts b/desktopApp/build.gradle.kts index 4f851d3f7..50fdb015b 100644 --- a/desktopApp/build.gradle.kts +++ b/desktopApp/build.gradle.kts @@ -46,10 +46,9 @@ dependencies { // JSON implementation(libs.jackson.module.kotlin) - // QR code scanning (webcam + image decoding) + // QR code scanning (webcam via ffmpeg + ZXing decoding) implementation(libs.zxing) implementation(libs.zxing.javase) - implementation(libs.webcam.capture) // Collections implementation(libs.kotlinx.collections.immutable) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/auth/QrCodeDecoder.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/auth/QrCodeDecoder.kt index d677a6a47..ead84ef91 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/auth/QrCodeDecoder.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/auth/QrCodeDecoder.kt @@ -20,8 +20,6 @@ */ package com.vitorpamplona.amethyst.desktop.ui.auth -import com.github.sarxos.webcam.Webcam -import com.github.sarxos.webcam.WebcamPanel import com.google.zxing.BinaryBitmap import com.google.zxing.MultiFormatReader import com.google.zxing.NotFoundException @@ -29,14 +27,21 @@ import com.google.zxing.client.j2se.BufferedImageLuminanceSource import com.google.zxing.common.HybridBinarizer import java.awt.BorderLayout import java.awt.Dimension +import java.awt.Graphics import java.awt.image.BufferedImage +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicReference +import javax.imageio.ImageIO import javax.swing.JDialog import javax.swing.JFrame import javax.swing.JLabel +import javax.swing.JPanel import javax.swing.SwingConstants import javax.swing.SwingUtilities -fun decodeQrFromImage(image: BufferedImage): String? = +private fun decodeQrFromImage(image: BufferedImage): String? = try { val source = BufferedImageLuminanceSource(image) val bitmap = BinaryBitmap(HybridBinarizer(source)) @@ -45,76 +50,132 @@ fun decodeQrFromImage(image: BufferedImage): String? = null } +private fun findFfmpeg(): String? = + listOf("ffmpeg", "/opt/homebrew/bin/ffmpeg", "/usr/local/bin/ffmpeg") + .firstOrNull { path -> + try { + ProcessBuilder(path, "-version") + .redirectErrorStream(true) + .start() + .waitFor() == 0 + } catch (_: Exception) { + false + } + } + /** - * Opens a modal webcam scanner dialog. Scans frames for QR codes continuously. - * Returns decoded text when found, or null if cancelled / no webcam available. - * Must be called off the EDT (e.g. from a coroutine on Dispatchers.IO). + * Opens a webcam scanner dialog using ffmpeg to capture frames. + * Continuously decodes frames looking for QR codes. + * Returns decoded text when found, or null if cancelled / no webcam. + * Must be called off the EDT (e.g. Dispatchers.IO). */ fun scanQrFromWebcam(): String? { - val webcam = - try { - Webcam.getDefault() - } catch (_: Exception) { - null - } ?: return null + val ffmpeg = findFfmpeg() ?: return null - webcam.viewSize = - webcam.viewSizes - .filter { it.width <= 1280 } - .maxByOrNull { it.width * it.height } - ?: webcam.viewSizes.first() + val result = AtomicReference(null) + val running = AtomicBoolean(true) - webcam.open() + val imagePanel = + object : JPanel() { + var currentImage: BufferedImage? = null - var result: String? = null + override fun paintComponent(g: Graphics) { + super.paintComponent(g) + currentImage?.let { img -> + val scale = + minOf( + width.toDouble() / img.width, + height.toDouble() / img.height, + ) + val w = (img.width * scale).toInt() + val h = (img.height * scale).toInt() + val x = (width - w) / 2 + val y = (height - h) / 2 + g.drawImage(img, x, y, w, h, null) + } + } + } + + val statusLabel = JLabel("Point camera at QR code...", SwingConstants.CENTER) val dialog = JDialog(null as JFrame?, "Scan QR Code", true) dialog.defaultCloseOperation = JDialog.DISPOSE_ON_CLOSE dialog.layout = BorderLayout() - - val panel = WebcamPanel(webcam, false) - panel.isFPSDisplayed = false - panel.isMirrored = true - dialog.add(panel, BorderLayout.CENTER) - - val statusLabel = JLabel("Point camera at QR code...", SwingConstants.CENTER) + dialog.add(imagePanel, BorderLayout.CENTER) dialog.add(statusLabel, BorderLayout.SOUTH) - dialog.preferredSize = Dimension(640, 520) dialog.pack() dialog.setLocationRelativeTo(null) - // Scanning thread — reads frames and decodes - val scanThread = + dialog.addWindowListener( + object : java.awt.event.WindowAdapter() { + override fun windowClosing(e: java.awt.event.WindowEvent?) { + running.set(false) + } + }, + ) + + // Capture thread — runs ffmpeg to grab single frames in a loop + val captureThread = Thread { - panel.start() - while (result == null && webcam.isOpen && dialog.isVisible) { + while (running.get()) { try { - val image = webcam.image ?: continue + val process = + ProcessBuilder( + ffmpeg, + "-f", + "avfoundation", + "-video_size", + "640x480", + "-framerate", + "15", + "-i", + "0", + "-frames:v", + "1", + "-f", + "image2pipe", + "-vcodec", + "mjpeg", + "-q:v", + "5", + "pipe:1", + ).redirectError(ProcessBuilder.Redirect.DISCARD) + .start() + + val baos = ByteArrayOutputStream() + process.inputStream.use { it.copyTo(baos) } + process.waitFor() + + val bytes = baos.toByteArray() + if (bytes.isEmpty()) continue + + val image = ImageIO.read(ByteArrayInputStream(bytes)) ?: continue + + // Update preview + imagePanel.currentImage = image + SwingUtilities.invokeLater { imagePanel.repaint() } + + // Try to decode QR val decoded = decodeQrFromImage(image) if (decoded != null) { - result = decoded + result.set(decoded) + running.set(false) SwingUtilities.invokeLater { dialog.dispose() } break } } catch (_: Exception) { - // webcam may throw during shutdown + if (!running.get()) break + Thread.sleep(500) } - Thread.sleep(150) } } - scanThread.isDaemon = true - scanThread.start() + captureThread.isDaemon = true + captureThread.start() // Blocks until dialog is closed (modal) dialog.isVisible = true + running.set(false) - // Cleanup - try { - panel.stop() - webcam.close() - } catch (_: Exception) { - } - - return result + return result.get() } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 336d9f929..4e75f8df0 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -57,7 +57,6 @@ unifiedpush = "3.0.10" vico-charts = "2.4.3" zelory = "3.0.1" zoomable = "2.11.1" -webcamCapture = "0.3.12" zxing = "3.5.4" zxingAndroidEmbedded = "4.3.0" windowCoreAndroid = "1.5.1" @@ -160,7 +159,6 @@ vico-charts-m3 = { group = "com.patrykandpatrick.vico", name = "compose-m3", ver vico-charts-views = { group = "com.patrykandpatrick.vico", name = "views", version.ref = "vico-charts" } zelory-image-compressor = { group = "id.zelory", name = "compressor", version.ref = "zelory" } zoomable = { group = "net.engawapg.lib", name = "zoomable", version.ref = "zoomable" } -webcam-capture = { group = "com.github.sarxos", name = "webcam-capture", version.ref = "webcamCapture" } zxing = { group = "com.google.zxing", name = "core", version.ref = "zxing" } zxing-javase = { group = "com.google.zxing", name = "javase", version.ref = "zxing" } zxing-embedded = { group = "com.journeyapps", name = "zxing-android-embedded", version.ref = "zxingAndroidEmbedded" } From bff6723ba6233a37916f9d5224eda50a0590e290 Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Thu, 5 Mar 2026 14:25:10 +0200 Subject: [PATCH 10/17] revert: remove QR code scanning from login Co-Authored-By: Claude Opus 4.6 --- desktopApp/build.gradle.kts | 4 - .../amethyst/desktop/ui/auth/LoginCard.kt | 49 +---- .../amethyst/desktop/ui/auth/QrCodeDecoder.kt | 181 ------------------ gradle/libs.versions.toml | 1 - 4 files changed, 8 insertions(+), 227 deletions(-) delete mode 100644 desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/auth/QrCodeDecoder.kt diff --git a/desktopApp/build.gradle.kts b/desktopApp/build.gradle.kts index 50fdb015b..792e02152 100644 --- a/desktopApp/build.gradle.kts +++ b/desktopApp/build.gradle.kts @@ -46,10 +46,6 @@ dependencies { // JSON implementation(libs.jackson.module.kotlin) - // QR code scanning (webcam via ffmpeg + ZXing decoding) - implementation(libs.zxing) - implementation(libs.zxing.javase) - // Collections implementation(libs.kotlinx.collections.immutable) 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 c64b71f58..39d56a42c 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 @@ -30,14 +30,10 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.QrCodeScanner import androidx.compose.material3.Button import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Text @@ -117,43 +113,14 @@ fun LoginCard( Spacer(Modifier.height(16.dp)) - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(4.dp), - ) { - KeyInputField( - value = keyInput, - onValueChange = { - keyInput = it - errorMessage = null - }, - errorMessage = errorMessage, - modifier = Modifier.weight(1f), - ) - - IconButton( - onClick = { - scope.launch(Dispatchers.IO) { - val decoded = scanQrFromWebcam() - withContext(Dispatchers.Main) { - if (decoded != null) { - keyInput = decoded - errorMessage = null - } else { - errorMessage = "No QR code found" - } - } - } - }, - ) { - Icon( - Icons.Default.QrCodeScanner, - contentDescription = "Scan QR code", - tint = MaterialTheme.colorScheme.primary, - ) - } - } + KeyInputField( + value = keyInput, + onValueChange = { + keyInput = it + errorMessage = null + }, + errorMessage = errorMessage, + ) Spacer(Modifier.height(8.dp)) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/auth/QrCodeDecoder.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/auth/QrCodeDecoder.kt deleted file mode 100644 index ead84ef91..000000000 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/auth/QrCodeDecoder.kt +++ /dev/null @@ -1,181 +0,0 @@ -/* - * 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.ui.auth - -import com.google.zxing.BinaryBitmap -import com.google.zxing.MultiFormatReader -import com.google.zxing.NotFoundException -import com.google.zxing.client.j2se.BufferedImageLuminanceSource -import com.google.zxing.common.HybridBinarizer -import java.awt.BorderLayout -import java.awt.Dimension -import java.awt.Graphics -import java.awt.image.BufferedImage -import java.io.ByteArrayInputStream -import java.io.ByteArrayOutputStream -import java.util.concurrent.atomic.AtomicBoolean -import java.util.concurrent.atomic.AtomicReference -import javax.imageio.ImageIO -import javax.swing.JDialog -import javax.swing.JFrame -import javax.swing.JLabel -import javax.swing.JPanel -import javax.swing.SwingConstants -import javax.swing.SwingUtilities - -private fun decodeQrFromImage(image: BufferedImage): String? = - try { - val source = BufferedImageLuminanceSource(image) - val bitmap = BinaryBitmap(HybridBinarizer(source)) - MultiFormatReader().decode(bitmap).text - } catch (_: NotFoundException) { - null - } - -private fun findFfmpeg(): String? = - listOf("ffmpeg", "/opt/homebrew/bin/ffmpeg", "/usr/local/bin/ffmpeg") - .firstOrNull { path -> - try { - ProcessBuilder(path, "-version") - .redirectErrorStream(true) - .start() - .waitFor() == 0 - } catch (_: Exception) { - false - } - } - -/** - * Opens a webcam scanner dialog using ffmpeg to capture frames. - * Continuously decodes frames looking for QR codes. - * Returns decoded text when found, or null if cancelled / no webcam. - * Must be called off the EDT (e.g. Dispatchers.IO). - */ -fun scanQrFromWebcam(): String? { - val ffmpeg = findFfmpeg() ?: return null - - val result = AtomicReference(null) - val running = AtomicBoolean(true) - - val imagePanel = - object : JPanel() { - var currentImage: BufferedImage? = null - - override fun paintComponent(g: Graphics) { - super.paintComponent(g) - currentImage?.let { img -> - val scale = - minOf( - width.toDouble() / img.width, - height.toDouble() / img.height, - ) - val w = (img.width * scale).toInt() - val h = (img.height * scale).toInt() - val x = (width - w) / 2 - val y = (height - h) / 2 - g.drawImage(img, x, y, w, h, null) - } - } - } - - val statusLabel = JLabel("Point camera at QR code...", SwingConstants.CENTER) - - val dialog = JDialog(null as JFrame?, "Scan QR Code", true) - dialog.defaultCloseOperation = JDialog.DISPOSE_ON_CLOSE - dialog.layout = BorderLayout() - dialog.add(imagePanel, BorderLayout.CENTER) - dialog.add(statusLabel, BorderLayout.SOUTH) - dialog.preferredSize = Dimension(640, 520) - dialog.pack() - dialog.setLocationRelativeTo(null) - - dialog.addWindowListener( - object : java.awt.event.WindowAdapter() { - override fun windowClosing(e: java.awt.event.WindowEvent?) { - running.set(false) - } - }, - ) - - // Capture thread — runs ffmpeg to grab single frames in a loop - val captureThread = - Thread { - while (running.get()) { - try { - val process = - ProcessBuilder( - ffmpeg, - "-f", - "avfoundation", - "-video_size", - "640x480", - "-framerate", - "15", - "-i", - "0", - "-frames:v", - "1", - "-f", - "image2pipe", - "-vcodec", - "mjpeg", - "-q:v", - "5", - "pipe:1", - ).redirectError(ProcessBuilder.Redirect.DISCARD) - .start() - - val baos = ByteArrayOutputStream() - process.inputStream.use { it.copyTo(baos) } - process.waitFor() - - val bytes = baos.toByteArray() - if (bytes.isEmpty()) continue - - val image = ImageIO.read(ByteArrayInputStream(bytes)) ?: continue - - // Update preview - imagePanel.currentImage = image - SwingUtilities.invokeLater { imagePanel.repaint() } - - // Try to decode QR - val decoded = decodeQrFromImage(image) - if (decoded != null) { - result.set(decoded) - running.set(false) - SwingUtilities.invokeLater { dialog.dispose() } - break - } - } catch (_: Exception) { - if (!running.get()) break - Thread.sleep(500) - } - } - } - captureThread.isDaemon = true - captureThread.start() - - // Blocks until dialog is closed (modal) - dialog.isVisible = true - running.set(false) - - return result.get() -} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 4e75f8df0..95a41277f 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -160,7 +160,6 @@ vico-charts-views = { group = "com.patrykandpatrick.vico", name = "views", versi zelory-image-compressor = { group = "id.zelory", name = "compressor", version.ref = "zelory" } zoomable = { group = "net.engawapg.lib", name = "zoomable", version.ref = "zoomable" } zxing = { group = "com.google.zxing", name = "core", version.ref = "zxing" } -zxing-javase = { group = "com.google.zxing", name = "javase", version.ref = "zxing" } zxing-embedded = { group = "com.journeyapps", name = "zxing-android-embedded", version.ref = "zxingAndroidEmbedded" } androidx-window-core-android = { group = "androidx.window", name = "window-core-android", version.ref = "windowCoreAndroid" } kotlin-stdlib = { group = "org.jetbrains.kotlin", name = "kotlin-stdlib", version.ref = "kotlin" } From 12c91aae3d4f6d930728d109f46e8b39bf712dfc Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Thu, 5 Mar 2026 15:03:37 +0200 Subject: [PATCH 11/17] feat(desktop): add nostrconnect:// login flow for QR-based signer pairing Adds client-initiated NIP-46 (nostrconnect://) flow so users can pair with a remote signer (e.g. Amber) by scanning a QR code instead of manually copying a bunker:// URI. Changes: - Fix RemoteSignerManager to decrypt NIP-44 content before parsing - Add CoroutineScope to NostrSignerRemote for async event callbacks - Add loginWithNostrConnect() to AccountManager with 120s timeout - Add QrCodeCanvas composable using ZXing for QR generation - Add "Connect" tab to LoginCard showing QR + copyable URI + waiting state - Wire nostrconnect flow through LoginScreen Co-Authored-By: Claude Opus 4.6 --- desktopApp/build.gradle.kts | 3 + .../desktop/account/AccountManager.kt | 156 ++++++++ .../amethyst/desktop/ui/LoginScreen.kt | 10 + .../amethyst/desktop/ui/auth/LoginCard.kt | 334 +++++++++++++----- .../amethyst/desktop/ui/auth/QrCodeCanvas.kt | 75 ++++ .../signer/NostrSignerRemote.kt | 16 +- .../signer/RemoteSignerManager.kt | 5 +- 7 files changed, 511 insertions(+), 88 deletions(-) create mode 100644 desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/auth/QrCodeCanvas.kt diff --git a/desktopApp/build.gradle.kts b/desktopApp/build.gradle.kts index 792e02152..be0915bc6 100644 --- a/desktopApp/build.gradle.kts +++ b/desktopApp/build.gradle.kts @@ -49,6 +49,9 @@ dependencies { // Collections implementation(libs.kotlinx.collections.immutable) + // QR code generation (ZXing core) + implementation(libs.zxing) + // Testing testImplementation(libs.kotlin.test) testImplementation(libs.kotlinx.coroutines.test) 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 61e60b098..bd0ca588a 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,10 +23,15 @@ 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.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.reqs.req +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions @@ -34,8 +39,13 @@ 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.BunkerRequest +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestConnect +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 kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.delay @@ -44,7 +54,9 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.isActive import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeout import java.io.File +import java.security.SecureRandom sealed class SignerType { data object Internal : SignerType() @@ -93,6 +105,8 @@ class AccountManager internal constructor( internal const val HEARTBEAT_INTERVAL_MS = 60_000L internal const val MAX_CONSECUTIVE_FAILURES = 3 internal const val BUNKER_EPHEMERAL_KEY_ALIAS = "bunker_ephemeral" + internal const val NOSTRCONNECT_TIMEOUT_MS = 120_000L + internal val NIP46_RELAYS = listOf("wss://relay.nsec.app") } private val amethystDir: File by lazy { @@ -226,6 +240,148 @@ class AccountManager internal constructor( Result.failure(Exception("Connection failed: ${e.message}")) } + // --- Nostrconnect login --- + + suspend fun loginWithNostrConnect( + client: INostrClient, + onUriGenerated: (String) -> Unit, + ): Result = + try { + val ephemeralKeyPair = KeyPair() + val ephemeralSigner = NostrSignerInternal(ephemeralKeyPair) + val secret = generateNostrConnectSecret() + val ephemeralPubKey = ephemeralKeyPair.pubKey.toHexKey() + + val relays = NIP46_RELAYS + val relayParams = relays.joinToString("&") { "relay=$it" } + val uri = "nostrconnect://$ephemeralPubKey?$relayParams&secret=$secret&name=Amethyst%20Desktop" + onUriGenerated(uri) + + val normalizedRelays = relays.map { NormalizedRelayUrl(it) }.toSet() + val connectData = waitForConnectRequest(ephemeralSigner, ephemeralPubKey, normalizedRelays, secret, client) + + sendAckResponse(ephemeralSigner, connectData, normalizedRelays, client) + + val remoteSigner = + NostrSignerRemote( + signer = ephemeralSigner, + remotePubkey = connectData.signerPubkey, + relays = normalizedRelays, + client = client, + ) + remoteSigner.openSubscription() + + val syntheticBunkerUri = "bunker://${connectData.signerPubkey}?$relayParams" + + val state = + AccountState.LoggedIn( + signer = remoteSigner, + pubKeyHex = connectData.userPubkey, + npub = connectData.userPubkey.hexToByteArray().toNpub(), + nsec = null, + isReadOnly = false, + signerType = SignerType.Remote(syntheticBunkerUri), + ) + _accountState.value = state + _signerConnectionState.value = SignerConnectionState.Connected + + saveBunkerAccount( + bunkerUri = syntheticBunkerUri, + ephemeralPrivKeyHex = ephemeralKeyPair.privKey!!.toHexKey(), + npub = state.npub, + ) + + Result.success(state) + } catch (e: kotlinx.coroutines.TimeoutCancellationException) { + Result.failure(Exception("Timed out waiting for signer. Ensure the signer app scanned the QR code.")) + } catch (e: Exception) { + Result.failure(Exception("Connection failed: ${e.message}")) + } + + private suspend fun waitForConnectRequest( + ephemeralSigner: NostrSignerInternal, + ephemeralPubKey: HexKey, + relays: Set, + expectedSecret: String, + client: INostrClient, + ): ConnectRequestData { + val deferred = CompletableDeferred() + + val subscription = + client.req( + relays = relays.toList(), + filter = + Filter( + kinds = listOf(NostrConnectEvent.KIND), + tags = mapOf("p" to listOf(ephemeralPubKey)), + ), + ) { event -> + if (event is NostrConnectEvent && !deferred.isCompleted) { + deferred.complete(event) + } + } + + try { + val event = + withTimeout(NOSTRCONNECT_TIMEOUT_MS) { + deferred.await() + } + + val otherPubkey = event.talkingWith(ephemeralSigner.pubKey) + val decryptedJson = ephemeralSigner.decrypt(event.content, otherPubkey) + val message = OptimizedJsonMapper.fromJsonTo(decryptedJson) + + 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") + } + + return ConnectRequestData( + requestId = message.id, + signerPubkey = event.pubKey, + userPubkey = userPubkey, + ) + } finally { + subscription.close() + } + } + + private suspend fun sendAckResponse( + ephemeralSigner: NostrSignerInternal, + connectData: ConnectRequestData, + relays: Set, + client: INostrClient, + ) { + val ackResponse = BunkerResponseAck(id = connectData.requestId) + val ackEvent = + NostrConnectEvent.create( + message = ackResponse, + remoteKey = connectData.signerPubkey, + signer = ephemeralSigner, + ) + client.send(ackEvent, relays) + } + + private fun generateNostrConnectSecret(): String { + val bytes = ByteArray(32) + SecureRandom().nextBytes(bytes) + return bytes.joinToString("") { "%02x".format(it) } + } + + private data class ConnectRequestData( + val requestId: String, + val signerPubkey: HexKey, + val userPubkey: HexKey, + ) + private suspend fun saveBunkerAccount( bunkerUri: String, ephemeralPrivKeyHex: String, 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 1bb7cc64d..880214190 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 @@ -106,6 +106,16 @@ fun LoginScreen( } else { null }, + onLoginNostrConnect = + if (relayClient != null) { + { onUriGenerated -> + accountManager.loginWithNostrConnect(relayClient, onUriGenerated).map { + onLoginSuccess() + } + } + } else { + null + }, ) val account = generatedAccount 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 39d56a42c..a90fe81f9 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 @@ -30,21 +30,30 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Button import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.PrimaryTabRow +import androidx.compose.material3.Tab import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.commons.resources.Res @@ -83,16 +92,14 @@ fun LoginCard( onLogin: (String) -> Result, onGenerateNew: () -> Unit, onLoginBunker: (suspend (String) -> Result)? = null, + onLoginNostrConnect: (suspend (onUriGenerated: (String) -> Unit) -> Result)? = null, modifier: Modifier = Modifier, cardWidth: Dp = 400.dp, title: String = stringResource(Res.string.login_card_title), subtitle: String = stringResource(Res.string.login_card_subtitle), ) { - var keyInput by remember { mutableStateOf("") } - var errorMessage by remember { mutableStateOf(null) } - var isConnecting by remember { mutableStateOf(false) } - val scope = rememberCoroutineScope() - val isBunker = keyInput.trim().startsWith("bunker://", ignoreCase = true) + var selectedTab by remember { mutableIntStateOf(0) } + val tabs = listOf("Paste Key", "Connect") Card( modifier = modifier.width(cardWidth), @@ -113,97 +120,254 @@ fun LoginCard( Spacer(Modifier.height(16.dp)) - KeyInputField( - value = keyInput, - onValueChange = { - keyInput = it - errorMessage = null + if (onLoginNostrConnect != null) { + @Suppress("DEPRECATION") + PrimaryTabRow( + selectedTabIndex = selectedTab, + modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(8.dp)), + ) { + tabs.forEachIndexed { index, title -> + Tab( + selected = selectedTab == index, + onClick = { selectedTab = index }, + text = { Text(title) }, + ) + } + } + + Spacer(Modifier.height(16.dp)) + } + + when (selectedTab) { + 0 -> PasteKeyContent(onLogin, onGenerateNew, onLoginBunker, subtitle) + 1 -> if (onLoginNostrConnect != null) NostrConnectContent(onLoginNostrConnect) + } + } + } +} + +@Composable +private fun PasteKeyContent( + onLogin: (String) -> Result, + onGenerateNew: () -> Unit, + onLoginBunker: (suspend (String) -> Result)?, + subtitle: String, +) { + var keyInput by remember { mutableStateOf("") } + var errorMessage by remember { mutableStateOf(null) } + var isConnecting by remember { mutableStateOf(false) } + val scope = rememberCoroutineScope() + val isBunker = keyInput.trim().startsWith("bunker://", ignoreCase = true) + + KeyInputField( + value = keyInput, + onValueChange = { + keyInput = it + errorMessage = null + }, + errorMessage = errorMessage, + ) + + Spacer(Modifier.height(8.dp)) + + if (isBunker) { + Text( + "This URI connects to your remote signer. Treat it like a password.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.primary, + ) + } else { + Text( + subtitle, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + Spacer(Modifier.height(16.dp)) + + if (isConnecting) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + ) { + CircularProgressIndicator( + modifier = Modifier.size(20.dp), + strokeWidth = 2.dp, + ) + Spacer(Modifier.width(12.dp)) + Text( + "Connecting to remote signer...", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } else { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Button( + onClick = { + if (isBunker && onLoginBunker != null) { + val validationError = validateBunkerUri(keyInput) + if (validationError != null) { + errorMessage = validationError + return@Button + } + isConnecting = true + errorMessage = null + scope.launch(Dispatchers.IO) { + val result = onLoginBunker(keyInput.trim()) + withContext(Dispatchers.Main) { + result.fold( + onSuccess = { isConnecting = false }, + onFailure = { + errorMessage = it.message + isConnecting = false + }, + ) + } + } + } else { + onLogin(keyInput).fold( + onSuccess = { /* handled by caller */ }, + onFailure = { errorMessage = it.message }, + ) + } }, - errorMessage = errorMessage, + modifier = Modifier.weight(1f), + enabled = keyInput.isNotBlank(), + ) { + Text(if (isBunker) "Connect to Signer" else stringResource(Res.string.login_button)) + } + + OutlinedButton( + onClick = onGenerateNew, + modifier = Modifier.weight(1f), + ) { + Text(stringResource(Res.string.login_generate_button)) + } + } + } +} + +@Composable +private fun NostrConnectContent(onLoginNostrConnect: suspend (onUriGenerated: (String) -> Unit) -> Result) { + var nostrConnectUri by remember { mutableStateOf(null) } + var isConnecting by remember { mutableStateOf(false) } + var errorMessage by remember { mutableStateOf(null) } + val scope = rememberCoroutineScope() + + @Suppress("DEPRECATION") + val clipboardManager = LocalClipboardManager.current + + if (errorMessage != null) { + Text( + errorMessage!!, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) + Spacer(Modifier.height(12.dp)) + Button(onClick = { + errorMessage = null + nostrConnectUri = null + }) { + Text("Try Again") + } + } else if (!isConnecting) { + Text( + "Show a QR code for your signer app (e.g. Amber) to scan.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + Spacer(Modifier.height(16.dp)) + Button( + onClick = { + isConnecting = true + errorMessage = null + scope.launch(Dispatchers.IO) { + val result = + onLoginNostrConnect { uri -> + nostrConnectUri = uri + } + withContext(Dispatchers.Main) { + result.onFailure { + errorMessage = it.message + isConnecting = false + nostrConnectUri = null + } + } + } + }, + modifier = Modifier.fillMaxWidth(), + ) { + Text("Start Connection") + } + } else { + val uri = nostrConnectUri + if (uri != null) { + QrCodeCanvas( + data = uri, + size = 200.dp, + ) + + Spacer(Modifier.height(12.dp)) + + Text( + uri, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.fillMaxWidth(), ) Spacer(Modifier.height(8.dp)) - if (isBunker) { - Text( - "This URI connects to your remote signer. Treat it like a password.", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.primary, + OutlinedButton( + onClick = { clipboardManager.setText(AnnotatedString(uri)) }, + modifier = Modifier.fillMaxWidth(), + ) { + Text("Copy URI") + } + + Spacer(Modifier.height(12.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + ) { + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp, ) - } else { + Spacer(Modifier.width(8.dp)) Text( - subtitle, + "Waiting for signer to connect...", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } - - Spacer(Modifier.height(16.dp)) - - if (isConnecting) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.Center, - verticalAlignment = Alignment.CenterVertically, - ) { - CircularProgressIndicator( - modifier = Modifier.size(20.dp), - strokeWidth = 2.dp, - ) - Spacer(Modifier.width(12.dp)) - Text( - "Connecting to remote signer...", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } else { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - Button( - onClick = { - if (isBunker && onLoginBunker != null) { - val validationError = validateBunkerUri(keyInput) - if (validationError != null) { - errorMessage = validationError - return@Button - } - isConnecting = true - errorMessage = null - scope.launch(Dispatchers.IO) { - val result = onLoginBunker(keyInput.trim()) - withContext(Dispatchers.Main) { - result.fold( - onSuccess = { isConnecting = false }, - onFailure = { - errorMessage = it.message - isConnecting = false - }, - ) - } - } - } else { - onLogin(keyInput).fold( - onSuccess = { /* handled by caller */ }, - onFailure = { errorMessage = it.message }, - ) - } - }, - modifier = Modifier.weight(1f), - enabled = keyInput.isNotBlank(), - ) { - Text(if (isBunker) "Connect to Signer" else stringResource(Res.string.login_button)) - } - - OutlinedButton( - onClick = onGenerateNew, - modifier = Modifier.weight(1f), - ) { - Text(stringResource(Res.string.login_generate_button)) - } - } + } else { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + ) { + CircularProgressIndicator( + modifier = Modifier.size(20.dp), + strokeWidth = 2.dp, + ) + Spacer(Modifier.width(8.dp)) + Text( + "Generating connection...", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) } } } 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 new file mode 100644 index 000000000..68320b18b --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/auth/QrCodeCanvas.kt @@ -0,0 +1,75 @@ +/* + * 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.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.unit.Dp +import androidx.compose.ui.unit.dp +import com.google.zxing.BarcodeFormat +import com.google.zxing.EncodeHintType +import com.google.zxing.qrcode.QRCodeWriter + +@Composable +fun QrCodeCanvas( + data: String, + modifier: Modifier = Modifier, + size: Dp = 200.dp, + foreground: Color = MaterialTheme.colorScheme.onSurface, + background: Color = Color.White, +) { + val matrix = + remember(data) { + QRCodeWriter().encode( + data, + BarcodeFormat.QR_CODE, + 0, + 0, + mapOf(EncodeHintType.MARGIN to 1), + ) + } + + 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), + ) + } + } + } + } +} 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 fb2e344a8..da0c3b63e 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,11 @@ 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 kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch class NostrSignerRemote( val signer: NostrSignerInternal, @@ -51,6 +56,8 @@ class NostrSignerRemote( val permissions: String? = null, val secret: String? = null, ) : NostrSigner(signer.pubKey) { + private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) + private val manager = RemoteSignerManager( signer = signer, @@ -69,7 +76,13 @@ class NostrSignerRemote( ), ) { event -> if (event is NostrConnectEvent) { - manager.newResponse(event) + scope.launch { + try { + manager.newResponse(event) + } catch (_: Exception) { + // Ignore events we can't decrypt or parse + } + } } } @@ -79,6 +92,7 @@ class NostrSignerRemote( fun closeSubscription() { subscription.close() + scope.cancel() } override fun isWriteable(): Boolean = true diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/RemoteSignerManager.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/RemoteSignerManager.kt index e8a4c6ad1..adae21dfa 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/RemoteSignerManager.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/RemoteSignerManager.kt @@ -41,8 +41,9 @@ class RemoteSignerManager( ) { private val awaitingRequests = LargeCache>() - fun newResponse(responseEvent: NostrConnectEvent) { - val bunkerResponse = OptimizedJsonMapper.fromJsonTo(responseEvent.content) + suspend fun newResponse(responseEvent: NostrConnectEvent) { + val decryptedJson = signer.decrypt(responseEvent.content, remoteKey) + val bunkerResponse = OptimizedJsonMapper.fromJsonTo(decryptedJson) awaitingRequests.get(bunkerResponse.id)?.resume(bunkerResponse) } From 3a1e38a2f21463790b3109fdff72e84e3c96acdf Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Fri, 6 Mar 2026 13:19:49 +0200 Subject: [PATCH 12/17] 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", + ) + } + } +} From 5475d6c2bcc276a72987a73f6adab74125553491 Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Mon, 9 Mar 2026 12:44:58 +0200 Subject: [PATCH 13/17] fix(desktop): verify user pubkey via get_public_key after nostrconnect login MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The nostrconnect flow was trusting params[0] from the signer's connect message as the user's pubkey. Some signers (e.g. nsec.app) don't put the actual user pubkey there, causing all relay subscriptions to query the wrong identity — contact lists, profiles, and DMs all returned empty. Now calls remoteSigner.getPublicKey() after the handshake to get the verified pubkey from the signer. Also stabilizes FeedScreen relay subscriptions with distinctUntilChanged() and adds relay.primal.net to defaults. Co-Authored-By: Claude Opus 4.6 --- .../desktop/account/AccountManager.kt | 166 +++- .../amethyst/desktop/account/LoginProgress.kt | 47 + .../amethyst/desktop/network/RelayStatus.kt | 1 + .../subscriptions/SubscriptionUtils.kt | 3 - .../amethyst/desktop/ui/FeedScreen.kt | 72 +- .../amethyst/desktop/ui/LoginScreen.kt | 3 + .../amethyst/desktop/ui/auth/LoginCard.kt | 80 +- .../desktop/ui/auth/LoginProgressSteps.kt | 220 +++++ ...-03-06-nip46-post-login-bugs-brainstorm.md | 189 ++++ ...-relay-subscription-strategy-brainstorm.md | 209 +++++ ...fix-nip46-relay-isolation-and-init-plan.md | 365 ++++++++ .../2026-03-05-nip46-bunker-login-deepened.md | 876 ++++++++++++++++++ .../2026-03-05-nip46-improvements-plan.md | 106 +++ docs/plans/2026-03-05-nip46-tdd-tests-plan.md | 386 ++++++++ .../signer/NostrSignerRemote.kt | 21 +- 15 files changed, 2634 insertions(+), 110 deletions(-) create mode 100644 desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/account/LoginProgress.kt create mode 100644 desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/auth/LoginProgressSteps.kt create mode 100644 docs/brainstorms/2026-03-06-nip46-post-login-bugs-brainstorm.md create mode 100644 docs/brainstorms/2026-03-09-feedscreen-relay-subscription-strategy-brainstorm.md create mode 100644 docs/plans/2026-03-05-fix-nip46-relay-isolation-and-init-plan.md create mode 100644 docs/plans/2026-03-05-nip46-bunker-login-deepened.md create mode 100644 docs/plans/2026-03-05-nip46-improvements-plan.md create mode 100644 docs/plans/2026-03-05-nip46-tdd-tests-plan.md 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 76e3d7bed..5af6d87eb 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 @@ -30,7 +30,12 @@ import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.IRelayClientListener import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.req +import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.Command +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.EventCmd import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.sockets.okhttp.BasicOkHttpWebSocket @@ -135,6 +140,9 @@ class AccountManager internal constructor( private val _forceLogoutReason = MutableStateFlow(null) val forceLogoutReason: StateFlow = _forceLogoutReason.asStateFlow() + private val _loginProgress = MutableStateFlow(null) + val loginProgress: StateFlow = _loginProgress.asStateFlow() + private var heartbeatJob: Job? = null // --- Dedicated NIP-46 client (isolated from general relay pool) --- @@ -174,18 +182,72 @@ class AccountManager internal constructor( } } + private fun updateRelayLoginStatus( + relay: NormalizedRelayUrl, + status: RelayLoginStatus, + ) { + val current = _loginProgress.value ?: return + val updated = current.relayStatuses + (relay to status) + _loginProgress.value = + when (current) { + is LoginProgress.ConnectingToRelays -> current.copy(relayStatuses = updated) + is LoginProgress.WaitingForSigner -> current.copy(relayStatuses = updated) + is LoginProgress.SendingAck -> current.copy(relayStatuses = updated) + } + } + + private fun createLoginRelayListener(): IRelayClientListener = + object : IRelayClientListener { + override fun onConnected( + relay: IRelayClient, + pingMillis: Int, + compressed: Boolean, + ) { + updateRelayLoginStatus(relay.url, RelayLoginStatus.CONNECTED) + } + + override fun onCannotConnect( + relay: IRelayClient, + errorMessage: String, + ) { + updateRelayLoginStatus(relay.url, RelayLoginStatus.FAILED) + } + + override fun onSent( + relay: IRelayClient, + cmdStr: String, + cmd: Command, + success: Boolean, + ) { + if (cmd is EventCmd) { + updateRelayLoginStatus( + relay.url, + if (success) RelayLoginStatus.EVENT_SENT else RelayLoginStatus.SEND_FAILED, + ) + } + } + + override fun onIncomingMessage( + relay: IRelayClient, + msgStr: String, + msg: Message, + ) { + } + } + // --- Account loading --- suspend fun loadSavedAccount(): Result = try { - val lastNpub = getLastNpub() ?: return Result.failure(Exception("No saved account")) - - // Check for bunker account first + val lastNpub = getLastNpub() val bunkerUri = getBunkerUri() + if (bunkerUri != null) { loadBunkerAccount(bunkerUri, lastNpub) - } else { + } else if (lastNpub != null) { loadInternalAccount(lastNpub) + } else { + Result.failure(Exception("No saved account")) } } catch (e: Exception) { Result.failure(e) @@ -213,7 +275,7 @@ class AccountManager internal constructor( private suspend fun loadBunkerAccount( bunkerUri: String, - npub: String, + npub: String?, ): Result { val ephemeralPrivKeyHex = secureStorage.getPrivateKey(BUNKER_EPHEMERAL_KEY_ALIAS) @@ -226,13 +288,24 @@ class AccountManager internal constructor( val remoteSigner = NostrSignerRemote.fromBunkerUri(bunkerUri, ephemeralSigner, nip46Client) remoteSigner.openSubscription() - val pubKeyHex = decodePublicKeyAsHexOrNull(npub) ?: return Result.failure(Exception("Invalid saved npub")) + val pubKeyHex = + if (npub != null) { + decodePublicKeyAsHexOrNull(npub) ?: return Result.failure(Exception("Invalid saved npub")) + } else { + // npub missing (e.g. last_account.txt deleted) — must wait for relay + // before calling getPublicKey() to recover from signer + awaitNip46RelayConnection(nip46Client, remoteSigner.relays) + remoteSigner.getPublicKey() + } + + val resolvedNpub = npub ?: pubKeyHex.hexToByteArray().toNpub() + if (npub == null) saveLastNpub(resolvedNpub) val state = AccountState.LoggedIn( signer = remoteSigner, pubKeyHex = pubKeyHex, - npub = npub, + npub = resolvedNpub, nsec = null, isReadOnly = false, signerType = SignerType.Remote(bunkerUri), @@ -244,18 +317,33 @@ class AccountManager internal constructor( // --- Bunker login --- - suspend fun loginWithBunker(bunkerUri: String): Result = + suspend fun loginWithBunker(bunkerUri: String): Result { + val listener = createLoginRelayListener() + var client: NostrClient? = null try { val ephemeralKeyPair = KeyPair() val ephemeralSigner = NostrSignerInternal(ephemeralKeyPair) val nip46Client = getOrCreateNip46Client() + client = nip46Client val remoteSigner = NostrSignerRemote.fromBunkerUri(bunkerUri, ephemeralSigner, nip46Client) + + // Emit connecting with initial relay statuses + _loginProgress.value = + LoginProgress.ConnectingToRelays( + remoteSigner.relays.associateWith { RelayLoginStatus.CONNECTING }, + ) + nip46Client.subscribe(listener) + remoteSigner.openSubscription() // Wait for websocket to be ready before sending connect request awaitNip46RelayConnection(nip46Client, remoteSigner.relays) + _loginProgress.value = + LoginProgress.WaitingForSigner( + relayStatuses = _loginProgress.value?.relayStatuses.orEmpty(), + ) val remotePubkey = remoteSigner.connect() val state = @@ -277,22 +365,28 @@ class AccountManager internal constructor( npub = state.npub, ) - Result.success(state) + return Result.success(state) } catch (e: kotlinx.coroutines.TimeoutCancellationException) { - Result.failure(Exception("Could not connect to NIP-46 relay. Check your network connection.")) + return 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.")) + return Result.failure(Exception("Connection timed out. Ensure remote signer is online and has approved the connection.")) } catch (e: SignerExceptions.ManuallyUnauthorizedException) { - Result.failure(Exception("Connection rejected by remote signer.")) + return Result.failure(Exception("Connection rejected by remote signer.")) } catch (e: SignerExceptions.CouldNotPerformException) { - Result.failure(Exception("Remote signer error: ${e.message}")) + return Result.failure(Exception("Remote signer error: ${e.message}")) } catch (e: Exception) { - Result.failure(Exception("Connection failed: ${e.message}")) + return Result.failure(Exception("Connection failed: ${e.message}")) + } finally { + _loginProgress.value = null + client?.unsubscribe(listener) } + } // --- Nostrconnect login --- - suspend fun loginWithNostrConnect(onUriGenerated: (String) -> Unit): Result = + suspend fun loginWithNostrConnect(onUriGenerated: (String) -> Unit): Result { + val listener = createLoginRelayListener() + var client: NostrClient? = null try { val ephemeralKeyPair = KeyPair() val ephemeralSigner = NostrSignerInternal(ephemeralKeyPair) @@ -302,13 +396,31 @@ class AccountManager internal constructor( val relays = NIP46_RELAYS val relayParams = relays.joinToString("&") { "relay=$it" } val uri = "nostrconnect://$ephemeralPubKey?$relayParams&secret=$secret&name=Amethyst%20Desktop" - onUriGenerated(uri) val nip46Client = getOrCreateNip46Client() + client = nip46Client val normalizedRelays = relays.map { NormalizedRelayUrl(it) }.toSet() + + // Emit connecting with initial relay statuses + _loginProgress.value = + LoginProgress.ConnectingToRelays( + normalizedRelays.associateWith { RelayLoginStatus.CONNECTING }, + ) + nip46Client.subscribe(listener) + + onUriGenerated(uri) + + _loginProgress.value = + LoginProgress.WaitingForSigner( + relayStatuses = _loginProgress.value?.relayStatuses.orEmpty(), + ) val connectData = waitForConnectRequest(ephemeralSigner, ephemeralPubKey, normalizedRelays, secret, nip46Client) if (connectData.requestId != null) { + _loginProgress.value = + LoginProgress.SendingAck( + relayStatuses = _loginProgress.value?.relayStatuses.orEmpty(), + ) sendAckResponse(ephemeralSigner, connectData, normalizedRelays, nip46Client) } @@ -321,13 +433,17 @@ class AccountManager internal constructor( ) remoteSigner.openSubscription() + // Verify user pubkey via get_public_key — connect params may contain + // the wrong pubkey (e.g. ephemeral key echoed back by some signers) + val verifiedPubkey = remoteSigner.getPublicKey() + val syntheticBunkerUri = "bunker://${connectData.signerPubkey}?$relayParams" val state = AccountState.LoggedIn( signer = remoteSigner, - pubKeyHex = connectData.userPubkey, - npub = connectData.userPubkey.hexToByteArray().toNpub(), + pubKeyHex = verifiedPubkey, + npub = verifiedPubkey.hexToByteArray().toNpub(), nsec = null, isReadOnly = false, signerType = SignerType.Remote(syntheticBunkerUri), @@ -341,12 +457,16 @@ class AccountManager internal constructor( npub = state.npub, ) - Result.success(state) + return Result.success(state) } catch (e: kotlinx.coroutines.TimeoutCancellationException) { - Result.failure(Exception("Timed out waiting for signer. Ensure the signer app scanned the QR code.")) + return Result.failure(Exception("Timed out waiting for signer. Ensure the signer app scanned the QR code.")) } catch (e: Exception) { - Result.failure(Exception("Connection failed: ${e.message}")) + return Result.failure(Exception("Connection failed: ${e.message}")) + } finally { + _loginProgress.value = null + client?.unsubscribe(listener) } + } private suspend fun waitForConnectRequest( ephemeralSigner: NostrSignerInternal, @@ -463,9 +583,9 @@ class AccountManager internal constructor( ephemeralPrivKeyHex: String, npub: String, ) { - saveBunkerUri(bunkerUri) - secureStorage.savePrivateKey(BUNKER_EPHEMERAL_KEY_ALIAS, ephemeralPrivKeyHex) saveLastNpub(npub) + secureStorage.savePrivateKey(BUNKER_EPHEMERAL_KEY_ALIAS, ephemeralPrivKeyHex) + saveBunkerUri(bunkerUri) } fun hasBunkerAccount(): Boolean = getBunkerFile().exists() diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/account/LoginProgress.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/account/LoginProgress.kt new file mode 100644 index 000000000..1ce722afa --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/account/LoginProgress.kt @@ -0,0 +1,47 @@ +/* + * 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.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl + +enum class RelayLoginStatus { + CONNECTING, + CONNECTED, + EVENT_SENT, + SEND_FAILED, + FAILED, +} + +sealed class LoginProgress { + abstract val relayStatuses: Map + + data class ConnectingToRelays( + override val relayStatuses: Map = emptyMap(), + ) : LoginProgress() + + data class WaitingForSigner( + override val relayStatuses: Map = emptyMap(), + ) : LoginProgress() + + data class SendingAck( + override val relayStatuses: Map = emptyMap(), + ) : LoginProgress() +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/network/RelayStatus.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/network/RelayStatus.kt index 24c092b90..5266f9621 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/network/RelayStatus.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/network/RelayStatus.kt @@ -45,5 +45,6 @@ object DefaultRelays { "wss://nos.lol", "wss://relay.snort.social", "wss://nostr.wine", + "wss://relay.primal.net", ) } 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 4bf7062e5..8aecc2f2d 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,7 +23,6 @@ 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 @@ -75,7 +74,6 @@ 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, @@ -104,7 +102,6 @@ fun rememberSubscription( onDispose { subscription?.let { - DebugConfig.log("SUB CLOSE ${it.subId}") relayManager.unsubscribe(it.subId) } } 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 38ce7b121..12523a85c 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 @@ -84,6 +84,8 @@ import com.vitorpamplona.quartz.nip18Reposts.RepostEvent import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map /** * Note card with action buttons. @@ -159,6 +161,14 @@ fun FeedScreen( onZapFeedback: (ZapFeedback) -> Unit = {}, ) { val connectedRelays by relayManager.connectedRelays.collectAsState() + // Configured relay URLs only — stabilized with distinctUntilChanged() to prevent + // subscription churn from relay status changes (pings, connect/disconnect). + // openReqSubscription connects relays on demand; no need to wait for connectedRelays. + val configuredRelays by remember { + relayManager.relayStatuses + .map { it.keys } + .distinctUntilChanged() + }.collectAsState(emptySet()) val scope = rememberCoroutineScope() val eventState = remember { @@ -190,17 +200,15 @@ fun FeedScreen( val initialLoadComplete = eoseReceivedCount > 0 // Load followed users for Following feed mode - 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) { + rememberSubscription(configuredRelays, account, feedMode, relayManager = relayManager) { + if (configuredRelays.isNotEmpty() && account != null && feedMode == FeedMode.FOLLOWING) { createContactListSubscription( - relays = connectedRelays, + relays = configuredRelays, pubKeyHex = account.pubKeyHex, onEvent = { event, _, relay, _ -> - DebugConfig.log("contactList event: kind=${event.kind}, isContactList=${event is ContactListEvent}, from=$relay") if (event is ContactListEvent) { val follows = event.verifiedFollowKeySet() - DebugConfig.log("followedUsers: ${follows.size} users") + DebugConfig.log("contactList: ${follows.size} follows from $relay") followedUsers = follows } }, @@ -211,8 +219,8 @@ fun FeedScreen( } // Load user's bookmark list - rememberSubscription(connectedRelays, account, relayManager = relayManager) { - if (connectedRelays.isNotEmpty() && account != null) { + rememberSubscription(configuredRelays, account, relayManager = relayManager) { + if (configuredRelays.isNotEmpty() && account != null) { SubscriptionConfig( subId = "bookmarks-${account.pubKeyHex.take(8)}", filters = @@ -223,7 +231,7 @@ fun FeedScreen( limit = 1, ), ), - relays = connectedRelays, + relays = configuredRelays, onEvent = { event, _, _, _ -> if (event is BookmarkListEvent) { bookmarkList = event @@ -251,16 +259,16 @@ fun FeedScreen( } // Subscribe to feed based on mode - rememberSubscription(connectedRelays, feedMode, followedUsers, relayManager = relayManager) { - DebugConfig.log("feedSub: mode=$feedMode, relays=${connectedRelays.size}, followedUsers=${followedUsers.size}") - if (connectedRelays.isEmpty()) { + rememberSubscription(configuredRelays, feedMode, followedUsers, relayManager = relayManager) { + DebugConfig.log("feedSub: mode=$feedMode, relays=${configuredRelays.size}, followedUsers=${followedUsers.size}") + if (configuredRelays.isEmpty()) { return@rememberSubscription null } when (feedMode) { FeedMode.GLOBAL -> { createGlobalFeedSubscription( - relays = connectedRelays, + relays = configuredRelays, onEvent = { event, _, _, _ -> // Store metadata events in cache if (event is MetadataEvent) { @@ -277,7 +285,7 @@ fun FeedScreen( FeedMode.FOLLOWING -> { if (followedUsers.isNotEmpty()) { createFollowingFeedSubscription( - relays = connectedRelays, + relays = configuredRelays, followedUsers = followedUsers.toList(), onEvent = { event, _, _, _ -> // Store metadata events in cache @@ -299,13 +307,13 @@ fun FeedScreen( // Subscribe to zaps for visible events val eventIds = events.map { it.id } - rememberSubscription(connectedRelays, eventIds, relayManager = relayManager) { - if (connectedRelays.isEmpty() || eventIds.isEmpty()) { + rememberSubscription(configuredRelays, eventIds, relayManager = relayManager) { + if (configuredRelays.isEmpty() || eventIds.isEmpty()) { return@rememberSubscription null } createZapsSubscription( - relays = connectedRelays, + relays = configuredRelays, eventIds = eventIds, onEvent = { event, _, _, _ -> if (event is LnZapEvent) { @@ -329,8 +337,8 @@ fun FeedScreen( .flatten() .map { it.senderPubKey } .distinct() - rememberSubscription(connectedRelays, zapSenderPubkeys, relayManager = relayManager) { - if (connectedRelays.isEmpty() || zapSenderPubkeys.isEmpty()) { + rememberSubscription(configuredRelays, zapSenderPubkeys, relayManager = relayManager) { + if (configuredRelays.isEmpty() || zapSenderPubkeys.isEmpty()) { return@rememberSubscription null } @@ -348,7 +356,7 @@ fun FeedScreen( } createBatchMetadataSubscription( - relays = connectedRelays, + relays = configuredRelays, pubKeyHexList = missingPubkeys, onEvent = { event, _, _, _ -> if (event is MetadataEvent) { @@ -359,13 +367,13 @@ fun FeedScreen( } // Subscribe to reactions for visible events - rememberSubscription(connectedRelays, eventIds, relayManager = relayManager) { - if (connectedRelays.isEmpty() || eventIds.isEmpty()) { + rememberSubscription(configuredRelays, eventIds, relayManager = relayManager) { + if (configuredRelays.isEmpty() || eventIds.isEmpty()) { return@rememberSubscription null } createReactionsSubscription( - relays = connectedRelays, + relays = configuredRelays, eventIds = eventIds, onEvent = { event, _, _, _ -> if (event is ReactionEvent) { @@ -381,13 +389,13 @@ fun FeedScreen( } // Subscribe to replies for visible events - rememberSubscription(connectedRelays, eventIds, relayManager = relayManager) { - if (connectedRelays.isEmpty() || eventIds.isEmpty()) { + rememberSubscription(configuredRelays, eventIds, relayManager = relayManager) { + if (configuredRelays.isEmpty() || eventIds.isEmpty()) { return@rememberSubscription null } createRepliesSubscription( - relays = connectedRelays, + relays = configuredRelays, eventIds = eventIds, onEvent = { event, _, _, _ -> // Find the event this is replying to @@ -408,13 +416,13 @@ fun FeedScreen( } // Subscribe to reposts for visible events - rememberSubscription(connectedRelays, eventIds, relayManager = relayManager) { - if (connectedRelays.isEmpty() || eventIds.isEmpty()) { + rememberSubscription(configuredRelays, eventIds, relayManager = relayManager) { + if (configuredRelays.isEmpty() || eventIds.isEmpty()) { return@rememberSubscription null } createRepostsSubscription( - relays = connectedRelays, + relays = configuredRelays, eventIds = eventIds, onEvent = { event, _, _, _ -> if (event is RepostEvent) { @@ -440,13 +448,13 @@ fun FeedScreen( } // Fallback subscription if coordinator not available - rememberSubscription(connectedRelays, authorPubkeys, subscriptionsCoordinator, relayManager = relayManager) { + rememberSubscription(configuredRelays, authorPubkeys, subscriptionsCoordinator, relayManager = relayManager) { // Skip if using coordinator if (subscriptionsCoordinator != null) { return@rememberSubscription null } - if (connectedRelays.isEmpty() || authorPubkeys.isEmpty()) { + if (configuredRelays.isEmpty() || authorPubkeys.isEmpty()) { return@rememberSubscription null } @@ -464,7 +472,7 @@ fun FeedScreen( } createBatchMetadataSubscription( - relays = connectedRelays, + relays = configuredRelays, 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 050d829ed..52b5dbee0 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 @@ -41,6 +41,7 @@ import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -71,6 +72,7 @@ fun LoginScreen( var showNewKeyDialog by remember { mutableStateOf(false) } var generatedAccount by remember { mutableStateOf(null) } val scope = rememberCoroutineScope() + val loginProgress by accountManager.loginProgress.collectAsState() Column( modifier = Modifier.fillMaxSize().padding(32.dp), @@ -116,6 +118,7 @@ fun LoginScreen( onLoginSuccess() } }, + loginProgress = loginProgress, ) val account = generatedAccount 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 04e86c919..67d1bcdf7 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,6 +61,7 @@ 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.LoginProgress import com.vitorpamplona.amethyst.desktop.account.validateBunkerUri import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -73,6 +74,7 @@ fun LoginCard( onGenerateNew: () -> Unit, onLoginBunker: (suspend (String) -> Result)? = null, onLoginNostrConnect: (suspend (onUriGenerated: (String) -> Unit) -> Result)? = null, + loginProgress: LoginProgress? = null, modifier: Modifier = Modifier, cardWidth: Dp = 400.dp, title: String = stringResource(Res.string.login_card_title), @@ -119,8 +121,8 @@ fun LoginCard( } when (selectedTab) { - 0 -> PasteKeyContent(onLogin, onGenerateNew, onLoginBunker, subtitle) - 1 -> if (onLoginNostrConnect != null) NostrConnectContent(onLoginNostrConnect) + 0 -> PasteKeyContent(onLogin, onGenerateNew, onLoginBunker, subtitle, loginProgress) + 1 -> if (onLoginNostrConnect != null) NostrConnectContent(onLoginNostrConnect, loginProgress) } } } @@ -132,6 +134,7 @@ private fun PasteKeyContent( onGenerateNew: () -> Unit, onLoginBunker: (suspend (String) -> Result)?, subtitle: String, + loginProgress: LoginProgress? = null, ) { var keyInput by remember { mutableStateOf("") } var errorMessage by remember { mutableStateOf(null) } @@ -167,21 +170,25 @@ private fun PasteKeyContent( Spacer(Modifier.height(16.dp)) if (isConnecting) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.Center, - verticalAlignment = Alignment.CenterVertically, - ) { - CircularProgressIndicator( - modifier = Modifier.size(20.dp), - strokeWidth = 2.dp, - ) - Spacer(Modifier.width(12.dp)) - Text( - "Connecting to remote signer...", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + if (loginProgress != null) { + LoginProgressSteps(loginProgress) + } else { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + ) { + CircularProgressIndicator( + modifier = Modifier.size(20.dp), + strokeWidth = 2.dp, + ) + Spacer(Modifier.width(12.dp)) + Text( + "Connecting to remote signer...", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } } } else { Row( @@ -234,7 +241,10 @@ private fun PasteKeyContent( } @Composable -private fun NostrConnectContent(onLoginNostrConnect: suspend (onUriGenerated: (String) -> Unit) -> Result) { +private fun NostrConnectContent( + onLoginNostrConnect: suspend (onUriGenerated: (String) -> Unit) -> Result, + loginProgress: LoginProgress? = null, +) { var nostrConnectUri by remember { mutableStateOf(null) } var isConnecting by remember { mutableStateOf(false) } var errorMessage by remember { mutableStateOf(null) } @@ -316,21 +326,25 @@ private fun NostrConnectContent(onLoginNostrConnect: suspend (onUriGenerated: (S Spacer(Modifier.height(12.dp)) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.Center, - verticalAlignment = Alignment.CenterVertically, - ) { - CircularProgressIndicator( - modifier = Modifier.size(16.dp), - strokeWidth = 2.dp, - ) - Spacer(Modifier.width(8.dp)) - Text( - "Waiting for signer to connect...", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + if (loginProgress != null) { + LoginProgressSteps(loginProgress) + } else { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + ) { + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp, + ) + Spacer(Modifier.width(8.dp)) + Text( + "Waiting for signer to connect...", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } } } else { Row( diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/auth/LoginProgressSteps.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/auth/LoginProgressSteps.kt new file mode 100644 index 000000000..08655a5c4 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/auth/LoginProgressSteps.kt @@ -0,0 +1,220 @@ +/* + * 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.ui.auth + +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.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +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.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +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.desktop.account.LoginProgress +import com.vitorpamplona.amethyst.desktop.account.RelayLoginStatus + +private enum class StepState { DONE, ACTIVE, PENDING } + +private data class StepInfo( + val label: String, + val state: StepState, +) + +@Composable +fun LoginProgressSteps( + progress: LoginProgress, + modifier: Modifier = Modifier, +) { + val steps = buildStepList(progress) + + Column( + modifier = modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + steps.forEach { step -> + StepRow(step) + + // Show relay rows under the active step + if (step.state == StepState.ACTIVE && progress.relayStatuses.isNotEmpty()) { + progress.relayStatuses.forEach { (relay, status) -> + RelayRow(relay.url, status) + } + } + } + } +} + +private fun buildStepList(progress: LoginProgress): List { + val orderedSteps = + listOf( + "Connecting to relays", + "Waiting for signer", + "Sending acknowledgment", + ) + + val activeIndex = + when (progress) { + is LoginProgress.ConnectingToRelays -> 0 + is LoginProgress.WaitingForSigner -> 1 + is LoginProgress.SendingAck -> 2 + } + + return orderedSteps.mapIndexed { index, label -> + StepInfo( + label = label, + state = + when { + index < activeIndex -> StepState.DONE + index == activeIndex -> StepState.ACTIVE + else -> StepState.PENDING + }, + ) + } +} + +@Composable +private fun StepRow(step: StepInfo) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth(), + ) { + when (step.state) { + StepState.DONE -> { + Icon( + Icons.Default.Check, + contentDescription = null, + tint = Color(0xFF4CAF50), + modifier = Modifier.size(16.dp), + ) + } + + StepState.ACTIVE -> { + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp, + ) + } + + StepState.PENDING -> { + Spacer(Modifier.size(16.dp)) + } + } + Spacer(Modifier.width(8.dp)) + Text( + step.label, + style = MaterialTheme.typography.bodySmall, + color = + when (step.state) { + StepState.DONE -> Color(0xFF4CAF50) + StepState.ACTIVE -> MaterialTheme.colorScheme.onSurface + StepState.PENDING -> MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f) + }, + ) + } +} + +@Composable +private fun RelayRow( + url: String, + status: RelayLoginStatus, +) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth().padding(start = 24.dp), + ) { + when (status) { + RelayLoginStatus.EVENT_SENT -> { + Icon( + Icons.Default.Check, + contentDescription = null, + tint = Color(0xFF2196F3), + modifier = Modifier.size(12.dp), + ) + } + + RelayLoginStatus.CONNECTED -> { + Icon( + Icons.Default.Check, + contentDescription = null, + tint = Color(0xFF4CAF50), + modifier = Modifier.size(12.dp), + ) + } + + RelayLoginStatus.FAILED, + RelayLoginStatus.SEND_FAILED, + -> { + Icon( + Icons.Default.Close, + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + modifier = Modifier.size(12.dp), + ) + } + + RelayLoginStatus.CONNECTING -> { + CircularProgressIndicator( + modifier = Modifier.size(12.dp), + strokeWidth = 1.5.dp, + ) + } + } + Spacer(Modifier.width(6.dp)) + val label = url.removePrefix("wss://").removeSuffix("/") + val statusLabel = + when (status) { + RelayLoginStatus.EVENT_SENT -> "$label (sent)" + RelayLoginStatus.SEND_FAILED -> "$label (send failed)" + RelayLoginStatus.FAILED -> "$label (failed)" + else -> label + } + Text( + statusLabel, + style = MaterialTheme.typography.labelSmall, + color = + when (status) { + RelayLoginStatus.FAILED, RelayLoginStatus.SEND_FAILED -> { + MaterialTheme.colorScheme.error.copy(alpha = 0.8f) + } + + RelayLoginStatus.EVENT_SENT -> { + Color(0xFF2196F3) + } + + else -> { + MaterialTheme.colorScheme.onSurfaceVariant + } + }, + ) + } +} diff --git a/docs/brainstorms/2026-03-06-nip46-post-login-bugs-brainstorm.md b/docs/brainstorms/2026-03-06-nip46-post-login-bugs-brainstorm.md new file mode 100644 index 000000000..607446d47 --- /dev/null +++ b/docs/brainstorms/2026-03-06-nip46-post-login-bugs-brainstorm.md @@ -0,0 +1,189 @@ +--- +date: 2026-03-06 +topic: nip46-post-login-bugs +--- + +# NIP-46 Post-Login Bugs: Content Not Loading + Bunker Timeout + +## What's Happening + +Two bugs on branch `feat/nip46-bunker-login` after the NIP-46 relay isolation work: + +### Bug 1: `followedUsers=0` — Feed Never Populates After Login + +**Symptoms:** +- Contact list subscription opens/closes rapidly (subscription churn) +- No `[DEBUG-FEED] contactList event:` log — no kind-3 event ever arrives +- Feed stays on "Loading followed users..." forever + +**Logs:** +``` +[DEBUG-FEED] contactList sub: relays=5, account=0b24845a, mode=FOLLOWING +[DEBUG-FEED] feedSub: mode=FOLLOWING, relays=5, followedUsers=0 +[DEBUG-SUB] OPEN contacts-0b24845a-1772773857361 relays=5 +[DEBUG-SUB] CLOSE contacts-0b24845a-1772773857361 +[DEBUG-SUB] OPEN contacts-0b24845a-1772773867946 relays=5 +[DEBUG-SUB] CLOSE contacts-0b24845a-1772773867946 +``` + +### Bug 2: `bunker://` Login Times Out (30s) + +**Symptoms:** +- Pasting bunker:// URI → "Connection timed out" after 30s +- `RemoteSignerManager.timeout = 30000` controls this + +## Root Cause Analysis + +### Bug 1: Subscription Churn from `relayStatuses.keys` + +**The pattern** (`FeedScreen.kt:193`): +```kotlin +rememberSubscription(relayStatuses.keys, account, feedMode, ...) { ... } +``` + +**The problem:** +1. `relayStatuses` is a `StateFlow>` +2. Every relay status change (connect, disconnect, ping) emits a new map +3. `.keys` produces a structurally-equal but reference-different set +4. `remember(*keys)` re-evaluates → `DisposableEffect` disposes old sub → opens new sub +5. New sub gets a fresh `subId` (uses `System.currentTimeMillis()`) +6. Relay's response to old REQ arrives → client drops it (unknown subId) +7. New REQ sent → relay starts processing → another status change → cycle repeats + +**Timeline:** +``` +T+0s: Relay A connects → relayStatuses emits → sub opens (subId=1001) +T+1s: Relay B connects → relayStatuses emits → sub CLOSES (subId=1001), opens (subId=1002) +T+2s: Relay C connects → same cycle → CLOSE 1002, OPEN 1003 +T+3s: Relay responds to subId=1001 → DROPPED (sub already closed) +T+4s: Relay responds to subId=1003 → maybe received, maybe closed again +``` + +**Contributing factor:** `relayStatuses.keys` includes configured-but-not-connected relays. REQs to unconnected relays are queued but may never be sent. + +### Bug 2: NIP-46 Relay Connection Race + +**The flow** (`AccountManager.loginWithBunker()`): +```kotlin +val nip46Client = getOrCreateNip46Client() // creates NostrClient, calls connect() (no-op: no relays) +val remoteSigner = NostrSignerRemote.fromBunkerUri(bunkerUri, signer, nip46Client) +remoteSigner.openSubscription() // calls client.req(relays=[relay.nsec.app]) → lazy connect +val remotePubkey = remoteSigner.connect() // sends connect request, waits 30s for response +``` + +**The problem:** +- `openSubscription()` triggers `sendOrConnectAndSync(relay.nsec.app, REQ)` — async connection +- `connect()` calls `launchWaitAndParse()` which sends EVENT immediately +- If websocket to relay.nsec.app isn't established yet, EVENT send fails/queues +- Even if EVENT sends, relay may not have processed REQ yet → response has no matching sub +- 30s timeout expires + +**Also:** Only 1 relay (`wss://relay.nsec.app`) — single point of failure. + +## Approaches + +### Bug 1 Fix: Stabilize Subscription Keys + +#### Approach A: Use `connectedRelays` + Debounce (Recommended) + +Replace `relayStatuses.keys` with a debounced/stable relay set. + +```kotlin +// Stable relay set that only updates when relay URLs actually change +val stableRelays by remember { + snapshotFlow { connectedRelays } + .distinctUntilChanged() + .debounce(2000) // wait for relays to stabilize +}.collectAsState(initial = emptySet()) + +rememberSubscription(stableRelays, account, feedMode, ...) { ... } +``` + +**Pros:** Eliminates churn, only resubscribes when relay set actually changes +**Cons:** 2s delay before first subscription opens + +#### Approach B: Separate Subscription Lifecycle from Relay Set + +Don't use relay set as a recomposition key at all. Open subscription ONCE with whatever relays are available, never re-create it. + +```kotlin +// Only recompose on account/feedMode changes, not relay changes +rememberSubscription(account, feedMode, relayManager = relayManager) { + val relays = connectedRelays // snapshot, not reactive + if (relays.isNotEmpty() && account != null) { + createContactListSubscription(relays = relays, ...) + } else null +} +``` + +**Pros:** Zero churn, simplest fix +**Cons:** If initial relay set is empty, subscription never opens (need LaunchedEffect to wait for relays) + +#### Approach C: LaunchedEffect + One-Shot Subscription + +Don't use `rememberSubscription` for contact list at all. Use a `LaunchedEffect` that waits for relays then subscribes once. + +```kotlin +LaunchedEffect(account) { + val relays = relayManager.connectedRelays.first { it.isNotEmpty() } + relayManager.subscribe("contacts-${account.pubKeyHex.take(8)}", + listOf(FilterBuilders.contactList(account.pubKeyHex)), relays, + listener = object : IRequestListener { ... }) +} +``` + +**Pros:** No churn, explicit lifecycle, clear intent +**Cons:** Manual cleanup needed, doesn't auto-update if relay set changes later + +### Bug 2 Fix: Wait for NIP-46 Relay Connection + +#### Approach A: Wait for Connected Relay Before connect() (Recommended) + +```kotlin +val nip46Client = getOrCreateNip46Client() +val remoteSigner = NostrSignerRemote.fromBunkerUri(bunkerUri, signer, nip46Client) +remoteSigner.openSubscription() + +// Wait for NIP-46 relay to actually connect before sending connect request +nip46Client.connectedRelaysFlow().first { relays -> + remoteSigner.relays.any { it in relays } +} + +val remotePubkey = remoteSigner.connect() +``` + +**Pros:** Guarantees relay is connected before handshake +**Cons:** Adds wait time; if relay never connects, blocks indefinitely (need timeout) + +#### Approach B: Add Retry Logic to connect() + +Wrap `connect()` in retry with backoff: +```kotlin +val remotePubkey = retry(maxAttempts = 3, delayMs = 5000) { + remoteSigner.connect() +} +``` + +**Pros:** Handles transient failures +**Cons:** Doesn't fix root cause, just masks it; up to 90s total wait + +#### Approach C: Increase Timeout + Add Debug Logging + +Bump `RemoteSignerManager.timeout` to 60s, add logging to track relay connection state. + +**Pros:** Quick, low-risk +**Cons:** Doesn't fix the race; just makes timeout less likely + +## Key Decisions + +- Bug 1 is the higher priority — it affects ALL logins, not just bunker +- Bug 1 Approach B or C preferred — simplest, eliminates churn entirely +- Bug 2 Approach A preferred — fixes the actual race condition +- Both fixes are independent and can be done in parallel + +## Open Questions + +1. Does the contact list fail for nsec/key login too, or only bunker? (Would confirm if it's relay churn vs NIP-46 specific) +2. Is `relay.nsec.app` reachable from this machine? (`websocat wss://relay.nsec.app` test) +3. Should the bunker URI's relays be used for content subscriptions too? (User's relays vs default relays) +4. Should we add a fallback relay for NIP-46? (e.g., `wss://relay.damus.io` alongside `relay.nsec.app`) diff --git a/docs/brainstorms/2026-03-09-feedscreen-relay-subscription-strategy-brainstorm.md b/docs/brainstorms/2026-03-09-feedscreen-relay-subscription-strategy-brainstorm.md new file mode 100644 index 000000000..e68e16c5e --- /dev/null +++ b/docs/brainstorms/2026-03-09-feedscreen-relay-subscription-strategy-brainstorm.md @@ -0,0 +1,209 @@ +# Brainstorm: FeedScreen Relay Subscription Strategy + +**Date:** 2026-03-09 +**Status:** Investigation complete, solution needed +**Branch:** `feat/nip46-bunker-login` + +## Problem Statement + +Follows don't load for nostrconnect:// login. The root cause is a **chicken-and-egg problem** with how FeedScreen drives relay subscriptions — two competing approaches each solve one problem but break the other. + +## What We Found + +### The Two Approaches + +| Approach | Drives relay connections? | Avoids subscription churn? | +|----------|--------------------------|---------------------------| +| `configuredRelays` (relayStatuses.keys) | YES — openReqSubscription connects on demand | NO — status map emits on every ping/connect/disconnect | +| `connectedRelays` (connectedRelaysFlow) | NO — subscriptions never open if nothing is connected | YES — only changes when relay actually connects/disconnects | + +### How `configuredRelays` Works (Current Working Dir) + +```kotlin +val relayStatusMap by relayManager.relayStatuses.collectAsState() +val configuredRelays = relayStatusMap.keys // all relay URLs, connected or not + +rememberSubscription(configuredRelays, account, feedMode, ...) { + createContactListSubscription(relays = configuredRelays, ...) +} +``` + +- `addDefaultRelays()` populates `relayStatuses` map immediately at app startup +- `openReqSubscription()` (inside subscribe) connects to relays on demand +- Relays connect, REQs are sent, events arrive +- **Problem:** `relayStatusMap` emits a new Map on every relay status change (connect, ping, disconnect). Each emission triggers recomposition. `relayStatusMap.keys` creates a new Set instance. Even though `Set.equals()` is structural, `remember(*keys)` in Compose may see the new reference + trigger config lambda re-evaluation, generating a new subId via `generateSubId()` (uses `System.currentTimeMillis()`). DisposableEffect sees new subId -> closes old subscription -> opens new one. Contact list responses to old subId are dropped. + +### How `connectedRelays` Works (Committed in e58aaf411) + +```kotlin +val connectedRelays by relayManager.connectedRelays.collectAsState() + +rememberSubscription(connectedRelays, account, feedMode, ...) { + createContactListSubscription(relays = connectedRelays, ...) +} +``` + +- Only includes actually-connected relays +- Subscription only recreates when a relay actually connects/disconnects +- **Problem:** Chicken-and-egg. Nothing triggers relay connections in the first place. `addDefaultRelays()` only adds to the status map, NOT to the NostrClient. `relayManager.connect()` is a no-op when NostrClient has no registered relays. Relays only get registered when `openReqSubscription()` is called. But `openReqSubscription()` is never called because `connectedRelays` is empty, so the subscription lambda returns null. + +### Why nsec/bunker Login Appeared to Work with `connectedRelays` + +The `subscriptionsCoordinator.start()` in Main.kt kicks off a `rateLimiter.start` that calls `client.openReqSubscription()` for metadata. This incidentally registers relays with the NostrClient and triggers connections. Once relays connect, `connectedRelays` becomes non-empty, and FeedScreen subscriptions fire. + +But this is fragile — it depends on the coordinator's metadata requests happening before FeedScreen needs relays. For nostrconnect (where login takes longer), the timing may differ. + +## Architecture Context + +### Relay Registration Flow +``` +addDefaultRelays() -> relayStatuses map only (local state) +connect() -> NostrClient.connect() (no-op if no relays registered) +subscribe() -> openReqSubscription() -> registers relays + connects + sends REQ +``` + +Key insight: **Subscriptions are what trigger relay connections**, not `addDefaultRelays()` or `connect()`. + +### Key Files +- `FeedScreen.kt` — all feed subscriptions, follows loading +- `SubscriptionUtils.kt` — `rememberSubscription()` with `remember(*keys)` + `DisposableEffect` +- `RelayConnectionManager.kt` — `relayStatuses` (Map), `connectedRelays` (Set), `subscribe()` +- `FeedSubscription.kt` — `createContactListSubscription()`, `generateSubId()` (uses timestamp) + +### The Churn Mechanism (Detail) +``` +1. relayStatusMap emits (relay A pings) +2. FeedScreen recomposes +3. configuredRelays = relayStatusMap.keys (new Set instance) +4. remember(*keys) — keys include configuredRelays +5. IF keys changed: config() runs -> generateSubId("contacts-...") -> new subId +6. DisposableEffect sees new subId -> onDispose (close old sub) -> open new sub +7. Relay was responding to old subId -> response dropped +8. New sub sent -> relay starts processing -> another status change -> goto 1 +``` + +The critical question: does `remember(*keys)` trigger re-evaluation when `configuredRelays` has same content but different reference? In Compose, `remember` uses `==` (structural equality). `Set.equals()` compares contents. So **if relay URLs haven't changed, remember should NOT recompute**. + +But `relayStatusMap` itself changes (values change), causing recomposition. During recomposition, `configuredRelays = relayStatusMap.keys` runs. If the underlying Map implementation returns the same key set by reference, `remember` won't recompute. But if it returns a new Set (likely, since we do `.toMutableMap().apply{...}` in `updateRelayStatus`), then... `Set.equals()` should still return true. + +**This needs empirical verification** — add logging in `rememberSubscription` to confirm whether subscriptions are actually being recreated. + +## Candidate Solutions + +### Approach A: Stabilize configuredRelays Key + +Keep using `configuredRelays` to drive subscriptions (solves chicken-and-egg), but prevent churn by stabilizing the key: + +```kotlin +// Derive relay URLs once, only update when URLs actually change +val configuredRelayUrls by remember { + derivedStateOf { + relayManager.relayStatuses.value.keys + } +} +``` + +Or use `distinctUntilChanged()` on the Flow before collecting: + +```kotlin +val configuredRelays by relayManager.relayStatuses + .map { it.keys } + .distinctUntilChanged() + .collectAsState(emptySet()) +``` + +**Pro:** Subscriptions drive relay connections AND keys are stable. +**Con:** Subscriptions still target unconnected relays (but openReqSubscription handles this). + +### Approach B: Use configuredRelays for initial, connectedRelays after + +Two-phase approach: +1. First subscription uses `configuredRelays` to trigger relay connections +2. Once relays connect, switch to `connectedRelays` for stability + +```kotlin +val relaySet = if (connectedRelays.isEmpty()) configuredRelays else connectedRelays +``` + +**Pro:** Gets the best of both worlds. +**Con:** Subscription recreates once during the transition. More complex logic. + +### Approach C: Separate relay connection from subscriptions + +Add relay registration to `addDefaultRelays()` so `connect()` actually works: + +```kotlin +fun addRelay(url: String): NormalizedRelayUrl? { + val normalized = RelayUrlNormalizer.normalizeOrNull(url) ?: return null + updateRelayStatus(normalized) { it.copy(connected = false) } + _client.registerRelay(normalized) // NEW: register with NostrClient + return normalized +} +``` + +Then FeedScreen can safely use `connectedRelays` because relays will connect via `connect()` independently of subscriptions. + +**Pro:** Clean separation. `connectedRelays` works correctly everywhere. +**Con:** Requires changes to NostrClient API (may not have `registerRelay`). Bigger change. + +### Approach D: Empirically verify churn isn't happening + +Add debug logging to `rememberSubscription` to confirm whether the contactList subscription is actually being recreated. If `Set.equals()` works correctly and `remember` doesn't recompute, then `configuredRelays` approach works fine and the follows issue has a different root cause. + +```kotlin +@Composable +fun rememberSubscription(vararg keys: Any?, ...) { + val subscription = remember(*keys) { + DebugConfig.log("SUB RECOMPUTE keys=${keys.contentToString()}") + config() + } + // ... +} +``` + +**Pro:** Cheapest path. May reveal the actual bug. +**Con:** Doesn't fix anything by itself. + +## Recommendation + +**Start with Approach D** (verify), then **Approach A** (stabilize) if churn is confirmed. + +Approach A with `distinctUntilChanged()` is the cleanest fix — it preserves the property that subscriptions drive relay connections while eliminating any possible churn from status map updates. + +## Decision: D then A + +### D: Empirical Verification (implemented) + +Added `SUB RECOMPUTE` log inside `remember(*keys)` in `rememberSubscription()`. Fires only when Compose re-evaluates the config lambda (i.e., keys actually changed). Compare frequency against existing `SUB OPEN`/`SUB CLOSE` logs. + +**File:** `SubscriptionUtils.kt:73-78` + +Run with `AMETHYST_DEBUG=true ./gradlew :desktopApp:run` and watch for: +- `SUB RECOMPUTE` spam = churn confirmed, A is needed +- `SUB RECOMPUTE` fires once per subscription = no churn, root cause is elsewhere + +### A: Stabilize configuredRelays (implemented) + +Replaced raw `relayStatusMap.keys` with a `distinctUntilChanged()` flow: + +```kotlin +val configuredRelays by remember { + relayManager.relayStatuses + .map { it.keys } + .distinctUntilChanged() +}.collectAsState(emptySet()) +``` + +**File:** `FeedScreen.kt:163-167` + +This ensures `configuredRelays` only emits when the set of relay URLs actually changes, not on every ping/status update. `openReqSubscription` still drives relay connections on demand. + +## Open Questions + +1. **If churn isn't the issue, what is?** Could be a timing issue where the contactList REQ arrives at the relay before the relay has the event cached, and no retry mechanism exists. + +2. **Does `openReqSubscription` reliably deliver events for not-yet-connected relays?** It should queue and send REQ after connecting, but need to verify the NostrClient implementation. + +3. **Should we add a retry/resubscribe mechanism?** If the first contactList subscription gets EOSE with no results, should we retry after a delay? + +4. **Is the `since` filter on contactList needed?** ContactList events have no `since` filter currently — they request the latest kind-3 event. But if the relay is slow to index, the response might be empty. diff --git a/docs/plans/2026-03-05-fix-nip46-relay-isolation-and-init-plan.md b/docs/plans/2026-03-05-fix-nip46-relay-isolation-and-init-plan.md new file mode 100644 index 000000000..7a426d820 --- /dev/null +++ b/docs/plans/2026-03-05-fix-nip46-relay-isolation-and-init-plan.md @@ -0,0 +1,365 @@ +--- +title: "fix: NIP-46 relay isolation, pool init, and stale response bugs" +type: fix +status: active +date: 2026-03-05 +deepened: 2026-03-05 +--- + +# fix: NIP-46 relay isolation, pool init, and stale response bugs + +## Enhancement Summary + +**Deepened on:** 2026-03-05 +**Review agents used:** Kotlin expert, Kotlin coroutines, Compose expert, Nostr protocol, Architecture, Code simplicity, Performance + +### Key Improvements from Deepening +1. **DEADLOCK FIX**: Phase 2 `availableRelays` wait causes circular dependency — switched to `connectedRelays` +2. **Thread safety**: Added `Mutex` to `getOrCreateNip46Client()` for coroutine safety +3. **Simplified**: Removed YAGNI (WebsocketBuilder injection, debug listener, nullable coordinator) +4. **`since` filter adjusted**: Use 60s buffer (`TimeUtils.now() - 60`) to handle clock skew +5. **Connection readiness**: Wait for NIP-46 relay connection before sending REQ + +### New Risks Discovered +- `NostrClient.disconnect()` does NOT cancel internal coroutine scope (two `stateIn(Eagerly)` flows leak). Fix: call `scope.cancel()` in `disconnectNip46Client()` — see Step 3 +- `NostrClient.connect()` is non-blocking — `openSubscription()` may race. Mitigated by relay auto-queue in `PoolRequests` +- `connectedRelays` flow fires when general relays connect, breaking the circular dependency that `availableRelays` had + +## Overview + +Three bugs in the NIP-46 remote signer implementation on desktop prevent account-specific content from loading, break session restoration, and cause sign requests to never reach Amber. All trace to two root causes: shared `NostrClient` pool pollution and relay pool initialization timing. + +## Problem Statement + +### Bug 1: No account-specific content after nostrconnect login +After successful NIP-46 login, home feed shows global notes but no followed users, profile data, or DMs. `DesktopRelaySubscriptionsCoordinator.indexRelays` is captured as an empty snapshot at construction time (`relayManager.availableRelays.value` in `Main.kt:411`), so the metadata coordinator never fetches profiles/avatars. + +### Bug 2: Session restoration fails with ClosedMessage loop +On restart, `NostrSignerRemote` opens its subscription on `relay.nsec.app` via the shared `NostrClient`. This adds `relay.nsec.app` to the general relay pool. All other subscriptions (DMs, metadata, feeds) get sent there too. `relay.nsec.app` rejects non-NIP-46 subs with `ClosedMessage`. `PoolRequests` auto-retries on `ClosedMessage` (line 216-233), creating an infinite `ReqCmd -> ClosedMessage -> CloseCmd -> ReqCmd` loop that drowns out actual NIP-46 responses. + +### Bug 3: Posting never reaches Amber +Sign request is sent successfully (`EventCmd success=true`, `OkMessage`) but the response subscription is constantly disrupted by the ClosedMessage loop from Bug 2. Amber's response arrives during a subscription gap or gets lost in the churn. + +## Root Causes + +| Root Cause | Bugs | Evidence | +|-----------|------|----------| +| **Shared NostrClient** — NIP-46 relay leaks into general pool | 2, 3 | Every major NIP-46 impl (NDK, Snort, nostrudel, Coracle, Lume) uses relay isolation. Our shared client is unique and broken. | +| **`indexRelays` empty snapshot** — captured before pool populates | 1 | `relayManager.availableRelays.value` at coordinator construction is `emptySet()` because `addRelay()` only updates UI status map, not the NostrClient pool. Relays enter the pool lazily via `openReqSubscription`. | + +## Proposed Solution + +### Fix 1: Dedicated NostrClient for NIP-46 (Bugs 2 + 3) + +Create a separate `NostrClient` instance exclusively for NIP-46 traffic inside `AccountManager`. Pass it to `NostrSignerRemote` instead of the shared general client. + +**Design decisions:** +- `AccountManager` owns the dedicated client internally (not injected) — simplest, matches NDK's pattern +- Shares the same `OkHttpClient` instance via `DesktopHttpClient::getHttpClient` — efficient, no extra thread pools +- ~~`AccountManager` takes a `WebsocketBuilder` constructor param~~ **SIMPLIFIED**: Hardcode `BasicOkHttpWebSocket.Builder(DesktopHttpClient::getHttpClient)` inside `getOrCreateNip46Client()` — no need to inject what never varies +- Dedicated client is disconnected on `logout()` + +### Fix 2: Defer subscriptionsCoordinator creation until relays connected (Bug 1) + +Create `DesktopRelaySubscriptionsCoordinator` inside `LaunchedEffect` after `connectedRelays` is non-empty. Coordinator is nullable — `indexRelays` is `val` (immutable), so it cannot be updated after construction. + +> **DEADLOCK WARNING (from performance review):** Original plan used `availableRelays.first { it.isNotEmpty() }` but `availableRelays` only populates when `openReqSubscription()` is called — which requires the coordinator. Circular dependency. Using `connectedRelays` instead — it fires when WebSocket connects, independent of subscriptions. + +### Fix 3: Add `since` filter to NIP-46 subscription + +Pass `since = TimeUtils.now() - 60` in the `Filter` constructor inside `NostrSignerRemote`. The 60s buffer handles clock skew between client and relay while still filtering out truly stale responses from previous sessions. + +## Technical Approach + +### Phase 1: Dedicated NIP-46 NostrClient + +#### Step 1: Update AccountManager — add dedicated client + +**File:** `desktopApp/src/jvmMain/.../desktop/account/AccountManager.kt` + +No constructor changes needed (YAGNI — WebsocketBuilder injection removed). + +```kotlin +@Stable +class AccountManager internal constructor( + private val secureStorage: SecureKeyStorage, + private val homeDir: File = File(System.getProperty("user.home")), +) { + // Dedicated NIP-46 client — isolated from general relay pool + private val nip46ClientMutex = Mutex() + private var nip46Client: NostrClient? = null + + private suspend fun getOrCreateNip46Client(): NostrClient { + return nip46ClientMutex.withLock { + nip46Client ?: NostrClient( + BasicOkHttpWebSocket.Builder(DesktopHttpClient::getHttpClient) + ).also { + nip46Client = it + it.connect() + } + } + } + + private fun disconnectNip46Client() { + nip46Client?.disconnect() + // NostrClient.disconnect() only closes WebSockets but leaks two + // stateIn(Eagerly) flows. Cancel scope to prevent coroutine leak. + // TODO: upstream a close() method to NostrClient (see improvement ticket) + nip46Client = null + } + // ... +} +``` + +> **Thread safety (from Kotlin expert review):** `getOrCreateNip46Client()` is called from `loginWithBunker()`, `loginWithNostrConnect()`, and `loadBunkerAccount()` — all suspend functions potentially called from different coroutines. `Mutex` prevents double-creation races. + +> **No debug logging listener (from simplicity review):** The original plan added an `IRelayClientListener` for `[NIP-46]` prefixed logging. Removed — `println` debugging should be added ad-hoc, not baked in. The existing NIP-46 logging in `NostrSignerRemote` and `RemoteSignerManager` is sufficient. + +#### Step 2: Update login methods to use dedicated client + +**File:** `desktopApp/src/jvmMain/.../desktop/account/AccountManager.kt` + +Remove `client: INostrClient` parameter from all NIP-46 methods. Use `getOrCreateNip46Client()` internally. + +```kotlin +// BEFORE +suspend fun loginWithBunker(bunkerUri: String, client: INostrClient): Result + +// AFTER +suspend fun loginWithBunker(bunkerUri: String): Result +``` + +Same for: +- `loginWithNostrConnect(onUriGenerated: (String) -> Unit)` — remove `client` param +- `loadSavedAccount()` — remove `client` param +- `loadBunkerAccount(bunkerUri, npub)` — remove `client` param +- `waitForConnectRequest(...)` — remove `client` param +- `sendAckResponse(...)` — remove `client` param + +#### Step 3: Add NIP-46 client cleanup to logout + +**File:** `desktopApp/src/jvmMain/.../desktop/account/AccountManager.kt` + +```kotlin +suspend fun logout(deleteKey: Boolean = false) { + val current = currentAccount() + if (current != null) { + if (current.signerType is SignerType.Remote) { + (current.signer as? NostrSignerRemote)?.closeSubscription() + disconnectNip46Client() // disconnect dedicated client + // ... existing key cleanup + } + // ... existing logout logic + } + // ... +} +``` + +#### Step 4: Update Main.kt wiring + +**File:** `desktopApp/src/jvmMain/.../desktop/Main.kt` + +Remove `relayManager.client` from all AccountManager calls: + +```kotlin +// BEFORE (line 434) +val result = accountManager.loadSavedAccount(relayManager.client) + +// AFTER +val result = accountManager.loadSavedAccount() +``` + +```kotlin +// BEFORE (line 423-441) — bunker restore flow +if (accountManager.hasBunkerAccount()) { + accountManager.setConnectingRelays() + val connected = withTimeoutOrNull(30_000L) { + relayManager.connectedRelays.first { it.isNotEmpty() } + } + if (connected == null) { + accountManager.logout(deleteKey = true) + } else { + val result = accountManager.loadSavedAccount(relayManager.client) + // ... + } +} + +// AFTER — no need to wait for general relays; dedicated client connects independently +if (accountManager.hasBunkerAccount()) { + accountManager.setConnectingRelays() + val result = accountManager.loadSavedAccount() + if (result.isSuccess) { + accountManager.startHeartbeat(scope) + } else { + accountManager.logout(deleteKey = true) + } +} +``` + +Update `LoginScreen` call — remove `relayClient` param: + +```kotlin +// BEFORE (line 466) +LoginScreen(accountManager = accountManager, relayClient = relayManager.client, ...) + +// AFTER +LoginScreen(accountManager = accountManager, ...) +``` + +#### Step 5: Update LoginScreen and LoginCard + +**File:** `desktopApp/src/jvmMain/.../desktop/ui/LoginScreen.kt` +**File:** `desktopApp/src/jvmMain/.../desktop/ui/auth/LoginCard.kt` + +Remove `relayClient: INostrClient` parameter from both composables. The `AccountManager` now handles NIP-46 client internally. + +#### Step 6: Clean up RelayConnectionManager NIP-46 logging + +**File:** `desktopApp/src/jvmMain/.../desktop/network/RelayConnectionManager.kt` + +Remove all `nsec.app`-specific logging from relay listener callbacks (lines 180-231). After relay isolation, the general `RelayConnectionManager` will never see NIP-46 traffic. Simplify each callback to just the `updateRelayStatus()` call. + +### Phase 2: Fix Relay Pool Initialization + +#### Step 7: Defer subscriptionsCoordinator creation + +**File:** `desktopApp/src/jvmMain/.../desktop/Main.kt` + +> **RESOLVED**: `indexRelays` is `private val` (immutable). Must use nullable coordinator — create inside `LaunchedEffect` after relays connect. + +```kotlin +// BEFORE (line 406-414) +val subscriptionsCoordinator = remember(relayManager, localCache) { + DesktopRelaySubscriptionsCoordinator( + client = relayManager.client, + scope = scope, + indexRelays = relayManager.availableRelays.value, // empty! + localCache = localCache, + ) +} + +// AFTER — nullable, created after relays connect +var subscriptionsCoordinator by remember { mutableStateOf(null) } + +LaunchedEffect(relayManager, localCache) { + // Wait for connected (NOT availableRelays — that deadlocks, see below) + relayManager.connectedRelays.first { it.isNotEmpty() } + subscriptionsCoordinator = DesktopRelaySubscriptionsCoordinator( + client = relayManager.client, + scope = scope, + indexRelays = relayManager.connectedRelays.value, // now populated + localCache = localCache, + ).also { it.start() } +} + +DisposableEffect(Unit) { + onDispose { + subscriptionsCoordinator?.clear() + } +} +``` + +> **Deadlock explanation:** `availableRelays` derives from `NostrClient.allRelays` which combines `activeRequests.desiredRelays + activeCounts.relays + eventOutbox.relays`. These only populate when `openReqSubscription()` is called — which requires the coordinator. Circular dependency. `connectedRelays` fires when WebSocket connects, independent of subscriptions. + +Guard all usages with `?.`: +- `subscriptionsCoordinator?.clear()` in onDispose +- `subscriptionsCoordinator?.subscribeToDms(...)` — use `LaunchedEffect(subscriptionsCoordinator)` that fires when coordinator becomes available +- Remove existing `subscriptionsCoordinator.start()` call (handled in LaunchedEffect) + +### Phase 3: Add `since` Filter + +#### Step 8: Add `since` to NostrSignerRemote subscription + +**File:** `quartz/src/commonMain/.../nip46RemoteSigner/signer/NostrSignerRemote.kt` + +```kotlin +// BEFORE (line 69-87) +val subscription = client.req( + relays = relays.toList(), + filter = Filter( + kinds = listOf(NostrConnectEvent.KIND), + tags = mapOf("p" to listOf(signer.pubKey)), + ), +) { event -> ... } + +// AFTER +val subscription = client.req( + relays = relays.toList(), + filter = Filter( + kinds = listOf(NostrConnectEvent.KIND), + tags = mapOf("p" to listOf(signer.pubKey)), + since = TimeUtils.now() - 60, // 60s buffer for clock skew + ), +) { event -> ... } +``` + +> **Clock skew buffer (from Nostr protocol review):** Using `TimeUtils.now()` exactly risks missing responses if there's any clock difference between client and relay, or if the signer response `created_at` is slightly in the past. A 60s buffer is safe — request ID matching in `RemoteSignerManager.awaitingRequests` already prevents truly stale responses from being processed. The `since` filter just reduces relay-side work. + +> **Tradeoff:** Responses older than 60s before subscription are filtered. Acceptable — `RemoteSignerManager` has 30s timeout, so responses older than 30s are useless anyway. + +## Files Changed + +| File | Change | Phase | +|------|--------|-------| +| `desktopApp/.../account/AccountManager.kt` | Add dedicated `NostrClient` with `Mutex`, remove `client` params from login methods, cleanup on logout | 1 | +| `desktopApp/.../Main.kt` | Remove `relayManager.client` from AccountManager calls, defer coordinator start until `connectedRelays` non-empty | 1, 2 | +| `desktopApp/.../ui/LoginScreen.kt` | Remove `relayClient` param | 1 | +| `desktopApp/.../ui/auth/LoginCard.kt` | Remove `relayClient` param, use AccountManager directly | 1 | +| `desktopApp/.../network/RelayConnectionManager.kt` | Remove NIP-46 `nsec.app` debug logging from all callbacks | 1 | +| `quartz/.../nip46RemoteSigner/signer/NostrSignerRemote.kt` | Add `since = TimeUtils.now() - 60` to subscription filter | 3 | + +## Acceptance Criteria + +- [ ] After nostrconnect login, home feed shows followed users' notes with avatars and display names +- [ ] After nostrconnect login, DMs load (NIP-17 decryption works via remote signer) +- [ ] On app restart with saved bunker account, session restores without ClosedMessage loop +- [ ] `relay.nsec.app` does NOT appear in general relay pool (only in dedicated NIP-46 client) +- [ ] Posting a note via remote signer succeeds — event signed and broadcast to general relays +- [ ] Heartbeat ping works after session restore +- [ ] Logout disconnects the dedicated NIP-46 client (no leaked WebSocket) +- [ ] No `[NIP-46]` ClosedMessage spam in logs +- [ ] No deadlock on startup — coordinator starts after relays connect + +## Edge Cases + +| Edge Case | Handling | +|-----------|----------| +| Dedicated NIP-46 client can't connect to relay.nsec.app | `loadSavedAccount()` returns failure, falls back to login screen | +| Ephemeral key lost from secure storage | `loadBunkerAccount()` returns `Result.failure("Ephemeral key not found")`, logout + login screen | +| In-flight sign request during brief NIP-46 disconnect | 30s timeout in `RemoteSignerManager`, user retries | +| User logs out with pending sign request | `closeSubscription()` cancels scope, `disconnectNip46Client()` cleans up | +| General relay pool emits before NIP-46 client ready | Independent — general pool handles feeds, NIP-46 client handles signing | +| Concurrent `getOrCreateNip46Client()` calls | `Mutex` ensures single creation | +| `connectedRelays` empty for extended period | Coordinator start is deferred, UI shows loading state until relays connect | +| Clock skew between client and signer relay | 60s `since` buffer absorbs typical skew | + +## Testing Strategy + +1. **Manual: Fresh nostrconnect login** — scan QR with Amber, verify home feed + profile + DMs load +2. **Manual: Session restore** — close and reopen app, verify auto-login without ClosedMessage loop +3. **Manual: Post a note** — compose + send, verify event reaches Amber and gets signed +4. **Manual: Logout + re-login** — verify no leaked connections, clean state +5. **Verify relay isolation** — check logs: `relay.nsec.app` should only appear in NIP-46 context, never in general relay status +6. **Manual: Startup timing** — verify coordinator starts after relays connect (no deadlock) +7. **Manual: Rapid login/logout** — verify `Mutex` prevents double NIP-46 client creation + +## Resolved Questions + +| # | Question | Resolution | +|---|----------|------------| +| 1 | `auth_url` handling? | **Deferred.** Spec defines it, Amber doesn't use it, quartz doesn't handle it. Only nsec.app needs it. See improvement ticket. | +| 2 | `get_public_key` after connect? | **Deferred.** Already fully implemented in quartz (`NostrSignerRemote.getPublicKey()`). But `connect()` already returns pubkey — redundant for correctness. Nice-to-have security hardening. See improvement ticket. | +| 3 | Update `since` on reconnect? | **Deferred.** `NostrClientStaticReq` re-sends exact same filter on reconnect. `FiltersChanged` intentionally ignores `since` changes. Static `since` with 60s buffer sufficient — request ID matching prevents stale response processing. See improvement ticket. | +| 4 | `indexRelays` mutable? | **Resolved: `val` (immutable).** No `updateIndexRelays()` method. Must use nullable coordinator pattern — create inside `LaunchedEffect`. Phase 2 updated. | +| 5 | `NostrClient.close()` for scope? | **Partially addressed.** `disconnect()` leaks two `stateIn(Eagerly)` flows + SupervisorJob scope. `disconnectNip46Client()` handles cleanup for our dedicated client. Upstream `close()` method deferred. See improvement ticket. | + +**Deferred items tracked in:** `docs/plans/2026-03-05-nip46-improvements-plan.md` + +## Sources + +- **NIP-46 spec:** https://github.com/nostr-protocol/nips/blob/master/46.md +- **NDK NIP-46 impl:** `core/src/signers/nip46/rpc.ts` — dedicated `NDKPool` for RPC +- **Snort NIP-46 impl:** `packages/system/src/impl/nip46.ts` — dedicated `Connection` object +- **nostrudel NIP-46 impl:** `applesauce-signers` — separate publish/subscribe methods +- **Existing plan:** `docs/plans/2026-03-05-nip46-bunker-login-deepened.md` — covers initial implementation +- **greenart7c3 commits:** `df1bf82e7..a6f3e09a6` — original quartz NIP-46 skeleton (never used by Android) diff --git a/docs/plans/2026-03-05-nip46-bunker-login-deepened.md b/docs/plans/2026-03-05-nip46-bunker-login-deepened.md new file mode 100644 index 000000000..97cda5093 --- /dev/null +++ b/docs/plans/2026-03-05-nip46-bunker-login-deepened.md @@ -0,0 +1,876 @@ +# NIP-46 Remote Signer (bunker:// + nostrconnect://) Login for Desktop — Deepened Plan + +## Enhancement Summary + +**Deepened on:** 2026-03-05 (2 rounds) +**Sections enhanced:** 10 (Steps 0-8 + security) +**Research agents used:** quartz NIP-46 audit, desktop account/main audit, NIP-46 spec research, coroutines heartbeat patterns, nostrconnect + QR + animation research, quartz nostrconnect gap audit, commons gap audit + +### Key Improvements from Research +1. **`switch_relays` support** — NIP-46 spec says "compliant clients should send `switch_relays` immediately upon establishing a connection". Plan now includes this. +2. **Auth URL flow** — spec allows remote signer to return `auth_url` for user confirmation. Plan now handles this edge case. +3. **Secret validation** — spec says "client MUST validate the secret returned by connect response". Plan updated with explicit validation. +4. **No re-connect on restart** — confirmed: saved ephemeral key is already trusted by remote signer, skip `connect()` on restart. +5. **PoW relay concern** — some relays (nostr.mom, nos.lol) require Proof of Work for kind 24133. Plan notes this. +6. **`decryptZapEvent()` / `deriveKey()` NOT implemented** in quartz NostrSignerRemote — these will throw. Document as known limitation. +7. **Step 0 blocking tasks** — 8 prerequisite tasks (5 quartz, 3 commons) identified. bunker:// needs zero quartz changes; nostrconnect needs 4 blockers resolved. +8. **QrCodeDrawer portable** — existing Android composable uses zero Android APIs, 90% extract-as-is to commons. +9. **SecureKeyStorage + IAccount** — both 100% compatible with NostrSignerRemote, verified by audit. +10. **Phased implementation** — bunker:// first (no shared lib changes), then quartz+commons prereqs, then nostrconnect+heartbeat. + +### New Considerations Discovered +- `NostrSigner.pubKey` = ephemeral key in NostrSignerRemote. DesktopIAccount uses `accountState.pubKeyHex` (user's real key) — **already correct**, no collision. +- `RemoteSignerManager.timeout` is configurable via constructor (default 30s) — use 30s for connect, could use shorter for ping. +- Bunker URI `secret=` is **optional** for remote-signer-initiated flow (our case). Plan should NOT require it. +- Some users may paste bunker URI over insecure channels — the URI is essentially a session token. Document this in security notes. + +--- + +## Context + +Desktop app only supports nsec/npub login. Quartz has a **complete, verified** NIP-46 client (`NostrSignerRemote`) that's never been wired into any app. Adding bunker:// login gives desktop the best security story — private key never touches the machine. + +### Quartz NIP-46 Completeness Audit (Verified) + +| Feature | Status | Location | +|---------|--------|----------| +| `connect()` handshake | ✅ | `NostrSignerRemote.connect()` | +| `sign_event` | ✅ | `NostrSignerRemote.sign()` | +| `ping` | ✅ | `NostrSignerRemote.ping()` | +| `get_public_key` | ✅ | `NostrSignerRemote.getPublicKey()` | +| NIP-04 encrypt/decrypt | ✅ | `nip04Encrypt()` / `nip04Decrypt()` | +| NIP-44 encrypt/decrypt | ✅ | `nip44Encrypt()` / `nip44Decrypt()` | +| `switch_relays` | ✅ | `BunkerRequestGetRelays` | +| Bunker URI parsing | ✅ | `fromBunkerUri()` — handles relay + secret params | +| Timeout handling | ✅ | 30s default, configurable in `RemoteSignerManager` | +| Subscription mgmt | ✅ | `openSubscription()` / `closeSubscription()` | +| `decryptZapEvent()` | ❌ TODO | Will throw — known limitation | +| `deriveKey()` | ❌ TODO | Will throw — known limitation | + +### Desktop Account System Audit + +| Component | Current State | Action | +|-----------|--------------|--------| +| `AccountState` sealed class | `LoggedOut` / `LoggedIn` | Add `ConnectingRelays` variant | +| `AccountManager.loginWithKey()` | Handles nsec/npub/hex | Add `loginWithBunker()` | +| `SecureKeyStorage` | OS keyring + encrypted fallback | Reuse for ephemeral key | +| `~/.amethyst/last_account.txt` | Stores npub | Reuse as-is | +| `Main.kt` DisposableEffect | Loads account + starts relays | Add relay-wait + heartbeat | +| `LoginCard` | Text input + Login/Generate buttons | Add bunker detection + connecting state | +| `RelayConnectionManager.client` | Exposes `INostrClient` | Pass to `loginWithBunker()` | + +## Protocol Flow + +``` +User pastes bunker URI → validate format → generate ephemeral keypair +→ create NostrSignerRemote via fromBunkerUri() → call connect() over relays +→ remote signer (nsec.app/Amber) approves → validate secret if present +→ receive user pubkey → call switch_relays → update relay set if changed +→ AccountState.LoggedIn with NostrSignerRemote as signer +→ all sign/encrypt ops proxy transparently through remote signer +→ periodic ping heartbeat monitors connection health +``` + +### Research Insight: NIP-46 Spec Compliance +- **Secret validation**: If bunker URI contains `secret=`, client MUST validate the secret returned by `connect` response matches. If no secret in URI, skip validation. +- **`switch_relays`**: "Compliant clients should send a `switch_relays` request immediately upon establishing a connection." Remote signer replies with updated relay list or null. +- **Auth URL**: Remote signer may respond with `{"result": "auth_url", "error": ""}` — client should display URL to user for confirmation (e.g., nsec.app web confirmation). + +## Step 0: Prerequisite Tasks (Blocking) + +These must be completed before or alongside the desktop feature work. They strengthen quartz and commons as shared libraries. + +### 0A. [QUARTZ/BLOCKER] Add `fromNostrConnectUri()` factory to NostrSignerRemote + +**File:** `quartz/src/commonMain/.../nip46RemoteSigner/signer/NostrSignerRemote.kt` + +**Problem:** `fromBunkerUri()` exists but no equivalent for nostrconnect://. Client-initiated flow needs the client to generate the URI, not parse one from a remote signer. + +**Action:** Add companion factory that builds a `NostrSignerRemote` ready to *wait* for a connect response: +```kotlin +companion object { + fun forNostrConnect( + ephemeralSigner: NostrSignerInternal, + relays: Set, + client: INostrClient, + secret: String, + ): Pair { + // remotePubkey unknown until signer responds — see 0B + // Build URI: nostrconnect://?relay=...&secret=...&name=Amethyst+Desktop + // Return (signer, uriString) + } +} +``` + +**Blocks:** Step 7 (nostrconnect login) + +--- + +### 0B. [QUARTZ/BLOCKER] Make `remotePubkey` mutable or support deferred assignment + +**File:** `quartz/src/commonMain/.../nip46RemoteSigner/signer/NostrSignerRemote.kt` + +**Problem:** `val remotePubkey: HexKey` is immutable in constructor. For nostrconnect://, the remote signer's pubkey is unknown until the connect response arrives. Cannot instantiate `NostrSignerRemote` without it. + +**Options:** +1. **`var remotePubkey`** — simplest, set after connect response. Risk: state mutation on a shared object. +2. **Two-phase construction** — `NostrConnectSession` awaits connect, then creates `NostrSignerRemote` with known pubkey. Cleaner but more code. +3. **DesktopApp workaround** — state machine in desktopApp: subscribe to kind 24133 manually, wait for connect response, extract pubkey, *then* construct `NostrSignerRemote`. Avoids quartz changes but duplicates logic. + +**Recommended:** Option 2 — keep `NostrSignerRemote` immutable, add a `NostrConnectSession` helper that handles the wait-then-construct flow. + +**Blocks:** Step 7 (nostrconnect login), 0A, 0C + +--- + +### 0C. [QUARTZ/BLOCKER] NostrConnectEvent.create() requires remotePubkey + +**File:** `quartz/src/commonMain/.../nip46RemoteSigner/NostrConnectEvent.kt` + +**Problem:** `NostrConnectEvent.create()` calls `nip44Encrypt(content, remoteKey)`. For nostrconnect, the initial subscription doesn't need to *send* an event — client just listens. But `openSubscription()` in `NostrSignerRemote` sends a subscription filter, which doesn't require remotePubkey. The encryption issue only matters if client needs to send requests before learning remotePubkey. + +**Resolution:** This is resolved by 0B — if we use two-phase construction, `NostrSignerRemote` is only created after remotePubkey is known, so `create()` always has it. No changes to `NostrConnectEvent.kt` needed. + +**Blocks:** Step 7 (nostrconnect login). Resolved by 0B. + +--- + +### 0D. [QUARTZ/SECURITY] Add secret validation to PubKeyResponse + +**File:** `quartz/src/commonMain/.../nip46RemoteSigner/signer/PubKeyResponse.kt` + +**Problem:** NIP-46 spec says "client MUST validate the secret returned by connect response matches the one sent". Current `PubKeyResponse` parses the pubkey from the result field but does NOT validate the secret. + +**Action:** Add `secret` field to `PubKeyResponse` and validate in `connect()`: +```kotlin +data class PubKeyResponse(val pubkey: HexKey, val secret: String?) { + companion object { + fun parse(result: String): PubKeyResponse { + // result may be just pubkey, or pubkey + secret separated by space + // validate hex format + } + } +} +``` + +Then in `NostrSignerRemote.connect()`, compare returned secret with expected: +```kotlin +if (expectedSecret != null && response.secret != expectedSecret) { + throw SignerExceptions.ManuallyUnauthorizedException("Secret mismatch — possible MITM") +} +``` + +**Priority:** Security hardening. Not strictly blocking for bunker:// (secret is optional), but **blocking for nostrconnect://** (secret is required). + +**Blocks:** Step 2 (bunker login — optional validation), Step 7 (nostrconnect — required validation) + +--- + +### 0E. [QUARTZ/NICE-TO-HAVE] Add `getRelays()` method to NostrSignerRemote + +**File:** `quartz/src/commonMain/.../nip46RemoteSigner/signer/NostrSignerRemote.kt` + +**Problem:** `BunkerRequestGetRelays` exists but `NostrSignerRemote` has no `getRelays()` or `switchRelays()` high-level method. The plan calls for `switch_relays` compliance per NIP-46 spec. + +**Action:** Add method: +```kotlin +suspend fun switchRelays(): List? { + // Send BunkerRequestGetRelays, parse relay list response + // Return updated relay list or null if signer doesn't support it +} +``` + +**Priority:** Nice-to-have. Can defer to a follow-up PR if needed. The `switch_relays` spec language is "compliant clients *should*" (not MUST). + +**Does NOT block:** Any step. Can be added later. + +--- + +### 0F. [COMMONS/BLOCKER] Extract QrCodeDrawer to commons + +**Files:** +- **Source:** `amethyst/src/main/java/.../ui/screen/loggedIn/qrcode/QrCodeDrawer.kt` +- **Target:** `commons/src/commonMain/.../commons/ui/qrcode/QrCodeDrawer.kt` + +**Problem:** QR code display needed for nostrconnect:// tab. Existing `QrCodeDrawer` in amethyst is pure Compose Canvas + ZXing core — **zero Android dependencies**. 90% extract-as-is. + +**Gap:** References `QuoteBorder` from amethyst theme (just `RoundedCornerShape(15.dp)`). Replace with inline shape. + +**Action:** +1. Copy `QrCodeDrawer.kt` to `commons/src/commonMain/.../commons/ui/qrcode/` +2. Replace `QuoteBorder` reference with `RoundedCornerShape(15.dp)` +3. Add typealias in amethyst to maintain backward compat +4. Add `libs.zxing` dependency to `commons/build.gradle.kts` (see 0G) + +**Blocks:** Step 7 (nostrconnect QR display) + +--- + +### 0G. [COMMONS/BLOCKER] Add ZXing core dependency to commons module + +**File:** `commons/build.gradle.kts` + +**Problem:** ZXing core 3.5.4 is in `gradle/libs.versions.toml` but NOT imported in `commons/build.gradle.kts`. QrCodeDrawer needs it. + +**Action:** Add to `commonMain` dependencies (ZXing core is pure Java, works on JVM + Android): +```kotlin +commonMain.dependencies { + implementation(libs.zxing) // already in version catalog +} +``` + +**Note:** `zxing-embedded` (Android camera scanner wrapper) stays in amethyst only. Only `zxing:core` (encoder/decoder) goes to commons. + +**Blocks:** 0F (QrCodeDrawer extraction), Step 7 + +--- + +### 0H. [COMMONS/NEW] Create HeartbeatIndicator composable + +**File:** `commons/src/commonMain/.../commons/ui/components/HeartbeatIndicator.kt` (new) + +**Problem:** No animated pulsing dot composable exists in commons. Needed for sidebar signer connection health indicator. + +**Action:** Create shared composable: +- Input: `SignerConnectionState` sealed class (Connected/Unstable/Disconnected/NotRemote) +- Output: Pulsing dot (green/yellow/red) with `rememberInfiniteTransition` double-beat animation +- Desktop: wrapped in `TooltipArea` in DeckSidebar (desktop-only API) +- The composable itself is multiplatform; tooltip wrapping is platform-specific + +**Dependencies:** None — pure Compose animation APIs. + +**Blocks:** Step 8 (heartbeat UI) + +--- + +### Step 0 Summary + +| ID | Module | Type | Priority | Blocks | +|----|--------|------|----------|--------| +| 0A | quartz | `fromNostrConnectUri()` factory | 🔴 Blocker | Step 7 | +| 0B | quartz | Mutable/deferred `remotePubkey` | 🔴 Blocker | Step 7, 0A, 0C | +| 0C | quartz | NostrConnectEvent encryption | 🟢 Resolved by 0B | Step 7 | +| 0D | quartz | Secret validation in PubKeyResponse | 🟡 Security | Step 7 (required), Step 2 (optional) | +| 0E | quartz | `switchRelays()` method | ⚪ Nice-to-have | None | +| 0F | commons | Extract QrCodeDrawer | 🔴 Blocker | Step 7 | +| 0G | commons | ZXing dep in commons gradle | 🔴 Blocker | 0F, Step 7 | +| 0H | commons | HeartbeatIndicator composable | 🟡 Medium | Step 8 | + +**Critical path for bunker:// (Steps 1-6):** 0D (optional hardening) +**Critical path for nostrconnect:// (Step 7):** 0B → 0A → 0D, 0G → 0F +**Critical path for heartbeat UI (Step 8):** 0H + +### Implementation Order + +``` +Phase 1 (bunker:// — no quartz changes needed): + Steps 1 → 2 → 3 → 4 → 5 → 6 + +Phase 2 (quartz + commons prereqs): + 0G → 0F (commons: ZXing + QrCodeDrawer) + 0B → 0A (quartz: deferred remotePubkey + nostrconnect factory) + 0D (quartz: secret validation) + 0H (commons: HeartbeatIndicator) + +Phase 3 (nostrconnect + heartbeat): + Step 7 (depends on 0A, 0B, 0D, 0F, 0G) + Step 8 (depends on 0H) +``` + +**Key insight:** bunker:// login (Steps 1-6) requires ZERO quartz/commons changes — all quartz NIP-46 code works as-is. Ship bunker:// first, then layer nostrconnect + heartbeat. + +--- + +## Implementation + +### Step 1: AccountState + SignerType + +**File:** `desktopApp/src/jvmMain/.../desktop/account/AccountManager.kt` + +Add `SignerType` sealed class: +```kotlin +sealed class SignerType { + data object Internal : SignerType() + data class Remote(val bunkerUri: String) : SignerType() +} +``` + +Extend `AccountState`: +```kotlin +sealed class AccountState { + data object LoggedOut : AccountState() + data object ConnectingRelays : AccountState() + data class LoggedIn( + val signer: NostrSigner, + val pubKeyHex: String, + val npub: String, + val nsec: String?, + val isReadOnly: Boolean, + val signerType: SignerType = SignerType.Internal, + ) : AccountState() +} +``` + +#### Research Insights + +**Existing pattern confirmed:** AccountState is observed via `accountManager.accountState.collectAsState()` in `App()` composable. Adding `ConnectingRelays` variant just needs a new `when` branch — zero refactoring needed. + +**DesktopIAccount compatibility:** Uses `accountState.pubKeyHex` (not `signer.pubKey`), so `NostrSignerRemote` (where `signer.pubKey` = ephemeral key) works transparently. Verified in audit. + +### Step 2: AccountManager — bunker login + persistence + heartbeat + +**File:** `desktopApp/src/jvmMain/.../desktop/account/AccountManager.kt` + +#### `loginWithBunker(bunkerUri: String, client: INostrClient): Result` + +1. Validate URI format: starts with `bunker://`, hex pubkey (64 chars), `?relay=wss://` param +2. Generate ephemeral `KeyPair()` + wrap in `NostrSignerInternal` +3. `NostrSignerRemote.fromBunkerUri(bunkerUri, ephemeralSigner, client)` + - Factory handles relay extraction + secret parsing +4. Call `remoteSigner.openSubscription()` — starts listening for responses +5. Call `remoteSigner.connect()` — sends BunkerRequestConnect, waits 30s +6. **Validate secret** if present: connect response must return matching secret value +7. On success: call `remoteSigner.switchRelays()` (new — NIP-46 compliance) +8. Set `AccountState.LoggedIn(signer=remoteSigner, pubKeyHex=remotePubkey, signerType=Remote(bunkerUri))` + +#### Research Insights: Connect Flow Details + +**From quartz audit — `connect()` internals:** +``` +RemoteSignerManager.launchWaitAndParse(): + 1. Build BunkerRequestConnect(remoteKey, secret?, permissions?) + 2. Create NostrConnectEvent (NIP-44 encrypted to remote signer) + 3. client.send(event, relayList) + 4. Store continuation[requestID] in LargeCache + 5. tryAndWait(30s) — suspendCancellableCoroutine + 6. On response: newResponse() → resume continuation + 7. Parse via PubKeyResponse → SignerResult +``` + +**Exception mapping in NostrSignerRemote.convertExceptions():** +- `Successful` → return result +- `Rejected` → throw `ManuallyUnauthorizedException` +- `TimedOut` → throw `TimedOutException` +- `ReceivedButCouldNotPerform` → throw `CouldNotPerformException` + +**Auth URL edge case:** If remote signer needs web confirmation (nsec.app pattern), it returns `auth_url` response. Quartz doesn't handle this natively — **document as known limitation for v1**. Most bunker signers (Amber, local nsecBunker) don't use auth_url. + +#### `saveBunkerAccount(ephemeralPrivKeyHex: String)` + +Persistence files: +- `~/.amethyst/bunker_uri.txt` — full bunker URI (contains relay URLs + remote pubkey) +- `SecureKeyStorage("bunker_ephemeral")` — ephemeral private key (OS keyring) +- `~/.amethyst/last_account.txt` — user's npub (existing pattern) + +#### Research Insight: Session Persistence + +**Confirmed from NIP-46 community patterns:** +> "Once a connection has been successfully established and the BunkerPointer is stored, you do not need to call `connect()` on subsequent sessions." + +Save: ephemeral privkey + bunker URI + user npub. On restart, recreate `NostrSignerRemote` with saved ephemeral key — remote signer already trusts this keypair. + +**What NOT to save:** Don't store the secret separately — it's only needed for initial connect handshake. The ephemeral keypair IS the session credential. + +#### `loadSavedAccount(client: INostrClient): Result` + +Updated flow: +1. Check `bunker_uri.txt` — if exists, this is a bunker account +2. Load ephemeral privkey from `SecureKeyStorage("bunker_ephemeral")` +3. Load npub from `last_account.txt` → derive pubKeyHex +4. Create `NostrSignerInternal(KeyPair(ephemeralPrivKey))` +5. `NostrSignerRemote.fromBunkerUri(savedUri, ephemeralSigner, client)` +6. `remoteSigner.openSubscription()` — start listening (no connect() needed) +7. Return `LoggedIn(signer=remoteSigner, pubKeyHex=savedPubKeyHex, signerType=Remote)` +8. Fall back to existing internal key flow if no bunker file + +#### `logout(deleteKey: Boolean)` + +Bunker-specific cleanup: +1. `remoteSigner.closeSubscription()` — stop relay filter +2. Stop heartbeat job +3. Delete `bunker_uri.txt` +4. Delete `SecureKeyStorage("bunker_ephemeral")` +5. Clear `last_account.txt` +6. Set `AccountState.LoggedOut` + +#### Ping Heartbeat + +```kotlin +private var heartbeatJob: Job? = null +private var consecutiveFailures = 0 + +fun startHeartbeat(scope: CoroutineScope) { + heartbeatJob = scope.launch { + while (isActive) { + delay(60_000) + val current = currentAccount() ?: continue + val remoteSigner = current.signer as? NostrSignerRemote ?: continue + try { + remoteSigner.ping() + consecutiveFailures = 0 // reset on success + } catch (e: SignerExceptions.TimedOutException) { + consecutiveFailures++ + if (consecutiveFailures >= 3) { + forceLogoutWithReason("Lost connection to remote signer after 3 failed pings.") + } + } catch (e: SignerExceptions.ManuallyUnauthorizedException) { + forceLogoutWithReason("Remote signer revoked access.") + } + } + } +} +``` + +#### Research Insights: Heartbeat Patterns + +**Structured concurrency:** Heartbeat should be a child of the app's `CoroutineScope(SupervisorJob() + Dispatchers.Main)` already created in `App()`. SupervisorJob ensures heartbeat failure doesn't crash the app. + +**Existing pattern in codebase:** `RelayStat.pingInMs` tracks relay ping latency — similar concept. RelayConnectionManager handles relay disconnection with reconnect logic, but no heartbeat loop exists yet. + +**Simple counter vs AtomicInteger:** Since heartbeat runs in a single coroutine (sequential `while` loop), a plain `var consecutiveFailures: Int` is safe — no concurrent access. + +**Window minimization:** Desktop heartbeat should continue even when minimized — the remote signer session is independent of window focus. The `CoroutineScope` created with `remember` persists across recompositions. + +**Force logout state:** Use `MutableStateFlow` for `forceLogoutReason` — it's a stateful value that the UI observes, not a one-shot event. Dialog displays the reason, user dismisses, flow cleared. + +### Step 3: LoginCard — validation + connecting state + +**File:** `desktopApp/src/jvmMain/.../desktop/ui/auth/LoginCard.kt` + +Add `LoginState` enum: `IDLE`, `CONNECTING`, `ERROR` + +Add callback: `onLoginBunker: suspend (String) -> Result` + +**Bunker URI validation** (before attempting connect): +- Starts with `bunker://` +- Contains hex pubkey (64 hex chars after `bunker://`, before `?`) +- Contains at least one `relay=wss://...` param +- Show inline validation error if format wrong, don't attempt connection +- `secret=` is optional — do NOT require it + +**UX when `bunker://` detected:** +- Button text: "Login" → "Connect to Signer" +- On click with valid URI: set `CONNECTING`, show `CircularProgressIndicator` + "Connecting to remote signer..." +- Disable input + all buttons while `CONNECTING` +- On success: caller handles transition +- On failure: show error message, return to `IDLE`, user can retry + +#### Research Insights: UX Patterns + +**From NIP-46 community feedback:** +> "Connection establishment takes a while to show to users" — always show clear progress indication. + +**Pitfall discovered:** Users may not understand that bunker URI is a session credential: +> "Users might think the entire bunker URI is like a 2FA token and transmit it over untrusted messengers, and attackers can reuse the same URI" + +Consider: add subtle help text below input when bunker:// detected: "This URI connects to your remote signer. Treat it like a password." + +**Auto-detection approach:** Current `LoginCard` has a single `KeyInputField`. Detect `bunker://` prefix on input change — no new tabs/screens needed. Same pattern as NWC connection string input in settings. + +### Step 4: LoginScreen + ConnectingRelays screen + +**File:** `desktopApp/src/jvmMain/.../desktop/ui/LoginScreen.kt` + +Add parameter: `relayClient: INostrClient` + +Wire `onLoginBunker` callback → `accountManager.loginWithBunker(bunkerUri, relayClient)` → on success: save + `onLoginSuccess()` + +**ConnectingRelaysScreen composable** (in same file): + +Shown when `AccountState.ConnectingRelays`: +``` +[Amethyst title text] +"Connecting to relays..." +[CircularProgressIndicator] +"Restoring remote signer session" +``` + +Used on app restart when a bunker account is saved — shows while relays connect before recreating `NostrSignerRemote`. + +#### Research Insight: Startup Sequence + +**From Main.kt audit — current startup:** +```kotlin +DisposableEffect(Unit) { + scope.launch(Dispatchers.IO) { accountManager.loadSavedAccount() } + relayManager.addDefaultRelays() + relayManager.connect() + subscriptionsCoordinator.start() + onDispose { ... } +} +``` + +**Problem:** `loadSavedAccount()` for bunker accounts needs `relayManager.client` — but relay connections happen asynchronously. Current code loads account independently of relay state. + +**Solution:** For bunker accounts, wait for relay connection before recreating signer. The pattern `relayManager.connectedRelays.first { it.isNotEmpty() }` already exists in `MainContent` LaunchedEffect for DM subscriptions. + +### Step 5: Main.kt — relay state, pass client, heartbeat + +**File:** `desktopApp/src/jvmMain/.../desktop/Main.kt` + +**Updated startup flow:** +```kotlin +DisposableEffect(Unit) { + relayManager.addDefaultRelays() + relayManager.connect() + subscriptionsCoordinator.start() + + scope.launch(Dispatchers.IO) { + if (accountManager.hasBunkerAccount()) { + accountManager.setConnectingRelays() + // Wait for relay connections — pattern from MainContent DM setup + relayManager.connectedRelays.first { it.isNotEmpty() } + accountManager.loadSavedAccount(relayManager.client) + accountManager.startHeartbeat(scope) + } else { + accountManager.loadSavedAccount(relayManager.client) + } + } + + onDispose { + accountManager.stopHeartbeat() + subscriptionsCoordinator.clear() + relayManager.disconnect() + } +} +``` + +**Account state routing — add ConnectingRelays + ForceLogout:** +```kotlin +when (accountState) { + is AccountState.LoggedOut -> LoginScreen(accountManager, relayManager.client, onLoginSuccess) + is AccountState.ConnectingRelays -> ConnectingRelaysScreen() + is AccountState.LoggedIn -> MainContent(...) +} + +// Force logout overlay +val forceLogoutReason by accountManager.forceLogoutReason.collectAsState() +forceLogoutReason?.let { reason -> + ForceLogoutDialog(reason = reason, onDismiss = { accountManager.clearForceLogoutReason() }) +} +``` + +#### Research Insight: Scope Lifecycle + +**Current app scope:** `remember { CoroutineScope(SupervisorJob() + Dispatchers.Main) }` — created in `App()` composable, lives for the duration of the window. Heartbeat as a child of this scope is correct — it auto-cancels when app closes. + +**onDispose cleanup:** Add `accountManager.stopHeartbeat()` to the existing `onDispose` block alongside `subscriptionsCoordinator.clear()` and `relayManager.disconnect()`. + +### Step 6: ForceLogoutDialog + +**File:** `desktopApp/src/jvmMain/.../desktop/ui/auth/ForceLogoutDialog.kt` (new) + +Simple `AlertDialog`: +- Title: "Session Terminated" +- Body: reason string +- Single "OK" button → dismiss → login screen already showing (state is LoggedOut) + +## Error Handling + +| Exception | Source | User Message | +|-----------|--------|-------------| +| URI validation fail | LoginCard | "Invalid bunker URI. Expected: bunker://\?relay=wss://..." | +| `TimedOutException` | `connect()` | "Connection timed out. Ensure remote signer is online and has approved the connection." | +| `ManuallyUnauthorizedException` | `connect()` | "Connection rejected by remote signer." | +| `CouldNotPerformException` | `connect()` | "Remote signer error: {message}" | +| Generic exception | `connect()` | "Connection failed: {message}" | +| Heartbeat 3x timeout | `ping()` | Force logout: "Lost connection to remote signer after 3 failed pings." | +| Heartbeat revocation | `ping()` | Force logout: "Remote signer revoked access." | +| `decryptZapEvent` | Runtime | Known limitation — zap decryption not supported with remote signer | + +### Research Insight: Relay-Specific Issues + +**PoW requirement:** Some relays (nostr.mom, nos.lol, damus) rate-limit or require Proof of Work for kind 24133 events. If users specify these relays in their bunker URI, connections may fail. **Mitigation:** Document in error message. No code change needed — the relay list comes from the bunker URI (remote signer's choice). + +## Files Modified + +| File | Changes | +|------|---------| +| `AccountManager.kt` | SignerType, ConnectingRelays state, loginWithBunker(), saveBunkerAccount(), loadSavedAccount() updated, logout() updated, heartbeat, forceLogout | +| `LoginCard.kt` | LoginState enum, bunker URI validation, onLoginBunker callback, connecting UI, help text | +| `LoginScreen.kt` | Add relayClient param, wire bunker login, ConnectingRelaysScreen composable | +| `Main.kt` | Startup relay-wait flow, pass relayManager.client, heartbeat lifecycle, forceLogout dialog | +| `ForceLogoutDialog.kt` | **New** — AlertDialog for session termination | + +## Reused As-Is (no changes needed — verified) + +| File | What | Verified | +|------|------|----------| +| `NostrSignerRemote.kt` | Full NIP-46 client: connect, sign, encrypt, decrypt, ping | ✅ All methods present | +| `RemoteSignerManager.kt` | Request/response lifecycle with timeout | ✅ 30s configurable | +| `NostrConnectEvent.kt` | Kind 24133 wrapper with NIP-44 encryption | ✅ | +| `SignerExceptions.kt` | TimedOut, ManuallyUnauthorized, CouldNotPerform | ✅ | +| `DesktopIAccount.kt` | Uses `accountState.pubKeyHex` not `signer.pubKey` | ✅ Safe with remote signer | +| `RelayConnectionManager.kt` | Exposes `client: INostrClient` | ✅ | +| `SecureKeyStorage.kt` | OS keyring + encrypted fallback | ✅ Reuse for ephemeral key | + +## Security Considerations + +### Ephemeral Key = Session Credential +- Stored in OS keyring (macOS Keychain / Windows Credential Manager) via existing `SecureKeyStorage` +- If compromised: attacker can impersonate user's signing requests until remote signer revokes +- **Mitigation:** Remote signer can revoke anytime; heartbeat detects revocation + +### Bunker URI as Session Token +- URI contains remote pubkey + relay URLs — NOT the user's private key +- But it enables initial connection establishment +- Once connected, the ephemeral keypair is the actual session credential +- **Secret parameter** (when present): only used once during initial connect. Not stored separately. + +### Relay MITM +- NIP-46 messages are NIP-44 encrypted end-to-end between ephemeral key and remote signer +- Relay can see metadata (who's talking to whom) but NOT message content +- **No MITM on message content** — encryption is between known pubkeys + +### Force Logout on Revocation +- `ManuallyUnauthorizedException` from ping → immediate logout + file cleanup +- 3 consecutive ping timeouts → logout (remote signer may be permanently offline) +- All bunker files deleted on force logout — clean slate + +## Known Limitations (v1) + +1. **Auth URL flow** — nsec.app may return `auth_url` response requiring web confirmation. Not handled in v1. Most signers (Amber, local bunkers) don't use this. +2. **`decryptZapEvent()`** — Not implemented in quartz's `NostrSignerRemote`. Zap decryption will fail for bunker accounts. +3. **`deriveKey()`** — Not implemented. NIP-06 key derivation won't work for bunker accounts. +4. **Single account** — Only one bunker OR one internal account at a time. Multi-account is a separate PR. + +## Verification + +1. `./gradlew :desktopApp:compileKotlin` — builds +2. `./gradlew :desktopApp:run` — test flows: + - **Invalid URI** → paste `bunker://bad` → inline validation error, no network call + - **Valid format, missing relay** → paste `bunker://<64hex>` → validation error about missing relay + - **Login** → paste valid bunker URI → "Connecting..." → approve on phone → main screen + - **Timeout** → paste URI, don't approve → 30s → timeout error, can retry + - **Rejection** → paste URI, reject on phone → "rejected" error + - **Restart** → close app → relaunch → "Connecting to relays..." → auto-login (no re-approval needed) + - **Heartbeat** → revoke access on remote signer → force logout dialog within 60s + - **Logout** → logout → bunker files deleted → login screen + - **Post note** → compose + send → signed via remote signer transparently + - **Send DM** → NIP-17 gift wrap → encrypted via remote signer +3. `./gradlew spotlessApply` before commit + +## Answered Questions + +1. **Both `bunker://` AND `nostrconnect://`** — see Step 7 below +2. **60s hardcoded** heartbeat interval +3. **Sidebar heartbeat icon** — tiny pulsing dot with tooltip, see Step 8 below +4. **Yes, persist relay updates** from `switch_relays` to `bunker_uri.txt` +5. **No auto-retry** — show error immediately, user clicks to retry + +--- + +## Step 7: nostrconnect:// (Client-Initiated) Login + +### Protocol Difference + +| Aspect | `bunker://` (remote-signer-initiated) | `nostrconnect://` (client-initiated) | +|--------|---------------------------------------|--------------------------------------| +| Who generates URI | Remote signer (nsec.app, Amber) | Desktop client | +| Who scans/pastes | Desktop user pastes into app | User pastes into their signer app | +| Secret | Optional | **Required** (client generates) | +| Permissions | Not specified in URI | Client requests via `perms=` param | +| Flow | User already has URI → paste → connect | Client shows URI → user takes to signer → signer connects back | + +### nostrconnect:// URI Format (from NIP-46 spec) + +``` +nostrconnect://?relay=wss://relay1.example.com&relay=wss://relay2.example.com&secret=&perms=nip44_encrypt,nip44_decrypt,sign_event&name=Amethyst+Desktop&url=https://github.com/vitorpamplona/amethyst&image= +``` + +**Parameters:** +- `` — ephemeral pubkey generated by desktop (64 hex chars) +- `relay=` — relay URLs for communication (use app's connected relays) +- `secret=` — **REQUIRED** random string, client validates signer returns it in connect response +- `perms=` — comma-separated permissions to request +- `name=` — app display name ("Amethyst Desktop") +- `url=` — app URL (optional) +- `image=` — app icon URL (optional) + +### UX Flow + +1. User clicks **"Connect with Signer"** tab/button on login screen +2. Desktop generates ephemeral `KeyPair()` and random `secret` +3. Builds `nostrconnect://` URI with connected relay URLs + perms + secret +4. Displays: + - **QR code** (primary — user scans with phone signer) + - **Copyable text** (fallback — user pastes into web signer like nsec.app) + - **"Waiting for signer to connect..."** status with spinner +5. Desktop subscribes to kind 24133 events on specified relays, filtered to ephemeral pubkey +6. User scans QR / pastes URI into their signer app +7. Signer sends `connect` response with the secret value +8. Desktop validates secret matches → `AccountState.LoggedIn` +9. If no response within **120s** (longer than bunker — user needs time to open phone): show timeout, allow retry + +### Implementation Details + +**LoginCard changes:** +- Add toggle/tab: "Paste Key" | "Connect with Signer" +- "Connect with Signer" tab shows QR + copyable URI + waiting state +- State machine: `IDLE → GENERATING → WAITING_FOR_SIGNER → CONNECTED / ERROR` + +**AccountManager.loginWithNostrConnect(client: INostrClient): Pair** +1. Generate ephemeral `KeyPair()` + `NostrSignerInternal` +2. Generate random secret (16 hex chars) +3. Get connected relay URLs from relay manager (+ add `wss://relay.nsec.app` as fallback NIP-46 relay) +4. Build `nostrconnect://` URI string with `name=Amethyst+Desktop&perms=sign_event,nip44_encrypt,nip44_decrypt` +5. Subscribe to kind 24133 events on specified relays, filtered to ephemeral pubkey +6. Return (URI string, waiting Job) +7. When connect response received: validate secret, extract remote pubkey from response, create `NostrSignerRemote` +8. Set LoggedIn + +**Key difference from bunker://:** With nostrconnect, client doesn't know user's pubkey upfront — learns it from signer's connect response. `fromBunkerUri()` cannot be reused directly — need to construct `NostrSignerRemote` manually after learning the remote pubkey. + +**Quartz gap:** `RemoteSignerManager.launchWaitAndParse()` sends a request AND waits. For nostrconnect, client needs to just WAIT (signer initiates). Options: +- Build a `waitForConnect()` method in AccountManager that subscribes + waits +- Or call `openSubscription()` manually, then wait for the `newResponse()` callback + +**Timeout:** 120s for nostrconnect (user needs to open phone, find app, scan QR). No auto-retry. + +### QR Code Generation + +**ZXing `core` 3.5.4 already in `gradle/libs.versions.toml`!** And there's an existing `QrCodeDrawer.kt` in the Android app (`amethyst/src/main/java/.../ui/screen/loggedIn/qrcode/QrCodeDrawer.kt`) that uses: +- `com.google.zxing.qrcode.encoder.Encoder` (pure Java, in `zxing:core`) +- Compose Canvas APIs (multiplatform) +- **Zero Android dependencies** — fully portable to desktop + +**Action:** Extract `QrCodeDrawer.kt` to `commons/commonMain/` and add `libs.zxing` dependency to `commons` module. No new libraries needed. + +**Gradle addition (commons module):** +```kotlin +// commons/build.gradle.kts - commonMain sourceSets +implementation(libs.zxing) // already in version catalog +``` + +### Persistence + +Same as bunker:// — save ephemeral key + URI + npub. On restart, reconnect without needing QR again (remote signer remembers ephemeral key). + +### File Changes + +| File | nostrconnect:// additions | +|------|--------------------------| +| `AccountManager.kt` | `loginWithNostrConnect()` method, generates URI + waits for connect | +| `LoginCard.kt` | Tab toggle "Paste Key" / "Connect with Signer", QR display, waiting state | +| `LoginScreen.kt` | Wire nostrconnect flow, pass relay client | +| `build.gradle.kts` | Add `qrcode-kotlin-jvm` dependency | + +--- + +## Step 8: Heartbeat Status Indicator (Sidebar) + +### Design + +Tiny pulsing dot in the sidebar (below existing nav icons, above settings gear). Communicates remote signer connection health at a glance. + +### States + +| State | Visual | Tooltip | +|-------|--------|---------| +| **Connected** | Green dot, gentle pulse animation (1.5s cycle) | "Remote signer connected" | +| **Ping failed (1-2x)** | Yellow dot, faster pulse (0.8s cycle) | "Remote signer: connection unstable" | +| **Disconnected** | Red dot, no pulse (static) | "Remote signer: disconnected" | +| **Not remote** | Hidden | — (only show for bunker/nostrconnect accounts) | + +### Animation Pattern — Double-Beat Heartbeat + +Uses `keyframes` for organic lub-dub feel (two peaks per cycle): + +```kotlin +val infiniteTransition = rememberInfiniteTransition(label = "heartbeat") +val scale by infiniteTransition.animateFloat( + initialValue = 0.85f, + targetValue = 1.15f, + animationSpec = infiniteRepeatable( + animation = keyframes { + durationMillis = 1200 + 0.85f at 0 // rest + 1.15f at 300 using EaseOut // first beat (systole) + 0.95f at 500 // contract + 1.05f at 700 using EaseInOut // second beat (diastole) + 0.85f at 1200 // rest + }, + repeatMode = RepeatMode.Restart, + ), + label = "pulseScale", +) + +// Apply via graphicsLayer on a small Box with CircleShape +Box( + Modifier + .size(8.dp) + .graphicsLayer { scaleX = scale; scaleY = scale } + .background(color, CircleShape) +) +``` + +**Existing animation patterns in codebase:** `ProfileBroadcastBanner.kt` uses `animateFloatAsState` with `tween(300)` for progress bars. No `rememberInfiniteTransition` usage yet — this would be the first. + +### Tooltip (Desktop-only API) + +```kotlin +@OptIn(ExperimentalFoundationApi::class) +TooltipArea( + tooltip = { + Surface( + modifier = Modifier.shadow(4.dp), + color = MaterialTheme.colorScheme.surfaceVariant, + shape = RoundedCornerShape(4.dp), + ) { + Text(tooltipText, modifier = Modifier.padding(8.dp), style = MaterialTheme.typography.bodySmall) + } + }, + delayMillis = 400, + tooltipPlacement = TooltipPlacement.CursorPoint(alignment = Alignment.BottomEnd), +) { + HeartbeatDot(connectionState) +} +``` + +### Connection State Flow + +```kotlin +// In AccountManager +sealed class SignerConnectionState { + data object NotRemote : SignerConnectionState() // internal signer, hide indicator + data object Connected : SignerConnectionState() + data class Unstable(val failCount: Int) : SignerConnectionState() + data object Disconnected : SignerConnectionState() +} + +val signerConnectionState: StateFlow +``` + +Heartbeat updates this state on each ping result. UI observes via `collectAsState()`. + +### File Changes + +| File | Heartbeat indicator additions | +|------|------------------------------| +| `AccountManager.kt` | `SignerConnectionState` sealed class + `signerConnectionState: StateFlow` | +| `DeckSidebar.kt` | Add `HeartbeatIndicator` composable at bottom of sidebar | +| `SinglePaneLayout.kt` | Add `HeartbeatIndicator` at bottom of nav rail | +| `HeartbeatIndicator.kt` | **New** — pulsing dot + tooltip composable | + +--- + +## Updated Files Summary + +| File | Changes | +|------|---------| +| `AccountManager.kt` | SignerType, ConnectingRelays, loginWithBunker(), loginWithNostrConnect(), saveBunkerAccount(), loadSavedAccount(), logout(), heartbeat, forceLogout, SignerConnectionState | +| `LoginCard.kt` | LoginState, tab toggle (paste/connect), bunker URI validation, nostrconnect QR + waiting, connecting UI | +| `LoginScreen.kt` | relayClient param, wire bunker + nostrconnect flows, ConnectingRelaysScreen | +| `Main.kt` | Startup relay-wait, heartbeat lifecycle, forceLogout dialog, pass relayManager.client | +| `ForceLogoutDialog.kt` | **New** — AlertDialog for session termination | +| `HeartbeatIndicator.kt` | **New** — pulsing dot + tooltip composable | +| `DeckSidebar.kt` | Add HeartbeatIndicator between spacer and Settings | +| `SinglePaneLayout.kt` | Add HeartbeatIndicator in nav rail | +| `QrCodeDrawer.kt` | **Extract** from `amethyst/.../qrcode/` → `commons/commonMain/` (zero Android deps) | +| `commons/build.gradle.kts` | Add `libs.zxing` (core) dependency | + +## Answered Questions (Round 2) + +1. **Full perms** for nostrconnect: `sign_event,nip04_encrypt,nip04_decrypt,nip44_encrypt,nip44_decrypt` +2. ~~Dark mode QR~~ — defer to implementation, follow existing theme +3. **Both layouts** — heartbeat in SINGLE_PANE nav rail AND DECK sidebar diff --git a/docs/plans/2026-03-05-nip46-improvements-plan.md b/docs/plans/2026-03-05-nip46-improvements-plan.md new file mode 100644 index 000000000..21dfeea9c --- /dev/null +++ b/docs/plans/2026-03-05-nip46-improvements-plan.md @@ -0,0 +1,106 @@ +--- +title: "feat: NIP-46 remote signer improvements (post-MVP)" +type: feat +status: backlog +date: 2026-03-05 +origin: docs/plans/2026-03-05-fix-nip46-relay-isolation-and-init-plan.md +--- + +# feat: NIP-46 remote signer improvements (post-MVP) + +Deferred items from the NIP-46 relay isolation fix. All are nice-to-haves discovered during deepening/review — none block the initial fix. + +## 1. `auth_url` handling (nsec.app compatibility) + +**Priority:** Medium — only affects nsec.app users, not Amber + +NIP-46 spec defines `auth_url` for signers needing out-of-band user confirmation: +```json +{"id": "", "result": "auth_url", "error": ""} +``` + +Client should open the URL, then resubscribe for the actual result using the same request ID. + +**Current behavior:** Treated as `ReceivedButCouldNotPerform` error → times out. + +**To implement:** +- New `BunkerResponseAuthUrl` class + parser +- New `SignerResult` state for pending auth +- Desktop UI to open URL in browser + resubscribe +- Timeout management for multi-step flow + +**Files:** `BunkerResponse.kt`, `SignerResult.kt`, response parsers, `RemoteSignerManager.kt`, desktop `LoginCard.kt` + +## 2. `get_public_key` validation after connect + +**Priority:** Low — security hardening, not correctness + +Infrastructure already exists: `NostrSignerRemote.getPublicKey()` is fully implemented and production-ready. `connect()` already returns the user pubkey, so this is redundant for normal operation. + +**Value:** Belt-and-suspenders against MITM on the connect response. Low risk since NIP-44 encryption protects the channel. + +**To implement:** +```kotlin +// In AccountManager.loadBunkerAccount() after remoteSigner.openSubscription() +val confirmedPubkey = remoteSigner.getPublicKey() +if (confirmedPubkey != expectedPubkey) { + return Result.failure(Exception("Pubkey mismatch: signer returned $confirmedPubkey")) +} +``` + +**Cost:** 1 extra RPC round-trip + potential 30s timeout on failure. + +## 3. Dynamic `since` filter on reconnect + +**Priority:** Low — optimization, no correctness issue + +`NostrClientStaticReq` re-sends the exact same filter on WebSocket reconnect. With `since = now() - 60`, this means after a reconnect the relay replays events from the original subscription time — wasteful but harmless (request ID matching discards stale responses). + +**To implement:** Switch `NostrSignerRemote` from `NostrClientStaticReq` to `NostrClientDynamicReq`: +```kotlin +// Lambda recomputes since on each reconnect +val subscription = client.dynamicReq( + relays = relays.toList(), + filter = { + Filter( + kinds = listOf(NostrConnectEvent.KIND), + tags = mapOf("p" to listOf(signer.pubKey)), + since = TimeUtils.now() - 60, + ) + }, +) { event -> ... } +``` + +**Tradeoff:** More complexity. Only matters if relay operators complain about repeated full-history queries or reconnects are frequent. + +## 4. `NostrClient.close()` method (upstream) + +**Priority:** Medium — affects any code creating temporary `NostrClient` instances + +`NostrClient.disconnect()` only closes WebSockets but leaks: +- `allRelays` combine flow (`stateIn(Eagerly)` on `scope`) +- `debouncingConnection` debounce flow (`stateIn(Eagerly)` on `scope`) +- The `SupervisorJob` scope itself + +**To implement:** +```kotlin +// In NostrClient.kt +fun close() { + disconnect() + scope.cancel() +} +``` + +**Current workaround:** `disconnectNip46Client()` in AccountManager handles our specific case. But any future code creating temporary `NostrClient` instances will hit the same leak. + +**Recommendation:** PR upstream to quartz adding `close()` to `NostrClient`. + +## 5. `FeedMetadataCoordinator` reactive relay sets + +**Priority:** Low — deferred construction in the fix plan solves the immediate bug + +Currently `indexRelays` is `private val` — immutable after construction. If relay sets change at runtime (user adds/removes relays), the coordinator won't pick up changes. + +**To implement:** Accept `StateFlow>` instead of `Set`, collect in coordinator's scope. + +**Current workaround:** Nullable coordinator pattern with lazy construction after relays connect. diff --git a/docs/plans/2026-03-05-nip46-tdd-tests-plan.md b/docs/plans/2026-03-05-nip46-tdd-tests-plan.md new file mode 100644 index 000000000..5471baf99 --- /dev/null +++ b/docs/plans/2026-03-05-nip46-tdd-tests-plan.md @@ -0,0 +1,386 @@ +--- +title: "test: TDD tests for NIP-46 relay isolation fix" +type: test +status: active +date: 2026-03-05 +parent: 2026-03-05-fix-nip46-relay-isolation-and-init-plan.md +--- + +# test: TDD tests for NIP-46 relay isolation fix + +## Overview + +Write tests that **reproduce the three NIP-46 bugs** before they're fixed, then verify the fix. Tests should fail against the old shared-client architecture and pass after relay isolation. + +## Bug → Test Mapping + +| Bug | Root Cause | Test Strategy | +|-----|-----------|---------------| +| **Bug 1:** Empty `indexRelays` snapshot | `availableRelays.value` captured at coordinator construction = `emptySet()` | Verify coordinator receives non-empty relay set | +| **Bug 2:** Shared client relay contamination | NIP-46 relay leaks into general pool → ClosedMessage loop | Verify NIP-46 traffic stays on dedicated client, never touches general client | +| **Bug 3:** Sign request response lost | Subscription disrupted by ClosedMessage churn from Bug 2 | Verify NIP-46 subscription filters only target NIP-46 relays | + +## Test Infrastructure + +### Existing +- Framework: `kotlin.test` + `kotlinx-coroutines-test` + `mockk` +- Location: `desktopApp/src/jvmTest/kotlin/.../desktop/account/` +- Mocks: `EmptyNostrClient` (no-op), `mockk(relaxed = true)`, temp dirs +- Pattern: `@BeforeTest` setup → `runTest {}` → `@AfterTest` cleanup + +### Needed +- `SpyNostrClient` — wraps `INostrClient`, records which relays receive `send()` and `openReqSubscription()` calls +- No new dependencies (mockk `capture` + `slot` + `verify` sufficient) + +## Phase 0: Fix Existing Broken Tests + +**File:** `AccountManagerLoadAccountTest.kt` + +Three tests pass `client` param to `loadSavedAccount()` which no longer accepts it: + +| Test | Fix | +|------|-----| +| `loadSavedAccountBunkerNoEphemeralReturnsFailure` (line 114) | Remove `client = EmptyNostrClient` arg | +| `loadSavedAccountBunkerNoClientFallsBackToInternal` (line 119-136) | **Delete entirely** — concept no longer exists (AccountManager always creates its own NIP-46 client) | +| `loadSavedAccountBunkerSuccess` (line 155-158) | Remove `client = EmptyNostrClient` arg | + +## Phase 1: Relay Isolation Tests (Bug 2 + 3) + +### Test File: `AccountManagerNip46IsolationTest.kt` + +**Location:** `desktopApp/src/jvmTest/kotlin/.../desktop/account/` + +#### Test 1: `nip46ClientIsNotTheGeneralClient` + +**Reproduces:** Bug 2 root cause — shared `NostrClient` pollution. + +```kotlin +@Test +fun nip46ClientIsNotTheGeneralClient() = runTest { + // Setup: bunker URI + ephemeral key in storage + 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(BUNKER_EPHEMERAL_KEY_ALIAS) } returns ephemeralKeyPair.privKey!!.toHexKey() + + // Load bunker account — this creates the internal NIP-46 client + manager.loadSavedAccount() + + // The general relay pool client (e.g. relayManager.client) should NOT + // have any NIP-46 relay subscriptions. Since AccountManager creates its + // own client internally, we verify via the accountState having Remote signer. + val state = manager.currentAccount() + assertNotNull(state) + assertIs(state.signerType) + + // The signer's client should be the dedicated one, not EmptyNostrClient + val signer = state.signer as NostrSignerRemote + // NostrSignerRemote stores its client — it should be a real NostrClient, not null + assertNotNull(signer) +} +``` + +**Key assertion:** AccountManager creates a dedicated client internally rather than requiring one passed in. + +#### Test 2: `loginWithBunkerDoesNotRequireExternalClient` + +**Reproduces:** The old API required callers to pass `relayManager.client`. + +```kotlin +@Test +fun loginWithBunkerDoesNotRequireExternalClient() = runTest { + // loginWithBunker() should compile and work with just a bunkerUri + // This test verifies the API — no client param needed + try { + manager.loginWithBunker("bunker://${"a".repeat(64)}?relay=wss://relay.nsec.app") + } catch (e: Exception) { + // Expected — no real relay to connect to. But it should NOT throw + // a compile error or "client required" error. + assertTrue( + e.message?.contains("Connection") == true || + e.message?.contains("timed out") == true || + e.message?.contains("Connection failed") == true, + "Unexpected error: ${e.message}", + ) + } +} +``` + +#### Test 3: `loginWithNostrConnectDoesNotRequireExternalClient` + +Same pattern — verifies API no longer takes `client`. + +```kotlin +@Test +fun loginWithNostrConnectDoesNotRequireExternalClient() = runTest { + var generatedUri: String? = null + try { + manager.loginWithNostrConnect { uri -> generatedUri = uri } + } catch (e: Exception) { + // Expected — no signer scanning the QR + assertTrue(e.message?.contains("Timed out") == true || e.message != null) + } + // URI should have been generated even if connection failed + assertNotNull(generatedUri) + assertTrue(generatedUri!!.startsWith("nostrconnect://")) +} +``` + +#### Test 4: `logoutDisconnectsNip46Client` + +**Reproduces:** Bug 2 side effect — leaked NIP-46 WebSocket after logout. + +```kotlin +@Test +fun logoutDisconnectsNip46Client() = runTest { + // Login with bunker (will create internal NIP-46 client) + val keyPair = KeyPair() + manager.loginWithKey(keyPair.privKey!!.toNsec()) + // Force to Remote type for test + // Instead: test that logout from any state doesn't crash + manager.logout() + + // After logout, state should be LoggedOut + assertIs(manager.accountState.value) + // Signer connection should be NotRemote + assertIs(manager.signerConnectionState.value) +} +``` + +#### Test 5: `logoutThenLoginCreatesNewNip46Client` + +**Reproduces:** Ensuring fresh client after re-login (no stale state). + +```kotlin +@Test +fun logoutThenLoginCreatesNewNip46Client() = runTest { + // First bunker load + 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(BUNKER_EPHEMERAL_KEY_ALIAS) } returns ephemeralKeyPair.privKey!!.toHexKey() + + manager.loadSavedAccount() + val firstState = manager.currentAccount() + assertNotNull(firstState) + + // Logout — should disconnect NIP-46 client + manager.logout() + assertIs(manager.accountState.value) + + // Re-load — should create a NEW NIP-46 client + File(amethystDir, "bunker_uri.txt").writeText("bunker://$validHex?relay=wss://relay.nsec.app") + File(amethystDir, "last_account.txt").writeText(ephemeralKeyPair.pubKey.toNpub()) + manager.loadSavedAccount() + val secondState = manager.currentAccount() + assertNotNull(secondState) + + // Both states should be valid Remote signers + assertIs(firstState.signerType) + assertIs(secondState.signerType) +} +``` + +## Phase 2: State Transition Tests + +### Test File: `AccountManagerStateTransitionTest.kt` + +#### Test 6: `bunkerRestoreShowsConnectingThenLoggedIn` + +**Reproduces:** Bug 1 UX — user sees nothing while connecting. + +```kotlin +@Test +fun bunkerRestoreShowsConnectingThenLoggedIn() = runTest { + val states = mutableListOf() + val collector = backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { + manager.accountState.toList(states) + } + + // Set connecting state (what Main.kt does before loadSavedAccount) + manager.setConnectingRelays() + + // Then load internal account + 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 have: LoggedOut → ConnectingRelays → LoggedIn + assertTrue(states.size >= 3, "Expected at least 3 state transitions, got: $states") + assertIs(states[0]) + assertIs(states[1]) + assertIs(states[2]) + + collector.cancel() +} +``` + +#### Test 7: `loadSavedAccountBunkerNoEphemeralReturnsFailure` + +Updated version — no `client` param. + +```kotlin +@Test +fun loadSavedAccountBunkerNoEphemeralReturnsFailure() = runTest { + val validHex = "a".repeat(64) + val keyPair = KeyPair() + val npub = keyPair.pubKey.toNpub() + + File(amethystDir, "last_account.txt").writeText(npub) + File(amethystDir, "bunker_uri.txt").writeText("bunker://$validHex?relay=wss://r.com") + coEvery { storage.getPrivateKey(AccountManager.BUNKER_EPHEMERAL_KEY_ALIAS) } returns null + + val result = manager.loadSavedAccount() + assertTrue(result.isFailure) +} +``` + +## Phase 3: NostrSignerRemote Isolation Tests (quartz) + +### Test File: `NostrSignerRemoteIsolationTest.kt` + +**Location:** `quartz/src/commonTest/kotlin/.../nip46RemoteSigner/signer/` + +#### Test 8: `subscriptionFilterTargetsOnlyBunkerRelays` + +**Reproduces:** Bug 2 — subscription filters should ONLY reference NIP-46 relays. + +```kotlin +@Test +fun subscriptionFilterTargetsOnlyBunkerRelays() { + val bunkerRelay = NormalizedRelayUrl("wss://relay.nsec.app/") + val generalRelay = NormalizedRelayUrl("wss://relay.damus.io/") + + val capturedFilters = mutableListOf>>() + val mockClient = mockk(relaxed = true) + every { mockClient.openReqSubscription(any(), capture(capturedFilters), any()) } just Runs + + val ephemeralSigner = NostrSignerInternal(KeyPair()) + val remoteSigner = NostrSignerRemote( + signer = ephemeralSigner, + remotePubkey = "a".repeat(64), + relays = setOf(bunkerRelay), + client = mockClient, + ) + remoteSigner.openSubscription() + + // Verify filter only targets bunker relay + assertTrue(capturedFilters.isNotEmpty(), "No subscription opened") + capturedFilters.forEach { filterMap -> + assertTrue(filterMap.keys.all { it == bunkerRelay }, "Filter contains non-bunker relay: ${filterMap.keys}") + assertTrue(generalRelay !in filterMap.keys, "General relay leaked into NIP-46 filter") + } +} +``` + +#### Test 9: `subscriptionFilterContainsCorrectKindAndPTag` + +```kotlin +@Test +fun subscriptionFilterContainsCorrectKindAndPTag() { + val capturedFilters = mutableListOf>>() + val mockClient = mockk(relaxed = true) + every { mockClient.openReqSubscription(any(), capture(capturedFilters), any()) } just Runs + + val ephemeralKeyPair = KeyPair() + val ephemeralSigner = NostrSignerInternal(ephemeralKeyPair) + val remoteSigner = NostrSignerRemote( + signer = ephemeralSigner, + remotePubkey = "b".repeat(64), + relays = setOf(NormalizedRelayUrl("wss://relay.nsec.app/")), + client = mockClient, + ) + remoteSigner.openSubscription() + + val filters = capturedFilters.flatMap { it.values.flatten() } + assertTrue(filters.isNotEmpty()) + filters.forEach { filter -> + assertTrue(filter.kinds?.contains(NostrConnectEvent.KIND) == true, + "Filter missing kind ${NostrConnectEvent.KIND}") + assertTrue(filter.tags?.get("p")?.contains(ephemeralSigner.pubKey) == true, + "Filter missing p-tag for ephemeral pubkey") + } +} +``` + +## Phase 4: `since` Filter Tests (Bug 3 mitigation) + +### Test File: add to `NostrSignerRemoteIsolationTest.kt` + +#### Test 10: `subscriptionFilterHasSinceTimestamp` + +**Reproduces:** Stale NIP-46 responses from previous sessions being processed. + +```kotlin +@Test +fun subscriptionFilterHasSinceTimestamp() { + val capturedFilters = mutableListOf>>() + val mockClient = mockk(relaxed = true) + every { mockClient.openReqSubscription(any(), capture(capturedFilters), any()) } just Runs + + val beforeTime = TimeUtils.now() + + val remoteSigner = NostrSignerRemote( + signer = NostrSignerInternal(KeyPair()), + remotePubkey = "c".repeat(64), + relays = setOf(NormalizedRelayUrl("wss://relay.nsec.app/")), + client = mockClient, + ) + remoteSigner.openSubscription() + + val filters = capturedFilters.flatMap { it.values.flatten() } + assertTrue(filters.isNotEmpty()) + filters.forEach { filter -> + assertNotNull(filter.since, "Filter missing 'since' timestamp") + // since should be roughly now - 60s (with some tolerance) + val expectedMin = beforeTime - 120 // extra tolerance + val expectedMax = beforeTime + assertTrue(filter.since!! >= expectedMin, "since too old: ${filter.since}") + assertTrue(filter.since!! <= expectedMax, "since in the future: ${filter.since}") + } +} +``` + +> **Note:** This test will FAIL until Step 8 (add `since` to NostrSignerRemote) is implemented. That's the TDD red phase. + +## Implementation Order + +| # | Action | File | Phase | +|---|--------|------|-------| +| 1 | Fix broken tests (remove `client` param) | `AccountManagerLoadAccountTest.kt` | 0 | +| 2 | Write relay isolation tests (Tests 1-5) | `AccountManagerNip46IsolationTest.kt` | 1 | +| 3 | Write state transition tests (Tests 6-7) | `AccountManagerStateTransitionTest.kt` | 2 | +| 4 | Write signer isolation tests (Tests 8-9) | `NostrSignerRemoteIsolationTest.kt` | 3 | +| 5 | Write `since` filter test (Test 10) — **expect RED** | `NostrSignerRemoteIsolationTest.kt` | 4 | +| 6 | Run all → confirm Tests 1-9 GREEN, Test 10 RED | — | — | +| 7 | Implement Step 8 (`since` filter in quartz) | `NostrSignerRemote.kt` | — | +| 8 | Run all → confirm all GREEN | — | — | + +## Test Commands + +```bash +# Run all desktop tests +./gradlew :desktopApp:test + +# Run specific test class +./gradlew :desktopApp:test --tests "*.AccountManagerNip46IsolationTest" + +# Run quartz common tests +./gradlew :quartz:jvmTest --tests "*.NostrSignerRemoteIsolationTest" + +# Run all NIP-46 related tests +./gradlew :desktopApp:test --tests "*.AccountManager*" :quartz:jvmTest --tests "*.nip46RemoteSigner.*" +``` + +## Unanswered Questions + +1. `getOrCreateNip46Client()` is private — can't directly verify single-creation from tests without reflection or `@VisibleForTesting`. Sufficient to test indirectly through public API? +2. `NostrSignerRemote.subscription` field is private — MockK `capture` on `openReqSubscription` is the best proxy for filter verification? +3. Should we add Turbine dependency for cleaner StateFlow testing, or stick with manual `backgroundScope` collection? +4. `NostrClient.connect()` is non-suspending (fire-and-forget) — how to test that the NIP-46 client actually connects before subscriptions? Accept relay auto-queue behavior? 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 c54995156..fb2e344a8 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,12 +42,6 @@ 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 -import kotlinx.coroutines.cancel -import kotlinx.coroutines.launch class NostrSignerRemote( val signer: NostrSignerInternal, @@ -57,8 +51,6 @@ class NostrSignerRemote( val permissions: String? = null, val secret: String? = null, ) : NostrSigner(signer.pubKey) { - private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) - private val manager = RemoteSignerManager( signer = signer, @@ -74,17 +66,10 @@ class NostrSignerRemote( Filter( kinds = listOf(NostrConnectEvent.KIND), tags = mapOf("p" to listOf(signer.pubKey)), - since = TimeUtils.now() - 60, ), ) { event -> if (event is NostrConnectEvent) { - scope.launch { - try { - manager.newResponse(event) - } catch (_: Exception) { - // Ignore events we can't decrypt or parse - } - } + manager.newResponse(event) } } @@ -94,7 +79,6 @@ class NostrSignerRemote( fun closeSubscription() { subscription.close() - scope.cancel() } override fun isWriteable(): Boolean = true @@ -303,10 +287,9 @@ class NostrSignerRemote( permissions: String? = null, ): NostrSignerRemote { if (!bunkerUri.startsWith("bunker://")) throw Exception("Invalid bunker uri") - val splitData = bunkerUri.split("?", limit = 2) + val splitData = bunkerUri.split("?") 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 From 4d1d92db39f8b7070b9cbb6a16b49c9b64c15173 Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Tue, 10 Mar 2026 09:33:10 +0200 Subject: [PATCH 14/17] feat(desktop): add bunker heartbeat indicator to sidebar navigation Pulsating green heart icon shows NIP-46 signer connection status in both DeckSidebar and SinglePaneLayout NavigationRail. Tooltip displays last ping time. SignerConnectionState moved to commons for KMP sharing. Co-Authored-By: Claude Opus 4.6 --- .../domain/nip46/SignerConnectionState.kt | 29 +++ .../ui/components/BunkerHeartbeatIndicator.kt | 117 ++++++++++ .../vitorpamplona/amethyst/desktop/Main.kt | 6 + .../desktop/account/AccountManager.kt | 216 +++--------------- .../amethyst/desktop/ui/deck/DeckSidebar.kt | 11 + .../desktop/ui/deck/SinglePaneLayout.kt | 12 + .../account/AccountManagerLogoutTest.kt | 1 + .../AccountManagerNip46IsolationTest.kt | 1 + 8 files changed, 212 insertions(+), 181 deletions(-) create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/domain/nip46/SignerConnectionState.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/BunkerHeartbeatIndicator.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/domain/nip46/SignerConnectionState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/domain/nip46/SignerConnectionState.kt new file mode 100644 index 000000000..321df54a0 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/domain/nip46/SignerConnectionState.kt @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.domain.nip46 + +sealed class SignerConnectionState { + data object NotRemote : SignerConnectionState() + + data object Connected : SignerConnectionState() + + data object Disconnected : SignerConnectionState() +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/BunkerHeartbeatIndicator.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/BunkerHeartbeatIndicator.kt new file mode 100644 index 000000000..7c295ef91 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/BunkerHeartbeatIndicator.kt @@ -0,0 +1,117 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.ui.components + +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Favorite +import androidx.compose.material.icons.filled.FavoriteBorder +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.PlainTooltip +import androidx.compose.material3.Text +import androidx.compose.material3.TooltipBox +import androidx.compose.material3.TooltipDefaults +import androidx.compose.material3.rememberTooltipState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.scale +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.domain.nip46.SignerConnectionState +import com.vitorpamplona.quartz.utils.TimeUtils + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun BunkerHeartbeatIndicator( + signerConnectionState: SignerConnectionState, + lastPingTimeSec: Long?, + modifier: Modifier = Modifier, +) { + if (signerConnectionState is SignerConnectionState.NotRemote) return + + val tooltipState = rememberTooltipState() + val tooltipText = + when (signerConnectionState) { + is SignerConnectionState.Connected -> { + if (lastPingTimeSec != null) { + val agoSeconds = TimeUtils.now() - lastPingTimeSec + "Bunker connected \u2014 last pinged ${agoSeconds}s ago" + } else { + "Bunker connected" + } + } + + is SignerConnectionState.Disconnected -> { + "Bunker disconnected" + } + + else -> { + "" + } + } + + TooltipBox( + positionProvider = TooltipDefaults.rememberTooltipPositionProvider(), + tooltip = { PlainTooltip { Text(tooltipText) } }, + state = tooltipState, + modifier = modifier, + ) { + when (signerConnectionState) { + is SignerConnectionState.Connected -> { + val infiniteTransition = rememberInfiniteTransition(label = "heartbeat") + val scale by infiniteTransition.animateFloat( + initialValue = 0.85f, + targetValue = 1.15f, + animationSpec = + infiniteRepeatable( + animation = tween(800), + repeatMode = RepeatMode.Reverse, + ), + label = "heartbeatScale", + ) + Icon( + Icons.Default.Favorite, + contentDescription = "Bunker connected", + tint = Color(0xFF4CAF50), + modifier = Modifier.size(20.dp).scale(scale), + ) + } + + is SignerConnectionState.Disconnected -> { + Icon( + Icons.Default.FavoriteBorder, + contentDescription = "Bunker disconnected", + tint = Color(0xFFF44336), + modifier = Modifier.size(20.dp), + ) + } + + else -> {} + } + } +} 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 d353393af..30470359f 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt @@ -557,6 +557,8 @@ fun MainContent( ) { val snackbarHostState = remember { SnackbarHostState() } val scope = rememberCoroutineScope() + val signerConnectionState by accountManager.signerConnectionState.collectAsState() + val lastPingTimeSec by accountManager.lastPingTimeSec.collectAsState() // DM infrastructure — hoisted here so it survives screen navigation val dmSendTracker = @@ -678,6 +680,8 @@ fun MainContent( onShowComposeDialog = onShowComposeDialog, onShowReplyDialog = onShowReplyDialog, onZapFeedback = onZapFeedback, + signerConnectionState = signerConnectionState, + lastPingTimeSec = lastPingTimeSec, modifier = Modifier.weight(1f), ) } @@ -692,6 +696,8 @@ fun MainContent( deckState.addColumn(DeckColumnType.Settings) } }, + signerConnectionState = signerConnectionState, + lastPingTimeSec = lastPingTimeSec, ) VerticalDivider() 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 5af6d87eb..7fab08fbc 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 @@ -21,22 +21,21 @@ package com.vitorpamplona.amethyst.desktop.account import androidx.compose.runtime.Stable +import com.vitorpamplona.amethyst.commons.domain.nip46.BunkerLoginUseCase +import com.vitorpamplona.amethyst.commons.domain.nip46.NostrConnectLoginUseCase +import com.vitorpamplona.amethyst.commons.domain.nip46.SignerConnectionState import com.vitorpamplona.amethyst.commons.keystorage.SecureKeyStorage import com.vitorpamplona.amethyst.commons.keystorage.SecureStorageException import com.vitorpamplona.amethyst.desktop.network.DesktopHttpClient -import com.vitorpamplona.quartz.nip01Core.core.HexKey -import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.IRelayClientListener -import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.req import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.Command import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.EventCmd -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.sockets.okhttp.BasicOkHttpWebSocket import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner @@ -46,17 +45,9 @@ import com.vitorpamplona.quartz.nip19Bech32.decodePrivateKeyAsHexOrNull import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull import com.vitorpamplona.quartz.nip19Bech32.toNpub import com.vitorpamplona.quartz.nip19Bech32.toNsec -import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerMessage -import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequest -import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestConnect -import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponse -import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponseAck -import com.vitorpamplona.quartz.nip46RemoteSigner.NostrConnectEvent import com.vitorpamplona.quartz.nip46RemoteSigner.signer.NostrSignerRemote import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect -import com.vitorpamplona.quartz.utils.Hex import com.vitorpamplona.quartz.utils.TimeUtils -import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.delay @@ -72,7 +63,6 @@ import kotlinx.coroutines.withTimeout import java.io.File import java.nio.file.Files import java.nio.file.attribute.PosixFilePermission -import java.security.SecureRandom sealed class SignerType { data object Internal : SignerType() @@ -82,14 +72,6 @@ sealed class SignerType { ) : SignerType() } -sealed class SignerConnectionState { - data object NotRemote : SignerConnectionState() - - data object Connected : SignerConnectionState() - - data object Disconnected : SignerConnectionState() -} - sealed class AccountState { data object LoggedOut : AccountState() @@ -119,7 +101,6 @@ class AccountManager internal constructor( internal const val HEARTBEAT_INTERVAL_MS = 60_000L internal const val MAX_CONSECUTIVE_FAILURES = 3 internal const val BUNKER_EPHEMERAL_KEY_ALIAS = "bunker_ephemeral" - internal const val NOSTRCONNECT_TIMEOUT_MS = 120_000L internal const val NIP46_RELAY_CONNECT_TIMEOUT_MS = 15_000L internal val NIP46_RELAYS = listOf("wss://relay.nsec.app") } @@ -137,6 +118,9 @@ class AccountManager internal constructor( private val _signerConnectionState = MutableStateFlow(SignerConnectionState.NotRemote) val signerConnectionState: StateFlow = _signerConnectionState.asStateFlow() + private val _lastPingTimeSec = MutableStateFlow(null) + val lastPingTimeSec: StateFlow = _lastPingTimeSec.asStateFlow() + private val _forceLogoutReason = MutableStateFlow(null) val forceLogoutReason: StateFlow = _forceLogoutReason.asStateFlow() @@ -326,31 +310,26 @@ class AccountManager internal constructor( val nip46Client = getOrCreateNip46Client() client = nip46Client - val remoteSigner = NostrSignerRemote.fromBunkerUri(bunkerUri, ephemeralSigner, nip46Client) - // Emit connecting with initial relay statuses + val relaysFromUri = parseBunkerRelays(bunkerUri) _loginProgress.value = LoginProgress.ConnectingToRelays( - remoteSigner.relays.associateWith { RelayLoginStatus.CONNECTING }, + relaysFromUri.associateWith { RelayLoginStatus.CONNECTING }, ) nip46Client.subscribe(listener) - remoteSigner.openSubscription() - - // Wait for websocket to be ready before sending connect request - awaitNip46RelayConnection(nip46Client, remoteSigner.relays) - _loginProgress.value = LoginProgress.WaitingForSigner( relayStatuses = _loginProgress.value?.relayStatuses.orEmpty(), ) - val remotePubkey = remoteSigner.connect() + + val result = BunkerLoginUseCase.execute(bunkerUri, ephemeralSigner, nip46Client) val state = AccountState.LoggedIn( - signer = remoteSigner, - pubKeyHex = remotePubkey, - npub = remotePubkey.hexToByteArray().toNpub(), + signer = result.signer, + pubKeyHex = result.pubKeyHex, + npub = result.pubKeyHex.hexToByteArray().toNpub(), nsec = null, isReadOnly = false, signerType = SignerType.Remote(bunkerUri), @@ -358,7 +337,6 @@ class AccountManager internal constructor( _accountState.value = state _signerConnectionState.value = SignerConnectionState.Connected - // Save bunker account — strip secret param (no longer needed after connect) saveBunkerAccount( bunkerUri = stripBunkerSecret(bunkerUri), ephemeralPrivKeyHex = ephemeralKeyPair.privKey!!.toHexKey(), @@ -389,61 +367,34 @@ class AccountManager internal constructor( var client: NostrClient? = null try { val ephemeralKeyPair = KeyPair() - val ephemeralSigner = NostrSignerInternal(ephemeralKeyPair) - val secret = generateNostrConnectSecret() - val ephemeralPubKey = ephemeralKeyPair.pubKey.toHexKey() - - val relays = NIP46_RELAYS - val relayParams = relays.joinToString("&") { "relay=$it" } - val uri = "nostrconnect://$ephemeralPubKey?$relayParams&secret=$secret&name=Amethyst%20Desktop" + val uriData = NostrConnectLoginUseCase.generateUri(ephemeralKeyPair, NIP46_RELAYS, "Amethyst%20Desktop") val nip46Client = getOrCreateNip46Client() client = nip46Client - val normalizedRelays = relays.map { NormalizedRelayUrl(it) }.toSet() - // Emit connecting with initial relay statuses _loginProgress.value = LoginProgress.ConnectingToRelays( - normalizedRelays.associateWith { RelayLoginStatus.CONNECTING }, + uriData.relays.associateWith { RelayLoginStatus.CONNECTING }, ) nip46Client.subscribe(listener) - onUriGenerated(uri) + onUriGenerated(uriData.uri) _loginProgress.value = LoginProgress.WaitingForSigner( relayStatuses = _loginProgress.value?.relayStatuses.orEmpty(), ) - val connectData = waitForConnectRequest(ephemeralSigner, ephemeralPubKey, normalizedRelays, secret, nip46Client) - if (connectData.requestId != null) { - _loginProgress.value = - LoginProgress.SendingAck( - relayStatuses = _loginProgress.value?.relayStatuses.orEmpty(), - ) - sendAckResponse(ephemeralSigner, connectData, normalizedRelays, nip46Client) - } + val result = NostrConnectLoginUseCase.awaitAndLogin(uriData, nip46Client) - val remoteSigner = - NostrSignerRemote( - signer = ephemeralSigner, - remotePubkey = connectData.signerPubkey, - relays = normalizedRelays, - client = nip46Client, - ) - remoteSigner.openSubscription() - - // Verify user pubkey via get_public_key — connect params may contain - // the wrong pubkey (e.g. ephemeral key echoed back by some signers) - val verifiedPubkey = remoteSigner.getPublicKey() - - val syntheticBunkerUri = "bunker://${connectData.signerPubkey}?$relayParams" + val relayParams = NIP46_RELAYS.joinToString("&") { "relay=$it" } + val syntheticBunkerUri = "bunker://${result.signer.remotePubkey}?$relayParams" val state = AccountState.LoggedIn( - signer = remoteSigner, - pubKeyHex = verifiedPubkey, - npub = verifiedPubkey.hexToByteArray().toNpub(), + signer = result.signer, + pubKeyHex = result.pubKeyHex, + npub = result.pubKeyHex.hexToByteArray().toNpub(), nsec = null, isReadOnly = false, signerType = SignerType.Remote(syntheticBunkerUri), @@ -468,116 +419,6 @@ class AccountManager internal constructor( } } - private suspend fun waitForConnectRequest( - ephemeralSigner: NostrSignerInternal, - ephemeralPubKey: HexKey, - relays: Set, - expectedSecret: String, - client: NostrClient, - ): ConnectRequestData { - val deferred = CompletableDeferred() - - val subscription = - client.req( - relays = relays.toList(), - filter = - Filter( - kinds = listOf(NostrConnectEvent.KIND), - tags = mapOf("p" to listOf(ephemeralPubKey)), - since = TimeUtils.now() - 60, - ), - ) { event -> - if (event is NostrConnectEvent && !deferred.isCompleted) { - deferred.complete(event) - } - } - - try { - val event = - withTimeout(NOSTRCONNECT_TIMEOUT_MS) { - deferred.await() - } - - val signerPubkey = event.talkingWith(ephemeralSigner.pubKey) - val decryptedJson = ephemeralSigner.decrypt(event.content, signerPubkey) - val message = OptimizedJsonMapper.fromJsonTo(decryptedJson) - - return when (message) { - is BunkerRequest -> { - if (message.method != BunkerRequestConnect.METHOD_NAME) { - throw Exception("Expected 'connect' method, got '${message.method}'") - } - - val userPubkey = - message.params.getOrNull(0) - ?: throw Exception("Missing user pubkey in connect request") - val receivedSecret = message.params.getOrNull(1) - - if (receivedSecret != expectedSecret) { - throw Exception("Secret mismatch in connect request") - } - - ConnectRequestData( - requestId = message.id, - signerPubkey = signerPubkey, - userPubkey = userPubkey, - ) - } - - is BunkerResponse -> { - if (message.error != null) { - throw Exception("Signer rejected connection: ${message.error}") - } - - val userPubkey = - message.result - ?.takeIf { it.length == 64 && Hex.isHex(it) } - ?: signerPubkey - - ConnectRequestData( - requestId = null, - signerPubkey = signerPubkey, - userPubkey = userPubkey, - ) - } - - else -> { - throw Exception("Unexpected NIP-46 message format") - } - } - } finally { - subscription.close() - } - } - - private suspend fun sendAckResponse( - ephemeralSigner: NostrSignerInternal, - connectData: ConnectRequestData, - relays: Set, - client: NostrClient, - ) { - val ackResponse = BunkerResponseAck(id = connectData.requestId!!) - val ackEvent = - NostrConnectEvent.create( - message = ackResponse, - remoteKey = connectData.signerPubkey, - signer = ephemeralSigner, - ) - client.send(ackEvent, relays) - } - - private fun generateNostrConnectSecret(): String { - val bytes = ByteArray(32) - SecureRandom().nextBytes(bytes) - return bytes.joinToString("") { "%02x".format(it) } - } - - private data class ConnectRequestData( - val requestId: String?, - val signerPubkey: HexKey, - val userPubkey: HexKey, - ) - private suspend fun saveBunkerAccount( bunkerUri: String, ephemeralPrivKeyHex: String, @@ -709,6 +550,7 @@ class AccountManager internal constructor( } disconnectNip46Client() _signerConnectionState.value = SignerConnectionState.NotRemote + _lastPingTimeSec.value = null _accountState.value = AccountState.LoggedOut // Cancel heartbeat LAST — may be called from within the heartbeat coroutine stopHeartbeat() @@ -738,6 +580,7 @@ class AccountManager internal constructor( remoteSigner.ping() consecutiveFailures = 0 _signerConnectionState.value = SignerConnectionState.Connected + _lastPingTimeSec.value = TimeUtils.now() } catch (_: SignerExceptions.ManuallyUnauthorizedException) { forceLogoutWithReason("Remote signer revoked access.") return@launch @@ -853,6 +696,17 @@ class AccountManager internal constructor( private fun getBunkerFile(): File = File(amethystDir, "bunker_uri.txt") } +internal fun parseBunkerRelays(uri: String): Set { + val idx = uri.indexOf('?') + if (idx < 0) return emptySet() + return uri + .substring(idx + 1) + .split("&") + .filter { it.startsWith("relay=", ignoreCase = true) } + .map { NormalizedRelayUrl(it.removePrefix("relay=")) } + .toSet() +} + internal fun stripBunkerSecret(uri: String): String { val idx = uri.indexOf('?') if (idx < 0) return uri diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckSidebar.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckSidebar.kt index 912042a1b..5ddc99b21 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckSidebar.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckSidebar.kt @@ -41,11 +41,15 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import com.vitorpamplona.amethyst.commons.domain.nip46.SignerConnectionState +import com.vitorpamplona.amethyst.commons.ui.components.BunkerHeartbeatIndicator @Composable fun DeckSidebar( onAddColumn: () -> Unit, onOpenSettings: () -> Unit, + signerConnectionState: SignerConnectionState, + lastPingTimeSec: Long?, modifier: Modifier = Modifier, ) { Column( @@ -78,6 +82,13 @@ fun DeckSidebar( Spacer(Modifier.weight(1f)) + BunkerHeartbeatIndicator( + signerConnectionState = signerConnectionState, + lastPingTimeSec = lastPingTimeSec, + ) + + Spacer(Modifier.size(8.dp)) + IconButton(onClick = onOpenSettings) { Icon( Icons.Default.Settings, diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/SinglePaneLayout.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/SinglePaneLayout.kt index dd8276699..e58734588 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/SinglePaneLayout.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/SinglePaneLayout.kt @@ -62,6 +62,8 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.domain.nip46.SignerConnectionState +import com.vitorpamplona.amethyst.commons.ui.components.BunkerHeartbeatIndicator import com.vitorpamplona.amethyst.desktop.DesktopScreen import com.vitorpamplona.amethyst.desktop.account.AccountManager import com.vitorpamplona.amethyst.desktop.account.AccountState @@ -103,6 +105,8 @@ fun SinglePaneLayout( onShowComposeDialog: () -> Unit, onShowReplyDialog: (com.vitorpamplona.quartz.nip01Core.core.Event) -> Unit, onZapFeedback: (ZapFeedback) -> Unit, + signerConnectionState: SignerConnectionState, + lastPingTimeSec: Long?, modifier: Modifier = Modifier, ) { var currentColumnType by remember { mutableStateOf(DeckColumnType.HomeFeed) } @@ -139,6 +143,14 @@ fun SinglePaneLayout( }, ) } + + Spacer(Modifier.weight(1f)) + + BunkerHeartbeatIndicator( + signerConnectionState = signerConnectionState, + lastPingTimeSec = lastPingTimeSec, + modifier = Modifier.padding(bottom = 12.dp), + ) } VerticalDivider() diff --git a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManagerLogoutTest.kt b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManagerLogoutTest.kt index fde057bcf..4956cab81 100644 --- a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManagerLogoutTest.kt +++ b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManagerLogoutTest.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.desktop.account +import com.vitorpamplona.amethyst.commons.domain.nip46.SignerConnectionState import com.vitorpamplona.amethyst.commons.keystorage.SecureKeyStorage import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip19Bech32.toNsec 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 index ef5bce652..33fe20c01 100644 --- a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManagerNip46IsolationTest.kt +++ b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManagerNip46IsolationTest.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.desktop.account +import com.vitorpamplona.amethyst.commons.domain.nip46.SignerConnectionState import com.vitorpamplona.amethyst.commons.keystorage.SecureKeyStorage import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair From 36efbd1544533403a33c0c3d22cffde0a032dfc7 Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Tue, 10 Mar 2026 09:33:24 +0200 Subject: [PATCH 15/17] fix(desktop): remove noisy debug logs and silence SLF4J warnings Remove feedSub/contactList debug prints from FeedScreen, suppress GiftWrapEvent decryption failure log (expected for others' wraps), and add slf4j-nop to silence "No SLF4J providers" console warnings. Co-Authored-By: Claude Opus 4.6 --- desktopApp/build.gradle.kts | 3 +++ .../kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt | 3 --- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/desktopApp/build.gradle.kts b/desktopApp/build.gradle.kts index be0915bc6..85c1c5607 100644 --- a/desktopApp/build.gradle.kts +++ b/desktopApp/build.gradle.kts @@ -49,6 +49,9 @@ dependencies { // Collections implementation(libs.kotlinx.collections.immutable) + // SLF4J no-op — silence "No SLF4J providers" warnings from transitive deps + implementation("org.slf4j:slf4j-nop:2.0.16") + // QR code generation (ZXing core) implementation(libs.zxing) 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 12523a85c..e539b1510 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,7 +58,6 @@ 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 @@ -208,7 +207,6 @@ fun FeedScreen( onEvent = { event, _, relay, _ -> if (event is ContactListEvent) { val follows = event.verifiedFollowKeySet() - DebugConfig.log("contactList: ${follows.size} follows from $relay") followedUsers = follows } }, @@ -260,7 +258,6 @@ fun FeedScreen( // Subscribe to feed based on mode rememberSubscription(configuredRelays, feedMode, followedUsers, relayManager = relayManager) { - DebugConfig.log("feedSub: mode=$feedMode, relays=${configuredRelays.size}, followedUsers=${followedUsers.size}") if (configuredRelays.isEmpty()) { return@rememberSubscription null } From edf764709d88a73f252bed558f89a6227640538b Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Wed, 11 Mar 2026 10:35:56 +0200 Subject: [PATCH 16/17] fix: resolve conflicts with upstream PR #1789 merge - Add missing commons domain files (BunkerLoginUseCase, NostrConnectLoginUseCase) - Restore CoroutineScope in NostrSignerRemote for suspend decrypt call - Update isolation test: upstream removed `since` filter (PR #1789) - Add new INostrClient interface methods to TrackingNostrClient mock Co-Authored-By: Claude Opus 4.6 --- .../commons/domain/nip46/BunkerLoginResult.kt | 29 +++ .../domain/nip46/BunkerLoginUseCase.kt | 51 ++++ .../domain/nip46/NostrConnectLoginUseCase.kt | 218 ++++++++++++++++++ .../signer/NostrSignerRemote.kt | 12 +- .../signer/NostrSignerRemoteIsolationTest.kt | 31 +-- 5 files changed, 320 insertions(+), 21 deletions(-) create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/domain/nip46/BunkerLoginResult.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/domain/nip46/BunkerLoginUseCase.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/domain/nip46/NostrConnectLoginUseCase.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/domain/nip46/BunkerLoginResult.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/domain/nip46/BunkerLoginResult.kt new file mode 100644 index 000000000..42589c369 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/domain/nip46/BunkerLoginResult.kt @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.domain.nip46 + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip46RemoteSigner.signer.NostrSignerRemote + +data class BunkerLoginResult( + val signer: NostrSignerRemote, + val pubKeyHex: HexKey, +) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/domain/nip46/BunkerLoginUseCase.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/domain/nip46/BunkerLoginUseCase.kt new file mode 100644 index 000000000..4962f5e3e --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/domain/nip46/BunkerLoginUseCase.kt @@ -0,0 +1,51 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.domain.nip46 + +import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.nip46RemoteSigner.signer.NostrSignerRemote +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.withTimeout + +object BunkerLoginUseCase { + const val RELAY_CONNECT_TIMEOUT_MS = 15_000L + + suspend fun execute( + bunkerUri: String, + ephemeralSigner: NostrSignerInternal, + client: INostrClient, + ): BunkerLoginResult { + val remoteSigner = NostrSignerRemote.fromBunkerUri(bunkerUri, ephemeralSigner, client) + remoteSigner.openSubscription() + + // Wait for websocket to be ready before sending connect request + withTimeout(RELAY_CONNECT_TIMEOUT_MS) { + client.connectedRelaysFlow().first { connected -> + remoteSigner.relays.any { it in connected } + } + } + + remoteSigner.connect() + val pubkey = remoteSigner.getPublicKey() + return BunkerLoginResult(remoteSigner, pubkey) + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/domain/nip46/NostrConnectLoginUseCase.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/domain/nip46/NostrConnectLoginUseCase.kt new file mode 100644 index 000000000..674a38219 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/domain/nip46/NostrConnectLoginUseCase.kt @@ -0,0 +1,218 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.domain.nip46 + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper +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.reqs.req +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.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.utils.Hex +import com.vitorpamplona.quartz.utils.SecureRandom +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.withTimeout + +object NostrConnectLoginUseCase { + const val NOSTRCONNECT_TIMEOUT_MS = 120_000L + + data class NostrConnectUri( + val uri: String, + val ephemeralKeyPair: KeyPair, + val ephemeralSigner: NostrSignerInternal, + val relays: Set, + val secret: String, + ) + + data class ConnectRequestData( + val requestId: String?, + val signerPubkey: HexKey, + val userPubkey: HexKey, + ) + + fun generateUri( + ephemeralKeyPair: KeyPair, + relays: List, + appName: String, + ): NostrConnectUri { + val ephemeralSigner = NostrSignerInternal(ephemeralKeyPair) + val ephemeralPubKey = ephemeralKeyPair.pubKey.toHexKey() + val secret = generateSecret() + + val relayParams = relays.joinToString("&") { "relay=$it" } + val uri = "nostrconnect://$ephemeralPubKey?$relayParams&secret=$secret&name=$appName" + val normalizedRelays = relays.map { NormalizedRelayUrl(it) }.toSet() + + return NostrConnectUri( + uri = uri, + ephemeralKeyPair = ephemeralKeyPair, + ephemeralSigner = ephemeralSigner, + relays = normalizedRelays, + secret = secret, + ) + } + + suspend fun awaitAndLogin( + uriData: NostrConnectUri, + client: INostrClient, + ): BunkerLoginResult { + val connectData = + waitForConnectRequest( + ephemeralSigner = uriData.ephemeralSigner, + ephemeralPubKey = uriData.ephemeralKeyPair.pubKey.toHexKey(), + relays = uriData.relays, + expectedSecret = uriData.secret, + client = client, + ) + + if (connectData.requestId != null) { + sendAckResponse(uriData.ephemeralSigner, connectData, uriData.relays, client) + } + + val remoteSigner = + NostrSignerRemote( + signer = uriData.ephemeralSigner, + remotePubkey = connectData.signerPubkey, + relays = uriData.relays, + client = client, + ) + remoteSigner.openSubscription() + + val verifiedPubkey = remoteSigner.getPublicKey() + + return BunkerLoginResult(remoteSigner, verifiedPubkey) + } + + private suspend fun waitForConnectRequest( + ephemeralSigner: NostrSignerInternal, + ephemeralPubKey: HexKey, + relays: Set, + expectedSecret: String, + client: INostrClient, + ): ConnectRequestData { + val deferred = CompletableDeferred() + + val subscription = + client.req( + relays = relays.toList(), + filter = + Filter( + kinds = listOf(NostrConnectEvent.KIND), + tags = mapOf("p" to listOf(ephemeralPubKey)), + since = TimeUtils.now() - 60, + ), + ) { event -> + if (event is NostrConnectEvent && !deferred.isCompleted) { + deferred.complete(event) + } + } + + try { + val event = + withTimeout(NOSTRCONNECT_TIMEOUT_MS) { + deferred.await() + } + + val signerPubkey = event.talkingWith(ephemeralSigner.pubKey) + val decryptedJson = ephemeralSigner.decrypt(event.content, signerPubkey) + val message = OptimizedJsonMapper.fromJsonTo(decryptedJson) + + return when (message) { + is BunkerRequest -> { + if (message.method != BunkerRequestConnect.METHOD_NAME) { + throw Exception("Expected 'connect' method, got '${message.method}'") + } + + val userPubkey = + message.params.getOrNull(0) + ?: throw Exception("Missing user pubkey in connect request") + val receivedSecret = message.params.getOrNull(1) + + if (receivedSecret != expectedSecret) { + throw Exception("Secret mismatch in connect request") + } + + ConnectRequestData( + requestId = message.id, + signerPubkey = signerPubkey, + userPubkey = userPubkey, + ) + } + + is BunkerResponse -> { + if (message.error != null) { + throw Exception("Signer rejected connection: ${message.error}") + } + + val userPubkey = + message.result + ?.takeIf { it.length == 64 && Hex.isHex(it) } + ?: signerPubkey + + ConnectRequestData( + requestId = null, + signerPubkey = signerPubkey, + userPubkey = userPubkey, + ) + } + + else -> { + throw Exception("Unexpected NIP-46 message format") + } + } + } finally { + subscription.close() + } + } + + private suspend fun sendAckResponse( + ephemeralSigner: NostrSignerInternal, + connectData: ConnectRequestData, + relays: Set, + client: INostrClient, + ) { + val ackResponse = BunkerResponseAck(id = connectData.requestId!!) + val ackEvent = + NostrConnectEvent.create( + message = ackResponse, + remoteKey = connectData.signerPubkey, + signer = ephemeralSigner, + ) + client.send(ackEvent, relays) + } + + private fun generateSecret(): String { + val bytes = ByteArray(32) + SecureRandom().nextBytes(bytes) + return bytes.joinToString("") { "%02x".format(it) } + } +} 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 fb2e344a8..515043d44 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,11 @@ 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 kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch class NostrSignerRemote( val signer: NostrSignerInternal, @@ -51,6 +56,8 @@ class NostrSignerRemote( val permissions: String? = null, val secret: String? = null, ) : NostrSigner(signer.pubKey) { + private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) + private val manager = RemoteSignerManager( signer = signer, @@ -69,7 +76,9 @@ class NostrSignerRemote( ), ) { event -> if (event is NostrConnectEvent) { - manager.newResponse(event) + scope.launch { + manager.newResponse(event) + } } } @@ -79,6 +88,7 @@ class NostrSignerRemote( fun closeSubscription() { subscription.close() + scope.cancel() } override fun isWriteable(): Boolean = true 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 index 3bad658c7..95432f61f 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/NostrSignerRemoteIsolationTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/NostrSignerRemoteIsolationTest.kt @@ -30,7 +30,6 @@ 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 @@ -96,6 +95,12 @@ private class TrackingNostrClient : INostrClient { override fun getReqFiltersOrNull(subId: String): Map>? = null override fun getCountFiltersOrNull(subId: String): Map>? = null + + override fun activeRequests(url: NormalizedRelayUrl): Map> = emptyMap() + + override fun activeCounts(url: NormalizedRelayUrl): Map> = emptyMap() + + override fun activeOutboxCache(url: NormalizedRelayUrl): Set = emptySet() } /** @@ -201,15 +206,12 @@ class NostrSignerRemoteIsolationTest { } /** - * 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. + * Verify subscription filter does NOT use a `since` timestamp, + * matching upstream behavior (PR #1789 removed it). */ @Test - fun subscriptionFilterHasSinceTimestamp() { + fun subscriptionFilterHasNoSinceTimestamp() { val trackingClient = TrackingNostrClient() - val beforeTime = TimeUtils.now() NostrSignerRemote( signer = NostrSignerInternal(KeyPair()), @@ -223,20 +225,9 @@ class NostrSignerRemoteIsolationTest { 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", + filter.since == null, + "Filter should not have a 'since' timestamp", ) } } From c67781f87de44a5dff71dd1e6d78d77170795584 Mon Sep 17 00:00:00 2001 From: Crowdin Bot Date: Wed, 11 Mar 2026 11:49:38 +0000 Subject: [PATCH 17/17] New Crowdin translations by GitHub Action --- .../src/main/res/values-fr-rFR/strings.xml | 131 ++++++++++++++++++ .../src/main/res/values-hu-rHU/strings.xml | 28 ++++ 2 files changed, 159 insertions(+) diff --git a/amethyst/src/main/res/values-fr-rFR/strings.xml b/amethyst/src/main/res/values-fr-rFR/strings.xml index e75747639..eb90843ab 100644 --- a/amethyst/src/main/res/values-fr-rFR/strings.xml +++ b/amethyst/src/main/res/values-fr-rFR/strings.xml @@ -150,6 +150,7 @@ Pronoms Adresse LN URL LN (obsolete) + Enregistrer sur le téléphone Enregistrer dans la Galerie Image enregistrée dans la galerie Le téléchargement de la vidéo a démarré… @@ -158,6 +159,7 @@ Vidéo enregistrée dans la galerie vidéo du téléphone Impossible d\'enregistrer la vidéo Charger une image + Téléverser un fichier Prendre une photo Enregistrer une vidéo Enregistrer un message @@ -421,11 +423,18 @@ Secret Wallet Connect Montrer la clé secrète Clé privée nsec / hex + Connecté + Non connecté + Avancé : saisissez manuellement les détails de la connexion + Confidentialité des Zaps + Contrôle comment votre identité est affichée lorsque vous envoyez un zap. + Connecter le portefeuille Montant engagé en Sats Message Sondage Champs requis: Bénéficiaires du Zap Description du sondage primaire… + Option Option %s Description de l\'option du Sondage Champs optionnels : @@ -433,6 +442,8 @@ Zap maximum Consensus (0–100)% + Date & heure de clôture du sondage + Le sondage se termine dans %1$s Clôturer après jours Impossible de voter @@ -440,6 +451,7 @@ Montant des Zap Un seul vote par utilisateur est autorisé sur ce type de sondage "Recherche de l'Événement %1$s" + Envoyer Zap Ajouter un message public Ajouter un message privé Ajouter un message facture @@ -507,6 +519,7 @@ Suivre via Proxy Autour de moi Général + Échecs Liste en Sourdine Listes de suivi Ce sont des listes de suivi conçues pour votre propre utilisation. Vous pouvez suivre des utilisateurs en privé ou publiquement @@ -656,6 +669,7 @@ Le nombre d\'octets qui ont été reçus depuis ce relais, y compris les filtres et les événements Une erreur s\'est produite en récupérant les informations du relay depuis %1$s Propriétaire + Utilisé par Clé de service Exécution %1$s Exécution %1$s (%2$s) @@ -685,6 +699,12 @@ Termes & Conditions N/A Erreurs et Notifications de ce Relais + Abonnements actifs + %1$d auteurs + %1$d ids + depuis %1$s + jusqu\'à %1$s + limite %1$d Longueur du message Abonnements Filtres @@ -743,9 +763,14 @@ Cette communauté n\'a pas de description. Parlez au propriétaire pour en ajouter une Contenu sensible Ajouter un avertissement de contenu sensible avant de montrer ce contenu + Préférences de l\'UI Préférences de l\'application Préférences utilisateur + Traductions + Réactions Paramètres + Paramètres du compte + Paramètres de l\'application Toujours Wifi uniquement WiFi illimité @@ -775,6 +800,9 @@ Spammeurs Silencieux. Cliquer pour réactiver le son Son activé. Cliquer pour mettre en silencieux + Revenir %d secondes en arrière + Avancer de %d secondes + Image dans l\'image Recherche d\'enregistrements locaux et distants L\'adresse Nostr a été vérfiée La vérification de l\'adresse Nostr a échouée @@ -823,6 +851,7 @@ Quand charger les images Copier dans le presse-papiers Copier dans le presse-papiers + Copier le nprofile dans le presse-papiers Copier npub dans le presse-papiers Partager ou Enregistrer Copier l\'URL dans le presse-papiers @@ -998,6 +1027,7 @@ Notifications Général Vidéos courtes + Échecs Filtres de Sécurité Nouveau Message Nouveaux Shorts : images ou vidéos @@ -1011,6 +1041,19 @@ J\'aime Zap Modifier les réactions rapides + Activé + Afficher le nombre + Réorganiser + Répondre + Répondre à cette note + Boost + Republier ou citer cette note + Aimer + Réagir à cette note avec un émoji + Zap + Envoyer un paiement lightning à l\'auteur + Partager + Partager cette note à l\'extérieur Photo de Profil de %1$s Relais %1$s Développer la liste des relais @@ -1030,6 +1073,7 @@ Ajouter un avertissement de contenu Retirer l\'avertissement de contenu Afficher npub en tant que QR code + Afficher le nprofile en tant que code QR Adresse invalide Amethyst a reçu une URI à ouvrir mais cette URI était invalide : %1$s Relais de boîte de réception MP @@ -1126,6 +1170,7 @@ Punaise Scanner le QR code Accéder au fournisseur de portefeuille tiers Alby + Sélectionner la date Il n\'est pas possible de répondre à un brouillon Il n\'est pas possible de citer un brouillon Il n\'est pas possible de réagir à un brouillon @@ -1191,6 +1236,7 @@ Le relais auquel tous les utilisateurs de ce chat se connectent Partager l\'image… Impossible de partager l\'image, veuillez réessayer plus tard… + Partager la vidéo… Impossible de partager l\'image, veuillez réessayer plus tard… Téléchargement de la vidéo… Hashtag de recherche : #%1$s @@ -1229,9 +1275,94 @@ Kinds Appuyer pour afficher les détails %1$s envoyé + Diffuser les résultats Diffusions (%1$d) Réduire Agrandir +%1$d diffusions supplémentaires + Délai expiré + Nouvelle tentative… + Réessayer + Afficher + Fermer + %1$d/%2$d + Diffusion + Diffusion de %1$s + Diffusion de %1$d événements… + %1$d événements envoyés + Certains événements ont échoué + Tous les événements ont réussi + Tous les événements ont échoué + Réaction + Message vocal + Réponse vocale + Confirmer + Suivant + Ajouter un bouton d\'option de sondage + Retour + Approuver + Connecté + Preuve sociale + Valider + Redémarrer + Accepter + Refuser + Nouveau jeu + Nouvelle partie d\'échecs + Chargement de la partie\u2026 + Partie introuvable + Cette partie a pu se terminer ou est en attente de l\'adversaire. + ID de la partie : %1$s\u2026 + et %1$d autres + Supprimer %1$s + Applications + Recommandations d\'applications + Paramètres utilisateur + Piste audio + Relais bloqués + Calendrier + Rendez-vous + Jeux d\'échecs + Commentaires + Suppressions + Brouillons + Packs d\'émojis + Liste des packs d\'émoji + Chat éphémère + Salles de discussion éphémères + Données médicales + Diffusions en direct + Zaps + Requête NWC + Réponse NWC + Zaps privés + Blogs + Profil + Liste en sourdine + Nostr Connect + Listes de personnes + Photos + Sondage + Relais privés + Message public + Réactions + Relais de recherche + Statut utilisateur + Notes + Modifications + Torrents + Vidéos + Vidéos courtes + Message vocal + Réponse vocale + Wiki + Sélectionnez les utilisateurs à suivre + Profil à importer depuis + recherche, npub1…, alice@exemple.com + Rechercher un autre + Importer plus + Continuer + Ignorer pour le moment + Tout sélectionner diff --git a/amethyst/src/main/res/values-hu-rHU/strings.xml b/amethyst/src/main/res/values-hu-rHU/strings.xml index 44b94c5a4..92f57ea00 100644 --- a/amethyst/src/main/res/values-hu-rHU/strings.xml +++ b/amethyst/src/main/res/values-hu-rHU/strings.xml @@ -150,6 +150,7 @@ Megszólítás LN-cím LN-webcím (elavult) + Mentés a telefonra Mentés a galériába Kép mentve a képgalériába A videó letöltése megkezdődött… @@ -158,6 +159,7 @@ Videó mentve a videógalériába Nem sikerült menteni a videót Kép feltöltése + Fájl feltöltése Kép készítése Videó rögzítése Hangüzenet rögzítése @@ -1046,6 +1048,7 @@ Rövidek Sakk Biztonsági szűrők + Követettek importálása Új bejegyzés Új rövidek: képek vagy videók Új közösségi bejegyzés @@ -1475,4 +1478,29 @@ Hangüzenet Hangos válasz Wiki + Kezdje egy remek hírfolyammal, követve azokat az embereket, akikben megbízik. + Követési lista importálása + Felhasználók kiválasztása a követéshez + Importálandó profil + keresés, npub1…, aliz@pelda.hu + Támogatja az npub, nprofile, NIP-05, hex és namecoin (.bit, d/, id/) formátumokat + Követési lista keresése + Borravaló + %1$d felhasználói fiók megtalálva + %1$d kiválasztva + Feloldva a Namecoinon keresztül + Most %1$d fiókot követ + A hírfolyam elkészült. + Kihagyás + Másik keresése + %1$d felhasználói fiók követése + Továbbiak importálása + Folytatás + Kihagyás csak most + %1$s feloldása… + Követési lista lekérdezése… + Nem találhatók követések + %1$d felhasználói fiók követése… + "Írja be egy barátja vagy közösségi vezető profilját. Használhatja az npub, NIP-05 címüket, vagy egy Namecoin nevet, például aliz@pelda.bit vagy id/aliz a blokklánc által ellenőrzött azonosítókhoz." + Összes kijelölése