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 <noreply@anthropic.com>
This commit is contained in:
parent
d22157b40c
commit
aca6aebcb6
2 changed files with 173 additions and 1 deletions
|
|
@ -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() {
|
|||
<div
|
||||
class="flex min-h-dvh lg:h-dvh w-screen flex-col overflow-y-auto lg:overflow-hidden bg-background font-sans text-foreground"
|
||||
>
|
||||
<!-- Unpaired: interactive QR-pairing wizard (aiolabs/bitspire#52) -->
|
||||
<PairingWizard v-if="showPairingWizard" />
|
||||
|
||||
<!-- Maintenance screen: shown when initialization fails in production -->
|
||||
<div
|
||||
v-if="atmStore.initError"
|
||||
v-else-if="atmStore.initError"
|
||||
class="flex flex-1 flex-col items-center justify-center gap-6 p-8"
|
||||
>
|
||||
<svg
|
||||
|
|
|
|||
160
apps/machine/src/components/PairingWizard.vue
Normal file
160
apps/machine/src/components/PairingWizard.vue
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
<script setup lang="ts">
|
||||
/**
|
||||
* 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<Phase>('probing')
|
||||
const errorMessage = ref('')
|
||||
const videoEl = ref<HTMLVideoElement | null>(null)
|
||||
|
||||
const sources = shallowRef<PairingSource[]>([])
|
||||
const activeSource = shallowRef<PairingSource | null>(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)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-1 flex-col items-center justify-center gap-6 p-8">
|
||||
<h1 class="text-2xl lg:text-[3.5rem] font-bold">Pair This Machine</h1>
|
||||
|
||||
<!-- Camera viewfinder -->
|
||||
<div
|
||||
v-show="phase === 'scanning' && activeSource?.kind === 'qr'"
|
||||
class="relative overflow-hidden rounded-2xl border-4 border-primary/40 bg-black"
|
||||
style="width: min(80vw, 28rem); aspect-ratio: 1 / 1"
|
||||
>
|
||||
<video ref="videoEl" class="h-full w-full object-cover" muted autoplay playsinline></video>
|
||||
<!-- Reticle -->
|
||||
<div class="pointer-events-none absolute inset-6 rounded-xl border-2 border-white/70"></div>
|
||||
</div>
|
||||
|
||||
<p
|
||||
v-if="phase === 'scanning'"
|
||||
class="max-w-md text-center text-base lg:text-2xl text-muted-foreground"
|
||||
>
|
||||
Hold the operator's pairing QR up to the camera.
|
||||
</p>
|
||||
|
||||
<p v-if="phase === 'probing'" class="text-base lg:text-2xl text-muted-foreground">
|
||||
Starting camera…
|
||||
</p>
|
||||
|
||||
<div v-if="phase === 'pairing'" class="flex flex-col items-center gap-4">
|
||||
<p class="text-base lg:text-2xl text-muted-foreground">Pairing accepted — restarting…</p>
|
||||
</div>
|
||||
|
||||
<p
|
||||
v-if="phase === 'no-source'"
|
||||
class="max-w-md text-center text-base lg:text-2xl text-muted-foreground"
|
||||
>
|
||||
No camera or NFC reader is available on this machine. Pair by provisioning
|
||||
<span class="font-mono">VITE_SPIRE_SEED</span> instead.
|
||||
</p>
|
||||
|
||||
<p
|
||||
v-if="phase === 'error'"
|
||||
class="max-w-md text-center text-base lg:text-xl text-destructive"
|
||||
>
|
||||
{{ errorMessage }}
|
||||
</p>
|
||||
|
||||
<!-- Transient rejected-scan hint while still scanning -->
|
||||
<p
|
||||
v-if="phase === 'scanning' && errorMessage"
|
||||
class="max-w-md text-center text-sm lg:text-base text-warning"
|
||||
>
|
||||
{{ errorMessage }}
|
||||
</p>
|
||||
|
||||
<!-- Alternate sources (e.g. NFC) when more than one is available -->
|
||||
<div v-if="sources.length > 1" class="flex gap-3">
|
||||
<button
|
||||
v-for="source in sources"
|
||||
:key="source.kind"
|
||||
class="rounded-lg border border-border px-4 py-2 text-sm"
|
||||
:class="activeSource?.kind === source.kind ? 'bg-primary text-primary-foreground' : ''"
|
||||
@click="startWith(source)"
|
||||
>
|
||||
{{ source.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
Loading…
Add table
Add a link
Reference in a new issue