diff --git a/apps/machine/src/components/PairingWizard.vue b/apps/machine/src/components/PairingWizard.vue index f34409e..fb343e1 100644 --- a/apps/machine/src/components/PairingWizard.vue +++ b/apps/machine/src/components/PairingWizard.vue @@ -10,15 +10,18 @@ * 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 { computed, onMounted, onUnmounted, ref, shallowRef } from 'vue' import { availablePairingSources, ingestScannedSeed, + parseScannedSeed, + testRelay, type PairingSource, + type RelayTestResult, type StopCapture, } from '@/services/pairing' -type Phase = 'probing' | 'scanning' | 'no-source' | 'pairing' | 'error' +type Phase = 'probing' | 'scanning' | 'review' | 'no-source' | 'pairing' | 'error' const phase = ref('probing') const errorMessage = ref('') @@ -28,6 +31,19 @@ const sources = shallowRef([]) const activeSource = shallowRef(null) let stopCapture: StopCapture | null = null +// Review-step state: the scanned-but-not-yet-committed seed + relay tests. +const scannedRaw = ref('') +const previewSpire = ref('') +const previewRelays = ref([]) +type RelayState = { status: 'idle' | 'testing' | 'done'; result?: RelayTestResult } +const relayTests = ref>({}) +const testingRelays = ref(false) +const committing = ref(false) + +const anyRelayFailed = computed(() => + Object.values(relayTests.value).some((s) => s.status === 'done' && s.result != null && !s.result.ok), +) + async function startWith(source: PairingSource) { await teardown() activeSource.value = source @@ -50,18 +66,58 @@ 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' + // Validate only — don't commit yet. Show a review step with the decoded + // relay + a "test relay" button so a well-formed but unreachable relay is + // caught before we relaunch into a pairing crash-loop (aiolabs/bitspire#70). + const preview = parseScannedSeed(raw) + if (preview.ok) { + await teardown() // camera off during review + scannedRaw.value = raw.trim() + previewSpire.value = preview.spirePubkey + previewRelays.value = preview.relays + relayTests.value = Object.fromEntries(preview.relays.map((r) => [r, { status: 'idle' }])) + errorMessage.value = '' + phase.value = 'review' 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 + // Reject non-seed / malformed scans (a stray QR, a corrupted relay) and resume. + console.warn('[Pairing] rejected scan:', preview.reason, preview.message) + errorMessage.value = 'That code is not a valid pairing code. Show the operator pairing QR.' + handling = false + if (activeSource.value) await startWith(activeSource.value) +} + +/** Probe every relay in the scanned seed and record reachability. */ +async function testRelays() { + testingRelays.value = true + await Promise.all( + previewRelays.value.map(async (url) => { + relayTests.value[url] = { status: 'testing' } + const result = await testRelay(url) + relayTests.value[url] = { status: 'done', result } + }), + ) + testingRelays.value = false +} + +/** Commit the reviewed seed: persist + relaunch into the real pairing path. */ +async function confirmPair() { + committing.value = true + const result = await ingestScannedSeed(scannedRaw.value) + if (result.ok) { + phase.value = 'pairing' // relaunch in flight + return + } + committing.value = false + errorMessage.value = result.message + phase.value = 'error' +} + +/** Discard the scan and go back to scanning. */ +async function rescan() { + scannedRaw.value = '' + previewRelays.value = [] + relayTests.value = {} handling = false if (activeSource.value) await startWith(activeSource.value) } @@ -121,6 +177,67 @@ onUnmounted(teardown)

Pairing accepted — restarting…

+ +
+

+ Pairing code scanned. Test the relay, then pair. +

+
+

Spire

+

{{ previewSpire.slice(0, 16) }}…

+

Relay(s)

+
    +
  • + {{ url }} + + + + +
  • +
+
+ +
+ + + +
+ +

+ A relay looks unreachable from this machine — pairing will fail unless it can reach the + relay. Check the URL/network, or rescan a corrected code. +

+
+

{ const trimmed = (raw || '').trim() diff --git a/apps/machine/src/services/pairing/relay-test.ts b/apps/machine/src/services/pairing/relay-test.ts new file mode 100644 index 0000000..338c983 --- /dev/null +++ b/apps/machine/src/services/pairing/relay-test.ts @@ -0,0 +1,69 @@ +/** + * Relay reachability probe for the pairing wizard (aiolabs/bitspire#70). + * + * `parseSpireSeed` catches a MALFORMED relay (e.g. a QR misread of `ws://` into + * `As://`), but a well-formed-yet-unreachable relay — `ws://localhost:…` baked + * into a seed for a remote machine, a wrong LAN IP, or a relay that's simply + * down — still parses fine and would only fail later as a NIP-46 connect + * crash-loop. This opens a WebSocket to the relay (and sends a NIP-01 REQ so a + * real relay answers) so the operator can confirm reachability on-machine, + * before committing the pairing. + */ + +export interface RelayTestResult { + url: string + ok: boolean + /** Round-trip time to open (ms), when reachable. */ + ms?: number + /** True when the relay answered our REQ — i.e. it's actually a nostr relay. */ + answered?: boolean + error?: string +} + +/** Open a WebSocket to `url` and report whether it connects within `timeoutMs`. */ +export function testRelay(url: string, timeoutMs = 6000): Promise { + return new Promise((resolve) => { + const start = Date.now() + let ws: WebSocket | null = null + let settled = false + + const finish = (r: Omit): void => { + if (settled) return + settled = true + clearTimeout(timer) + try { + ws?.close() + } catch { + /* already closing */ + } + resolve({ url, ...r }) + } + + const timer = setTimeout( + () => finish({ ok: false, error: `timed out after ${timeoutMs}ms` }), + timeoutMs, + ) + + try { + ws = new WebSocket(url) + } catch (e) { + finish({ ok: false, error: e instanceof Error ? e.message : 'invalid relay URL' }) + return + } + + ws.onopen = () => { + // Connected. Probe it as a nostr relay; a genuine relay replies (EOSE / + // notice). If it stays silent we still count the open as reachable. + try { + ws?.send(JSON.stringify(['REQ', 'bitspire-relay-test', { limit: 0 }])) + } catch { + /* send failed, but the socket opened → still reachable */ + } + const graceMs = Math.min(600, timeoutMs) + setTimeout(() => finish({ ok: true, ms: Date.now() - start, answered: false }), graceMs) + } + ws.onmessage = () => finish({ ok: true, ms: Date.now() - start, answered: true }) + ws.onerror = () => + finish({ ok: false, error: 'connection failed (unreachable or not a relay)' }) + }) +}