import { createApp, App } from "vue"; import { QRScannerService, ScanListener, QRScannerOptions } from "./types"; import QRScannerDialog from "@/components/QRScanner/QRScannerDialog.vue"; import { logger } from "@/utils/logger"; export class WebDialogQRScanner implements QRScannerService { private dialogInstance: App | null = null; private dialogComponent: InstanceType | null = null; private scanListener: ScanListener | null = null; private isScanning = false; private container: HTMLElement | null = null; private sessionId: number | null = null; private failsafeTimeout: any = null; constructor(private options?: QRScannerOptions) {} async checkPermissions(): Promise { try { console.log("[QRScanner] Checking camera permissions..."); const permissions = await navigator.permissions.query({ name: "camera" as PermissionName, }); console.log("[QRScanner] Permission state:", permissions.state); return permissions.state === "granted"; } catch (error) { console.error("[QRScanner] Error checking camera permissions:", error); return false; } } async requestPermissions(): Promise { try { // First check if we have any video devices const devices = await navigator.mediaDevices.enumerateDevices(); const videoDevices = devices.filter( (device) => device.kind === "videoinput", ); if (videoDevices.length === 0) { logger.error("No video devices found"); throw new Error("No camera found on this device"); } // Try to get a stream with specific constraints const stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: "environment", width: { ideal: 1280 }, height: { ideal: 720 }, }, }); // Stop the test stream immediately stream.getTracks().forEach((track) => track.stop()); return true; } catch (error) { const wrappedError = error instanceof Error ? error : new Error(String(error)); logger.error("Error requesting camera permissions:", { error: wrappedError.message, stack: wrappedError.stack, name: wrappedError.name, }); // Provide more specific error messages if ( wrappedError.name === "NotFoundError" || wrappedError.name === "DevicesNotFoundError" ) { throw new Error("No camera found on this device"); } else if ( wrappedError.name === "NotAllowedError" || wrappedError.name === "PermissionDeniedError" ) { throw new Error( "Camera access denied. Please grant camera permission and try again", ); } else if ( wrappedError.name === "NotReadableError" || wrappedError.name === "TrackStartError" ) { throw new Error("Camera is in use by another application"); } else { throw new Error(`Camera error: ${wrappedError.message}`); } } } async isSupported(): Promise { try { // Check for secure context first if (!window.isSecureContext) { logger.warn("Camera access requires HTTPS (secure context)"); return false; } // Check for camera API support if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) { logger.warn("Camera API not supported in this browser"); return false; } // Check if we have any video devices const devices = await navigator.mediaDevices.enumerateDevices(); const hasVideoDevices = devices.some( (device) => device.kind === "videoinput", ); if (!hasVideoDevices) { logger.warn("No video devices found"); return false; } return true; } catch (error) { logger.error("Error checking camera support:", { error: error instanceof Error ? error.message : String(error), stack: error instanceof Error ? error.stack : undefined, }); return false; } } async startScan(): Promise { if (this.isScanning) { return; } try { this.isScanning = true; this.sessionId = Date.now(); console.log(`[WebDialogQRScanner] Opening dialog, session: ${this.sessionId}`); // Create and mount dialog component this.container = document.createElement("div"); document.body.appendChild(this.container); this.dialogInstance = createApp(QRScannerDialog, { onScan: (result: string) => { if (this.scanListener) { this.scanListener.onScan(result); } }, onError: (error: Error) => { if (this.scanListener?.onError) { this.scanListener.onError(error); } }, onClose: () => { console.log(`[WebDialogQRScanner] onClose received from dialog, session: ${this.sessionId}`); this.stopScan('dialog onClose'); }, options: this.options, sessionId: this.sessionId, }); this.dialogComponent = this.dialogInstance.mount(this.container) as InstanceType; // Failsafe: force cleanup after 60s if dialog is still open this.failsafeTimeout = setTimeout(() => { if (this.isScanning) { console.warn(`[WebDialogQRScanner] Failsafe triggered, forcing cleanup for session: ${this.sessionId}`); this.stopScan('failsafe timeout'); } }, 60000); console.log(`[WebDialogQRScanner] Failsafe timeout set for session: ${this.sessionId}`); } catch (error) { this.isScanning = false; const wrappedError = error instanceof Error ? error : new Error(String(error)); if (this.scanListener?.onError) { this.scanListener.onError(wrappedError); } logger.error("Error starting scan:", wrappedError); this.cleanupContainer(); throw wrappedError; } } async stopScan(reason: string = 'manual') : Promise { if (!this.isScanning) { return; } try { console.log(`[WebDialogQRScanner] stopScan called, reason: ${reason}, session: ${this.sessionId}`); if (this.dialogComponent) { await this.dialogComponent.close(); console.log(`[WebDialogQRScanner] dialogComponent.close() called, session: ${this.sessionId}`); } if (this.dialogInstance) { this.dialogInstance.unmount(); console.log(`[WebDialogQRScanner] dialogInstance.unmount() called, session: ${this.sessionId}`); } } catch (error) { const wrappedError = error instanceof Error ? error : new Error(String(error)); logger.error("Error stopping scan:", wrappedError); throw wrappedError; } finally { this.isScanning = false; if (this.failsafeTimeout) { clearTimeout(this.failsafeTimeout); this.failsafeTimeout = null; console.log(`[WebDialogQRScanner] Failsafe timeout cleared, session: ${this.sessionId}`); } this.cleanupContainer(); } } addListener(listener: ScanListener): void { this.scanListener = listener; } private cleanupContainer(): void { if (this.container && this.container.parentNode) { this.container.parentNode.removeChild(this.container); console.log(`[WebDialogQRScanner] Dialog container removed from DOM, session: ${this.sessionId}`); } else { console.log(`[WebDialogQRScanner] Dialog container NOT removed from DOM, session: ${this.sessionId}`); } this.container = null; } async cleanup(): Promise { try { await this.stopScan('cleanup'); } catch (error) { const wrappedError = error instanceof Error ? error : new Error(String(error)); logger.error("Error during cleanup:", wrappedError); throw wrappedError; } finally { this.dialogComponent = null; this.dialogInstance = null; this.scanListener = null; this.cleanupContainer(); this.sessionId = null; } } }