Compare commits
90 Commits
build-ios
...
trent-twea
| Author | SHA1 | Date | |
|---|---|---|---|
| d555bc3e9c | |||
|
|
141415977e | ||
|
|
981ccbf269 | ||
|
|
b74ec8ecbb | ||
|
|
7b3b1c930e | ||
|
|
85aa2981ad | ||
|
|
a86e577127 | ||
|
|
788d162b1c | ||
|
|
616a69b7fd | ||
|
|
efab9b968c | ||
|
|
70174aea93 | ||
|
|
7f12595c91 | ||
|
|
8f0d09e480 | ||
|
|
cfc0730e75 | ||
| bfbb9a933d | |||
| 674bbfa00c | |||
| 80b754246e | |||
| 5fcf6a1f90 | |||
| 9da12e76fd | |||
|
|
04193f61c7 | ||
|
|
0ca4916a05 | ||
| 925ce830b4 | |||
|
|
d14635c44d | ||
|
|
eb5c9565a6 | ||
|
|
ea108b754e | ||
|
|
e4155e1a20 | ||
|
|
7e9682ce67 | ||
| c7f1148fe4 | |||
| ae9f1ee09f | |||
| 4d0463f7f7 | |||
| 748c4c7a50 | |||
| 35bb9d2207 | |||
|
|
fd914aa46c | ||
|
|
ba1453104f | ||
|
|
3c7f13d604 | ||
|
|
8e8eef2ab5 | ||
|
|
ea17ef930c | ||
|
|
5242a24110 | ||
|
|
93e860e0ac | ||
|
|
f874973bfa | ||
|
|
74b9caa94f | ||
|
|
fdd1ff80ad | ||
|
|
5d195d06ba | ||
|
|
79707d2811 | ||
|
|
9b73e05bdb | ||
|
|
1b7c5decd3 | ||
|
|
8c8fb6fe7d | ||
|
|
29983f11a9 | ||
|
|
5c559606df | ||
|
|
37166fc141 | ||
|
|
01ef7c1fa9 | ||
|
|
2bb71653ac | ||
|
|
7baae7ea7a | ||
|
|
cb1d979431 | ||
|
|
b999a04595 | ||
|
|
0f9826a39d | ||
|
|
8cc17bd09d | ||
|
|
9dc9878472 | ||
|
|
22283e32f2 | ||
|
|
99863ec186 | ||
|
|
8d2dffb012 | ||
|
|
538cbef701 | ||
|
|
7b7940189e | ||
|
|
35b038036a | ||
|
|
b9cafbe269 | ||
|
|
559f52e6d6 | ||
|
|
eb44b624d6 | ||
|
|
6fdbc7f588 | ||
|
|
7e8caae69a | ||
|
|
7b29232b2c | ||
|
|
e7cb5ffd33 | ||
|
|
272f2a91a6 | ||
|
|
f750ea5d10 | ||
|
|
78116329d4 | ||
|
|
2753e142cf | ||
| 9a840ab74a | |||
|
|
c6c49260ef | ||
|
|
87438e7b6b | ||
|
|
3ce2ea9b4e | ||
|
|
8e6ba68560 | ||
|
|
ca9ca5fca7 | ||
|
|
4abb188da3 | ||
|
|
30e448faf8 | ||
|
|
a8812714a3 | ||
|
|
2855d4b8d5 | ||
|
|
b85e6d2958 | ||
|
|
7d260365be | ||
|
|
72de271f6c | ||
|
|
2055097cf2 | ||
|
|
6b38b1a347 |
292
.cursor/rules/architectural_decision_record.mdc
Normal file
292
.cursor/rules/architectural_decision_record.mdc
Normal file
@@ -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
|
||||
276
.cursor/rules/timesafari.mdc
Normal file
276
.cursor/rules/timesafari.mdc
Normal file
@@ -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
|
||||
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -42,7 +42,7 @@ dist-electron-packages
|
||||
.ruby-version
|
||||
+.env
|
||||
|
||||
# Generated test files
|
||||
# Test files generated by scripts test-ios.js & test-android.js
|
||||
.generated/
|
||||
|
||||
.env.default
|
||||
@@ -53,5 +53,4 @@ build_logs/
|
||||
|
||||
android/app/src/main/assets/public
|
||||
android/app/src/main/res
|
||||
android/.gradle/buildOutputCleanup/buildOutputCleanup.lock
|
||||
android/.gradle/file-system.probe
|
||||
|
||||
|
||||
481
BUILDING.md
481
BUILDING.md
@@ -11,7 +11,7 @@ For a quick dev environment setup, use [pkgx](https://pkgx.dev).
|
||||
- Git
|
||||
- For Android builds: Android Studio with SDK installed
|
||||
- For iOS builds: macOS with Xcode and ruby gems & bundle
|
||||
- pkgx +rubygems.org sh
|
||||
- `pkgx +rubygems.org sh`
|
||||
|
||||
- ... and you may have to fix these, especially with pkgx
|
||||
|
||||
@@ -54,7 +54,7 @@ Install dependencies:
|
||||
1. Run the production build:
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
npm run build:web
|
||||
```
|
||||
|
||||
The built files will be in the `dist` directory.
|
||||
@@ -111,8 +111,106 @@ TIME_SAFARI_APP_TITLE="TimeSafari_Test" VITE_APP_SERVER=https://test.timesafari.
|
||||
|
||||
* Record the new hash in the changelog. Edit package.json to increment version & add "-beta", `npm install`, and commit. Also record what version is on production.
|
||||
|
||||
## Docker Deployment
|
||||
|
||||
The application can be containerized using Docker for consistent deployment across environments.
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Docker installed on your system
|
||||
- Docker Compose (optional, for multi-container setups)
|
||||
|
||||
### Building the Docker Image
|
||||
|
||||
1. Build the Docker image:
|
||||
|
||||
```bash
|
||||
docker build -t timesafari:latest .
|
||||
```
|
||||
|
||||
2. For development builds with specific environment variables:
|
||||
|
||||
```bash
|
||||
docker build --build-arg NODE_ENV=development -t timesafari:dev .
|
||||
```
|
||||
|
||||
### Running the Container
|
||||
|
||||
1. Run the container:
|
||||
|
||||
```bash
|
||||
docker run -d -p 80:80 timesafari:latest
|
||||
```
|
||||
|
||||
2. For development with hot-reloading:
|
||||
|
||||
```bash
|
||||
docker run -d -p 80:80 -v $(pwd):/app timesafari:dev
|
||||
```
|
||||
|
||||
### Using Docker Compose
|
||||
|
||||
Create a `docker-compose.yml` file:
|
||||
|
||||
```yaml
|
||||
version: '3.8'
|
||||
services:
|
||||
timesafari:
|
||||
build: .
|
||||
ports:
|
||||
- "80:80"
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
restart: unless-stopped
|
||||
```
|
||||
|
||||
Run with Docker Compose:
|
||||
|
||||
```bash
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
### Production Deployment
|
||||
|
||||
For production deployment, consider the following:
|
||||
|
||||
1. Use specific version tags instead of 'latest'
|
||||
2. Implement health checks
|
||||
3. Configure proper logging
|
||||
4. Set up reverse proxy with SSL termination
|
||||
5. Use Docker secrets for sensitive data
|
||||
|
||||
Example production deployment:
|
||||
|
||||
```bash
|
||||
# Build with specific version
|
||||
docker build -t timesafari:1.0.0 .
|
||||
|
||||
# Run with production settings
|
||||
docker run -d \
|
||||
--name timesafari \
|
||||
-p 80:80 \
|
||||
--restart unless-stopped \
|
||||
-e NODE_ENV=production \
|
||||
timesafari:1.0.0
|
||||
```
|
||||
|
||||
### Troubleshooting Docker
|
||||
|
||||
1. **Container fails to start**
|
||||
- Check logs: `docker logs <container_id>`
|
||||
- Verify port availability
|
||||
- Check environment variables
|
||||
|
||||
2. **Build fails**
|
||||
- Ensure all dependencies are in package.json
|
||||
- Check Dockerfile syntax
|
||||
- Verify build context
|
||||
|
||||
3. **Performance issues**
|
||||
- Monitor container resources: `docker stats`
|
||||
- Check nginx configuration
|
||||
- Verify caching settings
|
||||
|
||||
## Desktop Build (Electron)
|
||||
|
||||
@@ -138,21 +236,75 @@ TIME_SAFARI_APP_TITLE="TimeSafari_Test" VITE_APP_SERVER=https://test.timesafari.
|
||||
- AppImage: `dist-electron-packages/TimeSafari-x.x.x.AppImage`
|
||||
- DEB: `dist-electron-packages/timesafari_x.x.x_amd64.deb`
|
||||
|
||||
### macOS Build
|
||||
|
||||
1. Build the electron app in production mode:
|
||||
|
||||
```bash
|
||||
npm run build:electron-prod
|
||||
```
|
||||
|
||||
2. Package the Electron app for macOS:
|
||||
|
||||
```bash
|
||||
# For Intel Macs
|
||||
npm run electron:build-mac
|
||||
|
||||
# For Universal build (Intel + Apple Silicon)
|
||||
npm run electron:build-mac-universal
|
||||
```
|
||||
|
||||
3. The packaged applications will be in `dist-electron-packages/`:
|
||||
- `.app` bundle: `TimeSafari.app`
|
||||
- `.dmg` installer: `TimeSafari-x.x.x.dmg`
|
||||
- `.zip` archive: `TimeSafari-x.x.x-mac.zip`
|
||||
|
||||
### Code Signing and Notarization (macOS)
|
||||
|
||||
For public distribution on macOS, you need to code sign and notarize your app:
|
||||
|
||||
1. Set up environment variables:
|
||||
```bash
|
||||
export CSC_LINK=/path/to/your/certificate.p12
|
||||
export CSC_KEY_PASSWORD=your_certificate_password
|
||||
export APPLE_ID=your_apple_id
|
||||
export APPLE_ID_PASSWORD=your_app_specific_password
|
||||
```
|
||||
|
||||
2. Build with signing:
|
||||
```bash
|
||||
npm run electron:build-mac
|
||||
```
|
||||
|
||||
### Running the Packaged App
|
||||
|
||||
- AppImage: Make executable and run
|
||||
- **Linux**:
|
||||
- AppImage: Make executable and run
|
||||
```bash
|
||||
chmod +x dist-electron-packages/TimeSafari-*.AppImage
|
||||
./dist-electron-packages/TimeSafari-*.AppImage
|
||||
```
|
||||
- DEB: Install and run
|
||||
```bash
|
||||
sudo dpkg -i dist-electron-packages/timesafari_*_amd64.deb
|
||||
timesafari
|
||||
```
|
||||
|
||||
```bash
|
||||
chmod +x dist-electron-packages/TimeSafari-*.AppImage
|
||||
./dist-electron-packages/TimeSafari-*.AppImage
|
||||
```
|
||||
- **macOS**:
|
||||
- `.app` bundle: Double-click `TimeSafari.app` in Finder
|
||||
- `.dmg` installer:
|
||||
1. Double-click the `.dmg` file
|
||||
2. Drag the app to your Applications folder
|
||||
3. Launch from Applications
|
||||
- `.zip` archive:
|
||||
1. Extract the `.zip` file
|
||||
2. Move `TimeSafari.app` to your Applications folder
|
||||
3. Launch from Applications
|
||||
|
||||
- DEB: Install and run
|
||||
|
||||
```bash
|
||||
sudo dpkg -i dist-electron-packages/timesafari_*_amd64.deb
|
||||
timesafari
|
||||
```
|
||||
Note: If you get a security warning when running the app:
|
||||
1. Right-click the app
|
||||
2. Select "Open"
|
||||
3. Click "Open" in the security dialog
|
||||
|
||||
### Development Testing
|
||||
|
||||
@@ -175,6 +327,7 @@ Prerequisites: macOS with Xcode installed
|
||||
1. Build the web assets:
|
||||
|
||||
```bash
|
||||
npm run build:web
|
||||
npm run build:capacitor
|
||||
```
|
||||
|
||||
@@ -184,6 +337,8 @@ Prerequisites: macOS with Xcode installed
|
||||
npx cap sync ios
|
||||
```
|
||||
|
||||
- If that fails with "Could not find..." then look at the "gem_path" instructions above.
|
||||
|
||||
3. Copy the assets:
|
||||
|
||||
```bash
|
||||
@@ -191,13 +346,38 @@ Prerequisites: macOS with Xcode installed
|
||||
npx capacitor-assets generate --ios
|
||||
```
|
||||
|
||||
3. Open the project in Xcode:
|
||||
4. Bump the version to match Android
|
||||
|
||||
```
|
||||
cd ios/App
|
||||
xcrun agvtool new-version 15
|
||||
# Unfortunately this edits Info.plist directly.
|
||||
#xcrun agvtool new-marketing-version 0.4.5
|
||||
cat App.xcodeproj/project.pbxproj | sed "s/MARKETING_VERSION = .*;/MARKETING_VERSION = 0.4.5;/g" > temp
|
||||
mv temp App.xcodeproj/project.pbxproj
|
||||
cd -
|
||||
```
|
||||
|
||||
5. Open the project in Xcode:
|
||||
|
||||
```bash
|
||||
npx cap open ios
|
||||
```
|
||||
|
||||
4. Use Xcode to build and run on simulator or device.
|
||||
6. Use Xcode to build and run on simulator or device.
|
||||
|
||||
7. Release
|
||||
|
||||
* Under "General" renamed a bunch of things to "Time Safari"
|
||||
* Choose Product -> Destination -> Build Any iOS
|
||||
* Choose Product -> Archive
|
||||
* This will trigger a build and take time, needing user's "login" keychain password which is just their login password, repeatedly.
|
||||
* If it fails with `building for 'iOS', but linking in dylib (.../.pkgx/zlib.net/v1.3.0/lib/libz.1.3.dylib) built for 'macOS'` then run XCode outside that terminal (ie. not with `npx cap open ios`).
|
||||
* Click Distribute -> App Store Connect
|
||||
* In AppStoreConnect, add the build to the distribution: remove the current build with the "-" when you hover over it, then "Add Build" with the new build.
|
||||
* It can take 15 minutes for the build to show up in the list of builds.
|
||||
* You'll probably have to "Manage" something about encryption, disallowed in France.
|
||||
* Then "Save" and "Add to Review" and "Resubmit to App Review".
|
||||
|
||||
#### First-time iOS Configuration
|
||||
|
||||
@@ -218,6 +398,7 @@ Prerequisites: Android Studio with SDK installed
|
||||
cd android
|
||||
./gradlew clean
|
||||
./gradlew assembleDebug
|
||||
cd ..
|
||||
```
|
||||
|
||||
2. Update Android project with latest build:
|
||||
@@ -232,13 +413,15 @@ Prerequisites: Android Studio with SDK installed
|
||||
npx capacitor-assets generate --android
|
||||
```
|
||||
|
||||
4. Open the project in Android Studio:
|
||||
4. Bump version to match iOS, in android/app/build.gradleq
|
||||
|
||||
5. Open the project in Android Studio:
|
||||
|
||||
```bash
|
||||
npx cap open android
|
||||
```
|
||||
|
||||
5. Use Android Studio to build and run on emulator or device.
|
||||
6. Use Android Studio to build and run on emulator or device.
|
||||
|
||||
## Android Build from the console
|
||||
|
||||
@@ -256,12 +439,23 @@ Prerequisites: Android Studio with SDK installed
|
||||
./gradlew bundleDebug -Dlint.baselines.continue=true
|
||||
```
|
||||
|
||||
... or, to create a signed release, add the app/gradle.properties.secrets file (see properties at top of app/build.gradle) and the app/time-safari-upload-key-pkcs12.jks file, then `bundleRelease`:
|
||||
... or, to create a signed release:
|
||||
|
||||
* Setup by adding the app/gradle.properties.secrets file (see properties at top of app/build.gradle) and the app/time-safari-upload-key-pkcs12.jks file
|
||||
* In app/build.gradle, bump the versionCode and maybe the versionName
|
||||
* Then `bundleRelease`:
|
||||
|
||||
```bash
|
||||
./gradlew bundleRelease -Dlint.baselines.continue=true
|
||||
```
|
||||
|
||||
... and find your `aab` file at app/build/outputs/bundle/release
|
||||
|
||||
At play.google.com/console:
|
||||
|
||||
- Create new release, upload, hit Next.
|
||||
|
||||
- Save & send changes for review.
|
||||
|
||||
|
||||
## First-time Android Configuration for deep links
|
||||
@@ -275,253 +469,4 @@ You must add the following intent filter to the `android/app/src/main/AndroidMan
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="timesafari" />
|
||||
</intent-filter>
|
||||
```
|
||||
|
||||
You must also add the following to the `android/app/build.gradle` file:
|
||||
|
||||
```gradle
|
||||
android {
|
||||
// ... existing config ...
|
||||
|
||||
lintOptions {
|
||||
disable 'UnsanitizedFilenameFromContentProvider'
|
||||
abortOnError false
|
||||
baseline file("lint-baseline.xml")
|
||||
|
||||
// Ignore Capacitor module issues
|
||||
ignore 'DefaultLocale'
|
||||
ignore 'UnsanitizedFilenameFromContentProvider'
|
||||
ignore 'LintBaseline'
|
||||
ignore 'LintBaselineFixed'
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Modify `/android/build.gradle` to use a stable version of AGP and make sure Kotlin version is compatible.
|
||||
|
||||
```gradle
|
||||
buildscript {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
dependencies {
|
||||
// Use a stable version of AGP
|
||||
classpath 'com.android.tools.build:gradle:8.1.0'
|
||||
|
||||
// Make sure Kotlin version is compatible
|
||||
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:1.8.0"
|
||||
}
|
||||
}
|
||||
|
||||
allprojects {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
|
||||
// Add this to handle version conflicts
|
||||
configurations.all {
|
||||
resolutionStrategy {
|
||||
force 'org.jetbrains.kotlin:kotlin-stdlib:1.8.0'
|
||||
force 'org.jetbrains.kotlin:kotlin-stdlib-common:1.8.0'
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## PyWebView Desktop Build
|
||||
|
||||
### Prerequisites for PyWebView
|
||||
|
||||
- Python 3.8 or higher
|
||||
- pip (Python package manager)
|
||||
- virtualenv (recommended)
|
||||
- System dependencies:
|
||||
|
||||
```bash
|
||||
# For Ubuntu/Debian
|
||||
sudo apt-get install python3-webview
|
||||
# or
|
||||
sudo apt-get install python3-gi python3-gi-cairo gir1.2-gtk-3.0 gir1.2-webkit2-4.0
|
||||
|
||||
# For Arch Linux
|
||||
sudo pacman -S webkit2gtk python-gobject python-cairo
|
||||
|
||||
# For Fedora
|
||||
sudo dnf install python3-webview
|
||||
# or
|
||||
sudo dnf install python3-gobject python3-cairo webkit2gtk3
|
||||
```
|
||||
|
||||
### Setup
|
||||
|
||||
1. Create and activate a virtual environment (recommended):
|
||||
|
||||
```bash
|
||||
python -m venv .venv
|
||||
source .venv/bin/activate # On Linux/macOS
|
||||
# or
|
||||
.venv\Scripts\activate # On Windows
|
||||
```
|
||||
|
||||
2. Install Python dependencies:
|
||||
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
### Troubleshooting
|
||||
|
||||
If encountering PyInstaller version errors:
|
||||
|
||||
```bash
|
||||
# Try installing the latest stable version
|
||||
pip install --upgrade pyinstaller
|
||||
```
|
||||
|
||||
### Development of PyWebView
|
||||
|
||||
1. Start the PyWebView development build:
|
||||
|
||||
```bash
|
||||
npm run pywebview:dev
|
||||
```
|
||||
|
||||
### Building for Distribution
|
||||
|
||||
#### Linux
|
||||
|
||||
```bash
|
||||
npm run pywebview:package-linux
|
||||
```
|
||||
|
||||
The packaged application will be in `dist/TimeSafari`
|
||||
|
||||
#### Windows
|
||||
|
||||
```bash
|
||||
npm run pywebview:package-win
|
||||
```
|
||||
|
||||
The packaged application will be in `dist/TimeSafari`
|
||||
|
||||
#### macOS
|
||||
|
||||
```bash
|
||||
npm run pywebview:package-mac
|
||||
```
|
||||
|
||||
The packaged application will be in `dist/TimeSafari`
|
||||
|
||||
## Testing
|
||||
|
||||
Run all tests (requires XCode and Android Studio/device):
|
||||
|
||||
```bash
|
||||
npm run test:all
|
||||
```
|
||||
|
||||
See [TESTING.md](test-playwright/TESTING.md) for more details.
|
||||
|
||||
## Linting
|
||||
|
||||
Check code style:
|
||||
|
||||
```bash
|
||||
npm run lint
|
||||
```
|
||||
|
||||
Fix code style issues:
|
||||
|
||||
```bash
|
||||
npm run lint-fix
|
||||
```
|
||||
|
||||
## Environment Configuration
|
||||
|
||||
See `.env.*` files for configuration.
|
||||
|
||||
## Notes
|
||||
|
||||
- The application uses PWA (Progressive Web App) features for web builds
|
||||
- Electron builds disable PWA features automatically
|
||||
- Build output directories:
|
||||
- Web: `dist/`
|
||||
- Electron: `dist-electron/`
|
||||
- Capacitor: `dist-capacitor/`
|
||||
|
||||
## Deployment
|
||||
|
||||
### Version Management
|
||||
|
||||
1. Update CHANGELOG.md with new changes
|
||||
2. Update version in package.json
|
||||
3. Commit changes and tag release:
|
||||
|
||||
```bash
|
||||
git tag <VERSION_TAG>
|
||||
git push origin <VERSION_TAG>
|
||||
```
|
||||
|
||||
4. After deployment, update package.json with next version + "-beta"
|
||||
|
||||
### Test Server
|
||||
|
||||
```bash
|
||||
# Build using staging environment
|
||||
npm run build -- --mode staging
|
||||
|
||||
# Deploy to test server
|
||||
rsync -azvu -e "ssh -i ~/.ssh/<YOUR_KEY>" dist ubuntutest@test.timesafari.app:time-safari/
|
||||
```
|
||||
|
||||
### Production Server
|
||||
|
||||
```bash
|
||||
# On the production server:
|
||||
pkgx +npm sh
|
||||
cd crowd-funder-for-time-pwa
|
||||
git checkout master && git pull
|
||||
git checkout <VERSION_TAG>
|
||||
npm install
|
||||
npm run build
|
||||
cd -
|
||||
|
||||
# Backup and deploy
|
||||
mv time-safari/dist time-safari-dist-prev.0 && mv crowd-funder-for-time-pwa/dist time-safari/
|
||||
```
|
||||
|
||||
## Troubleshooting Builds
|
||||
|
||||
### Common Build Issues
|
||||
|
||||
1. **Missing Environment Variables**
|
||||
- Check that all required variables are set in your .env file
|
||||
- For development, ensure local services are running on correct ports
|
||||
|
||||
2. **Electron Build Failures**
|
||||
- Verify Node.js version compatibility
|
||||
- Check that all required dependencies are installed
|
||||
- Ensure proper paths in electron/main.js
|
||||
|
||||
3. **Mobile Build Issues**
|
||||
- For iOS: Xcode command line tools must be installed
|
||||
- For Android: Correct SDK version must be installed
|
||||
- Check Capacitor configuration in capacitor.config.ts
|
||||
|
||||
|
||||
# List all installed packages
|
||||
adb shell pm list packages | grep timesafari
|
||||
|
||||
# Force stop the app (if it's running)
|
||||
adb shell am force-stop app.timesafari
|
||||
|
||||
# Clear app data (if you don't want to fully uninstall)
|
||||
adb shell pm clear app.timesafari
|
||||
|
||||
# Uninstall for all users
|
||||
adb shell pm uninstall -k --user 0 app.timesafari
|
||||
|
||||
# Check if app is installed
|
||||
adb shell pm path app.timesafari
|
||||
```
|
||||
42
Dockerfile
Normal file
42
Dockerfile
Normal file
@@ -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;"]
|
||||
22
README.md
22
README.md
@@ -31,7 +31,9 @@ See [TESTING.md](test-playwright/TESTING.md) for detailed test instructions.
|
||||
|
||||
## Icons
|
||||
|
||||
To add an icon, add to main.ts and reference with `fa` element and `icon` attribute with the hyphenated name.
|
||||
Application icons are in the `assets` directory, processed by the `capacitor-assets` command.
|
||||
|
||||
To add a Font Awesome icon, add to main.ts and reference with `font-awesome` element and `icon` attribute with the hyphenated name.
|
||||
|
||||
## Other
|
||||
|
||||
@@ -44,6 +46,24 @@ To add an icon, add to main.ts and reference with `fa` element and `icon` attrib
|
||||
|
||||
* If you are deploying in a subdirectory, add it to `publicPath` in vue.config.js, eg: `publicPath: "/app/time-tracker/",`
|
||||
|
||||
### Code Organization
|
||||
|
||||
The project uses a centralized approach to type definitions and interfaces:
|
||||
|
||||
* `src/interfaces/` - Contains all TypeScript interfaces and type definitions
|
||||
* `deepLinks.ts` - Deep linking type system and Zod validation schemas
|
||||
* `give.ts` - Give-related interfaces and type definitions
|
||||
* `claims.ts` - Claim-related interfaces and verifiable credentials
|
||||
* `common.ts` - Shared interfaces and utility types
|
||||
* Other domain-specific interface files
|
||||
|
||||
Key principles:
|
||||
- All interfaces and types are defined in the interfaces folder
|
||||
- Zod schemas are used for runtime validation and type generation
|
||||
- Domain-specific interfaces are separated into their own files
|
||||
- Common interfaces are shared through `common.ts`
|
||||
- Type definitions are generated from Zod schemas where possible
|
||||
|
||||
### Kudos
|
||||
|
||||
Gifts make the world go 'round!
|
||||
|
||||
20
android/.gitignore
vendored
20
android/.gitignore
vendored
@@ -1,5 +1,17 @@
|
||||
# Using Android gitignore template: https://github.com/github/gitignore/blob/HEAD/Android.gitignore
|
||||
|
||||
app/build/*
|
||||
!app/build/.npmkeep
|
||||
|
||||
# Copied web assets
|
||||
app/src/main/assets/public
|
||||
|
||||
# Generated Config files
|
||||
app/src/main/assets/capacitor.config.json
|
||||
app/src/main/assets/capacitor.plugins.json
|
||||
app/src/main/res/xml/config.xml
|
||||
|
||||
# secrets
|
||||
app/gradle.properties.secrets
|
||||
app/time-safari-upload-key-pkcs12.jks
|
||||
|
||||
@@ -94,11 +106,3 @@ lint/tmp/
|
||||
|
||||
# Cordova plugins for Capacitor
|
||||
capacitor-cordova-android-plugins
|
||||
|
||||
# Copied web assets
|
||||
app/src/main/assets/public
|
||||
|
||||
# Generated Config files
|
||||
app/src/main/assets/capacitor.config.json
|
||||
app/src/main/assets/capacitor.plugins.json
|
||||
app/src/main/res/xml/config.xml
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
#Wed Apr 09 09:01:13 UTC 2025
|
||||
gradle.version=8.11.1
|
||||
Binary file not shown.
2
android/app/.gitignore
vendored
2
android/app/.gitignore
vendored
@@ -1,2 +0,0 @@
|
||||
/build/*
|
||||
!/build/.npmkeep
|
||||
@@ -9,6 +9,7 @@ android {
|
||||
|
||||
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
|
||||
dependencies {
|
||||
implementation project(':capacitor-mlkit-barcode-scanning')
|
||||
implementation project(':capacitor-app')
|
||||
implementation project(':capacitor-camera')
|
||||
implementation project(':capacitor-filesystem')
|
||||
|
||||
28
android/app/google-services.json
Normal file
28
android/app/google-services.json
Normal file
@@ -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": []
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -41,4 +41,6 @@
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
<uses-feature android:name="android.hardware.camera" android:required="true" />
|
||||
</manifest>
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
[
|
||||
{
|
||||
"pkg": "@capacitor-mlkit/barcode-scanning",
|
||||
"classpath": "io.capawesome.capacitorjs.plugins.mlkit.barcodescanning.BarcodeScannerPlugin"
|
||||
},
|
||||
{
|
||||
"pkg": "@capacitor/app",
|
||||
"classpath": "com.capacitorjs.plugins.app.AppPlugin"
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
include ':capacitor-android'
|
||||
project(':capacitor-android').projectDir = new File('../node_modules/@capacitor/android/capacitor')
|
||||
|
||||
include ':capacitor-mlkit-barcode-scanning'
|
||||
project(':capacitor-mlkit-barcode-scanning').projectDir = new File('../node_modules/@capacitor-mlkit/barcode-scanning/android')
|
||||
|
||||
include ':capacitor-app'
|
||||
project(':capacitor-app').projectDir = new File('../node_modules/@capacitor/app/android')
|
||||
|
||||
|
||||
2
assets/README.md
Normal file
2
assets/README.md
Normal file
@@ -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.
|
||||
4
build.sh
Executable file
4
build.sh
Executable file
@@ -0,0 +1,4 @@
|
||||
#!/bin/bash
|
||||
export IMAGENAME="$(basename $PWD):1.0"
|
||||
|
||||
docker build . --network=host -t $IMAGENAME --no-cache
|
||||
21
capacitor.config.json
Normal file
21
capacitor.config.json
Normal file
@@ -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;
|
||||
@@ -9,21 +9,95 @@ The deep linking system uses a multi-layered type safety approach:
|
||||
- Enforces parameter requirements
|
||||
- Sanitizes input data
|
||||
- Provides detailed validation errors
|
||||
- Generates TypeScript types automatically
|
||||
|
||||
2. **TypeScript Types**
|
||||
- Generated from Zod schemas
|
||||
- Generated from Zod schemas using `z.infer`
|
||||
- Ensures compile-time type safety
|
||||
- Provides IDE autocompletion
|
||||
- Catches type errors during development
|
||||
- Maintains single source of truth for types
|
||||
|
||||
3. **Router Integration**
|
||||
- Type-safe parameter passing
|
||||
- Route-specific parameter validation
|
||||
- Query parameter type checking
|
||||
- Automatic type inference for route parameters
|
||||
|
||||
## Type System Implementation
|
||||
|
||||
### Zod Schema to TypeScript Type Generation
|
||||
|
||||
```typescript
|
||||
// Define the schema
|
||||
const claimSchema = z.object({
|
||||
id: z.string(),
|
||||
view: z.enum(["details", "certificate", "raw"]).optional()
|
||||
});
|
||||
|
||||
// TypeScript type is automatically generated
|
||||
type ClaimParams = z.infer<typeof claimSchema>;
|
||||
// Equivalent to:
|
||||
// type ClaimParams = {
|
||||
// id: string;
|
||||
// view?: "details" | "certificate" | "raw";
|
||||
// }
|
||||
```
|
||||
|
||||
### Type Safety Layers
|
||||
|
||||
1. **Schema Definition**
|
||||
```typescript
|
||||
// src/interfaces/deepLinks.ts
|
||||
export const deepLinkSchemas = {
|
||||
claim: z.object({
|
||||
id: z.string(),
|
||||
view: z.enum(["details", "certificate", "raw"]).optional()
|
||||
}),
|
||||
// Other route schemas...
|
||||
};
|
||||
```
|
||||
|
||||
2. **Type Generation**
|
||||
```typescript
|
||||
// Types are automatically generated from schemas
|
||||
export type DeepLinkParams = {
|
||||
[K in keyof typeof deepLinkSchemas]: z.infer<(typeof deepLinkSchemas)[K]>;
|
||||
};
|
||||
```
|
||||
|
||||
3. **Runtime Validation**
|
||||
```typescript
|
||||
// In DeepLinkHandler
|
||||
const result = deepLinkSchemas.claim.safeParse(params);
|
||||
if (!result.success) {
|
||||
// Handle validation errors
|
||||
console.error(result.error);
|
||||
}
|
||||
```
|
||||
|
||||
### Error Handling Types
|
||||
|
||||
```typescript
|
||||
export interface DeepLinkError extends Error {
|
||||
code: string;
|
||||
details?: unknown;
|
||||
}
|
||||
|
||||
// Usage in error handling
|
||||
try {
|
||||
await handler.handleDeepLink(url);
|
||||
} catch (error) {
|
||||
if (error instanceof DeepLinkError) {
|
||||
// Type-safe error handling
|
||||
console.error(error.code, error.message);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Implementation Files
|
||||
|
||||
- `src/types/deepLinks.ts`: Type definitions and validation schemas
|
||||
- `src/interfaces/deepLinks.ts`: Type definitions and validation schemas
|
||||
- `src/services/deepLinks.ts`: Deep link processing service
|
||||
- `src/main.capacitor.ts`: Capacitor integration
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
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
|
||||
|
||||
805
doc/qr-code-implementation-guide.md
Normal file
805
doc/qr-code-implementation-guide.md
Normal file
@@ -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
|
||||
@@ -3,7 +3,7 @@
|
||||
<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">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1.0,viewport-fit=cover">
|
||||
<link rel="icon" href="/favicon.ico">
|
||||
<title>TimeSafari</title>
|
||||
</head>
|
||||
|
||||
17
ios/.gitignore
vendored
17
ios/.gitignore
vendored
@@ -1,16 +1,13 @@
|
||||
App/build
|
||||
App/output
|
||||
App/Pods
|
||||
|
||||
App/*.xcodeproj/xcuserdata/
|
||||
App/*.xcworkspace/xcuserdata/
|
||||
App/*/public
|
||||
|
||||
# Generated Config files
|
||||
App/*/capacitor.config.json
|
||||
App/*/config.xml
|
||||
App/output
|
||||
App/App/public
|
||||
DerivedData
|
||||
xcuserdata
|
||||
|
||||
# Cordova plugins for Capacitor
|
||||
capacitor-cordova-ios-plugins
|
||||
|
||||
DerivedData
|
||||
# Generated Config files
|
||||
App/App/capacitor.config.json
|
||||
App/App/config.xml
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
objects = {
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
2BC611FE3D7967BDB623FF21 /* Pods_App.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E0C2082015AEE6A0776A3EAB /* Pods_App.framework */; };
|
||||
2FAD9763203C412B000D30F8 /* config.xml in Resources */ = {isa = PBXBuildFile; fileRef = 2FAD9762203C412B000D30F8 /* config.xml */; };
|
||||
50379B232058CBB4000EE86E /* capacitor.config.json in Resources */ = {isa = PBXBuildFile; fileRef = 50379B222058CBB4000EE86E /* capacitor.config.json */; };
|
||||
504EC3081FED79650016851F /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 504EC3071FED79650016851F /* AppDelegate.swift */; };
|
||||
@@ -15,6 +14,7 @@
|
||||
504EC30F1FED79650016851F /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 504EC30E1FED79650016851F /* Assets.xcassets */; };
|
||||
504EC3121FED79650016851F /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 504EC3101FED79650016851F /* LaunchScreen.storyboard */; };
|
||||
50B271D11FEDC1A000F3C39B /* public in Resources */ = {isa = PBXBuildFile; fileRef = 50B271D01FEDC1A000F3C39B /* public */; };
|
||||
A084ECDBA7D38E1E42DFC39D /* Pods_App.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = AF277DCFFFF123FFC6DF26C7 /* Pods_App.framework */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
@@ -27,10 +27,8 @@
|
||||
504EC3111FED79650016851F /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
|
||||
504EC3131FED79650016851F /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||
50B271D01FEDC1A000F3C39B /* public */ = {isa = PBXFileReference; lastKnownFileType = folder; path = public; sourceTree = "<group>"; };
|
||||
821226CEE4D47A540167CC8F /* Pods-Time Safari.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Time Safari.debug.xcconfig"; path = "Pods/Target Support Files/Pods-Time Safari/Pods-Time Safari.debug.xcconfig"; sourceTree = "<group>"; };
|
||||
AF277DCFFFF123FFC6DF26C7 /* Pods_App.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_App.framework; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
AF51FD2D460BCFE21FA515B2 /* Pods-App.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App.release.xcconfig"; path = "Pods/Target Support Files/Pods-App/Pods-App.release.xcconfig"; sourceTree = "<group>"; };
|
||||
E0C2082015AEE6A0776A3EAB /* Pods_App.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_App.framework; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
EF03C3F99471948925ED5AC3 /* Pods-Time Safari.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Time Safari.release.xcconfig"; path = "Pods/Target Support Files/Pods-Time Safari/Pods-Time Safari.release.xcconfig"; sourceTree = "<group>"; };
|
||||
FC68EB0AF532CFC21C3344DD /* Pods-App.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App.debug.xcconfig"; path = "Pods/Target Support Files/Pods-App/Pods-App.debug.xcconfig"; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
@@ -39,7 +37,7 @@
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
2BC611FE3D7967BDB623FF21 /* Pods_App.framework in Frameworks */,
|
||||
A084ECDBA7D38E1E42DFC39D /* Pods_App.framework in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -49,7 +47,7 @@
|
||||
27E2DDA53C4D2A4D1A88CE4A /* Frameworks */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
E0C2082015AEE6A0776A3EAB /* Pods_App.framework */,
|
||||
AF277DCFFFF123FFC6DF26C7 /* Pods_App.framework */,
|
||||
);
|
||||
name = Frameworks;
|
||||
sourceTree = "<group>";
|
||||
@@ -92,8 +90,6 @@
|
||||
children = (
|
||||
FC68EB0AF532CFC21C3344DD /* Pods-App.debug.xcconfig */,
|
||||
AF51FD2D460BCFE21FA515B2 /* Pods-App.release.xcconfig */,
|
||||
821226CEE4D47A540167CC8F /* Pods-Time Safari.debug.xcconfig */,
|
||||
EF03C3F99471948925ED5AC3 /* Pods-Time Safari.release.xcconfig */,
|
||||
);
|
||||
name = Pods;
|
||||
sourceTree = "<group>";
|
||||
@@ -116,7 +112,7 @@
|
||||
dependencies = (
|
||||
);
|
||||
name = App;
|
||||
productName = "Time Safari";
|
||||
productName = App;
|
||||
productReference = 504EC3041FED79650016851F /* App.app */;
|
||||
productType = "com.apple.product-type.application";
|
||||
};
|
||||
@@ -145,6 +141,8 @@
|
||||
Base,
|
||||
);
|
||||
mainGroup = 504EC2FB1FED79650016851F;
|
||||
packageReferences = (
|
||||
);
|
||||
productRefGroup = 504EC3051FED79650016851F /* Products */;
|
||||
projectDirPath = "";
|
||||
projectRoot = "";
|
||||
@@ -350,18 +348,19 @@
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 12;
|
||||
CURRENT_PROJECT_VERSION = 17;
|
||||
DEVELOPMENT_TEAM = GM3FS5JQPH;
|
||||
INFOPLIST_FILE = App/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
|
||||
MARKETING_VERSION = 0.4.4;
|
||||
MARKETING_VERSION = 0.4.6;
|
||||
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = app.timesafari;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
VERSIONING_SYSTEM = "apple-generic"; /* allows agvtool to set *_VERSION settings */
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
@@ -371,17 +370,18 @@
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 12;
|
||||
CURRENT_PROJECT_VERSION = 17;
|
||||
DEVELOPMENT_TEAM = GM3FS5JQPH;
|
||||
INFOPLIST_FILE = App/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
|
||||
MARKETING_VERSION = 0.4.4;
|
||||
MARKETING_VERSION = 0.4.6;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = app.timesafari;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
VERSIONING_SYSTEM = "apple-generic"; /* allows agvtool to set *_VERSION settings */
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
<?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>IDEDidComputeMac32BitWarning</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -5,7 +5,7 @@
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>en</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>TimeSafari</string>
|
||||
<string>TimeSafari</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
@@ -23,9 +23,9 @@
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>Upload photos and scan friends' QR codes</string>
|
||||
<string>Time Safari allows you to take photos, and also scan QR codes from contacts.</string>
|
||||
<key>NSPhotoLibraryUsageDescription</key>
|
||||
<string>Upload photos for gifts</string>
|
||||
<string>Time Safari allows you to upload photos.</string>
|
||||
<key>UILaunchStoryboardName</key>
|
||||
<string>LaunchScreen</string>
|
||||
<key>UIMainStoryboardFile</key>
|
||||
@@ -49,16 +49,5 @@
|
||||
</array>
|
||||
<key>UIViewControllerBasedStatusBarAppearance</key>
|
||||
<true/>
|
||||
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>CFBundleURLName</key>
|
||||
<string>app.timesafari</string>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>timesafari</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array></dict>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
20
ios/App/App/entitlements.mac.plist
Normal file
20
ios/App/App/entitlements.mac.plist
Normal file
@@ -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>
|
||||
@@ -11,6 +11,7 @@ install! 'cocoapods', :disable_input_output_paths => true
|
||||
def capacitor_pods
|
||||
pod 'Capacitor', :path => '../../node_modules/@capacitor/ios'
|
||||
pod 'CapacitorCordova', :path => '../../node_modules/@capacitor/ios'
|
||||
pod 'CapacitorMlkitBarcodeScanning', :path => '../../node_modules/@capacitor-mlkit/barcode-scanning'
|
||||
pod 'CapacitorApp', :path => '../../node_modules/@capacitor/app'
|
||||
pod 'CapacitorCamera', :path => '../../node_modules/@capacitor/camera'
|
||||
pod 'CapacitorFilesystem', :path => '../../node_modules/@capacitor/filesystem'
|
||||
|
||||
@@ -8,10 +8,71 @@ PODS:
|
||||
- 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:
|
||||
- "Capacitor (from `../../node_modules/@capacitor/ios`)"
|
||||
@@ -19,9 +80,25 @@ DEPENDENCIES:
|
||||
- "CapacitorCamera (from `../../node_modules/@capacitor/camera`)"
|
||||
- "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:
|
||||
Capacitor:
|
||||
:path: "../../node_modules/@capacitor/ios"
|
||||
@@ -33,6 +110,8 @@ EXTERNAL SOURCES:
|
||||
: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:
|
||||
@@ -44,9 +123,22 @@ SPEC CHECKSUMS:
|
||||
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: 1e9280368fd410520414f5741bf8fdfe7847b965
|
||||
PODFILE CHECKSUM: 7e7e09e6937de7f015393aecf2cf7823645689b3
|
||||
|
||||
COCOAPODS: 1.16.2
|
||||
|
||||
7791
package-lock.json
generated
7791
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
58
package.json
58
package.json
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "timesafari",
|
||||
"version": "0.4.4",
|
||||
"version": "0.4.6",
|
||||
"description": "Time Safari Application",
|
||||
"author": {
|
||||
"name": "Time Safari Team"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "vite --config vite.config.dev.mts",
|
||||
"dev": "vite --config vite.config.dev.mts --host",
|
||||
"serve": "vite preview",
|
||||
"build": "VITE_GIT_HASH=`git log -1 --pretty=format:%h` vite build --config vite.config.mts",
|
||||
"lint": "eslint --ext .js,.ts,.vue --ignore-path .gitignore src",
|
||||
@@ -22,12 +22,13 @@
|
||||
"check:ios-device": "xcrun xctrace list devices 2>&1 | grep -w 'Booted' || (echo 'No iOS simulator running' && exit 1)",
|
||||
"clean:electron": "rimraf dist-electron",
|
||||
"build:pywebview": "vite build --config vite.config.pywebview.mts",
|
||||
"build:electron": "npm run clean:electron && vite build --config vite.config.electron.mts && node scripts/build-electron.js",
|
||||
"build:capacitor": "vite build --config vite.config.capacitor.mts",
|
||||
"build:web": "vite build --config vite.config.web.mts",
|
||||
"electron:dev": "npm run build && electron dist-electron",
|
||||
"electron:start": "electron dist-electron",
|
||||
"build:android": "rm -rf dist && npm run build:web && npm run build:capacitor && cd android && ./gradlew clean && ./gradlew assembleDebug && cd .. && npx cap sync android && npx capacitor-assets generate --android && npx cap open android",
|
||||
"build:electron": "npm run clean:electron && tsc -p tsconfig.electron.json && vite build --config vite.config.electron.mts && node scripts/build-electron.js",
|
||||
"build:capacitor": "vite build --mode capacitor --config vite.config.capacitor.mts",
|
||||
"build:web": "VITE_GIT_HASH=`git log -1 --pretty=format:%h` vite build --config vite.config.web.mts",
|
||||
"electron:dev": "npm run build && electron .",
|
||||
"electron:start": "electron .",
|
||||
"clean:android": "adb uninstall app.timesafari.app || true",
|
||||
"build:android": "npm run clean:android && rm -rf dist && npm run build:web && npm run build:capacitor && cd android && ./gradlew clean && ./gradlew assembleDebug && cd .. && npx cap sync android && npx capacitor-assets generate --android && npx cap open android",
|
||||
"electron:build-linux": "npm run build:electron && electron-builder --linux AppImage",
|
||||
"electron:build-linux-deb": "npm run build:electron && electron-builder --linux deb",
|
||||
"electron:build-linux-prod": "NODE_ENV=production npm run build:electron && electron-builder --linux AppImage",
|
||||
@@ -40,9 +41,12 @@
|
||||
"fastlane:ios:beta": "cd ios && fastlane beta",
|
||||
"fastlane:ios:release": "cd ios && fastlane release",
|
||||
"fastlane:android:beta": "cd android && fastlane beta",
|
||||
"fastlane:android:release": "cd android && fastlane release"
|
||||
"fastlane:android:release": "cd android && fastlane release",
|
||||
"electron:build-mac": "npm run build:electron-prod && electron-builder --mac",
|
||||
"electron:build-mac-universal": "npm run build:electron-prod && electron-builder --mac --universal"
|
||||
},
|
||||
"dependencies": {
|
||||
"@capacitor-mlkit/barcode-scanning": "^6.0.0",
|
||||
"@capacitor/android": "^6.2.0",
|
||||
"@capacitor/app": "^6.0.0",
|
||||
"@capacitor/camera": "^6.0.0",
|
||||
@@ -91,6 +95,7 @@
|
||||
"jdenticon": "^3.2.0",
|
||||
"js-generate-password": "^0.1.9",
|
||||
"js-yaml": "^4.1.0",
|
||||
"jsqr": "^1.4.0",
|
||||
"leaflet": "^1.9.4",
|
||||
"localstorage-slim": "^2.7.0",
|
||||
"lru-cache": "^10.2.0",
|
||||
@@ -167,13 +172,12 @@
|
||||
},
|
||||
"files": [
|
||||
"dist-electron/**/*",
|
||||
"src/electron/**/*",
|
||||
"main.js"
|
||||
"dist/**/*"
|
||||
],
|
||||
"extraResources": [
|
||||
{
|
||||
"from": "dist-electron",
|
||||
"to": "."
|
||||
"from": "dist",
|
||||
"to": "www"
|
||||
}
|
||||
],
|
||||
"linux": {
|
||||
@@ -184,6 +188,32 @@
|
||||
"category": "Office",
|
||||
"icon": "build/icon.png"
|
||||
},
|
||||
"asar": true
|
||||
"asar": true,
|
||||
"mac": {
|
||||
"target": [
|
||||
"dmg",
|
||||
"zip"
|
||||
],
|
||||
"category": "public.app-category.productivity",
|
||||
"icon": "build/icon.png",
|
||||
"hardenedRuntime": true,
|
||||
"gatekeeperAssess": false,
|
||||
"entitlements": "ios/App/App/entitlements.mac.plist",
|
||||
"entitlementsInherit": "ios/App/App/entitlements.mac.plist"
|
||||
},
|
||||
"dmg": {
|
||||
"contents": [
|
||||
{
|
||||
"x": 130,
|
||||
"y": 220
|
||||
},
|
||||
{
|
||||
"x": 410,
|
||||
"y": 220,
|
||||
"type": "link",
|
||||
"path": "/Applications"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
dependencies:
|
||||
- gradle
|
||||
- java
|
||||
- pod
|
||||
|
||||
# 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 fs = require('fs-extra');
|
||||
|
||||
async function main() {
|
||||
try {
|
||||
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
|
||||
const wwwDir = path.join(distElectronDir, 'www');
|
||||
await fs.ensureDir(wwwDir);
|
||||
await fs.copy('dist', wwwDir);
|
||||
const webDistPath = path.join(__dirname, '..', 'dist');
|
||||
const electronDistPath = path.join(__dirname, '..', 'dist-electron');
|
||||
const wwwPath = path.join(electronDistPath, 'www');
|
||||
|
||||
// Copy and fix index.html
|
||||
const indexPath = path.join(wwwDir, 'index.html');
|
||||
let indexContent = await fs.readFile(indexPath, 'utf8');
|
||||
// Create www directory if it doesn't exist
|
||||
if (!fs.existsSync(wwwPath)) {
|
||||
fs.mkdirSync(wwwPath, { recursive: true });
|
||||
}
|
||||
|
||||
// More comprehensive path fixing
|
||||
// Copy web files to www directory
|
||||
fs.cpSync(webDistPath, wwwPath, { recursive: true });
|
||||
|
||||
// Fix asset paths in index.html
|
||||
const indexPath = path.join(wwwPath, 'index.html');
|
||||
let indexContent = fs.readFileSync(indexPath, 'utf8');
|
||||
|
||||
// Fix asset paths
|
||||
indexContent = indexContent
|
||||
// Fix absolute paths to be relative
|
||||
.replace(/src="\//g, 'src="\./')
|
||||
.replace(/href="\//g, 'href="\./')
|
||||
// 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/');
|
||||
.replace(/\/assets\//g, './assets/')
|
||||
.replace(/href="\//g, 'href="./')
|
||||
.replace(/src="\//g, 'src="./');
|
||||
|
||||
// Debug output
|
||||
fs.writeFileSync(indexPath, indexContent);
|
||||
|
||||
// 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.slice(0, 500));
|
||||
|
||||
await fs.writeFile(indexPath, indexContent);
|
||||
console.log('Sample of fixed content:', indexContent.substring(0, 500));
|
||||
|
||||
console.log('Copied and fixed web files in:', wwwDir);
|
||||
console.log('Copied and fixed web files in:', wwwPath);
|
||||
|
||||
// Copy 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 the main process file with inlined logger
|
||||
const mainContent = `const { app, BrowserWindow } = require("electron");
|
||||
const path = require("path");
|
||||
const fs = require("fs");
|
||||
|
||||
// Create package.json for production
|
||||
const devPackageJson = require('../package.json');
|
||||
const prodPackageJson = {
|
||||
name: devPackageJson.name,
|
||||
version: devPackageJson.version,
|
||||
description: devPackageJson.description,
|
||||
author: devPackageJson.author,
|
||||
main: 'main.js',
|
||||
private: true,
|
||||
};
|
||||
// Inline logger implementation
|
||||
const logger = {
|
||||
log: (...args) => console.log(...args),
|
||||
error: (...args) => console.error(...args),
|
||||
info: (...args) => console.info(...args),
|
||||
warn: (...args) => console.warn(...args),
|
||||
debug: (...args) => console.debug(...args),
|
||||
};
|
||||
|
||||
await fs.writeJson(
|
||||
path.join(distElectronDir, 'package.json'),
|
||||
prodPackageJson,
|
||||
{ spaces: 2 }
|
||||
);
|
||||
// Check if running in dev mode
|
||||
const isDev = process.argv.includes("--inspect");
|
||||
|
||||
// Verify the build
|
||||
console.log('\nVerifying build structure:');
|
||||
const files = await fs.readdir(distElectronDir);
|
||||
console.log('Files in dist-electron:', files);
|
||||
function createWindow() {
|
||||
// Add before createWindow function
|
||||
const preloadPath = path.join(__dirname, "preload.js");
|
||||
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');
|
||||
}
|
||||
if (!files.includes('preload.js')) {
|
||||
throw new Error('preload.js not found in build directory');
|
||||
}
|
||||
if (!files.includes('package.json')) {
|
||||
throw new Error('package.json not found in build directory');
|
||||
}
|
||||
// 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"),
|
||||
},
|
||||
});
|
||||
|
||||
console.log('Build completed successfully!');
|
||||
} catch (error) {
|
||||
console.error('Build failed:', error);
|
||||
process.exit(1);
|
||||
// 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();
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
// 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);
|
||||
});
|
||||
`;
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
// Verify build structure
|
||||
console.log('\nVerifying build structure:');
|
||||
console.log('Files in dist-electron:', fs.readdirSync(electronDistPath));
|
||||
|
||||
console.log('Build completed successfully!');
|
||||
@@ -4,9 +4,9 @@
|
||||
#
|
||||
# Prerequisites: openssl, jq
|
||||
#
|
||||
# Usage: source ./openssl_signing_console.sh
|
||||
# Usage: source /scripts/openssl_signing_console.sh
|
||||
#
|
||||
# For a more complete explanation, see ./openssl_signing_console.rst
|
||||
# For a more complete explanation, see /doc/openssl_signing_console.rst
|
||||
|
||||
|
||||
# Generate a key and extract the public part
|
||||
@@ -441,8 +441,8 @@ const configureIosProject = async (log) => {
|
||||
try {
|
||||
// Try to run pod install normally first
|
||||
log('🔄 Running "pod install" in ios/App directory...');
|
||||
execSync('cd ios/App && pod install', { stdio: 'inherit' });
|
||||
log('✅ CocoaPods installation completed');
|
||||
execSync('cd ios/App && pod install', { stdio: 'inherit' });
|
||||
log('✅ CocoaPods installation completed');
|
||||
} catch (error) {
|
||||
// If that fails, provide detailed instructions
|
||||
log(`⚠️ CocoaPods installation failed: ${error.message}`);
|
||||
@@ -623,28 +623,28 @@ const runDeeplinkTests = async (log) => {
|
||||
}
|
||||
|
||||
// Now we can safely create the deeplink tests knowing we have valid data
|
||||
const deeplinkTests = [
|
||||
{
|
||||
const deeplinkTests = [
|
||||
{
|
||||
url: `timesafari://claim/${testEnv.CLAIM_ID}`,
|
||||
description: 'Claim view'
|
||||
},
|
||||
{
|
||||
description: 'Claim view'
|
||||
},
|
||||
{
|
||||
url: `timesafari://claim-cert/${testEnv.CERT_ID || testEnv.CLAIM_ID}`,
|
||||
description: 'Claim certificate view'
|
||||
},
|
||||
{
|
||||
description: 'Claim certificate view'
|
||||
},
|
||||
{
|
||||
url: `timesafari://claim-add-raw/${testEnv.RAW_CLAIM_ID || testEnv.CLAIM_ID}`,
|
||||
description: 'Raw claim addition'
|
||||
},
|
||||
{
|
||||
url: 'timesafari://did/test',
|
||||
description: 'DID view with test identifier'
|
||||
},
|
||||
{
|
||||
url: `timesafari://did/${testEnv.CONTACT1_DID}`,
|
||||
description: 'DID view with contact DID'
|
||||
},
|
||||
{
|
||||
description: 'Raw claim addition'
|
||||
},
|
||||
{
|
||||
url: 'timesafari://did/test',
|
||||
description: 'DID view with test identifier'
|
||||
},
|
||||
{
|
||||
url: `timesafari://did/${testEnv.CONTACT1_DID}`,
|
||||
description: 'DID view with contact DID'
|
||||
},
|
||||
{
|
||||
url: (() => {
|
||||
if (!testEnv?.CONTACT1_DID) {
|
||||
throw new Error('Cannot construct contact-edit URL: CONTACT1_DID is missing');
|
||||
@@ -653,13 +653,13 @@ const runDeeplinkTests = async (log) => {
|
||||
log('Created contact-edit URL:', url);
|
||||
return url;
|
||||
})(),
|
||||
description: 'Contact editing'
|
||||
},
|
||||
{
|
||||
url: `timesafari://contacts/import?contacts=${encodeURIComponent(JSON.stringify(contacts))}`,
|
||||
description: 'Contacts import'
|
||||
}
|
||||
];
|
||||
description: 'Contact editing'
|
||||
},
|
||||
{
|
||||
url: `timesafari://contacts/import?contacts=${encodeURIComponent(JSON.stringify(contacts))}`,
|
||||
description: 'Contacts import'
|
||||
}
|
||||
];
|
||||
|
||||
// Log the final test configuration
|
||||
log('\n5. Final Test Configuration:');
|
||||
|
||||
27
src/App.vue
27
src/App.vue
@@ -4,7 +4,7 @@
|
||||
<!-- Messages in the upper-right - https://github.com/emmanuelsw/notiwind -->
|
||||
<NotificationGroup group="alert">
|
||||
<div
|
||||
class="fixed top-4 right-4 w-full max-w-sm flex flex-col items-start justify-end"
|
||||
class="fixed top-[calc(env(safe-area-inset-top)+1rem)] right-4 left-4 sm:left-auto sm:w-full sm:max-w-sm flex flex-col items-start justify-end"
|
||||
>
|
||||
<Notification
|
||||
v-slot="{ notifications, close }"
|
||||
@@ -144,10 +144,10 @@
|
||||
<!--
|
||||
This "group" of "modal" is the prompt for an answer.
|
||||
Set "type" as follows: "confirm" for yes/no, and "notification" ones:
|
||||
"-permission", "-mute", "-off"
|
||||
"-permission", "-mute", "-off"
|
||||
-->
|
||||
<NotificationGroup group="modal">
|
||||
<div class="fixed z-[100] top-0 inset-x-0 w-full">
|
||||
<div class="fixed z-[100] top-[env(safe-area-inset-top)] inset-x-0 w-full">
|
||||
<Notification
|
||||
v-slot="{ notifications, close }"
|
||||
enter="transform ease-out duration-300 transition"
|
||||
@@ -167,10 +167,10 @@
|
||||
role="alert"
|
||||
>
|
||||
<!--
|
||||
Type of "confirm" will post a message.
|
||||
With onYes function, show a "Yes" button to call that function.
|
||||
With onNo function, show a "No" button to call that function,
|
||||
and pass it state of "askAgain" field shown if you set promptToStopAsking.
|
||||
Type of "confirm" will post a message.
|
||||
With onYes function, show a "Yes" button to call that function.
|
||||
With onNo function, show a "No" button to call that function,
|
||||
and pass it state of "askAgain" field shown if you set promptToStopAsking.
|
||||
-->
|
||||
<div
|
||||
v-if="notification.type === 'confirm'"
|
||||
@@ -539,4 +539,15 @@ export default class App extends Vue {
|
||||
}
|
||||
</script>
|
||||
|
||||
<style></style>
|
||||
<style>
|
||||
#Content {
|
||||
padding-left: 1.5rem;
|
||||
padding-right: 1.5rem;
|
||||
padding-top: calc(env(safe-area-inset-top) + 1.5rem);
|
||||
padding-bottom: calc(env(safe-area-inset-bottom) + 1.5rem);
|
||||
}
|
||||
|
||||
#QuickNav ~ #Content {
|
||||
padding-bottom: calc(env(safe-area-inset-bottom) + 6rem);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -57,7 +57,7 @@
|
||||
class="w-full h-auto max-w-lg max-h-96 object-contain mx-auto drop-shadow-md"
|
||||
:src="record.image"
|
||||
alt="Activity image"
|
||||
@load="$emit('cacheImage', record.image)"
|
||||
@load="cacheImage(record.image)"
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
@@ -182,7 +182,7 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Prop, Vue } from "vue-facing-decorator";
|
||||
import { Component, Prop, Vue, Emit } from "vue-facing-decorator";
|
||||
import { GiveRecordWithContactInfo } from "../types";
|
||||
import EntityIcon from "./EntityIcon.vue";
|
||||
import { isGiveClaimType, notifyWhyCannotConfirm } from "../libs/util";
|
||||
@@ -202,6 +202,11 @@ export default class ActivityListItem extends Vue {
|
||||
@Prop() activeDid!: string;
|
||||
@Prop() confirmerIdList?: string[];
|
||||
|
||||
@Emit()
|
||||
cacheImage(image: string) {
|
||||
return image;
|
||||
}
|
||||
|
||||
get fetchAmount(): string {
|
||||
const claim =
|
||||
(this.record.fullClaim as unknown).claim || this.record.fullClaim;
|
||||
|
||||
@@ -99,6 +99,7 @@ import * as libsUtil from "../libs/util";
|
||||
import { db, retrieveSettingsForActiveAccount } from "../db/index";
|
||||
import { Contact } from "../db/tables/contacts";
|
||||
import { retrieveAccountDids } from "../libs/util";
|
||||
import { logger } from "../utils/logger";
|
||||
|
||||
@Component
|
||||
export default class GiftedDialog extends Vue {
|
||||
@@ -117,7 +118,6 @@ export default class GiftedDialog extends Vue {
|
||||
customTitle?: string;
|
||||
description = "";
|
||||
giver?: libsUtil.GiverReceiverInputInfo; // undefined means no identified giver agent
|
||||
isTrade = false;
|
||||
offerId = "";
|
||||
prompt = "";
|
||||
receiver?: libsUtil.GiverReceiverInputInfo;
|
||||
@@ -301,7 +301,7 @@ export default class GiftedDialog extends Vue {
|
||||
unitCode,
|
||||
this.toProjectId,
|
||||
this.offerId,
|
||||
this.isTrade,
|
||||
false,
|
||||
undefined,
|
||||
this.fromProjectId,
|
||||
);
|
||||
@@ -327,7 +327,7 @@ export default class GiftedDialog extends Vue {
|
||||
group: "alert",
|
||||
type: "success",
|
||||
title: "Success",
|
||||
text: `That ${this.isTrade ? "trade" : "gift"} was recorded.`,
|
||||
text: `That gift was recorded.`,
|
||||
},
|
||||
7000,
|
||||
);
|
||||
|
||||
@@ -1,108 +1,282 @@
|
||||
<template>
|
||||
<div v-if="visible" class="dialog-overlay z-[60]">
|
||||
<div class="dialog relative">
|
||||
<div class="text-lg text-center font-light relative z-50">
|
||||
<div class="text-lg text-center font-bold relative">
|
||||
<h1 id="ViewHeading" class="text-center font-bold">
|
||||
<span v-if="uploading">Uploading Image…</span>
|
||||
<span v-else-if="blob">Crop Image</span>
|
||||
<span v-else-if="showCameraPreview">Upload Image</span>
|
||||
<span v-else>Add Photo</span>
|
||||
</h1>
|
||||
<div
|
||||
id="ViewHeading"
|
||||
class="text-center font-bold absolute top-0 left-0 right-0 px-4 py-0.5 bg-black/50 text-white leading-none"
|
||||
>
|
||||
Add Photo
|
||||
</div>
|
||||
<div
|
||||
class="text-lg text-center px-2 py-0.5 leading-none absolute right-0 top-0 text-white"
|
||||
class="text-2xl text-center px-1 py-0.5 leading-none absolute -right-1 top-0"
|
||||
@click="close()"
|
||||
>
|
||||
<font-awesome icon="xmark" class="w-[1em]"></font-awesome>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="text-center mt-8">
|
||||
<div>
|
||||
<font-awesome
|
||||
icon="camera"
|
||||
class="bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-2 rounded-md"
|
||||
@click="openPhotoDialog()"
|
||||
/>
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<input type="file" @change="uploadImageFile" />
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<span class="mt-2">
|
||||
... or paste a URL:
|
||||
<input v-model="imageUrl" type="text" class="border-2" />
|
||||
</span>
|
||||
<span class="ml-2">
|
||||
<font-awesome
|
||||
<div class="mt-4">
|
||||
<template v-if="isRegistered">
|
||||
<div v-if="!blob">
|
||||
<div
|
||||
class="border-b border-dashed border-slate-300 text-orange-400 mb-4 font-bold text-sm"
|
||||
>
|
||||
<span class="block w-fit mx-auto -mb-2.5 bg-white px-2">
|
||||
Take a photo with your camera
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="showCameraPreview"
|
||||
class="camera-preview relative flex bg-black overflow-hidden mb-4"
|
||||
>
|
||||
<div class="camera-container w-full h-full relative">
|
||||
<video
|
||||
ref="videoElement"
|
||||
class="camera-video w-full h-full object-cover"
|
||||
autoplay
|
||||
playsinline
|
||||
muted
|
||||
></video>
|
||||
<button
|
||||
class="absolute bottom-4 left-1/2 -translate-x-1/2 bg-white text-slate-800 p-3 rounded-full text-2xl leading-none"
|
||||
@click="capturePhoto"
|
||||
>
|
||||
<font-awesome icon="camera" class="w-[1em]" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="border-b border-dashed border-slate-300 text-orange-400 mt-4 mb-4 font-bold text-sm"
|
||||
>
|
||||
<span class="block w-fit mx-auto -mb-2.5 bg-white px-2">
|
||||
OR choose a file from your device
|
||||
</span>
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<input
|
||||
type="file"
|
||||
class="w-full file:text-center file:bg-gradient-to-b file:from-slate-400 file:to-slate-700 file:shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] file:text-white file:px-3 file:py-2 file:rounded-md file:border-none file:cursor-pointer file:me-2"
|
||||
@change="uploadImageFile"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="border-b border-dashed border-slate-300 text-orange-400 mt-4 mb-4 font-bold text-sm"
|
||||
>
|
||||
<span class="block w-fit mx-auto -mb-2.5 bg-white px-2">
|
||||
OR paste an image URL
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 mt-4">
|
||||
<input
|
||||
v-model="imageUrl"
|
||||
type="text"
|
||||
class="block w-full rounded border border-slate-400 px-4 py-2"
|
||||
placeholder="https://example.com/image.jpg"
|
||||
/>
|
||||
<button
|
||||
v-if="imageUrl"
|
||||
icon="check"
|
||||
class="bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-2 rounded-md cursor-pointer"
|
||||
class="bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-3 py-2 rounded-md cursor-pointer"
|
||||
@click="acceptUrl"
|
||||
/>
|
||||
<!-- so that there's no shifting when it becomes visible -->
|
||||
<font-awesome
|
||||
v-else
|
||||
icon="check"
|
||||
class="text-white bg-white px-2 py-2"
|
||||
/>
|
||||
</span>
|
||||
>
|
||||
<font-awesome icon="check" class="fa-fw" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<div v-if="uploading" class="flex justify-center">
|
||||
<font-awesome
|
||||
icon="spinner"
|
||||
class="fa-spin fa-3x text-center block px-12 py-12"
|
||||
/>
|
||||
</div>
|
||||
<div v-else>
|
||||
<div v-if="crop">
|
||||
<VuePictureCropper
|
||||
:box-style="{
|
||||
backgroundColor: '#f8f8f8',
|
||||
margin: 'auto',
|
||||
}"
|
||||
:img="createBlobURL(blob)"
|
||||
:options="{
|
||||
viewMode: 1,
|
||||
dragMode: 'crop',
|
||||
aspectRatio: 1 / 1,
|
||||
}"
|
||||
class="max-h-[90vh] max-w-[90vw] object-contain"
|
||||
/>
|
||||
</div>
|
||||
<div v-else>
|
||||
<div class="flex justify-center">
|
||||
<img
|
||||
:src="createBlobURL(blob)"
|
||||
class="mt-2 rounded max-h-[90vh] max-w-[90vw] object-contain"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
:class="[
|
||||
'grid gap-2 mt-2',
|
||||
showRetry ? 'grid-cols-2' : 'grid-cols-1',
|
||||
]"
|
||||
>
|
||||
<button
|
||||
class="bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white py-2 px-3 rounded-md"
|
||||
@click="uploadImage"
|
||||
>
|
||||
<span>Upload</span>
|
||||
</button>
|
||||
<button
|
||||
v-if="showRetry"
|
||||
class="bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white py-2 px-3 rounded-md"
|
||||
@click="retryImage"
|
||||
>
|
||||
<span>Retry</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div
|
||||
id="noticeBeforeUpload"
|
||||
class="bg-amber-200 text-amber-900 border-amber-500 border-dashed border text-center rounded-md overflow-hidden px-4 py-3"
|
||||
role="alert"
|
||||
aria-live="polite"
|
||||
>
|
||||
<p class="mb-2">
|
||||
Before you can upload a photo, a friend needs to register you.
|
||||
</p>
|
||||
<router-link
|
||||
:to="{ name: 'contact-qr' }"
|
||||
class="inline-block text-md uppercase 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-4 py-2 rounded-md"
|
||||
>
|
||||
Share Your Info
|
||||
</router-link>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<PhotoDialog ref="photoDialog" />
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import axios from "axios";
|
||||
import { ref } from "vue";
|
||||
import { Component, Vue } from "vue-facing-decorator";
|
||||
|
||||
import PhotoDialog from "../components/PhotoDialog.vue";
|
||||
import { NotificationIface } from "../constants/app";
|
||||
import VuePictureCropper, { cropper } from "vue-picture-cropper";
|
||||
import { DEFAULT_IMAGE_API_SERVER, NotificationIface } from "../constants/app";
|
||||
import { retrieveSettingsForActiveAccount } from "../db/index";
|
||||
import { accessToken } from "../libs/crypto";
|
||||
import { logger } from "../utils/logger";
|
||||
import { PlatformServiceFactory } from "../services/PlatformServiceFactory";
|
||||
|
||||
const inputImageFileNameRef = ref<Blob>();
|
||||
|
||||
@Component({
|
||||
components: { PhotoDialog },
|
||||
components: { VuePictureCropper },
|
||||
props: {
|
||||
isRegistered: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
export default class ImageMethodDialog extends Vue {
|
||||
$notify!: (notification: NotificationIface, timeout?: number) => void;
|
||||
|
||||
claimType: string;
|
||||
/** Active DID for user authentication */
|
||||
activeDid = "";
|
||||
|
||||
/** Current image blob being processed */
|
||||
blob?: Blob;
|
||||
|
||||
/** Type of claim for the image */
|
||||
claimType: string = "";
|
||||
|
||||
/** Whether to show cropping interface */
|
||||
crop: boolean = false;
|
||||
|
||||
/** Name of the selected file */
|
||||
fileName?: string;
|
||||
|
||||
/** Callback function to set image URL after upload */
|
||||
imageCallback: (imageUrl?: string) => void = () => {};
|
||||
|
||||
/** URL for image input */
|
||||
imageUrl?: string;
|
||||
|
||||
/** Whether to show retry button */
|
||||
showRetry = true;
|
||||
|
||||
/** Upload progress state */
|
||||
uploading = false;
|
||||
|
||||
/** Dialog visibility state */
|
||||
visible = false;
|
||||
|
||||
/** Whether to show camera preview */
|
||||
showCameraPreview = false;
|
||||
|
||||
/** Camera stream reference */
|
||||
private cameraStream: MediaStream | null = null;
|
||||
|
||||
private platformService = PlatformServiceFactory.getInstance();
|
||||
URL = window.URL || window.webkitURL;
|
||||
|
||||
private platformCapabilities = this.platformService.getCapabilities();
|
||||
|
||||
/**
|
||||
* Lifecycle hook: Initializes component and retrieves user settings
|
||||
* @throws {Error} When settings retrieval fails
|
||||
*/
|
||||
async mounted() {
|
||||
console.log("ImageMethodDialog mounted");
|
||||
try {
|
||||
const settings = await retrieveSettingsForActiveAccount();
|
||||
this.activeDid = settings.activeDid || "";
|
||||
} catch (error: unknown) {
|
||||
logger.error("Error retrieving settings from database:", error);
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error",
|
||||
text:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "There was an error retrieving your settings.",
|
||||
},
|
||||
-1,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lifecycle hook: Cleans up camera stream when component is destroyed
|
||||
*/
|
||||
beforeDestroy() {
|
||||
this.stopCameraPreview();
|
||||
}
|
||||
|
||||
open(setImageFn: (arg: string) => void, claimType: string, crop?: boolean) {
|
||||
this.claimType = claimType;
|
||||
this.crop = !!crop;
|
||||
this.imageCallback = setImageFn;
|
||||
|
||||
this.visible = true;
|
||||
}
|
||||
|
||||
openPhotoDialog(blob?: Blob, fileName?: string) {
|
||||
this.visible = false;
|
||||
|
||||
(this.$refs.photoDialog as PhotoDialog).open(
|
||||
this.imageCallback,
|
||||
this.claimType,
|
||||
this.crop,
|
||||
blob,
|
||||
fileName,
|
||||
);
|
||||
// Start camera preview immediately if not on mobile
|
||||
if (!this.platformCapabilities.isMobile) {
|
||||
this.startCameraPreview();
|
||||
}
|
||||
}
|
||||
|
||||
async uploadImageFile(event: Event) {
|
||||
this.visible = false;
|
||||
const target = event.target as HTMLInputElement;
|
||||
if (!target.files) return;
|
||||
|
||||
inputImageFileNameRef.value = event.target.files[0];
|
||||
// https://developer.mozilla.org/en-US/docs/Web/API/File
|
||||
// ... plus it has a `type` property from my testing
|
||||
inputImageFileNameRef.value = target.files[0];
|
||||
const file = inputImageFileNameRef.value;
|
||||
if (file != null) {
|
||||
const reader = new FileReader();
|
||||
@@ -112,7 +286,9 @@ export default class ImageMethodDialog extends Vue {
|
||||
const blob = new Blob([new Uint8Array(data)], {
|
||||
type: file.type,
|
||||
});
|
||||
this.openPhotoDialog(blob, file.name as string);
|
||||
this.blob = blob;
|
||||
this.fileName = file.name;
|
||||
this.showRetry = false;
|
||||
}
|
||||
};
|
||||
reader.readAsArrayBuffer(file as Blob);
|
||||
@@ -120,21 +296,16 @@ export default class ImageMethodDialog extends Vue {
|
||||
}
|
||||
|
||||
async acceptUrl() {
|
||||
this.visible = false;
|
||||
if (this.crop) {
|
||||
try {
|
||||
const urlBlobResponse: Blob = await axios.get(this.imageUrl as string, {
|
||||
responseType: "blob", // This ensures the data is returned as a Blob
|
||||
const urlBlobResponse = await axios.get(this.imageUrl as string, {
|
||||
responseType: "blob",
|
||||
});
|
||||
const fullUrl = new URL(this.imageUrl as string);
|
||||
const fileName = fullUrl.pathname.split("/").pop() as string;
|
||||
(this.$refs.photoDialog as PhotoDialog).open(
|
||||
this.imageCallback,
|
||||
this.claimType,
|
||||
this.crop,
|
||||
urlBlobResponse.data as Blob,
|
||||
fileName,
|
||||
);
|
||||
this.blob = urlBlobResponse.data as Blob;
|
||||
this.fileName = fileName;
|
||||
this.showRetry = false;
|
||||
} catch (error) {
|
||||
this.$notify(
|
||||
{
|
||||
@@ -148,11 +319,219 @@ export default class ImageMethodDialog extends Vue {
|
||||
}
|
||||
} else {
|
||||
this.imageCallback(this.imageUrl);
|
||||
this.close();
|
||||
}
|
||||
}
|
||||
|
||||
close() {
|
||||
this.visible = false;
|
||||
this.stopCameraPreview();
|
||||
const bottomNav = document.querySelector("#QuickNav") as HTMLElement;
|
||||
if (bottomNav) {
|
||||
bottomNav.style.display = "";
|
||||
}
|
||||
this.blob = undefined;
|
||||
this.showCameraPreview = false;
|
||||
}
|
||||
|
||||
async startCameraPreview() {
|
||||
logger.debug("startCameraPreview called");
|
||||
logger.debug("Current showCameraPreview state:", this.showCameraPreview);
|
||||
logger.debug("Platform capabilities:", this.platformCapabilities);
|
||||
|
||||
if (this.platformCapabilities.isMobile) {
|
||||
logger.debug("Using platform service for mobile device");
|
||||
try {
|
||||
const result = await this.platformService.takePicture();
|
||||
this.blob = result.blob;
|
||||
this.fileName = result.fileName;
|
||||
} catch (error) {
|
||||
logger.error("Error taking picture:", error);
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error",
|
||||
text: "Failed to take picture. Please try again.",
|
||||
},
|
||||
5000,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
logger.debug("Starting camera preview for desktop browser");
|
||||
try {
|
||||
this.showCameraPreview = true;
|
||||
await this.$nextTick();
|
||||
|
||||
const stream = await navigator.mediaDevices.getUserMedia({
|
||||
video: { facingMode: "environment" },
|
||||
});
|
||||
this.cameraStream = stream;
|
||||
|
||||
await this.$nextTick();
|
||||
|
||||
const videoElement = this.$refs.videoElement as HTMLVideoElement;
|
||||
if (videoElement) {
|
||||
videoElement.srcObject = stream;
|
||||
await new Promise((resolve) => {
|
||||
videoElement.onloadedmetadata = () => {
|
||||
videoElement.play().then(() => {
|
||||
resolve(true);
|
||||
});
|
||||
};
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error("Error starting camera preview:", error);
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error",
|
||||
text: "Failed to access camera. Please try again.",
|
||||
},
|
||||
5000,
|
||||
);
|
||||
this.showCameraPreview = false;
|
||||
}
|
||||
}
|
||||
|
||||
stopCameraPreview() {
|
||||
if (this.cameraStream) {
|
||||
this.cameraStream.getTracks().forEach((track) => track.stop());
|
||||
this.cameraStream = null;
|
||||
}
|
||||
this.showCameraPreview = false;
|
||||
}
|
||||
|
||||
async capturePhoto() {
|
||||
if (!this.cameraStream) return;
|
||||
|
||||
try {
|
||||
const videoElement = this.$refs.videoElement as HTMLVideoElement;
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = videoElement.videoWidth;
|
||||
canvas.height = videoElement.videoHeight;
|
||||
const ctx = canvas.getContext("2d");
|
||||
ctx?.drawImage(videoElement, 0, 0, canvas.width, canvas.height);
|
||||
|
||||
canvas.toBlob(
|
||||
(blob) => {
|
||||
if (blob) {
|
||||
this.blob = blob;
|
||||
this.fileName = `photo_${Date.now()}.jpg`;
|
||||
this.showRetry = true;
|
||||
this.stopCameraPreview();
|
||||
}
|
||||
},
|
||||
"image/jpeg",
|
||||
0.95,
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error("Error capturing photo:", error);
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error",
|
||||
text: "Failed to capture photo. Please try again.",
|
||||
},
|
||||
5000,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private createBlobURL(blob: Blob): string {
|
||||
return URL.createObjectURL(blob);
|
||||
}
|
||||
|
||||
async retryImage() {
|
||||
this.blob = undefined;
|
||||
if (!this.platformCapabilities.isMobile) {
|
||||
await this.startCameraPreview();
|
||||
}
|
||||
}
|
||||
|
||||
async uploadImage() {
|
||||
this.uploading = true;
|
||||
|
||||
if (this.crop) {
|
||||
this.blob = (await cropper?.getBlob()) || undefined;
|
||||
}
|
||||
|
||||
const token = await accessToken(this.activeDid);
|
||||
const headers = {
|
||||
Authorization: "Bearer " + token,
|
||||
};
|
||||
const formData = new FormData();
|
||||
if (!this.blob) {
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error",
|
||||
text: "There was an error finding the picture. Please try again.",
|
||||
},
|
||||
5000,
|
||||
);
|
||||
this.uploading = false;
|
||||
return;
|
||||
}
|
||||
formData.append("image", this.blob, this.fileName || "photo.jpg");
|
||||
formData.append("claimType", this.claimType);
|
||||
try {
|
||||
if (
|
||||
window.location.hostname === "localhost" &&
|
||||
!DEFAULT_IMAGE_API_SERVER.includes("localhost")
|
||||
) {
|
||||
logger.log(
|
||||
"Using shared image API server, so only users on that server can play with images.",
|
||||
);
|
||||
}
|
||||
const response = await axios.post(
|
||||
DEFAULT_IMAGE_API_SERVER + "/image",
|
||||
formData,
|
||||
{ headers },
|
||||
);
|
||||
this.uploading = false;
|
||||
|
||||
this.close();
|
||||
this.imageCallback(response.data.url as string);
|
||||
} catch (error: unknown) {
|
||||
let errorMessage = "There was an error saving the picture.";
|
||||
|
||||
if (axios.isAxiosError(error)) {
|
||||
const status = error.response?.status;
|
||||
const data = error.response?.data;
|
||||
|
||||
if (status === 401) {
|
||||
errorMessage = "Authentication failed. Please try logging in again.";
|
||||
} else if (status === 413) {
|
||||
errorMessage = "Image file is too large. Please try a smaller image.";
|
||||
} else if (status === 415) {
|
||||
errorMessage =
|
||||
"Unsupported image format. Please try a different image.";
|
||||
} else if (status && status >= 500) {
|
||||
errorMessage = "Server error. Please try again later.";
|
||||
} else if (data?.message) {
|
||||
errorMessage = data.message;
|
||||
}
|
||||
}
|
||||
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error",
|
||||
text: errorMessage,
|
||||
},
|
||||
5000,
|
||||
);
|
||||
this.uploading = false;
|
||||
this.blob = undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -178,5 +557,9 @@ export default class ImageMethodDialog extends Vue {
|
||||
border-radius: 0.5rem;
|
||||
width: 100%;
|
||||
max-width: 700px;
|
||||
max-height: 90vh;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -40,6 +40,7 @@
|
||||
<script lang="ts">
|
||||
import { Component, Vue, Prop } from "vue-facing-decorator";
|
||||
import { UAParser } from "ua-parser-js";
|
||||
import { logger } from "../utils/logger";
|
||||
|
||||
@Component({ emits: ["update:isOpen"] })
|
||||
export default class ImageViewer extends Vue {
|
||||
|
||||
@@ -1,18 +1,30 @@
|
||||
/** * PhotoDialog.vue - Cross-platform photo capture and selection component * *
|
||||
This component provides a unified interface for taking photos and selecting
|
||||
images * across different platforms (web, mobile) using the PlatformService. It
|
||||
supports: * - Taking photos using device camera * - Selecting images from device
|
||||
gallery * - Image cropping functionality * - Image upload to server * - Error
|
||||
handling and user feedback * * Features: * - Responsive design with mobile-first
|
||||
approach * - Cross-platform compatibility through PlatformService * - Image
|
||||
cropping with aspect ratio control * - Progress feedback during upload * -
|
||||
Comprehensive error handling * * @author Matthew Raymer * @version 1.0.0 * @file
|
||||
PhotoDialog.vue */
|
||||
|
||||
<template>
|
||||
<div v-if="visible" class="dialog-overlay z-[60]">
|
||||
<div class="dialog relative">
|
||||
<div class="text-lg text-center font-light relative z-50">
|
||||
<div
|
||||
id="ViewHeading"
|
||||
class="text-center font-bold absolute top-0 left-0 right-0 px-4 py-0.5 bg-black/50 text-white leading-none"
|
||||
class="text-center font-bold absolute top-0 inset-x-0 px-4 py-2 bg-black/50 text-white leading-none pointer-events-none"
|
||||
>
|
||||
<span v-if="uploading"> Uploading... </span>
|
||||
<span v-else-if="blob"> Look Good? </span>
|
||||
<span v-else-if="showCameraPreview"> Take Photo </span>
|
||||
<span v-else> Say "Cheese"! </span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="text-lg text-center px-2 py-0.5 leading-none absolute right-0 top-0 text-white"
|
||||
class="text-lg text-center px-2 py-2 leading-none absolute right-0 top-0 text-white cursor-pointer"
|
||||
@click="close()"
|
||||
>
|
||||
<font-awesome icon="xmark" class="w-[1em]"></font-awesome>
|
||||
@@ -36,7 +48,7 @@
|
||||
:options="{
|
||||
viewMode: 1,
|
||||
dragMode: 'crop',
|
||||
aspectRatio: 9 / 9,
|
||||
aspectRatio: 1 / 1,
|
||||
}"
|
||||
class="max-h-[90vh] max-w-[90vw] object-contain"
|
||||
/>
|
||||
@@ -49,31 +61,45 @@
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="absolute bottom-[1rem] left-[1rem] px-2 py-1">
|
||||
<div class="grid grid-cols-2 gap-2 mt-2">
|
||||
<button
|
||||
class="bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white py-1 px-2 rounded-md"
|
||||
class="bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white py-2 px-3 rounded-md"
|
||||
@click="uploadImage"
|
||||
>
|
||||
<span>Upload</span>
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
v-if="showRetry"
|
||||
class="absolute bottom-[1rem] right-[1rem] px-2 py-1"
|
||||
>
|
||||
<button
|
||||
class="bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white py-1 px-2 rounded-md"
|
||||
v-if="showRetry"
|
||||
class="bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white py-2 px-3 rounded-md"
|
||||
@click="retryImage"
|
||||
>
|
||||
<span>Retry</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="showCameraPreview" class="camera-preview">
|
||||
<div class="camera-container">
|
||||
<video
|
||||
ref="videoElement"
|
||||
class="camera-video"
|
||||
autoplay
|
||||
playsinline
|
||||
muted
|
||||
></video>
|
||||
<button
|
||||
class="absolute bottom-4 left-1/2 -translate-x-1/2 bg-white text-slate-800 p-3 rounded-full text-2xl leading-none"
|
||||
@click="capturePhoto"
|
||||
>
|
||||
<font-awesome icon="camera" class="w-[1em]" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else>
|
||||
<div class="flex flex-col items-center justify-center gap-4 p-4">
|
||||
<button
|
||||
v-if="isRegistered"
|
||||
class="bg-blue-500 hover:bg-blue-700 text-white font-bold p-3 rounded-full text-2xl leading-none"
|
||||
@click="takePhoto"
|
||||
@click="startCameraPreview"
|
||||
>
|
||||
<font-awesome icon="camera" class="w-[1em]" />
|
||||
</button>
|
||||
@@ -90,16 +116,6 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
/**
|
||||
* PhotoDialog.vue - Cross-platform photo capture and selection component
|
||||
*
|
||||
* This component provides a unified interface for taking photos and selecting images
|
||||
* across different platforms using the PlatformService.
|
||||
*
|
||||
* @author Matthew Raymer
|
||||
* @file PhotoDialog.vue
|
||||
*/
|
||||
|
||||
import axios from "axios";
|
||||
import { Component, Vue } from "vue-facing-decorator";
|
||||
import VuePictureCropper, { cropper } from "vue-picture-cropper";
|
||||
@@ -113,38 +129,89 @@ import { PlatformServiceFactory } from "../services/PlatformServiceFactory";
|
||||
export default class PhotoDialog extends Vue {
|
||||
$notify!: (notification: NotificationIface, timeout?: number) => void;
|
||||
|
||||
/** Active DID for user authentication */
|
||||
activeDid = "";
|
||||
|
||||
/** Current image blob being processed */
|
||||
blob?: Blob;
|
||||
|
||||
/** Type of claim for the image */
|
||||
claimType = "";
|
||||
|
||||
/** Whether to show cropping interface */
|
||||
crop = false;
|
||||
|
||||
/** Name of the selected file */
|
||||
fileName?: string;
|
||||
|
||||
/** Callback function to set image URL after upload */
|
||||
setImageCallback: (arg: string) => void = () => {};
|
||||
|
||||
/** Whether to show retry button */
|
||||
showRetry = true;
|
||||
|
||||
/** Upload progress state */
|
||||
uploading = false;
|
||||
|
||||
/** Dialog visibility state */
|
||||
visible = false;
|
||||
|
||||
/** Whether to show camera preview */
|
||||
showCameraPreview = false;
|
||||
|
||||
/** Camera stream reference */
|
||||
private cameraStream: MediaStream | null = null;
|
||||
|
||||
private platformService = PlatformServiceFactory.getInstance();
|
||||
URL = window.URL || window.webkitURL;
|
||||
|
||||
isRegistered = false;
|
||||
private platformCapabilities = this.platformService.getCapabilities();
|
||||
|
||||
/**
|
||||
* Lifecycle hook: Initializes component and retrieves user settings
|
||||
* @throws {Error} When settings retrieval fails
|
||||
*/
|
||||
async mounted() {
|
||||
logger.log("PhotoDialog mounted");
|
||||
try {
|
||||
const settings = await retrieveSettingsForActiveAccount();
|
||||
this.activeDid = settings.activeDid || "";
|
||||
} catch (err: unknown) {
|
||||
logger.error("Error retrieving settings from database:", err);
|
||||
this.isRegistered = !!settings.isRegistered;
|
||||
logger.log("isRegistered:", this.isRegistered);
|
||||
} catch (error: unknown) {
|
||||
logger.error("Error retrieving settings from database:", error);
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error",
|
||||
text: err.message || "There was an error retrieving your settings.",
|
||||
text:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "There was an error retrieving your settings.",
|
||||
},
|
||||
-1,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
open(
|
||||
/**
|
||||
* Lifecycle hook: Cleans up camera stream when component is destroyed
|
||||
*/
|
||||
beforeDestroy() {
|
||||
this.stopCameraPreview();
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the photo dialog with specified configuration
|
||||
* @param setImageFn - Callback function to handle image URL after upload
|
||||
* @param claimType - Type of claim for the image
|
||||
* @param crop - Whether to enable cropping
|
||||
* @param blob - Optional existing image blob
|
||||
* @param inputFileName - Optional filename for the image
|
||||
*/
|
||||
async open(
|
||||
setImageFn: (arg: string) => void,
|
||||
claimType: string,
|
||||
crop?: boolean,
|
||||
@@ -167,11 +234,23 @@ export default class PhotoDialog extends Vue {
|
||||
this.blob = undefined;
|
||||
this.fileName = undefined;
|
||||
this.showRetry = true;
|
||||
// Start camera preview automatically if no blob is provided
|
||||
if (!this.platformCapabilities.isMobile) {
|
||||
await this.startCameraPreview();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes the photo dialog and resets state
|
||||
*/
|
||||
close() {
|
||||
logger.debug(
|
||||
"Dialog closing, current showCameraPreview:",
|
||||
this.showCameraPreview,
|
||||
);
|
||||
this.visible = false;
|
||||
this.stopCameraPreview();
|
||||
const bottomNav = document.querySelector("#QuickNav") as HTMLElement;
|
||||
if (bottomNav) {
|
||||
bottomNav.style.display = "";
|
||||
@@ -179,6 +258,158 @@ export default class PhotoDialog extends Vue {
|
||||
this.blob = undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts the camera preview
|
||||
*/
|
||||
async startCameraPreview() {
|
||||
logger.debug("startCameraPreview called");
|
||||
logger.debug("Current showCameraPreview state:", this.showCameraPreview);
|
||||
logger.debug("Platform capabilities:", this.platformCapabilities);
|
||||
|
||||
// If we're on a mobile device or using Capacitor, use the platform service
|
||||
if (this.platformCapabilities.isMobile) {
|
||||
logger.debug("Using platform service for mobile device");
|
||||
try {
|
||||
const result = await this.platformService.takePicture();
|
||||
this.blob = result.blob;
|
||||
this.fileName = result.fileName;
|
||||
} catch (error) {
|
||||
logger.error("Error taking picture:", error);
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error",
|
||||
text: "Failed to take picture. Please try again.",
|
||||
},
|
||||
5000,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// For desktop web browsers, use our custom preview
|
||||
logger.debug("Starting camera preview for desktop browser");
|
||||
try {
|
||||
// Set state before requesting camera access
|
||||
this.showCameraPreview = true;
|
||||
logger.debug("showCameraPreview set to:", this.showCameraPreview);
|
||||
|
||||
// Force a re-render
|
||||
await this.$nextTick();
|
||||
logger.debug(
|
||||
"After nextTick, showCameraPreview is:",
|
||||
this.showCameraPreview,
|
||||
);
|
||||
|
||||
logger.debug("Requesting camera access...");
|
||||
const stream = await navigator.mediaDevices.getUserMedia({
|
||||
video: { facingMode: "environment" },
|
||||
});
|
||||
logger.debug("Camera access granted, setting up video element");
|
||||
this.cameraStream = stream;
|
||||
|
||||
// Force another re-render after getting the stream
|
||||
await this.$nextTick();
|
||||
logger.debug(
|
||||
"After getting stream, showCameraPreview is:",
|
||||
this.showCameraPreview,
|
||||
);
|
||||
|
||||
const videoElement = this.$refs.videoElement as HTMLVideoElement;
|
||||
if (videoElement) {
|
||||
logger.debug("Video element found, setting srcObject");
|
||||
videoElement.srcObject = stream;
|
||||
// Wait for video to be ready
|
||||
await new Promise((resolve) => {
|
||||
videoElement.onloadedmetadata = () => {
|
||||
logger.debug("Video metadata loaded");
|
||||
videoElement.play().then(() => {
|
||||
logger.debug("Video playback started");
|
||||
resolve(true);
|
||||
});
|
||||
};
|
||||
});
|
||||
} else {
|
||||
logger.error("Video element not found");
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error("Error starting camera preview:", error);
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error",
|
||||
text: "Failed to access camera. Please try again.",
|
||||
},
|
||||
5000,
|
||||
);
|
||||
this.showCameraPreview = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops the camera preview and cleans up resources
|
||||
*/
|
||||
stopCameraPreview() {
|
||||
logger.debug(
|
||||
"Stopping camera preview, current showCameraPreview:",
|
||||
this.showCameraPreview,
|
||||
);
|
||||
if (this.cameraStream) {
|
||||
this.cameraStream.getTracks().forEach((track) => track.stop());
|
||||
this.cameraStream = null;
|
||||
}
|
||||
this.showCameraPreview = false;
|
||||
logger.debug(
|
||||
"After stopping, showCameraPreview is:",
|
||||
this.showCameraPreview,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Captures a photo from the camera preview
|
||||
*/
|
||||
async capturePhoto() {
|
||||
if (!this.cameraStream) return;
|
||||
|
||||
try {
|
||||
const videoElement = this.$refs.videoElement as HTMLVideoElement;
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = videoElement.videoWidth;
|
||||
canvas.height = videoElement.videoHeight;
|
||||
const ctx = canvas.getContext("2d");
|
||||
ctx?.drawImage(videoElement, 0, 0, canvas.width, canvas.height);
|
||||
|
||||
canvas.toBlob(
|
||||
(blob) => {
|
||||
if (blob) {
|
||||
this.blob = blob;
|
||||
this.fileName = `photo_${Date.now()}.jpg`;
|
||||
this.stopCameraPreview();
|
||||
}
|
||||
},
|
||||
"image/jpeg",
|
||||
0.95,
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error("Error capturing photo:", error);
|
||||
this.$notify(
|
||||
{
|
||||
group: "alert",
|
||||
type: "danger",
|
||||
title: "Error",
|
||||
text: "Failed to capture photo. Please try again.",
|
||||
},
|
||||
5000,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Captures a photo using device camera
|
||||
* @throws {Error} When camera access fails
|
||||
*/
|
||||
async takePhoto() {
|
||||
try {
|
||||
const result = await this.platformService.takePicture();
|
||||
@@ -198,6 +429,10 @@ export default class PhotoDialog extends Vue {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Selects an image from device gallery
|
||||
* @throws {Error} When gallery access fails
|
||||
*/
|
||||
async pickPhoto() {
|
||||
try {
|
||||
const result = await this.platformService.pickImage();
|
||||
@@ -217,14 +452,30 @@ export default class PhotoDialog extends Vue {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a blob URL for image preview
|
||||
* @param blob - Image blob to create URL for
|
||||
* @returns {string} Blob URL for the image
|
||||
*/
|
||||
private createBlobURL(blob: Blob): string {
|
||||
return URL.createObjectURL(blob);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resets the current image selection and restarts camera preview
|
||||
*/
|
||||
async retryImage() {
|
||||
this.blob = undefined;
|
||||
if (!this.platformCapabilities.isMobile) {
|
||||
await this.startCameraPreview();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Uploads the current image to the server
|
||||
* Handles cropping if enabled and manages upload state
|
||||
* @throws {Error} When upload fails or server returns error
|
||||
*/
|
||||
async uploadImage() {
|
||||
this.uploading = true;
|
||||
|
||||
@@ -339,6 +590,7 @@ export default class PhotoDialog extends Vue {
|
||||
</script>
|
||||
|
||||
<style>
|
||||
/* Dialog overlay styling */
|
||||
.dialog-overlay {
|
||||
z-index: 60;
|
||||
position: fixed;
|
||||
@@ -353,11 +605,50 @@ export default class PhotoDialog extends Vue {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
/* Dialog container styling */
|
||||
.dialog {
|
||||
background-color: white;
|
||||
padding: 1rem;
|
||||
border-radius: 0.5rem;
|
||||
width: 100%;
|
||||
max-width: 700px;
|
||||
max-height: 90vh;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Camera preview styling */
|
||||
.camera-preview {
|
||||
flex: 1;
|
||||
background-color: #000;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.camera-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.camera-video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.capture-button {
|
||||
position: absolute;
|
||||
bottom: 1rem;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: linear-gradient(to bottom, #60a5fa, #2563eb);
|
||||
color: white;
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-radius: 9999px;
|
||||
box-shadow: inset 0 -1px 0 0 rgba(0, 0, 0, 0.5);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
<template>
|
||||
<!-- QUICK NAV -->
|
||||
<nav id="QuickNav" class="fixed bottom-0 left-0 right-0 bg-slate-200 z-50">
|
||||
<nav
|
||||
id="QuickNav"
|
||||
class="fixed bottom-0 left-0 right-0 bg-slate-200 z-50 pb-[env(safe-area-inset-bottom)]"
|
||||
>
|
||||
<ul class="flex text-2xl px-6 py-2 gap-1 max-w-3xl mx-auto">
|
||||
<!-- Home Feed -->
|
||||
<li
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="absolute right-5 top-3">
|
||||
<div class="absolute right-5 top-[calc(env(safe-area-inset-top)+0.75rem)]">
|
||||
<span class="align-center text-red-500 mr-2">{{ message }}</span>
|
||||
<span class="ml-2">
|
||||
<router-link
|
||||
|
||||
187
src/electron/main.ts
Normal file
187
src/electron/main.ts
Normal file
@@ -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);
|
||||
});
|
||||
4
src/env.d.ts
vendored
Normal file
4
src/env.d.ts
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare const __USE_QR_READER__: boolean;
|
||||
declare const __IS_MOBILE__: boolean;
|
||||
@@ -1,11 +1,106 @@
|
||||
/**
|
||||
* @file Deep Link Interface Definitions
|
||||
* @file Deep Link Type Definitions and Validation Schemas
|
||||
* @author Matthew Raymer
|
||||
*
|
||||
* Defines the core interfaces for the deep linking system.
|
||||
* These interfaces are used across the deep linking implementation
|
||||
* to ensure type safety and consistent error handling.
|
||||
* 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({
|
||||
did: z.string(),
|
||||
}),
|
||||
};
|
||||
|
||||
export type DeepLinkParams = {
|
||||
[K in keyof typeof deepLinkSchemas]: z.infer<(typeof deepLinkSchemas)[K]>;
|
||||
};
|
||||
|
||||
export interface DeepLinkError extends Error {
|
||||
code: string;
|
||||
|
||||
21
src/interfaces/give.ts
Normal file
21
src/interfaces/give.ts
Normal file
@@ -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;
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
createEndorserJwtForDid,
|
||||
CONTACT_URL_PATH_ENDORSER_CH_OLD,
|
||||
CONTACT_IMPORT_ONE_URL_PATH_TIME_SAFARI,
|
||||
CONTACT_CONFIRM_URL_PATH_TIME_SAFARI,
|
||||
} from "../../libs/endorserServer";
|
||||
import { DEFAULT_DID_PROVIDER_NAME } from "../veramo/setup";
|
||||
import { logger } from "../../utils/logger";
|
||||
@@ -104,34 +105,41 @@ export const accessToken = async (did?: string) => {
|
||||
};
|
||||
|
||||
/**
|
||||
@return payload of JWT pulled out of any recognized URL path (if any)
|
||||
* Extract JWT from various URL formats
|
||||
* @param jwtUrlText The URL containing the JWT
|
||||
* @returns The extracted JWT or null if not found
|
||||
*/
|
||||
export const getContactJwtFromJwtUrl = (jwtUrlText: string) => {
|
||||
let jwtText = jwtUrlText;
|
||||
const appImportConfirmUrlLoc = jwtText.indexOf(
|
||||
CONTACT_IMPORT_CONFIRM_URL_PATH_TIME_SAFARI,
|
||||
);
|
||||
if (appImportConfirmUrlLoc > -1) {
|
||||
jwtText = jwtText.substring(
|
||||
appImportConfirmUrlLoc +
|
||||
CONTACT_IMPORT_CONFIRM_URL_PATH_TIME_SAFARI.length,
|
||||
);
|
||||
try {
|
||||
let jwtText = jwtUrlText;
|
||||
|
||||
// Try to extract JWT from URL paths
|
||||
const paths = [
|
||||
CONTACT_CONFIRM_URL_PATH_TIME_SAFARI,
|
||||
CONTACT_IMPORT_CONFIRM_URL_PATH_TIME_SAFARI,
|
||||
CONTACT_IMPORT_ONE_URL_PATH_TIME_SAFARI,
|
||||
CONTACT_URL_PATH_ENDORSER_CH_OLD,
|
||||
];
|
||||
|
||||
for (const path of paths) {
|
||||
const pathIndex = jwtText.indexOf(path);
|
||||
if (pathIndex > -1) {
|
||||
jwtText = jwtText.substring(pathIndex + path.length);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Validate JWT format
|
||||
if (!jwtText.match(/^[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+\.[A-Za-z0-9-_]*$/)) {
|
||||
logger.error("Invalid JWT format in URL:", jwtUrlText);
|
||||
return null;
|
||||
}
|
||||
|
||||
return jwtText;
|
||||
} catch (error) {
|
||||
logger.error("Error extracting JWT from URL:", error);
|
||||
return null;
|
||||
}
|
||||
const appImportOneUrlLoc = jwtText.indexOf(
|
||||
CONTACT_IMPORT_ONE_URL_PATH_TIME_SAFARI,
|
||||
);
|
||||
if (appImportOneUrlLoc > -1) {
|
||||
jwtText = jwtText.substring(
|
||||
appImportOneUrlLoc + CONTACT_IMPORT_ONE_URL_PATH_TIME_SAFARI.length,
|
||||
);
|
||||
}
|
||||
const endorserUrlPathLoc = jwtText.indexOf(CONTACT_URL_PATH_ENDORSER_CH_OLD);
|
||||
if (endorserUrlPathLoc > -1) {
|
||||
jwtText = jwtText.substring(
|
||||
endorserUrlPathLoc + CONTACT_URL_PATH_ENDORSER_CH_OLD.length,
|
||||
);
|
||||
}
|
||||
return jwtText;
|
||||
};
|
||||
|
||||
export const nextDerivationPath = (origDerivPath: string) => {
|
||||
|
||||
@@ -86,6 +86,12 @@ export const CONTACT_IMPORT_ONE_URL_PATH_TIME_SAFARI = "/contacts?contactJwt=";
|
||||
*/
|
||||
export const CONTACT_URL_PATH_ENDORSER_CH_OLD = "/contact?jwt=";
|
||||
|
||||
/**
|
||||
* URL path suffix for contact confirmation
|
||||
* @constant {string}
|
||||
*/
|
||||
export const CONTACT_CONFIRM_URL_PATH_TIME_SAFARI = "/contact/confirm/";
|
||||
|
||||
/**
|
||||
* The prefix for handle IDs, the permanent ID for claims on Endorser
|
||||
* @constant {string}
|
||||
@@ -644,7 +650,7 @@ export function hydrateGive(
|
||||
unitCode?: string,
|
||||
fulfillsProjectHandleId?: string,
|
||||
fulfillsOfferHandleId?: string,
|
||||
isTrade: boolean = false,
|
||||
isTrade: boolean = false, // remove, because this app is all for gifting
|
||||
imageUrl?: string,
|
||||
providerPlanHandleId?: string,
|
||||
lastClaimId?: string,
|
||||
@@ -731,7 +737,7 @@ export async function createAndSubmitGive(
|
||||
unitCode?: string,
|
||||
fulfillsProjectHandleId?: string,
|
||||
fulfillsOfferHandleId?: string,
|
||||
isTrade: boolean = false,
|
||||
isTrade: boolean = false, // remove, because this app is all for gifting
|
||||
imageUrl?: string,
|
||||
providerPlanHandleId?: string,
|
||||
): Promise<CreateAndSubmitClaimResult> {
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
faBurst,
|
||||
faCalendar,
|
||||
faCamera,
|
||||
faCameraRotate,
|
||||
faCaretDown,
|
||||
faChair,
|
||||
faCheck,
|
||||
@@ -53,6 +54,7 @@ import {
|
||||
faHandHoldingDollar,
|
||||
faHandHoldingHeart,
|
||||
faHouseChimney,
|
||||
faImage,
|
||||
faImagePortrait,
|
||||
faLeftRight,
|
||||
faLightbulb,
|
||||
@@ -97,6 +99,7 @@ library.add(
|
||||
faBurst,
|
||||
faCalendar,
|
||||
faCamera,
|
||||
faCameraRotate,
|
||||
faCaretDown,
|
||||
faChair,
|
||||
faCheck,
|
||||
@@ -133,6 +136,7 @@ library.add(
|
||||
faHandHoldingDollar,
|
||||
faHandHoldingHeart,
|
||||
faHouseChimney,
|
||||
faImage,
|
||||
faImagePortrait,
|
||||
faLeftRight,
|
||||
faLightbulb,
|
||||
@@ -29,7 +29,7 @@
|
||||
*/
|
||||
|
||||
import { initializeApp } from "./main.common";
|
||||
import { App } from "./lib/capacitor/app";
|
||||
import { App } from "./libs/capacitor/app";
|
||||
import router from "./router";
|
||||
import { handleApiError } from "./services/api";
|
||||
import { AxiosError } from "axios";
|
||||
|
||||
@@ -6,7 +6,7 @@ import axios from "axios";
|
||||
import VueAxios from "vue-axios";
|
||||
import Notifications from "notiwind";
|
||||
import "./assets/styles/tailwind.css";
|
||||
import { FontAwesomeIcon } from "./lib/fontawesome";
|
||||
import { FontAwesomeIcon } from "./libs/fontawesome";
|
||||
import Camera from "simple-vue-camera";
|
||||
import { logger } from "./utils/logger";
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
faBurst,
|
||||
faCalendar,
|
||||
faCamera,
|
||||
faCameraRotate,
|
||||
faCaretDown,
|
||||
faChair,
|
||||
faCheck,
|
||||
@@ -101,6 +102,7 @@ library.add(
|
||||
faBurst,
|
||||
faCalendar,
|
||||
faCamera,
|
||||
faCameraRotate,
|
||||
faCaretDown,
|
||||
faChair,
|
||||
faCheck,
|
||||
|
||||
@@ -87,6 +87,11 @@ const routes: Array<RouteRecordRaw> = [
|
||||
name: "contact-qr",
|
||||
component: () => import("../views/ContactQRScanShowView.vue"),
|
||||
},
|
||||
{
|
||||
path: "/contact-qr-scan",
|
||||
name: "contact-qr-scan",
|
||||
component: () => import("../views/ContactQRScanView.vue"),
|
||||
},
|
||||
{
|
||||
path: "/contacts",
|
||||
name: "contacts",
|
||||
|
||||
210
src/services/QRScanner/CapacitorQRScanner.ts
Normal file
210
src/services/QRScanner/CapacitorQRScanner.ts
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
100
src/services/QRScanner/QRScannerFactory.ts
Normal file
100
src/services/QRScanner/QRScannerFactory.ts
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
608
src/services/QRScanner/WebInlineQRScanner.ts
Normal file
608
src/services/QRScanner/WebInlineQRScanner.ts
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
69
src/services/QRScanner/types.ts
Normal file
69
src/services/QRScanner/types.ts
Normal file
@@ -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>;
|
||||
}
|
||||
@@ -7,7 +7,7 @@
|
||||
*
|
||||
* Architecture:
|
||||
* 1. DeepLinkHandler class encapsulates all deep link processing logic
|
||||
* 2. Uses Zod schemas from types/deepLinks for parameter validation
|
||||
* 2. Uses Zod schemas from interfaces/deepLinks for parameter validation
|
||||
* 3. Provides consistent error handling and logging
|
||||
* 4. Maps validated parameters to Vue router calls
|
||||
*
|
||||
@@ -51,7 +51,7 @@ import {
|
||||
baseUrlSchema,
|
||||
routeSchema,
|
||||
DeepLinkRoute,
|
||||
} from "../types/deepLinks";
|
||||
} from "../interfaces/deepLinks";
|
||||
import { logConsoleAndDb } from "../db";
|
||||
import type { DeepLinkError } from "../interfaces/deepLinks";
|
||||
|
||||
@@ -70,6 +70,31 @@ export class DeepLinkHandler {
|
||||
this.router = router;
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps deep link routes to their corresponding Vue router names and optional parameter keys.
|
||||
*
|
||||
* The paramKey is used to extract the parameter from the route path,
|
||||
* because "router.replace" expects the right parameter name for the route.
|
||||
* The default is "id".
|
||||
*/
|
||||
private readonly ROUTE_MAP: Record<
|
||||
string,
|
||||
{ name: string; paramKey?: string }
|
||||
> = {
|
||||
"user-profile": { name: "user-profile" },
|
||||
"project-details": { name: "project-details" },
|
||||
"onboard-meeting-setup": { name: "onboard-meeting-setup" },
|
||||
"invite-one-accept": { name: "invite-one-accept" },
|
||||
"contact-import": { name: "contact-import" },
|
||||
"confirm-gift": { name: "confirm-gift" },
|
||||
claim: { name: "claim" },
|
||||
"claim-cert": { name: "claim-cert" },
|
||||
"claim-add-raw": { name: "claim-add-raw" },
|
||||
"contact-edit": { name: "contact-edit", paramKey: "did" },
|
||||
contacts: { name: "contacts" },
|
||||
did: { name: "did", paramKey: "did" },
|
||||
};
|
||||
|
||||
/**
|
||||
* Parses deep link URL into path, params and query components.
|
||||
* Validates URL structure using Zod schemas.
|
||||
@@ -101,11 +126,15 @@ export class DeepLinkHandler {
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
path: routePath,
|
||||
params: param ? { id: param } : {},
|
||||
query,
|
||||
};
|
||||
const params: Record<string, string> = {};
|
||||
if (param) {
|
||||
if (this.ROUTE_MAP[routePath].paramKey) {
|
||||
params[this.ROUTE_MAP[routePath].paramKey] = param;
|
||||
} else {
|
||||
params["id"] = param;
|
||||
}
|
||||
}
|
||||
return { path: routePath, params, query };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -153,28 +182,13 @@ export class DeepLinkHandler {
|
||||
params: Record<string, string>,
|
||||
query: Record<string, string>,
|
||||
): Promise<void> {
|
||||
const routeMap: Record<string, string> = {
|
||||
"user-profile": "user-profile",
|
||||
"project-details": "project-details",
|
||||
"onboard-meeting-setup": "onboard-meeting-setup",
|
||||
"invite-one-accept": "invite-one-accept",
|
||||
"contact-import": "contact-import",
|
||||
"confirm-gift": "confirm-gift",
|
||||
claim: "claim",
|
||||
"claim-cert": "claim-cert",
|
||||
"claim-add-raw": "claim-add-raw",
|
||||
"contact-edit": "contact-edit",
|
||||
contacts: "contacts",
|
||||
did: "did",
|
||||
};
|
||||
|
||||
// First try to validate the route path
|
||||
let routeName: string;
|
||||
|
||||
try {
|
||||
// Validate route exists
|
||||
const validRoute = routeSchema.parse(path) as DeepLinkRoute;
|
||||
routeName = routeMap[validRoute];
|
||||
routeName = this.ROUTE_MAP[validRoute].name;
|
||||
} catch (error) {
|
||||
// Log the invalid route attempt
|
||||
logConsoleAndDb(`[DeepLink] Invalid route path: ${path}`, true);
|
||||
|
||||
@@ -318,12 +318,12 @@ export class CapacitorPlatformService implements PlatformService {
|
||||
async writeAndShareFile(fileName: string, content: string): Promise<void> {
|
||||
const timestamp = new Date().toISOString();
|
||||
const logData = {
|
||||
action: 'writeAndShareFile',
|
||||
action: "writeAndShareFile",
|
||||
fileName,
|
||||
contentLength: content.length,
|
||||
timestamp,
|
||||
};
|
||||
logger.log('[CapacitorPlatformService]', JSON.stringify(logData, null, 2));
|
||||
logger.log("[CapacitorPlatformService]", JSON.stringify(logData, null, 2));
|
||||
|
||||
try {
|
||||
const { uri } = await Filesystem.writeFile({
|
||||
@@ -334,13 +334,16 @@ export class CapacitorPlatformService implements PlatformService {
|
||||
recursive: true,
|
||||
});
|
||||
|
||||
logger.log('[CapacitorPlatformService] File write successful:', { uri, timestamp: new Date().toISOString() });
|
||||
logger.log("[CapacitorPlatformService] File write successful:", {
|
||||
uri,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
await Share.share({
|
||||
title: 'TimeSafari Backup',
|
||||
text: 'Here is your backup file.',
|
||||
title: "TimeSafari Backup",
|
||||
text: "Here is your backup file.",
|
||||
url: uri,
|
||||
dialogTitle: 'Share your backup file',
|
||||
dialogTitle: "Share your backup file",
|
||||
});
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
@@ -349,7 +352,10 @@ export class CapacitorPlatformService implements PlatformService {
|
||||
stack: err.stack,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
logger.error('[CapacitorPlatformService] Error writing or sharing file:', JSON.stringify(errLog, null, 2));
|
||||
logger.error(
|
||||
"[CapacitorPlatformService] Error writing or sharing file:",
|
||||
JSON.stringify(errLog, null, 2),
|
||||
);
|
||||
throw new Error(`Failed to write or share file: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,43 +72,164 @@ export class WebPlatformService implements PlatformService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens a file input dialog configured for camera capture.
|
||||
* Creates a temporary file input element to access the device camera.
|
||||
* 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
|
||||
* @throws Error if image capture fails or no image is selected
|
||||
*
|
||||
* @remarks
|
||||
* Uses the 'capture' attribute to prefer the device camera.
|
||||
* Falls back to file selection if camera is not available.
|
||||
* Processes the captured image to ensure consistent format.
|
||||
*/
|
||||
async takePicture(): Promise<ImageResult> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const input = document.createElement("input");
|
||||
input.type = "file";
|
||||
input.accept = "image/*";
|
||||
input.capture = "environment";
|
||||
const isMobile = /iPhone|iPad|iPod|Android/i.test(navigator.userAgent);
|
||||
const hasGetUserMedia = !!(
|
||||
navigator.mediaDevices && navigator.mediaDevices.getUserMedia
|
||||
);
|
||||
|
||||
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"));
|
||||
// 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"));
|
||||
}
|
||||
} 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);
|
||||
};
|
||||
|
||||
input.click();
|
||||
// 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();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -228,4 +349,14 @@ export class WebPlatformService implements PlatformService {
|
||||
// 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;
|
||||
}
|
||||
47
src/utils/LogCollector.ts
Normal file
47
src/utils/LogCollector.ts
Normal file
@@ -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();
|
||||
@@ -3,7 +3,7 @@ import { logToDb } from "../db";
|
||||
function safeStringify(obj: unknown) {
|
||||
const seen = new WeakSet();
|
||||
|
||||
return JSON.stringify(obj, (key, value) => {
|
||||
return JSON.stringify(obj, (_key, value) => {
|
||||
if (typeof value === "object" && value !== null) {
|
||||
if (seen.has(value)) {
|
||||
return "[Circular]";
|
||||
@@ -20,6 +20,14 @@ function safeStringify(obj: unknown) {
|
||||
}
|
||||
|
||||
export const logger = {
|
||||
debug: (message: string, ...args: unknown[]) => {
|
||||
if (process.env.NODE_ENV !== "production") {
|
||||
// eslint-disable-next-line no-console
|
||||
console.debug(message, ...args);
|
||||
const argsString = args.length > 0 ? " - " + safeStringify(args) : "";
|
||||
logToDb(message + argsString);
|
||||
}
|
||||
},
|
||||
log: (message: string, ...args: unknown[]) => {
|
||||
if (
|
||||
process.env.NODE_ENV !== "production" ||
|
||||
@@ -31,6 +39,17 @@ export const logger = {
|
||||
logToDb(message + argsString);
|
||||
}
|
||||
},
|
||||
info: (message: string, ...args: unknown[]) => {
|
||||
if (
|
||||
process.env.NODE_ENV !== "production" ||
|
||||
process.env.VITE_PLATFORM === "capacitor"
|
||||
) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.info(message, ...args);
|
||||
const argsString = args.length > 0 ? " - " + safeStringify(args) : "";
|
||||
logToDb(message + argsString);
|
||||
}
|
||||
},
|
||||
warn: (message: string, ...args: unknown[]) => {
|
||||
if (
|
||||
process.env.NODE_ENV !== "production" ||
|
||||
@@ -50,3 +69,11 @@ export const logger = {
|
||||
logToDb(message + argsString);
|
||||
},
|
||||
};
|
||||
|
||||
// Add CommonJS export for Electron
|
||||
if (typeof module !== "undefined" && module.exports) {
|
||||
module.exports = { logger };
|
||||
}
|
||||
|
||||
// Add default export for ESM
|
||||
export default { logger };
|
||||
|
||||
@@ -3,7 +3,12 @@
|
||||
<TopMessage />
|
||||
|
||||
<!-- CONTENT -->
|
||||
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
|
||||
<main
|
||||
id="Content"
|
||||
class="p-6 pb-24 max-w-3xl mx-auto"
|
||||
role="main"
|
||||
aria-label="Account Profile"
|
||||
>
|
||||
<!-- Heading -->
|
||||
<h1 id="ViewHeading" class="text-4xl text-center font-light">
|
||||
Your Identity
|
||||
@@ -14,6 +19,8 @@
|
||||
v-if="!activeDid"
|
||||
id="noticeBeforeShare"
|
||||
class="bg-amber-200 text-amber-900 border-amber-500 border-dashed border text-center rounded-md overflow-hidden px-4 py-3 mt-4"
|
||||
role="alert"
|
||||
aria-live="polite"
|
||||
>
|
||||
<p class="mb-4">
|
||||
<b>Note:</b> Before you can share with others or take any action, you
|
||||
@@ -28,10 +35,12 @@
|
||||
</div>
|
||||
|
||||
<!-- Identity Details -->
|
||||
<div
|
||||
<section
|
||||
id="sectionIdentityDetails"
|
||||
class="bg-slate-100 rounded-md overflow-hidden px-4 py-3 mt-4"
|
||||
aria-labelledby="identityDetailsHeading"
|
||||
>
|
||||
<h2 id="identityDetailsHeading" class="sr-only">Identity Details</h2>
|
||||
<div v-if="givenName">
|
||||
<h2 class="text-xl font-semibold mb-2">
|
||||
<span class="whitespace-nowrap">
|
||||
@@ -74,30 +83,56 @@
|
||||
:icon-size="96"
|
||||
:profile-image-url="profileImageUrl"
|
||||
class="inline-block align-text-bottom border border-slate-300 rounded"
|
||||
role="button"
|
||||
aria-label="View profile image in large size"
|
||||
tabindex="0"
|
||||
@click="showLargeIdenticonUrl = profileImageUrl"
|
||||
/>
|
||||
<font-awesome
|
||||
icon="trash-can"
|
||||
class="text-red-500 fa-fw ml-8 mt-8 w-12 h-12"
|
||||
role="button"
|
||||
aria-label="Delete profile image"
|
||||
tabindex="0"
|
||||
@click="confirmDeleteImage"
|
||||
/>
|
||||
</span>
|
||||
<div v-else class="text-center">
|
||||
<div class @click="openImageDialog()">
|
||||
<font-awesome
|
||||
icon="image-portrait"
|
||||
class="bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-2 rounded-l"
|
||||
/>
|
||||
<font-awesome
|
||||
icon="camera"
|
||||
class="bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-2 rounded-r"
|
||||
/>
|
||||
</div>
|
||||
<template v-if="isRegistered">
|
||||
<div
|
||||
class="inline-block 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-4 py-2 rounded-md"
|
||||
@click="openImageDialog()"
|
||||
>
|
||||
<font-awesome icon="user" class="fa-fw" />
|
||||
<font-awesome icon="camera" class="fa-fw" />
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div
|
||||
id="noticeBeforeUpload"
|
||||
class="bg-amber-200 text-amber-900 border-amber-500 border-dashed border text-center rounded-md overflow-hidden px-4 py-3"
|
||||
role="alert"
|
||||
aria-live="polite"
|
||||
>
|
||||
<p class="mb-2">
|
||||
Before you can upload a photo, a friend needs to register you.
|
||||
</p>
|
||||
<router-link
|
||||
:to="{ name: 'contact-qr' }"
|
||||
class="inline-block text-md uppercase 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-4 py-2 rounded-md"
|
||||
>
|
||||
Share Your Info
|
||||
</router-link>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<ImageMethodDialog ref="imageMethodDialog" />
|
||||
<ImageMethodDialog
|
||||
ref="imageMethodDialog"
|
||||
:is-registered="isRegistered"
|
||||
/>
|
||||
</div>
|
||||
<div class="mt-6">
|
||||
<div class="flex justify-center text-center">
|
||||
<div class="mt-4">
|
||||
<div class="flex justify-center text-center text-sm leading-tight mb-1">
|
||||
People {{ profileImageUrl ? "without your image" : "" }} see this
|
||||
<br />
|
||||
(if you've let them see your activity):
|
||||
@@ -131,21 +166,30 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-slate-500 text-sm font-bold">ID</div>
|
||||
<div
|
||||
class="text-sm text-slate-500 flex justify-start items-center mb-1"
|
||||
class="text-sm text-slate-500 flex justify-start items-center mt-2 mb-1"
|
||||
data-testId="didWrapper"
|
||||
role="region"
|
||||
aria-label="Your Identifier"
|
||||
>
|
||||
<code class="truncate">{{ activeDid }}</code>
|
||||
<div class="font-bold">ID: </div>
|
||||
<code class="truncate" aria-label="Your DID">{{ activeDid }}</code>
|
||||
<button
|
||||
class="ml-2"
|
||||
aria-label="Copy DID to clipboard"
|
||||
@click="
|
||||
doCopyTwoSecRedo(activeDid, () => (showDidCopy = !showDidCopy))
|
||||
"
|
||||
>
|
||||
<font-awesome icon="copy" class="text-slate-400 fa-fw"></font-awesome>
|
||||
<font-awesome
|
||||
icon="copy"
|
||||
class="text-slate-400 fa-fw"
|
||||
aria-hidden="true"
|
||||
></font-awesome>
|
||||
</button>
|
||||
<span v-show="showDidCopy">Copied</span>
|
||||
<span v-show="showDidCopy" role="status" aria-live="polite"
|
||||
>Copied</span
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="text-blue-500 text-sm font-bold">
|
||||
@@ -153,7 +197,7 @@
|
||||
Your Activity
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Registration notice -->
|
||||
<!--
|
||||
@@ -164,10 +208,12 @@
|
||||
v-if="!isRegistered"
|
||||
id="noticeBeforeAnnounce"
|
||||
class="bg-amber-200 text-amber-900 border-amber-500 border-dashed border text-center rounded-md overflow-hidden px-4 py-3 mt-4"
|
||||
role="alert"
|
||||
aria-live="polite"
|
||||
>
|
||||
<p class="mb-4">
|
||||
<b>Note:</b> Before you can publicly announce a new project or time
|
||||
commitment, a friend needs to register you.
|
||||
<p class="mb-2">
|
||||
Before you can publicly announce a new project or time commitment, a
|
||||
friend needs to register you.
|
||||
</p>
|
||||
<router-link
|
||||
:to="{ name: 'contact-qr' }"
|
||||
@@ -177,26 +223,33 @@
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<div
|
||||
<section
|
||||
v-if="isRegistered"
|
||||
id="sectionNotifications"
|
||||
class="bg-slate-100 rounded-md overflow-hidden px-4 py-4 mt-8 mb-8"
|
||||
aria-labelledby="notificationsHeading"
|
||||
>
|
||||
<!-- label -->
|
||||
<div class="mb-2 font-bold">Notifications</div>
|
||||
<h2 id="notificationsHeading" class="mb-2 font-bold">Notifications</h2>
|
||||
<div class="flex items-center justify-between">
|
||||
<!-- label -->
|
||||
<div>
|
||||
Reminder Notification
|
||||
<font-awesome
|
||||
icon="question-circle"
|
||||
class="text-slate-400 fa-fw ml-2 cursor-pointer"
|
||||
<button
|
||||
class="text-slate-400 fa-fw cursor-pointer"
|
||||
aria-label="Learn more about reminder notifications"
|
||||
@click.stop="showReminderNotificationInfo"
|
||||
/>
|
||||
>
|
||||
<font-awesome
|
||||
icon="question-circle"
|
||||
aria-hidden="true"
|
||||
></font-awesome>
|
||||
</button>
|
||||
</div>
|
||||
<!-- toggle -->
|
||||
<div
|
||||
class="relative ml-2 cursor-pointer"
|
||||
role="switch"
|
||||
:aria-checked="notifyingReminder"
|
||||
aria-label="Toggle reminder notifications"
|
||||
tabindex="0"
|
||||
@click="showReminderNotificationChoice()"
|
||||
>
|
||||
<!-- input -->
|
||||
@@ -219,7 +272,7 @@
|
||||
New Activity Notification
|
||||
<font-awesome
|
||||
icon="question-circle"
|
||||
class="text-slate-400 fa-fw ml-2 cursor-pointer"
|
||||
class="text-slate-400 fa-fw cursor-pointer"
|
||||
@click.stop="showNewActivityNotificationInfo"
|
||||
/>
|
||||
</div>
|
||||
@@ -245,52 +298,54 @@
|
||||
<div v-if="notifyingNewActivityTime" class="w-full text-right">
|
||||
{{ notifyingNewActivityTime.replace(" ", " ") }}
|
||||
</div>
|
||||
<router-link class="pl-4 text-sm text-blue-500" to="/help-notifications">
|
||||
Troubleshoot your notifications.
|
||||
</router-link>
|
||||
</div>
|
||||
<div class="mt-2 text-center">
|
||||
<router-link class="text-sm text-blue-500" to="/help-notifications">
|
||||
Troubleshoot your notifications…
|
||||
</router-link>
|
||||
</div>
|
||||
</section>
|
||||
<PushNotificationPermission ref="pushNotificationPermission" />
|
||||
|
||||
<div
|
||||
<section
|
||||
id="sectionSearchLocation"
|
||||
class="flex justify-between bg-slate-100 rounded-md overflow-hidden px-4 py-4 mt-8 mb-8"
|
||||
class="bg-slate-100 rounded-md overflow-hidden px-4 py-4 mt-8 mb-8"
|
||||
aria-labelledby="searchLocationHeading"
|
||||
>
|
||||
<!-- label -->
|
||||
<span class="mb-2 font-bold">Location for Searches</span>
|
||||
<h2 id="searchLocationHeading" class="mb-2 font-bold">
|
||||
Location for Searches
|
||||
</h2>
|
||||
<router-link
|
||||
:to="{ name: 'search-area' }"
|
||||
class="text-m 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-4 py-2 rounded-md mb-2"
|
||||
class="block w-full text-center 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-4 py-2 rounded-md"
|
||||
>
|
||||
{{ isSearchAreasSet ? "Change" : "Set" }} Search Area…
|
||||
</router-link>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- User Profile -->
|
||||
<div
|
||||
<section
|
||||
v-if="isRegistered"
|
||||
class="bg-slate-100 rounded-md overflow-hidden px-4 py-4 mt-8 mb-8"
|
||||
aria-labelledby="userProfileHeading"
|
||||
>
|
||||
<div v-if="loadingProfile" class="text-center mb-2">
|
||||
<font-awesome
|
||||
icon="spinner"
|
||||
class="fa-spin text-slate-400"
|
||||
></font-awesome>
|
||||
Loading profile...
|
||||
</div>
|
||||
<div v-else class="flex items-center mb-2">
|
||||
<span class="font-bold">Public Profile</span>
|
||||
<font-awesome
|
||||
icon="circle-info"
|
||||
class="text-slate-400 fa-fw ml-2 cursor-pointer"
|
||||
<h2 id="userProfileHeading" class="mb-2 font-bold">
|
||||
Public Profile
|
||||
<button
|
||||
class="text-slate-400 fa-fw cursor-pointer"
|
||||
aria-label="Learn more about public profile"
|
||||
@click="showProfileInfo"
|
||||
/>
|
||||
</div>
|
||||
>
|
||||
<font-awesome icon="circle-info" aria-hidden="true"></font-awesome>
|
||||
</button>
|
||||
</h2>
|
||||
<textarea
|
||||
v-model="userProfileDesc"
|
||||
class="w-full h-32 p-2 border border-slate-300 rounded-md"
|
||||
placeholder="Write something about yourself for the public..."
|
||||
:readonly="loadingProfile || savingProfile"
|
||||
:class="{ 'bg-slate-100': loadingProfile || savingProfile }"
|
||||
aria-label="Public profile description"
|
||||
:aria-busy="loadingProfile || savingProfile"
|
||||
></textarea>
|
||||
|
||||
<div class="flex items-center mb-4" @click="toggleUserProfileLocation">
|
||||
@@ -331,9 +386,9 @@
|
||||
</l-map>
|
||||
</div>
|
||||
<div v-if="!loadingProfile && !savingProfile">
|
||||
<div class="flex justify-between items-center">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2 mt-4">
|
||||
<button
|
||||
class="mt-2 px-4 py-2 bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white rounded-md"
|
||||
class="px-4 py-2 bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white rounded-md"
|
||||
:disabled="loadingProfile || savingProfile"
|
||||
:class="{
|
||||
'opacity-50 cursor-not-allowed': loadingProfile || savingProfile,
|
||||
@@ -343,7 +398,7 @@
|
||||
Save Profile
|
||||
</button>
|
||||
<button
|
||||
class="mt-2 px-4 py-2 bg-gradient-to-b from-red-400 to-red-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white rounded-md"
|
||||
class="px-4 py-2 bg-gradient-to-b from-red-400 to-red-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white rounded-md"
|
||||
:disabled="loadingProfile || savingProfile"
|
||||
:class="{
|
||||
'opacity-50 cursor-not-allowed':
|
||||
@@ -359,18 +414,28 @@
|
||||
</div>
|
||||
<div v-else-if="loadingProfile">Loading...</div>
|
||||
<div v-else>Saving...</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div
|
||||
<section
|
||||
v-if="activeDid"
|
||||
id="sectionUsageLimits"
|
||||
class="bg-slate-100 rounded-md overflow-hidden px-4 py-4 mt-8 mb-8"
|
||||
aria-labelledby="usageLimitsHeading"
|
||||
>
|
||||
<div class="mb-2 font-bold">Usage Limits</div>
|
||||
<h2 id="usageLimitsHeading" class="mb-2 font-bold">Usage Limits</h2>
|
||||
<!-- show spinner if loading limits -->
|
||||
<div v-if="loadingLimits" class="text-center">
|
||||
<div
|
||||
v-if="loadingLimits"
|
||||
class="text-center"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
Checking…
|
||||
<font-awesome icon="spinner" class="fa-spin"></font-awesome>
|
||||
<font-awesome
|
||||
icon="spinner"
|
||||
class="fa-spin"
|
||||
aria-hidden="true"
|
||||
></font-awesome>
|
||||
</div>
|
||||
<div class="mb-4 text-center">
|
||||
{{ limitsMessage }}
|
||||
@@ -413,24 +478,29 @@
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
class="block float-right w-fit text-center text-md bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-4 py-2 rounded-md mt-2"
|
||||
class="block w-full text-center text-md bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-4 py-2 rounded-md mt-4"
|
||||
@click="checkLimits()"
|
||||
>
|
||||
Recheck Limits
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<DataExportSection :active-did="activeDid" />
|
||||
|
||||
<!-- id used by puppeteer test script -->
|
||||
<h3
|
||||
id="advanced"
|
||||
class="text-blue-500 text-sm font-semibold mb-3"
|
||||
class="text-blue-500 text-sm font-semibold mb-3 cursor-pointer"
|
||||
@click="showAdvanced = !showAdvanced"
|
||||
>
|
||||
Advanced
|
||||
{{ showAdvanced ? "Hide Advanced Settings" : "Show Advanced Settings" }}
|
||||
</h3>
|
||||
<div v-if="showAdvanced || showGeneralAdvanced" id="sectionAdvanced">
|
||||
<section
|
||||
v-if="showAdvanced || showGeneralAdvanced"
|
||||
id="sectionAdvanced"
|
||||
aria-labelledby="advancedHeading"
|
||||
>
|
||||
<h2 id="advancedHeading" class="sr-only">Advanced Settings</h2>
|
||||
<p class="text-rose-600 mb-8">
|
||||
Beware: the features here can be confusing and even change data in ways
|
||||
you do not expect. But we support your freedom!
|
||||
@@ -601,41 +671,63 @@
|
||||
|
||||
<div id="sectionClaimServer">
|
||||
<h2 class="text-slate-500 text-sm font-bold mt-4">Claim Server</h2>
|
||||
<div class="px-4 py-4">
|
||||
<div
|
||||
class="px-4 py-4"
|
||||
role="group"
|
||||
aria-labelledby="claimServerHeading"
|
||||
>
|
||||
<h3 id="claimServerHeading" class="sr-only">
|
||||
Claim Server Configuration
|
||||
</h3>
|
||||
<label for="apiServerInput" class="sr-only">API Server URL</label>
|
||||
<input
|
||||
id="apiServerInput"
|
||||
v-model="apiServerInput"
|
||||
type="text"
|
||||
class="block w-full rounded border border-slate-400 px-4 py-2"
|
||||
aria-describedby="apiServerDescription"
|
||||
placeholder="Enter API server URL"
|
||||
/>
|
||||
<div id="apiServerDescription" class="sr-only" role="tooltip">
|
||||
Enter the URL for the claim server. You can use the buttons below to
|
||||
quickly set common server URLs.
|
||||
</div>
|
||||
<button
|
||||
v-if="apiServerInput != apiServer"
|
||||
class="w-full px-4 rounded bg-yellow-500 border border-slate-400"
|
||||
aria-label="Save API server URL"
|
||||
@click="onClickSaveApiServer()"
|
||||
>
|
||||
<font-awesome
|
||||
icon="floppy-disk"
|
||||
class="fa-fw"
|
||||
color="white"
|
||||
aria-hidden="true"
|
||||
></font-awesome>
|
||||
</button>
|
||||
<button
|
||||
class="px-3 rounded bg-slate-200 border border-slate-400"
|
||||
@click="apiServerInput = AppConstants.PROD_ENDORSER_API_SERVER"
|
||||
>
|
||||
Use Prod
|
||||
</button>
|
||||
<button
|
||||
class="px-3 rounded bg-slate-200 border border-slate-400"
|
||||
@click="apiServerInput = AppConstants.TEST_ENDORSER_API_SERVER"
|
||||
>
|
||||
Use Test
|
||||
</button>
|
||||
<button
|
||||
class="px-3 rounded bg-slate-200 border border-slate-400"
|
||||
@click="apiServerInput = AppConstants.LOCAL_ENDORSER_API_SERVER"
|
||||
>
|
||||
Use Local
|
||||
</button>
|
||||
<div class="mt-2" role="group" aria-label="Quick server selection">
|
||||
<button
|
||||
class="px-3 rounded bg-slate-200 border border-slate-400"
|
||||
aria-label="Use production server URL"
|
||||
@click="apiServerInput = AppConstants.PROD_ENDORSER_API_SERVER"
|
||||
>
|
||||
Use Prod
|
||||
</button>
|
||||
<button
|
||||
class="px-3 rounded bg-slate-200 border border-slate-400"
|
||||
aria-label="Use test server URL"
|
||||
@click="apiServerInput = AppConstants.TEST_ENDORSER_API_SERVER"
|
||||
>
|
||||
Use Test
|
||||
</button>
|
||||
<button
|
||||
class="px-3 rounded bg-slate-200 border border-slate-400"
|
||||
aria-label="Use local server URL"
|
||||
@click="apiServerInput = AppConstants.LOCAL_ENDORSER_API_SERVER"
|
||||
>
|
||||
Use Local
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label
|
||||
@@ -873,8 +965,8 @@
|
||||
>
|
||||
View Logs
|
||||
</router-link>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
430
src/views/ContactQRScanView.vue
Normal file
430
src/views/ContactQRScanView.vue
Normal file
@@ -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>
|
||||
@@ -289,7 +289,7 @@
|
||||
class="text-sm bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-1.5 rounded-md border border-slate-400"
|
||||
title="See more given activity"
|
||||
>
|
||||
<fa icon="file-lines" class="fa-fw" />
|
||||
<font-awesome icon="file-lines" class="fa-fw" />
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { VALID_DEEP_LINK_ROUTES } from "../types/deepLinks";
|
||||
import { VALID_DEEP_LINK_ROUTES } from "../interfaces/deepLinks";
|
||||
import { logConsoleAndDb } from "../db";
|
||||
import { logger } from "../utils/logger";
|
||||
|
||||
|
||||
@@ -215,11 +215,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-8 flex">
|
||||
<input v-model="isTrade" type="checkbox" class="h-6 w-6 mr-2" />
|
||||
<label class="text-sm mt-1">This was a trade (not a gift)</label>
|
||||
</div>
|
||||
|
||||
<div v-if="showGeneralAdvanced" class="mt-4 flex">
|
||||
<router-link
|
||||
:to="{
|
||||
@@ -307,7 +302,6 @@ export default class GiftedDetails extends Vue {
|
||||
giverName = "";
|
||||
hideBackButton = false;
|
||||
imageUrl = "";
|
||||
isTrade = false;
|
||||
message = "";
|
||||
offerId = "";
|
||||
prevCredToEdit?: GenericCredWrapper<GiveVerifiableCredential>;
|
||||
@@ -790,7 +784,7 @@ export default class GiftedDetails extends Vue {
|
||||
this.unitCode,
|
||||
fulfillsProjectId,
|
||||
this.offerId,
|
||||
this.isTrade,
|
||||
false,
|
||||
this.imageUrl,
|
||||
this.providerProjectId,
|
||||
);
|
||||
@@ -806,7 +800,7 @@ export default class GiftedDetails extends Vue {
|
||||
this.unitCode,
|
||||
fulfillsProjectId,
|
||||
this.offerId,
|
||||
this.isTrade,
|
||||
false,
|
||||
this.imageUrl,
|
||||
this.providerProjectId,
|
||||
);
|
||||
@@ -833,7 +827,7 @@ export default class GiftedDetails extends Vue {
|
||||
group: "alert",
|
||||
type: "success",
|
||||
title: "Success",
|
||||
text: `That ${this.isTrade ? "trade" : "gift"} was recorded.`,
|
||||
text: `That gift was recorded.`,
|
||||
},
|
||||
3000,
|
||||
);
|
||||
@@ -878,7 +872,7 @@ export default class GiftedDetails extends Vue {
|
||||
this.unitCode,
|
||||
fulfillsProjectId,
|
||||
this.offerId,
|
||||
this.isTrade,
|
||||
false,
|
||||
this.imageUrl,
|
||||
this.providerProjectId,
|
||||
this.prevCredToEdit?.id as string,
|
||||
|
||||
@@ -348,7 +348,7 @@ import {
|
||||
import { GiveSummaryRecord } from "../interfaces";
|
||||
import * as serverUtil from "../libs/endorserServer";
|
||||
import { logger } from "../utils/logger";
|
||||
import { GiveRecordWithContactInfo } from "types";
|
||||
import { GiveRecordWithContactInfo } from "../types";
|
||||
|
||||
interface Claim {
|
||||
claim?: Claim; // For nested claims in Verifiable Credentials
|
||||
|
||||
@@ -179,7 +179,7 @@ test('Confirm test API setting (may fail if you are running your own Time Safari
|
||||
const endorserTermInConfig = endorserTerm?.substring(ENDORSER_ENV_NAME.length + 1);
|
||||
|
||||
const endorserServer = endorserTermInConfig || 'https://test-api.endorser.ch';
|
||||
await expect(page.getByRole('textbox').nth(1)).toHaveValue(endorserServer);
|
||||
await expect(page.locator('#apiServerInput')).toHaveValue(endorserServer);
|
||||
});
|
||||
|
||||
test('Check User 0 can register a random person', async ({ page }) => {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { test, expect } from '@playwright/test';
|
||||
import { importUser } from './testUtils';
|
||||
|
||||
test('Record an offer', async ({ page }) => {
|
||||
test.setTimeout(45000);
|
||||
test.setTimeout(60000);
|
||||
|
||||
// Generate a random string of 3 characters, skipping the "0." at the beginning
|
||||
const randomString = Math.random().toString(36).substring(2, 5);
|
||||
|
||||
127
test-playwright/README.md
Normal file
127
test-playwright/README.md
Normal file
@@ -0,0 +1,127 @@
|
||||
# Playwright Test Suite
|
||||
|
||||
This directory contains the automated end-to-end test suite for Time Safari using Playwright. The tests verify critical functionality across web and mobile platforms.
|
||||
|
||||
## Test Structure
|
||||
|
||||
Tests are organized by feature area and numbered for execution order:
|
||||
|
||||
- `00-noid-tests.spec.ts` - Tests for unregistered users
|
||||
- `05-invite.spec.ts` - Contact invitation functionality
|
||||
- `10-check-usage-limits.spec.ts` - Usage limit verification
|
||||
- `20-create-project.spec.ts` - Project creation
|
||||
- `25-create-project-x10.spec.ts` - Bulk project creation
|
||||
- `30-record-gift.spec.ts` - Gift recording
|
||||
- `33-record-gift-x10.spec.ts` - Bulk gift recording
|
||||
- `35-record-gift-from-image-share.spec.ts` - Gift recording from shared images
|
||||
- `37-record-gift-on-project.spec.ts` - Project-specific gift recording
|
||||
- `40-add-contact.spec.ts` - Contact management
|
||||
- `50-record-offer.spec.ts` - Offer recording
|
||||
- `60-new-activity.spec.ts` - Activity feed updates
|
||||
|
||||
## Key Files
|
||||
|
||||
- `testUtils.ts` - Shared test utilities and helper functions
|
||||
- `TESTING.md` - Detailed testing guide and manual test procedures
|
||||
- `playwright.config-local.ts` - Playwright configuration for local testing
|
||||
- `exported-data.json` - Test data for import/export testing
|
||||
|
||||
## Prerequisites
|
||||
|
||||
1. Endorser server running locally (see TESTING.md for setup)
|
||||
```bash
|
||||
git clone https://github.com/time-endorser/endorser-ch.git
|
||||
cd endorser-ch
|
||||
npm install
|
||||
test/test.sh
|
||||
cp .env.local .env
|
||||
NODE_ENV=test-local npm run dev
|
||||
```
|
||||
2. Playwright browsers installed:
|
||||
```bash
|
||||
npx playwright install
|
||||
```
|
||||
3. For mobile testing:
|
||||
- XCode (for iOS)
|
||||
- Android Studio or connected Android device
|
||||
|
||||
## Running Tests
|
||||
|
||||
### Full Test Suite
|
||||
|
||||
```bash
|
||||
# Run all tests (web + mobile)
|
||||
npm run test:all
|
||||
|
||||
# Run web-only tests
|
||||
npm run test:web
|
||||
```
|
||||
|
||||
### Individual Tests
|
||||
|
||||
```bash
|
||||
# Run a specific test with tracing
|
||||
npx playwright test -c playwright.config-local.ts --trace on test-playwright/40-add-contact.spec.ts
|
||||
```
|
||||
|
||||
### Test Environment Options
|
||||
|
||||
1. Local Endorser Server (default):
|
||||
```bash
|
||||
NODE_ENV=test-local npm run dev
|
||||
```
|
||||
|
||||
2. Global Test Server:
|
||||
```bash
|
||||
VITE_DEFAULT_ENDORSER_API_SERVER=https://test-ledger.time.com npm run dev
|
||||
```
|
||||
|
||||
3. Minimal Test Data:
|
||||
```bash
|
||||
rm ../endorser-ch-test-local.sqlite3
|
||||
NODE_ENV=test-local npm run flyway migrate
|
||||
NODE_ENV=test-local npm run test test/controller0
|
||||
NODE_ENV=test-local npm run dev
|
||||
```
|
||||
|
||||
## Test Data
|
||||
|
||||
The test suite uses predefined test users, with User #0 having registration privileges. To use it: Profile -> Advanced -> Switch Identifier -> Add Another Identity -> You Have A Seed -> Advanced -> Use mnemonic for Test User #0 -> Import
|
||||
|
||||
More details available in TESTING.md
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
Common issues and solutions:
|
||||
|
||||
1. **Test Failures**
|
||||
- Some tests may fail intermittently - try rerunning
|
||||
- Check Endorser server logs for backend issues
|
||||
- Verify test environment setup
|
||||
|
||||
2. **Mobile Testing**
|
||||
- Ensure XCode/Android Studio is running
|
||||
- Check device connectivity
|
||||
- Verify browser installation
|
||||
|
||||
3. **Data Issues**
|
||||
- Clear browser data if tests fail due to stale state
|
||||
- Reset IndexedDB if needed
|
||||
- Check service worker status
|
||||
|
||||
For more detailed troubleshooting, see TESTING.md.
|
||||
|
||||
## Contributing
|
||||
|
||||
When adding new tests:
|
||||
1. Follow the existing naming convention
|
||||
2. Use testUtils.ts for common operations
|
||||
3. Add appropriate comments and documentation
|
||||
4. Update this README if adding new test categories
|
||||
5. Consider both web and mobile platforms
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [TESTING.md](./TESTING.md) - Detailed testing guide
|
||||
- [Playwright Documentation](https://playwright.dev/docs/intro)
|
||||
- Endorser server documentation for test setup
|
||||
@@ -1,69 +1,6 @@
|
||||
# Testing Guide
|
||||
|
||||
## Automated Tests
|
||||
|
||||
### Setup
|
||||
|
||||
1. Clone and set up the Endorser server:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/time-endorser/endorser-ch.git
|
||||
cd endorser-ch
|
||||
npm install
|
||||
test/test.sh
|
||||
cp .env.local .env
|
||||
NODE_ENV=test-local npm run dev
|
||||
```
|
||||
|
||||
If that fails, follow the instructions in the endorser-ch directory's README.md.
|
||||
|
||||
2. Install Playwright browsers:
|
||||
|
||||
```bash
|
||||
npx playwright install
|
||||
```
|
||||
|
||||
### Running Tests
|
||||
|
||||
#### Full Test Suite
|
||||
|
||||
To run all tests, make sure XCode is started and either Android Studio is started or an Android device is connected.
|
||||
|
||||
```bash
|
||||
npm run test:all
|
||||
```
|
||||
|
||||
Run only web tests:
|
||||
|
||||
```bash
|
||||
npm run test:web
|
||||
```
|
||||
|
||||
Note: Tests may occasionally fail and succeed on rerun (especially if a different test fails).
|
||||
|
||||
#### Single Test
|
||||
To run a single test with tracing enabled:
|
||||
|
||||
```bash
|
||||
npx playwright test -c playwright.config-local.ts --trace on test-playwright/40-add-contact.spec.ts
|
||||
```
|
||||
#### Global Test Server
|
||||
It's possible to use the global test Endorser (ledger) server, though not all tests currently succeed:
|
||||
|
||||
```bash
|
||||
VITE_DEFAULT_ENDORSER_API_SERVER=https://test-ledger.time.com npm run dev
|
||||
```
|
||||
|
||||
#### Minimal Test Data
|
||||
To run with minimal test data:
|
||||
|
||||
```bash
|
||||
rm ../endorser-ch-test-local.sqlite3
|
||||
NODE_ENV=test-local npm run flyway migrate
|
||||
NODE_ENV=test-local npm run test test/controller0
|
||||
NODE_ENV=test-local npm run dev
|
||||
```
|
||||
|
||||
Start with [README.md](./README.md). This file has more details.
|
||||
|
||||
## Test User Setup
|
||||
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
{
|
||||
"status": "failed",
|
||||
"failedTests": []
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
{
|
||||
"status": "failed",
|
||||
"failedTests": [
|
||||
"a29eb57667e0fb28c7e9-7a80e551e7f16a766d0d",
|
||||
"a29eb57667e0fb28c7e9-1a8c76601bb6ea4f735c",
|
||||
"a29eb57667e0fb28c7e9-0a3670fa77fcd5ac9827",
|
||||
"a29eb57667e0fb28c7e9-90c8866cf70c7f96647d",
|
||||
"a29eb57667e0fb28c7e9-4abc584edcf7a6a12389",
|
||||
"a29eb57667e0fb28c7e9-3b443656a23fd8e7eb76",
|
||||
"a29eb57667e0fb28c7e9-1f63cf7a41b756ffe01f",
|
||||
"a29eb57667e0fb28c7e9-4eb03633761e58eac0a4",
|
||||
"db48a48c514e3e2940e5-cef25040a0b285eed2ba",
|
||||
"1c818805c9b0ac973736-726f18ba6163d57238c8",
|
||||
"c52ae54d86eda05904f3-adf7525a07e75f4e3cc2",
|
||||
"2fac21b9c9c3eb062631-9d2d2e9a199603c11b9b",
|
||||
"64242279fe0133650483-20fbacc4e45c5561df6c",
|
||||
"a7ff64a290be94f9d82c-e26ceb13031dafad1133",
|
||||
"868977083268005e6ec0-c27d226d34e20ba4863d",
|
||||
"5e149db5da4a5e319bcc-3298c84d0ebfff5e6d7c",
|
||||
"5e149db5da4a5e319bcc-1981ba81641b6000f80b",
|
||||
"2b5f6d3352de2040032d-bf5ed3a9483d90c396dd",
|
||||
"2b5f6d3352de2040032d-6f52c3699c55c19ccad8",
|
||||
"2b5f6d3352de2040032d-0f478a3208f64651daa1",
|
||||
"2b5f6d3352de2040032d-a05f542cad739ee3b5b9",
|
||||
"955bdfdfe05b442c0f5d-a9ec2b8bc7bd90ea0439",
|
||||
"955bdfdfe05b442c0f5d-2c38171f673436923a8b",
|
||||
"1a1fd29d3f0573e705e6-a3a6805908fe9a29ab11"
|
||||
]
|
||||
}
|
||||
26
tsconfig.electron.json
Normal file
26
tsconfig.electron.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"target": "ES2020",
|
||||
"outDir": "dist-electron",
|
||||
"rootDir": "src",
|
||||
"sourceMap": true,
|
||||
"esModuleInterop": true,
|
||||
"allowJs": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"allowImportingTsExtensions": true,
|
||||
"types": ["vite/client"],
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"src/electron/**/*.ts",
|
||||
"src/utils/**/*.ts",
|
||||
"src/constants/**/*.ts"
|
||||
]
|
||||
}
|
||||
@@ -4,7 +4,9 @@
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"allowImportingTsExtensions": true,
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["vite.config.*"]
|
||||
}
|
||||
@@ -44,6 +44,8 @@ export async function createBuildConfig(mode: string) {
|
||||
'process.env.VITE_PLATFORM': JSON.stringify(mode),
|
||||
'process.env.VITE_PWA_ENABLED': JSON.stringify(!(isElectron || isPyWebView || isCapacitor)),
|
||||
__dirname: isElectron ? JSON.stringify(process.cwd()) : '""',
|
||||
__IS_MOBILE__: JSON.stringify(isCapacitor),
|
||||
__USE_QR_READER__: JSON.stringify(!isCapacitor),
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
|
||||
@@ -1,11 +1,58 @@
|
||||
import { defineConfig, mergeConfig } from "vite";
|
||||
import { createBuildConfig } from "./vite.config.common.mts";
|
||||
import path from 'path';
|
||||
|
||||
export default defineConfig(async () => {
|
||||
const baseConfig = await createBuildConfig('electron');
|
||||
|
||||
return mergeConfig(baseConfig, {
|
||||
plugins: [{
|
||||
build: {
|
||||
outDir: 'dist-electron',
|
||||
rollupOptions: {
|
||||
input: {
|
||||
main: path.resolve(__dirname, 'src/electron/main.ts'),
|
||||
preload: path.resolve(__dirname, 'src/electron/preload.js'),
|
||||
},
|
||||
external: ['electron'],
|
||||
output: {
|
||||
format: 'cjs',
|
||||
entryFileNames: '[name].js',
|
||||
assetFileNames: 'assets/[name].[ext]',
|
||||
},
|
||||
},
|
||||
target: 'node18',
|
||||
minify: false,
|
||||
sourcemap: true,
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, 'src'),
|
||||
},
|
||||
},
|
||||
optimizeDeps: {
|
||||
include: ['@/utils/logger']
|
||||
},
|
||||
plugins: [
|
||||
{
|
||||
name: 'typescript-transform',
|
||||
transform(code: string, id: string) {
|
||||
if (id.endsWith('main.ts')) {
|
||||
// Replace the logger import with inline logger
|
||||
return code.replace(
|
||||
/import\s*{\s*logger\s*}\s*from\s*['"]@\/utils\/logger['"];?/,
|
||||
`const logger = {
|
||||
log: (...args) => console.log(...args),
|
||||
error: (...args) => console.error(...args),
|
||||
info: (...args) => console.info(...args),
|
||||
warn: (...args) => console.warn(...args),
|
||||
debug: (...args) => console.debug(...args),
|
||||
};`
|
||||
);
|
||||
}
|
||||
return code;
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'remove-sw-imports',
|
||||
transform(code: string, id: string) {
|
||||
if (
|
||||
@@ -24,6 +71,12 @@ export default defineConfig(async () => {
|
||||
};
|
||||
}
|
||||
}
|
||||
}]
|
||||
}
|
||||
],
|
||||
ssr: {
|
||||
noExternal: ['@/utils/logger']
|
||||
},
|
||||
base: './',
|
||||
publicDir: 'public',
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user