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.network.DesktopRelayConnectionManager
import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator
import com.vitorpamplona.amethyst.desktop.ui.ComposeNoteDialog 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.LoginScreen
import com.vitorpamplona.amethyst.desktop.ui.ZapFeedback 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.chats.DmSendTracker
import com.vitorpamplona.amethyst.desktop.ui.deck.AddColumnDialog import com.vitorpamplona.amethyst.desktop.ui.deck.AddColumnDialog
import com.vitorpamplona.amethyst.desktop.ui.deck.DeckColumnType 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.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeoutOrNull
private val isMacOS = System.getProperty("os.name").lowercase().contains("mac") 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). * Desktop navigation state — used for in-column navigation (drill-down).
*/ */
sealed class DesktopScreen { 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( data class UserProfile(
val pubKeyHex: String, val pubKeyHex: String,
@@ -128,7 +132,7 @@ sealed class DesktopScreen {
val noteId: String, val noteId: String,
) : DesktopScreen() ) : DesktopScreen()
object Settings : DesktopScreen() data object Settings : DesktopScreen()
} }
fun main() = fun main() =
@@ -398,20 +402,40 @@ fun App(
// Try to load saved account on startup // Try to load saved account on startup
DisposableEffect(Unit) { DisposableEffect(Unit) {
scope.launch(Dispatchers.IO) {
// Load account on IO dispatcher to avoid blocking UI with password prompt (readLine)
accountManager.loadSavedAccount()
}
relayManager.addDefaultRelays() relayManager.addDefaultRelays()
relayManager.connect() relayManager.connect()
// Start subscriptions coordinator
subscriptionsCoordinator.start() 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 { onDispose {
accountManager.stopHeartbeat()
subscriptionsCoordinator.clear() subscriptionsCoordinator.clear()
relayManager.disconnect() relayManager.disconnect()
scope.cancel()
} }
} }
@@ -426,10 +450,21 @@ fun App(
is AccountState.LoggedOut -> { is AccountState.LoggedOut -> {
LoginScreen( LoginScreen(
accountManager = accountManager, 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 -> { is AccountState.LoggedIn -> {
val account = accountState as AccountState.LoggedIn val account = accountState as AccountState.LoggedIn
val nwcConnection by accountManager.nwcConnection.collectAsState() 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.hexToByteArray
import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair 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.NostrSigner
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal 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.decodePrivateKeyAsHexOrNull
import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull
import com.vitorpamplona.quartz.nip19Bech32.toNpub import com.vitorpamplona.quartz.nip19Bech32.toNpub
import com.vitorpamplona.quartz.nip19Bech32.toNsec import com.vitorpamplona.quartz.nip19Bech32.toNsec
import com.vitorpamplona.quartz.nip46RemoteSigner.signer.NostrSignerRemote
import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect 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.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import java.io.File 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 { sealed class AccountState {
data object LoggedOut : AccountState() data object LoggedOut : AccountState()
data object ConnectingRelays : AccountState()
data class LoggedIn( data class LoggedIn(
val signer: NostrSigner, val signer: NostrSigner,
val pubKeyHex: String, val pubKeyHex: String,
val npub: String, val npub: String,
val nsec: String?, val nsec: String?,
val isReadOnly: Boolean, val isReadOnly: Boolean,
val signerType: SignerType = SignerType.Internal,
) : AccountState() ) : AccountState()
} }
@@ -55,16 +84,18 @@ class AccountManager private constructor(
private val secureStorage: SecureKeyStorage, private val secureStorage: SecureKeyStorage,
) { ) {
companion object { 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 { fun create(context: Any? = null): AccountManager {
val storage = SecureKeyStorage.create(context) val storage = SecureKeyStorage.create(context)
return AccountManager(storage) 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) private val _accountState = MutableStateFlow<AccountState>(AccountState.LoggedOut)
@@ -73,43 +104,151 @@ class AccountManager private constructor(
private val _nwcConnection = MutableStateFlow<Nip47WalletConnect.Nip47URINorm?>(null) private val _nwcConnection = MutableStateFlow<Nip47WalletConnect.Nip47URINorm?>(null)
val nwcConnection: StateFlow<Nip47WalletConnect.Nip47URINorm?> = _nwcConnection.asStateFlow() val nwcConnection: StateFlow<Nip47WalletConnect.Nip47URINorm?> = _nwcConnection.asStateFlow()
/** private val _signerConnectionState = MutableStateFlow<SignerConnectionState>(SignerConnectionState.NotRemote)
* Loads the last saved account from secure storage. val signerConnectionState: StateFlow<SignerConnectionState> = _signerConnectionState.asStateFlow()
* Call on app startup.
*/ private val _forceLogoutReason = MutableStateFlow<String?>(null)
suspend fun loadSavedAccount(): Result<AccountState.LoggedIn> { val forceLogoutReason: StateFlow<String?> = _forceLogoutReason.asStateFlow()
return try {
// For simplicity, we'll store the last logged-in npub in a simple file private var heartbeatJob: Job? = null
// and use SecureKeyStorage to retrieve the private key
// --- Account loading ---
suspend fun loadSavedAccount(client: INostrClient? = null): Result<AccountState.LoggedIn> =
try {
val lastNpub = getLastNpub() ?: return Result.failure(Exception("No saved account")) val lastNpub = getLastNpub() ?: return Result.failure(Exception("No saved account"))
val privKeyHex = // Check for bunker account first
secureStorage.getPrivateKey(lastNpub) val bunkerUri = getBunkerUri()
?: return Result.failure(Exception("Private key not found for $lastNpub")) if (bunkerUri != null && client != null) {
loadBunkerAccount(bunkerUri, lastNpub, client)
val keyPair = KeyPair(privKey = privKeyHex.hexToByteArray()) } else {
val signer = NostrSignerInternal(keyPair) loadInternalAccount(lastNpub)
}
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)
} catch (e: Exception) { } catch (e: Exception) {
Result.failure(e) 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)
} }
/** private suspend fun loadBunkerAccount(
* Saves the current account to secure storage. 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> { suspend fun saveCurrentAccount(): Result<Unit> {
val current = currentAccount() ?: return Result.failure(Exception("No account logged in")) 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) { if (current.isReadOnly || current.nsec == null) {
return Result.failure(Exception("Cannot save read-only account")) return Result.failure(Exception("Cannot save read-only account"))
} }
@@ -146,7 +285,6 @@ class AccountManager private constructor(
fun loginWithKey(keyInput: String): Result<AccountState.LoggedIn> { fun loginWithKey(keyInput: String): Result<AccountState.LoggedIn> {
val trimmedInput = keyInput.trim() val trimmedInput = keyInput.trim()
// Try as private key first (nsec or hex)
val privKeyHex = decodePrivateKeyAsHexOrNull(trimmedInput) val privKeyHex = decodePrivateKeyAsHexOrNull(trimmedInput)
if (privKeyHex != null) { if (privKeyHex != null) {
return try { return try {
@@ -168,7 +306,6 @@ class AccountManager private constructor(
} }
} }
// Try as public key (npub or hex) - read-only mode
val pubKeyHex = decodePublicKeyAsHexOrNull(trimmedInput) val pubKeyHex = decodePublicKeyAsHexOrNull(trimmedInput)
if (pubKeyHex != null) { if (pubKeyHex != null) {
return try { 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) { suspend fun logout(deleteKey: Boolean = false) {
val current = currentAccount() val current = currentAccount()
if (deleteKey && current != null) { if (current != null) {
try { // Clean up remote signer if bunker account
secureStorage.deletePrivateKey(current.npub) if (current.signerType is SignerType.Remote) {
clearLastNpub() (current.signer as? NostrSignerRemote)?.closeSubscription()
} catch (e: SecureStorageException) { if (deleteKey) {
// Log error but still logout 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 _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 isLoggedIn(): Boolean = _accountState.value is AccountState.LoggedIn
fun currentAccount(): AccountState.LoggedIn? = _accountState.value as? AccountState.LoggedIn fun currentAccount(): AccountState.LoggedIn? = _accountState.value as? AccountState.LoggedIn
// NWC (Nostr Wallet Connect) methods // --- NWC ---
fun hasNwcSetup(): Boolean = _nwcConnection.value != null fun hasNwcSetup(): Boolean = _nwcConnection.value != null
fun setNwcConnection(uri: String): Result<Nip47WalletConnect.Nip47URINorm> = fun setNwcConnection(uri: String): Result<Nip47WalletConnect.Nip47URINorm> =
@@ -233,42 +436,60 @@ class AccountManager private constructor(
if (!uri.isNullOrEmpty()) { if (!uri.isNullOrEmpty()) {
try { try {
_nwcConnection.value = Nip47WalletConnect.parse(uri) _nwcConnection.value = Nip47WalletConnect.parse(uri)
} catch (e: Exception) { } catch (_: Exception) {
// Invalid stored URI, clear it
getNwcFile().delete() 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) { private fun saveNwcUri(uri: String) {
val file = getNwcFile() amethystDir.mkdirs()
file.parentFile?.mkdirs() getNwcFile().writeText(uri)
file.writeText(uri)
} }
private fun getNwcFile(): java.io.File { private fun getNwcFile(): File = File(amethystDir, "nwc_connection.txt")
val homeDir = System.getProperty("user.home")
return java.io.File(homeDir, ".amethyst/nwc_connection.txt")
}
// Simple file-based storage for last npub (non-sensitive data)
private fun getLastNpub(): String? { private fun getLastNpub(): String? {
val file = getPrefsFile() val file = getPrefsFile()
return if (file.exists()) file.readText().trim().takeIf { it.isNotEmpty() } else null return if (file.exists()) file.readText().trim().takeIf { it.isNotEmpty() } else null
} }
private fun saveLastNpub(npub: String) { private fun saveLastNpub(npub: String) {
val file = getPrefsFile() amethystDir.mkdirs()
file.parentFile?.mkdirs() getPrefsFile().writeText(npub)
file.writeText(npub)
} }
private fun clearLastNpub() { private fun clearLastNpub() {
getPrefsFile().delete() getPrefsFile().delete()
} }
private fun getPrefsFile(): File { private fun getPrefsFile(): File = File(amethystDir, "last_account.txt")
val homeDir = System.getProperty("user.home")
return File(homeDir, ".amethyst/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.fillMaxSize
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding 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.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable 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.account.AccountState
import com.vitorpamplona.amethyst.desktop.ui.auth.LoginCard import com.vitorpamplona.amethyst.desktop.ui.auth.LoginCard
import com.vitorpamplona.amethyst.desktop.ui.auth.NewKeyWarningCard import com.vitorpamplona.amethyst.desktop.ui.auth.NewKeyWarningCard
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
@Composable @Composable
fun LoginScreen( fun LoginScreen(
accountManager: AccountManager, accountManager: AccountManager,
relayClient: INostrClient?,
onLoginSuccess: () -> Unit, onLoginSuccess: () -> Unit,
) { ) {
var showNewKeyDialog by remember { mutableStateOf(false) } var showNewKeyDialog by remember { mutableStateOf(false) }
@@ -81,9 +86,8 @@ fun LoginScreen(
LoginCard( LoginCard(
onLogin = { keyInput -> onLogin = { keyInput ->
accountManager.loginWithKey(keyInput).map { accountManager.loginWithKey(keyInput).map {
// Save account to secure storage (use IO dispatcher to avoid blocking UI) scope.launch {
scope.launch(Dispatchers.IO) { withContext(Dispatchers.IO) { accountManager.saveCurrentAccount() }
accountManager.saveCurrentAccount()
onLoginSuccess() onLoginSuccess()
} }
} }
@@ -92,18 +96,28 @@ fun LoginScreen(
generatedAccount = accountManager.generateNewAccount() generatedAccount = accountManager.generateNewAccount()
showNewKeyDialog = true 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)) Spacer(Modifier.height(24.dp))
NewKeyWarningCard( NewKeyWarningCard(
npub = generatedAccount!!.npub, npub = account.npub,
nsec = generatedAccount!!.nsec, nsec = account.nsec,
onContinue = { onContinue = {
showNewKeyDialog = false showNewKeyDialog = false
// Save generated account (use IO dispatcher to avoid blocking UI) scope.launch {
scope.launch(Dispatchers.IO) { withContext(Dispatchers.IO) { accountManager.saveCurrentAccount() }
accountManager.saveCurrentAccount()
onLoginSuccess() 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.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text import androidx.compose.material3.Text
@@ -39,6 +41,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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_subtitle
import com.vitorpamplona.amethyst.commons.resources.login_card_title import com.vitorpamplona.amethyst.commons.resources.login_card_title
import com.vitorpamplona.amethyst.commons.resources.login_generate_button 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 import org.jetbrains.compose.resources.stringResource
/** private val HEX_64_REGEX = Regex("^[0-9a-fA-F]{64}$")
* Login card with Nostr key input field and action buttons.
* fun validateBunkerUri(input: String): String? {
* @param onLogin Callback when login is attempted with the key input val trimmed = input.trim()
* @param onGenerateNew Callback when "Generate New" is clicked if (!trimmed.startsWith("bunker://", ignoreCase = true)) return "Not a bunker URI"
* @param modifier Modifier for the card
* @param cardWidth Width of the card (default 400.dp) val afterScheme = trimmed.substring("bunker://".length)
* @param title Card title val parts = afterScheme.split("?", limit = 2)
* @param subtitle Subtitle/hint text 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 @Composable
fun LoginCard( fun LoginCard(
onLogin: (String) -> Result<Unit>, onLogin: (String) -> Result<Unit>,
onGenerateNew: () -> Unit, onGenerateNew: () -> Unit,
onLoginBunker: (suspend (String) -> Result<Unit>)? = null,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
cardWidth: Dp = 400.dp, cardWidth: Dp = 400.dp,
title: String = stringResource(Res.string.login_card_title), title: String = stringResource(Res.string.login_card_title),
@@ -72,6 +90,9 @@ fun LoginCard(
) { ) {
var keyInput by remember { mutableStateOf("") } var keyInput by remember { mutableStateOf("") }
var errorMessage by remember { mutableStateOf<String?>(null) } 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( Card(
modifier = modifier.width(cardWidth), modifier = modifier.width(cardWidth),
@@ -103,36 +124,85 @@ fun LoginCard(
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
Text( if (isBunker) {
subtitle, Text(
style = MaterialTheme.typography.bodySmall, "This URI connects to your remote signer. Treat it like a password.",
color = MaterialTheme.colorScheme.onSurfaceVariant, style = MaterialTheme.typography.bodySmall,
) color = MaterialTheme.colorScheme.primary,
)
} else {
Text(
subtitle,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Spacer(Modifier.height(16.dp)) Spacer(Modifier.height(16.dp))
Row( if (isConnecting) {
modifier = Modifier.fillMaxWidth(), Row(
horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth(),
) { horizontalArrangement = Arrangement.Center,
Button( verticalAlignment = Alignment.CenterVertically,
onClick = {
onLogin(keyInput).fold(
onSuccess = { /* handled by caller */ },
onFailure = { errorMessage = it.message },
)
},
modifier = Modifier.weight(1f),
enabled = keyInput.isNotBlank(),
) { ) {
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,
)
} }
} else {
OutlinedButton( Row(
onClick = onGenerateNew, modifier = Modifier.fillMaxWidth(),
modifier = Modifier.weight(1f), 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))
}
} }
} }
} }