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
|
||||
implementation(libs.kotlinx.collections.immutable)
|
||||
|
||||
// QR code generation (ZXing core)
|
||||
implementation(libs.zxing)
|
||||
|
||||
// Testing
|
||||
testImplementation(libs.kotlin.test)
|
||||
testImplementation(libs.kotlinx.coroutines.test)
|
||||
|
||||
+156
@@ -23,10 +23,15 @@ package com.vitorpamplona.amethyst.desktop.account
|
||||
import androidx.compose.runtime.Stable
|
||||
import com.vitorpamplona.amethyst.commons.keystorage.SecureKeyStorage
|
||||
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.toHexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||
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.NostrSignerInternal
|
||||
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.toNpub
|
||||
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.nip47WalletConnect.Nip47WalletConnect
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
@@ -44,7 +54,9 @@ import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import java.io.File
|
||||
import java.security.SecureRandom
|
||||
|
||||
sealed class SignerType {
|
||||
data object Internal : SignerType()
|
||||
@@ -93,6 +105,8 @@ class AccountManager internal constructor(
|
||||
internal const val HEARTBEAT_INTERVAL_MS = 60_000L
|
||||
internal const val MAX_CONSECUTIVE_FAILURES = 3
|
||||
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 {
|
||||
@@ -226,6 +240,148 @@ class AccountManager internal constructor(
|
||||
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(
|
||||
bunkerUri: String,
|
||||
ephemeralPrivKeyHex: String,
|
||||
|
||||
@@ -106,6 +106,16 @@ fun LoginScreen(
|
||||
} else {
|
||||
null
|
||||
},
|
||||
onLoginNostrConnect =
|
||||
if (relayClient != null) {
|
||||
{ onUriGenerated ->
|
||||
accountManager.loginWithNostrConnect(relayClient, onUriGenerated).map {
|
||||
onLoginSuccess()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
null
|
||||
},
|
||||
)
|
||||
|
||||
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.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
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.PrimaryTabRow
|
||||
import androidx.compose.material3.Tab
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
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
|
||||
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 com.vitorpamplona.amethyst.commons.resources.Res
|
||||
@@ -83,16 +92,14 @@ fun LoginCard(
|
||||
onLogin: (String) -> Result<Unit>,
|
||||
onGenerateNew: () -> Unit,
|
||||
onLoginBunker: (suspend (String) -> Result<Unit>)? = null,
|
||||
onLoginNostrConnect: (suspend (onUriGenerated: (String) -> Unit) -> Result<Unit>)? = null,
|
||||
modifier: Modifier = Modifier,
|
||||
cardWidth: Dp = 400.dp,
|
||||
title: String = stringResource(Res.string.login_card_title),
|
||||
subtitle: String = stringResource(Res.string.login_card_subtitle),
|
||||
) {
|
||||
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)
|
||||
var selectedTab by remember { mutableIntStateOf(0) }
|
||||
val tabs = listOf("Paste Key", "Connect")
|
||||
|
||||
Card(
|
||||
modifier = modifier.width(cardWidth),
|
||||
@@ -113,97 +120,254 @@ fun LoginCard(
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
KeyInputField(
|
||||
value = keyInput,
|
||||
onValueChange = {
|
||||
keyInput = it
|
||||
errorMessage = null
|
||||
if (onLoginNostrConnect != null) {
|
||||
@Suppress("DEPRECATION")
|
||||
PrimaryTabRow(
|
||||
selectedTabIndex = selectedTab,
|
||||
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))
|
||||
|
||||
if (isBunker) {
|
||||
Text(
|
||||
"This URI connects to your remote signer. Treat it like a password.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
OutlinedButton(
|
||||
onClick = { clipboardManager.setText(AnnotatedString(uri)) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
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(
|
||||
subtitle,
|
||||
"Waiting for signer to connect...",
|
||||
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 },
|
||||
)
|
||||
}
|
||||
},
|
||||
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))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(20.dp),
|
||||
strokeWidth = 2.dp,
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(
|
||||
"Generating connection...",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+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.LnZapRequestEvent
|
||||
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(
|
||||
val signer: NostrSignerInternal,
|
||||
@@ -51,6 +56,8 @@ class NostrSignerRemote(
|
||||
val permissions: String? = null,
|
||||
val secret: String? = null,
|
||||
) : NostrSigner(signer.pubKey) {
|
||||
private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
|
||||
|
||||
private val manager =
|
||||
RemoteSignerManager(
|
||||
signer = signer,
|
||||
@@ -69,7 +76,13 @@ class NostrSignerRemote(
|
||||
),
|
||||
) { event ->
|
||||
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() {
|
||||
subscription.close()
|
||||
scope.cancel()
|
||||
}
|
||||
|
||||
override fun isWriteable(): Boolean = true
|
||||
|
||||
+3
-2
@@ -41,8 +41,9 @@ class RemoteSignerManager(
|
||||
) {
|
||||
private val awaitingRequests = LargeCache<String, Continuation<BunkerResponse>>()
|
||||
|
||||
fun newResponse(responseEvent: NostrConnectEvent) {
|
||||
val bunkerResponse = OptimizedJsonMapper.fromJsonTo<BunkerResponse>(responseEvent.content)
|
||||
suspend fun newResponse(responseEvent: NostrConnectEvent) {
|
||||
val decryptedJson = signer.decrypt(responseEvent.content, remoteKey)
|
||||
val bunkerResponse = OptimizedJsonMapper.fromJsonTo<BunkerResponse>(decryptedJson)
|
||||
awaitingRequests.get(bunkerResponse.id)?.resume(bunkerResponse)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user