Scanner React
Un petit composant React qui capture un identifiant dans le navigateur et le transmet à votre backend, lequel appelle l’API DUST. Il prend en charge trois modes :
- DUST — envoie une image de scan DUST aux points de terminaison des identifiants pour résolution.
- Autre — utilise la caméra de l’appareil pour lire les identifiants QR, codes-barres et Data Matrix.
- Manuel — envoie des identifiants QR, codes-barres, Data Matrix ou NFC depuis un champ de texte.
Avant de commencer
- Rôle
- Un compte de service appartenant aux équipes dans lesquelles vous effectuez des scans
- Appareil
- Un accessoire optique DUST pour la capture DUST ; la caméra de l’appareil seule pour les codes QR, les codes-barres et Data Matrix
Emplacement des identifiants de connexion
Section intitulée « Emplacement des identifiants de connexion »Cette intégration comporte deux fichiers à deux emplacements différents, et cette séparation constitue l’intégralité de la conception de sécurité :
| Fichier | S’exécute dans | Contient les identifiants de connexion DUST ? |
|---|---|---|
DustScanner.tsx | Navigateur | Non — et il ne possède aucune prop permettant d’en accepter. |
Votre gestionnaire /api/dust-scan | Votre serveur | Oui. Il génère le jeton porteur, choisit le contexte et appelle /api/v1/tags/*. |
Installation
Section intitulée « Installation »bun add barcode-detectorLe composant suppose que React est déjà présent dans votre application.
bun add react react-dom barcode-detectorEnregistrez DustScanner.tsx et scanner.css dans votre projet.
Le mode Autre fonctionne aussi bien dans les navigateurs de bureau que sur téléphone. Il ouvre par défaut la caméra arrière et n’affiche l’image en miroir que pour une caméra orientée vers l’utilisateur, de sorte que la vue suit les mouvements de l’appareil. Aucun bouton n’est nécessaire : la lecture est continue et, lorsque plusieurs codes sont visibles, le plus proche du centre est retenu. Si le navigateur dispose d’un détecteur de codes-barres intégré qui lit QR, Data Matrix, Code 128 et EAN-13 (Chrome sur Android et macOS), le composant l’utilise ; partout ailleurs, Safari compris, il se rabat sur barcode-detector, une version WebAssembly de ZXing. Les navigateurs n’ouvrent la caméra que sur une page HTTPS.
1. Le gestionnaire serveur
Section intitulée « 1. Le gestionnaire serveur »Écrivez-le en premier — sans lui, le composant est inutilisable. Il reçoit le corps multipart envoyé par le composant, applique votre autorisation, ajoute les identifiants de connexion DUST et les en-têtes de contexte, puis transmet la requête au point de terminaison d’identifiant correspondant.
Deux éléments sont à fournir par vos soins : Session, qui représente ce que votre application sait déjà sur l’utilisateur connecté, et getDustToken(), l’échange de clé API mis en cache décrit dans Authentification.
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" }, });}Transmettez tels quels le statut et le corps renvoyés par DUST. Réduire tous les échecs à une erreur 500 supprime précisément les informations dont le navigateur a besoin pour distinguer « aucune correspondance » de « réessayez ».
2. Afficher le scanner
Section intitulée « 2. Afficher le 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)} /> );}Il n’y a aucun jeton à récupérer ni aucun verrou de chargement à placer autour de cette récupération. endpoint est un chemin sur votre propre origine ; le composant y envoie une requête avec credentials: "same-origin", de sorte que votre cookie de session existant authentifie l’appel.
searchTeamIds est disponible sous forme de prop pour une page qui choisit légitimement son propre périmètre, mais le gestionnaire serveur ci-dessus le remplace — un périmètre de recherche fourni par le navigateur constitue une requête, jamais une autorisation.
Lier à une fiche
Section intitulée « Lier à une fiche »<DustScanner endpoint="/api/dust-scan" operation="bind" threadId={threadId} tagDescription="Receiving scan" onResult={setResult}/>Vérifier un identifiant
Section intitulée « Vérifier un identifiant »La vérification nécessite les identifiants candidats déjà liés à cette fiche, sous forme d’objets contenant tagId et tagType — et non de chaînes d’identifiants. Lisez-les depuis la fiche sur votre serveur (GET /api/v1/threads/{thread_id} les renvoie dans thread.tags) et transmettez-les au composant :
<DustScanner endpoint="/api/dust-scan" operation="verify" threadId={threadId} verifyTags={dustIdentifiers} // [{ tagId: "8f2b…", tagType: "DUST" }] onResult={setResult}/>Gérer les résultats
Section intitulée « Gérer les résultats »onError reçoit un ScanSubmitError contenant les propriétés DUST code, status, detail et scanId. Adaptez le comportement en fonction de code — plusieurs de ces codes constituent des réponses, et non des défaillances :
code | Signification | Ce que l’opérateur doit voir |
|---|---|---|
IDENTIFIER_NOT_FOUND | Aucune correspondance dans les équipes interrogées | « Aucune correspondance » — pas une bannière d’erreur |
SCAN_SEARCH_INCOMPLETE | La recherche n’a pas pu être menée à terme | « Réessayez » — l’élément peut tout à fait être enregistré |
SCAN_LOW_KEYPOINTS / SCAN_NO_KEYPOINTS | La capture était inutilisable | « Effectuez un nouveau scan » |
IDENTIFIER_ALREADY_BOUND | Liaison refusée : l’identifiant est déjà associé à une autre fiche | Afficher le conflit |
IDENTIFIER_VERIFY_FAILED | La vérification avec un seul candidat n’a produit aucune correspondance | « Aucune correspondance » |
error.scanId est le récépissé de scan de la capture qui a échoué — consignez-le et communiquez-le au support lorsqu’un résultat est contesté. Le tableau complet figure dans Erreurs et résultats de scan.
Source du composant
Section intitulée « Source du composant »DustScanner.tsx — s’exécute dans le navigateur ; cliquez pour développer
/** * 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 — cliquez pour développer
.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;}Consultez Intégrer DUST Go si votre application s’exécute dans le navigateur mobile DUST Go au lieu d’utiliser directement la caméra de l’appareil.