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) --- build.js | 24 +++++++++- bun.lockb | Bin 17101 -> 17437 bytes dev.js | 9 +++- package.json | 1 + src/app.js | 129 +++++++++++++++++++++++++++++++++++++++++++++++++++ src/i18n.js | 8 ++++ src/jsqr-global.js | 7 +++ src/scan.js | 132 +++++++++++++++++++++++++++++++++++++++++++++++++++++ src/style.css | 7 +++ src/wallet.js | 19 ++++++++ 10 files changed, 332 insertions(+), 4 deletions(-) create mode 100644 src/jsqr-global.js create mode 100644 src/scan.js diff --git a/build.js b/build.js index f11a9f6..34d3d19 100644 --- a/build.js +++ b/build.js @@ -42,7 +42,9 @@ const MANIFEST = { // cross-origin explorer/Nostr requests. CACHE carries a build token so each // deploy supersedes the previous cache. {{VERSION}} is filled in at build time. const SW = `const CACHE = 'cold-{{VERSION}}'; -const SHELL = ['./', 'manifest.webmanifest', 'icon-192.png', 'icon-512.png']; +// jsqr.js is precached so the lazy QR decoder is available offline too (for +// installed PWAs on browsers without a native BarcodeDetector). +const SHELL = ['./', 'manifest.webmanifest', 'icon-192.png', 'icon-512.png', 'jsqr.js']; self.addEventListener('install', (e) => { self.skipWaiting(); @@ -91,6 +93,20 @@ const PWA_HEAD = ` const SW_REGISTER = ``; +// Bundle the standalone jsQR decoder (sets window.jsQR) for lazy loading. +export async function buildJsQr({ minify = true } = {}) { + const result = await Bun.build({ + entrypoints: ['./src/jsqr-global.js'], + target: 'browser', + minify, + }); + if (!result.success) { + for (const log of result.logs) console.error(log); + throw new Error('jsqr bundle failed'); + } + return result.outputs[0].text(); +} + export async function buildHtml({ minify = true, pwa = minify } = {}) { const result = await Bun.build({ entrypoints: ['./src/app.js'], @@ -128,6 +144,10 @@ if (import.meta.main) { const html = await buildHtml({ minify: true }); await Bun.write('dist/index.html', html); + // Lazy-loaded QR decoder — kept out of index.html, fetched only when a + // browser without BarcodeDetector opens the scanner. + await Bun.write('dist/jsqr.js', await buildJsQr()); + // PWA sidecars. await Bun.write('dist/manifest.webmanifest', JSON.stringify(MANIFEST, null, 2)); const version = Bun.hash(html).toString(36); @@ -136,5 +156,5 @@ if (import.meta.main) { const kb = (Buffer.byteLength(html) / 1024).toFixed(0); console.log(`✓ dist/index.html written (${kb} KB) — open it offline, no server needed`); - console.log(`✓ PWA: manifest.webmanifest, sw.js (cold-${version}), ${STATIC.length} icons`); + console.log(`✓ PWA: manifest.webmanifest, sw.js (cold-${version}), ${STATIC.length} icons, jsqr.js`); } diff --git a/bun.lockb b/bun.lockb index be4a74b..69a0668 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/dev.js b/dev.js index 099069c..dde8813 100644 --- a/dev.js +++ b/dev.js @@ -1,14 +1,19 @@ // Dev server: rebuilds the inlined bundle on every request so you just refresh // the browser to see changes. Same output as `bun run build`, unminified. -import { buildHtml } from './build.js'; +import { buildHtml, buildJsQr } from './build.js'; const port = Number(process.env.PORT || 5173); Bun.serve({ port, - async fetch() { + async fetch(req) { try { + if (new URL(req.url).pathname === '/jsqr.js') { + return new Response(await buildJsQr({ minify: false }), { + headers: { 'content-type': 'text/javascript; charset=utf-8' }, + }); + } const html = await buildHtml({ minify: false }); return new Response(html, { headers: { 'content-type': 'text/html; charset=utf-8' }, diff --git a/package.json b/package.json index eef5b5d..a9d2971 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "@scure/bip32": "^1.7.0", "@scure/bip39": "^1.6.0", "@scure/btc-signer": "^1.8.1", + "jsqr": "^1.4.0", "nostr-tools": "^2.23.5", "qrcode-generator": "^2.0.4" }, 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' }, diff --git a/src/i18n.js b/src/i18n.js index 9a02afe..556a4d0 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -104,6 +104,14 @@ const STR = { signBroadcast: 'Sign & broadcast', signTx: 'Sign transaction', signExport: 'Sign & export (broadcast elsewhere)', + scanQr: 'Scan QR', + scanHint: 'Point the camera at a QR code', + scanNoCamera: 'Could not access the camera.', + scanNoDecoder: 'QR scanning needs a connection the first time it’s used.', + scanUnrecognized: 'Couldn’t read a Bitcoin address from that QR.', + broadcastScanned: 'Broadcast transaction?', + broadcastNow: 'Broadcast', + scanOffline: 'You’re offline — reconnect to broadcast this transaction.', broadcastFailed: 'Broadcast failed: {msg}', signingFailed: 'Signing failed: {msg}', done: 'Done', diff --git a/src/jsqr-global.js b/src/jsqr-global.js new file mode 100644 index 0000000..5450160 --- /dev/null +++ b/src/jsqr-global.js @@ -0,0 +1,7 @@ +// Standalone bundle for the QR decoder, emitted as a separate dist/jsqr.js and +// loaded on demand by scan.js — only when a browser lacks the native +// BarcodeDetector API and the user actually opens the scanner. Keeps jsQR +// (~130 KB) out of the main single-file bundle and off the initial load. +import jsQR from 'jsqr'; + +window.jsQR = jsQR; diff --git a/src/scan.js b/src/scan.js new file mode 100644 index 0000000..a0d520c --- /dev/null +++ b/src/scan.js @@ -0,0 +1,132 @@ +// Camera QR scanner. Opens a fullscreen overlay, streams the back camera, and +// resolves with the decoded string (or null if the user cancels). +// +// Decoder strategy, to keep the main bundle small: +// - Prefer the native BarcodeDetector (Chrome desktop/Android) — no library. +// - Otherwise lazy-load jsQR from a separate dist/jsqr.js, only on first use. +// +// Needs a secure context with a camera (getUserMedia). Callers gate the button +// on navigator.mediaDevices?.getUserMedia so it never shows where unavailable. + +let jsqrLoader; + +// Load the jsQR fallback bundle on demand (separate file → off the initial load). +function loadJsQR() { + if (typeof window !== 'undefined' && window.jsQR) return Promise.resolve(window.jsQR); + if (!jsqrLoader) { + jsqrLoader = new Promise((resolve, reject) => { + const s = document.createElement('script'); + s.src = 'jsqr.js'; + s.onload = () => (window.jsQR ? resolve(window.jsQR) : reject(new Error('decoder unavailable'))); + s.onerror = () => reject(new Error('decoder unavailable')); + document.head.appendChild(s); + }); + } + return jsqrLoader; +} + +// Native detector, but only if it can actually do QR (on some platforms the API +// exists yet supports no formats — in which case we fall back to jsQR). +async function makeDetector() { + if (typeof window === 'undefined' || !('BarcodeDetector' in window)) return null; + try { + const fmts = await window.BarcodeDetector.getSupportedFormats(); + if (!fmts.includes('qr_code')) return null; + return new window.BarcodeDetector({ formats: ['qr_code'] }); + } catch { + return null; + } +} + +export async function scanQr(t) { + const label = (k, d) => (t ? t(k) : d); + + let stream; + try { + stream = await navigator.mediaDevices.getUserMedia({ + video: { facingMode: { ideal: 'environment' } }, + audio: false, + }); + } catch { + throw new Error(label('scanNoCamera', 'Could not access the camera.')); + } + + // Pick a decoder before scanning (lazy-loading jsQR if there's no native one). + const detector = await makeDetector(); + let jsQR = null; + if (!detector) { + try { + jsQR = await loadJsQR(); + } catch { + for (const tr of stream.getTracks()) tr.stop(); + throw new Error(label('scanNoDecoder', 'QR scanning needs a network connection the first time.')); + } + } + + return new Promise((resolve) => { + const overlay = document.createElement('div'); + overlay.className = 'scan-overlay'; + + const video = document.createElement('video'); + video.setAttribute('playsinline', ''); + video.muted = true; + video.srcObject = stream; + + const frame = document.createElement('div'); + frame.className = 'scan-frame'; + + const hint = document.createElement('div'); + hint.className = 'scan-hint'; + hint.textContent = label('scanHint', 'Point the camera at a QR code'); + + const close = document.createElement('button'); + close.className = 'scan-close'; + close.setAttribute('aria-label', 'Close'); + close.textContent = '✕'; + + overlay.append(video, frame, hint, close); + document.body.append(overlay); + + const canvas = document.createElement('canvas'); + const ctx = canvas.getContext('2d', { willReadFrequently: true }); + let raf = 0; + let done = false; + + function stop(result) { + if (done) return; + done = true; + cancelAnimationFrame(raf); + for (const tr of stream.getTracks()) tr.stop(); + overlay.remove(); + resolve(result); + } + close.onclick = () => stop(null); + overlay.onclick = (e) => { + if (e.target === overlay) stop(null); + }; + + async function tick() { + if (done) return; + if (video.readyState >= 2 && video.videoWidth) { + try { + let data = null; + if (detector) { + const codes = await detector.detect(video); + if (codes && codes.length) data = codes[0].rawValue; + } else { + canvas.width = video.videoWidth; + canvas.height = video.videoHeight; + ctx.drawImage(video, 0, 0, canvas.width, canvas.height); + const img = ctx.getImageData(0, 0, canvas.width, canvas.height); + const code = jsQR(img.data, img.width, img.height, { inversionAttempts: 'dontInvert' }); + if (code && code.data) data = code.data; + } + if (data) return stop(data); + } catch {} + } + raf = requestAnimationFrame(tick); + } + + video.play().then(() => { raf = requestAnimationFrame(tick); }).catch(() => stop(null)); + }); +} diff --git a/src/style.css b/src/style.css index 8a32bf4..69c3169 100644 --- a/src/style.css +++ b/src/style.css @@ -199,6 +199,13 @@ textarea { resize: vertical; min-height: 84px; font-family: var(--mono); font-si border-radius: 50%; animation: spin .7s linear infinite; display: inline-block; vertical-align: middle; } .spinner.sm { width: 13px; height: 13px; border-width: 2px; } + +/* ---- qr scanner overlay ---- */ +.scan-overlay { position: fixed; inset: 0; z-index: 60; background: #000; display: flex; align-items: center; justify-content: center; overflow: hidden; } +.scan-overlay video { width: 100%; height: 100%; object-fit: cover; } +.scan-frame { position: absolute; width: min(70vw, 260px); height: min(70vw, 260px); border: 3px solid #fff; border-radius: 18px; box-shadow: 0 0 0 9999px rgba(0,0,0,0.45); } +.scan-hint { position: absolute; bottom: 70px; left: 0; right: 0; text-align: center; color: #fff; font-size: 14px; padding: 0 24px; text-shadow: 0 1px 3px rgba(0,0,0,0.7); } +.scan-close { position: absolute; top: 16px; right: 16px; width: 42px; height: 42px; border-radius: 50%; background: rgba(0,0,0,0.5); color: #fff; border: 1px solid rgba(255,255,255,0.4); font-size: 18px; line-height: 1; cursor: pointer; } @keyframes spin { to { transform: rotate(360deg); } } .toast { position: fixed; left: 50%; bottom: 26px; transform: translateX(-50%); diff --git a/src/wallet.js b/src/wallet.js index 7152d31..2547b45 100644 --- a/src/wallet.js +++ b/src/wallet.js @@ -577,6 +577,25 @@ export class Wallet { return this.api.broadcast(hexTx); } + // Parse a raw (signed) transaction hex — used by scan-to-broadcast to show a + // confirmation (txid + outputs) before relaying someone's exported tx. + parseRawTx(rawHex) { + const tx = btc.Transaction.fromRaw(hex.decode(rawHex.trim()), { allowUnknownOutputs: true }); + const network = NETS[this.netName].net; + const outputs = []; + let total = 0; + for (let i = 0; i < tx.outputsLength; i++) { + const o = tx.getOutput(i); + total += Number(o.amount); + let address = ''; + try { + address = btc.Address(network).encode(btc.OutScript.decode(o.script)); + } catch {} + outputs.push({ address, value: Number(o.amount) }); + } + return { txid: tx.id, total, outputs }; + } + // --- realtime (mempool.space WebSocket) --------------------------------- // Pushes us new mempool/confirmed transactions for our addresses so history // and balances update with no polling. -- cgit v1.2.3