265 lines
8.5 KiB
TypeScript
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,
|
|
};
|
|
}
|