Compare commits

..

91 Commits

Author SHA1 Message Date
Matthew Raymer
a1b6add178 chore: linting; more non-errors need fixing 2025-06-14 11:10:37 +00:00
Matthew Raymer
d3a26a54d4 feat: optimize permission checking and reduce logging verbosity
- Add 30-second cache for storage permission status to prevent redundant checks
- Reduce excessive logging during file discovery operations
- Only log file discovery results when files are actually found
- Optimize backup files listing to reduce console noise
- Maintain essential debugging information while improving performance
- Eliminate redundant permission checks during app initialization
- Improve app startup performance with cached permission status

Performance improvements:
- Faster permission checks with caching mechanism
- Reduced console noise for better debugging experience
- Maintained all functionality while optimizing logging
- Better user experience with less redundant operations

Security: No changes to security model, only performance optimizations
Platforms: Android and iOS file handling improvements
2025-06-14 10:18:10 +00:00
Matthew Raymer
122b5b1a06 feat: implement comprehensive directory creation for Android 10+
- Add 4-strategy directory creation system for Android 10+ compatibility
- Strategy 1: Recursive file creation with temporary files
- Strategy 2: Parent-by-parent directory creation for nested paths
- Strategy 3: Simple file test creation in target directory
- Strategy 4: App-specific external directory creation
- Enhanced testing and user guidance for directory creation capabilities
- Comprehensive logging for debugging directory creation issues
- Guaranteed file saves with graceful fallback to app data directory
2025-06-14 10:09:08 +00:00
Matthew Raymer
9e8f08aa49 feat: improve backup file discovery and user guidance
- Add enhanced logging for debugging when no backup files found
- Improve "no backup files" UI with helpful guidance for users
- Better file filtering to exclude system files from backup lists
- Enhanced user experience for first-time users and permission-denied scenarios
2025-06-14 09:50:59 +00:00
Matthew Raymer
1529cc9689 fix: improve file discovery to find files in app data directory
- Fix listUserAccessibleFilesEnhanced to properly check app data directory
- Add detailed logging for file discovery results
- Ensure files saved to app data directory are discoverable
- Improve file discovery logging with file details

Resolves issue where saved backup files were not appearing in file list
Files saved to app data directory are now properly discoverable
2025-06-14 09:44:07 +00:00
Matthew Raymer
f7ed05d13f fix: implement permission request lock and optimize file access
- Add permission request lock to prevent concurrent requests
- Implement permission state tracking to avoid redundant checks
- Optimize file discovery with single permission check
- Enhance error handling for permission denials
- Add graceful degradation when permissions are denied
- Improve structured logging for better debugging

Resolves "Can request only one set of permissions at a time" warnings
Reduces redundant permission checks and file system operations
Ensures app continues to function with limited permissions
2025-06-14 09:41:24 +00:00
Matthew Raymer
7c8a6d0666 fix: improve Android file export with version-aware storage handling
- Add Android version detection from user agent
- Implement tiered storage strategy for Android 10+ restrictions
- Enhance directory creation logic with better error handling
- Add comprehensive fallback chain for file saving
- Improve permission denial handling for graceful degradation
- Add structured logging for better debugging

Resolves "Parent folder doesn't exist" errors on Android 10+
Ensures files are always saved with multiple fallback levels
2025-06-14 09:30:15 +00:00
Matthew Raymer
1aa285be55 WIP: Debug backup file discovery system - Fixed recursive directory search in CapacitorPlatformService to properly search subdirectories instead of excluding them - Added missing listFilesInDirectory method to all platform services for directory browsing functionality - Added debug methods (debugTimeSafariDirectory, createTestBackupFile, testDirectoryContexts) to help diagnose file visibility issues - Enhanced logging for backup file discovery process - Current issue: TimeSafari directory exists in Download but shows 'Directory does not exist' when trying to read contents - Need to investigate why JSON backup files are not being found despite directory existence 2025-06-13 13:58:14 +00:00
Matthew Raymer
2635c22c33 feat(backup-browser): add folder navigation, breadcrumbs, and debug mode for file discovery
- Implement folder navigation with breadcrumbs in BackupFilesList
- Distinguish files and folders in UI, allow folder navigation
- Add debug mode to forcibly treat all entries as files for diagnosis
- Add detailed debug logging to file discovery (readdir, stat, entries)
- Show warning in UI when debug mode is active
- Prepare for further improvements to handle stat failures gracefully

Co-authored-by: Matthew Raymer
2025-06-12 13:14:41 +00:00
Matthew Raymer
2d516b90b0 feat: implement user-accessible file storage with improved share dialog handling
- Add timeout mechanism for share dialogs to prevent hanging (15s timeout)
- Implement centralized share dialog handling with robust error handling
- Update file storage to use app's external storage (accessible via file managers)
- Add directory picker functionality for custom save locations
- Add listUserAccessibleFiles method to show saved files
- Add testListUserFiles functionality for debugging
- Improve logging and error handling throughout file operations
- Ensure compatibility with Android 11+ storage restrictions

Files are now saved to:
- Android: /storage/emulated/0/Android/data/app.timesafari.app/files/TimeSafari/
- iOS: /Documents/ (accessible via Files app)

Users can access files through file managers, app's file listing,
or use share dialog to save to Downloads folder.
2025-06-11 13:03:42 +00:00
Matthew Raymer
7a1329e1a4 feat: enhance file save and share with location selection options
- Add enhanced writeAndShareFile method with flexible save options
- Support save to Downloads folder (Android) and Documents (iOS)
- Add framework for file picker location selection
- Improve error handling with detailed result objects
- Update PlatformService interface for new options
- Add writeFile method for basic app storage operations
- Update DataExportSection to use enhanced API with location selection
- Maintain backward compatibility with existing implementations
- Add comprehensive logging for debugging and monitoring

This provides users with better control over where files are saved
while maintaining the existing share functionality and adding
multiple fallback strategies for improved reliability.
2025-06-11 08:40:33 +00:00
f255ea389b bump to build 26 and version 0.5.1 2025-06-11 00:46:46 -06:00
0d343b9877 Merge pull request 'fix creation of did-specific settings (with a rename)' (#138) from fix-did-specifics into master
Reviewed-on: #138
2025-06-11 02:14:41 -04:00
df06100c32 remove more debugging 2025-06-10 23:49:14 -06:00
Matthew Raymer
ac5ddfc6f2 style: fix line length in ContactsView ternary operator
- Break long CSS class strings into multiple concatenated lines
- Ensure all lines are under 100 characters for better readability
- Maintain same functionality and styling behavior
- Improve code maintainability and readability

Fixes: Long lines in conditional CSS class assignment
2025-06-11 05:45:58 +00:00
Matthew Raymer
89b3f30466 fix: debug and clean up GiftedPrompts contact retrieval logic
- Add comprehensive debug logging to identify contact list population issues
- Fix array indexing bug in contact mapping (someContactDbIndex -> 0)
- Clean up all console.log statements for production readiness
- Improve contact retrieval debugging for SQLite and Dexie databases
- Maintain core functionality while adding diagnostic capabilities

Debugging: Contact list population issues in GiftedPrompts component
Cleanup: Remove debug console.log statements
2025-06-11 05:40:05 +00:00
Matthew Raymer
3cb5cc096b refactor: use databaseUtil.updateDefaultSettings for feed filter settings
- Replace direct platform service calls with databaseUtil.updateDefaultSettings
- Remove manual SQL query construction in favor of centralized utility
- Improve code consistency and maintainability
- Add proper error handling through databaseUtil's built-in mechanisms
- Remove unused PlatformServiceFactory import
- Fix SQL syntax errors in clearAll and setAll methods (AND -> comma)
- Ensure both SQLite and Dexie databases are updated consistently

Improves: FeedFilters component architecture and error handling
Fixes: isNearby and filterFeedByVisible settings not being saved properly
2025-06-11 05:19:15 +00:00
Matthew Raymer
5df560154f fix: resolve cross-platform contactMethods JSON parsing inconsistencies
- Add platform-agnostic parseJsonField utility for contactMethods handling
- Update contact export functions (contactsToExportJson, contactToCsvLine)
- Fix contact storage in QR scan views (ContactQRScanShowView, ContactQRScanFullView)
- Ensure consistent JSON string storage across web SQLite and Capacitor SQLite
- Prevents "[object Object] is not valid JSON" errors when switching platforms
- Maintains compatibility between auto-parsing web SQLite and raw string Capacitor SQLite

Fixes: contactMethods parsing errors in export and QR scan functionality
Related: searchBoxes field had similar issue (already fixed)
2025-06-11 04:17:38 +00:00
Matthew Raymer
c1aa522e6c fix: resolve cross-platform SQLite JSON parsing inconsistencies
- Add platform-agnostic parseJsonField utility to handle different SQLite implementations
- Web SQLite (wa-sqlite/absurd-sql) auto-parses JSON strings to objects
- Capacitor SQLite returns raw strings requiring manual parsing
- Update searchBoxes parsing to use new utility for consistent behavior
- Fixes "[object Object] is not valid JSON" error when switching platforms
- Ensures compatibility between web and mobile SQLite implementations

Fixes: searchBoxes parsing errors in databaseUtil.ts
Related: contactMethods field has similar issue (needs same treatment)
2025-06-11 03:44:28 +00:00
a082469a01 fix creation of did-specific settings (with a rename) 2025-06-10 20:51:22 -06:00
Jose Olarte III
3544d7278d Optimized item actions
- Edited button labels for brevity
- Repositioned Totals toggle
- Restyled note about recent hours
- Various text size and spacing changes
2025-06-10 19:54:05 +08:00
Jose Olarte III
d3110506ea Optimized per-item layout
- Stacked contact name and DID
- Text truncates to leave room for action buttons when visible
- Separated "from / to" heading from buttons to minimize width
- Various spacing and alignment adjustments
2025-06-10 18:42:49 +08:00
8609f8458d bump to build 25 & version 0.5.0 2025-06-09 09:26:21 -06:00
8f5c34bc5f fix linting 2025-06-09 09:09:54 -06:00
b0d61b95ea Merge branch 'ui-fixes-2025-06-w2' 2025-06-09 08:44:42 -06:00
af7bd236a3 fix check for successful gift submission 2025-06-09 08:41:47 -06:00
d719338bcc fix problem setting 'loading' flag 2025-06-09 08:37:42 -06:00
6ddf2d1012 fix problem switching IDs (creating too many settings) 2025-06-09 08:33:33 -06:00
Jose Olarte III
1b2d4b623a Turned off automatic safe area in iOS
- Safe area implementations will solely depend on CSS styles for full control
- Eliminates doubled top and bottom padding in iOS
2025-06-09 20:20:17 +08:00
Jose Olarte III
16d5c917d2 Updated QR scanner call
- Searched for all other (outdated) calls to QR scanner dialog and updated them
- Fixed vite HTML spec warning
2025-06-09 19:36:06 +08:00
5976a4995e fix problem clicking on offer-delivery, plus some other hardening and phrasing 2025-06-08 20:13:32 -06:00
dcd0cc4c20 fix import for derived accounts and hopefully make other account-access code more robust 2025-06-08 19:14:41 -06:00
b3ca6c9d91 remove relative URL references in different target because mobile chokes 2025-06-08 16:55:03 -06:00
e9d800f601 fix a web test (all passing now) 2025-06-07 21:41:43 -06:00
b939a5e592 bump build to 23 and version to 0.4.8 2025-06-07 18:54:56 -06:00
aa62037fae bump to build 22 version 0.4.7 (though I think the android capacitor.config.json appId is wrong) 2025-06-07 18:45:24 -06:00
722020ea86 fix linting 2025-06-07 18:09:04 -06:00
96aa3f4a54 add Python dependency for electron on Mac 2025-06-07 17:54:31 -06:00
c0c5f9842b fix some errors and correct recent type duplications & bloat (cherry-picked from d8f2587d1c) 2025-06-07 17:53:36 -06:00
be27ca1855 fix more logic for tests (cherry-picked from 83acb028c7) 2025-06-07 17:42:06 -06:00
92e4570672 fix some incorrect logic & things AI hallucinated 2025-06-07 17:39:10 -06:00
820ae727ed fix linting 2025-06-07 17:19:01 -06:00
dbeb1c6b4b Merge branch 'sql-absurd-sql-back' 2025-06-07 17:18:10 -06:00
573e4b206a Merge branch 'search-map-fix' 2025-06-07 16:43:49 -06:00
abc05d426e update messaging for unknown icons on home feed 2025-06-07 16:23:07 -06:00
2ea7479d75 fix verbiage and fix the contact actions to be on the right-hand side 2025-06-07 16:03:47 -06:00
9ac9713172 fix linting 2025-06-07 15:40:52 -06:00
41dad3254d fix a non-existent description, move the description right below the image 2025-06-07 15:33:29 -06:00
485eac59a0 remove unnecessary data element from export 2025-06-07 14:02:22 -06:00
Matthew Raymer
73fc32b75d fix(import): ensure contact import works for both Dexie and absurd-sql backends
- Refactor importContacts to handle both Dexie and absurd-sql (SQLite) storage
- Add ContactDbRecord interface with all string fields strictly typed (never null)
- Add helper functions to coerce null/undefined to empty string for all string fields
- Guarantee contactMethods is always stored as a JSON string (never null)
- Add runtime validation for required fields (e.g., did)
- Ensure imported/updated contacts are type-safe and compatible with both backends
- Improve code documentation and maintainability

Security:
- No sensitive data exposed
- All fields validated and sanitized before database write
- Consistent data structure across storage backends

Testing:
- Import tested with both Dexie and absurd-sql backends
- Null/undefined fields correctly handled and coerced
- No linter/type errors remain
2025-06-07 06:01:17 +00:00
Matthew Raymer
3d8e40e92b feat(export): Replace CSV export with standardized JSON format
- Add contactsToExportJson utility function for standardized data export
- Replace CSV export with JSON format in DataExportSection
- Update file extension and MIME type to application/json
- Remove Dexie-specific export logic in favor of unified SQLite/Dexie approach
- Update success notifications to reflect JSON format
- Add TypeScript interfaces for export data structure

This change improves data portability and standardization by:
- Using a consistent JSON format for data export/import
- Supporting both SQLite and Dexie databases
- Including all contact fields in export
- Properly handling contactMethods as stringified JSON
- Maintaining backward compatibility with existing import tools

Security: No sensitive data exposure, maintains existing access controls
2025-06-07 05:02:33 +00:00
38e67f3533 update a DB save to match others, ie. first SQL then maybe Dexie 2025-06-06 19:50:16 -06:00
7f63ee7c80 add another way to fix the privacy policy manifest for third parties like GoogleToolboxForMac 2025-06-06 19:45:41 -06:00
6a47f0d3e7 format total numbers better 2025-06-06 19:40:53 -06:00
fc50a9d4c6 fix problem finding offer identifiers 2025-06-06 19:06:29 -06:00
Jose Olarte III
45f43ff363 Updated icon and splash assets 2025-06-06 18:15:42 +08:00
Jose Olarte III
7b1d4c4849 Adjusted iOS-specific paddings
- Switched to CSS max() for proper conditional padding when dealing with screens that have a notch, dynamic island, gesture bar, etc.
- Top padding should now appear more compact in iOS
2025-06-06 18:14:56 +08:00
Matthew Raymer
c1f2c3951a feat(db): improve settings retrieval resilience and logging
Enhance retrieveSettingsForActiveAccount with better error handling and logging
while maintaining core functionality. Changes focus on making the system more
debuggable and resilient without overcomplicating the logic.

Key improvements:
- Add structured error handling with specific try-catch blocks
- Implement detailed logging with [databaseUtil] prefix for easy filtering
- Add graceful fallbacks for searchBoxes parsing and missing settings
- Improve error recovery paths with safe defaults
- Maintain existing security model and data integrity

Security:
- No sensitive data in logs
- Safe JSON parsing with fallbacks
- Proper error boundaries
- Consistent state management
- Clear fallback paths

Testing:
- Verify settings retrieval works with/without active DID
- Check error handling for invalid searchBoxes
- Confirm logging provides clear debugging context
- Validate fallback to default settings works
2025-06-06 09:22:35 +00:00
9d4f726c31 bump to build # 19 version 0.4.7 for mobile packages 2025-06-05 20:30:27 -06:00
1d7f626645 fix SQL references to bad "key" -> "id" 2025-06-05 20:11:32 -06:00
c5228ba7ec fix retrieval of column names -- so now most ops are working (but not all, eg. set name) 2025-06-05 20:06:32 -06:00
6e1fcd8dee remove unused DB methods (for now) 2025-06-05 20:00:51 -06:00
5bb563d694 fix extraction of values from SQLite queries 2025-06-05 19:57:59 -06:00
a3951c9d66 refactor for clarity (no logic changes) 2025-06-05 18:30:25 -06:00
aa177a9b8c fix extraction of migration names for SQLite via Capacitor 2025-06-05 18:13:33 -06:00
03cb4720b8 fix Capacitor to use the same migrations (migrations run but accounts aren't created) 2025-06-04 22:01:14 -06:00
Jose Olarte III
0e65431f43 Map z-index fix + adjustments
- Set map z-index lower than nav
- Relocated search box to account for conditional visibility
- Various conditional fixes
- Spacing adjustments
2025-06-04 18:41:49 +08:00
297c5a2dbb disable SQLite in Java & Swift (since they don't compile) & add SQL queueing on startup
At this point, the app compiles and runs in Android & iOS but DB operations fail.
2025-06-03 19:59:28 -06:00
Jose Olarte III
92b9c9334c Clickable person & project icons
- Known entities get routed to their corresponding detail views
- Unknown entities pop up a notification
2025-06-02 21:35:15 +08:00
Jose Olarte III
706182ca0c Icon for hidden DID entity
- Display EntityIcon for known entities, eye-slash icon for hidden entities, and the person-question icon for unknown entities
- Design tweaks (spacings, mostly)
2025-06-02 17:44:37 +08:00
Matthew Raymer
68e0fc4976 merge(master): big merge for qrcode-reboot 2025-06-02 03:57:00 +00:00
504056eb90 add some time to test 30 (but shrink the per-loop timeout) 2025-06-01 15:08:32 -06:00
5a1007c49c add iOS development team ID 2025-06-01 14:29:32 -06:00
Jose Olarte III
cbc14e21ec Look in .own.did for DID, as well 2025-05-30 17:34:50 +08:00
Jose Olarte III
3e02b3924a Look for DID in .iss field instead of .own.did 2025-05-28 19:08:15 +08:00
Jose Olarte III
8b03789941 Change heading based on crop flag 2025-05-28 16:32:41 +08:00
Jose Olarte III
b4a6b99301 Better error handling for image upload 2025-05-28 16:17:49 +08:00
Jose Olarte III
e839997f91 TEST: platform- and camera-specific mirroring 2025-05-27 18:58:35 +08:00
Jose Olarte III
d8d054a0e1 Streamlined QR scanner web camera
- No need to stop and start camera preview
2025-05-27 18:57:12 +08:00
Jose Olarte III
efc720e47f Mobile native to use web camera
- Ensure consistent UI experience for uploading photos across mobile web and native
2025-05-27 17:46:19 +08:00
Jose Olarte III
0a85bea533 Feature: context-based default camera
- Specify the default camera (front / back) to use
2025-05-27 15:37:45 +08:00
Jose Olarte III
47501ae917 Linting 2025-05-26 19:23:41 +08:00
Jose Olarte III
28634839ec Feature: front/back camera toggle
- Added to gifting and profile dialog camera for now. Toggle button is hidden in desktop.
- WIP: same feature for QR scanner camera.
- WIP: ability to specify default camera depending on where it's called.
2025-05-26 19:23:28 +08:00
1b7c96ed9b don't highlight profile Advanced link in blue 2025-05-13 19:37:47 -06:00
41365fab8f add projectLink to onboarding meeting, plus enhancements to setup usability 2025-05-13 19:36:23 -06:00
5cc42be58a fix some test scripts 2025-04-08 20:31:47 -06:00
3d1a2eeb8d adjust to app.timesafari.app in more places 2025-04-08 20:29:08 -06:00
7b0ee2e44e more ios folders to ignore (until we figure out the right way to dance with capacitor-assets) 2025-04-06 19:52:44 -06:00
ac018997e8 adjust instructions for capacitor-assets and more files 2025-04-06 19:48:45 -06:00
6f449e9c1f restore important file from previous cleanup 2025-04-06 19:15:00 -06:00
543599a6a1 remove icon files that are generated by capacitor-assets 2025-04-06 19:02:01 -06:00
163 changed files with 8986 additions and 13762 deletions

View File

@@ -2,7 +2,7 @@
# iOS doesn't like spaces in the app title. # iOS doesn't like spaces in the app title.
TIME_SAFARI_APP_TITLE="TimeSafari_Dev" TIME_SAFARI_APP_TITLE="TimeSafari_Dev"
VITE_APP_SERVER=http://localhost:3000 VITE_APP_SERVER=http://localhost:8080
# This is the claim ID for actions in the BVC project, with the JWT ID on this environment (not production). # This is the claim ID for actions in the BVC project, with the JWT ID on this environment (not production).
VITE_BVC_MEETUPS_PROJECT_CLAIM_ID=https://endorser.ch/entity/01HWE8FWHQ1YGP7GFZYYPS272F VITE_BVC_MEETUPS_PROJECT_CLAIM_ID=https://endorser.ch/entity/01HWE8FWHQ1YGP7GFZYYPS272F
VITE_DEFAULT_ENDORSER_API_SERVER=http://localhost:3000 VITE_DEFAULT_ENDORSER_API_SERVER=http://localhost:3000

6
.gitignore vendored
View File

@@ -21,6 +21,7 @@ npm-debug.log*
yarn-debug.log* yarn-debug.log*
yarn-error.log* yarn-error.log*
pnpm-debug.log* pnpm-debug.log*
android/app/src/main/res/
# Editor directories and files # Editor directories and files
.idea .idea
@@ -51,6 +52,7 @@ vendor/
# Build logs # Build logs
build_logs/ build_logs/
android/app/src/main/assets/public # PWA icon files generated by capacitor-assets
android/app/src/main/res icons

View File

@@ -9,19 +9,6 @@ For a quick dev environment setup, use [pkgx](https://pkgx.dev).
- Node.js (LTS version recommended) - Node.js (LTS version recommended)
- npm (comes with Node.js) - npm (comes with Node.js)
- Git - Git
- For Android builds: Android Studio with SDK installed
- For iOS builds: macOS with Xcode and ruby gems & bundle
- `pkgx +rubygems.org sh`
- ... and you may have to fix these, especially with pkgx
```bash
gem_path=$(which gem)
shortened_path="${gem_path:h:h}"
export GEM_HOME=$shortened_path
export GEM_PATH=$shortened_path
```
- For desktop builds: Additional build tools based on your OS - For desktop builds: Additional build tools based on your OS
## Forks ## Forks
@@ -326,6 +313,32 @@ npm run build:electron-prod && npm run electron:start
Prerequisites: macOS with Xcode installed Prerequisites: macOS with Xcode installed
#### First-time iOS Configuration
- Generate certificates inside XCode.
- Right-click on App and under Signing & Capabilities set the Team.
#### Each Release
0. First time (or if XCode dependencies change):
- `pkgx +rubygems.org sh`
- ... and you may have to fix these, especially with pkgx
```bash
gem_path=$(which gem)
shortened_path="${gem_path:h:h}"
export GEM_HOME=$shortened_path
export GEM_PATH=$shortened_path
```
```bash
cd ios/App
pod install
```
1. Build the web assets: 1. Build the web assets:
```bash ```bash
@@ -334,6 +347,7 @@ Prerequisites: macOS with Xcode installed
npm run build:capacitor npm run build:capacitor
``` ```
2. Update iOS project with latest build: 2. Update iOS project with latest build:
```bash ```bash
@@ -345,7 +359,11 @@ Prerequisites: macOS with Xcode installed
3. Copy the assets: 3. Copy the assets:
```bash ```bash
# It makes no sense why capacitor-assets will not run without these but it actually changes the contents.
mkdir -p ios/App/App/Assets.xcassets/AppIcon.appiconset mkdir -p ios/App/App/Assets.xcassets/AppIcon.appiconset
echo '{"images":[]}' > ios/App/App/Assets.xcassets/AppIcon.appiconset/Contents.json
mkdir -p ios/App/App/Assets.xcassets/Splash.imageset
echo '{"images":[]}' > ios/App/App/Assets.xcassets/Splash.imageset/Contents.json
npx capacitor-assets generate --ios npx capacitor-assets generate --ios
``` ```
@@ -353,10 +371,10 @@ Prerequisites: macOS with Xcode installed
``` ```
cd ios/App cd ios/App
xcrun agvtool new-version 15 xcrun agvtool new-version 25
# Unfortunately this edits Info.plist directly. # Unfortunately this edits Info.plist directly.
#xcrun agvtool new-marketing-version 0.4.5 #xcrun agvtool new-marketing-version 0.4.5
cat App.xcodeproj/project.pbxproj | sed "s/MARKETING_VERSION = .*;/MARKETING_VERSION = 0.4.5;/g" > temp cat App.xcodeproj/project.pbxproj | sed "s/MARKETING_VERSION = .*;/MARKETING_VERSION = 0.5.1;/g" > temp
mv temp App.xcodeproj/project.pbxproj mv temp App.xcodeproj/project.pbxproj
cd - cd -
``` ```
@@ -369,28 +387,25 @@ Prerequisites: macOS with Xcode installed
6. Use Xcode to build and run on simulator or device. 6. Use Xcode to build and run on simulator or device.
* Select Product -> Destination with some Simulator version. Then click the run arrow.
7. Release 7. Release
* Under "General" renamed a bunch of things to "Time Safari" * Someday: Under "General" we want to rename a bunch of things to "Time Safari"
* Choose Product -> Destination -> Build Any iOS * Choose Product -> Destination -> Any iOS Device
* Choose Product -> Archive * Choose Product -> Archive
* This will trigger a build and take time, needing user's "login" keychain password which is just their login password, repeatedly. * This will trigger a build and take time, needing user's "login" keychain password (user's login password), repeatedly.
* If it fails with `building for 'iOS', but linking in dylib (.../.pkgx/zlib.net/v1.3.0/lib/libz.1.3.dylib) built for 'macOS'` then run XCode outside that terminal (ie. not with `npx cap open ios`). * 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 * 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. * In AppStoreConnect, add the build to the distribution: remove the current build with the "-" when you hover over it, then "Add Build" with the new build.
* May have to go to App Review, click Submission, then hover over the build and click "-".
* It can take 15 minutes for the build to show up in the list of builds. * 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. * You'll probably have to "Manage" something about encryption, disallowed in France.
* Then "Save" and "Add to Review" and "Resubmit to App Review". * Then "Save" and "Add to Review" and "Resubmit to App Review".
#### First-time iOS Configuration
- Generate certificates inside XCode.
- Right-click on App and under Signing & Capabilities set the Team.
### Android Build ### Android Build
Prerequisites: Android Studio with SDK installed Prerequisites: Android Studio with Java SDK installed
1. Build the web assets: 1. Build the web assets:
@@ -445,7 +460,9 @@ Prerequisites: Android Studio with SDK installed
* Then `bundleRelease`: * Then `bundleRelease`:
```bash ```bash
cd android
./gradlew bundleRelease -Dlint.baselines.continue=true ./gradlew bundleRelease -Dlint.baselines.continue=true
cd -
``` ```
... and find your `aab` file at app/build/outputs/bundle/release ... and find your `aab` file at app/build/outputs/bundle/release
@@ -458,6 +475,8 @@ At play.google.com/console:
- Hit "Next". - Hit "Next".
- Save, go to "Publishing Overview" as prompted, and click "Send changes for review". - Save, go to "Publishing Overview" as prompted, and click "Send changes for review".
- Note that if you add testers, you have to go to "Publishing Overview" and send those changes or your (closed) testers won't see it.
## First-time Android Configuration for deep links ## First-time Android Configuration for deep links

View File

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

533
CONTACT_BACKUP_SYSTEM.md Normal file
View File

@@ -0,0 +1,533 @@
# TimeSafari Contact Backup System
## Overview
The TimeSafari application implements a comprehensive contact backup and listing system that works across multiple platforms (Web, iOS, Android, Desktop). This document breaks down how contacts are saved, exported, and listed as backups.
## Architecture Components
### 1. Database Layer
#### Contact Data Structure
```typescript
interface Contact {
did: string; // Decentralized Identifier (primary key)
contactMethods?: ContactMethod[]; // Array of contact methods (EMAIL, SMS, etc.)
name?: string; // Display name
nextPubKeyHashB64?: string; // Base64 hash of next public key
notes?: string; // User notes
profileImageUrl?: string; // Profile image URL
publicKeyBase64?: string; // Base64 encoded public key
seesMe?: boolean; // Visibility setting
registered?: boolean; // Registration status
}
interface ContactMethod {
label: string; // Display label
type: string; // Type (EMAIL, SMS, WHATSAPP, etc.)
value: string; // Contact value
}
```
#### Database Schema
```sql
CREATE TABLE contacts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
did TEXT NOT NULL, -- Decentralized Identifier
name TEXT, -- Display name
contactMethods TEXT, -- JSON string of contact methods
nextPubKeyHashB64 TEXT, -- Next public key hash
notes TEXT, -- User notes
profileImageUrl TEXT, -- Profile image URL
publicKeyBase64 TEXT, -- Public key
seesMe BOOLEAN, -- Visibility flag
registered BOOLEAN -- Registration status
);
CREATE INDEX idx_contacts_did ON contacts(did);
CREATE INDEX idx_contacts_name ON contacts(name);
```
### 2. Contact Saving Operations
#### A. Adding New Contacts
**1. QR Code Scanning (`ContactQRScanFullView.vue`)**
```typescript
async addNewContact(contact: Contact) {
// Check for existing contact
const existingContacts = await platformService.dbQuery(
"SELECT * FROM contacts WHERE did = ?", [contact.did]
);
if (existingContact) {
// Handle duplicate
return;
}
// Convert contactMethods to JSON string for storage
contact.contactMethods = JSON.stringify(
parseJsonField(contact.contactMethods, [])
);
// Insert into database
const { sql, params } = databaseUtil.generateInsertStatement(
contact as unknown as Record<string, unknown>, "contacts"
);
await platformService.dbExec(sql, params);
}
```
**2. Manual Contact Addition (`ContactsView.vue`)**
```typescript
private async addContact(newContact: Contact) {
// Validate DID format
if (!isDid(newContact.did)) {
throw new Error("Invalid DID format");
}
// Generate and execute INSERT statement
const { sql, params } = databaseUtil.generateInsertStatement(
newContact as unknown as Record<string, unknown>, "contacts"
);
await platformService.dbExec(sql, params);
}
```
**3. Contact Import (`ContactImportView.vue`)**
```typescript
async importContacts() {
for (const contact of selectedContacts) {
const contactToStore = contactToDbRecord(contact);
if (existingContact) {
// Update existing contact
const { sql, params } = databaseUtil.generateUpdateStatement(
contactToStore, "contacts", "did = ?", [contact.did]
);
await platformService.dbExec(sql, params);
} else {
// Add new contact
const { sql, params } = databaseUtil.generateInsertStatement(
contactToStore, "contacts"
);
await platformService.dbExec(sql, params);
}
}
}
```
#### B. Updating Existing Contacts
**Contact Editing (`ContactEditView.vue`)**
```typescript
async saveEdit() {
// Normalize contact methods
const contactMethods = this.contactMethods.map(method => ({
...method,
type: method.type.toUpperCase()
}));
// Update database
const contactMethodsString = JSON.stringify(contactMethods);
await platformService.dbExec(
"UPDATE contacts SET name = ?, notes = ?, contactMethods = ? WHERE did = ?",
[this.contactName, this.contactNotes, contactMethodsString, this.contact?.did]
);
}
```
### 3. Contact Export/Backup System
#### A. Export Process (`DataExportSection.vue`)
#### 1. Data Retrieval
```typescript
async exportDatabase() {
// Query all contacts from database
const result = await platformService.dbQuery("SELECT * FROM contacts");
const allContacts = databaseUtil.mapQueryResultToValues(result) as Contact[];
// Convert to export format
const exportData = contactsToExportJson(allContacts);
const jsonStr = JSON.stringify(exportData, null, 2);
}
```
#### 2. Export Format Conversion (`libs/util.ts`)
```typescript
export const contactsToExportJson = (contacts: Contact[]): DatabaseExport => {
const rows = contacts.map((contact) => ({
did: contact.did,
name: contact.name || null,
contactMethods: contact.contactMethods
? JSON.stringify(parseJsonField(contact.contactMethods, []))
: null,
nextPubKeyHashB64: contact.nextPubKeyHashB64 || null,
notes: contact.notes || null,
profileImageUrl: contact.profileImageUrl || null,
publicKeyBase64: contact.publicKeyBase64 || null,
seesMe: contact.seesMe || false,
registered: contact.registered || false,
}));
return {
data: {
data: [{ tableName: "contacts", rows }]
}
};
};
```
#### 3. File Generation
```typescript
// Create timestamped filename
const timestamp = getTimestampForFilename();
const fileName = `${AppString.APP_NAME_NO_SPACES}-backup-contacts-${timestamp}.json`;
// Create blob and save
const blob = new Blob([jsonStr], { type: "application/json" });
```
#### B. Platform-Specific File Saving
##### 1. Web Platform (`WebPlatformService.ts`)**
```typescript
// Uses browser download API
const downloadUrl = URL.createObjectURL(blob);
const downloadAnchor = this.$refs.downloadLink as HTMLAnchorElement;
downloadAnchor.href = downloadUrl;
downloadAnchor.download = fileName;
downloadAnchor.click();
```
##### 2. Mobile Platforms (`CapacitorPlatformService.ts`)
```typescript
async writeAndShareFile(fileName: string, content: string, options = {}) {
let fileUri: string;
if (options.allowLocationSelection) {
// User chooses location
fileUri = await this.saveWithUserChoice(fileName, content, options.mimeType);
} else if (options.saveToPrivateStorage) {
// Save to app-private storage
const result = await Filesystem.writeFile({
path: fileName,
data: content,
directory: Directory.Data,
encoding: Encoding.UTF8,
recursive: true,
});
fileUri = result.uri;
} else {
// Save to user-accessible location (Downloads/Documents)
fileUri = await this.saveToDownloads(fileName, content);
}
// Share the file
return await this.shareFile(fileUri, fileName);
}
```
##### 3. Desktop Platforms (`ElectronPlatformService.ts`, `PyWebViewPlatformService.ts`)
```typescript
// Not implemented - returns empty results
async listBackupFiles(): Promise<Array<{name: string, uri: string, size?: number, type: 'contacts' | 'seed' | 'other', path?: string}>> {
return [];
}
```
### 4. Backup File Listing System
#### A. File Discovery (`CapacitorPlatformService.ts`)
##### 1. Enhanced File Discovery
```typescript
async listUserAccessibleFilesEnhanced(): Promise<Array<{name: string, uri: string, size?: number, path?: string}>> {
const allFiles: Array<{name: string, uri: string, size?: number, path?: string}> = [];
if (this.getCapabilities().isIOS) {
// iOS: Documents directory
const result = await Filesystem.readdir({
path: ".",
directory: Directory.Documents,
});
const files = result.files.map((file) => ({
name: typeof file === "string" ? file : file.name,
uri: `file://${file.uri || file}`,
size: typeof file === "string" ? undefined : file.size,
path: "Documents"
}));
allFiles.push(...files);
} else {
// Android: Multiple locations
const commonPaths = ["Download", "Documents", "Backups", "TimeSafari", "Data"];
for (const path of commonPaths) {
try {
const result = await Filesystem.readdir({
path: path,
directory: Directory.ExternalStorage,
});
// Filter for TimeSafari-related files
const relevantFiles = result.files
.filter(file => {
const fileName = typeof file === "string" ? file : file.name;
const name = fileName.toLowerCase();
return name.includes('timesafari') ||
name.includes('backup') ||
name.includes('contacts') ||
name.endsWith('.json');
})
.map((file) => ({
name: typeof file === "string" ? file : file.name,
uri: `file://${file.uri || file}`,
size: typeof file === "string" ? undefined : file.size,
path: path
}));
allFiles.push(...relevantFiles);
} catch (error) {
// Silently skip inaccessible directories
}
}
}
return allFiles;
}
```
**2. Backup File Filtering**
```typescript
async listBackupFiles(): Promise<Array<{name: string, uri: string, size?: number, type: 'contacts' | 'seed' | 'other', path?: string}>> {
const allFiles = await this.listUserAccessibleFilesEnhanced();
const backupFiles = allFiles
.filter(file => {
const name = file.name.toLowerCase();
// Exclude directory-access notification files
if (name.startsWith('timesafari-directory-access-') && name.endsWith('.txt')) {
return false;
}
// Check backup criteria
const isJson = name.endsWith('.json');
const hasTimeSafari = name.includes('timesafari');
const hasBackup = name.includes('backup');
const hasContacts = name.includes('contacts');
const hasSeed = name.includes('seed');
const hasExport = name.includes('export');
const hasData = name.includes('data');
return isJson || hasTimeSafari || hasBackup || hasContacts || hasSeed || hasExport || hasData;
})
.map(file => {
const name = file.name.toLowerCase();
let type: 'contacts' | 'seed' | 'other' = 'other';
// Categorize files
if (name.includes('contacts') || (name.includes('timesafari') && name.includes('backup'))) {
type = 'contacts';
} else if (name.includes('seed') || name.includes('mnemonic') || name.includes('private')) {
type = 'seed';
} else if (name.endsWith('.json')) {
type = 'other';
}
return { ...file, type };
});
return backupFiles;
}
```
#### B. UI Components (`BackupFilesList.vue`)
**1. File Display**
```typescript
@Component
export default class BackupFilesList extends Vue {
backupFiles: Array<{name: string, uri: string, size?: number, type: 'contacts' | 'seed' | 'other', path?: string}> = [];
selectedType: 'all' | 'contacts' | 'seed' | 'other' = 'all';
isLoading = false;
async refreshFiles() {
this.isLoading = true;
try {
this.backupFiles = await this.platformService.listBackupFiles();
// Log file type distribution
const typeCounts = {
contacts: this.backupFiles.filter(f => f.type === 'contacts').length,
seed: this.backupFiles.filter(f => f.type === 'seed').length,
other: this.backupFiles.filter(f => f.type === 'other').length,
total: this.backupFiles.length
};
} catch (error) {
// Handle error
} finally {
this.isLoading = false;
}
}
}
```
**2. File Operations**
```typescript
async openFile(fileUri: string, fileName: string) {
const result = await this.platformService.openFile(fileUri, fileName);
if (!result.success) {
throw new Error(result.error || "Failed to open file");
}
}
async openBackupDirectory() {
const result = await this.platformService.openBackupDirectory();
if (!result.success) {
throw new Error(result.error || "Failed to open backup directory");
}
}
```
### 5. Platform-Specific Storage Locations
#### A. iOS Platform
- **Primary Location**: Documents folder (accessible via Files app)
- **Persistence**: Survives app installations
- **Access**: Through iOS Files app
- **File Format**: JSON with timestamped filenames
#### B. Android Platform
- **Primary Locations**:
- `Download/TimeSafari/` (external storage)
- `TimeSafari/` (external storage)
- User-chosen locations via file picker
- **Persistence**: Survives app installations
- **Access**: Through file managers
- **File Format**: JSON with timestamped filenames
#### C. Web Platform
- **Primary Location**: Browser downloads folder
- **Persistence**: Depends on browser settings
- **Access**: Through browser download manager
- **File Format**: JSON with timestamped filenames
#### D. Desktop Platforms (Electron/PyWebView)
- **Status**: Not implemented
- **Fallback**: Returns empty arrays for file operations
### 6. File Naming Convention
#### A. Contact Backup Files
```
TimeSafari-backup-contacts-YYYY-MM-DD-HH-MM-SS.json
```
#### B. File Content Structure
```json
{
"data": {
"data": [
{
"tableName": "contacts",
"rows": [
{
"did": "did:ethr:0x...",
"name": "Contact Name",
"contactMethods": "[{\"type\":\"EMAIL\",\"value\":\"email@example.com\"}]",
"notes": "User notes",
"profileImageUrl": "https://...",
"publicKeyBase64": "base64...",
"seesMe": true,
"registered": false
}
]
}
]
}
}
```
### 7. Error Handling and Logging
#### A. Comprehensive Logging
```typescript
logger.log("[CapacitorPlatformService] File write successful:", {
uri: fileUri,
saved,
timestamp: new Date().toISOString(),
});
logger.log("[BackupFilesList] Refreshed backup files:", {
count: this.backupFiles.length,
files: this.backupFiles.map(f => ({
name: f.name,
type: f.type,
path: f.path,
size: f.size
})),
platform: this.platformCapabilities.isIOS ? "iOS" : "Android",
timestamp: new Date().toISOString(),
});
```
#### B. Error Recovery
```typescript
try {
// File operations
} catch (error) {
logger.error("[CapacitorPlatformService] Failed to list backup files:", error);
return [];
}
```
### 8. Security Considerations
#### A. Data Privacy
- Contact data is stored locally on device
- No cloud synchronization of contact data
- User controls visibility settings per contact
- Backup files contain only user-authorized data
#### B. File Access
- Platform-specific permission handling
- User choice for file locations
- Secure storage options for sensitive data
- Proper error handling for access failures
### 9. Performance Optimizations
#### A. Database Operations
- Indexed queries on `did` and `name` fields
- Batch operations for multiple contacts
- Efficient JSON serialization/deserialization
- Connection pooling and reuse
#### B. File Operations
- Asynchronous file I/O
- Efficient file discovery algorithms
- Caching of file lists
- Background refresh operations
## Summary
The TimeSafari contact backup system provides:
1. **Robust Data Storage**: SQLite-based contact storage with proper indexing
2. **Cross-Platform Compatibility**: Works on web, iOS, Android, and desktop
3. **Flexible Export Options**: Multiple file formats and storage locations
4. **Intelligent File Discovery**: Finds backup files regardless of user-chosen locations
5. **User-Friendly Interface**: Clear categorization and easy file management
6. **Comprehensive Logging**: Detailed tracking for debugging and monitoring
7. **Security-First Design**: Privacy-preserving with user-controlled data access
The system ensures that users can reliably backup and restore their contact data across different platforms while maintaining data integrity and user privacy.

View File

@@ -31,8 +31,8 @@ android {
applicationId "app.timesafari.app" applicationId "app.timesafari.app"
minSdkVersion rootProject.ext.minSdkVersion minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 18 versionCode 26
versionName "0.4.7" versionName "0.5.1"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions { aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps. // Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.

View File

@@ -32,7 +32,7 @@
} }
}, },
"ios": { "ios": {
"contentInset": "always", "contentInset": "never",
"allowsLinkPreview": true, "allowsLinkPreview": true,
"scrollEnabled": true, "scrollEnabled": true,
"limitsNavigationsToAppBoundDomains": true, "limitsNavigationsToAppBoundDomains": true,

View File

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

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

View File

@@ -1,5 +1,9 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android"> <adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/> <background>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/> <inset android:drawable="@mipmap/ic_launcher_background" android:inset="16.7%" />
</background>
<foreground>
<inset android:drawable="@mipmap/ic_launcher_foreground" android:inset="16.7%" />
</foreground>
</adaptive-icon> </adaptive-icon>

View File

@@ -1,5 +1,9 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android"> <adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/> <background>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/> <inset android:drawable="@mipmap/ic_launcher_background" android:inset="16.7%" />
</background>
<foreground>
<inset android:drawable="@mipmap/ic_launcher_foreground" android:inset="16.7%" />
</foreground>
</adaptive-icon> </adaptive-icon>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

BIN
assets/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 279 KiB

BIN
assets/splash-dark.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

BIN
assets/splash.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

View File

@@ -1,11 +1,10 @@
{ {
"appId": "com.timesafari.app", "appId": "app.timesafari",
"appName": "TimeSafari", "appName": "TimeSafari",
"webDir": "dist", "webDir": "dist",
"bundledWebRuntime": false, "bundledWebRuntime": false,
"server": { "server": {
"cleartext": true, "cleartext": true
"androidScheme": "https"
}, },
"plugins": { "plugins": {
"App": { "App": {
@@ -30,16 +29,10 @@
"biometricAuth": true, "biometricAuth": true,
"biometricTitle": "Biometric login for TimeSafari" "biometricTitle": "Biometric login for TimeSafari"
} }
},
"CapacitorSQLite": {
"electronIsEncryption": false,
"electronMacLocation": "~/Library/Application Support/TimeSafari",
"electronWindowsLocation": "C:\\ProgramData\\TimeSafari",
"electronLinuxLocation": "~/.local/share/TimeSafari"
} }
}, },
"ios": { "ios": {
"contentInset": "always", "contentInset": "never",
"allowsLinkPreview": true, "allowsLinkPreview": true,
"scrollEnabled": true, "scrollEnabled": true,
"limitsNavigationsToAppBoundDomains": true, "limitsNavigationsToAppBoundDomains": true,

View File

@@ -1,270 +0,0 @@
# Electron App Migration Strategy
## Overview
This document outlines the migration strategy for the TimeSafari Electron app, focusing on the transition from web-based storage to native SQLite implementation while maintaining cross-platform compatibility.
## Current Architecture
### 1. Platform Services
- `ElectronPlatformService`: Implements platform-specific features for desktop
- Uses `@capacitor-community/sqlite` for database operations
- Maintains compatibility with web/mobile platforms through shared interfaces
### 2. Database Implementation
- SQLite with native Node.js backend
- WAL journal mode for better concurrency
- Connection pooling for performance
- Migration system for schema updates
- Secure file permissions (0o755)
### 3. Build Process
```bash
# Development
npm run dev:electron
# Production Build
npm run build:web
npm run build:electron
npm run electron:build-linux # or electron:build-mac
```
## Migration Goals
1. **Data Integrity**
- Preserve existing data during migration
- Maintain data relationships
- Ensure ACID compliance
- Implement proper backup/restore
2. **Performance**
- Optimize SQLite configuration
- Implement connection pooling
- Use WAL journal mode
- Configure optimal PRAGMA settings
3. **Security**
- Secure file permissions
- Proper IPC communication
- Context isolation
- Safe preload scripts
4. **User Experience**
- Zero data loss
- Automatic migration
- Progress indicators
- Error recovery
## Implementation Details
### 1. Database Initialization
```typescript
// electron/src/rt/sqlite-init.ts
export async function initializeSQLite() {
// Set up database path with proper permissions
const dbPath = path.join(app.getPath('userData'), 'timesafari.db');
// Initialize SQLite plugin
const sqlite = new CapacitorSQLite();
// Configure database
await sqlite.createConnection({
database: 'timesafari',
path: dbPath,
encrypted: false,
mode: 'no-encryption'
});
// Set optimal PRAGMA settings
await sqlite.execute({
database: 'timesafari',
statements: [
'PRAGMA journal_mode = WAL;',
'PRAGMA synchronous = NORMAL;',
'PRAGMA foreign_keys = ON;'
]
});
}
```
### 2. Migration System
```typescript
// electron/src/rt/sqlite-migrations.ts
interface Migration {
version: number;
name: string;
description: string;
sql: string;
rollback?: string;
}
async function runMigrations(plugin: any, database: string) {
// Track migration state
const state = await getMigrationState(plugin, database);
// Execute migrations in transaction
for (const migration of pendingMigrations) {
await executeMigration(plugin, database, migration);
}
}
```
### 3. Platform Service Implementation
```typescript
// src/services/platforms/ElectronPlatformService.ts
export class ElectronPlatformService implements PlatformService {
private sqlite: any;
async dbQuery(sql: string, params: any[]): Promise<QueryExecResult> {
return await this.sqlite.execute({
database: 'timesafari',
statements: [{ statement: sql, values: params }]
});
}
}
```
### 4. Preload Script
```typescript
// electron/preload.ts
contextBridge.exposeInMainWorld('electron', {
sqlite: {
isAvailable: () => ipcRenderer.invoke('sqlite:isAvailable'),
execute: (method: string, ...args: unknown[]) =>
ipcRenderer.invoke('sqlite:execute', method, ...args)
},
getPath: (pathType: string) => ipcRenderer.invoke('get-path', pathType),
env: {
platform: 'electron'
}
});
```
## Build Configuration
### 1. Vite Configuration
```typescript
// vite.config.app.electron.mts
export default defineConfig({
build: {
outDir: 'dist',
emptyOutDir: true
},
define: {
'process.env.VITE_PLATFORM': JSON.stringify('electron'),
'process.env.VITE_PWA_ENABLED': JSON.stringify(false)
}
});
```
### 2. Package Scripts
```json
{
"scripts": {
"dev:electron": "vite build --watch --config vite.config.app.electron.mts",
"build:electron": "vite build --config vite.config.app.electron.mts",
"electron:build-linux": "electron-builder --linux",
"electron:build-mac": "electron-builder --mac"
}
}
```
## Testing Strategy
1. **Unit Tests**
- Database operations
- Migration system
- Platform service methods
- IPC communication
2. **Integration Tests**
- Full migration process
- Data integrity verification
- Cross-platform compatibility
- Error recovery
3. **End-to-End Tests**
- User workflows
- Data persistence
- UI interactions
- Platform-specific features
## Error Handling
1. **Database Errors**
- Connection failures
- Migration errors
- Query execution errors
- Transaction failures
2. **Platform Errors**
- File system errors
- IPC communication errors
- Permission issues
- Resource constraints
3. **Recovery Mechanisms**
- Automatic retry logic
- Transaction rollback
- State verification
- User notifications
## Security Considerations
1. **File System**
- Secure file permissions
- Path validation
- Access control
- Data encryption
2. **IPC Communication**
- Context isolation
- Channel validation
- Data sanitization
- Error handling
3. **Preload Scripts**
- Minimal API exposure
- Type safety
- Input validation
- Error boundaries
## Future Improvements
1. **Performance**
- Query optimization
- Index tuning
- Connection management
- Cache implementation
2. **Features**
- Offline support
- Sync capabilities
- Backup/restore
- Data export/import
3. **Security**
- Database encryption
- Secure storage
- Access control
- Audit logging
## Maintenance
1. **Regular Tasks**
- Database optimization
- Log rotation
- Error monitoring
- Performance tracking
2. **Updates**
- Dependency updates
- Security patches
- Feature additions
- Bug fixes
3. **Documentation**
- API documentation
- Migration guides
- Troubleshooting
- Best practices

55
electron/.gitignore vendored
View File

@@ -1,55 +0,0 @@
# NPM renames .gitignore to .npmignore
# In order to prevent that, we remove the initial "."
# And the CLI then renames it
app
node_modules
build
dist
logs
# Node.js dependencies
node_modules/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# Capacitor build outputs
web/
ios/
android/
electron/app/
# Capacitor SQLite plugin data (important!)
capacitor-sqlite/
# TypeScript / build output
dist/
build/
*.log
# Development / IDE files
.env.local
.env.development.local
.env.test.local
.env.production.local
# VS Code
.vscode/
!.vscode/extensions.json
# JetBrains IDEs (IntelliJ, WebStorm, etc.)
.idea/
*.iml
*.iws
# macOS specific
.DS_Store
*.swp
*~
*.tmp
# Windows specific
Thumbs.db
ehthumbs.db
Desktop.ini
$RECYCLE.BIN/

Binary file not shown.

Before

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 159 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

View File

@@ -1,62 +0,0 @@
{
"appId": "com.timesafari.app",
"appName": "TimeSafari",
"webDir": "dist",
"bundledWebRuntime": false,
"server": {
"cleartext": true,
"androidScheme": "https"
},
"plugins": {
"App": {
"appUrlOpen": {
"handlers": [
{
"url": "timesafari://*",
"autoVerify": true
}
]
}
},
"SQLite": {
"iosDatabaseLocation": "Library/CapacitorDatabase",
"iosIsEncryption": true,
"iosBiometric": {
"biometricAuth": true,
"biometricTitle": "Biometric login for TimeSafari"
},
"androidIsEncryption": true,
"androidBiometric": {
"biometricAuth": true,
"biometricTitle": "Biometric login for TimeSafari"
}
},
"CapacitorSQLite": {
"electronIsEncryption": false,
"electronMacLocation": "~/Library/Application Support/TimeSafari",
"electronWindowsLocation": "C:\\ProgramData\\TimeSafari"
}
},
"ios": {
"contentInset": "always",
"allowsLinkPreview": true,
"scrollEnabled": true,
"limitsNavigationsToAppBoundDomains": true,
"backgroundColor": "#ffffff",
"allowNavigation": [
"*.timesafari.app",
"*.jsdelivr.net",
"api.endorser.ch"
]
},
"android": {
"allowMixedContent": false,
"captureInput": true,
"webContentsDebuggingEnabled": false,
"allowNavigation": [
"*.timesafari.app",
"*.jsdelivr.net",
"api.endorser.ch"
]
}
}

View File

@@ -1,28 +0,0 @@
{
"appId": "com.yourdoamnin.yourapp",
"directories": {
"buildResources": "resources"
},
"files": [
"assets/**/*",
"build/**/*",
"capacitor.config.*",
"app/**/*"
],
"publish": {
"provider": "github"
},
"nsis": {
"allowElevation": true,
"oneClick": false,
"allowToChangeInstallationDirectory": true
},
"win": {
"target": "nsis",
"icon": "assets/appIcon.ico"
},
"mac": {
"category": "your.app.category.type",
"target": "dmg"
}
}

View File

@@ -1,75 +0,0 @@
/* eslint-disable no-undef */
/* eslint-disable @typescript-eslint/no-var-requires */
const cp = require('child_process');
const chokidar = require('chokidar');
const electron = require('electron');
let child = null;
const npmCmd = process.platform === 'win32' ? 'npm.cmd' : 'npm';
const reloadWatcher = {
debouncer: null,
ready: false,
watcher: null,
restarting: false,
};
///*
function runBuild() {
return new Promise((resolve, _reject) => {
let tempChild = cp.spawn(npmCmd, ['run', 'build']);
tempChild.once('exit', () => {
resolve();
});
tempChild.stdout.pipe(process.stdout);
});
}
//*/
async function spawnElectron() {
if (child !== null) {
child.stdin.pause();
child.kill();
child = null;
await runBuild();
}
child = cp.spawn(electron, ['--inspect=5858', './']);
child.on('exit', () => {
if (!reloadWatcher.restarting) {
process.exit(0);
}
});
child.stdout.pipe(process.stdout);
}
function setupReloadWatcher() {
reloadWatcher.watcher = chokidar
.watch('./src/**/*', {
ignored: /[/\\]\./,
persistent: true,
})
.on('ready', () => {
reloadWatcher.ready = true;
})
.on('all', (_event, _path) => {
if (reloadWatcher.ready) {
clearTimeout(reloadWatcher.debouncer);
reloadWatcher.debouncer = setTimeout(async () => {
console.log('Restarting');
reloadWatcher.restarting = true;
await spawnElectron();
reloadWatcher.restarting = false;
reloadWatcher.ready = false;
clearTimeout(reloadWatcher.debouncer);
reloadWatcher.debouncer = null;
reloadWatcher.watcher = null;
setupReloadWatcher();
}, 500);
}
});
}
(async () => {
await runBuild();
await spawnElectron();
setupReloadWatcher();
})();

File diff suppressed because it is too large Load Diff

View File

@@ -1,52 +0,0 @@
{
"name": "TimeSafari",
"version": "1.0.0",
"description": "TimeSafari Electron App",
"author": {
"name": "",
"email": ""
},
"repository": {
"type": "git",
"url": ""
},
"license": "MIT",
"main": "build/src/index.js",
"scripts": {
"build": "tsc && electron-rebuild",
"electron:start-live": "node ./live-runner.js",
"electron:start": "npm run build && electron --inspect=5858 ./",
"electron:pack": "npm run build && electron-builder build --dir -c ./electron-builder.config.json",
"electron:make": "npm run build && electron-builder build -c ./electron-builder.config.json -p always"
},
"dependencies": {
"@capacitor-community/electron": "^5.0.0",
"@capacitor-community/sqlite": "^6.0.2",
"better-sqlite3-multiple-ciphers": "^11.10.0",
"chokidar": "~3.5.3",
"crypto": "^1.0.1",
"crypto-js": "^4.2.0",
"electron-is-dev": "~2.0.0",
"electron-json-storage": "^4.6.0",
"electron-serve": "~1.1.0",
"electron-unhandled": "~4.0.1",
"electron-updater": "^5.3.0",
"electron-window-state": "^5.0.3",
"jszip": "^3.10.1",
"node-fetch": "^2.6.7",
"winston": "^3.17.0"
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.13",
"@types/crypto-js": "^4.2.2",
"@types/electron-json-storage": "^4.5.4",
"electron": "^26.2.2",
"electron-builder": "~23.6.0",
"source-map-support": "^0.5.21",
"typescript": "^5.0.4"
},
"keywords": [
"capacitor",
"electron"
]
}

View File

@@ -1,10 +0,0 @@
/* eslint-disable no-undef */
/* eslint-disable @typescript-eslint/no-var-requires */
const electronPublish = require('electron-publish');
class Publisher extends electronPublish.Publisher {
async upload(task) {
console.log('electron-publisher-custom', task.file);
}
}
module.exports = Publisher;

View File

@@ -1,140 +0,0 @@
import type { CapacitorElectronConfig } from '@capacitor-community/electron';
import { getCapacitorElectronConfig, setupElectronDeepLinking } from '@capacitor-community/electron';
import type { MenuItemConstructorOptions } from 'electron';
import { app, MenuItem } from 'electron';
import electronIsDev from 'electron-is-dev';
import unhandled from 'electron-unhandled';
import { autoUpdater } from 'electron-updater';
import { ElectronCapacitorApp, setupContentSecurityPolicy, setupReloadWatcher } from './setup';
import { initializeSQLite, setupSQLiteHandlers } from './rt/sqlite-init';
// Graceful handling of unhandled errors.
unhandled();
// Define our menu templates (these are optional)
const trayMenuTemplate: (MenuItemConstructorOptions | MenuItem)[] = [new MenuItem({ label: 'Quit App', role: 'quit' })];
const appMenuBarMenuTemplate: (MenuItemConstructorOptions | MenuItem)[] = [
{ role: process.platform === 'darwin' ? 'appMenu' : 'fileMenu' },
{ role: 'viewMenu' },
];
// Get Config options from capacitor.config
const capacitorFileConfig: CapacitorElectronConfig = getCapacitorElectronConfig();
// Initialize our app. You can pass menu templates into the app here.
const myCapacitorApp = new ElectronCapacitorApp(capacitorFileConfig, trayMenuTemplate, appMenuBarMenuTemplate);
// If deeplinking is enabled then we will set it up here.
if (capacitorFileConfig.electron?.deepLinkingEnabled) {
setupElectronDeepLinking(myCapacitorApp, {
customProtocol: capacitorFileConfig.electron.deepLinkingCustomProtocol ?? 'mycapacitorapp',
});
}
// If we are in Dev mode, use the file watcher components.
if (electronIsDev) {
setupReloadWatcher(myCapacitorApp);
}
// Run Application
(async () => {
try {
// Wait for electron app to be ready first
await app.whenReady();
console.log('[Electron Main Process] App is ready');
// Initialize SQLite plugin and handlers BEFORE creating any windows
console.log('[Electron Main Process] Initializing SQLite...');
setupSQLiteHandlers();
await initializeSQLite();
console.log('[Electron Main Process] SQLite initialization complete');
// Security - Set Content-Security-Policy
setupContentSecurityPolicy(myCapacitorApp.getCustomURLScheme());
// Initialize our app and create window
console.log('[Electron Main Process] Starting app initialization...');
await myCapacitorApp.init();
console.log('[Electron Main Process] App initialization complete');
// Get the main window
const mainWindow = myCapacitorApp.getMainWindow();
if (!mainWindow) {
throw new Error('Main window not available after app initialization');
}
// Wait for window to be ready and loaded
await new Promise<void>((resolve) => {
const handleReady = () => {
console.log('[Electron Main Process] Window ready to show');
mainWindow.show();
// Wait for window to finish loading
mainWindow.webContents.once('did-finish-load', () => {
console.log('[Electron Main Process] Window finished loading');
// Send SQLite ready signal after window is fully loaded
if (!mainWindow.isDestroyed()) {
mainWindow.webContents.send('sqlite-ready');
console.log('[Electron Main Process] Sent SQLite ready signal to renderer');
} else {
console.warn('[Electron Main Process] Window was destroyed before sending SQLite ready signal');
}
resolve();
});
};
// Always use the event since isReadyToShow is not reliable
mainWindow.once('ready-to-show', handleReady);
});
// Check for updates if we are in a packaged app
if (!electronIsDev) {
console.log('[Electron Main Process] Checking for updates...');
autoUpdater.checkForUpdatesAndNotify();
}
// Handle window close
mainWindow.on('closed', () => {
console.log('[Electron Main Process] Main window closed');
});
// Handle window close request
mainWindow.on('close', (event) => {
console.log('[Electron Main Process] Window close requested');
if (mainWindow.webContents.isLoading()) {
event.preventDefault();
console.log('[Electron Main Process] Deferring window close due to loading state');
mainWindow.webContents.once('did-finish-load', () => {
mainWindow.close();
});
}
});
} catch (error) {
console.error('[Electron Main Process] Fatal error during initialization:', error);
app.quit();
}
})();
// Handle when all of our windows are close (platforms have their own expectations).
app.on('window-all-closed', function () {
// On OS X it is common for applications and their menu bar
// to stay active until the user quits explicitly with Cmd + Q
if (process.platform !== 'darwin') {
app.quit();
}
});
// When the dock icon is clicked.
app.on('activate', async function () {
// On OS X it's common to re-create a window in the app when the
// dock icon is clicked and there are no other windows open.
if (myCapacitorApp.getMainWindow().isDestroyed()) {
await myCapacitorApp.init();
}
});
// Place all ipc or other electron api calls and custom functionality under this line

View File

@@ -1,303 +0,0 @@
/**
* Preload script for Electron
* Sets up secure IPC communication between renderer and main process
*
* @author Matthew Raymer
*/
import { contextBridge, ipcRenderer } from 'electron';
// Enhanced logger for preload script that forwards to main process
const logger = {
log: (...args: unknown[]) => {
console.log('[Preload]', ...args);
ipcRenderer.send('renderer-log', { level: 'log', args });
},
error: (...args: unknown[]) => {
console.error('[Preload]', ...args);
ipcRenderer.send('renderer-log', { level: 'error', args });
},
info: (...args: unknown[]) => {
console.info('[Preload]', ...args);
ipcRenderer.send('renderer-log', { level: 'info', args });
},
warn: (...args: unknown[]) => {
console.warn('[Preload]', ...args);
ipcRenderer.send('renderer-log', { level: 'warn', args });
},
debug: (...args: unknown[]) => {
console.debug('[Preload]', ...args);
ipcRenderer.send('renderer-log', { level: 'debug', args });
},
sqlite: {
log: (operation: string, ...args: unknown[]) => {
const message = ['[Preload][SQLite]', operation, ...args];
console.log(...message);
ipcRenderer.send('renderer-log', {
level: 'log',
args: message,
source: 'sqlite',
operation
});
},
error: (operation: string, error: unknown) => {
const message = ['[Preload][SQLite]', operation, 'failed:', error];
console.error(...message);
ipcRenderer.send('renderer-log', {
level: 'error',
args: message,
source: 'sqlite',
operation,
error: error instanceof Error ? {
name: error.name,
message: error.message,
stack: error.stack
} : error
});
},
debug: (operation: string, ...args: unknown[]) => {
const message = ['[Preload][SQLite]', operation, ...args];
console.debug(...message);
ipcRenderer.send('renderer-log', {
level: 'debug',
args: message,
source: 'sqlite',
operation
});
}
}
};
// Types for SQLite connection options
interface SQLiteConnectionOptions {
database: string;
version?: number;
readOnly?: boolean;
readonly?: boolean; // Handle both cases
encryption?: string;
mode?: string;
useNative?: boolean;
[key: string]: unknown; // Allow other properties
}
// Define valid channels for security
const VALID_CHANNELS = {
send: ['toMain'] as const,
receive: ['fromMain', 'sqlite-ready', 'database-status'] as const,
invoke: [
'sqlite-is-available',
'sqlite-echo',
'sqlite-create-connection',
'sqlite-execute',
'sqlite-query',
'sqlite-run',
'sqlite-close-connection',
'sqlite-open',
'sqlite-close',
'sqlite-is-db-open',
'sqlite-status',
'get-path',
'get-base-path'
] as const
};
type ValidSendChannel = typeof VALID_CHANNELS.send[number];
type ValidReceiveChannel = typeof VALID_CHANNELS.receive[number];
type ValidInvokeChannel = typeof VALID_CHANNELS.invoke[number];
// Create a secure IPC bridge
const createSecureIPCBridge = () => {
return {
send: (channel: string, data: unknown) => {
if (VALID_CHANNELS.send.includes(channel as ValidSendChannel)) {
logger.debug('IPC Send:', channel, data);
ipcRenderer.send(channel, data);
} else {
logger.warn(`[Preload] Attempted to send on invalid channel: ${channel}`);
}
},
receive: (channel: string, func: (...args: unknown[]) => void) => {
if (VALID_CHANNELS.receive.includes(channel as ValidReceiveChannel)) {
logger.debug('IPC Receive:', channel);
ipcRenderer.on(channel, (_event, ...args) => {
logger.debug('IPC Received:', channel, args);
func(...args);
});
} else {
logger.warn(`[Preload] Attempted to receive on invalid channel: ${channel}`);
}
},
once: (channel: string, func: (...args: unknown[]) => void) => {
if (VALID_CHANNELS.receive.includes(channel as ValidReceiveChannel)) {
logger.debug('IPC Once:', channel);
ipcRenderer.once(channel, (_event, ...args) => {
logger.debug('IPC Received Once:', channel, args);
func(...args);
});
} else {
logger.warn(`[Preload] Attempted to receive once on invalid channel: ${channel}`);
}
},
invoke: async (channel: string, ...args: unknown[]) => {
if (VALID_CHANNELS.invoke.includes(channel as ValidInvokeChannel)) {
logger.debug('IPC Invoke:', channel, args);
try {
const result = await ipcRenderer.invoke(channel, ...args);
logger.debug('IPC Invoke Result:', channel, result);
return result;
} catch (error) {
logger.error('IPC Invoke Error:', channel, error);
throw error;
}
} else {
logger.warn(`[Preload] Attempted to invoke on invalid channel: ${channel}`);
throw new Error(`Invalid channel: ${channel}`);
}
}
};
};
// Create SQLite proxy with retry logic
const createSQLiteProxy = () => {
const MAX_RETRIES = 3;
const RETRY_DELAY = 1000;
const withRetry = async <T>(operation: string, ...args: unknown[]): Promise<T> => {
let lastError: Error | undefined;
const operationId = `${operation}_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
const startTime = Date.now();
logger.sqlite.debug(operation, 'starting with args:', {
operationId,
args,
timestamp: new Date().toISOString()
});
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
try {
logger.sqlite.debug(operation, `attempt ${attempt}/${MAX_RETRIES}`, {
operationId,
attempt,
args,
timestamp: new Date().toISOString()
});
// Log the exact IPC call
logger.sqlite.debug(operation, 'invoking IPC', {
operationId,
channel: `sqlite-${operation}`,
args,
timestamp: new Date().toISOString()
});
const result = await ipcRenderer.invoke(`sqlite-${operation}`, ...args);
const duration = Date.now() - startTime;
logger.sqlite.log(operation, 'success', {
operationId,
attempt,
result,
duration: `${duration}ms`,
timestamp: new Date().toISOString()
});
return result as T;
} catch (error) {
const duration = Date.now() - startTime;
lastError = error instanceof Error ? error : new Error(String(error));
logger.sqlite.error(operation, {
operationId,
attempt,
error: {
name: lastError.name,
message: lastError.message,
stack: lastError.stack
},
args,
duration: `${duration}ms`,
timestamp: new Date().toISOString()
});
if (attempt < MAX_RETRIES) {
const backoffDelay = RETRY_DELAY * Math.pow(2, attempt - 1);
logger.warn(`[Preload] SQLite ${operation} failed (attempt ${attempt}/${MAX_RETRIES}), retrying in ${backoffDelay}ms...`, {
operationId,
error: lastError,
args,
nextAttemptIn: `${backoffDelay}ms`,
timestamp: new Date().toISOString()
});
await new Promise(resolve => setTimeout(resolve, backoffDelay));
}
}
}
const finalError = new Error(
`SQLite ${operation} failed after ${MAX_RETRIES} attempts: ${lastError?.message || "Unknown error"}`
);
logger.error('[Preload] SQLite operation failed permanently:', {
operation,
operationId,
error: {
name: finalError.name,
message: finalError.message,
stack: finalError.stack,
originalError: lastError
},
args,
attempts: MAX_RETRIES,
timestamp: new Date().toISOString()
});
throw finalError;
};
return {
isAvailable: () => withRetry('is-available'),
echo: (value: string) => withRetry('echo', { value }),
createConnection: (options: SQLiteConnectionOptions) => withRetry('create-connection', options),
closeConnection: (options: { database: string }) => withRetry('close-connection', options),
query: (options: { statement: string; values?: unknown[] }) => withRetry('query', options),
run: (options: { statement: string; values?: unknown[] }) => withRetry('run', options),
execute: (options: { statements: { statement: string; values?: unknown[] }[] }) => withRetry('execute', options),
getPlatform: () => Promise.resolve('electron')
};
};
try {
// Expose the secure IPC bridge and SQLite proxy
const electronAPI = {
ipcRenderer: createSecureIPCBridge(),
sqlite: createSQLiteProxy(),
env: {
platform: 'electron',
isDev: process.env.NODE_ENV === 'development'
}
};
// Log the exposed API for debugging
logger.debug('Exposing Electron API:', {
hasIpcRenderer: !!electronAPI.ipcRenderer,
hasSqlite: !!electronAPI.sqlite,
sqliteMethods: Object.keys(electronAPI.sqlite),
env: electronAPI.env
});
contextBridge.exposeInMainWorld('electron', electronAPI);
logger.info('[Preload] IPC bridge and SQLite proxy initialized successfully');
} catch (error) {
logger.error('[Preload] Failed to initialize IPC bridge:', error);
}
// Log startup
logger.log('[CapacitorSQLite] Preload script starting...');
// Handle window load
window.addEventListener('load', () => {
logger.log('[CapacitorSQLite] Preload script complete');
});

View File

@@ -1,6 +0,0 @@
/* eslint-disable @typescript-eslint/no-var-requires */
const CapacitorCommunitySqlite = require('../../../node_modules/@capacitor-community/sqlite/electron/dist/plugin.js');
module.exports = {
CapacitorCommunitySqlite,
}

View File

@@ -1,88 +0,0 @@
import { randomBytes } from 'crypto';
import { ipcRenderer, contextBridge } from 'electron';
import { EventEmitter } from 'events';
////////////////////////////////////////////////////////
// eslint-disable-next-line @typescript-eslint/no-var-requires
const plugins = require('./electron-plugins');
const randomId = (length = 5) => randomBytes(length).toString('hex');
const contextApi: {
[plugin: string]: { [functionName: string]: () => Promise<any> };
} = {};
Object.keys(plugins).forEach((pluginKey) => {
Object.keys(plugins[pluginKey])
.filter((className) => className !== 'default')
.forEach((classKey) => {
const functionList = Object.getOwnPropertyNames(plugins[pluginKey][classKey].prototype).filter(
(v) => v !== 'constructor'
);
if (!contextApi[classKey]) {
contextApi[classKey] = {};
}
functionList.forEach((functionName) => {
if (!contextApi[classKey][functionName]) {
contextApi[classKey][functionName] = (...args) => ipcRenderer.invoke(`${classKey}-${functionName}`, ...args);
}
});
// Events
if (plugins[pluginKey][classKey].prototype instanceof EventEmitter) {
const listeners: { [key: string]: { type: string; listener: (...args: any[]) => void } } = {};
const listenersOfTypeExist = (type) =>
!!Object.values(listeners).find((listenerObj) => listenerObj.type === type);
Object.assign(contextApi[classKey], {
addListener(type: string, callback: (...args) => void) {
const id = randomId();
// Deduplicate events
if (!listenersOfTypeExist(type)) {
ipcRenderer.send(`event-add-${classKey}`, type);
}
const eventHandler = (_, ...args) => callback(...args);
ipcRenderer.addListener(`event-${classKey}-${type}`, eventHandler);
listeners[id] = { type, listener: eventHandler };
return id;
},
removeListener(id: string) {
if (!listeners[id]) {
throw new Error('Invalid id');
}
const { type, listener } = listeners[id];
ipcRenderer.removeListener(`event-${classKey}-${type}`, listener);
delete listeners[id];
if (!listenersOfTypeExist(type)) {
ipcRenderer.send(`event-remove-${classKey}-${type}`);
}
},
removeAllListeners(type: string) {
Object.entries(listeners).forEach(([id, listenerObj]) => {
if (!type || listenerObj.type === type) {
ipcRenderer.removeListener(`event-${classKey}-${listenerObj.type}`, listenerObj.listener);
ipcRenderer.send(`event-remove-${classKey}-${listenerObj.type}`);
delete listeners[id];
}
});
},
});
}
});
});
contextBridge.exposeInMainWorld('CapacitorCustomPlatform', {
name: 'electron',
plugins: contextApi,
});
////////////////////////////////////////////////////////

View File

@@ -1,188 +0,0 @@
/**
* Enhanced logging system for TimeSafari Electron
* Provides structured logging with proper levels and formatting
* Supports both console and file output with different verbosity levels
*
* @author Matthew Raymer
*/
import { app, ipcMain } from 'electron';
import winston from 'winston';
import path from 'path';
import os from 'os';
import fs from 'fs';
// Extend Winston Logger type with our custom loggers
declare module 'winston' {
interface Logger {
sqlite: {
debug: (message: string, ...args: unknown[]) => void;
info: (message: string, ...args: unknown[]) => void;
warn: (message: string, ...args: unknown[]) => void;
error: (message: string, ...args: unknown[]) => void;
};
migration: {
debug: (message: string, ...args: unknown[]) => void;
info: (message: string, ...args: unknown[]) => void;
warn: (message: string, ...args: unknown[]) => void;
error: (message: string, ...args: unknown[]) => void;
};
}
}
// Create logs directory if it doesn't exist
const logsDir = path.join(app.getPath('userData'), 'logs');
if (!fs.existsSync(logsDir)) {
fs.mkdirSync(logsDir, { recursive: true });
}
// Custom format for console output with migration filtering
const consoleFormat = winston.format.combine(
winston.format.timestamp(),
winston.format.colorize(),
winston.format.printf(({ level, message, timestamp, ...metadata }) => {
// Skip migration logs unless DEBUG_MIGRATIONS is set
if (level === 'info' &&
typeof message === 'string' &&
message.includes('[Migration]') &&
!process.env.DEBUG_MIGRATIONS) {
return '';
}
let msg = `${timestamp} [${level}] ${message}`;
if (Object.keys(metadata).length > 0) {
msg += ` ${JSON.stringify(metadata, null, 2)}`;
}
return msg;
})
);
// Custom format for file output
const fileFormat = winston.format.combine(
winston.format.timestamp(),
winston.format.json()
);
// Create logger instance
const logger = winston.createLogger({
level: process.env.NODE_ENV === 'development' ? 'debug' : 'info',
format: fileFormat,
defaultMeta: { service: 'timesafari-electron' },
transports: [
// Console transport with custom format and migration filtering
new winston.transports.Console({
format: consoleFormat,
level: process.env.NODE_ENV === 'development' ? 'debug' : 'info',
silent: false // Ensure we can still see non-migration logs
}),
// File transport for all logs
new winston.transports.File({
filename: path.join(logsDir, 'error.log'),
level: 'error',
maxsize: 5242880, // 5MB
maxFiles: 5
}),
// File transport for all logs including debug
new winston.transports.File({
filename: path.join(logsDir, 'combined.log'),
maxsize: 5242880, // 5MB
maxFiles: 5
})
]
}) as winston.Logger & {
sqlite: {
debug: (message: string, ...args: unknown[]) => void;
info: (message: string, ...args: unknown[]) => void;
warn: (message: string, ...args: unknown[]) => void;
error: (message: string, ...args: unknown[]) => void;
};
migration: {
debug: (message: string, ...args: unknown[]) => void;
info: (message: string, ...args: unknown[]) => void;
warn: (message: string, ...args: unknown[]) => void;
error: (message: string, ...args: unknown[]) => void;
};
};
// Add SQLite specific logger
logger.sqlite = {
debug: (message: string, ...args: unknown[]) => {
logger.debug(`[SQLite] ${message}`, ...args);
},
info: (message: string, ...args: unknown[]) => {
logger.info(`[SQLite] ${message}`, ...args);
},
warn: (message: string, ...args: unknown[]) => {
logger.warn(`[SQLite] ${message}`, ...args);
},
error: (message: string, ...args: unknown[]) => {
logger.error(`[SQLite] ${message}`, ...args);
}
};
// Add migration specific logger with debug filtering
logger.migration = {
debug: (message: string, ...args: unknown[]) => {
if (process.env.DEBUG_MIGRATIONS) {
//logger.debug(`[Migration] ${message}`, ...args);
}
},
info: (message: string, ...args: unknown[]) => {
// Always log to file, but only log to console if DEBUG_MIGRATIONS is set
if (process.env.DEBUG_MIGRATIONS) {
//logger.info(`[Migration] ${message}`, ...args);
} else {
// Use a separate transport for migration logs to file only
const metadata = args[0] as Record<string, unknown>;
logger.write({
level: 'info',
message: `[Migration] ${message}`,
...(metadata || {})
});
}
},
warn: (message: string, ...args: unknown[]) => {
// Always log warnings to both console and file
//logger.warn(`[Migration] ${message}`, ...args);
},
error: (message: string, ...args: unknown[]) => {
// Always log errors to both console and file
//logger.error(`[Migration] ${message}`, ...args);
}
};
// Add renderer log handler
ipcMain.on('renderer-log', (_event, { level, args, source, operation, error }) => {
const message = args.map((arg: unknown) =>
typeof arg === 'object' ? JSON.stringify(arg, null, 2) : String(arg)
).join(' ');
const meta = {
source: source || 'renderer',
...(operation && { operation }),
...(error && { error })
};
switch (level) {
case 'error':
logger.error(message, meta);
break;
case 'warn':
logger.warn(message, meta);
break;
case 'info':
logger.info(message, meta);
break;
case 'debug':
logger.debug(message, meta);
break;
default:
logger.log(level, message, meta);
}
});
// Export logger instance
export { logger };
// Export a function to get the logs directory
export const getLogsDirectory = () => logsDir;

View File

@@ -1,14 +0,0 @@
/**
* Custom error class for SQLite operations
* Provides additional context and error tracking for SQLite operations
*/
export class SQLiteError extends Error {
constructor(
message: string,
public operation: string,
public cause?: unknown
) {
super(message);
this.name = 'SQLiteError';
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,442 +0,0 @@
import type { CapacitorElectronConfig } from '@capacitor-community/electron';
import {
CapElectronEventEmitter,
CapacitorSplashScreen,
setupCapacitorElectronPlugins,
} from '@capacitor-community/electron';
import chokidar from 'chokidar';
import type { MenuItemConstructorOptions } from 'electron';
import { app, BrowserWindow, Menu, MenuItem, nativeImage, Tray, session } from 'electron';
import electronIsDev from 'electron-is-dev';
import electronServe from 'electron-serve';
import windowStateKeeper from 'electron-window-state';
import { join } from 'path';
/**
* Reload watcher configuration and state management
* Prevents infinite reload loops and implements rate limiting
* Also prevents reloads during critical database operations
*
* @author Matthew Raymer
*/
const RELOAD_CONFIG = {
DEBOUNCE_MS: 1500,
COOLDOWN_MS: 5000,
MAX_RELOADS_PER_MINUTE: 10,
MAX_RELOADS_PER_SESSION: 100,
DATABASE_OPERATION_TIMEOUT_MS: 10000 // 10 second timeout for database operations
};
// Track database operation state
let isDatabaseOperationInProgress = false;
let lastDatabaseOperationTime = 0;
/**
* Checks if a database operation is in progress or recently completed
* @returns {boolean} Whether a database operation is active
*/
const isDatabaseOperationActive = (): boolean => {
const now = Date.now();
return isDatabaseOperationInProgress ||
(now - lastDatabaseOperationTime < RELOAD_CONFIG.DATABASE_OPERATION_TIMEOUT_MS);
};
/**
* Marks the start of a database operation
*/
export const startDatabaseOperation = (): void => {
isDatabaseOperationInProgress = true;
lastDatabaseOperationTime = Date.now();
};
/**
* Marks the end of a database operation
*/
export const endDatabaseOperation = (): void => {
isDatabaseOperationInProgress = false;
lastDatabaseOperationTime = Date.now();
};
const reloadWatcher = {
debouncer: null as NodeJS.Timeout | null,
ready: false,
watcher: null as chokidar.FSWatcher | null,
lastReloadTime: 0,
reloadCount: 0,
sessionReloadCount: 0,
resetTimeout: null as NodeJS.Timeout | null,
isReloading: false
};
/**
* Resets the reload counter after one minute
*/
const resetReloadCounter = () => {
reloadWatcher.reloadCount = 0;
reloadWatcher.resetTimeout = null;
};
/**
* Checks if a reload is allowed based on rate limits, cooldown, and database state
* @returns {boolean} Whether a reload is allowed
*/
const canReload = (): boolean => {
const now = Date.now();
// Check if database operation is active
if (isDatabaseOperationActive()) {
console.warn('[Reload Watcher] Skipping reload - database operation in progress');
return false;
}
// Check cooldown period
if (now - reloadWatcher.lastReloadTime < RELOAD_CONFIG.COOLDOWN_MS) {
console.warn('[Reload Watcher] Skipping reload - cooldown period active');
return false;
}
// Check per-minute limit
if (reloadWatcher.reloadCount >= RELOAD_CONFIG.MAX_RELOADS_PER_MINUTE) {
console.warn('[Reload Watcher] Skipping reload - maximum reloads per minute reached');
return false;
}
// Check session limit
if (reloadWatcher.sessionReloadCount >= RELOAD_CONFIG.MAX_RELOADS_PER_SESSION) {
console.error('[Reload Watcher] Maximum reloads per session reached. Please restart the application.');
return false;
}
return true;
};
/**
* Cleans up the current watcher instance
*/
const cleanupWatcher = () => {
if (reloadWatcher.watcher) {
reloadWatcher.watcher.close();
reloadWatcher.watcher = null;
}
if (reloadWatcher.debouncer) {
clearTimeout(reloadWatcher.debouncer);
reloadWatcher.debouncer = null;
}
if (reloadWatcher.resetTimeout) {
clearTimeout(reloadWatcher.resetTimeout);
reloadWatcher.resetTimeout = null;
}
};
/**
* Sets up the file watcher for development mode reloading
* Implements rate limiting and prevents infinite reload loops
*
* @param electronCapacitorApp - The Electron Capacitor app instance
*/
export function setupReloadWatcher(electronCapacitorApp: ElectronCapacitorApp): void {
// Cleanup any existing watcher
cleanupWatcher();
// Reset state
reloadWatcher.ready = false;
reloadWatcher.isReloading = false;
reloadWatcher.watcher = chokidar
.watch(join(app.getAppPath(), 'app'), {
ignored: /[/\\]\./,
persistent: true,
awaitWriteFinish: {
stabilityThreshold: 1000,
pollInterval: 100
}
})
.on('ready', () => {
reloadWatcher.ready = true;
console.log('[Reload Watcher] Ready to watch for changes');
})
.on('all', (_event, _path) => {
if (!reloadWatcher.ready || reloadWatcher.isReloading) {
return;
}
// Clear existing debouncer
if (reloadWatcher.debouncer) {
clearTimeout(reloadWatcher.debouncer);
}
// Set up new debouncer
reloadWatcher.debouncer = setTimeout(async () => {
if (!canReload()) {
return;
}
try {
reloadWatcher.isReloading = true;
// Update reload counters
reloadWatcher.lastReloadTime = Date.now();
reloadWatcher.reloadCount++;
reloadWatcher.sessionReloadCount++;
// Set up reset timeout for per-minute counter
if (!reloadWatcher.resetTimeout) {
reloadWatcher.resetTimeout = setTimeout(resetReloadCounter, 60000);
}
// Perform reload
console.log('[Reload Watcher] Reloading window...');
await electronCapacitorApp.getMainWindow().webContents.reload();
// Reset state after reload
reloadWatcher.ready = false;
reloadWatcher.isReloading = false;
// Re-setup watcher after successful reload
setupReloadWatcher(electronCapacitorApp);
} catch (error) {
console.error('[Reload Watcher] Error during reload:', error);
reloadWatcher.isReloading = false;
reloadWatcher.ready = true;
}
}, RELOAD_CONFIG.DEBOUNCE_MS);
})
.on('error', (error) => {
console.error('[Reload Watcher] Error:', error);
cleanupWatcher();
});
}
// Define our class to manage our app.
export class ElectronCapacitorApp {
private MainWindow: BrowserWindow | null = null;
private SplashScreen: CapacitorSplashScreen | null = null;
private TrayIcon: Tray | null = null;
private CapacitorFileConfig: CapacitorElectronConfig;
private TrayMenuTemplate: (MenuItem | MenuItemConstructorOptions)[] = [
new MenuItem({ label: 'Quit App', role: 'quit' }),
];
private AppMenuBarMenuTemplate: (MenuItem | MenuItemConstructorOptions)[] = [
{ role: process.platform === 'darwin' ? 'appMenu' : 'fileMenu' },
{ role: 'viewMenu' },
];
private mainWindowState;
private loadWebApp;
private customScheme: string;
constructor(
capacitorFileConfig: CapacitorElectronConfig,
trayMenuTemplate?: (MenuItemConstructorOptions | MenuItem)[],
appMenuBarMenuTemplate?: (MenuItemConstructorOptions | MenuItem)[]
) {
this.CapacitorFileConfig = capacitorFileConfig;
this.customScheme = this.CapacitorFileConfig.electron?.customUrlScheme ?? 'capacitor-electron';
if (trayMenuTemplate) {
this.TrayMenuTemplate = trayMenuTemplate;
}
if (appMenuBarMenuTemplate) {
this.AppMenuBarMenuTemplate = appMenuBarMenuTemplate;
}
// Setup our web app loader, this lets us load apps like react, vue, and angular without changing their build chains.
this.loadWebApp = electronServe({
directory: join(app.getAppPath(), 'app'),
scheme: this.customScheme,
});
}
// Helper function to load in the app.
private async loadMainWindow(thisRef: any) {
await thisRef.loadWebApp(thisRef.MainWindow);
}
// Expose the mainWindow ref for use outside of the class.
getMainWindow(): BrowserWindow {
return this.MainWindow;
}
getCustomURLScheme(): string {
return this.customScheme;
}
async init(): Promise<void> {
const icon = nativeImage.createFromPath(
join(app.getAppPath(), 'assets', process.platform === 'win32' ? 'appIcon.ico' : 'appIcon.png')
);
this.mainWindowState = windowStateKeeper({
defaultWidth: 1000,
defaultHeight: 800,
});
// Setup preload script path based on environment
const preloadPath = app.isPackaged
? join(process.resourcesPath, 'preload.js')
: join(__dirname, 'preload.js');
console.log('[Electron Main Process] Preload path:', preloadPath);
console.log('[Electron Main Process] Preload exists:', require('fs').existsSync(preloadPath));
this.MainWindow = new BrowserWindow({
icon,
show: false,
x: this.mainWindowState.x,
y: this.mainWindowState.y,
width: this.mainWindowState.width,
height: this.mainWindowState.height,
webPreferences: {
nodeIntegration: false,
contextIsolation: true,
sandbox: false,
preload: preloadPath,
webSecurity: true,
allowRunningInsecureContent: false,
},
});
this.mainWindowState.manage(this.MainWindow);
if (this.CapacitorFileConfig.backgroundColor) {
this.MainWindow.setBackgroundColor(this.CapacitorFileConfig.electron.backgroundColor);
}
// If we close the main window with the splashscreen enabled we need to destory the ref.
this.MainWindow.on('closed', () => {
if (this.SplashScreen?.getSplashWindow() && !this.SplashScreen.getSplashWindow().isDestroyed()) {
this.SplashScreen.getSplashWindow().close();
}
});
// When the tray icon is enabled, setup the options.
if (this.CapacitorFileConfig.electron?.trayIconAndMenuEnabled) {
this.TrayIcon = new Tray(icon);
this.TrayIcon.on('double-click', () => {
if (this.MainWindow) {
if (this.MainWindow.isVisible()) {
this.MainWindow.hide();
} else {
this.MainWindow.show();
this.MainWindow.focus();
}
}
});
this.TrayIcon.on('click', () => {
if (this.MainWindow) {
if (this.MainWindow.isVisible()) {
this.MainWindow.hide();
} else {
this.MainWindow.show();
this.MainWindow.focus();
}
}
});
this.TrayIcon.setToolTip(app.getName());
this.TrayIcon.setContextMenu(Menu.buildFromTemplate(this.TrayMenuTemplate));
}
// Setup the main manu bar at the top of our window.
Menu.setApplicationMenu(Menu.buildFromTemplate(this.AppMenuBarMenuTemplate));
// If the splashscreen is enabled, show it first while the main window loads then switch it out for the main window, or just load the main window from the start.
if (this.CapacitorFileConfig.electron?.splashScreenEnabled) {
this.SplashScreen = new CapacitorSplashScreen({
imageFilePath: join(
app.getAppPath(),
'assets',
this.CapacitorFileConfig.electron?.splashScreenImageName ?? 'splash.png'
),
windowWidth: 400,
windowHeight: 400,
});
this.SplashScreen.init(this.loadMainWindow, this);
} else {
this.loadMainWindow(this);
}
// Security
this.MainWindow.webContents.setWindowOpenHandler((details) => {
if (!details.url.includes(this.customScheme)) {
return { action: 'deny' };
} else {
return { action: 'allow' };
}
});
this.MainWindow.webContents.on('will-navigate', (event, _newURL) => {
if (!this.MainWindow.webContents.getURL().includes(this.customScheme)) {
event.preventDefault();
}
});
// Link electron plugins into the system.
setupCapacitorElectronPlugins();
// When the web app is loaded we hide the splashscreen if needed and show the mainwindow.
this.MainWindow.webContents.on('dom-ready', () => {
if (this.CapacitorFileConfig.electron?.splashScreenEnabled) {
this.SplashScreen.getSplashWindow().hide();
}
if (!this.CapacitorFileConfig.electron?.hideMainWindowOnLaunch) {
this.MainWindow.show();
}
// Re-register SQLite handlers after reload
if (electronIsDev) {
console.log('[Electron Main Process] Re-registering SQLite handlers after reload');
const { setupSQLiteHandlers } = require('./rt/sqlite-init');
setupSQLiteHandlers();
}
setTimeout(() => {
if (electronIsDev) {
this.MainWindow.webContents.openDevTools();
}
CapElectronEventEmitter.emit('CAPELECTRON_DeeplinkListenerInitialized', '');
}, 400);
});
}
}
// Set a CSP up for our application based on the custom scheme
export function setupContentSecurityPolicy(customScheme: string): void {
session.defaultSession.webRequest.onHeadersReceived((details, callback) => {
callback({
responseHeaders: {
...details.responseHeaders,
'Content-Security-Policy': [
// Base CSP for both dev and prod
`default-src ${customScheme}://*;`,
// Script sources
`script-src ${customScheme}://* 'self' 'unsafe-inline'${electronIsDev ? " 'unsafe-eval'" : ''};`,
// Style sources
`style-src ${customScheme}://* 'self' 'unsafe-inline' https://fonts.googleapis.com;`,
// Font sources
`font-src ${customScheme}://* 'self' https://fonts.gstatic.com;`,
// Image sources
`img-src ${customScheme}://* 'self' data: https:;`,
// Connect sources (for API calls)
`connect-src ${customScheme}://* 'self' https:;`,
// Worker sources
`worker-src ${customScheme}://* 'self' blob:;`,
// Frame sources
`frame-src ${customScheme}://* 'self';`,
// Media sources
`media-src ${customScheme}://* 'self' data:;`,
// Object sources
`object-src 'none';`,
// Base URI
`base-uri 'self';`,
// Form action
`form-action ${customScheme}://* 'self';`,
// Frame ancestors
`frame-ancestors 'none';`,
// Upgrade insecure requests
'upgrade-insecure-requests;',
// Block mixed content
'block-all-mixed-content;'
].join(' ')
},
});
});
}

View File

@@ -1,18 +0,0 @@
{
"compileOnSave": true,
"include": ["./src/**/*", "./capacitor.config.ts", "./capacitor.config.js"],
"compilerOptions": {
"outDir": "./build",
"importHelpers": true,
"target": "ES2020",
"module": "CommonJS",
"moduleResolution": "node",
"esModuleInterop": true,
"typeRoots": ["./node_modules/@types"],
"allowJs": true,
"rootDir": ".",
"skipLibCheck": true,
"resolveJsonModule": true
}
}

View File

@@ -1,155 +0,0 @@
#!/bin/bash
# experiment.sh
# Author: Matthew Raymer
# Description: Build script for TimeSafari Electron application
# This script handles the complete build process for the TimeSafari Electron app,
# including web asset compilation and Capacitor sync.
#
# Build Process:
# 1. Environment setup and dependency checks
# 2. Web asset compilation (Vite)
# 3. Capacitor sync
# 4. Electron start
#
# Dependencies:
# - Node.js and npm
# - TypeScript
# - Vite
# - @capacitor-community/electron
#
# Usage: ./experiment.sh
#
# Exit Codes:
# 1 - Required command not found
# 2 - TypeScript installation failed
# 3 - Build process failed
# 4 - Capacitor sync failed
# 5 - Electron start failed
# Exit on any error
set -e
# ANSI color codes for better output formatting
readonly RED='\033[0;31m'
readonly GREEN='\033[0;32m'
readonly YELLOW='\033[1;33m'
readonly BLUE='\033[0;34m'
readonly NC='\033[0m' # No Color
# Logging functions
log_info() {
echo -e "${BLUE}[$(date '+%Y-%m-%d %H:%M:%S')] [INFO]${NC} $1"
}
log_success() {
echo -e "${GREEN}[$(date '+%Y-%m-%d %H:%M:%S')] [SUCCESS]${NC} $1"
}
log_warn() {
echo -e "${YELLOW}[$(date '+%Y-%m-%d %H:%M:%S')] [WARN]${NC} $1"
}
log_error() {
echo -e "${RED}[$(date '+%Y-%m-%d %H:%M:%S')] [ERROR]${NC} $1"
}
# Function to check if a command exists
check_command() {
if ! command -v "$1" &> /dev/null; then
log_error "$1 is required but not installed."
exit 1
fi
log_info "Found $1: $(command -v "$1")"
}
# Function to measure and log execution time
measure_time() {
local start_time=$(date +%s)
"$@"
local end_time=$(date +%s)
local duration=$((end_time - start_time))
log_success "Completed in ${duration} seconds"
}
# Print build header
echo -e "\n${BLUE}=== TimeSafari Electron Build Process ===${NC}\n"
log_info "Starting build process at $(date)"
# Check required commands
log_info "Checking required dependencies..."
check_command node
check_command npm
check_command git
# Create application data directory
log_info "Setting up application directories..."
mkdir -p ~/.local/share/TimeSafari/timesafari
# Clean up previous builds
log_info "Cleaning previous builds..."
rm -rf dist* || log_warn "No previous builds to clean"
# Set environment variables for the build
log_info "Configuring build environment..."
export VITE_PLATFORM=electron
export VITE_PWA_ENABLED=false
export VITE_DISABLE_PWA=true
export DEBUG_MIGRATIONS=0
# Ensure TypeScript is installed
log_info "Verifying TypeScript installation..."
if [ ! -f "./node_modules/.bin/tsc" ]; then
log_info "Installing TypeScript..."
if ! npm install --save-dev typescript@~5.2.2; then
log_error "TypeScript installation failed!"
exit 2
fi
# Verify installation
if [ ! -f "./node_modules/.bin/tsc" ]; then
log_error "TypeScript installation verification failed!"
exit 2
fi
log_success "TypeScript installed successfully"
else
log_info "TypeScript already installed"
fi
# Get git hash for versioning
GIT_HASH=$(git log -1 --pretty=format:%h)
log_info "Using git hash: ${GIT_HASH}"
# Build web assets
log_info "Building web assets with Vite..."
if ! measure_time env VITE_GIT_HASH="$GIT_HASH" npx vite build --config vite.config.app.electron.mts --mode electron; then
log_error "Web asset build failed!"
exit 3
fi
# Sync with Capacitor
log_info "Syncing with Capacitor..."
if ! measure_time npx cap sync electron; then
log_error "Capacitor sync failed!"
exit 4
fi
# Restore capacitor config
log_info "Restoring capacitor config..."
if ! git checkout electron/capacitor.config.json; then
log_error "Failed to restore capacitor config!"
exit 4
fi
# Start Electron
log_info "Starting Electron..."
cd electron/
if ! measure_time npm run electron:start; then
log_error "Electron start failed!"
exit 5
fi
# Print build summary
log_success "Build and start completed successfully!"
echo -e "\n${GREEN}=== End of Build Process ===${NC}\n"
# Exit with success
exit 0

13
ios/.gitignore vendored
View File

@@ -11,3 +11,16 @@ capacitor-cordova-ios-plugins
# Generated Config files # Generated Config files
App/App/capacitor.config.json App/App/capacitor.config.json
App/App/config.xml App/App/config.xml
# User-specific Xcode files
App/App.xcodeproj/xcuserdata/*.xcuserdatad/
App/App.xcodeproj/*.xcuserstate
fastlane/report.xml
fastlane/Preview.html
fastlane/screenshots
fastlane/test_output
# Generated Icons from capacitor-assets (also Contents.json which is confusing; see BUILDING.md)
App/App/Assets.xcassets/AppIcon.appiconset
App/App/Assets.xcassets/Splash.imageset

View File

@@ -14,7 +14,7 @@
504EC30F1FED79650016851F /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 504EC30E1FED79650016851F /* Assets.xcassets */; }; 504EC30F1FED79650016851F /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 504EC30E1FED79650016851F /* Assets.xcassets */; };
504EC3121FED79650016851F /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 504EC3101FED79650016851F /* LaunchScreen.storyboard */; }; 504EC3121FED79650016851F /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 504EC3101FED79650016851F /* LaunchScreen.storyboard */; };
50B271D11FEDC1A000F3C39B /* public in Resources */ = {isa = PBXBuildFile; fileRef = 50B271D01FEDC1A000F3C39B /* public */; }; 50B271D11FEDC1A000F3C39B /* public in Resources */ = {isa = PBXBuildFile; fileRef = 50B271D01FEDC1A000F3C39B /* public */; };
A084ECDBA7D38E1E42DFC39D /* Pods_App.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = AF277DCFFFF123FFC6DF26C7 /* Pods_App.framework */; }; 97EF2DC6FD76C3643D680B8D /* Pods_App.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 90DCAFB4D8948F7A50C13800 /* Pods_App.framework */; };
/* End PBXBuildFile section */ /* End PBXBuildFile section */
/* Begin PBXFileReference section */ /* Begin PBXFileReference section */
@@ -27,9 +27,9 @@
504EC3111FED79650016851F /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; }; 504EC3111FED79650016851F /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
504EC3131FED79650016851F /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; }; 504EC3131FED79650016851F /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
50B271D01FEDC1A000F3C39B /* public */ = {isa = PBXFileReference; lastKnownFileType = folder; path = public; sourceTree = "<group>"; }; 50B271D01FEDC1A000F3C39B /* public */ = {isa = PBXFileReference; lastKnownFileType = folder; path = public; sourceTree = "<group>"; };
AF277DCFFFF123FFC6DF26C7 /* Pods_App.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_App.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 90DCAFB4D8948F7A50C13800 /* Pods_App.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_App.framework; sourceTree = BUILT_PRODUCTS_DIR; };
AF51FD2D460BCFE21FA515B2 /* Pods-App.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App.release.xcconfig"; path = "Pods/Target Support Files/Pods-App/Pods-App.release.xcconfig"; sourceTree = "<group>"; }; E2E9297D5D02C549106C77F9 /* Pods-App.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App.release.xcconfig"; path = "Target Support Files/Pods-App/Pods-App.release.xcconfig"; sourceTree = "<group>"; };
FC68EB0AF532CFC21C3344DD /* Pods-App.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App.debug.xcconfig"; path = "Pods/Target Support Files/Pods-App/Pods-App.debug.xcconfig"; sourceTree = "<group>"; }; EAEC6436E595F7CD3A1C9E96 /* Pods-App.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App.debug.xcconfig"; path = "Target Support Files/Pods-App/Pods-App.debug.xcconfig"; sourceTree = "<group>"; };
/* End PBXFileReference section */ /* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */ /* Begin PBXFrameworksBuildPhase section */
@@ -37,17 +37,17 @@
isa = PBXFrameworksBuildPhase; isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
A084ECDBA7D38E1E42DFC39D /* Pods_App.framework in Frameworks */, 97EF2DC6FD76C3643D680B8D /* Pods_App.framework in Frameworks */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
/* End PBXFrameworksBuildPhase section */ /* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */ /* Begin PBXGroup section */
27E2DDA53C4D2A4D1A88CE4A /* Frameworks */ = { 4B546315E668C7A13939F417 /* Frameworks */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
AF277DCFFFF123FFC6DF26C7 /* Pods_App.framework */, 90DCAFB4D8948F7A50C13800 /* Pods_App.framework */,
); );
name = Frameworks; name = Frameworks;
sourceTree = "<group>"; sourceTree = "<group>";
@@ -57,8 +57,8 @@
children = ( children = (
504EC3061FED79650016851F /* App */, 504EC3061FED79650016851F /* App */,
504EC3051FED79650016851F /* Products */, 504EC3051FED79650016851F /* Products */,
7F8756D8B27F46E3366F6CEA /* Pods */, BA325FFCDCE8D334E5C7AEBE /* Pods */,
27E2DDA53C4D2A4D1A88CE4A /* Frameworks */, 4B546315E668C7A13939F417 /* Frameworks */,
); );
sourceTree = "<group>"; sourceTree = "<group>";
}; };
@@ -85,13 +85,13 @@
path = App; path = App;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
7F8756D8B27F46E3366F6CEA /* Pods */ = { BA325FFCDCE8D334E5C7AEBE /* Pods */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
FC68EB0AF532CFC21C3344DD /* Pods-App.debug.xcconfig */, EAEC6436E595F7CD3A1C9E96 /* Pods-App.debug.xcconfig */,
AF51FD2D460BCFE21FA515B2 /* Pods-App.release.xcconfig */, E2E9297D5D02C549106C77F9 /* Pods-App.release.xcconfig */,
); );
name = Pods; path = Pods;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
/* End PBXGroup section */ /* End PBXGroup section */
@@ -101,12 +101,13 @@
isa = PBXNativeTarget; isa = PBXNativeTarget;
buildConfigurationList = 504EC3161FED79650016851F /* Build configuration list for PBXNativeTarget "App" */; buildConfigurationList = 504EC3161FED79650016851F /* Build configuration list for PBXNativeTarget "App" */;
buildPhases = ( buildPhases = (
6634F4EFEBD30273BCE97C65 /* [CP] Check Pods Manifest.lock */, 92977BEA1068CC097A57FC77 /* [CP] Check Pods Manifest.lock */,
504EC3001FED79650016851F /* Sources */, 504EC3001FED79650016851F /* Sources */,
504EC3011FED79650016851F /* Frameworks */, 504EC3011FED79650016851F /* Frameworks */,
504EC3021FED79650016851F /* Resources */, 504EC3021FED79650016851F /* Resources */,
9592DBEFFC6D2A0C8D5DEB22 /* [CP] Embed Pods Frameworks */,
012076E8FFE4BF260A79B034 /* Fix Privacy Manifest */, 012076E8FFE4BF260A79B034 /* Fix Privacy Manifest */,
3525031ED1C96EF4CF6E9959 /* [CP] Embed Pods Frameworks */,
96A7EF592DF3366D00084D51 /* Fix Privacy Manifest */,
); );
buildRules = ( buildRules = (
); );
@@ -186,28 +187,10 @@
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh; shellPath = /bin/sh;
shellScript = "\"${PROJECT_DIR}/app_privacy_manifest_fixer/fixer.sh\" "; shellScript = "\"${PROJECT_DIR}/app_privacy_manifest_fixer/fixer.sh\" \n";
showEnvVarsInLog = 0; showEnvVarsInLog = 0;
}; };
6634F4EFEBD30273BCE97C65 /* [CP] Check Pods Manifest.lock */ = { 3525031ED1C96EF4CF6E9959 /* [CP] Embed Pods Frameworks */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputPaths = (
"${PODS_PODFILE_DIR_PATH}/Podfile.lock",
"${PODS_ROOT}/Manifest.lock",
);
name = "[CP] Check Pods Manifest.lock";
outputPaths = (
"$(DERIVED_FILE_DIR)/Pods-App-checkManifestLockResult.txt",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
showEnvVarsInLog = 0;
};
9592DBEFFC6D2A0C8D5DEB22 /* [CP] Embed Pods Frameworks */ = {
isa = PBXShellScriptBuildPhase; isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
@@ -222,6 +205,47 @@
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-App/Pods-App-frameworks.sh\"\n"; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-App/Pods-App-frameworks.sh\"\n";
showEnvVarsInLog = 0; showEnvVarsInLog = 0;
}; };
92977BEA1068CC097A57FC77 /* [CP] Check Pods Manifest.lock */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
);
inputPaths = (
"${PODS_PODFILE_DIR_PATH}/Podfile.lock",
"${PODS_ROOT}/Manifest.lock",
);
name = "[CP] Check Pods Manifest.lock";
outputFileListPaths = (
);
outputPaths = (
"$(DERIVED_FILE_DIR)/Pods-App-checkManifestLockResult.txt",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
showEnvVarsInLog = 0;
};
96A7EF592DF3366D00084D51 /* Fix Privacy Manifest */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
);
inputPaths = (
);
name = "Fix Privacy Manifest";
outputFileListPaths = (
);
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "$PROJECT_DIR/app_privacy_manifest_fixer/fixer.sh\n";
};
/* End PBXShellScriptBuildPhase section */ /* End PBXShellScriptBuildPhase section */
/* Begin PBXSourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */
@@ -375,11 +399,12 @@
}; };
504EC3171FED79650016851F /* Debug */ = { 504EC3171FED79650016851F /* Debug */ = {
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
baseConfigurationReference = FC68EB0AF532CFC21C3344DD /* Pods-App.debug.xcconfig */; baseConfigurationReference = EAEC6436E595F7CD3A1C9E96 /* Pods-App.debug.xcconfig */;
buildSettings = { buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 18; CURRENT_PROJECT_VERSION = 26;
DEVELOPMENT_TEAM = GM3FS5JQPH;
ENABLE_APP_SANDBOX = NO; ENABLE_APP_SANDBOX = NO;
ENABLE_USER_SCRIPT_SANDBOXING = NO; ENABLE_USER_SCRIPT_SANDBOXING = NO;
INFOPLIST_FILE = App/Info.plist; INFOPLIST_FILE = App/Info.plist;
@@ -388,7 +413,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 0.4.7; MARKETING_VERSION = 0.5.1;
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\""; OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
PRODUCT_BUNDLE_IDENTIFIER = app.timesafari; PRODUCT_BUNDLE_IDENTIFIER = app.timesafari;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
@@ -401,11 +426,12 @@
}; };
504EC3181FED79650016851F /* Release */ = { 504EC3181FED79650016851F /* Release */ = {
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
baseConfigurationReference = AF51FD2D460BCFE21FA515B2 /* Pods-App.release.xcconfig */; baseConfigurationReference = E2E9297D5D02C549106C77F9 /* Pods-App.release.xcconfig */;
buildSettings = { buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 18; CURRENT_PROJECT_VERSION = 26;
DEVELOPMENT_TEAM = GM3FS5JQPH;
ENABLE_APP_SANDBOX = NO; ENABLE_APP_SANDBOX = NO;
ENABLE_USER_SCRIPT_SANDBOXING = NO; ENABLE_USER_SCRIPT_SANDBOXING = NO;
INFOPLIST_FILE = App/Info.plist; INFOPLIST_FILE = App/Info.plist;
@@ -414,7 +440,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 0.4.7; MARKETING_VERSION = 0.5.1;
PRODUCT_BUNDLE_IDENTIFIER = app.timesafari; PRODUCT_BUNDLE_IDENTIFIER = app.timesafari;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = ""; SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";

View File

@@ -9,8 +9,8 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
// Initialize SQLite // Initialize SQLite
let sqlite = SQLite() //let sqlite = SQLite()
sqlite.initialize() //sqlite.initialize()
// Override point for customization after application launch. // Override point for customization after application launch.
return true return true

Binary file not shown.

Before

Width:  |  Height:  |  Size: 116 KiB

View File

@@ -1,14 +0,0 @@
{
"images": [
{
"idiom": "universal",
"size": "1024x1024",
"filename": "AppIcon-512@2x.png",
"platform": "ios"
}
],
"info": {
"author": "xcode",
"version": 1
}
}

View File

@@ -1,23 +0,0 @@
{
"images" : [
{
"idiom" : "universal",
"filename" : "splash-2732x2732-2.png",
"scale" : "1x"
},
{
"idiom" : "universal",
"filename" : "splash-2732x2732-1.png",
"scale" : "2x"
},
{
"idiom" : "universal",
"filename" : "splash-2732x2732.png",
"scale" : "3x"
}
],
"info" : {
"version" : 1,
"author" : "xcode"
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

View File

@@ -27,4 +27,9 @@ end
post_install do |installer| post_install do |installer|
assertDeploymentTarget(installer) assertDeploymentTarget(installer)
installer.pods_project.targets.each do |target|
target.build_configurations.each do |config|
config.build_settings['EXCLUDED_ARCHS[sdk=iphonesimulator*]'] = 'arm64'
end
end
end end

View File

@@ -5,6 +5,10 @@ PODS:
- Capacitor - Capacitor
- CapacitorCamera (6.1.2): - CapacitorCamera (6.1.2):
- Capacitor - Capacitor
- CapacitorCommunitySqlite (6.0.2):
- Capacitor
- SQLCipher
- ZIPFoundation
- CapacitorCordova (6.2.1) - CapacitorCordova (6.2.1)
- CapacitorFilesystem (6.0.3): - CapacitorFilesystem (6.0.3):
- Capacitor - Capacitor
@@ -73,11 +77,18 @@ PODS:
- nanopb/decode (2.30910.0) - nanopb/decode (2.30910.0)
- nanopb/encode (2.30910.0) - nanopb/encode (2.30910.0)
- PromisesObjC (2.4.0) - PromisesObjC (2.4.0)
- SQLCipher (4.9.0):
- SQLCipher/standard (= 4.9.0)
- SQLCipher/common (4.9.0)
- SQLCipher/standard (4.9.0):
- SQLCipher/common
- ZIPFoundation (0.9.19)
DEPENDENCIES: DEPENDENCIES:
- "Capacitor (from `../../node_modules/@capacitor/ios`)" - "Capacitor (from `../../node_modules/@capacitor/ios`)"
- "CapacitorApp (from `../../node_modules/@capacitor/app`)" - "CapacitorApp (from `../../node_modules/@capacitor/app`)"
- "CapacitorCamera (from `../../node_modules/@capacitor/camera`)" - "CapacitorCamera (from `../../node_modules/@capacitor/camera`)"
- "CapacitorCommunitySqlite (from `../../node_modules/@capacitor-community/sqlite`)"
- "CapacitorCordova (from `../../node_modules/@capacitor/ios`)" - "CapacitorCordova (from `../../node_modules/@capacitor/ios`)"
- "CapacitorFilesystem (from `../../node_modules/@capacitor/filesystem`)" - "CapacitorFilesystem (from `../../node_modules/@capacitor/filesystem`)"
- "CapacitorMlkitBarcodeScanning (from `../../node_modules/@capacitor-mlkit/barcode-scanning`)" - "CapacitorMlkitBarcodeScanning (from `../../node_modules/@capacitor-mlkit/barcode-scanning`)"
@@ -98,6 +109,8 @@ SPEC REPOS:
- MLKitVision - MLKitVision
- nanopb - nanopb
- PromisesObjC - PromisesObjC
- SQLCipher
- ZIPFoundation
EXTERNAL SOURCES: EXTERNAL SOURCES:
Capacitor: Capacitor:
@@ -106,6 +119,8 @@ EXTERNAL SOURCES:
:path: "../../node_modules/@capacitor/app" :path: "../../node_modules/@capacitor/app"
CapacitorCamera: CapacitorCamera:
:path: "../../node_modules/@capacitor/camera" :path: "../../node_modules/@capacitor/camera"
CapacitorCommunitySqlite:
:path: "../../node_modules/@capacitor-community/sqlite"
CapacitorCordova: CapacitorCordova:
:path: "../../node_modules/@capacitor/ios" :path: "../../node_modules/@capacitor/ios"
CapacitorFilesystem: CapacitorFilesystem:
@@ -121,6 +136,7 @@ SPEC CHECKSUMS:
Capacitor: c95400d761e376be9da6be5a05f226c0e865cebf Capacitor: c95400d761e376be9da6be5a05f226c0e865cebf
CapacitorApp: e1e6b7d05e444d593ca16fd6d76f2b7c48b5aea7 CapacitorApp: e1e6b7d05e444d593ca16fd6d76f2b7c48b5aea7
CapacitorCamera: 9bc7b005d0e6f1d5f525b8137045b60cffffce79 CapacitorCamera: 9bc7b005d0e6f1d5f525b8137045b60cffffce79
CapacitorCommunitySqlite: 0299d20f4b00c2e6aa485a1d8932656753937b9b
CapacitorCordova: 8d93e14982f440181be7304aa9559ca631d77fff CapacitorCordova: 8d93e14982f440181be7304aa9559ca631d77fff
CapacitorFilesystem: 59270a63c60836248812671aa3b15df673fbaf74 CapacitorFilesystem: 59270a63c60836248812671aa3b15df673fbaf74
CapacitorMlkitBarcodeScanning: 7652be9c7922f39203a361de735d340ae37e134e CapacitorMlkitBarcodeScanning: 7652be9c7922f39203a361de735d340ae37e134e
@@ -138,7 +154,9 @@ SPEC CHECKSUMS:
MLKitVision: 90922bca854014a856f8b649d1f1f04f63fd9c79 MLKitVision: 90922bca854014a856f8b649d1f1f04f63fd9c79
nanopb: 438bc412db1928dac798aa6fd75726007be04262 nanopb: 438bc412db1928dac798aa6fd75726007be04262
PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47 PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47
SQLCipher: 31878d8ebd27e5c96db0b7cb695c96e9f8ad77da
ZIPFoundation: b8c29ea7ae353b309bc810586181fd073cb3312c
PODFILE CHECKSUM: 7e7e09e6937de7f015393aecf2cf7823645689b3 PODFILE CHECKSUM: f987510f7383b04a1b09ea8472bdadcd88b6c924
COCOAPODS: 1.16.2 COCOAPODS: 1.16.2

1153
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{ {
"name": "timesafari", "name": "timesafari",
"version": "0.4.6", "version": "0.5.1",
"description": "Time Safari Application", "description": "Time Safari Application",
"author": { "author": {
"name": "Time Safari Team" "name": "Time Safari Team"
@@ -11,7 +11,7 @@
"build": "VITE_GIT_HASH=`git log -1 --pretty=format:%h` vite build --config vite.config.mts", "build": "VITE_GIT_HASH=`git log -1 --pretty=format:%h` vite build --config vite.config.mts",
"lint": "eslint --ext .js,.ts,.vue --ignore-path .gitignore src", "lint": "eslint --ext .js,.ts,.vue --ignore-path .gitignore src",
"lint-fix": "eslint --ext .js,.ts,.vue --ignore-path .gitignore --fix src", "lint-fix": "eslint --ext .js,.ts,.vue --ignore-path .gitignore --fix src",
"prebuild": "eslint --ext .js,.ts,.vue --ignore-path .gitignore src && node sw_combine.cjs && node scripts/copy-wasm.cjs", "prebuild": "eslint --ext .js,.ts,.vue --ignore-path .gitignore src && node sw_combine.js && node scripts/copy-wasm.js",
"test:all": "npm run test:prerequisites && npm run build && npm run test:web && npm run test:mobile", "test:all": "npm run test:prerequisites && npm run build && npm run test:web && npm run test:mobile",
"test:prerequisites": "node scripts/check-prerequisites.js", "test:prerequisites": "node scripts/check-prerequisites.js",
"test:web": "npx playwright test -c playwright.config-local.ts --trace on", "test:web": "npx playwright test -c playwright.config-local.ts --trace on",
@@ -22,15 +22,14 @@
"check:ios-device": "xcrun xctrace list devices 2>&1 | grep -w 'Booted' || (echo 'No iOS simulator running' && exit 1)", "check:ios-device": "xcrun xctrace list devices 2>&1 | grep -w 'Booted' || (echo 'No iOS simulator running' && exit 1)",
"clean:electron": "rimraf dist-electron", "clean:electron": "rimraf dist-electron",
"build:pywebview": "vite build --config vite.config.pywebview.mts", "build:pywebview": "vite build --config vite.config.pywebview.mts",
"build:web": "VITE_GIT_HASH=`git log -1 --pretty=format:%h` vite build --config vite.config.web.mts", "build:electron": "npm run clean:electron && tsc -p tsconfig.electron.json && vite build --config vite.config.electron.mts && node scripts/build-electron.js",
"build:web:electron": "VITE_GIT_HASH=`git log -1 --pretty=format:%h` vite build --config vite.config.web.mts && VITE_GIT_HASH=`git log -1 --pretty=format:%h` vite build --config vite.config.electron.mts --mode electron",
"build:electron": "npm run clean:electron && npm run build:web:electron && tsc -p tsconfig.electron.json && vite build --config vite.config.electron.mts && node scripts/build-electron.cjs",
"build:capacitor": "vite build --mode capacitor --config vite.config.capacitor.mts", "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:dev": "npm run build && electron .",
"electron:start": "electron .", "electron:start": "electron .",
"clean:android": "adb uninstall app.timesafari.app || true", "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", "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": "electron-builder --linux AppImage", "electron:build-linux": "npm run build:electron && electron-builder --linux AppImage",
"electron:build-linux-deb": "npm run build:electron && electron-builder --linux deb", "electron:build-linux-deb": "npm run build:electron && electron-builder --linux deb",
"electron:build-linux-prod": "NODE_ENV=production npm run build:electron && electron-builder --linux AppImage", "electron:build-linux-prod": "NODE_ENV=production npm run build:electron && electron-builder --linux AppImage",
"build:electron-prod": "NODE_ENV=production npm run build:electron", "build:electron-prod": "NODE_ENV=production npm run build:electron",
@@ -47,7 +46,7 @@
"electron:build-mac-universal": "npm run build:electron-prod && electron-builder --mac --universal" "electron:build-mac-universal": "npm run build:electron-prod && electron-builder --mac --universal"
}, },
"dependencies": { "dependencies": {
"@capacitor-community/sqlite": "^6.0.2", "@capacitor-community/sqlite": "6.0.2",
"@capacitor-mlkit/barcode-scanning": "^6.0.0", "@capacitor-mlkit/barcode-scanning": "^6.0.0",
"@capacitor/android": "^6.2.0", "@capacitor/android": "^6.2.0",
"@capacitor/app": "^6.0.0", "@capacitor/app": "^6.0.0",
@@ -58,8 +57,8 @@
"@capacitor/ios": "^6.2.0", "@capacitor/ios": "^6.2.0",
"@capacitor/share": "^6.0.3", "@capacitor/share": "^6.0.3",
"@capawesome/capacitor-file-picker": "^6.2.0", "@capawesome/capacitor-file-picker": "^6.2.0",
"@dicebear/collection": "^5.4.3", "@dicebear/collection": "^5.4.1",
"@dicebear/core": "^5.4.3", "@dicebear/core": "^5.4.1",
"@ethersproject/hdnode": "^5.7.0", "@ethersproject/hdnode": "^5.7.0",
"@ethersproject/wallet": "^5.8.0", "@ethersproject/wallet": "^5.8.0",
"@fortawesome/fontawesome-svg-core": "^6.5.1", "@fortawesome/fontawesome-svg-core": "^6.5.1",
@@ -70,7 +69,7 @@
"@peculiar/asn1-schema": "^2.3.8", "@peculiar/asn1-schema": "^2.3.8",
"@pvermeer/dexie-encrypted-addon": "^3.0.0", "@pvermeer/dexie-encrypted-addon": "^3.0.0",
"@simplewebauthn/browser": "^10.0.0", "@simplewebauthn/browser": "^10.0.0",
"@simplewebauthn/server": "^10.0.1", "@simplewebauthn/server": "^10.0.0",
"@tweenjs/tween.js": "^21.1.1", "@tweenjs/tween.js": "^21.1.1",
"@types/qrcode": "^1.5.5", "@types/qrcode": "^1.5.5",
"@veramo/core": "^5.6.0", "@veramo/core": "^5.6.0",
@@ -87,7 +86,6 @@
"absurd-sql": "^0.0.54", "absurd-sql": "^0.0.54",
"asn1-ber": "^1.2.2", "asn1-ber": "^1.2.2",
"axios": "^1.6.8", "axios": "^1.6.8",
"better-sqlite3-multiple-ciphers": "^11.10.0",
"cbor-x": "^1.5.9", "cbor-x": "^1.5.9",
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
"dexie": "^3.2.7", "dexie": "^3.2.7",
@@ -95,23 +93,22 @@
"did-jwt": "^7.4.7", "did-jwt": "^7.4.7",
"did-resolver": "^4.1.0", "did-resolver": "^4.1.0",
"dotenv": "^16.0.3", "dotenv": "^16.0.3",
"electron-json-storage": "^4.6.0", "ethereum-cryptography": "^2.1.3",
"ethereum-cryptography": "^2.2.1",
"ethereumjs-util": "^7.1.5", "ethereumjs-util": "^7.1.5",
"jdenticon": "^3.3.0", "jdenticon": "^3.2.0",
"js-generate-password": "^0.1.9", "js-generate-password": "^0.1.9",
"js-yaml": "^4.1.0", "js-yaml": "^4.1.0",
"jsqr": "^1.4.0", "jsqr": "^1.4.0",
"leaflet": "^1.9.4", "leaflet": "^1.9.4",
"localstorage-slim": "^2.7.0", "localstorage-slim": "^2.7.0",
"lru-cache": "^10.4.3", "lru-cache": "^10.2.0",
"luxon": "^3.4.4", "luxon": "^3.4.4",
"merkletreejs": "^0.3.11", "merkletreejs": "^0.3.11",
"nostr-tools": "^2.13.1", "nostr-tools": "^2.10.4",
"notiwind": "^2.0.2", "notiwind": "^2.0.2",
"papaparse": "^5.4.1", "papaparse": "^5.4.1",
"pina": "^0.20.2204228", "pina": "^0.20.2204228",
"pinia-plugin-persistedstate": "^3.2.3", "pinia-plugin-persistedstate": "^3.2.1",
"qr-code-generator-vue3": "^1.4.21", "qr-code-generator-vue3": "^1.4.21",
"qrcode": "^1.5.4", "qrcode": "^1.5.4",
"ramda": "^0.29.1", "ramda": "^0.29.1",
@@ -127,13 +124,12 @@
"vue-axios": "^3.5.2", "vue-axios": "^3.5.2",
"vue-facing-decorator": "^3.0.4", "vue-facing-decorator": "^3.0.4",
"vue-picture-cropper": "^0.7.0", "vue-picture-cropper": "^0.7.0",
"vue-qrcode-reader": "^5.7.2", "vue-qrcode-reader": "^5.5.3",
"vue-router": "^4.5.0", "vue-router": "^4.5.0",
"web-did-resolver": "^2.0.30", "web-did-resolver": "^2.0.27",
"zod": "^3.24.2" "zod": "^3.24.2"
}, },
"devDependencies": { "devDependencies": {
"@capacitor-community/electron": "^5.0.1",
"@capacitor/assets": "^3.0.5", "@capacitor/assets": "^3.0.5",
"@playwright/test": "^1.45.2", "@playwright/test": "^1.45.2",
"@types/dom-webcodecs": "^0.1.7", "@types/dom-webcodecs": "^0.1.7",
@@ -148,7 +144,7 @@
"@types/ua-parser-js": "^0.7.39", "@types/ua-parser-js": "^0.7.39",
"@typescript-eslint/eslint-plugin": "^6.21.0", "@typescript-eslint/eslint-plugin": "^6.21.0",
"@typescript-eslint/parser": "^6.21.0", "@typescript-eslint/parser": "^6.21.0",
"@vitejs/plugin-vue": "^5.2.4", "@vitejs/plugin-vue": "^5.2.1",
"@vue/eslint-config-typescript": "^11.0.3", "@vue/eslint-config-typescript": "^11.0.3",
"autoprefixer": "^10.4.19", "autoprefixer": "^10.4.19",
"browserify-fs": "^1.0.0", "browserify-fs": "^1.0.0",
@@ -168,32 +164,26 @@
"postcss": "^8.4.38", "postcss": "^8.4.38",
"prettier": "^3.2.5", "prettier": "^3.2.5",
"rimraf": "^6.0.1", "rimraf": "^6.0.1",
"source-map-support": "^0.5.21",
"tailwindcss": "^3.4.1", "tailwindcss": "^3.4.1",
"typescript": "~5.2.2", "typescript": "~5.2.2",
"vite": "^5.2.0", "vite": "^5.2.0",
"vite-plugin-pwa": "^1.0.0" "vite-plugin-pwa": "^1.0.0"
}, },
"main": "./dist-electron/main.mjs", "main": "./dist-electron/main.js",
"build": { "build": {
"appId": "app.timesafari", "appId": "app.timesafari.app",
"productName": "TimeSafari", "productName": "TimeSafari",
"directories": { "directories": {
"output": "dist-electron-packages" "output": "dist-electron-packages"
}, },
"files": [ "files": [
"dist-electron/**/*", "dist-electron/**/*",
"dist/**/*", "dist/**/*"
"capacitor.config.json"
], ],
"extraResources": [ "extraResources": [
{ {
"from": "dist-electron/www", "from": "dist-electron/www",
"to": "www" "to": "www"
},
{
"from": "dist-electron/resources/preload.js",
"to": "preload.js"
} }
], ],
"linux": { "linux": {
@@ -231,6 +221,5 @@
} }
] ]
} }
}, }
"type": "module"
} }

View File

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

View File

@@ -1,96 +0,0 @@
const fs = require("fs");
const fse = require("fs-extra");
const path = require("path");
const { execSync } = require('child_process');
console.log("Starting Electron build finalization...");
// Define paths
const distPath = path.join(__dirname, "..", "dist");
const electronDistPath = path.join(__dirname, "..", "dist-electron");
const wwwPath = path.join(electronDistPath, "www");
const builtIndexPath = path.join(distPath, "index.html");
const finalIndexPath = path.join(wwwPath, "index.html");
// Ensure target directory exists
if (!fs.existsSync(wwwPath)) {
fs.mkdirSync(wwwPath, { recursive: true });
}
// Copy assets directory
const assetsSrc = path.join(distPath, "assets");
const assetsDest = path.join(wwwPath, "assets");
if (fs.existsSync(assetsSrc)) {
fse.copySync(assetsSrc, assetsDest, { overwrite: true });
}
// Copy favicon.ico
const faviconSrc = path.join(distPath, "favicon.ico");
if (fs.existsSync(faviconSrc)) {
fs.copyFileSync(faviconSrc, path.join(wwwPath, "favicon.ico"));
}
// Copy manifest.webmanifest
const manifestSrc = path.join(distPath, "manifest.webmanifest");
if (fs.existsSync(manifestSrc)) {
fs.copyFileSync(manifestSrc, path.join(wwwPath, "manifest.webmanifest"));
}
// Load and modify index.html from Vite output
let indexContent = fs.readFileSync(builtIndexPath, "utf-8");
// Inject the window.process shim after the first <script> block
indexContent = indexContent.replace(
/<script[^>]*type="module"[^>]*>/,
match => `${match}\n window.process = { env: { VITE_PLATFORM: 'electron' } };`
);
// Write the modified index.html to dist-electron/www
fs.writeFileSync(finalIndexPath, indexContent);
// Copy preload script to resources
const preloadSrc = path.join(electronDistPath, "preload.mjs");
const preloadDest = path.join(electronDistPath, "resources", "preload.js");
// Ensure resources directory exists
const resourcesDir = path.join(electronDistPath, "resources");
if (!fs.existsSync(resourcesDir)) {
fs.mkdirSync(resourcesDir, { recursive: true });
}
if (fs.existsSync(preloadSrc)) {
// Read the preload script
let preloadContent = fs.readFileSync(preloadSrc, 'utf-8');
// Convert ESM to CommonJS if needed
preloadContent = preloadContent
.replace(/import\s*{\s*([^}]+)\s*}\s*from\s*['"]electron['"];?/g, 'const { $1 } = require("electron");')
.replace(/export\s*{([^}]+)};?/g, '')
.replace(/export\s+default\s+([^;]+);?/g, 'module.exports = $1;');
// Write the modified preload script
fs.writeFileSync(preloadDest, preloadContent);
console.log("Preload script copied and converted to resources directory");
} else {
console.error("Preload script not found at:", preloadSrc);
process.exit(1);
}
// Copy capacitor.config.json to dist-electron
try {
console.log("Copying capacitor.config.json to dist-electron...");
const configPath = path.join(process.cwd(), 'capacitor.config.json');
const targetPath = path.join(process.cwd(), 'dist-electron', 'capacitor.config.json');
if (!fs.existsSync(configPath)) {
throw new Error('capacitor.config.json not found in project root');
}
fs.copyFileSync(configPath, targetPath);
console.log("Successfully copied capacitor.config.json");
} catch (error) {
console.error("Failed to copy capacitor.config.json:", error);
throw error;
}
console.log("Electron index.html copied and patched for Electron context.");

165
scripts/build-electron.js Normal file
View File

@@ -0,0 +1,165 @@
const fs = require('fs');
const path = require('path');
console.log('Starting electron build process...');
// Define paths
const electronDistPath = path.join(__dirname, '..', 'dist-electron');
const wwwPath = path.join(electronDistPath, 'www');
// Create www directory if it doesn't exist
if (!fs.existsSync(wwwPath)) {
fs.mkdirSync(wwwPath, { recursive: true });
}
// Create a platform-specific index.html for Electron
const initialIndexContent = `<!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,viewport-fit=cover">
<link rel="icon" href="/favicon.ico">
<title>TimeSafari</title>
</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>
<script type="module">
// Force electron platform
window.process = { env: { VITE_PLATFORM: 'electron' } };
import('./src/main.electron.ts');
</script>
</body>
</html>`;
// Write the Electron-specific index.html
fs.writeFileSync(path.join(wwwPath, 'index.html'), initialIndexContent);
// Copy only necessary assets from web build
const webDistPath = path.join(__dirname, '..', 'dist');
if (fs.existsSync(webDistPath)) {
// Copy assets directory
const assetsSrc = path.join(webDistPath, 'assets');
const assetsDest = path.join(wwwPath, 'assets');
if (fs.existsSync(assetsSrc)) {
fs.cpSync(assetsSrc, assetsDest, { recursive: true });
}
// Copy favicon
const faviconSrc = path.join(webDistPath, 'favicon.ico');
if (fs.existsSync(faviconSrc)) {
fs.copyFileSync(faviconSrc, path.join(wwwPath, 'favicon.ico'));
}
}
// Remove service worker files
const swFilesToRemove = [
'sw.js',
'sw.js.map',
'workbox-*.js',
'workbox-*.js.map',
'registerSW.js',
'manifest.webmanifest',
'**/workbox-*.js',
'**/workbox-*.js.map',
'**/sw.js',
'**/sw.js.map',
'**/registerSW.js',
'**/manifest.webmanifest'
];
console.log('Removing service worker files...');
swFilesToRemove.forEach(pattern => {
const files = fs.readdirSync(wwwPath).filter(file =>
file.match(new RegExp(pattern.replace(/\*/g, '.*')))
);
files.forEach(file => {
const filePath = path.join(wwwPath, file);
console.log(`Removing ${filePath}`);
try {
fs.unlinkSync(filePath);
} catch (err) {
console.warn(`Could not remove ${filePath}:`, err.message);
}
});
});
// Also check and remove from assets directory
const assetsPath = path.join(wwwPath, 'assets');
if (fs.existsSync(assetsPath)) {
swFilesToRemove.forEach(pattern => {
const files = fs.readdirSync(assetsPath).filter(file =>
file.match(new RegExp(pattern.replace(/\*/g, '.*')))
);
files.forEach(file => {
const filePath = path.join(assetsPath, file);
console.log(`Removing ${filePath}`);
try {
fs.unlinkSync(filePath);
} catch (err) {
console.warn(`Could not remove ${filePath}:`, err.message);
}
});
});
}
// Modify index.html to remove service worker registration
const indexPath = path.join(wwwPath, 'index.html');
if (fs.existsSync(indexPath)) {
console.log('Modifying index.html to remove service worker registration...');
let indexContent = fs.readFileSync(indexPath, 'utf8');
// Remove service worker registration script
indexContent = indexContent
.replace(/<script[^>]*id="vite-plugin-pwa:register-sw"[^>]*><\/script>/g, '')
.replace(/<script[^>]*registerServiceWorker[^>]*><\/script>/g, '')
.replace(/<link[^>]*rel="manifest"[^>]*>/g, '')
.replace(/<link[^>]*rel="serviceworker"[^>]*>/g, '')
.replace(/navigator\.serviceWorker\.register\([^)]*\)/g, '')
.replace(/if\s*\(\s*['"]serviceWorker['"]\s*in\s*navigator\s*\)\s*{[^}]*}/g, '');
fs.writeFileSync(indexPath, indexContent);
console.log('Successfully modified index.html');
}
// Fix asset paths
console.log('Fixing asset paths in index.html...');
let modifiedIndexContent = fs.readFileSync(indexPath, 'utf8');
modifiedIndexContent = modifiedIndexContent
.replace(/\/assets\//g, './assets/')
.replace(/href="\//g, 'href="./')
.replace(/src="\//g, 'src="./');
fs.writeFileSync(indexPath, modifiedIndexContent);
// Verify no service worker references remain
const finalContent = fs.readFileSync(indexPath, 'utf8');
if (finalContent.includes('serviceWorker') || finalContent.includes('workbox')) {
console.warn('Warning: Service worker references may still exist in index.html');
}
// Check for remaining /assets/ paths
console.log('After path fixing, checking for remaining /assets/ paths:', finalContent.includes('/assets/'));
console.log('Sample of fixed content:', finalContent.substring(0, 500));
console.log('Copied and fixed web files in:', wwwPath);
// Copy main process files
console.log('Copying main process files...');
// Copy the main process file instead of creating a template
const mainSrcPath = path.join(__dirname, '..', 'dist-electron', 'main.js');
const mainDestPath = path.join(electronDistPath, 'main.js');
if (fs.existsSync(mainSrcPath)) {
fs.copyFileSync(mainSrcPath, mainDestPath);
console.log('Copied main process file successfully');
} else {
console.error('Main process file not found at:', mainSrcPath);
process.exit(1);
}
console.log('Electron build process completed successfully');

View File

@@ -51,7 +51,7 @@ const { existsSync } = require('fs');
*/ */
function checkCommand(command, errorMessage) { function checkCommand(command, errorMessage) {
try { try {
execSync(command + ' --version', { stdio: 'ignore' }); execSync(command, { stdio: 'ignore' });
return true; return true;
} catch (e) { } catch (e) {
console.error(`${errorMessage}`); console.error(`${errorMessage}`);
@@ -164,10 +164,10 @@ function main() {
// Check required command line tools // Check required command line tools
// These are essential for building and testing the application // These are essential for building and testing the application
success &= checkCommand('node', 'Node.js is required'); success &= checkCommand('node --version', 'Node.js is required');
success &= checkCommand('npm', 'npm is required'); success &= checkCommand('npm --version', 'npm is required');
success &= checkCommand('gradle', 'Gradle is required for Android builds'); success &= checkCommand('gradle --version', 'Gradle is required for Android builds');
success &= checkCommand('xcodebuild', 'Xcode is required for iOS builds'); success &= checkCommand('xcodebuild --help', 'Xcode is required for iOS builds');
// Check platform-specific development environments // Check platform-specific development environments
success &= checkAndroidSetup(); success &= checkAndroidSetup();

View File

@@ -170,7 +170,7 @@ const executeDeeplink = async (url, description, log) => {
try { try {
// Stop the app before executing the deep link // Stop the app before executing the deep link
execSync('adb shell am force-stop app.timesafari'); execSync('adb shell am force-stop app.timesafari.app');
await new Promise(resolve => setTimeout(resolve, 1000)); // Wait 1s await new Promise(resolve => setTimeout(resolve, 1000)); // Wait 1s
execSync(`adb shell am start -W -a android.intent.action.VIEW -d "${url}" -c android.intent.category.BROWSABLE`); execSync(`adb shell am start -W -a android.intent.action.VIEW -d "${url}" -c android.intent.category.BROWSABLE`);

View File

@@ -4,7 +4,7 @@
<!-- Messages in the upper-right - https://github.com/emmanuelsw/notiwind --> <!-- Messages in the upper-right - https://github.com/emmanuelsw/notiwind -->
<NotificationGroup group="alert"> <NotificationGroup group="alert">
<div <div
class="fixed top-[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" class="fixed z-[90] top-[max(1rem,env(safe-area-inset-top))] right-4 left-4 sm:left-auto sm:w-full sm:max-w-sm flex flex-col items-start justify-end"
> >
<Notification <Notification
v-slot="{ notifications, close }" v-slot="{ notifications, close }"
@@ -459,10 +459,9 @@ export default class App extends Vue {
return true; return true;
} }
const serverSubscription = const serverSubscription = {
typeof subscription === "object" && subscription !== null ...subscription,
? { ...subscription } };
: {};
if (!allGoingOff) { if (!allGoingOff) {
serverSubscription["notifyType"] = notification.title; serverSubscription["notifyType"] = notification.title;
logger.log( logger.log(
@@ -549,13 +548,13 @@ export default class App extends Vue {
<style> <style>
#Content { #Content {
padding-left: 1.5rem; padding-left: max(1.5rem, env(safe-area-inset-left));
padding-right: 1.5rem; padding-right: max(1.5rem, env(safe-area-inset-right));
padding-top: calc(env(safe-area-inset-top) + 1.5rem); padding-top: max(1.5rem, env(safe-area-inset-top));
padding-bottom: calc(env(safe-area-inset-bottom) + 1.5rem); padding-bottom: max(1.5rem, env(safe-area-inset-bottom));
} }
#QuickNav ~ #Content { #QuickNav ~ #Content {
padding-bottom: calc(env(safe-area-inset-bottom) + 6rem); padding-bottom: calc(env(safe-area-inset-bottom) + 6.333rem);
} }
</style> </style>

View File

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

View File

@@ -0,0 +1,894 @@
/** * Backup Files List Component * * Displays a list of backup files saved by
the app and provides options to: * - View backup files by type (contacts, seed,
other) * - Open individual files in the device's file viewer * - Access the
backup directory in the device's file explorer * * @component * @displayName
BackupFilesList * @example * ```vue *
<BackupFilesList />
* ``` */
<template>
<div class="backup-files-list">
<div class="flex justify-between items-center mb-4">
<h3 class="text-lg font-semibold">Backup Files</h3>
<div class="flex gap-2">
<button
v-if="platformCapabilities.hasFileSystem"
class="text-sm bg-blue-500 hover:bg-blue-600 text-white px-3 py-1 rounded"
:disabled="isLoading"
@click="refreshFiles()"
>
<font-awesome
icon="refresh"
class="fa-fw"
:class="{ 'animate-spin': isLoading }"
/>
Refresh
</button>
<button
v-if="platformCapabilities.hasFileSystem"
class="text-sm bg-green-500 hover:bg-green-600 text-white px-3 py-1 rounded"
:disabled="isLoading"
@click="openBackupDirectory()"
>
<font-awesome icon="folder-open" class="fa-fw" />
Open Directory
</button>
<button
v-if="platformCapabilities.hasFileSystem && isDevelopment"
class="text-sm bg-yellow-500 hover:bg-yellow-600 text-white px-3 py-1 rounded"
:disabled="isLoading"
title="Debug file discovery (development only)"
@click="debugFileDiscovery()"
>
<font-awesome icon="bug" class="fa-fw" />
Debug
</button>
<button
:disabled="isLoading"
class="px-3 py-1 bg-green-500 text-white rounded text-sm hover:bg-green-600 disabled:opacity-50"
@click="createTestBackup"
>
Create Test Backup
</button>
<button
:disabled="isLoading"
class="px-3 py-1 bg-purple-500 text-white rounded text-sm hover:bg-purple-600 disabled:opacity-50"
@click="testDirectoryContexts"
>
Test Contexts
</button>
</div>
</div>
<div v-if="isLoading" class="text-center py-4">
<font-awesome icon="spinner" class="animate-spin fa-2x" />
<p class="mt-2">Loading backup files...</p>
</div>
<div
v-else-if="backupFiles.length === 0"
class="text-center py-4 text-gray-500"
>
<font-awesome icon="folder-open" class="fa-2x mb-2" />
<p>No backup files found</p>
<p class="text-sm mt-1">
Create backups using the export functions above
</p>
<div
class="mt-3 p-3 bg-blue-50 border border-blue-200 rounded-lg text-left"
>
<p class="text-sm font-medium text-blue-800 mb-2">
💡 How to create backup files:
</p>
<ul class="text-xs text-blue-700 space-y-1">
<li>
Use the "Export Contacts" button above to create contact backups
</li>
<li> Use the "Export Seed" button to backup your recovery phrase</li>
<li>
Backup files are saved to persistent storage that survives app
installations
</li>
<li
v-if="platformCapabilities.isMobile && !platformCapabilities.isIOS"
class="text-orange-700"
>
On Android: Files are saved to Downloads/TimeSafari or app data
directory
</li>
<li v-if="platformCapabilities.isIOS" class="text-orange-700">
On iOS: Files are saved to Documents folder (accessible via Files
app)
</li>
</ul>
</div>
</div>
<div v-else class="space-y-2">
<!-- File Type Filter -->
<div class="flex gap-2 mb-3">
<button
v-for="type in ['all', 'contacts', 'seed', 'other'] as const"
:key="type"
:class="[
'text-sm px-3 py-1 rounded border',
selectedType === type
? 'bg-blue-500 text-white border-blue-500'
: 'bg-white text-gray-700 border-gray-300 hover:bg-gray-50',
]"
@click="selectedType = type"
>
{{
type === "all"
? "All"
: type.charAt(0).toUpperCase() + type.slice(1)
}}
<span class="ml-1 text-xs"> ({{ getFileCountByType(type) }}) </span>
</button>
</div>
<!-- Files List -->
<div class="flex items-center gap-2 mb-2">
<span v-for="(crumb, idx) in breadcrumbs" :key="idx">
<span
v-if="idx < breadcrumbs.length - 1"
class="text-blue-600 cursor-pointer underline"
@click="goToBreadcrumb(idx)"
>
{{ crumb }}
</span>
<span v-else class="font-bold">{{ crumb }}</span>
<span v-if="idx < breadcrumbs.length - 1"> / </span>
</span>
</div>
<div v-if="currentPath.length > 1" class="mb-2">
<button class="text-xs text-blue-500 underline" @click="goUp">
Up
</button>
</div>
<div class="mb-2">
<label class="inline-flex items-center">
<input
v-model="debugShowAll"
type="checkbox"
class="mr-2"
@change="loadDirectory"
/>
<span class="text-xs">Debug: Show all entries as files</span>
</label>
<span v-if="debugShowAll" class="text-xs text-red-600 ml-2"
>[Debug mode: forcibly treating all entries as files]</span
>
</div>
<div class="space-y-2 max-h-64 overflow-y-auto">
<div
v-for="entry in folders"
:key="'folder-' + entry.path"
class="flex items-center justify-between p-3 bg-white border border-gray-200 rounded-lg hover:bg-gray-50 cursor-pointer"
@click="openFolder(entry)"
>
<div class="flex items-center gap-2">
<font-awesome icon="folder" class="fa-fw text-yellow-500" />
<span class="font-medium">{{ entry.name }}</span>
<span
class="text-xs bg-gray-200 text-gray-700 px-2 py-0.5 rounded-full ml-2"
>Folder</span
>
</div>
</div>
<div
v-for="entry in files"
:key="'file-' + entry.path"
class="flex items-center justify-between p-3 bg-white border border-gray-200 rounded-lg hover:bg-gray-50"
>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2">
<font-awesome icon="file-alt" class="fa-fw text-gray-500" />
<span class="font-medium truncate">{{ entry.name }}</span>
</div>
<div class="text-sm text-gray-500 mt-1">
<span v-if="entry.size">{{ formatFileSize(entry.size) }}</span>
<span v-else>Size unknown</span>
<span
v-if="entry.path && !platformCapabilities.isIOS"
class="ml-2 text-xs text-blue-600"
>📁 {{ entry.path }}</span
>
</div>
</div>
<div class="flex gap-2 ml-3">
<button
class="text-blue-500 hover:text-blue-700 p-1"
title="Open file"
@click="openFile(entry.uri, entry.name)"
>
<font-awesome icon="external-link-alt" class="fa-fw" />
</button>
</div>
</div>
</div>
<!-- Summary -->
<div class="text-sm text-gray-500 mt-3 pt-3 border-t">
Showing {{ filteredFiles.length }} of {{ backupFiles.length }} backup
files
</div>
<div class="text-sm text-gray-600 mb-2">
<p>
📁 Backup files are saved to persistent storage that survives app
installations:
</p>
<ul class="list-disc list-inside ml-2 mt-1 text-xs">
<li v-if="platformCapabilities.isIOS">
iOS: Documents folder (accessible via Files app)
</li>
<li
v-if="platformCapabilities.isMobile && !platformCapabilities.isIOS"
>
Android: Downloads/TimeSafari or external storage (accessible via
file managers)
</li>
<li v-if="!platformCapabilities.isMobile">
Desktop: User's download directory
</li>
</ul>
</div>
</div>
</div>
</template>
<script lang="ts">
import { Component, Vue, Watch } from "vue-facing-decorator";
import { NotificationIface } from "../constants/app";
import { logger } from "../utils/logger";
import { PlatformServiceFactory } from "../services/PlatformServiceFactory";
import {
PlatformService,
PlatformCapabilities,
} from "../services/PlatformService";
/**
* @vue-component
* Backup Files List Component
* Displays and manages backup files with platform-specific functionality
*/
@Component
export default class BackupFilesList extends Vue {
/**
* Notification function injected by Vue
* Used to show success/error messages to the user
*/
$notify!: (notification: NotificationIface, timeout?: number) => void;
/**
* 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();
}
/**
* List of backup files found on the device
*/
backupFiles: Array<{
name: string;
uri: string;
size?: number;
type: "contacts" | "seed" | "other";
path?: string;
}> = [];
/**
* Currently selected file type filter
*/
selectedType: "all" | "contacts" | "seed" | "other" = "all";
/**
* Loading state for file operations
*/
isLoading = false;
/**
* Interval for periodic refresh (5 minutes)
*/
private refreshInterval: number | null = null;
/**
* Current path for folder navigation (array for breadcrumbs)
*/
currentPath: string[] = [];
/**
* List of files/folders in the current directory
*/
directoryEntries: Array<{
name: string;
uri: string;
size?: number;
path: string;
type: "file" | "folder";
}> = [];
/**
* Temporary debug mode to show all entries as files
*/
debugShowAll = false;
/**
* Checks and requests storage permissions if needed.
* Returns true if permission is granted, false otherwise.
*/
private async ensureStoragePermission(): Promise<boolean> {
logger.log(
"[BackupFilesList] ensureStoragePermission called. platformCapabilities:",
this.platformCapabilities,
);
if (!this.platformCapabilities.hasFileSystem) return true;
// Only relevant for native platforms (Android/iOS)
const platformService = this.platformService as any;
if (typeof platformService.checkStoragePermissions === "function") {
try {
await platformService.checkStoragePermissions();
logger.log("[BackupFilesList] Storage permission granted.");
return true;
} catch (error) {
logger.error("[BackupFilesList] Storage permission denied:", error);
// Get specific guidance for the platform
let guidance =
"This app needs permission to access your files to list and restore backups.";
if (
typeof platformService.getStoragePermissionGuidance === "function"
) {
try {
guidance = await platformService.getStoragePermissionGuidance();
} catch (guidanceError) {
logger.warn(
"[BackupFilesList] Could not get permission guidance:",
guidanceError,
);
}
}
this.$notify(
{
group: "alert",
type: "warning",
title: "Storage Permission Required",
text: guidance,
},
10000, // Show for 10 seconds to give user time to read
);
return false;
}
}
return true;
}
/**
* Lifecycle hook to load backup files when component is mounted
*/
async mounted() {
logger.log(
"[BackupFilesList] mounted hook called. platformCapabilities:",
this.platformCapabilities,
);
if (this.platformCapabilities.hasFileSystem) {
// Check/request permission before loading
const hasPermission = await this.ensureStoragePermission();
if (hasPermission) {
// Set default root path
if (this.platformCapabilities.isIOS) {
this.currentPath = ["."];
} else {
this.currentPath = ["Download", "TimeSafari"];
}
await this.loadDirectory();
this.refreshInterval = window.setInterval(
() => {
this.loadDirectory();
},
5 * 60 * 1000,
);
}
}
}
/**
* Lifecycle hook to clean up resources when component is unmounted
*/
beforeUnmount() {
if (this.refreshInterval) {
clearInterval(this.refreshInterval);
this.refreshInterval = null;
}
}
/**
* Computed property for filtered files based on selected type
* Note: The 'All' tab count is sometimes too small. Logging for debugging.
*/
get filteredFiles() {
if (this.selectedType === "all") {
logger.log("[BackupFilesList] filteredFiles (All):", this.backupFiles);
return this.backupFiles;
}
const filtered = this.backupFiles.filter(
(file) => file.type === this.selectedType,
);
logger.log(
`[BackupFilesList] filteredFiles (${this.selectedType}):`,
filtered,
);
return filtered;
}
/**
* Computed property to check if we're in development mode
*/
get isDevelopment(): boolean {
return import.meta.env.DEV;
}
/**
* Load the current directory entries
*/
async loadDirectory() {
if (!this.platformCapabilities.hasFileSystem) return;
this.isLoading = true;
try {
const path =
this.currentPath.join("/") ||
(this.platformCapabilities.isIOS ? "." : "Download/TimeSafari");
this.directoryEntries = await (
this.platformService as PlatformService
).listFilesInDirectory(path, this.debugShowAll);
logger.log("[BackupFilesList] Loaded directory:", {
path,
entries: this.directoryEntries,
});
} catch (error) {
logger.error("[BackupFilesList] Failed to load directory:", error);
this.directoryEntries = [];
} finally {
this.isLoading = false;
}
}
/**
* Navigate into a folder
*/
async openFolder(entry: { name: string; path: string }) {
this.currentPath.push(entry.name);
await this.loadDirectory();
}
/**
* Navigate to a breadcrumb
*/
async goToBreadcrumb(index: number) {
this.currentPath = this.currentPath.slice(0, index + 1);
await this.loadDirectory();
}
/**
* Go up one directory
*/
async goUp() {
if (this.currentPath.length > 1) {
this.currentPath.pop();
await this.loadDirectory();
}
}
/**
* Computed property for breadcrumbs
*/
get breadcrumbs() {
return this.currentPath;
}
/**
* Computed property for showing files and folders
*/
get folders() {
return this.directoryEntries.filter((e) => e.type === "folder");
}
get files() {
return this.directoryEntries.filter((e) => e.type === "file");
}
/**
* Refreshes the list of backup files from the device
*/
async refreshFiles() {
logger.log("[BackupFilesList] refreshFiles called.");
if (!this.platformCapabilities.hasFileSystem) {
return;
}
// Check/request permission before refreshing
const hasPermission = await this.ensureStoragePermission();
if (!hasPermission) {
this.backupFiles = [];
this.isLoading = false;
return;
}
this.isLoading = true;
try {
this.backupFiles = await this.platformService.listBackupFiles();
logger.log("[BackupFilesList] Refreshed backup files:", {
count: this.backupFiles.length,
files: this.backupFiles.map((f) => ({
name: f.name,
type: f.type,
path: f.path,
size: f.size,
})),
platform: this.platformCapabilities.isIOS ? "iOS" : "Android",
timestamp: new Date().toISOString(),
});
// Debug: Log file type distribution
const typeCounts = {
contacts: this.backupFiles.filter((f) => f.type === "contacts").length,
seed: this.backupFiles.filter((f) => f.type === "seed").length,
other: this.backupFiles.filter((f) => f.type === "other").length,
total: this.backupFiles.length,
};
logger.log("[BackupFilesList] File type distribution:", typeCounts);
// Log the full backupFiles array for debugging the 'All' tab count
logger.log(
"[BackupFilesList] backupFiles array for All tab:",
this.backupFiles,
);
} catch (error) {
logger.error("[BackupFilesList] Failed to refresh backup files:", error);
this.$notify(
{
group: "alert",
type: "danger",
title: "Error Loading Files",
text: "Failed to load backup files from your device.",
},
5000,
);
} finally {
this.isLoading = false;
}
}
/**
* Creates a test backup file for debugging purposes
*/
async createTestBackup() {
try {
this.isLoading = true;
logger.log("[BackupFilesList] Creating test backup file");
const result = await this.platformService.createTestBackupFile();
if (result.success) {
logger.log("[BackupFilesList] Test backup file created successfully:", {
fileName: result.fileName,
uri: result.uri,
timestamp: new Date().toISOString(),
});
this.$notify(
{
group: "alert",
type: "success",
title: "Test Backup Created",
text: `Test backup file "${result.fileName}" created successfully. Refresh the list to see it.`,
},
5000,
);
// Refresh the file list to show the new test file
await this.refreshFiles();
} else {
throw new Error(result.error || "Failed to create test backup file");
}
} catch (error) {
logger.error(
"[BackupFilesList] Failed to create test backup file:",
error,
);
this.$notify(
{
group: "alert",
type: "danger",
title: "Test Backup Failed",
text: "Failed to create test backup file. Check the console for details.",
},
5000,
);
} finally {
this.isLoading = false;
}
}
/**
* Tests different directory contexts to debug file visibility issues
*/
async testDirectoryContexts() {
try {
this.isLoading = true;
logger.log("[BackupFilesList] Testing directory contexts");
const debugOutput = await this.platformService.testDirectoryContexts();
logger.log(
"[BackupFilesList] Directory context test results:",
debugOutput,
);
// Show the debug output in a notification or alert
this.$notify(
{
group: "alert",
type: "info",
title: "Directory Context Test",
text: "Directory context test completed. Check the console for detailed results.",
},
5000,
);
// Also log the full output to console for easy access
logger.log("=== Directory Context Test Results ===");
logger.log(debugOutput);
logger.log("=== End Test Results ===");
} catch (error) {
logger.error(
"[BackupFilesList] Failed to test directory contexts:",
error,
);
this.$notify(
{
group: "alert",
type: "danger",
title: "Context Test Failed",
text: "Failed to test directory contexts. Check the console for details.",
},
5000,
);
} finally {
this.isLoading = false;
}
}
/**
* Refreshes the file list after a backup is created
* This method can be called from parent components
*/
async refreshAfterSave() {
logger.log("[BackupFilesList] refreshAfterSave called");
await this.refreshFiles();
}
/**
* Opens a specific file in the device's file viewer
* @param fileUri - URI of the file to open
* @param fileName - Name of the file for display
*/
async openFile(fileUri: string, fileName: string) {
try {
const result = await this.platformService.openFile(fileUri, fileName);
if (result.success) {
logger.log("[BackupFilesList] File opened successfully:", {
fileName,
timestamp: new Date().toISOString(),
});
} else {
throw new Error(result.error || "Failed to open file");
}
} catch (error) {
logger.error("[BackupFilesList] Failed to open file:", error);
this.$notify(
{
group: "alert",
type: "danger",
title: "Error Opening File",
text: `Failed to open ${fileName}. ${error instanceof Error ? error.message : String(error)}`,
},
5000,
);
}
}
/**
* Opens the backup directory in the device's file explorer
*/
async openBackupDirectory() {
try {
const result = await this.platformService.openBackupDirectory();
if (result.success) {
logger.log("[BackupFilesList] Backup directory opened successfully:", {
timestamp: new Date().toISOString(),
});
} else {
throw new Error(result.error || "Failed to open backup directory");
}
} catch (error) {
logger.error("[BackupFilesList] Failed to open backup directory:", error);
this.$notify(
{
group: "alert",
type: "danger",
title: "Error Opening Directory",
text: `Failed to open backup directory. ${error instanceof Error ? error.message : String(error)}`,
},
5000,
);
}
}
/**
* Gets the count of files for a specific type
* Note: The 'All' tab count is sometimes too small. Logging for debugging.
*/
getFileCountByType(type: "all" | "contacts" | "seed" | "other"): number {
let count;
if (type === "all") {
count = this.backupFiles.length;
logger.log(
"[BackupFilesList] getFileCountByType (All):",
count,
this.backupFiles,
);
return count;
}
count = this.backupFiles.filter((file) => file.type === type).length;
logger.log(`[BackupFilesList] getFileCountByType (${type}):`, count);
return count;
}
/**
* Gets the appropriate icon for a file type
* @param type - File type
* @returns FontAwesome icon name
*/
getFileIcon(type: "contacts" | "seed" | "other"): string {
switch (type) {
case "contacts":
return "address-book";
case "seed":
return "key";
default:
return "file-alt";
}
}
/**
* Gets the appropriate icon color for a file type
* @param type - File type
* @returns CSS color class
*/
getFileIconColor(type: "contacts" | "seed" | "other"): string {
switch (type) {
case "contacts":
return "text-blue-500";
case "seed":
return "text-orange-500";
default:
return "text-gray-500";
}
}
/**
* Gets the appropriate badge color for a file type
* @param type - File type
* @returns CSS color class
*/
getTypeBadgeColor(type: "contacts" | "seed" | "other"): string {
switch (type) {
case "contacts":
return "bg-blue-100 text-blue-800";
case "seed":
return "bg-orange-100 text-orange-800";
default:
return "bg-gray-100 text-gray-800";
}
}
/**
* Formats file size in human-readable format
* @param bytes - File size in bytes
* @returns Formatted file size string
*/
formatFileSize(bytes: number): string {
if (bytes === 0) return "0 Bytes";
const k = 1024;
const sizes = ["Bytes", "KB", "MB", "GB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
}
/**
* Debug method to test file discovery
* Can be called from browser console for troubleshooting
*/
public async debugFileDiscovery() {
try {
logger.log("[BackupFilesList] Starting debug file discovery...");
// Test the platform service's test methods
const platformService = PlatformServiceFactory.getInstance();
// Test listing all user files
const allFilesResult = await platformService.testListUserFiles();
logger.log(
"[BackupFilesList] All user files test result:",
allFilesResult,
);
// Test listing backup files specifically
const backupFilesResult = await platformService.testBackupFiles();
logger.log(
"[BackupFilesList] Backup files test result:",
backupFilesResult,
);
// Note: testListAllBackupFiles method is not part of the PlatformService interface
// It exists only in CapacitorPlatformService implementation
// If needed, this could be added to the interface or called via type assertion
// Test debug listing all files without filtering (if available)
if ("debugListAllFiles" in platformService) {
const debugAllFiles = await (
platformService as any
).debugListAllFiles();
logger.log("[BackupFilesList] Debug all files (no filtering):", {
count: debugAllFiles.length,
files: debugAllFiles.map((f: any) => ({
name: f.name,
path: f.path,
size: f.size,
})),
});
}
// Test comprehensive step-by-step debug (if available)
if ("debugFileDiscoveryStepByStep" in platformService) {
const stepByStepDebug = await (
platformService as any
).debugFileDiscoveryStepByStep();
logger.log(
"[BackupFilesList] Step-by-step debug output:",
stepByStepDebug,
);
}
return {
allFiles: allFilesResult,
backupFiles: backupFilesResult,
currentBackupFiles: this.backupFiles,
debugAllFiles:
"debugListAllFiles" in platformService
? await (platformService as any).debugListAllFiles()
: null,
};
} catch (error) {
logger.error("[BackupFilesList] Debug file discovery failed:", error);
throw error;
}
}
@Watch("platformCapabilities.hasFileSystem", { immediate: true })
async onFileSystemCapabilityChanged(newVal: boolean) {
if (newVal) {
await this.refreshFiles();
}
}
}
</script>

View File

@@ -1,7 +1,8 @@
/** * Data Export Section Component * * Provides UI and functionality for /** * Data Export Section Component * * Provides UI and functionality for
exporting user data and backing up identifier seeds. * Includes buttons for seed exporting user data and backing up identifier seeds. * Includes buttons for seed
backup and database export, with platform-specific download instructions. * * backup and database export, with platform-specific download instructions. * Also
@component * @displayName DataExportSection * @example * ```vue * displays a list of backup files with options to open them in the device's file
explorer. * * @component * @displayName DataExportSection * @example * ```vue *
<DataExportSection :active-did="currentDid" /> <DataExportSection :active-did="currentDid" />
* ``` */ * ``` */
@@ -24,9 +25,7 @@ backup and database export, with platform-specific download instructions. * *
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" 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()" @click="exportDatabase()"
> >
Download Settings & Contacts Download Contacts
<br />
(excluding Identifier Data)
</button> </button>
<a <a
ref="downloadLink" ref="downloadLink"
@@ -45,38 +44,52 @@ backup and database export, with platform-specific download instructions. * *
v-if="platformCapabilities.isIOS" v-if="platformCapabilities.isIOS"
class="list-disc list-outside ml-4" class="list-disc list-outside ml-4"
> >
On iOS: You will be prompted to choose a location to save your backup On iOS: Files are saved to Documents folder (accessible via Files app)
file. and persist between app installations.
</li> </li>
<li <li
v-if="platformCapabilities.isMobile && !platformCapabilities.isIOS" v-if="platformCapabilities.isMobile && !platformCapabilities.isIOS"
class="list-disc list-outside ml-4" class="list-disc list-outside ml-4"
> >
On Android: You will be prompted to choose a location to save your On Android: Files are saved to Downloads/TimeSafari or external
backup file. storage (accessible via file managers) and persist between app
installations.
</li> </li>
</ul> </ul>
</div> </div>
<!-- Backup Files List -->
<div
v-if="platformCapabilities.hasFileSystem"
class="mt-6 pt-6 border-t border-gray-300"
>
<BackupFilesList ref="backupFilesList" />
</div>
</div> </div>
</template> </template>
<script lang="ts"> <script lang="ts">
import { Component, Prop, Vue } from "vue-facing-decorator"; import { Component, Prop, Vue } from "vue-facing-decorator";
import { AppString, NotificationIface, USE_DEXIE_DB } from "../constants/app"; import { AppString, NotificationIface } from "../constants/app";
import { db } from "../db/index";
import { logger } from "../utils/logger"; import { Contact } from "../db/tables/contacts";
import * as databaseUtil from "../db/databaseUtil";
import { logger, getTimestampForFilename } from "../utils/logger";
import { PlatformServiceFactory } from "../services/PlatformServiceFactory"; import { PlatformServiceFactory } from "../services/PlatformServiceFactory";
import { import {
PlatformService, PlatformService,
PlatformCapabilities, PlatformCapabilities,
} from "../services/PlatformService"; } from "../services/PlatformService";
import { contactsToExportJson } from "../libs/util";
import BackupFilesList from "./BackupFilesList.vue";
/** /**
* @vue-component * @vue-component
* Data Export Section Component * Data Export Section Component
* Handles database export and seed backup functionality with platform-specific behavior * Handles database export and seed backup functionality with platform-specific behavior
*/ */
@Component @Component({ components: { BackupFilesList } })
export default class DataExportSection extends Vue { export default class DataExportSection extends Vue {
/** /**
* Notification function injected by Vue * Notification function injected by Vue
@@ -131,24 +144,27 @@ export default class DataExportSection extends Vue {
*/ */
public async exportDatabase() { public async exportDatabase() {
try { try {
if (!USE_DEXIE_DB) { let allContacts: Contact[] = [];
throw new Error("Not implemented"); const platformService = PlatformServiceFactory.getInstance();
const result = await platformService.dbQuery(`SELECT * FROM contacts`);
if (result) {
allContacts = databaseUtil.mapQueryResultToValues(
result,
) as unknown as Contact[];
} }
const blob = await db.export({ // if (USE_DEXIE_DB) {
prettyJson: true, // await db.open();
transform: (table, value, key) => { // allContacts = await db.contacts.toArray();
if (table === "contacts") { // }
// Dexie inserts a number 0 when some are undefined, so we need to totally remove them.
Object.keys(value).forEach((prop) => { // Convert contacts to export format
if (value[prop] === undefined) { const exportData = contactsToExportJson(allContacts);
delete value[prop]; const jsonStr = JSON.stringify(exportData, null, 2);
} const blob = new Blob([jsonStr], { type: "application/json" });
});
} // Create timestamped filename
return { value, key }; const timestamp = getTimestampForFilename();
}, const fileName = `${AppString.APP_NAME_NO_SPACES}-backup-contacts-${timestamp}.json`;
});
const fileName = `${AppString.APP_NAME_NO_SPACES}-backup.json`;
if (this.platformCapabilities.hasFileDownload) { if (this.platformCapabilities.hasFileDownload) {
// Web platform: Use download link // Web platform: Use download link
@@ -159,9 +175,21 @@ export default class DataExportSection extends Vue {
downloadAnchor.click(); downloadAnchor.click();
setTimeout(() => URL.revokeObjectURL(this.downloadUrl), 1000); setTimeout(() => URL.revokeObjectURL(this.downloadUrl), 1000);
} else if (this.platformCapabilities.hasFileSystem) { } else if (this.platformCapabilities.hasFileSystem) {
// Native platform: Write to app directory // Native platform: Write to user-accessible location and share
const content = await blob.text(); const result = await this.platformService.writeAndShareFile(
await this.platformService.writeAndShareFile(fileName, content); fileName,
jsonStr,
{
allowLocationSelection: true,
showLocationSelectionDialog: true,
mimeType: "application/json",
},
);
// Handle the result
if (!result.saved) {
throw new Error(result.error || "Failed to save file");
}
} else { } else {
throw new Error("This platform does not support file downloads."); throw new Error("This platform does not support file downloads.");
} }
@@ -172,11 +200,20 @@ export default class DataExportSection extends Vue {
type: "success", type: "success",
title: "Export Successful", title: "Export Successful",
text: this.platformCapabilities.hasFileDownload text: this.platformCapabilities.hasFileDownload
? "See your downloads directory for the backup. It is in the Dexie format." ? "See your downloads directory for the backup."
: "You should have been prompted to save your backup file.", : "Backup saved to persistent storage that survives app installations. Use the share dialog to access your file and choose where to save it permanently.",
}, },
-1, 5000,
); );
// Refresh the backup files list
const backupFilesList = this.$refs.backupFilesList as any;
if (
backupFilesList &&
typeof backupFilesList.refreshAfterSave === "function"
) {
await backupFilesList.refreshAfterSave();
}
} catch (error) { } catch (error) {
logger.error("Export Error:", error); logger.error("Export Error:", error);
this.$notify( this.$notify(
@@ -210,5 +247,18 @@ export default class DataExportSection extends Vue {
hidden: !this.downloadUrl || !this.platformCapabilities.hasFileDownload, hidden: !this.downloadUrl || !this.platformCapabilities.hasFileDownload,
}; };
} }
async mounted() {
// Ensure permissions are requested and refresh backup files list on mount
if (this.platformCapabilities.hasFileSystem) {
const backupFilesList = this.$refs.backupFilesList as any;
if (
backupFilesList &&
typeof backupFilesList.refreshFiles === "function"
) {
await backupFilesList.refreshFiles();
}
}
}
} }
</script> </script>

View File

@@ -104,7 +104,6 @@ import { USE_DEXIE_DB } from "@/constants/app";
import * as databaseUtil from "../db/databaseUtil"; import * as databaseUtil from "../db/databaseUtil";
import { MASTER_SETTINGS_KEY } from "../db/tables/settings"; import { MASTER_SETTINGS_KEY } from "../db/tables/settings";
import { db, retrieveSettingsForActiveAccount } from "../db/index"; import { db, retrieveSettingsForActiveAccount } from "../db/index";
import { PlatformServiceFactory } from "@/services/PlatformServiceFactory";
@Component({ @Component({
components: { components: {
@@ -143,19 +142,23 @@ export default class FeedFilters extends Vue {
async toggleHasVisibleDid() { async toggleHasVisibleDid() {
this.settingChanged = true; this.settingChanged = true;
this.hasVisibleDid = !this.hasVisibleDid; this.hasVisibleDid = !this.hasVisibleDid;
await db.settings.update(MASTER_SETTINGS_KEY, { await databaseUtil.updateDefaultSettings({
filterFeedByVisible: this.hasVisibleDid, filterFeedByVisible: this.hasVisibleDid,
}); });
if (USE_DEXIE_DB) {
await db.settings.update(MASTER_SETTINGS_KEY, {
filterFeedByVisible: this.hasVisibleDid,
});
}
} }
async toggleNearby() { async toggleNearby() {
this.settingChanged = true; this.settingChanged = true;
this.isNearby = !this.isNearby; this.isNearby = !this.isNearby;
const platformService = PlatformServiceFactory.getInstance(); await databaseUtil.updateDefaultSettings({
await platformService.dbExec( filterFeedByNearby: this.isNearby,
`UPDATE settings SET filterFeedByNearby = ? WHERE id = ?`, });
[this.isNearby, MASTER_SETTINGS_KEY],
);
if (USE_DEXIE_DB) { if (USE_DEXIE_DB) {
await db.settings.update(MASTER_SETTINGS_KEY, { await db.settings.update(MASTER_SETTINGS_KEY, {
@@ -169,11 +172,10 @@ export default class FeedFilters extends Vue {
this.settingChanged = true; this.settingChanged = true;
} }
const platformService = PlatformServiceFactory.getInstance(); await databaseUtil.updateDefaultSettings({
await platformService.dbExec( filterFeedByNearby: false,
`UPDATE settings SET filterFeedByNearby = ? AND filterFeedByVisible = ? WHERE id = ?`, filterFeedByVisible: false,
[false, false, MASTER_SETTINGS_KEY], });
);
if (USE_DEXIE_DB) { if (USE_DEXIE_DB) {
await db.settings.update(MASTER_SETTINGS_KEY, { await db.settings.update(MASTER_SETTINGS_KEY, {
@@ -191,11 +193,10 @@ export default class FeedFilters extends Vue {
this.settingChanged = true; this.settingChanged = true;
} }
const platformService = PlatformServiceFactory.getInstance(); await databaseUtil.updateDefaultSettings({
await platformService.dbExec( filterFeedByNearby: true,
`UPDATE settings SET filterFeedByNearby = ? AND filterFeedByVisible = ? WHERE id = ?`, filterFeedByVisible: true,
[true, true, MASTER_SETTINGS_KEY], });
);
if (USE_DEXIE_DB) { if (USE_DEXIE_DB) {
await db.settings.update(MASTER_SETTINGS_KEY, { await db.settings.update(MASTER_SETTINGS_KEY, {

View File

@@ -227,6 +227,7 @@ export default class GivenPrompts extends Vue {
let someContactDbIndex = Math.floor(Math.random() * this.numContacts); let someContactDbIndex = Math.floor(Math.random() * this.numContacts);
let count = 0; let count = 0;
// as long as the index has an entry, loop // as long as the index has an entry, loop
while ( while (
this.shownContactDbIndices[someContactDbIndex] != null && this.shownContactDbIndices[someContactDbIndex] != null &&
@@ -245,9 +246,8 @@ export default class GivenPrompts extends Vue {
[someContactDbIndex], [someContactDbIndex],
); );
if (result) { if (result) {
this.currentContact = databaseUtil.mapQueryResultToValues(result)[ const mappedContacts = databaseUtil.mapQueryResultToValues(result);
someContactDbIndex this.currentContact = mappedContacts[0] as unknown as Contact;
] as unknown as Contact;
} }
if (USE_DEXIE_DB) { if (USE_DEXIE_DB) {
await db.open(); await db.open();

View File

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

View File

@@ -4,7 +4,9 @@
<div class="text-lg text-center font-bold relative"> <div class="text-lg text-center font-bold relative">
<h1 id="ViewHeading" class="text-center font-bold"> <h1 id="ViewHeading" class="text-center font-bold">
<span v-if="uploading">Uploading Image&hellip;</span> <span v-if="uploading">Uploading Image&hellip;</span>
<span v-else-if="blob">Crop Image</span> <span v-else-if="blob">{{
crop ? "Crop Image" : "Preview Image"
}}</span>
<span v-else-if="showCameraPreview">Upload Image</span> <span v-else-if="showCameraPreview">Upload Image</span>
<span v-else>Add Photo</span> <span v-else>Add Photo</span>
</h1> </h1>
@@ -119,12 +121,23 @@
playsinline playsinline
muted muted
></video> ></video>
<button <div
class="absolute bottom-4 left-1/2 -translate-x-1/2 bg-white text-slate-800 p-3 rounded-full text-2xl leading-none" class="absolute bottom-4 inset-x-0 flex items-center justify-center gap-4"
@click="capturePhoto"
> >
<font-awesome icon="camera" class="w-[1em]" /> <button
</button> class="bg-white text-slate-800 p-3 rounded-full text-2xl leading-none"
@click="capturePhoto"
>
<font-awesome icon="camera" class="w-[1em]" />
</button>
<button
v-if="platformCapabilities.isMobile"
class="bg-white text-slate-800 p-3 rounded-full text-2xl leading-none"
@click="rotateCamera"
>
<font-awesome icon="rotate" class="w-[1em]" />
</button>
</div>
</div> </div>
</div> </div>
<div <div
@@ -229,12 +242,12 @@
<p class="mb-2"> <p class="mb-2">
Before you can upload a photo, a friend needs to register you. Before you can upload a photo, a friend needs to register you.
</p> </p>
<router-link <button
: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" class="inline-block text-md uppercase bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-4 py-2 rounded-md"
@click="handleQRCodeClick"
> >
Share Your Info Share Your Info
</router-link> </button>
</div> </div>
</template> </template>
</div> </div>
@@ -247,6 +260,7 @@ import axios from "axios";
import { ref } from "vue"; import { ref } from "vue";
import { Component, Vue } from "vue-facing-decorator"; import { Component, Vue } from "vue-facing-decorator";
import VuePictureCropper, { cropper } from "vue-picture-cropper"; import VuePictureCropper, { cropper } from "vue-picture-cropper";
import { Capacitor } from "@capacitor/core";
import { import {
DEFAULT_IMAGE_API_SERVER, DEFAULT_IMAGE_API_SERVER,
NotificationIface, NotificationIface,
@@ -254,7 +268,7 @@ import {
} from "../constants/app"; } from "../constants/app";
import { retrieveSettingsForActiveAccount } from "../db/index"; import { retrieveSettingsForActiveAccount } from "../db/index";
import { accessToken } from "../libs/crypto"; import { accessToken } from "../libs/crypto";
import { logger } from "../utils/logger"; import { logger, getTimestampForFilename } from "../utils/logger";
import { PlatformServiceFactory } from "../services/PlatformServiceFactory"; import { PlatformServiceFactory } from "../services/PlatformServiceFactory";
import * as databaseUtil from "../db/databaseUtil"; import * as databaseUtil from "../db/databaseUtil";
@@ -267,6 +281,11 @@ const inputImageFileNameRef = ref<Blob>();
type: Boolean, type: Boolean,
default: true, default: true,
}, },
defaultCameraMode: {
type: String,
default: "environment",
validator: (value: string) => ["environment", "user"].includes(value),
},
}, },
}) })
export default class ImageMethodDialog extends Vue { export default class ImageMethodDialog extends Vue {
@@ -308,6 +327,9 @@ export default class ImageMethodDialog extends Vue {
/** Camera stream reference */ /** Camera stream reference */
private cameraStream: MediaStream | null = null; private cameraStream: MediaStream | null = null;
/** Current camera facing mode */
private currentFacingMode: "environment" | "user" = "environment";
private platformService = PlatformServiceFactory.getInstance(); private platformService = PlatformServiceFactory.getInstance();
URL = window.URL || window.webkitURL; URL = window.URL || window.webkitURL;
@@ -369,15 +391,16 @@ export default class ImageMethodDialog extends Vue {
} }
open(setImageFn: (arg: string) => void, claimType: string, crop?: boolean) { open(setImageFn: (arg: string) => void, claimType: string, crop?: boolean) {
logger.debug("ImageMethodDialog.open called");
this.claimType = claimType; this.claimType = claimType;
this.crop = !!crop; this.crop = !!crop;
this.imageCallback = setImageFn; this.imageCallback = setImageFn;
this.visible = true; this.visible = true;
this.currentFacingMode = this.defaultCameraMode as "environment" | "user";
// Start camera preview immediately if not on mobile // Start camera preview immediately
if (!this.platformCapabilities.isNativeApp) { logger.debug("Starting camera preview from open()");
this.startCameraPreview(); this.startCameraPreview();
}
} }
async uploadImageFile(event: Event) { async uploadImageFile(event: Event) {
@@ -446,46 +469,24 @@ export default class ImageMethodDialog extends Vue {
logger.debug("startCameraPreview called"); logger.debug("startCameraPreview called");
logger.debug("Current showCameraPreview state:", this.showCameraPreview); logger.debug("Current showCameraPreview state:", this.showCameraPreview);
logger.debug("Platform capabilities:", this.platformCapabilities); logger.debug("Platform capabilities:", this.platformCapabilities);
logger.debug("MediaDevices available:", !!navigator.mediaDevices);
logger.debug(
"getUserMedia available:",
!!(navigator.mediaDevices && navigator.mediaDevices.getUserMedia),
);
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 { try {
this.cameraState = "initializing"; this.cameraState = "initializing";
this.cameraStateMessage = "Requesting camera access..."; this.cameraStateMessage = "Requesting camera access...";
this.showCameraPreview = true; this.showCameraPreview = true;
await this.$nextTick(); await this.$nextTick();
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
throw new Error("Camera API not available in this browser");
}
const stream = await navigator.mediaDevices.getUserMedia({ const stream = await navigator.mediaDevices.getUserMedia({
video: { facingMode: "environment" }, video: { facingMode: this.currentFacingMode },
}); });
logger.debug("Camera access granted"); logger.debug("Camera access granted");
this.cameraStream = stream; this.cameraStream = stream;
@@ -499,25 +500,36 @@ export default class ImageMethodDialog extends Vue {
videoElement.srcObject = stream; videoElement.srcObject = stream;
await new Promise((resolve) => { await new Promise((resolve) => {
videoElement.onloadedmetadata = () => { videoElement.onloadedmetadata = () => {
videoElement.play().then(() => { videoElement
resolve(true); .play()
}); .then(() => {
logger.debug("Video element started playing");
resolve(true);
})
.catch((error) => {
logger.error("Error playing video:", error);
throw error;
});
}; };
}); });
} else {
logger.error("Video element not found");
throw new Error("Video element not found");
} }
} catch (error) { } catch (error) {
logger.error("Error starting camera preview:", error); logger.error("Error starting camera preview:", error);
let errorMessage = let errorMessage =
error instanceof Error ? error.message : "Failed to access camera"; error instanceof Error ? error.message : "Failed to access camera";
if ( if (
error.name === "NotReadableError" || error instanceof Error &&
error.name === "TrackStartError" (error.name === "NotReadableError" || error.name === "TrackStartError")
) { ) {
errorMessage = errorMessage =
"Camera is in use by another application. Please close any other apps or browser tabs using the camera and try again."; "Camera is in use by another application. Please close any other apps or browser tabs using the camera and try again.";
} else if ( } else if (
error.name === "NotAllowedError" || error instanceof Error &&
error.name === "PermissionDeniedError" (error.name === "NotAllowedError" ||
error.name === "PermissionDeniedError")
) { ) {
errorMessage = errorMessage =
"Camera access was denied. Please allow camera access in your browser settings."; "Camera access was denied. Please allow camera access in your browser settings.";
@@ -525,6 +537,7 @@ export default class ImageMethodDialog extends Vue {
this.cameraState = "error"; this.cameraState = "error";
this.cameraStateMessage = errorMessage; this.cameraStateMessage = errorMessage;
this.error = errorMessage; this.error = errorMessage;
this.showCameraPreview = false;
this.$notify( this.$notify(
{ {
group: "alert", group: "alert",
@@ -534,7 +547,6 @@ export default class ImageMethodDialog extends Vue {
}, },
5000, 5000,
); );
this.showCameraPreview = false;
} }
} }
@@ -564,7 +576,7 @@ export default class ImageMethodDialog extends Vue {
(blob) => { (blob) => {
if (blob) { if (blob) {
this.blob = blob; this.blob = blob;
this.fileName = `photo_${Date.now()}.jpg`; this.fileName = `photo-${getTimestampForFilename()}.jpg`;
this.showRetry = true; this.showRetry = true;
this.stopCameraPreview(); this.stopCameraPreview();
} }
@@ -586,6 +598,21 @@ export default class ImageMethodDialog extends Vue {
} }
} }
async rotateCamera() {
// Toggle between front and back cameras
this.currentFacingMode =
this.currentFacingMode === "environment" ? "user" : "environment";
// Stop current stream
if (this.cameraStream) {
this.cameraStream.getTracks().forEach((track) => track.stop());
this.cameraStream = null;
}
// Start new stream with updated facing mode
await this.startCameraPreview();
}
private createBlobURL(blob: Blob): string { private createBlobURL(blob: Blob): string {
return URL.createObjectURL(blob); return URL.createObjectURL(blob);
} }
@@ -620,6 +647,7 @@ export default class ImageMethodDialog extends Vue {
5000, 5000,
); );
this.uploading = false; this.uploading = false;
this.close();
return; return;
} }
formData.append("image", this.blob, this.fileName || "photo.jpg"); formData.append("image", this.blob, this.fileName || "photo.jpg");
@@ -674,6 +702,7 @@ export default class ImageMethodDialog extends Vue {
); );
this.uploading = false; this.uploading = false;
this.blob = undefined; this.blob = undefined;
this.close();
} }
} }
@@ -681,6 +710,14 @@ export default class ImageMethodDialog extends Vue {
toggleDiagnostics() { toggleDiagnostics() {
this.showDiagnostics = !this.showDiagnostics; this.showDiagnostics = !this.showDiagnostics;
} }
private handleQRCodeClick() {
if (Capacitor.isNativePlatform()) {
this.$router.push({ name: "contact-qr-scan-full" });
} else {
this.$router.push({ name: "contact-qr" });
}
}
} }
</script> </script>

View File

@@ -301,7 +301,7 @@ export default class MembersList extends Vue {
this.decryptedMembers.length === 0 || this.decryptedMembers.length === 0 ||
this.decryptedMembers[0].member.memberId !== this.members[0].memberId this.decryptedMembers[0].member.memberId !== this.members[0].memberId
) { ) {
return "Your password is not the same as the organizer. Reload or have them check their password."; return "Your password is not the same as the organizer. Retry or have them check their password.";
} else { } else {
// the first (organizer) member was decrypted OK // the first (organizer) member was decrypted OK
return ""; return "";
@@ -342,7 +342,7 @@ export default class MembersList extends Vue {
group: "alert", group: "alert",
type: "info", type: "info",
title: "Contact Exists", title: "Contact Exists",
text: "They are in your contacts. If you want to remove them, you must do that from the contacts screen.", text: "They are in your contacts. To remove them, use the contacts page.",
}, },
10000, 10000,
); );
@@ -352,7 +352,7 @@ export default class MembersList extends Vue {
group: "alert", group: "alert",
type: "info", type: "info",
title: "Contact Available", title: "Contact Available",
text: "This is to add them to your contacts. If you want to remove them later, you must do that from the contacts screen.", text: "This is to add them to your contacts. To remove them later, use the contacts page.",
}, },
10000, 10000,
); );

View File

@@ -5,7 +5,7 @@
<h1 class="text-xl font-bold text-center mb-4 relative"> <h1 class="text-xl font-bold text-center mb-4 relative">
Welcome to Time Safari Welcome to Time Safari
<br /> <br />
- Showcasing Gratitude & Magnifying Time - Showcase Impact & Magnify Time
<div <div
class="text-lg text-center leading-none absolute right-0 -top-1" class="text-lg text-center leading-none absolute right-0 -top-1"
@click="onClickClose(true)" @click="onClickClose(true)"
@@ -14,6 +14,9 @@
</div> </div>
</h1> </h1>
The feed underneath this pop-up shows the latest contributions, some from
people and some from projects.
<p v-if="isRegistered" class="mt-4"> <p v-if="isRegistered" class="mt-4">
You can now log things that you've seen: You can now log things that you've seen:
<span v-if="numContacts > 0"> <span v-if="numContacts > 0">
@@ -23,14 +26,10 @@
<span class="bg-green-600 text-white rounded-full"> <span class="bg-green-600 text-white rounded-full">
<font-awesome icon="plus" class="fa-fw" /> <font-awesome icon="plus" class="fa-fw" />
</span> </span>
button to express your appreciation for... whatever -- maybe thanks for button to express your appreciation for... whatever.
showing you all these fascinating stories of
<em>gratitude</em>.
</p> </p>
<p v-else class="mt-4"> <p class="mt-4">
The feed underneath this pop-up shows the latest gifts that others have Once someone registers you, you can log your appreciation, too.
recognized. Once someone registers you, you can log your appreciation,
too.
</p> </p>
<p class="mt-4"> <p class="mt-4">
@@ -260,7 +259,7 @@ export default class OnboardingDialog extends Vue {
this.visible = true; this.visible = true;
if (this.page === OnboardPage.Create) { if (this.page === OnboardPage.Create) {
// we'll assume that they've been through all the other pages // we'll assume that they've been through all the other pages
await databaseUtil.updateAccountSettings(this.activeDid, { await databaseUtil.updateDidSpecificSettings(this.activeDid, {
finishedOnboarding: true, finishedOnboarding: true,
}); });
if (USE_DEXIE_DB) { if (USE_DEXIE_DB) {
@@ -274,7 +273,7 @@ export default class OnboardingDialog extends Vue {
async onClickClose(done?: boolean, goHome?: boolean) { async onClickClose(done?: boolean, goHome?: boolean) {
this.visible = false; this.visible = false;
if (done) { if (done) {
await databaseUtil.updateAccountSettings(this.activeDid, { await databaseUtil.updateDidSpecificSettings(this.activeDid, {
finishedOnboarding: true, finishedOnboarding: true,
}); });
if (USE_DEXIE_DB) { if (USE_DEXIE_DB) {

View File

@@ -127,7 +127,7 @@ import {
import * as databaseUtil from "../db/databaseUtil"; import * as databaseUtil from "../db/databaseUtil";
import { retrieveSettingsForActiveAccount } from "../db/index"; import { retrieveSettingsForActiveAccount } from "../db/index";
import { accessToken } from "../libs/crypto"; import { accessToken } from "../libs/crypto";
import { logger } from "../utils/logger"; import { logger, getTimestampForFilename } from "../utils/logger";
import { PlatformServiceFactory } from "../services/PlatformServiceFactory"; import { PlatformServiceFactory } from "../services/PlatformServiceFactory";
@Component({ components: { VuePictureCropper } }) @Component({ components: { VuePictureCropper } })
@@ -393,7 +393,7 @@ export default class PhotoDialog extends Vue {
(blob) => { (blob) => {
if (blob) { if (blob) {
this.blob = blob; this.blob = blob;
this.fileName = `photo_${Date.now()}.jpg`; this.fileName = `photo-${getTimestampForFilename()}.jpg`;
this.stopCameraPreview(); this.stopCameraPreview();
} }
}, },

View File

@@ -1,18 +1,14 @@
<!-- eslint-disable vue/no-v-html --> <!-- eslint-disable vue/no-v-html -->
<template> <template>
<a <a
v-if="linkToFull && imageUrl" v-if="linkToFullImage && imageUrl"
:href="imageUrl" :href="imageUrl"
target="_blank" target="_blank"
class="h-full w-full object-contain" class="h-full w-full object-contain"
> >
<div class="h-full w-full object-contain" v-html="generateIdenticon()" /> <div class="h-full w-full object-contain" v-html="generateIcon()" />
</a> </a>
<div <div v-else class="h-full w-full object-contain" v-html="generateIcon()" />
v-else
class="h-full w-full object-contain"
v-html="generateIdenticon()"
/>
</template> </template>
<script lang="ts"> <script lang="ts">
import { toSvg } from "jdenticon"; import { toSvg } from "jdenticon";
@@ -35,9 +31,9 @@ export default class ProjectIcon extends Vue {
@Prop entityId = ""; @Prop entityId = "";
@Prop iconSize = 0; @Prop iconSize = 0;
@Prop imageUrl = ""; @Prop imageUrl = "";
@Prop linkToFull = false; @Prop linkToFullImage = false;
generateIdenticon() { generateIcon() {
if (this.imageUrl) { if (this.imageUrl) {
return `<img src="${this.imageUrl}" class="w-full h-full object-contain" />`; return `<img src="${this.imageUrl}" class="w-full h-full object-contain" />`;
} else { } else {

View File

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

View File

@@ -41,6 +41,7 @@ import { NotificationIface, USE_DEXIE_DB } from "../constants/app";
import { db, retrieveSettingsForActiveAccount } from "../db/index"; import { db, retrieveSettingsForActiveAccount } from "../db/index";
import * as databaseUtil from "../db/databaseUtil"; import * as databaseUtil from "../db/databaseUtil";
import { MASTER_SETTINGS_KEY } from "../db/tables/settings"; import { MASTER_SETTINGS_KEY } from "../db/tables/settings";
import { PlatformServiceFactory } from "@/services/PlatformServiceFactory";
@Component @Component
export default class UserNameDialog extends Vue { export default class UserNameDialog extends Vue {
@@ -71,9 +72,11 @@ export default class UserNameDialog extends Vue {
} }
async onClickSaveChanges() { async onClickSaveChanges() {
await databaseUtil.updateDefaultSettings({ const platformService = PlatformServiceFactory.getInstance();
firstName: this.givenName, await platformService.dbExec(
}); "UPDATE settings SET firstName = ? WHERE id = ?",
[this.givenName, MASTER_SETTINGS_KEY],
);
if (USE_DEXIE_DB) { if (USE_DEXIE_DB) {
await db.settings.update(MASTER_SETTINGS_KEY, { await db.settings.update(MASTER_SETTINGS_KEY, {
firstName: this.givenName, firstName: this.givenName,

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