feat: NIP-46 remote signer via QR (Primal, or any nostrconnect-compatible app)

Adds a second way to sign in besides a NIP-07 browser extension: generate
a nostrconnect:// URI, show it as a QR code, and resolve once a remote
signer app (Primal mobile, etc.) scans and approves it. Session persists
in localStorage so a page reload doesn't require re-scanning.

lib/nip07.ts renamed to lib/signer.ts and generalized — it now abstracts
over both signer kinds behind the same {getPublicKey, signEvent} shape,
so buildNip98Header and the rest of the app don't care which is active.

On a branch (feature/nip46-qr-signer) rather than main, since this is
explicitly experimental and easy rollback matters here.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-31 00:51:34 +00:00
co-authored by Claude Sonnet 5
parent f6f16fbbda
commit ead3a9ae44
7 changed files with 346 additions and 59 deletions
+51
View File
@@ -0,0 +1,51 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import QRCode from 'qrcode';
import { connectNip46ViaQr } from '../lib/signer';
const emit = defineEmits<{
connected: [pubkey: string];
close: [];
}>();
const qrDataUrl = ref<string | null>(null);
const connecting = ref(true);
const error = ref<string | null>(null);
async function start(): Promise<void> {
connecting.value = true;
error.value = null;
qrDataUrl.value = null;
try {
const pubkey = await connectNip46ViaQr(async (uri) => {
qrDataUrl.value = await QRCode.toDataURL(uri, { margin: 1, width: 320 });
});
emit('connected', pubkey);
} catch (err) {
error.value = (err as Error).message;
} finally {
connecting.value = false;
}
}
onMounted(start);
</script>
<template>
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-4">
<div class="card w-full max-w-sm space-y-4 text-center">
<h2 class="text-lg font-semibold text-white">Connect a remote signer</h2>
<p class="text-sm text-white/60">Scan this with Primal (or any NIP-46-compatible signer app) to sign in without a browser extension.</p>
<img v-if="qrDataUrl" :src="qrDataUrl" alt="Nostr Connect QR code" class="mx-auto rounded-lg" />
<p v-if="connecting && !error" class="text-xs text-white/50">Waiting for approval</p>
<p v-if="error" class="text-sm text-red-400">{{ error }}</p>
<div class="flex gap-2">
<button type="button" class="btn-secondary flex-1" @click="emit('close')">Cancel</button>
<button v-if="error" type="button" class="btn-primary flex-1" @click="start">Retry</button>
</div>
</div>
</div>
</template>