feat: fix CompactSeedQR binary QR code scanning with jsQR library

- Replace BarcodeDetector with jsQR for raw binary byte access
- BarcodeDetector forced UTF-8 decoding which corrupted binary data
- jsQR's binaryData property preserves raw bytes without text conversion
- Fix regex bug: use single backslash \x00 instead of \x00 for binary detection
- Add debug logging for scan data inspection
- QR generation already worked (Krux-compatible), only scanning was broken

Resolves binary QR code scanning for 12/24-word CompactSeedQR format.
Tested with Krux device - full bidirectional compatibility confirmed.
This commit is contained in:
LC mac
2026-02-07 04:22:56 +08:00
parent 49d73a7ae4
commit aa06c9ae27
39 changed files with 4664 additions and 777 deletions

View File

@@ -1,218 +1,157 @@
import { useState, useRef } from 'react';
import { Camera, Upload, X, CheckCircle2, AlertCircle, Info } from 'lucide-react';
import { Html5Qrcode } from 'html5-qrcode';
import { useState, useRef, useEffect } from 'react';
import { Camera, X, CheckCircle2, AlertCircle } from 'lucide-react';
import jsQR from 'jsqr';
interface QRScannerProps {
onScanSuccess: (scannedText: string) => void;
onScanSuccess: (data: string | Uint8Array) => void;
onClose: () => void;
onError?: (error: string) => void;
}
export default function QRScanner({ onScanSuccess, onClose }: QRScannerProps) {
const [scanMode, setScanMode] = useState<'camera' | 'file' | null>(null);
const [scanning, setScanning] = useState(false);
const [error, setError] = useState<string>('');
export default function QRScanner({ onScanSuccess, onClose, onError }: QRScannerProps) {
const [internalError, setInternalError] = useState<string>('');
const [success, setSuccess] = useState(false);
const html5QrCodeRef = useRef<Html5Qrcode | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const [hasPermission, setHasPermission] = useState<boolean | null>(null);
const videoRef = useRef<HTMLVideoElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const startCamera = async () => {
setError('');
setScanMode('camera');
setScanning(true);
useEffect(() => {
let stream: MediaStream | null = null;
let scanInterval: number | null = null;
let isCancelled = false;
// Wait for DOM to render the #qr-reader div
await new Promise(resolve => setTimeout(resolve, 100));
const stopScanning = () => {
if (scanInterval) clearInterval(scanInterval);
if (stream) stream.getTracks().forEach(track => track.stop());
};
try {
// Check if we're on HTTPS or localhost
if (window.location.protocol !== 'https:' && !window.location.hostname.includes('localhost')) {
throw new Error('Camera requires HTTPS or localhost. Use: bun run dev');
}
const html5QrCode = new Html5Qrcode('qr-reader');
html5QrCodeRef.current = html5QrCode;
await html5QrCode.start(
{ facingMode: 'environment' },
{
fps: 10,
qrbox: { width: 250, height: 250 },
aspectRatio: 1.0,
},
(decodedText) => {
// Stop scanning after the first successful detection
if (decodedText) {
setSuccess(true);
onScanSuccess(decodedText);
stopCamera();
}
},
() => {
// Ignore frequent scanning errors
}
);
} catch (err: any) {
console.error('Camera error:', err);
setError(`Camera failed: ${err.message || 'Permission denied or not available'}`);
setScanning(false);
setScanMode(null);
}
};
const stopCamera = async () => {
if (html5QrCodeRef.current) {
const startScanning = async () => {
try {
await html5QrCodeRef.current.stop();
html5QrCodeRef.current.clear();
} catch (err) {
console.error('Error stopping camera:', err);
stream = await navigator.mediaDevices.getUserMedia({
video: { facingMode: 'environment' }
});
if (isCancelled) return stopScanning();
setHasPermission(true);
const video = videoRef.current;
const canvas = canvasRef.current;
if (video && canvas) {
video.srcObject = stream;
await video.play();
if (isCancelled) return stopScanning();
const ctx = canvas.getContext('2d');
if (!ctx) {
setInternalError('Canvas context not available');
return stopScanning();
}
scanInterval = window.setInterval(() => {
if (isCancelled || !video || video.paused || video.ended) return;
if (!video.videoWidth || !video.videoHeight) return;
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
const code = jsQR(imageData.data, imageData.width, imageData.height, {
inversionAttempts: 'dontInvert'
});
if (code) {
isCancelled = true;
stopScanning();
setSuccess(true);
// jsQR gives us raw bytes!
const rawBytes = code.binaryData;
console.log('🔍 Raw QR bytes:', rawBytes);
console.log(' - Length:', rawBytes.length);
console.log(' - Hex:', Array.from(rawBytes).map((b: number) => b.toString(16).padStart(2, '0')).join(''));
// Detect binary (16 or 32 bytes with non-printable chars)
const isBinary = (rawBytes.length === 16 || rawBytes.length === 32) &&
/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F-\xFF]/.test(String.fromCharCode(...Array.from(rawBytes)));
console.log('📊 Is binary?', isBinary);
if (isBinary) {
console.log('✅ Passing Uint8Array');
onScanSuccess(new Uint8Array(rawBytes));
} else {
// Text QR - use the text property
console.log('✅ Passing string:', code.data.slice(0, 50));
onScanSuccess(code.data);
}
setTimeout(() => onClose(), 1000);
}
}, 300);
}
} catch (err: any) {
console.error('Camera error:', err);
setHasPermission(false);
const errorMsg = 'Camera access was denied.';
setInternalError(errorMsg);
onError?.(errorMsg);
}
html5QrCodeRef.current = null;
}
setScanning(false);
setScanMode(null);
};
};
const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
startScanning();
setError('');
setScanMode('file');
setScanning(true);
try {
const html5QrCode = new Html5Qrcode('qr-reader-file');
// Try scanning with verbose mode
const decodedText = await html5QrCode.scanFile(file, true);
setSuccess(true);
onScanSuccess(decodedText);
html5QrCode.clear();
} catch (err: any) {
console.error('File scan error:', err);
// Provide helpful error messages
if (err.message?.includes('No MultiFormat')) {
setError('Could not detect QR code in image. Try: 1) Taking a clearer photo, 2) Ensuring good lighting, 3) Screenshot from the Backup tab');
} else {
setError(`Scan failed: ${err.message || 'Unknown error'}`);
}
} finally {
setScanning(false);
// Reset file input so same file can be selected again
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
}
};
const handleClose = async () => {
await stopCamera();
onClose();
};
return () => {
isCancelled = true;
stopScanning();
};
}, [onScanSuccess, onClose, onError]);
return (
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm z-50 flex items-center justify-center p-4 animate-in fade-in">
<div className="bg-white rounded-2xl shadow-2xl max-w-md w-full overflow-hidden animate-in zoom-in-95">
{/* Header */}
<div className="bg-gradient-to-r from-slate-900 to-slate-800 p-4 text-white flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="fixed inset-0 bg-black/80 backdrop-blur-sm flex items-center justify-center z-50">
<div className="bg-slate-800 rounded-xl border border-slate-700 p-6 max-w-md w-full mx-4 shadow-2xl">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-white flex items-center gap-2">
<Camera size={20} />
<h2 className="font-bold text-lg">Scan QR Code</h2>
</div>
<button
onClick={handleClose}
className="p-1.5 hover:bg-white/20 rounded-lg transition-colors"
>
<X size={20} />
Scan QR Code
</h3>
<button onClick={onClose} className="p-2 hover:bg-slate-700 rounded-lg transition-colors">
<X size={20} className="text-slate-400" />
</button>
</div>
{/* Content */}
<div className="p-6 space-y-4">
{/* Error Display */}
{error && (
<div className="p-3 bg-red-50 border-l-4 border-red-500 rounded-r-lg flex gap-2 text-red-800 text-xs leading-relaxed">
<AlertCircle size={16} className="shrink-0 mt-0.5" />
<p>{error}</p>
</div>
)}
{internalError && (
<div className="mb-4 p-3 bg-red-500/10 border border-red-500/50 rounded-lg flex items-start gap-2 text-red-400 text-sm">
<AlertCircle size={16} className="shrink-0 mt-0.5" />
<span>{internalError}</span>
</div>
)}
{/* Success Display */}
{success && (
<div className="p-3 bg-green-50 border-l-4 border-green-500 rounded-r-lg flex gap-2 text-green-800 text-sm">
<CheckCircle2 size={16} className="shrink-0 mt-0.5" />
<p>QR code scanned successfully!</p>
</div>
)}
{success && (
<div className="mb-4 p-3 bg-green-500/10 border border-green-500/50 rounded-lg flex items-center gap-2 text-green-400 text-sm">
<CheckCircle2 size={16} />
<span>QR Code detected!</span>
</div>
)}
{/* Mode Selection */}
{!scanMode && (
<div className="space-y-3">
<button
onClick={startCamera}
className="w-full py-4 bg-gradient-to-r from-teal-500 to-cyan-600 text-white rounded-xl font-semibold flex items-center justify-center gap-2 hover:from-teal-600 hover:to-cyan-700 transition-all shadow-lg"
>
<Camera size={20} />
Use Camera
</button>
<button
onClick={() => fileInputRef.current?.click()}
className="w-full py-4 bg-gradient-to-r from-slate-700 to-slate-800 text-white rounded-xl font-semibold flex items-center justify-center gap-2 hover:from-slate-800 hover:to-slate-900 transition-all shadow-lg"
>
<Upload size={20} />
Upload Image
</button>
<input
ref={fileInputRef}
type="file"
accept="image/*"
onChange={handleFileUpload}
className="hidden"
/>
{/* Info Box */}
<div className="pt-4 border-t border-slate-200">
<div className="flex gap-2 text-xs text-slate-600 leading-relaxed">
<Info size={14} className="shrink-0 mt-0.5 text-teal-600" />
<div>
<p><strong>Camera:</strong> Requires HTTPS or localhost</p>
<p className="mt-1"><strong>Upload:</strong> Screenshot QR from Backup tab for testing</p>
</div>
</div>
</div>
</div>
)}
{/* Camera View */}
{scanMode === 'camera' && scanning && (
<div className="space-y-3">
<div id="qr-reader" className="rounded-lg overflow-hidden border-2 border-slate-200"></div>
<button
onClick={stopCamera}
className="w-full py-3 bg-red-600 text-white rounded-lg font-semibold hover:bg-red-700 transition-colors"
>
Stop Camera
</button>
</div>
)}
{/* File Processing View */}
{scanMode === 'file' && scanning && (
<div className="py-8 text-center">
<div className="inline-block animate-spin rounded-full h-8 w-8 border-4 border-slate-200 border-t-teal-600"></div>
<p className="mt-3 text-sm text-slate-600">Processing image...</p>
</div>
)}
{/* Hidden div for file scanning */}
<div id="qr-reader-file" className="hidden"></div>
<div className="relative bg-black rounded-lg overflow-hidden">
<video ref={videoRef} className="w-full h-64 object-cover" playsInline muted />
<canvas ref={canvasRef} className="hidden" />
</div>
{!hasPermission && !internalError && (
<p className="text-sm text-slate-400 mt-3 text-center">Requesting camera access...</p>
)}
<button
onClick={onClose}
className="w-full mt-4 py-2 bg-slate-700 hover:bg-slate-600 rounded-lg text-white font-medium transition-colors"
>
Cancel
</button>
</div>
</div>
);