Merge gitea-ai/main — companion native-scan PR #104 (newer scanner rev wins in WalletScanModal)
Some checks failed
Demo images / Build & push demo images (push) Has been cancelled
Some checks failed
Demo images / Build & push demo images (push) Has been cancelled
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
commit
4199c43cc9
@ -18,7 +18,7 @@
|
|||||||
<div class="flex items-center justify-between gap-4 mb-4">
|
<div class="flex items-center justify-between gap-4 mb-4">
|
||||||
<div class="flex items-center gap-2 min-w-0">
|
<div class="flex items-center gap-2 min-w-0">
|
||||||
<button
|
<button
|
||||||
v-if="pane !== 'choose'"
|
v-if="pane !== 'scan'"
|
||||||
@click="goBack"
|
@click="goBack"
|
||||||
class="p-2 -ml-2 rounded-lg hover:bg-white/10 text-white/70 hover:text-white transition-colors shrink-0"
|
class="p-2 -ml-2 rounded-lg hover:bg-white/10 text-white/70 hover:text-white transition-colors shrink-0"
|
||||||
aria-label="Back"
|
aria-label="Back"
|
||||||
@ -41,63 +41,26 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Transition :name="direction === 'forward' ? 'pane-forward' : 'pane-back'" mode="out-in">
|
<Transition :name="direction === 'forward' ? 'pane-forward' : 'pane-back'" mode="out-in">
|
||||||
<!-- ============ CHOOSE PANE ============ -->
|
|
||||||
<!-- Always the first stop: the user picks camera or image upload
|
|
||||||
every time, instead of the camera auto-starting on open. -->
|
|
||||||
<div v-if="pane === 'choose'" key="choose">
|
|
||||||
<div class="grid grid-cols-2 gap-3 mb-4">
|
|
||||||
<button
|
|
||||||
@click="chooseCamera"
|
|
||||||
class="flex flex-col items-center gap-3 p-6 rounded-xl bg-white/5 border border-white/10 hover:bg-white/10 transition-colors"
|
|
||||||
>
|
|
||||||
<svg class="w-8 h-8 text-orange-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z" />
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M15 13a3 3 0 11-6 0 3 3 0 016 0z" />
|
|
||||||
</svg>
|
|
||||||
<span class="text-sm font-medium text-white">Scan with camera</span>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
@click="uploadInput?.click()"
|
|
||||||
class="flex flex-col items-center gap-3 p-6 rounded-xl bg-white/5 border border-white/10 hover:bg-white/10 transition-colors"
|
|
||||||
>
|
|
||||||
<svg class="w-8 h-8 text-orange-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 7.5L12 3m0 0L7.5 7.5M12 3v13.5" />
|
|
||||||
</svg>
|
|
||||||
<span class="text-sm font-medium text-white">Upload image</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
ref="uploadInput"
|
|
||||||
type="file"
|
|
||||||
accept="image/*"
|
|
||||||
class="hidden"
|
|
||||||
@change="onPhotoPicked"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div v-if="scanStatus" class="mb-4 p-3 bg-white/5 rounded-lg">
|
|
||||||
<p class="text-sm text-center" :class="scanStatusIsError ? 'text-red-400' : 'text-white/60'">
|
|
||||||
{{ scanStatus }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Paste fallback (also the path on camera-less nodes) -->
|
|
||||||
<div class="flex gap-2">
|
|
||||||
<input
|
|
||||||
v-model="pasteInput"
|
|
||||||
type="text"
|
|
||||||
placeholder="…or paste an invoice / address / token"
|
|
||||||
class="flex-1 input-glass font-mono text-xs"
|
|
||||||
@keydown.enter="submitPaste"
|
|
||||||
/>
|
|
||||||
<button @click="submitPaste" :disabled="!pasteInput.trim()" class="glass-button px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-40">
|
|
||||||
Use
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- ============ SCAN PANE ============ -->
|
<!-- ============ SCAN PANE ============ -->
|
||||||
<div v-else-if="pane === 'scan'" key="scan">
|
<div v-if="pane === 'scan'" key="scan">
|
||||||
<div class="relative w-full aspect-square rounded-xl overflow-hidden bg-black/40 border border-white/10 mb-4">
|
<!-- Chooser interstitial — every open: the user picks live camera
|
||||||
|
or a photo upload; neither starts until chosen. -->
|
||||||
|
<div v-if="scanChoice === 'unset'" class="w-full rounded-xl bg-black/30 border border-white/10 mb-4 p-6 flex flex-col items-center gap-3">
|
||||||
|
<svg class="w-10 h-10 text-white/40" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M3 8V6a2 2 0 012-2h2M3 16v2a2 2 0 002 2h2m10-16h2a2 2 0 012 2v2m-4 12h2a2 2 0 002-2v-2M7 12h10" />
|
||||||
|
</svg>
|
||||||
|
<p class="text-sm text-white/60 text-center">How do you want to read the QR?</p>
|
||||||
|
<!-- hasNativeQr: on the companion (plain http, no getUserMedia)
|
||||||
|
the native bridge still provides a live camera -->
|
||||||
|
<button v-if="!liveCameraUnavailable || hasNativeQr" @click="chooseCamera" class="glass-button w-full px-4 py-2.5 rounded-lg text-sm font-medium">
|
||||||
|
Scan with camera
|
||||||
|
</button>
|
||||||
|
<button @click="photoInput?.click()" class="glass-button w-full px-4 py-2.5 rounded-lg text-sm font-medium">
|
||||||
|
Upload / take a photo of the QR
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="relative w-full aspect-square rounded-xl overflow-hidden bg-black/40 border border-white/10 mb-4">
|
||||||
<!-- opacity (not v-if/v-show): the scanner needs the element, and a
|
<!-- opacity (not v-if/v-show): the scanner needs the element, and a
|
||||||
source-less <video> flashes a native play glyph in Android WebViews -->
|
source-less <video> flashes a native play glyph in Android WebViews -->
|
||||||
<video ref="videoElement" class="w-full h-full object-cover transition-opacity duration-200" :class="isScanning ? 'opacity-100' : 'opacity-0'" autoplay muted playsinline></video>
|
<video ref="videoElement" class="w-full h-full object-cover transition-opacity duration-200" :class="isScanning ? 'opacity-100' : 'opacity-0'" autoplay muted playsinline></video>
|
||||||
@ -127,17 +90,20 @@
|
|||||||
<button @click="photoInput?.click()" class="glass-button px-4 py-2 rounded-lg text-sm font-medium">
|
<button @click="photoInput?.click()" class="glass-button px-4 py-2 rounded-lg text-sm font-medium">
|
||||||
Take photo of QR
|
Take photo of QR
|
||||||
</button>
|
</button>
|
||||||
<input
|
|
||||||
ref="photoInput"
|
|
||||||
type="file"
|
|
||||||
accept="image/*"
|
|
||||||
capture="environment"
|
|
||||||
class="hidden"
|
|
||||||
@change="onPhotoPicked"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Single always-mounted picker input, shared by the chooser and
|
||||||
|
the in-camera fallback button -->
|
||||||
|
<input
|
||||||
|
ref="photoInput"
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
capture="environment"
|
||||||
|
class="hidden"
|
||||||
|
@change="onPhotoPicked"
|
||||||
|
/>
|
||||||
|
|
||||||
<div class="mb-4 p-3 bg-white/5 rounded-lg min-h-[3rem] flex items-center justify-center">
|
<div class="mb-4 p-3 bg-white/5 rounded-lg min-h-[3rem] flex items-center justify-center">
|
||||||
<p class="text-sm text-center" :class="scanStatusIsError ? 'text-red-400' : 'text-white/60'">
|
<p class="text-sm text-center" :class="scanStatusIsError ? 'text-red-400' : 'text-white/60'">
|
||||||
{{ scanStatus || 'Point the camera at a Lightning invoice, Bitcoin address, Cashu or Fedimint code' }}
|
{{ 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 Rail = 'onchain' | 'lightning' | 'cashu' | 'fedimint'
|
||||||
type Action = 'pay-invoice' | 'send-onchain' | 'redeem-token' | 'fedimint-join'
|
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
|
// delegated to a native camera modal (styled like this one) — the WebView's
|
||||||
// getUserMedia preview lags badly on phones. Decodes come back through the
|
// getUserMedia preview lags, and over plain http it doesn't exist at all.
|
||||||
// window.__archyQr* callbacks; we mirror status lines out to the native modal.
|
// Decodes come back through the window.__archyQr* callbacks; status lines
|
||||||
|
// (animated-QR progress, errors) mirror out to the native modal's strip.
|
||||||
interface ArchipelagoQrBridge {
|
interface ArchipelagoQrBridge {
|
||||||
open(): void
|
open(): void
|
||||||
setStatus(message: string, isError: boolean): void
|
setStatus(message: string, isError: boolean): void
|
||||||
@ -333,10 +300,9 @@ useModalKeyboard(modalRef, computed(() => props.show), close)
|
|||||||
useBodyScrollLock(computed(() => props.show))
|
useBodyScrollLock(computed(() => props.show))
|
||||||
|
|
||||||
// --- Pane state ---
|
// --- Pane state ---
|
||||||
const pane = ref<Pane>('choose')
|
const pane = ref<Pane>('scan')
|
||||||
const direction = ref<'forward' | 'back'>('forward')
|
const direction = ref<'forward' | 'back'>('forward')
|
||||||
const paneTitle = computed(() => {
|
const paneTitle = computed(() => {
|
||||||
if (pane.value === 'choose') return 'Send'
|
|
||||||
if (pane.value === 'scan') return 'Scan to send'
|
if (pane.value === 'scan') return 'Scan to send'
|
||||||
if (pane.value === 'success') return 'Success'
|
if (pane.value === 'success') return 'Success'
|
||||||
if (action.value === 'fedimint-join') return 'Join federation'
|
if (action.value === 'fedimint-join') return 'Join federation'
|
||||||
@ -350,26 +316,17 @@ function goTo(p: Pane, dir: 'forward' | 'back' = 'forward') {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function goBack() {
|
function goBack() {
|
||||||
if (pane.value === 'scan') {
|
if (pane.value === 'amount') {
|
||||||
stopScanning()
|
|
||||||
goTo('choose', 'back')
|
|
||||||
} else if (pane.value === 'amount') {
|
|
||||||
error.value = ''
|
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') {
|
} else if (pane.value === 'success') {
|
||||||
close()
|
close()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Chooser ---
|
|
||||||
function chooseCamera() {
|
|
||||||
scanStatus.value = ''
|
|
||||||
scanStatusIsError.value = false
|
|
||||||
if (startNativeScan()) return
|
|
||||||
goTo('scan')
|
|
||||||
nextTick(() => { if (!liveCameraUnavailable.value) startScanning() })
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Scanner ---
|
// --- Scanner ---
|
||||||
const videoElement = ref<HTMLVideoElement | null>(null)
|
const videoElement = ref<HTMLVideoElement | null>(null)
|
||||||
const isScanning = ref(false)
|
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
|
// open) — the fallback buttons stay hidden until it resolves, so they don't
|
||||||
// flash for a second on every open.
|
// flash for a second on every open.
|
||||||
const autoStarting = ref(false)
|
const autoStarting = ref(false)
|
||||||
const scanStatus = ref('')
|
|
||||||
const scanStatusIsError = ref(false)
|
// Camera-vs-photo chooser, shown on EVERY open (user request 2026-07-23):
|
||||||
const cameraError = ref(false)
|
// nothing starts until the user picks, and Back from a later pane returns to
|
||||||
const pasteInput = ref('')
|
// the live camera only if that's what they chose.
|
||||||
const qrScanner = ref<QrScanner | null>(null)
|
const scanChoice = ref<'unset' | 'camera'>('unset')
|
||||||
const animatedDecoder = useAnimatedQRDecoder()
|
|
||||||
|
function chooseCamera() {
|
||||||
|
if (startNativeScan()) return
|
||||||
|
scanChoice.value = 'camera'
|
||||||
|
void nextTick(() => startScanning())
|
||||||
|
}
|
||||||
|
|
||||||
// --- Native scanner (companion app) ---
|
// --- Native scanner (companion app) ---
|
||||||
|
const hasNativeQr = !!nativeWin.ArchipelagoQr
|
||||||
const nativeScanActive = ref(false)
|
const nativeScanActive = ref(false)
|
||||||
|
|
||||||
function startNativeScan(): boolean {
|
function startNativeScan(): boolean {
|
||||||
@ -396,14 +359,12 @@ function startNativeScan(): boolean {
|
|||||||
bridge.open()
|
bridge.open()
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
const scanStatus = ref('')
|
||||||
// Mirror scan status (animated-QR progress, "not recognised" errors) onto the
|
const scanStatusIsError = ref(false)
|
||||||
// native modal's status strip while it's up.
|
const cameraError = ref(false)
|
||||||
watch([scanStatus, scanStatusIsError], () => {
|
const pasteInput = ref('')
|
||||||
if (nativeScanActive.value) {
|
const qrScanner = ref<QrScanner | null>(null)
|
||||||
nativeWin.ArchipelagoQr?.setStatus(scanStatus.value, scanStatusIsError.value)
|
const animatedDecoder = useAnimatedQRDecoder()
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
async function startScanning() {
|
async function startScanning() {
|
||||||
cameraError.value = false
|
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() {
|
function submitPaste() {
|
||||||
const text = pasteInput.value.trim()
|
const text = pasteInput.value.trim()
|
||||||
if (!text) return
|
if (!text) return
|
||||||
@ -464,18 +433,16 @@ function submitPaste() {
|
|||||||
// undefined), but <input capture> opens the native camera in any browser,
|
// undefined), but <input capture> opens the native camera in any browser,
|
||||||
// PWA or WebView; the shot is decoded locally by qr-scanner's scanImage.
|
// PWA or WebView; the shot is decoded locally by qr-scanner's scanImage.
|
||||||
const photoInput = ref<HTMLInputElement | null>(null)
|
const photoInput = ref<HTMLInputElement | null>(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<HTMLInputElement | null>(null)
|
|
||||||
const liveCameraUnavailable = computed(() => !navigator.mediaDevices?.getUserMedia)
|
const liveCameraUnavailable = computed(() => !navigator.mediaDevices?.getUserMedia)
|
||||||
|
|
||||||
async function onPhotoPicked(e: Event) {
|
async function onPhotoPicked(e: Event) {
|
||||||
const input = e.target as HTMLInputElement
|
const input = e.target as HTMLInputElement
|
||||||
const file = input.files?.[0]
|
const file = input.files?.[0]
|
||||||
if (!file) return
|
if (!file) return
|
||||||
|
scanStatusIsError.value = false
|
||||||
|
scanStatus.value = 'Reading photo…'
|
||||||
try {
|
try {
|
||||||
const result = await QrScanner.scanImage(file, { returnDetailedScanResult: true })
|
handleScanned(await decodePhotoRobust(file))
|
||||||
handleScanned(result.data)
|
|
||||||
} catch {
|
} catch {
|
||||||
scanStatusIsError.value = true
|
scanStatusIsError.value = true
|
||||||
scanStatus.value = 'No QR code found in that photo — try again, closer and well-lit'
|
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<string> {
|
||||||
|
try {
|
||||||
|
const Detector = (window as unknown as { BarcodeDetector?: new (opts: { formats: string[] }) => { detect(src: ImageBitmap): Promise<Array<{ rawValue: string }>> } }).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) ---
|
// --- Detection (ported from k484's scanner) ---
|
||||||
const rail = ref<Rail>('lightning')
|
const rail = ref<Rail>('lightning')
|
||||||
const action = ref<Action>('pay-invoice')
|
const action = ref<Action>('pay-invoice')
|
||||||
@ -773,7 +762,7 @@ async function confirmSend() {
|
|||||||
function resetAll() {
|
function resetAll() {
|
||||||
stopScanning()
|
stopScanning()
|
||||||
animatedDecoder.reset()
|
animatedDecoder.reset()
|
||||||
pane.value = 'choose'
|
pane.value = 'scan'
|
||||||
direction.value = 'forward'
|
direction.value = 'forward'
|
||||||
scanStatus.value = ''
|
scanStatus.value = ''
|
||||||
scanStatusIsError.value = false
|
scanStatusIsError.value = false
|
||||||
@ -793,10 +782,11 @@ function close() {
|
|||||||
emit('close')
|
emit('close')
|
||||||
}
|
}
|
||||||
|
|
||||||
// Every open lands on the chooser — the camera never auto-starts.
|
|
||||||
watch(() => props.show, (open) => {
|
watch(() => props.show, (open) => {
|
||||||
if (open) {
|
if (open) {
|
||||||
resetAll()
|
resetAll()
|
||||||
|
// No auto-start: the chooser interstitial owns the first move every time.
|
||||||
|
scanChoice.value = 'unset'
|
||||||
} else {
|
} else {
|
||||||
stopScanning()
|
stopScanning()
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user