diff --git a/web/src/components/CameraScanner.tsx b/web/src/components/CameraScanner.tsx index b78a567..35bfaa0 100644 --- a/web/src/components/CameraScanner.tsx +++ b/web/src/components/CameraScanner.tsx @@ -18,7 +18,7 @@ export function CameraScanner({ const lastScan = useRef(""); const lastScanTime = useRef(0); - const startCamera = useCallback(async () => { + const startScanner = useCallback(async () => { const id = "apothecary-scanner"; let scanner = scannerRef.current; if (!scanner) { @@ -27,38 +27,79 @@ export function CameraScanner({ } if (scanner.isScanning) return; + await scanner.start( + { facingMode: "environment" }, + { fps: 10, qrbox: (w, h) => ({ width: Math.min(w - 40, 280), height: Math.min(h - 40, 280) }) }, + (text) => { + const now = Date.now(); + if (text === lastScan.current && now - lastScanTime.current < 3000) return; + lastScan.current = text; + lastScanTime.current = now; + onScan(text); + }, + () => {}, + ); + setStarted(true); try { - await scanner.start( - { facingMode: "environment" }, - { fps: 10, qrbox: (w, h) => ({ width: Math.min(w - 40, 280), height: Math.min(h - 40, 280) }) }, - (text) => { - const now = Date.now(); - if (text === lastScan.current && now - lastScanTime.current < 3000) return; - lastScan.current = text; - lastScanTime.current = now; - onScan(text); - }, - () => {}, - ); - setStarted(true); - try { - const caps = scanner.getRunningTrackCameraCapabilities(); - if (caps.torchFeature().isSupported()) { - setTorchAvailable(true); - } - } catch { - // torch check can fail on some browsers + const caps = scanner.getRunningTrackCameraCapabilities(); + if (caps.torchFeature().isSupported()) { + setTorchAvailable(true); } - } catch (err: unknown) { - setError(err instanceof Error ? err.message : "Camera access denied"); + } catch { + // torch check can fail on some browsers } }, [onScan]); - // On non-iOS browsers, getUserMedia works fine outside a gesture — auto-start + const startCamera = useCallback(async () => { + if (!window.isSecureContext) { + setError( + "Camera requires a secure connection (HTTPS). " + + "Access this site over https:// or on localhost to use the scanner.", + ); + return; + } + + if (!navigator.mediaDevices?.getUserMedia) { + setError("Camera API not available in this browser."); + return; + } + + // Request camera permission directly in the user gesture handler. + // html5-qrcode does async DOM work before calling getUserMedia internally, + // which breaks iOS Safari's transient user activation window. + // By calling getUserMedia here first, we trigger the permission prompt + // while the gesture is still active, then let html5-qrcode reuse the grant. + let stream: MediaStream; + try { + stream = await navigator.mediaDevices.getUserMedia({ + video: { facingMode: "environment" }, + }); + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + if (msg.includes("denied") || msg.includes("NotAllowedError")) { + setError( + "Camera permission was denied. Open your browser settings to allow camera access for this site, then try again.", + ); + } else { + setError(msg); + } + return; + } + + // Permission granted — stop this stream so html5-qrcode can open its own + stream.getTracks().forEach((t) => t.stop()); + + try { + await startScanner(); + } catch (err: unknown) { + setError(err instanceof Error ? err.message : "Scanner failed to start"); + } + }, [startScanner]); + useEffect(() => { - const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent) || - (navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1); - if (!isIOS) { + // Auto-start on desktop where getUserMedia works outside gestures + const isTouchDevice = "ontouchstart" in window || navigator.maxTouchPoints > 0; + if (!isTouchDevice && window.isSecureContext) { startCamera(); } return () => { @@ -81,6 +122,8 @@ export function CameraScanner({ } }; + const insecure = !window.isSecureContext; + return (
{!started && !error && (
-
- -
-
Tap to start camera
-
- Allow camera access when prompted -
+ {insecure ? ( + <> + +
HTTPS required
+
+ Camera access requires a secure connection. Access this site + over https:// to use the barcode scanner. +
+ + ) : ( + <> +
+ +
+
Tap to start camera
+
+ Allow camera access when prompted +
+ + )}
)} {error && ( @@ -212,22 +268,24 @@ export function CameraScanner({
Camera unavailable
{error}
- + {!insecure && ( + + )}
)} @@ -242,7 +300,7 @@ export function CameraScanner({ fontSize: 14, }} > - Point at a barcode or QR code + {started ? "Point at a barcode or QR code" : ""} );