Files
crowd-funder-from-jason/src/services/QRScanner/WebDialogQRScanner.ts
Matt Raymer 9dc9878472 fix(qr-scanner): robustly handle array/object detection results and guarantee dialog dismissal
- Update QRScannerDialog.vue to handle both array and object detection results in onDetect fallback logic (supports vue-qrcode-reader returning arrays).
- Ensure dialog closes and scan is processed for all detection result shapes.
- Use arrow function for close() to guarantee correct binding with vue-facing-decorator.
- Add enhanced logging for all dialog lifecycle and close/cleanup events.
- In WebDialogQRScanner, use direct mount result (not $refs) for dialogComponent to ensure correct instance.
- Add sessionId and improved logging for dialog open/close/cleanup lifecycle.
2025-04-29 06:10:12 -04:00

242 lines
7.9 KiB
TypeScript

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<typeof QRScannerDialog> | 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<boolean> {
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<boolean> {
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<boolean> {
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<void> {
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<typeof QRScannerDialog>;
// 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<void> {
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<void> {
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;
}
}
}