Salta ai contenuti

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

Questa integrazione comprende due file in due posizioni e la loro separazione costituisce l’intero modello di sicurezza:

FileDove viene eseguitoContiene la credenziale DUST?
DustScanner.tsxBrowserNo, e non dispone di alcuna prop che possa accettarne una.
Il tuo gestore /api/dust-scanIl tuo serverSì. Genera il token bearer, sceglie il contesto e chiama /api/v1/tags/*.
Terminal window
bun add barcode-detector

Il componente presuppone che React sia già presente nella tua app.

Salva 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.

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.

server/dust-scan.ts — runs on YOUR SERVER
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».

IdentifyPage.tsx — runs in the BROWSER
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.

BindPage.tsx — runs in the BROWSER
<DustScanner
endpoint="/api/dust-scan"
operation="bind"
threadId={threadId}
tagDescription="Receiving scan"
onResult={setResult}
/>

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:

VerifyPage.tsx — runs in the BROWSER
<DustScanner
endpoint="/api/dust-scan"
operation="verify"
threadId={threadId}
verifyTags={dustIdentifiers} // [{ tagId: "8f2b…", tagType: "DUST" }]
onResult={setResult}
/>

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:

codeSignificatoCosa deve vedere l’operatore
IDENTIFIER_NOT_FOUNDNessuna corrispondenza nei Team inclusi nella ricerca«Nessuna corrispondenza», non un banner di errore
SCAN_SEARCH_INCOMPLETENon è stato possibile completare la ricerca«Riprova»: l’oggetto potrebbe essere stato effettivamente registrato
SCAN_LOW_KEYPOINTS / SCAN_NO_KEYPOINTSL’acquisizione era inutilizzabile«Esegui nuovamente la scansione»
IDENTIFIER_ALREADY_BOUNDAssociazione rifiutata: l’Identificatore è già associato a un’altra SchedaMostra il conflitto
IDENTIFIER_VERIFY_FAILEDLa 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.

DustScanner.tsx — viene eseguito nel browser; fai clic per espandere
DustScanner.tsx
/**
* 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
scanner.css
.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.