aboutsummaryrefslogtreecommitdiffstats
path: root/src/scan.js
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-06-23 23:05:52 -0700
committerAdam Soltys <[email protected]>2026-06-23 23:05:52 -0700
commitd5e55c9c577feb6f8000bad3c37045a2cd7c9b4c (patch)
treec4260128cae53c949c29e1fe802c4d4a5aca94d0 /src/scan.js
parentca7d85e8603ad5ee6a31afbf56777cedfc3931b5 (diff)
downloadcoinosv3-d5e55c9c577feb6f8000bad3c37045a2cd7c9b4c.tar.gz
coinosv3-d5e55c9c577feb6f8000bad3c37045a2cd7c9b4c.zip
Add QR scanner to the send page (lazy-loaded decoder)
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) <[email protected]>
Diffstat (limited to 'src/scan.js')
-rw-r--r--src/scan.js132
1 files changed, 132 insertions, 0 deletions
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));
+ });
+}