diff --git a/neode-ui/src/components/WalletScanModal.vue b/neode-ui/src/components/WalletScanModal.vue index 6aa86204..01dc82a9 100644 --- a/neode-ui/src/components/WalletScanModal.vue +++ b/neode-ui/src/components/WalletScanModal.vue @@ -18,7 +18,7 @@
- -
- - -
-

- {{ scanStatus }} -

-
- - -
- - -
-
- -
-
+
+ +
+ + + +

How do you want to read the QR?

+ + + +
+ +
@@ -127,17 +90,20 @@ -
+ + +

{{ scanStatus || 'Point the camera at a Lightning invoice, Bitcoin address, Cashu or Fedimint code' }} @@ -305,12 +271,13 @@ import { useBodyScrollLock } from '@/composables/useBodyScrollLock' type Rail = 'onchain' | 'lightning' | 'cashu' | 'fedimint' type Action = 'pay-invoice' | 'send-onchain' | 'redeem-token' | 'fedimint-join' -type Pane = 'choose' | 'scan' | 'amount' | 'success' +type Pane = 'scan' | 'amount' | 'success' -// JS bridge the Android companion app injects: when present, live scanning is +// JS bridge the Android companion injects: when present, live scanning is // delegated to a native camera modal (styled like this one) — the WebView's -// getUserMedia preview lags badly on phones. Decodes come back through the -// window.__archyQr* callbacks; we mirror status lines out to the native modal. +// getUserMedia preview lags, and over plain http it doesn't exist at all. +// Decodes come back through the window.__archyQr* callbacks; status lines +// (animated-QR progress, errors) mirror out to the native modal's strip. interface ArchipelagoQrBridge { open(): void setStatus(message: string, isError: boolean): void @@ -333,10 +300,9 @@ useModalKeyboard(modalRef, computed(() => props.show), close) useBodyScrollLock(computed(() => props.show)) // --- Pane state --- -const pane = ref('choose') +const pane = ref('scan') const direction = ref<'forward' | 'back'>('forward') const paneTitle = computed(() => { - if (pane.value === 'choose') return 'Send' if (pane.value === 'scan') return 'Scan to send' if (pane.value === 'success') return 'Success' if (action.value === 'fedimint-join') return 'Join federation' @@ -350,26 +316,17 @@ function goTo(p: Pane, dir: 'forward' | 'back' = 'forward') { } function goBack() { - if (pane.value === 'scan') { - stopScanning() - goTo('choose', 'back') - } else if (pane.value === 'amount') { + if (pane.value === 'amount') { error.value = '' - goTo('choose', 'back') + goTo('scan', 'back') + // Only relight the camera when that's what the user chose; otherwise + // they land back on the scan/upload chooser. + nextTick(() => { if (scanChoice.value === 'camera' && !liveCameraUnavailable.value) startScanning() }) } else if (pane.value === 'success') { close() } } -// --- Chooser --- -function chooseCamera() { - scanStatus.value = '' - scanStatusIsError.value = false - if (startNativeScan()) return - goTo('scan') - nextTick(() => { if (!liveCameraUnavailable.value) startScanning() }) -} - // --- Scanner --- const videoElement = ref(null) const isScanning = ref(false) @@ -377,14 +334,20 @@ const isScanning = ref(false) // open) — the fallback buttons stay hidden until it resolves, so they don't // flash for a second on every open. const autoStarting = ref(false) -const scanStatus = ref('') -const scanStatusIsError = ref(false) -const cameraError = ref(false) -const pasteInput = ref('') -const qrScanner = ref(null) -const animatedDecoder = useAnimatedQRDecoder() + +// Camera-vs-photo chooser, shown on EVERY open (user request 2026-07-23): +// nothing starts until the user picks, and Back from a later pane returns to +// the live camera only if that's what they chose. +const scanChoice = ref<'unset' | 'camera'>('unset') + +function chooseCamera() { + if (startNativeScan()) return + scanChoice.value = 'camera' + void nextTick(() => startScanning()) +} // --- Native scanner (companion app) --- +const hasNativeQr = !!nativeWin.ArchipelagoQr const nativeScanActive = ref(false) function startNativeScan(): boolean { @@ -396,14 +359,12 @@ function startNativeScan(): boolean { bridge.open() return true } - -// Mirror scan status (animated-QR progress, "not recognised" errors) onto the -// native modal's status strip while it's up. -watch([scanStatus, scanStatusIsError], () => { - if (nativeScanActive.value) { - nativeWin.ArchipelagoQr?.setStatus(scanStatus.value, scanStatusIsError.value) - } -}) +const scanStatus = ref('') +const scanStatusIsError = ref(false) +const cameraError = ref(false) +const pasteInput = ref('') +const qrScanner = ref(null) +const animatedDecoder = useAnimatedQRDecoder() async function startScanning() { cameraError.value = false @@ -453,6 +414,14 @@ function stopScanning() { } } +// Mirror status lines onto the native modal while it's up — it covers the +// page, so this strip is the only feedback the user can see. +watch([scanStatus, scanStatusIsError], () => { + if (nativeScanActive.value) { + nativeWin.ArchipelagoQr?.setStatus(scanStatus.value, scanStatusIsError.value) + } +}) + function submitPaste() { const text = pasteInput.value.trim() if (!text) return @@ -464,18 +433,16 @@ function submitPaste() { // undefined), but opens the native camera in any browser, // PWA or WebView; the shot is decoded locally by qr-scanner's scanImage. const photoInput = ref(null) -// Chooser-pane picker: same decode path, but no capture attribute — the user -// picks an existing image (screenshot, saved QR) rather than taking a photo. -const uploadInput = ref(null) const liveCameraUnavailable = computed(() => !navigator.mediaDevices?.getUserMedia) async function onPhotoPicked(e: Event) { const input = e.target as HTMLInputElement const file = input.files?.[0] if (!file) return + scanStatusIsError.value = false + scanStatus.value = 'Reading photo…' try { - const result = await QrScanner.scanImage(file, { returnDetailedScanResult: true }) - handleScanned(result.data) + handleScanned(await decodePhotoRobust(file)) } catch { scanStatusIsError.value = true scanStatus.value = 'No QR code found in that photo — try again, closer and well-lit' @@ -484,6 +451,28 @@ async function onPhotoPicked(e: Event) { } } +/** Decode a QR photo with every engine we have. On the companion app the + * photo path IS the scan path (plain-http LAN = no secure context = no + * live camera), and Lightning invoices make DENSE codes that the wasm + * engine's single pass often misses (reported 2026-07-22: "camera not + * picking up the invoice"). Android WebView's native BarcodeDetector is + * far stronger on dense codes, so try it first; fall back to qr-scanner. */ +async function decodePhotoRobust(file: File): Promise { + try { + const Detector = (window as unknown as { BarcodeDetector?: new (opts: { formats: string[] }) => { detect(src: ImageBitmap): Promise> } }).BarcodeDetector + if (Detector) { + const bmp = await createImageBitmap(file) + const codes = await new Detector({ formats: ['qr_code'] }).detect(bmp) + const hit = codes.find(c => c.rawValue) + if (hit) return hit.rawValue + } + } catch { + // Native detector unavailable/failed — wasm engine below. + } + const result = await QrScanner.scanImage(file, { returnDetailedScanResult: true }) + return result.data +} + // --- Detection (ported from k484's scanner) --- const rail = ref('lightning') const action = ref('pay-invoice') @@ -773,7 +762,7 @@ async function confirmSend() { function resetAll() { stopScanning() animatedDecoder.reset() - pane.value = 'choose' + pane.value = 'scan' direction.value = 'forward' scanStatus.value = '' scanStatusIsError.value = false @@ -793,10 +782,11 @@ function close() { emit('close') } -// Every open lands on the chooser — the camera never auto-starts. watch(() => props.show, (open) => { if (open) { resetAll() + // No auto-start: the chooser interstitial owns the first move every time. + scanChoice.value = 'unset' } else { stopScanning() }