Merge pull request #2 from nh9961/cursor/fix-ios-barcode-scanner-581a

Fix barcode scanner on iOS Safari
This commit is contained in:
Ned Halksworth
2026-05-27 23:04:14 +01:00
committed by GitHub
5 changed files with 255 additions and 83 deletions
+28 -39
View File
@@ -9,6 +9,7 @@
"version": "1.0.0",
"license": "MIT",
"dependencies": {
"@undecaf/barcode-detector-polyfill": "^0.9.23",
"@vitejs/plugin-react": "^4.3.4",
"@zxing/browser": "^0.2.0",
"appwrite": "^25.0.0",
@@ -1160,9 +1161,6 @@
"cpu": [
"arm"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1176,9 +1174,6 @@
"cpu": [
"arm"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1192,9 +1187,6 @@
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1208,9 +1200,6 @@
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1224,9 +1213,6 @@
"cpu": [
"loong64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1240,9 +1226,6 @@
"cpu": [
"loong64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1256,9 +1239,6 @@
"cpu": [
"ppc64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1272,9 +1252,6 @@
"cpu": [
"ppc64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1288,9 +1265,6 @@
"cpu": [
"riscv64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1304,9 +1278,6 @@
"cpu": [
"riscv64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1320,9 +1291,6 @@
"cpu": [
"s390x"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1336,9 +1304,6 @@
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1352,9 +1317,6 @@
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1880,6 +1842,24 @@
"url": "https://opencollective.com/eslint"
}
},
"node_modules/@undecaf/barcode-detector-polyfill": {
"version": "0.9.23",
"resolved": "https://registry.npmjs.org/@undecaf/barcode-detector-polyfill/-/barcode-detector-polyfill-0.9.23.tgz",
"integrity": "sha512-qVr7jSUbE5a30X9dByDym2NzsqyH+MFwyFiu4QSHDQMLCImTJj/et7pEcOtGqlL4UB5J6J3d0hK4/5d4MMowYA==",
"license": "MIT",
"dependencies": {
"@undecaf/zbar-wasm": "^0.9.16"
}
},
"node_modules/@undecaf/zbar-wasm": {
"version": "0.9.16",
"resolved": "https://registry.npmjs.org/@undecaf/zbar-wasm/-/zbar-wasm-0.9.16.tgz",
"integrity": "sha512-T5PcT6g+tLScGjR4WmnRErNvfKqEc3kRg2ux14wHmIDNbvNeXa0BkFK19PRK/jb6zGy5NyWtn4ko6KeNuZc/fQ==",
"license": "LGPL-2.1+",
"dependencies": {
"jschardet": "^3.0.0"
}
},
"node_modules/@vitejs/plugin-react": {
"version": "4.7.0",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
@@ -3622,6 +3602,15 @@
"js-yaml": "bin/js-yaml.js"
}
},
"node_modules/jschardet": {
"version": "3.1.4",
"resolved": "https://registry.npmjs.org/jschardet/-/jschardet-3.1.4.tgz",
"integrity": "sha512-/kmVISmrwVwtyYU40iQUOp3SUPk2dhNCMsZBQX0R1/jZ8maaXJ/oZIzUOiyOqcgtLnETFKYChbJ5iDC/eWmFHg==",
"license": "LGPL-2.1+",
"engines": {
"node": ">=0.1.90"
}
},
"node_modules/jsesc": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
+1
View File
@@ -12,6 +12,7 @@
"setup:appwrite": "node scripts/setup-appwrite.mjs"
},
"dependencies": {
"@undecaf/barcode-detector-polyfill": "^0.9.23",
"@vitejs/plugin-react": "^4.3.4",
"@zxing/browser": "^0.2.0",
"appwrite": "^25.0.0",
+11 -1
View File
@@ -191,8 +191,11 @@ export function BarcodeScannerModal({
window.setTimeout(() => closeButtonRef.current?.focus(), 80);
let active = true;
let frameId = 0;
const startScanner = () => {
const video = videoRef.current;
if (!video) return undefined;
if (!video || !active) return;
void startBarcodeScanner(video, handleScannerResult, handleScannerError)
.then((controller) => {
@@ -209,6 +212,11 @@ export function BarcodeScannerModal({
setScannerError(error);
setPhase("error");
});
};
frameId = window.requestAnimationFrame(() => {
window.requestAnimationFrame(startScanner);
});
void listBarcodeCatalog()
.then((catalog) => {
@@ -224,6 +232,7 @@ export function BarcodeScannerModal({
return () => {
active = false;
window.cancelAnimationFrame(frameId);
stopScanner();
};
}, [applyManualDefaults, handleScannerError, handleScannerResult, open, stopScanner, userId]);
@@ -335,6 +344,7 @@ export function BarcodeScannerModal({
<video
ref={videoRef}
className="aspect-[3/4] w-full bg-black object-cover sm:aspect-video"
autoPlay
muted
playsInline
aria-label="Live camera preview"
+47
View File
@@ -0,0 +1,47 @@
type BarcodeDetectorConstructor = {
new (options?: { formats?: string[] }): {
detect: (source: ImageBitmapSource) => Promise<Array<{ rawValue?: string; format?: string }>>;
};
getSupportedFormats?: () => Promise<string[]>;
};
type WindowWithBarcodeDetector = Window & {
BarcodeDetector?: BarcodeDetectorConstructor;
};
let detectorReady: Promise<void> | null = null;
export function isAppleMobileDevice() {
if (typeof navigator === "undefined") return false;
const ua = navigator.userAgent;
return /iPad|iPhone|iPod/i.test(ua) || (navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1);
}
async function loadBarcodeDetectorPolyfill() {
const { BarcodeDetectorPolyfill } = await import("@undecaf/barcode-detector-polyfill");
return BarcodeDetectorPolyfill;
}
export function ensureBarcodeDetector() {
if (detectorReady) return detectorReady;
detectorReady = (async () => {
const globalWindow = window as WindowWithBarcodeDetector;
const shouldForcePolyfill = isAppleMobileDevice();
if (shouldForcePolyfill) {
globalWindow.BarcodeDetector = await loadBarcodeDetectorPolyfill();
return;
}
try {
const getSupportedFormats = globalWindow.BarcodeDetector?.getSupportedFormats;
if (!getSupportedFormats) return;
await getSupportedFormats.call(globalWindow.BarcodeDetector);
} catch {
globalWindow.BarcodeDetector = await loadBarcodeDetectorPolyfill();
}
})();
return detectorReady;
}
+142 -17
View File
@@ -4,6 +4,7 @@ import {
BrowserMultiFormatReader,
type IScannerControls,
} from "@zxing/browser";
import { ensureBarcodeDetector, isAppleMobileDevice } from "./barcodeDetectorSupport";
import { normalizeBarcode } from "./barcodeLookup";
export type BarcodeScannerErrorCode =
@@ -54,7 +55,7 @@ const ZXING_FORMATS = [
BarcodeFormat.UPC_A,
BarcodeFormat.UPC_E,
];
const SCAN_CONSTRAINTS: MediaStreamConstraints = {
const PREFERRED_SCAN_CONSTRAINTS: MediaStreamConstraints = {
video: {
facingMode: { ideal: "environment" },
width: { ideal: 1280 },
@@ -62,6 +63,8 @@ const SCAN_CONSTRAINTS: MediaStreamConstraints = {
},
audio: false,
};
const IOS_NATIVE_SCAN_INTERVAL_MS = 150;
const VIDEO_READY_TIMEOUT_MS = 10_000;
export async function startBarcodeScanner(
videoElement: HTMLVideoElement,
@@ -72,6 +75,8 @@ export async function startBarcodeScanner(
throw toScannerError(new Error("Camera access is not supported in this browser."));
}
await ensureBarcodeDetector();
if (await supportsNativeBarcodeDetector()) {
try {
return await startNativeBarcodeScanner(videoElement, onResult);
@@ -121,15 +126,15 @@ function startNativeBarcodeScanner(
return new Promise((resolve, reject) => {
let stopped = false;
let animationFrame = 0;
let scanTimeout = 0;
let scanning = false;
let stream: MediaStream | null = null;
async function start() {
try {
stream = await navigator.mediaDevices.getUserMedia(SCAN_CONSTRAINTS);
videoElement.srcObject = stream;
videoElement.setAttribute("playsinline", "true");
videoElement.muted = true;
await videoElement.play();
stream = await getCameraStream();
prepareVideoElement(videoElement, stream);
await waitForVideoReady(videoElement);
const Detector = (window as WindowWithBarcodeDetector).BarcodeDetector;
if (!Detector) {
@@ -140,13 +145,15 @@ function startNativeBarcodeScanner(
const stop = () => {
stopped = true;
window.cancelAnimationFrame(animationFrame);
window.clearTimeout(scanTimeout);
stopVideoStream(videoElement);
};
const scan = async () => {
if (stopped) return;
if (stopped || scanning) return;
scanning = true;
try {
if (videoElement.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA) {
if (isVideoFrameReady(videoElement)) {
const barcodes = await detector.detect(videoElement);
const barcode = barcodes.find((item) => normalizeBarcode(item.rawValue ?? ""));
if (barcode?.rawValue) {
@@ -156,12 +163,32 @@ function startNativeBarcodeScanner(
});
}
}
} catch {
// Keep scanning; transient frame errors are common on mobile Safari.
} finally {
if (!stopped) animationFrame = window.requestAnimationFrame(() => void scan());
scanning = false;
}
};
animationFrame = window.requestAnimationFrame(() => void scan());
const scheduleNextScan = () => {
if (stopped) return;
if (isAppleMobileDevice()) {
scanTimeout = window.setTimeout(() => {
void scan().finally(() => {
if (!stopped) scheduleNextScan();
});
}, IOS_NATIVE_SCAN_INTERVAL_MS);
return;
}
animationFrame = window.requestAnimationFrame(() => {
void scan().finally(() => {
if (!stopped) scheduleNextScan();
});
});
};
scheduleNextScan();
resolve({ mode: "native", stop });
} catch (error) {
if (stream) stream.getTracks().forEach((track) => track.stop());
@@ -178,14 +205,17 @@ async function startZxingBarcodeScanner(
onResult: (result: BarcodeScanResult) => void,
onError: (error: BarcodeScannerError) => void,
): Promise<BarcodeScannerController> {
const reader = new BrowserMultiFormatReader();
const reader = new BrowserMultiFormatReader(undefined, {
delayBetweenScanAttempts: isAppleMobileDevice() ? 150 : 500,
});
reader.possibleFormats = ZXING_FORMATS;
try {
const controls = await reader.decodeFromConstraints(
SCAN_CONSTRAINTS,
videoElement,
(result, error) => {
const stream = await getCameraStream();
prepareVideoElement(videoElement, stream);
await waitForVideoReady(videoElement);
const controls = await reader.decodeFromStream(stream, videoElement, (result, error) => {
if (result) {
onResult({
value: normalizeBarcode(result.getText()),
@@ -196,8 +226,7 @@ async function startZxingBarcodeScanner(
if (error && !/not.?found/i.test(error.name) && !/not.?found/i.test(error.message)) {
onError(toScannerError(error));
}
},
);
});
return {
mode: "zxing",
@@ -229,6 +258,102 @@ async function supportsNativeBarcodeDetector() {
}
}
async function getCameraStream() {
const attempts: MediaStreamConstraints[] = [
PREFERRED_SCAN_CONSTRAINTS,
{ video: { facingMode: { ideal: "environment" } }, audio: false },
{ video: { facingMode: "environment" }, audio: false },
{ video: true, audio: false },
];
let lastError: unknown;
for (const constraints of attempts) {
try {
return await navigator.mediaDevices.getUserMedia(constraints);
} catch (error) {
lastError = error;
if (isCameraAccessError(error) && !(error instanceof DOMException && error.name === "OverconstrainedError")) {
throw error;
}
}
}
throw lastError ?? new Error("Could not access the camera.");
}
function prepareVideoElement(videoElement: HTMLVideoElement, stream: MediaStream) {
videoElement.srcObject = stream;
videoElement.setAttribute("playsinline", "true");
videoElement.setAttribute("webkit-playsinline", "true");
videoElement.setAttribute("autoplay", "true");
videoElement.muted = true;
}
function isVideoFrameReady(videoElement: HTMLVideoElement) {
return videoElement.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA && videoElement.videoWidth > 0;
}
async function waitForVideoReady(videoElement: HTMLVideoElement) {
if (isVideoFrameReady(videoElement)) {
await playVideoElement(videoElement);
return;
}
await new Promise<void>((resolve, reject) => {
let settled = false;
const settle = (action: () => void) => {
if (settled) return;
settled = true;
cleanup();
action();
};
const tryReady = () => {
if (!isVideoFrameReady(videoElement)) return false;
settle(() => {
void playVideoElement(videoElement).then(resolve).catch(reject);
});
return true;
};
const onReady = () => {
tryReady();
};
const onError = () => {
settle(() => reject(new Error("Camera preview failed to start.")));
};
const cleanup = () => {
window.clearTimeout(timeoutId);
videoElement.removeEventListener("loadedmetadata", onReady);
videoElement.removeEventListener("loadeddata", onReady);
videoElement.removeEventListener("error", onError);
};
const timeoutId = window.setTimeout(() => {
settle(() => reject(new Error("Camera preview timed out.")));
}, VIDEO_READY_TIMEOUT_MS);
videoElement.addEventListener("loadedmetadata", onReady);
videoElement.addEventListener("loadeddata", onReady);
videoElement.addEventListener("error", onError, { once: true });
tryReady();
});
}
async function playVideoElement(videoElement: HTMLVideoElement) {
try {
await videoElement.play();
} catch (error) {
if (videoElement.paused) {
throw error;
}
}
}
function isCameraAccessError(error: unknown) {
if (!(error instanceof DOMException)) return false;
return ["NotAllowedError", "NotFoundError", "NotReadableError", "OverconstrainedError"].includes(error.name);