Fix iOS camera: direct getUserMedia in gesture handler, detect insecure context
Build and push image / build (push) Successful in 1m20s

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 <noreply@anthropic.com>
This commit is contained in:
2026-06-06 11:34:09 -04:00
parent ddaeea0223
commit d7da7afe5e
+70 -12
View File
@@ -18,7 +18,7 @@ export function CameraScanner({
const lastScan = useRef(""); const lastScan = useRef("");
const lastScanTime = useRef(0); const lastScanTime = useRef(0);
const startCamera = useCallback(async () => { const startScanner = useCallback(async () => {
const id = "apothecary-scanner"; const id = "apothecary-scanner";
let scanner = scannerRef.current; let scanner = scannerRef.current;
if (!scanner) { if (!scanner) {
@@ -27,7 +27,6 @@ export function CameraScanner({
} }
if (scanner.isScanning) return; if (scanner.isScanning) return;
try {
await scanner.start( await scanner.start(
{ facingMode: "environment" }, { facingMode: "environment" },
{ fps: 10, qrbox: (w, h) => ({ width: Math.min(w - 40, 280), height: Math.min(h - 40, 280) }) }, { fps: 10, qrbox: (w, h) => ({ width: Math.min(w - 40, 280), height: Math.min(h - 40, 280) }) },
@@ -49,16 +48,58 @@ export function CameraScanner({
} catch { } catch {
// torch check can fail on some browsers // torch check can fail on some browsers
} }
} catch (err: unknown) {
setError(err instanceof Error ? err.message : "Camera access denied");
}
}, [onScan]); }, [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(() => { useEffect(() => {
const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent) || // Auto-start on desktop where getUserMedia works outside gestures
(navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1); const isTouchDevice = "ontouchstart" in window || navigator.maxTouchPoints > 0;
if (!isIOS) { if (!isTouchDevice && window.isSecureContext) {
startCamera(); startCamera();
} }
return () => { return () => {
@@ -81,6 +122,8 @@ export function CameraScanner({
} }
}; };
const insecure = !window.isSecureContext;
return ( return (
<div <div
style={{ style={{
@@ -160,7 +203,7 @@ export function CameraScanner({
/> />
{!started && !error && ( {!started && !error && (
<div <div
onClick={startCamera} onClick={insecure ? undefined : startCamera}
style={{ style={{
position: "absolute", position: "absolute",
inset: 0, inset: 0,
@@ -172,9 +215,20 @@ export function CameraScanner({
padding: 32, padding: 32,
color: "#fff", color: "#fff",
textAlign: "center", textAlign: "center",
cursor: "pointer", cursor: insecure ? "default" : "pointer",
}} }}
> >
{insecure ? (
<>
<Icon name="camera" size={48} color="rgba(255,255,255,0.5)" />
<div style={{ fontSize: 16, fontWeight: 500 }}>HTTPS required</div>
<div style={{ fontSize: 13, opacity: 0.7, maxWidth: 300 }}>
Camera access requires a secure connection. Access this site
over <strong>https://</strong> to use the barcode scanner.
</div>
</>
) : (
<>
<div <div
style={{ style={{
width: 80, width: 80,
@@ -192,6 +246,8 @@ export function CameraScanner({
<div style={{ fontSize: 13, opacity: 0.7, maxWidth: 300 }}> <div style={{ fontSize: 13, opacity: 0.7, maxWidth: 300 }}>
Allow camera access when prompted Allow camera access when prompted
</div> </div>
</>
)}
</div> </div>
)} )}
{error && ( {error && (
@@ -212,6 +268,7 @@ export function CameraScanner({
<Icon name="camera" size={48} color="rgba(255,255,255,0.5)" /> <Icon name="camera" size={48} color="rgba(255,255,255,0.5)" />
<div style={{ fontSize: 16, fontWeight: 500 }}>Camera unavailable</div> <div style={{ fontSize: 16, fontWeight: 500 }}>Camera unavailable</div>
<div style={{ fontSize: 13, opacity: 0.7, maxWidth: 300 }}>{error}</div> <div style={{ fontSize: 13, opacity: 0.7, maxWidth: 300 }}>{error}</div>
{!insecure && (
<button <button
onClick={() => { setError(null); startCamera(); }} onClick={() => { setError(null); startCamera(); }}
style={{ style={{
@@ -228,6 +285,7 @@ export function CameraScanner({
> >
Try again Try again
</button> </button>
)}
</div> </div>
)} )}
</div> </div>
@@ -242,7 +300,7 @@ export function CameraScanner({
fontSize: 14, fontSize: 14,
}} }}
> >
Point at a barcode or QR code {started ? "Point at a barcode or QR code" : ""}
</div> </div>
</div> </div>
); );