From d5e55c9c577feb6f8000bad3c37045a2cd7c9b4c Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Tue, 23 Jun 2026 23:05:52 -0700 Subject: Add QR scanner to the send page (lazy-loaded decoder) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scan a Bitcoin address, a BIP21 URI (fills address + amount), or a signed-transaction QR (opens a broadcast confirmation, then relays it) from a viewfinder button on the Send form. Decoder strategy keeps the main bundle small: - Prefer the native BarcodeDetector (Chrome desktop/Android) — no lib. - Otherwise lazy-load jsQR from a separate dist/jsqr.js, only when the scanner is opened. jsQR stays out of index.html (~424 KB vs ~551 KB) and off the initial load. - The service worker precaches jsqr.js so offline scanning works for installed PWAs on browsers without BarcodeDetector. The scanner needs a camera + secure context, so it never shows on a standalone file:// page; offline air-gapped signing is unaffected. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/app.js | 129 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 129 insertions(+) (limited to 'src/app.js') diff --git a/src/app.js b/src/app.js index 11f0589..286372e 100644 --- a/src/app.js +++ b/src/app.js @@ -6,6 +6,7 @@ import { Wallet, newMnemonic, isValidMnemonic, utxoId } from './wallet.js'; import { qrSvg } from './qr.js'; +import { scanQr } from './scan.js'; import { t, LANGS, getLang, setLang, isRTL } from './i18n.js'; import { fmtBtc, @@ -39,6 +40,7 @@ const ui = { txDetail: null, // txid being viewed in the history detail view send: blankSend(), draft: null, // built tx summary awaiting review + broadcastTx: null, // scanned signed tx awaiting broadcast confirmation sendError: '', sendResult: null, // { txid } | { signedHex, txid } busy: false, @@ -600,6 +602,7 @@ function lock() { ui.revealShown = false; ui.receiveSeenIndex = null; ui.txDetail = null; + ui.broadcastTx = null; render(); } @@ -835,10 +838,132 @@ function receiveTab() { // ---------------------------------------------------------------- Send function sendTab() { if (ui.sendResult) return sendResultView(); + if (ui.broadcastTx) return broadcastConfirmView(); if (ui.draft) return reviewView(); return sendForm(); } +// QR scanning is only possible in a secure context with a camera. +const canScan = () => + typeof navigator !== 'undefined' && !!(navigator.mediaDevices && navigator.mediaDevices.getUserMedia); + +// Open the camera, then route the decoded payload: a BIP21 URI or address fills +// the form; a raw signed-tx hex goes to a broadcast confirmation. +async function scanIntoSend() { + let text; + try { + text = await scanQr(t); + } catch (e) { + ui.sendError = e.message; + render(); + return; + } + if (text) handleScanned(text); +} + +function handleScanned(raw) { + const text = raw.trim(); + const s = ui.send; + ui.sendError = ''; + + if (/^bitcoin:/i.test(text)) { + const { address, amount } = parseBip21(text); + if (!address) { + ui.sendError = t('scanUnrecognized'); + render(); + return; + } + s.recipients[0].address = address; + if (amount != null) { + s.recipients[0].amount = amount; + s.max = false; + } + render(); + return; + } + + // A raw signed transaction (hex) — confirm before broadcasting. + const compact = text.replace(/\s+/g, ''); + if (/^[0-9a-fA-F]+$/.test(compact) && compact.length >= 100 && compact.length % 2 === 0) { + try { + const info = wallet.parseRawTx(compact); + ui.broadcastTx = { hex: compact, ...info }; + render(); + return; + } catch { + /* not a parseable tx — fall through and treat as an address */ + } + } + + // Otherwise treat it as an address (review will validate it). + s.recipients[0].address = text; + render(); +} + +// bitcoin:
?amount=&label=... — amount is in BTC; convert to the +// current display unit so it lands in the form's amount field correctly. +function parseBip21(uri) { + const m = /^bitcoin:([^?]*)(?:\?(.*))?$/i.exec(uri.trim()); + if (!m) return {}; + const address = decodeURIComponent((m[1] || '').trim()); + let amount = null; + if (m[2]) { + const amt = new URLSearchParams(m[2]).get('amount'); + if (amt && isFinite(Number(amt)) && Number(amt) > 0) { + const sats = Math.round(Number(amt) * SATS); + amount = unit === 'sats' ? String(sats) : String(Number(amt)); + } + } + return { address, amount }; +} + +function broadcastConfirmView() { + const b = ui.broadcastTx; + return h( + 'div', + { class: 'card col' }, + h('h3', {}, t('broadcastScanned')), + h( + 'div', + { class: 'summary col', style: 'gap:0' }, + ...b.outputs.map((o) => + h('div', { class: 'line' }, + h('span', { class: 'k mono break' }, o.address ? shortAddr(o.address, 14, 8) : '—'), + h('span', { class: 'v' }, fmtAmount(o.value), ' ', unitTag()) + ) + ), + h('div', { class: 'line' }, + h('span', { class: 'k' }, t('transactionId')), + h('span', { class: 'v mono break' }, shortTxid(b.txid)) + ) + ), + ui.sendError && h('div', { class: 'notice err' }, ui.sendError), + h('div', { class: 'row gap6' }, + h('button', { class: 'btn-ghost', onClick: () => { ui.broadcastTx = null; ui.sendError = ''; render(); } }, t('back')), + ui.busy + ? h('button', { class: 'btn-primary grow', disabled: true }, h('span', { class: 'spinner' })) + : h('button', { class: 'btn-primary grow', onClick: broadcastScanned }, t('broadcastNow')) + ) + ); +} + +async function broadcastScanned() { + if (wallet.offline) { ui.sendError = t('scanOffline'); render(); return; } + ui.busy = true; + ui.sendError = ''; + render(); + try { + const txid = await wallet.broadcast(ui.broadcastTx.hex); + ui.sendResult = { txid }; + ui.broadcastTx = null; + await wallet.scan().catch(() => {}); + } catch (e) { + ui.sendError = t('broadcastFailed', { msg: e.message }); + } + ui.busy = false; + render(); +} + // One recipient: address + amount. Max is only offered for a single recipient. function recipientRow(s, r, i) { const single = s.recipients.length === 1; @@ -852,6 +977,10 @@ function recipientRow(s, r, i) { autocapitalize: 'none', autocomplete: 'off', spellcheck: 'false', value: r.address, onInput: (e) => (r.address = e.target.value), }), + i === 0 && canScan() && h('button', { + type: 'button', class: 'btn-sm', title: t('scanQr'), onClick: scanIntoSend, + html: '', + }), !single && h('button', { type: 'button', class: 'btn-sm', title: t('remove'), onClick: () => { s.recipients.splice(i, 1); render(); } }, '✕') ), h('div', { class: 'input-group' }, -- cgit v1.2.3