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:
@@ -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() },
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+281
-60
@@ -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),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+54
@@ -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")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
+104
-34
@@ -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))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user