From b0a679b1c2e2ceb5fa964bfe665c3f8748386bc1 Mon Sep 17 00:00:00 2001 From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Date: Tue, 30 Jun 2026 23:12:22 -0700 Subject: [PATCH] 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 --- package-lock.json | 7 +++ package.json | 1 + webapp/src/components/vault/VaultEditor.tsx | 53 ++++++++++++++------- 3 files changed, 44 insertions(+), 17 deletions(-) diff --git a/package-lock.json b/package-lock.json index 59a0a90..d1a6e21 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ "@tanstack/react-query": "^5.101.2", "@zip.js/zip.js": "^2.8.26", "fflate": "^0.8.3", + "jsqr": "1.4.0", "lucide-preact": "^1.22.0", "preact": "^10.29.3", "qrcode-generator": "^2.0.4", @@ -3442,6 +3443,12 @@ "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": { "version": "4.1.5", "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", diff --git a/package.json b/package.json index cd137f2..e87be07 100644 --- a/package.json +++ b/package.json @@ -67,6 +67,7 @@ "@tanstack/react-query": "^5.101.2", "@zip.js/zip.js": "^2.8.26", "fflate": "^0.8.3", + "jsqr": "1.4.0", "lucide-preact": "^1.22.0", "preact": "^10.29.3", "qrcode-generator": "^2.0.4", diff --git a/webapp/src/components/vault/VaultEditor.tsx b/webapp/src/components/vault/VaultEditor.tsx index f238c46..a052b6c 100644 --- a/webapp/src/components/vault/VaultEditor.tsx +++ b/webapp/src/components/vault/VaultEditor.tsx @@ -1,6 +1,7 @@ import type { RefObject } from 'preact'; import { createPortal } from 'preact/compat'; 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 { useDialogLifecycle } from '@/components/ConfirmDialog'; 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'] }); }; - const decodeTotpQrImage = async (source: ImageBitmapSource): Promise => { + 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 => { const detector = createTotpQrDetector(); - if (!detector) { - setTotpQrStatus(t('txt_totp_qr_unsupported')); - return false; + if (detector) { + try { + 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 = String(results[0]?.rawValue || '').trim(); - if (!value) return false; - return applyTotpQrValue(value); + const value = decodeTotpQrCanvas(source); + return value ? applyTotpQrValue(value) : false; }; const handleTotpQrFile = async (file: File | null) => { @@ -207,13 +225,6 @@ export default function VaultEditor(props: VaultEditorProps) { } let stopped = false; const detector = createTotpQrDetector(); - if (!detector) { - setTotpQrStatus(t('txt_totp_qr_unsupported')); - return () => { - stopped = true; - stopTotpQrScanner(); - }; - } if (!navigator.mediaDevices?.getUserMedia) { setTotpQrStatus(t('txt_totp_qr_camera_unavailable')); return () => { @@ -230,8 +241,16 @@ export default function VaultEditor(props: VaultEditorProps) { return; } try { - const results = await detector.detect(video); - const value = String(results[0]?.rawValue || '').trim(); + let value = ''; + 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; } catch { // Keep the camera active; transient frame decode failures are common.