Scanner React
Un piccolo componente React che acquisisce un Identificatore nel browser e lo passa al tuo backend, che chiama l’API DUST. Supporta tre modalità:
- DUST — invia un’immagine di scansione DUST da risolvere tramite gli endpoint degli Identificatori.
- Altro — utilizza la fotocamera del dispositivo per leggere Identificatori QR, a codice a barre e Data Matrix.
- Manuale — invia Identificatori QR, a codice a barre, Data Matrix o NFC da un campo di testo.
Prima di iniziare
- Ruolo
- Un account di servizio appartenente ai Team nei quali esegui le scansioni
- Dispositivo
- Un accessorio ottico DUST per l'acquisizione DUST; la sola fotocamera del dispositivo per QR, codici a barre e Data Matrix
Dove risiede la credenziale
Sezione intitolata “Dove risiede la credenziale”Questa integrazione comprende due file in due posizioni e la loro separazione costituisce l’intero modello di sicurezza:
| File | Dove viene eseguito | Contiene la credenziale DUST? |
|---|---|---|
DustScanner.tsx | Browser | No, e non dispone di alcuna prop che possa accettarne una. |
Il tuo gestore /api/dust-scan | Il tuo server | Sì. Genera il token bearer, sceglie il contesto e chiama /api/v1/tags/*. |
Installazione
Sezione intitolata “Installazione”bun add barcode-detectorIl componente presuppone che React sia già presente nella tua app.
bun add react react-dom barcode-detectorSalva DustScanner.tsx e scanner.css nel tuo progetto.
La modalità Altro funziona allo stesso modo nei browser desktop e su smartphone. Apre per impostazione predefinita la fotocamera posteriore e specchia l’immagine solo per una fotocamera rivolta verso l’utente, così la visuale si sposta come il dispositivo. Non c’è alcun pulsante da premere: la lettura è continua e, quando più codici sono inquadrati, viene scelto quello più vicino al centro. Se il browser dispone di un rilevatore di codici a barre integrato che legge QR, Data Matrix, Code 128 ed EAN-13 (Chrome su Android e macOS), il componente lo utilizza; altrove, Safari compreso, ricorre a barcode-detector, una build WebAssembly di ZXing. I browser aprono la fotocamera solo su pagine HTTPS.
1. Il gestore del server
Sezione intitolata “1. Il gestore del server”Scrivi prima questo: senza di esso il componente è inutilizzabile. Riceve il corpo multipart inviato dal componente, applica la tua autorizzazione, aggiunge la credenziale DUST e le intestazioni di contesto, quindi inoltra la richiesta all’endpoint degli Identificatori corrispondente.
Devi fornire due elementi: Session, ossia tutto ciò che la tua app già conosce sull’utente autenticato, e getDustToken(), lo scambio memorizzato nella cache della chiave API descritto in Autenticazione.
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" }, });}Inoltra senza modifiche lo stato e il corpo restituiti da DUST. Trasformare ogni errore in un 500 elimina proprio le informazioni necessarie al browser per distinguere tra «nessuna corrispondenza» e «riprova».
2. Visualizza lo scanner
Sezione intitolata “2. Visualizza lo 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)} /> );}Non è necessario recuperare alcun token e non esiste alcun blocco di caricamento che ne attenda uno. endpoint è un percorso nella tua stessa origine; il componente vi invia richieste con credentials: "same-origin", quindi la chiamata viene autenticata dal cookie della sessione esistente.
searchTeamIds è disponibile come prop per una pagina che scelga legittimamente il proprio ambito, ma il gestore del server riportato sopra lo sovrascrive: un ambito di ricerca fornito dal browser è una richiesta, mai un’autorizzazione.
Associa a una Scheda
Sezione intitolata “Associa a una Scheda”<DustScanner endpoint="/api/dust-scan" operation="bind" threadId={threadId} tagDescription="Receiving scan" onResult={setResult}/>Verifica un Identificatore
Sezione intitolata “Verifica un Identificatore”La verifica richiede gli identificatori candidati già associati a quella Scheda, sotto forma di oggetti con tagId e tagType, non come stringhe ID. Leggili dalla Scheda sul tuo server (GET /api/v1/threads/{thread_id} li restituisce in thread.tags) e passali al componente:
<DustScanner endpoint="/api/dust-scan" operation="verify" threadId={threadId} verifyTags={dustIdentifiers} // [{ tagId: "8f2b…", tagType: "DUST" }] onResult={setResult}/>Gestione degli esiti
Sezione intitolata “Gestione degli esiti”onError riceve un ScanSubmitError contenente code, status, detail e scanId di DUST. Gestisci i casi in base a code: molti di essi sono risposte, non errori:
code | Significato | Cosa deve vedere l’operatore |
|---|---|---|
IDENTIFIER_NOT_FOUND | Nessuna corrispondenza nei Team inclusi nella ricerca | «Nessuna corrispondenza», non un banner di errore |
SCAN_SEARCH_INCOMPLETE | Non è stato possibile completare la ricerca | «Riprova»: l’oggetto potrebbe essere stato effettivamente registrato |
SCAN_LOW_KEYPOINTS / SCAN_NO_KEYPOINTS | L’acquisizione era inutilizzabile | «Esegui nuovamente la scansione» |
IDENTIFIER_ALREADY_BOUND | Associazione rifiutata: l’Identificatore è già associato a un’altra Scheda | Mostra il conflitto |
IDENTIFIER_VERIFY_FAILED | La verifica con un singolo candidato non ha prodotto una corrispondenza | «Nessuna corrispondenza» |
error.scanId è la ricevuta di scansione dell’acquisizione non riuscita: registrala nei log e comunicala all’assistenza quando un risultato viene contestato. La tabella completa è disponibile in Errori ed esiti delle scansioni.
Codice sorgente del componente
Sezione intitolata “Codice sorgente del componente”DustScanner.tsx — viene eseguito nel browser; fai clic per espandere
/** * 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 — fai clic per espandere
.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;}Consulta Integra con DUST Go se la tua app viene eseguita all’interno del browser mobile di DUST Go anziché utilizzare direttamente la fotocamera del dispositivo.