| 1 | // Rung 2 of the fresh-device ladder: scan the QR on the Account Key card. |
| 2 | // |
| 3 | // Split out of device.js only because that file hit its line cap. It takes |
| 4 | // everything it needs as arguments — no imports beyond the DOM — so the |
| 5 | // decision about what a scanned string means stays in one place. |
| 6 | // |
| 7 | // # Why this rung can be absent |
| 8 | // |
| 9 | // It is offered only where the browser can actually decode a QR code. |
| 10 | // BarcodeDetector is in Chromium and on Android; Safari and Firefox have |
| 11 | // nothing, and shipping a decoder inside the module graph that holds the keys |
| 12 | // is a great deal of code for a convenience. |
| 13 | // |
| 14 | // PROTOCOL.md's rule is that every screen offers all AVAILABLE rungs and never |
| 15 | // dead-ends. Hiding this one costs a shortcut, not a way in: typing the same |
| 16 | // code off the same card is the rung below, and it always works. |
| 17 | // |
| 18 | // The QR encodes exactly the printed string — kit.js draws both from the same |
| 19 | // value — so a scan and a typed line reach the identical code path. |
| 20 | |
| 21 | const $ = (sel, root = document) => root.querySelector(sel); |
| 22 | |
| 23 | export async function wireScanning(need, status, say, readCode, arrived) { |
| 24 | const li = $('[data-rung="scan"]', need); |
| 25 | if (!li || !("BarcodeDetector" in window)) return; |
| 26 | |
| 27 | let formats = []; |
| 28 | try { |
| 29 | formats = await window.BarcodeDetector.getSupportedFormats(); |
| 30 | } catch { |
| 31 | return; |
| 32 | } |
| 33 | if (!formats.includes("qr_code")) return; |
| 34 | li.hidden = false; |
| 35 | |
| 36 | const panel = $("[data-scan]", li); |
| 37 | const video = $("[data-video]", li); |
| 38 | const scanStatus = $("[data-scan-status]", li); |
| 39 | const detector = new window.BarcodeDetector({ formats: ["qr_code"] }); |
| 40 | const state = { stream: null, timer: null }; |
| 41 | |
| 42 | function stop() { |
| 43 | if (state.timer) clearTimeout(state.timer); |
| 44 | state.timer = null; |
| 45 | if (state.stream) for (const t of state.stream.getTracks()) t.stop(); |
| 46 | state.stream = null; |
| 47 | panel.hidden = true; |
| 48 | } |
| 49 | |
| 50 | async function look() { |
| 51 | try { |
| 52 | for (const found of await detector.detect(video)) { |
| 53 | // A camera pointed at a card sees other things too — the recovery |
| 54 | // sheet's QR is a different tag and simply will not parse, so a |
| 55 | // failure here means "keep looking", not "something is wrong". |
| 56 | const accountKey = await readCode(found.rawValue, scanStatus) |
| 57 | .catch(() => null); |
| 58 | if (accountKey) { |
| 59 | stop(); |
| 60 | arrived(accountKey); |
| 61 | return; |
| 62 | } |
| 63 | } |
| 64 | } catch (err) { |
| 65 | console.error("CONTRA scan failed", err); |
| 66 | } |
| 67 | state.timer = setTimeout(look, 250); |
| 68 | } |
| 69 | |
| 70 | $("[data-start-scan]", li).addEventListener("click", async () => { |
| 71 | panel.hidden = false; |
| 72 | try { |
| 73 | state.stream = await navigator.mediaDevices.getUserMedia({ |
| 74 | video: { facingMode: "environment" }, |
| 75 | }); |
| 76 | } catch (err) { |
| 77 | // A refused camera is a normal answer, not a failure of the app. |
| 78 | console.error("CONTRA could not open the camera", err); |
| 79 | say(scanStatus, "This browser would not give us the camera. Type the " + |
| 80 | "code instead — it is the same key.", "error"); |
| 81 | return; |
| 82 | } |
| 83 | video.srcObject = state.stream; |
| 84 | await video.play(); |
| 85 | say(scanStatus, "Point it at the QR code on your card."); |
| 86 | look(); |
| 87 | }); |
| 88 | |
| 89 | $("[data-stop-scan]", li).addEventListener("click", stop); |
| 90 | |
| 91 | // A camera left running is a camera left running. Stopping on hide covers |
| 92 | // the tab-switch case that no button press ever reaches. |
| 93 | document.addEventListener("visibilitychange", () => { |
| 94 | if (document.hidden && state.stream) stop(); |
| 95 | }); |
| 96 | } |
| 97 | |