Compare commits

...

169 Commits

Author SHA1 Message Date
Matthew Raymer
d9ce884513 fix: configure Vite for proper Node.js module handling in Electron
- Add vite-plugin-node-polyfills to provide Node.js built-in module polyfills
- Configure build target as 'node18' for Electron environment
- Switch to CommonJS format for Electron builds
- Add specific polyfills for sqlite3 dependencies (util, stream, buffer)
- Mark Node.js built-in modules as external in Electron builds

This fixes the "Module util has been externalized" error by properly handling
Node.js modules in the Electron environment, particularly for sqlite3 which
depends on Node.js built-in modules.
2025-05-26 14:06:21 +00:00
Matthew Raymer
a1a1543ae1 fix: update component imports in HomeView.vue
- Replace non-existent index.ts import with direct component imports
- Fix ChoiceButtonDialog import to use default import syntax
- Import ImageViewer directly from its component file

This fixes the component loading issues while maintaining the existing functionality.
The remaining linter errors are unrelated to these import changes and should be
addressed separately.
2025-05-26 13:22:59 +00:00
Matt Raymer
93591a5815 docs: storage documentation and feature checklist 2025-05-26 08:43:33 -04:00
Matt Raymer
b30c4c8b30 refactor: migrate database operations to PlatformService
- Add account management methods to PlatformService interface
- Implement account operations in all platform services
- Fix PlatformCapabilities interface by adding sqlite property
- Update util.ts to use PlatformService for account operations
- Standardize account and settings management across platforms

This change improves code organization by:
- Centralizing database operations through PlatformService
- Ensuring consistent account management across platforms
- Making platform-specific implementations more maintainable
- Reducing direct database access in utility functions

Note: Some linter errors remain regarding db.accounts access and sqlite
capabilities that need to be addressed in a follow-up commit.
2025-05-26 06:54:10 -04:00
Matt Raymer
1f9db0ba94 chore:update 2025-05-26 00:33:24 -04:00
Matt Raymer
bdc2d71d3c docs: migrate web storage implementation from wa-sql to absurd-sql
- Update web platform storage solution to use absurd-sql with IndexedDB backend

- Replace wa-sqlite dependencies with absurd-sql and @jlongster/sql.js

- Update WebSQLiteService implementation with SQLiteFS and IndexedDBBackend

- Add performance optimizations (WAL mode, mmap, temp store)

- Add type-safe query method and improved error handling

- Update platform capabilities matrix with new features

- Add absurd-sql compatibility checks in migration service

This change improves transaction support, performance, and reliability of the web platform's SQLite implementation.
2025-05-26 00:32:26 -04:00
2647c5a77d fix migrations logging error 2025-05-25 21:52:27 -06:00
Matt Raymer
682fceb1c6 Merge remote-tracking branch 'refs/remotes/origin/sql-absurd-sql' into sql-absurd-sql 2025-05-25 23:37:43 -04:00
Matt Raymer
e0013008b4 refactor: improve type safety and browser compatibility - Replace any types with SqlValue[] in migration system - Add browser-compatible implementations of Node.js modules (crypto, fs, path) - Update Vite config to handle Node.js module polyfills - Remove outdated migration documentation files 2025-05-25 23:37:08 -04:00
0674d98670 fix BUILDING instructions 2025-05-25 21:29:57 -06:00
Matt Raymer
ee441d1aea refactor(db): improve type safety in migration system
- Replace any[] with SqlValue[] type for SQL parameters in runMigrations
- Update import to use QueryExecResult from interfaces/database
- Add proper typing for SQL parameter values (string | number | null | Uint8Array)

This change improves type safety and helps catch potential SQL parameter
type mismatches at compile time, reducing the risk of runtime errors
or data corruption.
2025-05-25 23:09:53 -04:00
Matt Raymer
75f6e99200 chore: update migration documents and move to new home 2025-05-25 22:50:32 -04:00
Matt Raymer
52c9e57ef4 Merge remote-tracking branch 'refs/remotes/origin/sql-absurd-sql' into sql-absurd-sql 2025-05-25 22:47:36 -04:00
603823d808 add to build instructions for electron on mac 2025-05-25 20:48:51 -06:00
5f24f4975d fix linting 2025-05-25 20:48:33 -06:00
5057d7d07f don't always apply the camera-implementation cursor rules 2025-05-25 20:37:16 -06:00
946e88d903 add a input area for arbitrary SQL on the test page 2025-05-25 20:27:06 -06:00
Matt Raymer
cbfb1ebf57 Merge branch 'new-storage' into sql-absurd-sql 2025-05-25 22:25:56 -04:00
a38934e38d fix problems with race conditions and multiple DatabaseService instances 2025-05-25 19:46:15 -06:00
a3bdcfd168 fix problem with initialization & refactor types 2025-05-25 18:32:41 -06:00
83771caee1 add more to the inital migration, and refactor the locations of types 2025-05-25 17:55:04 -06:00
da35b225cd remove unused setting 2025-05-25 15:49:36 -06:00
8c3920e108 add DB setup with migrations 2025-05-25 11:06:30 -06:00
54f269054f fix error loading WASM file 2025-05-25 07:45:07 -06:00
Matt Raymer
574520d9b3 feat(db): Implement SQLite database layer with migration support
Add SQLite database implementation with comprehensive features:

- Core database functionality:
  - Connection management and pooling
  - Schema creation and validation
  - Transaction support with rollback
  - Backup and restore capabilities
  - Health checks and integrity verification

- Data migration:
  - Migration utilities from Dexie to SQLite
  - Data transformation and validation
  - Migration verification and rollback
  - Backup before migration

- CRUD operations for all entities:
  - Accounts, contacts, and contact methods
  - Settings and secrets
  - Logging and audit trails

- Type safety and error handling:
  - Full TypeScript type definitions
  - Runtime data validation
  - Comprehensive error handling
  - Transaction safety

Note: Requires @wa-sqlite/sql.js package to be installed
2025-05-25 04:52:16 -04:00
6556eb55a3 add the other pieces for the previous commit 2025-05-25 01:18:58 -06:00
Matt Raymer
28e848e386 docs: add comprehensive migration guide for Dexie to wa-sqlite
- Add detailed migration process documentation including preparation, data migration, and rollback strategies\n- Include TypeScript implementation examples for MigrationService, DataMigration, and RollbackService\n- Add Vue component for migration progress tracking with error handling\n- Document testing strategy with unit and integration test examples\n- Define clear success criteria and timeline for migration\n- Include platform-specific considerations and prerequisites\n- Add post-migration verification and monitoring guidelines
2025-05-25 03:18:12 -04:00
Matt Raymer
55f56174a5 docs: enhance secure storage implementation documentation
- Add comprehensive platform-specific implementations for web and native platforms

- Include detailed error handling and recovery strategies

- Add complete testing strategy with platform-specific tests

- Add practical before/after usage examples

- Add appendix with schema, error codes, and platform capabilities

- Improve documentation structure and readability

- Add migration strategy for web platform

- Include platform-specific security features and optimizations
2025-05-25 03:16:12 -04:00
634e2bb2fb try absurd-sql, which fails in browser with: SyntaxError: Cannot use import statement outside a module (at registerSQLWorker.js... 2025-05-25 01:06:31 -06:00
Matt Raymer
30fb3aee8e docs: enhance secure storage implementation documentation
Add detailed platform implementations, usage examples, and error handling: - Add comprehensive platform-specific implementations for Web (Dexie) and Capacitor (SQLite) - Include detailed database initialization and security features - Add practical usage examples for account and settings management - Document error handling strategies and edge cases - Add concurrency management and data integrity checks - Include platform transition handling

This update provides a complete reference for implementing secure storage across different platforms while maintaining backward compatibility.
2025-05-24 23:37:09 -04:00
e254837951 tweak messages & commentary 2025-05-24 20:55:54 -06:00
8417cad2f3 add documentation for some new work: storage 2025-05-24 18:22:05 -06:00
0f56b659c1 adjust privacy notice with the name 2025-05-23 13:07:56 -06:00
a8bfcb720a adjust the messaging for setting one's name 2025-05-23 13:04:49 -06:00
c23e30c431 remove duplicate prompt to get registered 2025-05-23 12:59:57 -06:00
1129a13e20 add more error handling and messaging when there are bad DB errors 2025-05-23 12:35:16 -06:00
5b6c59c232 show an error if the import goes badly 2025-05-23 11:09:22 -06:00
295a2d9f63 don't export 0s for undefined values! 2025-05-23 11:06:37 -06:00
Jose Olarte III
6e14ccdbbc Fix: mirror camera view
- Always when using a desktop browser
- NEEDS TESTING: Conditionally in mobile
2025-05-23 19:21:20 +08:00
Jose Olarte III
d636b21744 Fix: limit image crop stage height
- Addresses issue when extra-tall portrait images are uploaded for cropping
2025-05-23 17:25:22 +08:00
37b7c4ed36 various instructions (and their timeouts) 2025-05-22 12:27:37 -06:00
Jose Olarte III
f7728aadf0 Revert iOS app ID 2025-05-22 22:36:16 +08:00
Jose Olarte III
ce34257ba1 De-coupled web and mobile QR scanner views
- Separate scanner views for web and mobile platforms: different libraries, similar layouts
- Mobile: QR code overlaid on top of full-screen camera view
- Mobile: added framing box + instruction text
- Mobile: increased debounce time to compensate for behavior of MLkit scanner
- Web: removed Capacitor-related code and platform-specific conditions
- Web: adjusted max-size of QR code and camera view to better fit newer iOS device screens
- Web + mobile: camera view remains active when a QR scan is triggered
2025-05-22 19:46:19 +08:00
Matt Raymer
190c972f57 Remove ContactScanView and rename ContactQRScanView to ContactQRScanFullView
- Deleted ContactScanView.vue and its route from the router.
- Renamed ContactQRScanView.vue to ContactQRScanFullView.vue.
- Updated all router paths, names, and references for consistency.
- Fixed related links and imports to use the new view/component name.
2025-05-21 05:17:25 -04:00
Jose Olarte III
831df4b253 Restored diagnostics repositioning 2025-05-21 16:39:21 +08:00
Matt Raymer
55176ed5db Remove ContactScanView and update QR scan view naming for consistency
- Deleted ContactScanView.vue and removed its route from the router
- Renamed ContactQRScanView.vue to ContactQRScanFullView.vue
- Updated all router paths, names, and references to use 'contact-qr-scan-full'
- Updated related router links in ContactQRScanShowView.vue for consistency
- Ensured all naming and routing is consolidated and matches the new view/component name
2025-05-21 04:16:58 -04:00
Matt Raymer
b491262bef Only request camera permissions on user gesture in ImageMethodDialog
- Removed automatic call to startCameraPreview() from mounted() lifecycle hook
- Camera preview (and permission prompt) now only starts in open(), triggered by user action
- Prevents unnecessary permission prompts on page load and improves UX
2025-05-21 03:48:57 -04:00
Jose Olarte III
a1c18458e7 Repositioned diagnostics over camera preview 2025-05-21 15:02:30 +08:00
Jose Olarte III
995af4e576 Restrict camera height in portrait mode 2025-05-21 14:49:29 +08:00
Matt Raymer
8ac728d488 Remove temporary alert() debug calls from ImageMethodDialog camera preview
- Cleaned up all alert() calls used for diagnosing camera access issues on mobile browsers
- Camera preview now starts without pop-up interruptions
- Retained logging and user notifications for error handling and diagnostics
2025-05-21 02:23:52 -04:00
Matt Raymer
913f11b66c Merge remote-tracking branch 'refs/remotes/origin/qrcode-reboot' into qrcode-reboot 2025-05-20 23:08:37 -04:00
Matt Raymer
79882715d8 fix: update Alpine version to 3.20 for stable package repositories
- Change base image from node:22-alpine to node:22-alpine3.20
- Resolves package installation issues with Alpine 3.21 repositories
- Ensures stable access to build dependencies (python3, gcc, etc.)

This change fixes the Docker build process by using a more stable
version of Alpine Linux that has reliable package repositories.
2025-05-20 23:07:49 -04:00
22978a1eda bump to build 18 version 0.4.7 to match the latest upload to ios 2025-05-20 20:27:38 -06:00
79b2218129 add a privacy-fixer project that may have fixed the GoogleToolboxForMac privacy manifext problem
https://github.com/crasowas/app_privacy_manifest_fixer
2025-05-20 20:24:21 -06:00
52685702c1 Merge pull request 'trent-tweaks' (#133) from trent-tweaks into qrcode-reboot
Reviewed-on: #133
2025-05-20 10:52:33 -04:00
d555bc3e9c Merge branch 'qrcode-reboot' into trent-tweaks 2025-05-20 08:52:08 -06:00
Jose Olarte III
141415977e Fix linting errors 2025-05-20 21:35:42 +08:00
Jose Olarte III
981ccbf269 iOS photo library permission 2025-05-20 21:33:15 +08:00
Jose Olarte III
b74ec8ecbb Design: polished dialog UI 2025-05-20 20:54:42 +08:00
Matt Raymer
7b3b1c930e refactor: consolidate type system and improve documentation
- Move type definitions from src/types/ to src/interfaces/ for better organization
- Enhance deep linking type system documentation with detailed examples
- Update package dependencies to latest versions
- Improve code organization in README.md
- Fix formatting in WebPlatformService.ts

This change consolidates all type definitions into the interfaces folder,
improves type safety documentation, and updates dependencies for better
maintainability. The deep linking system now has clearer documentation
about its type system and validation approach.

Breaking: Removes src/types/ directory in favor of src/interfaces/
2025-05-20 03:15:23 -04:00
Matt Raymer
85aa2981ad docs: add comprehensive camera switching implementation guide
Add detailed documentation for camera switching functionality across web and mobile platforms:

- Add camera management interfaces to QRScannerService
- Document MLKit Barcode Scanner configuration for Capacitor
- Add platform-specific implementations for iOS and Android
- Include camera state management and error handling
- Add performance optimization guidelines
- Document testing requirements and scenarios

Key additions:
- Camera switching implementation for both platforms
- Platform-specific considerations (iOS/Android)
- Battery and memory optimization strategies
- Comprehensive testing guidelines
- Error handling and state management
- Security and permission considerations

This update provides a complete reference for implementing robust
camera switching functionality in the QR code scanner.
2025-05-20 02:50:56 -04:00
Matt Raymer
a86e577127 style: improve code formatting and readability
- Format Vue template attributes and event handlers for better readability
- Reorganize component props and event bindings
- Improve error handling and state management in QR scanner
- Add proper aria labels and accessibility attributes
- Refactor camera state handling in WebInlineQRScanner
- Clean up promise handling in WebPlatformService
- Standardize string quotes to double quotes
- Improve component structure and indentation

No functional changes, purely code style and maintainability improvements.
2025-05-20 01:15:47 -04:00
Matt Raymer
788d162b1c refactor: move lib directory to libs for consistency
- Move src/lib/capacitor to src/libs/capacitor
- Move src/lib/fontawesome.ts to src/libs/fontawesome.ts
- Update import paths in main.capacitor.ts and main.common.ts
- Remove empty src/lib directory

This change standardizes the project structure by using the 'libs'
directory consistently throughout the codebase.
2025-05-19 22:25:12 -04:00
Matt Raymer
616a69b7fd chore: update capacitor config and script paths
- Update capacitor.config.json:
  - Change appId from com.brownspank.timesafari to app.timesafari
  - Add server configuration with cleartext enabled
  - Add plugins configuration for App URL handling
- Update script documentation paths:
  - Change ./openssl_signing_console.sh to /scripts/openssl_signing_console.sh
  - Change ./openssl_signing_console.rst to /doc/openssl_signing_console.rst

This change standardizes the app identifier and adds necessary
capacitor configurations for development, while also fixing script
documentation paths to use absolute references.
2025-05-19 22:18:24 -04:00
Jose Olarte III
efab9b968c Merge branch 'qrcode-reboot' of ssh://173.199.124.46:222/trent_larson/crowd-funder-for-time-pwa into qrcode-reboot 2025-05-19 18:50:27 +08:00
Jose Olarte III
70174aea93 Fix: current photo dialog 2025-05-19 18:50:08 +08:00
Matt Raymer
7f12595c91 docs: consolidate QR code implementation documentation
Merge multiple QR code documentation files into a single comprehensive guide
that accurately reflects the current implementation. The consolidated guide:

- Combines information from qr-code-implementation-guide.mdc,
  qr-code-handling-rule.mdc, and camera-implementation.md
- Clarifies the relationship between ContactQRScanView and ContactQRScanShowView
- Streamlines build configuration documentation
- Adds detailed sections on error handling, security, and best practices
- Improves organization and readability of implementation details
- Removes redundant information while preserving critical details

This change improves documentation maintainability and provides a single
source of truth for QR code implementation details.
2025-05-19 06:28:46 -04:00
Matt Raymer
8f0d09e480 chore: cleanup documents 2025-05-19 05:44:12 -04:00
Matt Raymer
cfc0730e75 feat: implement comprehensive camera state management
- Add CameraState type and CameraStateListener interface for standardized state handling
- Implement camera state tracking in WebInlineQRScanner:
  - Add state management properties and methods
  - Update state transitions during camera operations
  - Add proper error state handling for different scenarios
- Enhance QR scanner UI with improved state feedback:
  - Add color-coded status indicators
  - Implement state-specific messages and notifications
  - Add user-friendly error notifications for common issues
- Improve error handling with specific states for:
  - Camera in use by another application
  - Permission denied
  - Camera not found
  - General errors

This change improves the user experience by providing clear visual
feedback about the camera's state and better error handling with
actionable notifications.
2025-05-19 04:40:18 -04:00
bfbb9a933d add a pod dependency to environment 2025-05-18 20:22:48 -06:00
674bbfa00c bump version to match attempts in app stores 2025-05-18 20:22:24 -06:00
80b754246e add more ios tweaks for app store 2025-05-18 20:20:41 -06:00
5fcf6a1f90 add documentation, especially for build processes 2025-05-18 20:19:29 -06:00
9da12e76fd refactor files that should be ignored 2025-05-18 20:15:50 -06:00
Matt Raymer
04193f61c7 Merging 2025-05-16 06:23:46 -04:00
Matt Raymer
0ca4916a05 chore: update camera documentation 2025-05-16 06:17:10 -04:00
925ce830b4 remove duplicate instructions 2025-05-15 20:48:06 -06:00
Jose Olarte III
d14635c44d UI tweaks 2025-05-15 18:17:58 +08:00
Matt Raymer
eb5c9565a6 fix: remove duplicate Advanced heading and improve UX
- Remove redundant Advanced heading from advanced settings section\n- Make clickable text more descriptive (Show/Hide Advanced Settings)\n- Add cursor-pointer class for better UX\n- Make section heading screen-reader only for accessibility
2025-05-15 03:37:01 -04:00
Matthew Raymer
ea108b754e feat(accessibility): enhance AccountViewView and document test suite
- Add ARIA annotations and roles to AccountViewView for better screen reader support
  - Add role="tooltip" to API server description
  - Improve input control accessibility with proper ARIA attributes
  - Add descriptive labels and aria-labels for interactive elements

- Create comprehensive README.md for Playwright test suite
  - Document test structure and organization
  - Add setup instructions and prerequisites
  - Include troubleshooting guide and contribution guidelines
  - Link to related documentation

This change improves accessibility compliance and makes the test suite
more maintainable for contributors.
2025-05-15 06:19:36 +00:00
Matt Raymer
e4155e1a20 feat(ui): disable all photo upload actions for unregistered users
- Updated ImageMethodDialog.vue to accept an isRegistered prop and show "Register to Upload a Photo" message instead of upload UI when not registered.
- Updated AccountViewView.vue to pass isRegistered to ImageMethodDialog and replace all profile photo add/upload buttons with the same message for unregistered users.
- Ensures consistent UX and prevents unregistered users from accessing any photo upload features.
2025-05-14 05:39:03 -04:00
Matt Raymer
7e9682ce67 feat(web): enable desktop webcam capture in WebPlatformService
- Updated WebPlatformService.takePicture() to use getUserMedia for webcam capture on desktop browsers, providing a live video preview and capture button in an overlay.
- Retained file input with capture attribute for mobile browsers and as a fallback if webcam access fails.
- Ensured interface and factory pattern compatibility; no changes required in PhotoDialog.vue or PlatformServiceFactory.
- Added a stub for writeAndShareFile to satisfy the PlatformService interface on web.
2025-05-14 04:35:33 -04:00
c7f1148fe4 add logger import where needed 2025-05-13 22:22:19 -06:00
ae9f1ee09f update package-lock with the latest build 2025-05-13 20:11:47 -06:00
4d0463f7f7 update with lint-fix 2025-05-13 20:11:34 -06:00
748c4c7a50 add documentation 2025-05-12 19:11:18 -06:00
35bb9d2207 remove ability to mark a 'trade', ensuring this only sends & retrieves gifts 2025-05-09 21:37:48 -06:00
Jose Olarte III
fd914aa46c Removed unneeded elements 2025-05-09 19:56:45 +08:00
Jose Olarte III
ba1453104f UI tweaks to QR scanner
- Removed QR code border
- Changed QR code size to eliminate whitespace baked into image
- Removed scanning frame
- Removed camera selector button
- Restyled camera stop buttons in both web and Capacitor for consistency
- Added iOS safe area to Capacitor camera overlay
2025-05-09 19:53:06 +08:00
Jose Olarte III
3c7f13d604 Merge branch 'qrcode-reboot' of ssh://173.199.124.46:222/trent_larson/crowd-funder-for-time-pwa into qrcode-reboot 2025-05-08 20:14:24 +08:00
Jose Olarte III
8e8eef2ab5 Safe area implementation for iOS
Dynamic padding to clear certain iOS UI elements such as the notch, dynamic island and gesture bar, to ensure they don't overlap with our own UI elements.
2025-05-08 20:11:45 +08:00
Matt Raymer
ea17ef930c chore: adjusting file location 2025-05-08 04:50:27 -04:00
Jose Olarte III
5242a24110 Web: trigger camera start on view load 2025-05-08 15:51:00 +08:00
Matt Raymer
93e860e0ac feat(qr-scanner): implement WebInlineQRScanner with jsQR integration
- Add jsQR library for QR code detection and scanning
- Implement WebInlineQRScanner class with comprehensive camera handling
- Add detailed logging throughout scanner lifecycle
- Include error handling and cleanup procedures
- Add blur detection for QR codes
- Implement FPS throttling for performance optimization
- Add device compatibility checks and permission handling

The scanner now provides:
- Camera stream management
- QR code detection with blur prevention
- Performance optimized scanning (15 FPS target)
- Detailed logging for debugging
- Proper cleanup of resources
2025-05-08 01:58:39 -04:00
Jose Olarte III
f874973bfa Improvements to contact QR scanner UI
Plus some narrow-screen fixes to NotificationGroup
2025-05-07 21:23:12 +08:00
Matt Raymer
74b9caa94f chore: updates for qr code reader rules, linting, and cleanup 2025-05-07 01:57:18 -04:00
Jose Olarte III
fdd1ff80ad Complete: unified QR display + capture 2025-05-06 21:35:24 +08:00
Matt Raymer
5d195d06ba style: improve code formatting and type safety
- Add proper type annotation for onDetect result parameter
- Fix indentation and line wrapping in template
- Remove unused WebInlineQRScanner import
- Clean up button attribute ordering
- Fix whitespace and formatting issues
- Remove unused empty divs
2025-05-06 02:45:24 -04:00
Jose Olarte III
79707d2811 WIP: Unified contact QR code display + capture 2025-05-05 20:52:20 +08:00
Jose Olarte III
9b73e05bdb Merge branch 'qrcode-reboot' of ssh://173.199.124.46:222/trent_larson/crowd-funder-for-time-pwa into qrcode-reboot 2025-05-02 21:31:58 +08:00
Jose Olarte III
1b7c5decd3 Stop scanner when cancelling 2025-05-02 21:29:08 +08:00
Jose Olarte III
8c8fb6fe7d De-coupled web and mobile scanners 2025-05-02 21:28:46 +08:00
Matthew Raymer
29983f11a9 doc: camera system details 2025-05-02 10:30:09 +00:00
Matthew Raymer
5c559606df docs: add macOS build and packaging instructions
- Add detailed macOS build procedure for Electron app
- Include instructions for Intel and Universal builds
- Add code signing and notarization requirements
- Document running instructions for .app, .dmg, and .zip formats
- Add security warning handling instructions
2025-05-02 03:22:07 -07:00
Matthew Raymer
37166fc141 docs(PhotoDialog): improve component documentation and error handling
- Add comprehensive JSDoc documentation for component features and capabilities
- Fix line wrapping in file-level documentation header
- Improve error message formatting for better readability
- Remove unused PhotoResult interface
- Maintain consistent documentation style with project standards

The documentation improvements help developers better understand the component's
cross-platform photo handling capabilities and error management approach.
2025-05-02 08:39:29 +00:00
Jose Olarte III
01ef7c1fa9 Merge branch 'qrcode-reboot' of ssh://173.199.124.46:222/trent_larson/crowd-funder-for-time-pwa into qrcode-reboot 2025-05-01 21:32:48 +08:00
Jose Olarte III
2bb71653ac New contact QR scan view for Capacitor version 2025-05-01 21:32:15 +08:00
Matthew Raymer
7baae7ea7a chore: lint fix 2025-05-01 09:47:20 +00:00
Matthew Raymer
cb1d979431 refactor(electron): improve build process and configuration
- Enhance electron build configuration with proper asset handling
- Add comprehensive logging and error tracking
- Implement CSP headers for security
- Fix module exports for logger compatibility
- Update TypeScript and Vite configs for better build support
- Improve development workflow with better dev tools integration
2025-05-01 09:30:02 +00:00
Matt Raymer
b999a04595 cursor(ADR): attempt to keep changes between the lines when making platform level changes 2025-04-30 02:45:30 -04:00
Matt Raymer
0f9826a39d refactor: replace console.log with logger utility
- Replace all console.log statements with appropriate logger methods in QRScannerDialog.vue
- Replace console.log statements with logger methods in WebDialogQRScanner.ts
- Fix TypeScript type for failsafeTimeout from 'any' to 'unknown'
- Update LogCollector.ts to use 'unknown' type instead of 'any'
- Add eslint-disable comments for console overrides in LogCollector

This change improves logging consistency across the application by using the centralized logger utility, which provides better error handling, log persistence, and environment-aware logging.
2025-04-29 23:22:10 -04:00
Jose Olarte III
8cc17bd09d iOS camera usage description 2025-04-30 09:47:41 +08:00
Matt Raymer
9dc9878472 fix(qr-scanner): robustly handle array/object detection results and guarantee dialog dismissal
- Update QRScannerDialog.vue to handle both array and object detection results in onDetect fallback logic (supports vue-qrcode-reader returning arrays).
- Ensure dialog closes and scan is processed for all detection result shapes.
- Use arrow function for close() to guarantee correct binding with vue-facing-decorator.
- Add enhanced logging for all dialog lifecycle and close/cleanup events.
- In WebDialogQRScanner, use direct mount result (not $refs) for dialogComponent to ensure correct instance.
- Add sessionId and improved logging for dialog open/close/cleanup lifecycle.
2025-04-29 06:10:12 -04:00
Jose Olarte III
22283e32f2 Merge branch 'qrcode-reboot' of ssh://173.199.124.46:222/trent_larson/crowd-funder-for-time-pwa into qrcode-reboot 2025-04-28 21:59:15 +08:00
Jose Olarte III
99863ec186 iOS Capacitor setup 2025-04-28 21:59:00 +08:00
Matthew Raymer
8d2dffb012 fix: lint 2025-04-28 12:31:56 +00:00
Matthew Raymer
538cbef701 feat(qr): improve camera error feedback and robustness in QR scanner
- Display prominent, actionable error banners in QR scanner dialog for camera access issues
- Add troubleshooting tips for common camera errors (no device, denied, in use, HTTPS)
- Enhance error handling and logging in WebDialogQRScanner for device detection and permissions
- Use proper type narrowing for promise handling in QRScannerDialog to resolve linter errors
- Improve user experience and clarity when camera access fails or is unavailable
2025-04-28 11:58:15 +00:00
Matthew Raymer
7b7940189e chore(qr): add unconditional debug panel and simplify onInit for event binding test
- Add always-visible debug panel to QRScannerDialog to confirm template updates
- Simplify onInit signature and add alert to verify @init event is firing
- Refine error handling in onInit for clarity during debugging
2025-04-28 10:18:15 +00:00
Matthew Raymer
35b038036a chore(qr): add visible debug output and version bump for device-side troubleshooting
- Bump version string in QRScannerDialog to include build number for cache-busting and verification
- Add debugMessage UI panel to display internal state and debug info directly in the dialog
- Add alert() and debugMessage updates at key points in QR scanner initialization for device-visible feedback
2025-04-28 09:07:27 +00:00
Matthew Raymer
b9cafbe269 debug: add an old-school alert 2025-04-28 08:49:16 +00:00
Matthew Raymer
559f52e6d6 fix(qr): add timeout fallback for QR scanner initialization
- Adds a 4-second timeout to force initialization complete if the QR scanner promise never resolves
- Prevents UI from being stuck on "Checking camera access..." when camera is active but init promise hangs
- Retains retry logic for transient initialization failures
2025-04-28 08:01:21 +00:00
Matthew Raymer
eb44b624d6 fix(qr): add retry logic to QR scanner initialization
- Retries QR scanner initialization up to 3 times if it fails, with a delay between attempts
- Improves user experience on slow or delayed camera hardware/browser permission responses
- Updates status message to reflect retry attempts
2025-04-28 07:25:25 +00:00
Matthew Raymer
6fdbc7f588 debug: comment out promise 2025-04-28 05:48:00 +00:00
Matthew Raymer
7e8caae69a Merge branch 'qrcode-reboot' of ssh://173.199.124.46:222/trent_larson/crowd-funder-for-time-pwa into qrcode-reboot 2025-04-28 04:35:07 +00:00
Matthew Raymer
7b29232b2c style: fix max-len warnings in QRScannerDialog.vue SVG paths
- Break long SVG path 'd' attributes into multiple lines for readability
- Ensure all lines comply with max-len linting rule
2025-04-28 04:22:34 +00:00
Matthew Raymer
e7cb5ffd33 docs: add Docker deployment instructions to BUILDING.md
- Add comprehensive Docker deployment section
- Include build and run instructions for development and production
- Add Docker Compose configuration example
- Include troubleshooting guide for common Docker issues
- Document best practices for production deployment
2025-04-26 10:42:32 +00:00
Matt Raymer
272f2a91a6 refactor(QRScanner): improve camera handling and UI feedback
- Add detailed camera status and initialization feedback\n- Implement proper error handling with specific error messages\n- Add camera switching functionality with visual indicator\n- Improve TypeScript types with DetectionResult interface\n- Fix duplicate onError method with consolidated error handling\n- Add version display (v1.1.0)\n- Enhance UI with better status indicators and debug info\n- Clean up code formatting and improve maintainability
2025-04-25 06:31:18 -04:00
Matt Raymer
f750ea5d10 feat(qr-scanner): Enhance QR scanner dialog with user feedback
- Add status messages for different scanning states (initializing, scanning, error)
- Add visual feedback with color-coded scanning frame and animations
- Add camera switch button for toggling between front/back cameras
- Add scanning instructions and tips in the footer
- Add retry button for error recovery
- Improve error handling and state management
- Add browser compatibility message for unsupported browsers

This change improves the user experience by providing clear visual feedback
and guidance during the QR code scanning process.
2025-04-25 04:55:36 -04:00
Matt Raymer
78116329d4 feat(qr-scanner): Add detailed logging for QR code scanning process
- Add browser capability detection logging (userAgent, mediaDevices, getUserMedia)
- Add detailed error logging with stack traces and error names
- Add new event handlers for detect and error events
- Add logging for key scanning events (init, detect, decode, close)
- Improve error handling with structured error objects

This change will help diagnose QR code registration issues by providing
more detailed information about the scanning process and any errors that occur.
2025-04-25 04:32:35 -04:00
Matt Raymer
2753e142cf feature: adding Dockerfile for online testing or deployment to docker 2025-04-25 04:20:20 -04:00
9a840ab74a ran lint-fix 2025-04-24 09:35:45 -06:00
Matthew Raymer
c6c49260ef fix: add HTTPS requirement check for camera access
- Check for secure context before attempting camera access
- Show clear user feedback when HTTPS is required
- Prevent confusing permission errors on insecure connections
2025-04-24 09:55:01 +00:00
Matthew Raymer
87438e7b6b fix: improve camera permission error feedback
- Add user-visible notification when camera access is denied
- Keep error message in UI for better visibility
- Pass notification timeout as second argument
2025-04-24 09:48:58 +00:00
Matthew Raymer
3ce2ea9b4e fix: standardize FontAwesome usage and improve error handling
- Change <fa> to <font-awesome> for consistent component naming
- Add structured error logging in QR scanner services
- Fix cacheImage event handling in ActivityListItem
- Improve code formatting and error wrapping
2025-04-24 09:34:01 +00:00
Matthew Raymer
8e6ba68560 fix: correct import paths and add host flag for dev server
- Update import path for GiveRecordWithContactInfo to use relative path
- Add --host flag to dev script for network access during development
2025-04-24 09:03:04 +00:00
Matthew Raymer
ca9ca5fca7 fix: prevent duplicate contacts during QR code scanning
- Add explicit duplicate contact check before database insertion
- Show warning notification when duplicate contact is detected
- Return early to prevent duplicate database entries
- Improve user feedback for duplicate contact scenarios

This change ensures users get clear feedback when scanning an already-added contact and prevents duplicate entries in the contacts database.
2025-04-24 08:42:44 +00:00
Matthew Raymer
4abb188da3 refactor(qr): improve QR scanner robustness and lifecycle management
- Add cleanup promise to prevent concurrent cleanup operations
- Add proper component lifecycle tracking with isMounted flag
- Add isCleaningUp flag to prevent operations during cleanup
- Add debug level logging for better diagnostics
- Add structured error logging with stack traces
- Add proper error handling in component initialization
- Add proper cleanup of event listeners and camera resources
- Add proper handling of app pause/resume events
- Add proper error boundaries around camera operations
- Improve error message formatting and consistency

The QR scanner now properly handles lifecycle events, cleans up resources,
and provides better error diagnostics. This improves reliability on mobile
devices and prevents potential memory leaks.
2025-04-22 11:26:27 +00:00
Matthew Raymer
30e448faf8 refactor(qr): improve QR code scanning robustness and error handling
- Enhance JWT extraction with unified path handling and validation
- Add debouncing to prevent duplicate scans
- Improve error handling and logging throughout QR flow
- Add proper TypeScript interfaces for QR scan results
- Implement mobile app lifecycle handlers (pause/resume)
- Enhance logging with structured data and consistent levels
- Clean up scanner resources properly on component destroy
- Split contact handling into separate method for better organization
- Add proper type for UserNameDialog ref

This commit improves the reliability and maintainability of the QR code
scanning functionality while adding better error handling and logging.
2025-04-22 11:04:56 +00:00
Matthew Raymer
a8812714a3 fix(qr): improve QR scanner implementation and error handling
- Implement robust QR scanner factory with platform detection
- Add proper camera permissions to Android manifest
- Improve error handling and logging across scanner implementations
- Add continuous scanning mode for Capacitor/MLKit scanner
- Enhance UI feedback during scanning process
- Fix build configuration for proper platform detection
- Clean up resources properly in scanner components
- Add TypeScript improvements and error wrapping

The changes include:
- Adding CAMERA permission to AndroidManifest.xml
- Setting proper build flags (__IS_MOBILE__, __USE_QR_READER__)
- Implementing continuous scanning mode for better UX
- Adding proper cleanup of scanner resources
- Improving error handling and type safety
- Enhancing UI with loading states and error messages
2025-04-22 10:00:37 +00:00
Matthew Raymer
2855d4b8d5 chore: cleanup and test 2025-04-22 07:48:04 +00:00
Matthew Raymer
b85e6d2958 Merge branch 'qrcode-reboot' of ssh://173.199.124.46:222/trent_larson/crowd-funder-for-time-pwa into qrcode-reboot 2025-04-22 07:06:13 +00:00
Matthew Raymer
7d260365be fix(deep-links): standardize DID parameter name and add route mapping docs
- Change DID schema parameter from 'id' to 'did' for consistency
- Add documentation for deep link route mapping functionality
2025-04-22 06:57:14 +00:00
Matthew Raymer
72de271f6c feat: Add MLKit barcode scanning plugin for Android
- Added @capacitor-mlkit/barcode-scanning@6.0.0 dependency
- Integrated MLKit plugin into Android project configuration
- Updated capacitor.settings.gradle to include MLKit module
- Added MLKit implementation to app dependencies

This change enables native QR code scanning capabilities on Android
devices using Google's MLKit barcode scanning technology.

Security:
- Uses official Google MLKit implementation
- Properly handles camera permissions
- Implements secure barcode scanning

BREAKING CHANGE: Requires Android API level 21+ for MLKit support
2025-04-21 13:05:01 +00:00
Matthew Raymer
2055097cf2 feature(qrcode): reboot qrcode reader 2025-04-21 10:13:12 +00:00
Matthew Raymer
6b38b1a347 test: increase timeout for record offer test to 60s
The record offer test occasionally hits the 45s timeout limit.
Increasing to 60s provides more headroom, aligning with actual
test durations seen in Firefox (up to 43.1s for similar operations).
2025-04-21 06:14:34 +00:00
ca455e9593 modify files to make the ios build & distribution work 2025-04-18 20:40:41 -06:00
5ada70b05e fix the reference to the secrets file 2025-04-15 21:24:03 -06:00
Matthew Raymer
4f9b146a66 fix: improve file sharing on Android using app-private storage
- Replace direct file writing with app-private storage + share dialog
- Add Share plugin for cross-platform file sharing
- Update file paths configuration for Android
- Fix permission issues by using Directory.Data instead of Documents
- Simplify file export flow in DataExportSection
2025-04-11 07:13:07 +00:00
Matthew Raymer
2b638ce2a7 chore: add an android build script to simplify creation of versions 2025-04-10 10:55:40 +00:00
Matthew Raymer
0b528af2a6 WIP: Fix Android file writing permissions and path handling
- Refactor writeFile method to properly handle Android Storage Access Framework (SAF) URIs
- Update path construction for Android to use Download directory with correct permissions
- Enhance error handling and logging throughout file operations
- Add detailed logging for debugging file system operations
- Fix permission checking logic to handle "File does not exist" case correctly
- Improve error messages and stack traces in logs
- Add timestamp to all log entries for better debugging
- Use proper directory types (ExternalStorage for Android, Documents for iOS)
- Add UTF-8 encoding specification for file writes

This is a work in progress as we're still seeing permission issues with Android file writing.
2025-04-09 13:05:42 +00:00
Matthew Raymer
008211bc21 feat(android): implement file picker for data export
- Add @capawesome/capacitor-file-picker dependency
- Update DataExportSection UI text to reflect new file picker behavior
- Implement file picker in CapacitorPlatformService
- Add debug logging for path handling
- Fix logger to show messages in Capacitor environment

WIP: File path handling still needs refinement
2025-04-09 10:04:03 +00:00
Matthew Raymer
6955a36458 chore: clean up lock file 2025-04-09 08:25:20 +00:00
Matthew Raymer
ba079ea983 chore: remove generated index.html from git repo 2025-04-09 07:53:01 +00:00
Matthew Raymer
d7b3c5ec9d fix: remove last "any" lint messages 2025-04-09 07:37:54 +00:00
Matthew Raymer
d83a25f47e chore: linted with auto-fix 2025-04-09 07:17:21 +00:00
Matthew Raymer
fb40dc0ff7 feat(android): update Capacitor assets and fix Xcode project version
- Generate new launcher icons for all densities (mdpi, hdpi, xhdpi, xxhdpi, xxxhdpi)
- Update both regular and round icon variants
- Fix Xcode project version numbers (0920 -> 920)
- Add missing file provider paths meta-data
2025-04-09 07:10:30 +00:00
Matthew Raymer
d03fa55001 refactor(platform): replace platform checks with capability-based system
- Add PlatformCapabilities interface to define available features
- Remove isWeb(), isCapacitor(), isElectron(), isPyWebView() methods
- Update platform services to implement getCapabilities()
- Refactor DataExportSection to use capability checks instead of platform checks
- Improve platform abstraction and separation of concerns
- Make platform-specific logic more maintainable and extensible

This change decouples components from specific platform implementations,
making the codebase more maintainable and easier to extend with new platforms.
2025-04-08 11:29:39 +00:00
Matthew Raymer
c8eff4d39e chore(deps): update React Native and Metro dependencies
- Update react-native from 0.78.2 to 0.79.0
- Update metro and related packages from 0.81.4 to 0.82.1
- Update @react-native/virtualized-lists to 0.79.0
- Update @react-native/normalize-colors to 0.79.0
- Update @react-native/codegen to 0.79.0
- Remove unused dependencies and their related code
- Update debug configuration to use v4.4.0 instead of v2.6.9
- Update sudo-prompt to 9.1.1
- Update synckit to 0.11.3
- Gradle updated from 8.9.0 to 8.9.1

This update improves build stability and removes deprecated dependencies.
2025-04-08 11:14:23 +00:00
Matthew Raymer
b8a7771edf feat(export): adapt DataExportSection for platform-specific file handling
Integrate PlatformServiceFactory to provide platform-specific data export:
- Add platform-specific file saving for Capacitor and other platforms
- Use web download mechanism only in web platform
- Conditionally show platform-specific save instructions
- Add iOS/Android detection for targeted guidance
- Update success messages based on platform context
- Improve download link visibility logic for web platform

This change ensures proper file handling across web, mobile, and desktop
platforms while maintaining a consistent user experience.
2025-04-07 08:50:09 +00:00
Matthew Raymer
5d845fb112 docs: add comprehensive JSDoc documentation to service layer
Add detailed TypeScript JSDoc documentation to core service modules:
- api.ts: Document error handling utilities and platform-specific logging
- plan.ts: Document plan/claim loading with retry mechanism
- deepLinks.ts: Document URL parsing and routing functionality
- Platform services:
  - CapacitorPlatformService: Document mobile platform capabilities
  - ElectronPlatformService: Document desktop placeholder implementation
  - PyWebViewPlatformService: Document Python bridge placeholder
  - WebPlatformService: Document web platform limitations and features

Key improvements:
- Add detailed @remarks sections explaining implementation details
- Include usage examples with TypeScript code snippets
- Document error handling and platform-specific behaviors
- Add @todo tags for unimplemented features
- Fix PlanResponse interface to include headers property

This documentation enhances code maintainability and developer experience
by providing clear guidance on service layer functionality and usage.
2025-04-07 07:49:39 +00:00
Matthew Raymer
660f2170de fix: improve error handling in photo upload
- Add proper unknown type for error handling in PhotoDialog
- Remove any type in favor of unknown for better type safety
- Fix error message access with type guards
2025-04-07 07:29:52 +00:00
Matthew Raymer
94bd649003 refactor: improve camera controls and modularize data export
- Add detailed error logging for image upload failures in PhotoDialog and SharedPhotoView
- Extract DataExportSection into standalone component with proper prop handling
- Fix Backup Identifier Seed visibility by passing activeDid prop
2025-04-07 07:17:43 +00:00
Matthew Raymer
b2d628cfeb chore: commit gitignore 2025-04-07 06:43:56 +00:00
Matthew Raymer
00e52f8dca feat: enhance error logging and upgrade Android build tools
- Add detailed error logging for image upload failures
- Upgrade Gradle to 8.11.1 and Android build tools to 8.9.0
- Add Capacitor camera and filesystem modules to Android build
2025-04-07 06:37:14 +00:00
Matthew Raymer
073ce24f43 chore(deps): Add Capacitor camera and filesystem plugins
- Add @capacitor/camera@6.0.0 for cross-platform photo capture
- Add @capacitor/filesystem@6.0.0 for file system operations
- Maintain compatibility with existing Capacitor core v6.2.1

These plugins enable native camera access and file system operations
for the Capacitor platform implementation.
2025-04-06 13:28:50 +00:00
Matthew Raymer
2c84bb50b3 **refactor(PhotoDialog, PlatformService): Implement cross-platform photo capture and encapsulated image processing**
- Replace direct camera library with platform-agnostic `PlatformService`
- Move platform-specific image processing logic to respective platform implementations
- Introduce `ImageResult` interface for consistent image handling across platforms
- Add support for native camera and image picker across all platforms
- Simplify `PhotoDialog` by removing platform-specific logic
- Maintain existing cropping and upload functionality
- Improve error handling and logging throughout
- Clean up UI for better user experience
- Add comprehensive documentation for usage and architecture

**BREAKING CHANGE:** Removes direct camera library dependency in favor of `PlatformService`

This change improves separation of concerns, enhances maintainability, and standardizes cross-platform image handling.
2025-04-06 13:04:26 +00:00
Matthew Raymer
abf18835f6 feat: update TypeScript config for platform services
- Add useDefineForClassFields for class field initialization
- Remove test-playwright from includes
- Add tsconfig.node.json reference
- Remove redundant node_modules exclude
2025-04-06 06:58:25 +00:00
Matthew Raymer
f72562804d feat: update TypeScript config for platform services
- Add useDefineForClassFields for class field initialization
- Remove test-playwright from includes
- Add tsconfig.node.json reference
- Remove redundant node_modules exclude
2025-04-06 06:58:14 +00:00
Matthew Raymer
bdc5ffafc1 baseline for this branch 2025-04-06 05:41:12 +00:00
181 changed files with 20316 additions and 5972 deletions

172
.cursor/rules/SQLITE.mdc Normal file
View File

@@ -0,0 +1,172 @@
---
description:
globs:
alwaysApply: true
---
# @capacitor-community/sqlite MDC Ruleset
## Project Overview
This ruleset is for the `@capacitor-community/sqlite` plugin, a Capacitor community plugin that provides native and Electron SQLite database functionality with encryption support.
## Key Features
- Native SQLite database support for iOS, Android, and Electron
- Database encryption support using SQLCipher (Native) and better-sqlite3-multiple-ciphers (Electron)
- Biometric authentication support
- Cross-platform database operations
- JSON import/export capabilities
- Database migration support
- Sync table functionality
## Platform Support Matrix
### Core Database Operations
| Operation | Android | iOS | Electron | Web |
|-----------|---------|-----|----------|-----|
| Create Connection (RW) | ✅ | ✅ | ✅ | ✅ |
| Create Connection (RO) | ✅ | ✅ | ✅ | ❌ |
| Open DB (non-encrypted) | ✅ | ✅ | ✅ | ✅ |
| Open DB (encrypted) | ✅ | ✅ | ✅ | ❌ |
| Execute/Query | ✅ | ✅ | ✅ | ✅ |
| Import/Export JSON | ✅ | ✅ | ✅ | ✅ |
### Security Features
| Feature | Android | iOS | Electron | Web |
|---------|---------|-----|----------|-----|
| Encryption | ✅ | ✅ | ✅ | ❌ |
| Biometric Auth | ✅ | ✅ | ✅ | ❌ |
| Secret Management | ✅ | ✅ | ✅ | ❌ |
## Configuration Requirements
### Base Configuration
```typescript
// capacitor.config.ts
{
plugins: {
CapacitorSQLite: {
iosDatabaseLocation: 'Library/CapacitorDatabase',
iosIsEncryption: true,
iosKeychainPrefix: 'your-app-prefix',
androidIsEncryption: true,
electronIsEncryption: true
}
}
}
```
### Platform-Specific Requirements
#### Android
- Minimum SDK: 23
- Target SDK: 35
- Required Gradle JDK: 21
- Required Android Gradle Plugin: 8.7.2
- Required manifest settings for backup prevention
- Required data extraction rules
#### iOS
- No additional configuration needed beyond base setup
- Supports biometric authentication
- Uses keychain for encryption
#### Electron
Required dependencies:
```json
{
"dependencies": {
"better-sqlite3-multiple-ciphers": "latest",
"electron-json-storage": "latest",
"jszip": "latest",
"node-fetch": "2.6.7",
"crypto": "latest",
"crypto-js": "latest"
}
}
```
#### Web
- Requires `sql.js` and `jeep-sqlite`
- Manual copy of `sql-wasm.wasm` to assets folder
- Framework-specific asset placement:
- Angular: `src/assets/`
- Vue/React: `public/assets/`
## Best Practices
### Database Operations
1. Always close connections after use
2. Use transactions for multiple operations
3. Implement proper error handling
4. Use prepared statements for queries
5. Implement proper database versioning
### Security
1. Always use encryption for sensitive data
2. Implement proper secret management
3. Use biometric authentication when available
4. Follow platform-specific security guidelines
### Performance
1. Use appropriate indexes
2. Implement connection pooling
3. Use transactions for bulk operations
4. Implement proper database cleanup
## Common Issues and Solutions
### Android
- Build data properties conflict: Add to `app/build.gradle`:
```gradle
packagingOptions {
exclude 'build-data.properties'
}
```
### Electron
- Node-fetch version must be ≤2.6.7
- For Capacitor Electron v5:
- Use Electron@25.8.4
- Add `"skipLibCheck": true` to tsconfig.json
### Web
- Ensure proper WASM file placement
- Handle browser compatibility
- Implement proper fallbacks
## Version Compatibility
- Requires Node.js ≥16.0.0
- Compatible with Capacitor ≥7.0.0
- Supports TypeScript 4.1.5+
## Testing Requirements
- Unit tests for database operations
- Platform-specific integration tests
- Encryption/decryption tests
- Biometric authentication tests
- Migration tests
- Sync functionality tests
## Documentation
- API Documentation: `/docs/API.md`
- Connection API: `/docs/APIConnection.md`
- DB Connection API: `/docs/APIDBConnection.md`
- Release Notes: `/docs/info_releases.md`
- Changelog: `CHANGELOG.md`
## Contributing Guidelines
- Follow Ionic coding standards
- Use provided linting and formatting tools
- Maintain platform compatibility
- Update documentation
- Add appropriate tests
- Follow semantic versioning
## Maintenance
- Regular security updates
- Platform compatibility checks
- Performance optimization
- Documentation updates
- Dependency updates
## License
MIT License - See LICENSE file for details

View File

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

View File

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

View File

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

View File

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

267
.cursor/rules/wa-sqlite.mdc Normal file
View File

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

View File

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

5
.gitignore vendored
View File

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

View File

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

36
Dockerfile Normal file
View File

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

View File

@@ -12,6 +12,8 @@ See [project.task.yaml](project.task.yaml) for current priorities.
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
npm install
npm run dev
@@ -31,7 +33,9 @@ See [TESTING.md](test-playwright/TESTING.md) for detailed test instructions.
## Icons
To add an icon, add to main.ts and reference with `fa` element and `icon` attribute with the hyphenated name.
Application icons are in the `assets` directory, processed by the `capacitor-assets` command.
To add a Font Awesome icon, add to main.ts and reference with `font-awesome` element and `icon` attribute with the hyphenated name.
## Other
@@ -44,6 +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/",`
### Code Organization
The project uses a centralized approach to type definitions and interfaces:
* `src/interfaces/` - Contains all TypeScript interfaces and type definitions
* `deepLinks.ts` - Deep linking type system and Zod validation schemas
* `give.ts` - Give-related interfaces and type definitions
* `claims.ts` - Claim-related interfaces and verifiable credentials
* `common.ts` - Shared interfaces and utility types
* Other domain-specific interface files
Key principles:
- All interfaces and types are defined in the interfaces folder
- Zod schemas are used for runtime validation and type generation
- Domain-specific interfaces are separated into their own files
- Common interfaces are shared through `common.ts`
- Type definitions are generated from Zod schemas where possible
### Kudos
Gifts make the world go 'round!

84
TASK_storage.md Normal file
View File

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

20
android/.gitignore vendored
View File

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

View File

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

Binary file not shown.

View File

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

View File

@@ -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 (!project.ext.MY_KEYSTORE_FILE) {
def secretsPropertiesFile = rootProject.file("gradle.properties.secrets")
def secretsPropertiesFile = rootProject.file("app/gradle.properties.secrets")
if (secretsPropertiesFile.exists()) {
Properties secretsProperties = new Properties()
secretsProperties.load(new FileInputStream(secretsPropertiesFile))
@@ -31,8 +31,8 @@ android {
applicationId "app.timesafari.app"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 9
versionName "0.4.4"
versionCode 18
versionName "0.4.7"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,26 @@
[
{
"pkg": "@capacitor-mlkit/barcode-scanning",
"classpath": "io.capawesome.capacitorjs.plugins.mlkit.barcodescanning.BarcodeScannerPlugin"
},
{
"pkg": "@capacitor/app",
"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"
}
]

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.4 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.5 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.2 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 23 KiB

View File

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

View File

@@ -7,7 +7,7 @@ buildscript {
mavenCentral()
}
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'
// NOTE: Do not place your application dependencies here; they belong

View File

@@ -2,5 +2,20 @@
include ':capacitor-android'
project(':capacitor-android').projectDir = new File('../node_modules/@capacitor/android/capacitor')
include ':capacitor-mlkit-barcode-scanning'
project(':capacitor-mlkit-barcode-scanning').projectDir = new File('../node_modules/@capacitor-mlkit/barcode-scanning/android')
include ':capacitor-app'
project(':capacitor-app').projectDir = new File('../node_modules/@capacitor/app/android')
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')

View File

@@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME
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
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME

2
assets/README.md Normal file
View File

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

4
build.sh Executable file
View File

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

21
capacitor.config.json Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,284 @@
# Secure Storage Implementation Guide for TimeSafari App
## Overview
This document outlines the implementation of secure storage for the TimeSafari app using a platform-agnostic approach with Capacitor and absurd-sql solutions. The implementation focuses on:
1. **Platform-Specific Storage Solutions**:
- Web: absurd-sql with IndexedDB backend and Web Worker support
- iOS/Android: Capacitor SQLite with native SQLite implementation
- Electron: Node SQLite (planned, not implemented)
2. **Key Features**:
- Platform-agnostic SQLite interface
- Web Worker support for web platform
- Consistent API across platforms
- Performance optimizations (WAL, mmap)
- Comprehensive error handling and logging
- Type-safe database operations
- Storage quota management
- Platform-specific security features
## Architecture
The storage implementation follows a layered architecture:
1. **Platform Service Layer**
- `PlatformService` interface defines platform capabilities
- Platform-specific implementations:
- `WebPlatformService`: Web platform with absurd-sql
- `CapacitorPlatformService`: Mobile platforms with native SQLite
- `ElectronPlatformService`: Desktop platform (planned)
- Platform detection and capability reporting
- Storage quota and feature detection
2. **SQLite Service Layer**
- `SQLiteOperations` interface for database operations
- Base implementation in `BaseSQLiteService`
- Platform-specific implementations:
- `AbsurdSQLService`: Web platform with Web Worker
- `CapacitorSQLiteService`: Mobile platforms with native SQLite
- `ElectronSQLiteService`: Desktop platform (planned)
- Common features:
- Transaction support
- Prepared statements
- Performance monitoring
- Error handling
- Database statistics
3. **Data Access Layer**
- Type-safe database operations
- Transaction support
- Prepared statements
- Performance monitoring
- Error recovery
- Data integrity verification
## Implementation Details
### Web Platform (absurd-sql)
The web implementation uses absurd-sql with the following features:
1. **Web Worker Support**
- SQLite operations run in a dedicated worker thread
- Main thread remains responsive
- SharedArrayBuffer support when available
- Worker initialization in `sqlite.worker.ts`
2. **IndexedDB Backend**
- Persistent storage using IndexedDB
- Automatic data synchronization
- Storage quota management (1GB limit)
- Virtual file system configuration
3. **Performance Optimizations**
- WAL mode for better concurrency
- Memory-mapped I/O (30GB when available)
- Prepared statement caching
- 2MB cache size
- Configurable performance settings
Example configuration:
```typescript
const webConfig: SQLiteConfig = {
name: 'timesafari',
useWAL: true,
useMMap: typeof SharedArrayBuffer !== 'undefined',
mmapSize: 30000000000,
usePreparedStatements: true,
maxPreparedStatements: 100
};
```
### Mobile Platform (Capacitor SQLite)
The mobile implementation uses Capacitor SQLite with:
1. **Native SQLite**
- Direct access to platform SQLite
- Native performance
- Platform-specific optimizations
- 2GB storage limit
2. **Platform Integration**
- iOS: Native SQLite with WAL support
- Android: Native SQLite with WAL support
- Platform-specific permissions handling
- Storage quota management
Example configuration:
```typescript
const mobileConfig: SQLiteConfig = {
name: 'timesafari',
useWAL: true,
useMMap: false, // Not supported on mobile
usePreparedStatements: true
};
```
## Database Schema
The implementation uses the following schema:
```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)
);
-- Performance indexes
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);
```
## Error Handling
The implementation includes comprehensive error handling:
1. **Error Types**
```typescript
export enum StorageErrorCodes {
INITIALIZATION_FAILED = 'STORAGE_INIT_FAILED',
QUERY_FAILED = 'STORAGE_QUERY_FAILED',
TRANSACTION_FAILED = 'STORAGE_TRANSACTION_FAILED',
PREPARED_STATEMENT_FAILED = 'STORAGE_PREPARED_STATEMENT_FAILED',
DATABASE_CORRUPTED = 'STORAGE_DB_CORRUPTED',
STORAGE_FULL = 'STORAGE_FULL',
CONCURRENT_ACCESS = 'STORAGE_CONCURRENT_ACCESS'
}
```
2. **Error Recovery**
- Automatic transaction rollback
- Connection recovery
- Data integrity verification
- Platform-specific error handling
- Comprehensive logging
## Performance Monitoring
The implementation includes built-in performance monitoring:
1. **Statistics**
```typescript
interface SQLiteStats {
totalQueries: number;
avgExecutionTime: number;
preparedStatements: number;
databaseSize: number;
walMode: boolean;
mmapActive: boolean;
}
```
2. **Monitoring Features**
- Query execution time tracking
- Database size monitoring
- Prepared statement usage
- WAL and mmap status
- Platform-specific metrics
## Security Considerations
1. **Web Platform**
- Worker thread isolation
- Storage quota monitoring
- Origin isolation
- Cross-origin protection
- SharedArrayBuffer availability check
2. **Mobile Platform**
- Platform-specific permissions
- Storage access control
- File system security
- Platform sandboxing
## Testing Strategy
1. **Unit Tests**
- Platform service tests
- SQLite service tests
- Error handling tests
- Performance tests
2. **Integration Tests**
- Cross-platform tests
- Migration tests
- Transaction tests
- Concurrency tests
3. **E2E Tests**
- Platform-specific workflows
- Error recovery scenarios
- Performance benchmarks
- Data integrity verification
## Success Criteria
1. **Performance**
- Query response time < 100ms
- Transaction completion < 500ms
- Memory usage < 50MB
- Database size < platform limits:
- Web: 1GB
- Mobile: 2GB
2. **Reliability**
- 99.9% uptime
- Zero data loss
- Automatic recovery
- Transaction atomicity
3. **Security**
- Platform-specific security features
- Storage access control
- Data protection
- Audit logging
4. **User Experience**
- Smooth platform transitions
- Clear error messages
- Progress indicators
- Recovery options
## Future Improvements
1. **Planned Features**
- SQLCipher integration for mobile
- Electron platform support
- Advanced backup/restore
- Cross-platform sync
2. **Security Enhancements**
- Biometric authentication
- Secure enclave usage
- Advanced encryption
- Key management
3. **Performance Optimizations**
- Advanced caching
- Query optimization
- Memory management
- Storage efficiency

View File

@@ -0,0 +1,759 @@
# Storage Implementation Checklist
## Core Services
### 1. Platform Service Layer
- [x] Create base `PlatformService` interface
- [x] Define platform capabilities
- [x] File system access detection
- [x] Camera availability
- [x] Mobile platform detection
- [x] iOS specific detection
- [x] File download capability
- [x] SQLite capabilities
- [x] Add SQLite operations interface
- [x] Database initialization
- [x] Query execution
- [x] Transaction management
- [x] Prepared statements
- [x] Database statistics
- [x] Include platform detection
- [x] Web platform detection
- [x] Mobile platform detection
- [x] Desktop platform detection
- [x] Add file system operations
- [x] File read operations
- [x] File write operations
- [x] File delete operations
- [x] Directory listing
- [x] Implement platform-specific services
- [x] `WebPlatformService`
- [x] AbsurdSQL integration
- [x] SQL.js initialization
- [x] IndexedDB backend setup
- [x] Virtual file system configuration
- [x] Web Worker support
- [x] Worker thread initialization
- [x] Message passing
- [x] Error handling
- [x] IndexedDB backend
- [x] Database creation
- [x] Transaction handling
- [x] Storage quota management (1GB limit)
- [x] SharedArrayBuffer detection
- [x] Feature detection
- [x] Fallback handling
- [x] File system operations (intentionally not supported)
- [x] File read operations (not available in web)
- [x] File write operations (not available in web)
- [x] File delete operations (not available in web)
- [x] Directory operations (not available in web)
- [x] Settings implementation
- [x] AbsurdSQL settings operations
- [x] Worker-based settings updates
- [x] IndexedDB transaction handling
- [x] SharedArrayBuffer support
- [x] Web-specific settings features
- [x] Storage quota management
- [x] Worker thread isolation
- [x] Cross-origin settings
- [x] Web performance optimizations
- [x] Settings caching
- [x] Batch updates
- [x] Worker message optimization
- [x] Account implementation
- [x] Web-specific account handling
- [x] Browser storage persistence
- [x] Session management
- [x] Cross-tab synchronization
- [x] Web security features
- [x] Origin isolation
- [x] Worker thread security
- [x] Storage access control
- [x] `CapacitorPlatformService`
- [x] Native SQLite integration
- [x] Database connection
- [x] Query execution
- [x] Transaction handling
- [x] Platform capabilities
- [x] iOS detection
- [x] Android detection
- [x] Feature availability
- [x] File system operations
- [x] File read/write
- [x] Directory operations
- [x] Storage permissions
- [x] iOS permissions
- [x] Android permissions
- [x] Permission request handling
- [x] Settings implementation
- [x] Native SQLite settings operations
- [x] Platform-specific SQLite optimizations
- [x] Native transaction handling
- [x] Platform storage management
- [x] Mobile-specific settings features
- [x] Platform preferences sync
- [x] Background state handling
- [x] Mobile performance optimizations
- [x] Native caching
- [x] Battery-efficient updates
- [x] Memory management
- [x] Account implementation
- [x] Mobile-specific account handling
- [x] Platform storage integration
- [x] Background state handling
- [x] Mobile security features
- [x] Platform sandboxing
- [x] Storage access control
- [x] App sandboxing
- [ ] `ElectronPlatformService` (planned)
- [ ] Node SQLite integration
- [ ] Database connection
- [ ] Query execution
- [ ] Transaction handling
- [ ] File system access
- [ ] File read operations
- [ ] File write operations
- [ ] File delete operations
- [ ] Directory operations
- [ ] IPC communication
- [ ] Main process communication
- [ ] Renderer process handling
- [ ] Message passing
- [ ] Native features implementation
- [ ] System dialogs
- [ ] Native menus
- [ ] System integration
- [ ] Settings implementation
- [ ] Node SQLite settings operations
- [ ] Main process SQLite handling
- [ ] IPC-based updates
- [ ] File system persistence
- [ ] Desktop-specific settings features
- [ ] System preferences integration
- [ ] Multi-window sync
- [ ] Offline state handling
- [ ] Desktop performance optimizations
- [ ] Process-based caching
- [ ] Window state management
- [ ] Resource optimization
- [ ] Account implementation
- [ ] Desktop-specific account handling
- [ ] System keychain integration
- [ ] Native authentication
- [ ] Process isolation
- [ ] Desktop security features
- [ ] Process sandboxing
- [ ] IPC security
- [ ] File system protection
### 2. SQLite Service Layer
- [x] Create base `BaseSQLiteService`
- [x] Common SQLite operations
- [x] Query execution
- [x] Transaction management
- [x] Prepared statements
- [x] Database statistics
- [x] Performance monitoring
- [x] Query timing
- [x] Memory usage
- [x] Database size
- [x] Statement caching
- [x] Error handling
- [x] Connection errors
- [x] Query errors
- [x] Transaction errors
- [x] Resource errors
- [x] Transaction support
- [x] Begin transaction
- [x] Commit transaction
- [x] Rollback transaction
- [x] Nested transactions
- [x] Implement platform-specific SQLite services
- [x] `AbsurdSQLService`
- [x] Web Worker initialization
- [x] Worker creation
- [x] Message handling
- [x] Error propagation
- [x] IndexedDB backend setup
- [x] Database creation
- [x] Transaction handling
- [x] Storage management
- [x] Prepared statements
- [x] Statement preparation
- [x] Parameter binding
- [x] Statement caching
- [x] Performance optimizations
- [x] WAL mode
- [x] Memory mapping
- [x] Cache configuration
- [x] WAL mode support
- [x] Journal mode configuration
- [x] Synchronization settings
- [x] Checkpoint handling
- [x] Memory-mapped I/O
- [x] MMAP size configuration (30GB)
- [x] Memory management
- [x] Performance monitoring
- [x] `CapacitorSQLiteService`
- [x] Native SQLite connection
- [x] Database initialization
- [x] Connection management
- [x] Error handling
- [x] Basic platform features
- [x] Query execution
- [x] Transaction handling
- [x] Statement management
- [x] Error handling
- [x] Connection errors
- [x] Query errors
- [x] Resource errors
- [x] WAL mode support
- [x] Journal mode
- [x] Synchronization
- [x] Checkpointing
- [ ] SQLCipher integration (planned)
- [ ] Encryption setup
- [ ] Key management
- [ ] Secure storage
- [ ] `ElectronSQLiteService` (planned)
- [ ] Node SQLite integration
- [ ] Database connection
- [ ] Query execution
- [ ] Transaction handling
- [ ] IPC communication
- [ ] Process communication
- [ ] Error handling
- [ ] Resource management
- [ ] File system access
- [ ] Native file operations
- [ ] Path handling
- [ ] Permissions
- [ ] Native features
- [ ] System integration
- [ ] Native dialogs
- [ ] Process management
### 3. Security Layer
- [x] Implement platform-specific security
- [x] Web platform
- [x] Worker isolation
- [x] Thread separation
- [x] Message security
- [x] Resource isolation
- [x] Storage quota management
- [x] Quota detection
- [x] Usage monitoring
- [x] Error handling
- [x] Origin isolation
- [x] Cross-origin protection
- [x] Resource isolation
- [x] Security policy
- [x] Storage security
- [x] Access control
- [x] Data protection
- [x] Quota management
- [x] Mobile platform
- [x] Platform permissions
- [x] Storage access
- [x] File operations
- [x] System integration
- [x] Platform security
- [x] App sandboxing
- [x] Storage protection
- [x] Access control
- [ ] SQLCipher integration (planned)
- [ ] Encryption setup
- [ ] Key management
- [ ] Secure storage
- [ ] Electron platform (planned)
- [ ] IPC security
- [ ] Message validation
- [ ] Process isolation
- [ ] Resource protection
- [ ] File system security
- [ ] Access control
- [ ] Path validation
- [ ] Permission management
- [ ] Auto-update security
- [ ] Update verification
- [ ] Code signing
- [ ] Rollback protection
- [ ] Native security features
- [ ] System integration
- [ ] Security policies
- [ ] Resource protection
## 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 Web Worker
- [x] Worker initialization
- [x] Message handling
- [x] Error propagation
- [x] Setup IndexedDB backend
- [x] Database creation
- [x] Transaction handling
- [x] Storage management
- [x] Configure database pragmas
```sql
PRAGMA journal_mode = WAL;
PRAGMA synchronous = NORMAL;
PRAGMA temp_store = MEMORY;
PRAGMA cache_size = -2000;
PRAGMA mmap_size = 30000000000;
```
- [x] Update build configuration
- [x] Configure worker bundling
- [x] Worker file handling
- [x] Asset management
- [x] Source maps
- [x] Setup asset handling
- [x] SQL.js WASM
- [x] Worker scripts
- [x] Static assets
- [x] Configure chunk splitting
- [x] Code splitting
- [x] Dynamic imports
- [x] Asset optimization
- [x] Implement fallback mechanisms
- [x] SharedArrayBuffer detection
- [x] Feature detection
- [x] Fallback handling
- [x] Error reporting
- [x] Storage quota monitoring
- [x] Quota detection
- [x] Usage tracking
- [x] Error handling
- [x] Worker initialization fallback
- [x] Fallback detection
- [x] Alternative initialization
- [x] Error recovery
- [x] Error recovery
- [x] Connection recovery
- [x] Transaction rollback
- [x] State restoration
### Mobile Platform
- [x] Setup Capacitor SQLite
- [x] Install dependencies
- [x] Core SQLite plugin
- [x] Platform plugins
- [x] Native dependencies
- [x] Configure native SQLite
- [x] Database initialization
- [x] Connection management
- [x] Query handling
- [x] Configure basic permissions
- [x] Storage access
- [x] File operations
- [x] System integration
- [x] Update Capacitor config
- [x] Add basic platform permissions
- [x] iOS permissions
- [x] Android permissions
- [x] Feature flags
- [x] Configure storage limits
- [x] iOS storage limits
- [x] Android storage limits
- [x] Quota management
- [x] Setup platform security
- [x] App sandboxing
- [x] Storage protection
- [x] Access control
### Electron Platform (planned)
- [ ] Setup Node SQLite
- [ ] Install dependencies
- [ ] SQLite3 module
- [ ] Native bindings
- [ ] Development tools
- [ ] Configure IPC
- [ ] Main process setup
- [ ] Renderer process handling
- [ ] Message passing
- [ ] Setup file system access
- [ ] Native file operations
- [ ] Path handling
- [ ] Permission management
- [ ] Implement secure storage
- [ ] Encryption setup
- [ ] Key management
- [ ] Secure containers
- [ ] Update Electron config
- [ ] Add security policies
- [ ] CSP configuration
- [ ] Process isolation
- [ ] Resource protection
- [ ] Configure file access
- [ ] Access control
- [ ] Path validation
- [ ] Permission management
- [ ] Setup auto-updates
- [ ] Update server
- [ ] Code signing
- [ ] Rollback protection
- [ ] Configure IPC security
- [ ] Message validation
- [ ] Process isolation
- [ ] Resource protection
## 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);
```
### 2. Type Definitions
- [x] Create interfaces
```typescript
interface PlatformCapabilities {
hasFileSystem: boolean;
hasCamera: boolean;
isMobile: boolean;
isIOS: boolean;
hasFileDownload: boolean;
needsFileHandlingInstructions: boolean;
sqlite: {
supported: boolean;
runsInWorker: boolean;
hasSharedArrayBuffer: boolean;
supportsWAL: boolean;
maxSize?: number;
};
}
interface SQLiteConfig {
name: string;
useWAL?: boolean;
useMMap?: boolean;
mmapSize?: number;
usePreparedStatements?: boolean;
maxPreparedStatements?: number;
}
interface SQLiteStats {
totalQueries: number;
avgExecutionTime: number;
preparedStatements: number;
databaseSize: number;
walMode: boolean;
mmapActive: boolean;
}
```
## Testing
### 1. Unit Tests
- [x] Test platform services
- [x] Platform detection
- [x] Web platform
- [x] Mobile platform
- [x] Desktop platform
- [x] Capability reporting
- [x] Feature detection
- [x] Platform specifics
- [x] Error cases
- [x] Basic SQLite operations
- [x] Query execution
- [x] Transaction handling
- [x] Error cases
- [x] Basic error handling
- [x] Connection errors
- [x] Query errors
- [x] Resource errors
### 2. Integration Tests
- [x] Test SQLite services
- [x] Web platform tests
- [x] Worker integration
- [x] IndexedDB backend
- [x] Performance tests
- [x] Basic mobile platform tests
- [x] Native SQLite
- [x] Platform features
- [x] Error handling
- [ ] Electron platform tests (planned)
- [ ] Node SQLite
- [ ] IPC communication
- [ ] File system
- [x] Cross-platform tests
- [x] Feature parity
- [x] Data consistency
- [x] Performance comparison
### 3. E2E Tests
- [x] Test workflows
- [x] Basic database operations
- [x] CRUD operations
- [x] Transaction handling
- [x] Error recovery
- [x] Platform transitions
- [x] Web to mobile
- [x] Mobile to web
- [x] State preservation
- [x] Basic error recovery
- [x] Connection loss
- [x] Transaction failure
- [x] Resource errors
- [x] Performance benchmarks
- [x] Query performance
- [x] Transaction speed
- [x] Memory usage
- [x] Storage efficiency
## Documentation
### 1. Technical Documentation
- [x] Update architecture docs
- [x] System overview
- [x] Component interaction
- [x] Platform specifics
- [x] Add basic API documentation
- [x] Interface definitions
- [x] Method signatures
- [x] Usage examples
- [x] Document platform capabilities
- [x] Feature matrix
- [x] Platform support
- [x] Limitations
- [x] Document security measures
- [x] Platform security
- [x] Access control
- [x] Security policies
### 2. User Documentation
- [x] Update basic user guides
- [x] Installation
- [x] Configuration
- [x] Basic usage
- [x] Add basic troubleshooting guides
- [x] Common issues
- [x] Error messages
- [x] Recovery steps
- [x] Document implemented platform features
- [x] Web platform
- [x] Mobile platform
- [x] Desktop platform
- [x] Add basic performance tips
- [x] Optimization techniques
- [x] Best practices
- [x] Platform specifics
## Monitoring and Analytics
### 1. Performance Monitoring
- [x] Basic query execution time
- [x] Query timing
- [x] Transaction timing
- [x] Statement timing
- [x] Database size monitoring
- [x] Size tracking
- [x] Growth patterns
- [x] Quota management
- [x] Basic memory usage
- [x] Heap usage
- [x] Cache usage
- [x] Worker memory
- [x] Worker performance
- [x] Message timing
- [x] Processing time
- [x] Resource usage
### 2. Error Tracking
- [x] Basic error logging
- [x] Error capture
- [x] Stack traces
- [x] Context data
- [x] Basic performance monitoring
- [x] Query metrics
- [x] Resource usage
- [x] Timing data
- [x] Platform-specific errors
- [x] Web platform
- [x] Mobile platform
- [x] Desktop platform
- [x] Basic recovery tracking
- [x] Recovery success
- [x] Failure patterns
- [x] User impact
## Security Audit
### 1. Code Review
- [x] Review platform services
- [x] Interface security
- [x] Data handling
- [x] Error management
- [x] Check basic SQLite implementations
- [x] Query security
- [x] Transaction safety
- [x] Resource management
- [x] Verify basic error handling
- [x] Error propagation
- [x] Recovery procedures
- [x] User feedback
- [x] Complete dependency audit
- [x] Security vulnerabilities
- [x] License compliance
- [x] Update requirements
### 2. Platform Security
- [x] Web platform
- [x] Worker isolation
- [x] Thread separation
- [x] Message security
- [x] Resource isolation
- [x] Basic storage security
- [x] Access control
- [x] Data protection
- [x] Quota management
- [x] Origin isolation
- [x] Cross-origin protection
- [x] Resource isolation
- [x] Security policy
- [x] Mobile platform
- [x] Platform permissions
- [x] Storage access
- [x] File operations
- [x] System integration
- [x] Platform security
- [x] App sandboxing
- [x] Storage protection
- [x] Access control
- [ ] SQLCipher integration (planned)
- [ ] Encryption setup
- [ ] Key management
- [ ] Secure storage
- [ ] Electron platform (planned)
- [ ] IPC security
- [ ] Message validation
- [ ] Process isolation
- [ ] Resource protection
- [ ] File system security
- [ ] Access control
- [ ] Path validation
- [ ] Permission management
- [ ] Auto-update security
- [ ] Update verification
- [ ] Code signing
- [ ] Rollback protection
## Success Criteria
### 1. Performance
- [x] Basic query response time < 100ms
- [x] Simple queries
- [x] Indexed queries
- [x] Prepared statements
- [x] Basic transaction completion < 500ms
- [x] Single operations
- [x] Batch operations
- [x] Complex transactions
- [x] Basic memory usage < 50MB
- [x] Normal operation
- [x] Peak usage
- [x] Background state
- [x] Database size < platform limits
- [x] Web platform (1GB)
- [x] Mobile platform (2GB)
- [ ] Desktop platform (10GB, planned)
### 2. Reliability
- [x] Basic uptime
- [x] Service availability
- [x] Connection stability
- [x] Error recovery
- [x] Basic data integrity
- [x] Transaction atomicity
- [x] Data consistency
- [x] Error handling
- [x] Basic recovery
- [x] Connection recovery
- [x] Transaction rollback
- [x] State restoration
- [x] Basic transaction atomicity
- [x] Commit success
- [x] Rollback handling
- [x] Error recovery
### 3. Security
- [x] Platform-specific security
- [x] Web platform security
- [x] Mobile platform security
- [ ] Desktop platform security (planned)
- [x] Basic access control
- [x] User permissions
- [x] Resource access
- [x] Operation limits
- [x] Basic audit logging
- [x] Access logs
- [x] Operation logs
- [x] Security events
- [ ] Advanced security features (planned)
- [ ] SQLCipher encryption
- [ ] Biometric authentication
- [ ] Secure enclave
- [ ] Key management
### 4. User Experience
- [x] Basic platform transitions
- [x] Web to mobile
- [x] Mobile to web
- [x] State preservation
- [x] Basic error messages
- [x] User feedback
- [x] Recovery guidance
- [x] Error context
- [x] Basic progress indicators
- [x] Operation status
- [x] Loading states
- [x] Completion feedback
- [x] Basic recovery options
- [x] Automatic recovery
- [x] Manual intervention
- [x] Data restoration

View File

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

10
ios/.gitignore vendored
View File

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

View File

@@ -3,7 +3,7 @@
archiveVersion = 1;
classes = {
};
objectVersion = 48;
objectVersion = 54;
objects = {
/* Begin PBXBuildFile section */
@@ -20,7 +20,7 @@
/* Begin PBXFileReference section */
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>"; };
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>"; };
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>"; };
@@ -65,7 +65,7 @@
504EC3051FED79650016851F /* Products */ = {
isa = PBXGroup;
children = (
504EC3041FED79650016851F /* Time Safari.app */,
504EC3041FED79650016851F /* App.app */,
);
name = Products;
sourceTree = "<group>";
@@ -97,23 +97,24 @@
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
504EC3031FED79650016851F /* Time Safari */ = {
504EC3031FED79650016851F /* App */ = {
isa = PBXNativeTarget;
buildConfigurationList = 504EC3161FED79650016851F /* Build configuration list for PBXNativeTarget "Time Safari" */;
buildConfigurationList = 504EC3161FED79650016851F /* Build configuration list for PBXNativeTarget "App" */;
buildPhases = (
6634F4EFEBD30273BCE97C65 /* [CP] Check Pods Manifest.lock */,
504EC3001FED79650016851F /* Sources */,
504EC3011FED79650016851F /* Frameworks */,
504EC3021FED79650016851F /* Resources */,
9592DBEFFC6D2A0C8D5DEB22 /* [CP] Embed Pods Frameworks */,
012076E8FFE4BF260A79B034 /* Fix Privacy Manifest */,
);
buildRules = (
);
dependencies = (
);
name = "Time Safari";
name = App;
productName = App;
productReference = 504EC3041FED79650016851F /* Time Safari.app */;
productReference = 504EC3041FED79650016851F /* App.app */;
productType = "com.apple.product-type.application";
};
/* End PBXNativeTarget section */
@@ -122,8 +123,9 @@
504EC2FC1FED79650016851F /* Project object */ = {
isa = PBXProject;
attributes = {
LastSwiftUpdateCheck = 0920;
LastUpgradeCheck = 0920;
BuildIndependentTargetsInParallel = YES;
LastSwiftUpdateCheck = 920;
LastUpgradeCheck = 1630;
TargetAttributes = {
504EC3031FED79650016851F = {
CreatedOnToolsVersion = 9.2;
@@ -132,7 +134,7 @@
};
};
};
buildConfigurationList = 504EC2FF1FED79650016851F /* Build configuration list for PBXProject "Time Safari" */;
buildConfigurationList = 504EC2FF1FED79650016851F /* Build configuration list for PBXProject "App" */;
compatibilityVersion = "Xcode 8.0";
developmentRegion = en;
hasScannedForEncodings = 0;
@@ -141,13 +143,11 @@
Base,
);
mainGroup = 504EC2FB1FED79650016851F;
packageReferences = (
);
productRefGroup = 504EC3051FED79650016851F /* Products */;
projectDirPath = "";
projectRoot = "";
targets = (
504EC3031FED79650016851F /* Time Safari */,
504EC3031FED79650016851F /* App */,
);
};
/* End PBXProject section */
@@ -169,6 +169,26 @@
/* End PBXResourcesBuildPhase section */
/* Begin PBXShellScriptBuildPhase section */
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\" ";
showEnvVarsInLog = 0;
};
6634F4EFEBD30273BCE97C65 /* [CP] Check Pods Manifest.lock */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
@@ -249,6 +269,7 @@
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
@@ -256,8 +277,10 @@
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_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_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
@@ -267,8 +290,10 @@
CODE_SIGN_IDENTITY = "iPhone Developer";
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = dwarf;
DEVELOPMENT_TEAM = 7XVXYPEQYJ;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu11;
GCC_DYNAMIC_NO_PIC = NO;
GCC_NO_COMMON_BLOCKS = YES;
@@ -306,6 +331,7 @@
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
@@ -313,8 +339,10 @@
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_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_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
@@ -324,8 +352,10 @@
CODE_SIGN_IDENTITY = "iPhone Developer";
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEVELOPMENT_TEAM = 7XVXYPEQYJ;
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu11;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
@@ -337,7 +367,8 @@
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
SWIFT_OPTIMIZATION_LEVEL = "-Owholemodule";
SWIFT_COMPILATION_MODE = wholemodule;
SWIFT_OPTIMIZATION_LEVEL = "-O";
VALIDATE_PRODUCT = YES;
};
name = Release;
@@ -348,20 +379,23 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = GM3FS5JQPH;
CURRENT_PROJECT_VERSION = 18;
ENABLE_APP_SANDBOX = NO;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
INFOPLIST_FILE = App/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = "Time Safari";
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
MARKETING_VERSION = 1.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 0.4.7;
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
PRODUCT_BUNDLE_IDENTIFIER = app.timesafari;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
VERSIONING_SYSTEM = "apple-generic";
};
name = Debug;
};
@@ -371,26 +405,29 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = GM3FS5JQPH;
CURRENT_PROJECT_VERSION = 18;
ENABLE_APP_SANDBOX = NO;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
INFOPLIST_FILE = App/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = "Time Safari";
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
MARKETING_VERSION = 1.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 0.4.7;
PRODUCT_BUNDLE_IDENTIFIER = app.timesafari;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
VERSIONING_SYSTEM = "apple-generic";
};
name = Release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
504EC2FF1FED79650016851F /* Build configuration list for PBXProject "Time Safari" */ = {
504EC2FF1FED79650016851F /* Build configuration list for PBXProject "App" */ = {
isa = XCConfigurationList;
buildConfigurations = (
504EC3141FED79650016851F /* Debug */,
@@ -399,7 +436,7 @@
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
504EC3161FED79650016851F /* Build configuration list for PBXNativeTarget "Time Safari" */ = {
504EC3161FED79650016851F /* Build configuration list for PBXNativeTarget "App" */ = {
isa = XCConfigurationList;
buildConfigurations = (
504EC3171FED79650016851F /* Debug */,

View File

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

View File

@@ -5,7 +5,7 @@
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleDisplayName</key>
<string>TimeSafari</string>
<string>TimeSafari</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
@@ -22,6 +22,10 @@
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>LSRequiresIPhoneOS</key>
<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>
<string>LaunchScreen</string>
<key>UIMainStoryboardFile</key>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,80 @@
# Copyright (c) 2024, crasowas.
#
# Use of this source code is governed by a MIT-style license
# that can be found in the LICENSE file or at
# https://opensource.org/licenses/MIT.
require 'xcodeproj'
RUN_SCRIPT_PHASE_NAME = 'Fix Privacy Manifest'
if ARGV.length < 2
puts "Usage: ruby xcode_install_helper.rb <project_path> <script_content> [install_builds_only (true|false)]"
exit 1
end
project_path = ARGV[0]
run_script_content = ARGV[1]
install_builds_only = ARGV[2] == 'true'
# Find the first .xcodeproj file in the project directory
xcodeproj_path = Dir.glob(File.join(project_path, "*.xcodeproj")).first
# Validate the .xcodeproj file existence
unless xcodeproj_path
puts "Error: No .xcodeproj file found in the specified directory."
exit 1
end
# Open the Xcode project file
begin
project = Xcodeproj::Project.open(xcodeproj_path)
rescue StandardError => e
puts "Error: Unable to open the project file - #{e.message}"
exit 1
end
# Process all targets in the project
project.targets.each do |target|
# Skip PBXAggregateTarget
if target.is_a?(Xcodeproj::Project::Object::PBXAggregateTarget)
puts "Skipping aggregate target: #{target.name}."
next
end
# Check if the target is a native application target
if target.product_type == 'com.apple.product-type.application'
puts "Processing target: #{target.name}..."
# Check for an existing Run Script phase with the specified name
existing_phase = target.shell_script_build_phases.find { |phase| phase.name == RUN_SCRIPT_PHASE_NAME }
# Remove the existing Run Script phase if found
if existing_phase
puts " - Removing existing Run Script."
target.build_phases.delete(existing_phase)
end
# Add the new Run Script phase at the end
puts " - Adding new Run Script."
new_phase = target.new_shell_script_build_phase(RUN_SCRIPT_PHASE_NAME)
new_phase.shell_script = run_script_content
# Disable showing environment variables in the build log
new_phase.show_env_vars_in_log = '0'
# Run only for deployment post-processing if install_builds_only is true
new_phase.run_only_for_deployment_postprocessing = install_builds_only ? '1' : '0'
# Disable dependency analysis to force the script to run on every build, unless restricted to deployment builds by post-processing setting
new_phase.always_out_of_date = '1'
else
puts "Skipping non-application target: #{target.name}."
end
end
# Save the project file
begin
project.save
puts "Successfully added the Run Script phase: '#{RUN_SCRIPT_PHASE_NAME}'."
rescue StandardError => e
puts "Error: Unable to save the project file - #{e.message}"
exit 1
end

View File

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

View File

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

View File

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

View File

@@ -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 修复、文档改进等。请确保遵循项目规范,并保持代码风格一致。感谢你的支持!

View File

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

View File

@@ -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 apps bundle for iOS, while for macOS, it should be located in `Contents/Resources/` within the apps 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 dont 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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 apps bundle for iOS, while for macOS, it should be located in `Contents/Resources/` within the apps 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 dont 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"

View File

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

View File

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

View File

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

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

7434
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@@ -1,98 +1,243 @@
const fs = require('fs');
const path = require('path');
const fs = require('fs-extra');
async function main() {
try {
console.log('Starting electron build process...');
// Create dist directory if it doesn't exist
const distElectronDir = path.resolve(__dirname, '../dist-electron');
await fs.ensureDir(distElectronDir);
// Copy web files
const wwwDir = path.join(distElectronDir, 'www');
await fs.ensureDir(wwwDir);
await fs.copy('dist', wwwDir);
const webDistPath = path.join(__dirname, '..', 'dist');
const electronDistPath = path.join(__dirname, '..', 'dist-electron');
const wwwPath = path.join(electronDistPath, 'www');
// Copy and fix index.html
const indexPath = path.join(wwwDir, 'index.html');
let indexContent = await fs.readFile(indexPath, 'utf8');
// Create www directory if it doesn't exist
if (!fs.existsSync(wwwPath)) {
fs.mkdirSync(wwwPath, { recursive: true });
}
// More comprehensive path fixing
// Copy web files to www directory
fs.cpSync(webDistPath, wwwPath, { recursive: true });
// Fix asset paths in index.html
const indexPath = path.join(wwwPath, 'index.html');
let indexContent = fs.readFileSync(indexPath, 'utf8');
// Fix asset paths
indexContent = indexContent
// Fix absolute paths to be relative
.replace(/src="\//g, 'src="\./')
.replace(/href="\//g, 'href="\./')
// Fix modulepreload paths
.replace(/<link [^>]*rel="modulepreload"[^>]*href="\/assets\//g, '<link rel="modulepreload" as="script" crossorigin="" href="./assets/')
.replace(/<link [^>]*rel="modulepreload"[^>]*href="\.\/assets\//g, '<link rel="modulepreload" as="script" crossorigin="" href="./assets/')
// Fix stylesheet paths
.replace(/<link [^>]*rel="stylesheet"[^>]*href="\/assets\//g, '<link rel="stylesheet" crossorigin="" href="./assets/')
.replace(/<link [^>]*rel="stylesheet"[^>]*href="\.\/assets\//g, '<link rel="stylesheet" crossorigin="" href="./assets/')
// Fix script paths
.replace(/src="\/assets\//g, 'src="./assets/')
.replace(/src="\.\/assets\//g, 'src="./assets/')
// Fix any remaining asset paths
.replace(/(['"]\/?)(assets\/)/g, '"./assets/');
.replace(/\/assets\//g, './assets/')
.replace(/href="\//g, 'href="./')
.replace(/src="\//g, 'src="./');
// Debug output
fs.writeFileSync(indexPath, indexContent);
// Check for remaining /assets/ paths
console.log('After path fixing, checking for remaining /assets/ paths:', indexContent.includes('/assets/'));
console.log('Sample of fixed content:', indexContent.slice(0, 500));
await fs.writeFile(indexPath, indexContent);
console.log('Sample of fixed content:', indexContent.substring(0, 500));
console.log('Copied and fixed web files in:', wwwDir);
console.log('Copied and fixed web files in:', wwwPath);
// Copy main process files
console.log('Copying main process files...');
const mainProcessFiles = [
['src/electron/main.js', 'main.js'],
['src/electron/preload.js', 'preload.js']
];
for (const [src, dest] of mainProcessFiles) {
const destPath = path.join(distElectronDir, dest);
console.log(`Copying ${src} to ${destPath}`);
await fs.copy(src, destPath);
}
// Create the main process file with inlined logger
const mainContent = `const { app, BrowserWindow } = require("electron");
const path = require("path");
const fs = require("fs");
// Create package.json for production
const devPackageJson = require('../package.json');
const prodPackageJson = {
name: devPackageJson.name,
version: devPackageJson.version,
description: devPackageJson.description,
author: devPackageJson.author,
main: 'main.js',
private: true,
};
// Inline logger implementation
const logger = {
log: (...args) => console.log(...args),
error: (...args) => console.error(...args),
info: (...args) => console.info(...args),
warn: (...args) => console.warn(...args),
debug: (...args) => console.debug(...args),
};
await fs.writeJson(
path.join(distElectronDir, 'package.json'),
prodPackageJson,
{ spaces: 2 }
);
// Check if running in dev mode
const isDev = process.argv.includes("--inspect");
// Verify the build
console.log('\nVerifying build structure:');
const files = await fs.readdir(distElectronDir);
console.log('Files in dist-electron:', files);
function createWindow() {
// Add before createWindow function
const preloadPath = path.join(__dirname, "preload.js");
logger.log("Checking preload path:", preloadPath);
logger.log("Preload exists:", fs.existsSync(preloadPath));
if (!files.includes('main.js')) {
throw new Error('main.js not found in build directory');
}
if (!files.includes('preload.js')) {
throw new Error('preload.js not found in build directory');
}
if (!files.includes('package.json')) {
throw new Error('package.json not found in build directory');
}
// Create the browser window.
const mainWindow = new BrowserWindow({
width: 1200,
height: 800,
webPreferences: {
nodeIntegration: false,
contextIsolation: true,
webSecurity: true,
allowRunningInsecureContent: false,
preload: path.join(__dirname, "preload.js"),
},
});
console.log('Build completed successfully!');
} catch (error) {
console.error('Build failed:', error);
process.exit(1);
// Always open DevTools for now
mainWindow.webContents.openDevTools();
// Intercept requests to fix asset paths
mainWindow.webContents.session.webRequest.onBeforeRequest(
{
urls: [
"file://*/*/assets/*",
"file://*/assets/*",
"file:///assets/*", // Catch absolute paths
"<all_urls>", // Catch all URLs as a fallback
],
},
(details, callback) => {
let url = details.url;
// Handle paths that don't start with file://
if (!url.startsWith("file://") && url.includes("/assets/")) {
url = \`file://\${path.join(__dirname, "www", url)}\`;
}
// Handle absolute paths starting with /assets/
if (url.includes("/assets/") && !url.includes("/www/assets/")) {
const baseDir = url.includes("dist-electron")
? url.substring(
0,
url.indexOf("/dist-electron") + "/dist-electron".length,
)
: \`file://\${__dirname}\`;
const assetPath = url.split("/assets/")[1];
const newUrl = \`\${baseDir}/www/assets/\${assetPath}\`;
callback({ redirectURL: newUrl });
return;
}
callback({}); // No redirect for other URLs
},
);
if (isDev) {
// Debug info
logger.log("Debug Info:");
logger.log("Running in dev mode:", isDev);
logger.log("App is packaged:", app.isPackaged);
logger.log("Process resource path:", process.resourcesPath);
logger.log("App path:", app.getAppPath());
logger.log("__dirname:", __dirname);
logger.log("process.cwd():", process.cwd());
}
const indexPath = path.join(__dirname, "www", "index.html");
if (isDev) {
logger.log("Loading index from:", indexPath);
logger.log("www path:", path.join(__dirname, "www"));
logger.log("www assets path:", path.join(__dirname, "www", "assets"));
}
if (!fs.existsSync(indexPath)) {
logger.error(\`Index file not found at: \${indexPath}\`);
throw new Error("Index file not found");
}
// Add CSP headers to allow API connections, Google Fonts, and zxing-wasm
mainWindow.webContents.session.webRequest.onHeadersReceived(
(details, callback) => {
callback({
responseHeaders: {
...details.responseHeaders,
"Content-Security-Policy": [
"default-src 'self';" +
"connect-src 'self' https://api.endorser.ch https://*.timesafari.app https://*.jsdelivr.net;" +
"img-src 'self' data: https: blob:;" +
"script-src 'self' 'unsafe-inline' 'unsafe-eval' https://*.jsdelivr.net;" +
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com;" +
"font-src 'self' data: https://fonts.gstatic.com;" +
"style-src-elem 'self' 'unsafe-inline' https://fonts.googleapis.com;" +
"worker-src 'self' blob:;",
],
},
});
},
);
// Load the index.html
mainWindow
.loadFile(indexPath)
.then(() => {
logger.log("Successfully loaded index.html");
if (isDev) {
mainWindow.webContents.openDevTools();
logger.log("DevTools opened - running in dev mode");
}
})
.catch((err) => {
logger.error("Failed to load index.html:", err);
logger.error("Attempted path:", indexPath);
});
// Listen for console messages from the renderer
mainWindow.webContents.on("console-message", (_event, _level, message) => {
logger.log("Renderer Console:", message);
});
// Add right after creating the BrowserWindow
mainWindow.webContents.on(
"did-fail-load",
(_event, errorCode, errorDescription) => {
logger.error("Page failed to load:", errorCode, errorDescription);
},
);
mainWindow.webContents.on("preload-error", (_event, preloadPath, error) => {
logger.error("Preload script error:", preloadPath, error);
});
mainWindow.webContents.on(
"console-message",
(_event, _level, message, line, sourceId) => {
logger.log("Renderer Console:", line, sourceId, message);
},
);
// Enable remote debugging when in dev mode
if (isDev) {
mainWindow.webContents.openDevTools();
}
}
main();
// Handle app ready
app.whenReady().then(createWindow);
// Handle all windows closed
app.on("window-all-closed", () => {
if (process.platform !== "darwin") {
app.quit();
}
});
app.on("activate", () => {
if (BrowserWindow.getAllWindows().length === 0) {
createWindow();
}
});
// Handle any errors
process.on("uncaughtException", (error) => {
logger.error("Uncaught Exception:", error);
});
`;
// Write the main process file
const mainDest = path.join(electronDistPath, 'main.js');
fs.writeFileSync(mainDest, mainContent);
// Copy preload script if it exists
const preloadSrc = path.join(__dirname, '..', 'src', 'electron', 'preload.js');
const preloadDest = path.join(electronDistPath, 'preload.js');
if (fs.existsSync(preloadSrc)) {
console.log(`Copying ${preloadSrc} to ${preloadDest}`);
fs.copyFileSync(preloadSrc, preloadDest);
}
// Verify build structure
console.log('\nVerifying build structure:');
console.log('Files in dist-electron:', fs.readdirSync(electronDistPath));
console.log('Build completed successfully!');

15
scripts/copy-wasm.js Normal file
View File

@@ -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 Executable file
View File

@@ -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 Executable file
View File

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

View File

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

View File

@@ -103,7 +103,7 @@ const cleanIosPlatform = async (log) => {
// Get app name from package.json
const packageJson = JSON.parse(readFileSync('package.json', 'utf8'));
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
const capacitorConfig = `
@@ -441,8 +441,8 @@ const configureIosProject = async (log) => {
try {
// Try to run pod install normally first
log('🔄 Running "pod install" in ios/App directory...');
execSync('cd ios/App && pod install', { stdio: 'inherit' });
log('✅ CocoaPods installation completed');
execSync('cd ios/App && pod install', { stdio: 'inherit' });
log('✅ CocoaPods installation completed');
} catch (error) {
// If that fails, provide detailed instructions
log(`⚠️ CocoaPods installation failed: ${error.message}`);
@@ -467,12 +467,12 @@ const configureIosProject = async (log) => {
// Build and test iOS project
const buildAndTestIos = async (log, simulator) => {
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' });
log('✅ Xcode clean completed');
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');
// 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
const deeplinkTests = [
{
const deeplinkTests = [
{
url: `timesafari://claim/${testEnv.CLAIM_ID}`,
description: 'Claim view'
},
{
description: 'Claim view'
},
{
url: `timesafari://claim-cert/${testEnv.CERT_ID || testEnv.CLAIM_ID}`,
description: 'Claim certificate view'
},
{
description: 'Claim certificate view'
},
{
url: `timesafari://claim-add-raw/${testEnv.RAW_CLAIM_ID || testEnv.CLAIM_ID}`,
description: 'Raw claim addition'
},
{
url: 'timesafari://did/test',
description: 'DID view with test identifier'
},
{
url: `timesafari://did/${testEnv.CONTACT1_DID}`,
description: 'DID view with contact DID'
},
{
description: 'Raw claim addition'
},
{
url: 'timesafari://did/test',
description: 'DID view with test identifier'
},
{
url: `timesafari://did/${testEnv.CONTACT1_DID}`,
description: 'DID view with contact DID'
},
{
url: (() => {
if (!testEnv?.CONTACT1_DID) {
throw new Error('Cannot construct contact-edit URL: CONTACT1_DID is missing');
@@ -653,13 +653,13 @@ const runDeeplinkTests = async (log) => {
log('Created contact-edit URL:', url);
return url;
})(),
description: 'Contact editing'
},
{
url: `timesafari://contacts/import?contacts=${encodeURIComponent(JSON.stringify(contacts))}`,
description: 'Contacts import'
}
];
description: 'Contact editing'
},
{
url: `timesafari://contacts/import?contacts=${encodeURIComponent(JSON.stringify(contacts))}`,
description: 'Contacts import'
}
];
// Log the final test configuration
log('\n5. Final Test Configuration:');

View File

@@ -4,7 +4,7 @@
<!-- Messages in the upper-right - https://github.com/emmanuelsw/notiwind -->
<NotificationGroup group="alert">
<div
class="fixed top-4 right-4 w-full max-w-sm flex flex-col items-start justify-end"
class="fixed top-[calc(env(safe-area-inset-top)+1rem)] right-4 left-4 sm:left-auto sm:w-full sm:max-w-sm flex flex-col items-start justify-end"
>
<Notification
v-slot="{ notifications, close }"
@@ -144,10 +144,10 @@
<!--
This "group" of "modal" is the prompt for an answer.
Set "type" as follows: "confirm" for yes/no, and "notification" ones:
"-permission", "-mute", "-off"
"-permission", "-mute", "-off"
-->
<NotificationGroup group="modal">
<div class="fixed z-[100] top-0 inset-x-0 w-full">
<div class="fixed z-[100] top-[env(safe-area-inset-top)] inset-x-0 w-full">
<Notification
v-slot="{ notifications, close }"
enter="transform ease-out duration-300 transition"
@@ -167,10 +167,10 @@
role="alert"
>
<!--
Type of "confirm" will post a message.
With onYes function, show a "Yes" button to call that function.
With onNo function, show a "No" button to call that function,
and pass it state of "askAgain" field shown if you set promptToStopAsking.
Type of "confirm" will post a message.
With onYes function, show a "Yes" button to call that function.
With onNo function, show a "No" button to call that function,
and pass it state of "askAgain" field shown if you set promptToStopAsking.
-->
<div
v-if="notification.type === 'confirm'"
@@ -539,4 +539,15 @@ export default class App extends Vue {
}
</script>
<style></style>
<style>
#Content {
padding-left: 1.5rem;
padding-right: 1.5rem;
padding-top: calc(env(safe-area-inset-top) + 1.5rem);
padding-bottom: calc(env(safe-area-inset-bottom) + 1.5rem);
}
#QuickNav ~ #Content {
padding-bottom: calc(env(safe-area-inset-bottom) + 6rem);
}
</style>

View File

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

View File

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

View File

@@ -99,8 +99,6 @@ import {
LTileLayer,
} from "@vue-leaflet/vue-leaflet";
import { Router } from "vue-router";
import { MASTER_SETTINGS_KEY } from "../db/tables/settings";
import { db, retrieveSettingsForActiveAccount } from "../db/index";
@Component({
components: {
@@ -122,7 +120,8 @@ export default class FeedFilters extends Vue {
async open(onCloseIfChanged: () => void) {
this.onCloseIfChanged = onCloseIfChanged;
const settings = await retrieveSettingsForActiveAccount();
const platform = this.$platform;
const settings = await platform.getActiveAccountSettings();
this.hasVisibleDid = !!settings.filterFeedByVisible;
this.isNearby = !!settings.filterFeedByNearby;
if (settings.searchBoxes && settings.searchBoxes.length > 0) {
@@ -136,7 +135,8 @@ export default class FeedFilters extends Vue {
async toggleHasVisibleDid() {
this.settingChanged = true;
this.hasVisibleDid = !this.hasVisibleDid;
await db.settings.update(MASTER_SETTINGS_KEY, {
const platform = this.$platform;
await platform.updateMasterSettings({
filterFeedByVisible: this.hasVisibleDid,
});
}
@@ -144,7 +144,8 @@ export default class FeedFilters extends Vue {
async toggleNearby() {
this.settingChanged = true;
this.isNearby = !this.isNearby;
await db.settings.update(MASTER_SETTINGS_KEY, {
const platform = this.$platform;
await platform.updateMasterSettings({
filterFeedByNearby: this.isNearby,
});
}
@@ -154,7 +155,8 @@ export default class FeedFilters extends Vue {
this.settingChanged = true;
}
await db.settings.update(MASTER_SETTINGS_KEY, {
const platform = this.$platform;
await platform.updateMasterSettings({
filterFeedByNearby: false,
filterFeedByVisible: false,
});
@@ -168,7 +170,8 @@ export default class FeedFilters extends Vue {
this.settingChanged = true;
}
await db.settings.update(MASTER_SETTINGS_KEY, {
const platform = this.$platform;
await platform.updateMasterSettings({
filterFeedByNearby: true,
filterFeedByVisible: true,
});

View File

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

View File

@@ -1,108 +1,382 @@
<template>
<div v-if="visible" class="dialog-overlay z-[60]">
<div class="dialog relative">
<div class="text-lg text-center font-light relative z-50">
<div class="text-lg text-center font-bold relative">
<h1 id="ViewHeading" class="text-center font-bold">
<span v-if="uploading">Uploading Image&hellip;</span>
<span v-else-if="blob">Crop Image</span>
<span v-else-if="showCameraPreview">Upload Image</span>
<span v-else>Add Photo</span>
</h1>
<div
id="ViewHeading"
class="text-center font-bold absolute top-0 left-0 right-0 px-4 py-0.5 bg-black/50 text-white leading-none"
>
Add Photo
</div>
<div
class="text-lg text-center px-2 py-0.5 leading-none absolute right-0 top-0 text-white"
class="text-2xl text-center px-1 py-0.5 leading-none absolute -right-1 top-0"
@click="close()"
>
<font-awesome icon="xmark" class="w-[1em]"></font-awesome>
</div>
</div>
<div>
<div class="text-center mt-8">
<div>
<font-awesome
icon="camera"
class="bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-2 rounded-md"
@click="openPhotoDialog()"
/>
</div>
<div class="mt-4">
<input type="file" @change="uploadImageFile" />
</div>
<div class="mt-4">
<span class="mt-2">
... or paste a URL:
<input v-model="imageUrl" type="text" class="border-2" />
</span>
<span class="ml-2">
<font-awesome
v-if="imageUrl"
icon="check"
class="bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-2 rounded-md cursor-pointer"
@click="acceptUrl"
/>
<!-- so that there's no shifting when it becomes visible -->
<font-awesome
v-else
icon="check"
class="text-white bg-white px-2 py-2"
/>
</span>
</div>
<!-- FEEDBACK: Show if camera preview is not visible after mounting -->
<div
v-if="!showCameraPreview && !blob && isRegistered"
class="bg-red-100 text-red-700 border border-red-400 rounded px-4 py-3 my-4 text-sm"
>
<strong>Camera preview not started.</strong>
<div v-if="cameraState === 'off'">
<span v-if="platformCapabilities.isMobile">
<b>Note:</b> This mobile browser may not support direct camera
access, or the app is treating it as a native app.<br />
<b>Tip:</b> Try using a desktop browser, or check if your browser
supports camera access for web apps.<br />
<b>Developer:</b> The platform detection logic may be skipping
camera preview for mobile browsers. <br />
<b>Action:</b> Review <code>platformCapabilities.isMobile</code> and
ensure web browsers on mobile are not treated as native apps.
</span>
<span v-else>
<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>
<button
class="absolute bottom-4 left-1/2 -translate-x-1/2 bg-white text-slate-800 p-3 rounded-full text-2xl leading-none"
@click="capturePhoto"
>
<font-awesome icon="camera" class="w-[1em]" />
</button>
</div>
</div>
<div
class="border-b border-dashed border-slate-300 text-orange-400 mt-4 mb-4 font-bold text-sm"
>
<span class="block w-fit mx-auto -mb-2.5 bg-white px-2">
OR choose a file from your device
</span>
</div>
<div class="mt-4">
<input
type="file"
class="w-full file:text-center file:bg-gradient-to-b file:from-slate-400 file:to-slate-700 file:shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] file:text-white file:px-3 file:py-2 file:rounded-md file:border-none file:cursor-pointer file:me-2"
@change="uploadImageFile"
/>
</div>
<div
class="border-b border-dashed border-slate-300 text-orange-400 mt-4 mb-4 font-bold text-sm"
>
<span class="block w-fit mx-auto -mb-2.5 bg-white px-2">
OR paste an image URL
</span>
</div>
<div class="flex items-center gap-2 mt-4">
<input
v-model="imageUrl"
type="text"
class="block w-full rounded border border-slate-400 px-4 py-2"
placeholder="https://example.com/image.jpg"
/>
<button
v-if="imageUrl"
class="bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-3 py-2 rounded-md cursor-pointer"
@click="acceptUrl"
>
<font-awesome icon="check" class="fa-fw" />
</button>
</div>
</div>
<div v-else>
<div v-if="uploading" class="flex justify-center">
<font-awesome
icon="spinner"
class="fa-spin fa-3x text-center block px-12 py-12"
/>
</div>
<div v-else>
<div v-if="crop">
<VuePictureCropper
:box-style="{
backgroundColor: '#f8f8f8',
margin: 'auto',
}"
:img="createBlobURL(blob)"
:options="{
viewMode: 1,
dragMode: 'crop',
aspectRatio: 1 / 1,
}"
class="max-h-[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>
</template>
<template v-else>
<div
id="noticeBeforeUpload"
class="bg-amber-200 text-amber-900 border-amber-500 border-dashed border text-center rounded-md overflow-hidden px-4 py-3"
role="alert"
aria-live="polite"
>
<p class="mb-2">
Before you can upload a photo, a friend needs to register you.
</p>
<router-link
:to="{ name: 'contact-qr' }"
class="inline-block text-md uppercase bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-4 py-2 rounded-md"
>
Share Your Info
</router-link>
</div>
</template>
</div>
</div>
</div>
<PhotoDialog ref="photoDialog" />
</template>
<script lang="ts">
import axios from "axios";
import { ref } from "vue";
import { Component, Vue } from "vue-facing-decorator";
import PhotoDialog from "../components/PhotoDialog.vue";
import { NotificationIface } from "../constants/app";
import VuePictureCropper, { cropper } from "vue-picture-cropper";
import { DEFAULT_IMAGE_API_SERVER, NotificationIface } from "../constants/app";
import { retrieveSettingsForActiveAccount } from "../db/index";
import { accessToken } from "../libs/crypto";
import { logger } from "../utils/logger";
import { PlatformServiceFactory } from "../services/PlatformServiceFactory";
const inputImageFileNameRef = ref<Blob>();
@Component({
components: { PhotoDialog },
components: { VuePictureCropper },
props: {
isRegistered: {
type: Boolean,
default: true,
},
},
})
export default class ImageMethodDialog extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void;
claimType: string;
/** Active DID for user authentication */
activeDid = "";
/** Current image blob being processed */
blob?: Blob;
/** Type of claim for the image */
claimType: string = "";
/** Whether to show cropping interface */
crop: boolean = false;
/** Name of the selected file */
fileName?: string;
/** Callback function to set image URL after upload */
imageCallback: (imageUrl?: string) => void = () => {};
/** URL for image input */
imageUrl?: string;
/** Whether to show retry button */
showRetry = true;
/** Upload progress state */
uploading = false;
/** Dialog visibility state */
visible = false;
/** Whether to show camera preview */
showCameraPreview = false;
/** Camera stream reference */
private cameraStream: MediaStream | null = null;
private platformService = PlatformServiceFactory.getInstance();
URL = window.URL || window.webkitURL;
private platformCapabilities = this.platformService.getCapabilities();
// 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 {
const settings = await retrieveSettingsForActiveAccount();
this.activeDid = settings.activeDid || "";
} catch (error: unknown) {
logger.error("Error retrieving settings from database:", error);
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text:
error instanceof Error
? error.message
: "There was an error retrieving your settings.",
},
-1,
);
}
}
/**
* Lifecycle hook: Cleans up camera stream when component is destroyed
*/
beforeDestroy() {
this.stopCameraPreview();
}
open(setImageFn: (arg: string) => void, claimType: string, crop?: boolean) {
this.claimType = claimType;
this.crop = !!crop;
this.imageCallback = setImageFn;
this.visible = true;
}
openPhotoDialog(blob?: Blob, fileName?: string) {
this.visible = false;
(this.$refs.photoDialog as PhotoDialog).open(
this.imageCallback,
this.claimType,
this.crop,
blob,
fileName,
);
// Start camera preview immediately if not on mobile
if (!this.platformCapabilities.isNativeApp) {
this.startCameraPreview();
}
}
async uploadImageFile(event: Event) {
this.visible = false;
const target = event.target as HTMLInputElement;
if (!target.files) return;
inputImageFileNameRef.value = event.target.files[0];
// https://developer.mozilla.org/en-US/docs/Web/API/File
// ... plus it has a `type` property from my testing
inputImageFileNameRef.value = target.files[0];
const file = inputImageFileNameRef.value;
if (file != null) {
const reader = new FileReader();
@@ -112,7 +386,9 @@ export default class ImageMethodDialog extends Vue {
const blob = new Blob([new Uint8Array(data)], {
type: file.type,
});
this.openPhotoDialog(blob, file.name as string);
this.blob = blob;
this.fileName = file.name;
this.showRetry = false;
}
};
reader.readAsArrayBuffer(file as Blob);
@@ -120,21 +396,16 @@ export default class ImageMethodDialog extends Vue {
}
async acceptUrl() {
this.visible = false;
if (this.crop) {
try {
const urlBlobResponse: Blob = await axios.get(this.imageUrl as string, {
responseType: "blob", // This ensures the data is returned as a Blob
const urlBlobResponse = await axios.get(this.imageUrl as string, {
responseType: "blob",
});
const fullUrl = new URL(this.imageUrl as string);
const fileName = fullUrl.pathname.split("/").pop() as string;
(this.$refs.photoDialog as PhotoDialog).open(
this.imageCallback,
this.claimType,
this.crop,
urlBlobResponse.data as Blob,
fileName,
);
this.blob = urlBlobResponse.data as Blob;
this.fileName = fileName;
this.showRetry = false;
} catch (error) {
this.$notify(
{
@@ -148,11 +419,259 @@ export default class ImageMethodDialog extends Vue {
}
} else {
this.imageCallback(this.imageUrl);
this.close();
}
}
close() {
this.visible = false;
this.stopCameraPreview();
const bottomNav = document.querySelector("#QuickNav") as HTMLElement;
if (bottomNav) {
bottomNav.style.display = "";
}
this.blob = undefined;
this.showCameraPreview = false;
}
async startCameraPreview() {
logger.debug("startCameraPreview called");
logger.debug("Current showCameraPreview state:", this.showCameraPreview);
logger.debug("Platform capabilities:", this.platformCapabilities);
if (this.platformCapabilities.isNativeApp) {
logger.debug("Using platform service for mobile device");
this.cameraState = "initializing";
this.cameraStateMessage = "Using platform camera service...";
try {
const result = await this.platformService.takePicture();
this.blob = result.blob;
this.fileName = result.fileName;
this.cameraState = "ready";
this.cameraStateMessage = "Photo captured successfully";
} catch (error) {
logger.error("Error taking picture:", error);
this.cameraState = "error";
this.cameraStateMessage =
error instanceof Error ? error.message : "Failed to take picture";
this.error =
error instanceof Error ? error.message : "Failed to take picture";
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "Failed to take picture. Please try again.",
},
5000,
);
}
return;
}
logger.debug("Starting camera preview for desktop browser");
try {
this.cameraState = "initializing";
this.cameraStateMessage = "Requesting camera access...";
this.showCameraPreview = true;
await this.$nextTick();
const stream = await navigator.mediaDevices.getUserMedia({
video: { facingMode: "environment" },
});
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(() => {
resolve(true);
});
};
});
}
} catch (error) {
logger.error("Error starting camera preview:", error);
let errorMessage =
error instanceof Error ? error.message : "Failed to access camera";
if (
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.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.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: errorMessage,
},
5000,
);
this.showCameraPreview = false;
}
}
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,
);
}
}
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;
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;
}
}
// Add toggle method
toggleDiagnostics() {
this.showDiagnostics = !this.showDiagnostics;
}
}
</script>
@@ -178,5 +697,16 @@ export default class ImageMethodDialog extends Vue {
border-radius: 0.5rem;
width: 100%;
max-width: 700px;
max-height: 90vh;
overflow: hidden;
display: flex;
flex-direction: column;
}
/* Add styles for diagnostic panel */
.diagnostic-panel {
font-family: monospace;
white-space: pre-wrap;
word-break: break-all;
}
</style>

View File

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

View File

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

View File

@@ -1,6 +1,9 @@
<template>
<!-- QUICK NAV -->
<nav id="QuickNav" class="fixed bottom-0 left-0 right-0 bg-slate-200 z-50">
<nav
id="QuickNav"
class="fixed bottom-0 left-0 right-0 bg-slate-200 z-50 pb-[env(safe-area-inset-bottom)]"
>
<ul class="flex text-2xl px-6 py-2 gap-1 max-w-3xl mx-auto">
<!-- Home Feed -->
<li

View File

@@ -1,5 +1,5 @@
<template>
<div class="absolute right-5 top-3">
<div class="absolute right-5 top-[calc(env(safe-area-inset-top)+0.75rem)]">
<span class="align-center text-red-500 mr-2">{{ message }}</span>
<span class="ml-2">
<router-link

106
src/db-sql/migration.ts Normal file
View File

@@ -0,0 +1,106 @@
import migrationService from "../services/migrationService";
import type { QueryExecResult, SqlValue } from "../interfaces/database";
// Each migration can include multiple SQL statements (with semicolons)
const MIGRATIONS = [
{
name: "001_initial",
// see ../db/tables files for explanations of the fields
sql: `
CREATE TABLE IF NOT EXISTS accounts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
dateCreated TEXT NOT NULL,
derivationPath TEXT,
did TEXT NOT NULL,
identity TEXT,
mnemonic TEXT,
passkeyCredIdHex TEXT,
publicKeyHex TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_accounts_did ON accounts(did);
CREATE TABLE IF NOT EXISTS secret (
id INTEGER PRIMARY KEY AUTOINCREMENT,
secret TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS settings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
accountDid TEXT,
activeDid TEXT,
apiServer TEXT,
filterFeedByNearby BOOLEAN,
filterFeedByVisible BOOLEAN,
finishedOnboarding BOOLEAN,
firstName TEXT,
hideRegisterPromptOnNewContact BOOLEAN,
isRegistered BOOLEAN,
lastName TEXT,
lastAckedOfferToUserJwtId TEXT,
lastAckedOfferToUserProjectsJwtId TEXT,
lastNotifiedClaimId TEXT,
lastViewedClaimId TEXT,
notifyingNewActivityTime TEXT,
notifyingReminderMessage TEXT,
notifyingReminderTime TEXT,
partnerApiServer TEXT,
passkeyExpirationMinutes INTEGER,
profileImageUrl TEXT,
searchBoxes TEXT, -- Stored as JSON string
showContactGivesInline BOOLEAN,
showGeneralAdvanced BOOLEAN,
showShortcutBvc BOOLEAN,
vapid TEXT,
warnIfProdServer BOOLEAN,
warnIfTestServer BOOLEAN,
webPushServer TEXT
);
CREATE INDEX IF NOT EXISTS idx_settings_accountDid ON settings(accountDid);
CREATE TABLE IF NOT EXISTS contacts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
did TEXT NOT NULL,
name TEXT,
contactMethods TEXT, -- Stored as JSON string
nextPubKeyHashB64 TEXT,
notes TEXT,
profileImageUrl TEXT,
publicKeyBase64 TEXT,
seesMe BOOLEAN,
registered BOOLEAN
);
CREATE INDEX IF NOT EXISTS idx_contacts_did ON contacts(did);
CREATE INDEX IF NOT EXISTS idx_contacts_name ON contacts(name);
CREATE TABLE IF NOT EXISTS logs (
date TEXT PRIMARY KEY,
message TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS temp (
id TEXT PRIMARY KEY,
blobB64 TEXT
);
`,
},
];
export async function registerMigrations(): Promise<void> {
// Register all migrations
for (const migration of MIGRATIONS) {
await migrationService.registerMigration(migration);
}
}
export async function runMigrations(
sqlExec: (
sql: string,
params?: SqlValue[],
) => Promise<Array<QueryExecResult>>,
): Promise<void> {
await registerMigrations();
await migrationService.runMigrations(sqlExec);
}

View File

@@ -87,9 +87,85 @@ const DEFAULT_SETTINGS: Settings = {
// Event handler to initialize the non-sensitive database with default settings
db.on("populate", async () => {
await db.settings.add(DEFAULT_SETTINGS);
try {
await db.settings.add(DEFAULT_SETTINGS);
} catch (error) {
logger.error("Error populating the database with default settings:", error);
}
});
// Helper function to safely open the database with retries
async function safeOpenDatabase(retries = 1, delay = 500): Promise<void> {
// logger.log("Starting safeOpenDatabase with retries:", retries);
for (let i = 0; i < retries; i++) {
try {
// logger.log(`Attempt ${i + 1}: Checking if database is open...`);
if (!db.isOpen()) {
// logger.log(`Attempt ${i + 1}: Database is closed, attempting to open...`);
// Create a promise that rejects after 5 seconds
const timeoutPromise = new Promise((_, reject) => {
setTimeout(() => reject(new Error("Database open timed out")), 500);
});
// Race between the open operation and the timeout
const openPromise = db.open();
// logger.log(`Attempt ${i + 1}: Waiting for db.open() promise...`);
await Promise.race([openPromise, timeoutPromise]);
// If we get here, the open succeeded
// logger.log(`Attempt ${i + 1}: Database opened successfully`);
return;
}
// logger.log(`Attempt ${i + 1}: Database was already open`);
return;
} catch (error) {
logger.error(`Attempt ${i + 1}: Database open failed:`, error);
if (i < retries - 1) {
logger.log(`Attempt ${i + 1}: Waiting ${delay}ms before retry...`);
await new Promise((resolve) => setTimeout(resolve, delay));
} else {
throw error;
}
}
}
}
export async function updateDefaultSettings(
settingsChanges: Settings,
): Promise<number> {
delete settingsChanges.accountDid; // just in case
// ensure there is no "id" that would override the key
delete settingsChanges.id;
try {
try {
// logger.log("Database state before open:", db.isOpen() ? "open" : "closed");
// logger.log("Database name:", db.name);
// logger.log("Database version:", db.verno);
await safeOpenDatabase();
} catch (openError: unknown) {
logger.error("Failed to open database:", openError, String(openError));
throw new Error(
`The database connection failed. We recommend you try again or restart the app.`,
);
}
const result = await db.settings.update(
MASTER_SETTINGS_KEY,
settingsChanges,
);
return result;
} catch (error) {
logger.error("Error updating default settings:", error);
if (error instanceof Error) {
throw error; // Re-throw if it's already an Error with a message
} else {
throw new Error(
`Failed to update settings. We recommend you try again or restart the app.`,
);
}
}
}
// Manage the encryption key.
// It's not really secure to maintain the secret next to the user's data.
@@ -183,15 +259,6 @@ export async function retrieveSettingsForActiveAccount(): Promise<Settings> {
}
}
export async function updateDefaultSettings(
settingsChanges: Settings,
): Promise<void> {
delete settingsChanges.accountDid; // just in case
// ensure there is no "id" that would override the key
delete settingsChanges.id;
await db.settings.update(MASTER_SETTINGS_KEY, settingsChanges);
}
export async function updateAccountSettings(
accountDid: string,
settingsChanges: Settings,

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