135 changed files with 15484 additions and 7884 deletions
@ -0,0 +1,292 @@ |
|||||
|
--- |
||||
|
description: |
||||
|
globs: |
||||
|
alwaysApply: true |
||||
|
--- |
||||
|
# TimeSafari Cross-Platform Architecture Guide |
||||
|
|
||||
|
## 1. Platform Support Matrix |
||||
|
|
||||
|
| Feature | Web (PWA) | Capacitor (Mobile) | Electron (Desktop) | PyWebView (Desktop) | |
||||
|
|---------|-----------|-------------------|-------------------|-------------------| |
||||
|
| QR Code Scanning | WebInlineQRScanner | @capacitor-mlkit/barcode-scanning | Not Implemented | Not Implemented | |
||||
|
| Deep Linking | URL Parameters | App URL Open Events | Not Implemented | Not Implemented | |
||||
|
| File System | Limited (Browser API) | Capacitor Filesystem | Electron fs | PyWebView Python Bridge | |
||||
|
| Camera Access | MediaDevices API | Capacitor Camera | Not Implemented | Not Implemented | |
||||
|
| Platform Detection | Web APIs | Capacitor.isNativePlatform() | process.env checks | process.env checks | |
||||
|
|
||||
|
## 2. Project Structure |
||||
|
|
||||
|
### 2.1 Core Directories |
||||
|
``` |
||||
|
src/ |
||||
|
├── components/ # Vue components |
||||
|
├── services/ # Platform services and business logic |
||||
|
├── views/ # Page components |
||||
|
├── router/ # Vue router configuration |
||||
|
├── types/ # TypeScript type definitions |
||||
|
├── utils/ # Utility functions |
||||
|
├── lib/ # Core libraries |
||||
|
├── platforms/ # Platform-specific implementations |
||||
|
├── electron/ # Electron-specific code |
||||
|
├── constants/ # Application constants |
||||
|
├── db/ # Database related code |
||||
|
├── interfaces/ # TypeScript interfaces and type definitions |
||||
|
└── assets/ # Static assets |
||||
|
``` |
||||
|
|
||||
|
### 2.2 Entry Points |
||||
|
``` |
||||
|
src/ |
||||
|
├── main.ts # Base entry |
||||
|
├── main.common.ts # Shared initialization |
||||
|
├── main.capacitor.ts # Mobile entry |
||||
|
├── main.electron.ts # Electron entry |
||||
|
├── main.pywebview.ts # PyWebView entry |
||||
|
└── main.web.ts # Web/PWA entry |
||||
|
``` |
||||
|
|
||||
|
### 2.3 Build Configurations |
||||
|
``` |
||||
|
root/ |
||||
|
├── vite.config.common.mts # Shared config |
||||
|
├── vite.config.capacitor.mts # Mobile build |
||||
|
├── vite.config.electron.mts # Electron build |
||||
|
├── vite.config.pywebview.mts # PyWebView build |
||||
|
├── vite.config.web.mts # Web/PWA build |
||||
|
└── vite.config.utils.mts # Build utilities |
||||
|
``` |
||||
|
|
||||
|
## 3. Service Architecture |
||||
|
|
||||
|
### 3.1 Service Organization |
||||
|
``` |
||||
|
services/ |
||||
|
├── QRScanner/ # QR code scanning service |
||||
|
│ ├── WebInlineQRScanner.ts |
||||
|
│ └── interfaces.ts |
||||
|
├── platforms/ # Platform-specific services |
||||
|
│ ├── WebPlatformService.ts |
||||
|
│ ├── CapacitorPlatformService.ts |
||||
|
│ ├── ElectronPlatformService.ts |
||||
|
│ └── PyWebViewPlatformService.ts |
||||
|
└── factory/ # Service factories |
||||
|
└── PlatformServiceFactory.ts |
||||
|
``` |
||||
|
|
||||
|
### 3.2 Service Factory Pattern |
||||
|
```typescript |
||||
|
// PlatformServiceFactory.ts |
||||
|
export class PlatformServiceFactory { |
||||
|
private static instance: PlatformService | null = null; |
||||
|
|
||||
|
public static getInstance(): PlatformService { |
||||
|
if (!PlatformServiceFactory.instance) { |
||||
|
const platform = process.env.VITE_PLATFORM || "web"; |
||||
|
PlatformServiceFactory.instance = createPlatformService(platform); |
||||
|
} |
||||
|
return PlatformServiceFactory.instance; |
||||
|
} |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
## 4. Feature Implementation Guidelines |
||||
|
|
||||
|
### 4.1 QR Code Scanning |
||||
|
|
||||
|
1. **Service Interface** |
||||
|
```typescript |
||||
|
interface QRScannerService { |
||||
|
checkPermissions(): Promise<boolean>; |
||||
|
requestPermissions(): Promise<boolean>; |
||||
|
isSupported(): Promise<boolean>; |
||||
|
startScan(): Promise<void>; |
||||
|
stopScan(): Promise<void>; |
||||
|
addListener(listener: ScanListener): void; |
||||
|
onStream(callback: (stream: MediaStream | null) => void): void; |
||||
|
cleanup(): Promise<void>; |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
2. **Platform-Specific Implementation** |
||||
|
```typescript |
||||
|
// WebInlineQRScanner.ts |
||||
|
export class WebInlineQRScanner implements QRScannerService { |
||||
|
private scanListener: ScanListener | null = null; |
||||
|
private isScanning = false; |
||||
|
private stream: MediaStream | null = null; |
||||
|
private events = new EventEmitter(); |
||||
|
|
||||
|
// Implementation of interface methods |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
### 4.2 Deep Linking |
||||
|
|
||||
|
1. **URL Structure** |
||||
|
```typescript |
||||
|
// Format: timesafari://<route>[/<param>][?queryParam1=value1] |
||||
|
interface DeepLinkParams { |
||||
|
route: string; |
||||
|
params?: Record<string, string>; |
||||
|
query?: Record<string, string>; |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
2. **Platform Handlers** |
||||
|
```typescript |
||||
|
// Capacitor |
||||
|
App.addListener("appUrlOpen", handleDeepLink); |
||||
|
|
||||
|
// Web |
||||
|
router.beforeEach((to, from, next) => { |
||||
|
handleWebDeepLink(to.query); |
||||
|
}); |
||||
|
``` |
||||
|
|
||||
|
## 5. Build Process |
||||
|
|
||||
|
### 5.1 Environment Configuration |
||||
|
```typescript |
||||
|
// vite.config.common.mts |
||||
|
export function createBuildConfig(mode: string) { |
||||
|
return { |
||||
|
define: { |
||||
|
'process.env.VITE_PLATFORM': JSON.stringify(mode), |
||||
|
'process.env.VITE_PWA_ENABLED': JSON.stringify(!isNative), |
||||
|
__IS_MOBILE__: JSON.stringify(isCapacitor), |
||||
|
__USE_QR_READER__: JSON.stringify(!isCapacitor) |
||||
|
} |
||||
|
}; |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
### 5.2 Platform-Specific Builds |
||||
|
|
||||
|
```bash |
||||
|
# Build commands from package.json |
||||
|
"build:web": "vite build --config vite.config.web.mts", |
||||
|
"build:capacitor": "vite build --config vite.config.capacitor.mts", |
||||
|
"build:electron": "vite build --config vite.config.electron.mts", |
||||
|
"build:pywebview": "vite build --config vite.config.pywebview.mts" |
||||
|
``` |
||||
|
|
||||
|
## 6. Testing Strategy |
||||
|
|
||||
|
### 6.1 Test Configuration |
||||
|
```typescript |
||||
|
// playwright.config-local.ts |
||||
|
const config: PlaywrightTestConfig = { |
||||
|
projects: [ |
||||
|
{ |
||||
|
name: 'web', |
||||
|
use: { browserName: 'chromium' } |
||||
|
}, |
||||
|
{ |
||||
|
name: 'mobile', |
||||
|
use: { ...devices['Pixel 5'] } |
||||
|
} |
||||
|
] |
||||
|
}; |
||||
|
``` |
||||
|
|
||||
|
### 6.2 Platform-Specific Tests |
||||
|
```typescript |
||||
|
test('QR scanning works on mobile', async ({ page }) => { |
||||
|
test.skip(!process.env.MOBILE_TEST, 'Mobile-only test'); |
||||
|
// Test implementation |
||||
|
}); |
||||
|
``` |
||||
|
|
||||
|
## 7. Error Handling |
||||
|
|
||||
|
### 7.1 Global Error Handler |
||||
|
```typescript |
||||
|
function setupGlobalErrorHandler(app: VueApp) { |
||||
|
app.config.errorHandler = (err, instance, info) => { |
||||
|
logger.error("[App Error]", { |
||||
|
error: err, |
||||
|
info, |
||||
|
component: instance?.$options.name |
||||
|
}); |
||||
|
}; |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
### 7.2 Platform-Specific Error Handling |
||||
|
```typescript |
||||
|
// API error handling for Capacitor |
||||
|
if (process.env.VITE_PLATFORM === 'capacitor') { |
||||
|
logger.error(`[Capacitor API Error] ${endpoint}:`, { |
||||
|
message: error.message, |
||||
|
status: error.response?.status |
||||
|
}); |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
## 8. Best Practices |
||||
|
|
||||
|
### 8.1 Code Organization |
||||
|
- Use platform-specific directories for unique implementations |
||||
|
- Share common code through service interfaces |
||||
|
- Implement feature detection before using platform capabilities |
||||
|
- Keep platform-specific code isolated in dedicated directories |
||||
|
- Use TypeScript interfaces for cross-platform compatibility |
||||
|
|
||||
|
### 8.2 Platform Detection |
||||
|
```typescript |
||||
|
const platformService = PlatformServiceFactory.getInstance(); |
||||
|
const capabilities = platformService.getCapabilities(); |
||||
|
|
||||
|
if (capabilities.hasCamera) { |
||||
|
// Implement camera features |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
### 8.3 Feature Implementation |
||||
|
1. Define platform-agnostic interface |
||||
|
2. Create platform-specific implementations |
||||
|
3. Use factory pattern for instantiation |
||||
|
4. Implement graceful fallbacks |
||||
|
5. Add comprehensive error handling |
||||
|
6. Use dependency injection for better testability |
||||
|
|
||||
|
## 9. Dependency Management |
||||
|
|
||||
|
### 9.1 Platform-Specific Dependencies |
||||
|
```json |
||||
|
{ |
||||
|
"dependencies": { |
||||
|
"@capacitor/core": "^6.2.0", |
||||
|
"electron": "^33.2.1", |
||||
|
"vue": "^3.4.0" |
||||
|
} |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
### 9.2 Conditional Loading |
||||
|
```typescript |
||||
|
if (process.env.VITE_PLATFORM === 'capacitor') { |
||||
|
await import('@capacitor/core'); |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
## 10. Security Considerations |
||||
|
|
||||
|
### 10.1 Permission Handling |
||||
|
```typescript |
||||
|
async checkPermissions(): Promise<boolean> { |
||||
|
if (platformService.isCapacitor()) { |
||||
|
return await checkNativePermissions(); |
||||
|
} |
||||
|
return await checkWebPermissions(); |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
### 10.2 Data Storage |
||||
|
- Use secure storage mechanisms for sensitive data |
||||
|
- Implement proper encryption for stored data |
||||
|
- Follow platform-specific security guidelines |
||||
|
- Regular security audits and updates |
||||
|
|
||||
|
This document should be updated as new features are added or platform-specific implementations change. Regular reviews ensure it remains current with the codebase. |
@ -0,0 +1,222 @@ |
|||||
|
--- |
||||
|
description: |
||||
|
globs: |
||||
|
alwaysApply: true |
||||
|
--- |
||||
|
# Camera Implementation Documentation |
||||
|
|
||||
|
## Overview |
||||
|
|
||||
|
This document describes how camera functionality is implemented across the TimeSafari application. The application uses cameras for two main purposes: |
||||
|
|
||||
|
1. QR Code scanning |
||||
|
2. Photo capture |
||||
|
|
||||
|
## Components |
||||
|
|
||||
|
### QRScannerDialog.vue |
||||
|
|
||||
|
Primary component for QR code scanning in web browsers. |
||||
|
|
||||
|
**Key Features:** |
||||
|
|
||||
|
- Uses `qrcode-stream` for web-based QR scanning |
||||
|
- Supports both front and back cameras |
||||
|
- Provides real-time camera status feedback |
||||
|
- Implements error handling with user-friendly messages |
||||
|
- Includes camera switching functionality |
||||
|
|
||||
|
**Camera Access Flow:** |
||||
|
|
||||
|
1. Checks for camera API availability |
||||
|
2. Enumerates available video devices |
||||
|
3. Requests camera permissions |
||||
|
4. Initializes camera stream with preferred settings |
||||
|
5. Handles various error conditions with specific messages |
||||
|
|
||||
|
### PhotoDialog.vue |
||||
|
|
||||
|
Component for photo capture and selection. |
||||
|
|
||||
|
**Key Features:** |
||||
|
|
||||
|
- Cross-platform photo capture interface |
||||
|
- Image cropping capabilities |
||||
|
- File selection fallback |
||||
|
- Unified interface for different platforms |
||||
|
|
||||
|
## Services |
||||
|
|
||||
|
### QRScanner Services |
||||
|
|
||||
|
#### WebDialogQRScanner |
||||
|
|
||||
|
Web-based implementation of QR scanning. |
||||
|
|
||||
|
**Key Methods:** |
||||
|
|
||||
|
- `checkPermissions()`: Verifies camera permission status |
||||
|
- `requestPermissions()`: Requests camera access |
||||
|
- `isSupported()`: Checks for camera API support |
||||
|
- Handles various error conditions with specific messages |
||||
|
|
||||
|
#### CapacitorQRScanner |
||||
|
|
||||
|
Native implementation using Capacitor's MLKit. |
||||
|
|
||||
|
**Key Features:** |
||||
|
|
||||
|
- Uses `@capacitor-mlkit/barcode-scanning` |
||||
|
- Supports both front and back cameras |
||||
|
- Implements permission management |
||||
|
- Provides continuous scanning capability |
||||
|
|
||||
|
### Platform Services |
||||
|
|
||||
|
#### WebPlatformService |
||||
|
|
||||
|
Web-specific implementation of platform features. |
||||
|
|
||||
|
**Camera Capabilities:** |
||||
|
|
||||
|
- Uses HTML5 file input with capture attribute |
||||
|
- Falls back to file selection if camera unavailable |
||||
|
- Processes captured images for consistent format |
||||
|
|
||||
|
#### CapacitorPlatformService |
||||
|
|
||||
|
Native implementation using Capacitor. |
||||
|
|
||||
|
**Camera Features:** |
||||
|
|
||||
|
- Uses `Camera.getPhoto()` for native camera access |
||||
|
- Supports image editing |
||||
|
- Configures high-quality image capture |
||||
|
- Handles base64 image processing |
||||
|
|
||||
|
#### ElectronPlatformService |
||||
|
|
||||
|
Desktop implementation (currently unimplemented). |
||||
|
|
||||
|
**Status:** |
||||
|
|
||||
|
- Camera functionality not yet implemented |
||||
|
- Planned to use Electron's media APIs |
||||
|
|
||||
|
## Platform-Specific Considerations |
||||
|
|
||||
|
### iOS |
||||
|
|
||||
|
- Requires `NSCameraUsageDescription` in Info.plist |
||||
|
- Supports both front and back cameras |
||||
|
- Implements proper permission handling |
||||
|
|
||||
|
### Android |
||||
|
|
||||
|
- Requires camera permissions in manifest |
||||
|
- Supports both front and back cameras |
||||
|
- Handles permission requests through Capacitor |
||||
|
|
||||
|
### Web |
||||
|
|
||||
|
- Requires HTTPS for camera access |
||||
|
- Implements fallback mechanisms |
||||
|
- Handles browser compatibility issues |
||||
|
|
||||
|
## Error Handling |
||||
|
|
||||
|
### Common Error Scenarios |
||||
|
|
||||
|
1. No camera found |
||||
|
2. Permission denied |
||||
|
3. Camera in use by another application |
||||
|
4. HTTPS required |
||||
|
5. Browser compatibility issues |
||||
|
|
||||
|
### Error Response |
||||
|
|
||||
|
- User-friendly error messages |
||||
|
- Troubleshooting tips |
||||
|
- Clear instructions for resolution |
||||
|
- Platform-specific guidance |
||||
|
|
||||
|
## Security Considerations |
||||
|
|
||||
|
### Permission Management |
||||
|
|
||||
|
- Explicit permission requests |
||||
|
- Permission state tracking |
||||
|
- Graceful handling of denied permissions |
||||
|
|
||||
|
### Data Handling |
||||
|
|
||||
|
- Secure image processing |
||||
|
- Proper cleanup of camera resources |
||||
|
- No persistent storage of camera data |
||||
|
|
||||
|
## Best Practices |
||||
|
|
||||
|
### Camera Access |
||||
|
|
||||
|
1. Always check for camera availability |
||||
|
2. Request permissions explicitly |
||||
|
3. Handle all error conditions |
||||
|
4. Provide clear user feedback |
||||
|
5. Implement proper cleanup |
||||
|
|
||||
|
### Performance |
||||
|
|
||||
|
1. Optimize camera resolution |
||||
|
2. Implement proper resource cleanup |
||||
|
3. Handle camera switching efficiently |
||||
|
4. Manage memory usage |
||||
|
|
||||
|
### User Experience |
||||
|
|
||||
|
1. Clear status indicators |
||||
|
2. Intuitive camera controls |
||||
|
3. Helpful error messages |
||||
|
4. Smooth camera switching |
||||
|
5. Responsive UI feedback |
||||
|
|
||||
|
## Future Improvements |
||||
|
|
||||
|
### Planned Enhancements |
||||
|
|
||||
|
1. Implement Electron camera support |
||||
|
2. Add advanced camera features |
||||
|
3. Improve error handling |
||||
|
4. Enhance user feedback |
||||
|
5. Optimize performance |
||||
|
|
||||
|
### Known Issues |
||||
|
|
||||
|
1. Electron camera implementation pending |
||||
|
2. Some browser compatibility limitations |
||||
|
3. Platform-specific quirks to address |
||||
|
|
||||
|
## Dependencies |
||||
|
|
||||
|
### Key Packages |
||||
|
|
||||
|
- `@capacitor-mlkit/barcode-scanning` |
||||
|
- `qrcode-stream` |
||||
|
- `vue-picture-cropper` |
||||
|
- Platform-specific camera APIs |
||||
|
|
||||
|
## Testing |
||||
|
|
||||
|
### Test Scenarios |
||||
|
|
||||
|
1. Permission handling |
||||
|
2. Camera switching |
||||
|
3. Error conditions |
||||
|
4. Platform compatibility |
||||
|
5. Performance metrics |
||||
|
|
||||
|
### Test Environment |
||||
|
|
||||
|
- Multiple browsers |
||||
|
- iOS and Android devices |
||||
|
- Desktop platforms |
||||
|
- Various network conditions |
@ -0,0 +1,276 @@ |
|||||
|
--- |
||||
|
description: |
||||
|
globs: |
||||
|
alwaysApply: true |
||||
|
--- |
||||
|
--- |
||||
|
description: |
||||
|
globs: |
||||
|
alwaysApply: true |
||||
|
--- |
||||
|
# Time Safari Context |
||||
|
|
||||
|
## Project Overview |
||||
|
|
||||
|
Time Safari is an application designed to foster community building through gifts, gratitude, and collaborative projects. The app should make it extremely easy and intuitive for users of any age and capability to recognize contributions, build trust networks, and organize collective action. It is built on services that preserve privacy and data sovereignty. |
||||
|
|
||||
|
The ultimate goals of Time Safari are two-fold: |
||||
|
|
||||
|
1. **Connect** Make it easy, rewarding, and non-threatening for people to connect with others who have similar interests, and to initiate activities together. This helps people accomplish and learn from other individuals in less-structured environments; moreover, it helps them discover who they want to continue to support and with whom they want to maintain relationships. |
||||
|
|
||||
|
2. **Reveal** Widely advertise the great support and rewards that are being given and accepted freely, especially non-monetary ones. Using visuals and text, display the kind of impact that gifts are making in the lives of others. Also show useful and engaging reports of project statistics and personal accomplishments. |
||||
|
|
||||
|
|
||||
|
## Core Approaches |
||||
|
|
||||
|
Time Safari should help everyday users build meaningful connections and organize collective efforts by: |
||||
|
|
||||
|
1. **Recognizing Contributions**: Creating permanent, verifiable records of gifts and contributions people give to each other and their communities. |
||||
|
|
||||
|
2. **Facilitating Collaboration**: Making it ridiculously easy for people to ask for or propose help on projects and interests that matter to them. |
||||
|
|
||||
|
3. **Building Trust Networks**: Enabling users to maintain their network and activity visibility. Developing reputation through verified contributions and references, which can be selectively shown to others outside the network. |
||||
|
|
||||
|
4. **Preserving Privacy**: Ensuring personal identifiers are only shared with explicitly authorized contacts, allowing private individuals including children to participate safely. |
||||
|
|
||||
|
5. **Engaging Content**: Displaying people's records in compelling stories, and highlighting those projects that are lifting people's lives long-term, both in physical support and in emotional-spiritual-creative thriving. |
||||
|
|
||||
|
|
||||
|
## Technical Foundation |
||||
|
|
||||
|
This application is built on a privacy-preserving claims architecture (via endorser.ch) with these key characteristics: |
||||
|
|
||||
|
- **Decentralized Identifiers (DIDs)**: User identities are based on public/private key pairs stored on their devices |
||||
|
- **Cryptographic Verification**: All claims and confirmations are cryptographically signed |
||||
|
- **User-Controlled Visibility**: Users explicitly control who can see their identifiers and data |
||||
|
- **Merkle-Chained Claims**: Claims are cryptographically chained for verification and integrity |
||||
|
- **Native and Web App**: Works on Capacitor (iOS, Android), Desktop (Electron and CEFPython), and web browsers |
||||
|
|
||||
|
## User Journey |
||||
|
|
||||
|
The typical progression of usage follows these stages: |
||||
|
|
||||
|
1. **Gratitude & Recognition**: Users begin by expressing and recording gratitude for gifts received, building a foundation of acknowledgment. |
||||
|
|
||||
|
2. **Project Proposals**: Users propose projects and ideas, reaching out to connect with others who share similar interests. |
||||
|
|
||||
|
3. **Action Triggers**: Offers of help serve as triggers and motivations to execute proposed projects, moving from ideas to action. |
||||
|
|
||||
|
## Context for LLM Development |
||||
|
|
||||
|
When developing new functionality for Time Safari, consider these design principles: |
||||
|
|
||||
|
1. **Accessibility First**: Features should be usable by non-technical users with minimal learning curve. |
||||
|
|
||||
|
2. **Privacy by Design**: All features must respect user privacy and data sovereignty. |
||||
|
|
||||
|
3. **Progressive Enhancement**: Core functionality should work across all devices, with richer experiences where supported. |
||||
|
|
||||
|
4. **Voluntary Collaboration**: The system should enable but never coerce participation. |
||||
|
|
||||
|
5. **Trust Building**: Features should help build verifiable trust between users. |
||||
|
|
||||
|
6. **Network Effects**: Consider how features scale as more users join the platform. |
||||
|
|
||||
|
7. **Low Resource Requirements**: The system should be lightweight enough to run on inexpensive devices users already own. |
||||
|
|
||||
|
## Use Cases to Support |
||||
|
|
||||
|
LLM development should focus on enhancing these key use cases: |
||||
|
|
||||
|
1. **Community Building**: Tools that help people find others with shared interests and values. |
||||
|
|
||||
|
2. **Project Coordination**: Features that make it easy to propose collaborative projects and to submit suggestions and offers to existing ones. |
||||
|
|
||||
|
3. **Reputation Building**: Methods for users to showcase their contributions and reliability, in contexts where they explicitly reveal that information. |
||||
|
|
||||
|
4. **Governance Experimentation**: Features that facilitate decision-making and collective governance. |
||||
|
|
||||
|
## Constraints |
||||
|
|
||||
|
When developing new features, be mindful of these constraints: |
||||
|
|
||||
|
1. **Privacy Preservation**: User identifiers must remain private except when explicitly shared. |
||||
|
|
||||
|
2. **Platform Limitations**: Features must work within the constraints of the target app platforms, while aiming to leverage the best platform technology available. |
||||
|
|
||||
|
3. **Endorser API Limitations**: Backend features are constrained by the endorser.ch API capabilities. |
||||
|
|
||||
|
4. **Performance on Low-End Devices**: The application should remain performant on older/simpler devices. |
||||
|
|
||||
|
5. **Offline-First When Possible**: Key functionality should work offline when feasible. |
||||
|
|
||||
|
## Project Technologies |
||||
|
|
||||
|
- Typescript using ES6 classes using vue-facing-decorator |
||||
|
- TailwindCSS |
||||
|
- Vite Build Tool |
||||
|
- Playwright E2E testing |
||||
|
- IndexDB |
||||
|
- Camera, Image uploads, QR Code reader, ... |
||||
|
|
||||
|
## Mobile Features |
||||
|
|
||||
|
- Deep Linking |
||||
|
- Local Notifications via a custom Capacitor plugin |
||||
|
|
||||
|
## Project Architecture |
||||
|
|
||||
|
- The application must work on web browser, PWA (Progressive Web Application), desktop via Electron, and mobile via Capacitor |
||||
|
- Building for each platform is managed via Vite |
||||
|
|
||||
|
## Core Development Principles |
||||
|
|
||||
|
### DRY development |
||||
|
- **Code Reuse** |
||||
|
- Extract common functionality into utility functions |
||||
|
- Create reusable components for UI patterns |
||||
|
- Implement service classes for shared business logic |
||||
|
- Use mixins for cross-cutting concerns |
||||
|
- Leverage TypeScript interfaces for shared type definitions |
||||
|
|
||||
|
- **Component Patterns** |
||||
|
- Create base components for common UI elements |
||||
|
- Implement higher-order components for shared behavior |
||||
|
- Use slot patterns for flexible component composition |
||||
|
- Create composable services for business logic |
||||
|
- Implement factory patterns for component creation |
||||
|
|
||||
|
- **State Management** |
||||
|
- Centralize state in Pinia stores |
||||
|
- Use computed properties for derived state |
||||
|
- Implement shared state selectors |
||||
|
- Create reusable state mutations |
||||
|
- Use action creators for common operations |
||||
|
|
||||
|
- **Error Handling** |
||||
|
- Implement centralized error handling |
||||
|
- Create reusable error components |
||||
|
- Use error boundary components |
||||
|
- Implement consistent error logging |
||||
|
- Create error type definitions |
||||
|
|
||||
|
- **Type Definitions** |
||||
|
- Create shared interfaces for common data structures |
||||
|
- Use type aliases for complex types |
||||
|
- Implement generic types for reusable components |
||||
|
- Create utility types for common patterns |
||||
|
- Use discriminated unions for state management |
||||
|
|
||||
|
- **API Integration** |
||||
|
- Create reusable API client classes |
||||
|
- Implement request/response interceptors |
||||
|
- Use consistent error handling patterns |
||||
|
- Create type-safe API endpoints |
||||
|
- Implement caching strategies |
||||
|
|
||||
|
- **Platform Services** |
||||
|
- Abstract platform-specific code behind interfaces |
||||
|
- Create platform-agnostic service layers |
||||
|
- Implement feature detection |
||||
|
- Use dependency injection for services |
||||
|
- Create service factories |
||||
|
|
||||
|
- **Testing** |
||||
|
- Create reusable test utilities |
||||
|
- Implement test factories |
||||
|
- Use shared test configurations |
||||
|
- Create reusable test helpers |
||||
|
- Implement consistent test patterns |
||||
|
|
||||
|
### SOLID Principles |
||||
|
- **Single Responsibility**: Each class/component should have only one reason to change |
||||
|
- Components should focus on one specific feature (e.g., QR scanning, DID management) |
||||
|
- Services should handle one type of functionality (e.g., platform services, crypto services) |
||||
|
- Utilities should provide focused helper functions |
||||
|
|
||||
|
- **Open/Closed**: Software entities should be open for extension but closed for modification |
||||
|
- Use interfaces for service definitions |
||||
|
- Implement plugin architecture for platform-specific features |
||||
|
- Allow component behavior extension through props and events |
||||
|
|
||||
|
- **Liskov Substitution**: Objects should be replaceable with their subtypes |
||||
|
- Platform services should work consistently across web/mobile |
||||
|
- Authentication providers should be interchangeable |
||||
|
- Storage implementations should be swappable |
||||
|
|
||||
|
- **Interface Segregation**: Clients shouldn't depend on interfaces they don't use |
||||
|
- Break down large service interfaces into smaller, focused ones |
||||
|
- Component props should be minimal and purposeful |
||||
|
- Event emissions should be specific and targeted |
||||
|
|
||||
|
- **Dependency Inversion**: High-level modules shouldn't depend on low-level modules |
||||
|
- Use dependency injection for services |
||||
|
- Abstract platform-specific code behind interfaces |
||||
|
- Implement factory patterns for component creation |
||||
|
|
||||
|
### Law of Demeter |
||||
|
- Components should only communicate with immediate dependencies |
||||
|
- Avoid chaining method calls (e.g., `this.service.getUser().getProfile().getName()`) |
||||
|
- Use mediator patterns for complex component interactions |
||||
|
- Implement facade patterns for subsystem access |
||||
|
- Keep component communication through defined events and props |
||||
|
|
||||
|
### Composition over Inheritance |
||||
|
- Prefer building components through composition |
||||
|
- Use mixins for shared functionality |
||||
|
- Implement feature toggles through props |
||||
|
- Create higher-order components for common patterns |
||||
|
- Use service composition for complex features |
||||
|
|
||||
|
### Interface Segregation |
||||
|
- Define clear interfaces for services |
||||
|
- Keep component APIs minimal and focused |
||||
|
- Split large interfaces into smaller, specific ones |
||||
|
- Use TypeScript interfaces for type definitions |
||||
|
- Implement role-based interfaces for different use cases |
||||
|
|
||||
|
### Fail Fast |
||||
|
- Validate inputs early in the process |
||||
|
- Use TypeScript strict mode |
||||
|
- Implement comprehensive error handling |
||||
|
- Add runtime checks for critical operations |
||||
|
- Use assertions for development-time validation |
||||
|
|
||||
|
### Principle of Least Astonishment |
||||
|
- Follow Vue.js conventions consistently |
||||
|
- Use familiar naming patterns |
||||
|
- Implement predictable component behaviors |
||||
|
- Maintain consistent error handling |
||||
|
- Keep UI interactions intuitive |
||||
|
|
||||
|
### Information Hiding |
||||
|
- Encapsulate implementation details |
||||
|
- Use private class members |
||||
|
- Implement proper access modifiers |
||||
|
- Hide complex logic behind simple interfaces |
||||
|
- Use TypeScript's access modifiers effectively |
||||
|
|
||||
|
### Single Source of Truth |
||||
|
- Use Pinia for state management |
||||
|
- Maintain one source for user data |
||||
|
- Centralize configuration management |
||||
|
- Use computed properties for derived state |
||||
|
- Implement proper state synchronization |
||||
|
|
||||
|
### Principle of Least Privilege |
||||
|
- Implement proper access control |
||||
|
- Use minimal required permissions |
||||
|
- Follow privacy-by-design principles |
||||
|
- Restrict component access to necessary data |
||||
|
- Implement proper authentication/authorization |
||||
|
|
||||
|
### Continuous Integration/Continuous Deployment (CI/CD) |
||||
|
- Automated testing on every commit |
||||
|
- Consistent build process across platforms |
||||
|
- Automated deployment pipelines |
||||
|
- Quality gates for code merging |
||||
|
- Environment-specific configurations |
||||
|
|
||||
|
This expanded documentation provides: |
||||
|
1. Clear principles for development |
||||
|
2. Practical implementation guidelines |
||||
|
3. Real-world examples |
||||
|
4. TypeScript integration |
||||
|
5. Best practices for Time Safari |
||||
|
|
@ -0,0 +1,267 @@ |
|||||
|
--- |
||||
|
description: |
||||
|
globs: |
||||
|
alwaysApply: true |
||||
|
--- |
||||
|
# wa-sqlite Usage Guide |
||||
|
|
||||
|
## Table of Contents |
||||
|
- [1. Overview](#1-overview) |
||||
|
- [2. Installation](#2-installation) |
||||
|
- [3. Basic Setup](#3-basic-setup) |
||||
|
- [3.1 Import and Initialize](#31-import-and-initialize) |
||||
|
- [3.2 Basic Database Operations](#32-basic-database-operations) |
||||
|
- [4. Virtual File Systems (VFS)](#4-virtual-file-systems-vfs) |
||||
|
- [4.1 Available VFS Options](#41-available-vfs-options) |
||||
|
- [4.2 Using a VFS](#42-using-a-vfs) |
||||
|
- [5. Best Practices](#5-best-practices) |
||||
|
- [5.1 Error Handling](#51-error-handling) |
||||
|
- [5.2 Transaction Management](#52-transaction-management) |
||||
|
- [5.3 Prepared Statements](#53-prepared-statements) |
||||
|
- [6. Performance Considerations](#6-performance-considerations) |
||||
|
- [7. Common Issues and Solutions](#7-common-issues-and-solutions) |
||||
|
- [8. TypeScript Support](#8-typescript-support) |
||||
|
|
||||
|
## 1. Overview |
||||
|
wa-sqlite is a WebAssembly build of SQLite that enables SQLite database operations in web browsers and JavaScript environments. It provides both synchronous and asynchronous builds, with support for custom virtual file systems (VFS) for persistent storage. |
||||
|
|
||||
|
## 2. Installation |
||||
|
```bash |
||||
|
npm install wa-sqlite |
||||
|
# or |
||||
|
yarn add wa-sqlite |
||||
|
``` |
||||
|
|
||||
|
## 3. Basic Setup |
||||
|
|
||||
|
### 3.1 Import and Initialize |
||||
|
```javascript |
||||
|
// Choose one of these imports based on your needs: |
||||
|
// - wa-sqlite.mjs: Synchronous build |
||||
|
// - wa-sqlite-async.mjs: Asynchronous build (required for async VFS) |
||||
|
// - wa-sqlite-jspi.mjs: JSPI-based async build (experimental, Chromium only) |
||||
|
import SQLiteESMFactory from 'wa-sqlite/dist/wa-sqlite.mjs'; |
||||
|
import * as SQLite from 'wa-sqlite'; |
||||
|
|
||||
|
async function initDatabase() { |
||||
|
// Initialize SQLite module |
||||
|
const module = await SQLiteESMFactory(); |
||||
|
const sqlite3 = SQLite.Factory(module); |
||||
|
|
||||
|
// Open database (returns a Promise) |
||||
|
const db = await sqlite3.open_v2('myDatabase'); |
||||
|
return { sqlite3, db }; |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
### 3.2 Basic Database Operations |
||||
|
```javascript |
||||
|
async function basicOperations() { |
||||
|
const { sqlite3, db } = await initDatabase(); |
||||
|
|
||||
|
try { |
||||
|
// Create a table |
||||
|
await sqlite3.exec(db, ` |
||||
|
CREATE TABLE IF NOT EXISTS users ( |
||||
|
id INTEGER PRIMARY KEY, |
||||
|
name TEXT NOT NULL, |
||||
|
email TEXT UNIQUE |
||||
|
) |
||||
|
`); |
||||
|
|
||||
|
// Insert data |
||||
|
await sqlite3.exec(db, ` |
||||
|
INSERT INTO users (name, email) |
||||
|
VALUES ('John Doe', 'john@example.com') |
||||
|
`); |
||||
|
|
||||
|
// Query data |
||||
|
const results = []; |
||||
|
await sqlite3.exec(db, 'SELECT * FROM users', (row, columns) => { |
||||
|
results.push({ row, columns }); |
||||
|
}); |
||||
|
|
||||
|
return results; |
||||
|
} finally { |
||||
|
// Always close the database when done |
||||
|
await sqlite3.close(db); |
||||
|
} |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
## 4. Virtual File Systems (VFS) |
||||
|
|
||||
|
### 4.1 Available VFS Options |
||||
|
wa-sqlite provides several VFS implementations for persistent storage: |
||||
|
|
||||
|
1. **IDBBatchAtomicVFS** (Recommended for general use) |
||||
|
- Uses IndexedDB with batch atomic writes |
||||
|
- Works in all contexts (Window, Worker, Service Worker) |
||||
|
- Supports WAL mode |
||||
|
- Best performance with `PRAGMA synchronous=normal` |
||||
|
|
||||
|
2. **IDBMirrorVFS** |
||||
|
- Keeps files in memory, persists to IndexedDB |
||||
|
- Works in all contexts |
||||
|
- Good for smaller databases |
||||
|
|
||||
|
3. **OPFS-based VFS** (Origin Private File System) |
||||
|
- Various implementations available: |
||||
|
- AccessHandlePoolVFS |
||||
|
- OPFSAdaptiveVFS |
||||
|
- OPFSCoopSyncVFS |
||||
|
- OPFSPermutedVFS |
||||
|
- Better performance but limited to Worker contexts |
||||
|
|
||||
|
### 4.2 Using a VFS |
||||
|
```javascript |
||||
|
import { IDBBatchAtomicVFS } from 'wa-sqlite/src/examples/IDBBatchAtomicVFS.js'; |
||||
|
import SQLiteESMFactory from 'wa-sqlite/dist/wa-sqlite-async.mjs'; |
||||
|
import * as SQLite from 'wa-sqlite'; |
||||
|
|
||||
|
async function initDatabaseWithVFS() { |
||||
|
const module = await SQLiteESMFactory(); |
||||
|
const sqlite3 = SQLite.Factory(module); |
||||
|
|
||||
|
// Register VFS |
||||
|
const vfs = await IDBBatchAtomicVFS.create('myApp', module); |
||||
|
sqlite3.vfs_register(vfs, true); |
||||
|
|
||||
|
// Open database with VFS |
||||
|
const db = await sqlite3.open_v2('myDatabase'); |
||||
|
|
||||
|
// Configure for better performance |
||||
|
await sqlite3.exec(db, 'PRAGMA synchronous = normal'); |
||||
|
await sqlite3.exec(db, 'PRAGMA journal_mode = WAL'); |
||||
|
|
||||
|
return { sqlite3, db }; |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
## 5. Best Practices |
||||
|
|
||||
|
### 5.1 Error Handling |
||||
|
```javascript |
||||
|
async function safeDatabaseOperation() { |
||||
|
const { sqlite3, db } = await initDatabase(); |
||||
|
|
||||
|
try { |
||||
|
await sqlite3.exec(db, 'SELECT * FROM non_existent_table'); |
||||
|
} catch (error) { |
||||
|
if (error.code === SQLite.SQLITE_ERROR) { |
||||
|
console.error('SQL error:', error.message); |
||||
|
} else { |
||||
|
console.error('Database error:', error); |
||||
|
} |
||||
|
} finally { |
||||
|
await sqlite3.close(db); |
||||
|
} |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
### 5.2 Transaction Management |
||||
|
```javascript |
||||
|
async function transactionExample() { |
||||
|
const { sqlite3, db } = await initDatabase(); |
||||
|
|
||||
|
try { |
||||
|
await sqlite3.exec(db, 'BEGIN TRANSACTION'); |
||||
|
|
||||
|
// Perform multiple operations |
||||
|
await sqlite3.exec(db, 'INSERT INTO users (name) VALUES (?)', ['Alice']); |
||||
|
await sqlite3.exec(db, 'INSERT INTO users (name) VALUES (?)', ['Bob']); |
||||
|
|
||||
|
await sqlite3.exec(db, 'COMMIT'); |
||||
|
} catch (error) { |
||||
|
await sqlite3.exec(db, 'ROLLBACK'); |
||||
|
throw error; |
||||
|
} finally { |
||||
|
await sqlite3.close(db); |
||||
|
} |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
### 5.3 Prepared Statements |
||||
|
```javascript |
||||
|
async function preparedStatementExample() { |
||||
|
const { sqlite3, db } = await initDatabase(); |
||||
|
|
||||
|
try { |
||||
|
// Prepare statement |
||||
|
const stmt = await sqlite3.prepare(db, 'SELECT * FROM users WHERE id = ?'); |
||||
|
|
||||
|
// Execute with different parameters |
||||
|
await sqlite3.bind(stmt, 1, 1); |
||||
|
while (await sqlite3.step(stmt) === SQLite.SQLITE_ROW) { |
||||
|
const row = sqlite3.row(stmt); |
||||
|
console.log(row); |
||||
|
} |
||||
|
|
||||
|
// Reset and reuse |
||||
|
await sqlite3.reset(stmt); |
||||
|
await sqlite3.bind(stmt, 1, 2); |
||||
|
// ... execute again |
||||
|
|
||||
|
await sqlite3.finalize(stmt); |
||||
|
} finally { |
||||
|
await sqlite3.close(db); |
||||
|
} |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
## 6. Performance Considerations |
||||
|
|
||||
|
1. **VFS Selection** |
||||
|
- Use IDBBatchAtomicVFS for general-purpose applications |
||||
|
- Consider OPFS-based VFS for better performance in Worker contexts |
||||
|
- Use MemoryVFS for temporary databases |
||||
|
|
||||
|
2. **Configuration** |
||||
|
- Set appropriate page size (default is usually fine) |
||||
|
- Use WAL mode for better concurrency |
||||
|
- Consider `PRAGMA synchronous=normal` for better performance |
||||
|
- Adjust cache size based on your needs |
||||
|
|
||||
|
3. **Concurrency** |
||||
|
- Use transactions for multiple operations |
||||
|
- Be aware of VFS-specific concurrency limitations |
||||
|
- Consider using Web Workers for heavy database operations |
||||
|
|
||||
|
## 7. Common Issues and Solutions |
||||
|
|
||||
|
1. **Database Locking** |
||||
|
- Use appropriate transaction isolation levels |
||||
|
- Implement retry logic for busy errors |
||||
|
- Consider using WAL mode |
||||
|
|
||||
|
2. **Storage Limitations** |
||||
|
- Be aware of browser storage quotas |
||||
|
- Implement cleanup strategies |
||||
|
- Monitor database size |
||||
|
|
||||
|
3. **Cross-Context Access** |
||||
|
- Use appropriate VFS for your context |
||||
|
- Consider message passing for cross-context communication |
||||
|
- Be aware of storage access limitations |
||||
|
|
||||
|
## 8. TypeScript Support |
||||
|
wa-sqlite includes TypeScript definitions. The main types are: |
||||
|
|
||||
|
```typescript |
||||
|
type SQLiteCompatibleType = number | string | Uint8Array | Array<number> | bigint | null; |
||||
|
|
||||
|
interface SQLiteAPI { |
||||
|
open_v2(filename: string, flags?: number, zVfs?: string): Promise<number>; |
||||
|
exec(db: number, sql: string, callback?: (row: any[], columns: string[]) => void): Promise<number>; |
||||
|
close(db: number): Promise<number>; |
||||
|
// ... other methods |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
## Additional Resources |
||||
|
|
||||
|
- [Official GitHub Repository](https://github.com/rhashimoto/wa-sqlite) |
||||
|
- [Online Demo](https://rhashimoto.github.io/wa-sqlite/demo/) |
||||
|
- [API Reference](https://rhashimoto.github.io/wa-sqlite/docs/) |
||||
|
- [FAQ](https://github.com/rhashimoto/wa-sqlite/issues?q=is%3Aissue+label%3Afaq+) |
||||
|
- [Discussion Forums](https://github.com/rhashimoto/wa-sqlite/discussions) |
@ -0,0 +1,36 @@ |
|||||
|
# Build stage |
||||
|
FROM node:22-alpine3.20 AS builder |
||||
|
|
||||
|
# Install build dependencies |
||||
|
|
||||
|
RUN apk add --no-cache bash git 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;"] |
@ -0,0 +1,84 @@ |
|||||
|
|
||||
|
# What to do about storage for native apps? |
||||
|
|
||||
|
|
||||
|
## Problem |
||||
|
|
||||
|
We can't trust iOS IndexedDB to persist. I want to start delivering an app to people now, in preparation for presentations mid-June: Rotary on June 12 and Porcfest on June 17. |
||||
|
|
||||
|
* Apple WebKit puts a [7-day cap on IndexedDB](https://webkit.org/blog/10218/full-third-party-cookie-blocking-and-more/). |
||||
|
|
||||
|
* The web standards expose a `persist` method to mark memory as persistent, and [supposedly WebView supports it](https://developer.mozilla.org/en-US/docs/Web/API/StorageManager/persisted), but too many other things indicate it's not reliable. I've talked with [ChatGPT](https://chatgpt.com/share/68322f40-84c8-8007-b213-855f7962989a) & Venice & Claude (in Cursor); [this answer from Perplexity](https://www.perplexity.ai/search/which-platforms-prompt-the-use-HUQLqy4qQD2cRbkmO4CgHg) says that most platforms don't prompt and Safari doesn't support it; I don't know if that means WebKit as well. |
||||
|
|
||||
|
* Capacitor says [not to trust it on iOS](https://capacitorjs.com/docs/v6/guides/storage). |
||||
|
|
||||
|
Also, with sensitive data, the accounts info should be encrypted. |
||||
|
|
||||
|
|
||||
|
# Options |
||||
|
|
||||
|
* There is a community [SQLite plugin for Capacitor](https://github.com/capacitor-community/sqlite) with encryption by [SQLCipher](https://github.com/sqlcipher/sqlcipher). |
||||
|
|
||||
|
* [This tutorial](https://jepiqueau.github.io/2023/09/05/Ionic7Vue-SQLite-CRUD-App.html#part-1---web---table-of-contents) shows how that plugin works for web as well as native. |
||||
|
|
||||
|
* Capacitor abstracts [user preferences in an API](https://capacitorjs.com/docs/apis/preferences), which uses different underlying libraries on iOS & Android. Unfortunately, it won't do any filtering or searching, and is only meant for small amounts of data. (It could be used for settings and for identifiers, but contacts will grow and image blobs won't work.) |
||||
|
|
||||
|
* There are hints that Capacitor offers another custom storage API but all I could find was that Preferences API. |
||||
|
|
||||
|
* [Ionic Storage](https://ionic.io/docs/secure-storage) is an enterprise solution, which also supports encryption. |
||||
|
|
||||
|
* Not an option yet: Dexie may support SQLite in [a future version](https://dexie.org/roadmap/dexie5.0). |
||||
|
|
||||
|
|
||||
|
|
||||
|
# Current Plan |
||||
|
|
||||
|
* Implement SQLite for Capacitor & web, with encryption. That will allow us to test quickly and keep the same interface for native & web, but we don't deal with migrations for current web users. |
||||
|
|
||||
|
* After that is delivered, write a migration for current web users from IndexedDB to SQLite. |
||||
|
|
||||
|
|
||||
|
|
||||
|
# Current method calls |
||||
|
|
||||
|
... which is not 100% complete because the AI that generated thus claimed no usage of 'temp' DB. |
||||
|
|
||||
|
### Secret Database (secretDB) - Used for storing the encryption key |
||||
|
|
||||
|
secretDB.open() - Opens the database |
||||
|
secretDB.secret.get(MASTER_SECRET_KEY) - Retrieves the secret key |
||||
|
secretDB.secret.add({ id: MASTER_SECRET_KEY, secret }) - Adds a new secret key |
||||
|
|
||||
|
### Accounts Database (accountsDB) - Used for storing sensitive account information |
||||
|
|
||||
|
accountsDB.open() - Opens the database |
||||
|
accountsDB.accounts.count() - Counts number of accounts |
||||
|
accountsDB.accounts.toArray() - Gets all accounts |
||||
|
accountsDB.accounts.where("did").equals(did).first() - Gets a specific account by DID |
||||
|
accountsDB.accounts.add(account) - Adds a new account |
||||
|
|
||||
|
### Non-sensitive Database (db) - Used for settings, contacts, logs, and temp data |
||||
|
|
||||
|
Settings operations: |
||||
|
export all settings (Dexie format) |
||||
|
db.settings.get(MASTER_SETTINGS_KEY) - Gets default settings |
||||
|
db.settings.where("accountDid").equals(did).first() - Gets account-specific settings |
||||
|
db.settings.where("accountDid").equals(did).modify(settingsChanges) - Updates account settings |
||||
|
db.settings.add(settingsChanges) - Adds new settings |
||||
|
db.settings.count() - Counts number of settings |
||||
|
db.settings.update(key, changes) - Updates settings |
||||
|
|
||||
|
Contacts operations: |
||||
|
export all contacts (Dexie format) |
||||
|
db.contacts.toArray() - Gets all contacts |
||||
|
db.contacts.add(contact) - Adds a new contact |
||||
|
db.contacts.update(did, contactData) - Updates a contact |
||||
|
db.contacts.delete(did) - Deletes a contact |
||||
|
db.contacts.where("did").equals(did).first() - Gets a specific contact by DID |
||||
|
|
||||
|
Logs operations: |
||||
|
db.logs.get(todayKey) - Gets logs for a specific day |
||||
|
db.logs.update(todayKey, { message: fullMessage }) - Updates logs |
||||
|
db.logs.clear() - Clears all logs |
||||
|
|
||||
|
|
Binary file not shown.
@ -1,2 +0,0 @@ |
|||||
#Fri Mar 21 07:27:50 UTC 2025 |
|
||||
gradle.version=8.2.1 |
|
Binary file not shown.
@ -1,2 +0,0 @@ |
|||||
/build/* |
|
||||
!/build/.npmkeep |
|
@ -0,0 +1,28 @@ |
|||||
|
{ |
||||
|
"project_info": { |
||||
|
"project_number": "123456789000", |
||||
|
"project_id": "timesafari-app", |
||||
|
"storage_bucket": "timesafari-app.appspot.com" |
||||
|
}, |
||||
|
"client": [ |
||||
|
{ |
||||
|
"client_info": { |
||||
|
"mobilesdk_app_id": "1:123456789000:android:1234567890abcdef", |
||||
|
"android_client_info": { |
||||
|
"package_name": "app.timesafari.app" |
||||
|
} |
||||
|
}, |
||||
|
"oauth_client": [], |
||||
|
"api_key": [ |
||||
|
{ |
||||
|
"current_key": "AIzaSyDummyKeyForBuildPurposesOnly12345" |
||||
|
} |
||||
|
], |
||||
|
"services": { |
||||
|
"appinvite_service": { |
||||
|
"other_platform_oauth_client": [] |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
] |
||||
|
} |
@ -1,6 +1,26 @@ |
|||||
[ |
[ |
||||
|
{ |
||||
|
"pkg": "@capacitor-mlkit/barcode-scanning", |
||||
|
"classpath": "io.capawesome.capacitorjs.plugins.mlkit.barcodescanning.BarcodeScannerPlugin" |
||||
|
}, |
||||
{ |
{ |
||||
"pkg": "@capacitor/app", |
"pkg": "@capacitor/app", |
||||
"classpath": "com.capacitorjs.plugins.app.AppPlugin" |
"classpath": "com.capacitorjs.plugins.app.AppPlugin" |
||||
|
}, |
||||
|
{ |
||||
|
"pkg": "@capacitor/camera", |
||||
|
"classpath": "com.capacitorjs.plugins.camera.CameraPlugin" |
||||
|
}, |
||||
|
{ |
||||
|
"pkg": "@capacitor/filesystem", |
||||
|
"classpath": "com.capacitorjs.plugins.filesystem.FilesystemPlugin" |
||||
|
}, |
||||
|
{ |
||||
|
"pkg": "@capacitor/share", |
||||
|
"classpath": "com.capacitorjs.plugins.share.SharePlugin" |
||||
|
}, |
||||
|
{ |
||||
|
"pkg": "@capawesome/capacitor-file-picker", |
||||
|
"classpath": "io.capawesome.capacitorjs.plugins.filepicker.FilePickerPlugin" |
||||
} |
} |
||||
] |
] |
||||
|
@ -1,17 +0,0 @@ |
|||||
<!DOCTYPE html> |
|
||||
<html lang=""> |
|
||||
<head> |
|
||||
<meta charset="utf-8"> |
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge"> |
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1.0"> |
|
||||
<link rel="icon" href="/favicon.ico"> |
|
||||
<title>TimeSafari</title> |
|
||||
<script type="module" crossorigin src="/assets/index-CZMUlUNO.js"></script> |
|
||||
</head> |
|
||||
<body> |
|
||||
<noscript> |
|
||||
<strong>We're sorry but TimeSafari doesn't work properly without JavaScript enabled. Please enable it to continue.</strong> |
|
||||
</noscript> |
|
||||
<div id="app"></div> |
|
||||
</body> |
|
||||
</html> |
|
@ -0,0 +1,2 @@ |
|||||
|
|
||||
|
Application icons are here. They are processed for android & ios by the `capacitor-assets` command, as indicated in the BUILDING.md file. |
@ -0,0 +1,4 @@ |
|||||
|
#!/bin/bash |
||||
|
export IMAGENAME="$(basename $PWD):1.0" |
||||
|
|
||||
|
docker build . --network=host -t $IMAGENAME --no-cache |
@ -0,0 +1,21 @@ |
|||||
|
{ |
||||
|
"appId": "app.timesafari", |
||||
|
"appName": "TimeSafari", |
||||
|
"webDir": "dist", |
||||
|
"bundledWebRuntime": false, |
||||
|
"server": { |
||||
|
"cleartext": true |
||||
|
}, |
||||
|
"plugins": { |
||||
|
"App": { |
||||
|
"appUrlOpen": { |
||||
|
"handlers": [ |
||||
|
{ |
||||
|
"url": "timesafari://*", |
||||
|
"autoVerify": true |
||||
|
} |
||||
|
] |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
@ -1,25 +0,0 @@ |
|||||
import { CapacitorConfig } from '@capacitor/cli'; |
|
||||
|
|
||||
const config: CapacitorConfig = { |
|
||||
appId: 'app.timesafari', |
|
||||
appName: 'TimeSafari', |
|
||||
webDir: 'dist', |
|
||||
bundledWebRuntime: false, |
|
||||
server: { |
|
||||
cleartext: true, |
|
||||
}, |
|
||||
plugins: { |
|
||||
App: { |
|
||||
appUrlOpen: { |
|
||||
handlers: [ |
|
||||
{ |
|
||||
url: "timesafari://*", |
|
||||
autoVerify: true |
|
||||
} |
|
||||
] |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
}; |
|
||||
|
|
||||
export default config; |
|
@ -1,6 +1,6 @@ |
|||||
JWT Creation & Verification |
JWT Creation & Verification |
||||
|
|
||||
To run this in a script, see ./openssl_signing_console.sh |
To run this in a script, see /scripts/openssl_signing_console.sh |
||||
|
|
||||
Prerequisites: openssl, jq |
Prerequisites: openssl, jq |
||||
|
|
@ -0,0 +1,805 @@ |
|||||
|
# QR Code Implementation Guide |
||||
|
|
||||
|
## Overview |
||||
|
|
||||
|
This document describes the QR code scanning and generation implementation in the TimeSafari application. The system uses a platform-agnostic design with specific implementations for web and mobile platforms. |
||||
|
|
||||
|
## Architecture |
||||
|
|
||||
|
### Directory Structure |
||||
|
``` |
||||
|
src/ |
||||
|
├── services/ |
||||
|
│ └── QRScanner/ |
||||
|
│ ├── types.ts # Core interfaces and types |
||||
|
│ ├── QRScannerFactory.ts # Factory for creating scanner instances |
||||
|
│ ├── CapacitorQRScanner.ts # Mobile implementation using MLKit |
||||
|
│ ├── WebInlineQRScanner.ts # Web implementation using MediaDevices API |
||||
|
│ └── interfaces.ts # Additional interfaces |
||||
|
├── components/ |
||||
|
│ └── QRScanner/ |
||||
|
│ └── QRScannerDialog.vue # Shared UI component |
||||
|
└── views/ |
||||
|
├── ContactQRScanView.vue # Dedicated scanning view |
||||
|
└── ContactQRScanShowView.vue # Combined QR display and scanning view |
||||
|
``` |
||||
|
|
||||
|
### Core Components |
||||
|
|
||||
|
1. **Factory Pattern** |
||||
|
- `QRScannerFactory` - Creates appropriate scanner instance based on platform |
||||
|
- Common interface `QRScannerService` implemented by all scanners |
||||
|
- Platform detection via Capacitor and build flags |
||||
|
|
||||
|
2. **Platform-Specific Implementations** |
||||
|
- `CapacitorQRScanner` - Native mobile implementation using MLKit |
||||
|
- `WebInlineQRScanner` - Web browser implementation using MediaDevices API |
||||
|
- `QRScannerDialog.vue` - Shared UI component |
||||
|
|
||||
|
3. **View Components** |
||||
|
- `ContactQRScanView` - Dedicated view for scanning QR codes |
||||
|
- `ContactQRScanShowView` - Combined view for displaying and scanning QR codes |
||||
|
|
||||
|
## Implementation Details |
||||
|
|
||||
|
### Core Interfaces |
||||
|
|
||||
|
```typescript |
||||
|
interface QRScannerService { |
||||
|
checkPermissions(): Promise<boolean>; |
||||
|
requestPermissions(): Promise<boolean>; |
||||
|
isSupported(): Promise<boolean>; |
||||
|
startScan(options?: QRScannerOptions): Promise<void>; |
||||
|
stopScan(): Promise<void>; |
||||
|
addListener(listener: ScanListener): void; |
||||
|
onStream(callback: (stream: MediaStream | null) => void): void; |
||||
|
cleanup(): Promise<void>; |
||||
|
getAvailableCameras(): Promise<MediaDeviceInfo[]>; |
||||
|
switchCamera(deviceId: string): Promise<void>; |
||||
|
getCurrentCamera(): Promise<MediaDeviceInfo | null>; |
||||
|
} |
||||
|
|
||||
|
interface ScanListener { |
||||
|
onScan: (result: string) => void; |
||||
|
onError?: (error: Error) => void; |
||||
|
} |
||||
|
|
||||
|
interface QRScannerOptions { |
||||
|
camera?: "front" | "back"; |
||||
|
showPreview?: boolean; |
||||
|
playSound?: boolean; |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
### Platform-Specific Implementations |
||||
|
|
||||
|
#### Mobile (Capacitor) |
||||
|
- Uses `@capacitor-mlkit/barcode-scanning` |
||||
|
- Native camera access through platform APIs |
||||
|
- Optimized for mobile performance |
||||
|
- Supports both iOS and Android |
||||
|
- Real-time QR code detection |
||||
|
- Back camera preferred for scanning |
||||
|
|
||||
|
Configuration: |
||||
|
```typescript |
||||
|
// capacitor.config.ts |
||||
|
const config: CapacitorConfig = { |
||||
|
plugins: { |
||||
|
MLKitBarcodeScanner: { |
||||
|
formats: ['QR_CODE'], |
||||
|
detectorSize: 1.0, |
||||
|
lensFacing: 'back', |
||||
|
googleBarcodeScannerModuleInstallState: true, |
||||
|
// Additional camera options |
||||
|
cameraOptions: { |
||||
|
quality: 0.8, |
||||
|
allowEditing: false, |
||||
|
resultType: 'uri', |
||||
|
sourceType: 'CAMERA', |
||||
|
saveToGallery: false |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
}; |
||||
|
``` |
||||
|
|
||||
|
#### Web |
||||
|
- Uses browser's MediaDevices API |
||||
|
- Vue.js components for UI |
||||
|
- EventEmitter for stream management |
||||
|
- Browser-based camera access |
||||
|
- Inline camera preview |
||||
|
- Responsive design |
||||
|
- Cross-browser compatibility |
||||
|
|
||||
|
### View Components |
||||
|
|
||||
|
#### ContactQRScanView |
||||
|
- Dedicated view for scanning QR codes |
||||
|
- Full-screen camera interface |
||||
|
- Simple UI focused on scanning |
||||
|
- Used primarily on native platforms |
||||
|
- Streamlined scanning experience |
||||
|
|
||||
|
#### ContactQRScanShowView |
||||
|
- Combined view for QR code display and scanning |
||||
|
- Shows user's own QR code |
||||
|
- Handles user registration status |
||||
|
- Provides options to copy contact information |
||||
|
- Platform-specific scanning implementation: |
||||
|
- Native: Button to navigate to ContactQRScanView |
||||
|
- Web: Built-in scanning functionality |
||||
|
|
||||
|
### QR Code Workflow |
||||
|
|
||||
|
1. **Initiation** |
||||
|
- User selects "Scan QR Code" option |
||||
|
- Platform-specific scanner is initialized |
||||
|
- Camera permissions are verified |
||||
|
- Appropriate scanner component is loaded |
||||
|
|
||||
|
2. **Platform-Specific Implementation** |
||||
|
- Web: Uses `qrcode-stream` for real-time scanning |
||||
|
- Native: Uses `@capacitor-mlkit/barcode-scanning` |
||||
|
|
||||
|
3. **Scanning Process** |
||||
|
- Camera stream initialization |
||||
|
- Real-time frame analysis |
||||
|
- QR code detection and decoding |
||||
|
- Validation of QR code format |
||||
|
- Processing of contact information |
||||
|
|
||||
|
4. **Contact Processing** |
||||
|
- Decryption of contact data |
||||
|
- Validation of user information |
||||
|
- Verification of timestamp |
||||
|
- Check for duplicate contacts |
||||
|
- Processing of shared data |
||||
|
|
||||
|
## Build Configuration |
||||
|
|
||||
|
### Common Vite Configuration |
||||
|
```typescript |
||||
|
// vite.config.common.mts |
||||
|
export async function createBuildConfig(mode: string) { |
||||
|
const isCapacitor = mode === "capacitor"; |
||||
|
|
||||
|
return defineConfig({ |
||||
|
define: { |
||||
|
'process.env.VITE_PLATFORM': JSON.stringify(mode), |
||||
|
'process.env.VITE_PWA_ENABLED': JSON.stringify(!isNative), |
||||
|
__IS_MOBILE__: JSON.stringify(isCapacitor), |
||||
|
__USE_QR_READER__: JSON.stringify(!isCapacitor) |
||||
|
}, |
||||
|
optimizeDeps: { |
||||
|
include: [ |
||||
|
'@capacitor-mlkit/barcode-scanning', |
||||
|
'vue-qrcode-reader' |
||||
|
] |
||||
|
} |
||||
|
}); |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
### Platform-Specific Builds |
||||
|
```json |
||||
|
{ |
||||
|
"scripts": { |
||||
|
"build:web": "vite build --config vite.config.web.mts", |
||||
|
"build:capacitor": "vite build --config vite.config.capacitor.mts", |
||||
|
"build:all": "npm run build:web && npm run build:capacitor" |
||||
|
} |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
## Error Handling |
||||
|
|
||||
|
### Common Error Scenarios |
||||
|
1. No camera found |
||||
|
2. Permission denied |
||||
|
3. Camera in use by another application |
||||
|
4. HTTPS required |
||||
|
5. Browser compatibility issues |
||||
|
6. Invalid QR code format |
||||
|
7. Expired QR codes |
||||
|
8. Duplicate contact attempts |
||||
|
9. Network connectivity issues |
||||
|
|
||||
|
### Error Response |
||||
|
- User-friendly error messages |
||||
|
- Troubleshooting tips |
||||
|
- Clear instructions for resolution |
||||
|
- Platform-specific guidance |
||||
|
|
||||
|
## Security Considerations |
||||
|
|
||||
|
### QR Code Security |
||||
|
- Encryption of contact data |
||||
|
- Timestamp validation |
||||
|
- Version checking |
||||
|
- User verification |
||||
|
- Rate limiting for scans |
||||
|
|
||||
|
### Data Protection |
||||
|
- Secure transmission of contact data |
||||
|
- Validation of QR code authenticity |
||||
|
- Prevention of duplicate scans |
||||
|
- Protection against malicious codes |
||||
|
- Secure storage of contact information |
||||
|
|
||||
|
## Best Practices |
||||
|
|
||||
|
### Camera Access |
||||
|
1. Always check for camera availability |
||||
|
2. Request permissions explicitly |
||||
|
3. Handle all error conditions |
||||
|
4. Provide clear user feedback |
||||
|
5. Implement proper cleanup |
||||
|
|
||||
|
### Performance |
||||
|
1. Optimize camera resolution |
||||
|
2. Implement proper resource cleanup |
||||
|
3. Handle camera switching efficiently |
||||
|
4. Manage memory usage |
||||
|
5. Battery usage optimization |
||||
|
|
||||
|
### User Experience |
||||
|
1. Clear visual feedback |
||||
|
2. Camera preview |
||||
|
3. Scanning status indicators |
||||
|
4. Error messages |
||||
|
5. Success confirmations |
||||
|
6. Intuitive camera controls |
||||
|
7. Smooth camera switching |
||||
|
8. Responsive UI feedback |
||||
|
|
||||
|
## Testing |
||||
|
|
||||
|
### Test Scenarios |
||||
|
1. Permission handling |
||||
|
2. Camera switching |
||||
|
3. Error conditions |
||||
|
4. Platform compatibility |
||||
|
5. Performance metrics |
||||
|
6. QR code detection |
||||
|
7. Contact processing |
||||
|
8. Security validation |
||||
|
|
||||
|
### Test Environment |
||||
|
- Multiple browsers |
||||
|
- iOS and Android devices |
||||
|
- Various network conditions |
||||
|
- Different camera configurations |
||||
|
|
||||
|
## Dependencies |
||||
|
|
||||
|
### Key Packages |
||||
|
- `@capacitor-mlkit/barcode-scanning` |
||||
|
- `qrcode-stream` |
||||
|
- `vue-qrcode-reader` |
||||
|
- Platform-specific camera APIs |
||||
|
|
||||
|
## Maintenance |
||||
|
|
||||
|
### Regular Updates |
||||
|
- Keep dependencies updated |
||||
|
- Monitor platform changes |
||||
|
- Update documentation |
||||
|
- Review security patches |
||||
|
|
||||
|
### Performance Monitoring |
||||
|
- Track memory usage |
||||
|
- Monitor camera performance |
||||
|
- Check error rates |
||||
|
- Analyze user feedback |
||||
|
|
||||
|
## Camera Handling |
||||
|
|
||||
|
### Camera Switching Implementation |
||||
|
|
||||
|
The QR scanner supports camera switching on both mobile and desktop platforms through a unified interface. |
||||
|
|
||||
|
#### Platform-Specific Implementations |
||||
|
|
||||
|
1. **Mobile (Capacitor)** |
||||
|
- Uses `@capacitor-mlkit/barcode-scanning` |
||||
|
- Supports front/back camera switching |
||||
|
- Native camera access through platform APIs |
||||
|
- Optimized for mobile performance |
||||
|
|
||||
|
```typescript |
||||
|
// CapacitorQRScanner.ts |
||||
|
async startScan(options?: QRScannerOptions): Promise<void> { |
||||
|
const scanOptions: StartScanOptions = { |
||||
|
formats: [BarcodeFormat.QrCode], |
||||
|
lensFacing: options?.camera === "front" ? |
||||
|
LensFacing.Front : LensFacing.Back |
||||
|
}; |
||||
|
await BarcodeScanner.startScan(scanOptions); |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
2. **Web (Desktop)** |
||||
|
- Uses browser's MediaDevices API |
||||
|
- Supports multiple camera devices |
||||
|
- Dynamic camera enumeration |
||||
|
- Real-time camera switching |
||||
|
|
||||
|
```typescript |
||||
|
// WebInlineQRScanner.ts |
||||
|
async getAvailableCameras(): Promise<MediaDeviceInfo[]> { |
||||
|
const devices = await navigator.mediaDevices.enumerateDevices(); |
||||
|
return devices.filter(device => device.kind === 'videoinput'); |
||||
|
} |
||||
|
|
||||
|
async switchCamera(deviceId: string): Promise<void> { |
||||
|
// Stop current stream |
||||
|
await this.stopScan(); |
||||
|
|
||||
|
// Start new stream with selected camera |
||||
|
this.stream = await navigator.mediaDevices.getUserMedia({ |
||||
|
video: { |
||||
|
deviceId: { exact: deviceId }, |
||||
|
width: { ideal: 1280 }, |
||||
|
height: { ideal: 720 } |
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
// Update video and restart scanning |
||||
|
if (this.video) { |
||||
|
this.video.srcObject = this.stream; |
||||
|
await this.video.play(); |
||||
|
} |
||||
|
this.scanQRCode(); |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
### Core Interfaces |
||||
|
|
||||
|
```typescript |
||||
|
interface QRScannerService { |
||||
|
// ... existing methods ... |
||||
|
|
||||
|
/** Get available cameras */ |
||||
|
getAvailableCameras(): Promise<MediaDeviceInfo[]>; |
||||
|
|
||||
|
/** Switch to a specific camera */ |
||||
|
switchCamera(deviceId: string): Promise<void>; |
||||
|
|
||||
|
/** Get current camera info */ |
||||
|
getCurrentCamera(): Promise<MediaDeviceInfo | null>; |
||||
|
} |
||||
|
|
||||
|
interface QRScannerOptions { |
||||
|
/** Camera to use ('front' or 'back' for mobile) */ |
||||
|
camera?: "front" | "back"; |
||||
|
/** Whether to show a preview of the camera feed */ |
||||
|
showPreview?: boolean; |
||||
|
/** Whether to play a sound on successful scan */ |
||||
|
playSound?: boolean; |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
### UI Components |
||||
|
|
||||
|
The camera switching UI adapts to the platform: |
||||
|
|
||||
|
1. **Mobile Interface** |
||||
|
- Simple toggle button for front/back cameras |
||||
|
- Positioned in bottom-right corner |
||||
|
- Clear visual feedback during switching |
||||
|
- Native camera controls |
||||
|
|
||||
|
```vue |
||||
|
<button |
||||
|
v-if="isNativePlatform" |
||||
|
@click="toggleMobileCamera" |
||||
|
class="camera-switch-btn" |
||||
|
> |
||||
|
<font-awesome icon="camera-rotate" /> |
||||
|
Switch Camera |
||||
|
</button> |
||||
|
``` |
||||
|
|
||||
|
2. **Desktop Interface** |
||||
|
- Dropdown menu with all available cameras |
||||
|
- Camera labels and device IDs |
||||
|
- Real-time camera switching |
||||
|
- Responsive design |
||||
|
|
||||
|
```vue |
||||
|
<select |
||||
|
v-model="selectedCameraId" |
||||
|
@change="onCameraChange" |
||||
|
class="camera-select-dropdown" |
||||
|
> |
||||
|
<option |
||||
|
v-for="camera in availableCameras" |
||||
|
:key="camera.deviceId" |
||||
|
:value="camera.deviceId" |
||||
|
> |
||||
|
{{ camera.label || `Camera ${camera.deviceId.slice(0, 4)}` }} |
||||
|
</option> |
||||
|
</select> |
||||
|
``` |
||||
|
|
||||
|
### Error Handling |
||||
|
|
||||
|
The camera switching implementation includes comprehensive error handling: |
||||
|
|
||||
|
1. **Common Error Scenarios** |
||||
|
- Camera in use by another application |
||||
|
- Permission denied during switch |
||||
|
- Device not available |
||||
|
- Stream initialization failure |
||||
|
- Camera switch timeout |
||||
|
|
||||
|
2. **Error Response** |
||||
|
```typescript |
||||
|
private async handleCameraSwitch(deviceId: string): Promise<void> { |
||||
|
try { |
||||
|
this.updateCameraState("initializing", "Switching camera..."); |
||||
|
await this.switchCamera(deviceId); |
||||
|
this.updateCameraState("active", "Camera switched successfully"); |
||||
|
} catch (error) { |
||||
|
this.updateCameraState("error", "Failed to switch camera"); |
||||
|
throw error; |
||||
|
} |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
3. **User Feedback** |
||||
|
- Visual indicators during switching |
||||
|
- Error notifications |
||||
|
- Camera state updates |
||||
|
- Permission request dialogs |
||||
|
|
||||
|
### State Management |
||||
|
|
||||
|
The camera system maintains several states: |
||||
|
|
||||
|
1. **Camera States** |
||||
|
```typescript |
||||
|
type CameraState = |
||||
|
| "initializing" // Camera is being initialized |
||||
|
| "ready" // Camera is ready to use |
||||
|
| "active" // Camera is actively streaming |
||||
|
| "in_use" // Camera is in use by another application |
||||
|
| "permission_denied" // Camera permission was denied |
||||
|
| "not_found" // No camera found on device |
||||
|
| "error" // Generic error state |
||||
|
| "off"; // Camera is off |
||||
|
``` |
||||
|
|
||||
|
2. **State Transitions** |
||||
|
- Initialization → Ready |
||||
|
- Ready → Active |
||||
|
- Active → Switching |
||||
|
- Switching → Active/Error |
||||
|
- Any state → Off (on cleanup) |
||||
|
|
||||
|
### Best Practices |
||||
|
|
||||
|
1. **Camera Access** |
||||
|
- Always check permissions before switching |
||||
|
- Handle camera busy states |
||||
|
- Implement proper cleanup |
||||
|
- Monitor camera state changes |
||||
|
|
||||
|
2. **Performance** |
||||
|
- Optimize camera resolution |
||||
|
- Handle stream switching efficiently |
||||
|
- Manage memory usage |
||||
|
- Implement proper cleanup |
||||
|
|
||||
|
3. **User Experience** |
||||
|
- Clear visual feedback |
||||
|
- Smooth camera transitions |
||||
|
- Intuitive camera controls |
||||
|
- Responsive UI updates |
||||
|
- Accessible camera selection |
||||
|
|
||||
|
4. **Security** |
||||
|
- Secure camera access |
||||
|
- Permission management |
||||
|
- Device validation |
||||
|
- Stream security |
||||
|
|
||||
|
### Testing |
||||
|
|
||||
|
1. **Test Scenarios** |
||||
|
- Camera switching on both platforms |
||||
|
- Permission handling |
||||
|
- Error conditions |
||||
|
- Multiple camera devices |
||||
|
- Camera busy states |
||||
|
- Stream initialization |
||||
|
- UI responsiveness |
||||
|
|
||||
|
2. **Test Environment** |
||||
|
- Multiple mobile devices |
||||
|
- Various desktop browsers |
||||
|
- Different camera configurations |
||||
|
- Network conditions |
||||
|
- Permission states |
||||
|
|
||||
|
### Capacitor Implementation Details |
||||
|
|
||||
|
#### MLKit Barcode Scanner Configuration |
||||
|
|
||||
|
1. **Plugin Setup** |
||||
|
```typescript |
||||
|
// capacitor.config.ts |
||||
|
const config: CapacitorConfig = { |
||||
|
plugins: { |
||||
|
MLKitBarcodeScanner: { |
||||
|
formats: ['QR_CODE'], |
||||
|
detectorSize: 1.0, |
||||
|
lensFacing: 'back', |
||||
|
googleBarcodeScannerModuleInstallState: true, |
||||
|
// Additional camera options |
||||
|
cameraOptions: { |
||||
|
quality: 0.8, |
||||
|
allowEditing: false, |
||||
|
resultType: 'uri', |
||||
|
sourceType: 'CAMERA', |
||||
|
saveToGallery: false |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
}; |
||||
|
``` |
||||
|
|
||||
|
2. **Camera Management** |
||||
|
```typescript |
||||
|
// CapacitorQRScanner.ts |
||||
|
export class CapacitorQRScanner implements QRScannerService { |
||||
|
private currentLensFacing: LensFacing = LensFacing.Back; |
||||
|
|
||||
|
async getAvailableCameras(): Promise<MediaDeviceInfo[]> { |
||||
|
// On mobile, we have two fixed cameras |
||||
|
return [ |
||||
|
{ |
||||
|
deviceId: 'back', |
||||
|
label: 'Back Camera', |
||||
|
kind: 'videoinput' |
||||
|
}, |
||||
|
{ |
||||
|
deviceId: 'front', |
||||
|
label: 'Front Camera', |
||||
|
kind: 'videoinput' |
||||
|
} |
||||
|
] as MediaDeviceInfo[]; |
||||
|
} |
||||
|
|
||||
|
async switchCamera(deviceId: string): Promise<void> { |
||||
|
if (!this.isScanning) return; |
||||
|
|
||||
|
const newLensFacing = deviceId === 'front' ? |
||||
|
LensFacing.Front : LensFacing.Back; |
||||
|
|
||||
|
// Stop current scan |
||||
|
await this.stopScan(); |
||||
|
|
||||
|
// Update lens facing |
||||
|
this.currentLensFacing = newLensFacing; |
||||
|
|
||||
|
// Restart scan with new camera |
||||
|
await this.startScan({ |
||||
|
camera: deviceId as 'front' | 'back' |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
async getCurrentCamera(): Promise<MediaDeviceInfo | null> { |
||||
|
return { |
||||
|
deviceId: this.currentLensFacing === LensFacing.Front ? 'front' : 'back', |
||||
|
label: this.currentLensFacing === LensFacing.Front ? |
||||
|
'Front Camera' : 'Back Camera', |
||||
|
kind: 'videoinput' |
||||
|
} as MediaDeviceInfo; |
||||
|
} |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
3. **Camera State Management** |
||||
|
```typescript |
||||
|
// CapacitorQRScanner.ts |
||||
|
private async handleCameraState(): Promise<void> { |
||||
|
try { |
||||
|
// Check if camera is available |
||||
|
const { camera } = await BarcodeScanner.checkPermissions(); |
||||
|
|
||||
|
if (camera === 'denied') { |
||||
|
this.updateCameraState('permission_denied'); |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
// Check if camera is in use |
||||
|
const isInUse = await this.isCameraInUse(); |
||||
|
if (isInUse) { |
||||
|
this.updateCameraState('in_use'); |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
this.updateCameraState('ready'); |
||||
|
} catch (error) { |
||||
|
this.updateCameraState('error', error.message); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private async isCameraInUse(): Promise<boolean> { |
||||
|
try { |
||||
|
// Try to start a test scan |
||||
|
await BarcodeScanner.startScan({ |
||||
|
formats: [BarcodeFormat.QrCode], |
||||
|
lensFacing: this.currentLensFacing |
||||
|
}); |
||||
|
// If successful, stop it immediately |
||||
|
await BarcodeScanner.stopScan(); |
||||
|
return false; |
||||
|
} catch (error) { |
||||
|
return error.message.includes('camera in use'); |
||||
|
} |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
4. **Error Handling** |
||||
|
```typescript |
||||
|
// CapacitorQRScanner.ts |
||||
|
private async handleCameraError(error: Error): Promise<void> { |
||||
|
switch (error.name) { |
||||
|
case 'CameraPermissionDenied': |
||||
|
this.updateCameraState('permission_denied'); |
||||
|
break; |
||||
|
case 'CameraInUse': |
||||
|
this.updateCameraState('in_use'); |
||||
|
break; |
||||
|
case 'CameraUnavailable': |
||||
|
this.updateCameraState('not_found'); |
||||
|
break; |
||||
|
default: |
||||
|
this.updateCameraState('error', error.message); |
||||
|
} |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
#### Platform-Specific Considerations |
||||
|
|
||||
|
1. **iOS Implementation** |
||||
|
- Camera permissions in Info.plist |
||||
|
- Privacy descriptions |
||||
|
- Camera usage description |
||||
|
- Background camera access |
||||
|
|
||||
|
```xml |
||||
|
<!-- ios/App/App/Info.plist --> |
||||
|
<key>NSCameraUsageDescription</key> |
||||
|
<string>We need access to your camera to scan QR codes</string> |
||||
|
<key>NSPhotoLibraryUsageDescription</key> |
||||
|
<string>We need access to save scanned QR codes</string> |
||||
|
``` |
||||
|
|
||||
|
2. **Android Implementation** |
||||
|
- Camera permissions in AndroidManifest.xml |
||||
|
- Runtime permission handling |
||||
|
- Camera features declaration |
||||
|
- Hardware feature requirements |
||||
|
|
||||
|
```xml |
||||
|
<!-- android/app/src/main/AndroidManifest.xml --> |
||||
|
<uses-permission android:name="android.permission.CAMERA" /> |
||||
|
<uses-feature android:name="android.hardware.camera" /> |
||||
|
<uses-feature android:name="android.hardware.camera.autofocus" /> |
||||
|
``` |
||||
|
|
||||
|
3. **Platform-Specific Features** |
||||
|
- iOS: Camera orientation handling |
||||
|
- Android: Camera resolution optimization |
||||
|
- Both: Battery usage optimization |
||||
|
- Both: Memory management |
||||
|
|
||||
|
```typescript |
||||
|
// Platform-specific optimizations |
||||
|
private getPlatformSpecificOptions(): StartScanOptions { |
||||
|
const baseOptions: StartScanOptions = { |
||||
|
formats: [BarcodeFormat.QrCode], |
||||
|
lensFacing: this.currentLensFacing |
||||
|
}; |
||||
|
|
||||
|
if (Capacitor.getPlatform() === 'ios') { |
||||
|
return { |
||||
|
...baseOptions, |
||||
|
// iOS-specific options |
||||
|
cameraOptions: { |
||||
|
quality: 0.7, // Lower quality for better performance |
||||
|
allowEditing: false, |
||||
|
resultType: 'uri' |
||||
|
} |
||||
|
}; |
||||
|
} else if (Capacitor.getPlatform() === 'android') { |
||||
|
return { |
||||
|
...baseOptions, |
||||
|
// Android-specific options |
||||
|
cameraOptions: { |
||||
|
quality: 0.8, |
||||
|
allowEditing: false, |
||||
|
resultType: 'uri', |
||||
|
saveToGallery: false |
||||
|
} |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
return baseOptions; |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
#### Performance Optimization |
||||
|
|
||||
|
1. **Battery Usage** |
||||
|
```typescript |
||||
|
// CapacitorQRScanner.ts |
||||
|
private optimizeBatteryUsage(): void { |
||||
|
// Reduce scan frequency when battery is low |
||||
|
if (this.isLowBattery()) { |
||||
|
this.scanInterval = 2000; // 2 seconds between scans |
||||
|
} else { |
||||
|
this.scanInterval = 1000; // 1 second between scans |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private isLowBattery(): boolean { |
||||
|
// Check battery level if available |
||||
|
if (Capacitor.isPluginAvailable('Battery')) { |
||||
|
const { level } = await Battery.getBatteryLevel(); |
||||
|
return level < 0.2; // 20% or lower |
||||
|
} |
||||
|
return false; |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
2. **Memory Management** |
||||
|
```typescript |
||||
|
// CapacitorQRScanner.ts |
||||
|
private async cleanupResources(): Promise<void> { |
||||
|
// Stop scanning |
||||
|
await this.stopScan(); |
||||
|
|
||||
|
// Clear any stored camera data |
||||
|
this.currentLensFacing = LensFacing.Back; |
||||
|
|
||||
|
// Remove listeners |
||||
|
this.listenerHandles.forEach(handle => handle()); |
||||
|
this.listenerHandles = []; |
||||
|
|
||||
|
// Reset state |
||||
|
this.isScanning = false; |
||||
|
this.updateCameraState('off'); |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
#### Testing on Capacitor |
||||
|
|
||||
|
1. **Device Testing** |
||||
|
- Test on multiple iOS devices |
||||
|
- Test on multiple Android devices |
||||
|
- Test different camera configurations |
||||
|
- Test with different screen sizes |
||||
|
- Test with different OS versions |
||||
|
|
||||
|
2. **Camera Testing** |
||||
|
- Test front camera switching |
||||
|
- Test back camera switching |
||||
|
- Test camera permissions |
||||
|
- Test camera in use scenarios |
||||
|
- Test low light conditions |
||||
|
- Test different QR code sizes |
||||
|
- Test different QR code distances |
||||
|
|
||||
|
3. **Performance Testing** |
||||
|
- Battery usage monitoring |
||||
|
- Memory usage monitoring |
||||
|
- Camera switching speed |
||||
|
- QR code detection speed |
||||
|
- App responsiveness |
||||
|
- Background/foreground transitions |
@ -0,0 +1,554 @@ |
|||||
|
# Migration Guide: Dexie to wa-sqlite |
||||
|
|
||||
|
## Overview |
||||
|
|
||||
|
This document outlines the migration process from Dexie.js to wa-sqlite for the TimeSafari app's storage implementation. The migration aims to provide a consistent SQLite-based storage solution across all platforms while maintaining data integrity and ensuring a smooth transition for users. |
||||
|
|
||||
|
## Migration Goals |
||||
|
|
||||
|
1. **Data Integrity** |
||||
|
- Preserve all existing data |
||||
|
- Maintain data relationships |
||||
|
- Ensure data consistency |
||||
|
|
||||
|
2. **Performance** |
||||
|
- Improve query performance |
||||
|
- Reduce storage overhead |
||||
|
- Optimize for platform-specific features |
||||
|
|
||||
|
3. **Security** |
||||
|
- Maintain or improve encryption |
||||
|
- Preserve access controls |
||||
|
- Enhance data protection |
||||
|
|
||||
|
4. **User Experience** |
||||
|
- Zero data loss |
||||
|
- Minimal downtime |
||||
|
- Automatic migration where possible |
||||
|
|
||||
|
## Prerequisites |
||||
|
|
||||
|
1. **Backup Requirements** |
||||
|
```typescript |
||||
|
interface MigrationBackup { |
||||
|
timestamp: number; |
||||
|
accounts: Account[]; |
||||
|
settings: Setting[]; |
||||
|
contacts: Contact[]; |
||||
|
metadata: { |
||||
|
version: string; |
||||
|
platform: string; |
||||
|
dexieVersion: string; |
||||
|
}; |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
2. **Storage Requirements** |
||||
|
- Sufficient IndexedDB quota |
||||
|
- Available disk space for SQLite |
||||
|
- Backup storage space |
||||
|
|
||||
|
3. **Platform Support** |
||||
|
- Web: Modern browser with IndexedDB support |
||||
|
- iOS: iOS 13+ with SQLite support |
||||
|
- Android: Android 5+ with SQLite support |
||||
|
- Electron: Latest version with SQLite support |
||||
|
|
||||
|
## Migration Process |
||||
|
|
||||
|
### 1. Preparation |
||||
|
|
||||
|
```typescript |
||||
|
// src/services/storage/migration/MigrationService.ts |
||||
|
export class MigrationService { |
||||
|
private static instance: MigrationService; |
||||
|
private backup: MigrationBackup | null = null; |
||||
|
|
||||
|
async prepare(): Promise<void> { |
||||
|
try { |
||||
|
// 1. Check prerequisites |
||||
|
await this.checkPrerequisites(); |
||||
|
|
||||
|
// 2. Create backup |
||||
|
this.backup = await this.createBackup(); |
||||
|
|
||||
|
// 3. Verify backup integrity |
||||
|
await this.verifyBackup(); |
||||
|
|
||||
|
// 4. Initialize wa-sqlite |
||||
|
await this.initializeWaSqlite(); |
||||
|
} catch (error) { |
||||
|
throw new StorageError( |
||||
|
'Migration preparation failed', |
||||
|
StorageErrorCodes.MIGRATION_FAILED, |
||||
|
error |
||||
|
); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private async checkPrerequisites(): Promise<void> { |
||||
|
// Check IndexedDB availability |
||||
|
if (!window.indexedDB) { |
||||
|
throw new StorageError( |
||||
|
'IndexedDB not available', |
||||
|
StorageErrorCodes.INITIALIZATION_FAILED |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
// Check storage quota |
||||
|
const quota = await navigator.storage.estimate(); |
||||
|
if (quota.quota && quota.usage && quota.usage > quota.quota * 0.9) { |
||||
|
throw new StorageError( |
||||
|
'Insufficient storage space', |
||||
|
StorageErrorCodes.STORAGE_FULL |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
// Check platform support |
||||
|
const capabilities = await PlatformDetection.getCapabilities(); |
||||
|
if (!capabilities.hasFileSystem) { |
||||
|
throw new StorageError( |
||||
|
'Platform does not support required features', |
||||
|
StorageErrorCodes.INITIALIZATION_FAILED |
||||
|
); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private async createBackup(): Promise<MigrationBackup> { |
||||
|
const dexieDB = new Dexie('TimeSafariDB'); |
||||
|
|
||||
|
return { |
||||
|
timestamp: Date.now(), |
||||
|
accounts: await dexieDB.accounts.toArray(), |
||||
|
settings: await dexieDB.settings.toArray(), |
||||
|
contacts: await dexieDB.contacts.toArray(), |
||||
|
metadata: { |
||||
|
version: '1.0.0', |
||||
|
platform: await PlatformDetection.getPlatform(), |
||||
|
dexieVersion: Dexie.version |
||||
|
} |
||||
|
}; |
||||
|
} |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
### 2. Data Migration |
||||
|
|
||||
|
```typescript |
||||
|
// src/services/storage/migration/DataMigration.ts |
||||
|
export class DataMigration { |
||||
|
async migrate(backup: MigrationBackup): Promise<void> { |
||||
|
try { |
||||
|
// 1. Create new database schema |
||||
|
await this.createSchema(); |
||||
|
|
||||
|
// 2. Migrate accounts |
||||
|
await this.migrateAccounts(backup.accounts); |
||||
|
|
||||
|
// 3. Migrate settings |
||||
|
await this.migrateSettings(backup.settings); |
||||
|
|
||||
|
// 4. Migrate contacts |
||||
|
await this.migrateContacts(backup.contacts); |
||||
|
|
||||
|
// 5. Verify migration |
||||
|
await this.verifyMigration(backup); |
||||
|
} catch (error) { |
||||
|
// 6. Handle failure |
||||
|
await this.handleMigrationFailure(error, backup); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private async migrateAccounts(accounts: Account[]): Promise<void> { |
||||
|
const db = await this.getWaSqliteConnection(); |
||||
|
|
||||
|
// Use transaction for atomicity |
||||
|
await db.transaction(async (tx) => { |
||||
|
for (const account of accounts) { |
||||
|
await tx.execute(` |
||||
|
INSERT INTO accounts (did, public_key_hex, created_at, updated_at) |
||||
|
VALUES (?, ?, ?, ?) |
||||
|
`, [ |
||||
|
account.did, |
||||
|
account.publicKeyHex, |
||||
|
account.createdAt, |
||||
|
account.updatedAt |
||||
|
]); |
||||
|
} |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
private async verifyMigration(backup: MigrationBackup): Promise<void> { |
||||
|
const db = await this.getWaSqliteConnection(); |
||||
|
|
||||
|
// Verify account count |
||||
|
const accountCount = await db.selectValue( |
||||
|
'SELECT COUNT(*) FROM accounts' |
||||
|
); |
||||
|
if (accountCount !== backup.accounts.length) { |
||||
|
throw new StorageError( |
||||
|
'Account count mismatch', |
||||
|
StorageErrorCodes.VERIFICATION_FAILED |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
// Verify data integrity |
||||
|
await this.verifyDataIntegrity(backup); |
||||
|
} |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
### 3. Rollback Strategy |
||||
|
|
||||
|
```typescript |
||||
|
// src/services/storage/migration/RollbackService.ts |
||||
|
export class RollbackService { |
||||
|
async rollback(backup: MigrationBackup): Promise<void> { |
||||
|
try { |
||||
|
// 1. Stop all database operations |
||||
|
await this.stopDatabaseOperations(); |
||||
|
|
||||
|
// 2. Restore from backup |
||||
|
await this.restoreFromBackup(backup); |
||||
|
|
||||
|
// 3. Verify restoration |
||||
|
await this.verifyRestoration(backup); |
||||
|
|
||||
|
// 4. Clean up wa-sqlite |
||||
|
await this.cleanupWaSqlite(); |
||||
|
} catch (error) { |
||||
|
throw new StorageError( |
||||
|
'Rollback failed', |
||||
|
StorageErrorCodes.ROLLBACK_FAILED, |
||||
|
error |
||||
|
); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private async restoreFromBackup(backup: MigrationBackup): Promise<void> { |
||||
|
const dexieDB = new Dexie('TimeSafariDB'); |
||||
|
|
||||
|
// Restore accounts |
||||
|
await dexieDB.accounts.bulkPut(backup.accounts); |
||||
|
|
||||
|
// Restore settings |
||||
|
await dexieDB.settings.bulkPut(backup.settings); |
||||
|
|
||||
|
// Restore contacts |
||||
|
await dexieDB.contacts.bulkPut(backup.contacts); |
||||
|
} |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
## Migration UI |
||||
|
|
||||
|
```vue |
||||
|
<!-- src/components/MigrationProgress.vue --> |
||||
|
<template> |
||||
|
<div class="migration-progress"> |
||||
|
<h2>Database Migration</h2> |
||||
|
|
||||
|
<div class="progress-container"> |
||||
|
<div class="progress-bar" :style="{ width: `${progress}%` }" /> |
||||
|
<div class="progress-text">{{ progress }}%</div> |
||||
|
</div> |
||||
|
|
||||
|
<div class="status-message">{{ statusMessage }}</div> |
||||
|
|
||||
|
<div v-if="error" class="error-message"> |
||||
|
{{ error }} |
||||
|
<button @click="retryMigration">Retry</button> |
||||
|
</div> |
||||
|
</div> |
||||
|
</template> |
||||
|
|
||||
|
<script setup lang="ts"> |
||||
|
import { ref, onMounted } from 'vue'; |
||||
|
import { MigrationService } from '@/services/storage/migration/MigrationService'; |
||||
|
|
||||
|
const progress = ref(0); |
||||
|
const statusMessage = ref('Preparing migration...'); |
||||
|
const error = ref<string | null>(null); |
||||
|
|
||||
|
const migrationService = MigrationService.getInstance(); |
||||
|
|
||||
|
async function startMigration() { |
||||
|
try { |
||||
|
// 1. Preparation |
||||
|
statusMessage.value = 'Creating backup...'; |
||||
|
await migrationService.prepare(); |
||||
|
progress.value = 20; |
||||
|
|
||||
|
// 2. Data migration |
||||
|
statusMessage.value = 'Migrating data...'; |
||||
|
await migrationService.migrate(); |
||||
|
progress.value = 80; |
||||
|
|
||||
|
// 3. Verification |
||||
|
statusMessage.value = 'Verifying migration...'; |
||||
|
await migrationService.verify(); |
||||
|
progress.value = 100; |
||||
|
|
||||
|
statusMessage.value = 'Migration completed successfully!'; |
||||
|
} catch (err) { |
||||
|
error.value = err instanceof Error ? err.message : 'Migration failed'; |
||||
|
statusMessage.value = 'Migration failed'; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
async function retryMigration() { |
||||
|
error.value = null; |
||||
|
progress.value = 0; |
||||
|
await startMigration(); |
||||
|
} |
||||
|
|
||||
|
onMounted(() => { |
||||
|
startMigration(); |
||||
|
}); |
||||
|
</script> |
||||
|
|
||||
|
<style scoped> |
||||
|
.migration-progress { |
||||
|
padding: 2rem; |
||||
|
max-width: 600px; |
||||
|
margin: 0 auto; |
||||
|
} |
||||
|
|
||||
|
.progress-container { |
||||
|
position: relative; |
||||
|
height: 20px; |
||||
|
background: #eee; |
||||
|
border-radius: 10px; |
||||
|
overflow: hidden; |
||||
|
margin: 1rem 0; |
||||
|
} |
||||
|
|
||||
|
.progress-bar { |
||||
|
position: absolute; |
||||
|
height: 100%; |
||||
|
background: #4CAF50; |
||||
|
transition: width 0.3s ease; |
||||
|
} |
||||
|
|
||||
|
.progress-text { |
||||
|
position: absolute; |
||||
|
width: 100%; |
||||
|
text-align: center; |
||||
|
line-height: 20px; |
||||
|
color: #000; |
||||
|
} |
||||
|
|
||||
|
.status-message { |
||||
|
text-align: center; |
||||
|
margin: 1rem 0; |
||||
|
} |
||||
|
|
||||
|
.error-message { |
||||
|
color: #f44336; |
||||
|
text-align: center; |
||||
|
margin: 1rem 0; |
||||
|
} |
||||
|
|
||||
|
button { |
||||
|
margin-top: 1rem; |
||||
|
padding: 0.5rem 1rem; |
||||
|
background: #2196F3; |
||||
|
color: white; |
||||
|
border: none; |
||||
|
border-radius: 4px; |
||||
|
cursor: pointer; |
||||
|
} |
||||
|
|
||||
|
button:hover { |
||||
|
background: #1976D2; |
||||
|
} |
||||
|
</style> |
||||
|
``` |
||||
|
|
||||
|
## Testing Strategy |
||||
|
|
||||
|
1. **Unit Tests** |
||||
|
```typescript |
||||
|
// src/services/storage/migration/__tests__/MigrationService.spec.ts |
||||
|
describe('MigrationService', () => { |
||||
|
it('should create valid backup', async () => { |
||||
|
const service = MigrationService.getInstance(); |
||||
|
const backup = await service.createBackup(); |
||||
|
|
||||
|
expect(backup).toBeDefined(); |
||||
|
expect(backup.accounts).toBeInstanceOf(Array); |
||||
|
expect(backup.settings).toBeInstanceOf(Array); |
||||
|
expect(backup.contacts).toBeInstanceOf(Array); |
||||
|
}); |
||||
|
|
||||
|
it('should migrate data correctly', async () => { |
||||
|
const service = MigrationService.getInstance(); |
||||
|
const backup = await service.createBackup(); |
||||
|
|
||||
|
await service.migrate(backup); |
||||
|
|
||||
|
// Verify migration |
||||
|
const accounts = await service.getMigratedAccounts(); |
||||
|
expect(accounts).toHaveLength(backup.accounts.length); |
||||
|
}); |
||||
|
|
||||
|
it('should handle rollback correctly', async () => { |
||||
|
const service = MigrationService.getInstance(); |
||||
|
const backup = await service.createBackup(); |
||||
|
|
||||
|
// Simulate failed migration |
||||
|
await service.migrate(backup); |
||||
|
await service.simulateFailure(); |
||||
|
|
||||
|
// Perform rollback |
||||
|
await service.rollback(backup); |
||||
|
|
||||
|
// Verify rollback |
||||
|
const accounts = await service.getOriginalAccounts(); |
||||
|
expect(accounts).toHaveLength(backup.accounts.length); |
||||
|
}); |
||||
|
}); |
||||
|
``` |
||||
|
|
||||
|
2. **Integration Tests** |
||||
|
```typescript |
||||
|
// src/services/storage/migration/__tests__/integration/Migration.spec.ts |
||||
|
describe('Migration Integration', () => { |
||||
|
it('should handle concurrent access during migration', async () => { |
||||
|
const service = MigrationService.getInstance(); |
||||
|
|
||||
|
// Start migration |
||||
|
const migrationPromise = service.migrate(); |
||||
|
|
||||
|
// Simulate concurrent access |
||||
|
const accessPromises = Array(5).fill(null).map(() => |
||||
|
service.getAccount('did:test:123') |
||||
|
); |
||||
|
|
||||
|
// Wait for all operations |
||||
|
const [migrationResult, ...accessResults] = await Promise.allSettled([ |
||||
|
migrationPromise, |
||||
|
...accessPromises |
||||
|
]); |
||||
|
|
||||
|
// Verify results |
||||
|
expect(migrationResult.status).toBe('fulfilled'); |
||||
|
expect(accessResults.some(r => r.status === 'rejected')).toBe(true); |
||||
|
}); |
||||
|
|
||||
|
it('should maintain data integrity during platform transition', async () => { |
||||
|
const service = MigrationService.getInstance(); |
||||
|
|
||||
|
// Simulate platform change |
||||
|
await service.simulatePlatformChange(); |
||||
|
|
||||
|
// Verify data |
||||
|
const accounts = await service.getAllAccounts(); |
||||
|
const settings = await service.getAllSettings(); |
||||
|
const contacts = await service.getAllContacts(); |
||||
|
|
||||
|
expect(accounts).toBeDefined(); |
||||
|
expect(settings).toBeDefined(); |
||||
|
expect(contacts).toBeDefined(); |
||||
|
}); |
||||
|
}); |
||||
|
``` |
||||
|
|
||||
|
## Success Criteria |
||||
|
|
||||
|
1. **Data Integrity** |
||||
|
- [ ] All accounts migrated successfully |
||||
|
- [ ] All settings preserved |
||||
|
- [ ] All contacts transferred |
||||
|
- [ ] No data corruption |
||||
|
|
||||
|
2. **Performance** |
||||
|
- [ ] Migration completes within acceptable time |
||||
|
- [ ] No significant performance degradation |
||||
|
- [ ] Efficient storage usage |
||||
|
- [ ] Smooth user experience |
||||
|
|
||||
|
3. **Security** |
||||
|
- [ ] Encrypted data remains secure |
||||
|
- [ ] Access controls maintained |
||||
|
- [ ] No sensitive data exposure |
||||
|
- [ ] Secure backup process |
||||
|
|
||||
|
4. **User Experience** |
||||
|
- [ ] Clear migration progress |
||||
|
- [ ] Informative error messages |
||||
|
- [ ] Automatic recovery from failures |
||||
|
- [ ] No data loss |
||||
|
|
||||
|
## Rollback Plan |
||||
|
|
||||
|
1. **Automatic Rollback** |
||||
|
- Triggered by migration failure |
||||
|
- Restores from verified backup |
||||
|
- Maintains data consistency |
||||
|
- Logs rollback reason |
||||
|
|
||||
|
2. **Manual Rollback** |
||||
|
- Available through settings |
||||
|
- Requires user confirmation |
||||
|
- Preserves backup data |
||||
|
- Provides rollback status |
||||
|
|
||||
|
3. **Emergency Recovery** |
||||
|
- Manual backup restoration |
||||
|
- Database repair tools |
||||
|
- Data recovery procedures |
||||
|
- Support contact information |
||||
|
|
||||
|
## Post-Migration |
||||
|
|
||||
|
1. **Verification** |
||||
|
- Data integrity checks |
||||
|
- Performance monitoring |
||||
|
- Error rate tracking |
||||
|
- User feedback collection |
||||
|
|
||||
|
2. **Cleanup** |
||||
|
- Remove old database |
||||
|
- Clear migration artifacts |
||||
|
- Update application state |
||||
|
- Archive backup data |
||||
|
|
||||
|
3. **Monitoring** |
||||
|
- Track migration success rate |
||||
|
- Monitor performance metrics |
||||
|
- Collect error reports |
||||
|
- Gather user feedback |
||||
|
|
||||
|
## Support |
||||
|
|
||||
|
For assistance with migration: |
||||
|
1. Check the troubleshooting guide |
||||
|
2. Review error logs |
||||
|
3. Contact support team |
||||
|
4. Submit issue report |
||||
|
|
||||
|
## Timeline |
||||
|
|
||||
|
1. **Preparation Phase** (1 week) |
||||
|
- Backup system implementation |
||||
|
- Migration service development |
||||
|
- Testing framework setup |
||||
|
|
||||
|
2. **Testing Phase** (2 weeks) |
||||
|
- Unit testing |
||||
|
- Integration testing |
||||
|
- Performance testing |
||||
|
- Security testing |
||||
|
|
||||
|
3. **Deployment Phase** (1 week) |
||||
|
- Staged rollout |
||||
|
- Monitoring |
||||
|
- Support preparation |
||||
|
- Documentation updates |
||||
|
|
||||
|
4. **Post-Deployment** (2 weeks) |
||||
|
- Monitoring |
||||
|
- Bug fixes |
||||
|
- Performance optimization |
||||
|
- User feedback collection |
File diff suppressed because it is too large
@ -0,0 +1,20 @@ |
|||||
|
<?xml version="1.0" encoding="UTF-8"?> |
||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> |
||||
|
<plist version="1.0"> |
||||
|
<dict> |
||||
|
<key>com.apple.security.cs.allow-jit</key> |
||||
|
<true/> |
||||
|
<key>com.apple.security.cs.allow-unsigned-executable-memory</key> |
||||
|
<true/> |
||||
|
<key>com.apple.security.cs.debugger</key> |
||||
|
<true/> |
||||
|
<key>com.apple.security.device.audio-input</key> |
||||
|
<true/> |
||||
|
<key>com.apple.security.device.camera</key> |
||||
|
<true/> |
||||
|
<key>com.apple.security.personal-information.addressbook</key> |
||||
|
<true/> |
||||
|
<key>com.apple.security.personal-information.calendars</key> |
||||
|
<true/> |
||||
|
</dict> |
||||
|
</plist> |
@ -1,28 +1,144 @@ |
|||||
PODS: |
PODS: |
||||
- Capacitor (6.2.0): |
- Capacitor (6.2.1): |
||||
- CapacitorCordova |
- CapacitorCordova |
||||
- CapacitorApp (6.0.2): |
- CapacitorApp (6.0.2): |
||||
- Capacitor |
- Capacitor |
||||
- CapacitorCordova (6.2.0) |
- CapacitorCamera (6.1.2): |
||||
|
- Capacitor |
||||
|
- CapacitorCordova (6.2.1) |
||||
|
- CapacitorFilesystem (6.0.3): |
||||
|
- Capacitor |
||||
|
- CapacitorMlkitBarcodeScanning (6.2.0): |
||||
|
- Capacitor |
||||
|
- GoogleMLKit/BarcodeScanning (= 5.0.0) |
||||
|
- CapacitorShare (6.0.3): |
||||
|
- Capacitor |
||||
|
- CapawesomeCapacitorFilePicker (6.2.0): |
||||
|
- Capacitor |
||||
|
- GoogleDataTransport (9.4.1): |
||||
|
- GoogleUtilities/Environment (~> 7.7) |
||||
|
- nanopb (< 2.30911.0, >= 2.30908.0) |
||||
|
- PromisesObjC (< 3.0, >= 1.2) |
||||
|
- GoogleMLKit/BarcodeScanning (5.0.0): |
||||
|
- GoogleMLKit/MLKitCore |
||||
|
- MLKitBarcodeScanning (~> 4.0.0) |
||||
|
- GoogleMLKit/MLKitCore (5.0.0): |
||||
|
- MLKitCommon (~> 10.0.0) |
||||
|
- GoogleToolboxForMac/DebugUtils (2.3.2): |
||||
|
- GoogleToolboxForMac/Defines (= 2.3.2) |
||||
|
- GoogleToolboxForMac/Defines (2.3.2) |
||||
|
- GoogleToolboxForMac/Logger (2.3.2): |
||||
|
- GoogleToolboxForMac/Defines (= 2.3.2) |
||||
|
- "GoogleToolboxForMac/NSData+zlib (2.3.2)": |
||||
|
- GoogleToolboxForMac/Defines (= 2.3.2) |
||||
|
- "GoogleToolboxForMac/NSDictionary+URLArguments (2.3.2)": |
||||
|
- GoogleToolboxForMac/DebugUtils (= 2.3.2) |
||||
|
- GoogleToolboxForMac/Defines (= 2.3.2) |
||||
|
- "GoogleToolboxForMac/NSString+URLArguments (= 2.3.2)" |
||||
|
- "GoogleToolboxForMac/NSString+URLArguments (2.3.2)" |
||||
|
- GoogleUtilities/Environment (7.13.3): |
||||
|
- GoogleUtilities/Privacy |
||||
|
- PromisesObjC (< 3.0, >= 1.2) |
||||
|
- GoogleUtilities/Logger (7.13.3): |
||||
|
- GoogleUtilities/Environment |
||||
|
- GoogleUtilities/Privacy |
||||
|
- GoogleUtilities/Privacy (7.13.3) |
||||
|
- GoogleUtilities/UserDefaults (7.13.3): |
||||
|
- GoogleUtilities/Logger |
||||
|
- GoogleUtilities/Privacy |
||||
|
- GoogleUtilitiesComponents (1.1.0): |
||||
|
- GoogleUtilities/Logger |
||||
|
- GTMSessionFetcher/Core (3.5.0) |
||||
|
- MLImage (1.0.0-beta5) |
||||
|
- MLKitBarcodeScanning (4.0.0): |
||||
|
- MLKitCommon (~> 10.0) |
||||
|
- MLKitVision (~> 6.0) |
||||
|
- MLKitCommon (10.0.0): |
||||
|
- GoogleDataTransport (~> 9.0) |
||||
|
- GoogleToolboxForMac/Logger (~> 2.1) |
||||
|
- "GoogleToolboxForMac/NSData+zlib (~> 2.1)" |
||||
|
- "GoogleToolboxForMac/NSDictionary+URLArguments (~> 2.1)" |
||||
|
- GoogleUtilities/UserDefaults (~> 7.0) |
||||
|
- GoogleUtilitiesComponents (~> 1.0) |
||||
|
- GTMSessionFetcher/Core (< 4.0, >= 1.1) |
||||
|
- MLKitVision (6.0.0): |
||||
|
- GoogleToolboxForMac/Logger (~> 2.1) |
||||
|
- "GoogleToolboxForMac/NSData+zlib (~> 2.1)" |
||||
|
- GTMSessionFetcher/Core (< 4.0, >= 1.1) |
||||
|
- MLImage (= 1.0.0-beta5) |
||||
|
- MLKitCommon (~> 10.0) |
||||
|
- nanopb (2.30910.0): |
||||
|
- nanopb/decode (= 2.30910.0) |
||||
|
- nanopb/encode (= 2.30910.0) |
||||
|
- nanopb/decode (2.30910.0) |
||||
|
- nanopb/encode (2.30910.0) |
||||
|
- PromisesObjC (2.4.0) |
||||
|
|
||||
DEPENDENCIES: |
DEPENDENCIES: |
||||
- "Capacitor (from `../../node_modules/@capacitor/ios`)" |
- "Capacitor (from `../../node_modules/@capacitor/ios`)" |
||||
- "CapacitorApp (from `../../node_modules/@capacitor/app`)" |
- "CapacitorApp (from `../../node_modules/@capacitor/app`)" |
||||
|
- "CapacitorCamera (from `../../node_modules/@capacitor/camera`)" |
||||
- "CapacitorCordova (from `../../node_modules/@capacitor/ios`)" |
- "CapacitorCordova (from `../../node_modules/@capacitor/ios`)" |
||||
|
- "CapacitorFilesystem (from `../../node_modules/@capacitor/filesystem`)" |
||||
|
- "CapacitorMlkitBarcodeScanning (from `../../node_modules/@capacitor-mlkit/barcode-scanning`)" |
||||
|
- "CapacitorShare (from `../../node_modules/@capacitor/share`)" |
||||
|
- "CapawesomeCapacitorFilePicker (from `../../node_modules/@capawesome/capacitor-file-picker`)" |
||||
|
|
||||
|
SPEC REPOS: |
||||
|
trunk: |
||||
|
- GoogleDataTransport |
||||
|
- GoogleMLKit |
||||
|
- GoogleToolboxForMac |
||||
|
- GoogleUtilities |
||||
|
- GoogleUtilitiesComponents |
||||
|
- GTMSessionFetcher |
||||
|
- MLImage |
||||
|
- MLKitBarcodeScanning |
||||
|
- MLKitCommon |
||||
|
- MLKitVision |
||||
|
- nanopb |
||||
|
- PromisesObjC |
||||
|
|
||||
EXTERNAL SOURCES: |
EXTERNAL SOURCES: |
||||
Capacitor: |
Capacitor: |
||||
:path: "../../node_modules/@capacitor/ios" |
:path: "../../node_modules/@capacitor/ios" |
||||
CapacitorApp: |
CapacitorApp: |
||||
:path: "../../node_modules/@capacitor/app" |
:path: "../../node_modules/@capacitor/app" |
||||
|
CapacitorCamera: |
||||
|
:path: "../../node_modules/@capacitor/camera" |
||||
CapacitorCordova: |
CapacitorCordova: |
||||
:path: "../../node_modules/@capacitor/ios" |
:path: "../../node_modules/@capacitor/ios" |
||||
|
CapacitorFilesystem: |
||||
|
:path: "../../node_modules/@capacitor/filesystem" |
||||
|
CapacitorMlkitBarcodeScanning: |
||||
|
:path: "../../node_modules/@capacitor-mlkit/barcode-scanning" |
||||
|
CapacitorShare: |
||||
|
:path: "../../node_modules/@capacitor/share" |
||||
|
CapawesomeCapacitorFilePicker: |
||||
|
:path: "../../node_modules/@capawesome/capacitor-file-picker" |
||||
|
|
||||
SPEC CHECKSUMS: |
SPEC CHECKSUMS: |
||||
Capacitor: 05d35014f4425b0740fc8776481f6a369ad071bf |
Capacitor: c95400d761e376be9da6be5a05f226c0e865cebf |
||||
CapacitorApp: e1e6b7d05e444d593ca16fd6d76f2b7c48b5aea7 |
CapacitorApp: e1e6b7d05e444d593ca16fd6d76f2b7c48b5aea7 |
||||
CapacitorCordova: b33e7f4aa4ed105dd43283acdd940964374a87d9 |
CapacitorCamera: 9bc7b005d0e6f1d5f525b8137045b60cffffce79 |
||||
|
CapacitorCordova: 8d93e14982f440181be7304aa9559ca631d77fff |
||||
|
CapacitorFilesystem: 59270a63c60836248812671aa3b15df673fbaf74 |
||||
|
CapacitorMlkitBarcodeScanning: 7652be9c7922f39203a361de735d340ae37e134e |
||||
|
CapacitorShare: d2a742baec21c8f3b92b361a2fbd2401cdd8288e |
||||
|
CapawesomeCapacitorFilePicker: c40822f0a39f86855321943c7829d52bca7f01bd |
||||
|
GoogleDataTransport: 6c09b596d841063d76d4288cc2d2f42cc36e1e2a |
||||
|
GoogleMLKit: 90ba06e028795a50261f29500d238d6061538711 |
||||
|
GoogleToolboxForMac: 8bef7c7c5cf7291c687cf5354f39f9db6399ad34 |
||||
|
GoogleUtilities: ea963c370a38a8069cc5f7ba4ca849a60b6d7d15 |
||||
|
GoogleUtilitiesComponents: 679b2c881db3b615a2777504623df6122dd20afe |
||||
|
GTMSessionFetcher: 5aea5ba6bd522a239e236100971f10cb71b96ab6 |
||||
|
MLImage: 1824212150da33ef225fbd3dc49f184cf611046c |
||||
|
MLKitBarcodeScanning: 9cb0ec5ec65bbb5db31de4eba0a3289626beab4e |
||||
|
MLKitCommon: afcd11b6c0735066a0dde8b4bf2331f6197cbca2 |
||||
|
MLKitVision: 90922bca854014a856f8b649d1f1f04f63fd9c79 |
||||
|
nanopb: 438bc412db1928dac798aa6fd75726007be04262 |
||||
|
PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47 |
||||
|
|
||||
PODFILE CHECKSUM: 4233f5c5f414604460ff96d372542c311b0fb7a8 |
PODFILE CHECKSUM: 7e7e09e6937de7f015393aecf2cf7823645689b3 |
||||
|
|
||||
COCOAPODS: 1.16.2 |
COCOAPODS: 1.16.2 |
||||
|
@ -0,0 +1,5 @@ |
|||||
|
# macOS |
||||
|
.DS_Store |
||||
|
|
||||
|
# Build |
||||
|
/Build/ |
@ -0,0 +1,58 @@ |
|||||
|
## 1.4.1 |
||||
|
- Fix macOS app re-signing issue. |
||||
|
- Automatically enable Hardened Runtime in macOS codesign. |
||||
|
- Add clean script. |
||||
|
|
||||
|
## 1.4.0 |
||||
|
- Support for macOS app ([#9](https://github.com/crasowas/app_privacy_manifest_fixer/issues/9)). |
||||
|
|
||||
|
## 1.3.11 |
||||
|
- Fix install issue by skipping `PBXAggregateTarget` ([#4](https://github.com/crasowas/app_privacy_manifest_fixer/issues/4)). |
||||
|
|
||||
|
## 1.3.10 |
||||
|
- Fix app re-signing issue. |
||||
|
- Enhance Build Phases script robustness. |
||||
|
|
||||
|
## 1.3.9 |
||||
|
- Add log file output. |
||||
|
|
||||
|
## 1.3.8 |
||||
|
- Add version info to privacy access report. |
||||
|
- Remove empty tables from privacy access report. |
||||
|
|
||||
|
## 1.3.7 |
||||
|
- Enhance API symbols analysis with strings tool. |
||||
|
- Improve performance of API usage analysis. |
||||
|
|
||||
|
## 1.3.5 |
||||
|
- Fix issue with inaccurate privacy manifest search. |
||||
|
- Disable dependency analysis to force the script to run on every build. |
||||
|
- Add placeholder for privacy access report. |
||||
|
- Update build output directory naming convention. |
||||
|
- Add examples for privacy access report. |
||||
|
|
||||
|
## 1.3.0 |
||||
|
- Add privacy access report generation. |
||||
|
|
||||
|
## 1.2.3 |
||||
|
- Fix issue with relative path parameter. |
||||
|
- Add support for all application targets. |
||||
|
|
||||
|
## 1.2.1 |
||||
|
- Fix backup issue with empty user templates directory. |
||||
|
|
||||
|
## 1.2.0 |
||||
|
- Add uninstall script. |
||||
|
|
||||
|
## 1.1.2 |
||||
|
- Remove `Templates/.gitignore` to track `UserTemplates`. |
||||
|
- Fix incorrect use of `App.xcprivacy` template in `App.framework`. |
||||
|
|
||||
|
## 1.1.0 |
||||
|
- Add logs for latest release fetch failure. |
||||
|
- Fix issue with converting published time to local time. |
||||
|
- Disable showing environment variables in the build log. |
||||
|
- Add `--install-builds-only` command line option. |
||||
|
|
||||
|
## 1.0.0 |
||||
|
- Initial version. |
@ -0,0 +1,80 @@ |
|||||
|
#!/bin/bash |
||||
|
|
||||
|
# Copyright (c) 2025, crasowas. |
||||
|
# |
||||
|
# Use of this source code is governed by a MIT-style license |
||||
|
# that can be found in the LICENSE file or at |
||||
|
# https://opensource.org/licenses/MIT. |
||||
|
|
||||
|
set -e |
||||
|
|
||||
|
# Prevent duplicate loading |
||||
|
if [ -n "$CONSTANTS_SH_LOADED" ]; then |
||||
|
return |
||||
|
fi |
||||
|
|
||||
|
readonly CONSTANTS_SH_LOADED=1 |
||||
|
|
||||
|
# File name of the privacy manifest |
||||
|
readonly PRIVACY_MANIFEST_FILE_NAME="PrivacyInfo.xcprivacy" |
||||
|
|
||||
|
# Common privacy manifest template file names |
||||
|
readonly APP_TEMPLATE_FILE_NAME="AppTemplate.xcprivacy" |
||||
|
readonly FRAMEWORK_TEMPLATE_FILE_NAME="FrameworkTemplate.xcprivacy" |
||||
|
|
||||
|
# Universal delimiter |
||||
|
readonly DELIMITER=":" |
||||
|
|
||||
|
# Space escape symbol for handling space in path |
||||
|
readonly SPACE_ESCAPE="\u0020" |
||||
|
|
||||
|
# Default value when the version cannot be retrieved |
||||
|
readonly UNKNOWN_VERSION="unknown" |
||||
|
|
||||
|
# Categories of required reason APIs |
||||
|
readonly API_CATEGORIES=( |
||||
|
"NSPrivacyAccessedAPICategoryFileTimestamp" |
||||
|
"NSPrivacyAccessedAPICategorySystemBootTime" |
||||
|
"NSPrivacyAccessedAPICategoryDiskSpace" |
||||
|
"NSPrivacyAccessedAPICategoryActiveKeyboards" |
||||
|
"NSPrivacyAccessedAPICategoryUserDefaults" |
||||
|
) |
||||
|
|
||||
|
# Symbol of the required reason APIs and their categories |
||||
|
# |
||||
|
# See also: |
||||
|
# * https://developer.apple.com/documentation/bundleresources/describing-use-of-required-reason-api |
||||
|
# * https://github.com/Wooder/ios_17_required_reason_api_scanner/blob/main/required_reason_api_binary_scanner.sh |
||||
|
readonly API_SYMBOLS=( |
||||
|
# NSPrivacyAccessedAPICategoryFileTimestamp |
||||
|
"NSPrivacyAccessedAPICategoryFileTimestamp${DELIMITER}getattrlist" |
||||
|
"NSPrivacyAccessedAPICategoryFileTimestamp${DELIMITER}getattrlistbulk" |
||||
|
"NSPrivacyAccessedAPICategoryFileTimestamp${DELIMITER}fgetattrlist" |
||||
|
"NSPrivacyAccessedAPICategoryFileTimestamp${DELIMITER}stat" |
||||
|
"NSPrivacyAccessedAPICategoryFileTimestamp${DELIMITER}fstat" |
||||
|
"NSPrivacyAccessedAPICategoryFileTimestamp${DELIMITER}fstatat" |
||||
|
"NSPrivacyAccessedAPICategoryFileTimestamp${DELIMITER}lstat" |
||||
|
"NSPrivacyAccessedAPICategoryFileTimestamp${DELIMITER}getattrlistat" |
||||
|
"NSPrivacyAccessedAPICategoryFileTimestamp${DELIMITER}NSFileCreationDate" |
||||
|
"NSPrivacyAccessedAPICategoryFileTimestamp${DELIMITER}NSFileModificationDate" |
||||
|
"NSPrivacyAccessedAPICategoryFileTimestamp${DELIMITER}NSURLContentModificationDateKey" |
||||
|
"NSPrivacyAccessedAPICategoryFileTimestamp${DELIMITER}NSURLCreationDateKey" |
||||
|
# NSPrivacyAccessedAPICategorySystemBootTime |
||||
|
"NSPrivacyAccessedAPICategorySystemBootTime${DELIMITER}systemUptime" |
||||
|
"NSPrivacyAccessedAPICategorySystemBootTime${DELIMITER}mach_absolute_time" |
||||
|
# NSPrivacyAccessedAPICategoryDiskSpace |
||||
|
"NSPrivacyAccessedAPICategoryDiskSpace${DELIMITER}statfs" |
||||
|
"NSPrivacyAccessedAPICategoryDiskSpace${DELIMITER}statvfs" |
||||
|
"NSPrivacyAccessedAPICategoryDiskSpace${DELIMITER}fstatfs" |
||||
|
"NSPrivacyAccessedAPICategoryDiskSpace${DELIMITER}fstatvfs" |
||||
|
"NSPrivacyAccessedAPICategoryDiskSpace${DELIMITER}NSFileSystemFreeSize" |
||||
|
"NSPrivacyAccessedAPICategoryDiskSpace${DELIMITER}NSFileSystemSize" |
||||
|
"NSPrivacyAccessedAPICategoryDiskSpace${DELIMITER}NSURLVolumeAvailableCapacityKey" |
||||
|
"NSPrivacyAccessedAPICategoryDiskSpace${DELIMITER}NSURLVolumeAvailableCapacityForImportantUsageKey" |
||||
|
"NSPrivacyAccessedAPICategoryDiskSpace${DELIMITER}NSURLVolumeAvailableCapacityForOpportunisticUsageKey" |
||||
|
"NSPrivacyAccessedAPICategoryDiskSpace${DELIMITER}NSURLVolumeTotalCapacityKey" |
||||
|
# NSPrivacyAccessedAPICategoryActiveKeyboards |
||||
|
"NSPrivacyAccessedAPICategoryActiveKeyboards${DELIMITER}activeInputModes" |
||||
|
# NSPrivacyAccessedAPICategoryUserDefaults |
||||
|
"NSPrivacyAccessedAPICategoryUserDefaults${DELIMITER}NSUserDefaults" |
||||
|
) |
@ -0,0 +1,125 @@ |
|||||
|
#!/bin/bash |
||||
|
|
||||
|
# Copyright (c) 2025, crasowas. |
||||
|
# |
||||
|
# Use of this source code is governed by a MIT-style license |
||||
|
# that can be found in the LICENSE file or at |
||||
|
# https://opensource.org/licenses/MIT. |
||||
|
|
||||
|
set -e |
||||
|
|
||||
|
# Prevent duplicate loading |
||||
|
if [ -n "$UTILS_SH_LOADED" ]; then |
||||
|
return |
||||
|
fi |
||||
|
|
||||
|
readonly UTILS_SH_LOADED=1 |
||||
|
|
||||
|
# Absolute path of the script and the tool's root directory |
||||
|
script_path="$(realpath "${BASH_SOURCE[0]}")" |
||||
|
tool_root_path="$(dirname "$(dirname "$script_path")")" |
||||
|
|
||||
|
# Load common constants |
||||
|
source "$tool_root_path/Common/constants.sh" |
||||
|
|
||||
|
# Print the elements of an array along with their indices |
||||
|
function print_array() { |
||||
|
local -a array=("$@") |
||||
|
|
||||
|
for ((i=0; i<${#array[@]}; i++)); do |
||||
|
echo "[$i] $(decode_path "${array[i]}")" |
||||
|
done |
||||
|
} |
||||
|
|
||||
|
# Split a string into substrings using a specified delimiter |
||||
|
function split_string_by_delimiter() { |
||||
|
local string="$1" |
||||
|
local -a substrings=() |
||||
|
|
||||
|
IFS="$DELIMITER" read -ra substrings <<< "$string" |
||||
|
|
||||
|
echo "${substrings[@]}" |
||||
|
} |
||||
|
|
||||
|
# Encode a path string by replacing space with an escape character |
||||
|
function encode_path() { |
||||
|
echo "$1" | sed "s/ /$SPACE_ESCAPE/g" |
||||
|
} |
||||
|
|
||||
|
# Decode a path string by replacing encoded character with space |
||||
|
function decode_path() { |
||||
|
echo "$1" | sed "s/$SPACE_ESCAPE/ /g" |
||||
|
} |
||||
|
|
||||
|
# Get the dependency name by removing common suffixes |
||||
|
function get_dependency_name() { |
||||
|
local path="$1" |
||||
|
|
||||
|
local dir_name="$(basename "$path")" |
||||
|
# Remove `.app`, `.framework`, and `.xcframework` suffixes |
||||
|
local dep_name="${dir_name%.*}" |
||||
|
|
||||
|
echo "$dep_name" |
||||
|
} |
||||
|
|
||||
|
# Get the executable name from the specified `Info.plist` file |
||||
|
function get_plist_executable() { |
||||
|
local plist_file="$1" |
||||
|
|
||||
|
if [ ! -f "$plist_file" ]; then |
||||
|
echo "" |
||||
|
else |
||||
|
/usr/libexec/PlistBuddy -c "Print :CFBundleExecutable" "$plist_file" 2>/dev/null || echo "" |
||||
|
fi |
||||
|
} |
||||
|
|
||||
|
# Get the version from the specified `Info.plist` file |
||||
|
function get_plist_version() { |
||||
|
local plist_file="$1" |
||||
|
|
||||
|
if [ ! -f "$plist_file" ]; then |
||||
|
echo "$UNKNOWN_VERSION" |
||||
|
else |
||||
|
/usr/libexec/PlistBuddy -c "Print :CFBundleShortVersionString" "$plist_file" 2>/dev/null || echo "$UNKNOWN_VERSION" |
||||
|
fi |
||||
|
} |
||||
|
|
||||
|
# Get the path of the specified framework version |
||||
|
function get_framework_path() { |
||||
|
local path="$1" |
||||
|
local version_path="$2" |
||||
|
|
||||
|
if [ -z "$version_path" ]; then |
||||
|
echo "$path" |
||||
|
else |
||||
|
echo "$path/$version_path" |
||||
|
fi |
||||
|
} |
||||
|
|
||||
|
# Search for privacy manifest files in the specified directory |
||||
|
function search_privacy_manifest_files() { |
||||
|
local path="$1" |
||||
|
local -a privacy_manifest_files=() |
||||
|
|
||||
|
# Create a temporary file to store search results |
||||
|
local temp_file="$(mktemp)" |
||||
|
|
||||
|
# Ensure the temporary file is deleted on script exit |
||||
|
trap "rm -f $temp_file" EXIT |
||||
|
|
||||
|
# Find privacy manifest files within the specified directory and store the results in the temporary file |
||||
|
find "$path" -type f -name "$PRIVACY_MANIFEST_FILE_NAME" -print0 2>/dev/null > "$temp_file" |
||||
|
|
||||
|
while IFS= read -r -d '' file; do |
||||
|
privacy_manifest_files+=($(encode_path "$file")) |
||||
|
done < "$temp_file" |
||||
|
|
||||
|
echo "${privacy_manifest_files[@]}" |
||||
|
} |
||||
|
|
||||
|
# Get the privacy manifest file with the shortest path |
||||
|
function get_privacy_manifest_file() { |
||||
|
local privacy_manifest_file="$(printf "%s\n" "$@" | awk '{print length, $0}' | sort -n | head -n1 | cut -d ' ' -f2-)" |
||||
|
|
||||
|
echo "$(decode_path "$privacy_manifest_file")" |
||||
|
} |
@ -0,0 +1,80 @@ |
|||||
|
# Copyright (c) 2024, crasowas. |
||||
|
# |
||||
|
# Use of this source code is governed by a MIT-style license |
||||
|
# that can be found in the LICENSE file or at |
||||
|
# https://opensource.org/licenses/MIT. |
||||
|
|
||||
|
require 'xcodeproj' |
||||
|
|
||||
|
RUN_SCRIPT_PHASE_NAME = 'Fix Privacy Manifest' |
||||
|
|
||||
|
if ARGV.length < 2 |
||||
|
puts "Usage: ruby xcode_install_helper.rb <project_path> <script_content> [install_builds_only (true|false)]" |
||||
|
exit 1 |
||||
|
end |
||||
|
|
||||
|
project_path = ARGV[0] |
||||
|
run_script_content = ARGV[1] |
||||
|
install_builds_only = ARGV[2] == 'true' |
||||
|
|
||||
|
# Find the first .xcodeproj file in the project directory |
||||
|
xcodeproj_path = Dir.glob(File.join(project_path, "*.xcodeproj")).first |
||||
|
|
||||
|
# Validate the .xcodeproj file existence |
||||
|
unless xcodeproj_path |
||||
|
puts "Error: No .xcodeproj file found in the specified directory." |
||||
|
exit 1 |
||||
|
end |
||||
|
|
||||
|
# Open the Xcode project file |
||||
|
begin |
||||
|
project = Xcodeproj::Project.open(xcodeproj_path) |
||||
|
rescue StandardError => e |
||||
|
puts "Error: Unable to open the project file - #{e.message}" |
||||
|
exit 1 |
||||
|
end |
||||
|
|
||||
|
# Process all targets in the project |
||||
|
project.targets.each do |target| |
||||
|
# Skip PBXAggregateTarget |
||||
|
if target.is_a?(Xcodeproj::Project::Object::PBXAggregateTarget) |
||||
|
puts "Skipping aggregate target: #{target.name}." |
||||
|
next |
||||
|
end |
||||
|
|
||||
|
# Check if the target is a native application target |
||||
|
if target.product_type == 'com.apple.product-type.application' |
||||
|
puts "Processing target: #{target.name}..." |
||||
|
|
||||
|
# Check for an existing Run Script phase with the specified name |
||||
|
existing_phase = target.shell_script_build_phases.find { |phase| phase.name == RUN_SCRIPT_PHASE_NAME } |
||||
|
|
||||
|
# Remove the existing Run Script phase if found |
||||
|
if existing_phase |
||||
|
puts " - Removing existing Run Script." |
||||
|
target.build_phases.delete(existing_phase) |
||||
|
end |
||||
|
|
||||
|
# Add the new Run Script phase at the end |
||||
|
puts " - Adding new Run Script." |
||||
|
new_phase = target.new_shell_script_build_phase(RUN_SCRIPT_PHASE_NAME) |
||||
|
new_phase.shell_script = run_script_content |
||||
|
# Disable showing environment variables in the build log |
||||
|
new_phase.show_env_vars_in_log = '0' |
||||
|
# Run only for deployment post-processing if install_builds_only is true |
||||
|
new_phase.run_only_for_deployment_postprocessing = install_builds_only ? '1' : '0' |
||||
|
# Disable dependency analysis to force the script to run on every build, unless restricted to deployment builds by post-processing setting |
||||
|
new_phase.always_out_of_date = '1' |
||||
|
else |
||||
|
puts "Skipping non-application target: #{target.name}." |
||||
|
end |
||||
|
end |
||||
|
|
||||
|
# Save the project file |
||||
|
begin |
||||
|
project.save |
||||
|
puts "Successfully added the Run Script phase: '#{RUN_SCRIPT_PHASE_NAME}'." |
||||
|
rescue StandardError => e |
||||
|
puts "Error: Unable to save the project file - #{e.message}" |
||||
|
exit 1 |
||||
|
end |
@ -0,0 +1,63 @@ |
|||||
|
# Copyright (c) 2024, crasowas. |
||||
|
# |
||||
|
# Use of this source code is governed by a MIT-style license |
||||
|
# that can be found in the LICENSE file or at |
||||
|
# https://opensource.org/licenses/MIT. |
||||
|
|
||||
|
require 'xcodeproj' |
||||
|
|
||||
|
RUN_SCRIPT_PHASE_NAME = 'Fix Privacy Manifest' |
||||
|
|
||||
|
if ARGV.length < 1 |
||||
|
puts "Usage: ruby xcode_uninstall_helper.rb <project_path>" |
||||
|
exit 1 |
||||
|
end |
||||
|
|
||||
|
project_path = ARGV[0] |
||||
|
|
||||
|
# Find the first .xcodeproj file in the project directory |
||||
|
xcodeproj_path = Dir.glob(File.join(project_path, "*.xcodeproj")).first |
||||
|
|
||||
|
# Validate the .xcodeproj file existence |
||||
|
unless xcodeproj_path |
||||
|
puts "Error: No .xcodeproj file found in the specified directory." |
||||
|
exit 1 |
||||
|
end |
||||
|
|
||||
|
# Open the Xcode project file |
||||
|
begin |
||||
|
project = Xcodeproj::Project.open(xcodeproj_path) |
||||
|
rescue StandardError => e |
||||
|
puts "Error: Unable to open the project file - #{e.message}" |
||||
|
exit 1 |
||||
|
end |
||||
|
|
||||
|
# Process all targets in the project |
||||
|
project.targets.each do |target| |
||||
|
# Check if the target is an application target |
||||
|
if target.product_type == 'com.apple.product-type.application' |
||||
|
puts "Processing target: #{target.name}..." |
||||
|
|
||||
|
# Check for an existing Run Script phase with the specified name |
||||
|
existing_phase = target.shell_script_build_phases.find { |phase| phase.name == RUN_SCRIPT_PHASE_NAME } |
||||
|
|
||||
|
# Remove the existing Run Script phase if found |
||||
|
if existing_phase |
||||
|
puts " - Removing existing Run Script." |
||||
|
target.build_phases.delete(existing_phase) |
||||
|
else |
||||
|
puts " - No existing Run Script found." |
||||
|
end |
||||
|
else |
||||
|
puts "Skipping non-application target: #{target.name}." |
||||
|
end |
||||
|
end |
||||
|
|
||||
|
# Save the project file |
||||
|
begin |
||||
|
project.save |
||||
|
puts "Successfully removed the Run Script phase: '#{RUN_SCRIPT_PHASE_NAME}'." |
||||
|
rescue StandardError => e |
||||
|
puts "Error: Unable to save the project file - #{e.message}" |
||||
|
exit 1 |
||||
|
end |
@ -0,0 +1,21 @@ |
|||||
|
MIT License |
||||
|
|
||||
|
Copyright (c) 2024 crasowas |
||||
|
|
||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy |
||||
|
of this software and associated documentation files (the "Software"), to deal |
||||
|
in the Software without restriction, including without limitation the rights |
||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell |
||||
|
copies of the Software, and to permit persons to whom the Software is |
||||
|
furnished to do so, subject to the following conditions: |
||||
|
|
||||
|
The above copyright notice and this permission notice shall be included in all |
||||
|
copies or substantial portions of the Software. |
||||
|
|
||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR |
||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, |
||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE |
||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER |
||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, |
||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE |
||||
|
SOFTWARE. |
@ -0,0 +1,240 @@ |
|||||
|
# App Privacy Manifest Fixer |
||||
|
|
||||
|
[](https://github.com/crasowas/app_privacy_manifest_fixer/releases/latest) |
||||
|
 |
||||
|
[](https://opensource.org/licenses/MIT) |
||||
|
|
||||
|
**English | [简体中文](./README.zh-CN.md)** |
||||
|
|
||||
|
This tool is an automation solution based on Shell scripts, designed to analyze and fix the privacy manifest of iOS/macOS apps to ensure compliance with App Store requirements. It leverages the [App Store Privacy Manifest Analyzer](https://github.com/crasowas/app_store_required_privacy_manifest_analyser) to analyze API usage within the app and its dependencies, and generate or fix the `PrivacyInfo.xcprivacy` file. |
||||
|
|
||||
|
## ✨ Features |
||||
|
|
||||
|
- **Non-Intrusive Integration**: No need to modify the source code or adjust the project structure. |
||||
|
- **Fast Installation and Uninstallation**: Quickly install or uninstall the tool with a single command. |
||||
|
- **Automatic Analysis and Fixes**: Automatically analyzes API usage and fixes privacy manifest issues during the project build. |
||||
|
- **Flexible Template Customization**: Supports custom privacy manifest templates for apps and frameworks, accommodating various usage scenarios. |
||||
|
- **Privacy Access Report**: Automatically generates a report displaying the `NSPrivacyAccessedAPITypes` declarations for the app and SDKs. |
||||
|
- **Effortless Version Upgrades**: Provides an upgrade script for quick updates to the latest version. |
||||
|
|
||||
|
## 📥 Installation |
||||
|
|
||||
|
### Download the Tool |
||||
|
|
||||
|
1. Download the [latest release](https://github.com/crasowas/app_privacy_manifest_fixer/releases/latest). |
||||
|
2. Extract the downloaded file: |
||||
|
- The extracted directory is usually named `app_privacy_manifest_fixer-xxx` (where `xxx` is the version number). |
||||
|
- It is recommended to rename it to `app_privacy_manifest_fixer` or use the full directory name in subsequent paths. |
||||
|
- **It is advised to move the directory to your iOS/macOS project to avoid path-related issues on different devices, and to easily customize the privacy manifest template for each project**. |
||||
|
|
||||
|
### ⚡ Automatic Installation (Recommended) |
||||
|
|
||||
|
1. **Navigate to the tool's directory**: |
||||
|
|
||||
|
```shell |
||||
|
cd /path/to/app_privacy_manifest_fixer |
||||
|
``` |
||||
|
|
||||
|
2. **Run the installation script**: |
||||
|
|
||||
|
```shell |
||||
|
sh install.sh <project_path> |
||||
|
``` |
||||
|
|
||||
|
- For Flutter projects, `project_path` should be the path to the `ios/macos` directory within the Flutter project. |
||||
|
- If the installation command is run again, the tool will first remove any existing installation (if present). To modify command-line options, simply rerun the installation command without the need to uninstall first. |
||||
|
|
||||
|
### Manual Installation |
||||
|
|
||||
|
If you prefer not to use the installation script, you can manually add the `Fix Privacy Manifest` task to the Xcode **Build Phases**. Follow these steps: |
||||
|
|
||||
|
#### 1. Add the Script in Xcode |
||||
|
|
||||
|
- Open your iOS/macOS project in Xcode, go to the **TARGETS** tab, and select your app target. |
||||
|
- Navigate to **Build Phases**, click the **+** button, and select **New Run Script Phase**. |
||||
|
- Rename the newly created **Run Script** to `Fix Privacy Manifest`. |
||||
|
- In the Shell script box, add the following code: |
||||
|
|
||||
|
```shell |
||||
|
# Use relative path (recommended): if `app_privacy_manifest_fixer` is within the project directory |
||||
|
"$PROJECT_DIR/path/to/app_privacy_manifest_fixer/fixer.sh" |
||||
|
|
||||
|
# Use absolute path: if `app_privacy_manifest_fixer` is outside the project directory |
||||
|
# "/absolute/path/to/app_privacy_manifest_fixer/fixer.sh" |
||||
|
``` |
||||
|
|
||||
|
**Modify `path/to` or `absolute/path/to` as needed, and ensure the paths are correct. Remove or comment out the unused lines accordingly.** |
||||
|
|
||||
|
#### 2. Adjust the Script Execution Order |
||||
|
|
||||
|
**Move this script after all other Build Phases to ensure the privacy manifest is fixed after all resource copying and build tasks are completed**. |
||||
|
|
||||
|
### Build Phases Screenshot |
||||
|
|
||||
|
Below is a screenshot of the Xcode Build Phases configuration after successful automatic/manual installation (with no command-line options enabled): |
||||
|
|
||||
|
 |
||||
|
|
||||
|
## 🚀 Getting Started |
||||
|
|
||||
|
After installation, the tool will automatically run with each project build, and the resulting application bundle will include the fixes. |
||||
|
|
||||
|
If the `--install-builds-only` command-line option is enabled during installation, the tool will only run during the installation of the build. |
||||
|
|
||||
|
### Xcode Build Log Screenshot |
||||
|
|
||||
|
Below is a screenshot of the log output from the tool during the project build (by default, it will be saved to the `app_privacy_manifest_fixer/Build` directory, unless the `-s` command-line option is enabled): |
||||
|
|
||||
|
 |
||||
|
|
||||
|
## 📖 Usage |
||||
|
|
||||
|
### Command Line Options |
||||
|
|
||||
|
- **Force overwrite existing privacy manifest (Not recommended)**: |
||||
|
|
||||
|
```shell |
||||
|
sh install.sh <project_path> -f |
||||
|
``` |
||||
|
|
||||
|
Enabling the `-f` option will force the tool to generate a new privacy manifest based on the API usage analysis and privacy manifest template, overwriting the existing privacy manifest. By default (without `-f`), the tool only fixes missing privacy manifests. |
||||
|
|
||||
|
- **Silent mode**: |
||||
|
|
||||
|
```shell |
||||
|
sh install.sh <project_path> -s |
||||
|
``` |
||||
|
|
||||
|
Enabling the `-s` option disables output during the fix process. The tool will no longer copy the generated `*.app`, automatically generate the privacy access report, or output the fix logs. By default (without `-s`), these outputs are stored in the `app_privacy_manifest_fixer/Build` directory. |
||||
|
|
||||
|
- **Run only during installation builds (Recommended)**: |
||||
|
|
||||
|
```shell |
||||
|
sh install.sh <project_path> --install-builds-only |
||||
|
``` |
||||
|
|
||||
|
Enabling the `--install-builds-only` option makes the tool run only during installation builds (such as the **Archive** operation), optimizing build performance for daily development. If you manually installed, this option is ineffective, and you need to manually check the **"For install builds only"** option. |
||||
|
|
||||
|
**Note**: If the iOS/macOS project is built in a development environment (where the generated app contains `*.debug.dylib` files), the tool's API usage analysis results may be inaccurate. |
||||
|
|
||||
|
### Upgrade the Tool |
||||
|
|
||||
|
To update to the latest version, run the following command: |
||||
|
|
||||
|
```shell |
||||
|
sh upgrade.sh |
||||
|
``` |
||||
|
|
||||
|
### Uninstall the Tool |
||||
|
|
||||
|
To quickly uninstall the tool, run the following command: |
||||
|
|
||||
|
```shell |
||||
|
sh uninstall.sh <project_path> |
||||
|
``` |
||||
|
|
||||
|
### Clean the Tool-Generated Files |
||||
|
|
||||
|
To remove files generated by the tool, run the following command: |
||||
|
|
||||
|
```shell |
||||
|
sh clean.sh |
||||
|
``` |
||||
|
|
||||
|
## 🔥 Privacy Manifest Templates |
||||
|
|
||||
|
The privacy manifest templates are stored in the [`Templates`](https://github.com/crasowas/app_privacy_manifest_fixer/tree/main/Templates) directory, with the default templates already included in the root directory. |
||||
|
|
||||
|
**How can you customize the privacy manifests for apps or SDKs? Simply use [custom templates](#custom-templates)!** |
||||
|
|
||||
|
### Template Types |
||||
|
|
||||
|
The templates are categorized as follows: |
||||
|
- **AppTemplate.xcprivacy**: A privacy manifest template for the app. |
||||
|
- **FrameworkTemplate.xcprivacy**: A generic privacy manifest template for frameworks. |
||||
|
- **FrameworkName.xcprivacy**: A privacy manifest template for a specific framework, available only in the `Templates/UserTemplates` directory. |
||||
|
|
||||
|
### Template Priority |
||||
|
|
||||
|
For an app, the priority of privacy manifest templates is as follows: |
||||
|
- `Templates/UserTemplates/AppTemplate.xcprivacy` > `Templates/AppTemplate.xcprivacy` |
||||
|
|
||||
|
For a specific framework, the priority of privacy manifest templates is as follows: |
||||
|
- `Templates/UserTemplates/FrameworkName.xcprivacy` > `Templates/UserTemplates/FrameworkTemplate.xcprivacy` > `Templates/FrameworkTemplate.xcprivacy` |
||||
|
|
||||
|
### Default Templates |
||||
|
|
||||
|
The default templates are located in the `Templates` root directory and currently include the following templates: |
||||
|
- `Templates/AppTemplate.xcprivacy` |
||||
|
- `Templates/FrameworkTemplate.xcprivacy` |
||||
|
|
||||
|
These templates will be modified based on the API usage analysis results, especially the `NSPrivacyAccessedAPIType` entries, to generate new privacy manifests for fixes, ensuring compliance with App Store requirements. |
||||
|
|
||||
|
**If adjustments to the privacy manifest template are needed, such as in the following scenarios, avoid directly modifying the default templates. Instead, use a custom template. If a custom template with the same name exists, it will take precedence over the default template for fixes.** |
||||
|
- Generating a non-compliant privacy manifest due to inaccurate API usage analysis. |
||||
|
- Modifying the reason declared in the template. |
||||
|
- Adding declarations for collected data. |
||||
|
|
||||
|
The privacy access API categories and their associated declared reasons in `AppTemplate.xcprivacy` are listed below: |
||||
|
|
||||
|
| [NSPrivacyAccessedAPIType](https://developer.apple.com/documentation/bundleresources/app-privacy-configuration/nsprivacyaccessedapitypes/nsprivacyaccessedapitype) | [NSPrivacyAccessedAPITypeReasons](https://developer.apple.com/documentation/bundleresources/app-privacy-configuration/nsprivacyaccessedapitypes/nsprivacyaccessedapitypereasons) | |
||||
|
|--------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| |
||||
|
| NSPrivacyAccessedAPICategoryFileTimestamp | C617.1: Inside app or group container | |
||||
|
| NSPrivacyAccessedAPICategorySystemBootTime | 35F9.1: Measure time on-device | |
||||
|
| NSPrivacyAccessedAPICategoryDiskSpace | E174.1: Write or delete file on-device | |
||||
|
| NSPrivacyAccessedAPICategoryActiveKeyboards | 54BD.1: Customize UI on-device | |
||||
|
| NSPrivacyAccessedAPICategoryUserDefaults | CA92.1: Access info from same app | |
||||
|
|
||||
|
The privacy access API categories and their associated declared reasons in `FrameworkTemplate.xcprivacy` are listed below: |
||||
|
|
||||
|
| [NSPrivacyAccessedAPIType](https://developer.apple.com/documentation/bundleresources/app-privacy-configuration/nsprivacyaccessedapitypes/nsprivacyaccessedapitype) | [NSPrivacyAccessedAPITypeReasons](https://developer.apple.com/documentation/bundleresources/app-privacy-configuration/nsprivacyaccessedapitypes/nsprivacyaccessedapitypereasons) | |
||||
|
|--------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| |
||||
|
| NSPrivacyAccessedAPICategoryFileTimestamp | 0A2A.1: 3rd-party SDK wrapper on-device | |
||||
|
| NSPrivacyAccessedAPICategorySystemBootTime | 35F9.1: Measure time on-device | |
||||
|
| NSPrivacyAccessedAPICategoryDiskSpace | E174.1: Write or delete file on-device | |
||||
|
| NSPrivacyAccessedAPICategoryActiveKeyboards | 54BD.1: Customize UI on-device | |
||||
|
| NSPrivacyAccessedAPICategoryUserDefaults | C56D.1: 3rd-party SDK wrapper on-device | |
||||
|
|
||||
|
### Custom Templates |
||||
|
|
||||
|
To create custom templates, place them in the `Templates/UserTemplates` directory with the following structure: |
||||
|
- `Templates/UserTemplates/AppTemplate.xcprivacy` |
||||
|
- `Templates/UserTemplates/FrameworkTemplate.xcprivacy` |
||||
|
- `Templates/UserTemplates/FrameworkName.xcprivacy` |
||||
|
|
||||
|
Among these templates, only `FrameworkTemplate.xcprivacy` will be modified based on the API usage analysis results to adjust the `NSPrivacyAccessedAPIType` entries, thereby generating a new privacy manifest for framework fixes. The other templates will remain unchanged and will be directly used for fixes. |
||||
|
|
||||
|
**Important Notes:** |
||||
|
- The template for a specific framework must follow the naming convention `FrameworkName.xcprivacy`, where `FrameworkName` should match the name of the framework. For example, the template for `Flutter.framework` should be named `Flutter.xcprivacy`. |
||||
|
- For macOS frameworks, the naming convention should be `FrameworkName.Version.xcprivacy`, where the version name is added to distinguish different versions. For a single version macOS framework, the `Version` is typically `A`. |
||||
|
- The name of an SDK may not exactly match the name of the framework. To determine the correct framework name, check the `Frameworks` directory in the application bundle after building the project. |
||||
|
|
||||
|
## 📑 Privacy Access Report |
||||
|
|
||||
|
By default, the tool automatically generates privacy access reports for both the original and fixed versions of the app during each project build, and stores the reports in the `app_privacy_manifest_fixer/Build` directory. |
||||
|
|
||||
|
If you need to manually generate a privacy access report for a specific app, run the following command: |
||||
|
|
||||
|
```shell |
||||
|
sh Report/report.sh <app_path> <report_output_path> |
||||
|
# <app_path>: Path to the app (e.g., /path/to/App.app) |
||||
|
# <report_output_path>: Path to save the report file (e.g., /path/to/report.html) |
||||
|
``` |
||||
|
|
||||
|
**Note**: The report generated by the tool currently only includes the privacy access section (`NSPrivacyAccessedAPITypes`). To view the data collection section (`NSPrivacyCollectedDataTypes`), please use Xcode to generate the `PrivacyReport`. |
||||
|
|
||||
|
### Sample Report Screenshots |
||||
|
|
||||
|
| Original App Report (report-original.html) | Fixed App Report (report.html) | |
||||
|
|------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------| |
||||
|
|  |  | |
||||
|
|
||||
|
## 💡 Important Considerations |
||||
|
|
||||
|
- If the latest version of the SDK supports a privacy manifest, please upgrade as soon as possible to avoid unnecessary risks. |
||||
|
- This tool is a temporary solution and should not replace proper SDK management practices. |
||||
|
- Before submitting your app for review, ensure that the privacy manifest fix complies with the latest App Store requirements. |
||||
|
|
||||
|
## 🙌 Contributing |
||||
|
|
||||
|
Contributions in any form are welcome, including code optimizations, bug fixes, documentation improvements, and more. Please follow the project's guidelines and maintain a consistent coding style. Thank you for your support! |
@ -0,0 +1,240 @@ |
|||||
|
# App Privacy Manifest Fixer |
||||
|
|
||||
|
[](https://github.com/crasowas/app_privacy_manifest_fixer/releases/latest) |
||||
|
 |
||||
|
[](https://opensource.org/licenses/MIT) |
||||
|
|
||||
|
**[English](./README.md) | 简体中文** |
||||
|
|
||||
|
本工具是一个基于 Shell 脚本的自动化解决方案,旨在分析和修复 iOS/macOS App 的隐私清单,确保 App 符合 App Store 的要求。它利用 [App Store Privacy Manifest Analyzer](https://github.com/crasowas/app_store_required_privacy_manifest_analyser) 对 App 及其依赖项进行 API 使用分析,并生成或修复`PrivacyInfo.xcprivacy`文件。 |
||||
|
|
||||
|
## ✨ 功能特点 |
||||
|
|
||||
|
- **非侵入式集成**:无需修改源码或调整项目结构。 |
||||
|
- **极速安装与卸载**:一行命令即可快速完成工具的安装或卸载。 |
||||
|
- **自动分析与修复**:项目构建时自动分析 API 使用情况并修复隐私清单问题。 |
||||
|
- **灵活定制模板**:支持自定义 App 和 Framework 的隐私清单模板,满足多种使用场景。 |
||||
|
- **隐私访问报告**:自动生成报告用于查看 App 和 SDK 的`NSPrivacyAccessedAPITypes`声明情况。 |
||||
|
- **版本轻松升级**:提供升级脚本快速更新至最新版本。 |
||||
|
|
||||
|
## 📥 安装 |
||||
|
|
||||
|
### 下载工具 |
||||
|
|
||||
|
1. 下载[最新发布版本](https://github.com/crasowas/app_privacy_manifest_fixer/releases/latest)。 |
||||
|
2. 解压下载的文件: |
||||
|
- 解压后的目录通常为`app_privacy_manifest_fixer-xxx`(其中`xxx`是版本号)。 |
||||
|
- 建议重命名为`app_privacy_manifest_fixer`,或在后续路径中使用完整目录名。 |
||||
|
- **建议将该目录移动至 iOS/macOS 项目中,以避免因路径问题在不同设备上运行时出现错误,同时便于为每个项目单独自定义隐私清单模板**。 |
||||
|
|
||||
|
### ⚡ 自动安装(推荐) |
||||
|
|
||||
|
1. **切换到工具所在目录**: |
||||
|
|
||||
|
```shell |
||||
|
cd /path/to/app_privacy_manifest_fixer |
||||
|
``` |
||||
|
|
||||
|
2. **运行以下安装脚本**: |
||||
|
|
||||
|
```shell |
||||
|
sh install.sh <project_path> |
||||
|
``` |
||||
|
|
||||
|
- 如果是 Flutter 项目,`project_path`应为 Flutter 项目中的`ios/macos`目录路径。 |
||||
|
- 重复运行安装命令时,工具会先移除现有安装(如果有)。若需修改命令行选项,只需重新运行安装命令,无需先卸载。 |
||||
|
|
||||
|
### 手动安装 |
||||
|
|
||||
|
如果不使用安装脚本,可以手动添加`Fix Privacy Manifest`任务到 Xcode 的 **Build Phases** 完成安装。安装步骤如下: |
||||
|
|
||||
|
#### 1. 在 Xcode 中添加脚本 |
||||
|
|
||||
|
- 用 Xcode 打开你的 iOS/macOS 项目,进入 **TARGETS** 选项卡,选择你的 App 目标。 |
||||
|
- 进入 **Build Phases**,点击 **+** 按钮,选择 **New Run Script Phase**。 |
||||
|
- 将新建的 **Run Script** 重命名为`Fix Privacy Manifest`。 |
||||
|
- 在 Shell 脚本框中添加以下代码: |
||||
|
|
||||
|
```shell |
||||
|
# 使用相对路径(推荐):如果`app_privacy_manifest_fixer`在项目目录内 |
||||
|
"$PROJECT_DIR/path/to/app_privacy_manifest_fixer/fixer.sh" |
||||
|
|
||||
|
# 使用绝对路径:如果`app_privacy_manifest_fixer`不在项目目录内 |
||||
|
# "/absolute/path/to/app_privacy_manifest_fixer/fixer.sh" |
||||
|
``` |
||||
|
|
||||
|
**请根据实际情况修改`path/to`或`absolute/path/to`,并确保路径正确。同时,删除或注释掉不适用的行**。 |
||||
|
|
||||
|
#### 2. 调整脚本执行顺序 |
||||
|
|
||||
|
**将该脚本移动到所有其他 Build Phases 之后,确保隐私清单在所有资源拷贝和编译任务完成后再进行修复**。 |
||||
|
|
||||
|
### Build Phases 截图 |
||||
|
|
||||
|
下面是自动/手动安装成功后的 Xcode Build Phases 配置截图(未启用任何命令行选项): |
||||
|
|
||||
|
 |
||||
|
|
||||
|
## 🚀 快速开始 |
||||
|
|
||||
|
安装后,工具将在每次构建项目时自动运行,构建完成后得到的 App 包已经是修复后的结果。 |
||||
|
|
||||
|
如果启用`--install-builds-only`命令行选项安装,工具将仅在安装构建时运行。 |
||||
|
|
||||
|
### Xcode Build Log 截图 |
||||
|
|
||||
|
下面是项目构建时工具输出的日志截图(默认会存储到`app_privacy_manifest_fixer/Build`目录,除非启用`-s`命令行选项): |
||||
|
|
||||
|
 |
||||
|
|
||||
|
## 📖 使用方法 |
||||
|
|
||||
|
### 命令行选项 |
||||
|
|
||||
|
- **强制覆盖现有隐私清单(不推荐)**: |
||||
|
|
||||
|
```shell |
||||
|
sh install.sh <project_path> -f |
||||
|
``` |
||||
|
|
||||
|
启用`-f`选项后,工具会根据 API 使用分析结果和隐私清单模板生成新的隐私清单,并强制覆盖现有隐私清单。默认情况下(未启用`-f`),工具仅修复缺失的隐私清单。 |
||||
|
|
||||
|
- **静默模式**: |
||||
|
|
||||
|
```shell |
||||
|
sh install.sh <project_path> -s |
||||
|
``` |
||||
|
|
||||
|
启用`-s`选项后,工具将禁用修复时的输出,不再复制构建生成的`*.app`、自动生成隐私访问报告或输出修复日志。默认情况下(未启用`-s`),这些输出存储在`app_privacy_manifest_fixer/Build`目录。 |
||||
|
|
||||
|
- **仅在安装构建时运行(推荐)**: |
||||
|
|
||||
|
```shell |
||||
|
sh install.sh <project_path> --install-builds-only |
||||
|
``` |
||||
|
|
||||
|
启用`--install-builds-only`选项后,工具仅在执行安装构建(如 **Archive** 操作)时运行,以优化日常开发时的构建性能。如果你是手动安装的,该命令行选项无效,需要手动勾选 **"For install builds only"** 选项。 |
||||
|
|
||||
|
**注意**:如果 iOS/macOS 项目在开发环境构建(生成的 App 包含`*.debug.dylib`文件),工具的 API 使用分析结果可能不准确。 |
||||
|
|
||||
|
### 升级工具 |
||||
|
|
||||
|
要更新至最新版本,请运行以下命令: |
||||
|
|
||||
|
```shell |
||||
|
sh upgrade.sh |
||||
|
``` |
||||
|
|
||||
|
### 卸载工具 |
||||
|
|
||||
|
要快速卸载本工具,请运行以下命令: |
||||
|
|
||||
|
```shell |
||||
|
sh uninstall.sh <project_path> |
||||
|
``` |
||||
|
|
||||
|
### 清理工具生成的文件 |
||||
|
|
||||
|
要删除工具生成的文件,请运行以下命令: |
||||
|
|
||||
|
```shell |
||||
|
sh clean.sh |
||||
|
``` |
||||
|
|
||||
|
## 🔥 隐私清单模板 |
||||
|
|
||||
|
隐私清单模板存储在[`Templates`](https://github.com/crasowas/app_privacy_manifest_fixer/tree/main/Templates)目录,其中根目录已经包含默认模板。 |
||||
|
|
||||
|
**如何为 App 或 SDK 自定义隐私清单?只需使用[自定义模板](#自定义模板)!** |
||||
|
|
||||
|
### 模板类型 |
||||
|
|
||||
|
模板分为以下几类: |
||||
|
- **AppTemplate.xcprivacy**:App 的隐私清单模板。 |
||||
|
- **FrameworkTemplate.xcprivacy**:通用的 Framework 隐私清单模板。 |
||||
|
- **FrameworkName.xcprivacy**:特定的 Framework 隐私清单模板,仅在`Templates/UserTemplates`目录有效。 |
||||
|
|
||||
|
### 模板优先级 |
||||
|
|
||||
|
对于 App,隐私清单模板的优先级如下: |
||||
|
- `Templates/UserTemplates/AppTemplate.xcprivacy` > `Templates/AppTemplate.xcprivacy` |
||||
|
|
||||
|
对于特定的 Framework,隐私清单模板的优先级如下: |
||||
|
- `Templates/UserTemplates/FrameworkName.xcprivacy` > `Templates/UserTemplates/FrameworkTemplate.xcprivacy` > `Templates/FrameworkTemplate.xcprivacy` |
||||
|
|
||||
|
### 默认模板 |
||||
|
|
||||
|
默认模板位于`Templates`根目录,目前包括以下模板: |
||||
|
- `Templates/AppTemplate.xcprivacy` |
||||
|
- `Templates/FrameworkTemplate.xcprivacy` |
||||
|
|
||||
|
这些模板将根据 API 使用分析结果进行修改,特别是`NSPrivacyAccessedAPIType`条目将被调整,以生成新的隐私清单用于修复,确保符合 App Store 要求。 |
||||
|
|
||||
|
**如果需要调整隐私清单模板,例如以下场景,请避免直接修改默认模板,而是使用自定义模板。如果存在相同名称的自定义模板,它将优先于默认模板用于修复。** |
||||
|
- 由于 API 使用分析结果不准确,生成了不合规的隐私清单。 |
||||
|
- 需要修改模板中声明的理由。 |
||||
|
- 需要声明收集的数据。 |
||||
|
|
||||
|
`AppTemplate.xcprivacy`中隐私访问 API 类别及其对应声明的理由如下: |
||||
|
|
||||
|
| [NSPrivacyAccessedAPIType](https://developer.apple.com/documentation/bundleresources/app-privacy-configuration/nsprivacyaccessedapitypes/nsprivacyaccessedapitype) | [NSPrivacyAccessedAPITypeReasons](https://developer.apple.com/documentation/bundleresources/app-privacy-configuration/nsprivacyaccessedapitypes/nsprivacyaccessedapitypereasons) | |
||||
|
|--------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| |
||||
|
| NSPrivacyAccessedAPICategoryFileTimestamp | C617.1: Inside app or group container | |
||||
|
| NSPrivacyAccessedAPICategorySystemBootTime | 35F9.1: Measure time on-device | |
||||
|
| NSPrivacyAccessedAPICategoryDiskSpace | E174.1: Write or delete file on-device | |
||||
|
| NSPrivacyAccessedAPICategoryActiveKeyboards | 54BD.1: Customize UI on-device | |
||||
|
| NSPrivacyAccessedAPICategoryUserDefaults | CA92.1: Access info from same app | |
||||
|
|
||||
|
`FrameworkTemplate.xcprivacy`中隐私访问 API 类别及其对应声明的理由如下: |
||||
|
|
||||
|
| [NSPrivacyAccessedAPIType](https://developer.apple.com/documentation/bundleresources/app-privacy-configuration/nsprivacyaccessedapitypes/nsprivacyaccessedapitype) | [NSPrivacyAccessedAPITypeReasons](https://developer.apple.com/documentation/bundleresources/app-privacy-configuration/nsprivacyaccessedapitypes/nsprivacyaccessedapitypereasons) | |
||||
|
|--------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| |
||||
|
| NSPrivacyAccessedAPICategoryFileTimestamp | 0A2A.1: 3rd-party SDK wrapper on-device | |
||||
|
| NSPrivacyAccessedAPICategorySystemBootTime | 35F9.1: Measure time on-device | |
||||
|
| NSPrivacyAccessedAPICategoryDiskSpace | E174.1: Write or delete file on-device | |
||||
|
| NSPrivacyAccessedAPICategoryActiveKeyboards | 54BD.1: Customize UI on-device | |
||||
|
| NSPrivacyAccessedAPICategoryUserDefaults | C56D.1: 3rd-party SDK wrapper on-device | |
||||
|
|
||||
|
### 自定义模板 |
||||
|
|
||||
|
要创建自定义模板,请将其放在`Templates/UserTemplates`目录,结构如下: |
||||
|
- `Templates/UserTemplates/AppTemplate.xcprivacy` |
||||
|
- `Templates/UserTemplates/FrameworkTemplate.xcprivacy` |
||||
|
- `Templates/UserTemplates/FrameworkName.xcprivacy` |
||||
|
|
||||
|
在这些模板中,只有`FrameworkTemplate.xcprivacy`会根据 API 使用分析结果对`NSPrivacyAccessedAPIType`条目进行调整,以生成新的隐私清单用于 Framework 修复。其他模板保持不变,将直接用于修复。 |
||||
|
|
||||
|
**重要说明:** |
||||
|
- 特定的 Framework 模板必须遵循命名规范`FrameworkName.xcprivacy`,其中`FrameworkName`需与 Framework 的名称匹配。例如`Flutter.framework`的模板应命名为`Flutter.xcprivacy`。 |
||||
|
- 对于 macOS Framework,应遵循命名规范`FrameworkName.Version.xcprivacy`,额外增加版本名称用于区分不同的版本。对于单一版本的 macOS Framework,`Version`通常为`A`。 |
||||
|
- SDK 的名称可能与 Framework 的名称不完全一致。要确定正确的 Framework 名称,请在构建项目后检查 App 包中的`Frameworks`目录。 |
||||
|
|
||||
|
## 📑 隐私访问报告 |
||||
|
|
||||
|
默认情况下,工具会自动在每次构建时为原始 App 和修复后的 App 生成隐私访问报告,并存储到`app_privacy_manifest_fixer/Build`目录。 |
||||
|
|
||||
|
如果需要手动为特定 App 生成隐私访问报告,请运行以下命令: |
||||
|
|
||||
|
```shell |
||||
|
sh Report/report.sh <app_path> <report_output_path> |
||||
|
# <app_path>: App路径(例如:/path/to/App.app) |
||||
|
# <report_output_path>: 报告文件保存路径(例如:/path/to/report.html) |
||||
|
``` |
||||
|
|
||||
|
**注意**:工具生成的报告目前仅包含隐私访问部分(`NSPrivacyAccessedAPITypes`),如果想看数据收集部分(`NSPrivacyCollectedDataTypes`)请使用 Xcode 生成`PrivacyReport`。 |
||||
|
|
||||
|
### 报告示例截图 |
||||
|
|
||||
|
| 原始 App 报告(report-original.html) | 修复后 App 报告(report.html) | |
||||
|
|------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------| |
||||
|
|  |  | |
||||
|
|
||||
|
## 💡 重要考量 |
||||
|
|
||||
|
- 如果最新版本的 SDK 支持隐私清单,请尽可能升级,以避免不必要的风险。 |
||||
|
- 此工具仅为临时解决方案,不应替代正确的 SDK 管理实践。 |
||||
|
- 在提交 App 审核之前,请检查隐私清单修复后是否符合最新的 App Store 要求。 |
||||
|
|
||||
|
## 🙌 贡献 |
||||
|
|
||||
|
欢迎任何形式的贡献,包括代码优化、Bug 修复、文档改进等。请确保遵循项目规范,并保持代码风格一致。感谢你的支持! |
@ -0,0 +1,124 @@ |
|||||
|
<!-- |
||||
|
Copyright (c) 2024, crasowas. |
||||
|
|
||||
|
Use of this source code is governed by a MIT-style license |
||||
|
that can be found in the LICENSE file or at |
||||
|
https://opensource.org/licenses/MIT. |
||||
|
--> |
||||
|
|
||||
|
<!DOCTYPE html> |
||||
|
<html lang="en"> |
||||
|
<head> |
||||
|
<meta charset="UTF-8"> |
||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"> |
||||
|
|
||||
|
<title>Privacy Access Report</title> |
||||
|
|
||||
|
<style> |
||||
|
body { |
||||
|
font-family: Arial, sans-serif; |
||||
|
margin: 20px; |
||||
|
color: #333; |
||||
|
background-color: #f9f9f9; |
||||
|
line-height: 1.6; |
||||
|
} |
||||
|
|
||||
|
.card { |
||||
|
background-color: #fff; |
||||
|
border-radius: 10px; |
||||
|
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); |
||||
|
margin-bottom: 20px; |
||||
|
padding: 20px; |
||||
|
min-width: 735px; |
||||
|
} |
||||
|
|
||||
|
h2 { |
||||
|
font-size: 1.2em; |
||||
|
margin: 0 0 15px; |
||||
|
padding: 12px 20px; |
||||
|
color: #fff; |
||||
|
background-color: #5a9e6d; |
||||
|
border-radius: 8px; |
||||
|
display: flex; |
||||
|
justify-content: space-between; |
||||
|
align-items: center; |
||||
|
} |
||||
|
|
||||
|
h2 .version { |
||||
|
font-size: 0.7em; |
||||
|
color: #5a9e6d; |
||||
|
background: #f1f1f1; |
||||
|
padding: 2px 6px; |
||||
|
border-radius: 6px; |
||||
|
} |
||||
|
|
||||
|
a { |
||||
|
text-decoration: none; |
||||
|
color: #5a9e6d; |
||||
|
background-color: #fcfcfc; |
||||
|
padding: 8px 16px; |
||||
|
border: 1px solid #5a9e6d; |
||||
|
border-radius: 5px; |
||||
|
font-size: 0.9em; |
||||
|
margin-right: 16px; |
||||
|
transition: background-color 0.3s ease, color 0.3s ease; |
||||
|
} |
||||
|
|
||||
|
a:hover { |
||||
|
color: #fff; |
||||
|
background-color: #5a9e6d; |
||||
|
} |
||||
|
|
||||
|
a.warning { |
||||
|
color: #e0b73c; |
||||
|
background-color: #fcfcfc; |
||||
|
border: 1px solid #e0b73c; |
||||
|
} |
||||
|
|
||||
|
a.warning:hover { |
||||
|
color: #fff; |
||||
|
background-color: #e0b73c; |
||||
|
} |
||||
|
|
||||
|
table { |
||||
|
width: 100%; |
||||
|
border-collapse: collapse; |
||||
|
background-color: #fff; |
||||
|
border-radius: 8px; |
||||
|
overflow: hidden; |
||||
|
margin-top: 20px; |
||||
|
} |
||||
|
|
||||
|
th, |
||||
|
td { |
||||
|
border: 1px solid #ddd; |
||||
|
padding: 12px 20px; |
||||
|
text-align: left; |
||||
|
} |
||||
|
|
||||
|
th { |
||||
|
color: #fff; |
||||
|
background-color: #b0b8b1; |
||||
|
font-weight: bold; |
||||
|
} |
||||
|
|
||||
|
tbody tr:nth-child(odd) { |
||||
|
background-color: #f9f9f9; |
||||
|
} |
||||
|
|
||||
|
tbody tr:hover { |
||||
|
background-color: #f0f0f0; |
||||
|
} |
||||
|
</style> |
||||
|
</head> |
||||
|
<body> |
||||
|
<div class="card" style="display: flex; justify-content: space-between; align-items: center;"> |
||||
|
<span> |
||||
|
This report was generated using version <strong>{{TOOL_VERSION}}</strong>. |
||||
|
</span> |
||||
|
<a href="https://github.com/crasowas/app_privacy_manifest_fixer" target="_blank">Like this |
||||
|
project? 🌟Star it on GitHub!</a> |
||||
|
</div> |
||||
|
{{REPORT_CONTENT}} |
||||
|
</body> |
||||
|
</html> |
@ -0,0 +1,285 @@ |
|||||
|
#!/bin/bash |
||||
|
|
||||
|
# Copyright (c) 2024, crasowas. |
||||
|
# |
||||
|
# Use of this source code is governed by a MIT-style license |
||||
|
# that can be found in the LICENSE file or at |
||||
|
# https://opensource.org/licenses/MIT. |
||||
|
|
||||
|
set -e |
||||
|
|
||||
|
# Absolute path of the script and the tool's root directory |
||||
|
script_path="$(realpath "$0")" |
||||
|
tool_root_path="$(dirname "$(dirname "$script_path")")" |
||||
|
|
||||
|
# Load common constants and utils |
||||
|
source "$tool_root_path/Common/constants.sh" |
||||
|
source "$tool_root_path/Common/utils.sh" |
||||
|
|
||||
|
# Path to the app |
||||
|
app_path="$1" |
||||
|
|
||||
|
# Check if the app exists |
||||
|
if [ ! -d "$app_path" ] || [[ "$app_path" != *.app ]]; then |
||||
|
echo "Unable to find the app: $app_path" |
||||
|
exit 1 |
||||
|
fi |
||||
|
|
||||
|
# Check if the app is iOS or macOS |
||||
|
is_ios_app=true |
||||
|
frameworks_dir="$app_path/Frameworks" |
||||
|
if [ -d "$app_path/Contents/MacOS" ]; then |
||||
|
is_ios_app=false |
||||
|
frameworks_dir="$app_path/Contents/Frameworks" |
||||
|
fi |
||||
|
|
||||
|
report_output_file="$2" |
||||
|
# Additional arguments as template usage records |
||||
|
template_usage_records=("${@:2}") |
||||
|
|
||||
|
# Copy report template to output file |
||||
|
report_template_file="$tool_root_path/Report/report-template.html" |
||||
|
if ! rsync -a "$report_template_file" "$report_output_file"; then |
||||
|
echo "Failed to copy the report template to $report_output_file" |
||||
|
exit 1 |
||||
|
fi |
||||
|
|
||||
|
# Read the current tool's version from the VERSION file |
||||
|
tool_version_file="$tool_root_path/VERSION" |
||||
|
tool_version="N/A" |
||||
|
if [ -f "$tool_version_file" ]; then |
||||
|
tool_version="$(cat "$tool_version_file")" |
||||
|
fi |
||||
|
|
||||
|
# Initialize report content |
||||
|
report_content="" |
||||
|
|
||||
|
# Get the template file used for fixing based on the app or framework name |
||||
|
function get_used_template_file() { |
||||
|
local name="$1" |
||||
|
|
||||
|
for template_usage_record in "${template_usage_records[@]}"; do |
||||
|
if [[ "$template_usage_record" == "$name$DELIMITER"* ]]; then |
||||
|
echo "${template_usage_record#*$DELIMITER}" |
||||
|
return |
||||
|
fi |
||||
|
done |
||||
|
|
||||
|
echo "" |
||||
|
} |
||||
|
|
||||
|
# Analyze accessed API types and their corresponding reasons |
||||
|
function analyze_privacy_accessed_api() { |
||||
|
local privacy_manifest_file="$1" |
||||
|
local -a results=() |
||||
|
|
||||
|
if [ -f "$privacy_manifest_file" ]; then |
||||
|
local api_count=$(xmllint --xpath 'count(//dict/key[text()="NSPrivacyAccessedAPIType"])' "$privacy_manifest_file") |
||||
|
|
||||
|
for ((i=1; i<=api_count; i++)); do |
||||
|
local api_type=$(xmllint --xpath "(//dict/key[text()='NSPrivacyAccessedAPIType']/following-sibling::string[1])[$i]/text()" "$privacy_manifest_file" 2>/dev/null) |
||||
|
local api_reasons=$(xmllint --xpath "(//dict/key[text()='NSPrivacyAccessedAPITypeReasons']/following-sibling::array[1])[position()=$i]/string/text()" "$privacy_manifest_file" 2>/dev/null | paste -sd "/" -) |
||||
|
|
||||
|
if [ -z "$api_type" ]; then |
||||
|
api_type="N/A" |
||||
|
fi |
||||
|
|
||||
|
if [ -z "$api_reasons" ]; then |
||||
|
api_reasons="N/A" |
||||
|
fi |
||||
|
|
||||
|
results+=("$api_type$DELIMITER$api_reasons") |
||||
|
done |
||||
|
fi |
||||
|
|
||||
|
echo "${results[@]}" |
||||
|
} |
||||
|
|
||||
|
# Get the path to the `Info.plist` file for the specified app or framework |
||||
|
function get_plist_file() { |
||||
|
local path="$1" |
||||
|
local version_path="$2" |
||||
|
local plist_file="" |
||||
|
|
||||
|
if [[ "$path" == *.app ]]; then |
||||
|
if [ "$is_ios_app" == true ]; then |
||||
|
plist_file="$path/Info.plist" |
||||
|
else |
||||
|
plist_file="$path/Contents/Info.plist" |
||||
|
fi |
||||
|
elif [[ "$path" == *.framework ]]; then |
||||
|
local framework_path="$(get_framework_path "$path" "$version_path")" |
||||
|
|
||||
|
if [ "$is_ios_app" == true ]; then |
||||
|
plist_file="$framework_path/Info.plist" |
||||
|
else |
||||
|
plist_file="$framework_path/Resources/Info.plist" |
||||
|
fi |
||||
|
fi |
||||
|
|
||||
|
echo "$plist_file" |
||||
|
} |
||||
|
|
||||
|
# Add an HTML <div> element with the `card` class |
||||
|
function add_html_card_container() { |
||||
|
local card="$1" |
||||
|
|
||||
|
report_content="$report_content<div class=\"card\">$card</div>" |
||||
|
} |
||||
|
|
||||
|
# Generate an HTML <h2> element |
||||
|
function generate_html_header() { |
||||
|
local title="$1" |
||||
|
local version="$2" |
||||
|
|
||||
|
echo "<h2>$title<span class=\"version\">Version $version</span></h2>" |
||||
|
} |
||||
|
|
||||
|
# Generate an HTML <a> element with optional `warning` class |
||||
|
function generate_html_anchor() { |
||||
|
local text="$1" |
||||
|
local href="$2" |
||||
|
local warning="$3" |
||||
|
|
||||
|
if [ "$warning" == true ]; then |
||||
|
echo "<a class=\"warning\" href=\"$href\">$text</a>" |
||||
|
else |
||||
|
echo "<a href=\"$href\">$text</a>" |
||||
|
fi |
||||
|
} |
||||
|
|
||||
|
# Generate an HTML <table> element |
||||
|
function generate_html_table() { |
||||
|
local thead="$1" |
||||
|
local tbody="$2" |
||||
|
|
||||
|
echo "<table>$thead$tbody</table>" |
||||
|
} |
||||
|
|
||||
|
# Generate an HTML <thead> element |
||||
|
function generate_html_thead() { |
||||
|
local ths=("$@") |
||||
|
local tr="" |
||||
|
|
||||
|
for th in "${ths[@]}"; do |
||||
|
tr="$tr<th>$th</th>" |
||||
|
done |
||||
|
|
||||
|
echo "<thead><tr>$tr</tr></thead>" |
||||
|
} |
||||
|
|
||||
|
# Generate an HTML <tbody> element |
||||
|
function generate_html_tbody() { |
||||
|
local trs=("$@") |
||||
|
local tbody="" |
||||
|
|
||||
|
for tr in "${trs[@]}"; do |
||||
|
tbody="$tbody<tr>" |
||||
|
local tds=($(split_string_by_delimiter "$tr")) |
||||
|
|
||||
|
for td in "${tds[@]}"; do |
||||
|
tbody="$tbody<td>$td</td>" |
||||
|
done |
||||
|
|
||||
|
tbody="$tbody</tr>" |
||||
|
done |
||||
|
|
||||
|
echo "<tbody>$tbody</tbody>" |
||||
|
} |
||||
|
|
||||
|
# Generate the report content for the specified directory |
||||
|
function generate_report_content() { |
||||
|
local path="$1" |
||||
|
local version_path="$2" |
||||
|
local privacy_manifest_file="" |
||||
|
|
||||
|
if [[ "$path" == *.app ]]; then |
||||
|
# Per the documentation, the privacy manifest should be placed at the root of the app’s bundle for iOS, while for macOS, it should be located in `Contents/Resources/` within the app’s bundle |
||||
|
# Reference: https://developer.apple.com/documentation/bundleresources/adding-a-privacy-manifest-to-your-app-or-third-party-sdk#Add-a-privacy-manifest-to-your-app |
||||
|
if [ "$is_ios_app" == true ]; then |
||||
|
privacy_manifest_file="$path/$PRIVACY_MANIFEST_FILE_NAME" |
||||
|
else |
||||
|
privacy_manifest_file="$path/Contents/Resources/$PRIVACY_MANIFEST_FILE_NAME" |
||||
|
fi |
||||
|
else |
||||
|
# Per the documentation, the privacy manifest should be placed at the root of the iOS framework, while for a macOS framework with multiple versions, it should be located in the `Resources` directory within the corresponding version |
||||
|
# Some SDKs don’t follow the guideline, so we use a search-based approach for now |
||||
|
# Reference: https://developer.apple.com/documentation/bundleresources/adding-a-privacy-manifest-to-your-app-or-third-party-sdk#Add-a-privacy-manifest-to-your-framework |
||||
|
local framework_path="$(get_framework_path "$path" "$version_path")" |
||||
|
local privacy_manifest_files=($(search_privacy_manifest_files "$framework_path")) |
||||
|
privacy_manifest_file="$(get_privacy_manifest_file "${privacy_manifest_files[@]}")" |
||||
|
fi |
||||
|
|
||||
|
local name="$(basename "$path")" |
||||
|
local title="$name" |
||||
|
if [ -n "$version_path" ]; then |
||||
|
title="$name ($version_path)" |
||||
|
fi |
||||
|
|
||||
|
local plist_file="$(get_plist_file "$path" "$version_path")" |
||||
|
local version="$(get_plist_version "$plist_file")" |
||||
|
local card="$(generate_html_header "$title" "$version")" |
||||
|
|
||||
|
if [ -f "$privacy_manifest_file" ]; then |
||||
|
card="$card$(generate_html_anchor "$PRIVACY_MANIFEST_FILE_NAME" "$privacy_manifest_file" false)" |
||||
|
|
||||
|
local used_template_file="$(get_used_template_file "$name$version_path")" |
||||
|
|
||||
|
if [ -f "$used_template_file" ]; then |
||||
|
card="$card$(generate_html_anchor "Template Used: $(basename "$used_template_file")" "$used_template_file" false)" |
||||
|
fi |
||||
|
|
||||
|
local trs=($(analyze_privacy_accessed_api "$privacy_manifest_file")) |
||||
|
|
||||
|
# Generate table only if the accessed privacy API types array is not empty |
||||
|
if [[ ${#trs[@]} -gt 0 ]]; then |
||||
|
local thead="$(generate_html_thead "NSPrivacyAccessedAPIType" "NSPrivacyAccessedAPITypeReasons")" |
||||
|
local tbody="$(generate_html_tbody "${trs[@]}")" |
||||
|
|
||||
|
card="$card$(generate_html_table "$thead" "$tbody")" |
||||
|
fi |
||||
|
else |
||||
|
card="$card$(generate_html_anchor "Missing Privacy Manifest" "$path" true)" |
||||
|
fi |
||||
|
|
||||
|
add_html_card_container "$card" |
||||
|
} |
||||
|
|
||||
|
# Generate the report content for app |
||||
|
function generate_app_report_content() { |
||||
|
generate_report_content "$app_path" "" |
||||
|
} |
||||
|
|
||||
|
# Generate the report content for frameworks |
||||
|
function generate_frameworks_report_content() { |
||||
|
if ! [ -d "$frameworks_dir" ]; then |
||||
|
return |
||||
|
fi |
||||
|
|
||||
|
for path in "$frameworks_dir"/*; do |
||||
|
if [ -d "$path" ]; then |
||||
|
local versions_dir="$path/Versions" |
||||
|
|
||||
|
if [ -d "$versions_dir" ]; then |
||||
|
for version in $(ls -1 "$versions_dir" | grep -vE '^Current$'); do |
||||
|
local version_path="Versions/$version" |
||||
|
generate_report_content "$path" "$version_path" |
||||
|
done |
||||
|
else |
||||
|
generate_report_content "$path" "" |
||||
|
fi |
||||
|
fi |
||||
|
done |
||||
|
} |
||||
|
|
||||
|
# Generate the final report with all content |
||||
|
function generate_final_report() { |
||||
|
# Replace placeholders in the template with the tool's version and report content |
||||
|
sed -i "" -e "s|{{TOOL_VERSION}}|$tool_version|g" -e "s|{{REPORT_CONTENT}}|${report_content}|g" "$report_output_file" |
||||
|
|
||||
|
echo "Privacy Access Report has been generated: $report_output_file" |
||||
|
} |
||||
|
|
||||
|
generate_app_report_content |
||||
|
generate_frameworks_report_content |
||||
|
generate_final_report |
@ -0,0 +1,55 @@ |
|||||
|
<?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>NSPrivacyTracking</key> |
||||
|
<false/> |
||||
|
<key>NSPrivacyTrackingDomains</key> |
||||
|
<array/> |
||||
|
<key>NSPrivacyCollectedDataTypes</key> |
||||
|
<array/> |
||||
|
<key>NSPrivacyAccessedAPITypes</key> |
||||
|
<array> |
||||
|
<dict> |
||||
|
<key>NSPrivacyAccessedAPIType</key> |
||||
|
<string>NSPrivacyAccessedAPICategoryFileTimestamp</string> |
||||
|
<key>NSPrivacyAccessedAPITypeReasons</key> |
||||
|
<array> |
||||
|
<string>C617.1</string> |
||||
|
</array> |
||||
|
</dict> |
||||
|
<dict> |
||||
|
<key>NSPrivacyAccessedAPIType</key> |
||||
|
<string>NSPrivacyAccessedAPICategorySystemBootTime</string> |
||||
|
<key>NSPrivacyAccessedAPITypeReasons</key> |
||||
|
<array> |
||||
|
<string>35F9.1</string> |
||||
|
</array> |
||||
|
</dict> |
||||
|
<dict> |
||||
|
<key>NSPrivacyAccessedAPIType</key> |
||||
|
<string>NSPrivacyAccessedAPICategoryDiskSpace</string> |
||||
|
<key>NSPrivacyAccessedAPITypeReasons</key> |
||||
|
<array> |
||||
|
<string>E174.1</string> |
||||
|
</array> |
||||
|
</dict> |
||||
|
<dict> |
||||
|
<key>NSPrivacyAccessedAPIType</key> |
||||
|
<string>NSPrivacyAccessedAPICategoryActiveKeyboards</string> |
||||
|
<key>NSPrivacyAccessedAPITypeReasons</key> |
||||
|
<array> |
||||
|
<string>54BD.1</string> |
||||
|
</array> |
||||
|
</dict> |
||||
|
<dict> |
||||
|
<key>NSPrivacyAccessedAPIType</key> |
||||
|
<string>NSPrivacyAccessedAPICategoryUserDefaults</string> |
||||
|
<key>NSPrivacyAccessedAPITypeReasons</key> |
||||
|
<array> |
||||
|
<string>CA92.1</string> |
||||
|
</array> |
||||
|
</dict> |
||||
|
</array> |
||||
|
</dict> |
||||
|
</plist> |
@ -0,0 +1,55 @@ |
|||||
|
<?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>NSPrivacyTracking</key> |
||||
|
<false/> |
||||
|
<key>NSPrivacyTrackingDomains</key> |
||||
|
<array/> |
||||
|
<key>NSPrivacyCollectedDataTypes</key> |
||||
|
<array/> |
||||
|
<key>NSPrivacyAccessedAPITypes</key> |
||||
|
<array> |
||||
|
<dict> |
||||
|
<key>NSPrivacyAccessedAPIType</key> |
||||
|
<string>NSPrivacyAccessedAPICategoryFileTimestamp</string> |
||||
|
<key>NSPrivacyAccessedAPITypeReasons</key> |
||||
|
<array> |
||||
|
<string>0A2A.1</string> |
||||
|
</array> |
||||
|
</dict> |
||||
|
<dict> |
||||
|
<key>NSPrivacyAccessedAPIType</key> |
||||
|
<string>NSPrivacyAccessedAPICategorySystemBootTime</string> |
||||
|
<key>NSPrivacyAccessedAPITypeReasons</key> |
||||
|
<array> |
||||
|
<string>35F9.1</string> |
||||
|
</array> |
||||
|
</dict> |
||||
|
<dict> |
||||
|
<key>NSPrivacyAccessedAPIType</key> |
||||
|
<string>NSPrivacyAccessedAPICategoryDiskSpace</string> |
||||
|
<key>NSPrivacyAccessedAPITypeReasons</key> |
||||
|
<array> |
||||
|
<string>E174.1</string> |
||||
|
</array> |
||||
|
</dict> |
||||
|
<dict> |
||||
|
<key>NSPrivacyAccessedAPIType</key> |
||||
|
<string>NSPrivacyAccessedAPICategoryActiveKeyboards</string> |
||||
|
<key>NSPrivacyAccessedAPITypeReasons</key> |
||||
|
<array> |
||||
|
<string>54BD.1</string> |
||||
|
</array> |
||||
|
</dict> |
||||
|
<dict> |
||||
|
<key>NSPrivacyAccessedAPIType</key> |
||||
|
<string>NSPrivacyAccessedAPICategoryUserDefaults</string> |
||||
|
<key>NSPrivacyAccessedAPITypeReasons</key> |
||||
|
<array> |
||||
|
<string>C56D.1</string> |
||||
|
</array> |
||||
|
</dict> |
||||
|
</array> |
||||
|
</dict> |
||||
|
</plist> |
@ -0,0 +1 @@ |
|||||
|
v1.4.1 |
@ -0,0 +1,29 @@ |
|||||
|
#!/bin/bash |
||||
|
|
||||
|
# Copyright (c) 2025, crasowas. |
||||
|
# |
||||
|
# Use of this source code is governed by a MIT-style license |
||||
|
# that can be found in the LICENSE file or at |
||||
|
# https://opensource.org/licenses/MIT. |
||||
|
|
||||
|
set -e |
||||
|
|
||||
|
target_paths=("Build") |
||||
|
|
||||
|
echo "Cleaning..." |
||||
|
|
||||
|
deleted_anything=false |
||||
|
|
||||
|
for path in "${target_paths[@]}"; do |
||||
|
if [ -e "$path" ]; then |
||||
|
echo "Removing $path..." |
||||
|
rm -rf "./$path" |
||||
|
deleted_anything=true |
||||
|
fi |
||||
|
done |
||||
|
|
||||
|
if [ "$deleted_anything" == true ]; then |
||||
|
echo "Cleanup completed." |
||||
|
else |
||||
|
echo "Nothing to clean." |
||||
|
fi |
@ -0,0 +1,490 @@ |
|||||
|
#!/bin/bash |
||||
|
|
||||
|
# Copyright (c) 2024, crasowas. |
||||
|
# |
||||
|
# Use of this source code is governed by a MIT-style license |
||||
|
# that can be found in the LICENSE file or at |
||||
|
# https://opensource.org/licenses/MIT. |
||||
|
|
||||
|
set -e |
||||
|
|
||||
|
# Absolute path of the script and the tool's root directory |
||||
|
script_path="$(realpath "$0")" |
||||
|
tool_root_path="$(dirname "$script_path")" |
||||
|
|
||||
|
# Load common constants and utils |
||||
|
source "$tool_root_path/Common/constants.sh" |
||||
|
source "$tool_root_path/Common/utils.sh" |
||||
|
|
||||
|
# Force replace the existing privacy manifest when the `-f` option is enabled |
||||
|
force=false |
||||
|
|
||||
|
# Enable silent mode to disable build output when the `-s` option is enabled |
||||
|
silent=false |
||||
|
|
||||
|
# Parse command-line options |
||||
|
while getopts ":fs" opt; do |
||||
|
case $opt in |
||||
|
f) |
||||
|
force=true |
||||
|
;; |
||||
|
s) |
||||
|
silent=true |
||||
|
;; |
||||
|
\?) |
||||
|
echo "Invalid option: -$OPTARG" >&2 |
||||
|
exit 1 |
||||
|
;; |
||||
|
esac |
||||
|
done |
||||
|
|
||||
|
shift $((OPTIND - 1)) |
||||
|
|
||||
|
# Path of the app produced by the build process |
||||
|
app_path="${TARGET_BUILD_DIR}/${WRAPPER_NAME}" |
||||
|
|
||||
|
# Check if the app exists |
||||
|
if [ ! -d "$app_path" ] || [[ "$app_path" != *.app ]]; then |
||||
|
echo "Unable to find the app: $app_path" |
||||
|
exit 1 |
||||
|
fi |
||||
|
|
||||
|
# Check if the app is iOS or macOS |
||||
|
is_ios_app=true |
||||
|
frameworks_dir="$app_path/Frameworks" |
||||
|
if [ -d "$app_path/Contents/MacOS" ]; then |
||||
|
is_ios_app=false |
||||
|
frameworks_dir="$app_path/Contents/Frameworks" |
||||
|
fi |
||||
|
|
||||
|
# Default template directories |
||||
|
templates_dir="$tool_root_path/Templates" |
||||
|
user_templates_dir="$tool_root_path/Templates/UserTemplates" |
||||
|
|
||||
|
# Use user-defined app privacy manifest template if it exists, otherwise fallback to default |
||||
|
app_template_file="$user_templates_dir/$APP_TEMPLATE_FILE_NAME" |
||||
|
if [ ! -f "$app_template_file" ]; then |
||||
|
app_template_file="$templates_dir/$APP_TEMPLATE_FILE_NAME" |
||||
|
fi |
||||
|
|
||||
|
# Use user-defined framework privacy manifest template if it exists, otherwise fallback to default |
||||
|
framework_template_file="$user_templates_dir/$FRAMEWORK_TEMPLATE_FILE_NAME" |
||||
|
if [ ! -f "$framework_template_file" ]; then |
||||
|
framework_template_file="$templates_dir/$FRAMEWORK_TEMPLATE_FILE_NAME" |
||||
|
fi |
||||
|
|
||||
|
# Disable build output in silent mode |
||||
|
if [ "$silent" == false ]; then |
||||
|
# Script used to generate the privacy access report |
||||
|
report_script="$tool_root_path/Report/report.sh" |
||||
|
# An array to record template usage for generating the privacy access report |
||||
|
template_usage_records=() |
||||
|
|
||||
|
# Build output directory |
||||
|
build_dir="$tool_root_path/Build/${PRODUCT_NAME}-${CONFIGURATION}_${MARKETING_VERSION}_${CURRENT_PROJECT_VERSION}_$(date +%Y%m%d%H%M%S)" |
||||
|
# Ensure the build directory exists |
||||
|
mkdir -p "$build_dir" |
||||
|
|
||||
|
# Redirect both stdout and stderr to a log file while keeping console output |
||||
|
exec > >(tee "$build_dir/fix.log") 2>&1 |
||||
|
fi |
||||
|
|
||||
|
# Get the path to the `Info.plist` file for the specified app or framework |
||||
|
function get_plist_file() { |
||||
|
local path="$1" |
||||
|
local version_path="$2" |
||||
|
local plist_file="" |
||||
|
|
||||
|
if [[ "$path" == *.app ]]; then |
||||
|
if [ "$is_ios_app" == true ]; then |
||||
|
plist_file="$path/Info.plist" |
||||
|
else |
||||
|
plist_file="$path/Contents/Info.plist" |
||||
|
fi |
||||
|
elif [[ "$path" == *.framework ]]; then |
||||
|
local framework_path="$(get_framework_path "$path" "$version_path")" |
||||
|
|
||||
|
if [ "$is_ios_app" == true ]; then |
||||
|
plist_file="$framework_path/Info.plist" |
||||
|
else |
||||
|
plist_file="$framework_path/Resources/Info.plist" |
||||
|
fi |
||||
|
fi |
||||
|
|
||||
|
echo "$plist_file" |
||||
|
} |
||||
|
|
||||
|
# Get the path to the executable for the specified app or framework |
||||
|
function get_executable_path() { |
||||
|
local path="$1" |
||||
|
local version_path="$2" |
||||
|
local executable_path="" |
||||
|
|
||||
|
local plist_file="$(get_plist_file "$path" "$version_path")" |
||||
|
local executable_name="$(get_plist_executable "$plist_file")" |
||||
|
|
||||
|
if [[ "$path" == *.app ]]; then |
||||
|
if [ "$is_ios_app" == true ]; then |
||||
|
executable_path="$path/$executable_name" |
||||
|
else |
||||
|
executable_path="$path/Contents/MacOS/$executable_name" |
||||
|
fi |
||||
|
elif [[ "$path" == *.framework ]]; then |
||||
|
local framework_path="$(get_framework_path "$path" "$version_path")" |
||||
|
executable_path="$framework_path/$executable_name" |
||||
|
fi |
||||
|
|
||||
|
echo "$executable_path" |
||||
|
} |
||||
|
|
||||
|
# Analyze the specified binary file for API symbols and their categories |
||||
|
function analyze_binary_file() { |
||||
|
local path="$1" |
||||
|
local -a results=() |
||||
|
|
||||
|
# Check if the API symbol exists in the binary file using `nm` and `strings` |
||||
|
local nm_output=$(nm "$path" 2>/dev/null | xcrun swift-demangle) |
||||
|
local strings_output=$(strings "$path") |
||||
|
local combined_output="$nm_output"$'\n'"$strings_output" |
||||
|
|
||||
|
for api_symbol in "${API_SYMBOLS[@]}"; do |
||||
|
local substrings=($(split_string_by_delimiter "$api_symbol")) |
||||
|
local category=${substrings[0]} |
||||
|
local api=${substrings[1]} |
||||
|
|
||||
|
if echo "$combined_output" | grep -E "$api\$" >/dev/null; then |
||||
|
local index=-1 |
||||
|
for ((i=0; i < ${#results[@]}; i++)); do |
||||
|
local result="${results[i]}" |
||||
|
local result_substrings=($(split_string_by_delimiter "$result")) |
||||
|
# If the category matches an existing result, update it |
||||
|
if [ "$category" == "${result_substrings[0]}" ]; then |
||||
|
index=i |
||||
|
results[i]="${result_substrings[0]}$DELIMITER${result_substrings[1]},$api$DELIMITER${result_substrings[2]}" |
||||
|
break |
||||
|
fi |
||||
|
done |
||||
|
|
||||
|
# If no matching category found, add a new result |
||||
|
if [[ $index -eq -1 ]]; then |
||||
|
results+=("$category$DELIMITER$api$DELIMITER$(encode_path "$path")") |
||||
|
fi |
||||
|
fi |
||||
|
done |
||||
|
|
||||
|
echo "${results[@]}" |
||||
|
} |
||||
|
|
||||
|
# Analyze API usage in a binary file |
||||
|
function analyze_api_usage() { |
||||
|
local path="$1" |
||||
|
local version_path="$2" |
||||
|
local -a results=() |
||||
|
|
||||
|
local binary_file="$(get_executable_path "$path" "$version_path")" |
||||
|
|
||||
|
if [ -f "$binary_file" ]; then |
||||
|
results+=($(analyze_binary_file "$binary_file")) |
||||
|
fi |
||||
|
|
||||
|
echo "${results[@]}" |
||||
|
} |
||||
|
|
||||
|
|
||||
|
|
||||
|
# Get unique categories from analysis results |
||||
|
function get_categories() { |
||||
|
local results=("$@") |
||||
|
local -a categories=() |
||||
|
|
||||
|
for result in "${results[@]}"; do |
||||
|
local substrings=($(split_string_by_delimiter "$result")) |
||||
|
local category=${substrings[0]} |
||||
|
if [[ ! "${categories[@]}" =~ "$category" ]]; then |
||||
|
categories+=("$category") |
||||
|
fi |
||||
|
done |
||||
|
|
||||
|
echo "${categories[@]}" |
||||
|
} |
||||
|
|
||||
|
# Get template file for the specified app or framework |
||||
|
function get_template_file() { |
||||
|
local path="$1" |
||||
|
local version_path="$2" |
||||
|
local template_file="" |
||||
|
|
||||
|
if [[ "$path" == *.app ]]; then |
||||
|
template_file="$app_template_file" |
||||
|
else |
||||
|
# Give priority to the user-defined framework privacy manifest template |
||||
|
local dep_name="$(get_dependency_name "$path")" |
||||
|
if [ -n "$version_path" ]; then |
||||
|
dep_name="$dep_name.$(basename "$version_path")" |
||||
|
fi |
||||
|
|
||||
|
local dep_template_file="$user_templates_dir/${dep_name}.xcprivacy" |
||||
|
if [ -f "$dep_template_file" ]; then |
||||
|
template_file="$dep_template_file" |
||||
|
else |
||||
|
template_file="$framework_template_file" |
||||
|
fi |
||||
|
fi |
||||
|
|
||||
|
echo "$template_file" |
||||
|
} |
||||
|
|
||||
|
# Check if the specified template file should be modified |
||||
|
# |
||||
|
# The following template files will be modified based on analysis: |
||||
|
# * Templates/AppTemplate.xcprivacy |
||||
|
# * Templates/FrameworkTemplate.xcprivacy |
||||
|
# * Templates/UserTemplates/FrameworkTemplate.xcprivacy |
||||
|
function is_template_modifiable() { |
||||
|
local template_file="$1" |
||||
|
|
||||
|
local template_file_name="$(basename "$template_file")" |
||||
|
|
||||
|
if [[ "$template_file" != "$user_templates_dir"* ]] || [ "$template_file_name" == "$FRAMEWORK_TEMPLATE_FILE_NAME" ]; then |
||||
|
return 0 |
||||
|
else |
||||
|
return 1 |
||||
|
fi |
||||
|
} |
||||
|
|
||||
|
# Check if `Hardened Runtime` is enabled for the specified path |
||||
|
function is_hardened_runtime_enabled() { |
||||
|
local path="$1" |
||||
|
|
||||
|
# Check environment variable first |
||||
|
if [ "${ENABLE_HARDENED_RUNTIME:-}" == "YES" ]; then |
||||
|
return 0 |
||||
|
fi |
||||
|
|
||||
|
# Check the code signature flags if environment variable is not set |
||||
|
local flags=$(codesign -dvvv "$path" 2>&1 | grep "flags=") |
||||
|
if echo "$flags" | grep -q "runtime"; then |
||||
|
return 0 |
||||
|
else |
||||
|
return 1 |
||||
|
fi |
||||
|
} |
||||
|
|
||||
|
# Re-sign the target app or framework if code signing is enabled |
||||
|
function resign() { |
||||
|
local path="$1" |
||||
|
|
||||
|
if [ -n "${EXPANDED_CODE_SIGN_IDENTITY:-}" ] && [ "${CODE_SIGNING_REQUIRED:-}" != "NO" ] && [ "${CODE_SIGNING_ALLOWED:-}" != "NO" ]; then |
||||
|
echo "Re-signing $path with Identity ${EXPANDED_CODE_SIGN_IDENTITY_NAME:-$EXPANDED_CODE_SIGN_IDENTITY}" |
||||
|
|
||||
|
local codesign_cmd="/usr/bin/codesign --force --sign ${EXPANDED_CODE_SIGN_IDENTITY} ${OTHER_CODE_SIGN_FLAGS:-} --preserve-metadata=identifier,entitlements" |
||||
|
|
||||
|
if [ "$is_ios_app" == true ]; then |
||||
|
$codesign_cmd "$path" |
||||
|
else |
||||
|
if is_hardened_runtime_enabled "$path"; then |
||||
|
codesign_cmd="$codesign_cmd -o runtime" |
||||
|
fi |
||||
|
|
||||
|
if [ -d "$path/Contents/MacOS" ]; then |
||||
|
find "$path/Contents/MacOS" -type f -name "*.dylib" | while read -r dylib_file; do |
||||
|
$codesign_cmd "$dylib_file" |
||||
|
done |
||||
|
fi |
||||
|
|
||||
|
$codesign_cmd "$path" |
||||
|
fi |
||||
|
fi |
||||
|
} |
||||
|
|
||||
|
# Fix the privacy manifest for the app or specified framework |
||||
|
# To accelerate the build, existing privacy manifests will be left unchanged unless the `-f` option is enabled |
||||
|
# After fixing, the app or framework will be automatically re-signed |
||||
|
function fix() { |
||||
|
local path="$1" |
||||
|
local version_path="$2" |
||||
|
local force_resign="$3" |
||||
|
local privacy_manifest_file="" |
||||
|
|
||||
|
if [[ "$path" == *.app ]]; then |
||||
|
# Per the documentation, the privacy manifest should be placed at the root of the app’s bundle for iOS, while for macOS, it should be located in `Contents/Resources/` within the app’s bundle |
||||
|
# Reference: https://developer.apple.com/documentation/bundleresources/adding-a-privacy-manifest-to-your-app-or-third-party-sdk#Add-a-privacy-manifest-to-your-app |
||||
|
if [ "$is_ios_app" == true ]; then |
||||
|
privacy_manifest_file="$path/$PRIVACY_MANIFEST_FILE_NAME" |
||||
|
else |
||||
|
privacy_manifest_file="$path/Contents/Resources/$PRIVACY_MANIFEST_FILE_NAME" |
||||
|
fi |
||||
|
else |
||||
|
# Per the documentation, the privacy manifest should be placed at the root of the iOS framework, while for a macOS framework with multiple versions, it should be located in the `Resources` directory within the corresponding version |
||||
|
# Some SDKs don’t follow the guideline, so we use a search-based approach for now |
||||
|
# Reference: https://developer.apple.com/documentation/bundleresources/adding-a-privacy-manifest-to-your-app-or-third-party-sdk#Add-a-privacy-manifest-to-your-framework |
||||
|
local framework_path="$(get_framework_path "$path" "$version_path")" |
||||
|
local privacy_manifest_files=($(search_privacy_manifest_files "$framework_path")) |
||||
|
privacy_manifest_file="$(get_privacy_manifest_file "${privacy_manifest_files[@]}")" |
||||
|
|
||||
|
if [ -z "$privacy_manifest_file" ]; then |
||||
|
if [ "$is_ios_app" == true ]; then |
||||
|
privacy_manifest_file="$framework_path/$PRIVACY_MANIFEST_FILE_NAME" |
||||
|
else |
||||
|
privacy_manifest_file="$framework_path/Resources/$PRIVACY_MANIFEST_FILE_NAME" |
||||
|
fi |
||||
|
fi |
||||
|
fi |
||||
|
|
||||
|
# Check if the privacy manifest file exists |
||||
|
if [ -f "$privacy_manifest_file" ]; then |
||||
|
echo "💡 Found privacy manifest file: $privacy_manifest_file" |
||||
|
|
||||
|
if [ "$force" == false ]; then |
||||
|
if [ "$force_resign" == true ]; then |
||||
|
resign "$path" |
||||
|
fi |
||||
|
echo "✅ Privacy manifest file already exists, skipping fix." |
||||
|
return |
||||
|
fi |
||||
|
else |
||||
|
echo "⚠️ Missing privacy manifest file!" |
||||
|
fi |
||||
|
|
||||
|
local results=($(analyze_api_usage "$path" "$version_path")) |
||||
|
echo "API usage analysis result(s): ${#results[@]}" |
||||
|
print_array "${results[@]}" |
||||
|
|
||||
|
local template_file="$(get_template_file "$path" "$version_path")" |
||||
|
template_usage_records+=("$(basename "$path")$version_path$DELIMITER$template_file") |
||||
|
|
||||
|
# Copy the template file to the privacy manifest location, overwriting if it exists |
||||
|
cp "$template_file" "$privacy_manifest_file" |
||||
|
|
||||
|
if is_template_modifiable "$template_file"; then |
||||
|
local categories=($(get_categories "${results[@]}")) |
||||
|
local remove_categories=() |
||||
|
|
||||
|
# Check if categories is non-empty |
||||
|
if [[ ${#categories[@]} -gt 0 ]]; then |
||||
|
# Convert categories to a single space-separated string for easy matching |
||||
|
local categories_set=" ${categories[*]} " |
||||
|
|
||||
|
# Iterate through each element in `API_CATEGORIES` |
||||
|
for element in "${API_CATEGORIES[@]}"; do |
||||
|
# If element is not found in `categories_set`, add it to `remove_categories` |
||||
|
if [[ ! $categories_set =~ " $element " ]]; then |
||||
|
remove_categories+=("$element") |
||||
|
fi |
||||
|
done |
||||
|
else |
||||
|
# If categories is empty, add all of `API_CATEGORIES` to `remove_categories` |
||||
|
remove_categories=("${API_CATEGORIES[@]}") |
||||
|
fi |
||||
|
|
||||
|
# Remove extra spaces in the XML file to simplify node removal |
||||
|
xmllint --noblanks "$privacy_manifest_file" -o "$privacy_manifest_file" |
||||
|
|
||||
|
# Build a sed command to remove all matching nodes at once |
||||
|
local sed_pattern="" |
||||
|
for category in "${remove_categories[@]}"; do |
||||
|
# Find the node for the current category |
||||
|
local remove_node="$(xmllint --xpath "//dict[string='$category']" "$privacy_manifest_file" 2>/dev/null || true)" |
||||
|
|
||||
|
# If the node is found, escape special characters and append it to the sed pattern |
||||
|
if [ -n "$remove_node" ]; then |
||||
|
local escaped_node=$(echo "$remove_node" | sed 's/[\/&]/\\&/g') |
||||
|
sed_pattern+="s/$escaped_node//g;" |
||||
|
fi |
||||
|
done |
||||
|
|
||||
|
# Apply the combined sed pattern to the file if it's not empty |
||||
|
if [ -n "$sed_pattern" ]; then |
||||
|
sed -i "" "$sed_pattern" "$privacy_manifest_file" |
||||
|
fi |
||||
|
|
||||
|
# Reformat the XML file to restore spaces for readability |
||||
|
xmllint --format "$privacy_manifest_file" -o "$privacy_manifest_file" |
||||
|
fi |
||||
|
|
||||
|
resign "$path" |
||||
|
|
||||
|
echo "✅ Privacy manifest file fixed: $privacy_manifest_file." |
||||
|
} |
||||
|
|
||||
|
# Fix privacy manifests for all frameworks |
||||
|
function fix_frameworks() { |
||||
|
if ! [ -d "$frameworks_dir" ]; then |
||||
|
return |
||||
|
fi |
||||
|
|
||||
|
echo "🛠️ Fixing Frameworks..." |
||||
|
for path in "$frameworks_dir"/*; do |
||||
|
if [ -d "$path" ]; then |
||||
|
local dep_name="$(get_dependency_name "$path")" |
||||
|
local versions_dir="$path/Versions" |
||||
|
|
||||
|
if [ -d "$versions_dir" ]; then |
||||
|
for version in $(ls -1 "$versions_dir" | grep -vE '^Current$'); do |
||||
|
local version_path="Versions/$version" |
||||
|
echo "Analyzing $dep_name ($version_path) ..." |
||||
|
fix "$path" "$version_path" false |
||||
|
echo "" |
||||
|
done |
||||
|
else |
||||
|
echo "Analyzing $dep_name ..." |
||||
|
fix "$path" "" false |
||||
|
echo "" |
||||
|
fi |
||||
|
fi |
||||
|
done |
||||
|
} |
||||
|
|
||||
|
# Fix the privacy manifest for the app |
||||
|
function fix_app() { |
||||
|
echo "🛠️ Fixing $(basename "$app_path" .app) App..." |
||||
|
# Since the framework may have undergone fixes, the app must be forcefully re-signed |
||||
|
fix "$app_path" "" true |
||||
|
echo "" |
||||
|
} |
||||
|
|
||||
|
# Generate the privacy access report for the app |
||||
|
function generate_report() { |
||||
|
local original="$1" |
||||
|
|
||||
|
if [ "$silent" == true ]; then |
||||
|
return |
||||
|
fi |
||||
|
|
||||
|
local app_name="$(basename "$app_path")" |
||||
|
local name="${app_name%.*}" |
||||
|
local report_name="" |
||||
|
|
||||
|
# Adjust output names if the app is flagged as original |
||||
|
if [ "$original" == true ]; then |
||||
|
app_name="${name}-original.app" |
||||
|
report_name="report-original.html" |
||||
|
else |
||||
|
app_name="$name.app" |
||||
|
report_name="report.html" |
||||
|
fi |
||||
|
|
||||
|
local target_app_path="$build_dir/$app_name" |
||||
|
local report_path="$build_dir/$report_name" |
||||
|
|
||||
|
echo "Copy app to $target_app_path" |
||||
|
rsync -a "$app_path/" "$target_app_path/" |
||||
|
|
||||
|
# Generate the privacy access report using the script |
||||
|
sh "$report_script" "$target_app_path" "$report_path" "${template_usage_records[@]}" |
||||
|
echo "" |
||||
|
} |
||||
|
|
||||
|
start_time=$(date +%s) |
||||
|
|
||||
|
generate_report true |
||||
|
fix_frameworks |
||||
|
fix_app |
||||
|
generate_report false |
||||
|
|
||||
|
end_time=$(date +%s) |
||||
|
|
||||
|
echo "🎉 All fixed! ⏰ $((end_time - start_time)) seconds" |
||||
|
echo "🌟 If you found this script helpful, please consider giving it a star on GitHub. Your support is appreciated. Thank you!" |
||||
|
echo "🔗 Homepage: https://github.com/crasowas/app_privacy_manifest_fixer" |
||||
|
echo "🐛 Report issues: https://github.com/crasowas/app_privacy_manifest_fixer/issues" |
@ -0,0 +1,71 @@ |
|||||
|
#!/bin/bash |
||||
|
|
||||
|
# Copyright (c) 2024, crasowas. |
||||
|
# |
||||
|
# Use of this source code is governed by a MIT-style license |
||||
|
# that can be found in the LICENSE file or at |
||||
|
# https://opensource.org/licenses/MIT. |
||||
|
|
||||
|
set -e |
||||
|
|
||||
|
# Check if at least one argument (project_path) is provided |
||||
|
if [[ "$#" -lt 1 ]]; then |
||||
|
echo "Usage: $0 <project_path> [options...]" |
||||
|
exit 1 |
||||
|
fi |
||||
|
|
||||
|
project_path="$1" |
||||
|
|
||||
|
shift |
||||
|
|
||||
|
options=() |
||||
|
install_builds_only=false |
||||
|
|
||||
|
# Check if the `--install-builds-only` option is provided and separate it from other options |
||||
|
for arg in "$@"; do |
||||
|
if [ "$arg" == "--install-builds-only" ]; then |
||||
|
install_builds_only=true |
||||
|
else |
||||
|
options+=("$arg") |
||||
|
fi |
||||
|
done |
||||
|
|
||||
|
# Verify Ruby installation |
||||
|
if ! command -v ruby &>/dev/null; then |
||||
|
echo "Ruby is not installed. Please install Ruby and try again." |
||||
|
exit 1 |
||||
|
fi |
||||
|
|
||||
|
# Check if xcodeproj gem is installed |
||||
|
if ! gem list -i xcodeproj &>/dev/null; then |
||||
|
echo "The 'xcodeproj' gem is not installed." |
||||
|
read -p "Would you like to install it now? [Y/n] " response |
||||
|
if [[ "$response" =~ ^[Nn]$ ]]; then |
||||
|
echo "Please install 'xcodeproj' manually and re-run the script." |
||||
|
exit 1 |
||||
|
fi |
||||
|
gem install xcodeproj || { echo "Failed to install 'xcodeproj'."; exit 1; } |
||||
|
fi |
||||
|
|
||||
|
# Convert project path to an absolute path if it is relative |
||||
|
if [[ ! "$project_path" = /* ]]; then |
||||
|
project_path="$(realpath "$project_path")" |
||||
|
fi |
||||
|
|
||||
|
# Absolute path of the script and the tool's root directory |
||||
|
script_path="$(realpath "$0")" |
||||
|
tool_root_path="$(dirname "$script_path")" |
||||
|
|
||||
|
tool_portable_path="$tool_root_path" |
||||
|
# If the tool's root directory is inside the project path, make the path portable |
||||
|
if [[ "$tool_root_path" == "$project_path"* ]]; then |
||||
|
# Extract the path of the tool's root directory relative to the project path |
||||
|
tool_relative_path="${tool_root_path#$project_path}" |
||||
|
# Formulate a portable path using the `PROJECT_DIR` environment variable provided by Xcode |
||||
|
tool_portable_path="\${PROJECT_DIR}${tool_relative_path}" |
||||
|
fi |
||||
|
|
||||
|
run_script_content="\"$tool_portable_path/fixer.sh\" ${options[@]}" |
||||
|
|
||||
|
# Execute the Ruby helper script |
||||
|
ruby "$tool_root_path/Helper/xcode_install_helper.rb" "$project_path" "$run_script_content" "$install_builds_only" |
@ -0,0 +1,46 @@ |
|||||
|
#!/bin/bash |
||||
|
|
||||
|
# Copyright (c) 2024, crasowas. |
||||
|
# |
||||
|
# Use of this source code is governed by a MIT-style license |
||||
|
# that can be found in the LICENSE file or at |
||||
|
# https://opensource.org/licenses/MIT. |
||||
|
|
||||
|
set -e |
||||
|
|
||||
|
# Check if the project path is provided |
||||
|
if [[ $# -eq 0 ]]; then |
||||
|
echo "Usage: $0 <project_path>" |
||||
|
exit 1 |
||||
|
fi |
||||
|
|
||||
|
project_path="$1" |
||||
|
|
||||
|
# Verify Ruby installation |
||||
|
if ! command -v ruby &>/dev/null; then |
||||
|
echo "Ruby is not installed. Please install Ruby and try again." |
||||
|
exit 1 |
||||
|
fi |
||||
|
|
||||
|
# Check if xcodeproj gem is installed |
||||
|
if ! gem list -i xcodeproj &>/dev/null; then |
||||
|
echo "The 'xcodeproj' gem is not installed." |
||||
|
read -p "Would you like to install it now? [Y/n] " response |
||||
|
if [[ "$response" =~ ^[Nn]$ ]]; then |
||||
|
echo "Please install 'xcodeproj' manually and re-run the script." |
||||
|
exit 1 |
||||
|
fi |
||||
|
gem install xcodeproj || { echo "Failed to install 'xcodeproj'."; exit 1; } |
||||
|
fi |
||||
|
|
||||
|
# Convert project path to an absolute path if it is relative |
||||
|
if [[ ! "$project_path" = /* ]]; then |
||||
|
project_path="$(realpath "$project_path")" |
||||
|
fi |
||||
|
|
||||
|
# Absolute path of the script and the tool's root directory |
||||
|
script_path="$(realpath "$0")" |
||||
|
tool_root_path="$(dirname "$script_path")" |
||||
|
|
||||
|
# Execute the Ruby helper script |
||||
|
ruby "$tool_root_path/Helper/xcode_uninstall_helper.rb" "$project_path" |
@ -0,0 +1,108 @@ |
|||||
|
#!/bin/bash |
||||
|
|
||||
|
# Copyright (c) 2024, crasowas. |
||||
|
# |
||||
|
# Use of this source code is governed by a MIT-style license |
||||
|
# that can be found in the LICENSE file or at |
||||
|
# https://opensource.org/licenses/MIT. |
||||
|
|
||||
|
set -e |
||||
|
|
||||
|
# Absolute path of the script and the tool's root directory |
||||
|
script_path="$(realpath "$0")" |
||||
|
tool_root_path="$(dirname "$script_path")" |
||||
|
|
||||
|
# Repository details |
||||
|
readonly REPO_OWNER="crasowas" |
||||
|
readonly REPO_NAME="app_privacy_manifest_fixer" |
||||
|
|
||||
|
# URL to fetch the latest release information |
||||
|
readonly LATEST_RELEASE_URL="https://api.github.com/repos/$REPO_OWNER/$REPO_NAME/releases/latest" |
||||
|
|
||||
|
# Fetch the release information from GitHub API |
||||
|
release_info=$(curl -s "$LATEST_RELEASE_URL") |
||||
|
|
||||
|
# Extract the latest release version, download URL, and published time |
||||
|
latest_version=$(echo "$release_info" | grep -o '"tag_name": "[^"]*' | sed 's/"tag_name": "//') |
||||
|
download_url=$(echo "$release_info" | grep -o '"zipball_url": "[^"]*' | sed 's/"zipball_url": "//') |
||||
|
published_time=$(echo "$release_info" | grep -o '"published_at": "[^"]*' | sed 's/"published_at": "//') |
||||
|
|
||||
|
# Ensure the latest version, download URL, and published time are successfully retrieved |
||||
|
if [ -z "$latest_version" ] || [ -z "$download_url" ] || [ -z "$published_time" ]; then |
||||
|
echo "Unable to fetch the latest release information." |
||||
|
echo "Request URL: $LATEST_RELEASE_URL" |
||||
|
echo "Response Data: $release_info" |
||||
|
exit 1 |
||||
|
fi |
||||
|
|
||||
|
# Convert UTC time to local time |
||||
|
published_time=$(TZ=UTC date -j -f "%Y-%m-%dT%H:%M:%SZ" "$published_time" +"%s" | xargs -I{} date -j -r {} +"%Y-%m-%d %H:%M:%S %z") |
||||
|
|
||||
|
# Read the current tool's version from the VERSION file |
||||
|
tool_version_file="$tool_root_path/VERSION" |
||||
|
if [ ! -f "$tool_version_file" ]; then |
||||
|
echo "VERSION file not found." |
||||
|
exit 1 |
||||
|
fi |
||||
|
|
||||
|
local_version="$(cat "$tool_version_file")" |
||||
|
|
||||
|
# Skip upgrade if the current version is already the latest |
||||
|
if [ "$local_version" == "$latest_version" ]; then |
||||
|
echo "Version $latest_version • $published_time" |
||||
|
echo "Already up-to-date." |
||||
|
exit 0 |
||||
|
fi |
||||
|
|
||||
|
# Create a temporary directory for downloading the release |
||||
|
temp_dir=$(mktemp -d) |
||||
|
trap "rm -rf $temp_dir" EXIT |
||||
|
|
||||
|
download_file_name="latest-release.tar.gz" |
||||
|
|
||||
|
# Download the latest release archive |
||||
|
echo "Downloading version $latest_version..." |
||||
|
curl -L "$download_url" -o "$temp_dir/$download_file_name" |
||||
|
|
||||
|
# Check if the download was successful |
||||
|
if [ $? -ne 0 ]; then |
||||
|
echo "Download failed, please check your network connection and try again." |
||||
|
exit 1 |
||||
|
fi |
||||
|
|
||||
|
# Extract the downloaded release archive |
||||
|
echo "Extracting files..." |
||||
|
tar -xzf "$temp_dir/$download_file_name" -C "$temp_dir" |
||||
|
|
||||
|
# Find the extracted release |
||||
|
extracted_release_path=$(find "$temp_dir" -mindepth 1 -maxdepth 1 -type d -name "*$REPO_NAME*" | head -n 1) |
||||
|
|
||||
|
# Verify that an extracted release was found |
||||
|
if [ -z "$extracted_release_path" ]; then |
||||
|
echo "No extracted release found for the latest version." |
||||
|
exit 1 |
||||
|
fi |
||||
|
|
||||
|
user_templates_dir="$tool_root_path/Templates/UserTemplates" |
||||
|
user_templates_backup_dir="$temp_dir/Templates/UserTemplates" |
||||
|
|
||||
|
# Backup the user templates directory if it exists |
||||
|
if [ -d "$user_templates_dir" ]; then |
||||
|
echo "Backing up user templates..." |
||||
|
mkdir -p "$user_templates_backup_dir" |
||||
|
rsync -a --exclude='.*' "$user_templates_dir/" "$user_templates_backup_dir/" |
||||
|
fi |
||||
|
|
||||
|
# Replace old version files with the new version files |
||||
|
echo "Replacing old version files..." |
||||
|
rsync -a --delete "$extracted_release_path/" "$tool_root_path/" |
||||
|
|
||||
|
# Restore the user templates from the backup |
||||
|
if [ -d "$user_templates_backup_dir" ]; then |
||||
|
echo "Restoring user templates..." |
||||
|
rsync -a --exclude='.*' "$user_templates_backup_dir/" "$user_templates_dir/" |
||||
|
fi |
||||
|
|
||||
|
# Upgrade complete |
||||
|
echo "Version $latest_version • $published_time" |
||||
|
echo "Upgrade completed successfully!" |
File diff suppressed because it is too large
@ -1,5 +1,6 @@ |
|||||
dependencies: |
dependencies: |
||||
- gradle |
- gradle |
||||
- java |
- java |
||||
|
- pod |
||||
|
|
||||
# other dependencies are discovered via package.json & requirements.txt & Gemfile (I'm guessing). |
# other dependencies are discovered via package.json & requirements.txt & Gemfile (I'm guessing). |
||||
|
@ -1,98 +1,243 @@ |
|||||
|
const fs = require('fs'); |
||||
const path = require('path'); |
const path = require('path'); |
||||
const fs = require('fs-extra'); |
|
||||
|
|
||||
async function main() { |
|
||||
try { |
|
||||
console.log('Starting electron build process...'); |
console.log('Starting electron build process...'); |
||||
|
|
||||
// Create dist directory if it doesn't exist
|
|
||||
const distElectronDir = path.resolve(__dirname, '../dist-electron'); |
|
||||
await fs.ensureDir(distElectronDir); |
|
||||
|
|
||||
// Copy web files
|
// Copy web files
|
||||
const wwwDir = path.join(distElectronDir, 'www'); |
const webDistPath = path.join(__dirname, '..', 'dist'); |
||||
await fs.ensureDir(wwwDir); |
const electronDistPath = path.join(__dirname, '..', 'dist-electron'); |
||||
await fs.copy('dist', wwwDir); |
const wwwPath = path.join(electronDistPath, 'www'); |
||||
|
|
||||
|
// Create www directory if it doesn't exist
|
||||
|
if (!fs.existsSync(wwwPath)) { |
||||
|
fs.mkdirSync(wwwPath, { recursive: true }); |
||||
|
} |
||||
|
|
||||
// Copy and fix index.html
|
// Copy web files to www directory
|
||||
const indexPath = path.join(wwwDir, 'index.html'); |
fs.cpSync(webDistPath, wwwPath, { recursive: true }); |
||||
let indexContent = await fs.readFile(indexPath, 'utf8'); |
|
||||
|
|
||||
// More comprehensive path fixing
|
// Fix asset paths in index.html
|
||||
|
const indexPath = path.join(wwwPath, 'index.html'); |
||||
|
let indexContent = fs.readFileSync(indexPath, 'utf8'); |
||||
|
|
||||
|
// Fix asset paths
|
||||
indexContent = indexContent |
indexContent = indexContent |
||||
// Fix absolute paths to be relative
|
.replace(/\/assets\//g, './assets/') |
||||
.replace(/src="\//g, 'src="\./') |
.replace(/href="\//g, 'href="./') |
||||
.replace(/href="\//g, 'href="\./') |
.replace(/src="\//g, 'src="./'); |
||||
// Fix modulepreload paths
|
|
||||
.replace(/<link [^>]*rel="modulepreload"[^>]*href="\/assets\//g, '<link rel="modulepreload" as="script" crossorigin="" href="./assets/') |
fs.writeFileSync(indexPath, indexContent); |
||||
.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); |
// 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:', wwwDir); |
console.log('Copied and fixed web files in:', wwwPath); |
||||
|
|
||||
// Copy main process files
|
// Copy main process files
|
||||
console.log('Copying main process files...'); |
console.log('Copying main process files...'); |
||||
const mainProcessFiles = [ |
|
||||
['src/electron/main.js', 'main.js'], |
|
||||
['src/electron/preload.js', 'preload.js'] |
|
||||
]; |
|
||||
|
|
||||
for (const [src, dest] of mainProcessFiles) { |
|
||||
const destPath = path.join(distElectronDir, dest); |
|
||||
console.log(`Copying ${src} to ${destPath}`); |
|
||||
await fs.copy(src, destPath); |
|
||||
} |
|
||||
|
|
||||
// Create package.json for production
|
// Create the main process file with inlined logger
|
||||
const devPackageJson = require('../package.json'); |
const mainContent = `const { app, BrowserWindow } = require("electron");
|
||||
const prodPackageJson = { |
const path = require("path"); |
||||
name: devPackageJson.name, |
const fs = require("fs"); |
||||
version: devPackageJson.version, |
|
||||
description: devPackageJson.description, |
// Inline logger implementation
|
||||
author: devPackageJson.author, |
const logger = { |
||||
main: 'main.js', |
log: (...args) => console.log(...args), |
||||
private: true, |
error: (...args) => console.error(...args), |
||||
}; |
info: (...args) => console.info(...args), |
||||
|
warn: (...args) => console.warn(...args), |
||||
await fs.writeJson( |
debug: (...args) => console.debug(...args), |
||||
path.join(distElectronDir, 'package.json'), |
}; |
||||
prodPackageJson, |
|
||||
{ spaces: 2 } |
// Check if running in dev mode
|
||||
); |
const isDev = process.argv.includes("--inspect"); |
||||
|
|
||||
// Verify the build
|
function createWindow() { |
||||
console.log('\nVerifying build structure:'); |
// Add before createWindow function
|
||||
const files = await fs.readdir(distElectronDir); |
const preloadPath = path.join(__dirname, "preload.js"); |
||||
console.log('Files in dist-electron:', files); |
logger.log("Checking preload path:", preloadPath); |
||||
|
logger.log("Preload exists:", fs.existsSync(preloadPath)); |
||||
if (!files.includes('main.js')) { |
|
||||
throw new Error('main.js not found in build directory'); |
// Create the browser window.
|
||||
} |
const mainWindow = new BrowserWindow({ |
||||
if (!files.includes('preload.js')) { |
width: 1200, |
||||
throw new Error('preload.js not found in build directory'); |
height: 800, |
||||
} |
webPreferences: { |
||||
if (!files.includes('package.json')) { |
nodeIntegration: false, |
||||
throw new Error('package.json not found in build directory'); |
contextIsolation: true, |
||||
|
webSecurity: true, |
||||
|
allowRunningInsecureContent: false, |
||||
|
preload: path.join(__dirname, "preload.js"), |
||||
|
}, |
||||
|
}); |
||||
|
|
||||
|
// Always open DevTools for now
|
||||
|
mainWindow.webContents.openDevTools(); |
||||
|
|
||||
|
// Intercept requests to fix asset paths
|
||||
|
mainWindow.webContents.session.webRequest.onBeforeRequest( |
||||
|
{ |
||||
|
urls: [ |
||||
|
"file://*/*/assets/*", |
||||
|
"file://*/assets/*", |
||||
|
"file:///assets/*", // Catch absolute paths
|
||||
|
"<all_urls>", // Catch all URLs as a fallback
|
||||
|
], |
||||
|
}, |
||||
|
(details, callback) => { |
||||
|
let url = details.url; |
||||
|
|
||||
|
// Handle paths that don't start with file://
|
||||
|
if (!url.startsWith("file://") && url.includes("/assets/")) { |
||||
|
url = \`file://\${path.join(__dirname, "www", url)}\`;
|
||||
|
} |
||||
|
|
||||
|
// Handle absolute paths starting with /assets/
|
||||
|
if (url.includes("/assets/") && !url.includes("/www/assets/")) { |
||||
|
const baseDir = url.includes("dist-electron") |
||||
|
? url.substring( |
||||
|
0, |
||||
|
url.indexOf("/dist-electron") + "/dist-electron".length, |
||||
|
) |
||||
|
: \`file://\${__dirname}\`;
|
||||
|
const assetPath = url.split("/assets/")[1]; |
||||
|
const newUrl = \`\${baseDir}/www/assets/\${assetPath}\`;
|
||||
|
callback({ redirectURL: newUrl }); |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
callback({}); // No redirect for other URLs
|
||||
|
}, |
||||
|
); |
||||
|
|
||||
|
if (isDev) { |
||||
|
// Debug info
|
||||
|
logger.log("Debug Info:"); |
||||
|
logger.log("Running in dev mode:", isDev); |
||||
|
logger.log("App is packaged:", app.isPackaged); |
||||
|
logger.log("Process resource path:", process.resourcesPath); |
||||
|
logger.log("App path:", app.getAppPath()); |
||||
|
logger.log("__dirname:", __dirname); |
||||
|
logger.log("process.cwd():", process.cwd()); |
||||
|
} |
||||
|
|
||||
|
const indexPath = path.join(__dirname, "www", "index.html"); |
||||
|
|
||||
|
if (isDev) { |
||||
|
logger.log("Loading index from:", indexPath); |
||||
|
logger.log("www path:", path.join(__dirname, "www")); |
||||
|
logger.log("www assets path:", path.join(__dirname, "www", "assets")); |
||||
|
} |
||||
|
|
||||
|
if (!fs.existsSync(indexPath)) { |
||||
|
logger.error(\`Index file not found at: \${indexPath}\`);
|
||||
|
throw new Error("Index file not found"); |
||||
|
} |
||||
|
|
||||
|
// Add CSP headers to allow API connections, Google Fonts, and zxing-wasm
|
||||
|
mainWindow.webContents.session.webRequest.onHeadersReceived( |
||||
|
(details, callback) => { |
||||
|
callback({ |
||||
|
responseHeaders: { |
||||
|
...details.responseHeaders, |
||||
|
"Content-Security-Policy": [ |
||||
|
"default-src 'self';" + |
||||
|
"connect-src 'self' https://api.endorser.ch https://*.timesafari.app https://*.jsdelivr.net;" + |
||||
|
"img-src 'self' data: https: blob:;" + |
||||
|
"script-src 'self' 'unsafe-inline' 'unsafe-eval' https://*.jsdelivr.net;" + |
||||
|
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com;" + |
||||
|
"font-src 'self' data: https://fonts.gstatic.com;" + |
||||
|
"style-src-elem 'self' 'unsafe-inline' https://fonts.googleapis.com;" + |
||||
|
"worker-src 'self' blob:;", |
||||
|
], |
||||
|
}, |
||||
|
}); |
||||
|
}, |
||||
|
); |
||||
|
|
||||
|
// Load the index.html
|
||||
|
mainWindow |
||||
|
.loadFile(indexPath) |
||||
|
.then(() => { |
||||
|
logger.log("Successfully loaded index.html"); |
||||
|
if (isDev) { |
||||
|
mainWindow.webContents.openDevTools(); |
||||
|
logger.log("DevTools opened - running in dev mode"); |
||||
|
} |
||||
|
}) |
||||
|
.catch((err) => { |
||||
|
logger.error("Failed to load index.html:", err); |
||||
|
logger.error("Attempted path:", indexPath); |
||||
|
}); |
||||
|
|
||||
|
// Listen for console messages from the renderer
|
||||
|
mainWindow.webContents.on("console-message", (_event, _level, message) => { |
||||
|
logger.log("Renderer Console:", message); |
||||
|
}); |
||||
|
|
||||
|
// Add right after creating the BrowserWindow
|
||||
|
mainWindow.webContents.on( |
||||
|
"did-fail-load", |
||||
|
(_event, errorCode, errorDescription) => { |
||||
|
logger.error("Page failed to load:", errorCode, errorDescription); |
||||
|
}, |
||||
|
); |
||||
|
|
||||
|
mainWindow.webContents.on("preload-error", (_event, preloadPath, error) => { |
||||
|
logger.error("Preload script error:", preloadPath, error); |
||||
|
}); |
||||
|
|
||||
|
mainWindow.webContents.on( |
||||
|
"console-message", |
||||
|
(_event, _level, message, line, sourceId) => { |
||||
|
logger.log("Renderer Console:", line, sourceId, message); |
||||
|
}, |
||||
|
); |
||||
|
|
||||
|
// Enable remote debugging when in dev mode
|
||||
|
if (isDev) { |
||||
|
mainWindow.webContents.openDevTools(); |
||||
} |
} |
||||
|
} |
||||
|
|
||||
|
// Handle app ready
|
||||
|
app.whenReady().then(createWindow); |
||||
|
|
||||
|
// Handle all windows closed
|
||||
|
app.on("window-all-closed", () => { |
||||
|
if (process.platform !== "darwin") { |
||||
|
app.quit(); |
||||
|
} |
||||
|
}); |
||||
|
|
||||
console.log('Build completed successfully!'); |
app.on("activate", () => { |
||||
} catch (error) { |
if (BrowserWindow.getAllWindows().length === 0) { |
||||
console.error('Build failed:', error); |
createWindow(); |
||||
process.exit(1); |
|
||||
} |
} |
||||
|
}); |
||||
|
|
||||
|
// Handle any errors
|
||||
|
process.on("uncaughtException", (error) => { |
||||
|
logger.error("Uncaught Exception:", error); |
||||
|
}); |
||||
|
`;
|
||||
|
|
||||
|
// Write the main process file
|
||||
|
const mainDest = path.join(electronDistPath, 'main.js'); |
||||
|
fs.writeFileSync(mainDest, mainContent); |
||||
|
|
||||
|
// Copy preload script if it exists
|
||||
|
const preloadSrc = path.join(__dirname, '..', 'src', 'electron', 'preload.js'); |
||||
|
const preloadDest = path.join(electronDistPath, 'preload.js'); |
||||
|
if (fs.existsSync(preloadSrc)) { |
||||
|
console.log(`Copying ${preloadSrc} to ${preloadDest}`); |
||||
|
fs.copyFileSync(preloadSrc, preloadDest); |
||||
} |
} |
||||
|
|
||||
main(); |
// Verify build structure
|
||||
|
console.log('\nVerifying build structure:'); |
||||
|
console.log('Files in dist-electron:', fs.readdirSync(electronDistPath)); |
||||
|
|
||||
|
console.log('Build completed successfully!'); |
@ -0,0 +1,22 @@ |
|||||
|
#!/bin/bash |
||||
|
|
||||
|
# Clean the public directory |
||||
|
rm -rf android/app/src/main/assets/public/* |
||||
|
|
||||
|
# Copy web assets |
||||
|
cp -r dist/* android/app/src/main/assets/public/ |
||||
|
|
||||
|
# Ensure the directory structure exists |
||||
|
mkdir -p android/app/src/main/assets/public/assets |
||||
|
|
||||
|
# Copy the main index file |
||||
|
cp dist/index.html android/app/src/main/assets/public/ |
||||
|
|
||||
|
# Copy all assets |
||||
|
cp -r dist/assets/* android/app/src/main/assets/public/assets/ |
||||
|
|
||||
|
# Copy other necessary files |
||||
|
cp dist/favicon.ico android/app/src/main/assets/public/ |
||||
|
cp dist/robots.txt android/app/src/main/assets/public/ |
||||
|
|
||||
|
echo "Web assets copied successfully!" |
@ -0,0 +1,22 @@ |
|||||
|
#!/bin/bash |
||||
|
|
||||
|
# Create directories if they don't exist |
||||
|
mkdir -p android/app/src/main/res/mipmap-mdpi |
||||
|
mkdir -p android/app/src/main/res/mipmap-hdpi |
||||
|
mkdir -p android/app/src/main/res/mipmap-xhdpi |
||||
|
mkdir -p android/app/src/main/res/mipmap-xxhdpi |
||||
|
mkdir -p android/app/src/main/res/mipmap-xxxhdpi |
||||
|
|
||||
|
# Generate placeholder icons using ImageMagick |
||||
|
convert -size 48x48 xc:blue -gravity center -pointsize 20 -fill white -annotate 0 "TS" android/app/src/main/res/mipmap-mdpi/ic_launcher.png |
||||
|
convert -size 72x72 xc:blue -gravity center -pointsize 30 -fill white -annotate 0 "TS" android/app/src/main/res/mipmap-hdpi/ic_launcher.png |
||||
|
convert -size 96x96 xc:blue -gravity center -pointsize 40 -fill white -annotate 0 "TS" android/app/src/main/res/mipmap-xhdpi/ic_launcher.png |
||||
|
convert -size 144x144 xc:blue -gravity center -pointsize 60 -fill white -annotate 0 "TS" android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png |
||||
|
convert -size 192x192 xc:blue -gravity center -pointsize 80 -fill white -annotate 0 "TS" android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png |
||||
|
|
||||
|
# Copy to round versions |
||||
|
cp android/app/src/main/res/mipmap-mdpi/ic_launcher.png android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png |
||||
|
cp android/app/src/main/res/mipmap-hdpi/ic_launcher.png android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png |
||||
|
cp android/app/src/main/res/mipmap-xhdpi/ic_launcher.png android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png |
||||
|
cp android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png |
||||
|
cp android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png |
@ -0,0 +1,209 @@ |
|||||
|
/** * 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, |
||||
|
transform: (table, value, key) => { |
||||
|
if (table === "contacts") { |
||||
|
// Dexie inserts a number 0 when some are undefined, so we need to totally remove them. |
||||
|
Object.keys(value).forEach((prop) => { |
||||
|
if (value[prop] === undefined) { |
||||
|
delete value[prop]; |
||||
|
} |
||||
|
}); |
||||
|
} |
||||
|
return { value, key }; |
||||
|
}, |
||||
|
}); |
||||
|
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." |
||||
|
: "You should have been prompted to save your backup file.", |
||||
|
}, |
||||
|
-1, |
||||
|
); |
||||
|
} catch (error) { |
||||
|
logger.error("Export Error:", error); |
||||
|
this.$notify( |
||||
|
{ |
||||
|
group: "alert", |
||||
|
type: "danger", |
||||
|
title: "Export Error", |
||||
|
text: "There was an error exporting the data.", |
||||
|
}, |
||||
|
3000, |
||||
|
); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Computes class names for the initial download button |
||||
|
* @returns Object with 'hidden' class when download is in progress (web platform only) |
||||
|
*/ |
||||
|
public computedStartDownloadLinkClassNames() { |
||||
|
return { |
||||
|
hidden: this.downloadUrl && this.platformCapabilities.hasFileDownload, |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Computes class names for the secondary download link |
||||
|
* @returns Object with 'hidden' class when no download is available or not on web platform |
||||
|
*/ |
||||
|
public computedDownloadLinkClassNames() { |
||||
|
return { |
||||
|
hidden: !this.downloadUrl || !this.platformCapabilities.hasFileDownload, |
||||
|
}; |
||||
|
} |
||||
|
} |
||||
|
</script> |
@ -0,0 +1,187 @@ |
|||||
|
import { app, BrowserWindow } from "electron"; |
||||
|
import path from "path"; |
||||
|
import fs from "fs"; |
||||
|
|
||||
|
// Simple logger implementation
|
||||
|
const logger = { |
||||
|
// eslint-disable-next-line no-console
|
||||
|
log: (...args: unknown[]) => console.log(...args), |
||||
|
// eslint-disable-next-line no-console
|
||||
|
error: (...args: unknown[]) => console.error(...args), |
||||
|
// eslint-disable-next-line no-console
|
||||
|
info: (...args: unknown[]) => console.info(...args), |
||||
|
// eslint-disable-next-line no-console
|
||||
|
warn: (...args: unknown[]) => console.warn(...args), |
||||
|
// eslint-disable-next-line no-console
|
||||
|
debug: (...args: unknown[]) => console.debug(...args), |
||||
|
}; |
||||
|
|
||||
|
// Check if running in dev mode
|
||||
|
const isDev = process.argv.includes("--inspect"); |
||||
|
|
||||
|
function createWindow(): void { |
||||
|
// Add before createWindow function
|
||||
|
const preloadPath = path.join(__dirname, "preload.js"); |
||||
|
logger.log("Checking preload path:", preloadPath); |
||||
|
logger.log("Preload exists:", fs.existsSync(preloadPath)); |
||||
|
|
||||
|
// Create the browser window.
|
||||
|
const mainWindow = new BrowserWindow({ |
||||
|
width: 1200, |
||||
|
height: 800, |
||||
|
webPreferences: { |
||||
|
nodeIntegration: false, |
||||
|
contextIsolation: true, |
||||
|
webSecurity: true, |
||||
|
allowRunningInsecureContent: false, |
||||
|
preload: path.join(__dirname, "preload.js"), |
||||
|
}, |
||||
|
}); |
||||
|
|
||||
|
// Always open DevTools for now
|
||||
|
mainWindow.webContents.openDevTools(); |
||||
|
|
||||
|
// Intercept requests to fix asset paths
|
||||
|
mainWindow.webContents.session.webRequest.onBeforeRequest( |
||||
|
{ |
||||
|
urls: [ |
||||
|
"file://*/*/assets/*", |
||||
|
"file://*/assets/*", |
||||
|
"file:///assets/*", // Catch absolute paths
|
||||
|
"<all_urls>", // Catch all URLs as a fallback
|
||||
|
], |
||||
|
}, |
||||
|
(details, callback) => { |
||||
|
let url = details.url; |
||||
|
|
||||
|
// Handle paths that don't start with file://
|
||||
|
if (!url.startsWith("file://") && url.includes("/assets/")) { |
||||
|
url = `file://${path.join(__dirname, "www", url)}`; |
||||
|
} |
||||
|
|
||||
|
// Handle absolute paths starting with /assets/
|
||||
|
if (url.includes("/assets/") && !url.includes("/www/assets/")) { |
||||
|
const baseDir = url.includes("dist-electron") |
||||
|
? url.substring( |
||||
|
0, |
||||
|
url.indexOf("/dist-electron") + "/dist-electron".length, |
||||
|
) |
||||
|
: `file://${__dirname}`; |
||||
|
const assetPath = url.split("/assets/")[1]; |
||||
|
const newUrl = `${baseDir}/www/assets/${assetPath}`; |
||||
|
callback({ redirectURL: newUrl }); |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
callback({}); // No redirect for other URLs
|
||||
|
}, |
||||
|
); |
||||
|
|
||||
|
if (isDev) { |
||||
|
// Debug info
|
||||
|
logger.log("Debug Info:"); |
||||
|
logger.log("Running in dev mode:", isDev); |
||||
|
logger.log("App is packaged:", app.isPackaged); |
||||
|
logger.log("Process resource path:", process.resourcesPath); |
||||
|
logger.log("App path:", app.getAppPath()); |
||||
|
logger.log("__dirname:", __dirname); |
||||
|
logger.log("process.cwd():", process.cwd()); |
||||
|
} |
||||
|
|
||||
|
const indexPath = path.join(__dirname, "www", "index.html"); |
||||
|
|
||||
|
if (isDev) { |
||||
|
logger.log("Loading index from:", indexPath); |
||||
|
logger.log("www path:", path.join(__dirname, "www")); |
||||
|
logger.log("www assets path:", path.join(__dirname, "www", "assets")); |
||||
|
} |
||||
|
|
||||
|
if (!fs.existsSync(indexPath)) { |
||||
|
logger.error(`Index file not found at: ${indexPath}`); |
||||
|
throw new Error("Index file not found"); |
||||
|
} |
||||
|
|
||||
|
// Add CSP headers to allow API connections
|
||||
|
mainWindow.webContents.session.webRequest.onHeadersReceived( |
||||
|
(details, callback) => { |
||||
|
callback({ |
||||
|
responseHeaders: { |
||||
|
...details.responseHeaders, |
||||
|
"Content-Security-Policy": [ |
||||
|
"default-src 'self';" + |
||||
|
"connect-src 'self' https://api.endorser.ch https://*.timesafari.app;" + |
||||
|
"img-src 'self' data: https: blob:;" + |
||||
|
"script-src 'self' 'unsafe-inline' 'unsafe-eval';" + |
||||
|
"style-src 'self' 'unsafe-inline';" + |
||||
|
"font-src 'self' data:;", |
||||
|
], |
||||
|
}, |
||||
|
}); |
||||
|
}, |
||||
|
); |
||||
|
|
||||
|
// Load the index.html
|
||||
|
mainWindow |
||||
|
.loadFile(indexPath) |
||||
|
.then(() => { |
||||
|
logger.log("Successfully loaded index.html"); |
||||
|
if (isDev) { |
||||
|
mainWindow.webContents.openDevTools(); |
||||
|
logger.log("DevTools opened - running in dev mode"); |
||||
|
} |
||||
|
}) |
||||
|
.catch((err) => { |
||||
|
logger.error("Failed to load index.html:", err); |
||||
|
logger.error("Attempted path:", indexPath); |
||||
|
}); |
||||
|
|
||||
|
// Listen for console messages from the renderer
|
||||
|
mainWindow.webContents.on("console-message", (_event, _level, message) => { |
||||
|
logger.log("Renderer Console:", message); |
||||
|
}); |
||||
|
|
||||
|
// Add right after creating the BrowserWindow
|
||||
|
mainWindow.webContents.on( |
||||
|
"did-fail-load", |
||||
|
(_event, errorCode, errorDescription) => { |
||||
|
logger.error("Page failed to load:", errorCode, errorDescription); |
||||
|
}, |
||||
|
); |
||||
|
|
||||
|
mainWindow.webContents.on("preload-error", (_event, preloadPath, error) => { |
||||
|
logger.error("Preload script error:", preloadPath, error); |
||||
|
}); |
||||
|
|
||||
|
mainWindow.webContents.on( |
||||
|
"console-message", |
||||
|
(_event, _level, message, line, sourceId) => { |
||||
|
logger.log("Renderer Console:", line, sourceId, message); |
||||
|
}, |
||||
|
); |
||||
|
|
||||
|
// Enable remote debugging when in dev mode
|
||||
|
if (isDev) { |
||||
|
mainWindow.webContents.openDevTools(); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// Handle app ready
|
||||
|
app.whenReady().then(createWindow); |
||||
|
|
||||
|
// Handle all windows closed
|
||||
|
app.on("window-all-closed", () => { |
||||
|
if (process.platform !== "darwin") { |
||||
|
app.quit(); |
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
app.on("activate", () => { |
||||
|
if (BrowserWindow.getAllWindows().length === 0) { |
||||
|
createWindow(); |
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
// Handle any errors
|
||||
|
process.on("uncaughtException", (error) => { |
||||
|
logger.error("Uncaught Exception:", error); |
||||
|
}); |
@ -0,0 +1,4 @@ |
|||||
|
/// <reference types="vite/client" />
|
||||
|
|
||||
|
declare const __USE_QR_READER__: boolean; |
||||
|
declare const __IS_MOBILE__: boolean; |
@ -0,0 +1,21 @@ |
|||||
|
import { GiveSummaryRecord } from "./records"; |
||||
|
|
||||
|
// Common interface for contact information
|
||||
|
export interface ContactInfo { |
||||
|
known: boolean; |
||||
|
displayName: string; |
||||
|
profileImageUrl?: string; |
||||
|
} |
||||
|
|
||||
|
// Define the contact information fields
|
||||
|
interface GiveContactInfo { |
||||
|
giver: ContactInfo; |
||||
|
issuer: ContactInfo; |
||||
|
receiver: ContactInfo; |
||||
|
providerPlanName?: string; |
||||
|
recipientProjectName?: string; |
||||
|
image?: string; |
||||
|
} |
||||
|
|
||||
|
// Combine GiveSummaryRecord with contact information using intersection type
|
||||
|
export type GiveRecordWithContactInfo = GiveSummaryRecord & GiveContactInfo; |
@ -0,0 +1,109 @@ |
|||||
|
/** |
||||
|
* 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; |
||||
|
/** Whether the platform is a native app (Capacitor, Electron, etc.) */ |
||||
|
isNativeApp: 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>; |
||||
|
|
||||
|
/** |
||||
|
* Rotates the camera between front and back cameras. |
||||
|
* @returns Promise that resolves when the camera is rotated |
||||
|
*/ |
||||
|
rotateCamera(): Promise<void>; |
||||
|
|
||||
|
/** |
||||
|
* Handles deep link URLs for the application. |
||||
|
* @param url - The deep link URL to handle |
||||
|
* @returns Promise that resolves when the deep link has been handled |
||||
|
*/ |
||||
|
handleDeepLink(url: string): Promise<void>; |
||||
|
} |
@ -0,0 +1,58 @@ |
|||||
|
import { PlatformService } from "./PlatformService"; |
||||
|
import { WebPlatformService } from "./platforms/WebPlatformService"; |
||||
|
import { CapacitorPlatformService } from "./platforms/CapacitorPlatformService"; |
||||
|
import { ElectronPlatformService } from "./platforms/ElectronPlatformService"; |
||||
|
import { PyWebViewPlatformService } from "./platforms/PyWebViewPlatformService"; |
||||
|
|
||||
|
/** |
||||
|
* Factory class for creating platform-specific service implementations. |
||||
|
* Implements the Singleton pattern to ensure only one instance of PlatformService exists. |
||||
|
* |
||||
|
* The factory determines which platform implementation to use based on the VITE_PLATFORM |
||||
|
* environment variable. Supported platforms are: |
||||
|
* - capacitor: Mobile platform using Capacitor |
||||
|
* - electron: Desktop platform using Electron |
||||
|
* - pywebview: Python WebView implementation |
||||
|
* - web: Default web platform (fallback) |
||||
|
* |
||||
|
* @example |
||||
|
* ```typescript
|
||||
|
* const platformService = PlatformServiceFactory.getInstance(); |
||||
|
* await platformService.takePicture(); |
||||
|
* ``` |
||||
|
*/ |
||||
|
export class PlatformServiceFactory { |
||||
|
private static instance: PlatformService | null = null; |
||||
|
|
||||
|
/** |
||||
|
* Gets or creates the singleton instance of PlatformService. |
||||
|
* Creates the appropriate platform-specific implementation based on environment. |
||||
|
* |
||||
|
* @returns {PlatformService} The singleton instance of PlatformService |
||||
|
*/ |
||||
|
public static getInstance(): PlatformService { |
||||
|
if (PlatformServiceFactory.instance) { |
||||
|
return PlatformServiceFactory.instance; |
||||
|
} |
||||
|
|
||||
|
const platform = process.env.VITE_PLATFORM || "web"; |
||||
|
|
||||
|
switch (platform) { |
||||
|
case "capacitor": |
||||
|
PlatformServiceFactory.instance = new CapacitorPlatformService(); |
||||
|
break; |
||||
|
case "electron": |
||||
|
PlatformServiceFactory.instance = new ElectronPlatformService(); |
||||
|
break; |
||||
|
case "pywebview": |
||||
|
PlatformServiceFactory.instance = new PyWebViewPlatformService(); |
||||
|
break; |
||||
|
case "web": |
||||
|
default: |
||||
|
PlatformServiceFactory.instance = new WebPlatformService(); |
||||
|
break; |
||||
|
} |
||||
|
|
||||
|
return PlatformServiceFactory.instance; |
||||
|
} |
||||
|
} |
@ -0,0 +1,210 @@ |
|||||
|
import { |
||||
|
BarcodeScanner, |
||||
|
BarcodeFormat, |
||||
|
StartScanOptions, |
||||
|
LensFacing, |
||||
|
} from "@capacitor-mlkit/barcode-scanning"; |
||||
|
import { QRScannerService, ScanListener, QRScannerOptions } from "./types"; |
||||
|
import { logger } from "@/utils/logger"; |
||||
|
|
||||
|
export class CapacitorQRScanner implements QRScannerService { |
||||
|
private scanListener: ScanListener | null = null; |
||||
|
private isScanning = false; |
||||
|
private listenerHandles: Array<() => Promise<void>> = []; |
||||
|
private cleanupPromise: Promise<void> | null = null; |
||||
|
|
||||
|
async checkPermissions(): Promise<boolean> { |
||||
|
try { |
||||
|
logger.debug("Checking camera permissions"); |
||||
|
const { camera } = await BarcodeScanner.checkPermissions(); |
||||
|
return camera === "granted"; |
||||
|
} catch (error) { |
||||
|
const wrappedError = |
||||
|
error instanceof Error ? error : new Error(String(error)); |
||||
|
logger.error("Error checking camera permissions:", { |
||||
|
error: wrappedError.message, |
||||
|
}); |
||||
|
return false; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
async requestPermissions(): Promise<boolean> { |
||||
|
try { |
||||
|
// First check if we already have permissions
|
||||
|
if (await this.checkPermissions()) { |
||||
|
logger.debug("Camera permissions already granted"); |
||||
|
return true; |
||||
|
} |
||||
|
|
||||
|
logger.debug("Requesting camera permissions"); |
||||
|
const { camera } = await BarcodeScanner.requestPermissions(); |
||||
|
const granted = camera === "granted"; |
||||
|
logger.debug(`Camera permissions ${granted ? "granted" : "denied"}`); |
||||
|
return granted; |
||||
|
} catch (error) { |
||||
|
const wrappedError = |
||||
|
error instanceof Error ? error : new Error(String(error)); |
||||
|
logger.error("Error requesting camera permissions:", { |
||||
|
error: wrappedError.message, |
||||
|
}); |
||||
|
return false; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
async isSupported(): Promise<boolean> { |
||||
|
try { |
||||
|
logger.debug("Checking scanner support"); |
||||
|
const { supported } = await BarcodeScanner.isSupported(); |
||||
|
logger.debug(`Scanner support: ${supported}`); |
||||
|
return supported; |
||||
|
} catch (error) { |
||||
|
const wrappedError = |
||||
|
error instanceof Error ? error : new Error(String(error)); |
||||
|
logger.error("Error checking scanner support:", { |
||||
|
error: wrappedError.message, |
||||
|
}); |
||||
|
return false; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
async startScan(options?: QRScannerOptions): Promise<void> { |
||||
|
if (this.isScanning) { |
||||
|
logger.debug("Scanner already running"); |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
if (this.cleanupPromise) { |
||||
|
logger.debug("Waiting for previous cleanup to complete"); |
||||
|
await this.cleanupPromise; |
||||
|
} |
||||
|
|
||||
|
try { |
||||
|
// Ensure we have permissions before starting
|
||||
|
if (!(await this.checkPermissions())) { |
||||
|
logger.debug("Requesting camera permissions"); |
||||
|
const granted = await this.requestPermissions(); |
||||
|
if (!granted) { |
||||
|
throw new Error("Camera permission denied"); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// Check if scanning is supported
|
||||
|
if (!(await this.isSupported())) { |
||||
|
throw new Error("QR scanning not supported on this device"); |
||||
|
} |
||||
|
|
||||
|
logger.info("Starting MLKit scanner"); |
||||
|
this.isScanning = true; |
||||
|
|
||||
|
const scanOptions: StartScanOptions = { |
||||
|
formats: [BarcodeFormat.QrCode], |
||||
|
lensFacing: |
||||
|
options?.camera === "front" ? LensFacing.Front : LensFacing.Back, |
||||
|
}; |
||||
|
|
||||
|
logger.debug("Scanner options:", scanOptions); |
||||
|
|
||||
|
// Add listener for barcode scans
|
||||
|
const handle = await BarcodeScanner.addListener( |
||||
|
"barcodeScanned", |
||||
|
(result) => { |
||||
|
if (this.scanListener && result.barcode?.rawValue) { |
||||
|
this.scanListener.onScan(result.barcode.rawValue); |
||||
|
} |
||||
|
}, |
||||
|
); |
||||
|
this.listenerHandles.push(handle.remove); |
||||
|
|
||||
|
// Start continuous scanning
|
||||
|
await BarcodeScanner.startScan(scanOptions); |
||||
|
logger.info("MLKit scanner started successfully"); |
||||
|
} catch (error) { |
||||
|
const wrappedError = |
||||
|
error instanceof Error ? error : new Error(String(error)); |
||||
|
logger.error("Error during QR scan:", { |
||||
|
error: wrappedError.message, |
||||
|
stack: wrappedError.stack, |
||||
|
}); |
||||
|
this.isScanning = false; |
||||
|
await this.cleanup(); |
||||
|
this.scanListener?.onError?.(wrappedError); |
||||
|
throw wrappedError; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
async stopScan(): Promise<void> { |
||||
|
if (!this.isScanning) { |
||||
|
logger.debug("Scanner not running"); |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
try { |
||||
|
logger.debug("Stopping QR scanner"); |
||||
|
await BarcodeScanner.stopScan(); |
||||
|
logger.info("QR scanner stopped successfully"); |
||||
|
} catch (error) { |
||||
|
const wrappedError = |
||||
|
error instanceof Error ? error : new Error(String(error)); |
||||
|
logger.error("Error stopping QR scan:", { |
||||
|
error: wrappedError.message, |
||||
|
stack: wrappedError.stack, |
||||
|
}); |
||||
|
this.scanListener?.onError?.(wrappedError); |
||||
|
throw wrappedError; |
||||
|
} finally { |
||||
|
this.isScanning = false; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
addListener(listener: ScanListener): void { |
||||
|
this.scanListener = listener; |
||||
|
} |
||||
|
|
||||
|
async cleanup(): Promise<void> { |
||||
|
// Prevent multiple simultaneous cleanup attempts
|
||||
|
if (this.cleanupPromise) { |
||||
|
return this.cleanupPromise; |
||||
|
} |
||||
|
|
||||
|
this.cleanupPromise = (async () => { |
||||
|
try { |
||||
|
logger.debug("Starting QR scanner cleanup"); |
||||
|
|
||||
|
// Stop scanning if active
|
||||
|
if (this.isScanning) { |
||||
|
await this.stopScan(); |
||||
|
} |
||||
|
|
||||
|
// Remove all listeners
|
||||
|
for (const handle of this.listenerHandles) { |
||||
|
try { |
||||
|
await handle(); |
||||
|
} catch (error) { |
||||
|
logger.warn("Error removing listener:", error); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
logger.info("QR scanner cleanup completed"); |
||||
|
} catch (error) { |
||||
|
const wrappedError = |
||||
|
error instanceof Error ? error : new Error(String(error)); |
||||
|
logger.error("Error during cleanup:", { |
||||
|
error: wrappedError.message, |
||||
|
stack: wrappedError.stack, |
||||
|
}); |
||||
|
throw wrappedError; |
||||
|
} finally { |
||||
|
this.listenerHandles = []; |
||||
|
this.scanListener = null; |
||||
|
this.cleanupPromise = null; |
||||
|
} |
||||
|
})(); |
||||
|
|
||||
|
return this.cleanupPromise; |
||||
|
} |
||||
|
|
||||
|
onStream(callback: (stream: MediaStream | null) => void): void { |
||||
|
// No-op for native scanner
|
||||
|
callback(null); |
||||
|
} |
||||
|
} |
@ -0,0 +1,100 @@ |
|||||
|
import { Capacitor } from "@capacitor/core"; |
||||
|
import { QRScannerService } from "./types"; |
||||
|
import { CapacitorQRScanner } from "./CapacitorQRScanner"; |
||||
|
import { WebInlineQRScanner } from "./WebInlineQRScanner"; |
||||
|
import { logger } from "@/utils/logger"; |
||||
|
|
||||
|
/** |
||||
|
* Factory class for creating QR scanner instances based on platform |
||||
|
*/ |
||||
|
export class QRScannerFactory { |
||||
|
private static instance: QRScannerService | null = null; |
||||
|
|
||||
|
private static isNativePlatform(): boolean { |
||||
|
// Debug logging for build flags
|
||||
|
logger.log("Build flags:", { |
||||
|
IS_MOBILE: |
||||
|
typeof __IS_MOBILE__ !== "undefined" ? __IS_MOBILE__ : "undefined", |
||||
|
USE_QR_READER: |
||||
|
typeof __USE_QR_READER__ !== "undefined" |
||||
|
? __USE_QR_READER__ |
||||
|
: "undefined", |
||||
|
VITE_PLATFORM: process.env.VITE_PLATFORM, |
||||
|
}); |
||||
|
|
||||
|
const capacitorNative = Capacitor.isNativePlatform(); |
||||
|
const isMobile = |
||||
|
typeof __IS_MOBILE__ !== "undefined" ? __IS_MOBILE__ : capacitorNative; |
||||
|
const platform = Capacitor.getPlatform(); |
||||
|
|
||||
|
logger.log("Platform detection:", { |
||||
|
capacitorNative, |
||||
|
isMobile, |
||||
|
platform, |
||||
|
userAgent: navigator.userAgent, |
||||
|
}); |
||||
|
|
||||
|
// Always use native scanner on Android/iOS
|
||||
|
if (platform === "android" || platform === "ios") { |
||||
|
logger.log("Using native scanner due to platform:", platform); |
||||
|
return true; |
||||
|
} |
||||
|
|
||||
|
// For other platforms, use native if available
|
||||
|
const useNative = capacitorNative || isMobile; |
||||
|
logger.log("Platform decision:", { |
||||
|
useNative, |
||||
|
reason: useNative ? "capacitorNative/isMobile" : "web", |
||||
|
}); |
||||
|
return useNative; |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Get a QR scanner instance appropriate for the current platform |
||||
|
*/ |
||||
|
static getInstance(): QRScannerService { |
||||
|
if (!this.instance) { |
||||
|
const isNative = this.isNativePlatform(); |
||||
|
logger.log( |
||||
|
`Creating QR scanner for platform: ${isNative ? "native" : "web"}`, |
||||
|
); |
||||
|
|
||||
|
try { |
||||
|
if (isNative) { |
||||
|
logger.log("Using native MLKit scanner"); |
||||
|
this.instance = new CapacitorQRScanner(); |
||||
|
} else if ( |
||||
|
typeof __USE_QR_READER__ !== "undefined" |
||||
|
? __USE_QR_READER__ |
||||
|
: !isNative |
||||
|
) { |
||||
|
logger.log("Using web QR scanner"); |
||||
|
this.instance = new WebInlineQRScanner(); |
||||
|
} else { |
||||
|
throw new Error( |
||||
|
"No QR scanner implementation available for this platform", |
||||
|
); |
||||
|
} |
||||
|
} catch (error) { |
||||
|
logger.error("Error creating QR scanner:", error); |
||||
|
throw error; |
||||
|
} |
||||
|
} |
||||
|
return this.instance!; |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Clean up the current scanner instance |
||||
|
*/ |
||||
|
static async cleanup(): Promise<void> { |
||||
|
if (this.instance) { |
||||
|
try { |
||||
|
await this.instance.cleanup(); |
||||
|
} catch (error) { |
||||
|
logger.error("Error cleaning up QR scanner:", error); |
||||
|
} finally { |
||||
|
this.instance = null; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
@ -0,0 +1,676 @@ |
|||||
|
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...`, |
||||
|
); |
||||
|
|
||||
|
// First try the Permissions API if available
|
||||
|
if (navigator.permissions && navigator.permissions.query) { |
||||
|
try { |
||||
|
const permissions = await navigator.permissions.query({ |
||||
|
name: "camera" as PermissionName, |
||||
|
}); |
||||
|
logger.error( |
||||
|
`[WebInlineQRScanner:${this.id}] Permission state from Permissions API:`, |
||||
|
permissions.state, |
||||
|
); |
||||
|
if (permissions.state === "granted") { |
||||
|
this.updateCameraState("ready", "Camera permissions granted"); |
||||
|
return true; |
||||
|
} |
||||
|
} catch (permError) { |
||||
|
// Permissions API might not be supported, continue with getUserMedia check
|
||||
|
logger.error( |
||||
|
`[WebInlineQRScanner:${this.id}] Permissions API not supported:`, |
||||
|
permError, |
||||
|
); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// If Permissions API is not available or didn't return granted,
|
||||
|
// try a test getUserMedia call
|
||||
|
try { |
||||
|
const testStream = await navigator.mediaDevices.getUserMedia({ |
||||
|
video: true, |
||||
|
}); |
||||
|
// If we get here, we have permission
|
||||
|
testStream.getTracks().forEach((track) => track.stop()); |
||||
|
this.updateCameraState("ready", "Camera permissions granted"); |
||||
|
return true; |
||||
|
} catch (mediaError) { |
||||
|
const error = mediaError as Error; |
||||
|
logger.error( |
||||
|
`[WebInlineQRScanner:${this.id}] getUserMedia test failed:`, |
||||
|
{ |
||||
|
name: error.name, |
||||
|
message: error.message, |
||||
|
}, |
||||
|
); |
||||
|
|
||||
|
if ( |
||||
|
error.name === "NotAllowedError" || |
||||
|
error.name === "PermissionDeniedError" |
||||
|
) { |
||||
|
this.updateCameraState("permission_denied", "Camera access denied"); |
||||
|
return false; |
||||
|
} |
||||
|
// For other errors, we'll try requesting permissions explicitly
|
||||
|
return false; |
||||
|
} |
||||
|
} 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 })), |
||||
|
userAgent: navigator.userAgent, |
||||
|
}); |
||||
|
|
||||
|
if (videoDevices.length === 0) { |
||||
|
logger.error(`[WebInlineQRScanner:${this.id}] No video devices found`); |
||||
|
this.updateCameraState("not_found", "No camera found on this device"); |
||||
|
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 }, |
||||
|
}, |
||||
|
); |
||||
|
|
||||
|
try { |
||||
|
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 (mediaError) { |
||||
|
const error = mediaError as Error; |
||||
|
logger.error( |
||||
|
`[WebInlineQRScanner:${this.id}] Error requesting camera access:`, |
||||
|
{ |
||||
|
name: error.name, |
||||
|
message: error.message, |
||||
|
userAgent: navigator.userAgent, |
||||
|
}, |
||||
|
); |
||||
|
|
||||
|
// Update state based on error type
|
||||
|
if ( |
||||
|
error.name === "NotFoundError" || |
||||
|
error.name === "DevicesNotFoundError" |
||||
|
) { |
||||
|
this.updateCameraState("not_found", "No camera found on this device"); |
||||
|
throw new Error("No camera found on this device"); |
||||
|
} else if ( |
||||
|
error.name === "NotAllowedError" || |
||||
|
error.name === "PermissionDeniedError" |
||||
|
) { |
||||
|
this.updateCameraState("permission_denied", "Camera access denied"); |
||||
|
throw new Error( |
||||
|
"Camera access denied. Please grant camera permission and try again", |
||||
|
); |
||||
|
} else if ( |
||||
|
error.name === "NotReadableError" || |
||||
|
error.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", error.message); |
||||
|
throw new Error(`Camera error: ${error.message}`); |
||||
|
} |
||||
|
} |
||||
|
} catch (error) { |
||||
|
const wrappedError = |
||||
|
error instanceof Error ? error : new Error(String(error)); |
||||
|
logger.error( |
||||
|
`[WebInlineQRScanner:${this.id}] Error in requestPermissions:`, |
||||
|
{ |
||||
|
error: wrappedError.message, |
||||
|
stack: wrappedError.stack, |
||||
|
userAgent: navigator.userAgent, |
||||
|
}, |
||||
|
); |
||||
|
throw wrappedError; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
async isSupported(): Promise<boolean> { |
||||
|
try { |
||||
|
logger.error( |
||||
|
`[WebInlineQRScanner:${this.id}] Checking browser support...`, |
||||
|
); |
||||
|
// Check for secure context first
|
||||
|
if (!window.isSecureContext) { |
||||
|
logger.error( |
||||
|
`[WebInlineQRScanner:${this.id}] Camera access requires HTTPS (secure context)`, |
||||
|
); |
||||
|
return false; |
||||
|
} |
||||
|
|
||||
|
// Check for camera API support
|
||||
|
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) { |
||||
|
logger.error( |
||||
|
`[WebInlineQRScanner:${this.id}] Camera API not supported in this browser`, |
||||
|
); |
||||
|
return false; |
||||
|
} |
||||
|
|
||||
|
// Check if we have any video devices
|
||||
|
const devices = await navigator.mediaDevices.enumerateDevices(); |
||||
|
const hasVideoDevices = devices.some( |
||||
|
(device) => device.kind === "videoinput", |
||||
|
); |
||||
|
|
||||
|
logger.error(`[WebInlineQRScanner:${this.id}] Device support check:`, { |
||||
|
hasSecureContext: window.isSecureContext, |
||||
|
hasMediaDevices: !!navigator.mediaDevices, |
||||
|
hasGetUserMedia: !!navigator.mediaDevices?.getUserMedia, |
||||
|
hasVideoDevices, |
||||
|
deviceCount: devices.length, |
||||
|
}); |
||||
|
|
||||
|
if (!hasVideoDevices) { |
||||
|
logger.error(`[WebInlineQRScanner:${this.id}] No video devices found`); |
||||
|
return false; |
||||
|
} |
||||
|
|
||||
|
return true; |
||||
|
} catch (error) { |
||||
|
logger.error( |
||||
|
`[WebInlineQRScanner:${this.id}] Error checking camera support:`, |
||||
|
{ |
||||
|
error: error instanceof Error ? error.message : String(error), |
||||
|
stack: error instanceof Error ? error.stack : undefined, |
||||
|
}, |
||||
|
); |
||||
|
return false; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private async scanQRCode(): Promise<void> { |
||||
|
if (!this.video || !this.canvas || !this.context || !this.stream) { |
||||
|
logger.error( |
||||
|
`[WebInlineQRScanner:${this.id}] Cannot scan: missing required elements`, |
||||
|
{ |
||||
|
hasVideo: !!this.video, |
||||
|
hasCanvas: !!this.canvas, |
||||
|
hasContext: !!this.context, |
||||
|
hasStream: !!this.stream, |
||||
|
}, |
||||
|
); |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
try { |
||||
|
const now = Date.now(); |
||||
|
const timeSinceLastFrame = now - this.lastFrameTime; |
||||
|
|
||||
|
// Throttle frame processing to target FPS
|
||||
|
if (timeSinceLastFrame < this.FRAME_INTERVAL) { |
||||
|
this.animationFrameId = requestAnimationFrame(() => this.scanQRCode()); |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
this.lastFrameTime = now; |
||||
|
|
||||
|
// Set canvas dimensions to match video
|
||||
|
this.canvas.width = this.video.videoWidth; |
||||
|
this.canvas.height = this.video.videoHeight; |
||||
|
|
||||
|
// Draw video frame to canvas
|
||||
|
this.context.drawImage( |
||||
|
this.video, |
||||
|
0, |
||||
|
0, |
||||
|
this.canvas.width, |
||||
|
this.canvas.height, |
||||
|
); |
||||
|
|
||||
|
// Get image data from canvas
|
||||
|
const imageData = this.context.getImageData( |
||||
|
0, |
||||
|
0, |
||||
|
this.canvas.width, |
||||
|
this.canvas.height, |
||||
|
); |
||||
|
|
||||
|
// Increment scan attempts
|
||||
|
this.scanAttempts++; |
||||
|
const timeSinceLastScan = now - this.lastScanTime; |
||||
|
|
||||
|
// Log scan attempt every 100 frames or 1 second
|
||||
|
if (this.scanAttempts % 100 === 0 || timeSinceLastScan >= 1000) { |
||||
|
logger.error(`[WebInlineQRScanner:${this.id}] Scanning frame:`, { |
||||
|
attempt: this.scanAttempts, |
||||
|
dimensions: { |
||||
|
width: this.canvas.width, |
||||
|
height: this.canvas.height, |
||||
|
}, |
||||
|
fps: Math.round(1000 / timeSinceLastScan), |
||||
|
imageDataSize: imageData.data.length, |
||||
|
imageDataWidth: imageData.width, |
||||
|
imageDataHeight: imageData.height, |
||||
|
timeSinceLastFrame, |
||||
|
targetFPS: this.TARGET_FPS, |
||||
|
}); |
||||
|
this.lastScanTime = now; |
||||
|
} |
||||
|
|
||||
|
// Scan for QR code
|
||||
|
const code = jsQR(imageData.data, imageData.width, imageData.height, { |
||||
|
inversionAttempts: "attemptBoth", // Try both normal and inverted
|
||||
|
}); |
||||
|
|
||||
|
if (code) { |
||||
|
// Check if the QR code is blurry by examining the location points
|
||||
|
const { topRightCorner, topLeftCorner, bottomLeftCorner } = |
||||
|
code.location; |
||||
|
const width = Math.sqrt( |
||||
|
Math.pow(topRightCorner.x - topLeftCorner.x, 2) + |
||||
|
Math.pow(topRightCorner.y - topLeftCorner.y, 2), |
||||
|
); |
||||
|
const height = Math.sqrt( |
||||
|
Math.pow(bottomLeftCorner.x - topLeftCorner.x, 2) + |
||||
|
Math.pow(bottomLeftCorner.y - topLeftCorner.y, 2), |
||||
|
); |
||||
|
|
||||
|
// Adjust minimum size based on canvas dimensions
|
||||
|
const minSize = Math.min(this.canvas.width, this.canvas.height) * 0.1; // 10% of the smaller dimension
|
||||
|
const isBlurry = |
||||
|
width < minSize || |
||||
|
height < minSize || |
||||
|
!code.data || |
||||
|
code.data.length === 0; |
||||
|
|
||||
|
logger.error(`[WebInlineQRScanner:${this.id}] QR Code detected:`, { |
||||
|
data: code.data, |
||||
|
location: code.location, |
||||
|
attempts: this.scanAttempts, |
||||
|
isBlurry, |
||||
|
dimensions: { |
||||
|
width, |
||||
|
height, |
||||
|
minSize, |
||||
|
canvasWidth: this.canvas.width, |
||||
|
canvasHeight: this.canvas.height, |
||||
|
relativeWidth: width / this.canvas.width, |
||||
|
relativeHeight: height / this.canvas.height, |
||||
|
}, |
||||
|
corners: { |
||||
|
topLeft: topLeftCorner, |
||||
|
topRight: topRightCorner, |
||||
|
bottomLeft: bottomLeftCorner, |
||||
|
}, |
||||
|
}); |
||||
|
|
||||
|
if (isBlurry) { |
||||
|
if (this.scanListener?.onError) { |
||||
|
this.scanListener.onError( |
||||
|
new Error( |
||||
|
"QR code detected but too blurry to read. Please hold the camera steady and ensure the QR code is well-lit.", |
||||
|
), |
||||
|
); |
||||
|
} |
||||
|
// Continue scanning if QR code is blurry
|
||||
|
this.animationFrameId = requestAnimationFrame(() => |
||||
|
this.scanQRCode(), |
||||
|
); |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
if (this.scanListener?.onScan) { |
||||
|
this.scanListener.onScan(code.data); |
||||
|
} |
||||
|
// Stop scanning after successful detection
|
||||
|
await this.stopScan(); |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
// Continue scanning if no QR code found
|
||||
|
this.animationFrameId = requestAnimationFrame(() => this.scanQRCode()); |
||||
|
} catch (error) { |
||||
|
logger.error(`[WebInlineQRScanner:${this.id}] Error scanning QR code:`, { |
||||
|
error: error instanceof Error ? error.message : String(error), |
||||
|
stack: error instanceof Error ? error.stack : undefined, |
||||
|
attempt: this.scanAttempts, |
||||
|
videoState: this.video |
||||
|
? { |
||||
|
readyState: this.video.readyState, |
||||
|
paused: this.video.paused, |
||||
|
ended: this.video.ended, |
||||
|
width: this.video.videoWidth, |
||||
|
height: this.video.videoHeight, |
||||
|
} |
||||
|
: null, |
||||
|
canvasState: this.canvas |
||||
|
? { |
||||
|
width: this.canvas.width, |
||||
|
height: this.canvas.height, |
||||
|
} |
||||
|
: null, |
||||
|
}); |
||||
|
if (this.scanListener?.onError) { |
||||
|
this.scanListener.onError( |
||||
|
error instanceof Error ? error : new Error(String(error)), |
||||
|
); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
async startScan(): Promise<void> { |
||||
|
if (this.isScanning) { |
||||
|
logger.error(`[WebInlineQRScanner:${this.id}] Scanner already running`); |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
try { |
||||
|
this.isScanning = true; |
||||
|
this.scanAttempts = 0; |
||||
|
this.lastScanTime = Date.now(); |
||||
|
this.updateCameraState("initializing", "Starting camera..."); |
||||
|
logger.error(`[WebInlineQRScanner:${this.id}] Starting scan`); |
||||
|
|
||||
|
// Get camera stream
|
||||
|
logger.error( |
||||
|
`[WebInlineQRScanner:${this.id}] Requesting camera stream...`, |
||||
|
); |
||||
|
this.stream = await navigator.mediaDevices.getUserMedia({ |
||||
|
video: { |
||||
|
facingMode: "environment", |
||||
|
width: { ideal: 1280 }, |
||||
|
height: { ideal: 720 }, |
||||
|
}, |
||||
|
}); |
||||
|
|
||||
|
this.updateCameraState("active", "Camera is active"); |
||||
|
|
||||
|
logger.error(`[WebInlineQRScanner:${this.id}] Camera stream obtained:`, { |
||||
|
tracks: this.stream.getTracks().map((t) => ({ |
||||
|
kind: t.kind, |
||||
|
label: t.label, |
||||
|
readyState: t.readyState, |
||||
|
})), |
||||
|
}); |
||||
|
|
||||
|
// Set up video element
|
||||
|
if (this.video) { |
||||
|
this.video.srcObject = this.stream; |
||||
|
await this.video.play(); |
||||
|
logger.error( |
||||
|
`[WebInlineQRScanner:${this.id}] Video element started playing`, |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
// Emit stream to component
|
||||
|
this.events.emit("stream", this.stream); |
||||
|
logger.error(`[WebInlineQRScanner:${this.id}] Stream event emitted`); |
||||
|
|
||||
|
// Start QR code scanning
|
||||
|
this.scanQRCode(); |
||||
|
} catch (error) { |
||||
|
this.isScanning = false; |
||||
|
const wrappedError = |
||||
|
error instanceof Error ? error : new Error(String(error)); |
||||
|
|
||||
|
// Update state based on error type
|
||||
|
if ( |
||||
|
wrappedError.name === "NotReadableError" || |
||||
|
wrappedError.name === "TrackStartError" |
||||
|
) { |
||||
|
this.updateCameraState( |
||||
|
"in_use", |
||||
|
"Camera is in use by another application", |
||||
|
); |
||||
|
} else { |
||||
|
this.updateCameraState("error", wrappedError.message); |
||||
|
} |
||||
|
|
||||
|
if (this.scanListener?.onError) { |
||||
|
this.scanListener.onError(wrappedError); |
||||
|
} |
||||
|
throw wrappedError; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
async stopScan(): Promise<void> { |
||||
|
if (!this.isScanning) { |
||||
|
logger.error( |
||||
|
`[WebInlineQRScanner:${this.id}] Scanner not running, nothing to stop`, |
||||
|
); |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
try { |
||||
|
logger.error(`[WebInlineQRScanner:${this.id}] Stopping scan`, { |
||||
|
scanAttempts: this.scanAttempts, |
||||
|
duration: Date.now() - this.lastScanTime, |
||||
|
}); |
||||
|
|
||||
|
// Stop animation frame
|
||||
|
if (this.animationFrameId !== null) { |
||||
|
cancelAnimationFrame(this.animationFrameId); |
||||
|
this.animationFrameId = null; |
||||
|
logger.error( |
||||
|
`[WebInlineQRScanner:${this.id}] Animation frame cancelled`, |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
// Stop video
|
||||
|
if (this.video) { |
||||
|
this.video.pause(); |
||||
|
this.video.srcObject = null; |
||||
|
logger.error(`[WebInlineQRScanner:${this.id}] Video element stopped`); |
||||
|
} |
||||
|
|
||||
|
// Stop all tracks in the stream
|
||||
|
if (this.stream) { |
||||
|
this.stream.getTracks().forEach((track) => { |
||||
|
logger.error(`[WebInlineQRScanner:${this.id}] Stopping track:`, { |
||||
|
kind: track.kind, |
||||
|
label: track.label, |
||||
|
readyState: track.readyState, |
||||
|
}); |
||||
|
track.stop(); |
||||
|
}); |
||||
|
this.stream = null; |
||||
|
} |
||||
|
|
||||
|
// Emit stream stopped event
|
||||
|
this.events.emit("stream", null); |
||||
|
logger.error( |
||||
|
`[WebInlineQRScanner:${this.id}] Stream stopped event emitted`, |
||||
|
); |
||||
|
} catch (error) { |
||||
|
logger.error( |
||||
|
`[WebInlineQRScanner:${this.id}] Error stopping scan:`, |
||||
|
error, |
||||
|
); |
||||
|
this.updateCameraState("error", "Error stopping camera"); |
||||
|
throw error; |
||||
|
} finally { |
||||
|
this.isScanning = false; |
||||
|
logger.error(`[WebInlineQRScanner:${this.id}] Scan stopped successfully`); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
addListener(listener: ScanListener): void { |
||||
|
logger.error(`[WebInlineQRScanner:${this.id}] Adding scan listener`); |
||||
|
this.scanListener = listener; |
||||
|
} |
||||
|
|
||||
|
onStream(callback: (stream: MediaStream | null) => void): void { |
||||
|
logger.error( |
||||
|
`[WebInlineQRScanner:${this.id}] Adding stream event listener`, |
||||
|
); |
||||
|
this.events.on("stream", callback); |
||||
|
} |
||||
|
|
||||
|
async cleanup(): Promise<void> { |
||||
|
try { |
||||
|
logger.error(`[WebInlineQRScanner:${this.id}] Starting cleanup`); |
||||
|
await this.stopScan(); |
||||
|
this.events.removeAllListeners(); |
||||
|
logger.error(`[WebInlineQRScanner:${this.id}] Event listeners removed`); |
||||
|
|
||||
|
// Clean up DOM elements
|
||||
|
if (this.video) { |
||||
|
this.video.remove(); |
||||
|
this.video = null; |
||||
|
logger.error(`[WebInlineQRScanner:${this.id}] Video element removed`); |
||||
|
} |
||||
|
if (this.canvas) { |
||||
|
this.canvas.remove(); |
||||
|
this.canvas = null; |
||||
|
logger.error(`[WebInlineQRScanner:${this.id}] Canvas element removed`); |
||||
|
} |
||||
|
this.context = null; |
||||
|
logger.error( |
||||
|
`[WebInlineQRScanner:${this.id}] Cleanup completed successfully`, |
||||
|
); |
||||
|
} catch (error) { |
||||
|
logger.error( |
||||
|
`[WebInlineQRScanner:${this.id}] Error during cleanup:`, |
||||
|
error, |
||||
|
); |
||||
|
this.updateCameraState("error", "Error during cleanup"); |
||||
|
throw error; |
||||
|
} |
||||
|
} |
||||
|
} |
@ -0,0 +1,69 @@ |
|||||
|
// QR Scanner Service Types
|
||||
|
|
||||
|
/** |
||||
|
* Listener interface for QR code scan events |
||||
|
*/ |
||||
|
export interface ScanListener { |
||||
|
/** Called when a QR code is successfully scanned */ |
||||
|
onScan: (result: string) => void; |
||||
|
/** Called when an error occurs during scanning */ |
||||
|
onError?: (error: Error) => void; |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Options for configuring the QR scanner |
||||
|
*/ |
||||
|
export interface QRScannerOptions { |
||||
|
/** Camera to use ('front' or 'back') */ |
||||
|
camera?: "front" | "back"; |
||||
|
/** Whether to show a preview of the camera feed */ |
||||
|
showPreview?: boolean; |
||||
|
/** Whether to play a sound on successful scan */ |
||||
|
playSound?: boolean; |
||||
|
} |
||||
|
|
||||
|
export type CameraState = |
||||
|
| "initializing" // Camera is being initialized
|
||||
|
| "ready" // Camera is ready to use
|
||||
|
| "active" // Camera is actively streaming
|
||||
|
| "in_use" // Camera is in use by another application
|
||||
|
| "permission_denied" // Camera permission was denied
|
||||
|
| "not_found" // No camera found on device
|
||||
|
| "error" // Generic error state
|
||||
|
| "off"; // Camera is off/stopped
|
||||
|
|
||||
|
export interface CameraStateListener { |
||||
|
onStateChange: (state: CameraState, message?: string) => void; |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* Interface for QR scanner service implementations |
||||
|
*/ |
||||
|
export interface QRScannerService { |
||||
|
/** Check if camera permissions are granted */ |
||||
|
checkPermissions(): Promise<boolean>; |
||||
|
|
||||
|
/** Request camera permissions from the user */ |
||||
|
requestPermissions(): Promise<boolean>; |
||||
|
|
||||
|
/** Check if QR scanning is supported on this device */ |
||||
|
isSupported(): Promise<boolean>; |
||||
|
|
||||
|
/** Start scanning for QR codes */ |
||||
|
startScan(options?: QRScannerOptions): Promise<void>; |
||||
|
|
||||
|
/** Stop scanning for QR codes */ |
||||
|
stopScan(): Promise<void>; |
||||
|
|
||||
|
/** Add a listener for scan events */ |
||||
|
addListener(listener: ScanListener): void; |
||||
|
|
||||
|
/** Add a listener for camera state changes */ |
||||
|
addCameraStateListener(listener: CameraStateListener): void; |
||||
|
|
||||
|
/** Remove a camera state listener */ |
||||
|
removeCameraStateListener(listener: CameraStateListener): void; |
||||
|
|
||||
|
/** Clean up scanner resources */ |
||||
|
cleanup(): Promise<void>; |
||||
|
} |
Some files were not shown because too many files changed in this diff
Loading…
Reference in new issue