Files
Apothecary/web/src/components/CameraScanner.tsx
T
josh d7da7afe5e
Build and push image / build (push) Successful in 1m20s
Fix iOS camera: direct getUserMedia in gesture handler, detect insecure context
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>
2026-06-06 11:34:09 -04:00

308 lines
9.3 KiB
TypeScript

import { useCallback, useEffect, useRef, useState } from "react";
import { Html5Qrcode } from "html5-qrcode";
import { Icon } from "./primitives/index.js";
export function CameraScanner({
onScan,
onClose,
}: {
onScan: (decodedText: string) => void;
onClose: () => void;
}) {
const scannerRef = useRef<Html5Qrcode | null>(null);
const containerRef = useRef<HTMLDivElement>(null);
const [error, setError] = useState<string | null>(null);
const [started, setStarted] = useState(false);
const [torchOn, setTorchOn] = useState(false);
const [torchAvailable, setTorchAvailable] = useState(false);
const lastScan = useRef("");
const lastScanTime = useRef(0);
const startScanner = useCallback(async () => {
const id = "apothecary-scanner";
let scanner = scannerRef.current;
if (!scanner) {
scanner = new Html5Qrcode(id, { verbose: false });
scannerRef.current = scanner;
}
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 {
const caps = scanner.getRunningTrackCameraCapabilities();
if (caps.torchFeature().isSupported()) {
setTorchAvailable(true);
}
} catch {
// torch check can fail on some browsers
}
}, [onScan]);
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(() => {
// Auto-start on desktop where getUserMedia works outside gestures
const isTouchDevice = "ontouchstart" in window || navigator.maxTouchPoints > 0;
if (!isTouchDevice && window.isSecureContext) {
startCamera();
}
return () => {
if (scannerRef.current?.isScanning) {
scannerRef.current.stop().catch(() => {});
}
};
}, [startCamera]);
const toggleTorch = () => {
try {
const caps = scannerRef.current?.getRunningTrackCameraCapabilities();
if (caps?.torchFeature().isSupported()) {
const next = !torchOn;
caps.torchFeature().apply(next);
setTorchOn(next);
}
} catch {
// ignore
}
};
const insecure = !window.isSecureContext;
return (
<div
style={{
position: "fixed",
inset: 0,
zIndex: 50,
background: "#000",
display: "flex",
flexDirection: "column",
}}
>
{/* Top bar */}
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
padding: "env(safe-area-inset-top, 12px) 16px 12px",
position: "relative",
zIndex: 2,
}}
>
<button
onClick={onClose}
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
width: 44,
height: 44,
borderRadius: "50%",
background: "rgba(0,0,0,0.5)",
border: "none",
cursor: "pointer",
color: "#fff",
}}
>
<Icon name="close" size={22} color="#fff" />
</button>
<div
className="serif"
style={{ color: "#fff", fontSize: 18, fontWeight: 500 }}
>
Scan
</div>
<div style={{ display: "flex", gap: 8 }}>
{torchAvailable && (
<button
onClick={toggleTorch}
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
width: 44,
height: 44,
borderRadius: "50%",
background: torchOn ? "var(--sage)" : "rgba(0,0,0,0.5)",
border: "none",
cursor: "pointer",
color: "#fff",
}}
>
<Icon name="flash" size={20} color="#fff" />
</button>
)}
</div>
</div>
{/* Camera viewport */}
<div
ref={containerRef}
style={{ flex: 1, position: "relative", overflow: "hidden" }}
>
<div
id="apothecary-scanner"
style={{ width: "100%", height: "100%" }}
/>
{!started && !error && (
<div
onClick={insecure ? undefined : startCamera}
style={{
position: "absolute",
inset: 0,
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
gap: 16,
padding: 32,
color: "#fff",
textAlign: "center",
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
style={{
width: 80,
height: 80,
borderRadius: "50%",
background: "var(--sage)",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<Icon name="camera" size={36} color="#fff" />
</div>
<div style={{ fontSize: 16, fontWeight: 500 }}>Tap to start camera</div>
<div style={{ fontSize: 13, opacity: 0.7, maxWidth: 300 }}>
Allow camera access when prompted
</div>
</>
)}
</div>
)}
{error && (
<div
style={{
position: "absolute",
inset: 0,
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
gap: 16,
padding: 32,
color: "#fff",
textAlign: "center",
}}
>
<Icon name="camera" size={48} color="rgba(255,255,255,0.5)" />
<div style={{ fontSize: 16, fontWeight: 500 }}>Camera unavailable</div>
<div style={{ fontSize: 13, opacity: 0.7, maxWidth: 300 }}>{error}</div>
{!insecure && (
<button
onClick={() => { setError(null); startCamera(); }}
style={{
marginTop: 8,
padding: "10px 24px",
borderRadius: 8,
background: "var(--sage)",
color: "#fff",
border: "none",
fontSize: 14,
fontWeight: 500,
cursor: "pointer",
}}
>
Try again
</button>
)}
</div>
)}
</div>
{/* Bottom hint */}
<div
style={{
padding: "16px 24px",
paddingBottom: "calc(16px + env(safe-area-inset-bottom, 0px))",
textAlign: "center",
color: "rgba(255,255,255,0.7)",
fontSize: 14,
}}
>
{started ? "Point at a barcode or QR code" : ""}
</div>
</div>
);
}