From aca6aebcb6974a057615f189a54999affcb43238 Mon Sep 17 00:00:00 2001 From: Padreug Date: Tue, 23 Jun 2026 23:07:43 +0200 Subject: [PATCH] feat(machine): render QR-pairing wizard for unpaired machines Wires the capture + ingest pieces into a screen (aiolabs/bitspire#52). When the machine boots `unpaired` (fresh, or binding revoked/expired) and runs under Electron, App.vue renders `PairingWizard` in place of the static "Pairing Required" card. The wizard probes available sources, shows the camera viewfinder, and on a valid scan persists + relaunches. A stray/non-seed QR is rejected with a hint and scanning resumes. NFC (when present) appears as an alternate source button. Browser dev (no Electron bridge) still falls back to the static card. Co-Authored-By: Claude Opus 4.8 --- apps/machine/src/App.vue | 14 +- apps/machine/src/components/PairingWizard.vue | 160 ++++++++++++++++++ 2 files changed, 173 insertions(+), 1 deletion(-) create mode 100644 apps/machine/src/components/PairingWizard.vue diff --git a/apps/machine/src/App.vue b/apps/machine/src/App.vue index 48cfc8a..f28b639 100644 --- a/apps/machine/src/App.vue +++ b/apps/machine/src/App.vue @@ -8,6 +8,7 @@ import { classifyInitError } from '@/services/init-error' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' import { Sun, Moon } from 'lucide-vue-next' +import PairingWizard from '@/components/PairingWizard.vue' const atmStore = useAtmStore() const route = useRoute() @@ -61,6 +62,14 @@ const isKnownMaintenanceScreen = computed( () => !!atmStore.initError && atmStore.initError in MAINTENANCE_SCREENS ) +/** + * `unpaired` is interactive, not a dead-end: render the QR-pairing wizard so + * the operator can scan a spire-seed on-machine (aiolabs/bitspire#52). The + * wizard only works under Electron (needs the seed-persist + relaunch bridge); + * in browser dev it falls back to the static card. + */ +const showPairingWizard = computed(() => atmStore.initError === 'unpaired' && isElectron) + const formattedBtcPrice = computed(() => { if (atmStore.btcPrice === null) return null const local = `${atmStore.fiatCode}/BTC: ${atmStore.fiatSymbol}${Math.round(atmStore.btcPrice).toLocaleString()}` @@ -160,9 +169,12 @@ function toggleLiveServices() {
+ + +
+/** + * QR-pairing wizard (aiolabs/bitspire#52). + * + * Shown in place of the "Pairing Required" maintenance screen when the machine + * is unpaired. The operator displays the spire-seed QR (minted by spirekeeper) + * to the machine's camera; we decode it, persist it as VITE_SPIRE_SEED, and + * relaunch so the normal boot path performs the bunker pairing. + * + * Capture is abstracted behind PairingSource, so NFC (or a HAL scanner) can be + * offered later without changing this view. + */ +import { onMounted, onUnmounted, ref, shallowRef } from 'vue' +import { + availablePairingSources, + ingestScannedSeed, + type PairingSource, + type StopCapture, +} from '@/services/pairing' + +type Phase = 'probing' | 'scanning' | 'no-source' | 'pairing' | 'error' + +const phase = ref('probing') +const errorMessage = ref('') +const videoEl = ref(null) + +const sources = shallowRef([]) +const activeSource = shallowRef(null) +let stopCapture: StopCapture | null = null + +async function startWith(source: PairingSource) { + await teardown() + activeSource.value = source + errorMessage.value = '' + phase.value = 'scanning' + try { + stopCapture = await source.start({ + video: source.kind === 'qr' ? (videoEl.value ?? undefined) : undefined, + onScan: handleScan, + onError: (e) => console.warn('[Pairing] capture glitch:', e), + }) + } catch (e) { + phase.value = 'error' + errorMessage.value = + e instanceof Error ? e.message : 'Could not start the camera. Check permissions.' + } +} + +let handling = false +async function handleScan(raw: string) { + if (handling) return + handling = true + const result = await ingestScannedSeed(raw) + if (result.ok) { + // saveSpireSeed succeeded; relaunch is in flight — hold a friendly screen. + phase.value = 'pairing' + return + } + // Reject non-seed scans (a stray QR) and resume scanning. + console.warn('[Pairing] rejected scan:', result.reason, result.message) + errorMessage.value = + result.reason === 'invalid-seed' + ? 'That code is not a pairing code. Show the operator pairing QR.' + : result.message + handling = false + if (activeSource.value) await startWith(activeSource.value) +} + +async function teardown() { + if (stopCapture) { + try { + stopCapture() + } catch { + /* idempotent */ + } + stopCapture = null + } +} + +onMounted(async () => { + const available = await availablePairingSources() + sources.value = available + const first = available[0] + if (!first) { + phase.value = 'no-source' + return + } + await startWith(first) +}) + +onUnmounted(teardown) + + +
+

Pair This Machine

+ + +
+ + +
+
+ +

+ Hold the operator's pairing QR up to the camera. +

+ +

+ Starting camera… +

+ +
+

Pairing accepted — restarting…

+
+ +

+ No camera or NFC reader is available on this machine. Pair by provisioning + VITE_SPIRE_SEED instead. +

+ +

+ {{ errorMessage }} +

+ + +

+ {{ errorMessage }} +

+ + +
+ +
+
+