Browse Source

refactor(qr-scanner): improve QR code scanning with Web Workers and enhanced error handling

- Move QR code processing to Web Worker for better performance
- Add comprehensive camera state tracking and logging
- Implement proper app lifecycle management
- Add image size limits and optimization (max 1024px)
- Add proper cleanup of resources (worker, timeouts, listeners)
- Improve error handling with detailed error messages
- Add TypeScript interfaces for worker messages and QR code results
- Update Vite config to properly handle Capacitor builds
qrcode-capacitor
Matthew Raymer 3 months ago
parent
commit
f5846cbe78
  1. 7
      .cursor/rules/reports.mdc
  2. 16
      .cursor/rules/ts-cross-platform-rule.mdc
  3. BIN
      android/.gradle/file-system.probe
  4. 7
      package-lock.json
  5. 4
      package.json
  6. 34
      src/types/jsqr.d.ts
  7. 747
      src/views/ContactQRScanShowView.vue
  8. 4
      tsconfig.node.json
  9. 13
      vite.config.capacitor.mts
  10. 5
      vite.config.common.mts

7
.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:

16
.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

BIN
android/.gradle/file-system.probe

Binary file not shown.

7
package-lock.json

@ -57,6 +57,7 @@
"jdenticon": "^3.2.0", "jdenticon": "^3.2.0",
"js-generate-password": "^0.1.9", "js-generate-password": "^0.1.9",
"js-yaml": "^4.1.0", "js-yaml": "^4.1.0",
"jsqr": "^1.4.0",
"leaflet": "^1.9.4", "leaflet": "^1.9.4",
"localstorage-slim": "^2.7.0", "localstorage-slim": "^2.7.0",
"lru-cache": "^10.2.0", "lru-cache": "^10.2.0",
@ -19845,6 +19846,12 @@
"node": "*" "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": { "node_modules/katex": {
"version": "0.16.21", "version": "0.16.21",
"resolved": "https://registry.npmjs.org/katex/-/katex-0.16.21.tgz", "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.21.tgz",

4
package.json

@ -30,7 +30,8 @@
"build:web": "vite build --config vite.config.web.mts", "build:web": "vite build --config vite.config.web.mts",
"electron:dev": "npm run build && electron dist-electron", "electron:dev": "npm run build && electron dist-electron",
"electron:start": "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": "npm run build:electron && electron-builder --linux AppImage",
"electron:build-linux-deb": "npm run build:electron && electron-builder --linux deb", "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", "electron:build-linux-prod": "NODE_ENV=production npm run build:electron && electron-builder --linux AppImage",
@ -95,6 +96,7 @@
"jdenticon": "^3.2.0", "jdenticon": "^3.2.0",
"js-generate-password": "^0.1.9", "js-generate-password": "^0.1.9",
"js-yaml": "^4.1.0", "js-yaml": "^4.1.0",
"jsqr": "^1.4.0",
"leaflet": "^1.9.4", "leaflet": "^1.9.4",
"localstorage-slim": "^2.7.0", "localstorage-slim": "^2.7.0",
"lru-cache": "^10.2.0", "lru-cache": "^10.2.0",

34
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;
}

747
src/views/ContactQRScanShowView.vue

@ -109,26 +109,99 @@ import { PlatformService } from "../services/PlatformService";
import QuickNav from "../components/QuickNav.vue"; import QuickNav from "../components/QuickNav.vue";
import UserNameDialog from "../components/UserNameDialog.vue"; import UserNameDialog from "../components/UserNameDialog.vue";
import { NotificationIface } from "../constants/app"; import { NotificationIface } from "../constants/app";
import { db, retrieveSettingsForActiveAccount } from "../db/index"; import { db } from "../db/index";
import { Contact } from "../db/tables/contacts"; import { Contact } from "../db/tables/contacts";
import { MASTER_SETTINGS_KEY } from "../db/tables/settings"; import { MASTER_SETTINGS_KEY } from "../db/tables/settings";
import { getContactJwtFromJwtUrl } from "../libs/crypto"; import { getContactJwtFromJwtUrl } from "../libs/crypto";
import { import {
generateEndorserJwtUrlForAccount,
isDid, isDid,
register, register,
setVisibilityUtil, setVisibilityUtil,
} from "../libs/endorserServer"; } from "../libs/endorserServer";
import { decodeEndorserJwt, ETHR_DID_PREFIX } from "../libs/crypto/vc"; import { decodeEndorserJwt, ETHR_DID_PREFIX } from "../libs/crypto/vc";
import { retrieveAccountMetadata } from "../libs/util";
import { Router } from "vue-router"; import { Router } from "vue-router";
import { logger } from "../utils/logger"; 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 global constants
declare const __USE_QR_READER__: boolean; declare const __USE_QR_READER__: boolean;
declare const __IS_MOBILE__: 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>;
}
// 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({ @Component({
components: { components: {
QrcodeStream: __USE_QR_READER__ ? QrcodeStream : null, QrcodeStream: __USE_QR_READER__ ? QrcodeStream : null,
@ -158,158 +231,339 @@ export default class ContactQRScanShow extends Vue {
private platformService: PlatformService = private platformService: PlatformService =
PlatformServiceFactory.getInstance(); 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> };
async created() { async created() {
const settings = await retrieveSettingsForActiveAccount(); logger.log('ContactQRScanShow component created');
this.activeDid = settings.activeDid || ""; try {
this.apiServer = settings.apiServer || ""; // Remove any existing listeners first
this.givenName = settings.firstName || ""; await App.removeAllListeners();
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,
);
}
// Initialize camera with retry logic // Add app state listeners
if (this.useQRReader) { const stateListener = await App.addListener('appStateChange', (state: AppStateChangeEvent) => {
await this.initializeCamera(); 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.');
} }
} }
async initializeCamera(retryCount = 0): Promise<void> { private async loadInitialData() {
try { try {
const capabilities = this.platformService.getCapabilities(); // Load settings from DB
if (!capabilities.hasCamera) { await db.open();
this.danger("No camera available on this device.", "Camera Error"); const settings = await db.settings.get(MASTER_SETTINGS_KEY);
return; 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;
}
}
// Check camera permissions private cleanupCamera() {
const hasPermission = await this.checkCameraPermission(); try {
if (!hasPermission) { this.cameraActive = false;
this.danger( this.isCapturingPhoto = false;
"Camera permission is required to scan QR codes. Please enable camera access in your device settings.", this.addCameraState('cleanup');
"Permission Required" logger.log('Camera cleaned up successfully');
); } catch (error) {
return; logger.error('Error during camera cleanup:', error);
}
}
private addCameraState(state: CameraState | QRProcessingState, details?: Record<string, unknown>) {
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
} }
});
// If we get here, camera should be available this.lastCameraState = state;
this.$notify( }
{
group: "alert", beforeDestroy() {
type: "success", logger.log('ContactQRScanShow component being destroyed, initiating cleanup', {
title: "Camera Ready", cameraActive: this.cameraActive,
text: "Camera is ready to scan QR codes.", lastState: this.lastCameraState,
}, stateHistory: this.cameraStateHistory
3000 });
);
} catch (error) { // Remove all app lifecycle listeners
logger.error("Error initializing camera:", error); const cleanupListeners = async () => {
if (this.appStateListener) {
// Retry up to 3 times for certain errors try {
if (retryCount < 3) { await this.appStateListener.remove();
const isPermissionError = error instanceof Error && logger.log('App state change listener removed successfully');
(error.message.includes("permission") || } catch (error) {
error.message.includes("NotReadableError")); logger.error('Error removing app state change listener:', error);
if (isPermissionError) {
// Wait before retrying
await new Promise(resolve => setTimeout(resolve, 1000));
return this.initializeCamera(retryCount + 1);
} }
} }
this.danger( try {
"Failed to initialize camera. Please check your camera permissions and try again.", await App.removeAllListeners();
"Camera Error" 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 checkCameraPermission(): Promise<boolean> { private async handleQRCodeResult(data: string): Promise<void> {
try { try {
const capabilities = this.platformService.getCapabilities(); await this.onScanDetect([{ rawValue: data }]);
if (!capabilities.hasCamera) {
return false;
}
// Try to access camera to check permissions
await this.platformService.takePicture();
return true;
} catch (error) { } catch (error) {
logger.error("Camera permission check failed:", error); console.error('Failed to handle QR code result:', error);
return false; this.showError('Failed to process QR code data.');
} }
} }
async openMobileCamera(): Promise<void> { async initializeCamera(retryCount = 0): Promise<void> {
if (this.cameraActive) {
console.log('Camera already active, skipping initialization');
return;
}
try { try {
// Check permissions first console.log('Initializing camera...', { retryCount });
const hasPermission = await this.checkCameraPermission();
if (!hasPermission) { // Check camera permissions first
this.danger( const permissionStatus = await this.checkCameraPermission();
"Camera permission is required. Please enable camera access in your device settings.", if (permissionStatus.camera !== 'granted') {
"Permission Required" throw new Error('Camera permission not granted');
);
return;
} }
const image = await Camera.getPhoto({ this.cameraActive = true;
// Configure camera options
const cameraOptions: ImageOptions = {
quality: 90, quality: 90,
allowEditing: false, allowEditing: false,
resultType: CameraResultType.DataUrl, resultType: CameraResultType.DataUrl,
source: CameraSource.Camera, source: CameraSource.Camera
}); };
if (image.dataUrl) { console.log('Opening camera with options:', cameraOptions);
await this.processImageForQRCode(image.dataUrl); const image = await Camera.getPhoto(cameraOptions);
if (!image || !image.dataUrl) {
throw new Error('Failed to capture photo: No image data received');
} }
console.log('Photo captured successfully, processing image...');
await this.processImageForQRCode(image.dataUrl);
} catch (error) { } catch (error) {
logger.error("Error taking picture:", error); this.cameraActive = false;
this.danger( console.error('Camera initialization failed:', error instanceof Error ? error.message : String(error));
"Failed to access camera. Please check your camera permissions.",
"Camera Error" // Handle user cancellation separately
); if (error instanceof Error && error.message.includes('User cancelled photos app')) {
console.log('User cancelled photo capture');
return;
}
// 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 processImageForQRCode(_imageDataUrl: string) { async checkCameraPermission(): Promise<{ camera: CameraPermissionState }> {
try { try {
// Here you would implement QR code scanning from the image this.addCameraState('checking_permissions');
// For example, using jsQR: const capabilities = this.platformService.getCapabilities();
// const image = new Image(); if (!capabilities.hasCamera) {
// image.src = imageDataUrl; this.addCameraState('no_camera_capability');
// image.onload = () => { return { camera: 'denied' as CameraPermissionState };
// const canvas = document.createElement('canvas'); }
// const context = canvas.getContext('2d');
// canvas.width = image.width; const permissionStatus = await Camera.checkPermissions();
// canvas.height = image.height; this.addCameraState('permission_status_checked');
// context.drawImage(image, 0, 0);
// const imageData = context.getImageData(0, 0, canvas.width, canvas.height); if (permissionStatus.camera === 'prompt') {
// const code = jsQR(imageData.data, imageData.width, imageData.height); this.addCameraState('requesting_permission');
// if (code) { const requestResult = await Camera.requestPermissions();
// this.onScanDetect([{ rawValue: code.data }]); this.addCameraState('permission_requested');
// } return requestResult;
// }; }
return permissionStatus;
} catch (error) { } catch (error) {
logger.error("Error processing image for QR code:", error); this.addCameraState('permission_check_error');
this.danger( return { camera: 'denied' as CameraPermissionState };
"Failed to process the image. Please try again.", }
"Processing Error", }
);
async processImageForQRCode(imageDataUrl: string): Promise<void> {
try {
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 = 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'));
};
});
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');
}
// 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<QRCodeResult>((resolve, reject) => {
worker.onmessage = (e: MessageEvent<WorkerMessage>) => {
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('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; return;
} }
let newContact: Contact; let newContact: Contact;
try { try {
// Extract JWT from URL // Extract JWT from URL
const jwt = getContactJwtFromJwtUrl(url); const jwt = getContactJwtFromJwtUrl(url);
if (!jwt) { if (!jwt) {
this.danger( this.danger(
"Could not extract contact information from the QR code. Please try again.", "Could not extract contact information from the QR code. Please try again.",
"Invalid QR Code", "Invalid QR Code",
@ -366,11 +620,11 @@ export default class ContactQRScanShow extends Vue {
this.danger( this.danger(
"The QR code contains invalid data. Please scan a valid TimeSafari contact QR code.", "The QR code contains invalid data. Please scan a valid TimeSafari contact QR code.",
"Invalid Data", "Invalid Data",
); );
return; return;
} }
const { payload } = decodeEndorserJwt(jwt); const { payload } = decodeEndorserJwt(jwt);
if (!payload) { if (!payload) {
this.danger( this.danger(
"Could not decode the contact information. Please try again.", "Could not decode the contact information. Please try again.",
@ -388,7 +642,7 @@ export default class ContactQRScanShow extends Vue {
return; return;
} }
newContact = { newContact = {
did: payload.own?.did || payload.iss, did: payload.own?.did || payload.iss,
name: payload.own?.name, name: payload.own?.name,
nextPubKeyHashB64: payload.own?.nextPublicEncKeyHash, nextPubKeyHashB64: payload.own?.nextPublicEncKeyHash,
@ -397,15 +651,15 @@ export default class ContactQRScanShow extends Vue {
registered: payload.own?.registered, registered: payload.own?.registered,
}; };
if (!newContact.did) { if (!newContact.did) {
this.danger( this.danger(
"Missing contact identifier. Please scan a valid TimeSafari contact QR code.", "Missing contact identifier. Please scan a valid TimeSafari contact QR code.",
"Incomplete Contact", "Incomplete Contact",
); );
return; return;
} }
if (!isDid(newContact.did)) { if (!isDid(newContact.did)) {
this.danger( this.danger(
"Invalid contact identifier format. The identifier must begin with 'did:'.", "Invalid contact identifier format. The identifier must begin with 'did:'.",
"Invalid Identifier", "Invalid Identifier",
@ -413,66 +667,66 @@ export default class ContactQRScanShow extends Vue {
return; return;
} }
await db.open(); await db.open();
await db.contacts.add(newContact); await db.contacts.add(newContact);
let addedMessage; let addedMessage;
if (this.activeDid) { if (this.activeDid) {
await this.setVisibility(newContact, true); await this.setVisibility(newContact, true);
newContact.seesMe = true; newContact.seesMe = true;
addedMessage = "They were added, and your activity is visible to them."; addedMessage = "They were added, and your activity is visible to them.";
} else { } else {
addedMessage = "They were added."; addedMessage = "They were added.";
} }
this.$notify( this.$notify(
{ {
group: "alert", group: "alert",
type: "success", type: "success",
title: "Contact Added", title: "Contact Added",
text: addedMessage, text: addedMessage,
}, },
3000, 3000,
); );
if ( if (
this.isRegistered && this.isRegistered &&
!this.hideRegisterPromptOnNewContact && !this.hideRegisterPromptOnNewContact &&
!newContact.registered !newContact.registered
) { ) {
setTimeout(() => { setTimeout(() => {
this.$notify( this.$notify(
{ {
group: "modal", group: "modal",
type: "confirm", type: "confirm",
title: "Register", title: "Register",
text: "Do you want to register them?", text: "Do you want to register them?",
onCancel: async (stopAsking?: boolean) => { onCancel: async (stopAsking?: boolean) => {
if (stopAsking) { if (stopAsking) {
await db.settings.update(MASTER_SETTINGS_KEY, { await db.settings.update(MASTER_SETTINGS_KEY, {
hideRegisterPromptOnNewContact: stopAsking, hideRegisterPromptOnNewContact: stopAsking,
}); });
this.hideRegisterPromptOnNewContact = stopAsking; this.hideRegisterPromptOnNewContact = stopAsking;
} }
}, },
onNo: async (stopAsking?: boolean) => { onNo: async (stopAsking?: boolean) => {
if (stopAsking) { if (stopAsking) {
await db.settings.update(MASTER_SETTINGS_KEY, { await db.settings.update(MASTER_SETTINGS_KEY, {
hideRegisterPromptOnNewContact: stopAsking, hideRegisterPromptOnNewContact: stopAsking,
}); });
this.hideRegisterPromptOnNewContact = stopAsking; this.hideRegisterPromptOnNewContact = stopAsking;
} }
}, },
onYes: async () => { onYes: async () => {
await this.register(newContact); await this.register(newContact);
}, },
promptToStopAsking: true, promptToStopAsking: true,
}, },
-1, -1,
); );
}, 500); }, 500);
} }
} catch (e) { } catch (e) {
logger.error("Error processing QR code:", e); logger.error("Error processing QR code:", e);
this.danger( this.danger(
"Could not process the QR code. Please make sure you're scanning a valid TimeSafari contact QR code.", "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<void> { async onCopyUrlToClipboard(): Promise<void> {
try { try {
await this.platformService.writeToClipboard(this.qrValue); await this.platformService.writeToClipboard(this.qrValue);
this.$notify( this.$notify(
{ {
group: "alert", group: "alert",
type: "toast", type: "toast",
title: "Copied", title: "Copied",
text: "Contact URL was copied to clipboard.", text: "Contact URL was copied to clipboard.",
}, },
2000, 2000,
); );
} catch (error) { } catch (error) {
logger.error("Error copying to clipboard:", error); logger.error("Error copying to clipboard:", error);
this.danger("Failed to copy to clipboard", "Error"); this.danger("Failed to copy to clipboard", "Error");
@ -607,15 +861,15 @@ export default class ContactQRScanShow extends Vue {
async onCopyDidToClipboard(): Promise<void> { async onCopyDidToClipboard(): Promise<void> {
try { try {
await this.platformService.writeToClipboard(this.activeDid); await this.platformService.writeToClipboard(this.activeDid);
this.$notify( this.$notify(
{ {
group: "alert", group: "alert",
type: "info", type: "info",
title: "Copied", title: "Copied",
text: "Your DID was copied to the clipboard. Have them paste it in the box on their 'People' screen to add you.", text: "Your DID was copied to the clipboard. Have them paste it in the box on their 'People' screen to add you.",
}, },
5000, 5000,
); );
} catch (error) { } catch (error) {
logger.error("Error copying to clipboard:", error); logger.error("Error copying to clipboard:", error);
this.danger("Failed to copy to clipboard", "Error"); this.danger("Failed to copy to clipboard", "Error");
@ -712,5 +966,96 @@ export default class ContactQRScanShow extends Vue {
get isMobile(): boolean { get isMobile(): boolean {
return __IS_MOBILE__; 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');
}
}
} }
</script> </script>

4
tsconfig.node.json

@ -4,7 +4,9 @@
"skipLibCheck": true, "skipLibCheck": true,
"module": "ESNext", "module": "ESNext",
"moduleResolution": "bundler", "moduleResolution": "bundler",
"allowSyntheticDefaultImports": true "allowSyntheticDefaultImports": true,
"allowImportingTsExtensions": true,
"noEmit": true
}, },
"include": ["vite.config.*"] "include": ["vite.config.*"]
} }

13
vite.config.capacitor.mts

@ -1,4 +1,13 @@
import { defineConfig } from "vite"; import { defineConfig, mergeConfig } from "vite";
import { createBuildConfig } from "./vite.config.common.mts"; import { createBuildConfig } from "./vite.config.common.mts";
export default defineConfig(async () => createBuildConfig('capacitor')); 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
}
});
});

5
vite.config.common.mts

@ -36,7 +36,7 @@ export async function createBuildConfig(mode: string) {
assetsDir: 'assets', assetsDir: 'assets',
chunkSizeWarningLimit: 1000, chunkSizeWarningLimit: 1000,
rollupOptions: { rollupOptions: {
external: isCapacitor ? ['@capacitor/app'] : [] external: []
} }
}, },
define: { define: {
@ -44,10 +44,13 @@ export async function createBuildConfig(mode: string) {
'process.env.VITE_PLATFORM': JSON.stringify(mode), 'process.env.VITE_PLATFORM': JSON.stringify(mode),
'process.env.VITE_PWA_ENABLED': JSON.stringify(!(isElectron || isPyWebView || isCapacitor)), 'process.env.VITE_PWA_ENABLED': JSON.stringify(!(isElectron || isPyWebView || isCapacitor)),
__dirname: isElectron ? JSON.stringify(process.cwd()) : '""', __dirname: isElectron ? JSON.stringify(process.cwd()) : '""',
__USE_QR_READER__: JSON.stringify(!isCapacitor),
__IS_MOBILE__: JSON.stringify(isCapacitor),
}, },
resolve: { resolve: {
alias: { alias: {
'@': path.resolve(__dirname, './src'), '@': path.resolve(__dirname, './src'),
'@capacitor/app': path.resolve(__dirname, 'node_modules/@capacitor/app'),
...appConfig.aliasConfig, ...appConfig.aliasConfig,
'nostr-tools/nip06': mode === 'development' 'nostr-tools/nip06': mode === 'development'
? 'nostr-tools/nip06' ? 'nostr-tools/nip06'

Loading…
Cancel
Save