Compare commits
114 Commits
master
...
trent-twea
Author | SHA1 | Date |
---|---|---|
|
d555bc3e9c | 3 days ago |
|
141415977e | 3 days ago |
|
981ccbf269 | 3 days ago |
|
b74ec8ecbb | 3 days ago |
|
7b3b1c930e | 4 days ago |
|
85aa2981ad | 4 days ago |
|
a86e577127 | 4 days ago |
|
788d162b1c | 4 days ago |
|
616a69b7fd | 4 days ago |
|
efab9b968c | 4 days ago |
|
70174aea93 | 4 days ago |
|
7f12595c91 | 4 days ago |
|
8f0d09e480 | 4 days ago |
|
cfc0730e75 | 4 days ago |
|
bfbb9a933d | 5 days ago |
|
674bbfa00c | 5 days ago |
|
80b754246e | 5 days ago |
|
5fcf6a1f90 | 5 days ago |
|
9da12e76fd | 5 days ago |
|
04193f61c7 | 1 week ago |
|
0ca4916a05 | 1 week ago |
|
925ce830b4 | 1 week ago |
|
d14635c44d | 1 week ago |
|
eb5c9565a6 | 1 week ago |
|
ea108b754e | 1 week ago |
|
e4155e1a20 | 1 week ago |
|
7e9682ce67 | 1 week ago |
|
c7f1148fe4 | 1 week ago |
|
ae9f1ee09f | 1 week ago |
|
4d0463f7f7 | 1 week ago |
|
748c4c7a50 | 2 weeks ago |
|
35bb9d2207 | 2 weeks ago |
|
fd914aa46c | 2 weeks ago |
|
ba1453104f | 2 weeks ago |
|
3c7f13d604 | 2 weeks ago |
|
8e8eef2ab5 | 2 weeks ago |
|
ea17ef930c | 2 weeks ago |
|
5242a24110 | 2 weeks ago |
|
93e860e0ac | 2 weeks ago |
|
f874973bfa | 2 weeks ago |
|
74b9caa94f | 2 weeks ago |
|
fdd1ff80ad | 2 weeks ago |
|
5d195d06ba | 3 weeks ago |
|
79707d2811 | 3 weeks ago |
|
9b73e05bdb | 3 weeks ago |
|
1b7c5decd3 | 3 weeks ago |
|
8c8fb6fe7d | 3 weeks ago |
|
29983f11a9 | 3 weeks ago |
|
5c559606df | 3 weeks ago |
|
37166fc141 | 3 weeks ago |
|
01ef7c1fa9 | 3 weeks ago |
|
2bb71653ac | 3 weeks ago |
|
7baae7ea7a | 3 weeks ago |
|
cb1d979431 | 3 weeks ago |
|
b999a04595 | 3 weeks ago |
|
0f9826a39d | 3 weeks ago |
|
8cc17bd09d | 3 weeks ago |
|
9dc9878472 | 3 weeks ago |
|
22283e32f2 | 4 weeks ago |
|
99863ec186 | 4 weeks ago |
|
8d2dffb012 | 4 weeks ago |
|
538cbef701 | 4 weeks ago |
|
7b7940189e | 4 weeks ago |
|
35b038036a | 4 weeks ago |
|
b9cafbe269 | 4 weeks ago |
|
559f52e6d6 | 4 weeks ago |
|
eb44b624d6 | 4 weeks ago |
|
6fdbc7f588 | 4 weeks ago |
|
7e8caae69a | 4 weeks ago |
|
7b29232b2c | 4 weeks ago |
|
e7cb5ffd33 | 4 weeks ago |
|
272f2a91a6 | 4 weeks ago |
|
f750ea5d10 | 4 weeks ago |
|
78116329d4 | 4 weeks ago |
|
2753e142cf | 4 weeks ago |
|
9a840ab74a | 4 weeks ago |
|
c6c49260ef | 4 weeks ago |
|
87438e7b6b | 4 weeks ago |
|
3ce2ea9b4e | 4 weeks ago |
|
8e6ba68560 | 4 weeks ago |
|
ca9ca5fca7 | 4 weeks ago |
|
4abb188da3 | 1 month ago |
|
30e448faf8 | 1 month ago |
|
a8812714a3 | 1 month ago |
|
2855d4b8d5 | 1 month ago |
|
b85e6d2958 | 1 month ago |
|
7d260365be | 1 month ago |
|
72de271f6c | 1 month ago |
|
2055097cf2 | 1 month ago |
|
6b38b1a347 | 1 month ago |
|
ca455e9593 | 1 month ago |
|
5ada70b05e | 1 month ago |
|
4f9b146a66 | 1 month ago |
|
2b638ce2a7 | 1 month ago |
|
0b528af2a6 | 1 month ago |
|
008211bc21 | 1 month ago |
|
6955a36458 | 1 month ago |
|
ba079ea983 | 1 month ago |
|
d7b3c5ec9d | 1 month ago |
|
d83a25f47e | 1 month ago |
|
fb40dc0ff7 | 1 month ago |
|
d03fa55001 | 2 months ago |
|
c8eff4d39e | 2 months ago |
|
b8a7771edf | 2 months ago |
|
5d845fb112 | 2 months ago |
|
660f2170de | 2 months ago |
|
94bd649003 | 2 months ago |
|
b2d628cfeb | 2 months ago |
|
00e52f8dca | 2 months ago |
|
073ce24f43 | 2 months ago |
|
2c84bb50b3 | 2 months ago |
|
abf18835f6 | 2 months ago |
|
f72562804d | 2 months ago |
|
bdc5ffafc1 | 2 months ago |
@ -0,0 +1,292 @@ |
|||||
|
--- |
||||
|
description: |
||||
|
globs: |
||||
|
alwaysApply: true |
||||
|
--- |
||||
|
# TimeSafari Cross-Platform Architecture Guide |
||||
|
|
||||
|
## 1. Platform Support Matrix |
||||
|
|
||||
|
| Feature | Web (PWA) | Capacitor (Mobile) | Electron (Desktop) | PyWebView (Desktop) | |
||||
|
|---------|-----------|-------------------|-------------------|-------------------| |
||||
|
| QR Code Scanning | WebInlineQRScanner | @capacitor-mlkit/barcode-scanning | Not Implemented | Not Implemented | |
||||
|
| Deep Linking | URL Parameters | App URL Open Events | Not Implemented | Not Implemented | |
||||
|
| File System | Limited (Browser API) | Capacitor Filesystem | Electron fs | PyWebView Python Bridge | |
||||
|
| Camera Access | MediaDevices API | Capacitor Camera | Not Implemented | Not Implemented | |
||||
|
| Platform Detection | Web APIs | Capacitor.isNativePlatform() | process.env checks | process.env checks | |
||||
|
|
||||
|
## 2. Project Structure |
||||
|
|
||||
|
### 2.1 Core Directories |
||||
|
``` |
||||
|
src/ |
||||
|
├── components/ # Vue components |
||||
|
├── services/ # Platform services and business logic |
||||
|
├── views/ # Page components |
||||
|
├── router/ # Vue router configuration |
||||
|
├── types/ # TypeScript type definitions |
||||
|
├── utils/ # Utility functions |
||||
|
├── lib/ # Core libraries |
||||
|
├── platforms/ # Platform-specific implementations |
||||
|
├── electron/ # Electron-specific code |
||||
|
├── constants/ # Application constants |
||||
|
├── db/ # Database related code |
||||
|
├── interfaces/ # TypeScript interfaces and type definitions |
||||
|
└── assets/ # Static assets |
||||
|
``` |
||||
|
|
||||
|
### 2.2 Entry Points |
||||
|
``` |
||||
|
src/ |
||||
|
├── main.ts # Base entry |
||||
|
├── main.common.ts # Shared initialization |
||||
|
├── main.capacitor.ts # Mobile entry |
||||
|
├── main.electron.ts # Electron entry |
||||
|
├── main.pywebview.ts # PyWebView entry |
||||
|
└── main.web.ts # Web/PWA entry |
||||
|
``` |
||||
|
|
||||
|
### 2.3 Build Configurations |
||||
|
``` |
||||
|
root/ |
||||
|
├── vite.config.common.mts # Shared config |
||||
|
├── vite.config.capacitor.mts # Mobile build |
||||
|
├── vite.config.electron.mts # Electron build |
||||
|
├── vite.config.pywebview.mts # PyWebView build |
||||
|
├── vite.config.web.mts # Web/PWA build |
||||
|
└── vite.config.utils.mts # Build utilities |
||||
|
``` |
||||
|
|
||||
|
## 3. Service Architecture |
||||
|
|
||||
|
### 3.1 Service Organization |
||||
|
``` |
||||
|
services/ |
||||
|
├── QRScanner/ # QR code scanning service |
||||
|
│ ├── WebInlineQRScanner.ts |
||||
|
│ └── interfaces.ts |
||||
|
├── platforms/ # Platform-specific services |
||||
|
│ ├── WebPlatformService.ts |
||||
|
│ ├── CapacitorPlatformService.ts |
||||
|
│ ├── ElectronPlatformService.ts |
||||
|
│ └── PyWebViewPlatformService.ts |
||||
|
└── factory/ # Service factories |
||||
|
└── PlatformServiceFactory.ts |
||||
|
``` |
||||
|
|
||||
|
### 3.2 Service Factory Pattern |
||||
|
```typescript |
||||
|
// PlatformServiceFactory.ts |
||||
|
export class PlatformServiceFactory { |
||||
|
private static instance: PlatformService | null = null; |
||||
|
|
||||
|
public static getInstance(): PlatformService { |
||||
|
if (!PlatformServiceFactory.instance) { |
||||
|
const platform = process.env.VITE_PLATFORM || "web"; |
||||
|
PlatformServiceFactory.instance = createPlatformService(platform); |
||||
|
} |
||||
|
return PlatformServiceFactory.instance; |
||||
|
} |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
## 4. Feature Implementation Guidelines |
||||
|
|
||||
|
### 4.1 QR Code Scanning |
||||
|
|
||||
|
1. **Service Interface** |
||||
|
```typescript |
||||
|
interface QRScannerService { |
||||
|
checkPermissions(): Promise<boolean>; |
||||
|
requestPermissions(): Promise<boolean>; |
||||
|
isSupported(): Promise<boolean>; |
||||
|
startScan(): Promise<void>; |
||||
|
stopScan(): Promise<void>; |
||||
|
addListener(listener: ScanListener): void; |
||||
|
onStream(callback: (stream: MediaStream | null) => void): void; |
||||
|
cleanup(): Promise<void>; |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
2. **Platform-Specific Implementation** |
||||
|
```typescript |
||||
|
// WebInlineQRScanner.ts |
||||
|
export class WebInlineQRScanner implements QRScannerService { |
||||
|
private scanListener: ScanListener | null = null; |
||||
|
private isScanning = false; |
||||
|
private stream: MediaStream | null = null; |
||||
|
private events = new EventEmitter(); |
||||
|
|
||||
|
// Implementation of interface methods |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
### 4.2 Deep Linking |
||||
|
|
||||
|
1. **URL Structure** |
||||
|
```typescript |
||||
|
// Format: timesafari://<route>[/<param>][?queryParam1=value1] |
||||
|
interface DeepLinkParams { |
||||
|
route: string; |
||||
|
params?: Record<string, string>; |
||||
|
query?: Record<string, string>; |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
2. **Platform Handlers** |
||||
|
```typescript |
||||
|
// Capacitor |
||||
|
App.addListener("appUrlOpen", handleDeepLink); |
||||
|
|
||||
|
// Web |
||||
|
router.beforeEach((to, from, next) => { |
||||
|
handleWebDeepLink(to.query); |
||||
|
}); |
||||
|
``` |
||||
|
|
||||
|
## 5. Build Process |
||||
|
|
||||
|
### 5.1 Environment Configuration |
||||
|
```typescript |
||||
|
// vite.config.common.mts |
||||
|
export function createBuildConfig(mode: string) { |
||||
|
return { |
||||
|
define: { |
||||
|
'process.env.VITE_PLATFORM': JSON.stringify(mode), |
||||
|
'process.env.VITE_PWA_ENABLED': JSON.stringify(!isNative), |
||||
|
__IS_MOBILE__: JSON.stringify(isCapacitor), |
||||
|
__USE_QR_READER__: JSON.stringify(!isCapacitor) |
||||
|
} |
||||
|
}; |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
### 5.2 Platform-Specific Builds |
||||
|
|
||||
|
```bash |
||||
|
# Build commands from package.json |
||||
|
"build:web": "vite build --config vite.config.web.mts", |
||||
|
"build:capacitor": "vite build --config vite.config.capacitor.mts", |
||||
|
"build:electron": "vite build --config vite.config.electron.mts", |
||||
|
"build:pywebview": "vite build --config vite.config.pywebview.mts" |
||||
|
``` |
||||
|
|
||||
|
## 6. Testing Strategy |
||||
|
|
||||
|
### 6.1 Test Configuration |
||||
|
```typescript |
||||
|
// playwright.config-local.ts |
||||
|
const config: PlaywrightTestConfig = { |
||||
|
projects: [ |
||||
|
{ |
||||
|
name: 'web', |
||||
|
use: { browserName: 'chromium' } |
||||
|
}, |
||||
|
{ |
||||
|
name: 'mobile', |
||||
|
use: { ...devices['Pixel 5'] } |
||||
|
} |
||||
|
] |
||||
|
}; |
||||
|
``` |
||||
|
|
||||
|
### 6.2 Platform-Specific Tests |
||||
|
```typescript |
||||
|
test('QR scanning works on mobile', async ({ page }) => { |
||||
|
test.skip(!process.env.MOBILE_TEST, 'Mobile-only test'); |
||||
|
// Test implementation |
||||
|
}); |
||||
|
``` |
||||
|
|
||||
|
## 7. Error Handling |
||||
|
|
||||
|
### 7.1 Global Error Handler |
||||
|
```typescript |
||||
|
function setupGlobalErrorHandler(app: VueApp) { |
||||
|
app.config.errorHandler = (err, instance, info) => { |
||||
|
logger.error("[App Error]", { |
||||
|
error: err, |
||||
|
info, |
||||
|
component: instance?.$options.name |
||||
|
}); |
||||
|
}; |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
### 7.2 Platform-Specific Error Handling |
||||
|
```typescript |
||||
|
// API error handling for Capacitor |
||||
|
if (process.env.VITE_PLATFORM === 'capacitor') { |
||||
|
logger.error(`[Capacitor API Error] ${endpoint}:`, { |
||||
|
message: error.message, |
||||
|
status: error.response?.status |
||||
|
}); |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
## 8. Best Practices |
||||
|
|
||||
|
### 8.1 Code Organization |
||||
|
- Use platform-specific directories for unique implementations |
||||
|
- Share common code through service interfaces |
||||
|
- Implement feature detection before using platform capabilities |
||||
|
- Keep platform-specific code isolated in dedicated directories |
||||
|
- Use TypeScript interfaces for cross-platform compatibility |
||||
|
|
||||
|
### 8.2 Platform Detection |
||||
|
```typescript |
||||
|
const platformService = PlatformServiceFactory.getInstance(); |
||||
|
const capabilities = platformService.getCapabilities(); |
||||
|
|
||||
|
if (capabilities.hasCamera) { |
||||
|
// Implement camera features |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
### 8.3 Feature Implementation |
||||
|
1. Define platform-agnostic interface |
||||
|
2. Create platform-specific implementations |
||||
|
3. Use factory pattern for instantiation |
||||
|
4. Implement graceful fallbacks |
||||
|
5. Add comprehensive error handling |
||||
|
6. Use dependency injection for better testability |
||||
|
|
||||
|
## 9. Dependency Management |
||||
|
|
||||
|
### 9.1 Platform-Specific Dependencies |
||||
|
```json |
||||
|
{ |
||||
|
"dependencies": { |
||||
|
"@capacitor/core": "^6.2.0", |
||||
|
"electron": "^33.2.1", |
||||
|
"vue": "^3.4.0" |
||||
|
} |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
### 9.2 Conditional Loading |
||||
|
```typescript |
||||
|
if (process.env.VITE_PLATFORM === 'capacitor') { |
||||
|
await import('@capacitor/core'); |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
## 10. Security Considerations |
||||
|
|
||||
|
### 10.1 Permission Handling |
||||
|
```typescript |
||||
|
async checkPermissions(): Promise<boolean> { |
||||
|
if (platformService.isCapacitor()) { |
||||
|
return await checkNativePermissions(); |
||||
|
} |
||||
|
return await checkWebPermissions(); |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
### 10.2 Data Storage |
||||
|
- Use secure storage mechanisms for sensitive data |
||||
|
- Implement proper encryption for stored data |
||||
|
- Follow platform-specific security guidelines |
||||
|
- Regular security audits and updates |
||||
|
|
||||
|
This document should be updated as new features are added or platform-specific implementations change. Regular reviews ensure it remains current with the codebase. |
@ -0,0 +1,222 @@ |
|||||
|
--- |
||||
|
description: |
||||
|
globs: |
||||
|
alwaysApply: true |
||||
|
--- |
||||
|
# Camera Implementation Documentation |
||||
|
|
||||
|
## Overview |
||||
|
|
||||
|
This document describes how camera functionality is implemented across the TimeSafari application. The application uses cameras for two main purposes: |
||||
|
|
||||
|
1. QR Code scanning |
||||
|
2. Photo capture |
||||
|
|
||||
|
## Components |
||||
|
|
||||
|
### QRScannerDialog.vue |
||||
|
|
||||
|
Primary component for QR code scanning in web browsers. |
||||
|
|
||||
|
**Key Features:** |
||||
|
|
||||
|
- Uses `qrcode-stream` for web-based QR scanning |
||||
|
- Supports both front and back cameras |
||||
|
- Provides real-time camera status feedback |
||||
|
- Implements error handling with user-friendly messages |
||||
|
- Includes camera switching functionality |
||||
|
|
||||
|
**Camera Access Flow:** |
||||
|
|
||||
|
1. Checks for camera API availability |
||||
|
2. Enumerates available video devices |
||||
|
3. Requests camera permissions |
||||
|
4. Initializes camera stream with preferred settings |
||||
|
5. Handles various error conditions with specific messages |
||||
|
|
||||
|
### PhotoDialog.vue |
||||
|
|
||||
|
Component for photo capture and selection. |
||||
|
|
||||
|
**Key Features:** |
||||
|
|
||||
|
- Cross-platform photo capture interface |
||||
|
- Image cropping capabilities |
||||
|
- File selection fallback |
||||
|
- Unified interface for different platforms |
||||
|
|
||||
|
## Services |
||||
|
|
||||
|
### QRScanner Services |
||||
|
|
||||
|
#### WebDialogQRScanner |
||||
|
|
||||
|
Web-based implementation of QR scanning. |
||||
|
|
||||
|
**Key Methods:** |
||||
|
|
||||
|
- `checkPermissions()`: Verifies camera permission status |
||||
|
- `requestPermissions()`: Requests camera access |
||||
|
- `isSupported()`: Checks for camera API support |
||||
|
- Handles various error conditions with specific messages |
||||
|
|
||||
|
#### CapacitorQRScanner |
||||
|
|
||||
|
Native implementation using Capacitor's MLKit. |
||||
|
|
||||
|
**Key Features:** |
||||
|
|
||||
|
- Uses `@capacitor-mlkit/barcode-scanning` |
||||
|
- Supports both front and back cameras |
||||
|
- Implements permission management |
||||
|
- Provides continuous scanning capability |
||||
|
|
||||
|
### Platform Services |
||||
|
|
||||
|
#### WebPlatformService |
||||
|
|
||||
|
Web-specific implementation of platform features. |
||||
|
|
||||
|
**Camera Capabilities:** |
||||
|
|
||||
|
- Uses HTML5 file input with capture attribute |
||||
|
- Falls back to file selection if camera unavailable |
||||
|
- Processes captured images for consistent format |
||||
|
|
||||
|
#### CapacitorPlatformService |
||||
|
|
||||
|
Native implementation using Capacitor. |
||||
|
|
||||
|
**Camera Features:** |
||||
|
|
||||
|
- Uses `Camera.getPhoto()` for native camera access |
||||
|
- Supports image editing |
||||
|
- Configures high-quality image capture |
||||
|
- Handles base64 image processing |
||||
|
|
||||
|
#### ElectronPlatformService |
||||
|
|
||||
|
Desktop implementation (currently unimplemented). |
||||
|
|
||||
|
**Status:** |
||||
|
|
||||
|
- Camera functionality not yet implemented |
||||
|
- Planned to use Electron's media APIs |
||||
|
|
||||
|
## Platform-Specific Considerations |
||||
|
|
||||
|
### iOS |
||||
|
|
||||
|
- Requires `NSCameraUsageDescription` in Info.plist |
||||
|
- Supports both front and back cameras |
||||
|
- Implements proper permission handling |
||||
|
|
||||
|
### Android |
||||
|
|
||||
|
- Requires camera permissions in manifest |
||||
|
- Supports both front and back cameras |
||||
|
- Handles permission requests through Capacitor |
||||
|
|
||||
|
### Web |
||||
|
|
||||
|
- Requires HTTPS for camera access |
||||
|
- Implements fallback mechanisms |
||||
|
- Handles browser compatibility issues |
||||
|
|
||||
|
## Error Handling |
||||
|
|
||||
|
### Common Error Scenarios |
||||
|
|
||||
|
1. No camera found |
||||
|
2. Permission denied |
||||
|
3. Camera in use by another application |
||||
|
4. HTTPS required |
||||
|
5. Browser compatibility issues |
||||
|
|
||||
|
### Error Response |
||||
|
|
||||
|
- User-friendly error messages |
||||
|
- Troubleshooting tips |
||||
|
- Clear instructions for resolution |
||||
|
- Platform-specific guidance |
||||
|
|
||||
|
## Security Considerations |
||||
|
|
||||
|
### Permission Management |
||||
|
|
||||
|
- Explicit permission requests |
||||
|
- Permission state tracking |
||||
|
- Graceful handling of denied permissions |
||||
|
|
||||
|
### Data Handling |
||||
|
|
||||
|
- Secure image processing |
||||
|
- Proper cleanup of camera resources |
||||
|
- No persistent storage of camera data |
||||
|
|
||||
|
## Best Practices |
||||
|
|
||||
|
### Camera Access |
||||
|
|
||||
|
1. Always check for camera availability |
||||
|
2. Request permissions explicitly |
||||
|
3. Handle all error conditions |
||||
|
4. Provide clear user feedback |
||||
|
5. Implement proper cleanup |
||||
|
|
||||
|
### Performance |
||||
|
|
||||
|
1. Optimize camera resolution |
||||
|
2. Implement proper resource cleanup |
||||
|
3. Handle camera switching efficiently |
||||
|
4. Manage memory usage |
||||
|
|
||||
|
### User Experience |
||||
|
|
||||
|
1. Clear status indicators |
||||
|
2. Intuitive camera controls |
||||
|
3. Helpful error messages |
||||
|
4. Smooth camera switching |
||||
|
5. Responsive UI feedback |
||||
|
|
||||
|
## Future Improvements |
||||
|
|
||||
|
### Planned Enhancements |
||||
|
|
||||
|
1. Implement Electron camera support |
||||
|
2. Add advanced camera features |
||||
|
3. Improve error handling |
||||
|
4. Enhance user feedback |
||||
|
5. Optimize performance |
||||
|
|
||||
|
### Known Issues |
||||
|
|
||||
|
1. Electron camera implementation pending |
||||
|
2. Some browser compatibility limitations |
||||
|
3. Platform-specific quirks to address |
||||
|
|
||||
|
## Dependencies |
||||
|
|
||||
|
### Key Packages |
||||
|
|
||||
|
- `@capacitor-mlkit/barcode-scanning` |
||||
|
- `qrcode-stream` |
||||
|
- `vue-picture-cropper` |
||||
|
- Platform-specific camera APIs |
||||
|
|
||||
|
## Testing |
||||
|
|
||||
|
### Test Scenarios |
||||
|
|
||||
|
1. Permission handling |
||||
|
2. Camera switching |
||||
|
3. Error conditions |
||||
|
4. Platform compatibility |
||||
|
5. Performance metrics |
||||
|
|
||||
|
### Test Environment |
||||
|
|
||||
|
- Multiple browsers |
||||
|
- iOS and Android devices |
||||
|
- Desktop platforms |
||||
|
- Various network conditions |
@ -0,0 +1,276 @@ |
|||||
|
--- |
||||
|
description: |
||||
|
globs: |
||||
|
alwaysApply: true |
||||
|
--- |
||||
|
--- |
||||
|
description: |
||||
|
globs: |
||||
|
alwaysApply: true |
||||
|
--- |
||||
|
# Time Safari Context |
||||
|
|
||||
|
## Project Overview |
||||
|
|
||||
|
Time Safari is an application designed to foster community building through gifts, gratitude, and collaborative projects. The app should make it extremely easy and intuitive for users of any age and capability to recognize contributions, build trust networks, and organize collective action. It is built on services that preserve privacy and data sovereignty. |
||||
|
|
||||
|
The ultimate goals of Time Safari are two-fold: |
||||
|
|
||||
|
1. **Connect** Make it easy, rewarding, and non-threatening for people to connect with others who have similar interests, and to initiate activities together. This helps people accomplish and learn from other individuals in less-structured environments; moreover, it helps them discover who they want to continue to support and with whom they want to maintain relationships. |
||||
|
|
||||
|
2. **Reveal** Widely advertise the great support and rewards that are being given and accepted freely, especially non-monetary ones. Using visuals and text, display the kind of impact that gifts are making in the lives of others. Also show useful and engaging reports of project statistics and personal accomplishments. |
||||
|
|
||||
|
|
||||
|
## Core Approaches |
||||
|
|
||||
|
Time Safari should help everyday users build meaningful connections and organize collective efforts by: |
||||
|
|
||||
|
1. **Recognizing Contributions**: Creating permanent, verifiable records of gifts and contributions people give to each other and their communities. |
||||
|
|
||||
|
2. **Facilitating Collaboration**: Making it ridiculously easy for people to ask for or propose help on projects and interests that matter to them. |
||||
|
|
||||
|
3. **Building Trust Networks**: Enabling users to maintain their network and activity visibility. Developing reputation through verified contributions and references, which can be selectively shown to others outside the network. |
||||
|
|
||||
|
4. **Preserving Privacy**: Ensuring personal identifiers are only shared with explicitly authorized contacts, allowing private individuals including children to participate safely. |
||||
|
|
||||
|
5. **Engaging Content**: Displaying people's records in compelling stories, and highlighting those projects that are lifting people's lives long-term, both in physical support and in emotional-spiritual-creative thriving. |
||||
|
|
||||
|
|
||||
|
## Technical Foundation |
||||
|
|
||||
|
This application is built on a privacy-preserving claims architecture (via endorser.ch) with these key characteristics: |
||||
|
|
||||
|
- **Decentralized Identifiers (DIDs)**: User identities are based on public/private key pairs stored on their devices |
||||
|
- **Cryptographic Verification**: All claims and confirmations are cryptographically signed |
||||
|
- **User-Controlled Visibility**: Users explicitly control who can see their identifiers and data |
||||
|
- **Merkle-Chained Claims**: Claims are cryptographically chained for verification and integrity |
||||
|
- **Native and Web App**: Works on Capacitor (iOS, Android), Desktop (Electron and CEFPython), and web browsers |
||||
|
|
||||
|
## User Journey |
||||
|
|
||||
|
The typical progression of usage follows these stages: |
||||
|
|
||||
|
1. **Gratitude & Recognition**: Users begin by expressing and recording gratitude for gifts received, building a foundation of acknowledgment. |
||||
|
|
||||
|
2. **Project Proposals**: Users propose projects and ideas, reaching out to connect with others who share similar interests. |
||||
|
|
||||
|
3. **Action Triggers**: Offers of help serve as triggers and motivations to execute proposed projects, moving from ideas to action. |
||||
|
|
||||
|
## Context for LLM Development |
||||
|
|
||||
|
When developing new functionality for Time Safari, consider these design principles: |
||||
|
|
||||
|
1. **Accessibility First**: Features should be usable by non-technical users with minimal learning curve. |
||||
|
|
||||
|
2. **Privacy by Design**: All features must respect user privacy and data sovereignty. |
||||
|
|
||||
|
3. **Progressive Enhancement**: Core functionality should work across all devices, with richer experiences where supported. |
||||
|
|
||||
|
4. **Voluntary Collaboration**: The system should enable but never coerce participation. |
||||
|
|
||||
|
5. **Trust Building**: Features should help build verifiable trust between users. |
||||
|
|
||||
|
6. **Network Effects**: Consider how features scale as more users join the platform. |
||||
|
|
||||
|
7. **Low Resource Requirements**: The system should be lightweight enough to run on inexpensive devices users already own. |
||||
|
|
||||
|
## Use Cases to Support |
||||
|
|
||||
|
LLM development should focus on enhancing these key use cases: |
||||
|
|
||||
|
1. **Community Building**: Tools that help people find others with shared interests and values. |
||||
|
|
||||
|
2. **Project Coordination**: Features that make it easy to propose collaborative projects and to submit suggestions and offers to existing ones. |
||||
|
|
||||
|
3. **Reputation Building**: Methods for users to showcase their contributions and reliability, in contexts where they explicitly reveal that information. |
||||
|
|
||||
|
4. **Governance Experimentation**: Features that facilitate decision-making and collective governance. |
||||
|
|
||||
|
## Constraints |
||||
|
|
||||
|
When developing new features, be mindful of these constraints: |
||||
|
|
||||
|
1. **Privacy Preservation**: User identifiers must remain private except when explicitly shared. |
||||
|
|
||||
|
2. **Platform Limitations**: Features must work within the constraints of the target app platforms, while aiming to leverage the best platform technology available. |
||||
|
|
||||
|
3. **Endorser API Limitations**: Backend features are constrained by the endorser.ch API capabilities. |
||||
|
|
||||
|
4. **Performance on Low-End Devices**: The application should remain performant on older/simpler devices. |
||||
|
|
||||
|
5. **Offline-First When Possible**: Key functionality should work offline when feasible. |
||||
|
|
||||
|
## Project Technologies |
||||
|
|
||||
|
- Typescript using ES6 classes using vue-facing-decorator |
||||
|
- TailwindCSS |
||||
|
- Vite Build Tool |
||||
|
- Playwright E2E testing |
||||
|
- IndexDB |
||||
|
- Camera, Image uploads, QR Code reader, ... |
||||
|
|
||||
|
## Mobile Features |
||||
|
|
||||
|
- Deep Linking |
||||
|
- Local Notifications via a custom Capacitor plugin |
||||
|
|
||||
|
## Project Architecture |
||||
|
|
||||
|
- The application must work on web browser, PWA (Progressive Web Application), desktop via Electron, and mobile via Capacitor |
||||
|
- Building for each platform is managed via Vite |
||||
|
|
||||
|
## Core Development Principles |
||||
|
|
||||
|
### DRY development |
||||
|
- **Code Reuse** |
||||
|
- Extract common functionality into utility functions |
||||
|
- Create reusable components for UI patterns |
||||
|
- Implement service classes for shared business logic |
||||
|
- Use mixins for cross-cutting concerns |
||||
|
- Leverage TypeScript interfaces for shared type definitions |
||||
|
|
||||
|
- **Component Patterns** |
||||
|
- Create base components for common UI elements |
||||
|
- Implement higher-order components for shared behavior |
||||
|
- Use slot patterns for flexible component composition |
||||
|
- Create composable services for business logic |
||||
|
- Implement factory patterns for component creation |
||||
|
|
||||
|
- **State Management** |
||||
|
- Centralize state in Pinia stores |
||||
|
- Use computed properties for derived state |
||||
|
- Implement shared state selectors |
||||
|
- Create reusable state mutations |
||||
|
- Use action creators for common operations |
||||
|
|
||||
|
- **Error Handling** |
||||
|
- Implement centralized error handling |
||||
|
- Create reusable error components |
||||
|
- Use error boundary components |
||||
|
- Implement consistent error logging |
||||
|
- Create error type definitions |
||||
|
|
||||
|
- **Type Definitions** |
||||
|
- Create shared interfaces for common data structures |
||||
|
- Use type aliases for complex types |
||||
|
- Implement generic types for reusable components |
||||
|
- Create utility types for common patterns |
||||
|
- Use discriminated unions for state management |
||||
|
|
||||
|
- **API Integration** |
||||
|
- Create reusable API client classes |
||||
|
- Implement request/response interceptors |
||||
|
- Use consistent error handling patterns |
||||
|
- Create type-safe API endpoints |
||||
|
- Implement caching strategies |
||||
|
|
||||
|
- **Platform Services** |
||||
|
- Abstract platform-specific code behind interfaces |
||||
|
- Create platform-agnostic service layers |
||||
|
- Implement feature detection |
||||
|
- Use dependency injection for services |
||||
|
- Create service factories |
||||
|
|
||||
|
- **Testing** |
||||
|
- Create reusable test utilities |
||||
|
- Implement test factories |
||||
|
- Use shared test configurations |
||||
|
- Create reusable test helpers |
||||
|
- Implement consistent test patterns |
||||
|
|
||||
|
### SOLID Principles |
||||
|
- **Single Responsibility**: Each class/component should have only one reason to change |
||||
|
- Components should focus on one specific feature (e.g., QR scanning, DID management) |
||||
|
- Services should handle one type of functionality (e.g., platform services, crypto services) |
||||
|
- Utilities should provide focused helper functions |
||||
|
|
||||
|
- **Open/Closed**: Software entities should be open for extension but closed for modification |
||||
|
- Use interfaces for service definitions |
||||
|
- Implement plugin architecture for platform-specific features |
||||
|
- Allow component behavior extension through props and events |
||||
|
|
||||
|
- **Liskov Substitution**: Objects should be replaceable with their subtypes |
||||
|
- Platform services should work consistently across web/mobile |
||||
|
- Authentication providers should be interchangeable |
||||
|
- Storage implementations should be swappable |
||||
|
|
||||
|
- **Interface Segregation**: Clients shouldn't depend on interfaces they don't use |
||||
|
- Break down large service interfaces into smaller, focused ones |
||||
|
- Component props should be minimal and purposeful |
||||
|
- Event emissions should be specific and targeted |
||||
|
|
||||
|
- **Dependency Inversion**: High-level modules shouldn't depend on low-level modules |
||||
|
- Use dependency injection for services |
||||
|
- Abstract platform-specific code behind interfaces |
||||
|
- Implement factory patterns for component creation |
||||
|
|
||||
|
### Law of Demeter |
||||
|
- Components should only communicate with immediate dependencies |
||||
|
- Avoid chaining method calls (e.g., `this.service.getUser().getProfile().getName()`) |
||||
|
- Use mediator patterns for complex component interactions |
||||
|
- Implement facade patterns for subsystem access |
||||
|
- Keep component communication through defined events and props |
||||
|
|
||||
|
### Composition over Inheritance |
||||
|
- Prefer building components through composition |
||||
|
- Use mixins for shared functionality |
||||
|
- Implement feature toggles through props |
||||
|
- Create higher-order components for common patterns |
||||
|
- Use service composition for complex features |
||||
|
|
||||
|
### Interface Segregation |
||||
|
- Define clear interfaces for services |
||||
|
- Keep component APIs minimal and focused |
||||
|
- Split large interfaces into smaller, specific ones |
||||
|
- Use TypeScript interfaces for type definitions |
||||
|
- Implement role-based interfaces for different use cases |
||||
|
|
||||
|
### Fail Fast |
||||
|
- Validate inputs early in the process |
||||
|
- Use TypeScript strict mode |
||||
|
- Implement comprehensive error handling |
||||
|
- Add runtime checks for critical operations |
||||
|
- Use assertions for development-time validation |
||||
|
|
||||
|
### Principle of Least Astonishment |
||||
|
- Follow Vue.js conventions consistently |
||||
|
- Use familiar naming patterns |
||||
|
- Implement predictable component behaviors |
||||
|
- Maintain consistent error handling |
||||
|
- Keep UI interactions intuitive |
||||
|
|
||||
|
### Information Hiding |
||||
|
- Encapsulate implementation details |
||||
|
- Use private class members |
||||
|
- Implement proper access modifiers |
||||
|
- Hide complex logic behind simple interfaces |
||||
|
- Use TypeScript's access modifiers effectively |
||||
|
|
||||
|
### Single Source of Truth |
||||
|
- Use Pinia for state management |
||||
|
- Maintain one source for user data |
||||
|
- Centralize configuration management |
||||
|
- Use computed properties for derived state |
||||
|
- Implement proper state synchronization |
||||
|
|
||||
|
### Principle of Least Privilege |
||||
|
- Implement proper access control |
||||
|
- Use minimal required permissions |
||||
|
- Follow privacy-by-design principles |
||||
|
- Restrict component access to necessary data |
||||
|
- Implement proper authentication/authorization |
||||
|
|
||||
|
### Continuous Integration/Continuous Deployment (CI/CD) |
||||
|
- Automated testing on every commit |
||||
|
- Consistent build process across platforms |
||||
|
- Automated deployment pipelines |
||||
|
- Quality gates for code merging |
||||
|
- Environment-specific configurations |
||||
|
|
||||
|
This expanded documentation provides: |
||||
|
1. Clear principles for development |
||||
|
2. Practical implementation guidelines |
||||
|
3. Real-world examples |
||||
|
4. TypeScript integration |
||||
|
5. Best practices for Time Safari |
||||
|
|
@ -0,0 +1,42 @@ |
|||||
|
# Build stage |
||||
|
FROM node:22-alpine AS builder |
||||
|
|
||||
|
# Install build dependencies |
||||
|
|
||||
|
RUN apk add --no-cache \ |
||||
|
python3 \ |
||||
|
py3-pip \ |
||||
|
py3-setuptools \ |
||||
|
make \ |
||||
|
g++ \ |
||||
|
gcc |
||||
|
|
||||
|
# Set working directory |
||||
|
WORKDIR /app |
||||
|
|
||||
|
# Copy package files |
||||
|
COPY package*.json ./ |
||||
|
|
||||
|
# Install dependencies |
||||
|
RUN npm ci |
||||
|
|
||||
|
# Copy source code |
||||
|
COPY . . |
||||
|
|
||||
|
# Build the application |
||||
|
RUN npm run build:web |
||||
|
|
||||
|
# Production stage |
||||
|
FROM nginx:alpine |
||||
|
|
||||
|
# Copy built assets from builder stage |
||||
|
COPY --from=builder /app/dist /usr/share/nginx/html |
||||
|
|
||||
|
# Copy nginx configuration if needed |
||||
|
# COPY nginx.conf /etc/nginx/conf.d/default.conf |
||||
|
|
||||
|
# Expose port 80 |
||||
|
EXPOSE 80 |
||||
|
|
||||
|
# Start nginx |
||||
|
CMD ["nginx", "-g", "daemon off;"] |
@ -1,2 +0,0 @@ |
|||||
#Fri Mar 21 07:27:50 UTC 2025 |
|
||||
gradle.version=8.2.1 |
|
@ -1,2 +0,0 @@ |
|||||
/build/* |
|
||||
!/build/.npmkeep |
|
@ -0,0 +1,28 @@ |
|||||
|
{ |
||||
|
"project_info": { |
||||
|
"project_number": "123456789000", |
||||
|
"project_id": "timesafari-app", |
||||
|
"storage_bucket": "timesafari-app.appspot.com" |
||||
|
}, |
||||
|
"client": [ |
||||
|
{ |
||||
|
"client_info": { |
||||
|
"mobilesdk_app_id": "1:123456789000:android:1234567890abcdef", |
||||
|
"android_client_info": { |
||||
|
"package_name": "app.timesafari.app" |
||||
|
} |
||||
|
}, |
||||
|
"oauth_client": [], |
||||
|
"api_key": [ |
||||
|
{ |
||||
|
"current_key": "AIzaSyDummyKeyForBuildPurposesOnly12345" |
||||
|
} |
||||
|
], |
||||
|
"services": { |
||||
|
"appinvite_service": { |
||||
|
"other_platform_oauth_client": [] |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
] |
||||
|
} |
@ -1,6 +1,26 @@ |
|||||
[ |
[ |
||||
|
{ |
||||
|
"pkg": "@capacitor-mlkit/barcode-scanning", |
||||
|
"classpath": "io.capawesome.capacitorjs.plugins.mlkit.barcodescanning.BarcodeScannerPlugin" |
||||
|
}, |
||||
{ |
{ |
||||
"pkg": "@capacitor/app", |
"pkg": "@capacitor/app", |
||||
"classpath": "com.capacitorjs.plugins.app.AppPlugin" |
"classpath": "com.capacitorjs.plugins.app.AppPlugin" |
||||
|
}, |
||||
|
{ |
||||
|
"pkg": "@capacitor/camera", |
||||
|
"classpath": "com.capacitorjs.plugins.camera.CameraPlugin" |
||||
|
}, |
||||
|
{ |
||||
|
"pkg": "@capacitor/filesystem", |
||||
|
"classpath": "com.capacitorjs.plugins.filesystem.FilesystemPlugin" |
||||
|
}, |
||||
|
{ |
||||
|
"pkg": "@capacitor/share", |
||||
|
"classpath": "com.capacitorjs.plugins.share.SharePlugin" |
||||
|
}, |
||||
|
{ |
||||
|
"pkg": "@capawesome/capacitor-file-picker", |
||||
|
"classpath": "io.capawesome.capacitorjs.plugins.filepicker.FilePickerPlugin" |
||||
} |
} |
||||
] |
] |
||||
|
@ -1,17 +0,0 @@ |
|||||
<!DOCTYPE html> |
|
||||
<html lang=""> |
|
||||
<head> |
|
||||
<meta charset="utf-8"> |
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge"> |
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1.0"> |
|
||||
<link rel="icon" href="/favicon.ico"> |
|
||||
<title>TimeSafari</title> |
|
||||
<script type="module" crossorigin src="/assets/index-CZMUlUNO.js"></script> |
|
||||
</head> |
|
||||
<body> |
|
||||
<noscript> |
|
||||
<strong>We're sorry but TimeSafari doesn't work properly without JavaScript enabled. Please enable it to continue.</strong> |
|
||||
</noscript> |
|
||||
<div id="app"></div> |
|
||||
</body> |
|
||||
</html> |
|
After Width: | Height: | Size: 4.6 KiB |
After Width: | Height: | Size: 7.3 KiB |
After Width: | Height: | Size: 2.1 KiB |
After Width: | Height: | Size: 4.3 KiB |
After Width: | Height: | Size: 6.9 KiB |
After Width: | Height: | Size: 10 KiB |
After Width: | Height: | Size: 12 KiB |
After Width: | Height: | Size: 17 KiB |
After Width: | Height: | Size: 18 KiB |
After Width: | Height: | Size: 23 KiB |
@ -0,0 +1,2 @@ |
|||||
|
|
||||
|
Application icons are here. They are processed for android & ios by the `capacitor-assets` command, as indicated in the BUILDING.md file. |
@ -0,0 +1,4 @@ |
|||||
|
#!/bin/bash |
||||
|
export IMAGENAME="$(basename $PWD):1.0" |
||||
|
|
||||
|
docker build . --network=host -t $IMAGENAME --no-cache |
@ -0,0 +1,21 @@ |
|||||
|
{ |
||||
|
"appId": "app.timesafari", |
||||
|
"appName": "TimeSafari", |
||||
|
"webDir": "dist", |
||||
|
"bundledWebRuntime": false, |
||||
|
"server": { |
||||
|
"cleartext": true |
||||
|
}, |
||||
|
"plugins": { |
||||
|
"App": { |
||||
|
"appUrlOpen": { |
||||
|
"handlers": [ |
||||
|
{ |
||||
|
"url": "timesafari://*", |
||||
|
"autoVerify": true |
||||
|
} |
||||
|
] |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
@ -1,25 +0,0 @@ |
|||||
import { CapacitorConfig } from '@capacitor/cli'; |
|
||||
|
|
||||
const config: CapacitorConfig = { |
|
||||
appId: 'app.timesafari', |
|
||||
appName: 'TimeSafari', |
|
||||
webDir: 'dist', |
|
||||
bundledWebRuntime: false, |
|
||||
server: { |
|
||||
cleartext: true, |
|
||||
}, |
|
||||
plugins: { |
|
||||
App: { |
|
||||
appUrlOpen: { |
|
||||
handlers: [ |
|
||||
{ |
|
||||
url: "timesafari://*", |
|
||||
autoVerify: true |
|
||||
} |
|
||||
] |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
}; |
|
||||
|
|
||||
export default config; |
|
@ -1,6 +1,6 @@ |
|||||
JWT Creation & Verification |
JWT Creation & Verification |
||||
|
|
||||
To run this in a script, see ./openssl_signing_console.sh |
To run this in a script, see /scripts/openssl_signing_console.sh |
||||
|
|
||||
Prerequisites: openssl, jq |
Prerequisites: openssl, jq |
||||
|
|
@ -0,0 +1,805 @@ |
|||||
|
# QR Code Implementation Guide |
||||
|
|
||||
|
## Overview |
||||
|
|
||||
|
This document describes the QR code scanning and generation implementation in the TimeSafari application. The system uses a platform-agnostic design with specific implementations for web and mobile platforms. |
||||
|
|
||||
|
## Architecture |
||||
|
|
||||
|
### Directory Structure |
||||
|
``` |
||||
|
src/ |
||||
|
├── services/ |
||||
|
│ └── QRScanner/ |
||||
|
│ ├── types.ts # Core interfaces and types |
||||
|
│ ├── QRScannerFactory.ts # Factory for creating scanner instances |
||||
|
│ ├── CapacitorQRScanner.ts # Mobile implementation using MLKit |
||||
|
│ ├── WebInlineQRScanner.ts # Web implementation using MediaDevices API |
||||
|
│ └── interfaces.ts # Additional interfaces |
||||
|
├── components/ |
||||
|
│ └── QRScanner/ |
||||
|
│ └── QRScannerDialog.vue # Shared UI component |
||||
|
└── views/ |
||||
|
├── ContactQRScanView.vue # Dedicated scanning view |
||||
|
└── ContactQRScanShowView.vue # Combined QR display and scanning view |
||||
|
``` |
||||
|
|
||||
|
### Core Components |
||||
|
|
||||
|
1. **Factory Pattern** |
||||
|
- `QRScannerFactory` - Creates appropriate scanner instance based on platform |
||||
|
- Common interface `QRScannerService` implemented by all scanners |
||||
|
- Platform detection via Capacitor and build flags |
||||
|
|
||||
|
2. **Platform-Specific Implementations** |
||||
|
- `CapacitorQRScanner` - Native mobile implementation using MLKit |
||||
|
- `WebInlineQRScanner` - Web browser implementation using MediaDevices API |
||||
|
- `QRScannerDialog.vue` - Shared UI component |
||||
|
|
||||
|
3. **View Components** |
||||
|
- `ContactQRScanView` - Dedicated view for scanning QR codes |
||||
|
- `ContactQRScanShowView` - Combined view for displaying and scanning QR codes |
||||
|
|
||||
|
## Implementation Details |
||||
|
|
||||
|
### Core Interfaces |
||||
|
|
||||
|
```typescript |
||||
|
interface QRScannerService { |
||||
|
checkPermissions(): Promise<boolean>; |
||||
|
requestPermissions(): Promise<boolean>; |
||||
|
isSupported(): Promise<boolean>; |
||||
|
startScan(options?: QRScannerOptions): Promise<void>; |
||||
|
stopScan(): Promise<void>; |
||||
|
addListener(listener: ScanListener): void; |
||||
|
onStream(callback: (stream: MediaStream | null) => void): void; |
||||
|
cleanup(): Promise<void>; |
||||
|
getAvailableCameras(): Promise<MediaDeviceInfo[]>; |
||||
|
switchCamera(deviceId: string): Promise<void>; |
||||
|
getCurrentCamera(): Promise<MediaDeviceInfo | null>; |
||||
|
} |
||||
|
|
||||
|
interface ScanListener { |
||||
|
onScan: (result: string) => void; |
||||
|
onError?: (error: Error) => void; |
||||
|
} |
||||
|
|
||||
|
interface QRScannerOptions { |
||||
|
camera?: "front" | "back"; |
||||
|
showPreview?: boolean; |
||||
|
playSound?: boolean; |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
### Platform-Specific Implementations |
||||
|
|
||||
|
#### Mobile (Capacitor) |
||||
|
- Uses `@capacitor-mlkit/barcode-scanning` |
||||
|
- Native camera access through platform APIs |
||||
|
- Optimized for mobile performance |
||||
|
- Supports both iOS and Android |
||||
|
- Real-time QR code detection |
||||
|
- Back camera preferred for scanning |
||||
|
|
||||
|
Configuration: |
||||
|
```typescript |
||||
|
// capacitor.config.ts |
||||
|
const config: CapacitorConfig = { |
||||
|
plugins: { |
||||
|
MLKitBarcodeScanner: { |
||||
|
formats: ['QR_CODE'], |
||||
|
detectorSize: 1.0, |
||||
|
lensFacing: 'back', |
||||
|
googleBarcodeScannerModuleInstallState: true, |
||||
|
// Additional camera options |
||||
|
cameraOptions: { |
||||
|
quality: 0.8, |
||||
|
allowEditing: false, |
||||
|
resultType: 'uri', |
||||
|
sourceType: 'CAMERA', |
||||
|
saveToGallery: false |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
}; |
||||
|
``` |
||||
|
|
||||
|
#### Web |
||||
|
- Uses browser's MediaDevices API |
||||
|
- Vue.js components for UI |
||||
|
- EventEmitter for stream management |
||||
|
- Browser-based camera access |
||||
|
- Inline camera preview |
||||
|
- Responsive design |
||||
|
- Cross-browser compatibility |
||||
|
|
||||
|
### View Components |
||||
|
|
||||
|
#### ContactQRScanView |
||||
|
- Dedicated view for scanning QR codes |
||||
|
- Full-screen camera interface |
||||
|
- Simple UI focused on scanning |
||||
|
- Used primarily on native platforms |
||||
|
- Streamlined scanning experience |
||||
|
|
||||
|
#### ContactQRScanShowView |
||||
|
- Combined view for QR code display and scanning |
||||
|
- Shows user's own QR code |
||||
|
- Handles user registration status |
||||
|
- Provides options to copy contact information |
||||
|
- Platform-specific scanning implementation: |
||||
|
- Native: Button to navigate to ContactQRScanView |
||||
|
- Web: Built-in scanning functionality |
||||
|
|
||||
|
### QR Code Workflow |
||||
|
|
||||
|
1. **Initiation** |
||||
|
- User selects "Scan QR Code" option |
||||
|
- Platform-specific scanner is initialized |
||||
|
- Camera permissions are verified |
||||
|
- Appropriate scanner component is loaded |
||||
|
|
||||
|
2. **Platform-Specific Implementation** |
||||
|
- Web: Uses `qrcode-stream` for real-time scanning |
||||
|
- Native: Uses `@capacitor-mlkit/barcode-scanning` |
||||
|
|
||||
|
3. **Scanning Process** |
||||
|
- Camera stream initialization |
||||
|
- Real-time frame analysis |
||||
|
- QR code detection and decoding |
||||
|
- Validation of QR code format |
||||
|
- Processing of contact information |
||||
|
|
||||
|
4. **Contact Processing** |
||||
|
- Decryption of contact data |
||||
|
- Validation of user information |
||||
|
- Verification of timestamp |
||||
|
- Check for duplicate contacts |
||||
|
- Processing of shared data |
||||
|
|
||||
|
## Build Configuration |
||||
|
|
||||
|
### Common Vite Configuration |
||||
|
```typescript |
||||
|
// vite.config.common.mts |
||||
|
export async function createBuildConfig(mode: string) { |
||||
|
const isCapacitor = mode === "capacitor"; |
||||
|
|
||||
|
return defineConfig({ |
||||
|
define: { |
||||
|
'process.env.VITE_PLATFORM': JSON.stringify(mode), |
||||
|
'process.env.VITE_PWA_ENABLED': JSON.stringify(!isNative), |
||||
|
__IS_MOBILE__: JSON.stringify(isCapacitor), |
||||
|
__USE_QR_READER__: JSON.stringify(!isCapacitor) |
||||
|
}, |
||||
|
optimizeDeps: { |
||||
|
include: [ |
||||
|
'@capacitor-mlkit/barcode-scanning', |
||||
|
'vue-qrcode-reader' |
||||
|
] |
||||
|
} |
||||
|
}); |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
### Platform-Specific Builds |
||||
|
```json |
||||
|
{ |
||||
|
"scripts": { |
||||
|
"build:web": "vite build --config vite.config.web.mts", |
||||
|
"build:capacitor": "vite build --config vite.config.capacitor.mts", |
||||
|
"build:all": "npm run build:web && npm run build:capacitor" |
||||
|
} |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
## Error Handling |
||||
|
|
||||
|
### Common Error Scenarios |
||||
|
1. No camera found |
||||
|
2. Permission denied |
||||
|
3. Camera in use by another application |
||||
|
4. HTTPS required |
||||
|
5. Browser compatibility issues |
||||
|
6. Invalid QR code format |
||||
|
7. Expired QR codes |
||||
|
8. Duplicate contact attempts |
||||
|
9. Network connectivity issues |
||||
|
|
||||
|
### Error Response |
||||
|
- User-friendly error messages |
||||
|
- Troubleshooting tips |
||||
|
- Clear instructions for resolution |
||||
|
- Platform-specific guidance |
||||
|
|
||||
|
## Security Considerations |
||||
|
|
||||
|
### QR Code Security |
||||
|
- Encryption of contact data |
||||
|
- Timestamp validation |
||||
|
- Version checking |
||||
|
- User verification |
||||
|
- Rate limiting for scans |
||||
|
|
||||
|
### Data Protection |
||||
|
- Secure transmission of contact data |
||||
|
- Validation of QR code authenticity |
||||
|
- Prevention of duplicate scans |
||||
|
- Protection against malicious codes |
||||
|
- Secure storage of contact information |
||||
|
|
||||
|
## Best Practices |
||||
|
|
||||
|
### Camera Access |
||||
|
1. Always check for camera availability |
||||
|
2. Request permissions explicitly |
||||
|
3. Handle all error conditions |
||||
|
4. Provide clear user feedback |
||||
|
5. Implement proper cleanup |
||||
|
|
||||
|
### Performance |
||||
|
1. Optimize camera resolution |
||||
|
2. Implement proper resource cleanup |
||||
|
3. Handle camera switching efficiently |
||||
|
4. Manage memory usage |
||||
|
5. Battery usage optimization |
||||
|
|
||||
|
### User Experience |
||||
|
1. Clear visual feedback |
||||
|
2. Camera preview |
||||
|
3. Scanning status indicators |
||||
|
4. Error messages |
||||
|
5. Success confirmations |
||||
|
6. Intuitive camera controls |
||||
|
7. Smooth camera switching |
||||
|
8. Responsive UI feedback |
||||
|
|
||||
|
## Testing |
||||
|
|
||||
|
### Test Scenarios |
||||
|
1. Permission handling |
||||
|
2. Camera switching |
||||
|
3. Error conditions |
||||
|
4. Platform compatibility |
||||
|
5. Performance metrics |
||||
|
6. QR code detection |
||||
|
7. Contact processing |
||||
|
8. Security validation |
||||
|
|
||||
|
### Test Environment |
||||
|
- Multiple browsers |
||||
|
- iOS and Android devices |
||||
|
- Various network conditions |
||||
|
- Different camera configurations |
||||
|
|
||||
|
## Dependencies |
||||
|
|
||||
|
### Key Packages |
||||
|
- `@capacitor-mlkit/barcode-scanning` |
||||
|
- `qrcode-stream` |
||||
|
- `vue-qrcode-reader` |
||||
|
- Platform-specific camera APIs |
||||
|
|
||||
|
## Maintenance |
||||
|
|
||||
|
### Regular Updates |
||||
|
- Keep dependencies updated |
||||
|
- Monitor platform changes |
||||
|
- Update documentation |
||||
|
- Review security patches |
||||
|
|
||||
|
### Performance Monitoring |
||||
|
- Track memory usage |
||||
|
- Monitor camera performance |
||||
|
- Check error rates |
||||
|
- Analyze user feedback |
||||
|
|
||||
|
## Camera Handling |
||||
|
|
||||
|
### Camera Switching Implementation |
||||
|
|
||||
|
The QR scanner supports camera switching on both mobile and desktop platforms through a unified interface. |
||||
|
|
||||
|
#### Platform-Specific Implementations |
||||
|
|
||||
|
1. **Mobile (Capacitor)** |
||||
|
- Uses `@capacitor-mlkit/barcode-scanning` |
||||
|
- Supports front/back camera switching |
||||
|
- Native camera access through platform APIs |
||||
|
- Optimized for mobile performance |
||||
|
|
||||
|
```typescript |
||||
|
// CapacitorQRScanner.ts |
||||
|
async startScan(options?: QRScannerOptions): Promise<void> { |
||||
|
const scanOptions: StartScanOptions = { |
||||
|
formats: [BarcodeFormat.QrCode], |
||||
|
lensFacing: options?.camera === "front" ? |
||||
|
LensFacing.Front : LensFacing.Back |
||||
|
}; |
||||
|
await BarcodeScanner.startScan(scanOptions); |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
2. **Web (Desktop)** |
||||
|
- Uses browser's MediaDevices API |
||||
|
- Supports multiple camera devices |
||||
|
- Dynamic camera enumeration |
||||
|
- Real-time camera switching |
||||
|
|
||||
|
```typescript |
||||
|
// WebInlineQRScanner.ts |
||||
|
async getAvailableCameras(): Promise<MediaDeviceInfo[]> { |
||||
|
const devices = await navigator.mediaDevices.enumerateDevices(); |
||||
|
return devices.filter(device => device.kind === 'videoinput'); |
||||
|
} |
||||
|
|
||||
|
async switchCamera(deviceId: string): Promise<void> { |
||||
|
// Stop current stream |
||||
|
await this.stopScan(); |
||||
|
|
||||
|
// Start new stream with selected camera |
||||
|
this.stream = await navigator.mediaDevices.getUserMedia({ |
||||
|
video: { |
||||
|
deviceId: { exact: deviceId }, |
||||
|
width: { ideal: 1280 }, |
||||
|
height: { ideal: 720 } |
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
// Update video and restart scanning |
||||
|
if (this.video) { |
||||
|
this.video.srcObject = this.stream; |
||||
|
await this.video.play(); |
||||
|
} |
||||
|
this.scanQRCode(); |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
### Core Interfaces |
||||
|
|
||||
|
```typescript |
||||
|
interface QRScannerService { |
||||
|
// ... existing methods ... |
||||
|
|
||||
|
/** Get available cameras */ |
||||
|
getAvailableCameras(): Promise<MediaDeviceInfo[]>; |
||||
|
|
||||
|
/** Switch to a specific camera */ |
||||
|
switchCamera(deviceId: string): Promise<void>; |
||||
|
|
||||
|
/** Get current camera info */ |
||||
|
getCurrentCamera(): Promise<MediaDeviceInfo | null>; |
||||
|
} |
||||
|
|
||||
|
interface QRScannerOptions { |
||||
|
/** Camera to use ('front' or 'back' for mobile) */ |
||||
|
camera?: "front" | "back"; |
||||
|
/** Whether to show a preview of the camera feed */ |
||||
|
showPreview?: boolean; |
||||
|
/** Whether to play a sound on successful scan */ |
||||
|
playSound?: boolean; |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
### UI Components |
||||
|
|
||||
|
The camera switching UI adapts to the platform: |
||||
|
|
||||
|
1. **Mobile Interface** |
||||
|
- Simple toggle button for front/back cameras |
||||
|
- Positioned in bottom-right corner |
||||
|
- Clear visual feedback during switching |
||||
|
- Native camera controls |
||||
|
|
||||
|
```vue |
||||
|
<button |
||||
|
v-if="isNativePlatform" |
||||
|
@click="toggleMobileCamera" |
||||
|
class="camera-switch-btn" |
||||
|
> |
||||
|
<font-awesome icon="camera-rotate" /> |
||||
|
Switch Camera |
||||
|
</button> |
||||
|
``` |
||||
|
|
||||
|
2. **Desktop Interface** |
||||
|
- Dropdown menu with all available cameras |
||||
|
- Camera labels and device IDs |
||||
|
- Real-time camera switching |
||||
|
- Responsive design |
||||
|
|
||||
|
```vue |
||||
|
<select |
||||
|
v-model="selectedCameraId" |
||||
|
@change="onCameraChange" |
||||
|
class="camera-select-dropdown" |
||||
|
> |
||||
|
<option |
||||
|
v-for="camera in availableCameras" |
||||
|
:key="camera.deviceId" |
||||
|
:value="camera.deviceId" |
||||
|
> |
||||
|
{{ camera.label || `Camera ${camera.deviceId.slice(0, 4)}` }} |
||||
|
</option> |
||||
|
</select> |
||||
|
``` |
||||
|
|
||||
|
### Error Handling |
||||
|
|
||||
|
The camera switching implementation includes comprehensive error handling: |
||||
|
|
||||
|
1. **Common Error Scenarios** |
||||
|
- Camera in use by another application |
||||
|
- Permission denied during switch |
||||
|
- Device not available |
||||
|
- Stream initialization failure |
||||
|
- Camera switch timeout |
||||
|
|
||||
|
2. **Error Response** |
||||
|
```typescript |
||||
|
private async handleCameraSwitch(deviceId: string): Promise<void> { |
||||
|
try { |
||||
|
this.updateCameraState("initializing", "Switching camera..."); |
||||
|
await this.switchCamera(deviceId); |
||||
|
this.updateCameraState("active", "Camera switched successfully"); |
||||
|
} catch (error) { |
||||
|
this.updateCameraState("error", "Failed to switch camera"); |
||||
|
throw error; |
||||
|
} |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
3. **User Feedback** |
||||
|
- Visual indicators during switching |
||||
|
- Error notifications |
||||
|
- Camera state updates |
||||
|
- Permission request dialogs |
||||
|
|
||||
|
### State Management |
||||
|
|
||||
|
The camera system maintains several states: |
||||
|
|
||||
|
1. **Camera States** |
||||
|
```typescript |
||||
|
type CameraState = |
||||
|
| "initializing" // Camera is being initialized |
||||
|
| "ready" // Camera is ready to use |
||||
|
| "active" // Camera is actively streaming |
||||
|
| "in_use" // Camera is in use by another application |
||||
|
| "permission_denied" // Camera permission was denied |
||||
|
| "not_found" // No camera found on device |
||||
|
| "error" // Generic error state |
||||
|
| "off"; // Camera is off |
||||
|
``` |
||||
|
|
||||
|
2. **State Transitions** |
||||
|
- Initialization → Ready |
||||
|
- Ready → Active |
||||
|
- Active → Switching |
||||
|
- Switching → Active/Error |
||||
|
- Any state → Off (on cleanup) |
||||
|
|
||||
|
### Best Practices |
||||
|
|
||||
|
1. **Camera Access** |
||||
|
- Always check permissions before switching |
||||
|
- Handle camera busy states |
||||
|
- Implement proper cleanup |
||||
|
- Monitor camera state changes |
||||
|
|
||||
|
2. **Performance** |
||||
|
- Optimize camera resolution |
||||
|
- Handle stream switching efficiently |
||||
|
- Manage memory usage |
||||
|
- Implement proper cleanup |
||||
|
|
||||
|
3. **User Experience** |
||||
|
- Clear visual feedback |
||||
|
- Smooth camera transitions |
||||
|
- Intuitive camera controls |
||||
|
- Responsive UI updates |
||||
|
- Accessible camera selection |
||||
|
|
||||
|
4. **Security** |
||||
|
- Secure camera access |
||||
|
- Permission management |
||||
|
- Device validation |
||||
|
- Stream security |
||||
|
|
||||
|
### Testing |
||||
|
|
||||
|
1. **Test Scenarios** |
||||
|
- Camera switching on both platforms |
||||
|
- Permission handling |
||||
|
- Error conditions |
||||
|
- Multiple camera devices |
||||
|
- Camera busy states |
||||
|
- Stream initialization |
||||
|
- UI responsiveness |
||||
|
|
||||
|
2. **Test Environment** |
||||
|
- Multiple mobile devices |
||||
|
- Various desktop browsers |
||||
|
- Different camera configurations |
||||
|
- Network conditions |
||||
|
- Permission states |
||||
|
|
||||
|
### Capacitor Implementation Details |
||||
|
|
||||
|
#### MLKit Barcode Scanner Configuration |
||||
|
|
||||
|
1. **Plugin Setup** |
||||
|
```typescript |
||||
|
// capacitor.config.ts |
||||
|
const config: CapacitorConfig = { |
||||
|
plugins: { |
||||
|
MLKitBarcodeScanner: { |
||||
|
formats: ['QR_CODE'], |
||||
|
detectorSize: 1.0, |
||||
|
lensFacing: 'back', |
||||
|
googleBarcodeScannerModuleInstallState: true, |
||||
|
// Additional camera options |
||||
|
cameraOptions: { |
||||
|
quality: 0.8, |
||||
|
allowEditing: false, |
||||
|
resultType: 'uri', |
||||
|
sourceType: 'CAMERA', |
||||
|
saveToGallery: false |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
}; |
||||
|
``` |
||||
|
|
||||
|
2. **Camera Management** |
||||
|
```typescript |
||||
|
// CapacitorQRScanner.ts |
||||
|
export class CapacitorQRScanner implements QRScannerService { |
||||
|
private currentLensFacing: LensFacing = LensFacing.Back; |
||||
|
|
||||
|
async getAvailableCameras(): Promise<MediaDeviceInfo[]> { |
||||
|
// On mobile, we have two fixed cameras |
||||
|
return [ |
||||
|
{ |
||||
|
deviceId: 'back', |
||||
|
label: 'Back Camera', |
||||
|
kind: 'videoinput' |
||||
|
}, |
||||
|
{ |
||||
|
deviceId: 'front', |
||||
|
label: 'Front Camera', |
||||
|
kind: 'videoinput' |
||||
|
} |
||||
|
] as MediaDeviceInfo[]; |
||||
|
} |
||||
|
|
||||
|
async switchCamera(deviceId: string): Promise<void> { |
||||
|
if (!this.isScanning) return; |
||||
|
|
||||
|
const newLensFacing = deviceId === 'front' ? |
||||
|
LensFacing.Front : LensFacing.Back; |
||||
|
|
||||
|
// Stop current scan |
||||
|
await this.stopScan(); |
||||
|
|
||||
|
// Update lens facing |
||||
|
this.currentLensFacing = newLensFacing; |
||||
|
|
||||
|
// Restart scan with new camera |
||||
|
await this.startScan({ |
||||
|
camera: deviceId as 'front' | 'back' |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
async getCurrentCamera(): Promise<MediaDeviceInfo | null> { |
||||
|
return { |
||||
|
deviceId: this.currentLensFacing === LensFacing.Front ? 'front' : 'back', |
||||
|
label: this.currentLensFacing === LensFacing.Front ? |
||||
|
'Front Camera' : 'Back Camera', |
||||
|
kind: 'videoinput' |
||||
|
} as MediaDeviceInfo; |
||||
|
} |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
3. **Camera State Management** |
||||
|
```typescript |
||||
|
// CapacitorQRScanner.ts |
||||
|
private async handleCameraState(): Promise<void> { |
||||
|
try { |
||||
|
// Check if camera is available |
||||
|
const { camera } = await BarcodeScanner.checkPermissions(); |
||||
|
|
||||
|
if (camera === 'denied') { |
||||
|
this.updateCameraState('permission_denied'); |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
// Check if camera is in use |
||||
|
const isInUse = await this.isCameraInUse(); |
||||
|
if (isInUse) { |
||||
|
this.updateCameraState('in_use'); |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
this.updateCameraState('ready'); |
||||
|
} catch (error) { |
||||
|
this.updateCameraState('error', error.message); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private async isCameraInUse(): Promise<boolean> { |
||||
|
try { |
||||
|
// Try to start a test scan |
||||
|
await BarcodeScanner.startScan({ |
||||
|
formats: [BarcodeFormat.QrCode], |
||||
|
lensFacing: this.currentLensFacing |
||||
|
}); |
||||
|
// If successful, stop it immediately |
||||
|
await BarcodeScanner.stopScan(); |
||||
|
return false; |
||||
|
} catch (error) { |
||||
|
return error.message.includes('camera in use'); |
||||
|
} |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
4. **Error Handling** |
||||
|
```typescript |
||||
|
// CapacitorQRScanner.ts |
||||
|
private async handleCameraError(error: Error): Promise<void> { |
||||
|
switch (error.name) { |
||||
|
case 'CameraPermissionDenied': |
||||
|
this.updateCameraState('permission_denied'); |
||||
|
break; |
||||
|
case 'CameraInUse': |
||||
|
this.updateCameraState('in_use'); |
||||
|
break; |
||||
|
case 'CameraUnavailable': |
||||
|
this.updateCameraState('not_found'); |
||||
|
break; |
||||
|
default: |
||||
|
this.updateCameraState('error', error.message); |
||||
|
} |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
#### Platform-Specific Considerations |
||||
|
|
||||
|
1. **iOS Implementation** |
||||
|
- Camera permissions in Info.plist |
||||
|
- Privacy descriptions |
||||
|
- Camera usage description |
||||
|
- Background camera access |
||||
|
|
||||
|
```xml |
||||
|
<!-- ios/App/App/Info.plist --> |
||||
|
<key>NSCameraUsageDescription</key> |
||||
|
<string>We need access to your camera to scan QR codes</string> |
||||
|
<key>NSPhotoLibraryUsageDescription</key> |
||||
|
<string>We need access to save scanned QR codes</string> |
||||
|
``` |
||||
|
|
||||
|
2. **Android Implementation** |
||||
|
- Camera permissions in AndroidManifest.xml |
||||
|
- Runtime permission handling |
||||
|
- Camera features declaration |
||||
|
- Hardware feature requirements |
||||
|
|
||||
|
```xml |
||||
|
<!-- android/app/src/main/AndroidManifest.xml --> |
||||
|
<uses-permission android:name="android.permission.CAMERA" /> |
||||
|
<uses-feature android:name="android.hardware.camera" /> |
||||
|
<uses-feature android:name="android.hardware.camera.autofocus" /> |
||||
|
``` |
||||
|
|
||||
|
3. **Platform-Specific Features** |
||||
|
- iOS: Camera orientation handling |
||||
|
- Android: Camera resolution optimization |
||||
|
- Both: Battery usage optimization |
||||
|
- Both: Memory management |
||||
|
|
||||
|
```typescript |
||||
|
// Platform-specific optimizations |
||||
|
private getPlatformSpecificOptions(): StartScanOptions { |
||||
|
const baseOptions: StartScanOptions = { |
||||
|
formats: [BarcodeFormat.QrCode], |
||||
|
lensFacing: this.currentLensFacing |
||||
|
}; |
||||
|
|
||||
|
if (Capacitor.getPlatform() === 'ios') { |
||||
|
return { |
||||
|
...baseOptions, |
||||
|
// iOS-specific options |
||||
|
cameraOptions: { |
||||
|
quality: 0.7, // Lower quality for better performance |
||||
|
allowEditing: false, |
||||
|
resultType: 'uri' |
||||
|
} |
||||
|
}; |
||||
|
} else if (Capacitor.getPlatform() === 'android') { |
||||
|
return { |
||||
|
...baseOptions, |
||||
|
// Android-specific options |
||||
|
cameraOptions: { |
||||
|
quality: 0.8, |
||||
|
allowEditing: false, |
||||
|
resultType: 'uri', |
||||
|
saveToGallery: false |
||||
|
} |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
return baseOptions; |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
#### Performance Optimization |
||||
|
|
||||
|
1. **Battery Usage** |
||||
|
```typescript |
||||
|
// CapacitorQRScanner.ts |
||||
|
private optimizeBatteryUsage(): void { |
||||
|
// Reduce scan frequency when battery is low |
||||
|
if (this.isLowBattery()) { |
||||
|
this.scanInterval = 2000; // 2 seconds between scans |
||||
|
} else { |
||||
|
this.scanInterval = 1000; // 1 second between scans |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private isLowBattery(): boolean { |
||||
|
// Check battery level if available |
||||
|
if (Capacitor.isPluginAvailable('Battery')) { |
||||
|
const { level } = await Battery.getBatteryLevel(); |
||||
|
return level < 0.2; // 20% or lower |
||||
|
} |
||||
|
return false; |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
2. **Memory Management** |
||||
|
```typescript |
||||
|
// CapacitorQRScanner.ts |
||||
|
private async cleanupResources(): Promise<void> { |
||||
|
// Stop scanning |
||||
|
await this.stopScan(); |
||||
|
|
||||
|
// Clear any stored camera data |
||||
|
this.currentLensFacing = LensFacing.Back; |
||||
|
|
||||
|
// Remove listeners |
||||
|
this.listenerHandles.forEach(handle => handle()); |
||||
|
this.listenerHandles = []; |
||||
|
|
||||
|
// Reset state |
||||
|
this.isScanning = false; |
||||
|
this.updateCameraState('off'); |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
#### Testing on Capacitor |
||||
|
|
||||
|
1. **Device Testing** |
||||
|
- Test on multiple iOS devices |
||||
|
- Test on multiple Android devices |
||||
|
- Test different camera configurations |
||||
|
- Test with different screen sizes |
||||
|
- Test with different OS versions |
||||
|
|
||||
|
2. **Camera Testing** |
||||
|
- Test front camera switching |
||||
|
- Test back camera switching |
||||
|
- Test camera permissions |
||||
|
- Test camera in use scenarios |
||||
|
- Test low light conditions |
||||
|
- Test different QR code sizes |
||||
|
- Test different QR code distances |
||||
|
|
||||
|
3. **Performance Testing** |
||||
|
- Battery usage monitoring |
||||
|
- Memory usage monitoring |
||||
|
- Camera switching speed |
||||
|
- QR code detection speed |
||||
|
- App responsiveness |
||||
|
- Background/foreground transitions |
@ -0,0 +1,20 @@ |
|||||
|
<?xml version="1.0" encoding="UTF-8"?> |
||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> |
||||
|
<plist version="1.0"> |
||||
|
<dict> |
||||
|
<key>com.apple.security.cs.allow-jit</key> |
||||
|
<true/> |
||||
|
<key>com.apple.security.cs.allow-unsigned-executable-memory</key> |
||||
|
<true/> |
||||
|
<key>com.apple.security.cs.debugger</key> |
||||
|
<true/> |
||||
|
<key>com.apple.security.device.audio-input</key> |
||||
|
<true/> |
||||
|
<key>com.apple.security.device.camera</key> |
||||
|
<true/> |
||||
|
<key>com.apple.security.personal-information.addressbook</key> |
||||
|
<true/> |
||||
|
<key>com.apple.security.personal-information.calendars</key> |
||||
|
<true/> |
||||
|
</dict> |
||||
|
</plist> |
@ -1,28 +1,144 @@ |
|||||
PODS: |
PODS: |
||||
- Capacitor (6.2.0): |
- Capacitor (6.2.1): |
||||
- CapacitorCordova |
- CapacitorCordova |
||||
- CapacitorApp (6.0.2): |
- CapacitorApp (6.0.2): |
||||
- Capacitor |
- Capacitor |
||||
- CapacitorCordova (6.2.0) |
- CapacitorCamera (6.1.2): |
||||
|
- Capacitor |
||||
|
- CapacitorCordova (6.2.1) |
||||
|
- CapacitorFilesystem (6.0.3): |
||||
|
- Capacitor |
||||
|
- CapacitorMlkitBarcodeScanning (6.2.0): |
||||
|
- Capacitor |
||||
|
- GoogleMLKit/BarcodeScanning (= 5.0.0) |
||||
|
- CapacitorShare (6.0.3): |
||||
|
- Capacitor |
||||
|
- CapawesomeCapacitorFilePicker (6.2.0): |
||||
|
- Capacitor |
||||
|
- GoogleDataTransport (9.4.1): |
||||
|
- GoogleUtilities/Environment (~> 7.7) |
||||
|
- nanopb (< 2.30911.0, >= 2.30908.0) |
||||
|
- PromisesObjC (< 3.0, >= 1.2) |
||||
|
- GoogleMLKit/BarcodeScanning (5.0.0): |
||||
|
- GoogleMLKit/MLKitCore |
||||
|
- MLKitBarcodeScanning (~> 4.0.0) |
||||
|
- GoogleMLKit/MLKitCore (5.0.0): |
||||
|
- MLKitCommon (~> 10.0.0) |
||||
|
- GoogleToolboxForMac/DebugUtils (2.3.2): |
||||
|
- GoogleToolboxForMac/Defines (= 2.3.2) |
||||
|
- GoogleToolboxForMac/Defines (2.3.2) |
||||
|
- GoogleToolboxForMac/Logger (2.3.2): |
||||
|
- GoogleToolboxForMac/Defines (= 2.3.2) |
||||
|
- "GoogleToolboxForMac/NSData+zlib (2.3.2)": |
||||
|
- GoogleToolboxForMac/Defines (= 2.3.2) |
||||
|
- "GoogleToolboxForMac/NSDictionary+URLArguments (2.3.2)": |
||||
|
- GoogleToolboxForMac/DebugUtils (= 2.3.2) |
||||
|
- GoogleToolboxForMac/Defines (= 2.3.2) |
||||
|
- "GoogleToolboxForMac/NSString+URLArguments (= 2.3.2)" |
||||
|
- "GoogleToolboxForMac/NSString+URLArguments (2.3.2)" |
||||
|
- GoogleUtilities/Environment (7.13.3): |
||||
|
- GoogleUtilities/Privacy |
||||
|
- PromisesObjC (< 3.0, >= 1.2) |
||||
|
- GoogleUtilities/Logger (7.13.3): |
||||
|
- GoogleUtilities/Environment |
||||
|
- GoogleUtilities/Privacy |
||||
|
- GoogleUtilities/Privacy (7.13.3) |
||||
|
- GoogleUtilities/UserDefaults (7.13.3): |
||||
|
- GoogleUtilities/Logger |
||||
|
- GoogleUtilities/Privacy |
||||
|
- GoogleUtilitiesComponents (1.1.0): |
||||
|
- GoogleUtilities/Logger |
||||
|
- GTMSessionFetcher/Core (3.5.0) |
||||
|
- MLImage (1.0.0-beta5) |
||||
|
- MLKitBarcodeScanning (4.0.0): |
||||
|
- MLKitCommon (~> 10.0) |
||||
|
- MLKitVision (~> 6.0) |
||||
|
- MLKitCommon (10.0.0): |
||||
|
- GoogleDataTransport (~> 9.0) |
||||
|
- GoogleToolboxForMac/Logger (~> 2.1) |
||||
|
- "GoogleToolboxForMac/NSData+zlib (~> 2.1)" |
||||
|
- "GoogleToolboxForMac/NSDictionary+URLArguments (~> 2.1)" |
||||
|
- GoogleUtilities/UserDefaults (~> 7.0) |
||||
|
- GoogleUtilitiesComponents (~> 1.0) |
||||
|
- GTMSessionFetcher/Core (< 4.0, >= 1.1) |
||||
|
- MLKitVision (6.0.0): |
||||
|
- GoogleToolboxForMac/Logger (~> 2.1) |
||||
|
- "GoogleToolboxForMac/NSData+zlib (~> 2.1)" |
||||
|
- GTMSessionFetcher/Core (< 4.0, >= 1.1) |
||||
|
- MLImage (= 1.0.0-beta5) |
||||
|
- MLKitCommon (~> 10.0) |
||||
|
- nanopb (2.30910.0): |
||||
|
- nanopb/decode (= 2.30910.0) |
||||
|
- nanopb/encode (= 2.30910.0) |
||||
|
- nanopb/decode (2.30910.0) |
||||
|
- nanopb/encode (2.30910.0) |
||||
|
- PromisesObjC (2.4.0) |
||||
|
|
||||
DEPENDENCIES: |
DEPENDENCIES: |
||||
- "Capacitor (from `../../node_modules/@capacitor/ios`)" |
- "Capacitor (from `../../node_modules/@capacitor/ios`)" |
||||
- "CapacitorApp (from `../../node_modules/@capacitor/app`)" |
- "CapacitorApp (from `../../node_modules/@capacitor/app`)" |
||||
|
- "CapacitorCamera (from `../../node_modules/@capacitor/camera`)" |
||||
- "CapacitorCordova (from `../../node_modules/@capacitor/ios`)" |
- "CapacitorCordova (from `../../node_modules/@capacitor/ios`)" |
||||
|
- "CapacitorFilesystem (from `../../node_modules/@capacitor/filesystem`)" |
||||
|
- "CapacitorMlkitBarcodeScanning (from `../../node_modules/@capacitor-mlkit/barcode-scanning`)" |
||||
|
- "CapacitorShare (from `../../node_modules/@capacitor/share`)" |
||||
|
- "CapawesomeCapacitorFilePicker (from `../../node_modules/@capawesome/capacitor-file-picker`)" |
||||
|
|
||||
|
SPEC REPOS: |
||||
|
trunk: |
||||
|
- GoogleDataTransport |
||||
|
- GoogleMLKit |
||||
|
- GoogleToolboxForMac |
||||
|
- GoogleUtilities |
||||
|
- GoogleUtilitiesComponents |
||||
|
- GTMSessionFetcher |
||||
|
- MLImage |
||||
|
- MLKitBarcodeScanning |
||||
|
- MLKitCommon |
||||
|
- MLKitVision |
||||
|
- nanopb |
||||
|
- PromisesObjC |
||||
|
|
||||
EXTERNAL SOURCES: |
EXTERNAL SOURCES: |
||||
Capacitor: |
Capacitor: |
||||
:path: "../../node_modules/@capacitor/ios" |
:path: "../../node_modules/@capacitor/ios" |
||||
CapacitorApp: |
CapacitorApp: |
||||
:path: "../../node_modules/@capacitor/app" |
:path: "../../node_modules/@capacitor/app" |
||||
|
CapacitorCamera: |
||||
|
:path: "../../node_modules/@capacitor/camera" |
||||
CapacitorCordova: |
CapacitorCordova: |
||||
:path: "../../node_modules/@capacitor/ios" |
:path: "../../node_modules/@capacitor/ios" |
||||
|
CapacitorFilesystem: |
||||
|
:path: "../../node_modules/@capacitor/filesystem" |
||||
|
CapacitorMlkitBarcodeScanning: |
||||
|
:path: "../../node_modules/@capacitor-mlkit/barcode-scanning" |
||||
|
CapacitorShare: |
||||
|
:path: "../../node_modules/@capacitor/share" |
||||
|
CapawesomeCapacitorFilePicker: |
||||
|
:path: "../../node_modules/@capawesome/capacitor-file-picker" |
||||
|
|
||||
SPEC CHECKSUMS: |
SPEC CHECKSUMS: |
||||
Capacitor: 05d35014f4425b0740fc8776481f6a369ad071bf |
Capacitor: c95400d761e376be9da6be5a05f226c0e865cebf |
||||
CapacitorApp: e1e6b7d05e444d593ca16fd6d76f2b7c48b5aea7 |
CapacitorApp: e1e6b7d05e444d593ca16fd6d76f2b7c48b5aea7 |
||||
CapacitorCordova: b33e7f4aa4ed105dd43283acdd940964374a87d9 |
CapacitorCamera: 9bc7b005d0e6f1d5f525b8137045b60cffffce79 |
||||
|
CapacitorCordova: 8d93e14982f440181be7304aa9559ca631d77fff |
||||
|
CapacitorFilesystem: 59270a63c60836248812671aa3b15df673fbaf74 |
||||
|
CapacitorMlkitBarcodeScanning: 7652be9c7922f39203a361de735d340ae37e134e |
||||
|
CapacitorShare: d2a742baec21c8f3b92b361a2fbd2401cdd8288e |
||||
|
CapawesomeCapacitorFilePicker: c40822f0a39f86855321943c7829d52bca7f01bd |
||||
|
GoogleDataTransport: 6c09b596d841063d76d4288cc2d2f42cc36e1e2a |
||||
|
GoogleMLKit: 90ba06e028795a50261f29500d238d6061538711 |
||||
|
GoogleToolboxForMac: 8bef7c7c5cf7291c687cf5354f39f9db6399ad34 |
||||
|
GoogleUtilities: ea963c370a38a8069cc5f7ba4ca849a60b6d7d15 |
||||
|
GoogleUtilitiesComponents: 679b2c881db3b615a2777504623df6122dd20afe |
||||
|
GTMSessionFetcher: 5aea5ba6bd522a239e236100971f10cb71b96ab6 |
||||
|
MLImage: 1824212150da33ef225fbd3dc49f184cf611046c |
||||
|
MLKitBarcodeScanning: 9cb0ec5ec65bbb5db31de4eba0a3289626beab4e |
||||
|
MLKitCommon: afcd11b6c0735066a0dde8b4bf2331f6197cbca2 |
||||
|
MLKitVision: 90922bca854014a856f8b649d1f1f04f63fd9c79 |
||||
|
nanopb: 438bc412db1928dac798aa6fd75726007be04262 |
||||
|
PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47 |
||||
|
|
||||
PODFILE CHECKSUM: 4233f5c5f414604460ff96d372542c311b0fb7a8 |
PODFILE CHECKSUM: 7e7e09e6937de7f015393aecf2cf7823645689b3 |
||||
|
|
||||
COCOAPODS: 1.16.2 |
COCOAPODS: 1.16.2 |
||||
|
@ -1,5 +1,6 @@ |
|||||
dependencies: |
dependencies: |
||||
- gradle |
- gradle |
||||
- java |
- java |
||||
|
- pod |
||||
|
|
||||
# other dependencies are discovered via package.json & requirements.txt & Gemfile (I'm guessing). |
# other dependencies are discovered via package.json & requirements.txt & Gemfile (I'm guessing). |
||||
|
@ -1,98 +1,243 @@ |
|||||
|
const fs = require('fs'); |
||||
const path = require('path'); |
const path = require('path'); |
||||
const fs = require('fs-extra'); |
|
||||
|
|
||||
async function main() { |
|
||||
try { |
|
||||
console.log('Starting electron build process...'); |
console.log('Starting electron build process...'); |
||||
|
|
||||
// Create dist directory if it doesn't exist
|
|
||||
const distElectronDir = path.resolve(__dirname, '../dist-electron'); |
|
||||
await fs.ensureDir(distElectronDir); |
|
||||
|
|
||||
// Copy web files
|
// Copy web files
|
||||
const wwwDir = path.join(distElectronDir, 'www'); |
const webDistPath = path.join(__dirname, '..', 'dist'); |
||||
await fs.ensureDir(wwwDir); |
const electronDistPath = path.join(__dirname, '..', 'dist-electron'); |
||||
await fs.copy('dist', wwwDir); |
const wwwPath = path.join(electronDistPath, 'www'); |
||||
|
|
||||
|
// Create www directory if it doesn't exist
|
||||
|
if (!fs.existsSync(wwwPath)) { |
||||
|
fs.mkdirSync(wwwPath, { recursive: true }); |
||||
|
} |
||||
|
|
||||
// Copy and fix index.html
|
// Copy web files to www directory
|
||||
const indexPath = path.join(wwwDir, 'index.html'); |
fs.cpSync(webDistPath, wwwPath, { recursive: true }); |
||||
let indexContent = await fs.readFile(indexPath, 'utf8'); |
|
||||
|
|
||||
// More comprehensive path fixing
|
// Fix asset paths in index.html
|
||||
|
const indexPath = path.join(wwwPath, 'index.html'); |
||||
|
let indexContent = fs.readFileSync(indexPath, 'utf8'); |
||||
|
|
||||
|
// Fix asset paths
|
||||
indexContent = indexContent |
indexContent = indexContent |
||||
// Fix absolute paths to be relative
|
.replace(/\/assets\//g, './assets/') |
||||
.replace(/src="\//g, 'src="\./') |
.replace(/href="\//g, 'href="./') |
||||
.replace(/href="\//g, 'href="\./') |
.replace(/src="\//g, 'src="./'); |
||||
// Fix modulepreload paths
|
|
||||
.replace(/<link [^>]*rel="modulepreload"[^>]*href="\/assets\//g, '<link rel="modulepreload" as="script" crossorigin="" href="./assets/') |
|
||||
.replace(/<link [^>]*rel="modulepreload"[^>]*href="\.\/assets\//g, '<link rel="modulepreload" as="script" crossorigin="" href="./assets/') |
|
||||
// Fix stylesheet paths
|
|
||||
.replace(/<link [^>]*rel="stylesheet"[^>]*href="\/assets\//g, '<link rel="stylesheet" crossorigin="" href="./assets/') |
|
||||
.replace(/<link [^>]*rel="stylesheet"[^>]*href="\.\/assets\//g, '<link rel="stylesheet" crossorigin="" href="./assets/') |
|
||||
// Fix script paths
|
|
||||
.replace(/src="\/assets\//g, 'src="./assets/') |
|
||||
.replace(/src="\.\/assets\//g, 'src="./assets/') |
|
||||
// Fix any remaining asset paths
|
|
||||
.replace(/(['"]\/?)(assets\/)/g, '"./assets/'); |
|
||||
|
|
||||
// Debug output
|
|
||||
console.log('After path fixing, checking for remaining /assets/ paths:', indexContent.includes('/assets/')); |
|
||||
console.log('Sample of fixed content:', indexContent.slice(0, 500)); |
|
||||
|
|
||||
await fs.writeFile(indexPath, indexContent); |
fs.writeFileSync(indexPath, indexContent); |
||||
|
|
||||
console.log('Copied and fixed web files in:', wwwDir); |
// Check for remaining /assets/ paths
|
||||
|
console.log('After path fixing, checking for remaining /assets/ paths:', indexContent.includes('/assets/')); |
||||
|
console.log('Sample of fixed content:', indexContent.substring(0, 500)); |
||||
|
|
||||
|
console.log('Copied and fixed web files in:', wwwPath); |
||||
|
|
||||
// Copy main process files
|
// Copy main process files
|
||||
console.log('Copying main process files...'); |
console.log('Copying main process files...'); |
||||
const mainProcessFiles = [ |
|
||||
['src/electron/main.js', 'main.js'], |
|
||||
['src/electron/preload.js', 'preload.js'] |
|
||||
]; |
|
||||
|
|
||||
for (const [src, dest] of mainProcessFiles) { |
|
||||
const destPath = path.join(distElectronDir, dest); |
|
||||
console.log(`Copying ${src} to ${destPath}`); |
|
||||
await fs.copy(src, destPath); |
|
||||
} |
|
||||
|
|
||||
// Create package.json for production
|
// Create the main process file with inlined logger
|
||||
const devPackageJson = require('../package.json'); |
const mainContent = `const { app, BrowserWindow } = require("electron");
|
||||
const prodPackageJson = { |
const path = require("path"); |
||||
name: devPackageJson.name, |
const fs = require("fs"); |
||||
version: devPackageJson.version, |
|
||||
description: devPackageJson.description, |
// Inline logger implementation
|
||||
author: devPackageJson.author, |
const logger = { |
||||
main: 'main.js', |
log: (...args) => console.log(...args), |
||||
private: true, |
error: (...args) => console.error(...args), |
||||
}; |
info: (...args) => console.info(...args), |
||||
|
warn: (...args) => console.warn(...args), |
||||
await fs.writeJson( |
debug: (...args) => console.debug(...args), |
||||
path.join(distElectronDir, 'package.json'), |
}; |
||||
prodPackageJson, |
|
||||
{ spaces: 2 } |
// Check if running in dev mode
|
||||
); |
const isDev = process.argv.includes("--inspect"); |
||||
|
|
||||
// Verify the build
|
function createWindow() { |
||||
console.log('\nVerifying build structure:'); |
// Add before createWindow function
|
||||
const files = await fs.readdir(distElectronDir); |
const preloadPath = path.join(__dirname, "preload.js"); |
||||
console.log('Files in dist-electron:', files); |
logger.log("Checking preload path:", preloadPath); |
||||
|
logger.log("Preload exists:", fs.existsSync(preloadPath)); |
||||
if (!files.includes('main.js')) { |
|
||||
throw new Error('main.js not found in build directory'); |
// Create the browser window.
|
||||
} |
const mainWindow = new BrowserWindow({ |
||||
if (!files.includes('preload.js')) { |
width: 1200, |
||||
throw new Error('preload.js not found in build directory'); |
height: 800, |
||||
} |
webPreferences: { |
||||
if (!files.includes('package.json')) { |
nodeIntegration: false, |
||||
throw new Error('package.json not found in build directory'); |
contextIsolation: true, |
||||
|
webSecurity: true, |
||||
|
allowRunningInsecureContent: false, |
||||
|
preload: path.join(__dirname, "preload.js"), |
||||
|
}, |
||||
|
}); |
||||
|
|
||||
|
// Always open DevTools for now
|
||||
|
mainWindow.webContents.openDevTools(); |
||||
|
|
||||
|
// Intercept requests to fix asset paths
|
||||
|
mainWindow.webContents.session.webRequest.onBeforeRequest( |
||||
|
{ |
||||
|
urls: [ |
||||
|
"file://*/*/assets/*", |
||||
|
"file://*/assets/*", |
||||
|
"file:///assets/*", // Catch absolute paths
|
||||
|
"<all_urls>", // Catch all URLs as a fallback
|
||||
|
], |
||||
|
}, |
||||
|
(details, callback) => { |
||||
|
let url = details.url; |
||||
|
|
||||
|
// Handle paths that don't start with file://
|
||||
|
if (!url.startsWith("file://") && url.includes("/assets/")) { |
||||
|
url = \`file://\${path.join(__dirname, "www", url)}\`;
|
||||
|
} |
||||
|
|
||||
|
// Handle absolute paths starting with /assets/
|
||||
|
if (url.includes("/assets/") && !url.includes("/www/assets/")) { |
||||
|
const baseDir = url.includes("dist-electron") |
||||
|
? url.substring( |
||||
|
0, |
||||
|
url.indexOf("/dist-electron") + "/dist-electron".length, |
||||
|
) |
||||
|
: \`file://\${__dirname}\`;
|
||||
|
const assetPath = url.split("/assets/")[1]; |
||||
|
const newUrl = \`\${baseDir}/www/assets/\${assetPath}\`;
|
||||
|
callback({ redirectURL: newUrl }); |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
callback({}); // No redirect for other URLs
|
||||
|
}, |
||||
|
); |
||||
|
|
||||
|
if (isDev) { |
||||
|
// Debug info
|
||||
|
logger.log("Debug Info:"); |
||||
|
logger.log("Running in dev mode:", isDev); |
||||
|
logger.log("App is packaged:", app.isPackaged); |
||||
|
logger.log("Process resource path:", process.resourcesPath); |
||||
|
logger.log("App path:", app.getAppPath()); |
||||
|
logger.log("__dirname:", __dirname); |
||||
|
logger.log("process.cwd():", process.cwd()); |
||||
|
} |
||||
|
|
||||
|
const indexPath = path.join(__dirname, "www", "index.html"); |
||||
|
|
||||
|
if (isDev) { |
||||
|
logger.log("Loading index from:", indexPath); |
||||
|
logger.log("www path:", path.join(__dirname, "www")); |
||||
|
logger.log("www assets path:", path.join(__dirname, "www", "assets")); |
||||
|
} |
||||
|
|
||||
|
if (!fs.existsSync(indexPath)) { |
||||
|
logger.error(\`Index file not found at: \${indexPath}\`);
|
||||
|
throw new Error("Index file not found"); |
||||
|
} |
||||
|
|
||||
|
// Add CSP headers to allow API connections, Google Fonts, and zxing-wasm
|
||||
|
mainWindow.webContents.session.webRequest.onHeadersReceived( |
||||
|
(details, callback) => { |
||||
|
callback({ |
||||
|
responseHeaders: { |
||||
|
...details.responseHeaders, |
||||
|
"Content-Security-Policy": [ |
||||
|
"default-src 'self';" + |
||||
|
"connect-src 'self' https://api.endorser.ch https://*.timesafari.app https://*.jsdelivr.net;" + |
||||
|
"img-src 'self' data: https: blob:;" + |
||||
|
"script-src 'self' 'unsafe-inline' 'unsafe-eval' https://*.jsdelivr.net;" + |
||||
|
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com;" + |
||||
|
"font-src 'self' data: https://fonts.gstatic.com;" + |
||||
|
"style-src-elem 'self' 'unsafe-inline' https://fonts.googleapis.com;" + |
||||
|
"worker-src 'self' blob:;", |
||||
|
], |
||||
|
}, |
||||
|
}); |
||||
|
}, |
||||
|
); |
||||
|
|
||||
|
// Load the index.html
|
||||
|
mainWindow |
||||
|
.loadFile(indexPath) |
||||
|
.then(() => { |
||||
|
logger.log("Successfully loaded index.html"); |
||||
|
if (isDev) { |
||||
|
mainWindow.webContents.openDevTools(); |
||||
|
logger.log("DevTools opened - running in dev mode"); |
||||
|
} |
||||
|
}) |
||||
|
.catch((err) => { |
||||
|
logger.error("Failed to load index.html:", err); |
||||
|
logger.error("Attempted path:", indexPath); |
||||
|
}); |
||||
|
|
||||
|
// Listen for console messages from the renderer
|
||||
|
mainWindow.webContents.on("console-message", (_event, _level, message) => { |
||||
|
logger.log("Renderer Console:", message); |
||||
|
}); |
||||
|
|
||||
|
// Add right after creating the BrowserWindow
|
||||
|
mainWindow.webContents.on( |
||||
|
"did-fail-load", |
||||
|
(_event, errorCode, errorDescription) => { |
||||
|
logger.error("Page failed to load:", errorCode, errorDescription); |
||||
|
}, |
||||
|
); |
||||
|
|
||||
|
mainWindow.webContents.on("preload-error", (_event, preloadPath, error) => { |
||||
|
logger.error("Preload script error:", preloadPath, error); |
||||
|
}); |
||||
|
|
||||
|
mainWindow.webContents.on( |
||||
|
"console-message", |
||||
|
(_event, _level, message, line, sourceId) => { |
||||
|
logger.log("Renderer Console:", line, sourceId, message); |
||||
|
}, |
||||
|
); |
||||
|
|
||||
|
// Enable remote debugging when in dev mode
|
||||
|
if (isDev) { |
||||
|
mainWindow.webContents.openDevTools(); |
||||
} |
} |
||||
|
} |
||||
|
|
||||
|
// Handle app ready
|
||||
|
app.whenReady().then(createWindow); |
||||
|
|
||||
console.log('Build completed successfully!'); |
// Handle all windows closed
|
||||
} catch (error) { |
app.on("window-all-closed", () => { |
||||
console.error('Build failed:', error); |
if (process.platform !== "darwin") { |
||||
process.exit(1); |
app.quit(); |
||||
} |
} |
||||
|
}); |
||||
|
|
||||
|
app.on("activate", () => { |
||||
|
if (BrowserWindow.getAllWindows().length === 0) { |
||||
|
createWindow(); |
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
// Handle any errors
|
||||
|
process.on("uncaughtException", (error) => { |
||||
|
logger.error("Uncaught Exception:", error); |
||||
|
}); |
||||
|
`;
|
||||
|
|
||||
|
// Write the main process file
|
||||
|
const mainDest = path.join(electronDistPath, 'main.js'); |
||||
|
fs.writeFileSync(mainDest, mainContent); |
||||
|
|
||||
|
// Copy preload script if it exists
|
||||
|
const preloadSrc = path.join(__dirname, '..', 'src', 'electron', 'preload.js'); |
||||
|
const preloadDest = path.join(electronDistPath, 'preload.js'); |
||||
|
if (fs.existsSync(preloadSrc)) { |
||||
|
console.log(`Copying ${preloadSrc} to ${preloadDest}`); |
||||
|
fs.copyFileSync(preloadSrc, preloadDest); |
||||
} |
} |
||||
|
|
||||
main(); |
// Verify build structure
|
||||
|
console.log('\nVerifying build structure:'); |
||||
|
console.log('Files in dist-electron:', fs.readdirSync(electronDistPath)); |
||||
|
|
||||
|
console.log('Build completed successfully!'); |
@ -0,0 +1,22 @@ |
|||||
|
#!/bin/bash |
||||
|
|
||||
|
# Clean the public directory |
||||
|
rm -rf android/app/src/main/assets/public/* |
||||
|
|
||||
|
# Copy web assets |
||||
|
cp -r dist/* android/app/src/main/assets/public/ |
||||
|
|
||||
|
# Ensure the directory structure exists |
||||
|
mkdir -p android/app/src/main/assets/public/assets |
||||
|
|
||||
|
# Copy the main index file |
||||
|
cp dist/index.html android/app/src/main/assets/public/ |
||||
|
|
||||
|
# Copy all assets |
||||
|
cp -r dist/assets/* android/app/src/main/assets/public/assets/ |
||||
|
|
||||
|
# Copy other necessary files |
||||
|
cp dist/favicon.ico android/app/src/main/assets/public/ |
||||
|
cp dist/robots.txt android/app/src/main/assets/public/ |
||||
|
|
||||
|
echo "Web assets copied successfully!" |
@ -0,0 +1,22 @@ |
|||||
|
#!/bin/bash |
||||
|
|
||||
|
# Create directories if they don't exist |
||||
|
mkdir -p android/app/src/main/res/mipmap-mdpi |
||||
|
mkdir -p android/app/src/main/res/mipmap-hdpi |
||||
|
mkdir -p android/app/src/main/res/mipmap-xhdpi |
||||
|
mkdir -p android/app/src/main/res/mipmap-xxhdpi |
||||
|
mkdir -p android/app/src/main/res/mipmap-xxxhdpi |
||||
|
|
||||
|
# Generate placeholder icons using ImageMagick |
||||
|
convert -size 48x48 xc:blue -gravity center -pointsize 20 -fill white -annotate 0 "TS" android/app/src/main/res/mipmap-mdpi/ic_launcher.png |
||||
|
convert -size 72x72 xc:blue -gravity center -pointsize 30 -fill white -annotate 0 "TS" android/app/src/main/res/mipmap-hdpi/ic_launcher.png |
||||
|
convert -size 96x96 xc:blue -gravity center -pointsize 40 -fill white -annotate 0 "TS" android/app/src/main/res/mipmap-xhdpi/ic_launcher.png |
||||
|
convert -size 144x144 xc:blue -gravity center -pointsize 60 -fill white -annotate 0 "TS" android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png |
||||
|
convert -size 192x192 xc:blue -gravity center -pointsize 80 -fill white -annotate 0 "TS" android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png |
||||
|
|
||||
|
# Copy to round versions |
||||
|
cp android/app/src/main/res/mipmap-mdpi/ic_launcher.png android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png |
||||
|
cp android/app/src/main/res/mipmap-hdpi/ic_launcher.png android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png |
||||
|
cp android/app/src/main/res/mipmap-xhdpi/ic_launcher.png android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png |
||||
|
cp android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png |
||||
|
cp android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png |
@ -0,0 +1,196 @@ |
|||||
|
/** * Data Export Section Component * * Provides UI and functionality for |
||||
|
exporting user data and backing up identifier seeds. * Includes buttons for seed |
||||
|
backup and database export, with platform-specific download instructions. * * |
||||
|
@component * @displayName DataExportSection * @example * ```vue * |
||||
|
<DataExportSection :active-did="currentDid" /> |
||||
|
* ``` */ |
||||
|
|
||||
|
<template> |
||||
|
<div |
||||
|
id="sectionDataExport" |
||||
|
class="bg-slate-100 rounded-md overflow-hidden px-4 py-4 mt-8 mb-8" |
||||
|
> |
||||
|
<div class="mb-2 font-bold">Data Export</div> |
||||
|
<router-link |
||||
|
v-if="activeDid" |
||||
|
:to="{ name: 'seed-backup' }" |
||||
|
class="block w-full text-center text-md 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-2 rounded-md mb-2 mt-2" |
||||
|
> |
||||
|
Backup Identifier Seed |
||||
|
</router-link> |
||||
|
|
||||
|
<button |
||||
|
:class="computedStartDownloadLinkClassNames()" |
||||
|
class="block w-full text-center text-md 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-2 rounded-md" |
||||
|
@click="exportDatabase()" |
||||
|
> |
||||
|
Download Settings & Contacts |
||||
|
<br /> |
||||
|
(excluding Identifier Data) |
||||
|
</button> |
||||
|
<a |
||||
|
ref="downloadLink" |
||||
|
:class="computedDownloadLinkClassNames()" |
||||
|
class="block w-full text-center text-md bg-gradient-to-b from-green-500 to-green-800 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md mb-6" |
||||
|
> |
||||
|
If no download happened yet, click again here to download now. |
||||
|
</a> |
||||
|
<div v-if="platformCapabilities.needsFileHandlingInstructions" class="mt-4"> |
||||
|
<p> |
||||
|
After the download, you can save the file in your preferred storage |
||||
|
location. |
||||
|
</p> |
||||
|
<ul> |
||||
|
<li |
||||
|
v-if="platformCapabilities.isIOS" |
||||
|
class="list-disc list-outside ml-4" |
||||
|
> |
||||
|
On iOS: You will be prompted to choose a location to save your backup |
||||
|
file. |
||||
|
</li> |
||||
|
<li |
||||
|
v-if="platformCapabilities.isMobile && !platformCapabilities.isIOS" |
||||
|
class="list-disc list-outside ml-4" |
||||
|
> |
||||
|
On Android: You will be prompted to choose a location to save your |
||||
|
backup file. |
||||
|
</li> |
||||
|
</ul> |
||||
|
</div> |
||||
|
</div> |
||||
|
</template> |
||||
|
|
||||
|
<script lang="ts"> |
||||
|
import { Component, Prop, Vue } from "vue-facing-decorator"; |
||||
|
import { NotificationIface } from "../constants/app"; |
||||
|
import { db } from "../db/index"; |
||||
|
import { logger } from "../utils/logger"; |
||||
|
import { PlatformServiceFactory } from "../services/PlatformServiceFactory"; |
||||
|
import { |
||||
|
PlatformService, |
||||
|
PlatformCapabilities, |
||||
|
} from "../services/PlatformService"; |
||||
|
|
||||
|
/** |
||||
|
* @vue-component |
||||
|
* Data Export Section Component |
||||
|
* Handles database export and seed backup functionality with platform-specific behavior |
||||
|
*/ |
||||
|
@Component |
||||
|
export default class DataExportSection extends Vue { |
||||
|
/** |
||||
|
* Notification function injected by Vue |
||||
|
* Used to show success/error messages to the user |
||||
|
*/ |
||||
|
$notify!: (notification: NotificationIface, timeout?: number) => void; |
||||
|
|
||||
|
/** |
||||
|
* Active DID (Decentralized Identifier) of the user |
||||
|
* Controls visibility of seed backup option |
||||
|
* @required |
||||
|
*/ |
||||
|
@Prop({ required: true }) readonly activeDid!: string; |
||||
|
|
||||
|
/** |
||||
|
* URL for the database export download |
||||
|
* Created and revoked dynamically during export process |
||||
|
* Only used in web platform |
||||
|
*/ |
||||
|
downloadUrl = ""; |
||||
|
|
||||
|
/** |
||||
|
* Platform service instance for platform-specific operations |
||||
|
*/ |
||||
|
private platformService: PlatformService = |
||||
|
PlatformServiceFactory.getInstance(); |
||||
|
|
||||
|
/** |
||||
|
* Platform capabilities for the current platform |
||||
|
*/ |
||||
|
private get platformCapabilities(): PlatformCapabilities { |
||||
|
return this.platformService.getCapabilities(); |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Lifecycle hook to clean up resources |
||||
|
* Revokes object URL when component is unmounted (web platform only) |
||||
|
*/ |
||||
|
beforeUnmount() { |
||||
|
if (this.downloadUrl && this.platformCapabilities.hasFileDownload) { |
||||
|
URL.revokeObjectURL(this.downloadUrl); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Exports the database to a JSON file |
||||
|
* Uses platform-specific methods for saving the exported data |
||||
|
* Shows success/error notifications to user |
||||
|
* |
||||
|
* @throws {Error} If export fails |
||||
|
* @emits {Notification} Success or error notification |
||||
|
*/ |
||||
|
public async exportDatabase() { |
||||
|
try { |
||||
|
const blob = await db.export({ prettyJson: true }); |
||||
|
const fileName = `${db.name}-backup.json`; |
||||
|
|
||||
|
if (this.platformCapabilities.hasFileDownload) { |
||||
|
// Web platform: Use download link |
||||
|
this.downloadUrl = URL.createObjectURL(blob); |
||||
|
const downloadAnchor = this.$refs.downloadLink as HTMLAnchorElement; |
||||
|
downloadAnchor.href = this.downloadUrl; |
||||
|
downloadAnchor.download = fileName; |
||||
|
downloadAnchor.click(); |
||||
|
setTimeout(() => URL.revokeObjectURL(this.downloadUrl), 1000); |
||||
|
} else if (this.platformCapabilities.hasFileSystem) { |
||||
|
// Native platform: Write to app directory |
||||
|
const content = await blob.text(); |
||||
|
await this.platformService.writeAndShareFile(fileName, content); |
||||
|
} |
||||
|
|
||||
|
this.$notify( |
||||
|
{ |
||||
|
group: "alert", |
||||
|
type: "success", |
||||
|
title: "Export Successful", |
||||
|
text: this.platformCapabilities.hasFileDownload |
||||
|
? "See your downloads directory for the backup. It is in the Dexie format." |
||||
|
: "Please choose a location to save your backup file.", |
||||
|
}, |
||||
|
-1, |
||||
|
); |
||||
|
} catch (error) { |
||||
|
logger.error("Export Error:", error); |
||||
|
this.$notify( |
||||
|
{ |
||||
|
group: "alert", |
||||
|
type: "danger", |
||||
|
title: "Export Error", |
||||
|
text: "There was an error exporting the data.", |
||||
|
}, |
||||
|
3000, |
||||
|
); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Computes class names for the initial download button |
||||
|
* @returns Object with 'hidden' class when download is in progress (web platform only) |
||||
|
*/ |
||||
|
public computedStartDownloadLinkClassNames() { |
||||
|
return { |
||||
|
hidden: this.downloadUrl && this.platformCapabilities.hasFileDownload, |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Computes class names for the secondary download link |
||||
|
* @returns Object with 'hidden' class when no download is available or not on web platform |
||||
|
*/ |
||||
|
public computedDownloadLinkClassNames() { |
||||
|
return { |
||||
|
hidden: !this.downloadUrl || !this.platformCapabilities.hasFileDownload, |
||||
|
}; |
||||
|
} |
||||
|
} |
||||
|
</script> |
@ -0,0 +1,187 @@ |
|||||
|
import { app, BrowserWindow } from "electron"; |
||||
|
import path from "path"; |
||||
|
import fs from "fs"; |
||||
|
|
||||
|
// Simple logger implementation
|
||||
|
const logger = { |
||||
|
// eslint-disable-next-line no-console
|
||||
|
log: (...args: unknown[]) => console.log(...args), |
||||
|
// eslint-disable-next-line no-console
|
||||
|
error: (...args: unknown[]) => console.error(...args), |
||||
|
// eslint-disable-next-line no-console
|
||||
|
info: (...args: unknown[]) => console.info(...args), |
||||
|
// eslint-disable-next-line no-console
|
||||
|
warn: (...args: unknown[]) => console.warn(...args), |
||||
|
// eslint-disable-next-line no-console
|
||||
|
debug: (...args: unknown[]) => console.debug(...args), |
||||
|
}; |
||||
|
|
||||
|
// Check if running in dev mode
|
||||
|
const isDev = process.argv.includes("--inspect"); |
||||
|
|
||||
|
function createWindow(): void { |
||||
|
// Add before createWindow function
|
||||
|
const preloadPath = path.join(__dirname, "preload.js"); |
||||
|
logger.log("Checking preload path:", preloadPath); |
||||
|
logger.log("Preload exists:", fs.existsSync(preloadPath)); |
||||
|
|
||||
|
// Create the browser window.
|
||||
|
const mainWindow = new BrowserWindow({ |
||||
|
width: 1200, |
||||
|
height: 800, |
||||
|
webPreferences: { |
||||
|
nodeIntegration: false, |
||||
|
contextIsolation: true, |
||||
|
webSecurity: true, |
||||
|
allowRunningInsecureContent: false, |
||||
|
preload: path.join(__dirname, "preload.js"), |
||||
|
}, |
||||
|
}); |
||||
|
|
||||
|
// Always open DevTools for now
|
||||
|
mainWindow.webContents.openDevTools(); |
||||
|
|
||||
|
// Intercept requests to fix asset paths
|
||||
|
mainWindow.webContents.session.webRequest.onBeforeRequest( |
||||
|
{ |
||||
|
urls: [ |
||||
|
"file://*/*/assets/*", |
||||
|
"file://*/assets/*", |
||||
|
"file:///assets/*", // Catch absolute paths
|
||||
|
"<all_urls>", // Catch all URLs as a fallback
|
||||
|
], |
||||
|
}, |
||||
|
(details, callback) => { |
||||
|
let url = details.url; |
||||
|
|
||||
|
// Handle paths that don't start with file://
|
||||
|
if (!url.startsWith("file://") && url.includes("/assets/")) { |
||||
|
url = `file://${path.join(__dirname, "www", url)}`; |
||||
|
} |
||||
|
|
||||
|
// Handle absolute paths starting with /assets/
|
||||
|
if (url.includes("/assets/") && !url.includes("/www/assets/")) { |
||||
|
const baseDir = url.includes("dist-electron") |
||||
|
? url.substring( |
||||
|
0, |
||||
|
url.indexOf("/dist-electron") + "/dist-electron".length, |
||||
|
) |
||||
|
: `file://${__dirname}`; |
||||
|
const assetPath = url.split("/assets/")[1]; |
||||
|
const newUrl = `${baseDir}/www/assets/${assetPath}`; |
||||
|
callback({ redirectURL: newUrl }); |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
callback({}); // No redirect for other URLs
|
||||
|
}, |
||||
|
); |
||||
|
|
||||
|
if (isDev) { |
||||
|
// Debug info
|
||||
|
logger.log("Debug Info:"); |
||||
|
logger.log("Running in dev mode:", isDev); |
||||
|
logger.log("App is packaged:", app.isPackaged); |
||||
|
logger.log("Process resource path:", process.resourcesPath); |
||||
|
logger.log("App path:", app.getAppPath()); |
||||
|
logger.log("__dirname:", __dirname); |
||||
|
logger.log("process.cwd():", process.cwd()); |
||||
|
} |
||||
|
|
||||
|
const indexPath = path.join(__dirname, "www", "index.html"); |
||||
|
|
||||
|
if (isDev) { |
||||
|
logger.log("Loading index from:", indexPath); |
||||
|
logger.log("www path:", path.join(__dirname, "www")); |
||||
|
logger.log("www assets path:", path.join(__dirname, "www", "assets")); |
||||
|
} |
||||
|
|
||||
|
if (!fs.existsSync(indexPath)) { |
||||
|
logger.error(`Index file not found at: ${indexPath}`); |
||||
|
throw new Error("Index file not found"); |
||||
|
} |
||||
|
|
||||
|
// Add CSP headers to allow API connections
|
||||
|
mainWindow.webContents.session.webRequest.onHeadersReceived( |
||||
|
(details, callback) => { |
||||
|
callback({ |
||||
|
responseHeaders: { |
||||
|
...details.responseHeaders, |
||||
|
"Content-Security-Policy": [ |
||||
|
"default-src 'self';" + |
||||
|
"connect-src 'self' https://api.endorser.ch https://*.timesafari.app;" + |
||||
|
"img-src 'self' data: https: blob:;" + |
||||
|
"script-src 'self' 'unsafe-inline' 'unsafe-eval';" + |
||||
|
"style-src 'self' 'unsafe-inline';" + |
||||
|
"font-src 'self' data:;", |
||||
|
], |
||||
|
}, |
||||
|
}); |
||||
|
}, |
||||
|
); |
||||
|
|
||||
|
// Load the index.html
|
||||
|
mainWindow |
||||
|
.loadFile(indexPath) |
||||
|
.then(() => { |
||||
|
logger.log("Successfully loaded index.html"); |
||||
|
if (isDev) { |
||||
|
mainWindow.webContents.openDevTools(); |
||||
|
logger.log("DevTools opened - running in dev mode"); |
||||
|
} |
||||
|
}) |
||||
|
.catch((err) => { |
||||
|
logger.error("Failed to load index.html:", err); |
||||
|
logger.error("Attempted path:", indexPath); |
||||
|
}); |
||||
|
|
||||
|
// Listen for console messages from the renderer
|
||||
|
mainWindow.webContents.on("console-message", (_event, _level, message) => { |
||||
|
logger.log("Renderer Console:", message); |
||||
|
}); |
||||
|
|
||||
|
// Add right after creating the BrowserWindow
|
||||
|
mainWindow.webContents.on( |
||||
|
"did-fail-load", |
||||
|
(_event, errorCode, errorDescription) => { |
||||
|
logger.error("Page failed to load:", errorCode, errorDescription); |
||||
|
}, |
||||
|
); |
||||
|
|
||||
|
mainWindow.webContents.on("preload-error", (_event, preloadPath, error) => { |
||||
|
logger.error("Preload script error:", preloadPath, error); |
||||
|
}); |
||||
|
|
||||
|
mainWindow.webContents.on( |
||||
|
"console-message", |
||||
|
(_event, _level, message, line, sourceId) => { |
||||
|
logger.log("Renderer Console:", line, sourceId, message); |
||||
|
}, |
||||
|
); |
||||
|
|
||||
|
// Enable remote debugging when in dev mode
|
||||
|
if (isDev) { |
||||
|
mainWindow.webContents.openDevTools(); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// Handle app ready
|
||||
|
app.whenReady().then(createWindow); |
||||
|
|
||||
|
// Handle all windows closed
|
||||
|
app.on("window-all-closed", () => { |
||||
|
if (process.platform !== "darwin") { |
||||
|
app.quit(); |
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
app.on("activate", () => { |
||||
|
if (BrowserWindow.getAllWindows().length === 0) { |
||||
|
createWindow(); |
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
// Handle any errors
|
||||
|
process.on("uncaughtException", (error) => { |
||||
|
logger.error("Uncaught Exception:", error); |
||||
|
}); |
@ -0,0 +1,4 @@ |
|||||
|
/// <reference types="vite/client" />
|
||||
|
|
||||
|
declare const __USE_QR_READER__: boolean; |
||||
|
declare const __IS_MOBILE__: boolean; |
@ -0,0 +1,21 @@ |
|||||
|
import { GiveSummaryRecord } from "./records"; |
||||
|
|
||||
|
// Common interface for contact information
|
||||
|
export interface ContactInfo { |
||||
|
known: boolean; |
||||
|
displayName: string; |
||||
|
profileImageUrl?: string; |
||||
|
} |
||||
|
|
||||
|
// Define the contact information fields
|
||||
|
interface GiveContactInfo { |
||||
|
giver: ContactInfo; |
||||
|
issuer: ContactInfo; |
||||
|
receiver: ContactInfo; |
||||
|
providerPlanName?: string; |
||||
|
recipientProjectName?: string; |
||||
|
image?: string; |
||||
|
} |
||||
|
|
||||
|
// Combine GiveSummaryRecord with contact information using intersection type
|
||||
|
export type GiveRecordWithContactInfo = GiveSummaryRecord & GiveContactInfo; |
@ -0,0 +1,101 @@ |
|||||
|
/** |
||||
|
* Represents the result of an image capture or selection operation. |
||||
|
* Contains both the image data as a Blob and the associated filename. |
||||
|
*/ |
||||
|
export interface ImageResult { |
||||
|
/** The image data as a Blob object */ |
||||
|
blob: Blob; |
||||
|
/** The filename associated with the image */ |
||||
|
fileName: string; |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Platform capabilities interface defining what features are available |
||||
|
* on the current platform implementation |
||||
|
*/ |
||||
|
export interface PlatformCapabilities { |
||||
|
/** Whether the platform supports native file system access */ |
||||
|
hasFileSystem: boolean; |
||||
|
/** Whether the platform supports native camera access */ |
||||
|
hasCamera: boolean; |
||||
|
/** Whether the platform is a mobile device */ |
||||
|
isMobile: boolean; |
||||
|
/** Whether the platform is iOS specifically */ |
||||
|
isIOS: boolean; |
||||
|
/** Whether the platform supports native file download */ |
||||
|
hasFileDownload: boolean; |
||||
|
/** Whether the platform requires special file handling instructions */ |
||||
|
needsFileHandlingInstructions: boolean; |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Platform-agnostic interface for handling platform-specific operations. |
||||
|
* Provides a common API for file system operations, camera interactions, |
||||
|
* and platform detection across different platforms (web, mobile, desktop). |
||||
|
*/ |
||||
|
export interface PlatformService { |
||||
|
// Platform capabilities
|
||||
|
/** |
||||
|
* Gets the current platform's capabilities |
||||
|
* @returns Object describing what features are available on this platform |
||||
|
*/ |
||||
|
getCapabilities(): PlatformCapabilities; |
||||
|
|
||||
|
// File system operations
|
||||
|
/** |
||||
|
* Reads the contents of a file at the specified path. |
||||
|
* @param path - The path to the file to read |
||||
|
* @returns Promise resolving to the file contents as a string |
||||
|
*/ |
||||
|
readFile(path: string): Promise<string>; |
||||
|
|
||||
|
/** |
||||
|
* Writes content to a file at the specified path. |
||||
|
* @param path - The path where the file should be written |
||||
|
* @param content - The content to write to the file |
||||
|
* @returns Promise that resolves when the write is complete |
||||
|
*/ |
||||
|
writeFile(path: string, content: string): Promise<void>; |
||||
|
|
||||
|
/** |
||||
|
* Writes content to a file at the specified path and shares it. |
||||
|
* @param fileName - The filename of the file to write |
||||
|
* @param content - The content to write to the file |
||||
|
* @returns Promise that resolves when the write is complete |
||||
|
*/ |
||||
|
writeAndShareFile(fileName: string, content: string): Promise<void>; |
||||
|
|
||||
|
/** |
||||
|
* Deletes a file at the specified path. |
||||
|
* @param path - The path to the file to delete |
||||
|
* @returns Promise that resolves when the deletion is complete |
||||
|
*/ |
||||
|
deleteFile(path: string): Promise<void>; |
||||
|
|
||||
|
/** |
||||
|
* Lists all files in the specified directory. |
||||
|
* @param directory - The directory path to list |
||||
|
* @returns Promise resolving to an array of filenames |
||||
|
*/ |
||||
|
listFiles(directory: string): Promise<string[]>; |
||||
|
|
||||
|
// Camera operations
|
||||
|
/** |
||||
|
* Activates the device camera to take a picture. |
||||
|
* @returns Promise resolving to the captured image result |
||||
|
*/ |
||||
|
takePicture(): Promise<ImageResult>; |
||||
|
|
||||
|
/** |
||||
|
* Opens a file picker to select an existing image. |
||||
|
* @returns Promise resolving to the selected image result |
||||
|
*/ |
||||
|
pickImage(): Promise<ImageResult>; |
||||
|
|
||||
|
/** |
||||
|
* Handles deep link URLs for the application. |
||||
|
* @param url - The deep link URL to handle |
||||
|
* @returns Promise that resolves when the deep link has been handled |
||||
|
*/ |
||||
|
handleDeepLink(url: string): Promise<void>; |
||||
|
} |
@ -0,0 +1,58 @@ |
|||||
|
import { PlatformService } from "./PlatformService"; |
||||
|
import { WebPlatformService } from "./platforms/WebPlatformService"; |
||||
|
import { CapacitorPlatformService } from "./platforms/CapacitorPlatformService"; |
||||
|
import { ElectronPlatformService } from "./platforms/ElectronPlatformService"; |
||||
|
import { PyWebViewPlatformService } from "./platforms/PyWebViewPlatformService"; |
||||
|
|
||||
|
/** |
||||
|
* Factory class for creating platform-specific service implementations. |
||||
|
* Implements the Singleton pattern to ensure only one instance of PlatformService exists. |
||||
|
* |
||||
|
* The factory determines which platform implementation to use based on the VITE_PLATFORM |
||||
|
* environment variable. Supported platforms are: |
||||
|
* - capacitor: Mobile platform using Capacitor |
||||
|
* - electron: Desktop platform using Electron |
||||
|
* - pywebview: Python WebView implementation |
||||
|
* - web: Default web platform (fallback) |
||||
|
* |
||||
|
* @example |
||||
|
* ```typescript
|
||||
|
* const platformService = PlatformServiceFactory.getInstance(); |
||||
|
* await platformService.takePicture(); |
||||
|
* ``` |
||||
|
*/ |
||||
|
export class PlatformServiceFactory { |
||||
|
private static instance: PlatformService | null = null; |
||||
|
|
||||
|
/** |
||||
|
* Gets or creates the singleton instance of PlatformService. |
||||
|
* Creates the appropriate platform-specific implementation based on environment. |
||||
|
* |
||||
|
* @returns {PlatformService} The singleton instance of PlatformService |
||||
|
*/ |
||||
|
public static getInstance(): PlatformService { |
||||
|
if (PlatformServiceFactory.instance) { |
||||
|
return PlatformServiceFactory.instance; |
||||
|
} |
||||
|
|
||||
|
const platform = process.env.VITE_PLATFORM || "web"; |
||||
|
|
||||
|
switch (platform) { |
||||
|
case "capacitor": |
||||
|
PlatformServiceFactory.instance = new CapacitorPlatformService(); |
||||
|
break; |
||||
|
case "electron": |
||||
|
PlatformServiceFactory.instance = new ElectronPlatformService(); |
||||
|
break; |
||||
|
case "pywebview": |
||||
|
PlatformServiceFactory.instance = new PyWebViewPlatformService(); |
||||
|
break; |
||||
|
case "web": |
||||
|
default: |
||||
|
PlatformServiceFactory.instance = new WebPlatformService(); |
||||
|
break; |
||||
|
} |
||||
|
|
||||
|
return PlatformServiceFactory.instance; |
||||
|
} |
||||
|
} |
@ -0,0 +1,210 @@ |
|||||
|
import { |
||||
|
BarcodeScanner, |
||||
|
BarcodeFormat, |
||||
|
StartScanOptions, |
||||
|
LensFacing, |
||||
|
} from "@capacitor-mlkit/barcode-scanning"; |
||||
|
import { QRScannerService, ScanListener, QRScannerOptions } from "./types"; |
||||
|
import { logger } from "@/utils/logger"; |
||||
|
|
||||
|
export class CapacitorQRScanner implements QRScannerService { |
||||
|
private scanListener: ScanListener | null = null; |
||||
|
private isScanning = false; |
||||
|
private listenerHandles: Array<() => Promise<void>> = []; |
||||
|
private cleanupPromise: Promise<void> | null = null; |
||||
|
|
||||
|
async checkPermissions(): Promise<boolean> { |
||||
|
try { |
||||
|
logger.debug("Checking camera permissions"); |
||||
|
const { camera } = await BarcodeScanner.checkPermissions(); |
||||
|
return camera === "granted"; |
||||
|
} catch (error) { |
||||
|
const wrappedError = |
||||
|
error instanceof Error ? error : new Error(String(error)); |
||||
|
logger.error("Error checking camera permissions:", { |
||||
|
error: wrappedError.message, |
||||
|
}); |
||||
|
return false; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
async requestPermissions(): Promise<boolean> { |
||||
|
try { |
||||
|
// First check if we already have permissions
|
||||
|
if (await this.checkPermissions()) { |
||||
|
logger.debug("Camera permissions already granted"); |
||||
|
return true; |
||||
|
} |
||||
|
|
||||
|
logger.debug("Requesting camera permissions"); |
||||
|
const { camera } = await BarcodeScanner.requestPermissions(); |
||||
|
const granted = camera === "granted"; |
||||
|
logger.debug(`Camera permissions ${granted ? "granted" : "denied"}`); |
||||
|
return granted; |
||||
|
} catch (error) { |
||||
|
const wrappedError = |
||||
|
error instanceof Error ? error : new Error(String(error)); |
||||
|
logger.error("Error requesting camera permissions:", { |
||||
|
error: wrappedError.message, |
||||
|
}); |
||||
|
return false; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
async isSupported(): Promise<boolean> { |
||||
|
try { |
||||
|
logger.debug("Checking scanner support"); |
||||
|
const { supported } = await BarcodeScanner.isSupported(); |
||||
|
logger.debug(`Scanner support: ${supported}`); |
||||
|
return supported; |
||||
|
} catch (error) { |
||||
|
const wrappedError = |
||||
|
error instanceof Error ? error : new Error(String(error)); |
||||
|
logger.error("Error checking scanner support:", { |
||||
|
error: wrappedError.message, |
||||
|
}); |
||||
|
return false; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
async startScan(options?: QRScannerOptions): Promise<void> { |
||||
|
if (this.isScanning) { |
||||
|
logger.debug("Scanner already running"); |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
if (this.cleanupPromise) { |
||||
|
logger.debug("Waiting for previous cleanup to complete"); |
||||
|
await this.cleanupPromise; |
||||
|
} |
||||
|
|
||||
|
try { |
||||
|
// Ensure we have permissions before starting
|
||||
|
if (!(await this.checkPermissions())) { |
||||
|
logger.debug("Requesting camera permissions"); |
||||
|
const granted = await this.requestPermissions(); |
||||
|
if (!granted) { |
||||
|
throw new Error("Camera permission denied"); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// Check if scanning is supported
|
||||
|
if (!(await this.isSupported())) { |
||||
|
throw new Error("QR scanning not supported on this device"); |
||||
|
} |
||||
|
|
||||
|
logger.info("Starting MLKit scanner"); |
||||
|
this.isScanning = true; |
||||
|
|
||||
|
const scanOptions: StartScanOptions = { |
||||
|
formats: [BarcodeFormat.QrCode], |
||||
|
lensFacing: |
||||
|
options?.camera === "front" ? LensFacing.Front : LensFacing.Back, |
||||
|
}; |
||||
|
|
||||
|
logger.debug("Scanner options:", scanOptions); |
||||
|
|
||||
|
// Add listener for barcode scans
|
||||
|
const handle = await BarcodeScanner.addListener( |
||||
|
"barcodeScanned", |
||||
|
(result) => { |
||||
|
if (this.scanListener && result.barcode?.rawValue) { |
||||
|
this.scanListener.onScan(result.barcode.rawValue); |
||||
|
} |
||||
|
}, |
||||
|
); |
||||
|
this.listenerHandles.push(handle.remove); |
||||
|
|
||||
|
// Start continuous scanning
|
||||
|
await BarcodeScanner.startScan(scanOptions); |
||||
|
logger.info("MLKit scanner started successfully"); |
||||
|
} catch (error) { |
||||
|
const wrappedError = |
||||
|
error instanceof Error ? error : new Error(String(error)); |
||||
|
logger.error("Error during QR scan:", { |
||||
|
error: wrappedError.message, |
||||
|
stack: wrappedError.stack, |
||||
|
}); |
||||
|
this.isScanning = false; |
||||
|
await this.cleanup(); |
||||
|
this.scanListener?.onError?.(wrappedError); |
||||
|
throw wrappedError; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
async stopScan(): Promise<void> { |
||||
|
if (!this.isScanning) { |
||||
|
logger.debug("Scanner not running"); |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
try { |
||||
|
logger.debug("Stopping QR scanner"); |
||||
|
await BarcodeScanner.stopScan(); |
||||
|
logger.info("QR scanner stopped successfully"); |
||||
|
} catch (error) { |
||||
|
const wrappedError = |
||||
|
error instanceof Error ? error : new Error(String(error)); |
||||
|
logger.error("Error stopping QR scan:", { |
||||
|
error: wrappedError.message, |
||||
|
stack: wrappedError.stack, |
||||
|
}); |
||||
|
this.scanListener?.onError?.(wrappedError); |
||||
|
throw wrappedError; |
||||
|
} finally { |
||||
|
this.isScanning = false; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
addListener(listener: ScanListener): void { |
||||
|
this.scanListener = listener; |
||||
|
} |
||||
|
|
||||
|
async cleanup(): Promise<void> { |
||||
|
// Prevent multiple simultaneous cleanup attempts
|
||||
|
if (this.cleanupPromise) { |
||||
|
return this.cleanupPromise; |
||||
|
} |
||||
|
|
||||
|
this.cleanupPromise = (async () => { |
||||
|
try { |
||||
|
logger.debug("Starting QR scanner cleanup"); |
||||
|
|
||||
|
// Stop scanning if active
|
||||
|
if (this.isScanning) { |
||||
|
await this.stopScan(); |
||||
|
} |
||||
|
|
||||
|
// Remove all listeners
|
||||
|
for (const handle of this.listenerHandles) { |
||||
|
try { |
||||
|
await handle(); |
||||
|
} catch (error) { |
||||
|
logger.warn("Error removing listener:", error); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
logger.info("QR scanner cleanup completed"); |
||||
|
} catch (error) { |
||||
|
const wrappedError = |
||||
|
error instanceof Error ? error : new Error(String(error)); |
||||
|
logger.error("Error during cleanup:", { |
||||
|
error: wrappedError.message, |
||||
|
stack: wrappedError.stack, |
||||
|
}); |
||||
|
throw wrappedError; |
||||
|
} finally { |
||||
|
this.listenerHandles = []; |
||||
|
this.scanListener = null; |
||||
|
this.cleanupPromise = null; |
||||
|
} |
||||
|
})(); |
||||
|
|
||||
|
return this.cleanupPromise; |
||||
|
} |
||||
|
|
||||
|
onStream(callback: (stream: MediaStream | null) => void): void { |
||||
|
// No-op for native scanner
|
||||
|
callback(null); |
||||
|
} |
||||
|
} |
@ -0,0 +1,100 @@ |
|||||
|
import { Capacitor } from "@capacitor/core"; |
||||
|
import { QRScannerService } from "./types"; |
||||
|
import { CapacitorQRScanner } from "./CapacitorQRScanner"; |
||||
|
import { WebInlineQRScanner } from "./WebInlineQRScanner"; |
||||
|
import { logger } from "@/utils/logger"; |
||||
|
|
||||
|
/** |
||||
|
* Factory class for creating QR scanner instances based on platform |
||||
|
*/ |
||||
|
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 = |
||||
|
typeof __IS_MOBILE__ !== "undefined" ? __IS_MOBILE__ : capacitorNative; |
||||
|
const platform = Capacitor.getPlatform(); |
||||
|
|
||||
|
logger.log("Platform detection:", { |
||||
|
capacitorNative, |
||||
|
isMobile, |
||||
|
platform, |
||||
|
userAgent: navigator.userAgent, |
||||
|
}); |
||||
|
|
||||
|
// Always use native scanner on Android/iOS
|
||||
|
if (platform === "android" || platform === "ios") { |
||||
|
logger.log("Using native scanner due to platform:", platform); |
||||
|
return true; |
||||
|
} |
||||
|
|
||||
|
// For other platforms, use native if available
|
||||
|
const useNative = capacitorNative || isMobile; |
||||
|
logger.log("Platform decision:", { |
||||
|
useNative, |
||||
|
reason: useNative ? "capacitorNative/isMobile" : "web", |
||||
|
}); |
||||
|
return useNative; |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Get a QR scanner instance appropriate for the current platform |
||||
|
*/ |
||||
|
static getInstance(): QRScannerService { |
||||
|
if (!this.instance) { |
||||
|
const isNative = this.isNativePlatform(); |
||||
|
logger.log( |
||||
|
`Creating QR scanner for platform: ${isNative ? "native" : "web"}`, |
||||
|
); |
||||
|
|
||||
|
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 WebInlineQRScanner(); |
||||
|
} 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!; |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Clean up the current scanner instance |
||||
|
*/ |
||||
|
static async cleanup(): Promise<void> { |
||||
|
if (this.instance) { |
||||
|
try { |
||||
|
await this.instance.cleanup(); |
||||
|
} catch (error) { |
||||
|
logger.error("Error cleaning up QR scanner:", error); |
||||
|
} finally { |
||||
|
this.instance = null; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
@ -0,0 +1,608 @@ |
|||||
|
import { |
||||
|
QRScannerService, |
||||
|
ScanListener, |
||||
|
QRScannerOptions, |
||||
|
CameraState, |
||||
|
CameraStateListener, |
||||
|
} from "./types"; |
||||
|
import { logger } from "@/utils/logger"; |
||||
|
import { EventEmitter } from "events"; |
||||
|
import jsQR from "jsqr"; |
||||
|
|
||||
|
// Build identifier to help distinguish between builds
|
||||
|
const BUILD_ID = `build-${Date.now()}`; |
||||
|
|
||||
|
export class WebInlineQRScanner implements QRScannerService { |
||||
|
private scanListener: ScanListener | null = null; |
||||
|
private isScanning = false; |
||||
|
private stream: MediaStream | null = null; |
||||
|
private events = new EventEmitter(); |
||||
|
private canvas: HTMLCanvasElement | null = null; |
||||
|
private context: CanvasRenderingContext2D | null = null; |
||||
|
private video: HTMLVideoElement | null = null; |
||||
|
private animationFrameId: number | null = null; |
||||
|
private scanAttempts = 0; |
||||
|
private lastScanTime = 0; |
||||
|
private readonly id: string; |
||||
|
private readonly TARGET_FPS = 15; // Target 15 FPS for scanning
|
||||
|
private readonly FRAME_INTERVAL = 1000 / 15; // ~67ms between frames
|
||||
|
private lastFrameTime = 0; |
||||
|
private cameraStateListeners: Set<CameraStateListener> = new Set(); |
||||
|
private currentState: CameraState = "off"; |
||||
|
private currentStateMessage?: string; |
||||
|
|
||||
|
constructor(private options?: QRScannerOptions) { |
||||
|
// Generate a short random ID for this scanner instance
|
||||
|
this.id = Math.random().toString(36).substring(2, 8).toUpperCase(); |
||||
|
logger.error( |
||||
|
`[WebInlineQRScanner:${this.id}] Initializing scanner with options:`, |
||||
|
{ |
||||
|
...options, |
||||
|
buildId: BUILD_ID, |
||||
|
targetFps: this.TARGET_FPS, |
||||
|
}, |
||||
|
); |
||||
|
// Create canvas and video elements
|
||||
|
this.canvas = document.createElement("canvas"); |
||||
|
this.context = this.canvas.getContext("2d", { willReadFrequently: true }); |
||||
|
this.video = document.createElement("video"); |
||||
|
this.video.setAttribute("playsinline", "true"); // Required for iOS
|
||||
|
logger.error( |
||||
|
`[WebInlineQRScanner:${this.id}] DOM elements created successfully`, |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
private updateCameraState(state: CameraState, message?: string) { |
||||
|
this.currentState = state; |
||||
|
this.currentStateMessage = message; |
||||
|
this.cameraStateListeners.forEach((listener) => { |
||||
|
try { |
||||
|
listener.onStateChange(state, message); |
||||
|
logger.info( |
||||
|
`[WebInlineQRScanner:${this.id}] Camera state changed to: ${state}`, |
||||
|
{ |
||||
|
state, |
||||
|
message, |
||||
|
}, |
||||
|
); |
||||
|
} catch (error) { |
||||
|
logger.error( |
||||
|
`[WebInlineQRScanner:${this.id}] Error in camera state listener:`, |
||||
|
error, |
||||
|
); |
||||
|
} |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
addCameraStateListener(listener: CameraStateListener): void { |
||||
|
this.cameraStateListeners.add(listener); |
||||
|
// Immediately notify the new listener of current state
|
||||
|
listener.onStateChange(this.currentState, this.currentStateMessage); |
||||
|
} |
||||
|
|
||||
|
removeCameraStateListener(listener: CameraStateListener): void { |
||||
|
this.cameraStateListeners.delete(listener); |
||||
|
} |
||||
|
|
||||
|
async checkPermissions(): Promise<boolean> { |
||||
|
try { |
||||
|
this.updateCameraState("initializing", "Checking camera permissions..."); |
||||
|
logger.error( |
||||
|
`[WebInlineQRScanner:${this.id}] Checking camera permissions...`, |
||||
|
); |
||||
|
const permissions = await navigator.permissions.query({ |
||||
|
name: "camera" as PermissionName, |
||||
|
}); |
||||
|
logger.error( |
||||
|
`[WebInlineQRScanner:${this.id}] Permission state:`, |
||||
|
permissions.state, |
||||
|
); |
||||
|
const granted = permissions.state === "granted"; |
||||
|
this.updateCameraState(granted ? "ready" : "permission_denied"); |
||||
|
return granted; |
||||
|
} catch (error) { |
||||
|
logger.error( |
||||
|
`[WebInlineQRScanner:${this.id}] Error checking camera permissions:`, |
||||
|
{ |
||||
|
error: error instanceof Error ? error.message : String(error), |
||||
|
stack: error instanceof Error ? error.stack : undefined, |
||||
|
}, |
||||
|
); |
||||
|
this.updateCameraState("error", "Error checking camera permissions"); |
||||
|
return false; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
async requestPermissions(): Promise<boolean> { |
||||
|
try { |
||||
|
this.updateCameraState( |
||||
|
"initializing", |
||||
|
"Requesting camera permissions...", |
||||
|
); |
||||
|
logger.error( |
||||
|
`[WebInlineQRScanner:${this.id}] Requesting camera permissions...`, |
||||
|
); |
||||
|
// First check if we have any video devices
|
||||
|
const devices = await navigator.mediaDevices.enumerateDevices(); |
||||
|
const videoDevices = devices.filter( |
||||
|
(device) => device.kind === "videoinput", |
||||
|
); |
||||
|
|
||||
|
logger.error(`[WebInlineQRScanner:${this.id}] Found video devices:`, { |
||||
|
count: videoDevices.length, |
||||
|
devices: videoDevices.map((d) => ({ id: d.deviceId, label: d.label })), |
||||
|
}); |
||||
|
|
||||
|
if (videoDevices.length === 0) { |
||||
|
logger.error(`[WebInlineQRScanner:${this.id}] No video devices found`); |
||||
|
throw new Error("No camera found on this device"); |
||||
|
} |
||||
|
|
||||
|
// Try to get a stream with specific constraints
|
||||
|
logger.error( |
||||
|
`[WebInlineQRScanner:${this.id}] Requesting camera stream with constraints:`, |
||||
|
{ |
||||
|
facingMode: "environment", |
||||
|
width: { ideal: 1280 }, |
||||
|
height: { ideal: 720 }, |
||||
|
}, |
||||
|
); |
||||
|
|
||||
|
const stream = await navigator.mediaDevices.getUserMedia({ |
||||
|
video: { |
||||
|
facingMode: "environment", |
||||
|
width: { ideal: 1280 }, |
||||
|
height: { ideal: 720 }, |
||||
|
}, |
||||
|
}); |
||||
|
|
||||
|
this.updateCameraState("ready", "Camera permissions granted"); |
||||
|
|
||||
|
// Stop the test stream immediately
|
||||
|
stream.getTracks().forEach((track) => { |
||||
|
logger.error(`[WebInlineQRScanner:${this.id}] Stopping test track:`, { |
||||
|
kind: track.kind, |
||||
|
label: track.label, |
||||
|
readyState: track.readyState, |
||||
|
}); |
||||
|
track.stop(); |
||||
|
}); |
||||
|
return true; |
||||
|
} catch (error) { |
||||
|
const wrappedError = |
||||
|
error instanceof Error ? error : new Error(String(error)); |
||||
|
|
||||
|
// Update state based on error type
|
||||
|
if ( |
||||
|
wrappedError.name === "NotFoundError" || |
||||
|
wrappedError.name === "DevicesNotFoundError" |
||||
|
) { |
||||
|
this.updateCameraState("not_found", "No camera found on this device"); |
||||
|
throw new Error("No camera found on this device"); |
||||
|
} else if ( |
||||
|
wrappedError.name === "NotAllowedError" || |
||||
|
wrappedError.name === "PermissionDeniedError" |
||||
|
) { |
||||
|
this.updateCameraState("permission_denied", "Camera access denied"); |
||||
|
throw new Error( |
||||
|
"Camera access denied. Please grant camera permission and try again", |
||||
|
); |
||||
|
} else if ( |
||||
|
wrappedError.name === "NotReadableError" || |
||||
|
wrappedError.name === "TrackStartError" |
||||
|
) { |
||||
|
this.updateCameraState( |
||||
|
"in_use", |
||||
|
"Camera is in use by another application", |
||||
|
); |
||||
|
throw new Error("Camera is in use by another application"); |
||||
|
} else { |
||||
|
this.updateCameraState("error", wrappedError.message); |
||||
|
throw new Error(`Camera error: ${wrappedError.message}`); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
async isSupported(): Promise<boolean> { |
||||
|
try { |
||||
|
logger.error( |
||||
|
`[WebInlineQRScanner:${this.id}] Checking browser support...`, |
||||
|
); |
||||
|
// Check for secure context first
|
||||
|
if (!window.isSecureContext) { |
||||
|
logger.error( |
||||
|
`[WebInlineQRScanner:${this.id}] Camera access requires HTTPS (secure context)`, |
||||
|
); |
||||
|
return false; |
||||
|
} |
||||
|
|
||||
|
// Check for camera API support
|
||||
|
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) { |
||||
|
logger.error( |
||||
|
`[WebInlineQRScanner:${this.id}] Camera API not supported in this browser`, |
||||
|
); |
||||
|
return false; |
||||
|
} |
||||
|
|
||||
|
// Check if we have any video devices
|
||||
|
const devices = await navigator.mediaDevices.enumerateDevices(); |
||||
|
const hasVideoDevices = devices.some( |
||||
|
(device) => device.kind === "videoinput", |
||||
|
); |
||||
|
|
||||
|
logger.error(`[WebInlineQRScanner:${this.id}] Device support check:`, { |
||||
|
hasSecureContext: window.isSecureContext, |
||||
|
hasMediaDevices: !!navigator.mediaDevices, |
||||
|
hasGetUserMedia: !!navigator.mediaDevices?.getUserMedia, |
||||
|
hasVideoDevices, |
||||
|
deviceCount: devices.length, |
||||
|
}); |
||||
|
|
||||
|
if (!hasVideoDevices) { |
||||
|
logger.error(`[WebInlineQRScanner:${this.id}] No video devices found`); |
||||
|
return false; |
||||
|
} |
||||
|
|
||||
|
return true; |
||||
|
} catch (error) { |
||||
|
logger.error( |
||||
|
`[WebInlineQRScanner:${this.id}] Error checking camera support:`, |
||||
|
{ |
||||
|
error: error instanceof Error ? error.message : String(error), |
||||
|
stack: error instanceof Error ? error.stack : undefined, |
||||
|
}, |
||||
|
); |
||||
|
return false; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private async scanQRCode(): Promise<void> { |
||||
|
if (!this.video || !this.canvas || !this.context || !this.stream) { |
||||
|
logger.error( |
||||
|
`[WebInlineQRScanner:${this.id}] Cannot scan: missing required elements`, |
||||
|
{ |
||||
|
hasVideo: !!this.video, |
||||
|
hasCanvas: !!this.canvas, |
||||
|
hasContext: !!this.context, |
||||
|
hasStream: !!this.stream, |
||||
|
}, |
||||
|
); |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
try { |
||||
|
const now = Date.now(); |
||||
|
const timeSinceLastFrame = now - this.lastFrameTime; |
||||
|
|
||||
|
// Throttle frame processing to target FPS
|
||||
|
if (timeSinceLastFrame < this.FRAME_INTERVAL) { |
||||
|
this.animationFrameId = requestAnimationFrame(() => this.scanQRCode()); |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
this.lastFrameTime = now; |
||||
|
|
||||
|
// Set canvas dimensions to match video
|
||||
|
this.canvas.width = this.video.videoWidth; |
||||
|
this.canvas.height = this.video.videoHeight; |
||||
|
|
||||
|
// Draw video frame to canvas
|
||||
|
this.context.drawImage( |
||||
|
this.video, |
||||
|
0, |
||||
|
0, |
||||
|
this.canvas.width, |
||||
|
this.canvas.height, |
||||
|
); |
||||
|
|
||||
|
// Get image data from canvas
|
||||
|
const imageData = this.context.getImageData( |
||||
|
0, |
||||
|
0, |
||||
|
this.canvas.width, |
||||
|
this.canvas.height, |
||||
|
); |
||||
|
|
||||
|
// Increment scan attempts
|
||||
|
this.scanAttempts++; |
||||
|
const timeSinceLastScan = now - this.lastScanTime; |
||||
|
|
||||
|
// Log scan attempt every 100 frames or 1 second
|
||||
|
if (this.scanAttempts % 100 === 0 || timeSinceLastScan >= 1000) { |
||||
|
logger.error(`[WebInlineQRScanner:${this.id}] Scanning frame:`, { |
||||
|
attempt: this.scanAttempts, |
||||
|
dimensions: { |
||||
|
width: this.canvas.width, |
||||
|
height: this.canvas.height, |
||||
|
}, |
||||
|
fps: Math.round(1000 / timeSinceLastScan), |
||||
|
imageDataSize: imageData.data.length, |
||||
|
imageDataWidth: imageData.width, |
||||
|
imageDataHeight: imageData.height, |
||||
|
timeSinceLastFrame, |
||||
|
targetFPS: this.TARGET_FPS, |
||||
|
}); |
||||
|
this.lastScanTime = now; |
||||
|
} |
||||
|
|
||||
|
// Scan for QR code
|
||||
|
const code = jsQR(imageData.data, imageData.width, imageData.height, { |
||||
|
inversionAttempts: "attemptBoth", // Try both normal and inverted
|
||||
|
}); |
||||
|
|
||||
|
if (code) { |
||||
|
// Check if the QR code is blurry by examining the location points
|
||||
|
const { topRightCorner, topLeftCorner, bottomLeftCorner } = |
||||
|
code.location; |
||||
|
const width = Math.sqrt( |
||||
|
Math.pow(topRightCorner.x - topLeftCorner.x, 2) + |
||||
|
Math.pow(topRightCorner.y - topLeftCorner.y, 2), |
||||
|
); |
||||
|
const height = Math.sqrt( |
||||
|
Math.pow(bottomLeftCorner.x - topLeftCorner.x, 2) + |
||||
|
Math.pow(bottomLeftCorner.y - topLeftCorner.y, 2), |
||||
|
); |
||||
|
|
||||
|
// Adjust minimum size based on canvas dimensions
|
||||
|
const minSize = Math.min(this.canvas.width, this.canvas.height) * 0.1; // 10% of the smaller dimension
|
||||
|
const isBlurry = |
||||
|
width < minSize || |
||||
|
height < minSize || |
||||
|
!code.data || |
||||
|
code.data.length === 0; |
||||
|
|
||||
|
logger.error(`[WebInlineQRScanner:${this.id}] QR Code detected:`, { |
||||
|
data: code.data, |
||||
|
location: code.location, |
||||
|
attempts: this.scanAttempts, |
||||
|
isBlurry, |
||||
|
dimensions: { |
||||
|
width, |
||||
|
height, |
||||
|
minSize, |
||||
|
canvasWidth: this.canvas.width, |
||||
|
canvasHeight: this.canvas.height, |
||||
|
relativeWidth: width / this.canvas.width, |
||||
|
relativeHeight: height / this.canvas.height, |
||||
|
}, |
||||
|
corners: { |
||||
|
topLeft: topLeftCorner, |
||||
|
topRight: topRightCorner, |
||||
|
bottomLeft: bottomLeftCorner, |
||||
|
}, |
||||
|
}); |
||||
|
|
||||
|
if (isBlurry) { |
||||
|
if (this.scanListener?.onError) { |
||||
|
this.scanListener.onError( |
||||
|
new Error( |
||||
|
"QR code detected but too blurry to read. Please hold the camera steady and ensure the QR code is well-lit.", |
||||
|
), |
||||
|
); |
||||
|
} |
||||
|
// Continue scanning if QR code is blurry
|
||||
|
this.animationFrameId = requestAnimationFrame(() => |
||||
|
this.scanQRCode(), |
||||
|
); |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
if (this.scanListener?.onScan) { |
||||
|
this.scanListener.onScan(code.data); |
||||
|
} |
||||
|
// Stop scanning after successful detection
|
||||
|
await this.stopScan(); |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
// Continue scanning if no QR code found
|
||||
|
this.animationFrameId = requestAnimationFrame(() => this.scanQRCode()); |
||||
|
} catch (error) { |
||||
|
logger.error(`[WebInlineQRScanner:${this.id}] Error scanning QR code:`, { |
||||
|
error: error instanceof Error ? error.message : String(error), |
||||
|
stack: error instanceof Error ? error.stack : undefined, |
||||
|
attempt: this.scanAttempts, |
||||
|
videoState: this.video |
||||
|
? { |
||||
|
readyState: this.video.readyState, |
||||
|
paused: this.video.paused, |
||||
|
ended: this.video.ended, |
||||
|
width: this.video.videoWidth, |
||||
|
height: this.video.videoHeight, |
||||
|
} |
||||
|
: null, |
||||
|
canvasState: this.canvas |
||||
|
? { |
||||
|
width: this.canvas.width, |
||||
|
height: this.canvas.height, |
||||
|
} |
||||
|
: null, |
||||
|
}); |
||||
|
if (this.scanListener?.onError) { |
||||
|
this.scanListener.onError( |
||||
|
error instanceof Error ? error : new Error(String(error)), |
||||
|
); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
async startScan(): Promise<void> { |
||||
|
if (this.isScanning) { |
||||
|
logger.error(`[WebInlineQRScanner:${this.id}] Scanner already running`); |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
try { |
||||
|
this.isScanning = true; |
||||
|
this.scanAttempts = 0; |
||||
|
this.lastScanTime = Date.now(); |
||||
|
this.updateCameraState("initializing", "Starting camera..."); |
||||
|
logger.error(`[WebInlineQRScanner:${this.id}] Starting scan`); |
||||
|
|
||||
|
// Get camera stream
|
||||
|
logger.error( |
||||
|
`[WebInlineQRScanner:${this.id}] Requesting camera stream...`, |
||||
|
); |
||||
|
this.stream = await navigator.mediaDevices.getUserMedia({ |
||||
|
video: { |
||||
|
facingMode: "environment", |
||||
|
width: { ideal: 1280 }, |
||||
|
height: { ideal: 720 }, |
||||
|
}, |
||||
|
}); |
||||
|
|
||||
|
this.updateCameraState("active", "Camera is active"); |
||||
|
|
||||
|
logger.error(`[WebInlineQRScanner:${this.id}] Camera stream obtained:`, { |
||||
|
tracks: this.stream.getTracks().map((t) => ({ |
||||
|
kind: t.kind, |
||||
|
label: t.label, |
||||
|
readyState: t.readyState, |
||||
|
})), |
||||
|
}); |
||||
|
|
||||
|
// Set up video element
|
||||
|
if (this.video) { |
||||
|
this.video.srcObject = this.stream; |
||||
|
await this.video.play(); |
||||
|
logger.error( |
||||
|
`[WebInlineQRScanner:${this.id}] Video element started playing`, |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
// Emit stream to component
|
||||
|
this.events.emit("stream", this.stream); |
||||
|
logger.error(`[WebInlineQRScanner:${this.id}] Stream event emitted`); |
||||
|
|
||||
|
// Start QR code scanning
|
||||
|
this.scanQRCode(); |
||||
|
} catch (error) { |
||||
|
this.isScanning = false; |
||||
|
const wrappedError = |
||||
|
error instanceof Error ? error : new Error(String(error)); |
||||
|
|
||||
|
// Update state based on error type
|
||||
|
if ( |
||||
|
wrappedError.name === "NotReadableError" || |
||||
|
wrappedError.name === "TrackStartError" |
||||
|
) { |
||||
|
this.updateCameraState( |
||||
|
"in_use", |
||||
|
"Camera is in use by another application", |
||||
|
); |
||||
|
} else { |
||||
|
this.updateCameraState("error", wrappedError.message); |
||||
|
} |
||||
|
|
||||
|
if (this.scanListener?.onError) { |
||||
|
this.scanListener.onError(wrappedError); |
||||
|
} |
||||
|
throw wrappedError; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
async stopScan(): Promise<void> { |
||||
|
if (!this.isScanning) { |
||||
|
logger.error( |
||||
|
`[WebInlineQRScanner:${this.id}] Scanner not running, nothing to stop`, |
||||
|
); |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
try { |
||||
|
logger.error(`[WebInlineQRScanner:${this.id}] Stopping scan`, { |
||||
|
scanAttempts: this.scanAttempts, |
||||
|
duration: Date.now() - this.lastScanTime, |
||||
|
}); |
||||
|
|
||||
|
// Stop animation frame
|
||||
|
if (this.animationFrameId !== null) { |
||||
|
cancelAnimationFrame(this.animationFrameId); |
||||
|
this.animationFrameId = null; |
||||
|
logger.error( |
||||
|
`[WebInlineQRScanner:${this.id}] Animation frame cancelled`, |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
// Stop video
|
||||
|
if (this.video) { |
||||
|
this.video.pause(); |
||||
|
this.video.srcObject = null; |
||||
|
logger.error(`[WebInlineQRScanner:${this.id}] Video element stopped`); |
||||
|
} |
||||
|
|
||||
|
// Stop all tracks in the stream
|
||||
|
if (this.stream) { |
||||
|
this.stream.getTracks().forEach((track) => { |
||||
|
logger.error(`[WebInlineQRScanner:${this.id}] Stopping track:`, { |
||||
|
kind: track.kind, |
||||
|
label: track.label, |
||||
|
readyState: track.readyState, |
||||
|
}); |
||||
|
track.stop(); |
||||
|
}); |
||||
|
this.stream = null; |
||||
|
} |
||||
|
|
||||
|
// Emit stream stopped event
|
||||
|
this.events.emit("stream", null); |
||||
|
logger.error( |
||||
|
`[WebInlineQRScanner:${this.id}] Stream stopped event emitted`, |
||||
|
); |
||||
|
} catch (error) { |
||||
|
logger.error( |
||||
|
`[WebInlineQRScanner:${this.id}] Error stopping scan:`, |
||||
|
error, |
||||
|
); |
||||
|
this.updateCameraState("error", "Error stopping camera"); |
||||
|
throw error; |
||||
|
} finally { |
||||
|
this.isScanning = false; |
||||
|
logger.error(`[WebInlineQRScanner:${this.id}] Scan stopped successfully`); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
addListener(listener: ScanListener): void { |
||||
|
logger.error(`[WebInlineQRScanner:${this.id}] Adding scan listener`); |
||||
|
this.scanListener = listener; |
||||
|
} |
||||
|
|
||||
|
onStream(callback: (stream: MediaStream | null) => void): void { |
||||
|
logger.error( |
||||
|
`[WebInlineQRScanner:${this.id}] Adding stream event listener`, |
||||
|
); |
||||
|
this.events.on("stream", callback); |
||||
|
} |
||||
|
|
||||
|
async cleanup(): Promise<void> { |
||||
|
try { |
||||
|
logger.error(`[WebInlineQRScanner:${this.id}] Starting cleanup`); |
||||
|
await this.stopScan(); |
||||
|
this.events.removeAllListeners(); |
||||
|
logger.error(`[WebInlineQRScanner:${this.id}] Event listeners removed`); |
||||
|
|
||||
|
// Clean up DOM elements
|
||||
|
if (this.video) { |
||||
|
this.video.remove(); |
||||
|
this.video = null; |
||||
|
logger.error(`[WebInlineQRScanner:${this.id}] Video element removed`); |
||||
|
} |
||||
|
if (this.canvas) { |
||||
|
this.canvas.remove(); |
||||
|
this.canvas = null; |
||||
|
logger.error(`[WebInlineQRScanner:${this.id}] Canvas element removed`); |
||||
|
} |
||||
|
this.context = null; |
||||
|
logger.error( |
||||
|
`[WebInlineQRScanner:${this.id}] Cleanup completed successfully`, |
||||
|
); |
||||
|
} catch (error) { |
||||
|
logger.error( |
||||
|
`[WebInlineQRScanner:${this.id}] Error during cleanup:`, |
||||
|
error, |
||||
|
); |
||||
|
this.updateCameraState("error", "Error during cleanup"); |
||||
|
throw error; |
||||
|
} |
||||
|
} |
||||
|
} |
@ -0,0 +1,69 @@ |
|||||
|
// QR Scanner Service Types
|
||||
|
|
||||
|
/** |
||||
|
* Listener interface for QR code scan events |
||||
|
*/ |
||||
|
export interface ScanListener { |
||||
|
/** Called when a QR code is successfully scanned */ |
||||
|
onScan: (result: string) => void; |
||||
|
/** Called when an error occurs during scanning */ |
||||
|
onError?: (error: Error) => void; |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Options for configuring the QR scanner |
||||
|
*/ |
||||
|
export interface QRScannerOptions { |
||||
|
/** Camera to use ('front' or 'back') */ |
||||
|
camera?: "front" | "back"; |
||||
|
/** Whether to show a preview of the camera feed */ |
||||
|
showPreview?: boolean; |
||||
|
/** Whether to play a sound on successful scan */ |
||||
|
playSound?: boolean; |
||||
|
} |
||||
|
|
||||
|
export type CameraState = |
||||
|
| "initializing" // Camera is being initialized
|
||||
|
| "ready" // Camera is ready to use
|
||||
|
| "active" // Camera is actively streaming
|
||||
|
| "in_use" // Camera is in use by another application
|
||||
|
| "permission_denied" // Camera permission was denied
|
||||
|
| "not_found" // No camera found on device
|
||||
|
| "error" // Generic error state
|
||||
|
| "off"; // Camera is off/stopped
|
||||
|
|
||||
|
export interface CameraStateListener { |
||||
|
onStateChange: (state: CameraState, message?: string) => void; |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Interface for QR scanner service implementations |
||||
|
*/ |
||||
|
export interface QRScannerService { |
||||
|
/** Check if camera permissions are granted */ |
||||
|
checkPermissions(): Promise<boolean>; |
||||
|
|
||||
|
/** Request camera permissions from the user */ |
||||
|
requestPermissions(): Promise<boolean>; |
||||
|
|
||||
|
/** Check if QR scanning is supported on this device */ |
||||
|
isSupported(): Promise<boolean>; |
||||
|
|
||||
|
/** Start scanning for QR codes */ |
||||
|
startScan(options?: QRScannerOptions): Promise<void>; |
||||
|
|
||||
|
/** Stop scanning for QR codes */ |
||||
|
stopScan(): Promise<void>; |
||||
|
|
||||
|
/** Add a listener for scan events */ |
||||
|
addListener(listener: ScanListener): void; |
||||
|
|
||||
|
/** Add a listener for camera state changes */ |
||||
|
addCameraStateListener(listener: CameraStateListener): void; |
||||
|
|
||||
|
/** Remove a camera state listener */ |
||||
|
removeCameraStateListener(listener: CameraStateListener): void; |
||||
|
|
||||
|
/** Clean up scanner resources */ |
||||
|
cleanup(): Promise<void>; |
||||
|
} |
@ -0,0 +1,479 @@ |
|||||
|
import { |
||||
|
ImageResult, |
||||
|
PlatformService, |
||||
|
PlatformCapabilities, |
||||
|
} from "../PlatformService"; |
||||
|
import { Filesystem, Directory, Encoding } from "@capacitor/filesystem"; |
||||
|
import { Camera, CameraResultType, CameraSource } from "@capacitor/camera"; |
||||
|
import { Share } from "@capacitor/share"; |
||||
|
import { logger } from "../../utils/logger"; |
||||
|
|
||||
|
/** |
||||
|
* Platform service implementation for Capacitor (mobile) platform. |
||||
|
* Provides native mobile functionality through Capacitor plugins for: |
||||
|
* - File system operations |
||||
|
* - Camera and image picker |
||||
|
* - Platform-specific features |
||||
|
*/ |
||||
|
export class CapacitorPlatformService implements PlatformService { |
||||
|
/** |
||||
|
* Gets the capabilities of the Capacitor platform |
||||
|
* @returns Platform capabilities object |
||||
|
*/ |
||||
|
getCapabilities(): PlatformCapabilities { |
||||
|
return { |
||||
|
hasFileSystem: true, |
||||
|
hasCamera: true, |
||||
|
isMobile: true, |
||||
|
isIOS: /iPad|iPhone|iPod/.test(navigator.userAgent), |
||||
|
hasFileDownload: false, |
||||
|
needsFileHandlingInstructions: true, |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Checks and requests storage permissions if needed |
||||
|
* @returns Promise that resolves when permissions are granted |
||||
|
* @throws Error if permissions are denied |
||||
|
*/ |
||||
|
private async checkStoragePermissions(): Promise<void> { |
||||
|
try { |
||||
|
const logData = { |
||||
|
platform: this.getCapabilities().isIOS ? "iOS" : "Android", |
||||
|
timestamp: new Date().toISOString(), |
||||
|
}; |
||||
|
logger.log( |
||||
|
"Checking storage permissions", |
||||
|
JSON.stringify(logData, null, 2), |
||||
|
); |
||||
|
|
||||
|
if (this.getCapabilities().isIOS) { |
||||
|
// iOS uses different permission model
|
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
// Try to access a test directory to check permissions
|
||||
|
try { |
||||
|
await Filesystem.stat({ |
||||
|
path: "/storage/emulated/0/Download", |
||||
|
directory: Directory.Documents, |
||||
|
}); |
||||
|
logger.log( |
||||
|
"Storage permissions already granted", |
||||
|
JSON.stringify({ timestamp: new Date().toISOString() }, null, 2), |
||||
|
); |
||||
|
return; |
||||
|
} catch (error: unknown) { |
||||
|
const err = error as Error; |
||||
|
const errorLogData = { |
||||
|
error: { |
||||
|
message: err.message, |
||||
|
name: err.name, |
||||
|
stack: err.stack, |
||||
|
}, |
||||
|
timestamp: new Date().toISOString(), |
||||
|
}; |
||||
|
|
||||
|
// "File does not exist" is expected and not a permission error
|
||||
|
if (err.message === "File does not exist") { |
||||
|
logger.log( |
||||
|
"Directory does not exist (expected), proceeding with write", |
||||
|
JSON.stringify(errorLogData, null, 2), |
||||
|
); |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
// Check for actual permission errors
|
||||
|
if ( |
||||
|
err.message.includes("permission") || |
||||
|
err.message.includes("access") |
||||
|
) { |
||||
|
logger.log( |
||||
|
"Permission check failed, requesting permissions", |
||||
|
JSON.stringify(errorLogData, null, 2), |
||||
|
); |
||||
|
|
||||
|
// The Filesystem plugin will automatically request permissions when needed
|
||||
|
// We just need to try the operation again
|
||||
|
try { |
||||
|
await Filesystem.stat({ |
||||
|
path: "/storage/emulated/0/Download", |
||||
|
directory: Directory.Documents, |
||||
|
}); |
||||
|
logger.log( |
||||
|
"Storage permissions granted after request", |
||||
|
JSON.stringify({ timestamp: new Date().toISOString() }, null, 2), |
||||
|
); |
||||
|
return; |
||||
|
} catch (retryError: unknown) { |
||||
|
const retryErr = retryError as Error; |
||||
|
throw new Error( |
||||
|
`Failed to obtain storage permissions: ${retryErr.message}`, |
||||
|
); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// For any other error, log it but don't treat as permission error
|
||||
|
logger.log( |
||||
|
"Unexpected error during permission check", |
||||
|
JSON.stringify(errorLogData, null, 2), |
||||
|
); |
||||
|
return; |
||||
|
} |
||||
|
} catch (error: unknown) { |
||||
|
const err = error as Error; |
||||
|
const errorLogData = { |
||||
|
error: { |
||||
|
message: err.message, |
||||
|
name: err.name, |
||||
|
stack: err.stack, |
||||
|
}, |
||||
|
timestamp: new Date().toISOString(), |
||||
|
}; |
||||
|
logger.error( |
||||
|
"Error checking/requesting permissions", |
||||
|
JSON.stringify(errorLogData, null, 2), |
||||
|
); |
||||
|
throw new Error(`Failed to obtain storage permissions: ${err.message}`); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Reads a file from the app's data directory. |
||||
|
* @param path - Relative path to the file in the app's data directory |
||||
|
* @returns Promise resolving to the file contents as string |
||||
|
* @throws Error if file cannot be read or doesn't exist |
||||
|
*/ |
||||
|
async readFile(path: string): Promise<string> { |
||||
|
const file = await Filesystem.readFile({ |
||||
|
path, |
||||
|
directory: Directory.Data, |
||||
|
}); |
||||
|
if (file.data instanceof Blob) { |
||||
|
return await file.data.text(); |
||||
|
} |
||||
|
return file.data; |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Writes content to a file in the app's safe storage and offers sharing. |
||||
|
* |
||||
|
* Platform-specific behavior: |
||||
|
* - Saves to app's Documents directory |
||||
|
* - Offers sharing functionality to move file elsewhere |
||||
|
* |
||||
|
* The method handles: |
||||
|
* 1. Writing to app-safe storage |
||||
|
* 2. Sharing the file with user's preferred app |
||||
|
* 3. Error handling and logging |
||||
|
* |
||||
|
* @param fileName - The name of the file to create (e.g. "backup.json") |
||||
|
* @param content - The content to write to the file |
||||
|
* |
||||
|
* @throws Error if: |
||||
|
* - File writing fails |
||||
|
* - Sharing fails |
||||
|
* |
||||
|
* @example |
||||
|
* ```typescript
|
||||
|
* // Save and share a JSON file
|
||||
|
* await platformService.writeFile( |
||||
|
* "backup.json", |
||||
|
* JSON.stringify(data) |
||||
|
* ); |
||||
|
* ``` |
||||
|
*/ |
||||
|
async writeFile(fileName: string, content: string): Promise<void> { |
||||
|
try { |
||||
|
const logData = { |
||||
|
targetFileName: fileName, |
||||
|
contentLength: content.length, |
||||
|
platform: this.getCapabilities().isIOS ? "iOS" : "Android", |
||||
|
timestamp: new Date().toISOString(), |
||||
|
}; |
||||
|
logger.log( |
||||
|
"Starting writeFile operation", |
||||
|
JSON.stringify(logData, null, 2), |
||||
|
); |
||||
|
|
||||
|
// For Android, we need to handle content URIs differently
|
||||
|
if (this.getCapabilities().isIOS) { |
||||
|
// Write to app's Documents directory for iOS
|
||||
|
const writeResult = await Filesystem.writeFile({ |
||||
|
path: fileName, |
||||
|
data: content, |
||||
|
directory: Directory.Data, |
||||
|
encoding: Encoding.UTF8, |
||||
|
}); |
||||
|
|
||||
|
const writeSuccessLogData = { |
||||
|
path: writeResult.uri, |
||||
|
timestamp: new Date().toISOString(), |
||||
|
}; |
||||
|
logger.log( |
||||
|
"File write successful", |
||||
|
JSON.stringify(writeSuccessLogData, null, 2), |
||||
|
); |
||||
|
|
||||
|
// Offer to share the file
|
||||
|
try { |
||||
|
await Share.share({ |
||||
|
title: "TimeSafari Backup", |
||||
|
text: "Here is your TimeSafari backup file.", |
||||
|
url: writeResult.uri, |
||||
|
dialogTitle: "Share your backup", |
||||
|
}); |
||||
|
|
||||
|
logger.log( |
||||
|
"Share dialog shown", |
||||
|
JSON.stringify({ timestamp: new Date().toISOString() }, null, 2), |
||||
|
); |
||||
|
} catch (shareError) { |
||||
|
// Log share error but don't fail the operation
|
||||
|
logger.error( |
||||
|
"Share dialog failed", |
||||
|
JSON.stringify( |
||||
|
{ |
||||
|
error: shareError, |
||||
|
timestamp: new Date().toISOString(), |
||||
|
}, |
||||
|
null, |
||||
|
2, |
||||
|
), |
||||
|
); |
||||
|
} |
||||
|
} else { |
||||
|
// For Android, first write to app's Documents directory
|
||||
|
const writeResult = await Filesystem.writeFile({ |
||||
|
path: fileName, |
||||
|
data: content, |
||||
|
directory: Directory.Data, |
||||
|
encoding: Encoding.UTF8, |
||||
|
}); |
||||
|
|
||||
|
const writeSuccessLogData = { |
||||
|
path: writeResult.uri, |
||||
|
timestamp: new Date().toISOString(), |
||||
|
}; |
||||
|
logger.log( |
||||
|
"File write successful to app storage", |
||||
|
JSON.stringify(writeSuccessLogData, null, 2), |
||||
|
); |
||||
|
|
||||
|
// Then share the file to let user choose where to save it
|
||||
|
try { |
||||
|
await Share.share({ |
||||
|
title: "TimeSafari Backup", |
||||
|
text: "Here is your TimeSafari backup file.", |
||||
|
url: writeResult.uri, |
||||
|
dialogTitle: "Save your backup", |
||||
|
}); |
||||
|
|
||||
|
logger.log( |
||||
|
"Share dialog shown for Android", |
||||
|
JSON.stringify({ timestamp: new Date().toISOString() }, null, 2), |
||||
|
); |
||||
|
} catch (shareError) { |
||||
|
// Log share error but don't fail the operation
|
||||
|
logger.error( |
||||
|
"Share dialog failed for Android", |
||||
|
JSON.stringify( |
||||
|
{ |
||||
|
error: shareError, |
||||
|
timestamp: new Date().toISOString(), |
||||
|
}, |
||||
|
null, |
||||
|
2, |
||||
|
), |
||||
|
); |
||||
|
} |
||||
|
} |
||||
|
} catch (error: unknown) { |
||||
|
const err = error as Error; |
||||
|
const finalErrorLogData = { |
||||
|
error: { |
||||
|
message: err.message, |
||||
|
name: err.name, |
||||
|
stack: err.stack, |
||||
|
}, |
||||
|
timestamp: new Date().toISOString(), |
||||
|
}; |
||||
|
logger.error( |
||||
|
"Error in writeFile operation:", |
||||
|
JSON.stringify(finalErrorLogData, null, 2), |
||||
|
); |
||||
|
throw new Error(`Failed to save file: ${err.message}`); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Writes content to a file in the device's app-private storage. |
||||
|
* Then shares the file using the system share dialog. |
||||
|
* |
||||
|
* Works on both Android and iOS without needing external storage permissions. |
||||
|
* |
||||
|
* @param fileName - The name of the file to create (e.g. "backup.json") |
||||
|
* @param content - The content to write to the file |
||||
|
*/ |
||||
|
async writeAndShareFile(fileName: string, content: string): Promise<void> { |
||||
|
const timestamp = new Date().toISOString(); |
||||
|
const logData = { |
||||
|
action: "writeAndShareFile", |
||||
|
fileName, |
||||
|
contentLength: content.length, |
||||
|
timestamp, |
||||
|
}; |
||||
|
logger.log("[CapacitorPlatformService]", JSON.stringify(logData, null, 2)); |
||||
|
|
||||
|
try { |
||||
|
const { uri } = await Filesystem.writeFile({ |
||||
|
path: fileName, |
||||
|
data: content, |
||||
|
directory: Directory.Data, |
||||
|
encoding: Encoding.UTF8, |
||||
|
recursive: true, |
||||
|
}); |
||||
|
|
||||
|
logger.log("[CapacitorPlatformService] File write successful:", { |
||||
|
uri, |
||||
|
timestamp: new Date().toISOString(), |
||||
|
}); |
||||
|
|
||||
|
await Share.share({ |
||||
|
title: "TimeSafari Backup", |
||||
|
text: "Here is your backup file.", |
||||
|
url: uri, |
||||
|
dialogTitle: "Share your backup file", |
||||
|
}); |
||||
|
} catch (error) { |
||||
|
const err = error as Error; |
||||
|
const errLog = { |
||||
|
message: err.message, |
||||
|
stack: err.stack, |
||||
|
timestamp: new Date().toISOString(), |
||||
|
}; |
||||
|
logger.error( |
||||
|
"[CapacitorPlatformService] Error writing or sharing file:", |
||||
|
JSON.stringify(errLog, null, 2), |
||||
|
); |
||||
|
throw new Error(`Failed to write or share file: ${err.message}`); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Deletes a file from the app's data directory. |
||||
|
* @param path - Relative path to the file to delete |
||||
|
* @throws Error if deletion fails or file doesn't exist |
||||
|
*/ |
||||
|
async deleteFile(path: string): Promise<void> { |
||||
|
await Filesystem.deleteFile({ |
||||
|
path, |
||||
|
directory: Directory.Data, |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Lists files in the specified directory within app's data directory. |
||||
|
* @param directory - Relative path to the directory to list |
||||
|
* @returns Promise resolving to array of filenames |
||||
|
* @throws Error if directory cannot be read or doesn't exist |
||||
|
*/ |
||||
|
async listFiles(directory: string): Promise<string[]> { |
||||
|
const result = await Filesystem.readdir({ |
||||
|
path: directory, |
||||
|
directory: Directory.Data, |
||||
|
}); |
||||
|
return result.files.map((file) => |
||||
|
typeof file === "string" ? file : file.name, |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Opens the device camera to take a picture. |
||||
|
* Configures camera for high quality images with editing enabled. |
||||
|
* @returns Promise resolving to the captured image data |
||||
|
* @throws Error if camera access fails or user cancels |
||||
|
*/ |
||||
|
async takePicture(): Promise<ImageResult> { |
||||
|
try { |
||||
|
const image = await Camera.getPhoto({ |
||||
|
quality: 90, |
||||
|
allowEditing: true, |
||||
|
resultType: CameraResultType.Base64, |
||||
|
source: CameraSource.Camera, |
||||
|
}); |
||||
|
|
||||
|
const blob = await this.processImageData(image.base64String); |
||||
|
return { |
||||
|
blob, |
||||
|
fileName: `photo_${Date.now()}.${image.format || "jpg"}`, |
||||
|
}; |
||||
|
} catch (error) { |
||||
|
logger.error("Error taking picture with Capacitor:", error); |
||||
|
throw new Error("Failed to take picture"); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Opens the device photo gallery to pick an existing image. |
||||
|
* Configures picker for high quality images with editing enabled. |
||||
|
* @returns Promise resolving to the selected image data |
||||
|
* @throws Error if gallery access fails or user cancels |
||||
|
*/ |
||||
|
async pickImage(): Promise<ImageResult> { |
||||
|
try { |
||||
|
const image = await Camera.getPhoto({ |
||||
|
quality: 90, |
||||
|
allowEditing: true, |
||||
|
resultType: CameraResultType.Base64, |
||||
|
source: CameraSource.Photos, |
||||
|
}); |
||||
|
|
||||
|
const blob = await this.processImageData(image.base64String); |
||||
|
return { |
||||
|
blob, |
||||
|
fileName: `photo_${Date.now()}.${image.format || "jpg"}`, |
||||
|
}; |
||||
|
} catch (error) { |
||||
|
logger.error("Error picking image with Capacitor:", error); |
||||
|
throw new Error("Failed to pick image"); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Converts base64 image data to a Blob. |
||||
|
* @param base64String - Base64 encoded image data |
||||
|
* @returns Promise resolving to image Blob |
||||
|
* @throws Error if conversion fails |
||||
|
*/ |
||||
|
private async processImageData(base64String?: string): Promise<Blob> { |
||||
|
if (!base64String) { |
||||
|
throw new Error("No image data received"); |
||||
|
} |
||||
|
|
||||
|
// Convert base64 to blob
|
||||
|
const byteCharacters = atob(base64String); |
||||
|
const byteArrays = []; |
||||
|
for (let offset = 0; offset < byteCharacters.length; offset += 512) { |
||||
|
const slice = byteCharacters.slice(offset, offset + 512); |
||||
|
const byteNumbers = new Array(slice.length); |
||||
|
for (let i = 0; i < slice.length; i++) { |
||||
|
byteNumbers[i] = slice.charCodeAt(i); |
||||
|
} |
||||
|
const byteArray = new Uint8Array(byteNumbers); |
||||
|
byteArrays.push(byteArray); |
||||
|
} |
||||
|
return new Blob(byteArrays, { type: "image/jpeg" }); |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Handles deep link URLs for the application. |
||||
|
* Note: Capacitor handles deep links automatically. |
||||
|
* @param _url - The deep link URL (unused) |
||||
|
*/ |
||||
|
async handleDeepLink(_url: string): Promise<void> { |
||||
|
// Capacitor handles deep links automatically
|
||||
|
// This is just a placeholder for the interface
|
||||
|
return Promise.resolve(); |
||||
|
} |
||||
|
} |
@ -0,0 +1,111 @@ |
|||||
|
import { |
||||
|
ImageResult, |
||||
|
PlatformService, |
||||
|
PlatformCapabilities, |
||||
|
} from "../PlatformService"; |
||||
|
import { logger } from "../../utils/logger"; |
||||
|
|
||||
|
/** |
||||
|
* Platform service implementation for Electron (desktop) platform. |
||||
|
* Note: This is a placeholder implementation with most methods currently unimplemented. |
||||
|
* Implements the PlatformService interface but throws "Not implemented" errors for most operations. |
||||
|
* |
||||
|
* @remarks |
||||
|
* This service is intended for desktop application functionality through Electron. |
||||
|
* Future implementations should provide: |
||||
|
* - Native file system access |
||||
|
* - Desktop camera integration |
||||
|
* - System-level features |
||||
|
*/ |
||||
|
export class ElectronPlatformService implements PlatformService { |
||||
|
/** |
||||
|
* Gets the capabilities of the Electron platform |
||||
|
* @returns Platform capabilities object |
||||
|
*/ |
||||
|
getCapabilities(): PlatformCapabilities { |
||||
|
return { |
||||
|
hasFileSystem: false, // Not implemented yet
|
||||
|
hasCamera: false, // Not implemented yet
|
||||
|
isMobile: false, |
||||
|
isIOS: false, |
||||
|
hasFileDownload: false, // Not implemented yet
|
||||
|
needsFileHandlingInstructions: false, |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Reads a file from the filesystem. |
||||
|
* @param _path - Path to the file to read |
||||
|
* @returns Promise that should resolve to file contents |
||||
|
* @throws Error with "Not implemented" message |
||||
|
* @todo Implement file reading using Electron's file system API |
||||
|
*/ |
||||
|
async readFile(_path: string): Promise<string> { |
||||
|
throw new Error("Not implemented"); |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Writes content to a file. |
||||
|
* @param _path - Path where to write the file |
||||
|
* @param _content - Content to write to the file |
||||
|
* @throws Error with "Not implemented" message |
||||
|
* @todo Implement file writing using Electron's file system API |
||||
|
*/ |
||||
|
async writeFile(_path: string, _content: string): Promise<void> { |
||||
|
throw new Error("Not implemented"); |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Deletes a file from the filesystem. |
||||
|
* @param _path - Path to the file to delete |
||||
|
* @throws Error with "Not implemented" message |
||||
|
* @todo Implement file deletion using Electron's file system API |
||||
|
*/ |
||||
|
async deleteFile(_path: string): Promise<void> { |
||||
|
throw new Error("Not implemented"); |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Lists files in the specified directory. |
||||
|
* @param _directory - Path to the directory to list |
||||
|
* @returns Promise that should resolve to array of filenames |
||||
|
* @throws Error with "Not implemented" message |
||||
|
* @todo Implement directory listing using Electron's file system API |
||||
|
*/ |
||||
|
async listFiles(_directory: string): Promise<string[]> { |
||||
|
throw new Error("Not implemented"); |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Should open system camera to take a picture. |
||||
|
* @returns Promise that should resolve to captured image data |
||||
|
* @throws Error with "Not implemented" message |
||||
|
* @todo Implement camera access using Electron's media APIs |
||||
|
*/ |
||||
|
async takePicture(): Promise<ImageResult> { |
||||
|
logger.error("takePicture not implemented in Electron platform"); |
||||
|
throw new Error("Not implemented"); |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Should open system file picker for selecting an image. |
||||
|
* @returns Promise that should resolve to selected image data |
||||
|
* @throws Error with "Not implemented" message |
||||
|
* @todo Implement file picker using Electron's dialog API |
||||
|
*/ |
||||
|
async pickImage(): Promise<ImageResult> { |
||||
|
logger.error("pickImage not implemented in Electron platform"); |
||||
|
throw new Error("Not implemented"); |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Should handle deep link URLs for the desktop application. |
||||
|
* @param _url - The deep link URL to handle |
||||
|
* @throws Error with "Not implemented" message |
||||
|
* @todo Implement deep link handling using Electron's protocol handler |
||||
|
*/ |
||||
|
async handleDeepLink(_url: string): Promise<void> { |
||||
|
logger.error("handleDeepLink not implemented in Electron platform"); |
||||
|
throw new Error("Not implemented"); |
||||
|
} |
||||
|
} |
@ -0,0 +1,112 @@ |
|||||
|
import { |
||||
|
ImageResult, |
||||
|
PlatformService, |
||||
|
PlatformCapabilities, |
||||
|
} from "../PlatformService"; |
||||
|
import { logger } from "../../utils/logger"; |
||||
|
|
||||
|
/** |
||||
|
* Platform service implementation for PyWebView platform. |
||||
|
* Note: This is a placeholder implementation with most methods currently unimplemented. |
||||
|
* Implements the PlatformService interface but throws "Not implemented" errors for most operations. |
||||
|
* |
||||
|
* @remarks |
||||
|
* This service is intended for Python-based desktop applications using pywebview. |
||||
|
* Future implementations should provide: |
||||
|
* - Integration with Python backend file operations |
||||
|
* - System camera access through Python |
||||
|
* - Native system dialogs via pywebview |
||||
|
* - Python-JavaScript bridge functionality |
||||
|
*/ |
||||
|
export class PyWebViewPlatformService implements PlatformService { |
||||
|
/** |
||||
|
* Gets the capabilities of the PyWebView platform |
||||
|
* @returns Platform capabilities object |
||||
|
*/ |
||||
|
getCapabilities(): PlatformCapabilities { |
||||
|
return { |
||||
|
hasFileSystem: false, // Not implemented yet
|
||||
|
hasCamera: false, // Not implemented yet
|
||||
|
isMobile: false, |
||||
|
isIOS: false, |
||||
|
hasFileDownload: false, // Not implemented yet
|
||||
|
needsFileHandlingInstructions: false, |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Reads a file using the Python backend. |
||||
|
* @param _path - Path to the file to read |
||||
|
* @returns Promise that should resolve to file contents |
||||
|
* @throws Error with "Not implemented" message |
||||
|
* @todo Implement file reading through pywebview's Python-JavaScript bridge |
||||
|
*/ |
||||
|
async readFile(_path: string): Promise<string> { |
||||
|
throw new Error("Not implemented"); |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Writes content to a file using the Python backend. |
||||
|
* @param _path - Path where to write the file |
||||
|
* @param _content - Content to write to the file |
||||
|
* @throws Error with "Not implemented" message |
||||
|
* @todo Implement file writing through pywebview's Python-JavaScript bridge |
||||
|
*/ |
||||
|
async writeFile(_path: string, _content: string): Promise<void> { |
||||
|
throw new Error("Not implemented"); |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Deletes a file using the Python backend. |
||||
|
* @param _path - Path to the file to delete |
||||
|
* @throws Error with "Not implemented" message |
||||
|
* @todo Implement file deletion through pywebview's Python-JavaScript bridge |
||||
|
*/ |
||||
|
async deleteFile(_path: string): Promise<void> { |
||||
|
throw new Error("Not implemented"); |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Lists files in the specified directory using the Python backend. |
||||
|
* @param _directory - Path to the directory to list |
||||
|
* @returns Promise that should resolve to array of filenames |
||||
|
* @throws Error with "Not implemented" message |
||||
|
* @todo Implement directory listing through pywebview's Python-JavaScript bridge |
||||
|
*/ |
||||
|
async listFiles(_directory: string): Promise<string[]> { |
||||
|
throw new Error("Not implemented"); |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Should open system camera through Python backend. |
||||
|
* @returns Promise that should resolve to captured image data |
||||
|
* @throws Error with "Not implemented" message |
||||
|
* @todo Implement camera access using Python's camera libraries |
||||
|
*/ |
||||
|
async takePicture(): Promise<ImageResult> { |
||||
|
logger.error("takePicture not implemented in PyWebView platform"); |
||||
|
throw new Error("Not implemented"); |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Should open system file picker through pywebview. |
||||
|
* @returns Promise that should resolve to selected image data |
||||
|
* @throws Error with "Not implemented" message |
||||
|
* @todo Implement file picker using pywebview's file dialog API |
||||
|
*/ |
||||
|
async pickImage(): Promise<ImageResult> { |
||||
|
logger.error("pickImage not implemented in PyWebView platform"); |
||||
|
throw new Error("Not implemented"); |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Should handle deep link URLs through the Python backend. |
||||
|
* @param _url - The deep link URL to handle |
||||
|
* @throws Error with "Not implemented" message |
||||
|
* @todo Implement deep link handling using Python's URL handling capabilities |
||||
|
*/ |
||||
|
async handleDeepLink(_url: string): Promise<void> { |
||||
|
logger.error("handleDeepLink not implemented in PyWebView platform"); |
||||
|
throw new Error("Not implemented"); |
||||
|
} |
||||
|
} |
@ -0,0 +1,362 @@ |
|||||
|
import { |
||||
|
ImageResult, |
||||
|
PlatformService, |
||||
|
PlatformCapabilities, |
||||
|
} from "../PlatformService"; |
||||
|
import { logger } from "../../utils/logger"; |
||||
|
|
||||
|
/** |
||||
|
* Platform service implementation for web browser platform. |
||||
|
* Implements the PlatformService interface with web-specific functionality. |
||||
|
* |
||||
|
* @remarks |
||||
|
* This service provides web-based implementations for: |
||||
|
* - Image capture using the browser's file input |
||||
|
* - Image selection from local filesystem |
||||
|
* - Image processing and conversion |
||||
|
* |
||||
|
* Note: File system operations are not available in the web platform |
||||
|
* due to browser security restrictions. These methods throw appropriate errors. |
||||
|
*/ |
||||
|
export class WebPlatformService implements PlatformService { |
||||
|
/** |
||||
|
* Gets the capabilities of the web platform |
||||
|
* @returns Platform capabilities object |
||||
|
*/ |
||||
|
getCapabilities(): PlatformCapabilities { |
||||
|
return { |
||||
|
hasFileSystem: false, |
||||
|
hasCamera: true, // Through file input with capture
|
||||
|
isMobile: /iPhone|iPad|iPod|Android/i.test(navigator.userAgent), |
||||
|
isIOS: /iPad|iPhone|iPod/.test(navigator.userAgent), |
||||
|
hasFileDownload: true, |
||||
|
needsFileHandlingInstructions: false, |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Not supported in web platform. |
||||
|
* @param _path - Unused path parameter |
||||
|
* @throws Error indicating file system access is not available |
||||
|
*/ |
||||
|
async readFile(_path: string): Promise<string> { |
||||
|
throw new Error("File system access not available in web platform"); |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Not supported in web platform. |
||||
|
* @param _path - Unused path parameter |
||||
|
* @param _content - Unused content parameter |
||||
|
* @throws Error indicating file system access is not available |
||||
|
*/ |
||||
|
async writeFile(_path: string, _content: string): Promise<void> { |
||||
|
throw new Error("File system access not available in web platform"); |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Not supported in web platform. |
||||
|
* @param _path - Unused path parameter |
||||
|
* @throws Error indicating file system access is not available |
||||
|
*/ |
||||
|
async deleteFile(_path: string): Promise<void> { |
||||
|
throw new Error("File system access not available in web platform"); |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Not supported in web platform. |
||||
|
* @param _directory - Unused directory parameter |
||||
|
* @throws Error indicating file system access is not available |
||||
|
*/ |
||||
|
async listFiles(_directory: string): Promise<string[]> { |
||||
|
throw new Error("File system access not available in web platform"); |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Opens the device camera for photo capture on desktop browsers using getUserMedia. |
||||
|
* On mobile browsers, uses file input with capture attribute. |
||||
|
* Falls back to file input if getUserMedia is not available or fails. |
||||
|
* |
||||
|
* @returns Promise resolving to the captured image data |
||||
|
*/ |
||||
|
async takePicture(): Promise<ImageResult> { |
||||
|
const isMobile = /iPhone|iPad|iPod|Android/i.test(navigator.userAgent); |
||||
|
const hasGetUserMedia = !!( |
||||
|
navigator.mediaDevices && navigator.mediaDevices.getUserMedia |
||||
|
); |
||||
|
|
||||
|
// If on mobile, use file input with capture attribute (existing behavior)
|
||||
|
if (isMobile || !hasGetUserMedia) { |
||||
|
return new Promise((resolve, reject) => { |
||||
|
const input = document.createElement("input"); |
||||
|
input.type = "file"; |
||||
|
input.accept = "image/*"; |
||||
|
input.capture = "environment"; |
||||
|
|
||||
|
input.onchange = async (e) => { |
||||
|
const file = (e.target as HTMLInputElement).files?.[0]; |
||||
|
if (file) { |
||||
|
try { |
||||
|
const blob = await this.processImageFile(file); |
||||
|
resolve({ |
||||
|
blob, |
||||
|
fileName: file.name || "photo.jpg", |
||||
|
}); |
||||
|
} catch (error) { |
||||
|
logger.error("Error processing camera image:", error); |
||||
|
reject(new Error("Failed to process camera image")); |
||||
|
} |
||||
|
} else { |
||||
|
reject(new Error("No image captured")); |
||||
|
} |
||||
|
}; |
||||
|
|
||||
|
input.click(); |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
// Desktop: Use getUserMedia for webcam capture
|
||||
|
return new Promise((resolve, reject) => { |
||||
|
let stream: MediaStream | null = null; |
||||
|
let video: HTMLVideoElement | null = null; |
||||
|
let captureButton: HTMLButtonElement | null = null; |
||||
|
let overlay: HTMLDivElement | null = null; |
||||
|
const cleanup = () => { |
||||
|
if (stream) { |
||||
|
stream.getTracks().forEach((track) => track.stop()); |
||||
|
} |
||||
|
if (video && video.parentNode) video.parentNode.removeChild(video); |
||||
|
if (captureButton && captureButton.parentNode) |
||||
|
captureButton.parentNode.removeChild(captureButton); |
||||
|
if (overlay && overlay.parentNode) |
||||
|
overlay.parentNode.removeChild(overlay); |
||||
|
}; |
||||
|
|
||||
|
// Move async operations inside Promise body
|
||||
|
navigator.mediaDevices |
||||
|
.getUserMedia({ |
||||
|
video: { facingMode: "user" }, |
||||
|
}) |
||||
|
.then((mediaStream) => { |
||||
|
stream = mediaStream; |
||||
|
// Create overlay for video and button
|
||||
|
overlay = document.createElement("div"); |
||||
|
overlay.style.position = "fixed"; |
||||
|
overlay.style.top = "0"; |
||||
|
overlay.style.left = "0"; |
||||
|
overlay.style.width = "100vw"; |
||||
|
overlay.style.height = "100vh"; |
||||
|
overlay.style.background = "rgba(0,0,0,0.8)"; |
||||
|
overlay.style.display = "flex"; |
||||
|
overlay.style.flexDirection = "column"; |
||||
|
overlay.style.justifyContent = "center"; |
||||
|
overlay.style.alignItems = "center"; |
||||
|
overlay.style.zIndex = "9999"; |
||||
|
|
||||
|
video = document.createElement("video"); |
||||
|
video.autoplay = true; |
||||
|
video.playsInline = true; |
||||
|
video.style.maxWidth = "90vw"; |
||||
|
video.style.maxHeight = "70vh"; |
||||
|
video.srcObject = stream; |
||||
|
overlay.appendChild(video); |
||||
|
|
||||
|
captureButton = document.createElement("button"); |
||||
|
captureButton.textContent = "Capture Photo"; |
||||
|
captureButton.style.marginTop = "2rem"; |
||||
|
captureButton.style.padding = "1rem 2rem"; |
||||
|
captureButton.style.fontSize = "1.2rem"; |
||||
|
captureButton.style.background = "#2563eb"; |
||||
|
captureButton.style.color = "white"; |
||||
|
captureButton.style.border = "none"; |
||||
|
captureButton.style.borderRadius = "0.5rem"; |
||||
|
captureButton.style.cursor = "pointer"; |
||||
|
overlay.appendChild(captureButton); |
||||
|
|
||||
|
document.body.appendChild(overlay); |
||||
|
|
||||
|
captureButton.onclick = () => { |
||||
|
try { |
||||
|
// Create a canvas to capture the frame
|
||||
|
const canvas = document.createElement("canvas"); |
||||
|
canvas.width = video!.videoWidth; |
||||
|
canvas.height = video!.videoHeight; |
||||
|
const ctx = canvas.getContext("2d"); |
||||
|
ctx?.drawImage(video!, 0, 0, canvas.width, canvas.height); |
||||
|
canvas.toBlob( |
||||
|
(blob) => { |
||||
|
cleanup(); |
||||
|
if (blob) { |
||||
|
resolve({ |
||||
|
blob, |
||||
|
fileName: `photo_${Date.now()}.jpg`, |
||||
|
}); |
||||
|
} else { |
||||
|
reject(new Error("Failed to capture image from webcam")); |
||||
|
} |
||||
|
}, |
||||
|
"image/jpeg", |
||||
|
0.95, |
||||
|
); |
||||
|
} catch (err) { |
||||
|
cleanup(); |
||||
|
reject(err); |
||||
|
} |
||||
|
}; |
||||
|
}) |
||||
|
.catch((error) => { |
||||
|
cleanup(); |
||||
|
logger.error("Error accessing webcam:", error); |
||||
|
// Fallback to file input
|
||||
|
const input = document.createElement("input"); |
||||
|
input.type = "file"; |
||||
|
input.accept = "image/*"; |
||||
|
input.onchange = (e) => { |
||||
|
const file = (e.target as HTMLInputElement).files?.[0]; |
||||
|
if (file) { |
||||
|
this.processImageFile(file) |
||||
|
.then((blob) => { |
||||
|
resolve({ |
||||
|
blob, |
||||
|
fileName: file.name || "photo.jpg", |
||||
|
}); |
||||
|
}) |
||||
|
.catch((error) => { |
||||
|
logger.error("Error processing fallback image:", error); |
||||
|
reject(new Error("Failed to process fallback image")); |
||||
|
}); |
||||
|
} else { |
||||
|
reject(new Error("No image selected")); |
||||
|
} |
||||
|
}; |
||||
|
input.click(); |
||||
|
}); |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Opens a file input dialog for selecting an image file. |
||||
|
* Creates a temporary file input element to access local files. |
||||
|
* |
||||
|
* @returns Promise resolving to the selected image data |
||||
|
* @throws Error if image processing fails or no image is selected |
||||
|
* |
||||
|
* @remarks |
||||
|
* Allows selection of any image file type. |
||||
|
* Processes the selected image to ensure consistent format. |
||||
|
*/ |
||||
|
async pickImage(): Promise<ImageResult> { |
||||
|
return new Promise((resolve, reject) => { |
||||
|
const input = document.createElement("input"); |
||||
|
input.type = "file"; |
||||
|
input.accept = "image/*"; |
||||
|
|
||||
|
input.onchange = async (e) => { |
||||
|
const file = (e.target as HTMLInputElement).files?.[0]; |
||||
|
if (file) { |
||||
|
try { |
||||
|
const blob = await this.processImageFile(file); |
||||
|
resolve({ |
||||
|
blob, |
||||
|
fileName: file.name || "photo.jpg", |
||||
|
}); |
||||
|
} catch (error) { |
||||
|
logger.error("Error processing picked image:", error); |
||||
|
reject(new Error("Failed to process picked image")); |
||||
|
} |
||||
|
} else { |
||||
|
reject(new Error("No image selected")); |
||||
|
} |
||||
|
}; |
||||
|
|
||||
|
input.click(); |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Processes an image file to ensure consistent format. |
||||
|
* Converts the file to a data URL and then to a Blob. |
||||
|
* |
||||
|
* @param file - The image File object to process |
||||
|
* @returns Promise resolving to processed image Blob |
||||
|
* @throws Error if file reading or conversion fails |
||||
|
* |
||||
|
* @remarks |
||||
|
* This method ensures consistent image format across different |
||||
|
* input sources by converting through data URL to Blob. |
||||
|
*/ |
||||
|
private async processImageFile(file: File): Promise<Blob> { |
||||
|
return new Promise((resolve, reject) => { |
||||
|
const reader = new FileReader(); |
||||
|
reader.onload = (event) => { |
||||
|
const dataUrl = event.target?.result as string; |
||||
|
// Convert to blob to ensure consistent format
|
||||
|
fetch(dataUrl) |
||||
|
.then((res) => res.blob()) |
||||
|
.then((blob) => resolve(blob)) |
||||
|
.catch((error) => { |
||||
|
logger.error("Error converting data URL to blob:", error); |
||||
|
reject(error); |
||||
|
}); |
||||
|
}; |
||||
|
reader.onerror = (error) => { |
||||
|
logger.error("Error reading file:", error); |
||||
|
reject(error); |
||||
|
}; |
||||
|
reader.readAsDataURL(file); |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Checks if running on Capacitor platform. |
||||
|
* @returns false, as this is not Capacitor |
||||
|
*/ |
||||
|
isCapacitor(): boolean { |
||||
|
return false; |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Checks if running on Electron platform. |
||||
|
* @returns false, as this is not Electron |
||||
|
*/ |
||||
|
isElectron(): boolean { |
||||
|
return false; |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Checks if running on PyWebView platform. |
||||
|
* @returns false, as this is not PyWebView |
||||
|
*/ |
||||
|
isPyWebView(): boolean { |
||||
|
return false; |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Checks if running on web platform. |
||||
|
* @returns true, as this is the web implementation |
||||
|
*/ |
||||
|
isWeb(): boolean { |
||||
|
return true; |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Handles deep link URLs in the web platform. |
||||
|
* Deep links are handled through URL parameters in the web environment. |
||||
|
* |
||||
|
* @param _url - The deep link URL to handle (unused in web implementation) |
||||
|
* @returns Promise that resolves immediately as web handles URLs naturally |
||||
|
*/ |
||||
|
async handleDeepLink(_url: string): Promise<void> { |
||||
|
// Web platform can handle deep links through URL parameters
|
||||
|
return Promise.resolve(); |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Not supported in web platform. |
||||
|
* @param _fileName - Unused fileName parameter |
||||
|
* @param _content - Unused content parameter |
||||
|
* @throws Error indicating file system access is not available |
||||
|
*/ |
||||
|
async writeAndShareFile(_fileName: string, _content: string): Promise<void> { |
||||
|
throw new Error("File system access not available in web platform"); |
||||
|
} |
||||
|
} |
@ -1,103 +0,0 @@ |
|||||
/** |
|
||||
* @file Deep Link Type Definitions and Validation Schemas |
|
||||
* @author Matthew Raymer |
|
||||
* |
|
||||
* This file defines the type system and validation schemas for deep linking in the TimeSafari app. |
|
||||
* It uses Zod for runtime validation while providing TypeScript types for compile-time checking. |
|
||||
* |
|
||||
* Type Strategy: |
|
||||
* 1. Define base URL schema to validate the fundamental deep link structure |
|
||||
* 2. Define route-specific parameter schemas with exact validation rules |
|
||||
* 3. Generate TypeScript types from Zod schemas for type safety |
|
||||
* 4. Export both schemas and types for use in deep link handling |
|
||||
* |
|
||||
* Usage: |
|
||||
* - Import schemas for runtime validation in deep link handlers |
|
||||
* - Import types for type-safe parameter handling in components |
|
||||
* - Use DeepLinkParams type for type-safe access to route parameters |
|
||||
* |
|
||||
* @example |
|
||||
* // Runtime validation
|
|
||||
* const params = deepLinkSchemas.claim.parse({ id: "123", view: "details" }); |
|
||||
* |
|
||||
* // Type-safe parameter access
|
|
||||
* function handleClaimParams(params: DeepLinkParams["claim"]) { |
|
||||
* // TypeScript knows params.id exists and params.view is optional
|
|
||||
* } |
|
||||
*/ |
|
||||
import { z } from "zod"; |
|
||||
|
|
||||
// Add a union type of all valid route paths
|
|
||||
export const VALID_DEEP_LINK_ROUTES = [ |
|
||||
"user-profile", |
|
||||
"project-details", |
|
||||
"onboard-meeting-setup", |
|
||||
"invite-one-accept", |
|
||||
"contact-import", |
|
||||
"confirm-gift", |
|
||||
"claim", |
|
||||
"claim-cert", |
|
||||
"claim-add-raw", |
|
||||
"contact-edit", |
|
||||
"contacts", |
|
||||
"did", |
|
||||
] as const; |
|
||||
|
|
||||
// Create a type from the array
|
|
||||
export type DeepLinkRoute = (typeof VALID_DEEP_LINK_ROUTES)[number]; |
|
||||
|
|
||||
// Update your schema definitions to use this type
|
|
||||
export const baseUrlSchema = z.object({ |
|
||||
scheme: z.literal("timesafari"), |
|
||||
path: z.string(), |
|
||||
queryParams: z.record(z.string()).optional(), |
|
||||
}); |
|
||||
|
|
||||
// Use the type to ensure route validation
|
|
||||
export const routeSchema = z.enum(VALID_DEEP_LINK_ROUTES); |
|
||||
|
|
||||
// Parameter validation schemas for each route type
|
|
||||
export const deepLinkSchemas = { |
|
||||
"user-profile": z.object({ |
|
||||
id: z.string(), |
|
||||
}), |
|
||||
"project-details": z.object({ |
|
||||
id: z.string(), |
|
||||
}), |
|
||||
"onboard-meeting-setup": z.object({ |
|
||||
id: z.string(), |
|
||||
}), |
|
||||
"invite-one-accept": z.object({ |
|
||||
id: z.string(), |
|
||||
}), |
|
||||
"contact-import": z.object({ |
|
||||
jwt: z.string(), |
|
||||
}), |
|
||||
"confirm-gift": z.object({ |
|
||||
id: z.string(), |
|
||||
}), |
|
||||
claim: z.object({ |
|
||||
id: z.string(), |
|
||||
}), |
|
||||
"claim-cert": z.object({ |
|
||||
id: z.string(), |
|
||||
}), |
|
||||
"claim-add-raw": z.object({ |
|
||||
id: z.string(), |
|
||||
claim: z.string().optional(), |
|
||||
claimJwtId: z.string().optional(), |
|
||||
}), |
|
||||
"contact-edit": z.object({ |
|
||||
did: z.string(), |
|
||||
}), |
|
||||
contacts: z.object({ |
|
||||
contacts: z.string(), // JSON string of contacts array
|
|
||||
}), |
|
||||
did: z.object({ |
|
||||
id: z.string(), |
|
||||
}), |
|
||||
}; |
|
||||
|
|
||||
export type DeepLinkParams = { |
|
||||
[K in keyof typeof deepLinkSchemas]: z.infer<(typeof deepLinkSchemas)[K]>; |
|
||||
}; |
|
@ -1,25 +0,0 @@ |
|||||
import { GiveSummaryRecord, GiveVerifiableCredential } from "interfaces"; |
|
||||
|
|
||||
export interface GiveRecordWithContactInfo extends GiveSummaryRecord { |
|
||||
jwtId: string; |
|
||||
fullClaim: GiveVerifiableCredential; |
|
||||
giver: { |
|
||||
known: boolean; |
|
||||
displayName: string; |
|
||||
profileImageUrl?: string; |
|
||||
}; |
|
||||
issuer: { |
|
||||
known: boolean; |
|
||||
displayName: string; |
|
||||
profileImageUrl?: string; |
|
||||
}; |
|
||||
receiver: { |
|
||||
known: boolean; |
|
||||
displayName: string; |
|
||||
profileImageUrl?: string; |
|
||||
}; |
|
||||
providerPlanName?: string; |
|
||||
recipientProjectName?: string; |
|
||||
description: string; |
|
||||
image?: string; |
|
||||
} |
|
@ -0,0 +1,47 @@ |
|||||
|
type LogLevel = "log" | "info" | "warn" | "error"; |
||||
|
|
||||
|
interface LogEntry { |
||||
|
level: LogLevel; |
||||
|
message: unknown[]; |
||||
|
timestamp: string; |
||||
|
} |
||||
|
|
||||
|
class LogCollector { |
||||
|
private logs: LogEntry[] = []; |
||||
|
private originalConsole: Partial< |
||||
|
Record<LogLevel, (..._args: unknown[]) => void> |
||||
|
> = {}; |
||||
|
|
||||
|
constructor() { |
||||
|
(["log", "info", "warn", "error"] as LogLevel[]).forEach((level) => { |
||||
|
// eslint-disable-next-line no-console
|
||||
|
this.originalConsole[level] = console[level]; |
||||
|
// eslint-disable-next-line no-console
|
||||
|
console[level] = (..._args: unknown[]) => { |
||||
|
this.logs.push({ |
||||
|
level, |
||||
|
message: _args, |
||||
|
timestamp: new Date().toISOString(), |
||||
|
}); |
||||
|
this.originalConsole[level]?.apply(console, _args); |
||||
|
}; |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
getLogs(): string { |
||||
|
return this.logs |
||||
|
.map( |
||||
|
(entry) => |
||||
|
`[${entry.timestamp}] [${entry.level.toUpperCase()}] ${entry.message |
||||
|
.map((m) => (typeof m === "object" ? JSON.stringify(m) : String(m))) |
||||
|
.join(" ")}`,
|
||||
|
) |
||||
|
.join("\n"); |
||||
|
} |
||||
|
|
||||
|
clear() { |
||||
|
this.logs = []; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
export const logCollector = new LogCollector(); |
@ -0,0 +1,430 @@ |
|||||
|
<template> |
||||
|
<!-- CONTENT --> |
||||
|
<section id="Content" class="relativew-[100vw] h-[100vh]"> |
||||
|
<div |
||||
|
class="absolute inset-x-0 bottom-0 bg-black/50 p-6 pb-[calc(env(safe-area-inset-bottom)+1.5rem)]" |
||||
|
> |
||||
|
<p class="text-center text-white mb-3"> |
||||
|
Point your camera at a TimeSafari contact QR code to scan it |
||||
|
automatically. |
||||
|
</p> |
||||
|
|
||||
|
<p v-if="error" class="text-center text-rose-300 mb-3">{{ error }}</p> |
||||
|
|
||||
|
<div class="flex justify-center items-center"> |
||||
|
<button |
||||
|
class="text-center text-slate-600 leading-none bg-white p-2 rounded-full drop-shadow-lg" |
||||
|
@click="handleBack" |
||||
|
> |
||||
|
<font-awesome icon="xmark" class="size-6"></font-awesome> |
||||
|
</button> |
||||
|
</div> |
||||
|
</div> |
||||
|
</section> |
||||
|
</template> |
||||
|
|
||||
|
<script lang="ts"> |
||||
|
import { Component, Vue } from "vue-facing-decorator"; |
||||
|
import { Router } from "vue-router"; |
||||
|
import { logger } from "../utils/logger"; |
||||
|
import { QRScannerFactory } from "../services/QRScanner/QRScannerFactory"; |
||||
|
import QuickNav from "../components/QuickNav.vue"; |
||||
|
import { NotificationIface } from "../constants/app"; |
||||
|
import { db } from "../db/index"; |
||||
|
import { Contact } from "../db/tables/contacts"; |
||||
|
import { getContactJwtFromJwtUrl } from "../libs/crypto"; |
||||
|
import { decodeEndorserJwt } from "../libs/crypto/vc"; |
||||
|
import { retrieveSettingsForActiveAccount } from "../db/index"; |
||||
|
import { setVisibilityUtil } from "../libs/endorserServer"; |
||||
|
|
||||
|
interface QRScanResult { |
||||
|
rawValue?: string; |
||||
|
barcode?: string; |
||||
|
} |
||||
|
|
||||
|
@Component({ |
||||
|
components: { |
||||
|
QuickNav, |
||||
|
}, |
||||
|
}) |
||||
|
export default class ContactQRScan extends Vue { |
||||
|
$notify!: (notification: NotificationIface, timeout?: number) => void; |
||||
|
$router!: Router; |
||||
|
|
||||
|
isScanning = false; |
||||
|
error: string | null = null; |
||||
|
activeDid = ""; |
||||
|
apiServer = ""; |
||||
|
|
||||
|
// Add new properties to track scanning state |
||||
|
private lastScannedValue: string = ""; |
||||
|
private lastScanTime: number = 0; |
||||
|
private readonly SCAN_DEBOUNCE_MS = 2000; // Prevent duplicate scans within 2 seconds |
||||
|
|
||||
|
// Add cleanup tracking |
||||
|
private isCleaningUp = false; |
||||
|
private isMounted = false; |
||||
|
|
||||
|
async created() { |
||||
|
try { |
||||
|
const settings = await retrieveSettingsForActiveAccount(); |
||||
|
this.activeDid = settings.activeDid || ""; |
||||
|
this.apiServer = settings.apiServer || ""; |
||||
|
} catch (error) { |
||||
|
logger.error("Error initializing component:", { |
||||
|
error: error instanceof Error ? error.message : String(error), |
||||
|
stack: error instanceof Error ? error.stack : undefined, |
||||
|
}); |
||||
|
this.$notify({ |
||||
|
group: "alert", |
||||
|
type: "danger", |
||||
|
title: "Initialization Error", |
||||
|
text: "Failed to initialize QR scanner. Please try again.", |
||||
|
}); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
async startScanning() { |
||||
|
if (this.isCleaningUp) { |
||||
|
logger.debug("Cannot start scanning during cleanup"); |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
try { |
||||
|
this.error = null; |
||||
|
this.isScanning = true; |
||||
|
this.lastScannedValue = ""; |
||||
|
this.lastScanTime = 0; |
||||
|
|
||||
|
const scanner = QRScannerFactory.getInstance(); |
||||
|
|
||||
|
// Check if scanning is supported first |
||||
|
if (!(await scanner.isSupported())) { |
||||
|
this.error = |
||||
|
"Camera access requires HTTPS. Please use a secure connection."; |
||||
|
this.isScanning = false; |
||||
|
this.$notify( |
||||
|
{ |
||||
|
group: "alert", |
||||
|
type: "warning", |
||||
|
title: "HTTPS Required", |
||||
|
text: "Camera access requires a secure (HTTPS) connection", |
||||
|
}, |
||||
|
5000, |
||||
|
); |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
// Check permissions first |
||||
|
if (!(await scanner.checkPermissions())) { |
||||
|
const granted = await scanner.requestPermissions(); |
||||
|
if (!granted) { |
||||
|
this.error = "Camera permission denied"; |
||||
|
this.isScanning = false; |
||||
|
// Show notification for better visibility |
||||
|
this.$notify( |
||||
|
{ |
||||
|
group: "alert", |
||||
|
type: "warning", |
||||
|
title: "Camera Access Required", |
||||
|
text: "Camera permission denied", |
||||
|
}, |
||||
|
5000, |
||||
|
); |
||||
|
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: error instanceof Error ? error.message : String(error), |
||||
|
stack: error instanceof Error ? error.stack : undefined, |
||||
|
}); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
async stopScanning() { |
||||
|
try { |
||||
|
const scanner = QRScannerFactory.getInstance(); |
||||
|
await scanner.stopScan(); |
||||
|
} catch (error) { |
||||
|
logger.error("Error stopping scan:", { |
||||
|
error: error instanceof Error ? error.message : String(error), |
||||
|
stack: error instanceof Error ? error.stack : undefined, |
||||
|
}); |
||||
|
} finally { |
||||
|
this.isScanning = false; |
||||
|
this.lastScannedValue = ""; |
||||
|
this.lastScanTime = 0; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
async cleanupScanner() { |
||||
|
if (this.isCleaningUp) { |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
this.isCleaningUp = true; |
||||
|
try { |
||||
|
logger.info("Cleaning up QR scanner resources"); |
||||
|
await this.stopScanning(); |
||||
|
await QRScannerFactory.cleanup(); |
||||
|
} catch (error) { |
||||
|
logger.error("Error during scanner cleanup:", { |
||||
|
error: error instanceof Error ? error.message : String(error), |
||||
|
stack: error instanceof Error ? error.stack : undefined, |
||||
|
}); |
||||
|
} finally { |
||||
|
this.isCleaningUp = false; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Handle QR code scan result with debouncing to prevent duplicate scans |
||||
|
*/ |
||||
|
async onScanDetect(result: string | QRScanResult) { |
||||
|
try { |
||||
|
// Extract raw value from different possible formats |
||||
|
const rawValue = |
||||
|
typeof result === "string" |
||||
|
? result |
||||
|
: result?.rawValue || result?.barcode; |
||||
|
if (!rawValue) { |
||||
|
logger.warn("Invalid scan result - no value found:", result); |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
// Debounce duplicate scans |
||||
|
const now = Date.now(); |
||||
|
if ( |
||||
|
rawValue === this.lastScannedValue && |
||||
|
now - this.lastScanTime < this.SCAN_DEBOUNCE_MS |
||||
|
) { |
||||
|
logger.info("Ignoring duplicate scan:", rawValue); |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
// Update scan tracking |
||||
|
this.lastScannedValue = rawValue; |
||||
|
this.lastScanTime = now; |
||||
|
|
||||
|
logger.info("Processing QR code scan result:", rawValue); |
||||
|
|
||||
|
// Extract JWT |
||||
|
const jwt = getContactJwtFromJwtUrl(rawValue); |
||||
|
if (!jwt) { |
||||
|
logger.warn("Invalid QR code format - no JWT found in URL"); |
||||
|
this.$notify({ |
||||
|
group: "alert", |
||||
|
type: "danger", |
||||
|
title: "Invalid QR Code", |
||||
|
text: "This QR code does not contain valid contact information. Please scan a TimeSafari contact QR code.", |
||||
|
}); |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
// Process JWT and contact info |
||||
|
logger.info("Decoding JWT payload from QR code"); |
||||
|
const decodedJwt = await decodeEndorserJwt(jwt); |
||||
|
if (!decodedJwt?.payload?.own) { |
||||
|
logger.warn("Invalid JWT payload - missing 'own' field"); |
||||
|
this.$notify({ |
||||
|
group: "alert", |
||||
|
type: "danger", |
||||
|
title: "Invalid Contact Info", |
||||
|
text: "The contact information is incomplete or invalid.", |
||||
|
}); |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
const contactInfo = decodedJwt.payload.own; |
||||
|
if (!contactInfo.did) { |
||||
|
logger.warn("Invalid contact info - missing DID"); |
||||
|
this.$notify({ |
||||
|
group: "alert", |
||||
|
type: "danger", |
||||
|
title: "Invalid Contact", |
||||
|
text: "The contact DID is missing.", |
||||
|
}); |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
// Create contact object |
||||
|
const contact = { |
||||
|
did: contactInfo.did, |
||||
|
name: contactInfo.name || "", |
||||
|
email: contactInfo.email || "", |
||||
|
phone: contactInfo.phone || "", |
||||
|
company: contactInfo.company || "", |
||||
|
title: contactInfo.title || "", |
||||
|
notes: contactInfo.notes || "", |
||||
|
}; |
||||
|
|
||||
|
// Add contact and stop scanning |
||||
|
logger.info("Adding new contact to database:", { |
||||
|
did: contact.did, |
||||
|
name: contact.name, |
||||
|
}); |
||||
|
await this.addNewContact(contact); |
||||
|
await this.stopScanning(); |
||||
|
this.$router.back(); // Return to previous view after successful scan |
||||
|
} catch (error) { |
||||
|
logger.error("Error processing contact QR code:", { |
||||
|
error: error instanceof Error ? error.message : String(error), |
||||
|
stack: error instanceof Error ? error.stack : undefined, |
||||
|
}); |
||||
|
this.$notify({ |
||||
|
group: "alert", |
||||
|
type: "danger", |
||||
|
title: "Error", |
||||
|
text: |
||||
|
error instanceof Error |
||||
|
? error.message |
||||
|
: "Could not process QR code. Please try again.", |
||||
|
}); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
onScanError(error: Error) { |
||||
|
this.error = error.message; |
||||
|
logger.error("QR code scan error:", { |
||||
|
error: error.message, |
||||
|
stack: error.stack, |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
async setVisibility(contact: Contact, visibility: boolean) { |
||||
|
const result = await setVisibilityUtil( |
||||
|
this.activeDid, |
||||
|
this.apiServer, |
||||
|
this.axios, |
||||
|
db, |
||||
|
contact, |
||||
|
visibility, |
||||
|
); |
||||
|
if (result.error) { |
||||
|
this.$notify({ |
||||
|
group: "alert", |
||||
|
type: "danger", |
||||
|
title: "Error Setting Visibility", |
||||
|
text: result.error as string, |
||||
|
}); |
||||
|
} else if (!result.success) { |
||||
|
logger.warn("Unexpected result from setting visibility:", result); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
async addNewContact(contact: Contact) { |
||||
|
try { |
||||
|
logger.info("Opening database connection for new contact"); |
||||
|
await db.open(); |
||||
|
|
||||
|
// Check if contact already exists |
||||
|
const existingContacts = await db.contacts.toArray(); |
||||
|
const existingContact = existingContacts.find( |
||||
|
(c) => c.did === contact.did, |
||||
|
); |
||||
|
|
||||
|
if (existingContact) { |
||||
|
logger.info("Contact already exists", { did: contact.did }); |
||||
|
this.$notify( |
||||
|
{ |
||||
|
group: "alert", |
||||
|
type: "warning", |
||||
|
title: "Contact Exists", |
||||
|
text: "This contact has already been added to your list.", |
||||
|
}, |
||||
|
3000, |
||||
|
); |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
// Add new contact |
||||
|
await db.contacts.add(contact); |
||||
|
|
||||
|
if (this.activeDid) { |
||||
|
logger.info("Setting contact visibility", { did: contact.did }); |
||||
|
await this.setVisibility(contact, true); |
||||
|
contact.seesMe = true; |
||||
|
} |
||||
|
|
||||
|
this.$notify( |
||||
|
{ |
||||
|
group: "alert", |
||||
|
type: "success", |
||||
|
title: "Contact Added", |
||||
|
text: this.activeDid |
||||
|
? "They were added, and your activity is visible to them." |
||||
|
: "They were added.", |
||||
|
}, |
||||
|
3000, |
||||
|
); |
||||
|
} catch (error) { |
||||
|
logger.error("Error saving contact to database:", { |
||||
|
did: contact.did, |
||||
|
error: error instanceof Error ? error.message : String(error), |
||||
|
stack: error instanceof Error ? error.stack : undefined, |
||||
|
}); |
||||
|
this.$notify( |
||||
|
{ |
||||
|
group: "alert", |
||||
|
type: "danger", |
||||
|
title: "Contact Error", |
||||
|
text: "Could not save contact. Check if it already exists.", |
||||
|
}, |
||||
|
5000, |
||||
|
); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// Lifecycle hooks |
||||
|
mounted() { |
||||
|
this.isMounted = true; |
||||
|
document.addEventListener("pause", this.handleAppPause); |
||||
|
document.addEventListener("resume", this.handleAppResume); |
||||
|
this.startScanning(); // Automatically start scanning when view is mounted |
||||
|
} |
||||
|
|
||||
|
beforeDestroy() { |
||||
|
this.isMounted = false; |
||||
|
document.removeEventListener("pause", this.handleAppPause); |
||||
|
document.removeEventListener("resume", this.handleAppResume); |
||||
|
this.cleanupScanner(); |
||||
|
} |
||||
|
|
||||
|
async handleAppPause() { |
||||
|
if (!this.isMounted) return; |
||||
|
|
||||
|
logger.info("App paused, stopping scanner"); |
||||
|
await this.stopScanning(); |
||||
|
} |
||||
|
|
||||
|
handleAppResume() { |
||||
|
if (!this.isMounted) return; |
||||
|
|
||||
|
logger.info("App resumed, scanner can be restarted by user"); |
||||
|
this.isScanning = false; |
||||
|
} |
||||
|
|
||||
|
async handleBack() { |
||||
|
await this.cleanupScanner(); |
||||
|
this.$router.back(); |
||||
|
} |
||||
|
} |
||||
|
</script> |
||||
|
|
||||
|
<style scoped> |
||||
|
.aspect-square { |
||||
|
aspect-ratio: 1 / 1; |
||||
|
} |
||||
|
</style> |