From 70b02f00c63ba6f6cae939a5ebdedac6e3552eb6 Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Sun, 2 Aug 2026 23:19:23 -0700 Subject: Nostr login: connect a signer app by deep link or QR (nostrconnect://) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The client-initiated NIP-46 flow: coinos mints a nostrconnect:// URI — rendered as a tappable deep link (launches Amber & friends directly, no bunker URL to copy) and a QR for cross-device scanning — then waits on the relay; when the signer app answers with the secret, login continues by itself. One live attempt at a time, cancellable, three-minute timeout, and kind 24133 joins the relay whitelist so our own relay carries the handshake. The bunker:// paste path stays for signers that prefer it. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Wvj4aoeMZwA7VUGdJP9Swi --- src/features/nostrlogin.js | 54 +++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 51 insertions(+), 3 deletions(-) (limited to 'src/features') diff --git a/src/features/nostrlogin.js b/src/features/nostrlogin.js index c7254d3..6b55bbf 100644 --- a/src/features/nostrlogin.js +++ b/src/features/nostrlogin.js @@ -11,17 +11,17 @@ import { newMnemonic } from '../wallet.js'; import { npubOf } from '../nostr.js'; import { t } from '../i18n.js'; +import { qrSvg } from '../qr.js'; import { extensionSigner, bunkerSigner, keySigner, parseNostrSecret, - walletForSigner, publishWalletBackup, -} from '../nostr-login.js'; + walletForSigner, publishWalletBackup, nostrConnect} from '../nostr-login.js'; // The nostr ostrich, in the brand's monochrome. const OSTRICH = ''; export function nostrLoginFeature(ctx) { - const { h, ui, render, wallet, toast } = ctx; + const { h, ui, render, wallet, toast, copyBtn } = ctx; const load = () => wallet.loadFeatureState('nostrlogin', {}); const save = (st) => wallet.saveFeatureState('nostrlogin', st); @@ -104,6 +104,52 @@ export function nostrLoginFeature(ctx) { // ---- UI --------------------------------------------------------------- + // Client-initiated remote signing: one live connect attempt at a time. The + // URI renders as a deep link (tap launches the signer app) and a QR (scan + // from another device); when the app connects, login continues by itself. + let nc = null; + let ncRun = null; + function startNostrConnect(run) { + if (nc) nc.cancel(); + ncRun = run; + nc = nostrConnect(); + const mine = nc; + mine.ready.then((signer) => { + if (nc !== mine || !ncRun) return; + ui.nostrConnectOpen = false; + ncRun(() => Promise.resolve(signer)); + nc = null; + }).catch(() => { + if (nc === mine) { nc = null; if (ui.nostrConnectOpen) { ui.nostrConnectOpen = false; render(); } } + }); + } + function stopNostrConnect() { + if (nc) { nc.cancel(); nc = null; } + ncRun = null; + } + + function nostrConnectSection(run) { + if (!ui.nostrConnectOpen) { + return h('button', { + class: 'btn-block', disabled: ui.nostrLoginBusy, + onClick: () => { ui.nostrConnectOpen = true; startNostrConnect(run); render(); }, + }, t('nlConnectApp')); + } + if (!nc) return null; + return h('div', { class: 'col', style: 'gap:10px;align-items:center' }, + h('a', { + class: 'btn btn-block btn-primary', href: nc.uri, + style: 'text-align:center;display:block;text-decoration:none', + }, t('nlOpenSigner')), + h('div', { html: qrSvg(nc.uri) }), + h('div', { class: 'row gap6', style: 'align-items:center' }, + h('span', { class: 'spinner sm' }), + h('span', { class: 'small muted' }, t('nlWaitingSigner'))), + h('div', { class: 'row gap6' }, + copyBtn(nc.uri, t('copy')), + h('button', { class: 'btn-sm', onClick: () => { stopNostrConnect(); ui.nostrConnectOpen = false; render(); } }, t('cancel')))); + } + // The three ways in, as buttons. `run` receives a signer factory. function signerButtons(run) { const hasExt = typeof window !== 'undefined' && !!window.nostr; @@ -112,6 +158,7 @@ export function nostrLoginFeature(ctx) { ? h('button', { class: 'btn-block', disabled: ui.nostrLoginBusy, onClick: () => run(() => extensionSigner()) }, t('nlExtension')) : null, + nostrConnectSection(run), h('div', { class: 'row gap6' }, h('input', { type: 'password', placeholder: t('nlKeyOrBunker'), style: 'flex:1', @@ -190,6 +237,7 @@ export function nostrLoginFeature(ctx) { return { id: 'nostrlogin', + stop() { stopNostrConnect(); }, // The nostr identity this session is logged in as, for features that // should speak as the user (the payment address defaults to this npub). nostrLoginIdentity() { -- cgit v1.2.3