mirror of
https://github.com/kccleoc/seedpgp-web.git
synced 2026-03-07 09:57:50 +08:00
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:
@@ -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>
|
||||
);
|
||||
|
||||
@@ -3,39 +3,85 @@ import { Download } from 'lucide-react';
|
||||
import QRCode from 'qrcode';
|
||||
|
||||
interface QrDisplayProps {
|
||||
value: string;
|
||||
value: string | Uint8Array;
|
||||
}
|
||||
|
||||
export const QrDisplay: React.FC<QrDisplayProps> = ({ value }) => {
|
||||
const [dataUrl, setDataUrl] = useState<string>('');
|
||||
const [dataUrl, setDataUrl] = useState('');
|
||||
const [debugInfo, setDebugInfo] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (value) {
|
||||
QRCode.toDataURL(value, {
|
||||
errorCorrectionLevel: 'M',
|
||||
type: 'image/png',
|
||||
width: 512,
|
||||
margin: 4,
|
||||
color: {
|
||||
dark: '#000000',
|
||||
light: '#FFFFFF'
|
||||
}
|
||||
})
|
||||
.then(setDataUrl)
|
||||
.catch(console.error);
|
||||
if (!value) {
|
||||
setDataUrl('');
|
||||
return;
|
||||
}
|
||||
|
||||
const generateQR = async () => {
|
||||
try {
|
||||
console.log('🎨 QrDisplay generating QR for:', value);
|
||||
console.log(' - Type:', value instanceof Uint8Array ? 'Uint8Array' : typeof value);
|
||||
console.log(' - Length:', value.length);
|
||||
|
||||
if (value instanceof Uint8Array) {
|
||||
console.log(' - Hex:', Array.from(value).map(b => b.toString(16).padStart(2, '0')).join(''));
|
||||
|
||||
// Create canvas manually for precise control
|
||||
const canvas = document.createElement('canvas');
|
||||
|
||||
// Use the toCanvas method with Uint8Array directly
|
||||
await QRCode.toCanvas(canvas, [{
|
||||
data: value,
|
||||
mode: 'byte'
|
||||
}], {
|
||||
errorCorrectionLevel: 'L',
|
||||
width: 512,
|
||||
margin: 4,
|
||||
color: {
|
||||
dark: '#000000',
|
||||
light: '#FFFFFF'
|
||||
}
|
||||
});
|
||||
|
||||
const url = canvas.toDataURL('image/png');
|
||||
setDataUrl(url);
|
||||
setDebugInfo(`Binary QR: ${value.length} bytes`);
|
||||
console.log('✅ Binary QR generated successfully');
|
||||
} else {
|
||||
// For string data
|
||||
console.log(' - String data:', value.slice(0, 50));
|
||||
|
||||
const url = await QRCode.toDataURL(value, {
|
||||
errorCorrectionLevel: 'L',
|
||||
type: 'image/png',
|
||||
width: 512,
|
||||
margin: 4,
|
||||
color: {
|
||||
dark: '#000000',
|
||||
light: '#FFFFFF'
|
||||
}
|
||||
});
|
||||
|
||||
setDataUrl(url);
|
||||
setDebugInfo(`String QR: ${value.length} chars`);
|
||||
console.log('✅ String QR generated successfully');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('❌ QR generation error:', err);
|
||||
setDebugInfo(`Error: ${err}`);
|
||||
}
|
||||
};
|
||||
|
||||
generateQR();
|
||||
}, [value]);
|
||||
|
||||
const handleDownload = () => {
|
||||
if (!dataUrl) return;
|
||||
|
||||
// Generate filename: SeedPGP_YYYY-MM-DD_HHMMSS.png
|
||||
const now = new Date();
|
||||
const date = now.toISOString().split('T')[0]; // YYYY-MM-DD
|
||||
const time = now.toTimeString().split(' ')[0].replace(/:/g, ''); // HHMMSS
|
||||
const date = now.toISOString().split('T')[0];
|
||||
const time = now.toTimeString().split(' ')[0].replace(/:/g, '');
|
||||
const filename = `SeedPGP_${date}_${time}.png`;
|
||||
|
||||
// Create download link
|
||||
const link = document.createElement('a');
|
||||
link.href = dataUrl;
|
||||
link.download = filename;
|
||||
@@ -47,25 +93,27 @@ export const QrDisplay: React.FC<QrDisplayProps> = ({ value }) => {
|
||||
if (!dataUrl) return null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<div className="flex items-center justify-center p-4 bg-white rounded-xl border-2 border-slate-200">
|
||||
<img
|
||||
src={dataUrl}
|
||||
alt="SeedPGP QR Code"
|
||||
className="w-80 h-80"
|
||||
/>
|
||||
<div className="space-y-4">
|
||||
<div className="bg-white p-6 rounded-lg inline-block shadow-lg">
|
||||
<img src={dataUrl} alt="QR Code" className="w-full h-auto" />
|
||||
</div>
|
||||
|
||||
{debugInfo && (
|
||||
<div className="text-xs text-slate-500 font-mono">
|
||||
{debugInfo}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={handleDownload}
|
||||
className="inline-flex items-center gap-2 px-4 py-2.5 bg-gradient-to-r from-green-600 to-green-700 text-white rounded-lg font-semibold hover:from-green-700 hover:to-green-800 transition-all shadow-lg hover:shadow-xl"
|
||||
className="flex items-center gap-2 px-4 py-2 bg-teal-600 hover:bg-teal-700 text-white rounded-lg transition-colors"
|
||||
>
|
||||
<Download size={18} />
|
||||
<Download size={16} />
|
||||
Download QR Code
|
||||
</button>
|
||||
|
||||
<p className="text-xs text-slate-500 text-center max-w-sm">
|
||||
Downloads as: SeedPGP_2026-01-28_231645.png
|
||||
<p className="text-xs text-slate-500">
|
||||
Downloads as: SeedPGP_{new Date().toISOString().split('T')[0]}_HHMMSS.png
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -4,11 +4,12 @@
|
||||
* @description This component provides a full UI for the multi-step seed blending process,
|
||||
* handling various input formats, per-row decryption, and final output actions.
|
||||
*/
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { QrCode, X, Plus, CheckCircle2, AlertTriangle, RefreshCw, Sparkles, EyeOff, Lock, Key, ArrowRight } from 'lucide-react';
|
||||
import QRScanner from './QRScanner';
|
||||
import { decryptFromSeed, detectEncryptionMode } from '../lib/seedpgp';
|
||||
import { decryptFromKrux } from '../lib/krux';
|
||||
import { decodeSeedQR } from '../lib/seedqr'; // New import
|
||||
import { QrDisplay } from './QrDisplay';
|
||||
import {
|
||||
blendMnemonicsAsync,
|
||||
@@ -36,7 +37,7 @@ interface MnemonicEntry {
|
||||
rawInput: string;
|
||||
decryptedMnemonic: string | null;
|
||||
isEncrypted: boolean;
|
||||
inputType: 'text' | 'seedpgp' | 'krux';
|
||||
inputType: 'text' | 'seedpgp' | 'krux' | 'seedqr';
|
||||
passwordRequired: boolean;
|
||||
passwordInput: string;
|
||||
error: string | null;
|
||||
@@ -157,17 +158,68 @@ export function SeedBlender({ onDirtyStateChange, setMnemonicForBackup, requestT
|
||||
setShowQRScanner(true);
|
||||
};
|
||||
|
||||
const handleScanSuccess = (scannedText: string) => {
|
||||
const handleScanSuccess = useCallback(async (scannedData: string | Uint8Array) => {
|
||||
if (scanTargetIndex === null) return;
|
||||
|
||||
// Convert binary data to hex string if necessary
|
||||
const scannedText = typeof scannedData === 'string'
|
||||
? scannedData
|
||||
: Array.from(scannedData).map(b => b.toString(16).padStart(2, '0')).join('');
|
||||
|
||||
const mode = detectEncryptionMode(scannedText);
|
||||
const isEncrypted = mode === 'pgp' || mode === 'krux';
|
||||
updateEntry(scanTargetIndex, {
|
||||
rawInput: scannedText, isEncrypted, inputType: isEncrypted ? mode : 'text',
|
||||
passwordRequired: isEncrypted, decryptedMnemonic: isEncrypted ? null : scannedText, error: null,
|
||||
});
|
||||
let mnemonic = scannedText;
|
||||
let error: string | null = null;
|
||||
let inputType: 'text' | 'seedpgp' | 'krux' | 'seedqr' = 'text';
|
||||
|
||||
try {
|
||||
if (mode === 'seedqr') {
|
||||
mnemonic = await decodeSeedQR(scannedText);
|
||||
inputType = 'seedqr';
|
||||
updateEntry(scanTargetIndex, {
|
||||
rawInput: mnemonic,
|
||||
decryptedMnemonic: mnemonic,
|
||||
isEncrypted: false,
|
||||
passwordRequired: false,
|
||||
inputType,
|
||||
error: null,
|
||||
});
|
||||
} else if (mode === 'pgp' || mode === 'krux') {
|
||||
inputType = (mode === 'pgp' ? 'seedpgp' : mode);
|
||||
updateEntry(scanTargetIndex, {
|
||||
rawInput: scannedText,
|
||||
decryptedMnemonic: null,
|
||||
isEncrypted: true,
|
||||
passwordRequired: true,
|
||||
inputType,
|
||||
error: null,
|
||||
});
|
||||
} else { // text or un-recognized
|
||||
updateEntry(scanTargetIndex, {
|
||||
rawInput: scannedText,
|
||||
decryptedMnemonic: scannedText,
|
||||
isEncrypted: false,
|
||||
passwordRequired: false,
|
||||
inputType: 'text',
|
||||
error: null,
|
||||
});
|
||||
}
|
||||
} catch (e: any) {
|
||||
error = e.message || "Failed to process QR code";
|
||||
updateEntry(scanTargetIndex, { rawInput: scannedText, error });
|
||||
}
|
||||
setShowQRScanner(false);
|
||||
};
|
||||
}, [scanTargetIndex]);
|
||||
|
||||
const handleScanClose = useCallback(() => {
|
||||
setShowQRScanner(false);
|
||||
}, []);
|
||||
|
||||
const handleScanError = useCallback((errMsg: string) => {
|
||||
if (scanTargetIndex !== null) {
|
||||
updateEntry(scanTargetIndex, { error: errMsg });
|
||||
}
|
||||
}, [scanTargetIndex]);
|
||||
|
||||
const handleDecrypt = async (index: number) => {
|
||||
const entry = entries[index];
|
||||
if (!entry.isEncrypted || !entry.passwordInput) return;
|
||||
@@ -178,7 +230,7 @@ export function SeedBlender({ onDirtyStateChange, setMnemonicForBackup, requestT
|
||||
} else { // seedpgp
|
||||
mnemonic = (await decryptFromSeed({ frameText: entry.rawInput, messagePassword: entry.passwordInput, mode: 'pgp' })).w;
|
||||
}
|
||||
updateEntry(index, { decryptedMnemonic: mnemonic, isEncrypted: false, passwordRequired: false, error: null });
|
||||
updateEntry(index, { rawInput: mnemonic, decryptedMnemonic: mnemonic, isEncrypted: false, passwordRequired: false, error: null });
|
||||
} catch(e: any) {
|
||||
updateEntry(index, { error: e.message || "Decryption failed" });
|
||||
}
|
||||
@@ -251,7 +303,7 @@ export function SeedBlender({ onDirtyStateChange, setMnemonicForBackup, requestT
|
||||
|
||||
<div className="p-6 bg-slate-700/50 rounded-xl border border-slate-600 min-h-[10rem]">
|
||||
<h3 className="font-semibold text-lg mb-4 text-slate-200">Step 2: Blended Preview</h3>
|
||||
{blending ? <p className="text-sm text-slate-400">Blending...</p> : !blendError && blendedResult ? (<div className="space-y-4 animate-in fade-in">{xorStrength?.isWeak && (<div className="p-3 bg-amber-500/10 border border-amber-500/30 text-amber-300 rounded-lg text-sm flex gap-3"><AlertTriangle /><div><span className="font-bold">Weak XOR Result:</span> Detected only {xorStrength.uniqueBytes} unique bytes. This can happen if seeds are identical or too similar.</div></div>)}<div className="space-y-1"><label className="text-xs font-semibold text-slate-400">Blended Mnemonic (12-word)</label><p className="p-3 bg-slate-800 rounded-md font-mono text-sm text-slate-100 break-words">{blendedResult.blendedMnemonic12}</p></div>{blendedResult.blendedMnemonic24 && (<div className="space-y-1"><label className="text-xs font-semibold text-slate-400">Blended Mnemonic (24-word)</label><p className="p-3 bg-slate-800 rounded-md font-mono text-sm text-slate-100 break-words">{blendedResult.blendedMnemonic24}</p></div>)}</div>) : (<p className="text-sm text-slate-400">{blendError || 'Previews will appear here once you enter one or more valid mnemonics.'}</p>)}
|
||||
{blending ? <p className="text-sm text-slate-400">Blending...</p> : !blendError && blendedResult ? (<div className="space-y-4 animate-in fade-in">{xorStrength?.isWeak && (<div className="p-3 bg-amber-500/10 border border-amber-500/30 text-amber-300 rounded-lg text-sm flex gap-3"><AlertTriangle /><div><span className="font-bold">Weak XOR Result:</span> Detected only {xorStrength.uniqueBytes} unique bytes. This can happen if seeds are identical or too similar.</div></div>)}<div className="space-y-1"><label className="text-xs font-semibold text-slate-400">Blended Mnemonic (12-word)</label><p data-sensitive="Blended Mnemonic (12-word)" className="p-3 bg-slate-800 rounded-md font-mono text-sm text-slate-100 break-words">{blendedResult.blendedMnemonic12}</p></div>{blendedResult.blendedMnemonic24 && (<div className="space-y-1"><label className="text-xs font-semibold text-slate-400">Blended Mnemonic (24-word)</label><p data-sensitive="Blended Mnemonic (24-word)" className="p-3 bg-slate-800 rounded-md font-mono text-sm text-slate-100 break-words">{blendedResult.blendedMnemonic24}</p></div>)}</div>) : (<p className="text-sm text-slate-400">{blendError || 'Previews will appear here once you enter one or more valid mnemonics.'}</p>)}
|
||||
</div>
|
||||
|
||||
<div className="p-6 bg-slate-700/50 rounded-xl border border-slate-600">
|
||||
@@ -260,7 +312,7 @@ export function SeedBlender({ onDirtyStateChange, setMnemonicForBackup, requestT
|
||||
<textarea value={diceRolls} onChange={(e) => setDiceRolls(e.target.value.replace(/[^1-6]/g, ''))} placeholder="Enter 99+ dice rolls (e.g., 16345...)" className="w-full h-32 p-3 bg-slate-50 border-2 border-slate-200 rounded-lg text-lg font-mono text-slate-900 placeholder:text-slate-400" />
|
||||
{dicePatternWarning && (<div className="p-3 bg-amber-500/10 border border-amber-500/30 text-amber-300 rounded-lg text-sm flex gap-3"><AlertTriangle /><p><span className="font-bold">Warning:</span> {dicePatternWarning}</p></div>)}
|
||||
{diceStats && diceStats.length > 0 && (<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-center"><div className="p-3 bg-slate-800 rounded-lg"><p className="text-xs text-slate-400">Rolls</p><p className="text-lg font-bold">{diceStats.length}</p></div><div className="p-3 bg-slate-800 rounded-lg"><p className="text-xs text-slate-400">Entropy (bits)</p><p className="text-lg font-bold">{diceStats.estimatedEntropyBits.toFixed(1)}</p></div><div className="p-3 bg-slate-800 rounded-lg"><p className="text-xs text-slate-400">Mean</p><p className="text-lg font-bold">{diceStats.mean.toFixed(2)}</p></div><div className="p-3 bg-slate-800 rounded-lg"><p className="text-xs text-slate-400">Chi-Square</p><p className={`text-lg font-bold ${diceStats.chiSquare > 11.07 ? 'text-amber-400' : ''}`}>{diceStats.chiSquare.toFixed(2)}</p></div></div>)}
|
||||
{diceOnlyMnemonic && (<div className="space-y-1 pt-2"><label className="text-xs font-semibold text-slate-400">Dice-Only Preview Mnemonic</label><p className="p-3 bg-slate-800 rounded-md font-mono text-sm text-slate-100 break-words">{diceOnlyMnemonic}</p></div>)}
|
||||
{diceOnlyMnemonic && (<div className="space-y-1 pt-2"><label className="text-xs font-semibold text-slate-400">Dice-Only Preview Mnemonic</label><p data-sensitive="Dice-Only Preview Mnemonic" className="p-3 bg-slate-800 rounded-md font-mono text-sm text-slate-100 break-words">{diceOnlyMnemonic}</p></div>)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -269,7 +321,7 @@ export function SeedBlender({ onDirtyStateChange, setMnemonicForBackup, requestT
|
||||
{finalMnemonic ? (
|
||||
<div className="p-4 bg-gradient-to-br from-green-50 to-emerald-50 border-2 border-green-300 rounded-2xl shadow-lg">
|
||||
<div className="flex items-center justify-between mb-4"><span className="font-bold text-green-700 flex items-center gap-2 text-lg"><CheckCircle2 size={22} /> Final Mnemonic Generated</span><button onClick={() => setFinalMnemonic(null)} className="p-2.5 hover:bg-green-100 rounded-xl"><EyeOff size={22} /></button></div>
|
||||
<div className="p-6 bg-white rounded-xl border-2 border-green-200 shadow-sm"><p className="font-mono text-center text-lg text-slate-800 break-words">{finalMnemonic}</p></div>
|
||||
<div className="p-6 bg-white rounded-xl border-2 border-green-200 shadow-sm"><p data-sensitive="Final Blended Mnemonic" className="font-mono text-center text-lg text-slate-800 break-words">{finalMnemonic}</p></div>
|
||||
<div className="mt-4 p-3 bg-red-500/10 text-red-300 rounded-lg text-xs flex gap-2"><AlertTriangle size={16} className="shrink-0 mt-0.5" /><span><strong>Security Warning:</strong> Write this down immediately. Do not save it digitally.</span></div>
|
||||
<div className="grid grid-cols-2 gap-3 mt-4">
|
||||
<button onClick={() => setShowFinalQR(true)} className="w-full py-2.5 bg-slate-700 text-white rounded-lg font-semibold flex items-center justify-center gap-2"><QrCode size={16}/> Export as QR</button>
|
||||
@@ -282,7 +334,11 @@ export function SeedBlender({ onDirtyStateChange, setMnemonicForBackup, requestT
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showQRScanner && <QRScanner onScanSuccess={handleScanSuccess} onClose={() => setShowQRScanner(false)} />}
|
||||
{showQRScanner && <QRScanner
|
||||
onScanSuccess={handleScanSuccess}
|
||||
onClose={handleScanClose}
|
||||
onError={handleScanError}
|
||||
/>}
|
||||
{showFinalQR && finalMnemonic && (
|
||||
<div className="fixed inset-0 bg-black/60 z-50 flex items-center justify-center" onClick={() => setShowFinalQR(false)}>
|
||||
<div className="bg-white rounded-2xl p-4" onClick={e => e.stopPropagation()}>
|
||||
|
||||
Reference in New Issue
Block a user