Escáner React
Un pequeño componente de React que captura un identificador en el navegador y lo entrega a tu backend, que llama a la API de DUST. Admite tres modos:
- DUST — envía una imagen de escaneo de DUST para que los endpoints de identificadores la resuelvan.
- Otro — utiliza la cámara del dispositivo para leer identificadores QR, de códigos de barras y Data Matrix.
- Manual — envía identificadores QR, de códigos de barras, Data Matrix o NFC desde un campo de texto.
Antes de empezar
- Rol
- Una cuenta de servicio que pertenezca a los equipos en los que realizas escaneos
- Dispositivo
- Un accesorio óptico DUST para las capturas DUST; únicamente la cámara del dispositivo para QR, códigos de barras y Data Matrix
Dónde se encuentra la credencial
Sección titulada «Dónde se encuentra la credencial»Esta integración consta de dos archivos en dos lugares, y esta separación constituye todo el diseño de seguridad:
| Archivo | Se ejecuta en | ¿Contiene la credencial de DUST? |
|---|---|---|
DustScanner.tsx | Navegador | No; tampoco tiene ninguna prop que permita aceptar una. |
Tu controlador /api/dust-scan | Tu servidor | Sí. Emite el token de portador, elige el contexto y llama a /api/v1/tags/*. |
Instalación
Sección titulada «Instalación»bun add barcode-detectorEl componente presupone que React ya está disponible en tu aplicación.
bun add react react-dom barcode-detectorGuarda DustScanner.tsx y scanner.css en tu proyecto.
El modo Otro funciona igual en navegadores de escritorio y de móvil. Abre la cámara trasera de forma predeterminada y solo refleja la imagen cuando la cámara apunta al usuario, de modo que la vista se mueve igual que el dispositivo. No hay ningún botón que pulsar: lee de forma continua y, cuando hay varios códigos a la vista, toma el más cercano al centro. Si el navegador incorpora un detector de códigos de barras que lee QR, Data Matrix, Code 128 y EAN-13 (Chrome en Android y macOS), el componente lo utiliza; en los demás casos, incluido Safari, recurre a barcode-detector, una compilación de ZXing en WebAssembly. Los navegadores solo abren la cámara en páginas HTTPS.
1. El controlador del servidor
Sección titulada «1. El controlador del servidor»Escribe esto primero: el componente no sirve sin él. Recibe el cuerpo multipart que envía el componente, aplica tu autorización, añade la credencial de DUST y los encabezados de contexto, y reenvía la solicitud al endpoint de identificadores correspondiente.
Debes proporcionar dos elementos: Session, la información que tu aplicación ya tenga sobre el usuario que inició sesión, y getDustToken(), el intercambio almacenado en caché de la clave de API descrito en Autenticación.
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" }, });}Reenvía sin cambios el estado y el cuerpo de DUST. Convertir todos los fallos en un 500 elimina precisamente la información que el navegador necesita para distinguir entre «no hubo ninguna coincidencia» y «inténtalo de nuevo».
2. Renderizar el escáner
Sección titulada «2. Renderizar el escáner»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)} /> );}No hay ningún token que obtener ni ninguna barrera de carga a su alrededor. endpoint es una ruta de tu propio origen; el componente envía datos a ella con credentials: "same-origin", por lo que la cookie de sesión existente autentica la llamada.
searchTeamIds está disponible como prop para una página que tenga motivos legítimos para elegir su propio ámbito, pero el controlador del servidor anterior lo sobrescribe: un ámbito de búsqueda proporcionado por el navegador es una solicitud, nunca una autorización.
Vincular a una ficha
Sección titulada «Vincular a una ficha»<DustScanner endpoint="/api/dust-scan" operation="bind" threadId={threadId} tagDescription="Receiving scan" onResult={setResult}/>Verificar un identificador
Sección titulada «Verificar un identificador»La verificación necesita los identificadores candidatos ya vinculados a esa ficha, como objetos con tagId y tagType, no como cadenas de identificadores. Léelos desde la ficha en tu servidor (GET /api/v1/threads/{thread_id} los devuelve en thread.tags) y pásalos al componente:
<DustScanner endpoint="/api/dust-scan" operation="verify" threadId={threadId} verifyTags={dustIdentifiers} // [{ tagId: "8f2b…", tagType: "DUST" }] onResult={setResult}/>Gestionar los resultados
Sección titulada «Gestionar los resultados»onError recibe un ScanSubmitError que contiene los valores code, status, detail y scanId de DUST. Bifurca la lógica según code: varios de ellos son respuestas, no fallos:
code | Significado | Lo que debe ver el operador |
|---|---|---|
IDENTIFIER_NOT_FOUND | No hubo ninguna coincidencia en los equipos examinados | «Sin coincidencia», no un aviso de error |
SCAN_SEARCH_INCOMPLETE | No se pudo completar la búsqueda | «Inténtalo de nuevo»; es muy posible que el objeto esté inscrito |
SCAN_LOW_KEYPOINTS / SCAN_NO_KEYPOINTS | La captura no se podía utilizar | «Vuelve a escanear» |
IDENTIFIER_ALREADY_BOUND | Se rechazó la vinculación porque ya está vinculado a otra ficha | Mostrar el conflicto |
IDENTIFIER_VERIFY_FAILED | La verificación con un solo candidato no produjo una coincidencia | «No coincidió» |
error.scanId es el comprobante de escaneo de la captura que falló: regístralo e indícalo al equipo de soporte cuando se cuestione un resultado. La tabla completa se encuentra en Errores y resultados de escaneo.
Código fuente del componente
Sección titulada «Código fuente del componente»DustScanner.tsx — se ejecuta en el navegador; haz clic para expandir
/** * 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 — haz clic para expandir
.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 Integración con DUST Go si tu aplicación se ejecuta dentro del navegador móvil de DUST Go en lugar de utilizar directamente la cámara del dispositivo.