Browse Source

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
pull/133/head
Matt Raymer 4 weeks ago
parent
commit
93e860e0ac
  1. 6
      package-lock.json
  2. 6
      package.json
  3. 377
      src/services/QRScanner/WebInlineQRScanner.ts

6
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",

6
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,

377
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<boolean> {
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<boolean> {
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<boolean> {
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<void> {
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<void> {
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<void> {
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<void> {
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,
});
}
}
}

Loading…
Cancel
Save