deviant/snapshots/0153-qr-scanner-cross-platform.md
walerikus 6ea6279d13 docs(snapshots): backfill domain/summary/tags frontmatter + enhance index
Phase 1 of the docs-by-domain reorg. Every snapshot now carries YAML
frontmatter (domain + summary + tags); the auto-guess was hand-corrected
against the full index (T6/interchain -> cross-chain, CDP/MBA/stablecoin
-> fiatm, CitizenToken/Ballot -> governance, etc.). No file renamed/moved.

build-snapshot-index.mjs now reads the frontmatter and emits a greppable
INDEX.md: a top Quick-find table (No | domain | tags | summary) plus
per-domain sections with summary + tags. 224/224 tagged, 8 domains, zero
uncategorised.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 20:12:24 +02:00

13 KiB
Raw Blame History

domain summary tags
wallet Cross-platform QR scanner — native Capacitor + web getUserMedia, wired into 3 callsites

Snapshot 0153: Cross-platform QR scanner — native Capacitor + web getUserMedia, wired into 3 callsites

Captured 2026-06-13

Git state

  • Rev: (no git)
  • New: shells/app/src/qr/qr-uri.ts, shells/app/src/qr/QrScanner.tsx, shells/app/test/qr-uri.test.ts.
  • Updated: shells/app/package.json (+@capacitor-mlkit/barcode-scanning, +jsqr), shells/app/src/components/QuickActions.tsx (Scan tile → scanner + routing), shells/app/src/components/SendFlow.tsx (Scan QR button on the recipient field + accepts initialRecipient/initialAmountBch props from QuickActions), shells/app/src/screens/AppsScreen.tsx (Scan QR button in the Connect-URI view + detectProto now recognises the canonical cc: prefix).

Scope

Close the user-visible "QR doesn't work" gap. The shell already had explicit hooks for it — QuickActions.tsx:11 (// camera seam — needs the Capacitor barcode plugin on a device), AppsScreen.tsx:294 ("Camera scanning requires the device build (Capacitor plugin)"), AppsScreen.tsx:178 ("Scan a QR or paste a session URI") — but no implementation. This snapshot ships it.

Implementation

Cross-platform component, runtime backend selection via Capacitor.isNativePlatform():

  • Native (iOS / Android): @capacitor-mlkit/barcode-scanning@^7.3.0. Google ML Kit on Android, AVFoundation on iOS — surfaces the platform's own full-screen scanner. We request camera permission, install the ML Kit scanner module if needed (Android one-shot), call BarcodeScanner.scan({ formats: [BarcodeFormat.QrCode] }), and propagate the result. Dynamic import so the web bundle doesn't drag in the native plugin.
  • Web (PWA / desktop browser): navigator.mediaDevices.getUserMedia + jsqr@^1.4.0. Renders a <video> preview behind a scan-frame overlay, pumps frames into a hidden <canvas> on requestAnimationFrame, decodes each frame with jsQR(imageData, w, h, { inversionAttempts: 'attemptBoth' }). First hit stops the loop. Camera prefers facingMode: 'environment' (rear camera on phones, default elsewhere). All resources cleaned up on unmount (tracks stopped, RAF cancelled, video paused, srcObject nulled). jsQR is dynamic-imported → code-split into its own 130 KB / 47 KB-gzipped chunk that only loads when the scanner opens (see verification below).

Error states surfaced explicitly: permission denied (NotAllowedError/SecurityError), no camera (NotFoundError/OverconstrainedError), unsupported browser (no mediaDevices), unsupported native device (ML Kit isSupported() returns false).

URI router

src/qr/qr-uri.ts is the pure routing layer the scanner feeds into. Four classes, in detection order:

  1. Connect URI — wc: / cc: / cashconnect: / bchconnect: → protocol-tagged for the multi-protocol responder (ADR-0043).
  2. Payment URI (BIP-21) — bitcoincash:<addr>?amount=…&label=…&message=… (mainnet) / bchtest:<addr>?… (chipnet). amount is in BCH.
  3. Bare cashaddr — bitcoincash:q… / bchtest:q… / bchreg:q….
  4. Unknown — raw text surfaced verbatim; never silently guessed.

Plus bchToSats(bch) for the BIP-21 amount, with the consensus 21M BCH cap as a sanity guard. Pure module — no DOM, no SDK, no I/O — fully unit-tested.

JSON token definitions deliberately NOT inlined: QR capacity is too small for the ChipnetTokenFile shape under v40 even with binary mode; the right encoding is a URL pointing at the definition. Out of scope for this slice.

Three integration points (the existing UI hooks)

  1. QuickActions Scan tile — central FAB → Scan QR → QrScanner mounts → parseQrUri(text) routes:

    • payment / address → opens SendFlow with initialRecipient (+ initialAmountBch for BIP-21) pre-filled.
    • connect → copies URI to clipboard with a "Open Apps → Connect, paste it" hint. (Deep-link into the Apps tab is owned by App.tsx — separate UI slice.)
    • unknown → copies raw text to clipboard.
  2. AppsScreen ConnectUriView — adds a ⌗ Scan QR button below the URI textarea. On hit, only fills the URI field if the decoded text is a connect-URI (wc:/cc:/...); otherwise drops raw in so the existing "Unknown protocol" warning catches it. Also fixed detectProto to recognise the canonical cc: prefix (the cashconnect bundle rewrites wc: → cc: at proposal time — snapshot 0061).

  3. SendFlow recipient field — adds an inline ⌗ Scan button beside the input. On hit, parses with parseQrUri; for payment/address fills recipient (and amount if BIP-21 supplied one); for unknown payloads, drops the raw text in for visibility, with the existing looksLikeAddress warning catching invalid shapes. SendFlow now also accepts initialRecipient + initialAmountBch props so QuickActions can hand off the scan result.

The original release punted the QuickActions → Apps handoff to "clipboard + hint" because it crossed a tab boundary. That gap is now closed:

  • App.tsx carries a pendingConnectUri state and an openAppsConnect(uri) callback. The callback collapses any settings/pushed overlay, switches tab to 'apps', and sets the pending URI.
  • QuickActions accepts an optional onOpenAppsConnect?: (uri) => void prop. On a connect-kind QR scan it calls that, then closes the FAB sheet — falling back to clipboard copy only if the prop isn't wired.
  • AppsScreen accepts pendingConnectUri + onPendingConsumed. A small useEffect watches for an incoming URI: when one lands, it pre-fills pendingUri, jumps to view='connect-uri', then calls onPendingConsumed() so the parent clears its state and re-renders don't loop. The existing ConnectUriView then takes over (and from there the live useConnect.pair() runs the WC handshake — already wired in 0152).

End-to-end: open FAB → Scan QR → scan a wc: / cc: URI → Apps tab opens with the URI pre-filled in the textarea → tap Connect → the live responder pairs and surfaces the proposal preview. Same flow on phone, same on web.

Verification (all green)

  • npx tsc --noEmit (shell) → exit 0 (after the deep-link wiring).
  • npx vitest run → 74/74 passing (26 new qr-uri tests + 48 prior tests including the snapshot-0152 connector + classifier + signer suites).
  • npx vite build → production bundle clean in 11–14 s across iterations. jsQR is correctly code-split into its own dynamic chunk (jsQR-*.js, 130.64 KB → 47.44 KB gzipped) and only loads on first scanner open — the static bundle's index chunk grew by ~8 KB (QrScanner + qr-uri).
  • Vite dev preview reloaded post-edits; useConnect initialised without errors (only the expected metadata.url ≠ localhost dev warning from WalletKit, which is harmless on dev and disappears in the deployed PWA).
  • Camera-permission flow itself can't be exercised in the headless dev preview — the browser refuses getUserMedia without a user gesture in a trusted context. That step is the on-device validation gate.

Permission notes (for whoever runs cap add ios / native test builds)

  • iOS: Info.plist must include NSCameraUsageDescription — string like "Scan QR codes to pair with apps and fill payment addresses." Apple rejects the build without it. iOS platform hasn't been added yet (only android/ exists today).
  • Android: the @capacitor-mlkit/barcode-scanning plugin adds android.permission.CAMERA to the manifest automatically on cap sync. On first scan, ML Kit downloads the scanner module — surfaced by the installGoogleBarcodeScannerModule() step in runNativeScan; the user sees the system "Installing scanner…" dialog briefly. No app-level permission text required beyond the system prompt.
  • Web PWA: browsers gate camera access on HTTPS or localhost. The chipnet beta deploy (https://testchipnet.duckdns.org/, snapshot 0109) already satisfies this.

WizardConnect research (this turn's second ask)

Fetched + summarised https://docs.riftenlabs.com/wizardconnect/ to settle the "is WizardConnect WC2-under-the-hood or its own SDK" question that ADR-0041 §6

  • snapshot 0062 left as a probe. It is a distinct proprietary protocol — NOT WalletConnect v2 — built on Nostr NIP-17 gift-wrap encryption over a relay. So the previous plan ("if Paytaca pairs via wc: it's WC2 and free") is wrong as a fallback; if Paytaca-as-WizardConnect is the integration target, it needs its own adapter slot in shells/app/src/connect/.

Genuine advantages over WalletConnect / CashConnect

WC2 / wc2-bch-bcr CashConnect (CashRPC) WizardConnect
TLS-only relay (relay sees pairing metadata) Same relay (WC2) NIP-17 gift-wrap end-to-end encryption — "relay sees only ciphertext"
Reconnection requires re-pairing or session resurrection Same "Both sides can disconnect and reconnect independently and converge back to a live session without user action"
Per-address round-trips for new addresses Same "Wallet sends BIP32 xpubs in the handshake; the dApp derives all addresses locally with no further round-trips"
Hardcoded bch:bchtest / bch:bitcoincash chains Same "Derivation paths identified by semantic names (receive, change, defi) rather than numeric indices"

SDK shape (four packages)

  • @wizardconnect/core — transport + encryption + protocol types
  • @wizardconnect/wallet — WalletConnectionManager, multi-connection
  • @wizardconnect/dapp — DappConnectionManager, xpub state
  • @wizardconnect/react — components, hooks, QR dialog

License is LGPL-3.0-or-later with CLA — copyleft on modifications. Worth flagging for the project's license posture (the rest of Deviant ships under a non-copyleft license; consuming an LGPL library is usually fine but modifying the WizardConnect packages would copy back the LGPL terms).

Adapter slot in the multi-protocol responder

Today shells/app/src/connect/classifier.ts routes by WC2 proposal shape + URI prefix; both branches assume the relay is WC2. WizardConnect won't fit under that classifier — it's a parallel stack with its own relay (Nostr) and its own session lifecycle. Architecturally:

  • Add a third sibling next to wc-handler.ts / cc-handler.ts: wz-handler.ts, wrapping @wizardconnect/wallet WalletConnectionManager.
  • responder.ts would carry two clients now (WalletKit + Wizard manager). They don't conflict — they're isolated by transport — but the UI state surface (sessions list, pending proposal, sign request) needs to union both.
  • URI classification: a fourth prefix (TBD — the docs page doesn't pin the scheme; needs the SDK source to confirm) + a fourth branch in classifyUri.

This is a real follow-up, not "free" the way the Paytaca-might-be-WC2 hypothesis suggested. Recommendation: add WizardConnect when a concrete dApp the Deviant wallet needs to support speaks only WizardConnect — Paryon USD is the named precedent in wiki/connector.md. For now, the multi-protocol responder (WC + CC) covers the BCH dApp ecosystem the project already integrates with.

Why it's still interesting

The xpub-handshake-then-derive-locally model is genuinely better than the "dApp asks for each address" pattern of WC2 BCH — and the end-to-end encryption against the relay raises the privacy ceiling. Worth tracking if the BCH dApp ecosystem migrates; not worth pre-empting the migration today.

Deferred / next

  • D-1 contract recognition in SigningPreview — wire src/verify/'s multi-template verifier (ADR-0008 / 0015 / 0018) so a scanned-and-then- paired covenant tx renders "✓ canonical <template>" before approval (carry-over from snapshot 0152 / ADR-0041 §4).
  • Token-definition QR — render a URL pointing at a hosted definition rather than the JSON itself; route through TokenImportModal's fetch path once it lands. Out of scope this snapshot.
  • iOS platform add — cap add ios + Info.plist permission text + first build & sign — separate session, gated on Apple Developer access.
  • Device validation — pair a cc: URI from spike/wc-connect/ cashconnect.mjs against the shell scanner; scan a bitcoincash:?amount=… QR for Send; confirm the ML Kit module install on a fresh Android device.
  • WizardConnect adapter (per WizardConnect research above) — only when a dApp the wallet must support (e.g. Paryon USD) speaks WizardConnect-only. It's NOT a wc: fallback; it's a third sibling next to wc-handler.ts / cc-handler.ts running its own Nostr-relay client. License check (LGPL-3
    • CLA) before adopting any of the four @wizardconnect/* packages.