aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/app.js129
-rw-r--r--src/i18n.js8
-rw-r--r--src/jsqr-global.js7
-rw-r--r--src/scan.js132
-rw-r--r--src/style.css7
-rw-r--r--src/wallet.js19
6 files changed, 302 insertions, 0 deletions
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:<address>?amount=<btc>&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: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 8V5a1 1 0 0 1 1-1h3M16 4h3a1 1 0 0 1 1 1v3M20 16v3a1 1 0 0 1-1 1h-3M8 20H5a1 1 0 0 1-1-1v-3"/></svg>',
+ }),
!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.