feat(desktop): add nostrconnect:// login flow for QR-based signer pairing
Adds client-initiated NIP-46 (nostrconnect://) flow so users can pair with a remote signer (e.g. Amber) by scanning a QR code instead of manually copying a bunker:// URI. Changes: - Fix RemoteSignerManager to decrypt NIP-44 content before parsing - Add CoroutineScope to NostrSignerRemote for async event callbacks - Add loginWithNostrConnect() to AccountManager with 120s timeout - Add QrCodeCanvas composable using ZXing for QR generation - Add "Connect" tab to LoginCard showing QR + copyable URI + waiting state - Wire nostrconnect flow through LoginScreen Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -49,6 +49,9 @@ dependencies {
|
|||||||
// Collections
|
// Collections
|
||||||
implementation(libs.kotlinx.collections.immutable)
|
implementation(libs.kotlinx.collections.immutable)
|
||||||
|
|
||||||
|
// QR code generation (ZXing core)
|
||||||
|
implementation(libs.zxing)
|
||||||
|
|
||||||
// Testing
|
// Testing
|
||||||
testImplementation(libs.kotlin.test)
|
testImplementation(libs.kotlin.test)
|
||||||
testImplementation(libs.kotlinx.coroutines.test)
|
testImplementation(libs.kotlinx.coroutines.test)
|
||||||
|
|||||||
+156
@@ -23,10 +23,15 @@ package com.vitorpamplona.amethyst.desktop.account
|
|||||||
import androidx.compose.runtime.Stable
|
import androidx.compose.runtime.Stable
|
||||||
import com.vitorpamplona.amethyst.commons.keystorage.SecureKeyStorage
|
import com.vitorpamplona.amethyst.commons.keystorage.SecureKeyStorage
|
||||||
import com.vitorpamplona.amethyst.commons.keystorage.SecureStorageException
|
import com.vitorpamplona.amethyst.commons.keystorage.SecureStorageException
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper
|
||||||
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.relay.client.INostrClient
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.req
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||||
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.nip01Core.signers.SignerExceptions
|
||||||
@@ -34,8 +39,13 @@ 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.BunkerRequest
|
||||||
|
import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestConnect
|
||||||
|
import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponseAck
|
||||||
|
import com.vitorpamplona.quartz.nip46RemoteSigner.NostrConnectEvent
|
||||||
import com.vitorpamplona.quartz.nip46RemoteSigner.signer.NostrSignerRemote
|
import com.vitorpamplona.quartz.nip46RemoteSigner.signer.NostrSignerRemote
|
||||||
import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect
|
import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect
|
||||||
|
import kotlinx.coroutines.CompletableDeferred
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
@@ -44,7 +54,9 @@ import kotlinx.coroutines.flow.StateFlow
|
|||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.isActive
|
import kotlinx.coroutines.isActive
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withTimeout
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
import java.security.SecureRandom
|
||||||
|
|
||||||
sealed class SignerType {
|
sealed class SignerType {
|
||||||
data object Internal : SignerType()
|
data object Internal : SignerType()
|
||||||
@@ -93,6 +105,8 @@ class AccountManager internal constructor(
|
|||||||
internal const val HEARTBEAT_INTERVAL_MS = 60_000L
|
internal const val HEARTBEAT_INTERVAL_MS = 60_000L
|
||||||
internal const val MAX_CONSECUTIVE_FAILURES = 3
|
internal const val MAX_CONSECUTIVE_FAILURES = 3
|
||||||
internal const val BUNKER_EPHEMERAL_KEY_ALIAS = "bunker_ephemeral"
|
internal const val BUNKER_EPHEMERAL_KEY_ALIAS = "bunker_ephemeral"
|
||||||
|
internal const val NOSTRCONNECT_TIMEOUT_MS = 120_000L
|
||||||
|
internal val NIP46_RELAYS = listOf("wss://relay.nsec.app")
|
||||||
}
|
}
|
||||||
|
|
||||||
private val amethystDir: File by lazy {
|
private val amethystDir: File by lazy {
|
||||||
@@ -226,6 +240,148 @@ class AccountManager internal constructor(
|
|||||||
Result.failure(Exception("Connection failed: ${e.message}"))
|
Result.failure(Exception("Connection failed: ${e.message}"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Nostrconnect login ---
|
||||||
|
|
||||||
|
suspend fun loginWithNostrConnect(
|
||||||
|
client: INostrClient,
|
||||||
|
onUriGenerated: (String) -> Unit,
|
||||||
|
): Result<AccountState.LoggedIn> =
|
||||||
|
try {
|
||||||
|
val ephemeralKeyPair = KeyPair()
|
||||||
|
val ephemeralSigner = NostrSignerInternal(ephemeralKeyPair)
|
||||||
|
val secret = generateNostrConnectSecret()
|
||||||
|
val ephemeralPubKey = ephemeralKeyPair.pubKey.toHexKey()
|
||||||
|
|
||||||
|
val relays = NIP46_RELAYS
|
||||||
|
val relayParams = relays.joinToString("&") { "relay=$it" }
|
||||||
|
val uri = "nostrconnect://$ephemeralPubKey?$relayParams&secret=$secret&name=Amethyst%20Desktop"
|
||||||
|
onUriGenerated(uri)
|
||||||
|
|
||||||
|
val normalizedRelays = relays.map { NormalizedRelayUrl(it) }.toSet()
|
||||||
|
val connectData = waitForConnectRequest(ephemeralSigner, ephemeralPubKey, normalizedRelays, secret, client)
|
||||||
|
|
||||||
|
sendAckResponse(ephemeralSigner, connectData, normalizedRelays, client)
|
||||||
|
|
||||||
|
val remoteSigner =
|
||||||
|
NostrSignerRemote(
|
||||||
|
signer = ephemeralSigner,
|
||||||
|
remotePubkey = connectData.signerPubkey,
|
||||||
|
relays = normalizedRelays,
|
||||||
|
client = client,
|
||||||
|
)
|
||||||
|
remoteSigner.openSubscription()
|
||||||
|
|
||||||
|
val syntheticBunkerUri = "bunker://${connectData.signerPubkey}?$relayParams"
|
||||||
|
|
||||||
|
val state =
|
||||||
|
AccountState.LoggedIn(
|
||||||
|
signer = remoteSigner,
|
||||||
|
pubKeyHex = connectData.userPubkey,
|
||||||
|
npub = connectData.userPubkey.hexToByteArray().toNpub(),
|
||||||
|
nsec = null,
|
||||||
|
isReadOnly = false,
|
||||||
|
signerType = SignerType.Remote(syntheticBunkerUri),
|
||||||
|
)
|
||||||
|
_accountState.value = state
|
||||||
|
_signerConnectionState.value = SignerConnectionState.Connected
|
||||||
|
|
||||||
|
saveBunkerAccount(
|
||||||
|
bunkerUri = syntheticBunkerUri,
|
||||||
|
ephemeralPrivKeyHex = ephemeralKeyPair.privKey!!.toHexKey(),
|
||||||
|
npub = state.npub,
|
||||||
|
)
|
||||||
|
|
||||||
|
Result.success(state)
|
||||||
|
} catch (e: kotlinx.coroutines.TimeoutCancellationException) {
|
||||||
|
Result.failure(Exception("Timed out waiting for signer. Ensure the signer app scanned the QR code."))
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Result.failure(Exception("Connection failed: ${e.message}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun waitForConnectRequest(
|
||||||
|
ephemeralSigner: NostrSignerInternal,
|
||||||
|
ephemeralPubKey: HexKey,
|
||||||
|
relays: Set<NormalizedRelayUrl>,
|
||||||
|
expectedSecret: String,
|
||||||
|
client: INostrClient,
|
||||||
|
): ConnectRequestData {
|
||||||
|
val deferred = CompletableDeferred<NostrConnectEvent>()
|
||||||
|
|
||||||
|
val subscription =
|
||||||
|
client.req(
|
||||||
|
relays = relays.toList(),
|
||||||
|
filter =
|
||||||
|
Filter(
|
||||||
|
kinds = listOf(NostrConnectEvent.KIND),
|
||||||
|
tags = mapOf("p" to listOf(ephemeralPubKey)),
|
||||||
|
),
|
||||||
|
) { event ->
|
||||||
|
if (event is NostrConnectEvent && !deferred.isCompleted) {
|
||||||
|
deferred.complete(event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
val event =
|
||||||
|
withTimeout(NOSTRCONNECT_TIMEOUT_MS) {
|
||||||
|
deferred.await()
|
||||||
|
}
|
||||||
|
|
||||||
|
val otherPubkey = event.talkingWith(ephemeralSigner.pubKey)
|
||||||
|
val decryptedJson = ephemeralSigner.decrypt(event.content, otherPubkey)
|
||||||
|
val message = OptimizedJsonMapper.fromJsonTo<BunkerRequest>(decryptedJson)
|
||||||
|
|
||||||
|
if (message.method != BunkerRequestConnect.METHOD_NAME) {
|
||||||
|
throw Exception("Expected 'connect' method, got '${message.method}'")
|
||||||
|
}
|
||||||
|
|
||||||
|
val userPubkey =
|
||||||
|
message.params.getOrNull(0)
|
||||||
|
?: throw Exception("Missing user pubkey in connect request")
|
||||||
|
val receivedSecret = message.params.getOrNull(1)
|
||||||
|
|
||||||
|
if (receivedSecret != expectedSecret) {
|
||||||
|
throw Exception("Secret mismatch in connect request")
|
||||||
|
}
|
||||||
|
|
||||||
|
return ConnectRequestData(
|
||||||
|
requestId = message.id,
|
||||||
|
signerPubkey = event.pubKey,
|
||||||
|
userPubkey = userPubkey,
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
subscription.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun sendAckResponse(
|
||||||
|
ephemeralSigner: NostrSignerInternal,
|
||||||
|
connectData: ConnectRequestData,
|
||||||
|
relays: Set<NormalizedRelayUrl>,
|
||||||
|
client: INostrClient,
|
||||||
|
) {
|
||||||
|
val ackResponse = BunkerResponseAck(id = connectData.requestId)
|
||||||
|
val ackEvent =
|
||||||
|
NostrConnectEvent.create(
|
||||||
|
message = ackResponse,
|
||||||
|
remoteKey = connectData.signerPubkey,
|
||||||
|
signer = ephemeralSigner,
|
||||||
|
)
|
||||||
|
client.send(ackEvent, relays)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun generateNostrConnectSecret(): String {
|
||||||
|
val bytes = ByteArray(32)
|
||||||
|
SecureRandom().nextBytes(bytes)
|
||||||
|
return bytes.joinToString("") { "%02x".format(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class ConnectRequestData(
|
||||||
|
val requestId: String,
|
||||||
|
val signerPubkey: HexKey,
|
||||||
|
val userPubkey: HexKey,
|
||||||
|
)
|
||||||
|
|
||||||
private suspend fun saveBunkerAccount(
|
private suspend fun saveBunkerAccount(
|
||||||
bunkerUri: String,
|
bunkerUri: String,
|
||||||
ephemeralPrivKeyHex: String,
|
ephemeralPrivKeyHex: String,
|
||||||
|
|||||||
@@ -106,6 +106,16 @@ fun LoginScreen(
|
|||||||
} else {
|
} else {
|
||||||
null
|
null
|
||||||
},
|
},
|
||||||
|
onLoginNostrConnect =
|
||||||
|
if (relayClient != null) {
|
||||||
|
{ onUriGenerated ->
|
||||||
|
accountManager.loginWithNostrConnect(relayClient, onUriGenerated).map {
|
||||||
|
onLoginSuccess()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
val account = generatedAccount
|
val account = generatedAccount
|
||||||
|
|||||||
+249
-85
@@ -30,21 +30,30 @@ 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.size
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
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.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.PrimaryTabRow
|
||||||
|
import androidx.compose.material3.Tab
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
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.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
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.platform.LocalClipboardManager
|
||||||
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.unit.Dp
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.vitorpamplona.amethyst.commons.resources.Res
|
import com.vitorpamplona.amethyst.commons.resources.Res
|
||||||
@@ -83,16 +92,14 @@ fun LoginCard(
|
|||||||
onLogin: (String) -> Result<Unit>,
|
onLogin: (String) -> Result<Unit>,
|
||||||
onGenerateNew: () -> Unit,
|
onGenerateNew: () -> Unit,
|
||||||
onLoginBunker: (suspend (String) -> Result<Unit>)? = null,
|
onLoginBunker: (suspend (String) -> Result<Unit>)? = null,
|
||||||
|
onLoginNostrConnect: (suspend (onUriGenerated: (String) -> Unit) -> 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),
|
||||||
subtitle: String = stringResource(Res.string.login_card_subtitle),
|
subtitle: String = stringResource(Res.string.login_card_subtitle),
|
||||||
) {
|
) {
|
||||||
var keyInput by remember { mutableStateOf("") }
|
var selectedTab by remember { mutableIntStateOf(0) }
|
||||||
var errorMessage by remember { mutableStateOf<String?>(null) }
|
val tabs = listOf("Paste Key", "Connect")
|
||||||
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),
|
||||||
@@ -113,97 +120,254 @@ fun LoginCard(
|
|||||||
|
|
||||||
Spacer(Modifier.height(16.dp))
|
Spacer(Modifier.height(16.dp))
|
||||||
|
|
||||||
KeyInputField(
|
if (onLoginNostrConnect != null) {
|
||||||
value = keyInput,
|
@Suppress("DEPRECATION")
|
||||||
onValueChange = {
|
PrimaryTabRow(
|
||||||
keyInput = it
|
selectedTabIndex = selectedTab,
|
||||||
errorMessage = null
|
modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(8.dp)),
|
||||||
|
) {
|
||||||
|
tabs.forEachIndexed { index, title ->
|
||||||
|
Tab(
|
||||||
|
selected = selectedTab == index,
|
||||||
|
onClick = { selectedTab = index },
|
||||||
|
text = { Text(title) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
}
|
||||||
|
|
||||||
|
when (selectedTab) {
|
||||||
|
0 -> PasteKeyContent(onLogin, onGenerateNew, onLoginBunker, subtitle)
|
||||||
|
1 -> if (onLoginNostrConnect != null) NostrConnectContent(onLoginNostrConnect)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun PasteKeyContent(
|
||||||
|
onLogin: (String) -> Result<Unit>,
|
||||||
|
onGenerateNew: () -> Unit,
|
||||||
|
onLoginBunker: (suspend (String) -> Result<Unit>)?,
|
||||||
|
subtitle: String,
|
||||||
|
) {
|
||||||
|
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)
|
||||||
|
|
||||||
|
KeyInputField(
|
||||||
|
value = keyInput,
|
||||||
|
onValueChange = {
|
||||||
|
keyInput = it
|
||||||
|
errorMessage = null
|
||||||
|
},
|
||||||
|
errorMessage = errorMessage,
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
|
||||||
|
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))
|
||||||
|
|
||||||
|
if (isConnecting) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.Center,
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
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 {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
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 },
|
||||||
|
)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
errorMessage = errorMessage,
|
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun NostrConnectContent(onLoginNostrConnect: suspend (onUriGenerated: (String) -> Unit) -> Result<Unit>) {
|
||||||
|
var nostrConnectUri by remember { mutableStateOf<String?>(null) }
|
||||||
|
var isConnecting by remember { mutableStateOf(false) }
|
||||||
|
var errorMessage by remember { mutableStateOf<String?>(null) }
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
val clipboardManager = LocalClipboardManager.current
|
||||||
|
|
||||||
|
if (errorMessage != null) {
|
||||||
|
Text(
|
||||||
|
errorMessage!!,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
|
Button(onClick = {
|
||||||
|
errorMessage = null
|
||||||
|
nostrConnectUri = null
|
||||||
|
}) {
|
||||||
|
Text("Try Again")
|
||||||
|
}
|
||||||
|
} else if (!isConnecting) {
|
||||||
|
Text(
|
||||||
|
"Show a QR code for your signer app (e.g. Amber) to scan.",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
isConnecting = true
|
||||||
|
errorMessage = null
|
||||||
|
scope.launch(Dispatchers.IO) {
|
||||||
|
val result =
|
||||||
|
onLoginNostrConnect { uri ->
|
||||||
|
nostrConnectUri = uri
|
||||||
|
}
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
result.onFailure {
|
||||||
|
errorMessage = it.message
|
||||||
|
isConnecting = false
|
||||||
|
nostrConnectUri = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Text("Start Connection")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
val uri = nostrConnectUri
|
||||||
|
if (uri != null) {
|
||||||
|
QrCodeCanvas(
|
||||||
|
data = uri,
|
||||||
|
size = 200.dp,
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
|
|
||||||
|
Text(
|
||||||
|
uri,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
maxLines = 2,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
)
|
)
|
||||||
|
|
||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(8.dp))
|
||||||
|
|
||||||
if (isBunker) {
|
OutlinedButton(
|
||||||
Text(
|
onClick = { clipboardManager.setText(AnnotatedString(uri)) },
|
||||||
"This URI connects to your remote signer. Treat it like a password.",
|
modifier = Modifier.fillMaxWidth(),
|
||||||
style = MaterialTheme.typography.bodySmall,
|
) {
|
||||||
color = MaterialTheme.colorScheme.primary,
|
Text("Copy URI")
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.Center,
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier.size(16.dp),
|
||||||
|
strokeWidth = 2.dp,
|
||||||
)
|
)
|
||||||
} else {
|
Spacer(Modifier.width(8.dp))
|
||||||
Text(
|
Text(
|
||||||
subtitle,
|
"Waiting for signer to connect...",
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
Spacer(Modifier.height(16.dp))
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
if (isConnecting) {
|
horizontalArrangement = Arrangement.Center,
|
||||||
Row(
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
) {
|
||||||
horizontalArrangement = Arrangement.Center,
|
CircularProgressIndicator(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
modifier = Modifier.size(20.dp),
|
||||||
) {
|
strokeWidth = 2.dp,
|
||||||
CircularProgressIndicator(
|
)
|
||||||
modifier = Modifier.size(20.dp),
|
Spacer(Modifier.width(8.dp))
|
||||||
strokeWidth = 2.dp,
|
Text(
|
||||||
)
|
"Generating connection...",
|
||||||
Spacer(Modifier.width(12.dp))
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
Text(
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
"Connecting to remote signer...",
|
)
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
|
||||||
) {
|
|
||||||
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))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+75
@@ -0,0 +1,75 @@
|
|||||||
|
/*
|
||||||
|
* 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.foundation.Canvas
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import androidx.compose.ui.geometry.Size
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.google.zxing.BarcodeFormat
|
||||||
|
import com.google.zxing.EncodeHintType
|
||||||
|
import com.google.zxing.qrcode.QRCodeWriter
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun QrCodeCanvas(
|
||||||
|
data: String,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
size: Dp = 200.dp,
|
||||||
|
foreground: Color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
background: Color = Color.White,
|
||||||
|
) {
|
||||||
|
val matrix =
|
||||||
|
remember(data) {
|
||||||
|
QRCodeWriter().encode(
|
||||||
|
data,
|
||||||
|
BarcodeFormat.QR_CODE,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
mapOf(EncodeHintType.MARGIN to 1),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Canvas(modifier = modifier.size(size)) {
|
||||||
|
val cellWidth = this.size.width / matrix.width
|
||||||
|
val cellHeight = this.size.height / matrix.height
|
||||||
|
|
||||||
|
drawRect(background, Offset.Zero, this.size)
|
||||||
|
|
||||||
|
for (x in 0 until matrix.width) {
|
||||||
|
for (y in 0 until matrix.height) {
|
||||||
|
if (matrix.get(x, y)) {
|
||||||
|
drawRect(
|
||||||
|
color = foreground,
|
||||||
|
topLeft = Offset(x * cellWidth, y * cellHeight),
|
||||||
|
size = Size(cellWidth, cellHeight),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+15
-1
@@ -42,6 +42,11 @@ import com.vitorpamplona.quartz.nip46RemoteSigner.NostrConnectEvent
|
|||||||
import com.vitorpamplona.quartz.nip57Zaps.LnZapPrivateEvent
|
import com.vitorpamplona.quartz.nip57Zaps.LnZapPrivateEvent
|
||||||
import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent
|
import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent
|
||||||
import com.vitorpamplona.quartz.utils.Hex
|
import com.vitorpamplona.quartz.utils.Hex
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.cancel
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
class NostrSignerRemote(
|
class NostrSignerRemote(
|
||||||
val signer: NostrSignerInternal,
|
val signer: NostrSignerInternal,
|
||||||
@@ -51,6 +56,8 @@ class NostrSignerRemote(
|
|||||||
val permissions: String? = null,
|
val permissions: String? = null,
|
||||||
val secret: String? = null,
|
val secret: String? = null,
|
||||||
) : NostrSigner(signer.pubKey) {
|
) : NostrSigner(signer.pubKey) {
|
||||||
|
private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
|
||||||
|
|
||||||
private val manager =
|
private val manager =
|
||||||
RemoteSignerManager(
|
RemoteSignerManager(
|
||||||
signer = signer,
|
signer = signer,
|
||||||
@@ -69,7 +76,13 @@ class NostrSignerRemote(
|
|||||||
),
|
),
|
||||||
) { event ->
|
) { event ->
|
||||||
if (event is NostrConnectEvent) {
|
if (event is NostrConnectEvent) {
|
||||||
manager.newResponse(event)
|
scope.launch {
|
||||||
|
try {
|
||||||
|
manager.newResponse(event)
|
||||||
|
} catch (_: Exception) {
|
||||||
|
// Ignore events we can't decrypt or parse
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,6 +92,7 @@ class NostrSignerRemote(
|
|||||||
|
|
||||||
fun closeSubscription() {
|
fun closeSubscription() {
|
||||||
subscription.close()
|
subscription.close()
|
||||||
|
scope.cancel()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun isWriteable(): Boolean = true
|
override fun isWriteable(): Boolean = true
|
||||||
|
|||||||
+3
-2
@@ -41,8 +41,9 @@ class RemoteSignerManager(
|
|||||||
) {
|
) {
|
||||||
private val awaitingRequests = LargeCache<String, Continuation<BunkerResponse>>()
|
private val awaitingRequests = LargeCache<String, Continuation<BunkerResponse>>()
|
||||||
|
|
||||||
fun newResponse(responseEvent: NostrConnectEvent) {
|
suspend fun newResponse(responseEvent: NostrConnectEvent) {
|
||||||
val bunkerResponse = OptimizedJsonMapper.fromJsonTo<BunkerResponse>(responseEvent.content)
|
val decryptedJson = signer.decrypt(responseEvent.content, remoteKey)
|
||||||
|
val bunkerResponse = OptimizedJsonMapper.fromJsonTo<BunkerResponse>(decryptedJson)
|
||||||
awaitingRequests.get(bunkerResponse.id)?.resume(bunkerResponse)
|
awaitingRequests.get(bunkerResponse.id)?.resume(bunkerResponse)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user