SkateSpots/src/hooks/useQRCodeScanner.ts

265 lines
8.5 KiB
TypeScript

import { useState, useCallback, useRef } from 'react';
import { isValidClinkOffer } from '@/lib/clink';
// Html5Qrcode imported lazily inside startScanning to avoid canvas crash at module load
type Html5QrcodeType = import('html5-qrcode').Html5Qrcode;
export type QRScanResult =
| { type: 'lightning_invoice'; value: string }
| { type: 'lightning_address'; value: string }
| { type: 'cashu_token'; value: string }
| { type: 'cashu_request'; value: string }
| { type: 'bitcoin_address'; value: string }
| { type: 'npub'; value: string }
| { type: 'clink_offer'; value: string }
| { type: 'clink_debit'; value: string }
| { type: 'unknown'; value: string };
const LIGHTNING_INVOICE_RE = /^lnbc[a-z0-9]+$/i;
const BITCOIN_ADDRESS_RE = /^(bc1|3|1)[a-zA-Z0-9]{25,62}$/;
/**
* NUT-18 Cashu payment request. Two encodings:
* - bech32 `creq1…` (what a BIP21 `?creq=` carries, e.g. from a Numo terminal)
* - legacy raw `creqA…`
*/
const CASHU_REQUEST_RE = /^creq1[0-9a-z]+$/i;
const CASHU_REQUEST_RAW_RE = /^creqA[0-9A-Za-z+/=_-]+$/;
/** True for a NUT-18 Cashu payment request (`creq…`). */
export function isCashuRequest(value: string): boolean {
return CASHU_REQUEST_RE.test(value) || CASHU_REQUEST_RAW_RE.test(value);
}
/**
* Read a BIP21 query param WITHOUT `URLSearchParams`' `+`→space coercion (a raw
* `creqA…` request can contain base64 `+`). Returns null when absent.
*/
function bip21Param(query: string, key: string): string | null {
for (const pair of query.split('&')) {
const eq = pair.indexOf('=');
if (eq === -1) continue;
if (pair.slice(0, eq).toLowerCase() !== key) continue;
const raw = pair.slice(eq + 1);
try {
return decodeURIComponent(raw);
} catch {
return raw;
}
}
return null;
}
/**
* BIP21 `bitcoin:` URI — the shape a Numo POS emits, e.g.
* `bitcoin:?creq=…&lightning=lnbc…`. Prefer the Lightning invoice (payable by
* every wallet), then a Cashu request, then a bare on-chain address.
*/
function classifyBitcoinUri(uri: string): QRScanResult {
const rest = uri.slice('bitcoin:'.length);
const qIndex = rest.indexOf('?');
const address = (qIndex === -1 ? rest : rest.slice(0, qIndex)).trim();
const query = qIndex === -1 ? '' : rest.slice(qIndex + 1);
const lightning = bip21Param(query, 'lightning');
if (lightning && LIGHTNING_INVOICE_RE.test(lightning)) {
return { type: 'lightning_invoice', value: lightning };
}
const creq = bip21Param(query, 'creq');
if (creq && isCashuRequest(creq)) {
return { type: 'cashu_request', value: creq };
}
if (BITCOIN_ADDRESS_RE.test(address)) {
return { type: 'bitcoin_address', value: address };
}
return { type: 'unknown', value: uri };
}
/**
* Classify scanned QR code content
*/
export function classifyQRCode(decodedText: string): QRScanResult {
const trimmed = decodedText.trim();
// BIP21 bitcoin: URI — may carry a Lightning invoice and/or a Cashu request.
if (/^bitcoin:/i.test(trimmed)) {
return classifyBitcoinUri(trimmed);
}
// Lightning invoice: lnbc... or lightning:lnbc...
const lightningInvoiceMatch = trimmed.match(/^(?:lightning:)?(lnbc[a-z0-9]+)$/i);
if (lightningInvoiceMatch) {
return { type: 'lightning_invoice', value: lightningInvoiceMatch[1] };
}
// Cashu payment request (NUT-18): creq1... (bech32) or creqA... (raw)
if (isCashuRequest(trimmed)) {
return { type: 'cashu_request', value: trimmed };
}
// Lightning address: name@domain.tld or lightning:name@domain.tld
const lightningAddressMatch = trimmed.match(/^(?:lightning:)?([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})$/);
if (lightningAddressMatch) {
return { type: 'lightning_address', value: lightningAddressMatch[1] };
}
// Cashu token: cashu...
if (/^cashu[A-Za-z0-9+/=_-]+$/i.test(trimmed)) {
return { type: 'cashu_token', value: trimmed };
}
// Bitcoin address: bc1... or legacy (1..., 3...)
if (BITCOIN_ADDRESS_RE.test(trimmed)) {
return { type: 'bitcoin_address', value: trimmed };
}
// Nostr npub: npub1... or nostr:npub1...
const npubMatch = trimmed.match(/^(?:nostr:)?(npub1[a-z0-9]{58})$/);
if (npubMatch) {
return { type: 'npub', value: npubMatch[1] };
}
// CLINK Offer code: noffer1… or nostr:noffer1… (self-custodial static payment code).
// Validated with a real bech32+TLV decode (checksum included), not a regex.
const nofferMatch = trimmed.match(/^(?:nostr:)?(noffer1[023456789acdefghjklmnpqrstuvwxyz]{20,})$/i);
if (nofferMatch && isValidClinkOffer(nofferMatch[1])) {
return { type: 'clink_offer', value: nofferMatch[1].toLowerCase() };
}
// CLINK Debit authorization: ndebit1... or nostr:ndebit1...
const ndebitMatch = trimmed.match(/^(?:nostr:)?(ndebit1[023456789acdefghjklmnpqrstuvwxyz]{20,})$/i);
if (ndebitMatch) {
return { type: 'clink_debit', value: ndebitMatch[1].toLowerCase() };
}
return { type: 'unknown', value: trimmed };
}
export function useQRCodeScanner() {
const [isScanning, setIsScanning] = useState(false);
const [error, setError] = useState<string | null>(null);
const scannerRef = useRef<Html5QrcodeType | null>(null);
const isStartingRef = useRef(false);
const shouldCancelRef = useRef(false);
const startScanning = useCallback(async (
onResult: (result: QRScanResult) => void
): Promise<void> => {
try {
isStartingRef.current = true;
shouldCancelRef.current = false;
setError(null);
// Lazy load to avoid canvas crash at module load time
const { Html5Qrcode } = await import('html5-qrcode');
// Find available cameras
const devices = await Html5Qrcode.getCameras();
if (devices.length === 0) {
throw new Error('No cameras found');
}
if (shouldCancelRef.current) {
isStartingRef.current = false;
return;
}
// Use rear camera by default, or first available
const cameraId = devices.length > 1
? devices.find(d =>
d.label.toLowerCase().includes('back') ||
d.label.toLowerCase().includes('rear')
)?.id || devices[0].id
: devices[0].id;
// Initialize scanner
const scanner = new Html5Qrcode('qr-reader');
scannerRef.current = scanner;
// Start scanning
await scanner.start(
cameraId,
{
fps: 10,
aspectRatio: 1,
qrbox: 250
},
(decodedText) => {
const result = classifyQRCode(decodedText);
onResult(result);
},
(errorMessage) => {
// Ignore "QR code not found" errors
if (!errorMessage.includes('qr')) {
console.debug('QR scan error:', errorMessage);
}
}
);
isStartingRef.current = false;
setIsScanning(true);
} catch (err) {
isStartingRef.current = false;
let errorMessage = 'Failed to start camera';
if (err instanceof Error) {
errorMessage = err.message;
}
if (errorMessage.includes('NotAllowedError') || errorMessage.includes('Permission denied')) {
errorMessage = 'Camera permission denied. Please allow camera access.';
} else if (errorMessage.includes('NotFoundError') || errorMessage.includes('No device')) {
errorMessage = 'No camera found. Please connect a camera.';
}
setError(errorMessage);
setIsScanning(false);
}
}, []);
const stopScanning = useCallback(async (): Promise<void> => {
try {
if (isStartingRef.current) {
shouldCancelRef.current = true;
let attempts = 0;
while (isStartingRef.current && attempts < 50) {
await new Promise(resolve => setTimeout(resolve, 100));
attempts++;
}
}
if (scannerRef.current) {
const scanner = scannerRef.current;
const state = scanner.getState();
if (state === 2) { // SCANNING
await scanner.stop();
} else if (state === 1) { // STARTING
await new Promise(resolve => setTimeout(resolve, 200));
if (scanner.getState() === 2) {
await scanner.stop();
}
}
await scanner.clear();
scannerRef.current = null;
}
shouldCancelRef.current = false;
isStartingRef.current = false;
setIsScanning(false);
setError(null);
} catch (err) {
console.error('Error stopping scanner:', err);
scannerRef.current = null;
setIsScanning(false);
}
}, []);
return {
isScanning,
error,
startScanning,
stopScanning,
classifyQRCode,
};
}