fix: decode uploaded TOTP QR images when BarcodeDetector is unavailable

The TOTP QR reader relied solely on window.BarcodeDetector. On desktop
Chrome/Edge (Windows/Linux) that interface exists but has no working
backend, so detect() returns an empty array: uploading a valid QR image
fell through to "no QR code found" and the camera path bailed to
"unsupported" with an empty preview.

Add a dependency-free jsQR canvas fallback. decodeTotpQrImage now tries
BarcodeDetector first when present, then decodes the image via jsQR
before reporting not-found. The camera reader no longer hard-returns
"unsupported" when only BarcodeDetector is missing: it starts the camera
whenever getUserMedia is available and decodes frames with jsQR, which
also lets the preview render.

Fixes #276
This commit is contained in:
Matt Van Horn
2026-07-01 17:07:30 +08:00
committed by Shuai
parent ce3674669e
commit b0a679b1c2
3 changed files with 44 additions and 17 deletions
+7
View File
@@ -14,6 +14,7 @@
"@tanstack/react-query": "^5.101.2", "@tanstack/react-query": "^5.101.2",
"@zip.js/zip.js": "^2.8.26", "@zip.js/zip.js": "^2.8.26",
"fflate": "^0.8.3", "fflate": "^0.8.3",
"jsqr": "1.4.0",
"lucide-preact": "^1.22.0", "lucide-preact": "^1.22.0",
"preact": "^10.29.3", "preact": "^10.29.3",
"qrcode-generator": "^2.0.4", "qrcode-generator": "^2.0.4",
@@ -3442,6 +3443,12 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/jsqr": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/jsqr/-/jsqr-1.4.0.tgz",
"integrity": "sha512-dxLob7q65Xg2DvstYkRpkYtmKm2sPJ9oFhrhmudT1dZvNFFTlroai3AWSpLey/w5vMcLBXRgOJsbXpdN9HzU/A==",
"license": "Apache-2.0"
},
"node_modules/kleur": { "node_modules/kleur": {
"version": "4.1.5", "version": "4.1.5",
"resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz",
+1
View File
@@ -67,6 +67,7 @@
"@tanstack/react-query": "^5.101.2", "@tanstack/react-query": "^5.101.2",
"@zip.js/zip.js": "^2.8.26", "@zip.js/zip.js": "^2.8.26",
"fflate": "^0.8.3", "fflate": "^0.8.3",
"jsqr": "1.4.0",
"lucide-preact": "^1.22.0", "lucide-preact": "^1.22.0",
"preact": "^10.29.3", "preact": "^10.29.3",
"qrcode-generator": "^2.0.4", "qrcode-generator": "^2.0.4",
+36 -17
View File
@@ -1,6 +1,7 @@
import type { RefObject } from 'preact'; import type { RefObject } from 'preact';
import { createPortal } from 'preact/compat'; import { createPortal } from 'preact/compat';
import { ArrowDown, ArrowUp, CheckCheck, Download, Paperclip, Plus, QrCode, RefreshCw, Star, StarOff, Trash2, Upload, X } from 'lucide-preact'; import { ArrowDown, ArrowUp, CheckCheck, Download, Paperclip, Plus, QrCode, RefreshCw, Star, StarOff, Trash2, Upload, X } from 'lucide-preact';
import jsQR from 'jsqr';
import { useEffect, useRef, useState } from 'preact/hooks'; import { useEffect, useRef, useState } from 'preact/hooks';
import { useDialogLifecycle } from '@/components/ConfirmDialog'; import { useDialogLifecycle } from '@/components/ConfirmDialog';
import type { Cipher, Folder, VaultDraft, VaultDraftField } from '@/lib/types'; import type { Cipher, Folder, VaultDraft, VaultDraftField } from '@/lib/types';
@@ -171,16 +172,33 @@ export default function VaultEditor(props: VaultEditorProps) {
return new window.BarcodeDetector({ formats: ['qr_code'] }); return new window.BarcodeDetector({ formats: ['qr_code'] });
}; };
const decodeTotpQrImage = async (source: ImageBitmapSource): Promise<boolean> => { const decodeTotpQrCanvas = (source: ImageBitmap | HTMLVideoElement): string => {
const width = 'videoWidth' in source ? source.videoWidth : source.width;
const height = 'videoHeight' in source ? source.videoHeight : source.height;
if (!width || !height) return '';
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const context = canvas.getContext('2d');
if (!context) return '';
context.drawImage(source, 0, 0, width, height);
const imageData = context.getImageData(0, 0, width, height);
return String(jsQR(imageData.data, width, height)?.data || '').trim();
};
const decodeTotpQrImage = async (source: ImageBitmap): Promise<boolean> => {
const detector = createTotpQrDetector(); const detector = createTotpQrDetector();
if (!detector) { if (detector) {
setTotpQrStatus(t('txt_totp_qr_unsupported')); try {
return false; const results = await detector.detect(source);
const value = String(results[0]?.rawValue || '').trim();
if (value && applyTotpQrValue(value)) return true;
} catch {
// Fall back to jsQR when the native detector is present but not usable.
}
} }
const results = await detector.detect(source); const value = decodeTotpQrCanvas(source);
const value = String(results[0]?.rawValue || '').trim(); return value ? applyTotpQrValue(value) : false;
if (!value) return false;
return applyTotpQrValue(value);
}; };
const handleTotpQrFile = async (file: File | null) => { const handleTotpQrFile = async (file: File | null) => {
@@ -207,13 +225,6 @@ export default function VaultEditor(props: VaultEditorProps) {
} }
let stopped = false; let stopped = false;
const detector = createTotpQrDetector(); const detector = createTotpQrDetector();
if (!detector) {
setTotpQrStatus(t('txt_totp_qr_unsupported'));
return () => {
stopped = true;
stopTotpQrScanner();
};
}
if (!navigator.mediaDevices?.getUserMedia) { if (!navigator.mediaDevices?.getUserMedia) {
setTotpQrStatus(t('txt_totp_qr_camera_unavailable')); setTotpQrStatus(t('txt_totp_qr_camera_unavailable'));
return () => { return () => {
@@ -230,8 +241,16 @@ export default function VaultEditor(props: VaultEditorProps) {
return; return;
} }
try { try {
const results = await detector.detect(video); let value = '';
const value = String(results[0]?.rawValue || '').trim(); if (detector) {
try {
const results = await detector.detect(video);
value = String(results[0]?.rawValue || '').trim();
} catch {
// Fall back to jsQR when the native detector is present but not usable.
}
}
if (!value) value = decodeTotpQrCanvas(video);
if (value && applyTotpQrValue(value)) return; if (value && applyTotpQrValue(value)) return;
} catch { } catch {
// Keep the camera active; transient frame decode failures are common. // Keep the camera active; transient frame decode failures are common.