Files
crowd-funder-for-time-pwa/src/views/ContactQRScanShowView.vue
Matthew Raymer ea13250e5d refactor(QRScanner): Align with platform service architecture
- Reorganize QR scanner to follow platform service pattern
- Move QR scanner implementations to services/platforms directory
- Update QRScannerFactory to use PlatformServiceFactory pattern
- Consolidate common QR scanner interfaces with platform services
- Add proper error handling and logging across implementations
- Ensure consistent cleanup and lifecycle management
- Add PyWebView implementation placeholder for desktop
- Update Capacitor and Web implementations to match platform patterns
2025-04-17 06:59:43 +00:00

1003 lines
29 KiB
Vue

<template>
<QuickNav selected="Profile" />
<!-- CONTENT -->
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
<!-- Processing Overlay -->
<div
v-if="state.isProcessing"
class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"
>
<div class="bg-white rounded-lg p-6 text-center">
<div
class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500 mx-auto mb-4"
></div>
<p class="text-lg font-semibold">{{ state.processingStatus }}</p>
<p class="text-sm text-gray-600 mt-2">{{ state.processingDetails }}</p>
</div>
</div>
<!-- Breadcrumb -->
<div class="mb-8">
<!-- Back -->
<div class="relative px-7">
<h1
class="text-lg text-center font-light px-2 py-1 absolute -left-2 -top-1"
@click="$router.back()"
>
<font-awesome icon="chevron-left" class="fa-fw" />
</h1>
</div>
<!-- Heading -->
<h1 id="ViewHeading" class="text-4xl text-center font-light pt-4">
Your Contact Info
</h1>
<p
v-if="!givenName"
class="bg-amber-200 rounded-md overflow-hidden text-center px-4 py-3 mb-4"
>
<span class="text-red">Beware!</span>
You aren't sharing your name, so quickly
<br />
<span
class="bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-1 rounded-md"
@click="() => $refs.userNameDialog.open((name) => (givenName = name))"
>
click here to set it for them.
</span>
</p>
</div>
<UserNameDialog ref="userNameDialog" />
<div
v-if="activeDid && activeDid.startsWith(ETHR_DID_PREFIX)"
class="text-center"
@click="onCopyUrlToClipboard()"
>
<!--
Play with display options: https://qr-code-styling.com/
See docs: https://www.npmjs.com/package/qr-code-generator-vue3
-->
<QRCodeVue3
:value="qrValue"
:corners-square-options="{ type: 'extra-rounded' }"
:dots-options="{ type: 'square' }"
class="flex justify-center"
/>
<span>
Click the QR code to copy your contact info to your clipboard.
</span>
</div>
<div v-else-if="activeDid" class="text-center">
<!-- Not an ETHR DID so force them to paste it. (Passkey Peer DIDs are too big.) -->
<span class="text-blue-500" @click="onCopyDidToClipboard()">
Click here to copy your DID to your clipboard.
</span>
<span>
Then give it to them so they can paste it in their list of People.
</span>
</div>
<div v-else class="text-center">
You have no identitifiers yet, so
<router-link
:to="{ name: 'start' }"
class="bg-blue-500 text-white px-1.5 py-1 rounded-md"
>
create your identifier.
</router-link>
<br />
If you don't that first, these contacts won't see your activity.
</div>
<div class="text-center">
<h1 class="text-4xl text-center font-light pt-6">Scan Contact Info</h1>
<!-- Web QR Code Scanner -->
<qrcode-stream
v-if="useQRReader"
@detect="onScanDetect"
@error="onScanError"
/>
<!-- Mobile Camera Button -->
<div v-else class="mt-4">
<button
class="bg-blue-500 text-white px-4 py-2 rounded-md"
@click="openMobileCamera"
>
Open Camera
</button>
<p class="mt-2 text-sm text-gray-600">
If you do not see the camera, check your camera permissions.
</p>
</div>
</div>
</section>
</template>
<script lang="ts">
import { AxiosError } from "axios";
import QRCodeVue3 from "qr-code-generator-vue3";
import { Component, Vue } from "vue-facing-decorator";
import { QrcodeStream } from "vue-qrcode-reader";
import { PlatformServiceFactory } from "../services/PlatformServiceFactory";
import { PlatformService } from "../services/PlatformService";
import QuickNav from "../components/QuickNav.vue";
import UserNameDialog from "../components/UserNameDialog.vue";
import { NotificationIface } from "../constants/app";
import { db } from "../db/index";
import { Contact } from "../db/tables/contacts";
import { MASTER_SETTINGS_KEY } from "../db/tables/settings";
import { getContactJwtFromJwtUrl } from "../libs/crypto";
import { isDid, register, setVisibilityUtil } from "../libs/endorserServer";
import { decodeEndorserJwt, ETHR_DID_PREFIX } from "../libs/crypto/vc";
import { Router } from "vue-router";
import { logger } from "../utils/logger";
import { App } from "@capacitor/app";
import type { PluginListenerHandle } from "@capacitor/core";
import {
BarcodeScanner,
type ScanResult,
} from "@capacitor-mlkit/barcode-scanning";
import { reactive } from "vue";
// Declare global constants
declare const __USE_QR_READER__: boolean;
declare const __IS_MOBILE__: boolean;
// Define all possible camera states
type CameraState =
| "initializing"
| "ready"
| "error"
| "checking_permissions"
| "no_camera_capability"
| "permission_status_checked"
| "requesting_permission"
| "permission_requested"
| "permission_check_error"
| "camera_initialized"
| "camera_error"
| "opening_camera"
| "capture_already_in_progress"
| "photo_captured"
| "processing_photo"
| "no_image_data"
| "capture_error"
| "user_cancelled"
| "permission_error"
| "hardware_unavailable"
| "capture_completed"
| "cleanup"
| "processing_completed";
// Define all possible QR processing states
type QRProcessingState =
| "processing_image"
| "qr_code_detected"
| "no_qr_code_found"
| "processing_error";
interface CameraStateHistoryEntry {
state: CameraState | QRProcessingState;
timestamp: number;
details?: Record<string, unknown>;
}
interface AppStateChangeEvent {
isActive: boolean;
}
interface ScannerState {
isSupported: boolean;
granted: boolean;
denied: boolean;
isProcessing: boolean;
processingStatus: string;
processingDetails: string;
error: string;
status: string;
}
interface AppState {
isProcessing: boolean;
processingStatus: string;
processingDetails: string;
error: string;
scannerState: ScannerState;
}
@Component({
components: {
QRCodeVue3,
QrcodeStream,
QuickNav,
UserNameDialog,
},
})
export default class ContactQRScanShowView extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void;
$router!: Router;
declare $refs: {
userNameDialog: {
open: (callback: (name: string) => void) => void;
};
};
activeDid = "";
apiServer = "";
givenName = "";
hideRegisterPromptOnNewContact = false;
isRegistered = false;
qrValue = "";
ETHR_DID_PREFIX = ETHR_DID_PREFIX;
private platformService: PlatformService =
PlatformServiceFactory.getInstance();
private cameraActive = false;
private lastCameraState: CameraState | QRProcessingState = "initializing";
private cameraStateHistory: CameraStateHistoryEntry[] = [];
private readonly STATE_HISTORY_LIMIT = 20;
private isCapturingPhoto = false;
private appStateListener?: { remove: () => Promise<void> };
private scanListener: PluginListenerHandle | null = null;
private state = reactive<AppState>({
isProcessing: false,
processingStatus: "",
processingDetails: "",
error: "",
scannerState: {
isSupported: false,
granted: false,
denied: false,
isProcessing: false,
processingStatus: "",
processingDetails: "",
error: "",
status: "",
},
});
async created() {
logger.log("ContactQRScanShow component created");
try {
// Remove any existing listeners first
await this.cleanupAppListeners();
// Add app state listeners
await this.setupAppLifecycleListeners();
// Load initial data
await this.loadInitialData();
// Check if barcode scanning is supported
const { supported } = await BarcodeScanner.isSupported();
if (!supported) {
this.showError("Barcode scanning is not supported on this device");
return;
}
// Initialize scanner state but don't request permissions yet
this.state.scannerState.isSupported = supported;
logger.log("ContactQRScanShow initialization complete");
} catch (error) {
logger.error("Failed to initialize ContactQRScanShow:", error);
this.showError("Failed to initialize. Please try again.");
}
}
private async cleanupAppListeners(): Promise<void> {
try {
if (this.appStateListener) {
await this.appStateListener.remove();
this.appStateListener = undefined;
}
await App.removeAllListeners();
logger.log("App listeners cleaned up successfully");
} catch (error) {
logger.error("Error cleaning up app listeners:", error);
throw error;
}
}
private async setupAppLifecycleListeners(): Promise<void> {
try {
// Add app state change listener
this.appStateListener = await App.addListener(
"appStateChange",
(state: AppStateChangeEvent) => {
const stateInfo = {
isActive: state.isActive,
timestamp: new Date().toISOString(),
cameraActive: this.cameraActive,
scannerState: {
...this.state.scannerState,
// Convert complex objects to strings to avoid [object Object]
error: this.state.scannerState.error?.toString() || null,
},
};
logger.log("App state changed:", JSON.stringify(stateInfo, null, 2));
if (!state.isActive) {
this.cleanupCamera();
}
},
);
// Add pause listener
await App.addListener("pause", () => {
const pauseInfo = {
timestamp: new Date().toISOString(),
cameraActive: this.cameraActive,
scannerState: {
...this.state.scannerState,
error: this.state.scannerState.error?.toString() || null,
},
isProcessing: this.state.isProcessing,
};
logger.log("App paused:", JSON.stringify(pauseInfo, null, 2));
this.cleanupCamera();
});
// Add resume listener
await App.addListener("resume", () => {
const resumeInfo = {
timestamp: new Date().toISOString(),
cameraActive: this.cameraActive,
scannerState: {
...this.state.scannerState,
error: this.state.scannerState.error?.toString() || null,
},
isProcessing: this.state.isProcessing,
};
logger.log("App resumed:", JSON.stringify(resumeInfo, null, 2));
});
logger.log("App lifecycle listeners setup complete");
} catch (error) {
logger.error("Error setting up app lifecycle listeners:", error);
throw error;
}
}
private async loadInitialData() {
try {
// Load settings from DB
await db.open();
const settings = await db.settings.get(MASTER_SETTINGS_KEY);
if (settings) {
this.hideRegisterPromptOnNewContact =
settings.hideRegisterPromptOnNewContact || false;
}
logger.log("Initial data loaded successfully");
} catch (error) {
logger.error("Failed to load initial data:", error);
throw error;
}
}
private async cleanupCamera() {
try {
if (this.cameraActive) {
this.cameraActive = false;
this.isCapturingPhoto = false;
this.addCameraState("cleanup");
logger.log("Camera cleaned up successfully");
}
} catch (error) {
logger.error("Error during camera cleanup:", error);
}
}
private addCameraState(
state: CameraState | QRProcessingState,
details?: Record<string, unknown>,
) {
// Prevent duplicate state transitions
if (this.lastCameraState === state) {
return;
}
const entry: CameraStateHistoryEntry = {
state,
timestamp: Date.now(),
details: {
...details,
cameraActive: this.cameraActive,
isCapturingPhoto: this.isCapturingPhoto,
isProcessing: this.state.isProcessing,
},
};
this.cameraStateHistory.push(entry);
if (this.cameraStateHistory.length > this.STATE_HISTORY_LIMIT) {
this.cameraStateHistory.shift();
}
this.logWithDetails("Camera state transition", {
state: entry.state,
timestamp: entry.timestamp,
details: entry.details,
});
this.lastCameraState = state;
}
beforeDestroy() {
logger.log(
"ContactQRScanShow component being destroyed, initiating cleanup",
);
// Clean up scanner
this.stopScanning().catch((error) => {
logger.error("Error stopping scanner during destroy:", error);
});
// Remove all app lifecycle listeners
const cleanupListeners = async () => {
if (this.appStateListener) {
try {
await this.appStateListener.remove();
logger.log("App state change listener removed successfully");
} catch (error) {
logger.error("Error removing app state change listener:", error);
}
}
try {
await App.removeAllListeners();
logger.log("All app listeners removed successfully");
} catch (error) {
logger.error("Error removing all app listeners:", error);
}
};
// Cleanup everything
Promise.all([cleanupListeners(), this.cleanupCamera()]).catch((error) => {
logger.error("Error during component cleanup:", error);
});
}
async openMobileCamera() {
try {
this.state.isProcessing = true;
this.state.processingStatus = "Starting camera...";
logger.log("Opening mobile camera - starting initialization");
// Check current permission status
const status = await BarcodeScanner.checkPermissions();
logger.log("Camera permission status:", JSON.stringify(status, null, 2));
if (status.camera !== "granted") {
// Request permission if not granted
logger.log("Requesting camera permissions...");
const permissionStatus = await BarcodeScanner.requestPermissions();
if (permissionStatus.camera !== "granted") {
throw new Error("Camera permission not granted");
}
logger.log(
"Camera permission granted:",
JSON.stringify(permissionStatus, null, 2),
);
}
// Remove any existing listener first
try {
if (this.scanListener) {
logger.log("Removing existing barcode listener");
await this.scanListener.remove();
this.scanListener = null;
}
} catch (error) {
logger.error("Error removing existing listener:", error);
// Continue with setup even if removal fails
}
// Set up the listener before starting the scan
logger.log("Setting up new barcode listener");
this.scanListener = await BarcodeScanner.addListener(
"barcodesScanned",
async (result: ScanResult) => {
logger.log(
"Barcode scan result received:",
JSON.stringify(result, null, 2),
);
if (result.barcodes && result.barcodes.length > 0) {
this.state.processingDetails = `Processing QR code: ${result.barcodes[0].rawValue}`;
await this.handleScanResult(result.barcodes[0].rawValue);
}
},
);
logger.log("Barcode listener setup complete");
// Start the scanner
logger.log("Starting barcode scanner");
await BarcodeScanner.startScan();
logger.log("Barcode scanner started successfully");
this.state.isProcessing = false;
this.state.processingStatus = "";
} catch (error) {
logger.error("Failed to open camera:", error);
this.state.isProcessing = false;
this.state.processingStatus = "";
this.state.scannerState.status = "error";
this.showError(
error instanceof Error ? error.message : "Failed to open camera",
);
// Cleanup on error
try {
if (this.scanListener) {
await this.scanListener.remove();
this.scanListener = null;
}
} catch (cleanupError) {
logger.error("Error during cleanup:", cleanupError);
}
}
}
private async handleScanResult(rawValue: string) {
try {
this.state.isProcessing = true;
this.state.processingStatus = "Processing QR code...";
this.state.processingDetails = `Scanned value: ${rawValue}`;
// Stop scanning before processing
await this.stopScanning();
// Process the scan result
await this.onScanDetect({ rawValue });
} catch (error) {
logger.error("Error handling scan result:", error);
this.showError("Failed to process scan result");
} finally {
this.state.isProcessing = false;
this.state.processingStatus = "";
this.state.processingDetails = "";
}
}
private logWithDetails(message: string, details?: Record<string, unknown>) {
const formattedDetails = details
? "\n" +
JSON.stringify(
details,
(_key, value) => {
if (value instanceof Error) {
return {
message: value.message,
stack: value.stack,
name: value.name,
};
}
return value;
},
2,
)
: "";
logger.log(`${message}${formattedDetails}`);
}
danger(message: string, title = "Error", timeout = 5000): void {
this.$notify(
{
group: "alert",
type: "danger",
title: title,
text: message,
},
timeout,
);
}
/**
*
* @param content is the result of a QR scan, an array with one item with a rawValue property
*/
// Unfortunately, there are not typescript definitions for the qrcode-stream component yet.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
async onScanDetect(content: any): Promise<void> {
// Log the received content for debugging
logger.log("Scan result received:", JSON.stringify(content, null, 2));
// Handle both array format and direct object format
let rawValue: string | undefined;
if (Array.isArray(content)) {
rawValue = content[0]?.rawValue;
logger.log("Processing array format, rawValue:", rawValue);
} else if (content?.barcodes?.[0]?.rawValue) {
rawValue = content.barcodes[0].rawValue;
logger.log("Processing barcodes array format, rawValue:", rawValue);
} else if (content?.rawValue) {
rawValue = content.rawValue;
logger.log("Processing object format, rawValue:", rawValue);
} else if (typeof content === "string") {
rawValue = content;
logger.log("Processing string format, rawValue:", rawValue);
}
if (!rawValue) {
logger.error("No valid QR code content found in:", content);
this.danger("No QR code detected. Please try again.", "Scan Error");
return;
}
// Validate URL format first
if (!rawValue.startsWith("http://") && !rawValue.startsWith("https://")) {
logger.error("Invalid URL format:", rawValue);
this.danger(
"Invalid QR code format. Please scan a valid TimeSafari contact QR code.",
"Invalid Format",
);
return;
}
let newContact: Contact;
try {
// Extract JWT from URL
const jwt = getContactJwtFromJwtUrl(rawValue);
if (!jwt) {
logger.error("Failed to extract JWT from URL:", rawValue);
this.danger(
"Could not extract contact information from the QR code. Please try again.",
"Invalid QR Code",
);
return;
}
// Log JWT for debugging
logger.log("Extracted JWT:", jwt);
// Validate JWT format
if (
!jwt.match(/^[A-Za-z0-9-_=]+\.[A-Za-z0-9-_=]+\.?[A-Za-z0-9-_.+/=]*$/)
) {
logger.error("Invalid JWT format:", jwt);
this.danger(
"The QR code contains invalid data. Please scan a valid TimeSafari contact QR code.",
"Invalid Data",
);
return;
}
const { payload } = decodeEndorserJwt(jwt);
if (!payload) {
logger.error("Failed to decode JWT payload");
this.danger(
"Could not decode the contact information. Please try again.",
"Decode Error",
);
return;
}
// Log decoded payload for debugging
logger.log("Decoded JWT payload:", JSON.stringify(payload, null, 2));
// Validate required fields
if (!payload.own && !payload.iss) {
logger.error("Missing required fields in payload:", payload);
this.danger(
"Missing required contact information. Please scan a valid TimeSafari contact QR code.",
"Incomplete Data",
);
return;
}
newContact = {
did: payload.own?.did || payload.iss,
name: payload.own?.name,
nextPubKeyHashB64: payload.own?.nextPublicEncKeyHash,
profileImageUrl: payload.own?.profileImageUrl,
publicKeyBase64: payload.own?.publicEncKey,
registered: payload.own?.registered,
};
if (!newContact.did) {
this.danger(
"Missing contact identifier. Please scan a valid TimeSafari contact QR code.",
"Incomplete Contact",
);
return;
}
if (!isDid(newContact.did)) {
this.danger(
"Invalid contact identifier format. The identifier must begin with 'did:'.",
"Invalid Identifier",
);
return;
}
await db.open();
await db.contacts.add(newContact);
let addedMessage;
if (this.activeDid) {
await this.setVisibility(newContact, true);
newContact.seesMe = true;
addedMessage = "They were added, and your activity is visible to them.";
} else {
addedMessage = "They were added.";
}
this.$notify(
{
group: "alert",
type: "success",
title: "Contact Added",
text: addedMessage,
},
3000,
);
if (
this.isRegistered &&
!this.hideRegisterPromptOnNewContact &&
!newContact.registered
) {
setTimeout(() => {
this.$notify(
{
group: "modal",
type: "confirm",
title: "Register",
text: "Do you want to register them?",
onCancel: async (stopAsking?: boolean) => {
if (stopAsking) {
await db.settings.update(MASTER_SETTINGS_KEY, {
hideRegisterPromptOnNewContact: stopAsking,
});
this.hideRegisterPromptOnNewContact = stopAsking;
}
},
onNo: async (stopAsking?: boolean) => {
if (stopAsking) {
await db.settings.update(MASTER_SETTINGS_KEY, {
hideRegisterPromptOnNewContact: stopAsking,
});
this.hideRegisterPromptOnNewContact = stopAsking;
}
},
onYes: async () => {
await this.register(newContact);
},
promptToStopAsking: true,
},
-1,
);
}, 500);
}
} catch (e) {
logger.error("Error processing QR code:", e);
this.danger(
"Could not process the QR code. Please make sure you're scanning a valid TimeSafari contact QR code.",
"Processing Error",
);
}
}
async setVisibility(contact: Contact, visibility: boolean): Promise<void> {
const result = await setVisibilityUtil(
this.activeDid,
this.apiServer,
this.axios,
db,
contact,
visibility,
);
if (result.error) {
this.danger(result.error as string, "Error Setting Visibility");
} else if (!result.success) {
logger.error("Got strange result from setting visibility:", result);
}
}
async register(contact: Contact): Promise<void> {
this.$notify(
{
group: "alert",
type: "toast",
text: "",
title: "Registration submitted...",
},
1000,
);
try {
const regResult = await register(
this.activeDid,
this.apiServer,
this.axios,
contact,
);
if (regResult.success) {
contact.registered = true;
db.contacts.update(contact.did, { registered: true });
this.$notify(
{
group: "alert",
type: "success",
title: "Registration Success",
text:
(contact.name || "That unnamed person") + " has been registered.",
},
5000,
);
} else {
this.$notify(
{
group: "alert",
type: "danger",
title: "Registration Error",
text:
(regResult.error as string) ||
"Something went wrong during registration.",
},
5000,
);
}
} catch (error) {
logger.error("Error when registering:", error);
let userMessage = "There was an error.";
const serverError = error as AxiosError;
if (serverError) {
const responseData = serverError.response?.data as {
error?: { message?: string };
};
if (responseData?.error?.message) {
userMessage = responseData.error.message;
} else if (serverError.message) {
userMessage = serverError.message;
} else {
userMessage = JSON.stringify(serverError.toJSON());
}
} else {
userMessage = error as string;
}
this.$notify(
{
group: "alert",
type: "danger",
title: "Registration Error",
text: userMessage,
},
5000,
);
}
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
onScanError(error: any): void {
logger.error("Scan was invalid:", error);
this.$notify(
{
group: "alert",
type: "danger",
title: "Invalid Scan",
text: "The scan was invalid.",
},
5000,
);
}
async onCopyUrlToClipboard(): Promise<void> {
try {
await this.platformService.writeToClipboard(this.qrValue);
this.$notify(
{
group: "alert",
type: "toast",
title: "Copied",
text: "Contact URL was copied to clipboard.",
},
2000,
);
} catch (error) {
logger.error("Error copying to clipboard:", error);
this.danger("Failed to copy to clipboard", "Error");
}
}
async onCopyDidToClipboard(): Promise<void> {
try {
await this.platformService.writeToClipboard(this.activeDid);
this.$notify(
{
group: "alert",
type: "info",
title: "Copied",
text: "Your DID was copied to the clipboard. Have them paste it in the box on their 'People' screen to add you.",
},
5000,
);
} catch (error) {
logger.error("Error copying to clipboard:", error);
this.danger("Failed to copy to clipboard", "Error");
}
}
async copyToClipboard(text: string): Promise<void> {
try {
await this.platformService.writeToClipboard(text);
this.$notify(
{
group: "alert",
type: "success",
title: "Copied to clipboard",
text: "The DID has been copied to your clipboard.",
},
3000,
);
} catch (error) {
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "Failed to copy to clipboard.",
},
3000,
);
}
}
get useQRReader(): boolean {
return __USE_QR_READER__;
}
get isMobile(): boolean {
return __IS_MOBILE__;
}
private showError(message: string) {
this.state.error = message;
this.state.scannerState.error = message;
this.state.scannerState.status = "error";
// You might want to show this in your UI or use a toast notification
logger.error(message);
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: message,
},
5000,
);
}
async stopScanning() {
try {
// Remove the listener first
if (this.scanListener) {
await this.scanListener.remove();
this.scanListener = null;
}
// Stop the scanner
await BarcodeScanner.stopScan();
this.state.scannerState.processingStatus = "Scan stopped";
this.state.scannerState.isProcessing = false;
this.state.scannerState.processingDetails = "";
} catch (error: unknown) {
const errorMessage =
error instanceof Error ? error.message : String(error);
this.state.scannerState.error = `Error stopping scan: ${errorMessage}`;
this.state.scannerState.isProcessing = false;
logger.error("Error stopping scanner:", error);
}
}
private async handleError(error: unknown) {
const errorMessage = error instanceof Error ? error.message : String(error);
this.state.error = errorMessage;
this.state.scannerState.error = errorMessage;
}
}
</script>