From d7da7afe5e96104f81e817b69b76633e40518eba Mon Sep 17 00:00:00 2001 From: josh Date: Sat, 6 Jun 2026 11:34:09 -0400 Subject: [PATCH] Fix iOS camera: direct getUserMedia in gesture handler, detect insecure context MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two root causes for "camera access denied" without a permission prompt: 1. iOS Safari blocks getUserMedia on non-localhost HTTP origins entirely — no prompt, silent denial. Now detects window.isSecureContext and shows "HTTPS required" instead of the cryptic error. 2. html5-qrcode does async DOM work (creating video elements, styling) between the user tap and the actual getUserMedia call, which breaks iOS Safari's transient user activation window. Now calls navigator.mediaDevices.getUserMedia() directly in the tap handler to trigger the permission prompt while the gesture is still active, then stops that stream and lets html5-qrcode reuse the granted permission. Also improved error messages for denied permissions with guidance on how to fix it in browser settings. Co-Authored-By: Claude Opus 4.6 --- web/src/components/CameraScanner.tsx | 184 ++++++++++++++++++--------- 1 file changed, 121 insertions(+), 63 deletions(-) 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" : ""} );