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)) + } } } }