React Scanner
A small React component that captures an Identifier in the browser and hands it to your backend, which calls the DUST API. It supports three modes:
- DUST — sends a DUST scan image for the Identifier endpoints to resolve.
- Other — uses the device camera to read QR, barcode, and Data Matrix Identifiers.
- Manual — submits QR, barcode, Data Matrix, or NFC Identifiers from a text field.
Before you start
- Role
- A Service Account with membership of the Teams you scan into
- Device
- A DUST optical accessory for DUST capture; the device camera alone for QR, barcode and Data Matrix
Where the credential lives
Section titled “Where the credential lives”This integration has two files in two places, and the split is the whole security design:
| File | Runs | Holds the DUST credential? |
|---|---|---|
DustScanner.tsx | Browser | No — and it has no prop that would accept one. |
Your /api/dust-scan handler | Your server | Yes. It mints the bearer token, chooses the context, and calls /api/v1/tags/*. |
Install
Section titled “Install”bun add barcode-detectorThe component expects React to already be present in your app.
bun add react react-dom barcode-detectorSave DustScanner.tsx and scanner.css into your project.
The Other mode works in desktop and phone browsers alike. It opens the rear camera by default and mirrors the picture only for a camera that faces the user, so the view moves the way the device does. There is no button to press: it reads continuously, and when several codes are in view it takes the one nearest the center. Where the browser has a built-in barcode detector that reads QR, Data Matrix, Code 128, and EAN-13 (Chrome on Android and macOS), the component uses it; everywhere else, Safari included, it falls back to barcode-detector, a WebAssembly build of ZXing. Browsers only open the camera on an HTTPS page.
1. The server handler
Section titled “1. The server handler”Write this first — the component is useless without it. It receives the multipart body the component sends, applies your authorization, adds the DUST credential and context headers, and forwards to the matching Identifier endpoint.
Two pieces are yours to supply: Session, whatever your app already knows about the signed-in user, and getDustToken(), the cached API-key exchange from Authentication.
const APID_URL = "https://apid.dustid.io";
const ENDPOINTS = { identify: "/api/v1/tags/identify", bind: "/api/v1/tags/bind", verify: "/api/v1/tags/verify",} as const;
type Operation = keyof typeof ENDPOINTS;
/** Your app's session, however you already model it. */type Session = { mayScan: boolean; organizationId: string; teamId?: string; allowedTeamIds: string[];};
const fail = (code: string, message: string, status: number) => Response.json({ code, message, status }, { status });
export async function handleDustScan(request: Request, session: Session) { const form = await request.formData(); const operation = String(form.get("operation") ?? ""); if (!Object.hasOwn(ENDPOINTS, operation)) return fail("INVALID_REQUEST", "Unknown operation", 400);
// YOUR rules, not DUST's: may this user scan, and into which Teams? if (!session.mayScan) return fail("FORBIDDEN", "Not permitted", 403);
// Never trust the browser's scope. Replace whatever it sent. form.delete("operation"); if (operation === "identify") { form.set("searchTeamIds", JSON.stringify(session.allowedTeamIds)); }
const response = await fetch(`${APID_URL}${ENDPOINTS[operation as Operation]}`, { method: "POST", headers: { Authorization: `Bearer ${await getDustToken()}`, // server-held credential "Dust-Ctx-Org-Id": session.organizationId, ...(session.teamId ? { "Dust-Ctx-Team-Id": session.teamId } : {}), }, body: form, });
// Pass the DUST body and status straight through: the component branches on // `code`, and a 404 IDENTIFIER_NOT_FOUND is an ANSWER, not a proxy failure. return new Response(await response.text(), { status: response.status, headers: { "Content-Type": "application/json" }, });}Forward the DUST status and body verbatim. Collapsing every failure into a 500 throws away exactly the information the browser needs to tell “nothing matched” from “try again”.
2. Render the scanner
Section titled “2. Render the scanner”import { DustScanner } from "./DustScanner";import "./scanner.css";
export function IdentifyPage() { return ( <DustScanner endpoint="/api/dust-scan" operation="identify" onResult={(result) => console.log("scan result", result)} onError={(error) => console.warn(error.code, error.message, error.scanId)} /> );}There is no token to fetch and no loading gate around one. endpoint is a path on your own origin; the component posts to it with credentials: "same-origin", so your existing session cookie authenticates the call.
searchTeamIds is available as a prop for a page that legitimately chooses its own scope, but the server handler above overwrites it — a browser-supplied search scope is a request, never an authorization.
Bind to a Thread
Section titled “Bind to a Thread”<DustScanner endpoint="/api/dust-scan" operation="bind" threadId={threadId} tagDescription="Receiving scan" onResult={setResult}/>Verify an Identifier
Section titled “Verify an Identifier”Verify needs the candidate identifiers already bound to that Thread, as objects with tagId and tagType — not id strings. Read them from the Thread on your server (GET /api/v1/threads/{thread_id} returns them in thread.tags) and pass them down:
<DustScanner endpoint="/api/dust-scan" operation="verify" threadId={threadId} verifyTags={dustIdentifiers} // [{ tagId: "8f2b…", tagType: "DUST" }] onResult={setResult}/>Handling outcomes
Section titled “Handling outcomes”onError receives a ScanSubmitError carrying the DUST code, status, detail, and scanId. Branch on code — several of them are answers, not faults:
code | Meaning | What the operator should see |
|---|---|---|
IDENTIFIER_NOT_FOUND | Nothing matched in the searched Teams | ”No match” — not an error banner |
SCAN_SEARCH_INCOMPLETE | The search could not be completed | ”Try again” — the item may well be enrolled |
SCAN_LOW_KEYPOINTS / SCAN_NO_KEYPOINTS | The capture was unusable | ”Scan again” |
IDENTIFIER_ALREADY_BOUND | Bind refused: already on another Thread | Show the conflict |
IDENTIFIER_VERIFY_FAILED | Single-candidate verify did not match | ”Did not match” |
error.scanId is the scan receipt of the capture that failed — log it, and quote it to support when a result is disputed. The full table is in Errors and scan outcomes.
Component source
Section titled “Component source”DustScanner.tsx — runs in the browser; click to expand
/** * RUNS IN THE BROWSER. * * No DUST credential appears in this file, and none should: a DUST bearer token * carries the Service Account's full access and cannot be narrowed for a browser * session. The component captures an Identifier and posts it to an endpoint on * YOUR OWN origin; your server adds the DUST credential and context headers and * calls /api/v1/tags/*. See the server handler in the React Scanner guide: * https://docs.dustid.io/integrate/react-scanner/ * * The "Other" mode reads QR codes, barcodes and Data Matrix live from the device * camera with the Barcode Detection API: the browser's own BarcodeDetector where * it reads the formats below, otherwise the `barcode-detector` ponyfill * (zxing-cpp compiled to WebAssembly), which works in every browser, Safari * included. */import { useCallback, useEffect, useRef, useState } from "react";
type ScannerMode = "dust" | "other" | "manual";type ScanOperation = "identify" | "bind" | "verify";type NonDustTagType = "QR" | "BAR_CODE" | "DATA_MATRIX" | "NFC";type TagType = "DUST" | NonDustTagType;
type VerifyTag = { tagId: string; tagType: TagType;};
type DustScannerProps = { /** * An endpoint on your own origin that forwards the capture to the DUST API. * It receives the same multipart fields the DUST API takes, plus `operation`. */ endpoint: string; operation: ScanOperation; threadId?: string; /** Verify candidates: the identifiers already bound to `threadId`. */ verifyTags?: VerifyTag[]; /** Identify scope: the Team ids to search. Your server may override this. */ searchTeamIds?: string[]; tagDescription?: string; onResult?: (result: unknown) => void; onError?: (error: ScanSubmitError) => void;};
/** * A failed submission. `code` is the DUST error code when your endpoint passes * the DUST error body through — branch on it, never on `message`. * * Note that IDENTIFIER_NOT_FOUND is an ANSWER ("nothing matched"), not a fault, * and SCAN_SEARCH_INCOMPLETE means the search could not be completed and should * be retried. See https://docs.dustid.io/api/errors/ */export class ScanSubmitError extends Error { readonly status: number; readonly code?: string; readonly detail?: Record<string, unknown>; /** The Scan Receipt id, when the failure stored a scan. Worth logging. */ readonly scanId?: string;
constructor( message: string, init: { status: number; code?: string; detail?: Record<string, unknown> }, ) { super(message); this.name = "ScanSubmitError"; this.status = init.status; this.code = init.code; this.detail = init.detail; const scan = init.detail?.scan as { scanId?: string } | undefined; this.scanId = scan?.scanId; }}
const OUTCOME_MESSAGES: Record<string, string> = { IDENTIFIER_NOT_FOUND: "No match in the searched Teams.", IDENTIFIER_NOT_BOUND: "That Identifier is not bound to a Thread yet.", SCAN_SEARCH_INCOMPLETE: "The search could not be completed. Try again.", SCAN_BACKEND_UNAVAILABLE: "Scanning is temporarily unavailable. Try again.", SCAN_LOW_KEYPOINTS: "The capture was too indistinct. Scan again.", SCAN_NO_KEYPOINTS: "No usable detail in the capture. Scan again.", SCAN_IDENTICAL_SCAN: "That is the same image as the last capture. Scan again.", SCAN_AMBIGUOUS_MATCH: "More than one enrolled DUST matched. Contact support.", IDENTIFIER_ALREADY_BOUND: "That Identifier is already bound to another Thread.", IDENTIFIER_VERIFY_FAILED: "The capture did not match this Thread.",};
type Detection = { tagType: NonDustTagType; value: string; format?: string;};
/** * Symbologies the camera asks the decoder for, as Barcode Detection API format * names. Every entry needs a mapping in FORMAT_TO_TAG, or its reads are ignored. */const CAMERA_BARCODE_FORMATS = [ "qr_code", "micro_qr_code", "data_matrix", "aztec", "maxi_code", "pdf417", "code_128", "code_39", "code_93", "codabar", "itf", "ean_13", "ean_8", "upc_a", "upc_e", "databar", "databar_expanded",] as const;
/** * A browser's own BarcodeDetector is only used when it reads at least these; * with fewer, the ponyfill (which reads every format above) is the better engine. */const NATIVE_REQUIRED_FORMATS = ["qr_code", "data_matrix", "code_128", "ean_13"];
/** * Keyed by the format name the decoder reports (the native detector and the * ponyfill use the same names, and the ponyfill reports some variants). `format` * is sent as bind metadata, so several variants collapse onto one value. */const QR_CODE = { tagType: "QR", format: "qrcode" } as const;const AZTEC = { tagType: "DATA_MATRIX", format: "azteccode" } as const;const CODE_39 = { tagType: "BAR_CODE", format: "code39" } as const;const ITF = { tagType: "BAR_CODE", format: "interleaved2of5" } as const;const PDF_417 = { tagType: "BAR_CODE", format: "pdf417" } as const;const DATABAR = { tagType: "BAR_CODE", format: "databaromni" } as const;
const FORMAT_TO_TAG: Record<string, { tagType: NonDustTagType; format: string }> = { qr_code: QR_CODE, qr_code_model_1: QR_CODE, qr_code_model_2: QR_CODE, micro_qr_code: QR_CODE, rm_qr_code: QR_CODE, data_matrix: { tagType: "DATA_MATRIX", format: "datamatrix" }, aztec: AZTEC, aztec_code: AZTEC, aztec_rune: AZTEC, maxi_code: { tagType: "DATA_MATRIX", format: "maxicode" }, ean_13: { tagType: "BAR_CODE", format: "ean13" }, ean_8: { tagType: "BAR_CODE", format: "ean8" }, upc_a: { tagType: "BAR_CODE", format: "upca" }, upc_e: { tagType: "BAR_CODE", format: "upce" }, code_39: CODE_39, code_39_standard: CODE_39, code_39_extended: CODE_39, code_93: { tagType: "BAR_CODE", format: "code93" }, code_128: { tagType: "BAR_CODE", format: "code128" }, itf: ITF, itf_14: ITF, codabar: { tagType: "BAR_CODE", format: "rationalizedCodabar" }, pdf417: PDF_417, compact_pdf417: PDF_417, micro_pdf417: PDF_417, databar: DATABAR, databar_omni: DATABAR, databar_expanded: { tagType: "BAR_CODE", format: "databarexpanded" },};
// Manual entry can't know the concrete symbology, so choose a canonical// format from the same vocabulary the camera path uses.const MANUAL_FORMATS: Record<NonDustTagType, string> = { QR: FORMAT_TO_TAG.qr_code.format, BAR_CODE: FORMAT_TO_TAG.code_128.format, DATA_MATRIX: FORMAT_TO_TAG.data_matrix.format, NFC: "nfc",};
function normalizeIdentifier(value: string) { const trimmed = value.trim(); if (!trimmed || trimmed.length > 2000) return null;
try { const parsed = JSON.parse(trimmed); if (parsed && typeof parsed === "object" && typeof parsed.id === "string") { return parsed.id.trim() || null; } } catch { // Plain text identifiers are valid. }
return trimmed;}
function detectNonDust(barcode: { rawValue: string; format: string }): Detection | null { const value = normalizeIdentifier(barcode.rawValue); if (!value || !Object.hasOwn(FORMAT_TO_TAG, barcode.format)) return null;
const mapped = FORMAT_TO_TAG[barcode.format]; return { tagType: mapped.tagType, value, format: mapped.format, };}
// --- Barcode decoder --------------------------------------------------------
type DecodedBarcode = { rawValue: string; /** Barcode Detection API format name: `qr_code`, `data_matrix`, `code_128`, ... */ format: string; boundingBox: { x: number; y: number; width: number; height: number };};
type BarcodeReader = { detect: (source: HTMLCanvasElement) => Promise<DecodedBarcode[]>;};
// The Barcode Detection API is not in TypeScript's DOM types yet.type NativeBarcodeDetectorClass = { new (options: { formats: string[] }): { detect: (source: CanvasImageSource) => Promise<DecodedBarcode[]>; }; getSupportedFormats: () => Promise<string[]>;};
/** * Where the ponyfill loads `zxing_reader.wasm` (about 1 MB) from. Left alone, * it fetches the file from the jsDelivr CDN at runtime. Serve it from your own * origin instead: copy `node_modules/zxing-wasm/dist/reader/zxing_reader.wasm` * into your static files at this path, or let your bundler emit it, e.g. with Vite: * * import ZXING_WASM_URL from "zxing-wasm/reader/zxing_reader.wasm?url"; * * Use the zxing-wasm version that barcode-detector itself depends on. */const ZXING_WASM_URL = "/zxing_reader.wasm";
let readerPromise: Promise<BarcodeReader> | null = null;
/** One decoder per page, created on first use. */function getBarcodeReader(): Promise<BarcodeReader> { readerPromise ??= createBarcodeReader().catch((error: unknown) => { // A failed WebAssembly fetch (flaky network) must be retryable, not cached. readerPromise = null; throw error; }); return readerPromise;}
async function createBarcodeReader(): Promise<BarcodeReader> { const NativeBarcodeDetector = (globalThis as { BarcodeDetector?: NativeBarcodeDetectorClass }) .BarcodeDetector;
// Chrome on Android and macOS: the platform's own, hardware-accelerated detector. if (NativeBarcodeDetector) { try { const supported = new Set(await NativeBarcodeDetector.getSupportedFormats()); if (NATIVE_REQUIRED_FORMATS.every((format) => supported.has(format))) { const detector = new NativeBarcodeDetector({ formats: CAMERA_BARCODE_FORMATS.filter((format) => supported.has(format)), }); return { detect: (source) => detector.detect(source) }; } } catch { // Some Chromium builds expose the class but have no platform backend. } }
// Everywhere else (Safari on every iOS browser, Firefox): the ponyfill. // Imported on demand, so browsers with a native detector never download it. const { BarcodeDetector, prepareZXingModule } = await import("barcode-detector/ponyfill"); // Awaiting the load surfaces a failed fetch here, not on every frame. await prepareZXingModule({ overrides: { locateFile: (path: string, prefix: string) => path.endsWith(".wasm") ? ZXING_WASM_URL : prefix + path, }, fireImmediately: true, }); const detector = new BarcodeDetector({ formats: [...CAMERA_BARCODE_FORMATS] }); return { detect: (source) => detector.detect(source) };}
// --- Camera helpers ---------------------------------------------------------
/** About 8 decodes a second: responsive, and leaves a phone's CPU for the preview. */const SCAN_INTERVAL_MS = 120;/** Decode at native resolution up to this long edge; small Data Matrix needs the pixels. */const MAX_DECODE_SIDE = 1600;/** How long a code that was just submitted is ignored, so one label isn't sent twice. */const REPEAT_HOLD_MS = 2000;
function stopStream(stream: MediaStream | null) { for (const track of stream?.getTracks() ?? []) track.stop();}
/** * Mirror only a camera that faces the user; that reads like a mirror. A rear * camera shown mirrored moves the picture against the phone, which makes aiming * impossible. Desktop webcams usually report no facingMode and face the user. */function shouldMirrorCamera(facingMode: string | undefined, label: string | undefined) { if (facingMode === "user") return true; if (facingMode) return false; return !/\b(back|rear|environment)\b/i.test(label ?? "");}
/** Continuous autofocus where the browser exposes it (Chrome on Android); a no-op elsewhere. */function applyContinuousFocus(track: MediaStreamTrack) { const capabilities = track.getCapabilities?.() as { focusMode?: string[] } | undefined; if (!capabilities?.focusMode?.includes("continuous")) return; track .applyConstraints({ advanced: [{ focusMode: "continuous" } as MediaTrackConstraintSet] }) .catch(() => undefined);}
/** * The part of the video frame the viewfinder actually shows. The <video> is * drawn with `object-fit: cover`, so the visible region is a centred crop; * decoding exactly that region means "in the box on screen" is what gets read. */function getObjectCoverSourceRect(video: HTMLVideoElement) { const { videoWidth, videoHeight, clientWidth, clientHeight } = video; if (clientWidth <= 0 || clientHeight <= 0) { return { x: 0, y: 0, width: videoWidth, height: videoHeight }; }
const viewAspect = clientWidth / clientHeight; if (videoWidth / videoHeight > viewAspect) { const width = videoHeight * viewAspect; return { x: (videoWidth - width) / 2, y: 0, width, height: videoHeight }; }
const height = videoWidth / viewAspect; return { x: 0, y: (videoHeight - height) / 2, width: videoWidth, height };}
/** With several codes in view, the one nearest the centre is the one being aimed at. */function pickCentralBarcode(barcodes: DecodedBarcode[], width: number, height: number) { let best: DecodedBarcode | null = null; let bestDistance = Number.POSITIVE_INFINITY; for (const barcode of barcodes) { const box = barcode.boundingBox; const distance = Math.hypot( box.x + box.width / 2 - width / 2, box.y + box.height / 2 - height / 2, ); if (distance < bestDistance) { best = barcode; bestDistance = distance; } } return best;}
function cameraErrorMessage(error: unknown, fallback: string) { if (error instanceof DOMException) { if (error.name === "NotAllowedError") { return "Camera access was blocked. Allow the camera for this site and try again."; } if (error.name === "NotFoundError" || error.name === "OverconstrainedError") { return "No camera was found."; } if (error.name === "NotReadableError") return "The camera is in use by another app."; } return fallback;}
function appendJson(form: FormData, key: string, value: unknown) { if (value === undefined) return; form.set(key, typeof value === "string" ? value : JSON.stringify(value));}
export function DustScanner({ endpoint, operation, threadId, verifyTags = [], searchTeamIds, tagDescription, onResult, onError,}: DustScannerProps) { const [mode, setMode] = useState<ScannerMode>("dust"); const [manualType, setManualType] = useState<NonDustTagType>("QR"); const [manualValue, setManualValue] = useState(""); const [busy, setBusy] = useState(false); const [message, setMessage] = useState<string | null>(null); const videoRef = useRef<HTMLVideoElement | null>(null); const [mirrored, setMirrored] = useState(false); const [cameraRestart, setCameraRestart] = useState(0); const lastDetectionRef = useRef<string | null>(null); const cameraScanInFlightRef = useRef(false);
// Posts to YOUR endpoint, with your own session cookie. The DUST credential // lives on the other side of this call and never enters the browser. const submitForm = useCallback( async (form: FormData) => { const response = await fetch(endpoint, { method: "POST", body: form, credentials: "same-origin", });
const text = await response.text(); let data: Record<string, unknown> | null = null; try { data = text ? (JSON.parse(text) as Record<string, unknown>) : null; } catch { // A non-JSON body (an HTML error page, a proxy timeout) is still a failure. }
if (!response.ok) { const code = typeof data?.code === "string" ? data.code : undefined; const message = (code && OUTCOME_MESSAGES[code]) ?? (typeof data?.message === "string" ? data.message : null) ?? `Scan request failed with ${response.status}`; throw new ScanSubmitError(message, { status: response.status, code, detail: data?.detail as Record<string, unknown> | undefined, }); }
return data; }, [endpoint], );
const runScan = useCallback( async (scan: { tagType: TagType; data: string | Blob; metadata?: Record<string, unknown> }) => { setBusy(true); setMessage(null);
try { const form = new FormData(); // Your endpoint switches on this to pick the DUST operation. form.set("operation", operation); form.set("tagType", scan.tagType); form.set("data", scan.data);
if (operation === "identify") { // JSON array of Team UUIDs. Omitted entirely when not supplied, so the // server's own default scope applies. appendJson(form, "searchTeamIds", searchTeamIds); const result = await submitForm(form); onResult?.(result); setMessage("Identify complete."); return; }
if (!threadId) { throw new ScanSubmitError("threadId is required for bind and verify operations.", { status: 0, }); }
form.set("threadId", threadId);
if (operation === "bind") { if (tagDescription) form.set("tagDescription", tagDescription);
if (scan.tagType === "DUST") { // enrollmentSessionId is OPTIONAL. A fresh id per capture is only // meaningful if each capture really is its own session; share one id // across a batch when the captures belong together. appendJson(form, "options", { enrollmentSessionId: crypto.randomUUID() }); } else if (scan.metadata) { appendJson(form, "options", { metadata: scan.metadata }); }
const result = await submitForm(form); onResult?.(result); setMessage("Bind complete."); return; }
if (verifyTags.length === 0) { throw new ScanSubmitError("verifyTags is required for verify operations.", { status: 0 }); }
// `tags` is an array of OBJECTS ({ tagId, tagType }), not of id strings, // and it is required on every verify. appendJson(form, "tags", verifyTags); const result = await submitForm(form); // With two or more candidates the DUST API answers 200 with // `success: false` when nothing matched — read `success`, not the status. const failedBulk = verifyTags.length > 1 && result !== null && typeof result === "object" && (result as { success?: boolean }).success === false; onResult?.(result); setMessage(failedBulk ? "No identifier on this Thread matched." : "Verify complete."); } catch (error) { const err = error instanceof ScanSubmitError ? error : new ScanSubmitError( error instanceof Error ? error.message : "Unknown scanner error", { status: 0 }, ); setMessage(err.message); onError?.(err); } finally { setBusy(false); } }, [onError, onResult, operation, searchTeamIds, submitForm, tagDescription, threadId, verifyTags], );
const runScanRef = useRef(runScan); runScanRef.current = runScan; const onErrorRef = useRef(onError); onErrorRef.current = onError;
const handleDustFile = useCallback( async (file: File | null) => { if (!file) return; await runScan({ tagType: "DUST", data: file }); }, [runScan], );
const handleManualSubmit = useCallback(async () => { const value = normalizeIdentifier(manualValue); if (!value) { setMessage("Enter an identifier value."); return; }
await runScan({ tagType: manualType, data: value, metadata: { format: MANUAL_FORMATS[manualType] }, }); setManualValue(""); }, [manualType, manualValue, runScan]);
// Called for every frame a code is read in, so it de-duplicates: a code just // submitted (matched or not) is held off for REPEAT_HOLD_MS after it finishes, // and two submissions never overlap. const handleBarcode = useCallback(async (barcode: DecodedBarcode) => { const detection = detectNonDust(barcode); if (!detection) return;
const key = `${detection.tagType}:${detection.format}:${detection.value}`; if (cameraScanInFlightRef.current || lastDetectionRef.current === key) return; lastDetectionRef.current = key; cameraScanInFlightRef.current = true;
try { await runScanRef.current({ tagType: detection.tagType, data: detection.value, metadata: detection.format ? { format: detection.format } : undefined, }); } finally { cameraScanInFlightRef.current = false; }
window.setTimeout(() => { if (lastDetectionRef.current === key) lastDetectionRef.current = null; }, REPEAT_HOLD_MS); }, []);
// The live camera: rear camera by default, read continuously, no shutter button. useEffect(() => { if (mode !== "other") return; // Reopening the camera is keyed on this counter; see handleVisibility. void cameraRestart; const video = videoRef.current; if (!video) return;
let cancelled = false; let stream: MediaStream | null = null; let timer: number | undefined;
const fail = (error: unknown, fallback: string) => { if (cancelled) return; const message = cameraErrorMessage(error, fallback); setMessage(message); onErrorRef.current?.(new ScanSubmitError(message, { status: 0 })); };
// iOS ends the camera track while the page is in the background; reopen it on return. const handleVisibility = () => { if (document.visibilityState !== "visible") return; if (stream?.getVideoTracks().some((track) => track.readyState === "ended")) { setCameraRestart((count) => count + 1); } };
const start = async () => { if (typeof navigator.mediaDevices?.getUserMedia !== "function") { fail(null, "This browser cannot open the camera. Camera access needs HTTPS."); return; }
// Start loading the decoder while the camera permission prompt is up. const pendingReader = getBarcodeReader(); pendingReader.catch(() => undefined);
try { stream = await navigator.mediaDevices.getUserMedia({ audio: false, video: { // The rear camera on phones and tablets; a desktop's webcam otherwise. facingMode: { ideal: "environment" }, // Enough pixels for a small Data Matrix at arm's length. Without a // request, Safari delivers 640x480. width: { ideal: 1920 }, height: { ideal: 1080 }, }, }); } catch (error) { fail(error, "Could not start the camera."); return; }
if (cancelled) { stopStream(stream); return; }
const track = stream.getVideoTracks()[0]; setMirrored(shouldMirrorCamera(track?.getSettings().facingMode, track?.label)); if (track) applyContinuousFocus(track); video.srcObject = stream; await video.play().catch(() => undefined);
let reader: BarcodeReader; try { reader = await pendingReader; } catch (error) { fail(error, "Could not load the barcode decoder."); return; }
const canvas = document.createElement("canvas"); const context = canvas.getContext("2d", { willReadFrequently: true }); if (!context) { fail(null, "Could not load the barcode decoder."); return; }
const tick = async () => { if (cancelled) return;
if ( document.visibilityState === "visible" && video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA && video.videoWidth > 0 ) { const visible = getObjectCoverSourceRect(video); const scale = Math.min(1, MAX_DECODE_SIDE / Math.max(visible.width, visible.height)); const width = Math.max(1, Math.round(visible.width * scale)); const height = Math.max(1, Math.round(visible.height * scale)); if (canvas.width !== width) canvas.width = width; if (canvas.height !== height) canvas.height = height; context.drawImage( video, visible.x, visible.y, visible.width, visible.height, 0, 0, width, height, );
try { const barcode = pickCentralBarcode(await reader.detect(canvas), width, height); if (barcode && !cancelled) void handleBarcode(barcode); } catch { // A frame the decoder rejects is skipped; the next one is tried. } }
if (!cancelled) timer = window.setTimeout(tick, SCAN_INTERVAL_MS); };
void tick(); };
document.addEventListener("visibilitychange", handleVisibility); void start();
return () => { cancelled = true; window.clearTimeout(timer); document.removeEventListener("visibilitychange", handleVisibility); stopStream(stream); video.srcObject = null; }; }, [mode, cameraRestart, handleBarcode]);
return ( <section className="dust-scanner"> <div className="dust-scanner__modes" role="group" aria-label="Scanner mode"> {(["dust", "other", "manual"] as const).map((nextMode) => ( <button key={nextMode} type="button" aria-pressed={mode === nextMode} onClick={() => setMode(nextMode)} > {nextMode === "dust" ? "DUST" : nextMode === "other" ? "Other" : "Manual"} </button> ))} </div>
{mode === "dust" ? ( <label className="dust-scanner__dropzone"> <span>DUST image scan</span> <input disabled={busy} type="file" accept="image/*" capture="environment" onChange={(event) => void handleDustFile(event.currentTarget.files?.[0] ?? null)} /> </label> ) : null}
{mode === "other" ? ( <div className="dust-scanner__camera"> <video ref={videoRef} muted playsInline autoPlay disablePictureInPicture // The decode loop reads exactly what `object-fit: cover` shows. style={{ objectFit: "cover", transform: mirrored ? "scaleX(-1)" : undefined }} /> <p>Point the camera at a QR code, barcode or Data Matrix.</p> </div> ) : null}
{mode === "manual" ? ( <div className="dust-scanner__manual"> <select value={manualType} disabled={busy} aria-label="Identifier type" onChange={(event) => setManualType(event.currentTarget.value as NonDustTagType)} > <option value="QR">QR</option> <option value="BAR_CODE">Barcode</option> <option value="DATA_MATRIX">Data Matrix</option> <option value="NFC">NFC</option> </select> <input data-manual-identifier-input aria-label="Identifier value" value={manualValue} disabled={busy} placeholder="Identifier value" onChange={(event) => setManualValue(event.currentTarget.value)} onKeyDown={(event) => { if (event.key === "Enter") void handleManualSubmit(); }} /> <button type="button" disabled={busy} onClick={() => void handleManualSubmit()}> Submit </button> </div> ) : null}
{message ? <p role="status">{message}</p> : null} {busy ? <p>Processing scan...</p> : null} </section> );}scanner.css — click to expand
.dust-scanner { display: grid; gap: 1rem; max-width: 42rem;}
.dust-scanner__modes { display: inline-flex; width: fit-content; gap: 0.25rem; border: 1px solid #d4d4d8; border-radius: 8px; padding: 0.25rem;}
.dust-scanner__modes button { border: 0; border-radius: 6px; background: transparent; padding: 0.45rem 0.75rem; cursor: pointer;}
.dust-scanner__modes button[aria-pressed="true"] { background: #111827; color: white;}
.dust-scanner__dropzone,.dust-scanner__camera,.dust-scanner__manual { border: 1px solid #d4d4d8; border-radius: 8px; padding: 1rem;}
.dust-scanner__dropzone { display: grid; gap: 0.75rem;}
.dust-scanner__camera { display: grid; gap: 0.75rem;}
.dust-scanner__camera video { display: block; width: 100%; aspect-ratio: 4 / 3; border-radius: 6px; background: #000;}
.dust-scanner__camera p { margin: 0;}
.dust-scanner__manual { display: flex; flex-wrap: wrap; gap: 0.5rem;}
.dust-scanner__manual input { min-width: min(100%, 18rem); flex: 1;}See Integrate with DUST Go if your app runs inside the DUST Go mobile browser instead of using the device camera directly.