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 <noreply@anthropic.com>
This commit is contained in:
nrobi144
2026-03-05 07:19:54 +02:00
parent 9f903709e7
commit 6c24c52104
5 changed files with 558 additions and 120 deletions
@@ -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() },
)
}
}
}
}
@@ -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>(AccountState.LoggedOut)
@@ -73,43 +104,151 @@ class AccountManager private constructor(
private val _nwcConnection = MutableStateFlow<Nip47WalletConnect.Nip47URINorm?>(null)
val nwcConnection: StateFlow<Nip47WalletConnect.Nip47URINorm?> = _nwcConnection.asStateFlow()
/**
* Loads the last saved account from secure storage.
* Call on app startup.
*/
suspend fun loadSavedAccount(): Result<AccountState.LoggedIn> {
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>(SignerConnectionState.NotRemote)
val signerConnectionState: StateFlow<SignerConnectionState> = _signerConnectionState.asStateFlow()
private val _forceLogoutReason = MutableStateFlow<String?>(null)
val forceLogoutReason: StateFlow<String?> = _forceLogoutReason.asStateFlow()
private var heartbeatJob: Job? = null
// --- Account loading ---
suspend fun loadSavedAccount(client: INostrClient? = null): Result<AccountState.LoggedIn> =
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<AccountState.LoggedIn> {
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<AccountState.LoggedIn> {
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<AccountState.LoggedIn> =
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<Unit> {
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<AccountState.LoggedIn> {
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<Nip47WalletConnect.Nip47URINorm> =
@@ -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")
}
@@ -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),
)
}
}
@@ -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")
}
},
)
}
@@ -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<Unit>,
onGenerateNew: () -> Unit,
onLoginBunker: (suspend (String) -> Result<Unit>)? = 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<String?>(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))
}
}
}
}