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:
nrobi144
2026-03-05 15:03:37 +02:00
parent bff6723ba6
commit 12c91aae3d
7 changed files with 511 additions and 88 deletions
@@ -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
@@ -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)
}