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.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() },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+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.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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+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.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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user