Compare commits

...

114 Commits

Author SHA1 Message Date
Trent Larson d555bc3e9c Merge branch 'qrcode-reboot' into trent-tweaks 3 days ago
Jose Olarte III 141415977e Fix linting errors 3 days ago
Jose Olarte III 981ccbf269 iOS photo library permission 3 days ago
Jose Olarte III b74ec8ecbb Design: polished dialog UI 3 days ago
Matt Raymer 7b3b1c930e refactor: consolidate type system and improve documentation 4 days ago
Matt Raymer 85aa2981ad docs: add comprehensive camera switching implementation guide 4 days ago
Matt Raymer a86e577127 style: improve code formatting and readability 4 days ago
Matt Raymer 788d162b1c refactor: move lib directory to libs for consistency 4 days ago
Matt Raymer 616a69b7fd chore: update capacitor config and script paths 4 days ago
Jose Olarte III efab9b968c Merge branch 'qrcode-reboot' of ssh://173.199.124.46:222/trent_larson/crowd-funder-for-time-pwa into qrcode-reboot 4 days ago
Jose Olarte III 70174aea93 Fix: current photo dialog 4 days ago
Matt Raymer 7f12595c91 docs: consolidate QR code implementation documentation 4 days ago
Matt Raymer 8f0d09e480 chore: cleanup documents 4 days ago
Matt Raymer cfc0730e75 feat: implement comprehensive camera state management 4 days ago
Trent Larson bfbb9a933d add a pod dependency to environment 5 days ago
Trent Larson 674bbfa00c bump version to match attempts in app stores 5 days ago
Trent Larson 80b754246e add more ios tweaks for app store 5 days ago
Trent Larson 5fcf6a1f90 add documentation, especially for build processes 5 days ago
Trent Larson 9da12e76fd refactor files that should be ignored 5 days ago
Matt Raymer 04193f61c7 Merging 1 week ago
Matt Raymer 0ca4916a05 chore: update camera documentation 1 week ago
Trent Larson 925ce830b4 remove duplicate instructions 1 week ago
Jose Olarte III d14635c44d UI tweaks 1 week ago
Matt Raymer eb5c9565a6 fix: remove duplicate Advanced heading and improve UX 1 week ago
Matthew Raymer ea108b754e feat(accessibility): enhance AccountViewView and document test suite 1 week ago
Matt Raymer e4155e1a20 feat(ui): disable all photo upload actions for unregistered users 1 week ago
Matt Raymer 7e9682ce67 feat(web): enable desktop webcam capture in WebPlatformService 1 week ago
Trent Larson c7f1148fe4 add logger import where needed 1 week ago
Trent Larson ae9f1ee09f update package-lock with the latest build 1 week ago
Trent Larson 4d0463f7f7 update with lint-fix 1 week ago
Trent Larson 748c4c7a50 add documentation 2 weeks ago
Trent Larson 35bb9d2207 remove ability to mark a 'trade', ensuring this only sends & retrieves gifts 2 weeks ago
Jose Olarte III fd914aa46c Removed unneeded elements 2 weeks ago
Jose Olarte III ba1453104f UI tweaks to QR scanner 2 weeks ago
Jose Olarte III 3c7f13d604 Merge branch 'qrcode-reboot' of ssh://173.199.124.46:222/trent_larson/crowd-funder-for-time-pwa into qrcode-reboot 2 weeks ago
Jose Olarte III 8e8eef2ab5 Safe area implementation for iOS 2 weeks ago
Matt Raymer ea17ef930c chore: adjusting file location 2 weeks ago
Jose Olarte III 5242a24110 Web: trigger camera start on view load 2 weeks ago
Matt Raymer 93e860e0ac feat(qr-scanner): implement WebInlineQRScanner with jsQR integration 2 weeks ago
Jose Olarte III f874973bfa Improvements to contact QR scanner UI 2 weeks ago
Matt Raymer 74b9caa94f chore: updates for qr code reader rules, linting, and cleanup 2 weeks ago
Jose Olarte III fdd1ff80ad Complete: unified QR display + capture 2 weeks ago
Matt Raymer 5d195d06ba style: improve code formatting and type safety 3 weeks ago
Jose Olarte III 79707d2811 WIP: Unified contact QR code display + capture 3 weeks ago
Jose Olarte III 9b73e05bdb Merge branch 'qrcode-reboot' of ssh://173.199.124.46:222/trent_larson/crowd-funder-for-time-pwa into qrcode-reboot 3 weeks ago
Jose Olarte III 1b7c5decd3 Stop scanner when cancelling 3 weeks ago
Jose Olarte III 8c8fb6fe7d De-coupled web and mobile scanners 3 weeks ago
Matthew Raymer 29983f11a9 doc: camera system details 3 weeks ago
Matthew Raymer 5c559606df docs: add macOS build and packaging instructions 3 weeks ago
Matthew Raymer 37166fc141 docs(PhotoDialog): improve component documentation and error handling 3 weeks ago
Jose Olarte III 01ef7c1fa9 Merge branch 'qrcode-reboot' of ssh://173.199.124.46:222/trent_larson/crowd-funder-for-time-pwa into qrcode-reboot 3 weeks ago
Jose Olarte III 2bb71653ac New contact QR scan view for Capacitor version 3 weeks ago
Matthew Raymer 7baae7ea7a chore: lint fix 3 weeks ago
Matthew Raymer cb1d979431 refactor(electron): improve build process and configuration 3 weeks ago
Matt Raymer b999a04595 cursor(ADR): attempt to keep changes between the lines when making platform level changes 3 weeks ago
Matt Raymer 0f9826a39d refactor: replace console.log with logger utility 3 weeks ago
Jose Olarte III 8cc17bd09d iOS camera usage description 3 weeks ago
Matt Raymer 9dc9878472 fix(qr-scanner): robustly handle array/object detection results and guarantee dialog dismissal 3 weeks ago
Jose Olarte III 22283e32f2 Merge branch 'qrcode-reboot' of ssh://173.199.124.46:222/trent_larson/crowd-funder-for-time-pwa into qrcode-reboot 4 weeks ago
Jose Olarte III 99863ec186 iOS Capacitor setup 4 weeks ago
Matthew Raymer 8d2dffb012 fix: lint 4 weeks ago
Matthew Raymer 538cbef701 feat(qr): improve camera error feedback and robustness in QR scanner 4 weeks ago
Matthew Raymer 7b7940189e chore(qr): add unconditional debug panel and simplify onInit for event binding test 4 weeks ago
Matthew Raymer 35b038036a chore(qr): add visible debug output and version bump for device-side troubleshooting 4 weeks ago
Matthew Raymer b9cafbe269 debug: add an old-school alert 4 weeks ago
Matthew Raymer 559f52e6d6 fix(qr): add timeout fallback for QR scanner initialization 4 weeks ago
Matthew Raymer eb44b624d6 fix(qr): add retry logic to QR scanner initialization 4 weeks ago
Matthew Raymer 6fdbc7f588 debug: comment out promise 4 weeks ago
Matthew Raymer 7e8caae69a Merge branch 'qrcode-reboot' of ssh://173.199.124.46:222/trent_larson/crowd-funder-for-time-pwa into qrcode-reboot 4 weeks ago
Matthew Raymer 7b29232b2c style: fix max-len warnings in QRScannerDialog.vue SVG paths 4 weeks ago
Matthew Raymer e7cb5ffd33 docs: add Docker deployment instructions to BUILDING.md 4 weeks ago
Matt Raymer 272f2a91a6 refactor(QRScanner): improve camera handling and UI feedback 4 weeks ago
Matt Raymer f750ea5d10 feat(qr-scanner): Enhance QR scanner dialog with user feedback 4 weeks ago
Matt Raymer 78116329d4 feat(qr-scanner): Add detailed logging for QR code scanning process 4 weeks ago
Matt Raymer 2753e142cf feature: adding Dockerfile for online testing or deployment to docker 4 weeks ago
Trent Larson 9a840ab74a ran lint-fix 4 weeks ago
Matthew Raymer c6c49260ef fix: add HTTPS requirement check for camera access 4 weeks ago
Matthew Raymer 87438e7b6b fix: improve camera permission error feedback 4 weeks ago
Matthew Raymer 3ce2ea9b4e fix: standardize FontAwesome usage and improve error handling 4 weeks ago
Matthew Raymer 8e6ba68560 fix: correct import paths and add host flag for dev server 4 weeks ago
Matthew Raymer ca9ca5fca7 fix: prevent duplicate contacts during QR code scanning 4 weeks ago
Matthew Raymer 4abb188da3 refactor(qr): improve QR scanner robustness and lifecycle management 1 month ago
Matthew Raymer 30e448faf8 refactor(qr): improve QR code scanning robustness and error handling 1 month ago
Matthew Raymer a8812714a3 fix(qr): improve QR scanner implementation and error handling 1 month ago
Matthew Raymer 2855d4b8d5 chore: cleanup and test 1 month ago
Matthew Raymer b85e6d2958 Merge branch 'qrcode-reboot' of ssh://173.199.124.46:222/trent_larson/crowd-funder-for-time-pwa into qrcode-reboot 1 month ago
Matthew Raymer 7d260365be fix(deep-links): standardize DID parameter name and add route mapping docs 1 month ago
Matthew Raymer 72de271f6c feat: Add MLKit barcode scanning plugin for Android 1 month ago
Matthew Raymer 2055097cf2 feature(qrcode): reboot qrcode reader 1 month ago
Matthew Raymer 6b38b1a347 test: increase timeout for record offer test to 60s 1 month ago
Trent Larson ca455e9593 modify files to make the ios build & distribution work 1 month ago
Trent Larson 5ada70b05e fix the reference to the secrets file 1 month ago
Matthew Raymer 4f9b146a66 fix: improve file sharing on Android using app-private storage 1 month ago
Matthew Raymer 2b638ce2a7 chore: add an android build script to simplify creation of versions 1 month ago
Matthew Raymer 0b528af2a6 WIP: Fix Android file writing permissions and path handling 1 month ago
Matthew Raymer 008211bc21 feat(android): implement file picker for data export 1 month ago
Matthew Raymer 6955a36458 chore: clean up lock file 1 month ago
Matthew Raymer ba079ea983 chore: remove generated index.html from git repo 1 month ago
Matthew Raymer d7b3c5ec9d fix: remove last "any" lint messages 1 month ago
Matthew Raymer d83a25f47e chore: linted with auto-fix 1 month ago
Matthew Raymer fb40dc0ff7 feat(android): update Capacitor assets and fix Xcode project version 1 month ago
Matthew Raymer d03fa55001 refactor(platform): replace platform checks with capability-based system 2 months ago
Matthew Raymer c8eff4d39e chore(deps): update React Native and Metro dependencies 2 months ago
Matthew Raymer b8a7771edf feat(export): adapt DataExportSection for platform-specific file handling 2 months ago
Matthew Raymer 5d845fb112 docs: add comprehensive JSDoc documentation to service layer 2 months ago
Matthew Raymer 660f2170de fix: improve error handling in photo upload 2 months ago
Matthew Raymer 94bd649003 refactor: improve camera controls and modularize data export 2 months ago
Matthew Raymer b2d628cfeb chore: commit gitignore 2 months ago
Matthew Raymer 00e52f8dca feat: enhance error logging and upgrade Android build tools 2 months ago
Matthew Raymer 073ce24f43 chore(deps): Add Capacitor camera and filesystem plugins 2 months ago
Matthew Raymer 2c84bb50b3 **refactor(PhotoDialog, PlatformService): Implement cross-platform photo capture and encapsulated image processing** 2 months ago
Matthew Raymer abf18835f6 feat: update TypeScript config for platform services 2 months ago
Matthew Raymer f72562804d feat: update TypeScript config for platform services 2 months ago
Matthew Raymer bdc5ffafc1 baseline for this branch 2 months ago
  1. 292
      .cursor/rules/architectural_decision_record.mdc
  2. 222
      .cursor/rules/crowd-funder-for-time-pwa/docs/camera-implementation.mdc
  3. 276
      .cursor/rules/timesafari.mdc
  4. 3
      .eslintrc.js
  5. 5
      .gitignore
  6. 466
      BUILDING.md
  7. 42
      Dockerfile
  8. 22
      README.md
  9. 20
      android/.gitignore
  10. BIN
      android/.gradle/buildOutputCleanup/buildOutputCleanup.lock
  11. 2
      android/.gradle/buildOutputCleanup/cache.properties
  12. BIN
      android/.gradle/file-system.probe
  13. 0
      android/.gradle/vcs-1/gc.properties
  14. 2
      android/app/.gitignore
  15. 4
      android/app/build.gradle
  16. 5
      android/app/capacitor.build.gradle
  17. 28
      android/app/google-services.json
  18. 2
      android/app/src/androidTest/java/com/getcapacitor/myapp/ExampleInstrumentedTest.java
  19. 12
      android/app/src/main/AndroidManifest.xml
  20. 20
      android/app/src/main/assets/capacitor.plugins.json
  21. 17
      android/app/src/main/assets/public/index.html
  22. BIN
      android/app/src/main/res/mipmap-hdpi/ic_launcher.png
  23. BIN
      android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png
  24. BIN
      android/app/src/main/res/mipmap-mdpi/ic_launcher.png
  25. BIN
      android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png
  26. BIN
      android/app/src/main/res/mipmap-xhdpi/ic_launcher.png
  27. BIN
      android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png
  28. BIN
      android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
  29. BIN
      android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png
  30. BIN
      android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
  31. BIN
      android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png
  32. 1
      android/app/src/main/res/xml/file_paths.xml
  33. 2
      android/build.gradle
  34. 15
      android/capacitor.settings.gradle
  35. 2
      android/gradle/wrapper/gradle-wrapper.properties
  36. 2
      assets/README.md
  37. 4
      build.sh
  38. 21
      capacitor.config.json
  39. 25
      capacitor.config.ts
  40. 78
      doc/DEEP_LINKS.md
  41. 2
      doc/openssl_signing_console.rst
  42. 805
      doc/qr-code-implementation-guide.md
  43. 0
      doc/web-push.md
  44. 2
      index.html
  45. 10
      ios/.gitignore
  46. 38
      ios/App/App.xcodeproj/project.pbxproj
  47. 2
      ios/App/App.xcworkspace/contents.xcworkspacedata
  48. 4
      ios/App/App/Info.plist
  49. 20
      ios/App/App/entitlements.mac.plist
  50. 5
      ios/App/Podfile
  51. 126
      ios/App/Podfile.lock
  52. 8520
      package-lock.json
  53. 61
      package.json
  54. 1
      pkgx.yaml
  55. 289
      scripts/build-electron.js
  56. 22
      scripts/copy-web-assets.sh
  57. 22
      scripts/generate-icons.sh
  58. 4
      scripts/openssl_signing_console.sh
  59. 6
      scripts/test-ios.js
  60. 17
      src/App.vue
  61. 9
      src/components/ActivityListItem.vue
  62. 196
      src/components/DataExportSection.vue
  63. 6
      src/components/GiftedDialog.vue
  64. 511
      src/components/ImageMethodDialog.vue
  65. 1
      src/components/ImageViewer.vue
  66. 571
      src/components/PhotoDialog.vue
  67. 5
      src/components/QuickNav.vue
  68. 2
      src/components/TopMessage.vue
  69. 187
      src/electron/main.ts
  70. 4
      src/env.d.ts
  71. 103
      src/interfaces/deepLinks.ts
  72. 21
      src/interfaces/give.ts
  73. 0
      src/libs/capacitor/app.ts
  74. 48
      src/libs/crypto/index.ts
  75. 10
      src/libs/endorserServer.ts
  76. 4
      src/libs/fontawesome.ts
  77. 2
      src/main.capacitor.ts
  78. 2
      src/main.common.ts
  79. 2
      src/main.ts
  80. 5
      src/router/index.ts
  81. 101
      src/services/PlatformService.ts
  82. 58
      src/services/PlatformServiceFactory.ts
  83. 210
      src/services/QRScanner/CapacitorQRScanner.ts
  84. 100
      src/services/QRScanner/QRScannerFactory.ts
  85. 608
      src/services/QRScanner/WebInlineQRScanner.ts
  86. 69
      src/services/QRScanner/types.ts
  87. 35
      src/services/api.ts
  88. 107
      src/services/deepLinks.ts
  89. 59
      src/services/plan.ts
  90. 479
      src/services/platforms/CapacitorPlatformService.ts
  91. 111
      src/services/platforms/ElectronPlatformService.ts
  92. 112
      src/services/platforms/PyWebViewPlatformService.ts
  93. 362
      src/services/platforms/WebPlatformService.ts
  94. 103
      src/types/deepLinks.ts
  95. 25
      src/types/index.ts
  96. 47
      src/utils/LogCollector.ts
  97. 39
      src/utils/logger.ts
  98. 282
      src/views/AccountViewView.vue
  99. 841
      src/views/ContactQRScanShowView.vue
  100. 430
      src/views/ContactQRScanView.vue

292
.cursor/rules/architectural_decision_record.mdc

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

222
.cursor/rules/crowd-funder-for-time-pwa/docs/camera-implementation.mdc

@ -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

@ -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

3
.eslintrc.js

@ -26,6 +26,7 @@ module.exports = {
"no-debugger": process.env.NODE_ENV === "production" ? "error" : "warn", "no-debugger": process.env.NODE_ENV === "production" ? "error" : "warn",
"@typescript-eslint/no-explicit-any": "warn", "@typescript-eslint/no-explicit-any": "warn",
"@typescript-eslint/explicit-function-return-type": "off", "@typescript-eslint/explicit-function-return-type": "off",
"@typescript-eslint/no-unnecessary-type-constraint": "off" "@typescript-eslint/no-unnecessary-type-constraint": "off",
"@typescript-eslint/no-unused-vars": ["error", { "argsIgnorePattern": "^_" }]
}, },
}; };

5
.gitignore

@ -42,7 +42,7 @@ dist-electron-packages
.ruby-version .ruby-version
+.env +.env
# Generated test files # Test files generated by scripts test-ios.js & test-android.js
.generated/ .generated/
.env.default .env.default
@ -51,3 +51,6 @@ vendor/
# Build logs # Build logs
build_logs/ build_logs/
android/app/src/main/assets/public
android/app/src/main/res

466
BUILDING.md

@ -11,7 +11,7 @@ For a quick dev environment setup, use [pkgx](https://pkgx.dev).
- Git - Git
- For Android builds: Android Studio with SDK installed - For Android builds: Android Studio with SDK installed
- For iOS builds: macOS with Xcode and ruby gems & bundle - 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 - ... and you may have to fix these, especially with pkgx
@ -54,7 +54,7 @@ Install dependencies:
1. Run the production build: 1. Run the production build:
```bash ```bash
npm run build npm run build:web
``` ```
The built files will be in the `dist` directory. 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. * 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) ## Desktop Build (Electron)
@ -138,22 +236,76 @@ TIME_SAFARI_APP_TITLE="TimeSafari_Test" VITE_APP_SERVER=https://test.timesafari.
- AppImage: `dist-electron-packages/TimeSafari-x.x.x.AppImage` - AppImage: `dist-electron-packages/TimeSafari-x.x.x.AppImage`
- DEB: `dist-electron-packages/timesafari_x.x.x_amd64.deb` - 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 ### Running the Packaged App
- **Linux**:
- AppImage: Make executable and run - AppImage: Make executable and run
```bash ```bash
chmod +x dist-electron-packages/TimeSafari-*.AppImage chmod +x dist-electron-packages/TimeSafari-*.AppImage
./dist-electron-packages/TimeSafari-*.AppImage ./dist-electron-packages/TimeSafari-*.AppImage
``` ```
- DEB: Install and run - DEB: Install and run
```bash ```bash
sudo dpkg -i dist-electron-packages/timesafari_*_amd64.deb sudo dpkg -i dist-electron-packages/timesafari_*_amd64.deb
timesafari timesafari
``` ```
- **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
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 ### Development Testing
For testing the Electron build before packaging: For testing the Electron build before packaging:
@ -175,6 +327,7 @@ Prerequisites: macOS with Xcode installed
1. Build the web assets: 1. Build the web assets:
```bash ```bash
npm run build:web
npm run build:capacitor npm run build:capacitor
``` ```
@ -184,6 +337,8 @@ Prerequisites: macOS with Xcode installed
npx cap sync ios npx cap sync ios
``` ```
- If that fails with "Could not find..." then look at the "gem_path" instructions above.
3. Copy the assets: 3. Copy the assets:
```bash ```bash
@ -191,13 +346,38 @@ Prerequisites: macOS with Xcode installed
npx capacitor-assets generate --ios 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 ```bash
npx cap open ios 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 #### First-time iOS Configuration
@ -215,6 +395,10 @@ Prerequisites: Android Studio with SDK installed
rm -rf dist rm -rf dist
npm run build:web npm run build:web
npm run build:capacitor npm run build:capacitor
cd android
./gradlew clean
./gradlew assembleDebug
cd ..
``` ```
2. Update Android project with latest build: 2. Update Android project with latest build:
@ -229,13 +413,15 @@ Prerequisites: Android Studio with SDK installed
npx capacitor-assets generate --android 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 ```bash
npx cap open android 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 ## Android Build from the console
@ -253,12 +439,23 @@ Prerequisites: Android Studio with SDK installed
./gradlew bundleDebug -Dlint.baselines.continue=true ./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 ```bash
./gradlew bundleRelease -Dlint.baselines.continue=true ./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 ## First-time Android Configuration for deep links
@ -273,252 +470,3 @@ You must add the following intent filter to the `android/app/src/main/AndroidMan
<data android:scheme="timesafari" /> <data android:scheme="timesafari" />
</intent-filter> </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

@ -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

@ -31,7 +31,9 @@ See [TESTING.md](test-playwright/TESTING.md) for detailed test instructions.
## Icons ## 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 ## 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/",` * 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 ### Kudos
Gifts make the world go 'round! Gifts make the world go 'round!

20
android/.gitignore

@ -1,5 +1,17 @@
# Using Android gitignore template: https://github.com/github/gitignore/blob/HEAD/Android.gitignore # 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/gradle.properties.secrets
app/time-safari-upload-key-pkcs12.jks app/time-safari-upload-key-pkcs12.jks
@ -94,11 +106,3 @@ lint/tmp/
# Cordova plugins for Capacitor # Cordova plugins for Capacitor
capacitor-cordova-android-plugins 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

BIN
android/.gradle/buildOutputCleanup/buildOutputCleanup.lock

Binary file not shown.

2
android/.gradle/buildOutputCleanup/cache.properties

@ -1,2 +0,0 @@
#Fri Mar 21 07:27:50 UTC 2025
gradle.version=8.2.1

BIN
android/.gradle/file-system.probe

Binary file not shown.

0
android/.gradle/vcs-1/gc.properties

2
android/app/.gitignore

@ -1,2 +0,0 @@
/build/*
!/build/.npmkeep

4
android/app/build.gradle

@ -14,7 +14,7 @@ project.ext.MY_KEY_PASSWORD = System.getenv('ANDROID_KEY_PASSWORD') ?: ""
// If no environment variables, try to load from secrets file // If no environment variables, try to load from secrets file
if (!project.ext.MY_KEYSTORE_FILE) { if (!project.ext.MY_KEYSTORE_FILE) {
def secretsPropertiesFile = rootProject.file("gradle.properties.secrets") def secretsPropertiesFile = rootProject.file("app/gradle.properties.secrets")
if (secretsPropertiesFile.exists()) { if (secretsPropertiesFile.exists()) {
Properties secretsProperties = new Properties() Properties secretsProperties = new Properties()
secretsProperties.load(new FileInputStream(secretsPropertiesFile)) secretsProperties.load(new FileInputStream(secretsPropertiesFile))
@ -31,7 +31,7 @@ android {
applicationId "app.timesafari.app" applicationId "app.timesafari.app"
minSdkVersion rootProject.ext.minSdkVersion minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 9 versionCode 10
versionName "0.4.4" versionName "0.4.4"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions { aaptOptions {

5
android/app/capacitor.build.gradle

@ -9,7 +9,12 @@ android {
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle" apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
dependencies { dependencies {
implementation project(':capacitor-mlkit-barcode-scanning')
implementation project(':capacitor-app') implementation project(':capacitor-app')
implementation project(':capacitor-camera')
implementation project(':capacitor-filesystem')
implementation project(':capacitor-share')
implementation project(':capawesome-capacitor-file-picker')
} }

28
android/app/google-services.json

@ -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": []
}
}
}
]
}

2
android/app/src/androidTest/java/com/getcapacitor/myapp/ExampleInstrumentedTest.java

@ -21,6 +21,6 @@ public class ExampleInstrumentedTest {
// Context of the app under test. // Context of the app under test.
Context appContext = InstrumentationRegistry.getInstrumentation().getTargetContext(); Context appContext = InstrumentationRegistry.getInstrumentation().getTargetContext();
assertEquals("app.timesafari", appContext.getPackageName()); assertEquals("app.timesafari.app", appContext.getPackageName());
} }
} }

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

@ -1,6 +1,5 @@
<?xml version="1.0" encoding="utf-8" ?> <?xml version="1.0" encoding="utf-8" ?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application <application
android:allowBackup="true" android:allowBackup="true"
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
@ -8,7 +7,6 @@
android:roundIcon="@mipmap/ic_launcher_round" android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true" android:supportsRtl="true"
android:theme="@style/AppTheme"> android:theme="@style/AppTheme">
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode" android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode"
@ -16,7 +14,6 @@
android:label="@string/title_activity_main" android:label="@string/title_activity_main"
android:launchMode="singleTask" android:launchMode="singleTask"
android:theme="@style/AppTheme.NoActionBarLaunch"> android:theme="@style/AppTheme.NoActionBarLaunch">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
@ -28,7 +25,6 @@
<category android:name="android.intent.category.BROWSABLE" /> <category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="timesafari" /> <data android:scheme="timesafari" />
</intent-filter> </intent-filter>
</activity> </activity>
<provider <provider
@ -36,13 +32,15 @@
android:authorities="${applicationId}.fileprovider" android:authorities="${applicationId}.fileprovider"
android:exported="false" android:exported="false"
android:grantUriPermissions="true"> android:grantUriPermissions="true">
<meta-data <meta-data android:name="android.support.FILE_PROVIDER_PATHS" android:resource="@xml/file_paths" />
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths"></meta-data>
</provider> </provider>
</application> </application>
<!-- Permissions --> <!-- Permissions -->
<uses-permission android:name="android.permission.INTERNET" /> <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> </manifest>

20
android/app/src/main/assets/capacitor.plugins.json

@ -1,6 +1,26 @@
[ [
{
"pkg": "@capacitor-mlkit/barcode-scanning",
"classpath": "io.capawesome.capacitorjs.plugins.mlkit.barcodescanning.BarcodeScannerPlugin"
},
{ {
"pkg": "@capacitor/app", "pkg": "@capacitor/app",
"classpath": "com.capacitorjs.plugins.app.AppPlugin" "classpath": "com.capacitorjs.plugins.app.AppPlugin"
},
{
"pkg": "@capacitor/camera",
"classpath": "com.capacitorjs.plugins.camera.CameraPlugin"
},
{
"pkg": "@capacitor/filesystem",
"classpath": "com.capacitorjs.plugins.filesystem.FilesystemPlugin"
},
{
"pkg": "@capacitor/share",
"classpath": "com.capacitorjs.plugins.share.SharePlugin"
},
{
"pkg": "@capawesome/capacitor-file-picker",
"classpath": "io.capawesome.capacitorjs.plugins.filepicker.FilePickerPlugin"
} }
] ]

17
android/app/src/main/assets/public/index.html

@ -1,17 +0,0 @@
<!DOCTYPE html>
<html lang="">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<link rel="icon" href="/favicon.ico">
<title>TimeSafari</title>
<script type="module" crossorigin src="/assets/index-CZMUlUNO.js"></script>
</head>
<body>
<noscript>
<strong>We're sorry but TimeSafari doesn't work properly without JavaScript enabled. Please enable it to continue.</strong>
</noscript>
<div id="app"></div>
</body>
</html>

BIN
android/app/src/main/res/mipmap-hdpi/ic_launcher.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

BIN
android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

BIN
android/app/src/main/res/mipmap-mdpi/ic_launcher.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

BIN
android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

BIN
android/app/src/main/res/mipmap-xhdpi/ic_launcher.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

BIN
android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

BIN
android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

BIN
android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

BIN
android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

BIN
android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

1
android/app/src/main/res/xml/file_paths.xml

@ -2,4 +2,5 @@
<paths xmlns:android="http://schemas.android.com/apk/res/android"> <paths xmlns:android="http://schemas.android.com/apk/res/android">
<external-path name="my_images" path="." /> <external-path name="my_images" path="." />
<cache-path name="my_cache_images" path="." /> <cache-path name="my_cache_images" path="." />
<files-path name="my_files" path="." />
</paths> </paths>

2
android/build.gradle

@ -7,7 +7,7 @@ buildscript {
mavenCentral() mavenCentral()
} }
dependencies { dependencies {
classpath 'com.android.tools.build:gradle:8.2.1' classpath 'com.android.tools.build:gradle:8.9.1'
classpath 'com.google.gms:google-services:4.4.0' classpath 'com.google.gms:google-services:4.4.0'
// NOTE: Do not place your application dependencies here; they belong // NOTE: Do not place your application dependencies here; they belong

15
android/capacitor.settings.gradle

@ -2,5 +2,20 @@
include ':capacitor-android' include ':capacitor-android'
project(':capacitor-android').projectDir = new File('../node_modules/@capacitor/android/capacitor') 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' include ':capacitor-app'
project(':capacitor-app').projectDir = new File('../node_modules/@capacitor/app/android') project(':capacitor-app').projectDir = new File('../node_modules/@capacitor/app/android')
include ':capacitor-camera'
project(':capacitor-camera').projectDir = new File('../node_modules/@capacitor/camera/android')
include ':capacitor-filesystem'
project(':capacitor-filesystem').projectDir = new File('../node_modules/@capacitor/filesystem/android')
include ':capacitor-share'
project(':capacitor-share').projectDir = new File('../node_modules/@capacitor/share/android')
include ':capawesome-capacitor-file-picker'
project(':capawesome-capacitor-file-picker').projectDir = new File('../node_modules/@capawesome/capacitor-file-picker/android')

2
android/gradle/wrapper/gradle-wrapper.properties

@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.2.1-all.zip distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-all.zip
networkTimeout=10000 networkTimeout=10000
validateDistributionUrl=true validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME

2
assets/README.md

@ -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

@ -0,0 +1,4 @@
#!/bin/bash
export IMAGENAME="$(basename $PWD):1.0"
docker build . --network=host -t $IMAGENAME --no-cache

21
capacitor.config.json

@ -0,0 +1,21 @@
{
"appId": "app.timesafari",
"appName": "TimeSafari",
"webDir": "dist",
"bundledWebRuntime": false,
"server": {
"cleartext": true
},
"plugins": {
"App": {
"appUrlOpen": {
"handlers": [
{
"url": "timesafari://*",
"autoVerify": true
}
]
}
}
}
}

25
capacitor.config.ts

@ -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;

78
docs/DEEP_LINKS.md → doc/DEEP_LINKS.md

@ -9,21 +9,95 @@ The deep linking system uses a multi-layered type safety approach:
- Enforces parameter requirements - Enforces parameter requirements
- Sanitizes input data - Sanitizes input data
- Provides detailed validation errors - Provides detailed validation errors
- Generates TypeScript types automatically
2. **TypeScript Types** 2. **TypeScript Types**
- Generated from Zod schemas - Generated from Zod schemas using `z.infer`
- Ensures compile-time type safety - Ensures compile-time type safety
- Provides IDE autocompletion - Provides IDE autocompletion
- Catches type errors during development - Catches type errors during development
- Maintains single source of truth for types
3. **Router Integration** 3. **Router Integration**
- Type-safe parameter passing - Type-safe parameter passing
- Route-specific parameter validation - Route-specific parameter validation
- Query parameter type checking - 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 ## 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/services/deepLinks.ts`: Deep link processing service
- `src/main.capacitor.ts`: Capacitor integration - `src/main.capacitor.ts`: Capacitor integration

2
openssl_signing_console.rst → doc/openssl_signing_console.rst

@ -1,6 +1,6 @@
JWT Creation & Verification JWT Creation & Verification
To run this in a script, see ./openssl_signing_console.sh To run this in a script, see /scripts/openssl_signing_console.sh
Prerequisites: openssl, jq Prerequisites: openssl, jq

805
doc/qr-code-implementation-guide.md

@ -0,0 +1,805 @@
# QR Code Implementation Guide
## Overview
This document describes the QR code scanning and generation implementation in the TimeSafari application. The system uses a platform-agnostic design with specific implementations for web and mobile platforms.
## Architecture
### Directory Structure
```
src/
├── services/
│ └── QRScanner/
│ ├── types.ts # Core interfaces and types
│ ├── QRScannerFactory.ts # Factory for creating scanner instances
│ ├── CapacitorQRScanner.ts # Mobile implementation using MLKit
│ ├── WebInlineQRScanner.ts # Web implementation using MediaDevices API
│ └── interfaces.ts # Additional interfaces
├── components/
│ └── QRScanner/
│ └── QRScannerDialog.vue # Shared UI component
└── views/
├── ContactQRScanView.vue # Dedicated scanning view
└── ContactQRScanShowView.vue # Combined QR display and scanning view
```
### Core Components
1. **Factory Pattern**
- `QRScannerFactory` - Creates appropriate scanner instance based on platform
- Common interface `QRScannerService` implemented by all scanners
- Platform detection via Capacitor and build flags
2. **Platform-Specific Implementations**
- `CapacitorQRScanner` - Native mobile implementation using MLKit
- `WebInlineQRScanner` - Web browser implementation using MediaDevices API
- `QRScannerDialog.vue` - Shared UI component
3. **View Components**
- `ContactQRScanView` - Dedicated view for scanning QR codes
- `ContactQRScanShowView` - Combined view for displaying and scanning QR codes
## Implementation Details
### Core Interfaces
```typescript
interface QRScannerService {
checkPermissions(): Promise<boolean>;
requestPermissions(): Promise<boolean>;
isSupported(): Promise<boolean>;
startScan(options?: QRScannerOptions): Promise<void>;
stopScan(): Promise<void>;
addListener(listener: ScanListener): void;
onStream(callback: (stream: MediaStream | null) => void): void;
cleanup(): Promise<void>;
getAvailableCameras(): Promise<MediaDeviceInfo[]>;
switchCamera(deviceId: string): Promise<void>;
getCurrentCamera(): Promise<MediaDeviceInfo | null>;
}
interface ScanListener {
onScan: (result: string) => void;
onError?: (error: Error) => void;
}
interface QRScannerOptions {
camera?: "front" | "back";
showPreview?: boolean;
playSound?: boolean;
}
```
### Platform-Specific Implementations
#### Mobile (Capacitor)
- Uses `@capacitor-mlkit/barcode-scanning`
- Native camera access through platform APIs
- Optimized for mobile performance
- Supports both iOS and Android
- Real-time QR code detection
- Back camera preferred for scanning
Configuration:
```typescript
// capacitor.config.ts
const config: CapacitorConfig = {
plugins: {
MLKitBarcodeScanner: {
formats: ['QR_CODE'],
detectorSize: 1.0,
lensFacing: 'back',
googleBarcodeScannerModuleInstallState: true,
// Additional camera options
cameraOptions: {
quality: 0.8,
allowEditing: false,
resultType: 'uri',
sourceType: 'CAMERA',
saveToGallery: false
}
}
}
};
```
#### Web
- Uses browser's MediaDevices API
- Vue.js components for UI
- EventEmitter for stream management
- Browser-based camera access
- Inline camera preview
- Responsive design
- Cross-browser compatibility
### View Components
#### ContactQRScanView
- Dedicated view for scanning QR codes
- Full-screen camera interface
- Simple UI focused on scanning
- Used primarily on native platforms
- Streamlined scanning experience
#### ContactQRScanShowView
- Combined view for QR code display and scanning
- Shows user's own QR code
- Handles user registration status
- Provides options to copy contact information
- Platform-specific scanning implementation:
- Native: Button to navigate to ContactQRScanView
- Web: Built-in scanning functionality
### QR Code Workflow
1. **Initiation**
- User selects "Scan QR Code" option
- Platform-specific scanner is initialized
- Camera permissions are verified
- Appropriate scanner component is loaded
2. **Platform-Specific Implementation**
- Web: Uses `qrcode-stream` for real-time scanning
- Native: Uses `@capacitor-mlkit/barcode-scanning`
3. **Scanning Process**
- Camera stream initialization
- Real-time frame analysis
- QR code detection and decoding
- Validation of QR code format
- Processing of contact information
4. **Contact Processing**
- Decryption of contact data
- Validation of user information
- Verification of timestamp
- Check for duplicate contacts
- Processing of shared data
## Build Configuration
### Common Vite Configuration
```typescript
// vite.config.common.mts
export async function createBuildConfig(mode: string) {
const isCapacitor = mode === "capacitor";
return defineConfig({
define: {
'process.env.VITE_PLATFORM': JSON.stringify(mode),
'process.env.VITE_PWA_ENABLED': JSON.stringify(!isNative),
__IS_MOBILE__: JSON.stringify(isCapacitor),
__USE_QR_READER__: JSON.stringify(!isCapacitor)
},
optimizeDeps: {
include: [
'@capacitor-mlkit/barcode-scanning',
'vue-qrcode-reader'
]
}
});
}
```
### Platform-Specific Builds
```json
{
"scripts": {
"build:web": "vite build --config vite.config.web.mts",
"build:capacitor": "vite build --config vite.config.capacitor.mts",
"build:all": "npm run build:web && npm run build:capacitor"
}
}
```
## Error Handling
### Common Error Scenarios
1. No camera found
2. Permission denied
3. Camera in use by another application
4. HTTPS required
5. Browser compatibility issues
6. Invalid QR code format
7. Expired QR codes
8. Duplicate contact attempts
9. Network connectivity issues
### Error Response
- User-friendly error messages
- Troubleshooting tips
- Clear instructions for resolution
- Platform-specific guidance
## Security Considerations
### QR Code Security
- Encryption of contact data
- Timestamp validation
- Version checking
- User verification
- Rate limiting for scans
### Data Protection
- Secure transmission of contact data
- Validation of QR code authenticity
- Prevention of duplicate scans
- Protection against malicious codes
- Secure storage of contact information
## Best Practices
### Camera Access
1. Always check for camera availability
2. Request permissions explicitly
3. Handle all error conditions
4. Provide clear user feedback
5. Implement proper cleanup
### Performance
1. Optimize camera resolution
2. Implement proper resource cleanup
3. Handle camera switching efficiently
4. Manage memory usage
5. Battery usage optimization
### User Experience
1. Clear visual feedback
2. Camera preview
3. Scanning status indicators
4. Error messages
5. Success confirmations
6. Intuitive camera controls
7. Smooth camera switching
8. Responsive UI feedback
## Testing
### Test Scenarios
1. Permission handling
2. Camera switching
3. Error conditions
4. Platform compatibility
5. Performance metrics
6. QR code detection
7. Contact processing
8. Security validation
### Test Environment
- Multiple browsers
- iOS and Android devices
- Various network conditions
- Different camera configurations
## Dependencies
### Key Packages
- `@capacitor-mlkit/barcode-scanning`
- `qrcode-stream`
- `vue-qrcode-reader`
- Platform-specific camera APIs
## Maintenance
### Regular Updates
- Keep dependencies updated
- Monitor platform changes
- Update documentation
- Review security patches
### Performance Monitoring
- Track memory usage
- Monitor camera performance
- Check error rates
- Analyze user feedback
## Camera Handling
### Camera Switching Implementation
The QR scanner supports camera switching on both mobile and desktop platforms through a unified interface.
#### Platform-Specific Implementations
1. **Mobile (Capacitor)**
- Uses `@capacitor-mlkit/barcode-scanning`
- Supports front/back camera switching
- Native camera access through platform APIs
- Optimized for mobile performance
```typescript
// CapacitorQRScanner.ts
async startScan(options?: QRScannerOptions): Promise<void> {
const scanOptions: StartScanOptions = {
formats: [BarcodeFormat.QrCode],
lensFacing: options?.camera === "front" ?
LensFacing.Front : LensFacing.Back
};
await BarcodeScanner.startScan(scanOptions);
}
```
2. **Web (Desktop)**
- Uses browser's MediaDevices API
- Supports multiple camera devices
- Dynamic camera enumeration
- Real-time camera switching
```typescript
// WebInlineQRScanner.ts
async getAvailableCameras(): Promise<MediaDeviceInfo[]> {
const devices = await navigator.mediaDevices.enumerateDevices();
return devices.filter(device => device.kind === 'videoinput');
}
async switchCamera(deviceId: string): Promise<void> {
// Stop current stream
await this.stopScan();
// Start new stream with selected camera
this.stream = await navigator.mediaDevices.getUserMedia({
video: {
deviceId: { exact: deviceId },
width: { ideal: 1280 },
height: { ideal: 720 }
}
});
// Update video and restart scanning
if (this.video) {
this.video.srcObject = this.stream;
await this.video.play();
}
this.scanQRCode();
}
```
### Core Interfaces
```typescript
interface QRScannerService {
// ... existing methods ...
/** Get available cameras */
getAvailableCameras(): Promise<MediaDeviceInfo[]>;
/** Switch to a specific camera */
switchCamera(deviceId: string): Promise<void>;
/** Get current camera info */
getCurrentCamera(): Promise<MediaDeviceInfo | null>;
}
interface QRScannerOptions {
/** Camera to use ('front' or 'back' for mobile) */
camera?: "front" | "back";
/** Whether to show a preview of the camera feed */
showPreview?: boolean;
/** Whether to play a sound on successful scan */
playSound?: boolean;
}
```
### UI Components
The camera switching UI adapts to the platform:
1. **Mobile Interface**
- Simple toggle button for front/back cameras
- Positioned in bottom-right corner
- Clear visual feedback during switching
- Native camera controls
```vue
<button
v-if="isNativePlatform"
@click="toggleMobileCamera"
class="camera-switch-btn"
>
<font-awesome icon="camera-rotate" />
Switch Camera
</button>
```
2. **Desktop Interface**
- Dropdown menu with all available cameras
- Camera labels and device IDs
- Real-time camera switching
- Responsive design
```vue
<select
v-model="selectedCameraId"
@change="onCameraChange"
class="camera-select-dropdown"
>
<option
v-for="camera in availableCameras"
:key="camera.deviceId"
:value="camera.deviceId"
>
{{ camera.label || `Camera ${camera.deviceId.slice(0, 4)}` }}
</option>
</select>
```
### Error Handling
The camera switching implementation includes comprehensive error handling:
1. **Common Error Scenarios**
- Camera in use by another application
- Permission denied during switch
- Device not available
- Stream initialization failure
- Camera switch timeout
2. **Error Response**
```typescript
private async handleCameraSwitch(deviceId: string): Promise<void> {
try {
this.updateCameraState("initializing", "Switching camera...");
await this.switchCamera(deviceId);
this.updateCameraState("active", "Camera switched successfully");
} catch (error) {
this.updateCameraState("error", "Failed to switch camera");
throw error;
}
}
```
3. **User Feedback**
- Visual indicators during switching
- Error notifications
- Camera state updates
- Permission request dialogs
### State Management
The camera system maintains several states:
1. **Camera States**
```typescript
type CameraState =
| "initializing" // Camera is being initialized
| "ready" // Camera is ready to use
| "active" // Camera is actively streaming
| "in_use" // Camera is in use by another application
| "permission_denied" // Camera permission was denied
| "not_found" // No camera found on device
| "error" // Generic error state
| "off"; // Camera is off
```
2. **State Transitions**
- Initialization → Ready
- Ready → Active
- Active → Switching
- Switching → Active/Error
- Any state → Off (on cleanup)
### Best Practices
1. **Camera Access**
- Always check permissions before switching
- Handle camera busy states
- Implement proper cleanup
- Monitor camera state changes
2. **Performance**
- Optimize camera resolution
- Handle stream switching efficiently
- Manage memory usage
- Implement proper cleanup
3. **User Experience**
- Clear visual feedback
- Smooth camera transitions
- Intuitive camera controls
- Responsive UI updates
- Accessible camera selection
4. **Security**
- Secure camera access
- Permission management
- Device validation
- Stream security
### Testing
1. **Test Scenarios**
- Camera switching on both platforms
- Permission handling
- Error conditions
- Multiple camera devices
- Camera busy states
- Stream initialization
- UI responsiveness
2. **Test Environment**
- Multiple mobile devices
- Various desktop browsers
- Different camera configurations
- Network conditions
- Permission states
### Capacitor Implementation Details
#### MLKit Barcode Scanner Configuration
1. **Plugin Setup**
```typescript
// capacitor.config.ts
const config: CapacitorConfig = {
plugins: {
MLKitBarcodeScanner: {
formats: ['QR_CODE'],
detectorSize: 1.0,
lensFacing: 'back',
googleBarcodeScannerModuleInstallState: true,
// Additional camera options
cameraOptions: {
quality: 0.8,
allowEditing: false,
resultType: 'uri',
sourceType: 'CAMERA',
saveToGallery: false
}
}
}
};
```
2. **Camera Management**
```typescript
// CapacitorQRScanner.ts
export class CapacitorQRScanner implements QRScannerService {
private currentLensFacing: LensFacing = LensFacing.Back;
async getAvailableCameras(): Promise<MediaDeviceInfo[]> {
// On mobile, we have two fixed cameras
return [
{
deviceId: 'back',
label: 'Back Camera',
kind: 'videoinput'
},
{
deviceId: 'front',
label: 'Front Camera',
kind: 'videoinput'
}
] as MediaDeviceInfo[];
}
async switchCamera(deviceId: string): Promise<void> {
if (!this.isScanning) return;
const newLensFacing = deviceId === 'front' ?
LensFacing.Front : LensFacing.Back;
// Stop current scan
await this.stopScan();
// Update lens facing
this.currentLensFacing = newLensFacing;
// Restart scan with new camera
await this.startScan({
camera: deviceId as 'front' | 'back'
});
}
async getCurrentCamera(): Promise<MediaDeviceInfo | null> {
return {
deviceId: this.currentLensFacing === LensFacing.Front ? 'front' : 'back',
label: this.currentLensFacing === LensFacing.Front ?
'Front Camera' : 'Back Camera',
kind: 'videoinput'
} as MediaDeviceInfo;
}
}
```
3. **Camera State Management**
```typescript
// CapacitorQRScanner.ts
private async handleCameraState(): Promise<void> {
try {
// Check if camera is available
const { camera } = await BarcodeScanner.checkPermissions();
if (camera === 'denied') {
this.updateCameraState('permission_denied');
return;
}
// Check if camera is in use
const isInUse = await this.isCameraInUse();
if (isInUse) {
this.updateCameraState('in_use');
return;
}
this.updateCameraState('ready');
} catch (error) {
this.updateCameraState('error', error.message);
}
}
private async isCameraInUse(): Promise<boolean> {
try {
// Try to start a test scan
await BarcodeScanner.startScan({
formats: [BarcodeFormat.QrCode],
lensFacing: this.currentLensFacing
});
// If successful, stop it immediately
await BarcodeScanner.stopScan();
return false;
} catch (error) {
return error.message.includes('camera in use');
}
}
```
4. **Error Handling**
```typescript
// CapacitorQRScanner.ts
private async handleCameraError(error: Error): Promise<void> {
switch (error.name) {
case 'CameraPermissionDenied':
this.updateCameraState('permission_denied');
break;
case 'CameraInUse':
this.updateCameraState('in_use');
break;
case 'CameraUnavailable':
this.updateCameraState('not_found');
break;
default:
this.updateCameraState('error', error.message);
}
}
```
#### Platform-Specific Considerations
1. **iOS Implementation**
- Camera permissions in Info.plist
- Privacy descriptions
- Camera usage description
- Background camera access
```xml
<!-- ios/App/App/Info.plist -->
<key>NSCameraUsageDescription</key>
<string>We need access to your camera to scan QR codes</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>We need access to save scanned QR codes</string>
```
2. **Android Implementation**
- Camera permissions in AndroidManifest.xml
- Runtime permission handling
- Camera features declaration
- Hardware feature requirements
```xml
<!-- android/app/src/main/AndroidManifest.xml -->
<uses-permission android:name="android.permission.CAMERA" />
<uses-feature android:name="android.hardware.camera" />
<uses-feature android:name="android.hardware.camera.autofocus" />
```
3. **Platform-Specific Features**
- iOS: Camera orientation handling
- Android: Camera resolution optimization
- Both: Battery usage optimization
- Both: Memory management
```typescript
// Platform-specific optimizations
private getPlatformSpecificOptions(): StartScanOptions {
const baseOptions: StartScanOptions = {
formats: [BarcodeFormat.QrCode],
lensFacing: this.currentLensFacing
};
if (Capacitor.getPlatform() === 'ios') {
return {
...baseOptions,
// iOS-specific options
cameraOptions: {
quality: 0.7, // Lower quality for better performance
allowEditing: false,
resultType: 'uri'
}
};
} else if (Capacitor.getPlatform() === 'android') {
return {
...baseOptions,
// Android-specific options
cameraOptions: {
quality: 0.8,
allowEditing: false,
resultType: 'uri',
saveToGallery: false
}
};
}
return baseOptions;
}
```
#### Performance Optimization
1. **Battery Usage**
```typescript
// CapacitorQRScanner.ts
private optimizeBatteryUsage(): void {
// Reduce scan frequency when battery is low
if (this.isLowBattery()) {
this.scanInterval = 2000; // 2 seconds between scans
} else {
this.scanInterval = 1000; // 1 second between scans
}
}
private isLowBattery(): boolean {
// Check battery level if available
if (Capacitor.isPluginAvailable('Battery')) {
const { level } = await Battery.getBatteryLevel();
return level < 0.2; // 20% or lower
}
return false;
}
```
2. **Memory Management**
```typescript
// CapacitorQRScanner.ts
private async cleanupResources(): Promise<void> {
// Stop scanning
await this.stopScan();
// Clear any stored camera data
this.currentLensFacing = LensFacing.Back;
// Remove listeners
this.listenerHandles.forEach(handle => handle());
this.listenerHandles = [];
// Reset state
this.isScanning = false;
this.updateCameraState('off');
}
```
#### Testing on Capacitor
1. **Device Testing**
- Test on multiple iOS devices
- Test on multiple Android devices
- Test different camera configurations
- Test with different screen sizes
- Test with different OS versions
2. **Camera Testing**
- Test front camera switching
- Test back camera switching
- Test camera permissions
- Test camera in use scenarios
- Test low light conditions
- Test different QR code sizes
- Test different QR code distances
3. **Performance Testing**
- Battery usage monitoring
- Memory usage monitoring
- Camera switching speed
- QR code detection speed
- App responsiveness
- Background/foreground transitions

0
web-push.md → doc/web-push.md

2
index.html

@ -3,7 +3,7 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge"> <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"> <link rel="icon" href="/favicon.ico">
<title>TimeSafari</title> <title>TimeSafari</title>
</head> </head>

10
ios/.gitignore

@ -4,7 +4,6 @@ App/output
App/App/public App/App/public
DerivedData DerivedData
xcuserdata xcuserdata
*.xcuserstate
# Cordova plugins for Capacitor # Cordova plugins for Capacitor
capacitor-cordova-ios-plugins capacitor-cordova-ios-plugins
@ -12,12 +11,3 @@ capacitor-cordova-ios-plugins
# Generated Config files # Generated Config files
App/App/capacitor.config.json App/App/capacitor.config.json
App/App/config.xml App/App/config.xml
# User-specific Xcode files
App/App.xcodeproj/xcuserdata/*.xcuserdatad/
App/App.xcodeproj/*.xcuserstate
fastlane/report.xml
fastlane/Preview.html
fastlane/screenshots
fastlane/test_output

38
ios/App/Time Safari.xcodeproj/project.pbxproj → ios/App/App.xcodeproj/project.pbxproj

@ -20,7 +20,7 @@
/* Begin PBXFileReference section */ /* Begin PBXFileReference section */
2FAD9762203C412B000D30F8 /* config.xml */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = config.xml; sourceTree = "<group>"; }; 2FAD9762203C412B000D30F8 /* config.xml */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = config.xml; sourceTree = "<group>"; };
50379B222058CBB4000EE86E /* capacitor.config.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = capacitor.config.json; sourceTree = "<group>"; }; 50379B222058CBB4000EE86E /* capacitor.config.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = capacitor.config.json; sourceTree = "<group>"; };
504EC3041FED79650016851F /* Time Safari.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Time Safari.app"; sourceTree = BUILT_PRODUCTS_DIR; }; 504EC3041FED79650016851F /* App.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = App.app; sourceTree = BUILT_PRODUCTS_DIR; };
504EC3071FED79650016851F /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; }; 504EC3071FED79650016851F /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
504EC30C1FED79650016851F /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = "<group>"; }; 504EC30C1FED79650016851F /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = "<group>"; };
504EC30E1FED79650016851F /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; }; 504EC30E1FED79650016851F /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
@ -65,7 +65,7 @@
504EC3051FED79650016851F /* Products */ = { 504EC3051FED79650016851F /* Products */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
504EC3041FED79650016851F /* Time Safari.app */, 504EC3041FED79650016851F /* App.app */,
); );
name = Products; name = Products;
sourceTree = "<group>"; sourceTree = "<group>";
@ -97,9 +97,9 @@
/* End PBXGroup section */ /* End PBXGroup section */
/* Begin PBXNativeTarget section */ /* Begin PBXNativeTarget section */
504EC3031FED79650016851F /* Time Safari */ = { 504EC3031FED79650016851F /* App */ = {
isa = PBXNativeTarget; isa = PBXNativeTarget;
buildConfigurationList = 504EC3161FED79650016851F /* Build configuration list for PBXNativeTarget "Time Safari" */; buildConfigurationList = 504EC3161FED79650016851F /* Build configuration list for PBXNativeTarget "App" */;
buildPhases = ( buildPhases = (
6634F4EFEBD30273BCE97C65 /* [CP] Check Pods Manifest.lock */, 6634F4EFEBD30273BCE97C65 /* [CP] Check Pods Manifest.lock */,
504EC3001FED79650016851F /* Sources */, 504EC3001FED79650016851F /* Sources */,
@ -111,9 +111,9 @@
); );
dependencies = ( dependencies = (
); );
name = "Time Safari"; name = App;
productName = App; productName = App;
productReference = 504EC3041FED79650016851F /* Time Safari.app */; productReference = 504EC3041FED79650016851F /* App.app */;
productType = "com.apple.product-type.application"; productType = "com.apple.product-type.application";
}; };
/* End PBXNativeTarget section */ /* End PBXNativeTarget section */
@ -122,8 +122,8 @@
504EC2FC1FED79650016851F /* Project object */ = { 504EC2FC1FED79650016851F /* Project object */ = {
isa = PBXProject; isa = PBXProject;
attributes = { attributes = {
LastSwiftUpdateCheck = 0920; LastSwiftUpdateCheck = 920;
LastUpgradeCheck = 0920; LastUpgradeCheck = 920;
TargetAttributes = { TargetAttributes = {
504EC3031FED79650016851F = { 504EC3031FED79650016851F = {
CreatedOnToolsVersion = 9.2; CreatedOnToolsVersion = 9.2;
@ -132,7 +132,7 @@
}; };
}; };
}; };
buildConfigurationList = 504EC2FF1FED79650016851F /* Build configuration list for PBXProject "Time Safari" */; buildConfigurationList = 504EC2FF1FED79650016851F /* Build configuration list for PBXProject "App" */;
compatibilityVersion = "Xcode 8.0"; compatibilityVersion = "Xcode 8.0";
developmentRegion = en; developmentRegion = en;
hasScannedForEncodings = 0; hasScannedForEncodings = 0;
@ -147,7 +147,7 @@
projectDirPath = ""; projectDirPath = "";
projectRoot = ""; projectRoot = "";
targets = ( targets = (
504EC3031FED79650016851F /* Time Safari */, 504EC3031FED79650016851F /* App */,
); );
}; };
/* End PBXProject section */ /* End PBXProject section */
@ -348,20 +348,19 @@
buildSettings = { buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 17;
DEVELOPMENT_TEAM = GM3FS5JQPH; DEVELOPMENT_TEAM = GM3FS5JQPH;
INFOPLIST_FILE = App/Info.plist; INFOPLIST_FILE = App/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = "Time Safari";
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
IPHONEOS_DEPLOYMENT_TARGET = 13.0; IPHONEOS_DEPLOYMENT_TARGET = 13.0;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
MARKETING_VERSION = 1.0; MARKETING_VERSION = 0.4.6;
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\""; OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
PRODUCT_BUNDLE_IDENTIFIER = app.timesafari; PRODUCT_BUNDLE_IDENTIFIER = app.timesafari;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2"; TARGETED_DEVICE_FAMILY = "1,2";
VERSIONING_SYSTEM = "apple-generic"; /* allows agvtool to set *_VERSION settings */
}; };
name = Debug; name = Debug;
}; };
@ -371,26 +370,25 @@
buildSettings = { buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 17;
DEVELOPMENT_TEAM = GM3FS5JQPH; DEVELOPMENT_TEAM = GM3FS5JQPH;
INFOPLIST_FILE = App/Info.plist; INFOPLIST_FILE = App/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = "Time Safari";
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
IPHONEOS_DEPLOYMENT_TARGET = 13.0; IPHONEOS_DEPLOYMENT_TARGET = 13.0;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
MARKETING_VERSION = 1.0; MARKETING_VERSION = 0.4.6;
PRODUCT_BUNDLE_IDENTIFIER = app.timesafari; PRODUCT_BUNDLE_IDENTIFIER = app.timesafari;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = ""; SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2"; TARGETED_DEVICE_FAMILY = "1,2";
VERSIONING_SYSTEM = "apple-generic"; /* allows agvtool to set *_VERSION settings */
}; };
name = Release; name = Release;
}; };
/* End XCBuildConfiguration section */ /* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */ /* Begin XCConfigurationList section */
504EC2FF1FED79650016851F /* Build configuration list for PBXProject "Time Safari" */ = { 504EC2FF1FED79650016851F /* Build configuration list for PBXProject "App" */ = {
isa = XCConfigurationList; isa = XCConfigurationList;
buildConfigurations = ( buildConfigurations = (
504EC3141FED79650016851F /* Debug */, 504EC3141FED79650016851F /* Debug */,
@ -399,7 +397,7 @@
defaultConfigurationIsVisible = 0; defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release; defaultConfigurationName = Release;
}; };
504EC3161FED79650016851F /* Build configuration list for PBXNativeTarget "Time Safari" */ = { 504EC3161FED79650016851F /* Build configuration list for PBXNativeTarget "App" */ = {
isa = XCConfigurationList; isa = XCConfigurationList;
buildConfigurations = ( buildConfigurations = (
504EC3171FED79650016851F /* Debug */, 504EC3171FED79650016851F /* Debug */,

2
ios/App/App.xcworkspace/contents.xcworkspacedata

@ -2,7 +2,7 @@
<Workspace <Workspace
version = "1.0"> version = "1.0">
<FileRef <FileRef
location = "group:Time Safari.xcodeproj"> location = "group:App.xcodeproj">
</FileRef> </FileRef>
<FileRef <FileRef
location = "group:Pods/Pods.xcodeproj"> location = "group:Pods/Pods.xcodeproj">

4
ios/App/App/Info.plist

@ -22,6 +22,10 @@
<string>$(CURRENT_PROJECT_VERSION)</string> <string>$(CURRENT_PROJECT_VERSION)</string>
<key>LSRequiresIPhoneOS</key> <key>LSRequiresIPhoneOS</key>
<true/> <true/>
<key>NSCameraUsageDescription</key>
<string>Time Safari allows you to take photos, and also scan QR codes from contacts.</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>Time Safari allows you to upload photos.</string>
<key>UILaunchStoryboardName</key> <key>UILaunchStoryboardName</key>
<string>LaunchScreen</string> <string>LaunchScreen</string>
<key>UIMainStoryboardFile</key> <key>UIMainStoryboardFile</key>

20
ios/App/App/entitlements.mac.plist

@ -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>

5
ios/App/Podfile

@ -11,7 +11,12 @@ install! 'cocoapods', :disable_input_output_paths => true
def capacitor_pods def capacitor_pods
pod 'Capacitor', :path => '../../node_modules/@capacitor/ios' pod 'Capacitor', :path => '../../node_modules/@capacitor/ios'
pod 'CapacitorCordova', :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 'CapacitorApp', :path => '../../node_modules/@capacitor/app'
pod 'CapacitorCamera', :path => '../../node_modules/@capacitor/camera'
pod 'CapacitorFilesystem', :path => '../../node_modules/@capacitor/filesystem'
pod 'CapacitorShare', :path => '../../node_modules/@capacitor/share'
pod 'CapawesomeCapacitorFilePicker', :path => '../../node_modules/@capawesome/capacitor-file-picker'
end end
target 'App' do target 'App' do

126
ios/App/Podfile.lock

@ -1,28 +1,144 @@
PODS: PODS:
- Capacitor (6.2.0): - Capacitor (6.2.1):
- CapacitorCordova - CapacitorCordova
- CapacitorApp (6.0.2): - CapacitorApp (6.0.2):
- Capacitor - Capacitor
- CapacitorCordova (6.2.0) - CapacitorCamera (6.1.2):
- Capacitor
- CapacitorCordova (6.2.1)
- CapacitorFilesystem (6.0.3):
- Capacitor
- CapacitorMlkitBarcodeScanning (6.2.0):
- Capacitor
- GoogleMLKit/BarcodeScanning (= 5.0.0)
- CapacitorShare (6.0.3):
- Capacitor
- CapawesomeCapacitorFilePicker (6.2.0):
- Capacitor
- GoogleDataTransport (9.4.1):
- GoogleUtilities/Environment (~> 7.7)
- nanopb (< 2.30911.0, >= 2.30908.0)
- PromisesObjC (< 3.0, >= 1.2)
- GoogleMLKit/BarcodeScanning (5.0.0):
- GoogleMLKit/MLKitCore
- MLKitBarcodeScanning (~> 4.0.0)
- GoogleMLKit/MLKitCore (5.0.0):
- MLKitCommon (~> 10.0.0)
- GoogleToolboxForMac/DebugUtils (2.3.2):
- GoogleToolboxForMac/Defines (= 2.3.2)
- GoogleToolboxForMac/Defines (2.3.2)
- GoogleToolboxForMac/Logger (2.3.2):
- GoogleToolboxForMac/Defines (= 2.3.2)
- "GoogleToolboxForMac/NSData+zlib (2.3.2)":
- GoogleToolboxForMac/Defines (= 2.3.2)
- "GoogleToolboxForMac/NSDictionary+URLArguments (2.3.2)":
- GoogleToolboxForMac/DebugUtils (= 2.3.2)
- GoogleToolboxForMac/Defines (= 2.3.2)
- "GoogleToolboxForMac/NSString+URLArguments (= 2.3.2)"
- "GoogleToolboxForMac/NSString+URLArguments (2.3.2)"
- GoogleUtilities/Environment (7.13.3):
- GoogleUtilities/Privacy
- PromisesObjC (< 3.0, >= 1.2)
- GoogleUtilities/Logger (7.13.3):
- GoogleUtilities/Environment
- GoogleUtilities/Privacy
- GoogleUtilities/Privacy (7.13.3)
- GoogleUtilities/UserDefaults (7.13.3):
- GoogleUtilities/Logger
- GoogleUtilities/Privacy
- GoogleUtilitiesComponents (1.1.0):
- GoogleUtilities/Logger
- GTMSessionFetcher/Core (3.5.0)
- MLImage (1.0.0-beta5)
- MLKitBarcodeScanning (4.0.0):
- MLKitCommon (~> 10.0)
- MLKitVision (~> 6.0)
- MLKitCommon (10.0.0):
- GoogleDataTransport (~> 9.0)
- GoogleToolboxForMac/Logger (~> 2.1)
- "GoogleToolboxForMac/NSData+zlib (~> 2.1)"
- "GoogleToolboxForMac/NSDictionary+URLArguments (~> 2.1)"
- GoogleUtilities/UserDefaults (~> 7.0)
- GoogleUtilitiesComponents (~> 1.0)
- GTMSessionFetcher/Core (< 4.0, >= 1.1)
- MLKitVision (6.0.0):
- GoogleToolboxForMac/Logger (~> 2.1)
- "GoogleToolboxForMac/NSData+zlib (~> 2.1)"
- GTMSessionFetcher/Core (< 4.0, >= 1.1)
- MLImage (= 1.0.0-beta5)
- MLKitCommon (~> 10.0)
- nanopb (2.30910.0):
- nanopb/decode (= 2.30910.0)
- nanopb/encode (= 2.30910.0)
- nanopb/decode (2.30910.0)
- nanopb/encode (2.30910.0)
- PromisesObjC (2.4.0)
DEPENDENCIES: DEPENDENCIES:
- "Capacitor (from `../../node_modules/@capacitor/ios`)" - "Capacitor (from `../../node_modules/@capacitor/ios`)"
- "CapacitorApp (from `../../node_modules/@capacitor/app`)" - "CapacitorApp (from `../../node_modules/@capacitor/app`)"
- "CapacitorCamera (from `../../node_modules/@capacitor/camera`)"
- "CapacitorCordova (from `../../node_modules/@capacitor/ios`)" - "CapacitorCordova (from `../../node_modules/@capacitor/ios`)"
- "CapacitorFilesystem (from `../../node_modules/@capacitor/filesystem`)"
- "CapacitorMlkitBarcodeScanning (from `../../node_modules/@capacitor-mlkit/barcode-scanning`)"
- "CapacitorShare (from `../../node_modules/@capacitor/share`)"
- "CapawesomeCapacitorFilePicker (from `../../node_modules/@capawesome/capacitor-file-picker`)"
SPEC REPOS:
trunk:
- GoogleDataTransport
- GoogleMLKit
- GoogleToolboxForMac
- GoogleUtilities
- GoogleUtilitiesComponents
- GTMSessionFetcher
- MLImage
- MLKitBarcodeScanning
- MLKitCommon
- MLKitVision
- nanopb
- PromisesObjC
EXTERNAL SOURCES: EXTERNAL SOURCES:
Capacitor: Capacitor:
:path: "../../node_modules/@capacitor/ios" :path: "../../node_modules/@capacitor/ios"
CapacitorApp: CapacitorApp:
:path: "../../node_modules/@capacitor/app" :path: "../../node_modules/@capacitor/app"
CapacitorCamera:
:path: "../../node_modules/@capacitor/camera"
CapacitorCordova: CapacitorCordova:
:path: "../../node_modules/@capacitor/ios" :path: "../../node_modules/@capacitor/ios"
CapacitorFilesystem:
:path: "../../node_modules/@capacitor/filesystem"
CapacitorMlkitBarcodeScanning:
:path: "../../node_modules/@capacitor-mlkit/barcode-scanning"
CapacitorShare:
:path: "../../node_modules/@capacitor/share"
CapawesomeCapacitorFilePicker:
:path: "../../node_modules/@capawesome/capacitor-file-picker"
SPEC CHECKSUMS: SPEC CHECKSUMS:
Capacitor: 05d35014f4425b0740fc8776481f6a369ad071bf Capacitor: c95400d761e376be9da6be5a05f226c0e865cebf
CapacitorApp: e1e6b7d05e444d593ca16fd6d76f2b7c48b5aea7 CapacitorApp: e1e6b7d05e444d593ca16fd6d76f2b7c48b5aea7
CapacitorCordova: b33e7f4aa4ed105dd43283acdd940964374a87d9 CapacitorCamera: 9bc7b005d0e6f1d5f525b8137045b60cffffce79
CapacitorCordova: 8d93e14982f440181be7304aa9559ca631d77fff
CapacitorFilesystem: 59270a63c60836248812671aa3b15df673fbaf74
CapacitorMlkitBarcodeScanning: 7652be9c7922f39203a361de735d340ae37e134e
CapacitorShare: d2a742baec21c8f3b92b361a2fbd2401cdd8288e
CapawesomeCapacitorFilePicker: c40822f0a39f86855321943c7829d52bca7f01bd
GoogleDataTransport: 6c09b596d841063d76d4288cc2d2f42cc36e1e2a
GoogleMLKit: 90ba06e028795a50261f29500d238d6061538711
GoogleToolboxForMac: 8bef7c7c5cf7291c687cf5354f39f9db6399ad34
GoogleUtilities: ea963c370a38a8069cc5f7ba4ca849a60b6d7d15
GoogleUtilitiesComponents: 679b2c881db3b615a2777504623df6122dd20afe
GTMSessionFetcher: 5aea5ba6bd522a239e236100971f10cb71b96ab6
MLImage: 1824212150da33ef225fbd3dc49f184cf611046c
MLKitBarcodeScanning: 9cb0ec5ec65bbb5db31de4eba0a3289626beab4e
MLKitCommon: afcd11b6c0735066a0dde8b4bf2331f6197cbca2
MLKitVision: 90922bca854014a856f8b649d1f1f04f63fd9c79
nanopb: 438bc412db1928dac798aa6fd75726007be04262
PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47
PODFILE CHECKSUM: 4233f5c5f414604460ff96d372542c311b0fb7a8 PODFILE CHECKSUM: 7e7e09e6937de7f015393aecf2cf7823645689b3
COCOAPODS: 1.16.2 COCOAPODS: 1.16.2

8520
package-lock.json

File diff suppressed because it is too large

61
package.json

@ -1,12 +1,12 @@
{ {
"name": "timesafari", "name": "timesafari",
"version": "0.4.4", "version": "0.4.6",
"description": "Time Safari Application", "description": "Time Safari Application",
"author": { "author": {
"name": "Time Safari Team" "name": "Time Safari Team"
}, },
"scripts": { "scripts": {
"dev": "vite --config vite.config.dev.mts", "dev": "vite --config vite.config.dev.mts --host",
"serve": "vite preview", "serve": "vite preview",
"build": "VITE_GIT_HASH=`git log -1 --pretty=format:%h` vite build --config vite.config.mts", "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", "lint": "eslint --ext .js,.ts,.vue --ignore-path .gitignore src",
@ -22,11 +22,13 @@
"check:ios-device": "xcrun xctrace list devices 2>&1 | grep -w 'Booted' || (echo 'No iOS simulator running' && exit 1)", "check:ios-device": "xcrun xctrace list devices 2>&1 | grep -w 'Booted' || (echo 'No iOS simulator running' && exit 1)",
"clean:electron": "rimraf dist-electron", "clean:electron": "rimraf dist-electron",
"build:pywebview": "vite build --config vite.config.pywebview.mts", "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: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 --config vite.config.capacitor.mts", "build:capacitor": "vite build --mode capacitor --config vite.config.capacitor.mts",
"build:web": "vite build --config vite.config.web.mts", "build:web": "VITE_GIT_HASH=`git log -1 --pretty=format:%h` vite build --config vite.config.web.mts",
"electron:dev": "npm run build && electron dist-electron", "electron:dev": "npm run build && electron .",
"electron:start": "electron dist-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": "npm run build:electron && electron-builder --linux AppImage",
"electron:build-linux-deb": "npm run build:electron && electron-builder --linux deb", "electron:build-linux-deb": "npm run build:electron && electron-builder --linux deb",
"electron:build-linux-prod": "NODE_ENV=production npm run build:electron && electron-builder --linux AppImage", "electron:build-linux-prod": "NODE_ENV=production npm run build:electron && electron-builder --linux AppImage",
@ -39,14 +41,21 @@
"fastlane:ios:beta": "cd ios && fastlane beta", "fastlane:ios:beta": "cd ios && fastlane beta",
"fastlane:ios:release": "cd ios && fastlane release", "fastlane:ios:release": "cd ios && fastlane release",
"fastlane:android:beta": "cd android && fastlane beta", "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": { "dependencies": {
"@capacitor-mlkit/barcode-scanning": "^6.0.0",
"@capacitor/android": "^6.2.0", "@capacitor/android": "^6.2.0",
"@capacitor/app": "^6.0.0", "@capacitor/app": "^6.0.0",
"@capacitor/camera": "^6.0.0",
"@capacitor/cli": "^6.2.0", "@capacitor/cli": "^6.2.0",
"@capacitor/core": "^6.2.0", "@capacitor/core": "^6.2.0",
"@capacitor/filesystem": "^6.0.0",
"@capacitor/ios": "^6.2.0", "@capacitor/ios": "^6.2.0",
"@capacitor/share": "^6.0.3",
"@capawesome/capacitor-file-picker": "^6.2.0",
"@dicebear/collection": "^5.4.1", "@dicebear/collection": "^5.4.1",
"@dicebear/core": "^5.4.1", "@dicebear/core": "^5.4.1",
"@ethersproject/hdnode": "^5.7.0", "@ethersproject/hdnode": "^5.7.0",
@ -86,6 +95,7 @@
"jdenticon": "^3.2.0", "jdenticon": "^3.2.0",
"js-generate-password": "^0.1.9", "js-generate-password": "^0.1.9",
"js-yaml": "^4.1.0", "js-yaml": "^4.1.0",
"jsqr": "^1.4.0",
"leaflet": "^1.9.4", "leaflet": "^1.9.4",
"localstorage-slim": "^2.7.0", "localstorage-slim": "^2.7.0",
"lru-cache": "^10.2.0", "lru-cache": "^10.2.0",
@ -162,13 +172,12 @@
}, },
"files": [ "files": [
"dist-electron/**/*", "dist-electron/**/*",
"src/electron/**/*", "dist/**/*"
"main.js"
], ],
"extraResources": [ "extraResources": [
{ {
"from": "dist-electron", "from": "dist",
"to": "." "to": "www"
} }
], ],
"linux": { "linux": {
@ -179,6 +188,32 @@
"category": "Office", "category": "Office",
"icon": "build/icon.png" "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
pkgx.yaml

@ -1,5 +1,6 @@
dependencies: dependencies:
- gradle - gradle
- java - java
- pod
# other dependencies are discovered via package.json & requirements.txt & Gemfile (I'm guessing). # other dependencies are discovered via package.json & requirements.txt & Gemfile (I'm guessing).

289
scripts/build-electron.js

@ -1,98 +1,243 @@
const fs = require('fs');
const path = require('path'); const path = require('path');
const fs = require('fs-extra');
async function main() {
try {
console.log('Starting electron build process...'); console.log('Starting electron build process...');
// Create dist directory if it doesn't exist
const distElectronDir = path.resolve(__dirname, '../dist-electron');
await fs.ensureDir(distElectronDir);
// Copy web files // Copy web files
const wwwDir = path.join(distElectronDir, 'www'); const webDistPath = path.join(__dirname, '..', 'dist');
await fs.ensureDir(wwwDir); const electronDistPath = path.join(__dirname, '..', 'dist-electron');
await fs.copy('dist', wwwDir); const wwwPath = path.join(electronDistPath, 'www');
// Create www directory if it doesn't exist
if (!fs.existsSync(wwwPath)) {
fs.mkdirSync(wwwPath, { recursive: true });
}
// Copy and fix index.html // Copy web files to www directory
const indexPath = path.join(wwwDir, 'index.html'); fs.cpSync(webDistPath, wwwPath, { recursive: true });
let indexContent = await fs.readFile(indexPath, 'utf8');
// More comprehensive path fixing // Fix asset paths in index.html
const indexPath = path.join(wwwPath, 'index.html');
let indexContent = fs.readFileSync(indexPath, 'utf8');
// Fix asset paths
indexContent = indexContent indexContent = indexContent
// Fix absolute paths to be relative .replace(/\/assets\//g, './assets/')
.replace(/src="\//g, 'src="\./') .replace(/href="\//g, 'href="./')
.replace(/href="\//g, 'href="\./') .replace(/src="\//g, 'src="./');
// Fix modulepreload paths
.replace(/<link [^>]*rel="modulepreload"[^>]*href="\/assets\//g, '<link rel="modulepreload" as="script" crossorigin="" href="./assets/')
.replace(/<link [^>]*rel="modulepreload"[^>]*href="\.\/assets\//g, '<link rel="modulepreload" as="script" crossorigin="" href="./assets/')
// Fix stylesheet paths
.replace(/<link [^>]*rel="stylesheet"[^>]*href="\/assets\//g, '<link rel="stylesheet" crossorigin="" href="./assets/')
.replace(/<link [^>]*rel="stylesheet"[^>]*href="\.\/assets\//g, '<link rel="stylesheet" crossorigin="" href="./assets/')
// Fix script paths
.replace(/src="\/assets\//g, 'src="./assets/')
.replace(/src="\.\/assets\//g, 'src="./assets/')
// Fix any remaining asset paths
.replace(/(['"]\/?)(assets\/)/g, '"./assets/');
// Debug output
console.log('After path fixing, checking for remaining /assets/ paths:', indexContent.includes('/assets/'));
console.log('Sample of fixed content:', indexContent.slice(0, 500));
await fs.writeFile(indexPath, indexContent); fs.writeFileSync(indexPath, indexContent);
console.log('Copied and fixed web files in:', wwwDir); // Check for remaining /assets/ paths
console.log('After path fixing, checking for remaining /assets/ paths:', indexContent.includes('/assets/'));
console.log('Sample of fixed content:', indexContent.substring(0, 500));
console.log('Copied and fixed web files in:', wwwPath);
// Copy main process files // Copy main process files
console.log('Copying main process files...'); console.log('Copying main process files...');
const mainProcessFiles = [
['src/electron/main.js', 'main.js'],
['src/electron/preload.js', 'preload.js']
];
for (const [src, dest] of mainProcessFiles) {
const destPath = path.join(distElectronDir, dest);
console.log(`Copying ${src} to ${destPath}`);
await fs.copy(src, destPath);
}
// Create package.json for production // Create the main process file with inlined logger
const devPackageJson = require('../package.json'); const mainContent = `const { app, BrowserWindow } = require("electron");
const prodPackageJson = { const path = require("path");
name: devPackageJson.name, const fs = require("fs");
version: devPackageJson.version,
description: devPackageJson.description, // Inline logger implementation
author: devPackageJson.author, const logger = {
main: 'main.js', log: (...args) => console.log(...args),
private: true, error: (...args) => console.error(...args),
info: (...args) => console.info(...args),
warn: (...args) => console.warn(...args),
debug: (...args) => console.debug(...args),
}; };
await fs.writeJson( // Check if running in dev mode
path.join(distElectronDir, 'package.json'), const isDev = process.argv.includes("--inspect");
prodPackageJson,
{ spaces: 2 } 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));
// 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
},
); );
// Verify the build if (isDev) {
console.log('\nVerifying build structure:'); // Debug info
const files = await fs.readdir(distElectronDir); logger.log("Debug Info:");
console.log('Files in dist-electron:', files); 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 (!files.includes('main.js')) { if (isDev) {
throw new Error('main.js not found in build directory'); 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 (!files.includes('preload.js')) {
throw new Error('preload.js not found in build directory'); if (!fs.existsSync(indexPath)) {
logger.error(\`Index file not found at: \${indexPath}\`);
throw new Error("Index file not found");
} }
if (!files.includes('package.json')) {
throw new Error('package.json not found in build directory'); // 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);
},
);
console.log('Build completed successfully!'); mainWindow.webContents.on("preload-error", (_event, preloadPath, error) => {
} catch (error) { logger.error("Preload script error:", preloadPath, error);
console.error('Build failed:', error); });
process.exit(1);
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);
});
`;
// 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));
main(); console.log('Build completed successfully!');

22
scripts/copy-web-assets.sh

@ -0,0 +1,22 @@
#!/bin/bash
# Clean the public directory
rm -rf android/app/src/main/assets/public/*
# Copy web assets
cp -r dist/* android/app/src/main/assets/public/
# Ensure the directory structure exists
mkdir -p android/app/src/main/assets/public/assets
# Copy the main index file
cp dist/index.html android/app/src/main/assets/public/
# Copy all assets
cp -r dist/assets/* android/app/src/main/assets/public/assets/
# Copy other necessary files
cp dist/favicon.ico android/app/src/main/assets/public/
cp dist/robots.txt android/app/src/main/assets/public/
echo "Web assets copied successfully!"

22
scripts/generate-icons.sh

@ -0,0 +1,22 @@
#!/bin/bash
# Create directories if they don't exist
mkdir -p android/app/src/main/res/mipmap-mdpi
mkdir -p android/app/src/main/res/mipmap-hdpi
mkdir -p android/app/src/main/res/mipmap-xhdpi
mkdir -p android/app/src/main/res/mipmap-xxhdpi
mkdir -p android/app/src/main/res/mipmap-xxxhdpi
# Generate placeholder icons using ImageMagick
convert -size 48x48 xc:blue -gravity center -pointsize 20 -fill white -annotate 0 "TS" android/app/src/main/res/mipmap-mdpi/ic_launcher.png
convert -size 72x72 xc:blue -gravity center -pointsize 30 -fill white -annotate 0 "TS" android/app/src/main/res/mipmap-hdpi/ic_launcher.png
convert -size 96x96 xc:blue -gravity center -pointsize 40 -fill white -annotate 0 "TS" android/app/src/main/res/mipmap-xhdpi/ic_launcher.png
convert -size 144x144 xc:blue -gravity center -pointsize 60 -fill white -annotate 0 "TS" android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
convert -size 192x192 xc:blue -gravity center -pointsize 80 -fill white -annotate 0 "TS" android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
# Copy to round versions
cp android/app/src/main/res/mipmap-mdpi/ic_launcher.png android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png
cp android/app/src/main/res/mipmap-hdpi/ic_launcher.png android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png
cp android/app/src/main/res/mipmap-xhdpi/ic_launcher.png android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png
cp android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png
cp android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png

4
openssl_signing_console.sh → scripts/openssl_signing_console.sh

@ -4,9 +4,9 @@
# #
# Prerequisites: openssl, jq # 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 # Generate a key and extract the public part

6
scripts/test-ios.js

@ -103,7 +103,7 @@ const cleanIosPlatform = async (log) => {
// Get app name from package.json // Get app name from package.json
const packageJson = JSON.parse(readFileSync('package.json', 'utf8')); const packageJson = JSON.parse(readFileSync('package.json', 'utf8'));
const appName = packageJson.name || 'App'; const appName = packageJson.name || 'App';
const appId = packageJson.capacitor?.appId || 'io.ionic.starter'; const appId = packageJson.build.appId || 'io.ionic.starter';
// Create a minimal capacitor config // Create a minimal capacitor config
const capacitorConfig = ` const capacitorConfig = `
@ -467,12 +467,12 @@ const configureIosProject = async (log) => {
// Build and test iOS project // Build and test iOS project
const buildAndTestIos = async (log, simulator) => { const buildAndTestIos = async (log, simulator) => {
const simulatorName = simulator[0].name; const simulatorName = simulator[0].name;
log('🏗️ Building iOS project...'); log('🏗️ Building iOS project...', simulator[0]);
execSync('cd ios/App && xcodebuild clean -workspace App.xcworkspace -scheme App', { stdio: 'inherit' }); execSync('cd ios/App && xcodebuild clean -workspace App.xcworkspace -scheme App', { stdio: 'inherit' });
log('✅ Xcode clean completed'); log('✅ Xcode clean completed');
log(`🏗️ Building for simulator: ${simulatorName}`); log(`🏗️ Building for simulator: ${simulatorName}`);
execSync(`cd ios/App && xcodebuild build -workspace App.xcworkspace -scheme App -destination "platform=iOS Simulator,name=${simulatorName}"`, { stdio: 'inherit' }); execSync(`cd ios/App && xcodebuild build -workspace App.xcworkspace -scheme App -destination "platform=iOS Simulator,OS=17.2,name=${simulatorName}"`, { stdio: 'inherit' });
log('✅ Xcode build completed'); log('✅ Xcode build completed');
// Check if the project is configured for testing by querying the scheme capabilities // Check if the project is configured for testing by querying the scheme capabilities

17
src/App.vue

@ -4,7 +4,7 @@
<!-- Messages in the upper-right - https://github.com/emmanuelsw/notiwind --> <!-- Messages in the upper-right - https://github.com/emmanuelsw/notiwind -->
<NotificationGroup group="alert"> <NotificationGroup group="alert">
<div <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 <Notification
v-slot="{ notifications, close }" v-slot="{ notifications, close }"
@ -147,7 +147,7 @@
"-permission", "-mute", "-off" "-permission", "-mute", "-off"
--> -->
<NotificationGroup group="modal"> <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 <Notification
v-slot="{ notifications, close }" v-slot="{ notifications, close }"
enter="transform ease-out duration-300 transition" enter="transform ease-out duration-300 transition"
@ -539,4 +539,15 @@ export default class App extends Vue {
} }
</script> </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>

9
src/components/ActivityListItem.vue

@ -57,7 +57,7 @@
class="w-full h-auto max-w-lg max-h-96 object-contain mx-auto drop-shadow-md" class="w-full h-auto max-w-lg max-h-96 object-contain mx-auto drop-shadow-md"
:src="record.image" :src="record.image"
alt="Activity image" alt="Activity image"
@load="$emit('cacheImage', record.image)" @load="cacheImage(record.image)"
/> />
</a> </a>
</div> </div>
@ -182,7 +182,7 @@
</template> </template>
<script lang="ts"> <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 { GiveRecordWithContactInfo } from "../types";
import EntityIcon from "./EntityIcon.vue"; import EntityIcon from "./EntityIcon.vue";
import { isGiveClaimType, notifyWhyCannotConfirm } from "../libs/util"; import { isGiveClaimType, notifyWhyCannotConfirm } from "../libs/util";
@ -202,6 +202,11 @@ export default class ActivityListItem extends Vue {
@Prop() activeDid!: string; @Prop() activeDid!: string;
@Prop() confirmerIdList?: string[]; @Prop() confirmerIdList?: string[];
@Emit()
cacheImage(image: string) {
return image;
}
get fetchAmount(): string { get fetchAmount(): string {
const claim = const claim =
(this.record.fullClaim as unknown).claim || this.record.fullClaim; (this.record.fullClaim as unknown).claim || this.record.fullClaim;

196
src/components/DataExportSection.vue

@ -0,0 +1,196 @@
/** * Data Export Section Component * * Provides UI and functionality for
exporting user data and backing up identifier seeds. * Includes buttons for seed
backup and database export, with platform-specific download instructions. * *
@component * @displayName DataExportSection * @example * ```vue *
<DataExportSection :active-did="currentDid" />
* ``` */
<template>
<div
id="sectionDataExport"
class="bg-slate-100 rounded-md overflow-hidden px-4 py-4 mt-8 mb-8"
>
<div class="mb-2 font-bold">Data Export</div>
<router-link
v-if="activeDid"
:to="{ name: 'seed-backup' }"
class="block w-full text-center text-md bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md mb-2 mt-2"
>
Backup Identifier Seed
</router-link>
<button
:class="computedStartDownloadLinkClassNames()"
class="block w-full text-center text-md bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md"
@click="exportDatabase()"
>
Download Settings & Contacts
<br />
(excluding Identifier Data)
</button>
<a
ref="downloadLink"
:class="computedDownloadLinkClassNames()"
class="block w-full text-center text-md bg-gradient-to-b from-green-500 to-green-800 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md mb-6"
>
If no download happened yet, click again here to download now.
</a>
<div v-if="platformCapabilities.needsFileHandlingInstructions" class="mt-4">
<p>
After the download, you can save the file in your preferred storage
location.
</p>
<ul>
<li
v-if="platformCapabilities.isIOS"
class="list-disc list-outside ml-4"
>
On iOS: You will be prompted to choose a location to save your backup
file.
</li>
<li
v-if="platformCapabilities.isMobile && !platformCapabilities.isIOS"
class="list-disc list-outside ml-4"
>
On Android: You will be prompted to choose a location to save your
backup file.
</li>
</ul>
</div>
</div>
</template>
<script lang="ts">
import { Component, Prop, Vue } from "vue-facing-decorator";
import { NotificationIface } from "../constants/app";
import { db } from "../db/index";
import { logger } from "../utils/logger";
import { PlatformServiceFactory } from "../services/PlatformServiceFactory";
import {
PlatformService,
PlatformCapabilities,
} from "../services/PlatformService";
/**
* @vue-component
* Data Export Section Component
* Handles database export and seed backup functionality with platform-specific behavior
*/
@Component
export default class DataExportSection extends Vue {
/**
* Notification function injected by Vue
* Used to show success/error messages to the user
*/
$notify!: (notification: NotificationIface, timeout?: number) => void;
/**
* Active DID (Decentralized Identifier) of the user
* Controls visibility of seed backup option
* @required
*/
@Prop({ required: true }) readonly activeDid!: string;
/**
* URL for the database export download
* Created and revoked dynamically during export process
* Only used in web platform
*/
downloadUrl = "";
/**
* Platform service instance for platform-specific operations
*/
private platformService: PlatformService =
PlatformServiceFactory.getInstance();
/**
* Platform capabilities for the current platform
*/
private get platformCapabilities(): PlatformCapabilities {
return this.platformService.getCapabilities();
}
/**
* Lifecycle hook to clean up resources
* Revokes object URL when component is unmounted (web platform only)
*/
beforeUnmount() {
if (this.downloadUrl && this.platformCapabilities.hasFileDownload) {
URL.revokeObjectURL(this.downloadUrl);
}
}
/**
* Exports the database to a JSON file
* Uses platform-specific methods for saving the exported data
* Shows success/error notifications to user
*
* @throws {Error} If export fails
* @emits {Notification} Success or error notification
*/
public async exportDatabase() {
try {
const blob = await db.export({ prettyJson: true });
const fileName = `${db.name}-backup.json`;
if (this.platformCapabilities.hasFileDownload) {
// Web platform: Use download link
this.downloadUrl = URL.createObjectURL(blob);
const downloadAnchor = this.$refs.downloadLink as HTMLAnchorElement;
downloadAnchor.href = this.downloadUrl;
downloadAnchor.download = fileName;
downloadAnchor.click();
setTimeout(() => URL.revokeObjectURL(this.downloadUrl), 1000);
} else if (this.platformCapabilities.hasFileSystem) {
// Native platform: Write to app directory
const content = await blob.text();
await this.platformService.writeAndShareFile(fileName, content);
}
this.$notify(
{
group: "alert",
type: "success",
title: "Export Successful",
text: this.platformCapabilities.hasFileDownload
? "See your downloads directory for the backup. It is in the Dexie format."
: "Please choose a location to save your backup file.",
},
-1,
);
} catch (error) {
logger.error("Export Error:", error);
this.$notify(
{
group: "alert",
type: "danger",
title: "Export Error",
text: "There was an error exporting the data.",
},
3000,
);
}
}
/**
* Computes class names for the initial download button
* @returns Object with 'hidden' class when download is in progress (web platform only)
*/
public computedStartDownloadLinkClassNames() {
return {
hidden: this.downloadUrl && this.platformCapabilities.hasFileDownload,
};
}
/**
* Computes class names for the secondary download link
* @returns Object with 'hidden' class when no download is available or not on web platform
*/
public computedDownloadLinkClassNames() {
return {
hidden: !this.downloadUrl || !this.platformCapabilities.hasFileDownload,
};
}
}
</script>

6
src/components/GiftedDialog.vue

@ -99,6 +99,7 @@ import * as libsUtil from "../libs/util";
import { db, retrieveSettingsForActiveAccount } from "../db/index"; import { db, retrieveSettingsForActiveAccount } from "../db/index";
import { Contact } from "../db/tables/contacts"; import { Contact } from "../db/tables/contacts";
import { retrieveAccountDids } from "../libs/util"; import { retrieveAccountDids } from "../libs/util";
import { logger } from "../utils/logger";
@Component @Component
export default class GiftedDialog extends Vue { export default class GiftedDialog extends Vue {
@ -117,7 +118,6 @@ export default class GiftedDialog extends Vue {
customTitle?: string; customTitle?: string;
description = ""; description = "";
giver?: libsUtil.GiverReceiverInputInfo; // undefined means no identified giver agent giver?: libsUtil.GiverReceiverInputInfo; // undefined means no identified giver agent
isTrade = false;
offerId = ""; offerId = "";
prompt = ""; prompt = "";
receiver?: libsUtil.GiverReceiverInputInfo; receiver?: libsUtil.GiverReceiverInputInfo;
@ -301,7 +301,7 @@ export default class GiftedDialog extends Vue {
unitCode, unitCode,
this.toProjectId, this.toProjectId,
this.offerId, this.offerId,
this.isTrade, false,
undefined, undefined,
this.fromProjectId, this.fromProjectId,
); );
@ -327,7 +327,7 @@ export default class GiftedDialog extends Vue {
group: "alert", group: "alert",
type: "success", type: "success",
title: "Success", title: "Success",
text: `That ${this.isTrade ? "trade" : "gift"} was recorded.`, text: `That gift was recorded.`,
}, },
7000, 7000,
); );

511
src/components/ImageMethodDialog.vue

@ -1,108 +1,282 @@
<template> <template>
<div v-if="visible" class="dialog-overlay z-[60]"> <div v-if="visible" class="dialog-overlay z-[60]">
<div class="dialog relative"> <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&hellip;</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 <div
id="ViewHeading" class="text-2xl text-center px-1 py-0.5 leading-none absolute -right-1 top-0"
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"
@click="close()" @click="close()"
> >
<font-awesome icon="xmark" class="w-[1em]"></font-awesome> <font-awesome icon="xmark" class="w-[1em]"></font-awesome>
</div> </div>
</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"> <div class="mt-4">
<input type="file" @change="uploadImageFile" /> <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>
<div class="mt-4"> <div class="mt-4">
<span class="mt-2"> <input
... or paste a URL: type="file"
<input v-model="imageUrl" type="text" class="border-2" /> 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> </span>
<span class="ml-2"> </div>
<font-awesome <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" 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-3 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-2 py-2 rounded-md cursor-pointer"
@click="acceptUrl" @click="acceptUrl"
/> >
<!-- so that there's no shifting when it becomes visible --> <font-awesome icon="check" class="fa-fw" />
</button>
</div>
</div>
<div v-else>
<div v-if="uploading" class="flex justify-center">
<font-awesome <font-awesome
v-else icon="spinner"
icon="check" class="fa-spin fa-3x text-center block px-12 py-12"
class="text-white bg-white px-2 py-2" />
</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"
/> />
</span>
</div> </div>
</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> </div>
</div> </div>
<PhotoDialog ref="photoDialog" />
</template> </template>
<script lang="ts"> <script lang="ts">
import axios from "axios"; import axios from "axios";
import { ref } from "vue"; import { ref } from "vue";
import { Component, Vue } from "vue-facing-decorator"; import { Component, Vue } from "vue-facing-decorator";
import VuePictureCropper, { cropper } from "vue-picture-cropper";
import PhotoDialog from "../components/PhotoDialog.vue"; import { DEFAULT_IMAGE_API_SERVER, NotificationIface } from "../constants/app";
import { 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>(); const inputImageFileNameRef = ref<Blob>();
@Component({ @Component({
components: { PhotoDialog }, components: { VuePictureCropper },
props: {
isRegistered: {
type: Boolean,
default: true,
},
},
}) })
export default class ImageMethodDialog extends Vue { export default class ImageMethodDialog extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void; $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; crop: boolean = false;
/** Name of the selected file */
fileName?: string;
/** Callback function to set image URL after upload */
imageCallback: (imageUrl?: string) => void = () => {}; imageCallback: (imageUrl?: string) => void = () => {};
/** URL for image input */
imageUrl?: string; imageUrl?: string;
/** Whether to show retry button */
showRetry = true;
/** Upload progress state */
uploading = false;
/** Dialog visibility state */
visible = false; 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) { open(setImageFn: (arg: string) => void, claimType: string, crop?: boolean) {
this.claimType = claimType; this.claimType = claimType;
this.crop = !!crop; this.crop = !!crop;
this.imageCallback = setImageFn; this.imageCallback = setImageFn;
this.visible = true; this.visible = true;
}
openPhotoDialog(blob?: Blob, fileName?: string) {
this.visible = false;
(this.$refs.photoDialog as PhotoDialog).open( // Start camera preview immediately if not on mobile
this.imageCallback, if (!this.platformCapabilities.isMobile) {
this.claimType, this.startCameraPreview();
this.crop, }
blob,
fileName,
);
} }
async uploadImageFile(event: Event) { async uploadImageFile(event: Event) {
this.visible = false; const target = event.target as HTMLInputElement;
if (!target.files) return;
inputImageFileNameRef.value = event.target.files[0]; inputImageFileNameRef.value = target.files[0];
// https://developer.mozilla.org/en-US/docs/Web/API/File
// ... plus it has a `type` property from my testing
const file = inputImageFileNameRef.value; const file = inputImageFileNameRef.value;
if (file != null) { if (file != null) {
const reader = new FileReader(); const reader = new FileReader();
@ -112,7 +286,9 @@ export default class ImageMethodDialog extends Vue {
const blob = new Blob([new Uint8Array(data)], { const blob = new Blob([new Uint8Array(data)], {
type: file.type, 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); reader.readAsArrayBuffer(file as Blob);
@ -120,21 +296,16 @@ export default class ImageMethodDialog extends Vue {
} }
async acceptUrl() { async acceptUrl() {
this.visible = false;
if (this.crop) { if (this.crop) {
try { try {
const urlBlobResponse: Blob = await axios.get(this.imageUrl as string, { const urlBlobResponse = await axios.get(this.imageUrl as string, {
responseType: "blob", // This ensures the data is returned as a Blob responseType: "blob",
}); });
const fullUrl = new URL(this.imageUrl as string); const fullUrl = new URL(this.imageUrl as string);
const fileName = fullUrl.pathname.split("/").pop() as string; const fileName = fullUrl.pathname.split("/").pop() as string;
(this.$refs.photoDialog as PhotoDialog).open( this.blob = urlBlobResponse.data as Blob;
this.imageCallback, this.fileName = fileName;
this.claimType, this.showRetry = false;
this.crop,
urlBlobResponse.data as Blob,
fileName,
);
} catch (error) { } catch (error) {
this.$notify( this.$notify(
{ {
@ -148,11 +319,219 @@ export default class ImageMethodDialog extends Vue {
} }
} else { } else {
this.imageCallback(this.imageUrl); this.imageCallback(this.imageUrl);
this.close();
} }
} }
close() { close() {
this.visible = false; 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> </script>
@ -178,5 +557,9 @@ export default class ImageMethodDialog extends Vue {
border-radius: 0.5rem; border-radius: 0.5rem;
width: 100%; width: 100%;
max-width: 700px; max-width: 700px;
max-height: 90vh;
overflow: hidden;
display: flex;
flex-direction: column;
} }
</style> </style>

1
src/components/ImageViewer.vue

@ -40,6 +40,7 @@
<script lang="ts"> <script lang="ts">
import { Component, Vue, Prop } from "vue-facing-decorator"; import { Component, Vue, Prop } from "vue-facing-decorator";
import { UAParser } from "ua-parser-js"; import { UAParser } from "ua-parser-js";
import { logger } from "../utils/logger";
@Component({ emits: ["update:isOpen"] }) @Component({ emits: ["update:isOpen"] })
export default class ImageViewer extends Vue { export default class ImageViewer extends Vue {

571
src/components/PhotoDialog.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> <template>
<div v-if="visible" class="dialog-overlay z-[60]"> <div v-if="visible" class="dialog-overlay z-[60]">
<div class="dialog relative"> <div class="dialog relative">
<div class="text-lg text-center font-light relative z-50"> <div class="text-lg text-center font-light relative z-50">
<div <div
id="ViewHeading" 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-if="uploading"> Uploading... </span>
<span v-else-if="blob"> Look Good? </span> <span v-else-if="blob"> Look Good? </span>
<span v-else-if="showCameraPreview"> Take Photo </span>
<span v-else> Say "Cheese"! </span> <span v-else> Say "Cheese"! </span>
</div> </div>
<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()" @click="close()"
> >
<font-awesome icon="xmark" class="w-[1em]"></font-awesome> <font-awesome icon="xmark" class="w-[1em]"></font-awesome>
@ -36,15 +48,10 @@
:options="{ :options="{
viewMode: 1, viewMode: 1,
dragMode: 'crop', dragMode: 'crop',
aspectRatio: 9 / 9, aspectRatio: 1 / 1,
}" }"
class="max-h-[90vh] max-w-[90vw] object-contain" class="max-h-[90vh] max-w-[90vw] object-contain"
/> />
<!-- This gives a round cropper.
:presetMode="{
mode: 'round',
}"
-->
</div> </div>
<div v-else> <div v-else>
<div class="flex justify-center"> <div class="flex justify-center">
@ -54,67 +61,55 @@
/> />
</div> </div>
</div> </div>
<div class="absolute bottom-[1rem] left-[1rem] px-2 py-1"> <div class="grid grid-cols-2 gap-2 mt-2">
<button <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" @click="uploadImage"
> >
<span>Upload</span> <span>Upload</span>
</button> </button>
</div>
<div
v-if="showRetry"
class="absolute bottom-[1rem] right-[1rem] px-2 py-1"
>
<button <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" @click="retryImage"
> >
<span>Retry</span> <span>Retry</span>
</button> </button>
</div> </div>
</div> </div>
<div v-else ref="cameraContainer"> <div v-else-if="showCameraPreview" class="camera-preview">
<!-- <div class="camera-container">
Camera "resolution" doesn't change how it shows on screen but rather stretches the result, <video
eg. the following which just stretches it vertically: ref="videoElement"
:resolution="{ width: 375, height: 812 }" class="camera-video"
-->
<camera
ref="camera"
facing-mode="environment"
autoplay autoplay
@started="cameraStarted()" playsinline
> muted
<div ></video>
class="absolute portrait:bottom-0 portrait:left-0 portrait:right-0 portrait:pb-2 landscape:right-0 landscape:top-0 landscape:bottom-0 landscape:pr-4 flex landscape:flex-row justify-center items-center"
>
<button <button
class="bg-blue-500 hover:bg-blue-700 text-white font-bold p-3 rounded-full text-2xl leading-none" 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="takeImage()" @click="capturePhoto"
> >
<font-awesome icon="camera" class="w-[1em]"></font-awesome> <font-awesome icon="camera" class="w-[1em]" />
</button> </button>
</div> </div>
<div </div>
class="absolute portrait:bottom-2 portrait:right-16 landscape:right-0 landscape:bottom-16 landscape:pr-4 flex justify-center items-center" <div v-else>
> <div class="flex flex-col items-center justify-center gap-4 p-4">
<button <button
v-if="isRegistered"
class="bg-blue-500 hover:bg-blue-700 text-white font-bold p-3 rounded-full text-2xl leading-none" class="bg-blue-500 hover:bg-blue-700 text-white font-bold p-3 rounded-full text-2xl leading-none"
@click="swapMirrorClass()" @click="startCameraPreview"
> >
<font-awesome icon="left-right" class="w-[1em]"></font-awesome> <font-awesome icon="camera" class="w-[1em]" />
</button> </button>
</div>
<div v-if="numDevices > 1" class="absolute bottom-2 right-4">
<button <button
class="bg-blue-500 hover:bg-blue-700 text-white font-bold p-3 rounded-full text-2xl leading-none" class="bg-blue-500 hover:bg-blue-700 text-white font-bold p-3 rounded-full text-2xl leading-none"
@click="switchCamera()" @click="pickPhoto"
> >
<font-awesome icon="rotate" class="w-[1em]"></font-awesome> <font-awesome icon="image" class="w-[1em]" />
</button> </button>
</div> </div>
</camera>
</div> </div>
</div> </div>
</div> </div>
@ -122,58 +117,105 @@
<script lang="ts"> <script lang="ts">
import axios from "axios"; import axios from "axios";
import Camera from "simple-vue-camera";
import { Component, Vue } from "vue-facing-decorator"; import { Component, Vue } from "vue-facing-decorator";
import VuePictureCropper, { cropper } from "vue-picture-cropper"; import VuePictureCropper, { cropper } from "vue-picture-cropper";
import { DEFAULT_IMAGE_API_SERVER, NotificationIface } from "../constants/app"; import { DEFAULT_IMAGE_API_SERVER, NotificationIface } from "../constants/app";
import { retrieveSettingsForActiveAccount } from "../db/index"; import { retrieveSettingsForActiveAccount } from "../db/index";
import { accessToken } from "../libs/crypto"; import { accessToken } from "../libs/crypto";
import { logger } from "../utils/logger"; import { logger } from "../utils/logger";
import { PlatformServiceFactory } from "../services/PlatformServiceFactory";
@Component({ components: { Camera, VuePictureCropper } }) @Component({ components: { VuePictureCropper } })
export default class PhotoDialog extends Vue { export default class PhotoDialog extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void; $notify!: (notification: NotificationIface, timeout?: number) => void;
activeDeviceNumber = 0; /** Active DID for user authentication */
activeDid = ""; activeDid = "";
/** Current image blob being processed */
blob?: Blob; blob?: Blob;
/** Type of claim for the image */
claimType = ""; claimType = "";
/** Whether to show cropping interface */
crop = false; crop = false;
/** Name of the selected file */
fileName?: string; fileName?: string;
mirror = false;
numDevices = 0; /** Callback function to set image URL after upload */
setImageCallback: (arg: string) => void = () => {}; setImageCallback: (arg: string) => void = () => {};
/** Whether to show retry button */
showRetry = true; showRetry = true;
/** Upload progress state */
uploading = false; uploading = false;
/** Dialog visibility state */
visible = false; 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; 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() { async mounted() {
logger.log("PhotoDialog mounted");
try { try {
const settings = await retrieveSettingsForActiveAccount(); const settings = await retrieveSettingsForActiveAccount();
this.activeDid = settings.activeDid || ""; this.activeDid = settings.activeDid || "";
// eslint-disable-next-line @typescript-eslint/no-explicit-any this.isRegistered = !!settings.isRegistered;
} catch (err: any) { logger.log("isRegistered:", this.isRegistered);
logger.error("Error retrieving settings from database:", err); } catch (error: unknown) {
logger.error("Error retrieving settings from database:", error);
this.$notify( this.$notify(
{ {
group: "alert", group: "alert",
type: "danger", type: "danger",
title: "Error", 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, -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, setImageFn: (arg: string) => void,
claimType: string, claimType: string,
crop?: boolean, crop?: boolean,
blob?: Blob, // for image upload, just to use the cropping function blob?: Blob,
inputFileName?: string, inputFileName?: string,
) { ) {
this.visible = true; this.visible = true;
@ -187,17 +229,28 @@ export default class PhotoDialog extends Vue {
if (blob) { if (blob) {
this.blob = blob; this.blob = blob;
this.fileName = inputFileName; this.fileName = inputFileName;
// doesn't make sense to retry the file upload; they can cancel if they picked the wrong one
this.showRetry = false; this.showRetry = false;
} else { } else {
this.blob = undefined; this.blob = undefined;
this.fileName = undefined; this.fileName = undefined;
this.showRetry = true; 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() { close() {
logger.debug(
"Dialog closing, current showCameraPreview:",
this.showCameraPreview,
);
this.visible = false; this.visible = false;
this.stopCameraPreview();
const bottomNav = document.querySelector("#QuickNav") as HTMLElement; const bottomNav = document.querySelector("#QuickNav") as HTMLElement;
if (bottomNav) { if (bottomNav) {
bottomNav.style.display = ""; bottomNav.style.display = "";
@ -205,141 +258,224 @@ export default class PhotoDialog extends Vue {
this.blob = undefined; this.blob = undefined;
} }
async cameraStarted() { /**
const cameraComponent = this.$refs.camera as InstanceType<typeof Camera>; * Starts the camera preview
if (cameraComponent) { */
this.numDevices = (await cameraComponent.devices(["videoinput"])).length; async startCameraPreview() {
this.mirror = cameraComponent.facingMode === "user"; logger.debug("startCameraPreview called");
// figure out which device is active logger.debug("Current showCameraPreview state:", this.showCameraPreview);
const currentDeviceId = cameraComponent.currentDeviceID(); logger.debug("Platform capabilities:", this.platformCapabilities);
const devices = await cameraComponent.devices(["videoinput"]);
this.activeDeviceNumber = devices.findIndex( // If we're on a mobile device or using Capacitor, use the platform service
(device) => device.deviceId === currentDeviceId, 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;
} }
async switchCamera() { // For desktop web browsers, use our custom preview
const cameraComponent = this.$refs.camera as InstanceType<typeof Camera>; logger.debug("Starting camera preview for desktop browser");
this.activeDeviceNumber = (this.activeDeviceNumber + 1) % this.numDevices; try {
const devices = await cameraComponent?.devices(["videoinput"]); // Set state before requesting camera access
await cameraComponent?.changeCamera( this.showCameraPreview = true;
devices[this.activeDeviceNumber].deviceId, logger.debug("showCameraPreview set to:", this.showCameraPreview);
// Force a re-render
await this.$nextTick();
logger.debug(
"After nextTick, showCameraPreview is:",
this.showCameraPreview,
); );
}
async takeImage(/* payload: MouseEvent */) { logger.debug("Requesting camera access...");
const cameraComponent = this.$refs.camera as InstanceType<typeof Camera>; 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;
* This logic to set the image height & width correctly. if (videoElement) {
* Without it, the portrait orientation ends up with an image that is stretched horizontally. logger.debug("Video element found, setting srcObject");
* Note that it's the same with raw browser Javascript; see the "drawImage" example below. videoElement.srcObject = stream;
* Now that I've done it, I can't explain why it works. // Wait for video to be ready
*/ await new Promise((resolve) => {
let imageHeight = cameraComponent?.resolution?.height; videoElement.onloadedmetadata = () => {
let imageWidth = cameraComponent?.resolution?.width; logger.debug("Video metadata loaded");
const initialImageRatio = imageWidth / imageHeight; videoElement.play().then(() => {
const windowRatio = window.innerWidth / window.innerHeight; logger.debug("Video playback started");
if (initialImageRatio > 1 && windowRatio < 1) { resolve(true);
// the image is wider than it is tall, and the window is taller than it is wide });
// For some reason, mobile in portrait orientation renders a horizontally-stretched image. };
// We're gonna force it opposite. });
imageHeight = cameraComponent?.resolution?.width;
imageWidth = cameraComponent?.resolution?.height;
} else if (initialImageRatio < 1 && windowRatio > 1) {
// the image is taller than it is wide, and the window is wider than it is tall
// Haven't seen this happen, but we'll do it just in case.
imageHeight = cameraComponent?.resolution?.width;
imageWidth = cameraComponent?.resolution?.height;
}
const newImageRatio = imageWidth / imageHeight;
if (newImageRatio < windowRatio) {
// the image is a taller ratio than the window, so fit the height first
imageHeight = window.innerHeight / 2;
imageWidth = imageHeight * newImageRatio;
} else { } else {
// the image is a wider ratio than the window, so fit the width first logger.error("Video element not found");
imageWidth = window.innerWidth / 2; }
imageHeight = imageWidth / newImageRatio; } catch (error) {
} logger.error("Error starting camera preview:", error);
// The resolution is only necessary because of that mobile portrait-orientation case.
// The mobile emulation in a browser shows something stretched vertically, but real devices work fine.
this.blob =
(await cameraComponent?.snapshot({
height: imageHeight,
width: imageWidth,
})) || undefined;
// png is default
this.fileName = "snapshot.png";
if (!this.blob) {
this.$notify( this.$notify(
{ {
group: "alert", group: "alert",
type: "danger", type: "danger",
title: "Error", title: "Error",
text: "There was an error taking the picture. Please try again.", text: "Failed to access camera. Please try again.",
}, },
5000, 5000,
); );
return; this.showCameraPreview = false;
} }
} }
private createBlobURL(blob: Blob): string { /**
return URL.createObjectURL(blob); * 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;
async retryImage() { logger.debug(
this.blob = undefined; "After stopping, showCameraPreview is:",
this.showCameraPreview,
);
} }
/**** /**
* Captures a photo from the camera preview
*/
async capturePhoto() {
if (!this.cameraStream) return;
Here's an approach to photo capture without a library. It has similar quirks. try {
Now that we've fixed styling for simple-vue-camera, it's not critical to refactor. Maybe someday. 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);
<button id="start-camera" @click="cameraClicked">Start Camera</button> canvas.toBlob(
<video id="video" width="320" height="240" autoplay></video> (blob) => {
<button id="snap-photo" @click="photoSnapped">Snap Photo</button> if (blob) {
<canvas id="canvas" width="320" height="240"></canvas> 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,
);
}
}
async cameraClicked() { /**
const video = document.querySelector("#video"); * Captures a photo using device camera
const stream = await navigator.mediaDevices.getUserMedia({ * @throws {Error} When camera access fails
video: true, */
audio: false, async takePhoto() {
}); try {
if (video instanceof HTMLVideoElement) { const result = await this.platformService.takePicture();
video.srcObject = stream; this.blob = result.blob;
this.fileName = result.fileName;
} catch (error: unknown) {
logger.error("Error taking picture:", error);
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "Failed to take picture. Please try again.",
},
5000,
);
}
}
/**
* Selects an image from device gallery
* @throws {Error} When gallery access fails
*/
async pickPhoto() {
try {
const result = await this.platformService.pickImage();
this.blob = result.blob;
this.fileName = result.fileName;
} catch (error: unknown) {
logger.error("Error picking image:", error);
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "Failed to pick image. Please try again.",
},
5000,
);
} }
} }
photoSnapped() {
const video = document.querySelector("#video");
const canvas = document.querySelector("#canvas");
if (
canvas instanceof HTMLCanvasElement &&
video instanceof HTMLVideoElement
) {
canvas
?.getContext("2d")
?.drawImage(video, 0, 0, canvas.width, canvas.height);
// ... or set the blob:
// canvas?.toBlob(
// (blob) => {
// this.blob = blob;
// },
// "image/jpeg",
// 1,
// );
// data url of the image /**
const image_data_url = canvas?.toDataURL("image/jpeg"); * 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() { async uploadImage() {
this.uploading = true; this.uploading = true;
@ -350,11 +486,9 @@ export default class PhotoDialog extends Vue {
const token = await accessToken(this.activeDid); const token = await accessToken(this.activeDid);
const headers = { const headers = {
Authorization: "Bearer " + token, Authorization: "Bearer " + token,
// axios fills in Content-Type of multipart/form-data
}; };
const formData = new FormData(); const formData = new FormData();
if (!this.blob) { if (!this.blob) {
// yeah, this should never happen, but it helps with subsequent type checking
this.$notify( this.$notify(
{ {
group: "alert", group: "alert",
@ -367,7 +501,7 @@ export default class PhotoDialog extends Vue {
this.uploading = false; this.uploading = false;
return; return;
} }
formData.append("image", this.blob, this.fileName || "snapshot.png"); formData.append("image", this.blob, this.fileName || "photo.jpg");
formData.append("claimType", this.claimType); formData.append("claimType", this.claimType);
try { try {
if ( if (
@ -387,14 +521,64 @@ export default class PhotoDialog extends Vue {
this.close(); this.close();
this.setImageCallback(response.data.url as string); this.setImageCallback(response.data.url as string);
} catch (error) { } catch (error: unknown) {
logger.error("Error uploading the image", error); // Log the raw error first
logger.error("Raw error object:", JSON.stringify(error, null, 2));
let errorMessage = "There was an error saving the picture.";
if (axios.isAxiosError(error)) {
const status = error.response?.status;
const statusText = error.response?.statusText;
const data = error.response?.data;
// Log detailed error information
logger.error("Upload error details:", {
status,
statusText,
data: JSON.stringify(data, null, 2),
message: error.message,
config: {
url: error.config?.url,
method: error.config?.method,
headers: error.config?.headers,
},
});
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;
}
} else if (error instanceof Error) {
// Log non-Axios error with full details
logger.error("Non-Axios error details:", {
name: error.name,
message: error.message,
stack: error.stack,
error: JSON.stringify(error, Object.getOwnPropertyNames(error), 2),
});
} else {
// Log any other type of error
logger.error("Unknown error type:", {
error: JSON.stringify(error, null, 2),
type: typeof error,
});
}
this.$notify( this.$notify(
{ {
group: "alert", group: "alert",
type: "danger", type: "danger",
title: "Error", title: "Error",
text: "There was an error saving the picture.", text: errorMessage,
}, },
5000, 5000,
); );
@ -402,21 +586,11 @@ export default class PhotoDialog extends Vue {
this.blob = undefined; this.blob = undefined;
} }
} }
swapMirrorClass() {
this.mirror = !this.mirror;
if (this.mirror) {
(this.$refs.cameraContainer as HTMLElement).classList.add("mirror-video");
} else {
(this.$refs.cameraContainer as HTMLElement).classList.remove(
"mirror-video",
);
}
}
} }
</script> </script>
<style> <style>
/* Dialog overlay styling */
.dialog-overlay { .dialog-overlay {
z-index: 60; z-index: 60;
position: fixed; position: fixed;
@ -431,19 +605,50 @@ export default class PhotoDialog extends Vue {
padding: 1.5rem; padding: 1.5rem;
} }
/* Dialog container styling */
.dialog { .dialog {
background-color: white; background-color: white;
padding: 1rem; padding: 1rem;
border-radius: 0.5rem; border-radius: 0.5rem;
width: 100%; width: 100%;
max-width: 700px; 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;
} }
.mirror-video { .capture-button {
transform: scaleX(-1); position: absolute;
-webkit-transform: scaleX(-1); /* For Safari */ bottom: 1rem;
-moz-transform: scaleX(-1); /* For Firefox */ left: 50%;
-ms-transform: scaleX(-1); /* For IE */ transform: translateX(-50%);
-o-transform: scaleX(-1); /* For Opera */ 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> </style>

5
src/components/QuickNav.vue

@ -1,6 +1,9 @@
<template> <template>
<!-- QUICK NAV --> <!-- 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"> <ul class="flex text-2xl px-6 py-2 gap-1 max-w-3xl mx-auto">
<!-- Home Feed --> <!-- Home Feed -->
<li <li

2
src/components/TopMessage.vue

@ -1,5 +1,5 @@
<template> <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="align-center text-red-500 mr-2">{{ message }}</span>
<span class="ml-2"> <span class="ml-2">
<router-link <router-link

187
src/electron/main.ts

@ -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

@ -0,0 +1,4 @@
/// <reference types="vite/client" />
declare const __USE_QR_READER__: boolean;
declare const __IS_MOBILE__: boolean;

103
src/interfaces/deepLinks.ts

@ -1,11 +1,106 @@
/** /**
* @file Deep Link Interface Definitions * @file Deep Link Type Definitions and Validation Schemas
* @author Matthew Raymer * @author Matthew Raymer
* *
* Defines the core interfaces for the deep linking system. * This file defines the type system and validation schemas for deep linking in the TimeSafari app.
* These interfaces are used across the deep linking implementation * It uses Zod for runtime validation while providing TypeScript types for compile-time checking.
* to ensure type safety and consistent error handling. *
* 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 { export interface DeepLinkError extends Error {
code: string; code: string;

21
src/interfaces/give.ts

@ -0,0 +1,21 @@
import { GiveSummaryRecord } from "./records";
// Common interface for contact information
export interface ContactInfo {
known: boolean;
displayName: string;
profileImageUrl?: string;
}
// Define the contact information fields
interface GiveContactInfo {
giver: ContactInfo;
issuer: ContactInfo;
receiver: ContactInfo;
providerPlanName?: string;
recipientProjectName?: string;
image?: string;
}
// Combine GiveSummaryRecord with contact information using intersection type
export type GiveRecordWithContactInfo = GiveSummaryRecord & GiveContactInfo;

0
src/lib/capacitor/app.ts → src/libs/capacitor/app.ts

48
src/libs/crypto/index.ts

@ -9,6 +9,7 @@ import {
createEndorserJwtForDid, createEndorserJwtForDid,
CONTACT_URL_PATH_ENDORSER_CH_OLD, CONTACT_URL_PATH_ENDORSER_CH_OLD,
CONTACT_IMPORT_ONE_URL_PATH_TIME_SAFARI, CONTACT_IMPORT_ONE_URL_PATH_TIME_SAFARI,
CONTACT_CONFIRM_URL_PATH_TIME_SAFARI,
} from "../../libs/endorserServer"; } from "../../libs/endorserServer";
import { DEFAULT_DID_PROVIDER_NAME } from "../veramo/setup"; import { DEFAULT_DID_PROVIDER_NAME } from "../veramo/setup";
import { logger } from "../../utils/logger"; 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) => { export const getContactJwtFromJwtUrl = (jwtUrlText: string) => {
try {
let jwtText = jwtUrlText; let jwtText = jwtUrlText;
const appImportConfirmUrlLoc = jwtText.indexOf(
// Try to extract JWT from URL paths
const paths = [
CONTACT_CONFIRM_URL_PATH_TIME_SAFARI,
CONTACT_IMPORT_CONFIRM_URL_PATH_TIME_SAFARI, CONTACT_IMPORT_CONFIRM_URL_PATH_TIME_SAFARI,
);
if (appImportConfirmUrlLoc > -1) {
jwtText = jwtText.substring(
appImportConfirmUrlLoc +
CONTACT_IMPORT_CONFIRM_URL_PATH_TIME_SAFARI.length,
);
}
const appImportOneUrlLoc = jwtText.indexOf(
CONTACT_IMPORT_ONE_URL_PATH_TIME_SAFARI, CONTACT_IMPORT_ONE_URL_PATH_TIME_SAFARI,
); CONTACT_URL_PATH_ENDORSER_CH_OLD,
if (appImportOneUrlLoc > -1) { ];
jwtText = jwtText.substring(
appImportOneUrlLoc + CONTACT_IMPORT_ONE_URL_PATH_TIME_SAFARI.length, for (const path of paths) {
); const pathIndex = jwtText.indexOf(path);
if (pathIndex > -1) {
jwtText = jwtText.substring(pathIndex + path.length);
break;
} }
const endorserUrlPathLoc = jwtText.indexOf(CONTACT_URL_PATH_ENDORSER_CH_OLD);
if (endorserUrlPathLoc > -1) {
jwtText = jwtText.substring(
endorserUrlPathLoc + CONTACT_URL_PATH_ENDORSER_CH_OLD.length,
);
} }
// 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; return jwtText;
} catch (error) {
logger.error("Error extracting JWT from URL:", error);
return null;
}
}; };
export const nextDerivationPath = (origDerivPath: string) => { export const nextDerivationPath = (origDerivPath: string) => {

10
src/libs/endorserServer.ts

@ -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="; 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 * The prefix for handle IDs, the permanent ID for claims on Endorser
* @constant {string} * @constant {string}
@ -644,7 +650,7 @@ export function hydrateGive(
unitCode?: string, unitCode?: string,
fulfillsProjectHandleId?: string, fulfillsProjectHandleId?: string,
fulfillsOfferHandleId?: string, fulfillsOfferHandleId?: string,
isTrade: boolean = false, isTrade: boolean = false, // remove, because this app is all for gifting
imageUrl?: string, imageUrl?: string,
providerPlanHandleId?: string, providerPlanHandleId?: string,
lastClaimId?: string, lastClaimId?: string,
@ -731,7 +737,7 @@ export async function createAndSubmitGive(
unitCode?: string, unitCode?: string,
fulfillsProjectHandleId?: string, fulfillsProjectHandleId?: string,
fulfillsOfferHandleId?: string, fulfillsOfferHandleId?: string,
isTrade: boolean = false, isTrade: boolean = false, // remove, because this app is all for gifting
imageUrl?: string, imageUrl?: string,
providerPlanHandleId?: string, providerPlanHandleId?: string,
): Promise<CreateAndSubmitClaimResult> { ): Promise<CreateAndSubmitClaimResult> {

4
src/lib/fontawesome.ts → src/libs/fontawesome.ts

@ -17,6 +17,7 @@ import {
faBurst, faBurst,
faCalendar, faCalendar,
faCamera, faCamera,
faCameraRotate,
faCaretDown, faCaretDown,
faChair, faChair,
faCheck, faCheck,
@ -53,6 +54,7 @@ import {
faHandHoldingDollar, faHandHoldingDollar,
faHandHoldingHeart, faHandHoldingHeart,
faHouseChimney, faHouseChimney,
faImage,
faImagePortrait, faImagePortrait,
faLeftRight, faLeftRight,
faLightbulb, faLightbulb,
@ -97,6 +99,7 @@ library.add(
faBurst, faBurst,
faCalendar, faCalendar,
faCamera, faCamera,
faCameraRotate,
faCaretDown, faCaretDown,
faChair, faChair,
faCheck, faCheck,
@ -133,6 +136,7 @@ library.add(
faHandHoldingDollar, faHandHoldingDollar,
faHandHoldingHeart, faHandHoldingHeart,
faHouseChimney, faHouseChimney,
faImage,
faImagePortrait, faImagePortrait,
faLeftRight, faLeftRight,
faLightbulb, faLightbulb,

2
src/main.capacitor.ts

@ -29,7 +29,7 @@
*/ */
import { initializeApp } from "./main.common"; import { initializeApp } from "./main.common";
import { App } from "./lib/capacitor/app"; import { App } from "./libs/capacitor/app";
import router from "./router"; import router from "./router";
import { handleApiError } from "./services/api"; import { handleApiError } from "./services/api";
import { AxiosError } from "axios"; import { AxiosError } from "axios";

2
src/main.common.ts

@ -6,7 +6,7 @@ import axios from "axios";
import VueAxios from "vue-axios"; import VueAxios from "vue-axios";
import Notifications from "notiwind"; import Notifications from "notiwind";
import "./assets/styles/tailwind.css"; import "./assets/styles/tailwind.css";
import { FontAwesomeIcon } from "./lib/fontawesome"; import { FontAwesomeIcon } from "./libs/fontawesome";
import Camera from "simple-vue-camera"; import Camera from "simple-vue-camera";
import { logger } from "./utils/logger"; import { logger } from "./utils/logger";

2
src/main.ts

@ -21,6 +21,7 @@ import {
faBurst, faBurst,
faCalendar, faCalendar,
faCamera, faCamera,
faCameraRotate,
faCaretDown, faCaretDown,
faChair, faChair,
faCheck, faCheck,
@ -101,6 +102,7 @@ library.add(
faBurst, faBurst,
faCalendar, faCalendar,
faCamera, faCamera,
faCameraRotate,
faCaretDown, faCaretDown,
faChair, faChair,
faCheck, faCheck,

5
src/router/index.ts

@ -87,6 +87,11 @@ const routes: Array<RouteRecordRaw> = [
name: "contact-qr", name: "contact-qr",
component: () => import("../views/ContactQRScanShowView.vue"), component: () => import("../views/ContactQRScanShowView.vue"),
}, },
{
path: "/contact-qr-scan",
name: "contact-qr-scan",
component: () => import("../views/ContactQRScanView.vue"),
},
{ {
path: "/contacts", path: "/contacts",
name: "contacts", name: "contacts",

101
src/services/PlatformService.ts

@ -0,0 +1,101 @@
/**
* Represents the result of an image capture or selection operation.
* Contains both the image data as a Blob and the associated filename.
*/
export interface ImageResult {
/** The image data as a Blob object */
blob: Blob;
/** The filename associated with the image */
fileName: string;
}
/**
* Platform capabilities interface defining what features are available
* on the current platform implementation
*/
export interface PlatformCapabilities {
/** Whether the platform supports native file system access */
hasFileSystem: boolean;
/** Whether the platform supports native camera access */
hasCamera: boolean;
/** Whether the platform is a mobile device */
isMobile: boolean;
/** Whether the platform is iOS specifically */
isIOS: boolean;
/** Whether the platform supports native file download */
hasFileDownload: boolean;
/** Whether the platform requires special file handling instructions */
needsFileHandlingInstructions: boolean;
}
/**
* Platform-agnostic interface for handling platform-specific operations.
* Provides a common API for file system operations, camera interactions,
* and platform detection across different platforms (web, mobile, desktop).
*/
export interface PlatformService {
// Platform capabilities
/**
* Gets the current platform's capabilities
* @returns Object describing what features are available on this platform
*/
getCapabilities(): PlatformCapabilities;
// File system operations
/**
* Reads the contents of a file at the specified path.
* @param path - The path to the file to read
* @returns Promise resolving to the file contents as a string
*/
readFile(path: string): Promise<string>;
/**
* Writes content to a file at the specified path.
* @param path - The path where the file should be written
* @param content - The content to write to the file
* @returns Promise that resolves when the write is complete
*/
writeFile(path: string, content: string): Promise<void>;
/**
* Writes content to a file at the specified path and shares it.
* @param fileName - The filename of the file to write
* @param content - The content to write to the file
* @returns Promise that resolves when the write is complete
*/
writeAndShareFile(fileName: string, content: string): Promise<void>;
/**
* Deletes a file at the specified path.
* @param path - The path to the file to delete
* @returns Promise that resolves when the deletion is complete
*/
deleteFile(path: string): Promise<void>;
/**
* Lists all files in the specified directory.
* @param directory - The directory path to list
* @returns Promise resolving to an array of filenames
*/
listFiles(directory: string): Promise<string[]>;
// Camera operations
/**
* Activates the device camera to take a picture.
* @returns Promise resolving to the captured image result
*/
takePicture(): Promise<ImageResult>;
/**
* Opens a file picker to select an existing image.
* @returns Promise resolving to the selected image result
*/
pickImage(): Promise<ImageResult>;
/**
* Handles deep link URLs for the application.
* @param url - The deep link URL to handle
* @returns Promise that resolves when the deep link has been handled
*/
handleDeepLink(url: string): Promise<void>;
}

58
src/services/PlatformServiceFactory.ts

@ -0,0 +1,58 @@
import { PlatformService } from "./PlatformService";
import { WebPlatformService } from "./platforms/WebPlatformService";
import { CapacitorPlatformService } from "./platforms/CapacitorPlatformService";
import { ElectronPlatformService } from "./platforms/ElectronPlatformService";
import { PyWebViewPlatformService } from "./platforms/PyWebViewPlatformService";
/**
* Factory class for creating platform-specific service implementations.
* Implements the Singleton pattern to ensure only one instance of PlatformService exists.
*
* The factory determines which platform implementation to use based on the VITE_PLATFORM
* environment variable. Supported platforms are:
* - capacitor: Mobile platform using Capacitor
* - electron: Desktop platform using Electron
* - pywebview: Python WebView implementation
* - web: Default web platform (fallback)
*
* @example
* ```typescript
* const platformService = PlatformServiceFactory.getInstance();
* await platformService.takePicture();
* ```
*/
export class PlatformServiceFactory {
private static instance: PlatformService | null = null;
/**
* Gets or creates the singleton instance of PlatformService.
* Creates the appropriate platform-specific implementation based on environment.
*
* @returns {PlatformService} The singleton instance of PlatformService
*/
public static getInstance(): PlatformService {
if (PlatformServiceFactory.instance) {
return PlatformServiceFactory.instance;
}
const platform = process.env.VITE_PLATFORM || "web";
switch (platform) {
case "capacitor":
PlatformServiceFactory.instance = new CapacitorPlatformService();
break;
case "electron":
PlatformServiceFactory.instance = new ElectronPlatformService();
break;
case "pywebview":
PlatformServiceFactory.instance = new PyWebViewPlatformService();
break;
case "web":
default:
PlatformServiceFactory.instance = new WebPlatformService();
break;
}
return PlatformServiceFactory.instance;
}
}

210
src/services/QRScanner/CapacitorQRScanner.ts

@ -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

@ -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

@ -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

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

35
src/services/api.ts

@ -1,5 +1,40 @@
/**
* API error handling utilities for the application.
* Provides centralized error handling for API requests with platform-specific logging.
*
* @module api
*/
import { AxiosError } from "axios"; import { AxiosError } from "axios";
import { logger } from "../utils/logger"; import { logger } from "../utils/logger";
/**
* Handles API errors with platform-specific logging and error processing.
*
* @param error - The Axios error object from the failed request
* @param endpoint - The API endpoint that was called
* @returns null for rate limit errors (400), throws the error otherwise
* @throws The original error for non-rate-limit cases
*
* @remarks
* Special handling includes:
* - Enhanced logging for Capacitor platform
* - Rate limit detection and handling
* - Detailed error information logging including:
* - Error message
* - HTTP status
* - Response data
* - Request configuration (URL, method, headers)
*
* @example
* ```typescript
* try {
* await api.getData();
* } catch (error) {
* handleApiError(error as AxiosError, '/api/data');
* }
* ```
*/
export const handleApiError = (error: AxiosError, endpoint: string) => { export const handleApiError = (error: AxiosError, endpoint: string) => {
if (process.env.VITE_PLATFORM === "capacitor") { if (process.env.VITE_PLATFORM === "capacitor") {
logger.error(`[Capacitor API Error] ${endpoint}:`, { logger.error(`[Capacitor API Error] ${endpoint}:`, {

107
src/services/deepLinks.ts

@ -7,7 +7,7 @@
* *
* Architecture: * Architecture:
* 1. DeepLinkHandler class encapsulates all deep link processing logic * 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 * 3. Provides consistent error handling and logging
* 4. Maps validated parameters to Vue router calls * 4. Maps validated parameters to Vue router calls
* *
@ -23,6 +23,23 @@
* - Query parameter validation and sanitization * - Query parameter validation and sanitization
* - Type-safe parameter passing to router * - Type-safe parameter passing to router
* *
* Deep Link Format:
* timesafari://<route>[/<param>][?queryParam1=value1&queryParam2=value2]
*
* Supported Routes:
* - user-profile: View user profile
* - project-details: View project details
* - onboard-meeting-setup: Setup onboarding meeting
* - invite-one-accept: Accept invitation
* - contact-import: Import contacts
* - confirm-gift: Confirm gift
* - claim: View claim
* - claim-cert: View claim certificate
* - claim-add-raw: Add raw claim
* - contact-edit: Edit contact
* - contacts: View contacts
* - did: View DID
*
* @example * @example
* const handler = new DeepLinkHandler(router); * const handler = new DeepLinkHandler(router);
* await handler.handleDeepLink("timesafari://claim/123?view=details"); * await handler.handleDeepLink("timesafari://claim/123?view=details");
@ -34,19 +51,57 @@ import {
baseUrlSchema, baseUrlSchema,
routeSchema, routeSchema,
DeepLinkRoute, DeepLinkRoute,
} from "../types/deepLinks"; } from "../interfaces/deepLinks";
import { logConsoleAndDb } from "../db"; import { logConsoleAndDb } from "../db";
import type { DeepLinkError } from "../interfaces/deepLinks"; import type { DeepLinkError } from "../interfaces/deepLinks";
/**
* Handles processing and routing of deep links in the application.
* Provides validation, error handling, and routing for deep link URLs.
*/
export class DeepLinkHandler { export class DeepLinkHandler {
private router: Router; private router: Router;
/**
* Creates a new DeepLinkHandler instance.
* @param router - Vue Router instance for navigation
*/
constructor(router: Router) { constructor(router: Router) {
this.router = router; this.router = router;
} }
/** /**
* Parses deep link URL into path, params and query components * 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.
*
* @param url - The deep link URL to parse (format: scheme://path[?query])
* @throws {DeepLinkError} If URL format is invalid
* @returns Parsed URL components (path, params, query)
*/ */
private parseDeepLink(url: string) { private parseDeepLink(url: string) {
const parts = url.split("://"); const parts = url.split("://");
@ -71,16 +126,23 @@ export class DeepLinkHandler {
}); });
} }
return { const params: Record<string, string> = {};
path: routePath, if (param) {
params: param ? { id: param } : {}, if (this.ROUTE_MAP[routePath].paramKey) {
query, params[this.ROUTE_MAP[routePath].paramKey] = param;
}; } else {
params["id"] = param;
}
}
return { path: routePath, params, query };
} }
/** /**
* Processes incoming deep links and routes them appropriately * Processes incoming deep links and routes them appropriately.
* @param url The deep link URL to process * Handles validation, error handling, and routing to the correct view.
*
* @param url - The deep link URL to process
* @throws {DeepLinkError} If URL processing fails
*/ */
async handleDeepLink(url: string): Promise<void> { async handleDeepLink(url: string): Promise<void> {
try { try {
@ -107,35 +169,26 @@ export class DeepLinkHandler {
} }
/** /**
* Routes the deep link to appropriate view with validated parameters * Routes the deep link to appropriate view with validated parameters.
* Validates route and parameters using Zod schemas before routing.
*
* @param path - The route path from the deep link
* @param params - URL parameters
* @param query - Query string parameters
* @throws {DeepLinkError} If validation fails or route is invalid
*/ */
private async validateAndRoute( private async validateAndRoute(
path: string, path: string,
params: Record<string, string>, params: Record<string, string>,
query: Record<string, string>, query: Record<string, string>,
): Promise<void> { ): 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 // First try to validate the route path
let routeName: string; let routeName: string;
try { try {
// Validate route exists // Validate route exists
const validRoute = routeSchema.parse(path) as DeepLinkRoute; const validRoute = routeSchema.parse(path) as DeepLinkRoute;
routeName = routeMap[validRoute]; routeName = this.ROUTE_MAP[validRoute].name;
} catch (error) { } catch (error) {
// Log the invalid route attempt // Log the invalid route attempt
logConsoleAndDb(`[DeepLink] Invalid route path: ${path}`, true); logConsoleAndDb(`[DeepLink] Invalid route path: ${path}`, true);

59
src/services/plan.ts

@ -1,12 +1,55 @@
/**
* Plan service module for handling plan and claim data loading.
* Provides functionality to load plans with retry mechanism and error handling.
*
* @module plan
*/
import axios from "axios"; import axios from "axios";
import { logger } from "../utils/logger"; import { logger } from "../utils/logger";
/**
* Response interface for plan loading operations.
* Represents the structure of both successful and error responses.
*/
interface PlanResponse { interface PlanResponse {
/** The response data payload */
data?: unknown; data?: unknown;
/** HTTP status code of the response */
status?: number; status?: number;
/** Error message in case of failure */
error?: string; error?: string;
/** Response headers */
headers?: unknown;
} }
/**
* Loads a plan with automatic retry mechanism.
* Attempts to load the plan multiple times in case of failure.
*
* @param handle - The unique identifier for the plan or claim
* @param retries - Number of retry attempts (default: 3)
* @returns Promise resolving to PlanResponse
*
* @remarks
* - Implements exponential backoff with 1 second delay between retries
* - Provides detailed logging of each attempt and any errors
* - Handles both plan and claim flows based on handle content
* - Logs comprehensive error information including:
* - HTTP status and headers
* - Response data
* - Request configuration
*
* @example
* ```typescript
* const response = await loadPlanWithRetry('plan-123');
* if (response.error) {
* console.error(response.error);
* } else {
* console.log(response.data);
* }
* ```
*/
export const loadPlanWithRetry = async ( export const loadPlanWithRetry = async (
handle: string, handle: string,
retries = 3, retries = 3,
@ -58,6 +101,22 @@ export const loadPlanWithRetry = async (
} }
}; };
/**
* Makes a single API request to load a plan or claim.
* Determines the appropriate endpoint based on the handle.
*
* @param handle - The unique identifier for the plan or claim
* @returns Promise resolving to PlanResponse
* @throws Will throw an error if the API request fails
*
* @remarks
* - Automatically detects claim vs plan endpoints based on handle
* - Uses axios for HTTP requests
* - Provides detailed error logging
* - Different endpoints:
* - Claims: /api/claims/{handle}
* - Plans: /api/plans/{handle}
*/
export const loadPlan = async (handle: string): Promise<PlanResponse> => { export const loadPlan = async (handle: string): Promise<PlanResponse> => {
logger.log(`[Plan Service] Making API request for plan ${handle}`); logger.log(`[Plan Service] Making API request for plan ${handle}`);

479
src/services/platforms/CapacitorPlatformService.ts

@ -0,0 +1,479 @@
import {
ImageResult,
PlatformService,
PlatformCapabilities,
} from "../PlatformService";
import { Filesystem, Directory, Encoding } from "@capacitor/filesystem";
import { Camera, CameraResultType, CameraSource } from "@capacitor/camera";
import { Share } from "@capacitor/share";
import { logger } from "../../utils/logger";
/**
* Platform service implementation for Capacitor (mobile) platform.
* Provides native mobile functionality through Capacitor plugins for:
* - File system operations
* - Camera and image picker
* - Platform-specific features
*/
export class CapacitorPlatformService implements PlatformService {
/**
* Gets the capabilities of the Capacitor platform
* @returns Platform capabilities object
*/
getCapabilities(): PlatformCapabilities {
return {
hasFileSystem: true,
hasCamera: true,
isMobile: true,
isIOS: /iPad|iPhone|iPod/.test(navigator.userAgent),
hasFileDownload: false,
needsFileHandlingInstructions: true,
};
}
/**
* Checks and requests storage permissions if needed
* @returns Promise that resolves when permissions are granted
* @throws Error if permissions are denied
*/
private async checkStoragePermissions(): Promise<void> {
try {
const logData = {
platform: this.getCapabilities().isIOS ? "iOS" : "Android",
timestamp: new Date().toISOString(),
};
logger.log(
"Checking storage permissions",
JSON.stringify(logData, null, 2),
);
if (this.getCapabilities().isIOS) {
// iOS uses different permission model
return;
}
// Try to access a test directory to check permissions
try {
await Filesystem.stat({
path: "/storage/emulated/0/Download",
directory: Directory.Documents,
});
logger.log(
"Storage permissions already granted",
JSON.stringify({ timestamp: new Date().toISOString() }, null, 2),
);
return;
} catch (error: unknown) {
const err = error as Error;
const errorLogData = {
error: {
message: err.message,
name: err.name,
stack: err.stack,
},
timestamp: new Date().toISOString(),
};
// "File does not exist" is expected and not a permission error
if (err.message === "File does not exist") {
logger.log(
"Directory does not exist (expected), proceeding with write",
JSON.stringify(errorLogData, null, 2),
);
return;
}
// Check for actual permission errors
if (
err.message.includes("permission") ||
err.message.includes("access")
) {
logger.log(
"Permission check failed, requesting permissions",
JSON.stringify(errorLogData, null, 2),
);
// The Filesystem plugin will automatically request permissions when needed
// We just need to try the operation again
try {
await Filesystem.stat({
path: "/storage/emulated/0/Download",
directory: Directory.Documents,
});
logger.log(
"Storage permissions granted after request",
JSON.stringify({ timestamp: new Date().toISOString() }, null, 2),
);
return;
} catch (retryError: unknown) {
const retryErr = retryError as Error;
throw new Error(
`Failed to obtain storage permissions: ${retryErr.message}`,
);
}
}
// For any other error, log it but don't treat as permission error
logger.log(
"Unexpected error during permission check",
JSON.stringify(errorLogData, null, 2),
);
return;
}
} catch (error: unknown) {
const err = error as Error;
const errorLogData = {
error: {
message: err.message,
name: err.name,
stack: err.stack,
},
timestamp: new Date().toISOString(),
};
logger.error(
"Error checking/requesting permissions",
JSON.stringify(errorLogData, null, 2),
);
throw new Error(`Failed to obtain storage permissions: ${err.message}`);
}
}
/**
* Reads a file from the app's data directory.
* @param path - Relative path to the file in the app's data directory
* @returns Promise resolving to the file contents as string
* @throws Error if file cannot be read or doesn't exist
*/
async readFile(path: string): Promise<string> {
const file = await Filesystem.readFile({
path,
directory: Directory.Data,
});
if (file.data instanceof Blob) {
return await file.data.text();
}
return file.data;
}
/**
* Writes content to a file in the app's safe storage and offers sharing.
*
* Platform-specific behavior:
* - Saves to app's Documents directory
* - Offers sharing functionality to move file elsewhere
*
* The method handles:
* 1. Writing to app-safe storage
* 2. Sharing the file with user's preferred app
* 3. Error handling and logging
*
* @param fileName - The name of the file to create (e.g. "backup.json")
* @param content - The content to write to the file
*
* @throws Error if:
* - File writing fails
* - Sharing fails
*
* @example
* ```typescript
* // Save and share a JSON file
* await platformService.writeFile(
* "backup.json",
* JSON.stringify(data)
* );
* ```
*/
async writeFile(fileName: string, content: string): Promise<void> {
try {
const logData = {
targetFileName: fileName,
contentLength: content.length,
platform: this.getCapabilities().isIOS ? "iOS" : "Android",
timestamp: new Date().toISOString(),
};
logger.log(
"Starting writeFile operation",
JSON.stringify(logData, null, 2),
);
// For Android, we need to handle content URIs differently
if (this.getCapabilities().isIOS) {
// Write to app's Documents directory for iOS
const writeResult = await Filesystem.writeFile({
path: fileName,
data: content,
directory: Directory.Data,
encoding: Encoding.UTF8,
});
const writeSuccessLogData = {
path: writeResult.uri,
timestamp: new Date().toISOString(),
};
logger.log(
"File write successful",
JSON.stringify(writeSuccessLogData, null, 2),
);
// Offer to share the file
try {
await Share.share({
title: "TimeSafari Backup",
text: "Here is your TimeSafari backup file.",
url: writeResult.uri,
dialogTitle: "Share your backup",
});
logger.log(
"Share dialog shown",
JSON.stringify({ timestamp: new Date().toISOString() }, null, 2),
);
} catch (shareError) {
// Log share error but don't fail the operation
logger.error(
"Share dialog failed",
JSON.stringify(
{
error: shareError,
timestamp: new Date().toISOString(),
},
null,
2,
),
);
}
} else {
// For Android, first write to app's Documents directory
const writeResult = await Filesystem.writeFile({
path: fileName,
data: content,
directory: Directory.Data,
encoding: Encoding.UTF8,
});
const writeSuccessLogData = {
path: writeResult.uri,
timestamp: new Date().toISOString(),
};
logger.log(
"File write successful to app storage",
JSON.stringify(writeSuccessLogData, null, 2),
);
// Then share the file to let user choose where to save it
try {
await Share.share({
title: "TimeSafari Backup",
text: "Here is your TimeSafari backup file.",
url: writeResult.uri,
dialogTitle: "Save your backup",
});
logger.log(
"Share dialog shown for Android",
JSON.stringify({ timestamp: new Date().toISOString() }, null, 2),
);
} catch (shareError) {
// Log share error but don't fail the operation
logger.error(
"Share dialog failed for Android",
JSON.stringify(
{
error: shareError,
timestamp: new Date().toISOString(),
},
null,
2,
),
);
}
}
} catch (error: unknown) {
const err = error as Error;
const finalErrorLogData = {
error: {
message: err.message,
name: err.name,
stack: err.stack,
},
timestamp: new Date().toISOString(),
};
logger.error(
"Error in writeFile operation:",
JSON.stringify(finalErrorLogData, null, 2),
);
throw new Error(`Failed to save file: ${err.message}`);
}
}
/**
* Writes content to a file in the device's app-private storage.
* Then shares the file using the system share dialog.
*
* Works on both Android and iOS without needing external storage permissions.
*
* @param fileName - The name of the file to create (e.g. "backup.json")
* @param content - The content to write to the file
*/
async writeAndShareFile(fileName: string, content: string): Promise<void> {
const timestamp = new Date().toISOString();
const logData = {
action: "writeAndShareFile",
fileName,
contentLength: content.length,
timestamp,
};
logger.log("[CapacitorPlatformService]", JSON.stringify(logData, null, 2));
try {
const { uri } = await Filesystem.writeFile({
path: fileName,
data: content,
directory: Directory.Data,
encoding: Encoding.UTF8,
recursive: true,
});
logger.log("[CapacitorPlatformService] File write successful:", {
uri,
timestamp: new Date().toISOString(),
});
await Share.share({
title: "TimeSafari Backup",
text: "Here is your backup file.",
url: uri,
dialogTitle: "Share your backup file",
});
} catch (error) {
const err = error as Error;
const errLog = {
message: err.message,
stack: err.stack,
timestamp: new Date().toISOString(),
};
logger.error(
"[CapacitorPlatformService] Error writing or sharing file:",
JSON.stringify(errLog, null, 2),
);
throw new Error(`Failed to write or share file: ${err.message}`);
}
}
/**
* Deletes a file from the app's data directory.
* @param path - Relative path to the file to delete
* @throws Error if deletion fails or file doesn't exist
*/
async deleteFile(path: string): Promise<void> {
await Filesystem.deleteFile({
path,
directory: Directory.Data,
});
}
/**
* Lists files in the specified directory within app's data directory.
* @param directory - Relative path to the directory to list
* @returns Promise resolving to array of filenames
* @throws Error if directory cannot be read or doesn't exist
*/
async listFiles(directory: string): Promise<string[]> {
const result = await Filesystem.readdir({
path: directory,
directory: Directory.Data,
});
return result.files.map((file) =>
typeof file === "string" ? file : file.name,
);
}
/**
* Opens the device camera to take a picture.
* Configures camera for high quality images with editing enabled.
* @returns Promise resolving to the captured image data
* @throws Error if camera access fails or user cancels
*/
async takePicture(): Promise<ImageResult> {
try {
const image = await Camera.getPhoto({
quality: 90,
allowEditing: true,
resultType: CameraResultType.Base64,
source: CameraSource.Camera,
});
const blob = await this.processImageData(image.base64String);
return {
blob,
fileName: `photo_${Date.now()}.${image.format || "jpg"}`,
};
} catch (error) {
logger.error("Error taking picture with Capacitor:", error);
throw new Error("Failed to take picture");
}
}
/**
* Opens the device photo gallery to pick an existing image.
* Configures picker for high quality images with editing enabled.
* @returns Promise resolving to the selected image data
* @throws Error if gallery access fails or user cancels
*/
async pickImage(): Promise<ImageResult> {
try {
const image = await Camera.getPhoto({
quality: 90,
allowEditing: true,
resultType: CameraResultType.Base64,
source: CameraSource.Photos,
});
const blob = await this.processImageData(image.base64String);
return {
blob,
fileName: `photo_${Date.now()}.${image.format || "jpg"}`,
};
} catch (error) {
logger.error("Error picking image with Capacitor:", error);
throw new Error("Failed to pick image");
}
}
/**
* Converts base64 image data to a Blob.
* @param base64String - Base64 encoded image data
* @returns Promise resolving to image Blob
* @throws Error if conversion fails
*/
private async processImageData(base64String?: string): Promise<Blob> {
if (!base64String) {
throw new Error("No image data received");
}
// Convert base64 to blob
const byteCharacters = atob(base64String);
const byteArrays = [];
for (let offset = 0; offset < byteCharacters.length; offset += 512) {
const slice = byteCharacters.slice(offset, offset + 512);
const byteNumbers = new Array(slice.length);
for (let i = 0; i < slice.length; i++) {
byteNumbers[i] = slice.charCodeAt(i);
}
const byteArray = new Uint8Array(byteNumbers);
byteArrays.push(byteArray);
}
return new Blob(byteArrays, { type: "image/jpeg" });
}
/**
* Handles deep link URLs for the application.
* Note: Capacitor handles deep links automatically.
* @param _url - The deep link URL (unused)
*/
async handleDeepLink(_url: string): Promise<void> {
// Capacitor handles deep links automatically
// This is just a placeholder for the interface
return Promise.resolve();
}
}

111
src/services/platforms/ElectronPlatformService.ts

@ -0,0 +1,111 @@
import {
ImageResult,
PlatformService,
PlatformCapabilities,
} from "../PlatformService";
import { logger } from "../../utils/logger";
/**
* Platform service implementation for Electron (desktop) platform.
* Note: This is a placeholder implementation with most methods currently unimplemented.
* Implements the PlatformService interface but throws "Not implemented" errors for most operations.
*
* @remarks
* This service is intended for desktop application functionality through Electron.
* Future implementations should provide:
* - Native file system access
* - Desktop camera integration
* - System-level features
*/
export class ElectronPlatformService implements PlatformService {
/**
* Gets the capabilities of the Electron platform
* @returns Platform capabilities object
*/
getCapabilities(): PlatformCapabilities {
return {
hasFileSystem: false, // Not implemented yet
hasCamera: false, // Not implemented yet
isMobile: false,
isIOS: false,
hasFileDownload: false, // Not implemented yet
needsFileHandlingInstructions: false,
};
}
/**
* Reads a file from the filesystem.
* @param _path - Path to the file to read
* @returns Promise that should resolve to file contents
* @throws Error with "Not implemented" message
* @todo Implement file reading using Electron's file system API
*/
async readFile(_path: string): Promise<string> {
throw new Error("Not implemented");
}
/**
* Writes content to a file.
* @param _path - Path where to write the file
* @param _content - Content to write to the file
* @throws Error with "Not implemented" message
* @todo Implement file writing using Electron's file system API
*/
async writeFile(_path: string, _content: string): Promise<void> {
throw new Error("Not implemented");
}
/**
* Deletes a file from the filesystem.
* @param _path - Path to the file to delete
* @throws Error with "Not implemented" message
* @todo Implement file deletion using Electron's file system API
*/
async deleteFile(_path: string): Promise<void> {
throw new Error("Not implemented");
}
/**
* Lists files in the specified directory.
* @param _directory - Path to the directory to list
* @returns Promise that should resolve to array of filenames
* @throws Error with "Not implemented" message
* @todo Implement directory listing using Electron's file system API
*/
async listFiles(_directory: string): Promise<string[]> {
throw new Error("Not implemented");
}
/**
* Should open system camera to take a picture.
* @returns Promise that should resolve to captured image data
* @throws Error with "Not implemented" message
* @todo Implement camera access using Electron's media APIs
*/
async takePicture(): Promise<ImageResult> {
logger.error("takePicture not implemented in Electron platform");
throw new Error("Not implemented");
}
/**
* Should open system file picker for selecting an image.
* @returns Promise that should resolve to selected image data
* @throws Error with "Not implemented" message
* @todo Implement file picker using Electron's dialog API
*/
async pickImage(): Promise<ImageResult> {
logger.error("pickImage not implemented in Electron platform");
throw new Error("Not implemented");
}
/**
* Should handle deep link URLs for the desktop application.
* @param _url - The deep link URL to handle
* @throws Error with "Not implemented" message
* @todo Implement deep link handling using Electron's protocol handler
*/
async handleDeepLink(_url: string): Promise<void> {
logger.error("handleDeepLink not implemented in Electron platform");
throw new Error("Not implemented");
}
}

112
src/services/platforms/PyWebViewPlatformService.ts

@ -0,0 +1,112 @@
import {
ImageResult,
PlatformService,
PlatformCapabilities,
} from "../PlatformService";
import { logger } from "../../utils/logger";
/**
* Platform service implementation for PyWebView platform.
* Note: This is a placeholder implementation with most methods currently unimplemented.
* Implements the PlatformService interface but throws "Not implemented" errors for most operations.
*
* @remarks
* This service is intended for Python-based desktop applications using pywebview.
* Future implementations should provide:
* - Integration with Python backend file operations
* - System camera access through Python
* - Native system dialogs via pywebview
* - Python-JavaScript bridge functionality
*/
export class PyWebViewPlatformService implements PlatformService {
/**
* Gets the capabilities of the PyWebView platform
* @returns Platform capabilities object
*/
getCapabilities(): PlatformCapabilities {
return {
hasFileSystem: false, // Not implemented yet
hasCamera: false, // Not implemented yet
isMobile: false,
isIOS: false,
hasFileDownload: false, // Not implemented yet
needsFileHandlingInstructions: false,
};
}
/**
* Reads a file using the Python backend.
* @param _path - Path to the file to read
* @returns Promise that should resolve to file contents
* @throws Error with "Not implemented" message
* @todo Implement file reading through pywebview's Python-JavaScript bridge
*/
async readFile(_path: string): Promise<string> {
throw new Error("Not implemented");
}
/**
* Writes content to a file using the Python backend.
* @param _path - Path where to write the file
* @param _content - Content to write to the file
* @throws Error with "Not implemented" message
* @todo Implement file writing through pywebview's Python-JavaScript bridge
*/
async writeFile(_path: string, _content: string): Promise<void> {
throw new Error("Not implemented");
}
/**
* Deletes a file using the Python backend.
* @param _path - Path to the file to delete
* @throws Error with "Not implemented" message
* @todo Implement file deletion through pywebview's Python-JavaScript bridge
*/
async deleteFile(_path: string): Promise<void> {
throw new Error("Not implemented");
}
/**
* Lists files in the specified directory using the Python backend.
* @param _directory - Path to the directory to list
* @returns Promise that should resolve to array of filenames
* @throws Error with "Not implemented" message
* @todo Implement directory listing through pywebview's Python-JavaScript bridge
*/
async listFiles(_directory: string): Promise<string[]> {
throw new Error("Not implemented");
}
/**
* Should open system camera through Python backend.
* @returns Promise that should resolve to captured image data
* @throws Error with "Not implemented" message
* @todo Implement camera access using Python's camera libraries
*/
async takePicture(): Promise<ImageResult> {
logger.error("takePicture not implemented in PyWebView platform");
throw new Error("Not implemented");
}
/**
* Should open system file picker through pywebview.
* @returns Promise that should resolve to selected image data
* @throws Error with "Not implemented" message
* @todo Implement file picker using pywebview's file dialog API
*/
async pickImage(): Promise<ImageResult> {
logger.error("pickImage not implemented in PyWebView platform");
throw new Error("Not implemented");
}
/**
* Should handle deep link URLs through the Python backend.
* @param _url - The deep link URL to handle
* @throws Error with "Not implemented" message
* @todo Implement deep link handling using Python's URL handling capabilities
*/
async handleDeepLink(_url: string): Promise<void> {
logger.error("handleDeepLink not implemented in PyWebView platform");
throw new Error("Not implemented");
}
}

362
src/services/platforms/WebPlatformService.ts

@ -0,0 +1,362 @@
import {
ImageResult,
PlatformService,
PlatformCapabilities,
} from "../PlatformService";
import { logger } from "../../utils/logger";
/**
* Platform service implementation for web browser platform.
* Implements the PlatformService interface with web-specific functionality.
*
* @remarks
* This service provides web-based implementations for:
* - Image capture using the browser's file input
* - Image selection from local filesystem
* - Image processing and conversion
*
* Note: File system operations are not available in the web platform
* due to browser security restrictions. These methods throw appropriate errors.
*/
export class WebPlatformService implements PlatformService {
/**
* Gets the capabilities of the web platform
* @returns Platform capabilities object
*/
getCapabilities(): PlatformCapabilities {
return {
hasFileSystem: false,
hasCamera: true, // Through file input with capture
isMobile: /iPhone|iPad|iPod|Android/i.test(navigator.userAgent),
isIOS: /iPad|iPhone|iPod/.test(navigator.userAgent),
hasFileDownload: true,
needsFileHandlingInstructions: false,
};
}
/**
* Not supported in web platform.
* @param _path - Unused path parameter
* @throws Error indicating file system access is not available
*/
async readFile(_path: string): Promise<string> {
throw new Error("File system access not available in web platform");
}
/**
* Not supported in web platform.
* @param _path - Unused path parameter
* @param _content - Unused content parameter
* @throws Error indicating file system access is not available
*/
async writeFile(_path: string, _content: string): Promise<void> {
throw new Error("File system access not available in web platform");
}
/**
* Not supported in web platform.
* @param _path - Unused path parameter
* @throws Error indicating file system access is not available
*/
async deleteFile(_path: string): Promise<void> {
throw new Error("File system access not available in web platform");
}
/**
* Not supported in web platform.
* @param _directory - Unused directory parameter
* @throws Error indicating file system access is not available
*/
async listFiles(_directory: string): Promise<string[]> {
throw new Error("File system access not available in web platform");
}
/**
* Opens the device camera for photo capture on desktop browsers using getUserMedia.
* On mobile browsers, uses file input with capture attribute.
* Falls back to file input if getUserMedia is not available or fails.
*
* @returns Promise resolving to the captured image data
*/
async takePicture(): Promise<ImageResult> {
const isMobile = /iPhone|iPad|iPod|Android/i.test(navigator.userAgent);
const hasGetUserMedia = !!(
navigator.mediaDevices && navigator.mediaDevices.getUserMedia
);
// If on mobile, use file input with capture attribute (existing behavior)
if (isMobile || !hasGetUserMedia) {
return new Promise((resolve, reject) => {
const input = document.createElement("input");
input.type = "file";
input.accept = "image/*";
input.capture = "environment";
input.onchange = async (e) => {
const file = (e.target as HTMLInputElement).files?.[0];
if (file) {
try {
const blob = await this.processImageFile(file);
resolve({
blob,
fileName: file.name || "photo.jpg",
});
} catch (error) {
logger.error("Error processing camera image:", error);
reject(new Error("Failed to process camera image"));
}
} else {
reject(new Error("No image captured"));
}
};
input.click();
});
}
// Desktop: Use getUserMedia for webcam capture
return new Promise((resolve, reject) => {
let stream: MediaStream | null = null;
let video: HTMLVideoElement | null = null;
let captureButton: HTMLButtonElement | null = null;
let overlay: HTMLDivElement | null = null;
const cleanup = () => {
if (stream) {
stream.getTracks().forEach((track) => track.stop());
}
if (video && video.parentNode) video.parentNode.removeChild(video);
if (captureButton && captureButton.parentNode)
captureButton.parentNode.removeChild(captureButton);
if (overlay && overlay.parentNode)
overlay.parentNode.removeChild(overlay);
};
// Move async operations inside Promise body
navigator.mediaDevices
.getUserMedia({
video: { facingMode: "user" },
})
.then((mediaStream) => {
stream = mediaStream;
// Create overlay for video and button
overlay = document.createElement("div");
overlay.style.position = "fixed";
overlay.style.top = "0";
overlay.style.left = "0";
overlay.style.width = "100vw";
overlay.style.height = "100vh";
overlay.style.background = "rgba(0,0,0,0.8)";
overlay.style.display = "flex";
overlay.style.flexDirection = "column";
overlay.style.justifyContent = "center";
overlay.style.alignItems = "center";
overlay.style.zIndex = "9999";
video = document.createElement("video");
video.autoplay = true;
video.playsInline = true;
video.style.maxWidth = "90vw";
video.style.maxHeight = "70vh";
video.srcObject = stream;
overlay.appendChild(video);
captureButton = document.createElement("button");
captureButton.textContent = "Capture Photo";
captureButton.style.marginTop = "2rem";
captureButton.style.padding = "1rem 2rem";
captureButton.style.fontSize = "1.2rem";
captureButton.style.background = "#2563eb";
captureButton.style.color = "white";
captureButton.style.border = "none";
captureButton.style.borderRadius = "0.5rem";
captureButton.style.cursor = "pointer";
overlay.appendChild(captureButton);
document.body.appendChild(overlay);
captureButton.onclick = () => {
try {
// Create a canvas to capture the frame
const canvas = document.createElement("canvas");
canvas.width = video!.videoWidth;
canvas.height = video!.videoHeight;
const ctx = canvas.getContext("2d");
ctx?.drawImage(video!, 0, 0, canvas.width, canvas.height);
canvas.toBlob(
(blob) => {
cleanup();
if (blob) {
resolve({
blob,
fileName: `photo_${Date.now()}.jpg`,
});
} else {
reject(new Error("Failed to capture image from webcam"));
}
},
"image/jpeg",
0.95,
);
} catch (err) {
cleanup();
reject(err);
}
};
})
.catch((error) => {
cleanup();
logger.error("Error accessing webcam:", error);
// Fallback to file input
const input = document.createElement("input");
input.type = "file";
input.accept = "image/*";
input.onchange = (e) => {
const file = (e.target as HTMLInputElement).files?.[0];
if (file) {
this.processImageFile(file)
.then((blob) => {
resolve({
blob,
fileName: file.name || "photo.jpg",
});
})
.catch((error) => {
logger.error("Error processing fallback image:", error);
reject(new Error("Failed to process fallback image"));
});
} else {
reject(new Error("No image selected"));
}
};
input.click();
});
});
}
/**
* Opens a file input dialog for selecting an image file.
* Creates a temporary file input element to access local files.
*
* @returns Promise resolving to the selected image data
* @throws Error if image processing fails or no image is selected
*
* @remarks
* Allows selection of any image file type.
* Processes the selected image to ensure consistent format.
*/
async pickImage(): Promise<ImageResult> {
return new Promise((resolve, reject) => {
const input = document.createElement("input");
input.type = "file";
input.accept = "image/*";
input.onchange = async (e) => {
const file = (e.target as HTMLInputElement).files?.[0];
if (file) {
try {
const blob = await this.processImageFile(file);
resolve({
blob,
fileName: file.name || "photo.jpg",
});
} catch (error) {
logger.error("Error processing picked image:", error);
reject(new Error("Failed to process picked image"));
}
} else {
reject(new Error("No image selected"));
}
};
input.click();
});
}
/**
* Processes an image file to ensure consistent format.
* Converts the file to a data URL and then to a Blob.
*
* @param file - The image File object to process
* @returns Promise resolving to processed image Blob
* @throws Error if file reading or conversion fails
*
* @remarks
* This method ensures consistent image format across different
* input sources by converting through data URL to Blob.
*/
private async processImageFile(file: File): Promise<Blob> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = (event) => {
const dataUrl = event.target?.result as string;
// Convert to blob to ensure consistent format
fetch(dataUrl)
.then((res) => res.blob())
.then((blob) => resolve(blob))
.catch((error) => {
logger.error("Error converting data URL to blob:", error);
reject(error);
});
};
reader.onerror = (error) => {
logger.error("Error reading file:", error);
reject(error);
};
reader.readAsDataURL(file);
});
}
/**
* Checks if running on Capacitor platform.
* @returns false, as this is not Capacitor
*/
isCapacitor(): boolean {
return false;
}
/**
* Checks if running on Electron platform.
* @returns false, as this is not Electron
*/
isElectron(): boolean {
return false;
}
/**
* Checks if running on PyWebView platform.
* @returns false, as this is not PyWebView
*/
isPyWebView(): boolean {
return false;
}
/**
* Checks if running on web platform.
* @returns true, as this is the web implementation
*/
isWeb(): boolean {
return true;
}
/**
* Handles deep link URLs in the web platform.
* Deep links are handled through URL parameters in the web environment.
*
* @param _url - The deep link URL to handle (unused in web implementation)
* @returns Promise that resolves immediately as web handles URLs naturally
*/
async handleDeepLink(_url: string): Promise<void> {
// Web platform can handle deep links through URL parameters
return Promise.resolve();
}
/**
* Not supported in web platform.
* @param _fileName - Unused fileName parameter
* @param _content - Unused content parameter
* @throws Error indicating file system access is not available
*/
async writeAndShareFile(_fileName: string, _content: string): Promise<void> {
throw new Error("File system access not available in web platform");
}
}

103
src/types/deepLinks.ts

@ -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]>;
};

25
src/types/index.ts

@ -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

@ -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();

39
src/utils/logger.ts

@ -3,7 +3,7 @@ import { logToDb } from "../db";
function safeStringify(obj: unknown) { function safeStringify(obj: unknown) {
const seen = new WeakSet(); const seen = new WeakSet();
return JSON.stringify(obj, (key, value) => { return JSON.stringify(obj, (_key, value) => {
if (typeof value === "object" && value !== null) { if (typeof value === "object" && value !== null) {
if (seen.has(value)) { if (seen.has(value)) {
return "[Circular]"; return "[Circular]";
@ -20,16 +20,41 @@ function safeStringify(obj: unknown) {
} }
export const logger = { export const logger = {
log: (message: string, ...args: unknown[]) => { debug: (message: string, ...args: unknown[]) => {
if (process.env.NODE_ENV !== "production") { 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" ||
process.env.VITE_PLATFORM === "capacitor"
) {
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
console.log(message, ...args); console.log(message, ...args);
const argsString = args.length > 0 ? " - " + safeStringify(args) : ""; const argsString = args.length > 0 ? " - " + safeStringify(args) : "";
logToDb(message + argsString); 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[]) => { warn: (message: string, ...args: unknown[]) => {
if (process.env.NODE_ENV !== "production") { if (
process.env.NODE_ENV !== "production" ||
process.env.VITE_PLATFORM === "capacitor"
) {
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
console.warn(message, ...args); console.warn(message, ...args);
const argsString = args.length > 0 ? " - " + safeStringify(args) : ""; const argsString = args.length > 0 ? " - " + safeStringify(args) : "";
@ -44,3 +69,11 @@ export const logger = {
logToDb(message + argsString); 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 };

282
src/views/AccountViewView.vue

@ -3,7 +3,12 @@
<TopMessage /> <TopMessage />
<!-- CONTENT --> <!-- 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 --> <!-- Heading -->
<h1 id="ViewHeading" class="text-4xl text-center font-light"> <h1 id="ViewHeading" class="text-4xl text-center font-light">
Your Identity Your Identity
@ -14,6 +19,8 @@
v-if="!activeDid" v-if="!activeDid"
id="noticeBeforeShare" 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" 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"> <p class="mb-4">
<b>Note:</b> Before you can share with others or take any action, you <b>Note:</b> Before you can share with others or take any action, you
@ -28,10 +35,12 @@
</div> </div>
<!-- Identity Details --> <!-- Identity Details -->
<div <section
id="sectionIdentityDetails" id="sectionIdentityDetails"
class="bg-slate-100 rounded-md overflow-hidden px-4 py-3 mt-4" 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"> <div v-if="givenName">
<h2 class="text-xl font-semibold mb-2"> <h2 class="text-xl font-semibold mb-2">
<span class="whitespace-nowrap"> <span class="whitespace-nowrap">
@ -74,30 +83,56 @@
:icon-size="96" :icon-size="96"
:profile-image-url="profileImageUrl" :profile-image-url="profileImageUrl"
class="inline-block align-text-bottom border border-slate-300 rounded" 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" @click="showLargeIdenticonUrl = profileImageUrl"
/> />
<font-awesome <font-awesome
icon="trash-can" icon="trash-can"
class="text-red-500 fa-fw ml-8 mt-8 w-12 h-12" 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" @click="confirmDeleteImage"
/> />
</span> </span>
<div v-else class="text-center"> <div v-else class="text-center">
<div class @click="openImageDialog()"> <template v-if="isRegistered">
<font-awesome <div
icon="image-portrait" 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"
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" @click="openImageDialog()"
/> >
<font-awesome <font-awesome icon="user" class="fa-fw" />
icon="camera" <font-awesome icon="camera" class="fa-fw" />
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>
<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> </div>
</template>
</div> </div>
<ImageMethodDialog ref="imageMethodDialog" /> <ImageMethodDialog
ref="imageMethodDialog"
:is-registered="isRegistered"
/>
</div> </div>
<div class="mt-6"> <div class="mt-4">
<div class="flex justify-center text-center"> <div class="flex justify-center text-center text-sm leading-tight mb-1">
People {{ profileImageUrl ? "without your image" : "" }} see this People {{ profileImageUrl ? "without your image" : "" }} see this
<br /> <br />
(if you've let them see your activity): (if you've let them see your activity):
@ -131,21 +166,30 @@
</div> </div>
</div> </div>
<div class="text-slate-500 text-sm font-bold">ID</div>
<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" data-testId="didWrapper"
role="region"
aria-label="Your Identifier"
> >
<code class="truncate">{{ activeDid }}</code> <div class="font-bold">ID:&nbsp;</div>
<code class="truncate" aria-label="Your DID">{{ activeDid }}</code>
<button <button
class="ml-2" class="ml-2"
aria-label="Copy DID to clipboard"
@click=" @click="
doCopyTwoSecRedo(activeDid, () => (showDidCopy = !showDidCopy)) 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> </button>
<span v-show="showDidCopy">Copied</span> <span v-show="showDidCopy" role="status" aria-live="polite"
>Copied</span
>
</div> </div>
<div class="text-blue-500 text-sm font-bold"> <div class="text-blue-500 text-sm font-bold">
@ -153,7 +197,7 @@
Your Activity Your Activity
</router-link> </router-link>
</div> </div>
</div> </section>
<!-- Registration notice --> <!-- Registration notice -->
<!-- <!--
@ -164,10 +208,12 @@
v-if="!isRegistered" v-if="!isRegistered"
id="noticeBeforeAnnounce" 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" 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"> <p class="mb-2">
<b>Note:</b> Before you can publicly announce a new project or time Before you can publicly announce a new project or time commitment, a
commitment, a friend needs to register you. friend needs to register you.
</p> </p>
<router-link <router-link
:to="{ name: 'contact-qr' }" :to="{ name: 'contact-qr' }"
@ -177,26 +223,33 @@
</router-link> </router-link>
</div> </div>
<div <section
v-if="isRegistered" v-if="isRegistered"
id="sectionNotifications" id="sectionNotifications"
class="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="notificationsHeading"
> >
<!-- label --> <h2 id="notificationsHeading" class="mb-2 font-bold">Notifications</h2>
<div class="mb-2 font-bold">Notifications</div>
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<!-- label -->
<div> <div>
Reminder Notification Reminder Notification
<button
class="text-slate-400 fa-fw cursor-pointer"
aria-label="Learn more about reminder notifications"
@click.stop="showReminderNotificationInfo"
>
<font-awesome <font-awesome
icon="question-circle" icon="question-circle"
class="text-slate-400 fa-fw ml-2 cursor-pointer" aria-hidden="true"
@click.stop="showReminderNotificationInfo" ></font-awesome>
/> </button>
</div> </div>
<!-- toggle -->
<div <div
class="relative ml-2 cursor-pointer" class="relative ml-2 cursor-pointer"
role="switch"
:aria-checked="notifyingReminder"
aria-label="Toggle reminder notifications"
tabindex="0"
@click="showReminderNotificationChoice()" @click="showReminderNotificationChoice()"
> >
<!-- input --> <!-- input -->
@ -219,7 +272,7 @@
New Activity Notification New Activity Notification
<font-awesome <font-awesome
icon="question-circle" icon="question-circle"
class="text-slate-400 fa-fw ml-2 cursor-pointer" class="text-slate-400 fa-fw cursor-pointer"
@click.stop="showNewActivityNotificationInfo" @click.stop="showNewActivityNotificationInfo"
/> />
</div> </div>
@ -245,52 +298,54 @@
<div v-if="notifyingNewActivityTime" class="w-full text-right"> <div v-if="notifyingNewActivityTime" class="w-full text-right">
{{ notifyingNewActivityTime.replace(" ", "&nbsp;") }} {{ notifyingNewActivityTime.replace(" ", "&nbsp;") }}
</div> </div>
<router-link class="pl-4 text-sm text-blue-500" to="/help-notifications"> <div class="mt-2 text-center">
Troubleshoot your notifications. <router-link class="text-sm text-blue-500" to="/help-notifications">
Troubleshoot your notifications&hellip;
</router-link> </router-link>
</div> </div>
</section>
<PushNotificationPermission ref="pushNotificationPermission" /> <PushNotificationPermission ref="pushNotificationPermission" />
<div <section
id="sectionSearchLocation" 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 --> <h2 id="searchLocationHeading" class="mb-2 font-bold">
<span class="mb-2 font-bold">Location for Searches</span> Location for Searches
</h2>
<router-link <router-link
:to="{ name: 'search-area' }" :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 {{ isSearchAreasSet ? "Change" : "Set" }} Search Area
</router-link> </router-link>
</div> </section>
<!-- User Profile --> <!-- User Profile -->
<div <section
v-if="isRegistered" v-if="isRegistered"
class="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="userProfileHeading"
> >
<div v-if="loadingProfile" class="text-center mb-2"> <h2 id="userProfileHeading" class="mb-2 font-bold">
<font-awesome Public Profile
icon="spinner" <button
class="fa-spin text-slate-400" class="text-slate-400 fa-fw cursor-pointer"
></font-awesome> aria-label="Learn more about public profile"
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"
@click="showProfileInfo" @click="showProfileInfo"
/> >
</div> <font-awesome icon="circle-info" aria-hidden="true"></font-awesome>
</button>
</h2>
<textarea <textarea
v-model="userProfileDesc" v-model="userProfileDesc"
class="w-full h-32 p-2 border border-slate-300 rounded-md" class="w-full h-32 p-2 border border-slate-300 rounded-md"
placeholder="Write something about yourself for the public..." placeholder="Write something about yourself for the public..."
:readonly="loadingProfile || savingProfile" :readonly="loadingProfile || savingProfile"
:class="{ 'bg-slate-100': loadingProfile || savingProfile }" :class="{ 'bg-slate-100': loadingProfile || savingProfile }"
aria-label="Public profile description"
:aria-busy="loadingProfile || savingProfile"
></textarea> ></textarea>
<div class="flex items-center mb-4" @click="toggleUserProfileLocation"> <div class="flex items-center mb-4" @click="toggleUserProfileLocation">
@ -331,9 +386,9 @@
</l-map> </l-map>
</div> </div>
<div v-if="!loadingProfile && !savingProfile"> <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 <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" :disabled="loadingProfile || savingProfile"
:class="{ :class="{
'opacity-50 cursor-not-allowed': loadingProfile || savingProfile, 'opacity-50 cursor-not-allowed': loadingProfile || savingProfile,
@ -343,7 +398,7 @@
Save Profile Save Profile
</button> </button>
<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" :disabled="loadingProfile || savingProfile"
:class="{ :class="{
'opacity-50 cursor-not-allowed': 'opacity-50 cursor-not-allowed':
@ -359,18 +414,28 @@
</div> </div>
<div v-else-if="loadingProfile">Loading...</div> <div v-else-if="loadingProfile">Loading...</div>
<div v-else>Saving...</div> <div v-else>Saving...</div>
</div> </section>
<div <section
v-if="activeDid" v-if="activeDid"
id="sectionUsageLimits" id="sectionUsageLimits"
class="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="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 --> <!-- 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&hellip; Checking&hellip;
<font-awesome icon="spinner" class="fa-spin"></font-awesome> <font-awesome
icon="spinner"
class="fa-spin"
aria-hidden="true"
></font-awesome>
</div> </div>
<div class="mb-4 text-center"> <div class="mb-4 text-center">
{{ limitsMessage }} {{ limitsMessage }}
@ -413,70 +478,29 @@
</p> </p>
</div> </div>
<button <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()" @click="checkLimits()"
> >
Recheck Limits Recheck Limits
</button> </button>
</div> </section>
<div
id="sectionDataExport"
class="bg-slate-100 rounded-md overflow-hidden px-4 py-4 mt-8 mb-8"
>
<div class="mb-2 font-bold">Data Export</div>
<router-link
v-if="activeDid"
:to="{ name: 'seed-backup' }"
class="block w-full text-center text-md bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md mb-2 mt-2"
>
Backup Identifier Seed
</router-link>
<button <DataExportSection :active-did="activeDid" />
:class="computedStartDownloadLinkClassNames()"
class="block w-full text-center text-md bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md"
@click="exportDatabase()"
>
Download Settings & Contacts
<br />
(excluding Identifier Data)
</button>
<a
ref="downloadLink"
:class="computedDownloadLinkClassNames()"
class="block w-full text-center text-md bg-gradient-to-b from-green-500 to-green-800 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md mb-6"
>
If no download happened yet, click again here to download now.
</a>
<div class="mt-4">
<p>
After the download, you can save the file in your preferred storage
location.
</p>
<ul>
<li class="list-disc list-outside ml-4">
On iOS: Choose "More..." and select a place in iCloud, or go "Back"
and save to another location.
</li>
<li class="list-disc list-outside ml-4">
On Android: Choose "Open" and then share
<font-awesome icon="share-nodes" class="fa-fw" />
to your prefered place.
</li>
</ul>
</div>
</div>
<!-- id used by puppeteer test script --> <!-- id used by puppeteer test script -->
<h3 <h3
id="advanced" 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" @click="showAdvanced = !showAdvanced"
> >
Advanced {{ showAdvanced ? "Hide Advanced Settings" : "Show Advanced Settings" }}
</h3> </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"> <p class="text-rose-600 mb-8">
Beware: the features here can be confusing and even change data in ways Beware: the features here can be confusing and even change data in ways
you do not expect. But we support your freedom! you do not expect. But we support your freedom!
@ -647,42 +671,64 @@
<div id="sectionClaimServer"> <div id="sectionClaimServer">
<h2 class="text-slate-500 text-sm font-bold mt-4">Claim Server</h2> <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 <input
id="apiServerInput"
v-model="apiServerInput" v-model="apiServerInput"
type="text" type="text"
class="block w-full rounded border border-slate-400 px-4 py-2" 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 <button
v-if="apiServerInput != apiServer" v-if="apiServerInput != apiServer"
class="w-full px-4 rounded bg-yellow-500 border border-slate-400" class="w-full px-4 rounded bg-yellow-500 border border-slate-400"
aria-label="Save API server URL"
@click="onClickSaveApiServer()" @click="onClickSaveApiServer()"
> >
<font-awesome <font-awesome
icon="floppy-disk" icon="floppy-disk"
class="fa-fw" class="fa-fw"
color="white" color="white"
aria-hidden="true"
></font-awesome> ></font-awesome>
</button> </button>
<div class="mt-2" role="group" aria-label="Quick server selection">
<button <button
class="px-3 rounded bg-slate-200 border border-slate-400" class="px-3 rounded bg-slate-200 border border-slate-400"
aria-label="Use production server URL"
@click="apiServerInput = AppConstants.PROD_ENDORSER_API_SERVER" @click="apiServerInput = AppConstants.PROD_ENDORSER_API_SERVER"
> >
Use Prod Use Prod
</button> </button>
<button <button
class="px-3 rounded bg-slate-200 border border-slate-400" class="px-3 rounded bg-slate-200 border border-slate-400"
aria-label="Use test server URL"
@click="apiServerInput = AppConstants.TEST_ENDORSER_API_SERVER" @click="apiServerInput = AppConstants.TEST_ENDORSER_API_SERVER"
> >
Use Test Use Test
</button> </button>
<button <button
class="px-3 rounded bg-slate-200 border border-slate-400" class="px-3 rounded bg-slate-200 border border-slate-400"
aria-label="Use local server URL"
@click="apiServerInput = AppConstants.LOCAL_ENDORSER_API_SERVER" @click="apiServerInput = AppConstants.LOCAL_ENDORSER_API_SERVER"
> >
Use Local Use Local
</button> </button>
</div> </div>
</div>
<label <label
for="toggleProdWarningMessage" for="toggleProdWarningMessage"
@ -919,8 +965,8 @@
> >
View Logs View Logs
</router-link> </router-link>
</div>
</section> </section>
</main>
</template> </template>
<script lang="ts"> <script lang="ts">
@ -946,6 +992,7 @@ import PushNotificationPermission from "../components/PushNotificationPermission
import QuickNav from "../components/QuickNav.vue"; import QuickNav from "../components/QuickNav.vue";
import TopMessage from "../components/TopMessage.vue"; import TopMessage from "../components/TopMessage.vue";
import UserNameDialog from "../components/UserNameDialog.vue"; import UserNameDialog from "../components/UserNameDialog.vue";
import DataExportSection from "../components/DataExportSection.vue";
import { import {
AppString, AppString,
DEFAULT_IMAGE_API_SERVER, DEFAULT_IMAGE_API_SERVER,
@ -999,6 +1046,7 @@ const inputImportFileNameRef = ref<Blob>();
QuickNav, QuickNav,
TopMessage, TopMessage,
UserNameDialog, UserNameDialog,
DataExportSection,
}, },
}) })
export default class AccountViewView extends Vue { export default class AccountViewView extends Vue {

841
src/views/ContactQRScanShowView.vue

File diff suppressed because it is too large

430
src/views/ContactQRScanView.vue

@ -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>

Some files were not shown because too many files changed in this diff

Loading…
Cancel
Save