diff --git a/.cursor/rules/reports.mdc b/.cursor/rules/reports.mdc new file mode 100644 index 00000000..4f3bae14 --- /dev/null +++ b/.cursor/rules/reports.mdc @@ -0,0 +1,7 @@ +--- +description: +globs: +alwaysApply: false +--- +- make reports chronologically in paragraph form without using pronouns or references to people +- use this git command to make a report: \ No newline at end of file diff --git a/.cursor/rules/ts-cross-platform-rule.mdc b/.cursor/rules/ts-cross-platform-rule.mdc new file mode 100644 index 00000000..88ca04db --- /dev/null +++ b/.cursor/rules/ts-cross-platform-rule.mdc @@ -0,0 +1,16 @@ +--- +description: +globs: +alwaysApply: true +--- +- all cross platform builds need to conform to [PlatformService.ts](mdc:src/services/PlatformService.ts), and [PlatformServiceFactory.ts](mdc:src/services/PlatformServiceFactory.ts) +- [CapacitorPlatformService.ts](mdc:src/services/platforms/CapacitorPlatformService.ts) is used for mobile both iOS and Android +- [ElectronPlatformService.ts](mdc:src/services/platforms/ElectronPlatformService.ts) is used for cross-platform (Windows, MacOS, and Linux) desktop builds using Electron. +- [WebPlatformService.ts](mdc:src/services/platforms/WebPlatformService.ts) is used for traditional web browsers and PWA (Progressive Web Applications) +- [PyWebViewPlatformService.ts](mdc:src/services/platforms/PyWebViewPlatformService.ts) is used for handling a electron-like desktop application which can run Python +- Vite is used to differentiate builds for platforms +- @vite.config.mts is used for general configuration which uses environment variables to determine next actions +- @vite.config.common.mts handles common features in vite builds. +- [vite.config.capacitor.mts](mdc:vite.config.capacitor.mts) handles features of Vite builds for capacitor +- [vite.config.electron.mts](mdc:vite.config.electron.mts) handles features of Vite builds for electron +- [vite.config.web.mts](mdc:vite.config.web.mts) handles features of Vite builds for traditional web browsers and PWAs diff --git a/android/.gradle/file-system.probe b/android/.gradle/file-system.probe index c35ad2ac..888c821c 100644 Binary files a/android/.gradle/file-system.probe and b/android/.gradle/file-system.probe differ diff --git a/package-lock.json b/package-lock.json index 38916b28..30044347 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", @@ -19845,6 +19846,12 @@ "node": "*" } }, + "node_modules/jsqr": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/jsqr/-/jsqr-1.4.0.tgz", + "integrity": "sha512-dxLob7q65Xg2DvstYkRpkYtmKm2sPJ9oFhrhmudT1dZvNFFTlroai3AWSpLey/w5vMcLBXRgOJsbXpdN9HzU/A==", + "license": "Apache-2.0" + }, "node_modules/katex": { "version": "0.16.21", "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.21.tgz", diff --git a/package.json b/package.json index 78c6b859..1da68e05 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,8 @@ "build:web": "vite build --config vite.config.web.mts", "electron:dev": "npm run build && electron dist-electron", "electron:start": "electron dist-electron", - "build:android": "rm -rf dist && npm run build:web && npm run build:capacitor && cd android && ./gradlew clean && ./gradlew assembleDebug && cd .. && npx cap sync android && npx capacitor-assets generate --android && npx cap open android", + "clean:android": "adb uninstall app.timesafari.app || true", + "build:android": "npm run clean:android && rm -rf dist && npm run build:web && npm run build:capacitor && cd android && ./gradlew clean && ./gradlew assembleDebug && cd .. && npx cap sync android && npx capacitor-assets generate --android && npx cap open android", "electron:build-linux": "npm run build:electron && electron-builder --linux AppImage", "electron:build-linux-deb": "npm run build:electron && electron-builder --linux deb", "electron:build-linux-prod": "NODE_ENV=production npm run build:electron && electron-builder --linux AppImage", @@ -95,6 +96,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", diff --git a/src/types/jsqr.d.ts b/src/types/jsqr.d.ts new file mode 100644 index 00000000..3a29179d --- /dev/null +++ b/src/types/jsqr.d.ts @@ -0,0 +1,34 @@ +declare module 'jsqr' { + interface Point { + x: number; + y: number; + } + + interface QRLocation { + topLeft: Point; + topRight: Point; + bottomLeft: Point; + bottomRight: Point; + topLeftFinder: Point; + topRightFinder: Point; + bottomLeftFinder: Point; + bottomRightAlignment?: Point; + } + + interface QRCode { + binaryData: number[]; + data: string; + location: QRLocation; + } + + function jsQR( + imageData: Uint8ClampedArray, + width: number, + height: number, + options?: { + inversionAttempts?: 'dontInvert' | 'onlyInvert' | 'attemptBoth'; + } + ): QRCode | null; + + export default jsQR; +} \ No newline at end of file diff --git a/src/views/ContactQRScanShowView.vue b/src/views/ContactQRScanShowView.vue index 5d5563cf..37d47ad9 100644 --- a/src/views/ContactQRScanShowView.vue +++ b/src/views/ContactQRScanShowView.vue @@ -109,26 +109,99 @@ import { PlatformService } from "../services/PlatformService"; import QuickNav from "../components/QuickNav.vue"; import UserNameDialog from "../components/UserNameDialog.vue"; import { NotificationIface } from "../constants/app"; -import { db, retrieveSettingsForActiveAccount } from "../db/index"; +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 { - generateEndorserJwtUrlForAccount, isDid, register, setVisibilityUtil, } from "../libs/endorserServer"; import { decodeEndorserJwt, ETHR_DID_PREFIX } from "../libs/crypto/vc"; -import { retrieveAccountMetadata } from "../libs/util"; import { Router } from "vue-router"; import { logger } from "../utils/logger"; -import { Camera, CameraResultType, CameraSource } from "@capacitor/camera"; +import { Camera, CameraResultType, CameraSource, ImageOptions, CameraPermissionState } from '@capacitor/camera'; +import { App } from '@capacitor/app'; +import jsQR from "jsqr"; // 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; +} + + +// Custom AppState type to match our needs +interface CustomAppState { + state: 'active' | 'inactive' | 'background' | 'foreground'; +} + +interface AppStateChangeEvent { + isActive: boolean; +} + +// Define worker message types +interface QRCodeResult { + data: string; + location: { + topLeftCorner: { x: number; y: number }; + topRightCorner: { x: number; y: number }; + bottomRightCorner: { x: number; y: number }; + bottomLeftCorner: { x: number; y: number }; + }; +} + +interface WorkerSuccessMessage { + success: true; + code: QRCodeResult; +} + +interface WorkerErrorMessage { + success: false; + error: string; +} + +type WorkerMessage = WorkerSuccessMessage | WorkerErrorMessage; + @Component({ components: { QrcodeStream: __USE_QR_READER__ ? QrcodeStream : null, @@ -158,158 +231,339 @@ export default class ContactQRScanShow extends Vue { 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 }; + async created() { - const settings = await retrieveSettingsForActiveAccount(); - this.activeDid = settings.activeDid || ""; - this.apiServer = settings.apiServer || ""; - this.givenName = settings.firstName || ""; - this.hideRegisterPromptOnNewContact = - !!settings.hideRegisterPromptOnNewContact; - this.isRegistered = !!settings.isRegistered; - - const account = await retrieveAccountMetadata(this.activeDid); - if (account) { - const name = - (settings.firstName || "") + - (settings.lastName ? ` ${settings.lastName}` : ""); - - this.qrValue = await generateEndorserJwtUrlForAccount( - account, - !!settings.isRegistered, - name, - settings.profileImageUrl || "", - false, - ); + logger.log('ContactQRScanShow component created'); + try { + // Remove any existing listeners first + await App.removeAllListeners(); + + // Add app state listeners + const stateListener = await App.addListener('appStateChange', (state: AppStateChangeEvent) => { + logger.log('App state changed:', state); + if (!state.isActive && this.cameraActive) { + this.cleanupCamera(); + } + }); + this.appStateListener = stateListener; + + await App.addListener('pause', () => { + logger.log('App paused'); + if (this.cameraActive) { + this.cleanupCamera(); + } + }); + + await App.addListener('resume', () => { + logger.log('App resumed'); + if (this.cameraActive) { + this.initializeCamera(); + } + }); + + // Load initial data + await this.loadInitialData(); + + logger.log('ContactQRScanShow initialization complete'); + } catch (error) { + logger.error('Failed to initialize ContactQRScanShow:', error); + this.showError('Failed to initialize. Please try again.'); } + } - // Initialize camera with retry logic - if (this.useQRReader) { - await this.initializeCamera(); + 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 cleanupCamera() { + try { + 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) { + const entry: CameraStateHistoryEntry = { + state, + timestamp: Date.now(), + details + }; + + this.cameraStateHistory.push(entry); + + if (this.cameraStateHistory.length > this.STATE_HISTORY_LIMIT) { + this.cameraStateHistory.shift(); + } + + // Enhanced logging with better details + logger.log('Camera state transition:', { + state, + details: { + ...details, + cameraActive: this.cameraActive, + isCapturingPhoto: this.isCapturingPhoto, + historyLength: this.cameraStateHistory.length + } + }); + + this.lastCameraState = state; + } + + beforeDestroy() { + logger.log('ContactQRScanShow component being destroyed, initiating cleanup', { + cameraActive: this.cameraActive, + lastState: this.lastCameraState, + stateHistory: this.cameraStateHistory + }); + + // 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); + }); + } + + private async handleQRCodeResult(data: string): Promise { + try { + await this.onScanDetect([{ rawValue: data }]); + } catch (error) { + console.error('Failed to handle QR code result:', error); + this.showError('Failed to process QR code data.'); } } async initializeCamera(retryCount = 0): Promise { + if (this.cameraActive) { + console.log('Camera already active, skipping initialization'); + return; + } + try { - const capabilities = this.platformService.getCapabilities(); - if (!capabilities.hasCamera) { - this.danger("No camera available on this device.", "Camera Error"); - return; + console.log('Initializing camera...', { retryCount }); + + // Check camera permissions first + const permissionStatus = await this.checkCameraPermission(); + if (permissionStatus.camera !== 'granted') { + throw new Error('Camera permission not granted'); } - // Check camera permissions - const hasPermission = await this.checkCameraPermission(); - if (!hasPermission) { - this.danger( - "Camera permission is required to scan QR codes. Please enable camera access in your device settings.", - "Permission Required" - ); - return; + this.cameraActive = true; + + // Configure camera options + const cameraOptions: ImageOptions = { + quality: 90, + allowEditing: false, + resultType: CameraResultType.DataUrl, + source: CameraSource.Camera + }; + + console.log('Opening camera with options:', cameraOptions); + const image = await Camera.getPhoto(cameraOptions); + + if (!image || !image.dataUrl) { + throw new Error('Failed to capture photo: No image data received'); } - // If we get here, camera should be available - this.$notify( - { - group: "alert", - type: "success", - title: "Camera Ready", - text: "Camera is ready to scan QR codes.", - }, - 3000 - ); + console.log('Photo captured successfully, processing image...'); + await this.processImageForQRCode(image.dataUrl); + } catch (error) { - logger.error("Error initializing camera:", error); + this.cameraActive = false; + console.error('Camera initialization failed:', error instanceof Error ? error.message : String(error)); - // Retry up to 3 times for certain errors - if (retryCount < 3) { - const isPermissionError = error instanceof Error && - (error.message.includes("permission") || - error.message.includes("NotReadableError")); - - if (isPermissionError) { - // Wait before retrying - await new Promise(resolve => setTimeout(resolve, 1000)); - return this.initializeCamera(retryCount + 1); - } + // Handle user cancellation separately + if (error instanceof Error && error.message.includes('User cancelled photos app')) { + console.log('User cancelled photo capture'); + return; } - this.danger( - "Failed to initialize camera. Please check your camera permissions and try again.", - "Camera Error" - ); + // Handle retry logic + if (retryCount < 2) { + console.log('Retrying camera initialization...'); + await new Promise(resolve => setTimeout(resolve, 1000)); + return this.initializeCamera(retryCount + 1); + } + + this.showError('Failed to initialize camera. Please try again.'); } } - async checkCameraPermission(): Promise { + async checkCameraPermission(): Promise<{ camera: CameraPermissionState }> { try { + this.addCameraState('checking_permissions'); const capabilities = this.platformService.getCapabilities(); if (!capabilities.hasCamera) { - return false; + this.addCameraState('no_camera_capability'); + return { camera: 'denied' as CameraPermissionState }; } - // Try to access camera to check permissions - await this.platformService.takePicture(); - return true; + const permissionStatus = await Camera.checkPermissions(); + this.addCameraState('permission_status_checked'); + + if (permissionStatus.camera === 'prompt') { + this.addCameraState('requesting_permission'); + const requestResult = await Camera.requestPermissions(); + this.addCameraState('permission_requested'); + return requestResult; + } + + return permissionStatus; } catch (error) { - logger.error("Camera permission check failed:", error); - return false; + this.addCameraState('permission_check_error'); + return { camera: 'denied' as CameraPermissionState }; } } - async openMobileCamera(): Promise { + async processImageForQRCode(imageDataUrl: string): Promise { try { - // Check permissions first - const hasPermission = await this.checkCameraPermission(); - if (!hasPermission) { - this.danger( - "Camera permission is required. Please enable camera access in your device settings.", - "Permission Required" - ); - return; - } + logger.log('Starting QR code processing'); + + // Create worker for image processing + const worker = new Worker(URL.createObjectURL(new Blob([` + self.onmessage = async function(e) { + const { imageData, width, height } = e.data; + try { + // Import jsQR in the worker + importScripts('${window.location.origin}/assets/jsqr.js'); + const code = self.jsQR(imageData, width, height, { + inversionAttempts: "dontInvert" + }); + self.postMessage({ success: true, code }); + } catch (error) { + self.postMessage({ success: false, error: error.message }); + } + }; + `], { type: 'text/javascript' }))); - const image = await Camera.getPhoto({ - quality: 90, - allowEditing: false, - resultType: CameraResultType.DataUrl, - source: CameraSource.Camera, + const image = new Image(); + image.crossOrigin = 'anonymous'; + image.src = imageDataUrl; + + await new Promise((resolve, reject) => { + const timeout = setTimeout(() => reject(new Error('Image load timeout')), 5000); + image.onload = () => { + clearTimeout(timeout); + resolve(undefined); + }; + image.onerror = () => { + clearTimeout(timeout); + reject(new Error('Failed to load image')); + }; }); - if (image.dataUrl) { - await this.processImageForQRCode(image.dataUrl); + logger.log('Image loaded, creating canvas...'); + const canvas = document.createElement('canvas'); + const maxDimension = 1024; // Limit image size for better performance + + // Scale down image if needed while maintaining aspect ratio + let width = image.naturalWidth || 800; + let height = image.naturalHeight || 600; + if (width > maxDimension || height > maxDimension) { + if (width > height) { + height = Math.floor(height * (maxDimension / width)); + width = maxDimension; + } else { + width = Math.floor(width * (maxDimension / height)); + height = maxDimension; + } + } + + canvas.width = width; + canvas.height = height; + + const ctx = canvas.getContext('2d'); + if (!ctx) { + throw new Error('Failed to get canvas context'); } - } catch (error) { - logger.error("Error taking picture:", error); - this.danger( - "Failed to access camera. Please check your camera permissions.", - "Camera Error" - ); - } - } - async processImageForQRCode(_imageDataUrl: string) { - try { - // Here you would implement QR code scanning from the image - // For example, using jsQR: - // const image = new Image(); - // image.src = imageDataUrl; - // image.onload = () => { - // const canvas = document.createElement('canvas'); - // const context = canvas.getContext('2d'); - // canvas.width = image.width; - // canvas.height = image.height; - // context.drawImage(image, 0, 0); - // const imageData = context.getImageData(0, 0, canvas.width, canvas.height); - // const code = jsQR(imageData.data, imageData.width, imageData.height); - // if (code) { - // this.onScanDetect([{ rawValue: code.data }]); - // } - // }; + // Draw image maintaining orientation + ctx.save(); + ctx.drawImage(image, 0, 0, width, height); + ctx.restore(); + + const imageData = ctx.getImageData(0, 0, width, height); + + logger.log('Processing image data for QR code...', { + width, + height, + dataLength: imageData.data.length + }); + + // Process QR code in worker + const result = await new Promise((resolve, reject) => { + worker.onmessage = (e: MessageEvent) => { + if (e.data.success) { + resolve(e.data.code); + } else { + reject(new Error(e.data.error)); + } + }; + worker.onerror = reject; + worker.postMessage({ + imageData: imageData.data, + width: imageData.width, + height: imageData.height + }); + }); + + worker.terminate(); + + if (result) { + logger.log('QR code found:', { data: result.data }); + await this.handleQRCodeResult(result.data); + } else { + logger.log('No QR code found in image'); + this.showError('No QR code found. Please try again.'); + } } catch (error) { - logger.error("Error processing image for QR code:", error); - this.danger( - "Failed to process the image. Please try again.", - "Processing Error", - ); + logger.error('QR code processing failed:', error); + this.showError('Failed to process QR code. Please try again.'); + } finally { + this.cameraActive = false; + this.isCapturingPhoto = false; + this.addCameraState('processing_completed'); } } @@ -347,11 +601,11 @@ export default class ContactQRScanShow extends Vue { return; } - let newContact: Contact; - try { + let newContact: Contact; + try { // Extract JWT from URL - const jwt = getContactJwtFromJwtUrl(url); - if (!jwt) { + const jwt = getContactJwtFromJwtUrl(url); + if (!jwt) { this.danger( "Could not extract contact information from the QR code. Please try again.", "Invalid QR Code", @@ -366,11 +620,11 @@ export default class ContactQRScanShow extends Vue { this.danger( "The QR code contains invalid data. Please scan a valid TimeSafari contact QR code.", "Invalid Data", - ); - return; - } + ); + return; + } - const { payload } = decodeEndorserJwt(jwt); + const { payload } = decodeEndorserJwt(jwt); if (!payload) { this.danger( "Could not decode the contact information. Please try again.", @@ -388,7 +642,7 @@ export default class ContactQRScanShow extends Vue { return; } - newContact = { + newContact = { did: payload.own?.did || payload.iss, name: payload.own?.name, nextPubKeyHashB64: payload.own?.nextPublicEncKeyHash, @@ -397,15 +651,15 @@ export default class ContactQRScanShow extends Vue { registered: payload.own?.registered, }; - if (!newContact.did) { + if (!newContact.did) { this.danger( "Missing contact identifier. Please scan a valid TimeSafari contact QR code.", "Incomplete Contact", ); - return; - } + return; + } - if (!isDid(newContact.did)) { + if (!isDid(newContact.did)) { this.danger( "Invalid contact identifier format. The identifier must begin with 'did:'.", "Invalid Identifier", @@ -413,66 +667,66 @@ export default class ContactQRScanShow extends Vue { return; } - await db.open(); - await db.contacts.add(newContact); + await db.open(); + await db.contacts.add(newContact); - let addedMessage; - if (this.activeDid) { - await this.setVisibility(newContact, true); + 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."; - } + } else { + addedMessage = "They were added."; + } - this.$notify( - { - group: "alert", - type: "success", - title: "Contact Added", - text: addedMessage, - }, - 3000, - ); + 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?", + 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; - } - }, + 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) { + 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.", @@ -589,15 +843,15 @@ export default class ContactQRScanShow extends Vue { async onCopyUrlToClipboard(): Promise { try { await this.platformService.writeToClipboard(this.qrValue); - this.$notify( - { - group: "alert", - type: "toast", - title: "Copied", - text: "Contact URL was copied to clipboard.", - }, - 2000, - ); + 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"); @@ -607,15 +861,15 @@ export default class ContactQRScanShow extends Vue { async onCopyDidToClipboard(): Promise { 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, - ); + 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"); @@ -712,5 +966,96 @@ export default class ContactQRScanShow extends Vue { get isMobile(): boolean { return __IS_MOBILE__; } + + private showError(message: string) { + // Implement the logic to show a user-friendly error message to the user + console.error(message); + this.$notify( + { + group: "alert", + type: "danger", + title: "Error", + text: message, + }, + 5000, + ); + } + + async openMobileCamera() { + if (this.isCapturingPhoto) { + this.addCameraState('capture_already_in_progress'); + logger.warn('Camera capture already in progress, ignoring request'); + return; + } + + try { + this.isCapturingPhoto = true; + this.addCameraState('opening_camera'); + + const config = { + quality: 90, + allowEditing: false, + resultType: CameraResultType.DataUrl, + source: CameraSource.Camera + }; + + logger.log('Camera configuration:', config); + + const image = await Camera.getPhoto(config); + + this.addCameraState('photo_captured', { + hasDataUrl: !!image.dataUrl, + dataUrlLength: image.dataUrl?.length, + format: image.format, + saved: image.saved, + webPath: image.webPath, + base64String: image.dataUrl?.substring(0, 50) + '...' // Log first 50 chars of base64 + }); + + if (image.dataUrl) { + this.addCameraState('processing_photo'); + await this.processImageForQRCode(image.dataUrl); + } else { + this.addCameraState('no_image_data'); + logger.error('Camera returned no image data'); + this.$notify({ + type: 'error', + title: 'Camera Error', + text: 'No image was captured. Please try again.', + group: 'qr-scanner' + }); + } + } catch (error) { + this.addCameraState('capture_error', { + error, + errorName: error instanceof Error ? error.name : 'Unknown', + errorMessage: error instanceof Error ? error.message : String(error), + errorStack: error instanceof Error ? error.stack : undefined + }); + + if (error instanceof Error) { + if (error.message.includes('User cancelled photos app')) { + logger.log('User cancelled photo capture'); + this.addCameraState('user_cancelled'); + } else if (error.message.includes('permission')) { + logger.error('Camera permission error during capture'); + this.addCameraState('permission_error'); + } else if (error.message.includes('Camera is not available')) { + logger.error('Camera hardware not available'); + this.addCameraState('hardware_unavailable'); + } + } + + this.$notify({ + type: 'error', + title: 'Camera Error', + text: 'Failed to capture photo. Please check camera permissions and try again.', + group: 'qr-scanner' + }); + } finally { + this.isCapturingPhoto = false; + this.addCameraState('capture_completed'); + } + } } diff --git a/tsconfig.node.json b/tsconfig.node.json index 6aa7b5c6..f2bdbb1d 100644 --- a/tsconfig.node.json +++ b/tsconfig.node.json @@ -4,7 +4,9 @@ "skipLibCheck": true, "module": "ESNext", "moduleResolution": "bundler", - "allowSyntheticDefaultImports": true + "allowSyntheticDefaultImports": true, + "allowImportingTsExtensions": true, + "noEmit": true }, "include": ["vite.config.*"] } \ No newline at end of file diff --git a/vite.config.capacitor.mts b/vite.config.capacitor.mts index b47e5abe..24bec807 100644 --- a/vite.config.capacitor.mts +++ b/vite.config.capacitor.mts @@ -1,4 +1,13 @@ -import { defineConfig } from "vite"; +import { defineConfig, mergeConfig } from "vite"; import { createBuildConfig } from "./vite.config.common.mts"; -export default defineConfig(async () => createBuildConfig('capacitor')); \ No newline at end of file +export default defineConfig(async () => { + const baseConfig = await createBuildConfig('capacitor'); + + return mergeConfig(baseConfig, { + define: { + __USE_QR_READER__: false, // Disable web QR reader on mobile + __IS_MOBILE__: true, // Enable mobile-specific features + } + }); +}); \ No newline at end of file diff --git a/vite.config.common.mts b/vite.config.common.mts index 1e288f5f..dfac42e9 100644 --- a/vite.config.common.mts +++ b/vite.config.common.mts @@ -36,7 +36,7 @@ export async function createBuildConfig(mode: string) { assetsDir: 'assets', chunkSizeWarningLimit: 1000, rollupOptions: { - external: isCapacitor ? ['@capacitor/app'] : [] + external: [] } }, define: { @@ -44,10 +44,13 @@ export async function createBuildConfig(mode: string) { 'process.env.VITE_PLATFORM': JSON.stringify(mode), 'process.env.VITE_PWA_ENABLED': JSON.stringify(!(isElectron || isPyWebView || isCapacitor)), __dirname: isElectron ? JSON.stringify(process.cwd()) : '""', + __USE_QR_READER__: JSON.stringify(!isCapacitor), + __IS_MOBILE__: JSON.stringify(isCapacitor), }, resolve: { alias: { '@': path.resolve(__dirname, './src'), + '@capacitor/app': path.resolve(__dirname, 'node_modules/@capacitor/app'), ...appConfig.aliasConfig, 'nostr-tools/nip06': mode === 'development' ? 'nostr-tools/nip06'