carlos / contra Public

Clone
git clone https://amadan.net/carlos/contra

Plain git — no account needed to clone.

Download

Download this file

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
21const $ = (sel, root = document) => root.querySelector(sel);
22
23export 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