From 93e860e0ac98e25f058dfe88b319722140616caf Mon Sep 17 00:00:00 2001 From: Matt Raymer Date: Thu, 8 May 2025 01:58:39 -0400 Subject: [PATCH] feat(qr-scanner): implement WebInlineQRScanner with jsQR integration - Add jsQR library for QR code detection and scanning - Implement WebInlineQRScanner class with comprehensive camera handling - Add detailed logging throughout scanner lifecycle - Include error handling and cleanup procedures - Add blur detection for QR codes - Implement FPS throttling for performance optimization - Add device compatibility checks and permission handling The scanner now provides: - Camera stream management - QR code detection with blur prevention - Performance optimized scanning (15 FPS target) - Detailed logging for debugging - Proper cleanup of resources --- package-lock.json | 6 + package.json | 6 +- src/services/QRScanner/WebInlineQRScanner.ts | 377 +++++++++++++++++-- 3 files changed, 365 insertions(+), 24 deletions(-) diff --git a/package-lock.json b/package-lock.json index 87f1beac..7d42bee7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -57,6 +57,7 @@ "jdenticon": "^3.2.0", "js-generate-password": "^0.1.9", "js-yaml": "^4.1.0", + "jsqr": "^1.4.0", "leaflet": "^1.9.4", "localstorage-slim": "^2.7.0", "lru-cache": "^10.2.0", @@ -17734,6 +17735,11 @@ "node": "*" } }, + "node_modules/jsqr": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/jsqr/-/jsqr-1.4.0.tgz", + "integrity": "sha512-dxLob7q65Xg2DvstYkRpkYtmKm2sPJ9oFhrhmudT1dZvNFFTlroai3AWSpLey/w5vMcLBXRgOJsbXpdN9HzU/A==" + }, "node_modules/katex": { "version": "0.16.22", "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.22.tgz", diff --git a/package.json b/package.json index 724b6a22..29210ad9 100644 --- a/package.json +++ b/package.json @@ -95,6 +95,7 @@ "jdenticon": "^3.2.0", "js-generate-password": "^0.1.9", "js-yaml": "^4.1.0", + "jsqr": "^1.4.0", "leaflet": "^1.9.4", "localstorage-slim": "^2.7.0", "lru-cache": "^10.2.0", @@ -189,7 +190,10 @@ }, "asar": true, "mac": { - "target": ["dmg", "zip"], + "target": [ + "dmg", + "zip" + ], "category": "public.app-category.productivity", "icon": "build/icon.png", "hardenedRuntime": true, diff --git a/src/services/QRScanner/WebInlineQRScanner.ts b/src/services/QRScanner/WebInlineQRScanner.ts index 49c4611d..d7457420 100644 --- a/src/services/QRScanner/WebInlineQRScanner.ts +++ b/src/services/QRScanner/WebInlineQRScanner.ts @@ -1,43 +1,104 @@ import { QRScannerService, ScanListener, QRScannerOptions } from "./types"; import { logger } from "@/utils/logger"; import { EventEmitter } from "events"; +import jsQR from "jsqr"; + +// Build identifier to help distinguish between builds +const BUILD_ID = `build-${Date.now()}`; export class WebInlineQRScanner implements QRScannerService { private scanListener: ScanListener | null = null; private isScanning = false; private stream: MediaStream | null = null; private events = new EventEmitter(); + private canvas: HTMLCanvasElement | null = null; + private context: CanvasRenderingContext2D | null = null; + private video: HTMLVideoElement | null = null; + private animationFrameId: number | null = null; + private scanAttempts = 0; + private lastScanTime = 0; + private readonly id: string; + private readonly TARGET_FPS = 15; // Target 15 FPS for scanning + private readonly FRAME_INTERVAL = 1000 / 15; // ~67ms between frames + private lastFrameTime = 0; - constructor(private options?: QRScannerOptions) {} + constructor(private options?: QRScannerOptions) { + // Generate a short random ID for this scanner instance + this.id = Math.random().toString(36).substring(2, 8).toUpperCase(); + logger.error( + `[WebInlineQRScanner:${this.id}] Initializing scanner with options:`, + { + ...options, + buildId: BUILD_ID, + targetFps: this.TARGET_FPS, + }, + ); + // Create canvas and video elements + this.canvas = document.createElement("canvas"); + this.context = this.canvas.getContext("2d", { willReadFrequently: true }); + this.video = document.createElement("video"); + this.video.setAttribute("playsinline", "true"); // Required for iOS + logger.error( + `[WebInlineQRScanner:${this.id}] DOM elements created successfully`, + ); + } async checkPermissions(): Promise { try { - logger.log("[QRScanner] Checking camera permissions..."); + logger.error( + `[WebInlineQRScanner:${this.id}] Checking camera permissions...`, + ); const permissions = await navigator.permissions.query({ name: "camera" as PermissionName, }); - logger.log("[QRScanner] Permission state:", permissions.state); + logger.error( + `[WebInlineQRScanner:${this.id}] Permission state:`, + permissions.state, + ); return permissions.state === "granted"; } catch (error) { - logger.error("[QRScanner] Error checking camera permissions:", error); + logger.error( + `[WebInlineQRScanner:${this.id}] Error checking camera permissions:`, + { + error: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + }, + ); return false; } } async requestPermissions(): Promise { try { + logger.error( + `[WebInlineQRScanner:${this.id}] Requesting camera permissions...`, + ); // First check if we have any video devices const devices = await navigator.mediaDevices.enumerateDevices(); const videoDevices = devices.filter( (device) => device.kind === "videoinput", ); + logger.error(`[WebInlineQRScanner:${this.id}] Found video devices:`, { + count: videoDevices.length, + devices: videoDevices.map((d) => ({ id: d.deviceId, label: d.label })), + }); + if (videoDevices.length === 0) { - logger.error("No video devices found"); + logger.error(`[WebInlineQRScanner:${this.id}] No video devices found`); throw new Error("No camera found on this device"); } // Try to get a stream with specific constraints + logger.error( + `[WebInlineQRScanner:${this.id}] Requesting camera stream with constraints:`, + { + facingMode: "environment", + width: { ideal: 1280 }, + height: { ideal: 720 }, + }, + ); + const stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: "environment", @@ -46,17 +107,31 @@ export class WebInlineQRScanner implements QRScannerService { }, }); + logger.error( + `[WebInlineQRScanner:${this.id}] Camera stream obtained successfully`, + ); + // Stop the test stream immediately - stream.getTracks().forEach((track) => track.stop()); + stream.getTracks().forEach((track) => { + logger.error(`[WebInlineQRScanner:${this.id}] Stopping test track:`, { + kind: track.kind, + label: track.label, + readyState: track.readyState, + }); + 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, - }); + logger.error( + `[WebInlineQRScanner:${this.id}] Error requesting camera permissions:`, + { + error: wrappedError.message, + stack: wrappedError.stack, + name: wrappedError.name, + }, + ); // Provide more specific error messages if ( @@ -84,15 +159,20 @@ export class WebInlineQRScanner implements QRScannerService { async isSupported(): Promise { try { + logger.error(`[WebInlineQRScanner:${this.id}] Checking browser support...`); // Check for secure context first if (!window.isSecureContext) { - logger.warn("Camera access requires HTTPS (secure context)"); + logger.error( + `[WebInlineQRScanner:${this.id}] 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"); + logger.error( + `[WebInlineQRScanner:${this.id}] Camera API not supported in this browser`, + ); return false; } @@ -102,31 +182,200 @@ export class WebInlineQRScanner implements QRScannerService { (device) => device.kind === "videoinput", ); + logger.error(`[WebInlineQRScanner:${this.id}] Device support check:`, { + hasSecureContext: window.isSecureContext, + hasMediaDevices: !!navigator.mediaDevices, + hasGetUserMedia: !!navigator.mediaDevices?.getUserMedia, + hasVideoDevices, + deviceCount: devices.length, + }); + if (!hasVideoDevices) { - logger.warn("No video devices found"); + logger.error(`[WebInlineQRScanner:${this.id}] No video devices found`); return false; } return true; } catch (error) { - logger.error("Error checking camera support:", { + logger.error( + `[WebInlineQRScanner:${this.id}] Error checking camera support:`, + { + error: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + }, + ); + return false; + } + } + + private async scanQRCode(): Promise { + if (!this.video || !this.canvas || !this.context || !this.stream) { + logger.error( + `[WebInlineQRScanner:${this.id}] Cannot scan: missing required elements`, + { + hasVideo: !!this.video, + hasCanvas: !!this.canvas, + hasContext: !!this.context, + hasStream: !!this.stream, + }, + ); + return; + } + + try { + const now = Date.now(); + const timeSinceLastFrame = now - this.lastFrameTime; + + // Throttle frame processing to target FPS + if (timeSinceLastFrame < this.FRAME_INTERVAL) { + this.animationFrameId = requestAnimationFrame(() => this.scanQRCode()); + return; + } + + this.lastFrameTime = now; + + // Set canvas dimensions to match video + this.canvas.width = this.video.videoWidth; + this.canvas.height = this.video.videoHeight; + + // Draw video frame to canvas + this.context.drawImage( + this.video, + 0, + 0, + this.canvas.width, + this.canvas.height, + ); + + // Get image data from canvas + const imageData = this.context.getImageData( + 0, + 0, + this.canvas.width, + this.canvas.height, + ); + + // Increment scan attempts + this.scanAttempts++; + const timeSinceLastScan = now - this.lastScanTime; + + // Log scan attempt every 100 frames or 1 second + if (this.scanAttempts % 100 === 0 || timeSinceLastScan >= 1000) { + logger.error(`[WebInlineQRScanner:${this.id}] Scanning frame:`, { + attempt: this.scanAttempts, + dimensions: { + width: this.canvas.width, + height: this.canvas.height, + }, + fps: Math.round(1000 / timeSinceLastScan), + imageDataSize: imageData.data.length, + imageDataWidth: imageData.width, + imageDataHeight: imageData.height, + timeSinceLastFrame, + targetFPS: this.TARGET_FPS, + }); + this.lastScanTime = now; + } + + // Scan for QR code + const code = jsQR(imageData.data, imageData.width, imageData.height, { + inversionAttempts: "attemptBoth", // Try both normal and inverted + }); + + if (code) { + // Check if the QR code is blurry by examining the location points + const { topRightCorner, topLeftCorner, bottomLeftCorner } = code.location; + const width = Math.sqrt( + Math.pow(topRightCorner.x - topLeftCorner.x, 2) + Math.pow(topRightCorner.y - topLeftCorner.y, 2) + ); + const height = Math.sqrt( + Math.pow(bottomLeftCorner.x - topLeftCorner.x, 2) + Math.pow(bottomLeftCorner.y - topLeftCorner.y, 2) + ); + + // Adjust minimum size based on canvas dimensions + const minSize = Math.min(this.canvas.width, this.canvas.height) * 0.1; // 10% of the smaller dimension + const isBlurry = width < minSize || height < minSize || + !code.data || code.data.length === 0; + + logger.error(`[WebInlineQRScanner:${this.id}] QR Code detected:`, { + data: code.data, + location: code.location, + attempts: this.scanAttempts, + isBlurry, + dimensions: { + width, + height, + minSize, + canvasWidth: this.canvas.width, + canvasHeight: this.canvas.height, + relativeWidth: width / this.canvas.width, + relativeHeight: height / this.canvas.height + }, + corners: { + topLeft: topLeftCorner, + topRight: topRightCorner, + bottomLeft: bottomLeftCorner + } + }); + + if (isBlurry) { + if (this.scanListener?.onError) { + this.scanListener.onError(new Error("QR code detected but too blurry to read. Please hold the camera steady and ensure the QR code is well-lit.")); + } + // Continue scanning if QR code is blurry + this.animationFrameId = requestAnimationFrame(() => this.scanQRCode()); + return; + } + + if (this.scanListener?.onScan) { + this.scanListener.onScan(code.data); + } + // Stop scanning after successful detection + await this.stopScan(); + return; + } + + // Continue scanning if no QR code found + this.animationFrameId = requestAnimationFrame(() => this.scanQRCode()); + } catch (error) { + logger.error(`[WebInlineQRScanner:${this.id}] Error scanning QR code:`, { error: error instanceof Error ? error.message : String(error), stack: error instanceof Error ? error.stack : undefined, + attempt: this.scanAttempts, + videoState: this.video ? { + readyState: this.video.readyState, + paused: this.video.paused, + ended: this.video.ended, + width: this.video.videoWidth, + height: this.video.videoHeight + } : null, + canvasState: this.canvas ? { + width: this.canvas.width, + height: this.canvas.height + } : null }); - return false; + if (this.scanListener?.onError) { + this.scanListener.onError( + error instanceof Error ? error : new Error(String(error)), + ); + } } } async startScan(): Promise { if (this.isScanning) { + logger.error(`[WebInlineQRScanner:${this.id}] Scanner already running`); return; } try { this.isScanning = true; - logger.log("[WebInlineQRScanner] Starting scan"); + this.scanAttempts = 0; + this.lastScanTime = Date.now(); + logger.error(`[WebInlineQRScanner:${this.id}] Starting scan`); // Get camera stream + logger.error(`[WebInlineQRScanner:${this.id}] Requesting camera stream...`); this.stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: "environment", @@ -135,61 +384,143 @@ export class WebInlineQRScanner implements QRScannerService { }, }); + logger.error(`[WebInlineQRScanner:${this.id}] Camera stream obtained:`, { + tracks: this.stream.getTracks().map((t) => ({ + kind: t.kind, + label: t.label, + readyState: t.readyState, + })), + }); + + // Set up video element + if (this.video) { + this.video.srcObject = this.stream; + await this.video.play(); + logger.error( + `[WebInlineQRScanner:${this.id}] Video element started playing`, + ); + } + // Emit stream to component this.events.emit("stream", this.stream); + logger.error(`[WebInlineQRScanner:${this.id}] Stream event emitted`); + + // Start QR code scanning + this.scanQRCode(); } catch (error) { this.isScanning = false; const wrappedError = error instanceof Error ? error : new Error(String(error)); + logger.error(`[WebInlineQRScanner:${this.id}] Error starting scan:`, { + error: wrappedError.message, + stack: wrappedError.stack, + name: wrappedError.name, + }); if (this.scanListener?.onError) { this.scanListener.onError(wrappedError); } - logger.error("Error starting scan:", wrappedError); throw wrappedError; } } async stopScan(): Promise { if (!this.isScanning) { + logger.error( + `[WebInlineQRScanner:${this.id}] Scanner not running, nothing to stop`, + ); return; } try { - logger.log("[WebInlineQRScanner] Stopping scan"); + logger.error(`[WebInlineQRScanner:${this.id}] Stopping scan`, { + scanAttempts: this.scanAttempts, + duration: Date.now() - this.lastScanTime, + }); + + // Stop animation frame + if (this.animationFrameId !== null) { + cancelAnimationFrame(this.animationFrameId); + this.animationFrameId = null; + logger.error(`[WebInlineQRScanner:${this.id}] Animation frame cancelled`); + } + + // Stop video + if (this.video) { + this.video.pause(); + this.video.srcObject = null; + logger.error(`[WebInlineQRScanner:${this.id}] Video element stopped`); + } // Stop all tracks in the stream if (this.stream) { - this.stream.getTracks().forEach((track) => track.stop()); + this.stream.getTracks().forEach((track) => { + logger.error(`[WebInlineQRScanner:${this.id}] Stopping track:`, { + kind: track.kind, + label: track.label, + readyState: track.readyState, + }); + track.stop(); + }); this.stream = null; } // Emit stream stopped event this.events.emit("stream", null); + logger.error( + `[WebInlineQRScanner:${this.id}] Stream stopped event emitted`, + ); } catch (error) { const wrappedError = error instanceof Error ? error : new Error(String(error)); - logger.error("Error stopping scan:", wrappedError); + logger.error(`[WebInlineQRScanner:${this.id}] Error stopping scan:`, { + error: wrappedError.message, + stack: wrappedError.stack, + name: wrappedError.name, + }); throw wrappedError; } finally { this.isScanning = false; + logger.error(`[WebInlineQRScanner:${this.id}] Scan stopped successfully`); } } addListener(listener: ScanListener): void { + logger.error(`[WebInlineQRScanner:${this.id}] Adding scan listener`); this.scanListener = listener; } - // Add method to get stream events onStream(callback: (stream: MediaStream | null) => void): void { + logger.error(`[WebInlineQRScanner:${this.id}] Adding stream event listener`); this.events.on("stream", callback); } async cleanup(): Promise { try { + logger.error(`[WebInlineQRScanner:${this.id}] Starting cleanup`); await this.stopScan(); this.events.removeAllListeners(); + logger.error(`[WebInlineQRScanner:${this.id}] Event listeners removed`); + + // Clean up DOM elements + if (this.video) { + this.video.remove(); + this.video = null; + logger.error(`[WebInlineQRScanner:${this.id}] Video element removed`); + } + if (this.canvas) { + this.canvas.remove(); + this.canvas = null; + logger.error(`[WebInlineQRScanner:${this.id}] Canvas element removed`); + } + this.context = null; + logger.error( + `[WebInlineQRScanner:${this.id}] Cleanup completed successfully`, + ); } catch (error) { - logger.error("Error during cleanup:", error); + logger.error(`[WebInlineQRScanner:${this.id}] Error during cleanup:`, { + error: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + }); } } }