Browse Source

fix(qr): improve QR scanner implementation and error handling

- Implement robust QR scanner factory with platform detection
- Add proper camera permissions to Android manifest
- Improve error handling and logging across scanner implementations
- Add continuous scanning mode for Capacitor/MLKit scanner
- Enhance UI feedback during scanning process
- Fix build configuration for proper platform detection
- Clean up resources properly in scanner components
- Add TypeScript improvements and error wrapping

The changes include:
- Adding CAMERA permission to AndroidManifest.xml
- Setting proper build flags (__IS_MOBILE__, __USE_QR_READER__)
- Implementing continuous scanning mode for better UX
- Adding proper cleanup of scanner resources
- Improving error handling and type safety
- Enhancing UI with loading states and error messages
qrcode-reboot
Matthew Raymer 3 days ago
parent
commit
a8812714a3
  1. 578
      .cursor/rules/qr-code-implementation-guide.mdc
  2. BIN
      android/.gradle/file-system.probe
  3. 2
      android/app/src/main/AndroidManifest.xml
  4. 5
      package.json
  5. 26
      src/components/QRScanner/QRScannerDialog.vue
  6. 65
      src/services/QRScanner/CapacitorQRScanner.ts
  7. 53
      src/services/QRScanner/QRScannerFactory.ts
  8. 58
      src/services/QRScanner/WebDialogQRScanner.ts
  9. 194
      src/views/ContactQRScanShowView.vue
  10. 2
      vite.config.common.mts

578
.cursor/rules/qr-code-implementation-guide.mdc

@ -133,289 +133,61 @@ export class CapacitorQRScanner implements QRScannerService {
}
}
// Implement other interface methods...
}
```
5. **Implement Web Scanner**
```typescript
// WebDialogQRScanner.ts
export class WebDialogQRScanner implements QRScannerService {
private dialogInstance: App | null = null;
private dialogComponent: InstanceType<typeof QRScannerDialog> | null = null;
private scanListener: ScanListener | null = null;
async checkPermissions(): Promise<boolean> {
async requestPermissions() {
try {
const permissions = await navigator.permissions.query({
name: 'camera' as PermissionName
});
return permissions.state === 'granted';
const { camera } = await BarcodeScanner.requestPermissions();
return camera === 'granted';
} catch (error) {
logger.error('Error checking camera permissions:', error);
logger.error('Error requesting camera permissions:', error);
return false;
}
}
// Implement other interface methods...
}
```
6. **Create Dialog Component**
```vue
<!-- QRScannerDialog.vue -->
<template>
<div v-if="visible" class="dialog-overlay z-[60]">
<div class="dialog relative">
<!-- Dialog content -->
<div v-if="useQRReader">
<qrcode-stream
class="w-full max-w-lg mx-auto"
@detect="onScanDetect"
@error="onScanError"
/>
</div>
<div v-else>
<!-- Mobile camera button -->
</div>
</div>
</div>
</template>
<script lang="ts">
@Component({
components: { QrcodeStream }
})
export default class QRScannerDialog extends Vue {
// Implementation...
}
</script>
```
## Usage Example
```typescript
// In your component
async function scanQRCode() {
const scanner = QRScannerFactory.getInstance();
if (!(await scanner.checkPermissions())) {
const granted = await scanner.requestPermissions();
if (!granted) {
throw new Error('Camera permission denied');
}
async isSupported() {
return Capacitor.isNativePlatform();
}
scanner.addListener({
onScan: (result) => {
console.log('Scanned:', result);
},
onError: (error) => {
console.error('Scan error:', error);
}
});
await scanner.startScan();
}
// Cleanup when done
onUnmounted(() => {
QRScannerFactory.cleanup();
});
```
## Platform-Specific Notes
### Mobile (Capacitor)
- Uses MLKit for optimal performance
- Handles native permissions
- Supports both iOS and Android
- Uses back camera by default
- Handles device rotation
### Web
- Uses MediaDevices API
- Requires HTTPS for camera access
- Handles browser compatibility
- Manages memory and resources
- Provides fallback UI
## Testing
1. **Unit Tests**
- Test factory pattern
- Test platform detection
- Test error handling
- Test cleanup procedures
2. **Integration Tests**
- Test permission flows
- Test camera access
- Test QR code detection
- Test cross-platform behavior
3. **E2E Tests**
- Test full scanning workflow
- Test UI feedback
- Test error scenarios
- Test platform differences
## Common Issues and Solutions
1. **Permission Handling**
- Always check permissions first
- Provide clear user feedback
- Handle denial gracefully
- Implement retry logic
2. **Resource Management**
- Clean up after scanning
- Handle component unmounting
- Release camera resources
- Clear event listeners
3. **Error Handling**
- Log errors appropriately
- Provide user feedback
- Implement fallbacks
- Handle edge cases
4. **Performance**
- Optimize camera preview
- Handle memory usage
- Manage battery impact
- Consider device capabilities
# QR Code Implementation Guide
## Directory Structure
```
src/
├── components/
│ └── QRScanner/
│ ├── types.ts
│ ├── factory.ts
│ ├── CapacitorScanner.ts
│ ├── WebDialogScanner.ts
│ └── QRScannerDialog.vue
├── services/
│ └── QRScanner/
│ ├── types.ts
│ ├── QRScannerFactory.ts
│ ├── CapacitorQRScanner.ts
│ └── WebDialogQRScanner.ts
```
## Core Interfaces
```typescript
// types.ts
export interface ScanListener {
onScan: (result: string) => void;
onError?: (error: Error) => void;
}
export interface QRScannerService {
checkPermissions(): Promise<boolean>;
requestPermissions(): Promise<boolean>;
isSupported(): Promise<boolean>;
startScan(): Promise<void>;
stopScan(): Promise<void>;
addListener(listener: ScanListener): void;
cleanup(): Promise<void>;
}
```
## Configuration Files
### Vite Configuration
```typescript
// vite.config.ts
export default defineConfig({
define: {
__USE_QR_READER__: JSON.stringify(!isMobile),
__IS_MOBILE__: JSON.stringify(isMobile),
},
build: {
rollupOptions: {
external: isMobile ? ['vue-qrcode-reader'] : [],
}
}
});
```
async startScan() {
if (this.isScanning) return;
this.isScanning = true;
### Capacitor Configuration
```typescript
// capacitor.config.ts
const config: CapacitorConfig = {
plugins: {
MLKitBarcodeScanner: {
formats: ['QR_CODE'],
detectorSize: 1.0,
lensFacing: 'back',
googleBarcodeScannerModuleInstallState: true
try {
await BarcodeScanner.startScan();
} catch (error) {
this.isScanning = false;
throw error;
}
}
};
```
## Implementation Steps
1. **Install Dependencies**
```bash
npm install @capacitor-mlkit/barcode-scanning vue-qrcode-reader
```
2. **Create Core Types**
Create the interface files as shown above.
3. **Implement Factory**
```typescript
// QRScannerFactory.ts
export class QRScannerFactory {
private static instance: QRScannerService | null = null;
async stopScan() {
if (!this.isScanning) return;
this.isScanning = false;
static getInstance(): QRScannerService {
if (!this.instance) {
if (__IS_MOBILE__ || Capacitor.isNativePlatform()) {
this.instance = new CapacitorQRScanner();
} else if (__USE_QR_READER__) {
this.instance = new WebDialogQRScanner();
} else {
throw new Error('No QR scanner implementation available');
}
try {
await BarcodeScanner.stopScan();
} catch (error) {
logger.error('Error stopping scan:', error);
}
return this.instance;
}
static async cleanup() {
if (this.instance) {
await this.instance.cleanup();
this.instance = null;
}
addListener(listener: ScanListener) {
this.scanListener = listener;
const handle = BarcodeScanner.addListener('barcodeScanned', (result) => {
if (this.scanListener) {
this.scanListener.onScan(result.barcode);
}
});
this.listenerHandles.push(handle.remove);
}
}
```
4. **Implement Mobile Scanner**
```typescript
// CapacitorQRScanner.ts
export class CapacitorQRScanner implements QRScannerService {
private scanListener: ScanListener | null = null;
private isScanning = false;
private listenerHandles: Array<() => Promise<void>> = [];
async checkPermissions() {
try {
const { camera } = await BarcodeScanner.checkPermissions();
return camera === 'granted';
} catch (error) {
logger.error('Error checking camera permissions:', error);
return false;
async cleanup() {
await this.stopScan();
for (const handle of this.listenerHandles) {
await handle();
}
this.listenerHandles = [];
this.scanListener = null;
}
// Implement other interface methods...
}
```
@ -426,6 +198,7 @@ export class WebDialogQRScanner implements QRScannerService {
private dialogInstance: App | null = null;
private dialogComponent: InstanceType<typeof QRScannerDialog> | null = null;
private scanListener: ScanListener | null = null;
async checkPermissions(): Promise<boolean> {
try {
const permissions = await navigator.permissions.query({
@ -438,7 +211,61 @@ export class WebDialogQRScanner implements QRScannerService {
}
}
// Implement other interface methods...
async requestPermissions(): Promise<boolean> {
try {
const stream = await navigator.mediaDevices.getUserMedia({ video: true });
stream.getTracks().forEach(track => track.stop());
return true;
} catch (error) {
logger.error('Error requesting camera permissions:', error);
return false;
}
}
async isSupported(): Promise<boolean> {
return 'mediaDevices' in navigator && 'getUserMedia' in navigator.mediaDevices;
}
async startScan() {
if (this.dialogInstance) return;
const container = document.createElement('div');
document.body.appendChild(container);
this.dialogInstance = createApp(QRScannerDialog, {
onScan: (result: string) => {
if (this.scanListener) {
this.scanListener.onScan(result);
}
},
onError: (error: Error) => {
if (this.scanListener?.onError) {
this.scanListener.onError(error);
}
},
onClose: () => {
this.cleanup();
}
});
this.dialogComponent = this.dialogInstance.mount(container) as InstanceType<typeof QRScannerDialog>;
}
async stopScan() {
await this.cleanup();
}
addListener(listener: ScanListener) {
this.scanListener = listener;
}
async cleanup() {
if (this.dialogInstance) {
this.dialogInstance.unmount();
this.dialogInstance = null;
this.dialogComponent = null;
}
}
}
```
@ -448,29 +275,125 @@ export class WebDialogQRScanner implements QRScannerService {
<template>
<div v-if="visible" class="dialog-overlay z-[60]">
<div class="dialog relative">
<!-- Dialog content -->
<div v-if="useQRReader">
<qrcode-stream
class="w-full max-w-lg mx-auto"
@detect="onScanDetect"
@error="onScanError"
/>
<div class="dialog-header">
<h2>Scan QR Code</h2>
<button @click="onClose" class="close-button">×</button>
</div>
<div v-else>
<!-- Mobile camera button -->
<div class="dialog-content">
<div v-if="useQRReader">
<qrcode-stream
class="w-full max-w-lg mx-auto"
@detect="onScanDetect"
@error="onScanError"
/>
</div>
<div v-else>
<button @click="startMobileScan" class="scan-button">
Start Camera
</button>
</div>
</div>
</div>
</div>
</template>
<script lang="ts">
@Component({
components: { QrcodeStream }
})
export default class QRScannerDialog extends Vue {
// Implementation...
}
import { defineComponent } from 'vue';
import { QrcodeStream } from 'vue-qrcode-reader';
export default defineComponent({
name: 'QRScannerDialog',
components: { QrcodeStream },
props: {
onScan: {
type: Function,
required: true
},
onError: {
type: Function,
required: true
},
onClose: {
type: Function,
required: true
}
},
data() {
return {
visible: true,
useQRReader: __USE_QR_READER__
};
},
methods: {
onScanDetect(promisedResult: Promise<string>) {
promisedResult
.then(result => this.onScan(result))
.catch(error => this.onError(error));
},
onScanError(error: Error) {
this.onError(error);
},
async startMobileScan() {
try {
const scanner = QRScannerFactory.getInstance();
await scanner.startScan();
} catch (error) {
this.onError(error as Error);
}
}
}
});
</script>
<style scoped>
.dialog-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
}
.dialog {
background: white;
border-radius: 8px;
padding: 20px;
max-width: 90vw;
max-height: 90vh;
overflow: auto;
}
.dialog-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.close-button {
background: none;
border: none;
font-size: 24px;
cursor: pointer;
}
.scan-button {
background: #4CAF50;
color: white;
padding: 10px 20px;
border: none;
border-radius: 4px;
cursor: pointer;
}
.scan-button:hover {
background: #45a049;
}
</style>
```
## Usage Example
@ -513,6 +436,7 @@ onUnmounted(() => {
- Supports both iOS and Android
- Uses back camera by default
- Handles device rotation
- Provides native UI for scanning
### Web
- Uses MediaDevices API
@ -520,6 +444,7 @@ onUnmounted(() => {
- Handles browser compatibility
- Manages memory and resources
- Provides fallback UI
- Uses vue-qrcode-reader for web scanning
## Testing
@ -528,41 +453,74 @@ onUnmounted(() => {
- Test platform detection
- Test error handling
- Test cleanup procedures
- Test permission flows
2. **Integration Tests**
- Test permission flows
- Test camera access
- Test QR code detection
- Test cross-platform behavior
- Test UI components
- Test error scenarios
3. **E2E Tests**
- Test full scanning workflow
- Test UI feedback
- Test error scenarios
- Test platform differences
## Common Issues and Solutions
1. **Permission Handling**
- Always check permissions first
- Provide clear user feedback
- Handle denial gracefully
- Implement retry logic
2. **Resource Management**
- Clean up after scanning
- Handle component unmounting
- Release camera resources
- Clear event listeners
3. **Error Handling**
- Log errors appropriately
- Provide user feedback
- Implement fallbacks
- Handle edge cases
4. **Performance**
- Optimize camera preview
- Handle memory usage
- Manage battery impact
- Consider device capabilities
- Test complete scanning flow
- Test permission handling
- Test cross-platform compatibility
- Test error recovery
- Test cleanup procedures
## Best Practices
1. **Error Handling**
- Always handle permission errors gracefully
- Provide clear error messages to users
- Implement proper cleanup on errors
- Log errors for debugging
2. **Performance**
- Clean up resources when not in use
- Handle device rotation properly
- Optimize camera usage
- Manage memory efficiently
3. **Security**
- Request minimum required permissions
- Handle sensitive data securely
- Validate scanned data
- Implement proper cleanup
4. **User Experience**
- Provide clear feedback
- Handle edge cases gracefully
- Support both platforms seamlessly
- Implement proper loading states
## Troubleshooting
1. **Common Issues**
- Camera permissions denied
- Device not supported
- Scanner not working
- Memory leaks
- UI glitches
2. **Solutions**
- Check permissions
- Verify device support
- Debug scanner implementation
- Monitor memory usage
- Test UI components
## Maintenance
1. **Regular Updates**
- Keep dependencies updated
- Monitor platform changes
- Update documentation
- Review security patches
2. **Performance Monitoring**
- Track memory usage
- Monitor camera performance
- Check error rates
- Analyze user feedback

BIN
android/.gradle/file-system.probe

Binary file not shown.

2
android/app/src/main/AndroidManifest.xml

@ -41,4 +41,6 @@
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-feature android:name="android.hardware.camera" android:required="true" />
</manifest>

5
package.json

@ -23,11 +23,12 @@
"clean:electron": "rimraf dist-electron",
"build:pywebview": "vite build --config vite.config.pywebview.mts",
"build:electron": "npm run clean:electron && vite build --config vite.config.electron.mts && node scripts/build-electron.js",
"build:capacitor": "vite build --config vite.config.capacitor.mts",
"build:capacitor": "vite build --mode capacitor --config vite.config.capacitor.mts",
"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",

26
src/components/QRScanner/QRScannerDialog.vue

@ -80,15 +80,21 @@ import { QRScannerOptions } from "@/services/QRScanner/types";
import { logger } from "@/utils/logger";
import { Capacitor } from "@capacitor/core";
interface ScanProps {
onScan: (result: string) => void;
onError?: (error: Error) => void;
options?: QRScannerOptions;
}
@Component({
components: {
QrcodeStream,
},
})
export default class QRScannerDialog extends Vue {
@Prop({ type: Function, required: true }) onScan!: (result: string) => void;
@Prop({ type: Function }) onError?: (error: Error) => void;
@Prop({ type: Object }) options?: QRScannerOptions;
@Prop({ type: Function, required: true }) onScan!: ScanProps['onScan'];
@Prop({ type: Function }) onError?: ScanProps['onError'];
@Prop({ type: Object }) options?: ScanProps['options'];
visible = true;
error: string | null = null;
@ -126,11 +132,12 @@ export default class QRScannerDialog extends Vue {
await promise;
this.error = null;
} catch (error) {
this.error = error instanceof Error ? error.message : String(error);
const wrappedError = error instanceof Error ? error : new Error(String(error));
this.error = wrappedError.message;
if (this.onError) {
this.onError(error instanceof Error ? error : new Error(String(error)));
this.onError(wrappedError);
}
logger.error("Error initializing QR scanner:", error);
logger.error("Error initializing QR scanner:", wrappedError);
}
}
@ -139,11 +146,12 @@ export default class QRScannerDialog extends Vue {
this.onScan(result);
this.close();
} catch (error) {
this.error = error instanceof Error ? error.message : String(error);
const wrappedError = error instanceof Error ? error : new Error(String(error));
this.error = wrappedError.message;
if (this.onError) {
this.onError(error instanceof Error ? error : new Error(String(error)));
this.onError(wrappedError);
}
logger.error("Error handling QR scan result:", error);
logger.error("Error handling QR scan result:", wrappedError);
}
}

65
src/services/QRScanner/CapacitorQRScanner.ts

@ -10,13 +10,16 @@ import { logger } from "@/utils/logger";
export class CapacitorQRScanner implements QRScannerService {
private scanListener: ScanListener | null = null;
private isScanning = false;
private listenerHandles: Array<() => Promise<void>> = [];
async checkPermissions(): Promise<boolean> {
try {
const { camera } = await BarcodeScanner.checkPermissions();
return camera === "granted";
} catch (error) {
logger.error("Error checking camera permissions:", error);
const wrappedError =
error instanceof Error ? error : new Error(String(error));
logger.error("Error checking camera permissions:", wrappedError);
return false;
}
}
@ -32,7 +35,9 @@ export class CapacitorQRScanner implements QRScannerService {
const { camera } = await BarcodeScanner.requestPermissions();
return camera === "granted";
} catch (error) {
logger.error("Error requesting camera permissions:", error);
const wrappedError =
error instanceof Error ? error : new Error(String(error));
logger.error("Error requesting camera permissions:", wrappedError);
return false;
}
}
@ -42,7 +47,9 @@ export class CapacitorQRScanner implements QRScannerService {
const { supported } = await BarcodeScanner.isSupported();
return supported;
} catch (error) {
logger.error("Error checking scanner support:", error);
const wrappedError =
error instanceof Error ? error : new Error(String(error));
logger.error("Error checking scanner support:", wrappedError);
return false;
}
}
@ -79,17 +86,23 @@ export class CapacitorQRScanner implements QRScannerService {
};
logger.log("Scanner options:", scanOptions);
const result = await BarcodeScanner.scan(scanOptions);
logger.log("Scan result:", result);
if (result.barcodes.length > 0) {
this.scanListener?.onScan(result.barcodes[0].rawValue);
}
// Add listener for barcode scans
const handle = await BarcodeScanner.addListener('barcodeScanned', (result) => {
if (this.scanListener) {
this.scanListener.onScan(result.barcode.rawValue);
}
});
this.listenerHandles.push(handle.remove);
// Start continuous scanning
await BarcodeScanner.startScan(scanOptions);
} catch (error) {
logger.error("Error during QR scan:", error);
this.scanListener?.onError?.(error as Error);
} finally {
this.isScanning = false;
const wrappedError =
error instanceof Error ? error : new Error(String(error));
logger.error("Error during QR scan:", wrappedError);
this.scanListener?.onError?.(wrappedError);
throw wrappedError;
}
}
@ -100,10 +113,14 @@ export class CapacitorQRScanner implements QRScannerService {
try {
await BarcodeScanner.stopScan();
this.isScanning = false;
} catch (error) {
logger.error("Error stopping QR scan:", error);
this.scanListener?.onError?.(error as Error);
const wrappedError =
error instanceof Error ? error : new Error(String(error));
logger.error("Error stopping QR scan:", wrappedError);
this.scanListener?.onError?.(wrappedError);
throw wrappedError;
} finally {
this.isScanning = false;
}
}
@ -112,7 +129,19 @@ export class CapacitorQRScanner implements QRScannerService {
}
async cleanup(): Promise<void> {
await this.stopScan();
this.scanListener = null;
try {
await this.stopScan();
for (const handle of this.listenerHandles) {
await handle();
}
} catch (error) {
const wrappedError =
error instanceof Error ? error : new Error(String(error));
logger.error("Error during cleanup:", wrappedError);
throw wrappedError;
} finally {
this.listenerHandles = [];
this.scanListener = null;
}
}
}

53
src/services/QRScanner/QRScannerFactory.ts

@ -11,8 +11,15 @@ export class QRScannerFactory {
private static instance: QRScannerService | null = null;
private static isNativePlatform(): boolean {
// Debug logging for build flags
logger.log("Build flags:", {
IS_MOBILE: typeof __IS_MOBILE__ !== 'undefined' ? __IS_MOBILE__ : 'undefined',
USE_QR_READER: typeof __USE_QR_READER__ !== 'undefined' ? __USE_QR_READER__ : 'undefined',
VITE_PLATFORM: process.env.VITE_PLATFORM,
});
const capacitorNative = Capacitor.isNativePlatform();
const isMobile = __IS_MOBILE__;
const isMobile = typeof __IS_MOBILE__ !== 'undefined' ? __IS_MOBILE__ : capacitorNative;
const platform = Capacitor.getPlatform();
logger.log("Platform detection:", {
@ -22,12 +29,16 @@ export class QRScannerFactory {
userAgent: navigator.userAgent,
});
// Force native scanner on Android/iOS
// Always use native scanner on Android/iOS
if (platform === "android" || platform === "ios") {
logger.log("Using native scanner due to platform:", platform);
return true;
}
return capacitorNative || isMobile;
// For other platforms, use native if available
const useNative = capacitorNative || isMobile;
logger.log("Platform decision:", { useNative, reason: useNative ? "capacitorNative/isMobile" : "web" });
return useNative;
}
/**
@ -40,19 +51,24 @@ export class QRScannerFactory {
`Creating QR scanner for platform: ${isNative ? "native" : "web"}`,
);
if (isNative) {
logger.log("Using native MLKit scanner");
this.instance = new CapacitorQRScanner();
} else if (__USE_QR_READER__) {
logger.log("Using web QR scanner");
this.instance = new WebDialogQRScanner();
} else {
throw new Error(
"No QR scanner implementation available for this platform",
);
try {
if (isNative) {
logger.log("Using native MLKit scanner");
this.instance = new CapacitorQRScanner();
} else if (typeof __USE_QR_READER__ !== 'undefined' ? __USE_QR_READER__ : !isNative) {
logger.log("Using web QR scanner");
this.instance = new WebDialogQRScanner();
} else {
throw new Error(
"No QR scanner implementation available for this platform",
);
}
} catch (error) {
logger.error("Error creating QR scanner:", error);
throw error;
}
}
return this.instance!; // We know it's not null here
return this.instance!;
}
/**
@ -60,8 +76,13 @@ export class QRScannerFactory {
*/
static async cleanup(): Promise<void> {
if (this.instance) {
await this.instance.cleanup();
this.instance = null;
try {
await this.instance.cleanup();
} catch (error) {
logger.error("Error cleaning up QR scanner:", error);
} finally {
this.instance = null;
}
}
}
}

58
src/services/QRScanner/WebDialogQRScanner.ts

@ -8,6 +8,7 @@ export class WebDialogQRScanner implements QRScannerService {
private dialogComponent: InstanceType<typeof QRScannerDialog> | null = null;
private scanListener: ScanListener | null = null;
private isScanning = false;
private container: HTMLElement | null = null;
constructor(private options?: QRScannerOptions) {}
@ -18,7 +19,9 @@ export class WebDialogQRScanner implements QRScannerService {
});
return permissions.state === "granted";
} catch (error) {
logger.error("Error checking camera permissions:", error);
const wrappedError =
error instanceof Error ? error : new Error(String(error));
logger.error("Error checking camera permissions:", wrappedError);
return false;
}
}
@ -29,7 +32,9 @@ export class WebDialogQRScanner implements QRScannerService {
stream.getTracks().forEach((track) => track.stop());
return true;
} catch (error) {
logger.error("Error requesting camera permissions:", error);
const wrappedError =
error instanceof Error ? error : new Error(String(error));
logger.error("Error requesting camera permissions:", wrappedError);
return false;
}
}
@ -47,8 +52,8 @@ export class WebDialogQRScanner implements QRScannerService {
this.isScanning = true;
// Create and mount dialog component
const container = document.createElement("div");
document.body.appendChild(container);
this.container = document.createElement("div");
document.body.appendChild(this.container);
this.dialogInstance = createApp(QRScannerDialog, {
onScan: (result: string) => {
@ -64,16 +69,18 @@ export class WebDialogQRScanner implements QRScannerService {
options: this.options,
});
this.dialogComponent = this.dialogInstance.mount(container).$refs
this.dialogComponent = this.dialogInstance.mount(this.container).$refs
.dialog as InstanceType<typeof QRScannerDialog>;
} catch (error) {
this.isScanning = false;
const wrappedError =
error instanceof Error ? error : new Error(String(error));
if (this.scanListener?.onError) {
this.scanListener.onError(
error instanceof Error ? error : new Error(String(error)),
);
this.scanListener.onError(wrappedError);
}
logger.error("Error starting scan:", error);
logger.error("Error starting scan:", wrappedError);
this.cleanupContainer();
throw wrappedError;
}
}
@ -89,9 +96,14 @@ export class WebDialogQRScanner implements QRScannerService {
if (this.dialogInstance) {
this.dialogInstance.unmount();
}
this.isScanning = false;
} catch (error) {
logger.error("Error stopping scan:", error);
const wrappedError =
error instanceof Error ? error : new Error(String(error));
logger.error("Error stopping scan:", wrappedError);
throw wrappedError;
} finally {
this.isScanning = false;
this.cleanupContainer();
}
}
@ -99,10 +111,26 @@ export class WebDialogQRScanner implements QRScannerService {
this.scanListener = listener;
}
private cleanupContainer(): void {
if (this.container && this.container.parentNode) {
this.container.parentNode.removeChild(this.container);
}
this.container = null;
}
async cleanup(): Promise<void> {
await this.stopScan();
this.dialogComponent = null;
this.dialogInstance = null;
this.scanListener = null;
try {
await this.stopScan();
} catch (error) {
const wrappedError =
error instanceof Error ? error : new Error(String(error));
logger.error("Error during cleanup:", wrappedError);
throw wrappedError;
} finally {
this.dialogComponent = null;
this.dialogInstance = null;
this.scanListener = null;
this.cleanupContainer();
}
}
}

194
src/views/ContactQRScanShowView.vue

@ -27,7 +27,7 @@
<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="openUserNameDialog"
>
click here to set it for them.
</span>
@ -77,8 +77,19 @@
<div class="text-center">
<h1 class="text-4xl text-center font-light pt-6">Scan Contact Info</h1>
<qrcode-stream @detect="onScanDetect" @error="onScanError" />
<span>
<div v-if="isScanning" class="relative aspect-square">
<div class="absolute inset-0 border-2 border-blue-500 opacity-50 pointer-events-none"></div>
</div>
<div v-else>
<button
class="bg-blue-500 text-white px-4 py-2 rounded-md mt-4"
@click="startScanning"
>
Start Scanning
</button>
</div>
<span v-if="error" class="text-red-500 block mt-2">{{ error }}</span>
<span v-else class="block mt-2">
If you do not see a scanning camera window here, check your camera
permissions.
</span>
@ -90,7 +101,6 @@
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 { useClipboard } from "@vueuse/core";
import QuickNav from "../components/QuickNav.vue";
@ -110,9 +120,10 @@ 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 { QRScannerFactory } from "../services/QRScanner/QRScannerFactory";
@Component({
components: {
QrcodeStream,
QRCodeVue3,
QuickNav,
UserNameDialog,
@ -128,6 +139,8 @@ export default class ContactQRScanShow extends Vue {
hideRegisterPromptOnNewContact = false;
isRegistered = false;
qrValue = "";
isScanning = false;
error: string | null = null;
ETHR_DID_PREFIX = ETHR_DID_PREFIX;
@ -150,12 +163,55 @@ export default class ContactQRScanShow extends Vue {
account,
!!settings.isRegistered,
name,
settings.profileImageUrl,
settings.profileImageUrl || "",
false,
);
}
}
async startScanning() {
try {
this.error = null;
this.isScanning = true;
const scanner = QRScannerFactory.getInstance();
// Check permissions first
if (!(await scanner.checkPermissions())) {
const granted = await scanner.requestPermissions();
if (!granted) {
this.error = "Camera permission denied";
this.isScanning = false;
return;
}
}
// Add scan listener
scanner.addListener({
onScan: this.onScanDetect,
onError: this.onScanError
});
// Start scanning
await scanner.startScan();
} catch (error) {
this.error = error instanceof Error ? error.message : String(error);
this.isScanning = false;
logger.error("Error starting scan:", error);
}
}
async stopScanning() {
try {
const scanner = QRScannerFactory.getInstance();
await scanner.stopScan();
} catch (error) {
logger.error("Error stopping scan:", error);
} finally {
this.isScanning = false;
}
}
danger(message: string, title: string = "Error", timeout = 5000) {
this.$notify(
{
@ -169,49 +225,37 @@ export default class ContactQRScanShow extends Vue {
}
/**
*
* @param content is the result of a QR scan, an array with one item with a rawValue property
* Handle QR code scan result
*/
// 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) {
const url = content[0]?.rawValue;
if (url) {
async onScanDetect(result: string) {
try {
let newContact: Contact;
try {
const jwt = getContactJwtFromJwtUrl(url);
if (!jwt) {
this.$notify(
{
group: "alert",
type: "danger",
title: "No Contact Info",
text: "The contact info could not be parsed.",
},
3000,
);
return;
}
const { payload } = decodeEndorserJwt(jwt);
newContact = {
did: payload.own.did || payload.iss, // ".own.did" is reliable as of v 0.3.49
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("There is no DID.", "Incomplete Contact");
return;
}
if (!isDid(newContact.did)) {
this.danger("The DID must begin with 'did:'", "Invalid DID");
return;
}
} catch (e) {
logger.error("Error parsing QR info:", e);
this.danger("Could not parse the QR info.", "Read Error");
const jwt = getContactJwtFromJwtUrl(result);
if (!jwt) {
this.$notify(
{
group: "alert",
type: "danger",
title: "No Contact Info",
text: "The contact info could not be parsed.",
},
3000,
);
return;
}
const { payload } = decodeEndorserJwt(jwt);
newContact = {
did: payload.own.did || payload.iss, // ".own.did" is reliable as of v 0.3.49
name: payload.own.name,
nextPubKeyHashB64: payload.own.nextPublicEncKeyHash,
profileImageUrl: payload.own.profileImageUrl,
};
if (!newContact.did) {
this.danger("There is no DID.", "Incomplete Contact");
return;
}
if (!isDid(newContact.did)) {
this.danger("The DID must begin with 'did:'", "Invalid DID");
return;
}
@ -247,7 +291,7 @@ export default class ContactQRScanShow extends Vue {
type: "confirm",
title: "Register",
text: "Do you want to register them?",
onCancel: async (stopAsking: boolean) => {
onCancel: async (stopAsking?: boolean) => {
if (stopAsking) {
await db.settings.update(MASTER_SETTINGS_KEY, {
hideRegisterPromptOnNewContact: stopAsking,
@ -255,7 +299,7 @@ export default class ContactQRScanShow extends Vue {
this.hideRegisterPromptOnNewContact = stopAsking;
}
},
onNo: async (stopAsking: boolean) => {
onNo: async (stopAsking?: boolean) => {
if (stopAsking) {
await db.settings.update(MASTER_SETTINGS_KEY, {
hideRegisterPromptOnNewContact: stopAsking,
@ -285,16 +329,12 @@ export default class ContactQRScanShow extends Vue {
5000,
);
}
} else {
this.$notify(
{
group: "alert",
type: "danger",
title: "Invalid Contact QR Code",
text: "No QR code detected with contact information.",
},
5000,
);
// Stop scanning after successful scan
await this.stopScanning();
} catch (error) {
this.error = error instanceof Error ? error.message : String(error);
logger.error("Error processing scan result:", error);
}
}
@ -364,8 +404,8 @@ export default class ContactQRScanShow extends Vue {
let userMessage = "There was an error.";
const serverError = error as AxiosError;
if (serverError) {
if (serverError.response?.data?.error?.message) {
userMessage = serverError.response.data.error.message;
if (serverError.response?.data && typeof serverError.response.data === 'object' && 'message' in serverError.response.data) {
userMessage = (serverError.response.data as {message: string}).message;
} else if (serverError.message) {
userMessage = serverError.message; // Info for the user
} else {
@ -387,18 +427,9 @@ export default class ContactQRScanShow extends Vue {
}
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
onScanError(error: any) {
logger.error("Scan was invalid:", error);
this.$notify(
{
group: "alert",
type: "danger",
title: "Invalid Scan",
text: "The scan was invalid.",
},
5000,
);
onScanError(error: Error) {
this.error = error.message;
logger.error("Scan error:", error);
}
onCopyUrlToClipboard() {
@ -435,5 +466,22 @@ export default class ContactQRScanShow extends Vue {
);
});
}
openUserNameDialog() {
(this.$refs.userNameDialog as any).open((name: string) => {
this.givenName = name;
});
}
beforeDestroy() {
// Clean up scanner when component is destroyed
QRScannerFactory.cleanup();
}
}
</script>
<style scoped>
.aspect-square {
aspect-ratio: 1 / 1;
}
</style>

2
vite.config.common.mts

@ -44,6 +44,8 @@ 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()) : '""',
__IS_MOBILE__: JSON.stringify(isCapacitor),
__USE_QR_READER__: JSON.stringify(!isCapacitor),
},
resolve: {
alias: {

Loading…
Cancel
Save