From c9094d39ecfba5f850a5f093e64bcfa22f327588 Mon Sep 17 00:00:00 2001 From: josh Date: Sat, 6 Jun 2026 10:29:29 -0400 Subject: [PATCH] Fix iOS camera: require tap gesture before requesting getUserMedia iOS Safari blocks getUserMedia when called outside a user gesture context. The useEffect auto-start broke the gesture chain, causing permission denial without ever showing the prompt. Now on iOS the scanner shows a "Tap to start camera" button that triggers the permission request within the tap handler. Non-iOS browsers still auto-start. Also added a "Try again" button on the error state. Co-Authored-By: Claude Opus 4.6 --- web/src/components/CameraScanner.tsx | 108 +++++++++++++++++++++------ 1 file changed, 86 insertions(+), 22 deletions(-) diff --git a/web/src/components/CameraScanner.tsx b/web/src/components/CameraScanner.tsx index 48c9dc0..b78a567 100644 --- a/web/src/components/CameraScanner.tsx +++ b/web/src/components/CameraScanner.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { Html5Qrcode } from "html5-qrcode"; import { Icon } from "./primitives/index.js"; @@ -12,18 +12,23 @@ export function CameraScanner({ const scannerRef = useRef(null); const containerRef = useRef(null); const [error, setError] = useState(null); + const [started, setStarted] = useState(false); const [torchOn, setTorchOn] = useState(false); const [torchAvailable, setTorchAvailable] = useState(false); const lastScan = useRef(""); const lastScanTime = useRef(0); - useEffect(() => { + const startCamera = useCallback(async () => { const id = "apothecary-scanner"; - const scanner = new Html5Qrcode(id, { verbose: false }); - scannerRef.current = scanner; + let scanner = scannerRef.current; + if (!scanner) { + scanner = new Html5Qrcode(id, { verbose: false }); + scannerRef.current = scanner; + } + if (scanner.isScanning) return; - scanner - .start( + try { + await scanner.start( { facingMode: "environment" }, { fps: 10, qrbox: (w, h) => ({ width: Math.min(w - 40, 280), height: Math.min(h - 40, 280) }) }, (text) => { @@ -34,27 +39,34 @@ export function CameraScanner({ onScan(text); }, () => {}, - ) - .then(() => { - try { - const caps = scanner.getRunningTrackCameraCapabilities(); - if (caps.torchFeature().isSupported()) { - setTorchAvailable(true); - } - } catch { - // torch check can fail on some browsers + ); + setStarted(true); + try { + const caps = scanner.getRunningTrackCameraCapabilities(); + if (caps.torchFeature().isSupported()) { + setTorchAvailable(true); } - }) - .catch((err: Error) => { - setError(err.message || "Camera access denied"); - }); + } catch { + // torch check can fail on some browsers + } + } catch (err: unknown) { + setError(err instanceof Error ? err.message : "Camera access denied"); + } + }, [onScan]); + // On non-iOS browsers, getUserMedia works fine outside a gesture — auto-start + useEffect(() => { + const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent) || + (navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1); + if (!isIOS) { + startCamera(); + } return () => { - if (scanner.isScanning) { - scanner.stop().catch(() => {}); + if (scannerRef.current?.isScanning) { + scannerRef.current.stop().catch(() => {}); } }; - }, []); // eslint-disable-line react-hooks/exhaustive-deps + }, [startCamera]); const toggleTorch = () => { try { @@ -146,6 +158,42 @@ export function CameraScanner({ id="apothecary-scanner" style={{ width: "100%", height: "100%" }} /> + {!started && !error && ( +
+
+ +
+
Tap to start camera
+
+ Allow camera access when prompted +
+
+ )} {error && (
Camera unavailable
{error}
+
)}