You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
693 lines
22 KiB
693 lines
22 KiB
import {
|
|
QRScannerService,
|
|
ScanListener,
|
|
QRScannerOptions,
|
|
CameraState,
|
|
CameraStateListener,
|
|
} 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;
|
|
private cameraStateListeners: Set<CameraStateListener> = new Set();
|
|
private currentState: CameraState = "off";
|
|
private currentStateMessage?: string;
|
|
private options: QRScannerOptions;
|
|
|
|
constructor(options?: QRScannerOptions) {
|
|
// Generate a short random ID for this scanner instance
|
|
this.id = Math.random().toString(36).substring(2, 8).toUpperCase();
|
|
this.options = options ?? {};
|
|
logger.error(
|
|
`[WebInlineQRScanner:${this.id}] Initializing scanner with options:`,
|
|
{
|
|
...this.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`,
|
|
);
|
|
}
|
|
|
|
private updateCameraState(state: CameraState, message?: string) {
|
|
this.currentState = state;
|
|
this.currentStateMessage = message;
|
|
this.cameraStateListeners.forEach((listener) => {
|
|
try {
|
|
listener.onStateChange(state, message);
|
|
logger.info(
|
|
`[WebInlineQRScanner:${this.id}] Camera state changed to: ${state}`,
|
|
{
|
|
state,
|
|
message,
|
|
},
|
|
);
|
|
} catch (error) {
|
|
logger.error(
|
|
`[WebInlineQRScanner:${this.id}] Error in camera state listener:`,
|
|
error,
|
|
);
|
|
}
|
|
});
|
|
}
|
|
|
|
addCameraStateListener(listener: CameraStateListener): void {
|
|
this.cameraStateListeners.add(listener);
|
|
// Immediately notify the new listener of current state
|
|
listener.onStateChange(this.currentState, this.currentStateMessage);
|
|
}
|
|
|
|
removeCameraStateListener(listener: CameraStateListener): void {
|
|
this.cameraStateListeners.delete(listener);
|
|
}
|
|
|
|
async checkPermissions(): Promise<boolean> {
|
|
try {
|
|
this.updateCameraState("initializing", "Checking camera permissions...");
|
|
logger.error(
|
|
`[WebInlineQRScanner:${this.id}] Checking camera permissions...`,
|
|
);
|
|
|
|
// First try the Permissions API if available
|
|
if (navigator.permissions && navigator.permissions.query) {
|
|
try {
|
|
const permissions = await navigator.permissions.query({
|
|
name: "camera" as PermissionName,
|
|
});
|
|
logger.error(
|
|
`[WebInlineQRScanner:${this.id}] Permission state from Permissions API:`,
|
|
permissions.state,
|
|
);
|
|
if (permissions.state === "granted") {
|
|
this.updateCameraState("ready", "Camera permissions granted");
|
|
return true;
|
|
}
|
|
} catch (permError) {
|
|
// Permissions API might not be supported, continue with getUserMedia check
|
|
logger.error(
|
|
`[WebInlineQRScanner:${this.id}] Permissions API not supported:`,
|
|
permError,
|
|
);
|
|
}
|
|
}
|
|
|
|
// If Permissions API is not available or didn't return granted,
|
|
// try a test getUserMedia call
|
|
try {
|
|
const testStream = await navigator.mediaDevices.getUserMedia({
|
|
video: true,
|
|
});
|
|
// If we get here, we have permission
|
|
testStream.getTracks().forEach((track) => track.stop());
|
|
this.updateCameraState("ready", "Camera permissions granted");
|
|
return true;
|
|
} catch (mediaError) {
|
|
const error = mediaError as Error;
|
|
logger.error(
|
|
`[WebInlineQRScanner:${this.id}] getUserMedia test failed:`,
|
|
{
|
|
name: error.name,
|
|
message: error.message,
|
|
},
|
|
);
|
|
|
|
if (
|
|
error.name === "NotAllowedError" ||
|
|
error.name === "PermissionDeniedError"
|
|
) {
|
|
this.updateCameraState("permission_denied", "Camera access denied");
|
|
return false;
|
|
}
|
|
// For other errors, we'll try requesting permissions explicitly
|
|
return false;
|
|
}
|
|
} catch (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,
|
|
},
|
|
);
|
|
this.updateCameraState("error", "Error checking camera permissions");
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async requestPermissions(): Promise<boolean> {
|
|
try {
|
|
this.updateCameraState(
|
|
"initializing",
|
|
"Requesting camera permissions...",
|
|
);
|
|
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 })),
|
|
userAgent: navigator.userAgent,
|
|
});
|
|
|
|
if (videoDevices.length === 0) {
|
|
logger.error(`[WebInlineQRScanner:${this.id}] No video devices found`);
|
|
this.updateCameraState("not_found", "No camera found on this device");
|
|
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 },
|
|
},
|
|
);
|
|
|
|
try {
|
|
const stream = await navigator.mediaDevices.getUserMedia({
|
|
video: {
|
|
facingMode: "environment",
|
|
width: { ideal: 1280 },
|
|
height: { ideal: 720 },
|
|
},
|
|
});
|
|
|
|
this.updateCameraState("ready", "Camera permissions granted");
|
|
|
|
// Stop the test stream immediately
|
|
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 (mediaError) {
|
|
const error = mediaError as Error;
|
|
logger.error(
|
|
`[WebInlineQRScanner:${this.id}] Error requesting camera access:`,
|
|
{
|
|
name: error.name,
|
|
message: error.message,
|
|
userAgent: navigator.userAgent,
|
|
},
|
|
);
|
|
|
|
// Update state based on error type
|
|
if (
|
|
error.name === "NotFoundError" ||
|
|
error.name === "DevicesNotFoundError"
|
|
) {
|
|
this.updateCameraState("not_found", "No camera found on this device");
|
|
throw new Error("No camera found on this device");
|
|
} else if (
|
|
error.name === "NotAllowedError" ||
|
|
error.name === "PermissionDeniedError"
|
|
) {
|
|
this.updateCameraState("permission_denied", "Camera access denied");
|
|
throw new Error(
|
|
"Camera access denied. Please grant camera permission and try again",
|
|
);
|
|
} else if (
|
|
error.name === "NotReadableError" ||
|
|
error.name === "TrackStartError"
|
|
) {
|
|
this.updateCameraState(
|
|
"in_use",
|
|
"Camera is in use by another application",
|
|
);
|
|
throw new Error("Camera is in use by another application");
|
|
} else {
|
|
this.updateCameraState("error", error.message);
|
|
throw new Error(`Camera error: ${error.message}`);
|
|
}
|
|
}
|
|
} catch (error) {
|
|
const wrappedError =
|
|
error instanceof Error ? error : new Error(String(error));
|
|
logger.error(
|
|
`[WebInlineQRScanner:${this.id}] Error in requestPermissions:`,
|
|
{
|
|
error: wrappedError.message,
|
|
stack: wrappedError.stack,
|
|
userAgent: navigator.userAgent,
|
|
},
|
|
);
|
|
throw wrappedError;
|
|
}
|
|
}
|
|
|
|
async isSupported(): Promise<boolean> {
|
|
try {
|
|
logger.error(
|
|
`[WebInlineQRScanner:${this.id}] Checking browser support...`,
|
|
);
|
|
// Check for secure context first
|
|
if (!window.isSecureContext) {
|
|
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.error(
|
|
`[WebInlineQRScanner:${this.id}] 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",
|
|
);
|
|
|
|
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.error(`[WebInlineQRScanner:${this.id}] No video devices found`);
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
} catch (error) {
|
|
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,
|
|
});
|
|
if (this.scanListener?.onError) {
|
|
this.scanListener.onError(
|
|
error instanceof Error ? error : new Error(String(error)),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
async startScan(options?: QRScannerOptions): Promise<void> {
|
|
if (this.isScanning) {
|
|
logger.error(`[WebInlineQRScanner:${this.id}] Scanner already running`);
|
|
return;
|
|
}
|
|
|
|
// Update options if provided
|
|
if (options) {
|
|
this.options = { ...this.options, ...options };
|
|
}
|
|
|
|
try {
|
|
this.isScanning = true;
|
|
this.scanAttempts = 0;
|
|
this.lastScanTime = Date.now();
|
|
this.updateCameraState("initializing", "Starting camera...");
|
|
logger.error(
|
|
`[WebInlineQRScanner:${this.id}] Starting scan with options:`,
|
|
this.options,
|
|
);
|
|
|
|
// Get camera stream with options
|
|
logger.error(
|
|
`[WebInlineQRScanner:${this.id}] Requesting camera stream...`,
|
|
);
|
|
this.stream = await navigator.mediaDevices.getUserMedia({
|
|
video: {
|
|
facingMode: this.options.camera === "front" ? "user" : "environment",
|
|
width: { ideal: 1280 },
|
|
height: { ideal: 720 },
|
|
},
|
|
});
|
|
|
|
this.updateCameraState("active", "Camera is active");
|
|
|
|
logger.error(`[WebInlineQRScanner:${this.id}] Camera stream obtained:`, {
|
|
tracks: this.stream.getTracks().map((t) => ({
|
|
kind: t.kind,
|
|
label: t.label,
|
|
readyState: t.readyState,
|
|
})),
|
|
options: this.options,
|
|
});
|
|
|
|
// Set up video element
|
|
if (this.video) {
|
|
this.video.srcObject = this.stream;
|
|
// Only show preview if showPreview is true
|
|
if (this.options.showPreview) {
|
|
this.video.style.display = "block";
|
|
} else {
|
|
this.video.style.display = "none";
|
|
}
|
|
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));
|
|
|
|
// Update state based on error type
|
|
if (
|
|
wrappedError.name === "NotReadableError" ||
|
|
wrappedError.name === "TrackStartError"
|
|
) {
|
|
this.updateCameraState(
|
|
"in_use",
|
|
"Camera is in use by another application",
|
|
);
|
|
} else {
|
|
this.updateCameraState("error", wrappedError.message);
|
|
}
|
|
|
|
if (this.scanListener?.onError) {
|
|
this.scanListener.onError(wrappedError);
|
|
}
|
|
throw wrappedError;
|
|
}
|
|
}
|
|
|
|
async stopScan(): Promise<void> {
|
|
if (!this.isScanning) {
|
|
logger.error(
|
|
`[WebInlineQRScanner:${this.id}] Scanner not running, nothing to stop`,
|
|
);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
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) => {
|
|
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) {
|
|
logger.error(
|
|
`[WebInlineQRScanner:${this.id}] Error stopping scan:`,
|
|
error,
|
|
);
|
|
this.updateCameraState("error", "Error stopping camera");
|
|
throw error;
|
|
} 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;
|
|
}
|
|
|
|
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(
|
|
`[WebInlineQRScanner:${this.id}] Error during cleanup:`,
|
|
error,
|
|
);
|
|
this.updateCameraState("error", "Error during cleanup");
|
|
throw error;
|
|
}
|
|
}
|
|
}
|
|
|