// Scanbot Web SDK barcode scanner integration for the mobile pages. // // The SDK is intentionally loaded from public/scanbot instead of imported into // the Nuxt bundle. This keeps the WASM engine out of SSR/Vite and also makes the // same static assets available to the bundled Capacitor app. const SCANBOT_SCRIPT_ID = 'logship-scanbot-sdk' const SCANBOT_SCRIPT_SRC = '/scanbot/ScanbotSDK.min.js' const SCANBOT_ENGINE_PATH = '/scanbot/bin/barcode-scanner/' const SCANBOT_VERSION = '10.0.0' // Keep the camera scanner compatible with the formats supported by the former // Quagga integration, while also covering common 2D product/label codes. const SCANBOT_BARCODE_FORMATS = [ 'CODABAR', 'CODE_11', 'CODE_25', 'CODE_32', 'CODE_39', 'CODE_93', 'CODE_128', 'DATABAR', 'DATABAR_EXPANDED', 'DATABAR_LIMITED', 'EAN_8', 'EAN_13', 'IATA_2_OF_5', 'INDUSTRIAL_2_OF_5', 'ITF', 'MSI_PLESSEY', 'PHARMA_CODE', 'PZN_7', 'PZN_8', 'UPC_A', 'UPC_E', 'AZTEC', 'DATA_MATRIX', 'MAXI_CODE', 'MICRO_QR_CODE', 'MICRO_PDF_417', 'PDF_417', 'QR_CODE', 'RMQR_CODE' ] let scanbotScriptPromise = null let scanbotInitializationPromise = null const loadScanbotSdk = () => { if (typeof window === 'undefined') { return Promise.reject(new Error('Scanbot can only be loaded in a browser.')) } if (window.ScanbotSDK) return Promise.resolve(window.ScanbotSDK) if (scanbotScriptPromise) return scanbotScriptPromise scanbotScriptPromise = new Promise((resolve, reject) => { const existingScript = document.getElementById(SCANBOT_SCRIPT_ID) if (existingScript) { existingScript.addEventListener('load', () => { if (window.ScanbotSDK) resolve(window.ScanbotSDK) else reject(new Error('Scanbot SDK loaded without exposing ScanbotSDK.')) }, { once: true }) existingScript.addEventListener('error', () => reject(new Error('Scanbot SDK script failed to load.')), { once: true }) return } const script = document.createElement('script') script.id = SCANBOT_SCRIPT_ID script.src = SCANBOT_SCRIPT_SRC script.async = true script.onload = () => { if (window.ScanbotSDK) resolve(window.ScanbotSDK) else reject(new Error('Scanbot SDK loaded without exposing ScanbotSDK.')) } script.onerror = () => reject(new Error(`Unable to load ${SCANBOT_SCRIPT_SRC}.`)) document.head.appendChild(script) }).catch((error) => { scanbotScriptPromise = null throw error }) return scanbotScriptPromise } const initializeScanbot = async (licenseKey) => { const sdk = await loadScanbotSdk() if (!scanbotInitializationPromise) { scanbotInitializationPromise = sdk.initialize({ licenseKey: licenseKey || '', enginePath: SCANBOT_ENGINE_PATH, // The app is not cross-origin isolated. Explicitly disable threads so the // WebView does not attempt to create an unsupported threaded WASM worker. allowThreads: false, // Scanbot appends this string directly to engine filenames, so it must // include the query delimiter (otherwise it requests e.g. Core.jsv=...). requestSuffix: `?v=${SCANBOT_VERSION}` }).catch((error) => { scanbotInitializationPromise = null throw error }) } return scanbotInitializationPromise } export const useBarcodeScanner = (onScan, targetElementGetter) => { const runtimeConfig = useRuntimeConfig() const scanbotLicenseKey = runtimeConfig.public?.scanbotLicenseKey || '' const useScannerMode = useMobileBarcodeScanner() const showScanner = ref(false) const scannerError = ref('') const isProcessingScan = ref(false) const isInitialized = ref(false) // Scanbot can report the same barcode on several consecutive camera frames. // Pause detection briefly after accepting one result and keep a duplicate // window so a barcode held in front of the camera is processed only once. const SCAN_COOLDOWN_MS = 1200 const DUPLICATE_WINDOW_MS = 2500 let lastScannedCode = '' let lastScannedAt = 0 let cooldownTimer = null // The root Nuxt page uses keep-alive, so scanner teardown must happen on // deactivation as well as unmount. A generation counter also makes async SDK // initialization safe when the user navigates away while it is loading. let scannerHandle = null let scannerGeneration = 0 let pageVisible = true let startTimer = null const scheduleStart = () => { if (!useScannerMode.value || startTimer) return startTimer = setTimeout(() => { startTimer = null if (pageVisible && useScannerMode.value && !isInitialized.value) { startScanner() } }, 200) } onMounted(() => { if (typeof window !== 'undefined') { const savedPreference = localStorage.getItem('useBarcodeScanner') useScannerMode.value = savedPreference === 'true' scheduleStart() } }) onActivated(() => { pageVisible = true scheduleStart() }) onDeactivated(() => { pageVisible = false if (startTimer) { clearTimeout(startTimer) startTimer = null } stopScanner() }) onBeforeUnmount(() => { pageVisible = false if (startTimer) { clearTimeout(startTimer) startTimer = null } stopScanner() }) const cameraErrorMessage = (error) => { const name = error?.name || error?.code || '' const message = String(error?.message || '') if (name === 'NotAllowedError' || name === 'PermissionDeniedError' || /permission/i.test(message)) { return 'Kamera-Berechtigung verweigert. Bitte in den Android-Einstellungen für LogShip die Kamera erlauben – oder die App über „Update“ aktualisieren.' } if (name === 'NotFoundError' || name === 'DevicesNotFoundError') { return 'Keine Kamera gefunden.' } if (name === 'NotReadableError' || name === 'TrackStartError') { return 'Kamera wird bereits von einer anderen App verwendet.' } return 'Kamera konnte nicht gestartet werden: ' + (message || name || 'Unbekannter Fehler') } const findTargetElement = async () => { let targetElement = null let retries = 0 while (!targetElement && retries < 20) { targetElement = targetElementGetter?.() || document.querySelector('.scanner-video-container-inline') if (targetElement) return targetElement await new Promise(resolve => setTimeout(resolve, 100)) retries++ } return null } const handleDetection = (result, generation) => { if (generation !== scannerGeneration || !result || isProcessingScan.value) return const barcodes = Array.isArray(result?.barcodes) ? result.barcodes : Array.isArray(result?.items) ? result.items : [] const scannedCode = barcodes .map(barcode => barcode?.text ?? barcode?.value) .find(text => typeof text === 'string' && text.trim()) ?.trim() if (!scannedCode) return const now = Date.now() if (scannedCode === lastScannedCode && (now - lastScannedAt) < DUPLICATE_WINDOW_MS) return isProcessingScan.value = true lastScannedCode = scannedCode lastScannedAt = now try { scannerHandle?.pauseDetection?.() } catch (error) { // Pausing is an optimization; a successful decode is still usable if a // device/browser does not expose pauseDetection. console.debug('[ScanbotScanner] Could not pause detection:', error) } onScan?.(scannedCode) if (cooldownTimer) clearTimeout(cooldownTimer) cooldownTimer = setTimeout(() => { cooldownTimer = null isProcessingScan.value = false if (generation === scannerGeneration && useScannerMode.value && pageVisible) { try { scannerHandle?.resumeDetection?.() } catch (error) { console.debug('[ScanbotScanner] Could not resume detection:', error) } } }, SCAN_COOLDOWN_MS) } const startScanner = async () => { if (!useScannerMode.value || !pageVisible || isInitialized.value) return const generation = ++scannerGeneration showScanner.value = true scannerError.value = '' if (typeof navigator === 'undefined' || !navigator.mediaDevices?.getUserMedia) { scannerError.value = 'Kamera nicht verfügbar. Bitte die App aktualisieren (Kamera-Berechtigung fehlt in der installierten Version) oder einen Handscanner verwenden.' return } try { await nextTick() const targetElement = await findTargetElement() if (!targetElement) { scannerError.value = 'Scanner-Ansicht konnte nicht geöffnet werden.' return } const sdk = await initializeScanbot(scanbotLicenseKey) if (generation !== scannerGeneration || !useScannerMode.value || !pageVisible) return const handle = await sdk.createBarcodeScanner({ container: targetElement, // Explicit camera constraints make laptop webcams use a useful // recognition frame instead of their browser-dependent low default. videoConstraints: { width: { min: 640, ideal: 1920 }, height: { min: 480, ideal: 1080 } }, previewMode: 'FIT_IN', // Keep result callbacks active even if the SDK's selection overlay is // enabled by a future SDK default. overlay: { visible: false }, scannerConfiguration: { barcodeFormatConfigurations: [{ _type: 'BarcodeFormatCommonConfiguration', formats: SCANBOT_BARCODE_FORMATS }] }, captureDelay: SCAN_COOLDOWN_MS, fpsLimit: 20, desiredRecognitionResolution: 1920, onBarcodesDetected: result => handleDetection(result, generation), onError: error => { console.error('[ScanbotScanner] Detection error:', error) if (!scannerError.value) scannerError.value = cameraErrorMessage(error) } }) if (generation !== scannerGeneration || !useScannerMode.value || !pageVisible) { handle?.dispose?.() return } scannerHandle = handle isInitialized.value = true } catch (error) { console.error('[ScanbotScanner] Initialization error:', error) scannerError.value = cameraErrorMessage(error) scannerHandle = null isInitialized.value = false } } // The dashboard toggles the shared mode before navigating to a destination // page. React to that change immediately when this page is already mounted; // on navigation, onActivated/scheduleStart handles the same state. watch(useScannerMode, (enabled) => { if (enabled) { if (pageVisible) scheduleStart() } else { stopScanner() } }) const stopScanner = () => { scannerGeneration++ if (cooldownTimer) { clearTimeout(cooldownTimer) cooldownTimer = null } isProcessingScan.value = false lastScannedCode = '' lastScannedAt = 0 const handle = scannerHandle scannerHandle = null isInitialized.value = false try { handle?.dispose?.() } catch (error) { console.debug('[ScanbotScanner] Could not dispose scanner:', error) } showScanner.value = false scannerError.value = '' } const restartScanner = async () => { stopScanner() await nextTick() await new Promise(resolve => setTimeout(resolve, 100)) await startScanner() } return { useScannerMode, showScanner, scannerError, isProcessingScan, isInitialized, startScanner, stopScanner, restartScanner } }