Browse Source

Merge branch 'master' into gifting-ui-2025-05

gifting-ui-2025-05
Jose Olarte III 3 days ago
parent
commit
140c36a416
  1. 153
      .cursor/rules/absurd-sql.mdc
  2. 292
      .cursor/rules/architectural_decision_record.mdc
  3. 222
      .cursor/rules/crowd-funder-for-time-pwa/docs/camera-implementation.mdc
  4. 276
      .cursor/rules/timesafari.mdc
  5. 267
      .cursor/rules/wa-sqlite.mdc
  6. 3
      .env.development
  7. 6
      .env.example
  8. 1
      .env.production
  9. 1
      .env.staging
  10. 9
      .eslintrc.js
  11. 4
      .gitignore
  12. 536
      BUILDING.md
  13. 7
      CHANGELOG.md
  14. 36
      Dockerfile
  15. 24
      README.md
  16. 84
      TASK_storage.md
  17. 25
      android/.gitignore
  18. BIN
      android/.gradle/buildOutputCleanup/buildOutputCleanup.lock
  19. 2
      android/.gradle/buildOutputCleanup/cache.properties
  20. BIN
      android/.gradle/file-system.probe
  21. 2
      android/app/.gitignore
  22. 8
      android/app/build.gradle
  23. 6
      android/app/capacitor.build.gradle
  24. 28
      android/app/google-services.json
  25. 14
      android/app/src/main/AndroidManifest.xml
  26. 37
      android/app/src/main/assets/capacitor.config.json
  27. 24
      android/app/src/main/assets/capacitor.plugins.json
  28. 17
      android/app/src/main/assets/public/index.html
  29. 10
      android/app/src/main/java/app/timesafari/MainActivity.java
  30. 5
      android/app/src/main/java/timesafari/app/MainActivity.java
  31. 1
      android/app/src/main/res/xml/file_paths.xml
  32. 2
      android/build.gradle
  33. 18
      android/capacitor.settings.gradle
  34. 2
      android/gradle/wrapper/gradle-wrapper.properties
  35. 2
      assets/README.md
  36. BIN
      assets/icon.png
  37. BIN
      assets/splash-dark.png
  38. BIN
      assets/splash.png
  39. 4
      build.sh
  40. 56
      capacitor.config.json
  41. 25
      capacitor.config.ts
  42. 78
      doc/DEEP_LINKS.md
  43. 399
      doc/dexie-to-sqlite-mapping.md
  44. 613
      doc/migration-to-wa-sqlite.md
  45. 2
      doc/openssl_signing_console.rst
  46. 805
      doc/qr-code-implementation-guide.md
  47. 339
      doc/secure-storage-implementation.md
  48. 329
      doc/storage-implementation-checklist.md
  49. 0
      doc/web-push.md
  50. 2
      index.html
  51. 1
      ios/.gitignore
  52. 159
      ios/App/App.xcodeproj/project.pbxproj
  53. 2
      ios/App/App.xcworkspace/contents.xcworkspacedata
  54. 5
      ios/App/App/AppDelegate.swift
  55. 6
      ios/App/App/Info.plist
  56. 20
      ios/App/App/entitlements.mac.plist
  57. 11
      ios/App/Podfile
  58. 144
      ios/App/Podfile.lock
  59. 5
      ios/App/app_privacy_manifest_fixer/.gitignore
  60. 58
      ios/App/app_privacy_manifest_fixer/CHANGELOG.md
  61. 80
      ios/App/app_privacy_manifest_fixer/Common/constants.sh
  62. 125
      ios/App/app_privacy_manifest_fixer/Common/utils.sh
  63. 80
      ios/App/app_privacy_manifest_fixer/Helper/xcode_install_helper.rb
  64. 63
      ios/App/app_privacy_manifest_fixer/Helper/xcode_uninstall_helper.rb
  65. 21
      ios/App/app_privacy_manifest_fixer/LICENSE
  66. 240
      ios/App/app_privacy_manifest_fixer/README.md
  67. 240
      ios/App/app_privacy_manifest_fixer/README.zh-CN.md
  68. 124
      ios/App/app_privacy_manifest_fixer/Report/report-template.html
  69. 285
      ios/App/app_privacy_manifest_fixer/Report/report.sh
  70. 55
      ios/App/app_privacy_manifest_fixer/Templates/AppTemplate.xcprivacy
  71. 55
      ios/App/app_privacy_manifest_fixer/Templates/FrameworkTemplate.xcprivacy
  72. 0
      ios/App/app_privacy_manifest_fixer/Templates/UserTemplates/.gitkeep
  73. 1
      ios/App/app_privacy_manifest_fixer/VERSION
  74. 29
      ios/App/app_privacy_manifest_fixer/clean.sh
  75. 490
      ios/App/app_privacy_manifest_fixer/fixer.sh
  76. 71
      ios/App/app_privacy_manifest_fixer/install.sh
  77. 46
      ios/App/app_privacy_manifest_fixer/uninstall.sh
  78. 108
      ios/App/app_privacy_manifest_fixer/upgrade.sh
  79. 29
      main.js
  80. 7623
      package-lock.json
  81. 71
      package.json
  82. 2
      pkgx.yaml
  83. 1
      requirements.txt
  84. 245
      scripts/build-electron.js
  85. 15
      scripts/copy-wasm.js
  86. 22
      scripts/copy-web-assets.sh
  87. 22
      scripts/generate-icons.sh
  88. 4
      scripts/openssl_signing_console.sh
  89. 62
      scripts/test-ios.js
  90. 40
      src/App.vue
  91. 191
      src/components/ActivityListItem.vue
  92. 216
      src/components/DataExportSection.vue
  93. 42
      src/components/FeedFilters.vue
  94. 40
      src/components/GiftedDialog.vue
  95. 36
      src/components/GiftedPrompts.vue
  96. 6
      src/components/HiddenDidDialog.vue
  97. 713
      src/components/ImageMethodDialog.vue
  98. 1
      src/components/ImageViewer.vue
  99. 44
      src/components/MembersList.vue
  100. 22
      src/components/OfferDialog.vue

153
.cursor/rules/absurd-sql.mdc

@ -0,0 +1,153 @@
---
description:
globs:
alwaysApply: true
---
# Absurd SQL - Cursor Development Guide
## Project Overview
Absurd SQL is a backend implementation for sql.js that enables persistent SQLite databases in the browser by using IndexedDB as a block storage system. This guide provides rules and best practices for developing with this project in Cursor.
## Project Structure
```
absurd-sql/
├── src/ # Source code
├── dist/ # Built files
├── package.json # Dependencies and scripts
├── rollup.config.js # Build configuration
└── jest.config.js # Test configuration
```
## Development Rules
### 1. Worker Thread Requirements
- All SQL operations MUST be performed in a worker thread
- Main thread should only handle worker initialization and communication
- Never block the main thread with database operations
### 2. Code Organization
- Keep worker code in separate files (e.g., `*.worker.js`)
- Use ES modules for imports/exports
- Follow the project's existing module structure
### 3. Required Headers
When developing locally or deploying, ensure these headers are set:
```
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp
```
### 4. Browser Compatibility
- Primary target: Modern browsers with SharedArrayBuffer support
- Fallback mode: Safari (with limitations)
- Always test in both modes
### 5. Database Configuration
Recommended database settings:
```sql
PRAGMA journal_mode=MEMORY;
PRAGMA page_size=8192; -- Optional, but recommended
```
### 6. Development Workflow
1. Install dependencies:
```bash
yarn add @jlongster/sql.js absurd-sql
```
2. Development commands:
- `yarn build` - Build the project
- `yarn jest` - Run tests
- `yarn serve` - Start development server
### 7. Testing Guidelines
- Write tests for both SharedArrayBuffer and fallback modes
- Use Jest for testing
- Include performance benchmarks for critical operations
### 8. Performance Considerations
- Use bulk operations when possible
- Monitor read/write performance
- Consider using transactions for multiple operations
- Avoid unnecessary database connections
### 9. Error Handling
- Implement proper error handling for:
- Worker initialization failures
- Database connection issues
- Concurrent access conflicts (in fallback mode)
- Storage quota exceeded scenarios
### 10. Security Best Practices
- Never expose database operations directly to the client
- Validate all SQL queries
- Implement proper access controls
- Handle sensitive data appropriately
### 11. Code Style
- Follow ESLint configuration
- Use async/await for asynchronous operations
- Document complex database operations
- Include comments for non-obvious optimizations
### 12. Debugging
- Use `jest-debug` for debugging tests
- Monitor IndexedDB usage in browser dev tools
- Check worker communication in console
- Use performance monitoring tools
## Common Patterns
### Worker Initialization
```javascript
// Main thread
import { initBackend } from 'absurd-sql/dist/indexeddb-main-thread';
function init() {
let worker = new Worker(new URL('./index.worker.js', import.meta.url));
initBackend(worker);
}
```
### Database Setup
```javascript
// Worker thread
import initSqlJs from '@jlongster/sql.js';
import { SQLiteFS } from 'absurd-sql';
import IndexedDBBackend from 'absurd-sql/dist/indexeddb-backend';
async function setupDatabase() {
let SQL = await initSqlJs({ locateFile: file => file });
let sqlFS = new SQLiteFS(SQL.FS, new IndexedDBBackend());
SQL.register_for_idb(sqlFS);
SQL.FS.mkdir('/sql');
SQL.FS.mount(sqlFS, {}, '/sql');
return new SQL.Database('/sql/db.sqlite', { filename: true });
}
```
## Troubleshooting
### Common Issues
1. SharedArrayBuffer not available
- Check COOP/COEP headers
- Verify browser support
- Test fallback mode
2. Worker initialization failures
- Check file paths
- Verify module imports
- Check browser console for errors
3. Performance issues
- Monitor IndexedDB usage
- Check for unnecessary operations
- Verify transaction usage
## Resources
- [Project Demo](https://priceless-keller-d097e5.netlify.app/)
- [Example Project](https://github.com/jlongster/absurd-example-project)
- [Blog Post](https://jlongster.com/future-sql-web)
- [SQL.js Documentation](https://github.com/sql-js/sql.js/)

292
.cursor/rules/architectural_decision_record.mdc

@ -0,0 +1,292 @@
---
description:
globs:
alwaysApply: true
---
# TimeSafari Cross-Platform Architecture Guide
## 1. Platform Support Matrix
| Feature | Web (PWA) | Capacitor (Mobile) | Electron (Desktop) | PyWebView (Desktop) |
|---------|-----------|-------------------|-------------------|-------------------|
| QR Code Scanning | WebInlineQRScanner | @capacitor-mlkit/barcode-scanning | Not Implemented | Not Implemented |
| Deep Linking | URL Parameters | App URL Open Events | Not Implemented | Not Implemented |
| File System | Limited (Browser API) | Capacitor Filesystem | Electron fs | PyWebView Python Bridge |
| Camera Access | MediaDevices API | Capacitor Camera | Not Implemented | Not Implemented |
| Platform Detection | Web APIs | Capacitor.isNativePlatform() | process.env checks | process.env checks |
## 2. Project Structure
### 2.1 Core Directories
```
src/
├── components/ # Vue components
├── services/ # Platform services and business logic
├── views/ # Page components
├── router/ # Vue router configuration
├── types/ # TypeScript type definitions
├── utils/ # Utility functions
├── lib/ # Core libraries
├── platforms/ # Platform-specific implementations
├── electron/ # Electron-specific code
├── constants/ # Application constants
├── db/ # Database related code
├── interfaces/ # TypeScript interfaces and type definitions
└── assets/ # Static assets
```
### 2.2 Entry Points
```
src/
├── main.ts # Base entry
├── main.common.ts # Shared initialization
├── main.capacitor.ts # Mobile entry
├── main.electron.ts # Electron entry
├── main.pywebview.ts # PyWebView entry
└── main.web.ts # Web/PWA entry
```
### 2.3 Build Configurations
```
root/
├── vite.config.common.mts # Shared config
├── vite.config.capacitor.mts # Mobile build
├── vite.config.electron.mts # Electron build
├── vite.config.pywebview.mts # PyWebView build
├── vite.config.web.mts # Web/PWA build
└── vite.config.utils.mts # Build utilities
```
## 3. Service Architecture
### 3.1 Service Organization
```
services/
├── QRScanner/ # QR code scanning service
│ ├── WebInlineQRScanner.ts
│ └── interfaces.ts
├── platforms/ # Platform-specific services
│ ├── WebPlatformService.ts
│ ├── CapacitorPlatformService.ts
│ ├── ElectronPlatformService.ts
│ └── PyWebViewPlatformService.ts
└── factory/ # Service factories
└── PlatformServiceFactory.ts
```
### 3.2 Service Factory Pattern
```typescript
// PlatformServiceFactory.ts
export class PlatformServiceFactory {
private static instance: PlatformService | null = null;
public static getInstance(): PlatformService {
if (!PlatformServiceFactory.instance) {
const platform = process.env.VITE_PLATFORM || "web";
PlatformServiceFactory.instance = createPlatformService(platform);
}
return PlatformServiceFactory.instance;
}
}
```
## 4. Feature Implementation Guidelines
### 4.1 QR Code Scanning
1. **Service Interface**
```typescript
interface QRScannerService {
checkPermissions(): Promise<boolean>;
requestPermissions(): Promise<boolean>;
isSupported(): Promise<boolean>;
startScan(): Promise<void>;
stopScan(): Promise<void>;
addListener(listener: ScanListener): void;
onStream(callback: (stream: MediaStream | null) => void): void;
cleanup(): Promise<void>;
}
```
2. **Platform-Specific Implementation**
```typescript
// WebInlineQRScanner.ts
export class WebInlineQRScanner implements QRScannerService {
private scanListener: ScanListener | null = null;
private isScanning = false;
private stream: MediaStream | null = null;
private events = new EventEmitter();
// Implementation of interface methods
}
```
### 4.2 Deep Linking
1. **URL Structure**
```typescript
// Format: timesafari://<route>[/<param>][?queryParam1=value1]
interface DeepLinkParams {
route: string;
params?: Record<string, string>;
query?: Record<string, string>;
}
```
2. **Platform Handlers**
```typescript
// Capacitor
App.addListener("appUrlOpen", handleDeepLink);
// Web
router.beforeEach((to, from, next) => {
handleWebDeepLink(to.query);
});
```
## 5. Build Process
### 5.1 Environment Configuration
```typescript
// vite.config.common.mts
export function createBuildConfig(mode: string) {
return {
define: {
'process.env.VITE_PLATFORM': JSON.stringify(mode),
'process.env.VITE_PWA_ENABLED': JSON.stringify(!isNative),
__IS_MOBILE__: JSON.stringify(isCapacitor),
__USE_QR_READER__: JSON.stringify(!isCapacitor)
}
};
}
```
### 5.2 Platform-Specific Builds
```bash
# Build commands from package.json
"build:web": "vite build --config vite.config.web.mts",
"build:capacitor": "vite build --config vite.config.capacitor.mts",
"build:electron": "vite build --config vite.config.electron.mts",
"build:pywebview": "vite build --config vite.config.pywebview.mts"
```
## 6. Testing Strategy
### 6.1 Test Configuration
```typescript
// playwright.config-local.ts
const config: PlaywrightTestConfig = {
projects: [
{
name: 'web',
use: { browserName: 'chromium' }
},
{
name: 'mobile',
use: { ...devices['Pixel 5'] }
}
]
};
```
### 6.2 Platform-Specific Tests
```typescript
test('QR scanning works on mobile', async ({ page }) => {
test.skip(!process.env.MOBILE_TEST, 'Mobile-only test');
// Test implementation
});
```
## 7. Error Handling
### 7.1 Global Error Handler
```typescript
function setupGlobalErrorHandler(app: VueApp) {
app.config.errorHandler = (err, instance, info) => {
logger.error("[App Error]", {
error: err,
info,
component: instance?.$options.name
});
};
}
```
### 7.2 Platform-Specific Error Handling
```typescript
// API error handling for Capacitor
if (process.env.VITE_PLATFORM === 'capacitor') {
logger.error(`[Capacitor API Error] ${endpoint}:`, {
message: error.message,
status: error.response?.status
});
}
```
## 8. Best Practices
### 8.1 Code Organization
- Use platform-specific directories for unique implementations
- Share common code through service interfaces
- Implement feature detection before using platform capabilities
- Keep platform-specific code isolated in dedicated directories
- Use TypeScript interfaces for cross-platform compatibility
### 8.2 Platform Detection
```typescript
const platformService = PlatformServiceFactory.getInstance();
const capabilities = platformService.getCapabilities();
if (capabilities.hasCamera) {
// Implement camera features
}
```
### 8.3 Feature Implementation
1. Define platform-agnostic interface
2. Create platform-specific implementations
3. Use factory pattern for instantiation
4. Implement graceful fallbacks
5. Add comprehensive error handling
6. Use dependency injection for better testability
## 9. Dependency Management
### 9.1 Platform-Specific Dependencies
```json
{
"dependencies": {
"@capacitor/core": "^6.2.0",
"electron": "^33.2.1",
"vue": "^3.4.0"
}
}
```
### 9.2 Conditional Loading
```typescript
if (process.env.VITE_PLATFORM === 'capacitor') {
await import('@capacitor/core');
}
```
## 10. Security Considerations
### 10.1 Permission Handling
```typescript
async checkPermissions(): Promise<boolean> {
if (platformService.isCapacitor()) {
return await checkNativePermissions();
}
return await checkWebPermissions();
}
```
### 10.2 Data Storage
- Use secure storage mechanisms for sensitive data
- Implement proper encryption for stored data
- Follow platform-specific security guidelines
- Regular security audits and updates
This document should be updated as new features are added or platform-specific implementations change. Regular reviews ensure it remains current with the codebase.

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

@ -0,0 +1,222 @@
---
description:
globs:
alwaysApply: false
---
# Camera Implementation Documentation
## Overview
This document describes how camera functionality is implemented across the TimeSafari application. The application uses cameras for two main purposes:
1. QR Code scanning
2. Photo capture
## Components
### QRScannerDialog.vue
Primary component for QR code scanning in web browsers.
**Key Features:**
- Uses `qrcode-stream` for web-based QR scanning
- Supports both front and back cameras
- Provides real-time camera status feedback
- Implements error handling with user-friendly messages
- Includes camera switching functionality
**Camera Access Flow:**
1. Checks for camera API availability
2. Enumerates available video devices
3. Requests camera permissions
4. Initializes camera stream with preferred settings
5. Handles various error conditions with specific messages
### PhotoDialog.vue
Component for photo capture and selection.
**Key Features:**
- Cross-platform photo capture interface
- Image cropping capabilities
- File selection fallback
- Unified interface for different platforms
## Services
### QRScanner Services
#### WebDialogQRScanner
Web-based implementation of QR scanning.
**Key Methods:**
- `checkPermissions()`: Verifies camera permission status
- `requestPermissions()`: Requests camera access
- `isSupported()`: Checks for camera API support
- Handles various error conditions with specific messages
#### CapacitorQRScanner
Native implementation using Capacitor's MLKit.
**Key Features:**
- Uses `@capacitor-mlkit/barcode-scanning`
- Supports both front and back cameras
- Implements permission management
- Provides continuous scanning capability
### Platform Services
#### WebPlatformService
Web-specific implementation of platform features.
**Camera Capabilities:**
- Uses HTML5 file input with capture attribute
- Falls back to file selection if camera unavailable
- Processes captured images for consistent format
#### CapacitorPlatformService
Native implementation using Capacitor.
**Camera Features:**
- Uses `Camera.getPhoto()` for native camera access
- Supports image editing
- Configures high-quality image capture
- Handles base64 image processing
#### ElectronPlatformService
Desktop implementation (currently unimplemented).
**Status:**
- Camera functionality not yet implemented
- Planned to use Electron's media APIs
## Platform-Specific Considerations
### iOS
- Requires `NSCameraUsageDescription` in Info.plist
- Supports both front and back cameras
- Implements proper permission handling
### Android
- Requires camera permissions in manifest
- Supports both front and back cameras
- Handles permission requests through Capacitor
### Web
- Requires HTTPS for camera access
- Implements fallback mechanisms
- Handles browser compatibility issues
## Error Handling
### Common Error Scenarios
1. No camera found
2. Permission denied
3. Camera in use by another application
4. HTTPS required
5. Browser compatibility issues
### Error Response
- User-friendly error messages
- Troubleshooting tips
- Clear instructions for resolution
- Platform-specific guidance
## Security Considerations
### Permission Management
- Explicit permission requests
- Permission state tracking
- Graceful handling of denied permissions
### Data Handling
- Secure image processing
- Proper cleanup of camera resources
- No persistent storage of camera data
## Best Practices
### Camera Access
1. Always check for camera availability
2. Request permissions explicitly
3. Handle all error conditions
4. Provide clear user feedback
5. Implement proper cleanup
### Performance
1. Optimize camera resolution
2. Implement proper resource cleanup
3. Handle camera switching efficiently
4. Manage memory usage
### User Experience
1. Clear status indicators
2. Intuitive camera controls
3. Helpful error messages
4. Smooth camera switching
5. Responsive UI feedback
## Future Improvements
### Planned Enhancements
1. Implement Electron camera support
2. Add advanced camera features
3. Improve error handling
4. Enhance user feedback
5. Optimize performance
### Known Issues
1. Electron camera implementation pending
2. Some browser compatibility limitations
3. Platform-specific quirks to address
## Dependencies
### Key Packages
- `@capacitor-mlkit/barcode-scanning`
- `qrcode-stream`
- `vue-picture-cropper`
- Platform-specific camera APIs
## Testing
### Test Scenarios
1. Permission handling
2. Camera switching
3. Error conditions
4. Platform compatibility
5. Performance metrics
### Test Environment
- Multiple browsers
- iOS and Android devices
- Desktop platforms
- Various network conditions

276
.cursor/rules/timesafari.mdc

@ -0,0 +1,276 @@
---
description:
globs:
alwaysApply: true
---
---
description:
globs:
alwaysApply: true
---
# Time Safari Context
## Project Overview
Time Safari is an application designed to foster community building through gifts, gratitude, and collaborative projects. The app should make it extremely easy and intuitive for users of any age and capability to recognize contributions, build trust networks, and organize collective action. It is built on services that preserve privacy and data sovereignty.
The ultimate goals of Time Safari are two-fold:
1. **Connect** Make it easy, rewarding, and non-threatening for people to connect with others who have similar interests, and to initiate activities together. This helps people accomplish and learn from other individuals in less-structured environments; moreover, it helps them discover who they want to continue to support and with whom they want to maintain relationships.
2. **Reveal** Widely advertise the great support and rewards that are being given and accepted freely, especially non-monetary ones. Using visuals and text, display the kind of impact that gifts are making in the lives of others. Also show useful and engaging reports of project statistics and personal accomplishments.
## Core Approaches
Time Safari should help everyday users build meaningful connections and organize collective efforts by:
1. **Recognizing Contributions**: Creating permanent, verifiable records of gifts and contributions people give to each other and their communities.
2. **Facilitating Collaboration**: Making it ridiculously easy for people to ask for or propose help on projects and interests that matter to them.
3. **Building Trust Networks**: Enabling users to maintain their network and activity visibility. Developing reputation through verified contributions and references, which can be selectively shown to others outside the network.
4. **Preserving Privacy**: Ensuring personal identifiers are only shared with explicitly authorized contacts, allowing private individuals including children to participate safely.
5. **Engaging Content**: Displaying people's records in compelling stories, and highlighting those projects that are lifting people's lives long-term, both in physical support and in emotional-spiritual-creative thriving.
## Technical Foundation
This application is built on a privacy-preserving claims architecture (via endorser.ch) with these key characteristics:
- **Decentralized Identifiers (DIDs)**: User identities are based on public/private key pairs stored on their devices
- **Cryptographic Verification**: All claims and confirmations are cryptographically signed
- **User-Controlled Visibility**: Users explicitly control who can see their identifiers and data
- **Merkle-Chained Claims**: Claims are cryptographically chained for verification and integrity
- **Native and Web App**: Works on Capacitor (iOS, Android), Desktop (Electron and CEFPython), and web browsers
## User Journey
The typical progression of usage follows these stages:
1. **Gratitude & Recognition**: Users begin by expressing and recording gratitude for gifts received, building a foundation of acknowledgment.
2. **Project Proposals**: Users propose projects and ideas, reaching out to connect with others who share similar interests.
3. **Action Triggers**: Offers of help serve as triggers and motivations to execute proposed projects, moving from ideas to action.
## Context for LLM Development
When developing new functionality for Time Safari, consider these design principles:
1. **Accessibility First**: Features should be usable by non-technical users with minimal learning curve.
2. **Privacy by Design**: All features must respect user privacy and data sovereignty.
3. **Progressive Enhancement**: Core functionality should work across all devices, with richer experiences where supported.
4. **Voluntary Collaboration**: The system should enable but never coerce participation.
5. **Trust Building**: Features should help build verifiable trust between users.
6. **Network Effects**: Consider how features scale as more users join the platform.
7. **Low Resource Requirements**: The system should be lightweight enough to run on inexpensive devices users already own.
## Use Cases to Support
LLM development should focus on enhancing these key use cases:
1. **Community Building**: Tools that help people find others with shared interests and values.
2. **Project Coordination**: Features that make it easy to propose collaborative projects and to submit suggestions and offers to existing ones.
3. **Reputation Building**: Methods for users to showcase their contributions and reliability, in contexts where they explicitly reveal that information.
4. **Governance Experimentation**: Features that facilitate decision-making and collective governance.
## Constraints
When developing new features, be mindful of these constraints:
1. **Privacy Preservation**: User identifiers must remain private except when explicitly shared.
2. **Platform Limitations**: Features must work within the constraints of the target app platforms, while aiming to leverage the best platform technology available.
3. **Endorser API Limitations**: Backend features are constrained by the endorser.ch API capabilities.
4. **Performance on Low-End Devices**: The application should remain performant on older/simpler devices.
5. **Offline-First When Possible**: Key functionality should work offline when feasible.
## Project Technologies
- Typescript using ES6 classes using vue-facing-decorator
- TailwindCSS
- Vite Build Tool
- Playwright E2E testing
- IndexDB
- Camera, Image uploads, QR Code reader, ...
## Mobile Features
- Deep Linking
- Local Notifications via a custom Capacitor plugin
## Project Architecture
- The application must work on web browser, PWA (Progressive Web Application), desktop via Electron, and mobile via Capacitor
- Building for each platform is managed via Vite
## Core Development Principles
### DRY development
- **Code Reuse**
- Extract common functionality into utility functions
- Create reusable components for UI patterns
- Implement service classes for shared business logic
- Use mixins for cross-cutting concerns
- Leverage TypeScript interfaces for shared type definitions
- **Component Patterns**
- Create base components for common UI elements
- Implement higher-order components for shared behavior
- Use slot patterns for flexible component composition
- Create composable services for business logic
- Implement factory patterns for component creation
- **State Management**
- Centralize state in Pinia stores
- Use computed properties for derived state
- Implement shared state selectors
- Create reusable state mutations
- Use action creators for common operations
- **Error Handling**
- Implement centralized error handling
- Create reusable error components
- Use error boundary components
- Implement consistent error logging
- Create error type definitions
- **Type Definitions**
- Create shared interfaces for common data structures
- Use type aliases for complex types
- Implement generic types for reusable components
- Create utility types for common patterns
- Use discriminated unions for state management
- **API Integration**
- Create reusable API client classes
- Implement request/response interceptors
- Use consistent error handling patterns
- Create type-safe API endpoints
- Implement caching strategies
- **Platform Services**
- Abstract platform-specific code behind interfaces
- Create platform-agnostic service layers
- Implement feature detection
- Use dependency injection for services
- Create service factories
- **Testing**
- Create reusable test utilities
- Implement test factories
- Use shared test configurations
- Create reusable test helpers
- Implement consistent test patterns
### SOLID Principles
- **Single Responsibility**: Each class/component should have only one reason to change
- Components should focus on one specific feature (e.g., QR scanning, DID management)
- Services should handle one type of functionality (e.g., platform services, crypto services)
- Utilities should provide focused helper functions
- **Open/Closed**: Software entities should be open for extension but closed for modification
- Use interfaces for service definitions
- Implement plugin architecture for platform-specific features
- Allow component behavior extension through props and events
- **Liskov Substitution**: Objects should be replaceable with their subtypes
- Platform services should work consistently across web/mobile
- Authentication providers should be interchangeable
- Storage implementations should be swappable
- **Interface Segregation**: Clients shouldn't depend on interfaces they don't use
- Break down large service interfaces into smaller, focused ones
- Component props should be minimal and purposeful
- Event emissions should be specific and targeted
- **Dependency Inversion**: High-level modules shouldn't depend on low-level modules
- Use dependency injection for services
- Abstract platform-specific code behind interfaces
- Implement factory patterns for component creation
### Law of Demeter
- Components should only communicate with immediate dependencies
- Avoid chaining method calls (e.g., `this.service.getUser().getProfile().getName()`)
- Use mediator patterns for complex component interactions
- Implement facade patterns for subsystem access
- Keep component communication through defined events and props
### Composition over Inheritance
- Prefer building components through composition
- Use mixins for shared functionality
- Implement feature toggles through props
- Create higher-order components for common patterns
- Use service composition for complex features
### Interface Segregation
- Define clear interfaces for services
- Keep component APIs minimal and focused
- Split large interfaces into smaller, specific ones
- Use TypeScript interfaces for type definitions
- Implement role-based interfaces for different use cases
### Fail Fast
- Validate inputs early in the process
- Use TypeScript strict mode
- Implement comprehensive error handling
- Add runtime checks for critical operations
- Use assertions for development-time validation
### Principle of Least Astonishment
- Follow Vue.js conventions consistently
- Use familiar naming patterns
- Implement predictable component behaviors
- Maintain consistent error handling
- Keep UI interactions intuitive
### Information Hiding
- Encapsulate implementation details
- Use private class members
- Implement proper access modifiers
- Hide complex logic behind simple interfaces
- Use TypeScript's access modifiers effectively
### Single Source of Truth
- Use Pinia for state management
- Maintain one source for user data
- Centralize configuration management
- Use computed properties for derived state
- Implement proper state synchronization
### Principle of Least Privilege
- Implement proper access control
- Use minimal required permissions
- Follow privacy-by-design principles
- Restrict component access to necessary data
- Implement proper authentication/authorization
### Continuous Integration/Continuous Deployment (CI/CD)
- Automated testing on every commit
- Consistent build process across platforms
- Automated deployment pipelines
- Quality gates for code merging
- Environment-specific configurations
This expanded documentation provides:
1. Clear principles for development
2. Practical implementation guidelines
3. Real-world examples
4. TypeScript integration
5. Best practices for Time Safari

267
.cursor/rules/wa-sqlite.mdc

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

3
.env.development

@ -2,11 +2,12 @@
# iOS doesn't like spaces in the app title. # iOS doesn't like spaces in the app title.
TIME_SAFARI_APP_TITLE="TimeSafari_Dev" TIME_SAFARI_APP_TITLE="TimeSafari_Dev"
VITE_APP_SERVER=http://localhost:3000 VITE_APP_SERVER=http://localhost:8080
# This is the claim ID for actions in the BVC project, with the JWT ID on this environment (not production). # This is the claim ID for actions in the BVC project, with the JWT ID on this environment (not production).
VITE_BVC_MEETUPS_PROJECT_CLAIM_ID=https://endorser.ch/entity/01HWE8FWHQ1YGP7GFZYYPS272F VITE_BVC_MEETUPS_PROJECT_CLAIM_ID=https://endorser.ch/entity/01HWE8FWHQ1YGP7GFZYYPS272F
VITE_DEFAULT_ENDORSER_API_SERVER=http://localhost:3000 VITE_DEFAULT_ENDORSER_API_SERVER=http://localhost:3000
# Using shared server by default to ease setup, which works for shared test users. # Using shared server by default to ease setup, which works for shared test users.
VITE_DEFAULT_IMAGE_API_SERVER=https://test-image-api.timesafari.app VITE_DEFAULT_IMAGE_API_SERVER=https://test-image-api.timesafari.app
VITE_DEFAULT_PARTNER_API_SERVER=http://localhost:3000 VITE_DEFAULT_PARTNER_API_SERVER=http://localhost:3000
#VITE_DEFAULT_PUSH_SERVER... can't be set up with localhost domain
VITE_PASSKEYS_ENABLED=true VITE_PASSKEYS_ENABLED=true

6
.env.example

@ -1,6 +0,0 @@
# Admin DID credentials
ADMIN_DID=did:ethr:0x0000694B58C2cC69658993A90D3840C560f2F51F
ADMIN_PRIVATE_KEY=2b6472c026ec2aa2c4235c994a63868fc9212d18b58f6cbfe861b52e71330f5b
# API Configuration
ENDORSER_API_URL=https://test-api.endorser.ch/api/v2/claim

1
.env.production

@ -9,3 +9,4 @@ VITE_DEFAULT_ENDORSER_API_SERVER=https://api.endorser.ch
VITE_DEFAULT_IMAGE_API_SERVER=https://image-api.timesafari.app VITE_DEFAULT_IMAGE_API_SERVER=https://image-api.timesafari.app
VITE_DEFAULT_PARTNER_API_SERVER=https://partner-api.endorser.ch VITE_DEFAULT_PARTNER_API_SERVER=https://partner-api.endorser.ch
VITE_DEFAULT_PUSH_SERVER=https://timesafari.app

1
.env.staging

@ -9,4 +9,5 @@ VITE_DEFAULT_ENDORSER_API_SERVER=https://test-api.endorser.ch
VITE_DEFAULT_IMAGE_API_SERVER=https://test-image-api.timesafari.app VITE_DEFAULT_IMAGE_API_SERVER=https://test-image-api.timesafari.app
VITE_DEFAULT_PARTNER_API_SERVER=https://test-partner-api.endorser.ch VITE_DEFAULT_PARTNER_API_SERVER=https://test-partner-api.endorser.ch
VITE_DEFAULT_PUSH_SERVER=https://test.timesafari.app
VITE_PASSKEYS_ENABLED=true VITE_PASSKEYS_ENABLED=true

9
.eslintrc.js

@ -4,6 +4,12 @@ module.exports = {
node: true, node: true,
es2022: true, es2022: true,
}, },
ignorePatterns: [
'node_modules/',
'dist/',
'dist-electron/',
'*.d.ts'
],
extends: [ extends: [
"plugin:vue/vue3-recommended", "plugin:vue/vue3-recommended",
"eslint:recommended", "eslint:recommended",
@ -26,6 +32,7 @@ module.exports = {
"no-debugger": process.env.NODE_ENV === "production" ? "error" : "warn", "no-debugger": process.env.NODE_ENV === "production" ? "error" : "warn",
"@typescript-eslint/no-explicit-any": "warn", "@typescript-eslint/no-explicit-any": "warn",
"@typescript-eslint/explicit-function-return-type": "off", "@typescript-eslint/explicit-function-return-type": "off",
"@typescript-eslint/no-unnecessary-type-constraint": "off" "@typescript-eslint/no-unnecessary-type-constraint": "off",
"@typescript-eslint/no-unused-vars": ["error", { "argsIgnorePattern": "^_" }]
}, },
}; };

4
.gitignore

@ -42,7 +42,7 @@ dist-electron-packages
.ruby-version .ruby-version
+.env +.env
# Generated test files # Test files generated by scripts test-ios.js & test-android.js
.generated/ .generated/
.env.default .env.default
@ -53,3 +53,5 @@ build_logs/
# PWA icon files generated by capacitor-assets # PWA icon files generated by capacitor-assets
icons icons

536
BUILDING.md

@ -9,19 +9,6 @@ For a quick dev environment setup, use [pkgx](https://pkgx.dev).
- Node.js (LTS version recommended) - Node.js (LTS version recommended)
- npm (comes with Node.js) - npm (comes with Node.js)
- Git - Git
- For Android builds: Android Studio with SDK installed
- For iOS builds: macOS with Xcode and ruby gems & bundle
- pkgx +rubygems.org sh
- ... and you may have to fix these, especially with pkgx
```bash
gem_path=$(which gem)
shortened_path="${gem_path:h:h}"
export GEM_HOME=$shortened_path
export GEM_PATH=$shortened_path
```
- For desktop builds: Additional build tools based on your OS - For desktop builds: Additional build tools based on your OS
## Forks ## Forks
@ -54,7 +41,7 @@ Install dependencies:
1. Run the production build: 1. Run the production build:
```bash ```bash
npm run build npm run build:web
``` ```
The built files will be in the `dist` directory. The built files will be in the `dist` directory.
@ -84,7 +71,7 @@ Install dependencies:
* For test, build the app (because test server is not yet set up to build): * For test, build the app (because test server is not yet set up to build):
```bash ```bash
TIME_SAFARI_APP_TITLE="TimeSafari_Test" VITE_APP_SERVER=https://test.timesafari.app VITE_BVC_MEETUPS_PROJECT_CLAIM_ID=https://endorser.ch/entity/01HWE8FWHQ1YGP7GFZYYPS272F VITE_DEFAULT_ENDORSER_API_SERVER=https://test-api.endorser.ch VITE_DEFAULT_IMAGE_API_SERVER=https://test-image-api.timesafari.app VITE_DEFAULT_PARTNER_API_SERVER=https://test-partner-api.endorser.ch VITE_PASSKEYS_ENABLED=true npm run build TIME_SAFARI_APP_TITLE="TimeSafari_Test" VITE_APP_SERVER=https://test.timesafari.app VITE_BVC_MEETUPS_PROJECT_CLAIM_ID=https://endorser.ch/entity/01HWE8FWHQ1YGP7GFZYYPS272F VITE_DEFAULT_ENDORSER_API_SERVER=https://test-api.endorser.ch VITE_DEFAULT_IMAGE_API_SERVER=https://test-image-api.timesafari.app VITE_DEFAULT_PARTNER_API_SERVER=https://test-partner-api.endorser.ch VITE_DEFAULT_PUSH_SERVER=https://test.timesafari.app VITE_PASSKEYS_ENABLED=true npm run build
``` ```
... and transfer to the test server: ... and transfer to the test server:
@ -111,8 +98,106 @@ TIME_SAFARI_APP_TITLE="TimeSafari_Test" VITE_APP_SERVER=https://test.timesafari.
* Record the new hash in the changelog. Edit package.json to increment version & add "-beta", `npm install`, and commit. Also record what version is on production. * Record the new hash in the changelog. Edit package.json to increment version & add "-beta", `npm install`, and commit. Also record what version is on production.
## Docker Deployment
The application can be containerized using Docker for consistent deployment across environments.
### Prerequisites
- Docker installed on your system
- Docker Compose (optional, for multi-container setups)
### Building the Docker Image
1. Build the Docker image:
```bash
docker build -t timesafari:latest .
```
2. For development builds with specific environment variables:
```bash
docker build --build-arg NODE_ENV=development -t timesafari:dev .
```
### Running the Container
1. Run the container:
```bash
docker run -d -p 80:80 timesafari:latest
```
2. For development with hot-reloading:
```bash
docker run -d -p 80:80 -v $(pwd):/app timesafari:dev
```
### Using Docker Compose
Create a `docker-compose.yml` file:
```yaml
version: '3.8'
services:
timesafari:
build: .
ports:
- "80:80"
environment:
- NODE_ENV=production
restart: unless-stopped
```
Run with Docker Compose:
```bash
docker-compose up -d
```
### Production Deployment
For production deployment, consider the following:
1. Use specific version tags instead of 'latest'
2. Implement health checks
3. Configure proper logging
4. Set up reverse proxy with SSL termination
5. Use Docker secrets for sensitive data
Example production deployment:
```bash
# Build with specific version
docker build -t timesafari:1.0.0 .
# Run with production settings
docker run -d \
--name timesafari \
-p 80:80 \
--restart unless-stopped \
-e NODE_ENV=production \
timesafari:1.0.0
```
### Troubleshooting Docker
1. **Container fails to start**
- Check logs: `docker logs <container_id>`
- Verify port availability
- Check environment variables
2. **Build fails**
- Ensure all dependencies are in package.json
- Check Dockerfile syntax
- Verify build context
3. **Performance issues**
- Monitor container resources: `docker stats`
- Check nginx configuration
- Verify caching settings
## Desktop Build (Electron) ## Desktop Build (Electron)
@ -138,21 +223,77 @@ TIME_SAFARI_APP_TITLE="TimeSafari_Test" VITE_APP_SERVER=https://test.timesafari.
- AppImage: `dist-electron-packages/TimeSafari-x.x.x.AppImage` - AppImage: `dist-electron-packages/TimeSafari-x.x.x.AppImage`
- DEB: `dist-electron-packages/timesafari_x.x.x_amd64.deb` - DEB: `dist-electron-packages/timesafari_x.x.x_amd64.deb`
### Running the Packaged App ### macOS Build
- AppImage: Make executable and run 1. Build the electron app in production mode:
```bash ```bash
chmod +x dist-electron-packages/TimeSafari-*.AppImage npm run build:web
./dist-electron-packages/TimeSafari-*.AppImage npm run build:electron
``` npm run electron:build-mac
```
- DEB: Install and run 2. Package the Electron app for macOS:
```bash ```bash
sudo dpkg -i dist-electron-packages/timesafari_*_amd64.deb # For Intel Macs
timesafari npm run electron:build-mac
```
# For Universal build (Intel + Apple Silicon)
npm run electron:build-mac-universal
```
3. The packaged applications will be in `dist-electron-packages/`:
- `.app` bundle: `TimeSafari.app`
- `.dmg` installer: `TimeSafari-x.x.x.dmg`
- `.zip` archive: `TimeSafari-x.x.x-mac.zip`
### Code Signing and Notarization (macOS)
For public distribution on macOS, you need to code sign and notarize your app:
1. Set up environment variables:
```bash
export CSC_LINK=/path/to/your/certificate.p12
export CSC_KEY_PASSWORD=your_certificate_password
export APPLE_ID=your_apple_id
export APPLE_ID_PASSWORD=your_app_specific_password
```
2. Build with signing:
```bash
npm run electron:build-mac
```
### Running the Packaged App
- **Linux**:
- AppImage: Make executable and run
```bash
chmod +x dist-electron-packages/TimeSafari-*.AppImage
./dist-electron-packages/TimeSafari-*.AppImage
```
- DEB: Install and run
```bash
sudo dpkg -i dist-electron-packages/timesafari_*_amd64.deb
timesafari
```
- **macOS**:
- `.app` bundle: Double-click `TimeSafari.app` in Finder
- `.dmg` installer:
1. Double-click the `.dmg` file
2. Drag the app to your Applications folder
3. Launch from Applications
- `.zip` archive:
1. Extract the `.zip` file
2. Move `TimeSafari.app` to your Applications folder
3. Launch from Applications
Note: If you get a security warning when running the app:
1. Right-click the app
2. Select "Open"
3. Click "Open" in the security dialog
### Development Testing ### Development Testing
@ -172,18 +313,49 @@ npm run build:electron-prod && npm run electron:start
Prerequisites: macOS with Xcode installed Prerequisites: macOS with Xcode installed
#### First-time iOS Configuration
- Generate certificates inside XCode.
- Right-click on App and under Signing & Capabilities set the Team.
#### Each Release
0. First time (or if XCode dependencies change):
- `pkgx +rubygems.org sh`
- ... and you may have to fix these, especially with pkgx
```bash
gem_path=$(which gem)
shortened_path="${gem_path:h:h}"
export GEM_HOME=$shortened_path
export GEM_PATH=$shortened_path
```
```bash
cd ios/App
pod install
```
1. Build the web assets: 1. Build the web assets:
```bash ```bash
rm -rf dist
npm run build:web
npm run build:capacitor npm run build:capacitor
``` ```
2. Update iOS project with latest build: 2. Update iOS project with latest build:
```bash ```bash
npx cap sync ios npx cap sync ios
``` ```
- If that fails with "Could not find..." then look at the "gem_path" instructions above.
3. Copy the assets: 3. Copy the assets:
```bash ```bash
@ -195,23 +367,45 @@ Prerequisites: macOS with Xcode installed
npx capacitor-assets generate --ios npx capacitor-assets generate --ios
``` ```
3. Open the project in Xcode: 4. Bump the version to match Android:
```
cd ios/App
xcrun agvtool new-version 25
# Unfortunately this edits Info.plist directly.
#xcrun agvtool new-marketing-version 0.4.5
cat App.xcodeproj/project.pbxproj | sed "s/MARKETING_VERSION = .*;/MARKETING_VERSION = 0.5.1;/g" > temp
mv temp App.xcodeproj/project.pbxproj
cd -
```
5. Open the project in Xcode:
```bash ```bash
npx cap open ios npx cap open ios
``` ```
4. Use Xcode to build and run on simulator or device. 6. Use Xcode to build and run on simulator or device.
#### First-time iOS Configuration * Select Product -> Destination with some Simulator version. Then click the run arrow.
- Generate certificates inside XCode. 7. Release
- Right-click on App and under Signing & Capabilities set the Team. * Someday: Under "General" we want to rename a bunch of things to "Time Safari"
* Choose Product -> Destination -> Any iOS Device
* Choose Product -> Archive
* This will trigger a build and take time, needing user's "login" keychain password (user's login password), repeatedly.
* If it fails with `building for 'iOS', but linking in dylib (.../.pkgx/zlib.net/v1.3.0/lib/libz.1.3.dylib) built for 'macOS'` then run XCode outside that terminal (ie. not with `npx cap open ios`).
* Click Distribute -> App Store Connect
* In AppStoreConnect, add the build to the distribution: remove the current build with the "-" when you hover over it, then "Add Build" with the new build.
* May have to go to App Review, click Submission, then hover over the build and click "-".
* It can take 15 minutes for the build to show up in the list of builds.
* You'll probably have to "Manage" something about encryption, disallowed in France.
* Then "Save" and "Add to Review" and "Resubmit to App Review".
### Android Build ### Android Build
Prerequisites: Android Studio with SDK installed Prerequisites: Android Studio with Java SDK installed
1. Build the web assets: 1. Build the web assets:
@ -233,13 +427,15 @@ Prerequisites: Android Studio with SDK installed
npx capacitor-assets generate --android npx capacitor-assets generate --android
``` ```
4. Open the project in Android Studio: 4. Bump version to match iOS: android/app/build.gradle
5. Open the project in Android Studio:
```bash ```bash
npx cap open android npx cap open android
``` ```
5. Use Android Studio to build and run on emulator or device. 6. Use Android Studio to build and run on emulator or device.
## Android Build from the console ## Android Build from the console
@ -247,7 +443,7 @@ Prerequisites: Android Studio with SDK installed
cd android cd android
./gradlew clean ./gradlew clean
./gradlew build -Dlint.baselines.continue=true ./gradlew build -Dlint.baselines.continue=true
cd .. cd -
npx cap run android npx cap run android
``` ```
@ -257,12 +453,29 @@ Prerequisites: Android Studio with SDK installed
./gradlew bundleDebug -Dlint.baselines.continue=true ./gradlew bundleDebug -Dlint.baselines.continue=true
``` ```
... or, to create a signed release, add the app/gradle.properties.secrets file (see properties at top of app/build.gradle) and the app/time-safari-upload-key-pkcs12.jks file, then `bundleRelease`: ... or, to create a signed release:
* Setup by adding the app/gradle.properties.secrets file (see properties at top of app/build.gradle) and the app/time-safari-upload-key-pkcs12.jks file
* In app/build.gradle, bump the versionCode and maybe the versionName
* Then `bundleRelease`:
```bash ```bash
cd android
./gradlew bundleRelease -Dlint.baselines.continue=true ./gradlew bundleRelease -Dlint.baselines.continue=true
cd -
``` ```
... and find your `aab` file at app/build/outputs/bundle/release
At play.google.com/console:
- Go to the Testing Track (eg. Closed).
- Click "Create new release".
- Upload the `aab` file.
- Hit "Next".
- Save, go to "Publishing Overview" as prompted, and click "Send changes for review".
- Note that if you add testers, you have to go to "Publishing Overview" and send those changes or your (closed) testers won't see it.
## First-time Android Configuration for deep links ## First-time Android Configuration for deep links
@ -276,253 +489,4 @@ You must add the following intent filter to the `android/app/src/main/AndroidMan
<category android:name="android.intent.category.BROWSABLE" /> <category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="timesafari" /> <data android:scheme="timesafari" />
</intent-filter> </intent-filter>
``` ```
You must also add the following to the `android/app/build.gradle` file:
```gradle
android {
// ... existing config ...
lintOptions {
disable 'UnsanitizedFilenameFromContentProvider'
abortOnError false
baseline file("lint-baseline.xml")
// Ignore Capacitor module issues
ignore 'DefaultLocale'
ignore 'UnsanitizedFilenameFromContentProvider'
ignore 'LintBaseline'
ignore 'LintBaselineFixed'
}
}
```
Modify `/android/build.gradle` to use a stable version of AGP and make sure Kotlin version is compatible.
```gradle
buildscript {
repositories {
google()
mavenCentral()
}
dependencies {
// Use a stable version of AGP
classpath 'com.android.tools.build:gradle:8.1.0'
// Make sure Kotlin version is compatible
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:1.8.0"
}
}
allprojects {
repositories {
google()
mavenCentral()
}
}
// Add this to handle version conflicts
configurations.all {
resolutionStrategy {
force 'org.jetbrains.kotlin:kotlin-stdlib:1.8.0'
force 'org.jetbrains.kotlin:kotlin-stdlib-common:1.8.0'
}
}
```
## PyWebView Desktop Build
### Prerequisites for PyWebView
- Python 3.8 or higher
- pip (Python package manager)
- virtualenv (recommended)
- System dependencies:
```bash
# For Ubuntu/Debian
sudo apt-get install python3-webview
# or
sudo apt-get install python3-gi python3-gi-cairo gir1.2-gtk-3.0 gir1.2-webkit2-4.0
# For Arch Linux
sudo pacman -S webkit2gtk python-gobject python-cairo
# For Fedora
sudo dnf install python3-webview
# or
sudo dnf install python3-gobject python3-cairo webkit2gtk3
```
### Setup
1. Create and activate a virtual environment (recommended):
```bash
python -m venv .venv
source .venv/bin/activate # On Linux/macOS
# or
.venv\Scripts\activate # On Windows
```
2. Install Python dependencies:
```bash
pip install -r requirements.txt
```
### Troubleshooting
If encountering PyInstaller version errors:
```bash
# Try installing the latest stable version
pip install --upgrade pyinstaller
```
### Development of PyWebView
1. Start the PyWebView development build:
```bash
npm run pywebview:dev
```
### Building for Distribution
#### Linux
```bash
npm run pywebview:package-linux
```
The packaged application will be in `dist/TimeSafari`
#### Windows
```bash
npm run pywebview:package-win
```
The packaged application will be in `dist/TimeSafari`
#### macOS
```bash
npm run pywebview:package-mac
```
The packaged application will be in `dist/TimeSafari`
## Testing
Run all tests (requires XCode and Android Studio/device):
```bash
npm run test:all
```
See [TESTING.md](test-playwright/TESTING.md) for more details.
## Linting
Check code style:
```bash
npm run lint
```
Fix code style issues:
```bash
npm run lint-fix
```
## Environment Configuration
See `.env.*` files for configuration.
## Notes
- The application uses PWA (Progressive Web App) features for web builds
- Electron builds disable PWA features automatically
- Build output directories:
- Web: `dist/`
- Electron: `dist-electron/`
- Capacitor: `dist-capacitor/`
## Deployment
### Version Management
1. Update CHANGELOG.md with new changes
2. Update version in package.json
3. Commit changes and tag release:
```bash
git tag <VERSION_TAG>
git push origin <VERSION_TAG>
```
4. After deployment, update package.json with next version + "-beta"
### Test Server
```bash
# Build using staging environment
npm run build -- --mode staging
# Deploy to test server
rsync -azvu -e "ssh -i ~/.ssh/<YOUR_KEY>" dist ubuntutest@test.timesafari.app:time-safari/
```
### Production Server
```bash
# On the production server:
pkgx +npm sh
cd crowd-funder-for-time-pwa
git checkout master && git pull
git checkout <VERSION_TAG>
npm install
npm run build
cd -
# Backup and deploy
mv time-safari/dist time-safari-dist-prev.0 && mv crowd-funder-for-time-pwa/dist time-safari/
```
## Troubleshooting Builds
### Common Build Issues
1. **Missing Environment Variables**
- Check that all required variables are set in your .env file
- For development, ensure local services are running on correct ports
2. **Electron Build Failures**
- Verify Node.js version compatibility
- Check that all required dependencies are installed
- Ensure proper paths in electron/main.js
3. **Mobile Build Issues**
- For iOS: Xcode command line tools must be installed
- For Android: Correct SDK version must be installed
- Check Capacitor configuration in capacitor.config.ts
# List all installed packages
adb shell pm list packages | grep timesafari
# Force stop the app (if it's running)
adb shell am force-stop app.timesafari
# Clear app data (if you don't want to fully uninstall)
adb shell pm clear app.timesafari
# Uninstall for all users
adb shell pm uninstall -k --user 0 app.timesafari
# Check if app is installed
adb shell pm path app.timesafari

7
CHANGELOG.md

@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [0.4.7]
### Fixed
- Cameras everywhere
### Changed
- IndexedDB -> SQLite
## [0.4.5] - 2025.02.23 ## [0.4.5] - 2025.02.23
### Added ### Added
- Total amounts of gives on project page - Total amounts of gives on project page

36
Dockerfile

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

24
README.md

@ -12,6 +12,8 @@ See [project.task.yaml](project.task.yaml) for current priorities.
Quick start: Quick start:
* For setup, we recommend [pkgx](https://pkgx.dev), which installs what you need (either automatically or with the `dev` command). Core dependencies are typescript & npm; when building for other platforms, you'll need other things such as those in the pkgx.yaml & BUILDING.md files.
```bash ```bash
npm install npm install
npm run dev npm run dev
@ -31,7 +33,9 @@ See [TESTING.md](test-playwright/TESTING.md) for detailed test instructions.
## Icons ## Icons
To add an icon, add to main.ts and reference with `fa` element and `icon` attribute with the hyphenated name. Application icons are in the `assets` directory, processed by the `capacitor-assets` command.
To add a Font Awesome icon, add to main.ts and reference with `font-awesome` element and `icon` attribute with the hyphenated name.
## Other ## Other
@ -44,6 +48,24 @@ To add an icon, add to main.ts and reference with `fa` element and `icon` attrib
* If you are deploying in a subdirectory, add it to `publicPath` in vue.config.js, eg: `publicPath: "/app/time-tracker/",` * If you are deploying in a subdirectory, add it to `publicPath` in vue.config.js, eg: `publicPath: "/app/time-tracker/",`
### Code Organization
The project uses a centralized approach to type definitions and interfaces:
* `src/interfaces/` - Contains all TypeScript interfaces and type definitions
* `deepLinks.ts` - Deep linking type system and Zod validation schemas
* `give.ts` - Give-related interfaces and type definitions
* `claims.ts` - Claim-related interfaces and verifiable credentials
* `common.ts` - Shared interfaces and utility types
* Other domain-specific interface files
Key principles:
- All interfaces and types are defined in the interfaces folder
- Zod schemas are used for runtime validation and type generation
- Domain-specific interfaces are separated into their own files
- Common interfaces are shared through `common.ts`
- Type definitions are generated from Zod schemas where possible
### Kudos ### Kudos
Gifts make the world go 'round! Gifts make the world go 'round!

84
TASK_storage.md

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

25
android/.gitignore

@ -1,5 +1,17 @@
# Using Android gitignore template: https://github.com/github/gitignore/blob/HEAD/Android.gitignore # Using Android gitignore template: https://github.com/github/gitignore/blob/HEAD/Android.gitignore
app/build/*
!app/build/.npmkeep
# Copied web assets
app/src/main/assets/public
# Generated Config files
app/src/main/assets/capacitor.config.json
app/src/main/assets/capacitor.plugins.json
app/src/main/res/xml/config.xml
# secrets
app/gradle.properties.secrets app/gradle.properties.secrets
app/time-safari-upload-key-pkcs12.jks app/time-safari-upload-key-pkcs12.jks
@ -94,16 +106,3 @@ lint/tmp/
# Cordova plugins for Capacitor # Cordova plugins for Capacitor
capacitor-cordova-android-plugins capacitor-cordova-android-plugins
# Copied web assets
app/src/main/assets/public
# Generated Config files
app/src/main/assets/capacitor.config.json
app/src/main/assets/capacitor.plugins.json
app/src/main/res/xml/config.xml
# Generated Icons from capacitor-assets
app/src/main/res/drawable/*.png
app/src/main/res/drawable-*/*.png
app/src/main/res/mipmap-*/*.png

BIN
android/.gradle/buildOutputCleanup/buildOutputCleanup.lock

Binary file not shown.

2
android/.gradle/buildOutputCleanup/cache.properties

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

BIN
android/.gradle/file-system.probe

Binary file not shown.

2
android/app/.gitignore

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

8
android/app/build.gradle

@ -14,7 +14,7 @@ project.ext.MY_KEY_PASSWORD = System.getenv('ANDROID_KEY_PASSWORD') ?: ""
// If no environment variables, try to load from secrets file // If no environment variables, try to load from secrets file
if (!project.ext.MY_KEYSTORE_FILE) { if (!project.ext.MY_KEYSTORE_FILE) {
def secretsPropertiesFile = rootProject.file("gradle.properties.secrets") def secretsPropertiesFile = rootProject.file("app/gradle.properties.secrets")
if (secretsPropertiesFile.exists()) { if (secretsPropertiesFile.exists()) {
Properties secretsProperties = new Properties() Properties secretsProperties = new Properties()
secretsProperties.load(new FileInputStream(secretsPropertiesFile)) secretsProperties.load(new FileInputStream(secretsPropertiesFile))
@ -31,8 +31,8 @@ android {
applicationId "app.timesafari.app" applicationId "app.timesafari.app"
minSdkVersion rootProject.ext.minSdkVersion minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 9 versionCode 26
versionName "0.4.4" versionName "0.5.1"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions { aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps. // Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
@ -91,6 +91,8 @@ dependencies {
implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion" implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion"
implementation "androidx.core:core-splashscreen:$coreSplashScreenVersion" implementation "androidx.core:core-splashscreen:$coreSplashScreenVersion"
implementation project(':capacitor-android') implementation project(':capacitor-android')
implementation project(':capacitor-community-sqlite')
implementation "androidx.biometric:biometric:1.2.0-alpha05"
testImplementation "junit:junit:$junitVersion" testImplementation "junit:junit:$junitVersion"
androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion" androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion"
androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion" androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion"

6
android/app/capacitor.build.gradle

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

28
android/app/google-services.json

@ -0,0 +1,28 @@
{
"project_info": {
"project_number": "123456789000",
"project_id": "timesafari-app",
"storage_bucket": "timesafari-app.appspot.com"
},
"client": [
{
"client_info": {
"mobilesdk_app_id": "1:123456789000:android:1234567890abcdef",
"android_client_info": {
"package_name": "app.timesafari.app"
}
},
"oauth_client": [],
"api_key": [
{
"current_key": "AIzaSyDummyKeyForBuildPurposesOnly12345"
}
],
"services": {
"appinvite_service": {
"other_platform_oauth_client": []
}
}
}
]
}

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

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

37
android/app/src/main/assets/capacitor.config.json

@ -1,5 +1,5 @@
{ {
"appId": "app.timesafari.app", "appId": "app.timesafari",
"appName": "TimeSafari", "appName": "TimeSafari",
"webDir": "dist", "webDir": "dist",
"bundledWebRuntime": false, "bundledWebRuntime": false,
@ -16,6 +16,41 @@
} }
] ]
} }
},
"SQLite": {
"iosDatabaseLocation": "Library/CapacitorDatabase",
"iosIsEncryption": true,
"iosBiometric": {
"biometricAuth": true,
"biometricTitle": "Biometric login for TimeSafari"
},
"androidIsEncryption": true,
"androidBiometric": {
"biometricAuth": true,
"biometricTitle": "Biometric login for TimeSafari"
}
} }
},
"ios": {
"contentInset": "never",
"allowsLinkPreview": true,
"scrollEnabled": true,
"limitsNavigationsToAppBoundDomains": true,
"backgroundColor": "#ffffff",
"allowNavigation": [
"*.timesafari.app",
"*.jsdelivr.net",
"api.endorser.ch"
]
},
"android": {
"allowMixedContent": false,
"captureInput": true,
"webContentsDebuggingEnabled": false,
"allowNavigation": [
"*.timesafari.app",
"*.jsdelivr.net",
"api.endorser.ch"
]
} }
} }

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

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

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

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

10
android/app/src/main/java/app/timesafari/MainActivity.java

@ -1,7 +1,15 @@
package app.timesafari; package app.timesafari;
import android.os.Bundle;
import com.getcapacitor.BridgeActivity; import com.getcapacitor.BridgeActivity;
//import com.getcapacitor.community.sqlite.SQLite;
public class MainActivity extends BridgeActivity { public class MainActivity extends BridgeActivity {
// ... existing code ... @Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
// Initialize SQLite
//registerPlugin(SQLite.class);
}
} }

5
android/app/src/main/java/timesafari/app/MainActivity.java

@ -1,5 +0,0 @@
package timesafari.app;
import com.getcapacitor.BridgeActivity;
public class MainActivity extends BridgeActivity {}

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

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

2
android/build.gradle

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

18
android/capacitor.settings.gradle

@ -2,5 +2,23 @@
include ':capacitor-android' include ':capacitor-android'
project(':capacitor-android').projectDir = new File('../node_modules/@capacitor/android/capacitor') project(':capacitor-android').projectDir = new File('../node_modules/@capacitor/android/capacitor')
include ':capacitor-community-sqlite'
project(':capacitor-community-sqlite').projectDir = new File('../node_modules/@capacitor-community/sqlite/android')
include ':capacitor-mlkit-barcode-scanning'
project(':capacitor-mlkit-barcode-scanning').projectDir = new File('../node_modules/@capacitor-mlkit/barcode-scanning/android')
include ':capacitor-app' include ':capacitor-app'
project(':capacitor-app').projectDir = new File('../node_modules/@capacitor/app/android') project(':capacitor-app').projectDir = new File('../node_modules/@capacitor/app/android')
include ':capacitor-camera'
project(':capacitor-camera').projectDir = new File('../node_modules/@capacitor/camera/android')
include ':capacitor-filesystem'
project(':capacitor-filesystem').projectDir = new File('../node_modules/@capacitor/filesystem/android')
include ':capacitor-share'
project(':capacitor-share').projectDir = new File('../node_modules/@capacitor/share/android')
include ':capawesome-capacitor-file-picker'
project(':capawesome-capacitor-file-picker').projectDir = new File('../node_modules/@capawesome/capacitor-file-picker/android')

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

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

2
assets/README.md

@ -0,0 +1,2 @@
Application icons are here. They are processed for android & ios by the `capacitor-assets` command, as indicated in the BUILDING.md file.

BIN
assets/icon.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

After

Width:  |  Height:  |  Size: 279 KiB

BIN
assets/splash-dark.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

BIN
assets/splash.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

4
build.sh

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

56
capacitor.config.json

@ -0,0 +1,56 @@
{
"appId": "app.timesafari",
"appName": "TimeSafari",
"webDir": "dist",
"bundledWebRuntime": false,
"server": {
"cleartext": true
},
"plugins": {
"App": {
"appUrlOpen": {
"handlers": [
{
"url": "timesafari://*",
"autoVerify": true
}
]
}
},
"SQLite": {
"iosDatabaseLocation": "Library/CapacitorDatabase",
"iosIsEncryption": true,
"iosBiometric": {
"biometricAuth": true,
"biometricTitle": "Biometric login for TimeSafari"
},
"androidIsEncryption": true,
"androidBiometric": {
"biometricAuth": true,
"biometricTitle": "Biometric login for TimeSafari"
}
}
},
"ios": {
"contentInset": "never",
"allowsLinkPreview": true,
"scrollEnabled": true,
"limitsNavigationsToAppBoundDomains": true,
"backgroundColor": "#ffffff",
"allowNavigation": [
"*.timesafari.app",
"*.jsdelivr.net",
"api.endorser.ch"
]
},
"android": {
"allowMixedContent": false,
"captureInput": true,
"webContentsDebuggingEnabled": false,
"allowNavigation": [
"*.timesafari.app",
"*.jsdelivr.net",
"api.endorser.ch"
]
}
}

25
capacitor.config.ts

@ -1,25 +0,0 @@
import { CapacitorConfig } from '@capacitor/cli';
const config: CapacitorConfig = {
appId: 'app.timesafari',
appName: 'TimeSafari',
webDir: 'dist',
bundledWebRuntime: false,
server: {
cleartext: true,
},
plugins: {
App: {
appUrlOpen: {
handlers: [
{
url: "timesafari://*",
autoVerify: true
}
]
}
}
}
};
export default config;

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

@ -9,21 +9,95 @@ The deep linking system uses a multi-layered type safety approach:
- Enforces parameter requirements - Enforces parameter requirements
- Sanitizes input data - Sanitizes input data
- Provides detailed validation errors - Provides detailed validation errors
- Generates TypeScript types automatically
2. **TypeScript Types** 2. **TypeScript Types**
- Generated from Zod schemas - Generated from Zod schemas using `z.infer`
- Ensures compile-time type safety - Ensures compile-time type safety
- Provides IDE autocompletion - Provides IDE autocompletion
- Catches type errors during development - Catches type errors during development
- Maintains single source of truth for types
3. **Router Integration** 3. **Router Integration**
- Type-safe parameter passing - Type-safe parameter passing
- Route-specific parameter validation - Route-specific parameter validation
- Query parameter type checking - Query parameter type checking
- Automatic type inference for route parameters
## Type System Implementation
### Zod Schema to TypeScript Type Generation
```typescript
// Define the schema
const claimSchema = z.object({
id: z.string(),
view: z.enum(["details", "certificate", "raw"]).optional()
});
// TypeScript type is automatically generated
type ClaimParams = z.infer<typeof claimSchema>;
// Equivalent to:
// type ClaimParams = {
// id: string;
// view?: "details" | "certificate" | "raw";
// }
```
### Type Safety Layers
1. **Schema Definition**
```typescript
// src/interfaces/deepLinks.ts
export const deepLinkSchemas = {
claim: z.object({
id: z.string(),
view: z.enum(["details", "certificate", "raw"]).optional()
}),
// Other route schemas...
};
```
2. **Type Generation**
```typescript
// Types are automatically generated from schemas
export type DeepLinkParams = {
[K in keyof typeof deepLinkSchemas]: z.infer<(typeof deepLinkSchemas)[K]>;
};
```
3. **Runtime Validation**
```typescript
// In DeepLinkHandler
const result = deepLinkSchemas.claim.safeParse(params);
if (!result.success) {
// Handle validation errors
console.error(result.error);
}
```
### Error Handling Types
```typescript
export interface DeepLinkError extends Error {
code: string;
details?: unknown;
}
// Usage in error handling
try {
await handler.handleDeepLink(url);
} catch (error) {
if (error instanceof DeepLinkError) {
// Type-safe error handling
console.error(error.code, error.message);
}
}
```
## Implementation Files ## Implementation Files
- `src/types/deepLinks.ts`: Type definitions and validation schemas - `src/interfaces/deepLinks.ts`: Type definitions and validation schemas
- `src/services/deepLinks.ts`: Deep link processing service - `src/services/deepLinks.ts`: Deep link processing service
- `src/main.capacitor.ts`: Capacitor integration - `src/main.capacitor.ts`: Capacitor integration

399
doc/dexie-to-sqlite-mapping.md

@ -0,0 +1,399 @@
# Dexie to absurd-sql Mapping Guide
## Schema Mapping
### Current Dexie Schema
```typescript
// Current Dexie schema
const db = new Dexie('TimeSafariDB');
db.version(1).stores({
accounts: 'did, publicKeyHex, createdAt, updatedAt',
settings: 'key, value, updatedAt',
contacts: 'id, did, name, createdAt, updatedAt'
});
```
### New SQLite Schema
```sql
-- New SQLite schema
CREATE TABLE accounts (
did TEXT PRIMARY KEY,
public_key_hex TEXT NOT NULL,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE TABLE settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE TABLE contacts (
id TEXT PRIMARY KEY,
did TEXT NOT NULL,
name TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
FOREIGN KEY (did) REFERENCES accounts(did)
);
-- Indexes for performance
CREATE INDEX idx_accounts_created_at ON accounts(created_at);
CREATE INDEX idx_contacts_did ON contacts(did);
CREATE INDEX idx_settings_updated_at ON settings(updated_at);
```
## Query Mapping
### 1. Account Operations
#### Get Account by DID
```typescript
// Dexie
const account = await db.accounts.get(did);
// absurd-sql
const result = await db.exec(`
SELECT * FROM accounts WHERE did = ?
`, [did]);
const account = result[0]?.values[0];
```
#### Get All Accounts
```typescript
// Dexie
const accounts = await db.accounts.toArray();
// absurd-sql
const result = await db.exec(`
SELECT * FROM accounts ORDER BY created_at DESC
`);
const accounts = result[0]?.values || [];
```
#### Add Account
```typescript
// Dexie
await db.accounts.add({
did,
publicKeyHex,
createdAt: Date.now(),
updatedAt: Date.now()
});
// absurd-sql
await db.run(`
INSERT INTO accounts (did, public_key_hex, created_at, updated_at)
VALUES (?, ?, ?, ?)
`, [did, publicKeyHex, Date.now(), Date.now()]);
```
#### Update Account
```typescript
// Dexie
await db.accounts.update(did, {
publicKeyHex,
updatedAt: Date.now()
});
// absurd-sql
await db.run(`
UPDATE accounts
SET public_key_hex = ?, updated_at = ?
WHERE did = ?
`, [publicKeyHex, Date.now(), did]);
```
### 2. Settings Operations
#### Get Setting
```typescript
// Dexie
const setting = await db.settings.get(key);
// absurd-sql
const result = await db.exec(`
SELECT * FROM settings WHERE key = ?
`, [key]);
const setting = result[0]?.values[0];
```
#### Set Setting
```typescript
// Dexie
await db.settings.put({
key,
value,
updatedAt: Date.now()
});
// absurd-sql
await db.run(`
INSERT INTO settings (key, value, updated_at)
VALUES (?, ?, ?)
ON CONFLICT(key) DO UPDATE SET
value = excluded.value,
updated_at = excluded.updated_at
`, [key, value, Date.now()]);
```
### 3. Contact Operations
#### Get Contacts by Account
```typescript
// Dexie
const contacts = await db.contacts
.where('did')
.equals(accountDid)
.toArray();
// absurd-sql
const result = await db.exec(`
SELECT * FROM contacts
WHERE did = ?
ORDER BY created_at DESC
`, [accountDid]);
const contacts = result[0]?.values || [];
```
#### Add Contact
```typescript
// Dexie
await db.contacts.add({
id: generateId(),
did: accountDid,
name,
createdAt: Date.now(),
updatedAt: Date.now()
});
// absurd-sql
await db.run(`
INSERT INTO contacts (id, did, name, created_at, updated_at)
VALUES (?, ?, ?, ?, ?)
`, [generateId(), accountDid, name, Date.now(), Date.now()]);
```
## Transaction Mapping
### Batch Operations
```typescript
// Dexie
await db.transaction('rw', [db.accounts, db.contacts], async () => {
await db.accounts.add(account);
await db.contacts.bulkAdd(contacts);
});
// absurd-sql
await db.exec('BEGIN TRANSACTION;');
try {
await db.run(`
INSERT INTO accounts (did, public_key_hex, created_at, updated_at)
VALUES (?, ?, ?, ?)
`, [account.did, account.publicKeyHex, account.createdAt, account.updatedAt]);
for (const contact of contacts) {
await db.run(`
INSERT INTO contacts (id, did, name, created_at, updated_at)
VALUES (?, ?, ?, ?, ?)
`, [contact.id, contact.did, contact.name, contact.createdAt, contact.updatedAt]);
}
await db.exec('COMMIT;');
} catch (error) {
await db.exec('ROLLBACK;');
throw error;
}
```
## Migration Helper Functions
### 1. Data Export (Dexie to JSON)
```typescript
async function exportDexieData(): Promise<MigrationData> {
const db = new Dexie('TimeSafariDB');
return {
accounts: await db.accounts.toArray(),
settings: await db.settings.toArray(),
contacts: await db.contacts.toArray(),
metadata: {
version: '1.0.0',
timestamp: Date.now(),
dexieVersion: Dexie.version
}
};
}
```
### 2. Data Import (JSON to absurd-sql)
```typescript
async function importToAbsurdSql(data: MigrationData): Promise<void> {
await db.exec('BEGIN TRANSACTION;');
try {
// Import accounts
for (const account of data.accounts) {
await db.run(`
INSERT INTO accounts (did, public_key_hex, created_at, updated_at)
VALUES (?, ?, ?, ?)
`, [account.did, account.publicKeyHex, account.createdAt, account.updatedAt]);
}
// Import settings
for (const setting of data.settings) {
await db.run(`
INSERT INTO settings (key, value, updated_at)
VALUES (?, ?, ?)
`, [setting.key, setting.value, setting.updatedAt]);
}
// Import contacts
for (const contact of data.contacts) {
await db.run(`
INSERT INTO contacts (id, did, name, created_at, updated_at)
VALUES (?, ?, ?, ?, ?)
`, [contact.id, contact.did, contact.name, contact.createdAt, contact.updatedAt]);
}
await db.exec('COMMIT;');
} catch (error) {
await db.exec('ROLLBACK;');
throw error;
}
}
```
### 3. Verification
```typescript
async function verifyMigration(dexieData: MigrationData): Promise<boolean> {
// Verify account count
const accountResult = await db.exec('SELECT COUNT(*) as count FROM accounts');
const accountCount = accountResult[0].values[0][0];
if (accountCount !== dexieData.accounts.length) {
return false;
}
// Verify settings count
const settingsResult = await db.exec('SELECT COUNT(*) as count FROM settings');
const settingsCount = settingsResult[0].values[0][0];
if (settingsCount !== dexieData.settings.length) {
return false;
}
// Verify contacts count
const contactsResult = await db.exec('SELECT COUNT(*) as count FROM contacts');
const contactsCount = contactsResult[0].values[0][0];
if (contactsCount !== dexieData.contacts.length) {
return false;
}
// Verify data integrity
for (const account of dexieData.accounts) {
const result = await db.exec(
'SELECT * FROM accounts WHERE did = ?',
[account.did]
);
const migratedAccount = result[0]?.values[0];
if (!migratedAccount ||
migratedAccount[1] !== account.publicKeyHex) { // public_key_hex is second column
return false;
}
}
return true;
}
```
## Performance Considerations
### 1. Indexing
- Dexie automatically creates indexes based on the schema
- absurd-sql requires explicit index creation
- Added indexes for frequently queried fields
- Use `PRAGMA journal_mode=MEMORY;` for better performance
### 2. Batch Operations
- Dexie has built-in bulk operations
- absurd-sql uses transactions for batch operations
- Consider chunking large datasets
- Use prepared statements for repeated queries
### 3. Query Optimization
- Dexie uses IndexedDB's native indexing
- absurd-sql requires explicit query optimization
- Use prepared statements for repeated queries
- Consider using `PRAGMA synchronous=NORMAL;` for better performance
## Error Handling
### 1. Common Errors
```typescript
// Dexie errors
try {
await db.accounts.add(account);
} catch (error) {
if (error instanceof Dexie.ConstraintError) {
// Handle duplicate key
}
}
// absurd-sql errors
try {
await db.run(`
INSERT INTO accounts (did, public_key_hex, created_at, updated_at)
VALUES (?, ?, ?, ?)
`, [account.did, account.publicKeyHex, account.createdAt, account.updatedAt]);
} catch (error) {
if (error.message.includes('UNIQUE constraint failed')) {
// Handle duplicate key
}
}
```
### 2. Transaction Recovery
```typescript
// Dexie transaction
try {
await db.transaction('rw', db.accounts, async () => {
// Operations
});
} catch (error) {
// Dexie automatically rolls back
}
// absurd-sql transaction
try {
await db.exec('BEGIN TRANSACTION;');
// Operations
await db.exec('COMMIT;');
} catch (error) {
await db.exec('ROLLBACK;');
throw error;
}
```
## Migration Strategy
1. **Preparation**
- Export all Dexie data
- Verify data integrity
- Create SQLite schema
- Setup indexes
2. **Migration**
- Import data in transactions
- Verify each batch
- Handle errors gracefully
- Maintain backup
3. **Verification**
- Compare record counts
- Verify data integrity
- Test common queries
- Validate relationships
4. **Cleanup**
- Remove Dexie database
- Clear IndexedDB storage
- Update application code
- Remove old dependencies

613
doc/migration-to-wa-sqlite.md

@ -0,0 +1,613 @@
# Migration Guide: Dexie to absurd-sql
## Overview
This document outlines the migration process from Dexie.js to absurd-sql 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. **Dependencies**
```json
{
"@jlongster/sql.js": "^1.8.0",
"absurd-sql": "^1.8.0"
}
```
3. **Storage Requirements**
- Sufficient IndexedDB quota
- Available disk space for SQLite
- Backup storage space
4. **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
import initSqlJs from '@jlongster/sql.js';
import { SQLiteFS } from 'absurd-sql';
import IndexedDBBackend from 'absurd-sql/dist/indexeddb-backend';
export class MigrationService {
private static instance: MigrationService;
private backup: MigrationBackup | null = null;
private sql: any = null;
private db: any = 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 absurd-sql
await this.initializeAbsurdSql();
} catch (error) {
throw new StorageError(
'Migration preparation failed',
StorageErrorCodes.MIGRATION_FAILED,
error
);
}
}
private async initializeAbsurdSql(): Promise<void> {
// Initialize SQL.js
this.sql = await initSqlJs({
locateFile: (file: string) => {
return new URL(`/node_modules/@jlongster/sql.js/dist/${file}`, import.meta.url).href;
}
});
// Setup SQLiteFS with IndexedDB backend
const sqlFS = new SQLiteFS(this.sql.FS, new IndexedDBBackend());
this.sql.register_for_idb(sqlFS);
// Create and mount filesystem
this.sql.FS.mkdir('/sql');
this.sql.FS.mount(sqlFS, {}, '/sql');
// Open database
const path = '/sql/db.sqlite';
if (typeof SharedArrayBuffer === 'undefined') {
let stream = this.sql.FS.open(path, 'a+');
await stream.node.contents.readIfFallback();
this.sql.FS.close(stream);
}
this.db = new this.sql.Database(path, { filename: true });
if (!this.db) {
throw new StorageError(
'Database initialization failed',
StorageErrorCodes.INITIALIZATION_FAILED
);
}
// Configure database
await this.db.exec(`PRAGMA journal_mode=MEMORY;`);
}
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> {
// Use transaction for atomicity
await this.db.exec('BEGIN TRANSACTION;');
try {
for (const account of accounts) {
await this.db.run(`
INSERT INTO accounts (did, public_key_hex, created_at, updated_at)
VALUES (?, ?, ?, ?)
`, [
account.did,
account.publicKeyHex,
account.createdAt,
account.updatedAt
]);
}
await this.db.exec('COMMIT;');
} catch (error) {
await this.db.exec('ROLLBACK;');
throw error;
}
}
private async verifyMigration(backup: MigrationBackup): Promise<void> {
// Verify account count
const result = await this.db.exec('SELECT COUNT(*) as count FROM accounts');
const accountCount = result[0].values[0][0];
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 absurd-sql
await this.cleanupAbsurdSql();
} 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 initialize absurd-sql correctly', async () => {
const service = MigrationService.getInstance();
await service.initializeAbsurdSql();
expect(service.isInitialized()).toBe(true);
expect(service.getDatabase()).toBeDefined();
});
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

2
openssl_signing_console.rst → doc/openssl_signing_console.rst

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

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

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

339
doc/secure-storage-implementation.md

@ -0,0 +1,339 @@
# Secure Storage Implementation Guide for TimeSafari App
## Overview
This document outlines the implementation of secure storage for the TimeSafari app. The implementation focuses on:
1. **Platform-Specific Storage Solutions**:
- Web: SQLite with IndexedDB backend (absurd-sql)
- Electron: SQLite with Node.js backend
- Native: (Planned) SQLCipher with platform-specific secure storage
2. **Key Features**:
- SQLite-based storage using absurd-sql for web
- Platform-specific service factory pattern
- Consistent API across platforms
- Migration support from Dexie.js
## Quick Start
### 1. Installation
```bash
# Core dependencies
npm install @jlongster/sql.js
npm install absurd-sql
# Platform-specific dependencies (for future native support)
npm install @capacitor/preferences
npm install @capacitor-community/biometric-auth
```
### 2. Basic Usage
```typescript
// Using the platform service
import { PlatformServiceFactory } from '../services/PlatformServiceFactory';
// Get platform-specific service instance
const platformService = PlatformServiceFactory.getInstance();
// Example database operations
async function example() {
try {
// Query example
const result = await platformService.dbQuery(
"SELECT * FROM accounts WHERE did = ?",
[did]
);
// Execute example
await platformService.dbExec(
"INSERT INTO accounts (did, public_key_hex) VALUES (?, ?)",
[did, publicKeyHex]
);
} catch (error) {
console.error('Database operation failed:', error);
}
}
```
### 3. Platform Detection
```typescript
// src/services/PlatformServiceFactory.ts
export class PlatformServiceFactory {
static getInstance(): PlatformService {
if (process.env.ELECTRON) {
// Electron platform
return new ElectronPlatformService();
} else {
// Web platform (default)
return new AbsurdSqlDatabaseService();
}
}
}
```
### 4. Current Implementation Details
#### Web Platform (AbsurdSqlDatabaseService)
The web platform uses absurd-sql with IndexedDB backend:
```typescript
// src/services/AbsurdSqlDatabaseService.ts
export class AbsurdSqlDatabaseService implements PlatformService {
private static instance: AbsurdSqlDatabaseService | null = null;
private db: AbsurdSqlDatabase | null = null;
private initialized: boolean = false;
// Singleton pattern
static getInstance(): AbsurdSqlDatabaseService {
if (!AbsurdSqlDatabaseService.instance) {
AbsurdSqlDatabaseService.instance = new AbsurdSqlDatabaseService();
}
return AbsurdSqlDatabaseService.instance;
}
// Database operations
async dbQuery(sql: string, params: unknown[] = []): Promise<QueryExecResult[]> {
await this.waitForInitialization();
return this.queueOperation<QueryExecResult[]>("query", sql, params);
}
async dbExec(sql: string, params: unknown[] = []): Promise<void> {
await this.waitForInitialization();
await this.queueOperation<void>("run", sql, params);
}
}
```
Key features:
- Uses absurd-sql for SQLite in the browser
- Implements operation queuing for thread safety
- Handles initialization and connection management
- Provides consistent API across platforms
### 5. Migration from Dexie.js
The current implementation supports gradual migration from Dexie.js:
```typescript
// Example of dual-storage pattern
async function getAccount(did: string): Promise<Account | undefined> {
// Try SQLite first
const platform = PlatformServiceFactory.getInstance();
let account = await platform.dbQuery(
"SELECT * FROM accounts WHERE did = ?",
[did]
);
// Fallback to Dexie if needed
if (USE_DEXIE_DB) {
account = await db.accounts.get(did);
}
return account;
}
```
#### A. Modifying Code
When converting from Dexie.js to SQL-based implementation, follow these patterns:
1. **Database Access Pattern**
```typescript
// Before (Dexie)
const result = await db.table.where("field").equals(value).first();
// After (SQL)
const platform = PlatformServiceFactory.getInstance();
let result = await platform.dbQuery(
"SELECT * FROM table WHERE field = ?",
[value]
);
result = databaseUtil.mapQueryResultToValues(result);
// Fallback to Dexie if needed
if (USE_DEXIE_DB) {
result = await db.table.where("field").equals(value).first();
}
```
2. **Update Operations**
```typescript
// Before (Dexie)
await db.table.where("id").equals(id).modify(changes);
// After (SQL)
// For settings updates, use the utility methods:
await databaseUtil.updateDefaultSettings(changes);
// OR
await databaseUtil.updateAccountSettings(did, changes);
// For other tables, use direct SQL:
const platform = PlatformServiceFactory.getInstance();
await platform.dbExec(
"UPDATE table SET field1 = ?, field2 = ? WHERE id = ?",
[changes.field1, changes.field2, id]
);
// Fallback to Dexie if needed
if (USE_DEXIE_DB) {
await db.table.where("id").equals(id).modify(changes);
}
```
3. **Insert Operations**
```typescript
// Before (Dexie)
await db.table.add(item);
// After (SQL)
const platform = PlatformServiceFactory.getInstance();
const columns = Object.keys(item);
const values = Object.values(item);
const placeholders = values.map(() => '?').join(', ');
const sql = `INSERT INTO table (${columns.join(', ')}) VALUES (${placeholders})`;
await platform.dbExec(sql, values);
// Fallback to Dexie if needed
if (USE_DEXIE_DB) {
await db.table.add(item);
}
```
4. **Delete Operations**
```typescript
// Before (Dexie)
await db.table.where("id").equals(id).delete();
// After (SQL)
const platform = PlatformServiceFactory.getInstance();
await platform.dbExec("DELETE FROM table WHERE id = ?", [id]);
// Fallback to Dexie if needed
if (USE_DEXIE_DB) {
await db.table.where("id").equals(id).delete();
}
```
5. **Result Processing**
```typescript
// Before (Dexie)
const items = await db.table.toArray();
// After (SQL)
const platform = PlatformServiceFactory.getInstance();
let items = await platform.dbQuery("SELECT * FROM table");
items = databaseUtil.mapQueryResultToValues(items);
// Fallback to Dexie if needed
if (USE_DEXIE_DB) {
items = await db.table.toArray();
}
```
6. **Using Utility Methods**
When working with settings or other common operations, use the utility methods in `db/index.ts`:
```typescript
// Settings operations
await databaseUtil.updateDefaultSettings(settings);
await databaseUtil.updateAccountSettings(did, settings);
const settings = await databaseUtil.retrieveSettingsForDefaultAccount();
const settings = await databaseUtil.retrieveSettingsForActiveAccount();
// Logging operations
await databaseUtil.logToDb(message);
await databaseUtil.logConsoleAndDb(message, showInConsole);
```
Key Considerations:
- Always use `databaseUtil.mapQueryResultToValues()` to process SQL query results
- Use utility methods from `db/index.ts` when available instead of direct SQL
- Keep Dexie fallbacks wrapped in `if (USE_DEXIE_DB)` checks
- For queries that return results, use `let` variables to allow Dexie fallback to override
- For updates/inserts/deletes, execute both SQL and Dexie operations when `USE_DEXIE_DB` is true
Example Migration:
```typescript
// Before (Dexie)
export async function updateSettings(settings: Settings): Promise<void> {
await db.settings.put(settings);
}
// After (SQL)
export async function updateSettings(settings: Settings): Promise<void> {
const platform = PlatformServiceFactory.getInstance();
const { sql, params } = generateUpdateStatement(
settings,
"settings",
"id = ?",
[settings.id]
);
await platform.dbExec(sql, params);
}
```
Remember to:
- Create database access code to use the platform service, putting it in front of the Dexie version
- Instead of removing Dexie-specific code, keep it.
- For creates & updates & deletes, the duplicate code is fine.
- For queries where we use the results, make the setting from SQL into a 'let' variable, then wrap the Dexie code in a check for USE_DEXIE_DB from app.ts and if
it's true then use that result instead of the SQL code's result.
- Consider data migration needs, and warn if there are any potential migration problems
## Success Criteria
1. **Functionality**
- [x] Basic CRUD operations work correctly
- [x] Platform service factory pattern implemented
- [x] Error handling in place
- [ ] Native platform support (planned)
2. **Performance**
- [x] Database operations complete within acceptable time
- [x] Operation queuing for thread safety
- [x] Proper initialization handling
- [ ] Performance monitoring (planned)
3. **Security**
- [x] Basic data integrity
- [ ] Encryption (planned for native platforms)
- [ ] Secure key storage (planned)
- [ ] Platform-specific security features (planned)
4. **Testing**
- [x] Basic unit tests
- [ ] Comprehensive integration tests (planned)
- [ ] Platform-specific tests (planned)
- [ ] Migration tests (planned)
## Next Steps
1. **Native Platform Support**
- Implement SQLCipher for iOS/Android
- Add platform-specific secure storage
- Implement biometric authentication
2. **Enhanced Security**
- Add encryption for sensitive data
- Implement secure key storage
- Add platform-specific security features
3. **Testing and Monitoring**
- Add comprehensive test coverage
- Implement performance monitoring
- Add error tracking and analytics
4. **Documentation**
- Add API documentation
- Create migration guides
- Document security measures

329
doc/storage-implementation-checklist.md

@ -0,0 +1,329 @@
# Storage Implementation Checklist
## Core Services
### 1. Storage Service Layer
- [x] Create base `PlatformService` interface
- [x] Define common methods for all platforms
- [x] Add platform-specific method signatures
- [x] Include error handling types
- [x] Add migration support methods
- [x] Implement platform-specific services
- [x] `AbsurdSqlDatabaseService` (web)
- [x] Database initialization
- [x] VFS setup with IndexedDB backend
- [x] Connection management
- [x] Operation queuing
- [ ] `NativeSQLiteService` (iOS/Android) (planned)
- [ ] SQLCipher integration
- [ ] Native bridge setup
- [ ] File system access
- [ ] `ElectronSQLiteService` (planned)
- [ ] Node SQLite integration
- [ ] IPC communication
- [ ] File system access
### 2. Migration Services
- [x] Implement basic migration support
- [x] Dual-storage pattern (SQLite + Dexie)
- [x] Basic data verification
- [ ] Rollback procedures (planned)
- [ ] Progress tracking (planned)
- [ ] Create `MigrationUI` components (planned)
- [ ] Progress indicators
- [ ] Error handling
- [ ] User notifications
- [ ] Manual triggers
### 3. Security Layer
- [x] Basic data integrity
- [ ] Implement `EncryptionService` (planned)
- [ ] Key management
- [ ] Encryption/decryption
- [ ] Secure storage
- [ ] Add `BiometricService` (planned)
- [ ] Platform detection
- [ ] Authentication flow
- [ ] Fallback mechanisms
## Platform-Specific Implementation
### Web Platform
- [x] Setup absurd-sql
- [x] Install dependencies
```json
{
"@jlongster/sql.js": "^1.8.0",
"absurd-sql": "^1.8.0"
}
```
- [x] Configure VFS with IndexedDB backend
- [x] Setup worker threads
- [x] Implement operation queuing
- [x] Configure database pragmas
```sql
PRAGMA journal_mode=MEMORY;
PRAGMA synchronous=NORMAL;
PRAGMA foreign_keys=ON;
PRAGMA busy_timeout=5000;
```
- [x] Update build configuration
- [x] Modify `vite.config.ts`
- [x] Add worker configuration
- [x] Update chunk splitting
- [x] Configure asset handling
- [x] Implement IndexedDB backend
- [x] Create database service
- [x] Add operation queuing
- [x] Handle initialization
- [x] Implement atomic operations
### iOS Platform (Planned)
- [ ] Setup SQLCipher
- [ ] Install pod dependencies
- [ ] Configure encryption
- [ ] Setup keychain access
- [ ] Implement secure storage
- [ ] Update Capacitor config
- [ ] Modify `capacitor.config.ts`
- [ ] Add iOS permissions
- [ ] Configure backup
- [ ] Setup app groups
### Android Platform (Planned)
- [ ] Setup SQLCipher
- [ ] Add Gradle dependencies
- [ ] Configure encryption
- [ ] Setup keystore
- [ ] Implement secure storage
- [ ] Update Capacitor config
- [ ] Modify `capacitor.config.ts`
- [ ] Add Android permissions
- [ ] Configure backup
- [ ] Setup file provider
### Electron Platform (Planned)
- [ ] Setup Node SQLite
- [ ] Install dependencies
- [ ] Configure IPC
- [ ] Setup file system access
- [ ] Implement secure storage
- [ ] Update Electron config
- [ ] Modify `electron.config.ts`
- [ ] Add security policies
- [ ] Configure file access
- [ ] Setup auto-updates
## Data Models and Types
### 1. Database Schema
- [x] Define tables
```sql
-- Accounts table
CREATE TABLE accounts (
did TEXT PRIMARY KEY,
public_key_hex TEXT NOT NULL,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
-- Settings table
CREATE TABLE settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at INTEGER NOT NULL
);
-- Contacts table
CREATE TABLE contacts (
id TEXT PRIMARY KEY,
did TEXT NOT NULL,
name TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
FOREIGN KEY (did) REFERENCES accounts(did)
);
-- Indexes for performance
CREATE INDEX idx_accounts_created_at ON accounts(created_at);
CREATE INDEX idx_contacts_did ON contacts(did);
CREATE INDEX idx_settings_updated_at ON settings(updated_at);
```
- [x] Create indexes
- [x] Define constraints
- [ ] Add triggers (planned)
- [ ] Setup migrations (planned)
### 2. Type Definitions
- [x] Create interfaces
```typescript
interface Account {
did: string;
publicKeyHex: string;
createdAt: number;
updatedAt: number;
}
interface Setting {
key: string;
value: string;
updatedAt: number;
}
interface Contact {
id: string;
did: string;
name?: string;
createdAt: number;
updatedAt: number;
}
```
- [x] Add validation
- [x] Create DTOs
- [x] Define enums
- [x] Add type guards
## UI Components
### 1. Migration UI (Planned)
- [ ] Create components
- [ ] `MigrationProgress.vue`
- [ ] `MigrationError.vue`
- [ ] `MigrationSettings.vue`
- [ ] `MigrationStatus.vue`
### 2. Settings UI (Planned)
- [ ] Update components
- [ ] Add storage settings
- [ ] Add migration controls
- [ ] Add backup options
- [ ] Add security settings
### 3. Error Handling UI (Planned)
- [ ] Create components
- [ ] `StorageError.vue`
- [ ] `QuotaExceeded.vue`
- [ ] `MigrationFailed.vue`
- [ ] `RecoveryOptions.vue`
## Testing
### 1. Unit Tests
- [x] Basic service tests
- [x] Platform service tests
- [x] Database operation tests
- [ ] Security service tests (planned)
- [ ] Platform detection tests (planned)
### 2. Integration Tests (Planned)
- [ ] Test migrations
- [ ] Web platform tests
- [ ] iOS platform tests
- [ ] Android platform tests
- [ ] Electron platform tests
### 3. E2E Tests (Planned)
- [ ] Test workflows
- [ ] Account management
- [ ] Settings management
- [ ] Contact management
- [ ] Migration process
## Documentation
### 1. Technical Documentation
- [x] Update architecture docs
- [x] Add API documentation
- [ ] Create migration guides (planned)
- [ ] Document security measures (planned)
### 2. User Documentation (Planned)
- [ ] Update user guides
- [ ] Add troubleshooting guides
- [ ] Create FAQ
- [ ] Document new features
## Deployment
### 1. Build Process
- [x] Update build scripts
- [x] Add platform-specific builds
- [ ] Configure CI/CD (planned)
- [ ] Setup automated testing (planned)
### 2. Release Process (Planned)
- [ ] Create release checklist
- [ ] Add version management
- [ ] Setup rollback procedures
- [ ] Configure monitoring
## Monitoring and Analytics (Planned)
### 1. Error Tracking
- [ ] Setup error logging
- [ ] Add performance monitoring
- [ ] Configure alerts
- [ ] Create dashboards
### 2. Usage Analytics
- [ ] Add storage metrics
- [ ] Track migration success
- [ ] Monitor performance
- [ ] Collect user feedback
## Security Audit (Planned)
### 1. Code Review
- [ ] Review encryption
- [ ] Check access controls
- [ ] Verify data handling
- [ ] Audit dependencies
### 2. Penetration Testing
- [ ] Test data access
- [ ] Verify encryption
- [ ] Check authentication
- [ ] Review permissions
## Success Criteria
### 1. Performance
- [x] Query response time < 100ms
- [x] Operation queuing for thread safety
- [x] Proper initialization handling
- [ ] Migration time < 5s per 1000 records (planned)
- [ ] Storage overhead < 10% (planned)
- [ ] Memory usage < 50MB (planned)
### 2. Reliability
- [x] Basic data integrity
- [x] Operation queuing
- [ ] Automatic recovery (planned)
- [ ] Backup verification (planned)
- [ ] Transaction atomicity (planned)
- [ ] Data consistency (planned)
### 3. Security
- [x] Basic data integrity
- [ ] AES-256 encryption (planned)
- [ ] Secure key storage (planned)
- [ ] Access control (planned)
- [ ] Audit logging (planned)
### 4. User Experience
- [x] Basic database operations
- [ ] Smooth migration (planned)
- [ ] Clear error messages (planned)
- [ ] Progress indicators (planned)
- [ ] Recovery options (planned)

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

2
index.html

@ -3,7 +3,7 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge"> <meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width,initial-scale=1.0"> <meta name="viewport" content="width=device-width,initial-scale=1.0,viewport-fit=cover">
<link rel="icon" href="/favicon.ico"> <link rel="icon" href="/favicon.ico">
<title>TimeSafari</title> <title>TimeSafari</title>
</head> </head>

1
ios/.gitignore

@ -4,7 +4,6 @@ App/output
App/App/public App/App/public
DerivedData DerivedData
xcuserdata xcuserdata
*.xcuserstate
# Cordova plugins for Capacitor # Cordova plugins for Capacitor
capacitor-cordova-ios-plugins capacitor-cordova-ios-plugins

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

@ -3,7 +3,7 @@
archiveVersion = 1; archiveVersion = 1;
classes = { classes = {
}; };
objectVersion = 48; objectVersion = 54;
objects = { objects = {
/* Begin PBXBuildFile section */ /* Begin PBXBuildFile section */
@ -14,22 +14,22 @@
504EC30F1FED79650016851F /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 504EC30E1FED79650016851F /* Assets.xcassets */; }; 504EC30F1FED79650016851F /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 504EC30E1FED79650016851F /* Assets.xcassets */; };
504EC3121FED79650016851F /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 504EC3101FED79650016851F /* LaunchScreen.storyboard */; }; 504EC3121FED79650016851F /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 504EC3101FED79650016851F /* LaunchScreen.storyboard */; };
50B271D11FEDC1A000F3C39B /* public in Resources */ = {isa = PBXBuildFile; fileRef = 50B271D01FEDC1A000F3C39B /* public */; }; 50B271D11FEDC1A000F3C39B /* public in Resources */ = {isa = PBXBuildFile; fileRef = 50B271D01FEDC1A000F3C39B /* public */; };
A084ECDBA7D38E1E42DFC39D /* Pods_App.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = AF277DCFFFF123FFC6DF26C7 /* Pods_App.framework */; }; 97EF2DC6FD76C3643D680B8D /* Pods_App.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 90DCAFB4D8948F7A50C13800 /* Pods_App.framework */; };
/* End PBXBuildFile section */ /* End PBXBuildFile section */
/* Begin PBXFileReference section */ /* Begin PBXFileReference section */
2FAD9762203C412B000D30F8 /* config.xml */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = config.xml; sourceTree = "<group>"; }; 2FAD9762203C412B000D30F8 /* config.xml */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = config.xml; sourceTree = "<group>"; };
50379B222058CBB4000EE86E /* capacitor.config.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = capacitor.config.json; sourceTree = "<group>"; }; 50379B222058CBB4000EE86E /* capacitor.config.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = capacitor.config.json; sourceTree = "<group>"; };
504EC3041FED79650016851F /* Time Safari.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Time Safari.app"; sourceTree = BUILT_PRODUCTS_DIR; }; 504EC3041FED79650016851F /* App.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = App.app; sourceTree = BUILT_PRODUCTS_DIR; };
504EC3071FED79650016851F /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; }; 504EC3071FED79650016851F /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
504EC30C1FED79650016851F /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = "<group>"; }; 504EC30C1FED79650016851F /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = "<group>"; };
504EC30E1FED79650016851F /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; }; 504EC30E1FED79650016851F /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
504EC3111FED79650016851F /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; }; 504EC3111FED79650016851F /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
504EC3131FED79650016851F /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; }; 504EC3131FED79650016851F /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
50B271D01FEDC1A000F3C39B /* public */ = {isa = PBXFileReference; lastKnownFileType = folder; path = public; sourceTree = "<group>"; }; 50B271D01FEDC1A000F3C39B /* public */ = {isa = PBXFileReference; lastKnownFileType = folder; path = public; sourceTree = "<group>"; };
AF277DCFFFF123FFC6DF26C7 /* Pods_App.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_App.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 90DCAFB4D8948F7A50C13800 /* Pods_App.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_App.framework; sourceTree = BUILT_PRODUCTS_DIR; };
AF51FD2D460BCFE21FA515B2 /* Pods-App.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App.release.xcconfig"; path = "Pods/Target Support Files/Pods-App/Pods-App.release.xcconfig"; sourceTree = "<group>"; }; E2E9297D5D02C549106C77F9 /* Pods-App.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App.release.xcconfig"; path = "Target Support Files/Pods-App/Pods-App.release.xcconfig"; sourceTree = "<group>"; };
FC68EB0AF532CFC21C3344DD /* Pods-App.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App.debug.xcconfig"; path = "Pods/Target Support Files/Pods-App/Pods-App.debug.xcconfig"; sourceTree = "<group>"; }; EAEC6436E595F7CD3A1C9E96 /* Pods-App.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App.debug.xcconfig"; path = "Target Support Files/Pods-App/Pods-App.debug.xcconfig"; sourceTree = "<group>"; };
/* End PBXFileReference section */ /* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */ /* Begin PBXFrameworksBuildPhase section */
@ -37,17 +37,17 @@
isa = PBXFrameworksBuildPhase; isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
A084ECDBA7D38E1E42DFC39D /* Pods_App.framework in Frameworks */, 97EF2DC6FD76C3643D680B8D /* Pods_App.framework in Frameworks */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
/* End PBXFrameworksBuildPhase section */ /* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */ /* Begin PBXGroup section */
27E2DDA53C4D2A4D1A88CE4A /* Frameworks */ = { 4B546315E668C7A13939F417 /* Frameworks */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
AF277DCFFFF123FFC6DF26C7 /* Pods_App.framework */, 90DCAFB4D8948F7A50C13800 /* Pods_App.framework */,
); );
name = Frameworks; name = Frameworks;
sourceTree = "<group>"; sourceTree = "<group>";
@ -57,15 +57,15 @@
children = ( children = (
504EC3061FED79650016851F /* App */, 504EC3061FED79650016851F /* App */,
504EC3051FED79650016851F /* Products */, 504EC3051FED79650016851F /* Products */,
7F8756D8B27F46E3366F6CEA /* Pods */, BA325FFCDCE8D334E5C7AEBE /* Pods */,
27E2DDA53C4D2A4D1A88CE4A /* Frameworks */, 4B546315E668C7A13939F417 /* Frameworks */,
); );
sourceTree = "<group>"; sourceTree = "<group>";
}; };
504EC3051FED79650016851F /* Products */ = { 504EC3051FED79650016851F /* Products */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
504EC3041FED79650016851F /* Time Safari.app */, 504EC3041FED79650016851F /* App.app */,
); );
name = Products; name = Products;
sourceTree = "<group>"; sourceTree = "<group>";
@ -85,35 +85,37 @@
path = App; path = App;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
7F8756D8B27F46E3366F6CEA /* Pods */ = { BA325FFCDCE8D334E5C7AEBE /* Pods */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
FC68EB0AF532CFC21C3344DD /* Pods-App.debug.xcconfig */, EAEC6436E595F7CD3A1C9E96 /* Pods-App.debug.xcconfig */,
AF51FD2D460BCFE21FA515B2 /* Pods-App.release.xcconfig */, E2E9297D5D02C549106C77F9 /* Pods-App.release.xcconfig */,
); );
name = Pods; path = Pods;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
/* End PBXGroup section */ /* End PBXGroup section */
/* Begin PBXNativeTarget section */ /* Begin PBXNativeTarget section */
504EC3031FED79650016851F /* Time Safari */ = { 504EC3031FED79650016851F /* App */ = {
isa = PBXNativeTarget; isa = PBXNativeTarget;
buildConfigurationList = 504EC3161FED79650016851F /* Build configuration list for PBXNativeTarget "Time Safari" */; buildConfigurationList = 504EC3161FED79650016851F /* Build configuration list for PBXNativeTarget "App" */;
buildPhases = ( buildPhases = (
6634F4EFEBD30273BCE97C65 /* [CP] Check Pods Manifest.lock */, 92977BEA1068CC097A57FC77 /* [CP] Check Pods Manifest.lock */,
504EC3001FED79650016851F /* Sources */, 504EC3001FED79650016851F /* Sources */,
504EC3011FED79650016851F /* Frameworks */, 504EC3011FED79650016851F /* Frameworks */,
504EC3021FED79650016851F /* Resources */, 504EC3021FED79650016851F /* Resources */,
9592DBEFFC6D2A0C8D5DEB22 /* [CP] Embed Pods Frameworks */, 012076E8FFE4BF260A79B034 /* Fix Privacy Manifest */,
3525031ED1C96EF4CF6E9959 /* [CP] Embed Pods Frameworks */,
96A7EF592DF3366D00084D51 /* Fix Privacy Manifest */,
); );
buildRules = ( buildRules = (
); );
dependencies = ( dependencies = (
); );
name = "Time Safari"; name = App;
productName = App; productName = App;
productReference = 504EC3041FED79650016851F /* Time Safari.app */; productReference = 504EC3041FED79650016851F /* App.app */;
productType = "com.apple.product-type.application"; productType = "com.apple.product-type.application";
}; };
/* End PBXNativeTarget section */ /* End PBXNativeTarget section */
@ -122,8 +124,9 @@
504EC2FC1FED79650016851F /* Project object */ = { 504EC2FC1FED79650016851F /* Project object */ = {
isa = PBXProject; isa = PBXProject;
attributes = { attributes = {
LastSwiftUpdateCheck = 0920; BuildIndependentTargetsInParallel = YES;
LastUpgradeCheck = 0920; LastSwiftUpdateCheck = 920;
LastUpgradeCheck = 1630;
TargetAttributes = { TargetAttributes = {
504EC3031FED79650016851F = { 504EC3031FED79650016851F = {
CreatedOnToolsVersion = 9.2; CreatedOnToolsVersion = 9.2;
@ -132,7 +135,7 @@
}; };
}; };
}; };
buildConfigurationList = 504EC2FF1FED79650016851F /* Build configuration list for PBXProject "Time Safari" */; buildConfigurationList = 504EC2FF1FED79650016851F /* Build configuration list for PBXProject "App" */;
compatibilityVersion = "Xcode 8.0"; compatibilityVersion = "Xcode 8.0";
developmentRegion = en; developmentRegion = en;
hasScannedForEncodings = 0; hasScannedForEncodings = 0;
@ -141,13 +144,11 @@
Base, Base,
); );
mainGroup = 504EC2FB1FED79650016851F; mainGroup = 504EC2FB1FED79650016851F;
packageReferences = (
);
productRefGroup = 504EC3051FED79650016851F /* Products */; productRefGroup = 504EC3051FED79650016851F /* Products */;
projectDirPath = ""; projectDirPath = "";
projectRoot = ""; projectRoot = "";
targets = ( targets = (
504EC3031FED79650016851F /* Time Safari */, 504EC3031FED79650016851F /* App */,
); );
}; };
/* End PBXProject section */ /* End PBXProject section */
@ -169,16 +170,55 @@
/* End PBXResourcesBuildPhase section */ /* End PBXResourcesBuildPhase section */
/* Begin PBXShellScriptBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */
6634F4EFEBD30273BCE97C65 /* [CP] Check Pods Manifest.lock */ = { 012076E8FFE4BF260A79B034 /* Fix Privacy Manifest */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
);
inputPaths = (
);
name = "Fix Privacy Manifest";
outputFileListPaths = (
);
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PROJECT_DIR}/app_privacy_manifest_fixer/fixer.sh\" \n";
showEnvVarsInLog = 0;
};
3525031ED1C96EF4CF6E9959 /* [CP] Embed Pods Frameworks */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputPaths = (
);
name = "[CP] Embed Pods Frameworks";
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-App/Pods-App-frameworks.sh\"\n";
showEnvVarsInLog = 0;
};
92977BEA1068CC097A57FC77 /* [CP] Check Pods Manifest.lock */ = {
isa = PBXShellScriptBuildPhase; isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
); );
inputFileListPaths = (
);
inputPaths = ( inputPaths = (
"${PODS_PODFILE_DIR_PATH}/Podfile.lock", "${PODS_PODFILE_DIR_PATH}/Podfile.lock",
"${PODS_ROOT}/Manifest.lock", "${PODS_ROOT}/Manifest.lock",
); );
name = "[CP] Check Pods Manifest.lock"; name = "[CP] Check Pods Manifest.lock";
outputFileListPaths = (
);
outputPaths = ( outputPaths = (
"$(DERIVED_FILE_DIR)/Pods-App-checkManifestLockResult.txt", "$(DERIVED_FILE_DIR)/Pods-App-checkManifestLockResult.txt",
); );
@ -187,20 +227,24 @@
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
showEnvVarsInLog = 0; showEnvVarsInLog = 0;
}; };
9592DBEFFC6D2A0C8D5DEB22 /* [CP] Embed Pods Frameworks */ = { 96A7EF592DF3366D00084D51 /* Fix Privacy Manifest */ = {
isa = PBXShellScriptBuildPhase; isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
); );
inputFileListPaths = (
);
inputPaths = ( inputPaths = (
); );
name = "[CP] Embed Pods Frameworks"; name = "Fix Privacy Manifest";
outputFileListPaths = (
);
outputPaths = ( outputPaths = (
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh; shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-App/Pods-App-frameworks.sh\"\n"; shellScript = "$PROJECT_DIR/app_privacy_manifest_fixer/fixer.sh\n";
showEnvVarsInLog = 0;
}; };
/* End PBXShellScriptBuildPhase section */ /* End PBXShellScriptBuildPhase section */
@ -249,6 +293,7 @@
CLANG_WARN_BOOL_CONVERSION = YES; CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES; CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES; CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES; CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES; CLANG_WARN_EMPTY_BODY = YES;
@ -256,8 +301,10 @@
CLANG_WARN_INFINITE_RECURSION = YES; CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES; CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES; CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES; CLANG_WARN_SUSPICIOUS_MOVE = YES;
@ -267,8 +314,10 @@
CODE_SIGN_IDENTITY = "iPhone Developer"; CODE_SIGN_IDENTITY = "iPhone Developer";
COPY_PHASE_STRIP = NO; COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = dwarf; DEBUG_INFORMATION_FORMAT = dwarf;
DEVELOPMENT_TEAM = 7XVXYPEQYJ;
ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES; ENABLE_TESTABILITY = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu11; GCC_C_LANGUAGE_STANDARD = gnu11;
GCC_DYNAMIC_NO_PIC = NO; GCC_DYNAMIC_NO_PIC = NO;
GCC_NO_COMMON_BLOCKS = YES; GCC_NO_COMMON_BLOCKS = YES;
@ -306,6 +355,7 @@
CLANG_WARN_BOOL_CONVERSION = YES; CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES; CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES; CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES; CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES; CLANG_WARN_EMPTY_BODY = YES;
@ -313,8 +363,10 @@
CLANG_WARN_INFINITE_RECURSION = YES; CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES; CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES; CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES; CLANG_WARN_SUSPICIOUS_MOVE = YES;
@ -324,8 +376,10 @@
CODE_SIGN_IDENTITY = "iPhone Developer"; CODE_SIGN_IDENTITY = "iPhone Developer";
COPY_PHASE_STRIP = NO; COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEVELOPMENT_TEAM = 7XVXYPEQYJ;
ENABLE_NS_ASSERTIONS = NO; ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu11; GCC_C_LANGUAGE_STANDARD = gnu11;
GCC_NO_COMMON_BLOCKS = YES; GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES; GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
@ -337,60 +391,69 @@
IPHONEOS_DEPLOYMENT_TARGET = 13.0; IPHONEOS_DEPLOYMENT_TARGET = 13.0;
MTL_ENABLE_DEBUG_INFO = NO; MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos; SDKROOT = iphoneos;
SWIFT_OPTIMIZATION_LEVEL = "-Owholemodule"; SWIFT_COMPILATION_MODE = wholemodule;
SWIFT_OPTIMIZATION_LEVEL = "-O";
VALIDATE_PRODUCT = YES; VALIDATE_PRODUCT = YES;
}; };
name = Release; name = Release;
}; };
504EC3171FED79650016851F /* Debug */ = { 504EC3171FED79650016851F /* Debug */ = {
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
baseConfigurationReference = FC68EB0AF532CFC21C3344DD /* Pods-App.debug.xcconfig */; baseConfigurationReference = EAEC6436E595F7CD3A1C9E96 /* Pods-App.debug.xcconfig */;
buildSettings = { buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 26;
DEVELOPMENT_TEAM = GM3FS5JQPH; DEVELOPMENT_TEAM = GM3FS5JQPH;
ENABLE_APP_SANDBOX = NO;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
INFOPLIST_FILE = App/Info.plist; INFOPLIST_FILE = App/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = "Time Safari";
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
IPHONEOS_DEPLOYMENT_TARGET = 13.0; IPHONEOS_DEPLOYMENT_TARGET = 13.0;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; LD_RUNPATH_SEARCH_PATHS = (
MARKETING_VERSION = 1.0; "$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 0.5.1;
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\""; OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
PRODUCT_BUNDLE_IDENTIFIER = app.timesafari; PRODUCT_BUNDLE_IDENTIFIER = app.timesafari;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2"; TARGETED_DEVICE_FAMILY = "1,2";
VERSIONING_SYSTEM = "apple-generic";
}; };
name = Debug; name = Debug;
}; };
504EC3181FED79650016851F /* Release */ = { 504EC3181FED79650016851F /* Release */ = {
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
baseConfigurationReference = AF51FD2D460BCFE21FA515B2 /* Pods-App.release.xcconfig */; baseConfigurationReference = E2E9297D5D02C549106C77F9 /* Pods-App.release.xcconfig */;
buildSettings = { buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 26;
DEVELOPMENT_TEAM = GM3FS5JQPH; DEVELOPMENT_TEAM = GM3FS5JQPH;
ENABLE_APP_SANDBOX = NO;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
INFOPLIST_FILE = App/Info.plist; INFOPLIST_FILE = App/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = "Time Safari";
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
IPHONEOS_DEPLOYMENT_TARGET = 13.0; IPHONEOS_DEPLOYMENT_TARGET = 13.0;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; LD_RUNPATH_SEARCH_PATHS = (
MARKETING_VERSION = 1.0; "$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 0.5.1;
PRODUCT_BUNDLE_IDENTIFIER = app.timesafari; PRODUCT_BUNDLE_IDENTIFIER = app.timesafari;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = ""; SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2"; TARGETED_DEVICE_FAMILY = "1,2";
VERSIONING_SYSTEM = "apple-generic";
}; };
name = Release; name = Release;
}; };
/* End XCBuildConfiguration section */ /* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */ /* Begin XCConfigurationList section */
504EC2FF1FED79650016851F /* Build configuration list for PBXProject "Time Safari" */ = { 504EC2FF1FED79650016851F /* Build configuration list for PBXProject "App" */ = {
isa = XCConfigurationList; isa = XCConfigurationList;
buildConfigurations = ( buildConfigurations = (
504EC3141FED79650016851F /* Debug */, 504EC3141FED79650016851F /* Debug */,
@ -399,7 +462,7 @@
defaultConfigurationIsVisible = 0; defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release; defaultConfigurationName = Release;
}; };
504EC3161FED79650016851F /* Build configuration list for PBXNativeTarget "Time Safari" */ = { 504EC3161FED79650016851F /* Build configuration list for PBXNativeTarget "App" */ = {
isa = XCConfigurationList; isa = XCConfigurationList;
buildConfigurations = ( buildConfigurations = (
504EC3171FED79650016851F /* Debug */, 504EC3171FED79650016851F /* Debug */,

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

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

5
ios/App/App/AppDelegate.swift

@ -1,5 +1,6 @@
import UIKit import UIKit
import Capacitor import Capacitor
import CapacitorCommunitySqlite
@UIApplicationMain @UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate { class AppDelegate: UIResponder, UIApplicationDelegate {
@ -7,6 +8,10 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
var window: UIWindow? var window: UIWindow?
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
// Initialize SQLite
//let sqlite = SQLite()
//sqlite.initialize()
// Override point for customization after application launch. // Override point for customization after application launch.
return true return true
} }

6
ios/App/App/Info.plist

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

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

@ -0,0 +1,20 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.cs.allow-jit</key>
<true/>
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
<true/>
<key>com.apple.security.cs.debugger</key>
<true/>
<key>com.apple.security.device.audio-input</key>
<true/>
<key>com.apple.security.device.camera</key>
<true/>
<key>com.apple.security.personal-information.addressbook</key>
<true/>
<key>com.apple.security.personal-information.calendars</key>
<true/>
</dict>
</plist>

11
ios/App/Podfile

@ -11,7 +11,13 @@ install! 'cocoapods', :disable_input_output_paths => true
def capacitor_pods def capacitor_pods
pod 'Capacitor', :path => '../../node_modules/@capacitor/ios' pod 'Capacitor', :path => '../../node_modules/@capacitor/ios'
pod 'CapacitorCordova', :path => '../../node_modules/@capacitor/ios' pod 'CapacitorCordova', :path => '../../node_modules/@capacitor/ios'
pod 'CapacitorCommunitySqlite', :path => '../../node_modules/@capacitor-community/sqlite'
pod 'CapacitorMlkitBarcodeScanning', :path => '../../node_modules/@capacitor-mlkit/barcode-scanning'
pod 'CapacitorApp', :path => '../../node_modules/@capacitor/app' pod 'CapacitorApp', :path => '../../node_modules/@capacitor/app'
pod 'CapacitorCamera', :path => '../../node_modules/@capacitor/camera'
pod 'CapacitorFilesystem', :path => '../../node_modules/@capacitor/filesystem'
pod 'CapacitorShare', :path => '../../node_modules/@capacitor/share'
pod 'CapawesomeCapacitorFilePicker', :path => '../../node_modules/@capawesome/capacitor-file-picker'
end end
target 'App' do target 'App' do
@ -21,4 +27,9 @@ end
post_install do |installer| post_install do |installer|
assertDeploymentTarget(installer) assertDeploymentTarget(installer)
installer.pods_project.targets.each do |target|
target.build_configurations.each do |config|
config.build_settings['EXCLUDED_ARCHS[sdk=iphonesimulator*]'] = 'arm64'
end
end
end end

144
ios/App/Podfile.lock

@ -1,28 +1,162 @@
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
- CapacitorCommunitySqlite (6.0.2):
- Capacitor
- SQLCipher
- ZIPFoundation
- 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)
- SQLCipher (4.9.0):
- SQLCipher/standard (= 4.9.0)
- SQLCipher/common (4.9.0)
- SQLCipher/standard (4.9.0):
- SQLCipher/common
- ZIPFoundation (0.9.19)
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`)"
- "CapacitorCommunitySqlite (from `../../node_modules/@capacitor-community/sqlite`)"
- "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
- SQLCipher
- ZIPFoundation
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"
CapacitorCommunitySqlite:
:path: "../../node_modules/@capacitor-community/sqlite"
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
CapacitorCommunitySqlite: 0299d20f4b00c2e6aa485a1d8932656753937b9b
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
SQLCipher: 31878d8ebd27e5c96db0b7cb695c96e9f8ad77da
ZIPFoundation: b8c29ea7ae353b309bc810586181fd073cb3312c
PODFILE CHECKSUM: 4233f5c5f414604460ff96d372542c311b0fb7a8 PODFILE CHECKSUM: f987510f7383b04a1b09ea8472bdadcd88b6c924
COCOAPODS: 1.16.2 COCOAPODS: 1.16.2

5
ios/App/app_privacy_manifest_fixer/.gitignore

@ -0,0 +1,5 @@
# macOS
.DS_Store
# Build
/Build/

58
ios/App/app_privacy_manifest_fixer/CHANGELOG.md

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

80
ios/App/app_privacy_manifest_fixer/Common/constants.sh

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

125
ios/App/app_privacy_manifest_fixer/Common/utils.sh

@ -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")"
}

80
ios/App/app_privacy_manifest_fixer/Helper/xcode_install_helper.rb

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

63
ios/App/app_privacy_manifest_fixer/Helper/xcode_uninstall_helper.rb

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

21
ios/App/app_privacy_manifest_fixer/LICENSE

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

240
ios/App/app_privacy_manifest_fixer/README.md

@ -0,0 +1,240 @@
# App Privacy Manifest Fixer
[![Latest Version](https://img.shields.io/github/v/release/crasowas/app_privacy_manifest_fixer?logo=github)](https://github.com/crasowas/app_privacy_manifest_fixer/releases/latest)
![Supported Platforms](https://img.shields.io/badge/Supported%20Platforms-iOS%20%7C%20macOS-brightgreen)
[![License](https://img.shields.io/badge/License-MIT-green.svg)](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):
![Build Phases Screenshot](https://img.crasowas.dev/app_privacy_manifest_fixer/20250225011407.png)
## 🚀 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):
![Xcode Build Log Screenshot](https://img.crasowas.dev/app_privacy_manifest_fixer/20250225011551.png)
## 📖 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) |
|------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------|
| ![Original App Report](https://img.crasowas.dev/app_privacy_manifest_fixer/20241218230746.png) | ![Fixed App Report](https://img.crasowas.dev/app_privacy_manifest_fixer/20241218230822.png) |
## 💡 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!

240
ios/App/app_privacy_manifest_fixer/README.zh-CN.md

@ -0,0 +1,240 @@
# App Privacy Manifest Fixer
[![Latest Version](https://img.shields.io/github/v/release/crasowas/app_privacy_manifest_fixer?logo=github)](https://github.com/crasowas/app_privacy_manifest_fixer/releases/latest)
![Supported Platforms](https://img.shields.io/badge/Supported%20Platforms-iOS%20%7C%20macOS-brightgreen)
[![License](https://img.shields.io/badge/License-MIT-green.svg)](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 配置截图(未启用任何命令行选项):
![Build Phases Screenshot](https://img.crasowas.dev/app_privacy_manifest_fixer/20250225011407.png)
## 🚀 快速开始
安装后,工具将在每次构建项目时自动运行,构建完成后得到的 App 包已经是修复后的结果。
如果启用`--install-builds-only`命令行选项安装,工具将仅在安装构建时运行。
### Xcode Build Log 截图
下面是项目构建时工具输出的日志截图(默认会存储到`app_privacy_manifest_fixer/Build`目录,除非启用`-s`命令行选项):
![Xcode Build Log Screenshot](https://img.crasowas.dev/app_privacy_manifest_fixer/20250225011551.png)
## 📖 使用方法
### 命令行选项
- **强制覆盖现有隐私清单(不推荐)**
```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) |
|------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------|
| ![Original App Report](https://img.crasowas.dev/app_privacy_manifest_fixer/20241218230746.png) | ![Fixed App Report](https://img.crasowas.dev/app_privacy_manifest_fixer/20241218230822.png) |
## 💡 重要考量
- 如果最新版本的 SDK 支持隐私清单,请尽可能升级,以避免不必要的风险。
- 此工具仅为临时解决方案,不应替代正确的 SDK 管理实践。
- 在提交 App 审核之前,请检查隐私清单修复后是否符合最新的 App Store 要求。
## 🙌 贡献
欢迎任何形式的贡献,包括代码优化、Bug 修复、文档改进等。请确保遵循项目规范,并保持代码风格一致。感谢你的支持!

124
ios/App/app_privacy_manifest_fixer/Report/report-template.html

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

285
ios/App/app_privacy_manifest_fixer/Report/report.sh

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

55
ios/App/app_privacy_manifest_fixer/Templates/AppTemplate.xcprivacy

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

55
ios/App/app_privacy_manifest_fixer/Templates/FrameworkTemplate.xcprivacy

@ -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
android/.gradle/vcs-1/gc.properties → ios/App/app_privacy_manifest_fixer/Templates/UserTemplates/.gitkeep

1
ios/App/app_privacy_manifest_fixer/VERSION

@ -0,0 +1 @@
v1.4.1

29
ios/App/app_privacy_manifest_fixer/clean.sh

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

490
ios/App/app_privacy_manifest_fixer/fixer.sh

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

71
ios/App/app_privacy_manifest_fixer/install.sh

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

46
ios/App/app_privacy_manifest_fixer/uninstall.sh

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

108
ios/App/app_privacy_manifest_fixer/upgrade.sh

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

29
main.js

@ -1,29 +0,0 @@
const { app, BrowserWindow } = require('electron');
const path = require('path');
function createWindow() {
const win = new BrowserWindow({
width: 1200,
height: 800,
webPreferences: {
nodeIntegration: true,
contextIsolation: false
}
});
win.loadFile(path.join(__dirname, 'dist-electron/www/index.html'));
}
app.whenReady().then(createWindow);
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.quit();
}
});
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) {
createWindow();
}
});

7623
package-lock.json

File diff suppressed because it is too large

71
package.json

@ -1,17 +1,17 @@
{ {
"name": "timesafari", "name": "timesafari",
"version": "0.4.4", "version": "0.5.1",
"description": "Time Safari Application", "description": "Time Safari Application",
"author": { "author": {
"name": "Time Safari Team" "name": "Time Safari Team"
}, },
"scripts": { "scripts": {
"dev": "vite --config vite.config.dev.mts", "dev": "vite --config vite.config.dev.mts --host",
"serve": "vite preview", "serve": "vite preview",
"build": "VITE_GIT_HASH=`git log -1 --pretty=format:%h` vite build --config vite.config.mts", "build": "VITE_GIT_HASH=`git log -1 --pretty=format:%h` vite build --config vite.config.mts",
"lint": "eslint --ext .js,.ts,.vue --ignore-path .gitignore src", "lint": "eslint --ext .js,.ts,.vue --ignore-path .gitignore src",
"lint-fix": "eslint --ext .js,.ts,.vue --ignore-path .gitignore --fix src", "lint-fix": "eslint --ext .js,.ts,.vue --ignore-path .gitignore --fix src",
"prebuild": "eslint --ext .js,.ts,.vue --ignore-path .gitignore src && node sw_combine.js", "prebuild": "eslint --ext .js,.ts,.vue --ignore-path .gitignore src && node sw_combine.js && node scripts/copy-wasm.js",
"test:all": "npm run test:prerequisites && npm run build && npm run test:web && npm run test:mobile", "test:all": "npm run test:prerequisites && npm run build && npm run test:web && npm run test:mobile",
"test:prerequisites": "node scripts/check-prerequisites.js", "test:prerequisites": "node scripts/check-prerequisites.js",
"test:web": "npx playwright test -c playwright.config-local.ts --trace on", "test:web": "npx playwright test -c playwright.config-local.ts --trace on",
@ -22,11 +22,13 @@
"check:ios-device": "xcrun xctrace list devices 2>&1 | grep -w 'Booted' || (echo 'No iOS simulator running' && exit 1)", "check:ios-device": "xcrun xctrace list devices 2>&1 | grep -w 'Booted' || (echo 'No iOS simulator running' && exit 1)",
"clean:electron": "rimraf dist-electron", "clean:electron": "rimraf dist-electron",
"build:pywebview": "vite build --config vite.config.pywebview.mts", "build:pywebview": "vite build --config vite.config.pywebview.mts",
"build:electron": "npm run clean:electron && vite build --config vite.config.electron.mts && node scripts/build-electron.js", "build:electron": "npm run clean:electron && tsc -p tsconfig.electron.json && vite build --config vite.config.electron.mts && node scripts/build-electron.js",
"build:capacitor": "vite build --config vite.config.capacitor.mts", "build:capacitor": "vite build --mode capacitor --config vite.config.capacitor.mts",
"build:web": "vite build --config vite.config.web.mts", "build:web": "VITE_GIT_HASH=`git log -1 --pretty=format:%h` vite build --config vite.config.web.mts",
"electron:dev": "npm run build && electron dist-electron", "electron:dev": "npm run build && electron .",
"electron:start": "electron dist-electron", "electron:start": "electron .",
"clean:android": "adb uninstall app.timesafari.app || true",
"build:android": "npm run clean:android && rm -rf dist && npm run build:web && npm run build:capacitor && cd android && ./gradlew clean && ./gradlew assembleDebug && cd .. && npx cap sync android && npx capacitor-assets generate --android && npx cap open android",
"electron:build-linux": "npm run build:electron && electron-builder --linux AppImage", "electron:build-linux": "npm run build:electron && electron-builder --linux AppImage",
"electron:build-linux-deb": "npm run build:electron && electron-builder --linux deb", "electron:build-linux-deb": "npm run build:electron && electron-builder --linux deb",
"electron:build-linux-prod": "NODE_ENV=production npm run build:electron && electron-builder --linux AppImage", "electron:build-linux-prod": "NODE_ENV=production npm run build:electron && electron-builder --linux AppImage",
@ -39,14 +41,22 @@
"fastlane:ios:beta": "cd ios && fastlane beta", "fastlane:ios:beta": "cd ios && fastlane beta",
"fastlane:ios:release": "cd ios && fastlane release", "fastlane:ios:release": "cd ios && fastlane release",
"fastlane:android:beta": "cd android && fastlane beta", "fastlane:android:beta": "cd android && fastlane beta",
"fastlane:android:release": "cd android && fastlane release" "fastlane:android:release": "cd android && fastlane release",
"electron:build-mac": "npm run build:electron-prod && electron-builder --mac",
"electron:build-mac-universal": "npm run build:electron-prod && electron-builder --mac --universal"
}, },
"dependencies": { "dependencies": {
"@capacitor-community/sqlite": "6.0.2",
"@capacitor-mlkit/barcode-scanning": "^6.0.0",
"@capacitor/android": "^6.2.0", "@capacitor/android": "^6.2.0",
"@capacitor/app": "^6.0.0", "@capacitor/app": "^6.0.0",
"@capacitor/camera": "^6.0.0",
"@capacitor/cli": "^6.2.0", "@capacitor/cli": "^6.2.0",
"@capacitor/core": "^6.2.0", "@capacitor/core": "^6.2.0",
"@capacitor/filesystem": "^6.0.0",
"@capacitor/ios": "^6.2.0", "@capacitor/ios": "^6.2.0",
"@capacitor/share": "^6.0.3",
"@capawesome/capacitor-file-picker": "^6.2.0",
"@dicebear/collection": "^5.4.1", "@dicebear/collection": "^5.4.1",
"@dicebear/core": "^5.4.1", "@dicebear/core": "^5.4.1",
"@ethersproject/hdnode": "^5.7.0", "@ethersproject/hdnode": "^5.7.0",
@ -54,6 +64,7 @@
"@fortawesome/fontawesome-svg-core": "^6.5.1", "@fortawesome/fontawesome-svg-core": "^6.5.1",
"@fortawesome/free-solid-svg-icons": "^6.5.1", "@fortawesome/free-solid-svg-icons": "^6.5.1",
"@fortawesome/vue-fontawesome": "^3.0.6", "@fortawesome/vue-fontawesome": "^3.0.6",
"@jlongster/sql.js": "^1.6.7",
"@peculiar/asn1-ecc": "^2.3.8", "@peculiar/asn1-ecc": "^2.3.8",
"@peculiar/asn1-schema": "^2.3.8", "@peculiar/asn1-schema": "^2.3.8",
"@pvermeer/dexie-encrypted-addon": "^3.0.0", "@pvermeer/dexie-encrypted-addon": "^3.0.0",
@ -72,6 +83,7 @@
"@vue-leaflet/vue-leaflet": "^0.10.1", "@vue-leaflet/vue-leaflet": "^0.10.1",
"@vueuse/core": "^12.3.0", "@vueuse/core": "^12.3.0",
"@zxing/text-encoding": "^0.9.0", "@zxing/text-encoding": "^0.9.0",
"absurd-sql": "^0.0.54",
"asn1-ber": "^1.2.2", "asn1-ber": "^1.2.2",
"axios": "^1.6.8", "axios": "^1.6.8",
"cbor-x": "^1.5.9", "cbor-x": "^1.5.9",
@ -86,6 +98,7 @@
"jdenticon": "^3.2.0", "jdenticon": "^3.2.0",
"js-generate-password": "^0.1.9", "js-generate-password": "^0.1.9",
"js-yaml": "^4.1.0", "js-yaml": "^4.1.0",
"jsqr": "^1.4.0",
"leaflet": "^1.9.4", "leaflet": "^1.9.4",
"localstorage-slim": "^2.7.0", "localstorage-slim": "^2.7.0",
"lru-cache": "^10.2.0", "lru-cache": "^10.2.0",
@ -134,7 +147,9 @@
"@vitejs/plugin-vue": "^5.2.1", "@vitejs/plugin-vue": "^5.2.1",
"@vue/eslint-config-typescript": "^11.0.3", "@vue/eslint-config-typescript": "^11.0.3",
"autoprefixer": "^10.4.19", "autoprefixer": "^10.4.19",
"browserify-fs": "^1.0.0",
"concurrently": "^8.2.2", "concurrently": "^8.2.2",
"crypto-browserify": "^3.12.1",
"electron": "^33.2.1", "electron": "^33.2.1",
"electron-builder": "^25.1.8", "electron-builder": "^25.1.8",
"eslint": "^8.57.0", "eslint": "^8.57.0",
@ -145,13 +160,14 @@
"markdownlint": "^0.37.4", "markdownlint": "^0.37.4",
"markdownlint-cli": "^0.44.0", "markdownlint-cli": "^0.44.0",
"npm-check-updates": "^17.1.13", "npm-check-updates": "^17.1.13",
"path-browserify": "^1.0.1",
"postcss": "^8.4.38", "postcss": "^8.4.38",
"prettier": "^3.2.5", "prettier": "^3.2.5",
"rimraf": "^6.0.1", "rimraf": "^6.0.1",
"tailwindcss": "^3.4.1", "tailwindcss": "^3.4.1",
"typescript": "~5.2.2", "typescript": "~5.2.2",
"vite": "^5.2.0", "vite": "^5.2.0",
"vite-plugin-pwa": "^0.19.8" "vite-plugin-pwa": "^1.0.0"
}, },
"main": "./dist-electron/main.js", "main": "./dist-electron/main.js",
"build": { "build": {
@ -162,13 +178,12 @@
}, },
"files": [ "files": [
"dist-electron/**/*", "dist-electron/**/*",
"src/electron/**/*", "dist/**/*"
"main.js"
], ],
"extraResources": [ "extraResources": [
{ {
"from": "dist-electron", "from": "dist-electron/www",
"to": "." "to": "www"
} }
], ],
"linux": { "linux": {
@ -179,6 +194,32 @@
"category": "Office", "category": "Office",
"icon": "build/icon.png" "icon": "build/icon.png"
}, },
"asar": true "asar": true,
"mac": {
"target": [
"dmg",
"zip"
],
"category": "public.app-category.productivity",
"icon": "build/icon.png",
"hardenedRuntime": true,
"gatekeeperAssess": false,
"entitlements": "ios/App/App/entitlements.mac.plist",
"entitlementsInherit": "ios/App/App/entitlements.mac.plist"
},
"dmg": {
"contents": [
{
"x": 130,
"y": 220
},
{
"x": 410,
"y": 220,
"type": "link",
"path": "/Applications"
}
]
}
} }
} }

2
pkgx.yaml

@ -1,5 +1,7 @@
dependencies: dependencies:
- gradle - gradle
- java - java
- pod
- rubygems.org
# 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
requirements.txt

@ -1,5 +1,6 @@
eth_keys eth_keys
pywebview pywebview
pyinstaller>=6.12.0 pyinstaller>=6.12.0
setuptools>=69.0.0 # Required for distutils for electron-builder on macOS
# For development # For development
watchdog>=3.0.0 # For file watching support watchdog>=3.0.0 # For file watching support

245
scripts/build-electron.js

@ -1,98 +1,165 @@
const fs = require('fs');
const path = require('path'); const path = require('path');
const fs = require('fs-extra');
console.log('Starting electron build process...');
async function main() {
try { // Define paths
console.log('Starting electron build process...'); const electronDistPath = path.join(__dirname, '..', 'dist-electron');
const wwwPath = path.join(electronDistPath, 'www');
// Create dist directory if it doesn't exist
const distElectronDir = path.resolve(__dirname, '../dist-electron'); // Create www directory if it doesn't exist
await fs.ensureDir(distElectronDir); if (!fs.existsSync(wwwPath)) {
fs.mkdirSync(wwwPath, { recursive: true });
// Copy web files }
const wwwDir = path.join(distElectronDir, 'www');
await fs.ensureDir(wwwDir); // Create a platform-specific index.html for Electron
await fs.copy('dist', wwwDir); const initialIndexContent = `<!DOCTYPE html>
<html lang="">
// Copy and fix index.html <head>
const indexPath = path.join(wwwDir, 'index.html'); <meta charset="utf-8">
let indexContent = await fs.readFile(indexPath, 'utf8'); <meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width,initial-scale=1.0,viewport-fit=cover">
// More comprehensive path fixing <link rel="icon" href="/favicon.ico">
indexContent = indexContent <title>TimeSafari</title>
// Fix absolute paths to be relative </head>
.replace(/src="\//g, 'src="\./') <body>
.replace(/href="\//g, 'href="\./') <noscript>
// Fix modulepreload paths <strong>We're sorry but TimeSafari doesn't work properly without JavaScript enabled. Please enable it to continue.</strong>
.replace(/<link [^>]*rel="modulepreload"[^>]*href="\/assets\//g, '<link rel="modulepreload" as="script" crossorigin="" href="./assets/') </noscript>
.replace(/<link [^>]*rel="modulepreload"[^>]*href="\.\/assets\//g, '<link rel="modulepreload" as="script" crossorigin="" href="./assets/') <div id="app"></div>
// Fix stylesheet paths <script type="module">
.replace(/<link [^>]*rel="stylesheet"[^>]*href="\/assets\//g, '<link rel="stylesheet" crossorigin="" href="./assets/') // Force electron platform
.replace(/<link [^>]*rel="stylesheet"[^>]*href="\.\/assets\//g, '<link rel="stylesheet" crossorigin="" href="./assets/') window.process = { env: { VITE_PLATFORM: 'electron' } };
// Fix script paths import('./src/main.electron.ts');
.replace(/src="\/assets\//g, 'src="./assets/') </script>
.replace(/src="\.\/assets\//g, 'src="./assets/') </body>
// Fix any remaining asset paths </html>`;
.replace(/(['"]\/?)(assets\/)/g, '"./assets/');
// Write the Electron-specific index.html
// Debug output fs.writeFileSync(path.join(wwwPath, 'index.html'), initialIndexContent);
console.log('After path fixing, checking for remaining /assets/ paths:', indexContent.includes('/assets/'));
console.log('Sample of fixed content:', indexContent.slice(0, 500)); // Copy only necessary assets from web build
const webDistPath = path.join(__dirname, '..', 'dist');
await fs.writeFile(indexPath, indexContent); if (fs.existsSync(webDistPath)) {
// Copy assets directory
console.log('Copied and fixed web files in:', wwwDir); const assetsSrc = path.join(webDistPath, 'assets');
const assetsDest = path.join(wwwPath, 'assets');
// Copy main process files if (fs.existsSync(assetsSrc)) {
console.log('Copying main process files...'); fs.cpSync(assetsSrc, assetsDest, { recursive: true });
const mainProcessFiles = [ }
['src/electron/main.js', 'main.js'],
['src/electron/preload.js', 'preload.js'] // Copy favicon
]; const faviconSrc = path.join(webDistPath, 'favicon.ico');
if (fs.existsSync(faviconSrc)) {
for (const [src, dest] of mainProcessFiles) { fs.copyFileSync(faviconSrc, path.join(wwwPath, 'favicon.ico'));
const destPath = path.join(distElectronDir, dest); }
console.log(`Copying ${src} to ${destPath}`); }
await fs.copy(src, destPath);
// Remove service worker files
const swFilesToRemove = [
'sw.js',
'sw.js.map',
'workbox-*.js',
'workbox-*.js.map',
'registerSW.js',
'manifest.webmanifest',
'**/workbox-*.js',
'**/workbox-*.js.map',
'**/sw.js',
'**/sw.js.map',
'**/registerSW.js',
'**/manifest.webmanifest'
];
console.log('Removing service worker files...');
swFilesToRemove.forEach(pattern => {
const files = fs.readdirSync(wwwPath).filter(file =>
file.match(new RegExp(pattern.replace(/\*/g, '.*')))
);
files.forEach(file => {
const filePath = path.join(wwwPath, file);
console.log(`Removing ${filePath}`);
try {
fs.unlinkSync(filePath);
} catch (err) {
console.warn(`Could not remove ${filePath}:`, err.message);
} }
});
});
// Create package.json for production // Also check and remove from assets directory
const devPackageJson = require('../package.json'); const assetsPath = path.join(wwwPath, 'assets');
const prodPackageJson = { if (fs.existsSync(assetsPath)) {
name: devPackageJson.name, swFilesToRemove.forEach(pattern => {
version: devPackageJson.version, const files = fs.readdirSync(assetsPath).filter(file =>
description: devPackageJson.description, file.match(new RegExp(pattern.replace(/\*/g, '.*')))
author: devPackageJson.author,
main: 'main.js',
private: true,
};
await fs.writeJson(
path.join(distElectronDir, 'package.json'),
prodPackageJson,
{ spaces: 2 }
); );
files.forEach(file => {
const filePath = path.join(assetsPath, file);
console.log(`Removing ${filePath}`);
try {
fs.unlinkSync(filePath);
} catch (err) {
console.warn(`Could not remove ${filePath}:`, err.message);
}
});
});
}
// Verify the build // Modify index.html to remove service worker registration
console.log('\nVerifying build structure:'); const indexPath = path.join(wwwPath, 'index.html');
const files = await fs.readdir(distElectronDir); if (fs.existsSync(indexPath)) {
console.log('Files in dist-electron:', files); console.log('Modifying index.html to remove service worker registration...');
let indexContent = fs.readFileSync(indexPath, 'utf8');
// Remove service worker registration script
indexContent = indexContent
.replace(/<script[^>]*id="vite-plugin-pwa:register-sw"[^>]*><\/script>/g, '')
.replace(/<script[^>]*registerServiceWorker[^>]*><\/script>/g, '')
.replace(/<link[^>]*rel="manifest"[^>]*>/g, '')
.replace(/<link[^>]*rel="serviceworker"[^>]*>/g, '')
.replace(/navigator\.serviceWorker\.register\([^)]*\)/g, '')
.replace(/if\s*\(\s*['"]serviceWorker['"]\s*in\s*navigator\s*\)\s*{[^}]*}/g, '');
fs.writeFileSync(indexPath, indexContent);
console.log('Successfully modified index.html');
}
if (!files.includes('main.js')) { // Fix asset paths
throw new Error('main.js not found in build directory'); console.log('Fixing asset paths in index.html...');
} let modifiedIndexContent = fs.readFileSync(indexPath, 'utf8');
if (!files.includes('preload.js')) { modifiedIndexContent = modifiedIndexContent
throw new Error('preload.js not found in build directory'); .replace(/\/assets\//g, './assets/')
} .replace(/href="\//g, 'href="./')
if (!files.includes('package.json')) { .replace(/src="\//g, 'src="./');
throw new Error('package.json not found in build directory');
}
console.log('Build completed successfully!'); fs.writeFileSync(indexPath, modifiedIndexContent);
} catch (error) {
console.error('Build failed:', error); // Verify no service worker references remain
process.exit(1); const finalContent = fs.readFileSync(indexPath, 'utf8');
} if (finalContent.includes('serviceWorker') || finalContent.includes('workbox')) {
console.warn('Warning: Service worker references may still exist in index.html');
}
// Check for remaining /assets/ paths
console.log('After path fixing, checking for remaining /assets/ paths:', finalContent.includes('/assets/'));
console.log('Sample of fixed content:', finalContent.substring(0, 500));
console.log('Copied and fixed web files in:', wwwPath);
// Copy main process files
console.log('Copying main process files...');
// Copy the main process file instead of creating a template
const mainSrcPath = path.join(__dirname, '..', 'dist-electron', 'main.js');
const mainDestPath = path.join(electronDistPath, 'main.js');
if (fs.existsSync(mainSrcPath)) {
fs.copyFileSync(mainSrcPath, mainDestPath);
console.log('Copied main process file successfully');
} else {
console.error('Main process file not found at:', mainSrcPath);
process.exit(1);
} }
main(); console.log('Electron build process completed successfully');

15
scripts/copy-wasm.js

@ -0,0 +1,15 @@
const fs = require('fs');
const path = require('path');
// Create public/wasm directory if it doesn't exist
const wasmDir = path.join(__dirname, '../public/wasm');
if (!fs.existsSync(wasmDir)) {
fs.mkdirSync(wasmDir, { recursive: true });
}
// Copy the WASM file from node_modules to public/wasm
const sourceFile = path.join(__dirname, '../node_modules/@jlongster/sql.js/dist/sql-wasm.wasm');
const targetFile = path.join(wasmDir, 'sql-wasm.wasm');
fs.copyFileSync(sourceFile, targetFile);
console.log('WASM file copied successfully!');

22
scripts/copy-web-assets.sh

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

22
scripts/generate-icons.sh

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

4
openssl_signing_console.sh → scripts/openssl_signing_console.sh

@ -4,9 +4,9 @@
# #
# Prerequisites: openssl, jq # Prerequisites: openssl, jq
# #
# Usage: source ./openssl_signing_console.sh # Usage: source /scripts/openssl_signing_console.sh
# #
# For a more complete explanation, see ./openssl_signing_console.rst # For a more complete explanation, see /doc/openssl_signing_console.rst
# Generate a key and extract the public part # Generate a key and extract the public part

62
scripts/test-ios.js

@ -103,7 +103,7 @@ const cleanIosPlatform = async (log) => {
// Get app name from package.json // Get app name from package.json
const packageJson = JSON.parse(readFileSync('package.json', 'utf8')); const packageJson = JSON.parse(readFileSync('package.json', 'utf8'));
const appName = packageJson.name || 'App'; const appName = packageJson.name || 'App';
const appId = packageJson.capacitor?.appId || 'io.ionic.starter'; const appId = packageJson.build.appId || 'io.ionic.starter';
// Create a minimal capacitor config // Create a minimal capacitor config
const capacitorConfig = ` const capacitorConfig = `
@ -441,8 +441,8 @@ const configureIosProject = async (log) => {
try { try {
// Try to run pod install normally first // Try to run pod install normally first
log('🔄 Running "pod install" in ios/App directory...'); log('🔄 Running "pod install" in ios/App directory...');
execSync('cd ios/App && pod install', { stdio: 'inherit' }); execSync('cd ios/App && pod install', { stdio: 'inherit' });
log('✅ CocoaPods installation completed'); log('✅ CocoaPods installation completed');
} catch (error) { } catch (error) {
// If that fails, provide detailed instructions // If that fails, provide detailed instructions
log(`⚠️ CocoaPods installation failed: ${error.message}`); log(`⚠️ CocoaPods installation failed: ${error.message}`);
@ -467,12 +467,12 @@ const configureIosProject = async (log) => {
// Build and test iOS project // Build and test iOS project
const buildAndTestIos = async (log, simulator) => { const buildAndTestIos = async (log, simulator) => {
const simulatorName = simulator[0].name; const simulatorName = simulator[0].name;
log('🏗️ Building iOS project...'); log('🏗️ Building iOS project...', simulator[0]);
execSync('cd ios/App && xcodebuild clean -workspace App.xcworkspace -scheme App', { stdio: 'inherit' }); execSync('cd ios/App && xcodebuild clean -workspace App.xcworkspace -scheme App', { stdio: 'inherit' });
log('✅ Xcode clean completed'); log('✅ Xcode clean completed');
log(`🏗️ Building for simulator: ${simulatorName}`); log(`🏗️ Building for simulator: ${simulatorName}`);
execSync(`cd ios/App && xcodebuild build -workspace App.xcworkspace -scheme App -destination "platform=iOS Simulator,name=${simulatorName}"`, { stdio: 'inherit' }); execSync(`cd ios/App && xcodebuild build -workspace App.xcworkspace -scheme App -destination "platform=iOS Simulator,OS=17.2,name=${simulatorName}"`, { stdio: 'inherit' });
log('✅ Xcode build completed'); log('✅ Xcode build completed');
// Check if the project is configured for testing by querying the scheme capabilities // Check if the project is configured for testing by querying the scheme capabilities
@ -623,28 +623,28 @@ const runDeeplinkTests = async (log) => {
} }
// Now we can safely create the deeplink tests knowing we have valid data // Now we can safely create the deeplink tests knowing we have valid data
const deeplinkTests = [ const deeplinkTests = [
{ {
url: `timesafari://claim/${testEnv.CLAIM_ID}`, url: `timesafari://claim/${testEnv.CLAIM_ID}`,
description: 'Claim view' description: 'Claim view'
}, },
{ {
url: `timesafari://claim-cert/${testEnv.CERT_ID || testEnv.CLAIM_ID}`, url: `timesafari://claim-cert/${testEnv.CERT_ID || testEnv.CLAIM_ID}`,
description: 'Claim certificate view' description: 'Claim certificate view'
}, },
{ {
url: `timesafari://claim-add-raw/${testEnv.RAW_CLAIM_ID || testEnv.CLAIM_ID}`, url: `timesafari://claim-add-raw/${testEnv.RAW_CLAIM_ID || testEnv.CLAIM_ID}`,
description: 'Raw claim addition' description: 'Raw claim addition'
}, },
{ {
url: 'timesafari://did/test', url: 'timesafari://did/test',
description: 'DID view with test identifier' description: 'DID view with test identifier'
}, },
{ {
url: `timesafari://did/${testEnv.CONTACT1_DID}`, url: `timesafari://did/${testEnv.CONTACT1_DID}`,
description: 'DID view with contact DID' description: 'DID view with contact DID'
}, },
{ {
url: (() => { url: (() => {
if (!testEnv?.CONTACT1_DID) { if (!testEnv?.CONTACT1_DID) {
throw new Error('Cannot construct contact-edit URL: CONTACT1_DID is missing'); throw new Error('Cannot construct contact-edit URL: CONTACT1_DID is missing');
@ -653,13 +653,13 @@ const runDeeplinkTests = async (log) => {
log('Created contact-edit URL:', url); log('Created contact-edit URL:', url);
return url; return url;
})(), })(),
description: 'Contact editing' description: 'Contact editing'
}, },
{ {
url: `timesafari://contacts/import?contacts=${encodeURIComponent(JSON.stringify(contacts))}`, url: `timesafari://contacts/import?contacts=${encodeURIComponent(JSON.stringify(contacts))}`,
description: 'Contacts import' description: 'Contacts import'
} }
]; ];
// Log the final test configuration // Log the final test configuration
log('\n5. Final Test Configuration:'); log('\n5. Final Test Configuration:');

40
src/App.vue

@ -4,7 +4,7 @@
<!-- Messages in the upper-right - https://github.com/emmanuelsw/notiwind --> <!-- Messages in the upper-right - https://github.com/emmanuelsw/notiwind -->
<NotificationGroup group="alert"> <NotificationGroup group="alert">
<div <div
class="fixed top-4 right-4 w-full max-w-sm flex flex-col items-start justify-end" class="fixed z-[90] top-[max(1rem,env(safe-area-inset-top))] right-4 left-4 sm:left-auto sm:w-full sm:max-w-sm flex flex-col items-start justify-end"
> >
<Notification <Notification
v-slot="{ notifications, close }" v-slot="{ notifications, close }"
@ -144,10 +144,10 @@
<!-- <!--
This "group" of "modal" is the prompt for an answer. This "group" of "modal" is the prompt for an answer.
Set "type" as follows: "confirm" for yes/no, and "notification" ones: Set "type" as follows: "confirm" for yes/no, and "notification" ones:
"-permission", "-mute", "-off" "-permission", "-mute", "-off"
--> -->
<NotificationGroup group="modal"> <NotificationGroup group="modal">
<div class="fixed z-[100] top-0 inset-x-0 w-full"> <div class="fixed z-[100] top-[env(safe-area-inset-top)] inset-x-0 w-full">
<Notification <Notification
v-slot="{ notifications, close }" v-slot="{ notifications, close }"
enter="transform ease-out duration-300 transition" enter="transform ease-out duration-300 transition"
@ -167,10 +167,10 @@
role="alert" role="alert"
> >
<!-- <!--
Type of "confirm" will post a message. Type of "confirm" will post a message.
With onYes function, show a "Yes" button to call that function. With onYes function, show a "Yes" button to call that function.
With onNo function, show a "No" button to call that function, With onNo function, show a "No" button to call that function,
and pass it state of "askAgain" field shown if you set promptToStopAsking. and pass it state of "askAgain" field shown if you set promptToStopAsking.
--> -->
<div <div
v-if="notification.type === 'confirm'" v-if="notification.type === 'confirm'"
@ -330,8 +330,11 @@
<script lang="ts"> <script lang="ts">
import { Vue, Component } from "vue-facing-decorator"; import { Vue, Component } from "vue-facing-decorator";
import { logConsoleAndDb, retrieveSettingsForActiveAccount } from "./db/index";
import { NotificationIface } from "./constants/app"; import { NotificationIface, USE_DEXIE_DB } from "./constants/app";
import * as databaseUtil from "./db/databaseUtil";
import { retrieveSettingsForActiveAccount } from "./db/index";
import { logConsoleAndDb } from "./db/databaseUtil";
import { logger } from "./utils/logger"; import { logger } from "./utils/logger";
interface Settings { interface Settings {
@ -396,7 +399,11 @@ export default class App extends Vue {
try { try {
logger.log("Retrieving settings for the active account..."); logger.log("Retrieving settings for the active account...");
const settings: Settings = await retrieveSettingsForActiveAccount(); let settings: Settings =
await databaseUtil.retrieveSettingsForActiveAccount();
if (USE_DEXIE_DB) {
settings = await retrieveSettingsForActiveAccount();
}
logger.log("Retrieved settings:", settings); logger.log("Retrieved settings:", settings);
const notifyingNewActivity = !!settings?.notifyingNewActivityTime; const notifyingNewActivity = !!settings?.notifyingNewActivityTime;
@ -539,4 +546,15 @@ export default class App extends Vue {
} }
</script> </script>
<style></style> <style>
#Content {
padding-left: max(1.5rem, env(safe-area-inset-left));
padding-right: max(1.5rem, env(safe-area-inset-right));
padding-top: max(1.5rem, env(safe-area-inset-top));
padding-bottom: max(1.5rem, env(safe-area-inset-bottom));
}
#QuickNav ~ #Content {
padding-bottom: calc(env(safe-area-inset-bottom) + 6.333rem);
}
</style>

191
src/components/ActivityListItem.vue

@ -14,22 +14,34 @@
class="flex items-center justify-between gap-2 text-lg bg-slate-200 border border-slate-300 border-b-0 rounded-t-md px-3 sm:px-4 py-1 sm:py-2" class="flex items-center justify-between gap-2 text-lg bg-slate-200 border border-slate-300 border-b-0 rounded-t-md px-3 sm:px-4 py-1 sm:py-2"
> >
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<div v-if="record.issuerDid"> <router-link
v-if="record.issuerDid && !isHiddenDid(record.issuerDid)"
:to="{
path: '/did/' + encodeURIComponent(record.issuerDid),
}"
title="More details about this person"
>
<EntityIcon <EntityIcon
:entity-id="record.issuerDid" :entity-id="record.issuerDid"
class="rounded-full bg-white overflow-hidden !size-[2rem] object-cover" class="rounded-full bg-white overflow-hidden !size-[2rem] object-cover"
/> />
</div> </router-link>
<div v-else> <font-awesome
<font-awesome v-else-if="isHiddenDid(record.issuerDid)"
icon="person-circle-question" icon="eye-slash"
class="text-slate-300 text-[2rem]" class="text-slate-400 !size-[2rem] cursor-pointer"
/> @click="notifyHiddenPerson"
</div> />
<font-awesome
v-else
icon="person-circle-question"
class="text-slate-400 !size-[2rem] cursor-pointer"
@click="notifyUnknownPerson"
/>
<div> <div>
<h3 class="font-semibold"> <h3 v-if="record.issuer.known" class="font-semibold leading-tight">
{{ record.issuer.known ? record.issuer.displayName : "" }} {{ record.issuer.displayName }}
</h3> </h3>
<p class="ms-auto text-xs text-slate-500 italic"> <p class="ms-auto text-xs text-slate-500 italic">
{{ friendlyDate }} {{ friendlyDate }}
@ -37,7 +49,11 @@
</div> </div>
</div> </div>
<a class="cursor-pointer" @click="$emit('loadClaim', record.jwtId)"> <a
class="cursor-pointer"
data-testid="circle-info-link"
@click="$emit('loadClaim', record.jwtId)"
>
<font-awesome icon="circle-info" class="fa-fw text-slate-500" /> <font-awesome icon="circle-info" class="fa-fw text-slate-500" />
</a> </a>
</div> </div>
@ -46,7 +62,7 @@
<!-- Record Image --> <!-- Record Image -->
<div <div
v-if="record.image" v-if="record.image"
class="bg-cover mb-6 -mt-3 sm:-mt-4 -mx-3 sm:-mx-4" class="bg-cover mb-2 -mt-3 sm:-mt-4 -mx-3 sm:-mx-4"
:style="`background-image: url(${record.image});`" :style="`background-image: url(${record.image});`"
> >
<a <a
@ -57,34 +73,64 @@
class="w-full h-auto max-w-lg max-h-96 object-contain mx-auto drop-shadow-md" class="w-full h-auto max-w-lg max-h-96 object-contain mx-auto drop-shadow-md"
:src="record.image" :src="record.image"
alt="Activity image" alt="Activity image"
@load="$emit('cacheImage', record.image)" @load="cacheImage(record.image)"
/> />
</a> </a>
</div> </div>
<!-- Description -->
<p class="font-medium">
<a class="cursor-pointer" @click="$emit('loadClaim', record.jwtId)">
{{ description }}
</a>
</p>
<div <div
class="relative flex justify-between gap-4 max-w-[40rem] mx-auto mb-5" class="relative flex justify-between gap-4 max-w-[40rem] mx-auto mt-4"
> >
<!-- Source --> <!-- Source -->
<div <div
class="w-[8rem] sm:w-[12rem] text-center bg-white border border-slate-200 rounded p-2 sm:p-3" class="w-[7rem] sm:w-[12rem] text-center bg-white border border-slate-200 rounded p-2 sm:p-3"
> >
<div class="relative w-fit mx-auto"> <div class="relative w-fit mx-auto">
<div> <div>
<!-- Project Icon --> <!-- Project Icon -->
<div v-if="record.providerPlanName"> <div v-if="record.providerPlanName">
<ProjectIcon <router-link
:entity-id="record.providerPlanName" :to="{
:icon-size="48" path:
class="rounded size-[3rem] sm:size-[4rem] *:w-full *:h-full" '/project/' +
/> encodeURIComponent(record.providerPlanHandleId || ''),
}"
title="View project details"
>
<ProjectIcon
:entity-id="record.providerPlanHandleId || ''"
:icon-size="48"
class="rounded size-[3rem] sm:size-[4rem] *:w-full *:h-full"
/>
</router-link>
</div> </div>
<!-- Identicon for DIDs --> <!-- Identicon for DIDs -->
<div v-else-if="record.agentDid"> <div v-else-if="record.agentDid">
<EntityIcon <router-link
:entity-id="record.agentDid" v-if="!isHiddenDid(record.agentDid)"
:profile-image-url="record.issuer.profileImageUrl" :to="{
class="rounded-full bg-slate-100 overflow-hidden !size-[3rem] sm:!size-[4rem]" path: '/did/' + encodeURIComponent(record.agentDid),
}"
title="More details about this person"
>
<EntityIcon
:entity-id="record.agentDid"
:profile-image-url="record.issuer.profileImageUrl"
class="rounded-full bg-slate-100 overflow-hidden !size-[3rem] sm:!size-[4rem]"
/>
</router-link>
<font-awesome
v-else
icon="eye-slash"
class="text-slate-300 !size-[3rem] sm:!size-[4rem]"
@click="notifyHiddenPerson"
/> />
</div> </div>
<!-- Unknown Person --> <!-- Unknown Person -->
@ -92,6 +138,7 @@
<font-awesome <font-awesome
icon="person-circle-question" icon="person-circle-question"
class="text-slate-300 text-[3rem] sm:text-[4rem]" class="text-slate-300 text-[3rem] sm:text-[4rem]"
@click="notifyUnknownPerson"
/> />
</div> </div>
</div> </div>
@ -110,9 +157,11 @@
<!-- Arrow --> <!-- Arrow -->
<div <div
class="absolute inset-x-[8rem] sm:inset-x-[12rem] mx-2 top-1/2 -translate-y-1/2" class="absolute inset-x-[7rem] sm:inset-x-[12rem] mx-2 top-1/2 -translate-y-1/2"
> >
<div class="text-sm text-center leading-none font-semibold pe-[15px]"> <div
class="text-sm text-center leading-none font-semibold pe-2 sm:pe-4"
>
{{ fetchAmount }} {{ fetchAmount }}
</div> </div>
@ -129,24 +178,47 @@
<!-- Destination --> <!-- Destination -->
<div <div
class="w-[8rem] sm:w-[12rem] text-center bg-white border border-slate-200 rounded p-2 sm:p-3" class="w-[7rem] sm:w-[12rem] text-center bg-white border border-slate-200 rounded p-2 sm:p-3"
> >
<div class="relative w-fit mx-auto"> <div class="relative w-fit mx-auto">
<div> <div>
<!-- Project Icon --> <!-- Project Icon -->
<div v-if="record.recipientProjectName"> <div v-if="record.recipientProjectName">
<ProjectIcon <router-link
:entity-id="record.recipientProjectName" :to="{
:icon-size="48" path:
class="rounded size-[3rem] sm:size-[4rem] *:w-full *:h-full" '/project/' +
/> encodeURIComponent(record.fulfillsPlanHandleId || ''),
}"
title="View project details"
>
<ProjectIcon
:entity-id="record.fulfillsPlanHandleId || ''"
:icon-size="48"
class="rounded size-[3rem] sm:size-[4rem] *:w-full *:h-full"
/>
</router-link>
</div> </div>
<!-- Identicon for DIDs --> <!-- Identicon for DIDs -->
<div v-else-if="record.recipientDid"> <div v-else-if="record.recipientDid">
<EntityIcon <router-link
:entity-id="record.recipientDid" v-if="!isHiddenDid(record.recipientDid)"
:profile-image-url="record.receiver.profileImageUrl" :to="{
class="rounded-full bg-slate-100 overflow-hidden !size-[3rem] sm:!size-[4rem]" path: '/did/' + encodeURIComponent(record.recipientDid),
}"
title="More details about this person"
>
<EntityIcon
:entity-id="record.recipientDid"
:profile-image-url="record.receiver.profileImageUrl"
class="rounded-full bg-slate-100 overflow-hidden !size-[3rem] sm:!size-[4rem]"
/>
</router-link>
<font-awesome
v-else
icon="eye-slash"
class="text-slate-300 !size-[3rem] sm:!size-[4rem]"
@click="notifyHiddenPerson"
/> />
</div> </div>
<!-- Unknown Person --> <!-- Unknown Person -->
@ -154,6 +226,7 @@
<font-awesome <font-awesome
icon="person-circle-question" icon="person-circle-question"
class="text-slate-300 text-[3rem] sm:text-[4rem]" class="text-slate-300 text-[3rem] sm:text-[4rem]"
@click="notifyUnknownPerson"
/> />
</div> </div>
</div> </div>
@ -170,24 +243,18 @@
</div> </div>
</div> </div>
</div> </div>
<!-- Description -->
<p class="font-medium">
<a class="cursor-pointer" @click="$emit('loadClaim', record.jwtId)">
{{ description }}
</a>
</p>
</div> </div>
</li> </li>
</template> </template>
<script lang="ts"> <script lang="ts">
import { Component, Prop, Vue } from "vue-facing-decorator"; import { Component, Prop, Vue, Emit } from "vue-facing-decorator";
import { GiveRecordWithContactInfo } from "../types"; import { GiveRecordWithContactInfo } from "../types";
import EntityIcon from "./EntityIcon.vue"; import EntityIcon from "./EntityIcon.vue";
import { isGiveClaimType, notifyWhyCannotConfirm } from "../libs/util"; import { isGiveClaimType, notifyWhyCannotConfirm } from "../libs/util";
import { containsHiddenDid } from "../libs/endorserServer"; import { containsHiddenDid, isHiddenDid } from "../libs/endorserServer";
import ProjectIcon from "./ProjectIcon.vue"; import ProjectIcon from "./ProjectIcon.vue";
import { NotificationIface } from "../constants/app";
@Component({ @Component({
components: { components: {
@ -202,6 +269,38 @@ export default class ActivityListItem extends Vue {
@Prop() activeDid!: string; @Prop() activeDid!: string;
@Prop() confirmerIdList?: string[]; @Prop() confirmerIdList?: string[];
isHiddenDid = isHiddenDid;
$notify!: (notification: NotificationIface, timeout?: number) => void;
notifyHiddenPerson() {
this.$notify(
{
group: "alert",
type: "warning",
title: "Person Outside Your Network",
text: "This person is not visible to you.",
},
3000,
);
}
notifyUnknownPerson() {
this.$notify(
{
group: "alert",
type: "warning",
title: "Unidentified Person",
text: "Nobody specific was recognized.",
},
3000,
);
}
@Emit()
cacheImage(image: string) {
return image;
}
get fetchAmount(): string { get fetchAmount(): string {
const claim = const claim =
(this.record.fullClaim as unknown).claim || this.record.fullClaim; (this.record.fullClaim as unknown).claim || this.record.fullClaim;
@ -217,7 +316,7 @@ export default class ActivityListItem extends Vue {
const claim = const claim =
(this.record.fullClaim as unknown).claim || this.record.fullClaim; (this.record.fullClaim as unknown).claim || this.record.fullClaim;
return `${claim.description}`; return `${claim?.description || ""}`;
} }
private displayAmount(code: string, amt: number) { private displayAmount(code: string, amt: number) {

216
src/components/DataExportSection.vue

@ -0,0 +1,216 @@
/** * 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 Contacts
</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 { AppString, NotificationIface } from "../constants/app";
import { Contact } from "../db/tables/contacts";
import * as databaseUtil from "../db/databaseUtil";
import { logger } from "../utils/logger";
import { PlatformServiceFactory } from "../services/PlatformServiceFactory";
import {
PlatformService,
PlatformCapabilities,
} from "../services/PlatformService";
import { contactsToExportJson } from "../libs/util";
/**
* @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 {
let allContacts: Contact[] = [];
const platformService = PlatformServiceFactory.getInstance();
const result = await platformService.dbQuery(`SELECT * FROM contacts`);
if (result) {
allContacts = databaseUtil.mapQueryResultToValues(
result,
) as unknown as Contact[];
}
// if (USE_DEXIE_DB) {
// await db.open();
// allContacts = await db.contacts.toArray();
// }
// Convert contacts to export format
const exportData = contactsToExportJson(allContacts);
const jsonStr = JSON.stringify(exportData, null, 2);
const blob = new Blob([jsonStr], { type: "application/json" });
const fileName = `${AppString.APP_NAME_NO_SPACES}-backup-contacts.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
await this.platformService.writeAndShareFile(fileName, jsonStr);
} else {
throw new Error("This platform does not support file downloads.");
}
this.$notify(
{
group: "alert",
type: "success",
title: "Export Successful",
text: this.platformCapabilities.hasFileDownload
? "See your downloads directory for the backup."
: "The backup file has been saved.",
},
3000,
);
} 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>

42
src/components/FeedFilters.vue

@ -99,6 +99,9 @@ import {
LTileLayer, LTileLayer,
} from "@vue-leaflet/vue-leaflet"; } from "@vue-leaflet/vue-leaflet";
import { Router } from "vue-router"; import { Router } from "vue-router";
import { USE_DEXIE_DB } from "@/constants/app";
import * as databaseUtil from "../db/databaseUtil";
import { MASTER_SETTINGS_KEY } from "../db/tables/settings"; import { MASTER_SETTINGS_KEY } from "../db/tables/settings";
import { db, retrieveSettingsForActiveAccount } from "../db/index"; import { db, retrieveSettingsForActiveAccount } from "../db/index";
@ -122,7 +125,10 @@ export default class FeedFilters extends Vue {
async open(onCloseIfChanged: () => void) { async open(onCloseIfChanged: () => void) {
this.onCloseIfChanged = onCloseIfChanged; this.onCloseIfChanged = onCloseIfChanged;
const settings = await retrieveSettingsForActiveAccount(); let settings = await databaseUtil.retrieveSettingsForActiveAccount();
if (USE_DEXIE_DB) {
settings = await retrieveSettingsForActiveAccount();
}
this.hasVisibleDid = !!settings.filterFeedByVisible; this.hasVisibleDid = !!settings.filterFeedByVisible;
this.isNearby = !!settings.filterFeedByNearby; this.isNearby = !!settings.filterFeedByNearby;
if (settings.searchBoxes && settings.searchBoxes.length > 0) { if (settings.searchBoxes && settings.searchBoxes.length > 0) {
@ -136,17 +142,29 @@ export default class FeedFilters extends Vue {
async toggleHasVisibleDid() { async toggleHasVisibleDid() {
this.settingChanged = true; this.settingChanged = true;
this.hasVisibleDid = !this.hasVisibleDid; this.hasVisibleDid = !this.hasVisibleDid;
await db.settings.update(MASTER_SETTINGS_KEY, { await databaseUtil.updateDefaultSettings({
filterFeedByVisible: this.hasVisibleDid, filterFeedByVisible: this.hasVisibleDid,
}); });
if (USE_DEXIE_DB) {
await db.settings.update(MASTER_SETTINGS_KEY, {
filterFeedByVisible: this.hasVisibleDid,
});
}
} }
async toggleNearby() { async toggleNearby() {
this.settingChanged = true; this.settingChanged = true;
this.isNearby = !this.isNearby; this.isNearby = !this.isNearby;
await db.settings.update(MASTER_SETTINGS_KEY, { await databaseUtil.updateDefaultSettings({
filterFeedByNearby: this.isNearby, filterFeedByNearby: this.isNearby,
}); });
if (USE_DEXIE_DB) {
await db.settings.update(MASTER_SETTINGS_KEY, {
filterFeedByNearby: this.isNearby,
});
}
} }
async clearAll() { async clearAll() {
@ -154,11 +172,18 @@ export default class FeedFilters extends Vue {
this.settingChanged = true; this.settingChanged = true;
} }
await db.settings.update(MASTER_SETTINGS_KEY, { await databaseUtil.updateDefaultSettings({
filterFeedByNearby: false, filterFeedByNearby: false,
filterFeedByVisible: false, filterFeedByVisible: false,
}); });
if (USE_DEXIE_DB) {
await db.settings.update(MASTER_SETTINGS_KEY, {
filterFeedByNearby: false,
filterFeedByVisible: false,
});
}
this.hasVisibleDid = false; this.hasVisibleDid = false;
this.isNearby = false; this.isNearby = false;
} }
@ -168,11 +193,18 @@ export default class FeedFilters extends Vue {
this.settingChanged = true; this.settingChanged = true;
} }
await db.settings.update(MASTER_SETTINGS_KEY, { await databaseUtil.updateDefaultSettings({
filterFeedByNearby: true, filterFeedByNearby: true,
filterFeedByVisible: true, filterFeedByVisible: true,
}); });
if (USE_DEXIE_DB) {
await db.settings.update(MASTER_SETTINGS_KEY, {
filterFeedByNearby: true,
filterFeedByVisible: true,
});
}
this.hasVisibleDid = true; this.hasVisibleDid = true;
this.isNearby = true; this.isNearby = true;
} }

40
src/components/GiftedDialog.vue

@ -177,7 +177,7 @@
<script lang="ts"> <script lang="ts">
import { Vue, Component, Prop } from "vue-facing-decorator"; import { Vue, Component, Prop } from "vue-facing-decorator";
import { NotificationIface } from "../constants/app"; import { NotificationIface, USE_DEXIE_DB } from "../constants/app";
import { import {
createAndSubmitGive, createAndSubmitGive,
didInfo, didInfo,
@ -186,7 +186,10 @@ import {
import * as libsUtil from "../libs/util"; import * as libsUtil from "../libs/util";
import { db, retrieveSettingsForActiveAccount } from "../db/index"; import { db, retrieveSettingsForActiveAccount } from "../db/index";
import { Contact } from "../db/tables/contacts"; import { Contact } from "../db/tables/contacts";
import * as databaseUtil from "../db/databaseUtil";
import { retrieveAccountDids } from "../libs/util"; import { retrieveAccountDids } from "../libs/util";
import { logger } from "../utils/logger";
import { PlatformServiceFactory } from "@/services/PlatformServiceFactory";
import EntityIcon from "../components/EntityIcon.vue"; import EntityIcon from "../components/EntityIcon.vue";
@Component({ @Component({
@ -210,7 +213,6 @@ export default class GiftedDialog extends Vue {
customTitle?: string; customTitle?: string;
description = ""; description = "";
giver?: libsUtil.GiverReceiverInputInfo; // undefined means no identified giver agent giver?: libsUtil.GiverReceiverInputInfo; // undefined means no identified giver agent
isTrade = false;
offerId = ""; offerId = "";
prompt = ""; prompt = "";
receiver?: libsUtil.GiverReceiverInputInfo; receiver?: libsUtil.GiverReceiverInputInfo;
@ -239,11 +241,23 @@ export default class GiftedDialog extends Vue {
this.currentStep = giver ? 2 : 1; this.currentStep = giver ? 2 : 1;
try { try {
const settings = await retrieveSettingsForActiveAccount(); let settings = await databaseUtil.retrieveSettingsForActiveAccount();
if (USE_DEXIE_DB) {
settings = await retrieveSettingsForActiveAccount();
}
this.apiServer = settings.apiServer || ""; this.apiServer = settings.apiServer || "";
this.activeDid = settings.activeDid || ""; this.activeDid = settings.activeDid || "";
this.allContacts = await db.contacts.toArray(); const platformService = PlatformServiceFactory.getInstance();
const result = await platformService.dbQuery(`SELECT * FROM contacts`);
if (result) {
this.allContacts = databaseUtil.mapQueryResultToValues(
result,
) as unknown as Contact[];
}
if (USE_DEXIE_DB) {
this.allContacts = await db.contacts.toArray();
}
this.allMyDids = await retrieveAccountDids(); this.allMyDids = await retrieveAccountDids();
@ -397,15 +411,12 @@ export default class GiftedDialog extends Vue {
unitCode, unitCode,
this.toProjectId, this.toProjectId,
this.offerId, this.offerId,
this.isTrade, false,
undefined, undefined,
this.fromProjectId, this.fromProjectId,
); );
if ( if (!result.success) {
result.type === "error" ||
this.isGiveCreationError(result.response)
) {
const errorMessage = this.getGiveCreationErrorMessage(result); const errorMessage = this.getGiveCreationErrorMessage(result);
logger.error("Error with give creation result:", result); logger.error("Error with give creation result:", result);
this.$notify( this.$notify(
@ -423,7 +434,7 @@ export default class GiftedDialog extends Vue {
group: "alert", group: "alert",
type: "success", type: "success",
title: "Success", title: "Success",
text: `That ${this.isTrade ? "trade" : "gift"} was recorded.`, text: `That gift was recorded.`,
}, },
7000, 7000,
); );
@ -452,15 +463,6 @@ export default class GiftedDialog extends Vue {
// Helper functions for readability // Helper functions for readability
/**
* @param result response "data" from the server
* @returns true if the result indicates an error
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
isGiveCreationError(result: any) {
return result.status !== 201 || result.data?.error;
}
/** /**
* @param result direct response eg. ErrorResult or SuccessResult (potentially with embedded "data") * @param result direct response eg. ErrorResult or SuccessResult (potentially with embedded "data")
* @returns best guess at an error message * @returns best guess at an error message

36
src/components/GiftedPrompts.vue

@ -74,10 +74,12 @@
import { Vue, Component } from "vue-facing-decorator"; import { Vue, Component } from "vue-facing-decorator";
import { Router } from "vue-router"; import { Router } from "vue-router";
import { AppString, NotificationIface } from "../constants/app"; import { AppString, NotificationIface, USE_DEXIE_DB } from "../constants/app";
import { db } from "../db/index"; import { db } from "../db/index";
import { Contact } from "../db/tables/contacts"; import { Contact } from "../db/tables/contacts";
import * as databaseUtil from "../db/databaseUtil";
import { GiverReceiverInputInfo } from "../libs/util"; import { GiverReceiverInputInfo } from "../libs/util";
import { PlatformServiceFactory } from "@/services/PlatformServiceFactory";
@Component @Component
export default class GivenPrompts extends Vue { export default class GivenPrompts extends Vue {
@ -127,8 +129,16 @@ export default class GivenPrompts extends Vue {
this.visible = true; this.visible = true;
this.callbackOnFullGiftInfo = callbackOnFullGiftInfo; this.callbackOnFullGiftInfo = callbackOnFullGiftInfo;
await db.open(); const platformService = PlatformServiceFactory.getInstance();
this.numContacts = await db.contacts.count(); const result = await platformService.dbQuery(
"SELECT COUNT(*) FROM contacts",
);
if (result) {
this.numContacts = result.values[0][0] as number;
}
if (USE_DEXIE_DB) {
this.numContacts = await db.contacts.count();
}
this.shownContactDbIndices = new Array<boolean>(this.numContacts); // all undefined to start this.shownContactDbIndices = new Array<boolean>(this.numContacts); // all undefined to start
} }
@ -217,6 +227,7 @@ export default class GivenPrompts extends Vue {
let someContactDbIndex = Math.floor(Math.random() * this.numContacts); let someContactDbIndex = Math.floor(Math.random() * this.numContacts);
let count = 0; let count = 0;
// as long as the index has an entry, loop // as long as the index has an entry, loop
while ( while (
this.shownContactDbIndices[someContactDbIndex] != null && this.shownContactDbIndices[someContactDbIndex] != null &&
@ -229,10 +240,21 @@ export default class GivenPrompts extends Vue {
this.nextIdeaPastContacts(); this.nextIdeaPastContacts();
} else { } else {
// get the contact at that offset // get the contact at that offset
await db.open(); const platformService = PlatformServiceFactory.getInstance();
this.currentContact = await db.contacts const result = await platformService.dbQuery(
.offset(someContactDbIndex) "SELECT * FROM contacts LIMIT 1 OFFSET ?",
.first(); [someContactDbIndex],
);
if (result) {
const mappedContacts = databaseUtil.mapQueryResultToValues(result);
this.currentContact = mappedContacts[0] as unknown as Contact;
}
if (USE_DEXIE_DB) {
await db.open();
this.currentContact = await db.contacts
.offset(someContactDbIndex)
.first();
}
this.shownContactDbIndices[someContactDbIndex] = true; this.shownContactDbIndices[someContactDbIndex] = true;
} }
} }

6
src/components/HiddenDidDialog.vue

@ -48,11 +48,7 @@
<span> <span>
{{ didInfo(visDid) }} {{ didInfo(visDid) }}
<span v-if="!serverUtil.isEmptyOrHiddenDid(visDid)"> <span v-if="!serverUtil.isEmptyOrHiddenDid(visDid)">
<a <a :href="`/did/${visDid}`" class="text-blue-500">
:href="`/did/${visDid}`"
target="_blank"
class="text-blue-500"
>
<font-awesome <font-awesome
icon="arrow-up-right-from-square" icon="arrow-up-right-from-square"
class="fa-fw" class="fa-fw"

713
src/components/ImageMethodDialog.vue

@ -1,108 +1,413 @@
<template> <template>
<div v-if="visible" class="dialog-overlay z-[60]"> <div v-if="visible" class="dialog-overlay z-[60]">
<div class="dialog relative"> <div class="dialog relative">
<div class="text-lg text-center font-light relative z-50"> <div class="text-lg text-center font-bold relative">
<h1 id="ViewHeading" class="text-center font-bold">
<span v-if="uploading">Uploading Image&hellip;</span>
<span v-else-if="blob">{{
crop ? "Crop Image" : "Preview Image"
}}</span>
<span v-else-if="showCameraPreview">Upload Image</span>
<span v-else>Add Photo</span>
</h1>
<div <div
id="ViewHeading" class="text-2xl text-center px-1 py-0.5 leading-none absolute -right-1 top-0"
class="text-center font-bold absolute top-0 left-0 right-0 px-4 py-0.5 bg-black/50 text-white leading-none"
>
Add Photo
</div>
<div
class="text-lg text-center px-2 py-0.5 leading-none absolute right-0 top-0 text-white"
@click="close()" @click="close()"
> >
<font-awesome icon="xmark" class="w-[1em]"></font-awesome> <font-awesome icon="xmark" class="w-[1em]"></font-awesome>
</div> </div>
</div> </div>
<div> <!-- FEEDBACK: Show if camera preview is not visible after mounting -->
<div class="text-center mt-8"> <div
<div> v-if="!showCameraPreview && !blob && isRegistered"
<font-awesome class="bg-red-100 text-red-700 border border-red-400 rounded px-4 py-3 my-4 text-sm"
icon="camera" >
class="bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-2 rounded-md" <strong>Camera preview not started.</strong>
@click="openPhotoDialog()" <div v-if="cameraState === 'off'">
/> <span v-if="platformCapabilities.isMobile">
</div> <b>Note:</b> This mobile browser may not support direct camera
<div class="mt-4"> access, or the app is treating it as a native app.<br />
<input type="file" @change="uploadImageFile" /> <b>Tip:</b> Try using a desktop browser, or check if your browser
</div> supports camera access for web apps.<br />
<div class="mt-4"> <b>Developer:</b> The platform detection logic may be skipping
<span class="mt-2"> camera preview for mobile browsers. <br />
... or paste a URL: <b>Action:</b> Review <code>platformCapabilities.isMobile</code> and
<input v-model="imageUrl" type="text" class="border-2" /> ensure web browsers on mobile are not treated as native apps.
</span> </span>
<span class="ml-2"> <span v-else>
<font-awesome <b>Tip:</b> Your browser supports camera APIs, but the preview did
not start. Try refreshing the page or checking browser permissions.
</span>
</div>
<div v-else-if="cameraState === 'error'">
<b>Error:</b> {{ error || cameraStateMessage }}
</div>
<div v-else>
<b>Status:</b> {{ cameraStateMessage || "Unknown reason." }}
</div>
</div>
<div class="mt-4">
<template v-if="isRegistered">
<div v-if="!blob">
<div
class="border-b border-dashed border-slate-300 text-orange-400 mb-4 font-bold text-sm"
>
<span class="block w-fit mx-auto -mb-2.5 bg-white px-2">
Take a photo with your camera
</span>
</div>
<div
v-if="showCameraPreview"
class="camera-preview relative flex bg-black overflow-hidden mb-4"
>
<!-- Diagnostic Panel -->
<div
v-if="showDiagnostics"
class="absolute top-0 left-0 right-0 bg-black/80 text-white text-xs p-2 pt-8 z-20 overflow-auto max-h-[50vh]"
>
<div class="grid grid-cols-2 gap-2">
<div>
<p><strong>Camera State:</strong> {{ cameraState }}</p>
<p>
<strong>State Message:</strong>
{{ cameraStateMessage || "None" }}
</p>
<p><strong>Error:</strong> {{ error || "None" }}</p>
<p>
<strong>Preview Active:</strong>
{{ showCameraPreview ? "Yes" : "No" }}
</p>
<p>
<strong>Stream Active:</strong>
{{ !!cameraStream ? "Yes" : "No" }}
</p>
</div>
<div>
<p><strong>Browser:</strong> {{ userAgent }}</p>
<p>
<strong>HTTPS:</strong>
{{ isSecureContext ? "Yes" : "No" }}
</p>
<p>
<strong>MediaDevices:</strong>
{{ hasMediaDevices ? "Yes" : "No" }}
</p>
<p>
<strong>GetUserMedia:</strong>
{{ hasGetUserMedia ? "Yes" : "No" }}
</p>
<p>
<strong>Platform:</strong>
{{ platformCapabilities.isMobile ? "Mobile" : "Desktop" }}
</p>
</div>
</div>
</div>
<!-- Toggle Diagnostics Button -->
<button
class="absolute top-2 right-2 bg-black/50 text-white px-2 py-1 rounded text-xs z-30"
@click="toggleDiagnostics"
>
{{ showDiagnostics ? "Hide Diagnostics" : "Show Diagnostics" }}
</button>
<div class="camera-container w-full h-full relative">
<video
ref="videoElement"
class="camera-video w-full h-full object-cover"
autoplay
playsinline
muted
></video>
<div
class="absolute bottom-4 inset-x-0 flex items-center justify-center gap-4"
>
<button
class="bg-white text-slate-800 p-3 rounded-full text-2xl leading-none"
@click="capturePhoto"
>
<font-awesome icon="camera" class="w-[1em]" />
</button>
<button
v-if="platformCapabilities.isMobile"
class="bg-white text-slate-800 p-3 rounded-full text-2xl leading-none"
@click="rotateCamera"
>
<font-awesome icon="rotate" class="w-[1em]" />
</button>
</div>
</div>
</div>
<div
class="border-b border-dashed border-slate-300 text-orange-400 mt-4 mb-4 font-bold text-sm"
>
<span class="block w-fit mx-auto -mb-2.5 bg-white px-2">
OR choose a file from your device
</span>
</div>
<div class="mt-4">
<input
type="file"
class="w-full file:text-center file:bg-gradient-to-b file:from-slate-400 file:to-slate-700 file:shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] file:text-white file:px-3 file:py-2 file:rounded-md file:border-none file:cursor-pointer file:me-2"
@change="uploadImageFile"
/>
</div>
<div
class="border-b border-dashed border-slate-300 text-orange-400 mt-4 mb-4 font-bold text-sm"
>
<span class="block w-fit mx-auto -mb-2.5 bg-white px-2">
OR paste an image URL
</span>
</div>
<div class="flex items-center gap-2 mt-4">
<input
v-model="imageUrl"
type="text"
class="block w-full rounded border border-slate-400 px-4 py-2"
placeholder="https://example.com/image.jpg"
/>
<button
v-if="imageUrl" v-if="imageUrl"
icon="check" class="bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-3 py-2 rounded-md cursor-pointer"
class="bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-2 rounded-md cursor-pointer"
@click="acceptUrl" @click="acceptUrl"
/> >
<!-- so that there's no shifting when it becomes visible --> <font-awesome icon="check" class="fa-fw" />
</button>
</div>
</div>
<div v-else>
<div v-if="uploading" class="flex justify-center">
<font-awesome <font-awesome
v-else icon="spinner"
icon="check" class="fa-spin fa-3x text-center block px-12 py-12"
class="text-white bg-white px-2 py-2"
/> />
</span> </div>
<div v-else>
<div v-if="crop">
<VuePictureCropper
:box-style="{
backgroundColor: '#f8f8f8',
margin: 'auto',
}"
:img="createBlobURL(blob)"
:options="{
viewMode: 1,
dragMode: 'crop',
aspectRatio: 1 / 1,
}"
class="max-h-[50vh] max-w-[90vw] object-contain"
/>
</div>
<div v-else>
<div class="flex justify-center">
<img
:src="createBlobURL(blob)"
class="mt-2 rounded max-h-[50vh] max-w-[90vw] object-contain"
/>
</div>
</div>
<div
:class="[
'grid gap-2 mt-2',
showRetry ? 'grid-cols-2' : 'grid-cols-1',
]"
>
<button
class="bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white py-2 px-3 rounded-md"
@click="uploadImage"
>
<span>Upload</span>
</button>
<button
v-if="showRetry"
class="bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white py-2 px-3 rounded-md"
@click="retryImage"
>
<span>Retry</span>
</button>
</div>
</div>
</div> </div>
</div> </template>
<template v-else>
<div
id="noticeBeforeUpload"
class="bg-amber-200 text-amber-900 border-amber-500 border-dashed border text-center rounded-md overflow-hidden px-4 py-3"
role="alert"
aria-live="polite"
>
<p class="mb-2">
Before you can upload a photo, a friend needs to register you.
</p>
<button
class="inline-block text-md uppercase bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-4 py-2 rounded-md"
@click="handleQRCodeClick"
>
Share Your Info
</button>
</div>
</template>
</div> </div>
</div> </div>
</div> </div>
<PhotoDialog ref="photoDialog" />
</template> </template>
<script lang="ts"> <script lang="ts">
import axios from "axios"; import axios from "axios";
import { ref } from "vue"; import { ref } from "vue";
import { Component, Vue } from "vue-facing-decorator"; import { Component, Vue } from "vue-facing-decorator";
import VuePictureCropper, { cropper } from "vue-picture-cropper";
import PhotoDialog from "../components/PhotoDialog.vue"; import { Capacitor } from "@capacitor/core";
import { NotificationIface } from "../constants/app"; import {
DEFAULT_IMAGE_API_SERVER,
NotificationIface,
USE_DEXIE_DB,
} from "../constants/app";
import { retrieveSettingsForActiveAccount } from "../db/index";
import { accessToken } from "../libs/crypto";
import { logger } from "../utils/logger";
import { PlatformServiceFactory } from "../services/PlatformServiceFactory";
import * as databaseUtil from "../db/databaseUtil";
const inputImageFileNameRef = ref<Blob>(); const inputImageFileNameRef = ref<Blob>();
@Component({ @Component({
components: { PhotoDialog }, components: { VuePictureCropper },
props: {
isRegistered: {
type: Boolean,
default: true,
},
defaultCameraMode: {
type: String,
default: "environment",
validator: (value: string) => ["environment", "user"].includes(value),
},
},
}) })
export default class ImageMethodDialog extends Vue { export default class ImageMethodDialog extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void; $notify!: (notification: NotificationIface, timeout?: number) => void;
claimType: string; /** Active DID for user authentication */
activeDid = "";
/** Current image blob being processed */
blob?: Blob;
/** Type of claim for the image */
claimType: string = "";
/** Whether to show cropping interface */
crop: boolean = false; crop: boolean = false;
/** Name of the selected file */
fileName?: string;
/** Callback function to set image URL after upload */
imageCallback: (imageUrl?: string) => void = () => {}; imageCallback: (imageUrl?: string) => void = () => {};
/** URL for image input */
imageUrl?: string; imageUrl?: string;
/** Whether to show retry button */
showRetry = true;
/** Upload progress state */
uploading = false;
/** Dialog visibility state */
visible = false; visible = false;
/** Whether to show camera preview */
showCameraPreview = false;
/** Camera stream reference */
private cameraStream: MediaStream | null = null;
/** Current camera facing mode */
private currentFacingMode: "environment" | "user" = "environment";
private platformService = PlatformServiceFactory.getInstance();
URL = window.URL || window.webkitURL;
private platformCapabilities = this.platformService.getCapabilities();
// Add diagnostic properties
showDiagnostics = false;
userAgent = navigator.userAgent;
isSecureContext = window.isSecureContext;
hasMediaDevices = !!navigator.mediaDevices;
hasGetUserMedia = !!(
navigator.mediaDevices && navigator.mediaDevices.getUserMedia
);
cameraState:
| "off"
| "initializing"
| "ready"
| "active"
| "error"
| "permission_denied"
| "not_found"
| "in_use" = "off";
cameraStateMessage?: string;
error: string | null = null;
/**
* Lifecycle hook: Initializes component and retrieves user settings
* @throws {Error} When settings retrieval fails
*/
async mounted() {
try {
let settings = await databaseUtil.retrieveSettingsForActiveAccount();
if (USE_DEXIE_DB) {
settings = await retrieveSettingsForActiveAccount();
}
this.activeDid = settings.activeDid || "";
} catch (error: unknown) {
logger.error("Error retrieving settings from database:", error);
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text:
error instanceof Error
? error.message
: "There was an error retrieving your settings.",
},
-1,
);
}
}
/**
* Lifecycle hook: Cleans up camera stream when component is destroyed
*/
beforeDestroy() {
this.stopCameraPreview();
}
open(setImageFn: (arg: string) => void, claimType: string, crop?: boolean) { open(setImageFn: (arg: string) => void, claimType: string, crop?: boolean) {
logger.debug("ImageMethodDialog.open called");
this.claimType = claimType; this.claimType = claimType;
this.crop = !!crop; this.crop = !!crop;
this.imageCallback = setImageFn; this.imageCallback = setImageFn;
this.visible = true; this.visible = true;
} this.currentFacingMode = this.defaultCameraMode as "environment" | "user";
openPhotoDialog(blob?: Blob, fileName?: string) { // Start camera preview immediately
this.visible = false; logger.debug("Starting camera preview from open()");
this.startCameraPreview();
(this.$refs.photoDialog as PhotoDialog).open(
this.imageCallback,
this.claimType,
this.crop,
blob,
fileName,
);
} }
async uploadImageFile(event: Event) { async uploadImageFile(event: Event) {
this.visible = false; const target = event.target as HTMLInputElement;
if (!target.files) return;
inputImageFileNameRef.value = event.target.files[0]; inputImageFileNameRef.value = target.files[0];
// https://developer.mozilla.org/en-US/docs/Web/API/File
// ... plus it has a `type` property from my testing
const file = inputImageFileNameRef.value; const file = inputImageFileNameRef.value;
if (file != null) { if (file != null) {
const reader = new FileReader(); const reader = new FileReader();
@ -112,7 +417,9 @@ export default class ImageMethodDialog extends Vue {
const blob = new Blob([new Uint8Array(data)], { const blob = new Blob([new Uint8Array(data)], {
type: file.type, type: file.type,
}); });
this.openPhotoDialog(blob, file.name as string); this.blob = blob;
this.fileName = file.name;
this.showRetry = false;
} }
}; };
reader.readAsArrayBuffer(file as Blob); reader.readAsArrayBuffer(file as Blob);
@ -120,21 +427,16 @@ export default class ImageMethodDialog extends Vue {
} }
async acceptUrl() { async acceptUrl() {
this.visible = false;
if (this.crop) { if (this.crop) {
try { try {
const urlBlobResponse: Blob = await axios.get(this.imageUrl as string, { const urlBlobResponse = await axios.get(this.imageUrl as string, {
responseType: "blob", // This ensures the data is returned as a Blob responseType: "blob",
}); });
const fullUrl = new URL(this.imageUrl as string); const fullUrl = new URL(this.imageUrl as string);
const fileName = fullUrl.pathname.split("/").pop() as string; const fileName = fullUrl.pathname.split("/").pop() as string;
(this.$refs.photoDialog as PhotoDialog).open( this.blob = urlBlobResponse.data as Blob;
this.imageCallback, this.fileName = fileName;
this.claimType, this.showRetry = false;
this.crop,
urlBlobResponse.data as Blob,
fileName,
);
} catch (error) { } catch (error) {
this.$notify( this.$notify(
{ {
@ -148,11 +450,273 @@ export default class ImageMethodDialog extends Vue {
} }
} else { } else {
this.imageCallback(this.imageUrl); this.imageCallback(this.imageUrl);
this.close();
} }
} }
close() { close() {
this.visible = false; this.visible = false;
this.stopCameraPreview();
const bottomNav = document.querySelector("#QuickNav") as HTMLElement;
if (bottomNav) {
bottomNav.style.display = "";
}
this.blob = undefined;
this.showCameraPreview = false;
}
async startCameraPreview() {
logger.debug("startCameraPreview called");
logger.debug("Current showCameraPreview state:", this.showCameraPreview);
logger.debug("Platform capabilities:", this.platformCapabilities);
logger.debug("MediaDevices available:", !!navigator.mediaDevices);
logger.debug(
"getUserMedia available:",
!!(navigator.mediaDevices && navigator.mediaDevices.getUserMedia),
);
try {
this.cameraState = "initializing";
this.cameraStateMessage = "Requesting camera access...";
this.showCameraPreview = true;
await this.$nextTick();
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
throw new Error("Camera API not available in this browser");
}
const stream = await navigator.mediaDevices.getUserMedia({
video: { facingMode: this.currentFacingMode },
});
logger.debug("Camera access granted");
this.cameraStream = stream;
this.cameraState = "active";
this.cameraStateMessage = "Camera is active";
await this.$nextTick();
const videoElement = this.$refs.videoElement as HTMLVideoElement;
if (videoElement) {
videoElement.srcObject = stream;
await new Promise((resolve) => {
videoElement.onloadedmetadata = () => {
videoElement
.play()
.then(() => {
logger.debug("Video element started playing");
resolve(true);
})
.catch((error) => {
logger.error("Error playing video:", error);
throw error;
});
};
});
} else {
logger.error("Video element not found");
throw new Error("Video element not found");
}
} catch (error) {
logger.error("Error starting camera preview:", error);
let errorMessage =
error instanceof Error ? error.message : "Failed to access camera";
if (
error instanceof Error &&
(error.name === "NotReadableError" || error.name === "TrackStartError")
) {
errorMessage =
"Camera is in use by another application. Please close any other apps or browser tabs using the camera and try again.";
} else if (
error instanceof Error &&
(error.name === "NotAllowedError" ||
error.name === "PermissionDeniedError")
) {
errorMessage =
"Camera access was denied. Please allow camera access in your browser settings.";
}
this.cameraState = "error";
this.cameraStateMessage = errorMessage;
this.error = errorMessage;
this.showCameraPreview = false;
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: errorMessage,
},
5000,
);
}
}
stopCameraPreview() {
if (this.cameraStream) {
this.cameraStream.getTracks().forEach((track) => track.stop());
this.cameraStream = null;
}
this.showCameraPreview = false;
this.cameraState = "off";
this.cameraStateMessage = "Camera stopped";
this.error = null;
}
async capturePhoto() {
if (!this.cameraStream) return;
try {
const videoElement = this.$refs.videoElement as HTMLVideoElement;
const canvas = document.createElement("canvas");
canvas.width = videoElement.videoWidth;
canvas.height = videoElement.videoHeight;
const ctx = canvas.getContext("2d");
ctx?.drawImage(videoElement, 0, 0, canvas.width, canvas.height);
canvas.toBlob(
(blob) => {
if (blob) {
this.blob = blob;
this.fileName = `photo_${Date.now()}.jpg`;
this.showRetry = true;
this.stopCameraPreview();
}
},
"image/jpeg",
0.95,
);
} catch (error) {
logger.error("Error capturing photo:", error);
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "Failed to capture photo. Please try again.",
},
5000,
);
}
}
async rotateCamera() {
// Toggle between front and back cameras
this.currentFacingMode =
this.currentFacingMode === "environment" ? "user" : "environment";
// Stop current stream
if (this.cameraStream) {
this.cameraStream.getTracks().forEach((track) => track.stop());
this.cameraStream = null;
}
// Start new stream with updated facing mode
await this.startCameraPreview();
}
private createBlobURL(blob: Blob): string {
return URL.createObjectURL(blob);
}
async retryImage() {
this.blob = undefined;
if (!this.platformCapabilities.isNativeApp) {
await this.startCameraPreview();
}
}
async uploadImage() {
this.uploading = true;
if (this.crop) {
this.blob = (await cropper?.getBlob()) || undefined;
}
const token = await accessToken(this.activeDid);
const headers = {
Authorization: "Bearer " + token,
};
const formData = new FormData();
if (!this.blob) {
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "There was an error finding the picture. Please try again.",
},
5000,
);
this.uploading = false;
this.close();
return;
}
formData.append("image", this.blob, this.fileName || "photo.jpg");
formData.append("claimType", this.claimType);
try {
if (
window.location.hostname === "localhost" &&
!DEFAULT_IMAGE_API_SERVER.includes("localhost")
) {
logger.log(
"Using shared image API server, so only users on that server can play with images.",
);
}
const response = await axios.post(
DEFAULT_IMAGE_API_SERVER + "/image",
formData,
{ headers },
);
this.uploading = false;
this.close();
this.imageCallback(response.data.url as string);
} catch (error: unknown) {
let errorMessage = "There was an error saving the picture.";
if (axios.isAxiosError(error)) {
const status = error.response?.status;
const data = error.response?.data;
if (status === 401) {
errorMessage = "Authentication failed. Please try logging in again.";
} else if (status === 413) {
errorMessage = "Image file is too large. Please try a smaller image.";
} else if (status === 415) {
errorMessage =
"Unsupported image format. Please try a different image.";
} else if (status && status >= 500) {
errorMessage = "Server error. Please try again later.";
} else if (data?.message) {
errorMessage = data.message;
}
}
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: errorMessage,
},
5000,
);
this.uploading = false;
this.blob = undefined;
this.close();
}
}
// Add toggle method
toggleDiagnostics() {
this.showDiagnostics = !this.showDiagnostics;
}
private handleQRCodeClick() {
if (Capacitor.isNativePlatform()) {
this.$router.push({ name: "contact-qr-scan-full" });
} else {
this.$router.push({ name: "contact-qr" });
}
} }
} }
</script> </script>
@ -178,5 +742,16 @@ export default class ImageMethodDialog extends Vue {
border-radius: 0.5rem; border-radius: 0.5rem;
width: 100%; width: 100%;
max-width: 700px; max-width: 700px;
max-height: 90vh;
overflow: hidden;
display: flex;
flex-direction: column;
}
/* Add styles for diagnostic panel */
.diagnostic-panel {
font-family: monospace;
white-space: pre-wrap;
word-break: break-all;
} }
</style> </style>

1
src/components/ImageViewer.vue

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

44
src/components/MembersList.vue

@ -172,8 +172,10 @@ import {
} from "../libs/endorserServer"; } from "../libs/endorserServer";
import { decryptMessage } from "../libs/crypto"; import { decryptMessage } from "../libs/crypto";
import { Contact } from "../db/tables/contacts"; import { Contact } from "../db/tables/contacts";
import * as databaseUtil from "../db/databaseUtil";
import * as libsUtil from "../libs/util"; import * as libsUtil from "../libs/util";
import { NotificationIface } from "../constants/app"; import { NotificationIface, USE_DEXIE_DB } from "../constants/app";
import { PlatformServiceFactory } from "@/services/PlatformServiceFactory";
interface Member { interface Member {
admitted: boolean; admitted: boolean;
@ -209,7 +211,10 @@ export default class MembersList extends Vue {
contacts: Array<Contact> = []; contacts: Array<Contact> = [];
async created() { async created() {
const settings = await retrieveSettingsForActiveAccount(); let settings = await databaseUtil.retrieveSettingsForActiveAccount();
if (USE_DEXIE_DB) {
settings = await retrieveSettingsForActiveAccount();
}
this.activeDid = settings.activeDid || ""; this.activeDid = settings.activeDid || "";
this.apiServer = settings.apiServer || ""; this.apiServer = settings.apiServer || "";
this.firstName = settings.firstName || ""; this.firstName = settings.firstName || "";
@ -296,7 +301,7 @@ export default class MembersList extends Vue {
this.decryptedMembers.length === 0 || this.decryptedMembers.length === 0 ||
this.decryptedMembers[0].member.memberId !== this.members[0].memberId this.decryptedMembers[0].member.memberId !== this.members[0].memberId
) { ) {
return "Your password is not the same as the organizer. Reload or have them check their password."; return "Your password is not the same as the organizer. Retry or have them check their password.";
} else { } else {
// the first (organizer) member was decrypted OK // the first (organizer) member was decrypted OK
return ""; return "";
@ -337,7 +342,7 @@ export default class MembersList extends Vue {
group: "alert", group: "alert",
type: "info", type: "info",
title: "Contact Exists", title: "Contact Exists",
text: "They are in your contacts. If you want to remove them, you must do that from the contacts screen.", text: "They are in your contacts. To remove them, use the contacts page.",
}, },
10000, 10000,
); );
@ -347,7 +352,7 @@ export default class MembersList extends Vue {
group: "alert", group: "alert",
type: "info", type: "info",
title: "Contact Available", title: "Contact Available",
text: "This is to add them to your contacts. If you want to remove them later, you must do that from the contacts screen.", text: "This is to add them to your contacts. To remove them later, use the contacts page.",
}, },
10000, 10000,
); );
@ -355,7 +360,16 @@ export default class MembersList extends Vue {
} }
async loadContacts() { async loadContacts() {
this.contacts = await db.contacts.toArray(); const platformService = PlatformServiceFactory.getInstance();
const result = await platformService.dbQuery("SELECT * FROM contacts");
if (result) {
this.contacts = databaseUtil.mapQueryResultToValues(
result,
) as unknown as Contact[];
}
if (USE_DEXIE_DB) {
this.contacts = await db.contacts.toArray();
}
} }
getContactFor(did: string): Contact | undefined { getContactFor(did: string): Contact | undefined {
@ -439,7 +453,14 @@ export default class MembersList extends Vue {
if (result.success) { if (result.success) {
decrMember.isRegistered = true; decrMember.isRegistered = true;
if (oldContact) { if (oldContact) {
await db.contacts.update(decrMember.did, { registered: true }); const platformService = PlatformServiceFactory.getInstance();
await platformService.dbExec(
"UPDATE contacts SET registered = ? WHERE did = ?",
[true, decrMember.did],
);
if (USE_DEXIE_DB) {
await db.contacts.update(decrMember.did, { registered: true });
}
oldContact.registered = true; oldContact.registered = true;
} }
this.$notify( this.$notify(
@ -492,7 +513,14 @@ export default class MembersList extends Vue {
name: member.name, name: member.name,
}; };
await db.contacts.add(newContact); const platformService = PlatformServiceFactory.getInstance();
await platformService.dbExec(
"INSERT INTO contacts (did, name) VALUES (?, ?)",
[member.did, member.name],
);
if (USE_DEXIE_DB) {
await db.contacts.add(newContact);
}
this.contacts.push(newContact); this.contacts.push(newContact);
this.$notify( this.$notify(

22
src/components/OfferDialog.vue

@ -82,12 +82,13 @@
<script lang="ts"> <script lang="ts">
import { Vue, Component, Prop } from "vue-facing-decorator"; import { Vue, Component, Prop } from "vue-facing-decorator";
import { NotificationIface } from "../constants/app"; import { NotificationIface, USE_DEXIE_DB } from "../constants/app";
import { import {
createAndSubmitOffer, createAndSubmitOffer,
serverMessageForUser, serverMessageForUser,
} from "../libs/endorserServer"; } from "../libs/endorserServer";
import * as libsUtil from "../libs/util"; import * as libsUtil from "../libs/util";
import * as databaseUtil from "../db/databaseUtil";
import { retrieveSettingsForActiveAccount } from "../db/index"; import { retrieveSettingsForActiveAccount } from "../db/index";
import { logger } from "../utils/logger"; import { logger } from "../utils/logger";
@ -116,7 +117,10 @@ export default class OfferDialog extends Vue {
this.recipientDid = recipientDid; this.recipientDid = recipientDid;
this.recipientName = recipientName; this.recipientName = recipientName;
const settings = await retrieveSettingsForActiveAccount(); let settings = await databaseUtil.retrieveSettingsForActiveAccount();
if (USE_DEXIE_DB) {
settings = await retrieveSettingsForActiveAccount();
}
this.apiServer = settings.apiServer || ""; this.apiServer = settings.apiServer || "";
this.activeDid = settings.activeDid || ""; this.activeDid = settings.activeDid || "";
@ -245,10 +249,7 @@ export default class OfferDialog extends Vue {
this.projectId, this.projectId,
); );
if ( if (!result.success) {
result.type === "error" ||
this.isOfferCreationError(result.response)
) {
const errorMessage = this.getOfferCreationErrorMessage(result); const errorMessage = this.getOfferCreationErrorMessage(result);
logger.error("Error with offer creation result:", result); logger.error("Error with offer creation result:", result);
this.$notify( this.$notify(
@ -292,15 +293,6 @@ export default class OfferDialog extends Vue {
// Helper functions for readability // Helper functions for readability
/**
* @param result response "data" from the server
* @returns true if the result indicates an error
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
isOfferCreationError(result: any) {
return result.status !== 201 || result.data?.error;
}
/** /**
* @param result direct response eg. ErrorResult or SuccessResult (potentially with embedded "data") * @param result direct response eg. ErrorResult or SuccessResult (potentially with embedded "data")
* @returns best guess at an error message * @returns best guess at an error message

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

Loading…
Cancel
Save