Compare commits

..

1 Commits

Author SHA1 Message Date
Matthew Raymer
6d49be45ca fix: resolve Capacitor SQLite database connection issues
- Add comprehensive connection cleanup and retry logic
- Implement exponential backoff for database initialization
- Add app lifecycle management for proper resource cleanup
- Create diagnostic tools for troubleshooting database issues
- Fix CameraDirection enum usage for Capacitor Camera v6
- Temporarily disable encryption to isolate connection problems
- Add performance monitoring and health check capabilities
- Document fixes and troubleshooting procedures

Resolves: CapacitorSQLitePlugin null errors
Resolves: "Connection timesafari.sqlite already exists" conflicts
Resolves: Performance issues causing frame drops
Resolves: Memory management and garbage collection errors

Author: Matthew Raymer
2025-06-16 02:52:30 +00:00
70 changed files with 2203 additions and 6163 deletions

View File

@@ -41,7 +41,6 @@ Install dependencies:
1. Run the production build:
```bash
rm -rf dist
npm run build:web
```
@@ -61,22 +60,18 @@ Install dependencies:
* `npx prettier --write ./sw_scripts/`
* Update the ClickUp tasks & CHANGELOG.md & the version in package.json.
* Run install & build to make sure package-lock version is updated, linting works, etc: `npm install && npm run build:web`
* If also deploying to mobile, update the new version in the ios & android filed.
* Update the ClickUp tasks & CHANGELOG.md & the version in package.json, run `npm install`.
* Commit everything (since the commit hash is used the app).
* Put the commit hash in the changelog (which will help you remember to bump the version in the step later).
* Put the commit hash in the changelog (which will help you remember to bump the version later).
* Tag with the new version, [online](https://gitea.anomalistdesign.com/trent_larson/crowd-funder-for-time-pwa/releases) or `git tag 1.0.2 && git push origin 1.0.2`.
* Tag with the new version, [online](https://gitea.anomalistdesign.com/trent_larson/crowd-funder-for-time-pwa/releases) or `git tag 0.3.55 && git push origin 0.3.55`.
* For test, build the app (because test server is not yet set up to build):
```bash
TIME_SAFARI_APP_TITLE="TimeSafari_Test" VITE_APP_SERVER=https://test.timesafari.app VITE_BVC_MEETUPS_PROJECT_CLAIM_ID=https://endorser.ch/entity/01HWE8FWHQ1YGP7GFZYYPS272F VITE_DEFAULT_ENDORSER_API_SERVER=https://test-api.endorser.ch VITE_DEFAULT_IMAGE_API_SERVER=https://test-image-api.timesafari.app VITE_DEFAULT_PARTNER_API_SERVER=https://test-partner-api.endorser.ch VITE_DEFAULT_PUSH_SERVER=https://test.timesafari.app VITE_PASSKEYS_ENABLED=true npm run build:web
TIME_SAFARI_APP_TITLE="TimeSafari_Test" VITE_APP_SERVER=https://test.timesafari.app VITE_BVC_MEETUPS_PROJECT_CLAIM_ID=https://endorser.ch/entity/01HWE8FWHQ1YGP7GFZYYPS272F VITE_DEFAULT_ENDORSER_API_SERVER=https://test-api.endorser.ch VITE_DEFAULT_IMAGE_API_SERVER=https://test-image-api.timesafari.app VITE_DEFAULT_PARTNER_API_SERVER=https://test-partner-api.endorser.ch VITE_DEFAULT_PUSH_SERVER=https://test.timesafari.app VITE_PASSKEYS_ENABLED=true npm run build
```
... and transfer to the test server:
@@ -95,13 +90,13 @@ TIME_SAFARI_APP_TITLE="TimeSafari_Test" VITE_APP_SERVER=https://test.timesafari.
* `pkgx +npm sh`
* `cd crowd-funder-for-time-pwa && git checkout master && git pull && git checkout 1.0.2 && npm install && npm run build:web && cd -`
* `cd crowd-funder-for-time-pwa && git checkout master && git pull && git checkout 0.3.55 && npm install && npm run build && cd -`
(The plain `npm run build:web` uses the .env.production file.)
(The plain `npm run build` uses the .env.production file.)
* Back up the time-safari/dist folder & deploy: `mv time-safari/dist time-safari-dist-prev-2 && mv crowd-funder-for-time-pwa/dist time-safari/`
* Back up the time-safari/dist folder & deploy: `mv time-safari/dist time-safari-dist-prev.0 && mv crowd-funder-for-time-pwa/dist time-safari/`
* Record the new hash in the changelog. Edit package.json to increment version & add "-beta", `npm install`, commit, and push. Also record what version is on production.
* Record the new hash in the changelog. Edit package.json to increment version & add "-beta", `npm install`, and commit. Also record what version is on production.
## Docker Deployment
@@ -339,18 +334,25 @@ Prerequisites: macOS with Xcode installed
export GEM_PATH=$shortened_path
```
1. Build the web assets & update ios:
1. Check the iOS flag isIOS in CapacitorPlatformService (currently hard-coded for iOS build).
2. Build the web assets:
```bash
rm -rf dist
npm run build:web
npm run build:capacitor
```
3. Update iOS project with latest build:
```bash
npx cap sync ios
```
- If that fails with "Could not find..." then look at the "gem_path" instructions above.
3. Copy the assets:
4. Copy the assets:
```bash
# It makes no sense why capacitor-assets will not run without these but it actually changes the contents.
@@ -364,9 +366,12 @@ Prerequisites: macOS with Xcode installed
4. Bump the version to match Android & package.json:
```
cd ios/App && xcrun agvtool new-version 37 && perl -p -i -e "s/MARKETING_VERSION = .*;/MARKETING_VERSION = 1.0.4;/g" App.xcodeproj/project.pbxproj && cd -
cd ios/App
xcrun agvtool new-version 30
# Unfortunately this edits Info.plist directly.
#xcrun agvtool new-marketing-version 0.4.5
cat App.xcodeproj/project.pbxproj | sed "s/MARKETING_VERSION = .*;/MARKETING_VERSION = 0.5.4;/g" > temp && mv temp App.xcodeproj/project.pbxproj
cd -
```
5. Open the project in Xcode:
@@ -387,12 +392,13 @@ Prerequisites: macOS with Xcode installed
* This will trigger a build and take time, needing user's "login" keychain password (user's login password), repeatedly.
* If it fails with `building for 'iOS', but linking in dylib (.../.pkgx/zlib.net/v1.3.0/lib/libz.1.3.dylib) built for 'macOS'` then run XCode outside that terminal (ie. not with `npx cap open ios`).
* Click Distribute -> App Store Connect
* In AppStoreConnect, add the build to the distribution. You may have to 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.
* You'll probably have to "Manage" something about encryption, disallowed in France.
* Then "Save" and "Add to Review" and "Resubmit to App Review".
* Eventually it'll be "Ready for Distribution" which means
8. Revert the iOS flag isIOS in CapacitorPlatformService.
### Android Build
@@ -435,6 +441,7 @@ Prerequisites: Android Studio with Java SDK installed
./gradlew clean
./gradlew build -Dlint.baselines.continue=true
cd -
npx cap run android
```
... or, to create the `aab` file, `bundle` instead of `build`:

View File

@@ -6,41 +6,6 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [1.0.4] - 2025.07.20 - 002f2407208d56cc59c0aa7c880535ae4cbace8b
### Fixed
- Deep link for invite-one-accept
## [1.0.3] - 2025.07.12 - a9a8ba217cd6015321911e98e6843e988dc2c4ae
### Changed
- Photo is pinned to profile mode
### Fixed
- Deep link URLs (and other prod settings)
- Error in BVC begin view
## [1.0.2] - 2025.06.20 - 276e0a741bc327de3380c4e508cccb7fee58c06d
### Added
- Version on feed title
## [1.0.1] - 2025.06.20
### Added
- Allow a user to block someone else's content from view
## [1.0.0] - 2025.06.20 - 5aa693de6337e5dbb278bfddc6bd39094bc14f73
### Added
- Web-oriented migration from IndexedDB to SQLite
## [0.5.8]
### Added
- /deep-link/ path for URLs that are shared with people
### Changed
- External links now go to /deep-link/...
- Feed visuals now have arrow imagery from giver to receiver
## [0.4.7]
### Fixed

View File

@@ -3,32 +3,6 @@
[Time Safari](https://timesafari.org/) allows people to ease into collaboration: start with expressions of gratitude
and expand to crowd-fund with time & money, then record and see the impact of contributions.
## Database Migration Status
**Current Status**: The application is undergoing a migration from Dexie (IndexedDB) to SQLite using absurd-sql. This migration is in **Phase 2** with a well-defined migration fence in place.
### Migration Progress
-**SQLite Database Service**: Fully implemented with absurd-sql
-**Platform Service Layer**: Unified database interface across platforms
-**Settings Migration**: Core user settings transferred
-**Account Migration**: Identity and key management
- 🔄 **Contact Migration**: User contact data (via import interface)
- 📋 **Code Cleanup**: Remove unused Dexie imports
### Migration Fence
The migration is controlled by a **migration fence** that separates legacy Dexie code from the new SQLite implementation. See [Migration Fence Definition](doc/migration-fence-definition.md) for complete details.
**Key Points**:
- Legacy Dexie database is disabled by default (`USE_DEXIE_DB = false`)
- All database operations go through `PlatformService`
- Migration tools provide controlled access to both databases
- Clear separation between legacy and new code
### Migration Documentation
- [Migration Guide](doc/migration-to-wa-sqlite.md) - Complete migration process
- [Migration Fence Definition](doc/migration-fence-definition.md) - Fence boundaries and rules
- [Database Migration Guide](doc/database-migration-guide.md) - User-facing migration tools
## Roadmap
See [project.task.yaml](project.task.yaml) for current priorities.
@@ -47,15 +21,21 @@ npm run dev
See [BUILDING.md](BUILDING.md) for more details.
## Tests
See [TESTING.md](test-playwright/TESTING.md) for detailed test instructions.
## Icons
Application icons are in the `assets` directory, processed by the `capacitor-assets` command.
To add a Font Awesome icon, add to fontawesome.ts and reference with `font-awesome` element and `icon` attribute with the hyphenated name.
To add a Font Awesome icon, add to main.ts and reference with `font-awesome` element and `icon` attribute with the hyphenated name.
## Other
@@ -86,21 +66,6 @@ Key principles:
- Common interfaces are shared through `common.ts`
- Type definitions are generated from Zod schemas where possible
### Database Architecture
The application uses a platform-agnostic database layer:
* `src/services/PlatformService.ts` - Database interface definition
* `src/services/PlatformServiceFactory.ts` - Platform-specific service factory
* `src/services/AbsurdSqlDatabaseService.ts` - SQLite implementation
* `src/db/` - Legacy Dexie database (migration in progress)
**Development Guidelines**:
- Always use `PlatformService` for database operations
- Never import Dexie directly in application code
- Test with `USE_DEXIE_DB = false` for new features
- Use migration tools for data transfer between systems
### Kudos
Gifts make the world go 'round!

View File

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

View File

@@ -13,7 +13,6 @@
android:exported="true"
android:label="@string/title_activity_main"
android:launchMode="singleTask"
android:screenOrientation="portrait"
android:theme="@style/AppTheme.NoActionBarLaunch">
<intent-filter>
<action android:name="android.intent.action.MAIN" />

View File

@@ -19,14 +19,14 @@
},
"SQLite": {
"iosDatabaseLocation": "Library/CapacitorDatabase",
"iosIsEncryption": true,
"iosIsEncryption": false,
"iosBiometric": {
"biometricAuth": true,
"biometricAuth": false,
"biometricTitle": "Biometric login for TimeSafari"
},
"androidIsEncryption": true,
"androidIsEncryption": false,
"androidBiometric": {
"biometricAuth": true,
"biometricAuth": false,
"biometricTitle": "Biometric login for TimeSafari"
}
}

View File

@@ -19,14 +19,14 @@
},
"SQLite": {
"iosDatabaseLocation": "Library/CapacitorDatabase",
"iosIsEncryption": true,
"iosIsEncryption": false,
"iosBiometric": {
"biometricAuth": true,
"biometricAuth": false,
"biometricTitle": "Biometric login for TimeSafari"
},
"androidIsEncryption": true,
"androidIsEncryption": false,
"androidBiometric": {
"biometricAuth": true,
"biometricAuth": false,
"biometricTitle": "Biometric login for TimeSafari"
}
}

View File

@@ -100,7 +100,6 @@ try {
- `src/interfaces/deepLinks.ts`: Type definitions and validation schemas
- `src/services/deepLinks.ts`: Deep link processing service
- `src/main.capacitor.ts`: Capacitor integration
- `src/views/DeepLinkRedirectView.vue`: Page to handle links to both mobile and web
## Type Safety Examples

View File

@@ -1,295 +0,0 @@
# Database Migration Guide
## Overview
The Database Migration feature allows you to compare and migrate data between Dexie (IndexedDB) and SQLite databases in the TimeSafari application. This is particularly useful during the transition from the old Dexie-based storage system to the new SQLite-based system.
## Features
### 1. Database Comparison
- Compare data between Dexie and SQLite databases
- View detailed differences in contacts and settings
- Identify added, modified, and missing records
- Export comparison results for analysis
### 2. Data Migration
- Migrate contacts from Dexie to SQLite
- Migrate settings from Dexie to SQLite
- Option to overwrite existing records or skip them
- Comprehensive error handling and reporting
### 3. User Interface
- Modern, responsive UI built with Tailwind CSS
- Real-time loading states and progress indicators
- Clear success and error messaging
- Export functionality for comparison data
## Prerequisites
### Enable Dexie Database
Before using the migration features, you must enable the Dexie database by setting:
```typescript
// In constants/app.ts
export const USE_DEXIE_DB = true;
```
**Note**: This should only be enabled temporarily during migration. Remember to set it back to `false` after migration is complete.
## Accessing the Migration Interface
1. Navigate to the **Account** page in the TimeSafari app
2. Scroll down to find the **Database Migration** link
3. Click the link to open the migration interface
## Using the Migration Interface
### Step 1: Compare Databases
1. Click the **"Compare Databases"** button
2. The system will retrieve data from both Dexie and SQLite databases
3. Review the comparison results showing:
- Summary counts for each database
- Detailed differences (added, modified, missing records)
- Specific records that need attention
### Step 2: Review Differences
The comparison results are displayed in several sections:
#### Summary Cards
- **Dexie Contacts**: Number of contacts in Dexie database
- **SQLite Contacts**: Number of contacts in SQLite database
- **Dexie Settings**: Number of settings in Dexie database
- **SQLite Settings**: Number of settings in SQLite database
#### Contact Differences
- **Added**: Contacts in Dexie but not in SQLite
- **Modified**: Contacts that differ between databases
- **Missing**: Contacts in SQLite but not in Dexie
#### Settings Differences
- **Added**: Settings in Dexie but not in SQLite
- **Modified**: Settings that differ between databases
- **Missing**: Settings in SQLite but not in Dexie
### Step 3: Configure Migration Options
Before migrating data, configure the migration options:
- **Overwrite existing records**: When enabled, existing records in SQLite will be updated with data from Dexie. When disabled, existing records will be skipped.
### Step 4: Migrate Data
#### Migrate Contacts
1. Click the **"Migrate Contacts"** button
2. The system will transfer contacts from Dexie to SQLite
3. Review the migration results showing:
- Number of contacts successfully migrated
- Any warnings or errors encountered
#### Migrate Settings
1. Click the **"Migrate Settings"** button
2. The system will transfer settings from Dexie to SQLite
3. Review the migration results showing:
- Number of settings successfully migrated
- Any warnings or errors encountered
### Step 5: Export Comparison (Optional)
1. Click the **"Export Comparison"** button
2. A JSON file will be downloaded containing the complete comparison data
3. This file can be used for analysis or backup purposes
## Migration Process Details
### Contact Migration
The contact migration process:
1. **Retrieves** all contacts from Dexie database
2. **Checks** for existing contacts in SQLite by DID
3. **Inserts** new contacts or **updates** existing ones (if overwrite is enabled)
4. **Handles** complex fields like `contactMethods` (JSON arrays)
5. **Reports** success/failure for each contact
### Settings Migration
The settings migration process:
1. **Retrieves** all settings from Dexie database
2. **Focuses** on key user-facing settings:
- `firstName`
- `isRegistered`
- `profileImageUrl`
- `showShortcutBvc`
- `searchBoxes`
3. **Preserves** other settings in SQLite
4. **Reports** success/failure for each setting
## Error Handling
### Common Issues
#### Dexie Database Not Enabled
**Error**: "Dexie database is not enabled"
**Solution**: Set `USE_DEXIE_DB = true` in `constants/app.ts`
#### Database Connection Issues
**Error**: "Failed to retrieve Dexie contacts"
**Solution**: Check that the Dexie database is properly initialized and accessible
#### SQLite Query Errors
**Error**: "Failed to retrieve SQLite contacts"
**Solution**: Verify that the SQLite database is properly set up and the platform service is working
#### Migration Failures
**Error**: "Migration failed: [specific error]"
**Solution**: Review the error details and check data integrity in both databases
### Error Recovery
1. **Review** the error messages carefully
2. **Check** the browser console for additional details
3. **Verify** database connectivity and permissions
4. **Retry** the operation if appropriate
5. **Export** comparison data for manual review if needed
## Best Practices
### Before Migration
1. **Backup** your data if possible
2. **Test** the migration on a small dataset first
3. **Verify** that both databases are accessible
4. **Review** the comparison results before migrating
### During Migration
1. **Don't** interrupt the migration process
2. **Monitor** the progress and error messages
3. **Note** any warnings or skipped records
4. **Export** comparison data for reference
### After Migration
1. **Verify** that data was migrated correctly
2. **Test** the application functionality
3. **Disable** Dexie database (`USE_DEXIE_DB = false`)
4. **Clean up** any temporary files or exports
## Technical Details
### Database Schema
The migration handles the following data structures:
#### Contacts Table
```typescript
interface Contact {
did: string; // Decentralized Identifier
name: string; // Contact name
contactMethods: ContactMethod[]; // Array of contact methods
nextPubKeyHashB64: string; // Next public key hash
notes: string; // Contact notes
profileImageUrl: string; // Profile image URL
publicKeyBase64: string; // Public key in base64
seesMe: boolean; // Visibility flag
registered: boolean; // Registration status
}
```
#### Settings Table
```typescript
interface Settings {
id: number; // Settings ID
accountDid: string; // Account DID
activeDid: string; // Active DID
firstName: string; // User's first name
isRegistered: boolean; // Registration status
profileImageUrl: string; // Profile image URL
showShortcutBvc: boolean; // UI preference
searchBoxes: any[]; // Search configuration
// ... other fields
}
```
### Migration Logic
The migration service uses sophisticated comparison logic:
1. **Primary Key Matching**: Uses DID for contacts, ID for settings
2. **Deep Comparison**: Compares all fields including complex objects
3. **JSON Handling**: Properly handles JSON fields like `contactMethods` and `searchBoxes`
4. **Conflict Resolution**: Provides options for handling existing records
### Performance Considerations
- **Batch Processing**: Processes records one by one for reliability
- **Error Isolation**: Individual record failures don't stop the entire migration
- **Memory Management**: Handles large datasets efficiently
- **Progress Reporting**: Provides real-time feedback during migration
## Troubleshooting
### Migration Stuck
If the migration appears to be stuck:
1. **Check** the browser console for errors
2. **Refresh** the page and try again
3. **Verify** database connectivity
4. **Check** for large datasets that might take time
### Incomplete Migration
If migration doesn't complete:
1. **Review** error messages
2. **Check** data integrity in both databases
3. **Export** comparison data for manual review
4. **Consider** migrating in smaller batches
### Data Inconsistencies
If you notice data inconsistencies:
1. **Export** comparison data
2. **Review** the differences carefully
3. **Manually** verify critical records
4. **Consider** selective migration of specific records
## Support
For issues with the Database Migration feature:
1. **Check** this documentation first
2. **Review** the browser console for error details
3. **Export** comparison data for analysis
4. **Contact** the development team with specific error details
## Security Considerations
- **Data Privacy**: Migration data is processed locally and not sent to external servers
- **Access Control**: Only users with access to the account can perform migration
- **Data Integrity**: Migration preserves data integrity and handles conflicts gracefully
- **Audit Trail**: Export functionality provides an audit trail of migration operations
---
**Note**: This migration tool is designed for the transition period between database systems. Once migration is complete and verified, the Dexie database should be disabled to avoid confusion and potential data conflicts.

View File

@@ -3,7 +3,6 @@
## Schema Mapping
### Current Dexie Schema
```typescript
// Current Dexie schema
const db = new Dexie('TimeSafariDB');
@@ -16,7 +15,6 @@ db.version(1).stores({
```
### New SQLite Schema
```sql
-- New SQLite schema
CREATE TABLE accounts (
@@ -52,7 +50,6 @@ CREATE INDEX idx_settings_updated_at ON settings(updated_at);
### 1. Account Operations
#### Get Account by DID
```typescript
// Dexie
const account = await db.accounts.get(did);
@@ -65,7 +62,6 @@ const account = result[0]?.values[0];
```
#### Get All Accounts
```typescript
// Dexie
const accounts = await db.accounts.toArray();
@@ -78,7 +74,6 @@ const accounts = result[0]?.values || [];
```
#### Add Account
```typescript
// Dexie
await db.accounts.add({
@@ -96,7 +91,6 @@ await db.run(`
```
#### Update Account
```typescript
// Dexie
await db.accounts.update(did, {
@@ -106,7 +100,7 @@ await db.accounts.update(did, {
// absurd-sql
await db.run(`
UPDATE accounts
UPDATE accounts
SET public_key_hex = ?, updated_at = ?
WHERE did = ?
`, [publicKeyHex, Date.now(), did]);
@@ -115,7 +109,6 @@ await db.run(`
### 2. Settings Operations
#### Get Setting
```typescript
// Dexie
const setting = await db.settings.get(key);
@@ -128,7 +121,6 @@ const setting = result[0]?.values[0];
```
#### Set Setting
```typescript
// Dexie
await db.settings.put({
@@ -150,7 +142,6 @@ await db.run(`
### 3. Contact Operations
#### Get Contacts by Account
```typescript
// Dexie
const contacts = await db.contacts
@@ -160,7 +151,7 @@ const contacts = await db.contacts
// absurd-sql
const result = await db.exec(`
SELECT * FROM contacts
SELECT * FROM contacts
WHERE did = ?
ORDER BY created_at DESC
`, [accountDid]);
@@ -168,7 +159,6 @@ const contacts = result[0]?.values || [];
```
#### Add Contact
```typescript
// Dexie
await db.contacts.add({
@@ -189,7 +179,6 @@ await db.run(`
## Transaction Mapping
### Batch Operations
```typescript
// Dexie
await db.transaction('rw', [db.accounts, db.contacts], async () => {
@@ -221,11 +210,10 @@ try {
## Migration Helper Functions
### 1. Data Export (Dexie to JSON)
```typescript
async function exportDexieData(): Promise<MigrationData> {
const db = new Dexie('TimeSafariDB');
return {
accounts: await db.accounts.toArray(),
settings: await db.settings.toArray(),
@@ -240,7 +228,6 @@ async function exportDexieData(): Promise<MigrationData> {
```
### 2. Data Import (JSON to absurd-sql)
```typescript
async function importToAbsurdSql(data: MigrationData): Promise<void> {
await db.exec('BEGIN TRANSACTION;');
@@ -252,7 +239,7 @@ async function importToAbsurdSql(data: MigrationData): Promise<void> {
VALUES (?, ?, ?, ?)
`, [account.did, account.publicKeyHex, account.createdAt, account.updatedAt]);
}
// Import settings
for (const setting of data.settings) {
await db.run(`
@@ -260,7 +247,7 @@ async function importToAbsurdSql(data: MigrationData): Promise<void> {
VALUES (?, ?, ?)
`, [setting.key, setting.value, setting.updatedAt]);
}
// Import contacts
for (const contact of data.contacts) {
await db.run(`
@@ -277,7 +264,6 @@ async function importToAbsurdSql(data: MigrationData): Promise<void> {
```
### 3. Verification
```typescript
async function verifyMigration(dexieData: MigrationData): Promise<boolean> {
// Verify account count
@@ -286,21 +272,21 @@ async function verifyMigration(dexieData: MigrationData): Promise<boolean> {
if (accountCount !== dexieData.accounts.length) {
return false;
}
// Verify settings count
const settingsResult = await db.exec('SELECT COUNT(*) as count FROM settings');
const settingsCount = settingsResult[0].values[0][0];
if (settingsCount !== dexieData.settings.length) {
return false;
}
// Verify contacts count
const contactsResult = await db.exec('SELECT COUNT(*) as count FROM contacts');
const contactsCount = contactsResult[0].values[0][0];
if (contactsCount !== dexieData.contacts.length) {
return false;
}
// Verify data integrity
for (const account of dexieData.accounts) {
const result = await db.exec(
@@ -308,12 +294,12 @@ async function verifyMigration(dexieData: MigrationData): Promise<boolean> {
[account.did]
);
const migratedAccount = result[0]?.values[0];
if (!migratedAccount ||
if (!migratedAccount ||
migratedAccount[1] !== account.publicKeyHex) { // public_key_hex is second column
return false;
}
}
return true;
}
```
@@ -321,21 +307,18 @@ async function verifyMigration(dexieData: MigrationData): Promise<boolean> {
## Performance Considerations
### 1. Indexing
- Dexie automatically creates indexes based on the schema
- absurd-sql requires explicit index creation
- Added indexes for frequently queried fields
- Use `PRAGMA journal_mode=MEMORY;` for better performance
### 2. Batch Operations
- Dexie has built-in bulk operations
- absurd-sql uses transactions for batch operations
- Consider chunking large datasets
- Use prepared statements for repeated queries
### 3. Query Optimization
- Dexie uses IndexedDB's native indexing
- absurd-sql requires explicit query optimization
- Use prepared statements for repeated queries
@@ -344,7 +327,6 @@ async function verifyMigration(dexieData: MigrationData): Promise<boolean> {
## Error Handling
### 1. Common Errors
```typescript
// Dexie errors
try {
@@ -369,7 +351,6 @@ try {
```
### 2. Transaction Recovery
```typescript
// Dexie transaction
try {
@@ -415,4 +396,4 @@ try {
- Remove Dexie database
- Clear IndexedDB storage
- Update application code
- Remove old dependencies
- Remove old dependencies

View File

@@ -1,272 +0,0 @@
# Migration Fence Definition: Dexie to SQLite
## Overview
This document defines the **migration fence** - the boundary between the legacy Dexie (IndexedDB) storage system and the new SQLite-based storage system in TimeSafari. The fence ensures controlled migration while maintaining data integrity and application stability.
## Current Migration Status
### ✅ Completed Components
- **SQLite Database Service**: Fully implemented with absurd-sql
- **Platform Service Layer**: Unified database interface across platforms
- **Migration Tools**: Data comparison and transfer utilities
- **Schema Migration**: Complete table structure migration
- **Data Export/Import**: Backup and restore functionality
### 🔄 Active Migration Components
- **Settings Migration**: Core user settings transferred
- **Account Migration**: Identity and key management
- **Contact Migration**: User contact data (via import interface)
### ❌ Legacy Components (Fence Boundary)
- **Dexie Database**: Legacy IndexedDB storage (disabled by default)
- **Dexie-Specific Code**: Direct database access patterns
- **Legacy Migration Paths**: Old data transfer methods
## Migration Fence Definition
### 1. Configuration Boundary
```typescript
// src/constants/app.ts
export const USE_DEXIE_DB = false; // FENCE: Controls legacy database access
```
**Fence Rule**: When `USE_DEXIE_DB = false`:
- All new data operations use SQLite
- Legacy Dexie database is not initialized
- Migration tools are the only path to legacy data
**Fence Rule**: When `USE_DEXIE_DB = true`:
- Legacy database is available for migration
- Dual-write operations may be enabled
- Migration tools can access both databases
### 2. Service Layer Boundary
```typescript
// src/services/PlatformServiceFactory.ts
export class PlatformServiceFactory {
public static getInstance(): PlatformService {
// FENCE: All database operations go through platform service
// No direct Dexie access outside migration tools
}
}
```
**Fence Rule**: All database operations must use:
- `PlatformService.dbQuery()` for read operations
- `PlatformService.dbExec()` for write operations
- No direct `db.` or `accountsDBPromise` access in application code
### 3. Data Access Patterns
#### ✅ Allowed (Inside Fence)
```typescript
// Use platform service for all database operations
const platformService = PlatformServiceFactory.getInstance();
const contacts = await platformService.dbQuery(
"SELECT * FROM contacts WHERE did = ?",
[accountDid]
);
```
#### ❌ Forbidden (Outside Fence)
```typescript
// Direct Dexie access (legacy pattern)
const contacts = await db.contacts.where('did').equals(accountDid).toArray();
// Direct database reference
const result = await accountsDBPromise;
```
### 4. Migration Tool Boundary
```typescript
// src/services/indexedDBMigrationService.ts
// FENCE: Only migration tools can access both databases
export async function compareDatabases(): Promise<DataComparison> {
// This is the ONLY place where both databases are accessed
}
```
**Fence Rule**: Migration tools are the exclusive interface between:
- Legacy Dexie database
- New SQLite database
- Data comparison and transfer operations
## Migration Fence Guidelines
### 1. Code Development Rules
#### New Feature Development
- **Always** use `PlatformService` for database operations
- **Never** import or reference Dexie directly
- **Always** test with `USE_DEXIE_DB = false`
#### Legacy Code Maintenance
- **Only** modify Dexie code for migration purposes
- **Always** add migration tests for schema changes
- **Never** add new Dexie-specific features
### 2. Data Integrity Rules
#### Migration Safety
- **Always** create backups before migration
- **Always** verify data integrity after migration
- **Never** delete legacy data until verified
#### Rollback Strategy
- **Always** maintain ability to rollback to Dexie
- **Always** preserve migration logs
- **Never** assume migration is irreversible
### 3. Testing Requirements
#### Migration Testing
```typescript
// Required test pattern for migration
describe('Database Migration', () => {
it('should migrate data without loss', async () => {
// 1. Enable Dexie
// 2. Create test data
// 3. Run migration
// 4. Verify data integrity
// 5. Disable Dexie
});
});
```
#### Application Testing
```typescript
// Required test pattern for application features
describe('Feature with Database', () => {
it('should work with SQLite only', async () => {
// Test with USE_DEXIE_DB = false
// Verify all operations use PlatformService
});
});
```
## Migration Fence Enforcement
### 1. Static Analysis
#### ESLint Rules
```json
{
"rules": {
"no-restricted-imports": [
"error",
{
"patterns": [
{
"group": ["../db/index"],
"message": "Use PlatformService instead of direct Dexie access"
}
]
}
]
}
}
```
#### TypeScript Rules
```json
{
"compilerOptions": {
"strict": true,
"noImplicitAny": true
}
}
```
### 2. Runtime Checks
#### Development Mode Validation
```typescript
// Development-only fence validation
if (import.meta.env.DEV && USE_DEXIE_DB) {
console.warn('⚠️ Dexie is enabled - migration mode active');
}
```
#### Production Safety
```typescript
// Production fence enforcement
if (import.meta.env.PROD && USE_DEXIE_DB) {
throw new Error('Dexie cannot be enabled in production');
}
```
## Migration Fence Timeline
### Phase 1: Fence Establishment ✅
- [x] Define migration fence boundaries
- [x] Implement PlatformService layer
- [x] Create migration tools
- [x] Set `USE_DEXIE_DB = false` by default
### Phase 2: Data Migration 🔄
- [x] Migrate core settings
- [x] Migrate account data
- [ ] Complete contact migration
- [ ] Verify all data integrity
### Phase 3: Code Cleanup 📋
- [ ] Remove unused Dexie imports
- [ ] Clean up legacy database code
- [ ] Update all documentation
- [ ] Remove migration tools
### Phase 4: Fence Removal 🎯
- [ ] Remove `USE_DEXIE_DB` constant
- [ ] Remove Dexie dependencies
- [ ] Remove migration service
- [ ] Finalize SQLite-only architecture
## Security Considerations
### 1. Data Protection
- **Encryption**: Maintain encryption standards across migration
- **Access Control**: Preserve user privacy during migration
- **Audit Trail**: Log all migration operations
### 2. Error Handling
- **Graceful Degradation**: Handle migration failures gracefully
- **User Communication**: Clear messaging about migration status
- **Recovery Options**: Provide rollback mechanisms
## Performance Considerations
### 1. Migration Performance
- **Batch Operations**: Use transactions for bulk data transfer
- **Progress Indicators**: Show migration progress to users
- **Background Processing**: Non-blocking migration operations
### 2. Application Performance
- **Query Optimization**: Optimize SQLite queries for performance
- **Indexing Strategy**: Maintain proper database indexes
- **Memory Management**: Efficient memory usage during migration
## Documentation Requirements
### 1. Code Documentation
- **Migration Fence Comments**: Document fence boundaries in code
- **API Documentation**: Update all database API documentation
- **Migration Guides**: Comprehensive migration documentation
### 2. User Documentation
- **Migration Instructions**: Clear user migration steps
- **Troubleshooting**: Common migration issues and solutions
- **Rollback Instructions**: How to revert if needed
## Conclusion
The migration fence provides a controlled boundary between legacy and new database systems, ensuring:
- **Data Integrity**: No data loss during migration
- **Application Stability**: Consistent behavior across platforms
- **Development Clarity**: Clear guidelines for code development
- **Migration Safety**: Controlled and reversible migration process
This fence will remain in place until all data is successfully migrated and verified, at which point the legacy system can be safely removed.

View File

@@ -1,355 +0,0 @@
# Database Migration Security Audit Checklist
## Overview
This document provides a comprehensive security audit checklist for the Dexie to SQLite migration in TimeSafari. The checklist ensures that data protection, privacy, and security are maintained throughout the migration process.
## Pre-Migration Security Assessment
### 1. Data Classification and Sensitivity
- [ ] **Data Inventory**
- [ ] Identify all sensitive data types (DIDs, private keys, personal information)
- [ ] Document data retention requirements
- [ ] Map data relationships and dependencies
- [ ] Assess data sensitivity levels (public, internal, confidential, restricted)
- [ ] **Encryption Assessment**
- [ ] Verify current encryption methods for sensitive data
- [ ] Document encryption keys and their management
- [ ] Assess encryption strength and compliance
- [ ] Plan encryption migration strategy
### 2. Access Control Review
- [ ] **User Access Rights**
- [ ] Audit current user permissions and roles
- [ ] Document access control mechanisms
- [ ] Verify principle of least privilege
- [ ] Plan access control migration
- [ ] **System Access**
- [ ] Review database access patterns
- [ ] Document authentication mechanisms
- [ ] Assess session management
- [ ] Plan authentication migration
### 3. Compliance Requirements
- [ ] **Regulatory Compliance**
- [ ] Identify applicable regulations (GDPR, CCPA, etc.)
- [ ] Document data processing requirements
- [ ] Assess privacy impact
- [ ] Plan compliance verification
- [ ] **Industry Standards**
- [ ] Review security standards compliance
- [ ] Document security controls
- [ ] Assess audit requirements
- [ ] Plan standards compliance
## Migration Security Controls
### 1. Data Protection During Migration
- [ ] **Encryption in Transit**
- [ ] Verify all data transfers are encrypted
- [ ] Use secure communication protocols (TLS 1.3+)
- [ ] Implement secure API endpoints
- [ ] Monitor encryption status
- [ ] **Encryption at Rest**
- [ ] Maintain encryption for stored data
- [ ] Verify encryption key management
- [ ] Test encryption/decryption processes
- [ ] Document encryption procedures
### 2. Access Control During Migration
- [ ] **Authentication**
- [ ] Maintain user authentication during migration
- [ ] Verify session management
- [ ] Implement secure token handling
- [ ] Monitor authentication events
- [ ] **Authorization**
- [ ] Preserve user permissions during migration
- [ ] Verify role-based access control
- [ ] Implement audit logging
- [ ] Monitor access patterns
### 3. Data Integrity
- [ ] **Data Validation**
- [ ] Implement input validation for all data
- [ ] Verify data format consistency
- [ ] Test data transformation processes
- [ ] Document validation rules
- [ ] **Data Verification**
- [ ] Implement checksums for data integrity
- [ ] Verify data completeness after migration
- [ ] Test data consistency checks
- [ ] Document verification procedures
## Migration Process Security
### 1. Backup Security
- [ ] **Backup Creation**
- [ ] Create encrypted backups before migration
- [ ] Verify backup integrity
- [ ] Store backups securely
- [ ] Test backup restoration
- [ ] **Backup Access**
- [ ] Limit backup access to authorized personnel
- [ ] Implement backup access logging
- [ ] Verify backup encryption
- [ ] Document backup procedures
### 2. Migration Tool Security
- [ ] **Tool Authentication**
- [ ] Implement secure authentication for migration tools
- [ ] Verify tool access controls
- [ ] Monitor tool usage
- [ ] Document tool security
- [ ] **Tool Validation**
- [ ] Verify migration tool integrity
- [ ] Test tool security features
- [ ] Validate tool outputs
- [ ] Document tool validation
### 3. Error Handling
- [ ] **Error Security**
- [ ] Implement secure error handling
- [ ] Avoid information disclosure in errors
- [ ] Log security-relevant errors
- [ ] Document error procedures
- [ ] **Recovery Security**
- [ ] Implement secure recovery procedures
- [ ] Verify recovery data protection
- [ ] Test recovery processes
- [ ] Document recovery security
## Post-Migration Security
### 1. Data Verification
- [ ] **Data Completeness**
- [ ] Verify all data was migrated successfully
- [ ] Check for data corruption
- [ ] Validate data relationships
- [ ] Document verification results
- [ ] **Data Accuracy**
- [ ] Verify data accuracy after migration
- [ ] Test data consistency
- [ ] Validate data integrity
- [ ] Document accuracy checks
### 2. Access Control Verification
- [ ] **User Access**
- [ ] Verify user access rights after migration
- [ ] Test authentication mechanisms
- [ ] Validate authorization rules
- [ ] Document access verification
- [ ] **System Access**
- [ ] Verify system access controls
- [ ] Test API security
- [ ] Validate session management
- [ ] Document system security
### 3. Security Testing
- [ ] **Penetration Testing**
- [ ] Conduct security penetration testing
- [ ] Test for common vulnerabilities
- [ ] Verify security controls
- [ ] Document test results
- [ ] **Vulnerability Assessment**
- [ ] Scan for security vulnerabilities
- [ ] Assess security posture
- [ ] Identify security gaps
- [ ] Document assessment results
## Monitoring and Logging
### 1. Security Monitoring
- [ ] **Access Monitoring**
- [ ] Monitor database access patterns
- [ ] Track user authentication events
- [ ] Monitor system access
- [ ] Document monitoring procedures
- [ ] **Data Monitoring**
- [ ] Monitor data access patterns
- [ ] Track data modification events
- [ ] Monitor data integrity
- [ ] Document data monitoring
### 2. Security Logging
- [ ] **Audit Logging**
- [ ] Implement comprehensive audit logging
- [ ] Log all security-relevant events
- [ ] Secure log storage and access
- [ ] Document logging procedures
- [ ] **Log Analysis**
- [ ] Implement log analysis tools
- [ ] Monitor for security incidents
- [ ] Analyze security trends
- [ ] Document analysis procedures
## Incident Response
### 1. Security Incident Planning
- [ ] **Incident Response Plan**
- [ ] Develop security incident response plan
- [ ] Define incident response procedures
- [ ] Train incident response team
- [ ] Document response procedures
- [ ] **Incident Detection**
- [ ] Implement incident detection mechanisms
- [ ] Monitor for security incidents
- [ ] Establish incident reporting procedures
- [ ] Document detection procedures
### 2. Recovery Procedures
- [ ] **Data Recovery**
- [ ] Develop data recovery procedures
- [ ] Test recovery processes
- [ ] Verify recovery data integrity
- [ ] Document recovery procedures
- [ ] **System Recovery**
- [ ] Develop system recovery procedures
- [ ] Test system recovery
- [ ] Verify system security after recovery
- [ ] Document recovery procedures
## Compliance Verification
### 1. Regulatory Compliance
- [ ] **Privacy Compliance**
- [ ] Verify GDPR compliance
- [ ] Check CCPA compliance
- [ ] Assess other privacy regulations
- [ ] Document compliance status
- [ ] **Security Compliance**
- [ ] Verify security standard compliance
- [ ] Check industry requirements
- [ ] Assess security certifications
- [ ] Document compliance status
### 2. Audit Requirements
- [ ] **Audit Trail**
- [ ] Maintain comprehensive audit trail
- [ ] Verify audit log integrity
- [ ] Test audit log accessibility
- [ ] Document audit procedures
- [ ] **Audit Reporting**
- [ ] Generate audit reports
- [ ] Verify report accuracy
- [ ] Distribute reports securely
- [ ] Document reporting procedures
## Documentation and Training
### 1. Security Documentation
- [ ] **Security Procedures**
- [ ] Document security procedures
- [ ] Update security policies
- [ ] Create security guidelines
- [ ] Maintain documentation
- [ ] **Security Training**
- [ ] Develop security training materials
- [ ] Train staff on security procedures
- [ ] Verify training effectiveness
- [ ] Document training procedures
### 2. Ongoing Security
- [ ] **Security Maintenance**
- [ ] Establish security maintenance procedures
- [ ] Schedule security updates
- [ ] Monitor security trends
- [ ] Document maintenance procedures
- [ ] **Security Review**
- [ ] Conduct regular security reviews
- [ ] Update security controls
- [ ] Assess security effectiveness
- [ ] Document review procedures
## Risk Assessment
### 1. Risk Identification
- [ ] **Security Risks**
- [ ] Identify potential security risks
- [ ] Assess risk likelihood and impact
- [ ] Prioritize security risks
- [ ] Document risk assessment
- [ ] **Mitigation Strategies**
- [ ] Develop risk mitigation strategies
- [ ] Implement risk controls
- [ ] Monitor risk status
- [ ] Document mitigation procedures
### 2. Risk Monitoring
- [ ] **Risk Tracking**
- [ ] Track identified risks
- [ ] Monitor risk status
- [ ] Update risk assessments
- [ ] Document risk tracking
- [ ] **Risk Reporting**
- [ ] Generate risk reports
- [ ] Distribute risk information
- [ ] Update risk documentation
- [ ] Document reporting procedures
## Conclusion
This security audit checklist ensures that the database migration maintains the highest standards of data protection, privacy, and security. Regular review and updates of this checklist are essential to maintain security throughout the migration process and beyond.
### Security Checklist Summary
- [ ] **Pre-Migration Assessment**: Complete
- [ ] **Migration Controls**: Complete
- [ ] **Process Security**: Complete
- [ ] **Post-Migration Verification**: Complete
- [ ] **Monitoring and Logging**: Complete
- [ ] **Incident Response**: Complete
- [ ] **Compliance Verification**: Complete
- [ ] **Documentation and Training**: Complete
- [ ] **Risk Assessment**: Complete
**Overall Security Status**: [ ] Secure [ ] Needs Attention [ ] Critical Issues
**Next Review Date**: _______________
**Reviewed By**: _______________
**Approved By**: _______________

View File

@@ -4,223 +4,610 @@
This document outlines the migration process from Dexie.js to absurd-sql for the TimeSafari app's storage implementation. The migration aims to provide a consistent SQLite-based storage solution across all platforms while maintaining data integrity and ensuring a smooth transition for users.
**Current Status**: The migration is in **Phase 2** with a well-defined migration fence in place. Core settings and account data have been migrated, with contact migration in progress. **ActiveDid migration has been implemented** to ensure user identity continuity.
## Migration Goals
1. **Data Integrity**
- Preserve all existing data
- Maintain data relationships
- Ensure data consistency
- **Preserve user's active identity**
2. **Performance**
- Improve query performance
- Reduce storage overhead
- Optimize for platform-specific capabilities
- Optimize for platform-specific features
3. **User Experience**
- Seamless transition with no data loss
- Maintain user's active identity and preferences
- Preserve application state
3. **Security**
- Maintain or improve encryption
- Preserve access controls
- Enhance data protection
## Migration Architecture
4. **User Experience**
- Zero data loss
- Minimal downtime
- Automatic migration where possible
### Migration Fence
The migration fence is defined by the `USE_DEXIE_DB` constant in `src/constants/app.ts`:
- `USE_DEXIE_DB = false` (default): Uses SQLite database
- `USE_DEXIE_DB = true`: Uses Dexie database (for migration purposes)
## Prerequisites
### Migration Order
The migration follows a specific order to maintain data integrity:
1. **Backup Requirements**
```typescript
interface MigrationBackup {
timestamp: number;
accounts: Account[];
settings: Setting[];
contacts: Contact[];
metadata: {
version: string;
platform: string;
dexieVersion: string;
};
}
```
1. **Accounts** (foundational - contains DIDs)
2. **Settings** (references accountDid, activeDid)
3. **ActiveDid** (depends on accounts and settings) ⭐ **NEW**
4. **Contacts** (independent, but migrated after accounts for consistency)
2. **Dependencies**
```json
{
"@jlongster/sql.js": "^1.8.0",
"absurd-sql": "^1.8.0"
}
```
## ActiveDid Migration ⭐ **NEW FEATURE**
3. **Storage Requirements**
- Sufficient IndexedDB quota
- Available disk space for SQLite
- Backup storage space
### Problem Solved
Previously, the `activeDid` setting was not migrated from Dexie to SQLite, causing users to lose their active identity after migration.
### Solution Implemented
The migration now includes a dedicated step for migrating the `activeDid`:
1. **Detection**: Identifies the `activeDid` from Dexie master settings
2. **Validation**: Verifies the `activeDid` exists in SQLite accounts
3. **Migration**: Updates SQLite master settings with the `activeDid`
4. **Error Handling**: Graceful handling of missing accounts
### Implementation Details
#### New Function: `migrateActiveDid()`
```typescript
export async function migrateActiveDid(): Promise<MigrationResult> {
// 1. Get Dexie settings to find the activeDid
const dexieSettings = await getDexieSettings();
const masterSettings = dexieSettings.find(setting => !setting.accountDid);
// 2. Verify the activeDid exists in SQLite accounts
const accountExists = await platformService.dbQuery(
"SELECT did FROM accounts WHERE did = ?",
[dexieActiveDid],
);
// 3. Update SQLite master settings
await updateDefaultSettings({ activeDid: dexieActiveDid });
}
```
#### Enhanced `migrateSettings()` Function
The settings migration now includes activeDid handling:
- Extracts `activeDid` from Dexie master settings
- Validates account existence in SQLite
- Updates SQLite master settings with the `activeDid`
#### Updated `migrateAll()` Function
The complete migration now includes a dedicated step for activeDid:
```typescript
// Step 3: Migrate ActiveDid (depends on accounts and settings)
logger.info("[MigrationService] Step 3: Migrating activeDid...");
const activeDidResult = await migrateActiveDid();
```
### Benefits
-**User Identity Preservation**: Users maintain their active identity
-**Seamless Experience**: No need to manually select identity after migration
-**Data Consistency**: Ensures all identity-related settings are preserved
-**Error Resilience**: Graceful handling of edge cases
4. **Platform Support**
- Web: Modern browser with IndexedDB support
- iOS: iOS 13+ with SQLite support
- Android: Android 5+ with SQLite support
- Electron: Latest version with SQLite support
## Migration Process
### Phase 1: Preparation
- [x] Enable Dexie database access
- [x] Implement data comparison tools
- [x] Create migration service structure
### 1. Preparation
### Phase 2: Core Migration ✅
- [x] Account migration with `importFromMnemonic`
- [x] Settings migration (excluding activeDid)
- [x] **ActiveDid migration****COMPLETED**
- [x] Contact migration framework
### Phase 3: Validation and Cleanup 🔄
- [ ] Comprehensive data validation
- [ ] Performance testing
- [ ] User acceptance testing
- [ ] Dexie removal
## Usage
### Manual Migration
```typescript
import { migrateAll, migrateActiveDid } from '../services/indexedDBMigrationService';
// src/services/storage/migration/MigrationService.ts
import initSqlJs from '@jlongster/sql.js';
import { SQLiteFS } from 'absurd-sql';
import IndexedDBBackend from 'absurd-sql/dist/indexeddb-backend';
// Complete migration
const result = await migrateAll();
export class MigrationService {
private static instance: MigrationService;
private backup: MigrationBackup | null = null;
private sql: any = null;
private db: any = null;
// Or migrate just the activeDid
const activeDidResult = await migrateActiveDid();
async prepare(): Promise<void> {
try {
// 1. Check prerequisites
await this.checkPrerequisites();
// 2. Create backup
this.backup = await this.createBackup();
// 3. Verify backup integrity
await this.verifyBackup();
// 4. Initialize absurd-sql
await this.initializeAbsurdSql();
} catch (error) {
throw new StorageError(
'Migration preparation failed',
StorageErrorCodes.MIGRATION_FAILED,
error
);
}
}
private async initializeAbsurdSql(): Promise<void> {
// Initialize SQL.js
this.sql = await initSqlJs({
locateFile: (file: string) => {
return new URL(`/node_modules/@jlongster/sql.js/dist/${file}`, import.meta.url).href;
}
});
// Setup SQLiteFS with IndexedDB backend
const sqlFS = new SQLiteFS(this.sql.FS, new IndexedDBBackend());
this.sql.register_for_idb(sqlFS);
// Create and mount filesystem
this.sql.FS.mkdir('/sql');
this.sql.FS.mount(sqlFS, {}, '/sql');
// Open database
const path = '/sql/db.sqlite';
if (typeof SharedArrayBuffer === 'undefined') {
let stream = this.sql.FS.open(path, 'a+');
await stream.node.contents.readIfFallback();
this.sql.FS.close(stream);
}
this.db = new this.sql.Database(path, { filename: true });
if (!this.db) {
throw new StorageError(
'Database initialization failed',
StorageErrorCodes.INITIALIZATION_FAILED
);
}
// Configure database
await this.db.exec(`PRAGMA journal_mode=MEMORY;`);
}
private async checkPrerequisites(): Promise<void> {
// Check IndexedDB availability
if (!window.indexedDB) {
throw new StorageError(
'IndexedDB not available',
StorageErrorCodes.INITIALIZATION_FAILED
);
}
// Check storage quota
const quota = await navigator.storage.estimate();
if (quota.quota && quota.usage && quota.usage > quota.quota * 0.9) {
throw new StorageError(
'Insufficient storage space',
StorageErrorCodes.STORAGE_FULL
);
}
// Check platform support
const capabilities = await PlatformDetection.getCapabilities();
if (!capabilities.hasFileSystem) {
throw new StorageError(
'Platform does not support required features',
StorageErrorCodes.INITIALIZATION_FAILED
);
}
}
private async createBackup(): Promise<MigrationBackup> {
const dexieDB = new Dexie('TimeSafariDB');
return {
timestamp: Date.now(),
accounts: await dexieDB.accounts.toArray(),
settings: await dexieDB.settings.toArray(),
contacts: await dexieDB.contacts.toArray(),
metadata: {
version: '1.0.0',
platform: await PlatformDetection.getPlatform(),
dexieVersion: Dexie.version
}
};
}
}
```
### Migration Verification
### 2. Data Migration
```typescript
import { compareDatabases } from '../services/indexedDBMigrationService';
// src/services/storage/migration/DataMigration.ts
export class DataMigration {
async migrate(backup: MigrationBackup): Promise<void> {
try {
// 1. Create new database schema
await this.createSchema();
// 2. Migrate accounts
await this.migrateAccounts(backup.accounts);
// 3. Migrate settings
await this.migrateSettings(backup.settings);
// 4. Migrate contacts
await this.migrateContacts(backup.contacts);
// 5. Verify migration
await this.verifyMigration(backup);
} catch (error) {
// 6. Handle failure
await this.handleMigrationFailure(error, backup);
}
}
const comparison = await compareDatabases();
console.log('Migration differences:', comparison.differences);
private async migrateAccounts(accounts: Account[]): Promise<void> {
// Use transaction for atomicity
await this.db.exec('BEGIN TRANSACTION;');
try {
for (const account of accounts) {
await this.db.run(`
INSERT INTO accounts (did, public_key_hex, created_at, updated_at)
VALUES (?, ?, ?, ?)
`, [
account.did,
account.publicKeyHex,
account.createdAt,
account.updatedAt
]);
}
await this.db.exec('COMMIT;');
} catch (error) {
await this.db.exec('ROLLBACK;');
throw error;
}
}
private async verifyMigration(backup: MigrationBackup): Promise<void> {
// Verify account count
const result = await this.db.exec('SELECT COUNT(*) as count FROM accounts');
const accountCount = result[0].values[0][0];
if (accountCount !== backup.accounts.length) {
throw new StorageError(
'Account count mismatch',
StorageErrorCodes.VERIFICATION_FAILED
);
}
// Verify data integrity
await this.verifyDataIntegrity(backup);
}
}
```
## Error Handling
### 3. Rollback Strategy
### ActiveDid Migration Errors
- **Missing Account**: If the `activeDid` from Dexie doesn't exist in SQLite accounts
- **Database Errors**: Connection or query failures
- **Settings Update Failures**: Issues updating SQLite master settings
### Recovery Strategies
1. **Automatic Recovery**: Migration continues even if activeDid migration fails
2. **Manual Recovery**: Users can manually select their identity after migration
3. **Fallback**: System creates new identity if none exists
## Security Considerations
### Data Protection
- All sensitive data (mnemonics, private keys) are encrypted
- Migration preserves encryption standards
- No plaintext data exposure during migration
### Identity Verification
- ActiveDid migration validates account existence
- Prevents setting non-existent identities as active
- Maintains cryptographic integrity
## Testing
### Migration Testing
```bash
# Enable Dexie for testing
# Set USE_DEXIE_DB = true in constants/app.ts
# Run migration
npm run migrate
# Verify results
npm run test:migration
```
### ActiveDid Testing
```typescript
// Test activeDid migration specifically
const result = await migrateActiveDid();
expect(result.success).toBe(true);
expect(result.warnings).toContain('Successfully migrated activeDid');
// src/services/storage/migration/RollbackService.ts
export class RollbackService {
async rollback(backup: MigrationBackup): Promise<void> {
try {
// 1. Stop all database operations
await this.stopDatabaseOperations();
// 2. Restore from backup
await this.restoreFromBackup(backup);
// 3. Verify restoration
await this.verifyRestoration(backup);
// 4. Clean up absurd-sql
await this.cleanupAbsurdSql();
} catch (error) {
throw new StorageError(
'Rollback failed',
StorageErrorCodes.ROLLBACK_FAILED,
error
);
}
}
private async restoreFromBackup(backup: MigrationBackup): Promise<void> {
const dexieDB = new Dexie('TimeSafariDB');
// Restore accounts
await dexieDB.accounts.bulkPut(backup.accounts);
// Restore settings
await dexieDB.settings.bulkPut(backup.settings);
// Restore contacts
await dexieDB.contacts.bulkPut(backup.contacts);
}
}
```
## Troubleshooting
## Migration UI
### Common Issues
```vue
<!-- src/components/MigrationProgress.vue -->
<template>
<div class="migration-progress">
<h2>Database Migration</h2>
<div class="progress-container">
<div class="progress-bar" :style="{ width: `${progress}%` }" />
<div class="progress-text">{{ progress }}%</div>
</div>
<div class="status-message">{{ statusMessage }}</div>
<div v-if="error" class="error-message">
{{ error }}
<button @click="retryMigration">Retry</button>
</div>
</div>
</template>
1. **ActiveDid Not Found**
- Ensure accounts were migrated before activeDid migration
- Check that the Dexie activeDid exists in SQLite accounts
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { MigrationService } from '@/services/storage/migration/MigrationService';
2. **Migration Failures**
- Verify Dexie database is accessible
- Check SQLite database permissions
- Review migration logs for specific errors
const progress = ref(0);
const statusMessage = ref('Preparing migration...');
const error = ref<string | null>(null);
3. **Data Inconsistencies**
- Use `compareDatabases()` to identify differences
- Re-run migration if necessary
- Check for duplicate or conflicting records
const migrationService = MigrationService.getInstance();
### Debugging
```typescript
// Enable detailed logging
logger.setLevel('debug');
async function startMigration() {
try {
// 1. Preparation
statusMessage.value = 'Creating backup...';
await migrationService.prepare();
progress.value = 20;
// 2. Data migration
statusMessage.value = 'Migrating data...';
await migrationService.migrate();
progress.value = 80;
// 3. Verification
statusMessage.value = 'Verifying migration...';
await migrationService.verify();
progress.value = 100;
statusMessage.value = 'Migration completed successfully!';
} catch (err) {
error.value = err instanceof Error ? err.message : 'Migration failed';
statusMessage.value = 'Migration failed';
}
}
// Check migration status
const comparison = await compareDatabases();
console.log('Settings differences:', comparison.differences.settings);
async function retryMigration() {
error.value = null;
progress.value = 0;
await startMigration();
}
onMounted(() => {
startMigration();
});
</script>
<style scoped>
.migration-progress {
padding: 2rem;
max-width: 600px;
margin: 0 auto;
}
.progress-container {
position: relative;
height: 20px;
background: #eee;
border-radius: 10px;
overflow: hidden;
margin: 1rem 0;
}
.progress-bar {
position: absolute;
height: 100%;
background: #4CAF50;
transition: width 0.3s ease;
}
.progress-text {
position: absolute;
width: 100%;
text-align: center;
line-height: 20px;
color: #000;
}
.status-message {
text-align: center;
margin: 1rem 0;
}
.error-message {
color: #f44336;
text-align: center;
margin: 1rem 0;
}
button {
margin-top: 1rem;
padding: 0.5rem 1rem;
background: #2196F3;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
button:hover {
background: #1976D2;
}
</style>
```
## Future Enhancements
## Testing Strategy
### Planned Improvements
1. **Batch Processing**: Optimize for large datasets
2. **Incremental Migration**: Support partial migrations
3. **Rollback Capability**: Ability to revert migration
4. **Progress Tracking**: Real-time migration progress
1. **Unit Tests**
```typescript
// src/services/storage/migration/__tests__/MigrationService.spec.ts
describe('MigrationService', () => {
it('should initialize absurd-sql correctly', async () => {
const service = MigrationService.getInstance();
await service.initializeAbsurdSql();
expect(service.isInitialized()).toBe(true);
expect(service.getDatabase()).toBeDefined();
});
### Performance Optimizations
1. **Parallel Processing**: Migrate independent data concurrently
2. **Memory Management**: Optimize for large datasets
3. **Transaction Batching**: Reduce database round trips
it('should create valid backup', async () => {
const service = MigrationService.getInstance();
const backup = await service.createBackup();
expect(backup).toBeDefined();
expect(backup.accounts).toBeInstanceOf(Array);
expect(backup.settings).toBeInstanceOf(Array);
expect(backup.contacts).toBeInstanceOf(Array);
});
## Conclusion
it('should migrate data correctly', async () => {
const service = MigrationService.getInstance();
const backup = await service.createBackup();
await service.migrate(backup);
// Verify migration
const accounts = await service.getMigratedAccounts();
expect(accounts).toHaveLength(backup.accounts.length);
});
The Dexie to SQLite migration provides a robust, secure, and user-friendly transition path. The addition of activeDid migration ensures that users maintain their identity continuity throughout the migration process, significantly improving the user experience.
it('should handle rollback correctly', async () => {
const service = MigrationService.getInstance();
const backup = await service.createBackup();
// Simulate failed migration
await service.migrate(backup);
await service.simulateFailure();
// Perform rollback
await service.rollback(backup);
// Verify rollback
const accounts = await service.getOriginalAccounts();
expect(accounts).toHaveLength(backup.accounts.length);
});
});
```
The migration fence architecture allows for controlled, reversible migration while maintaining application stability and data integrity.
2. **Integration Tests**
```typescript
// src/services/storage/migration/__tests__/integration/Migration.spec.ts
describe('Migration Integration', () => {
it('should handle concurrent access during migration', async () => {
const service = MigrationService.getInstance();
// Start migration
const migrationPromise = service.migrate();
// Simulate concurrent access
const accessPromises = Array(5).fill(null).map(() =>
service.getAccount('did:test:123')
);
// Wait for all operations
const [migrationResult, ...accessResults] = await Promise.allSettled([
migrationPromise,
...accessPromises
]);
// Verify results
expect(migrationResult.status).toBe('fulfilled');
expect(accessResults.some(r => r.status === 'rejected')).toBe(true);
});
it('should maintain data integrity during platform transition', async () => {
const service = MigrationService.getInstance();
// Simulate platform change
await service.simulatePlatformChange();
// Verify data
const accounts = await service.getAllAccounts();
const settings = await service.getAllSettings();
const contacts = await service.getAllContacts();
expect(accounts).toBeDefined();
expect(settings).toBeDefined();
expect(contacts).toBeDefined();
});
});
```
## Success Criteria
1. **Data Integrity**
- [ ] All accounts migrated successfully
- [ ] All settings preserved
- [ ] All contacts transferred
- [ ] No data corruption
2. **Performance**
- [ ] Migration completes within acceptable time
- [ ] No significant performance degradation
- [ ] Efficient storage usage
- [ ] Smooth user experience
3. **Security**
- [ ] Encrypted data remains secure
- [ ] Access controls maintained
- [ ] No sensitive data exposure
- [ ] Secure backup process
4. **User Experience**
- [ ] Clear migration progress
- [ ] Informative error messages
- [ ] Automatic recovery from failures
- [ ] No data loss
## Rollback Plan
1. **Automatic Rollback**
- Triggered by migration failure
- Restores from verified backup
- Maintains data consistency
- Logs rollback reason
2. **Manual Rollback**
- Available through settings
- Requires user confirmation
- Preserves backup data
- Provides rollback status
3. **Emergency Recovery**
- Manual backup restoration
- Database repair tools
- Data recovery procedures
- Support contact information
## Post-Migration
1. **Verification**
- Data integrity checks
- Performance monitoring
- Error rate tracking
- User feedback collection
2. **Cleanup**
- Remove old database
- Clear migration artifacts
- Update application state
- Archive backup data
3. **Monitoring**
- Track migration success rate
- Monitor performance metrics
- Collect error reports
- Gather user feedback
## Support
For assistance with migration:
1. Check the troubleshooting guide
2. Review error logs
3. Contact support team
4. Submit issue report
## Timeline
1. **Preparation Phase** (1 week)
- Backup system implementation
- Migration service development
- Testing framework setup
2. **Testing Phase** (2 weeks)
- Unit testing
- Integration testing
- Performance testing
- Security testing
3. **Deployment Phase** (1 week)
- Staged rollout
- Monitoring
- Support preparation
- Documentation updates
4. **Post-Deployment** (2 weeks)
- Monitoring
- Bug fixes
- Performance optimization
- User feedback collection

View File

@@ -0,0 +1,221 @@
# Database Connection Fixes for TimeSafari
## Overview
This document outlines the fixes implemented to resolve database connection issues in the TimeSafari application, particularly for Capacitor SQLite on Android devices.
## Issues Identified
### 1. CapacitorSQLitePlugin Errors
- Multiple `*** ERROR CapacitorSQLitePlugin: null` messages in Android logs
- Database connection conflicts and initialization failures
- Connection leaks causing "Connection timesafari.sqlite already exists" errors
### 2. Performance Issues
- App skipping 57 frames due to main thread blocking
- Null pointer exceptions in garbage collection
- Memory management issues
### 3. Connection Management
- Lack of proper connection cleanup on app lifecycle events
- No retry logic for failed connections
- Missing error handling and recovery mechanisms
## Implemented Fixes
### 1. Enhanced Database Initialization
#### Connection Cleanup
- Added `cleanupExistingConnections()` method to properly close existing connections
- Implemented connection consistency checks before creating new connections
- Added proper error handling for connection cleanup failures
#### Retry Logic
- Implemented exponential backoff retry mechanism for database connections
- Maximum of 3 retry attempts with increasing delays
- Comprehensive error logging for each attempt
#### Database Configuration
- Configured optimal SQLite settings for performance and stability:
- `PRAGMA journal_mode=WAL` for better concurrency
- `PRAGMA synchronous=NORMAL` for balanced performance
- `PRAGMA cache_size=10000` for improved caching
- `PRAGMA temp_store=MEMORY` for faster temporary operations
- `PRAGMA mmap_size=268435456` (256MB) for memory mapping
### 2. Lifecycle Management
#### App Lifecycle Listeners
- Added event listeners for `beforeunload` and `visibilitychange`
- Automatic database cleanup when app goes to background
- Proper resource management to prevent connection leaks
#### Health Monitoring
- Implemented `healthCheck()` method for connection status monitoring
- Added `reinitializeDatabase()` for forced reconnection
- Performance metrics tracking for database operations
### 3. Error Handling and Diagnostics
#### Comprehensive Error Handling
- Enhanced error logging with detailed context
- Graceful degradation when database operations fail
- User-friendly error messages with recovery suggestions
#### Diagnostic Tools
- Created `databaseDiagnostics.ts` utility for troubleshooting
- Database stress testing capabilities
- Performance monitoring and reporting
- System information collection for debugging
### 4. Configuration Changes
#### Capacitor Configuration
- Temporarily disabled encryption to isolate connection issues
- Disabled biometric authentication to reduce complexity
- Maintained proper database location settings
#### Camera Integration Fixes
- Fixed `CameraDirection` enum usage for Capacitor Camera v6
- Updated from string literals to proper enum values
- Resolved TypeScript compilation errors
## Usage
### Running Diagnostics
```typescript
import { runDatabaseDiagnostics, stressTestDatabase } from '@/utils/databaseDiagnostics';
// Run comprehensive diagnostics
const diagnosticInfo = await runDatabaseDiagnostics();
console.log('Database status:', diagnosticInfo.connectionStatus);
// Run stress test
await stressTestDatabase(20);
```
### Health Checks
```typescript
import { PlatformServiceFactory } from '@/services/PlatformServiceFactory';
const platformService = PlatformServiceFactory.getInstance();
const health = await platformService.healthCheck();
if (!health.healthy) {
console.error('Database health check failed:', health.error);
// Attempt reinitialization
await platformService.reinitializeDatabase();
}
```
### Performance Monitoring
```typescript
import { logDatabasePerformance } from '@/utils/databaseDiagnostics';
// Wrap database operations with performance monitoring
const start = Date.now();
await platformService.dbQuery("SELECT * FROM users");
const duration = Date.now() - start;
logDatabasePerformance("User query", duration);
```
## Troubleshooting Guide
### Common Issues and Solutions
#### 1. "Connection timesafari.sqlite already exists"
**Cause**: Multiple database connections not properly closed
**Solution**:
- Use the enhanced cleanup methods
- Check for existing connections before creating new ones
- Implement proper app lifecycle management
#### 2. CapacitorSQLitePlugin null errors
**Cause**: Database initialization failures or connection conflicts
**Solution**:
- Use retry logic with exponential backoff
- Check connection consistency
- Verify database configuration settings
#### 3. Performance Issues
**Cause**: Main thread blocking or inefficient database operations
**Solution**:
- Use WAL journal mode for better concurrency
- Implement proper connection pooling
- Monitor and optimize query performance
#### 4. Memory Leaks
**Cause**: Database connections not properly closed
**Solution**:
- Implement proper cleanup on app lifecycle events
- Use health checks to monitor connection status
- Force reinitialization when issues are detected
### Debugging Steps
1. **Check Logs**: Look for database-related error messages
2. **Run Diagnostics**: Use `runDatabaseDiagnostics()` to get system status
3. **Monitor Performance**: Track query execution times
4. **Test Connections**: Use stress testing to identify issues
5. **Verify Configuration**: Check Capacitor and SQLite settings
### Recovery Procedures
#### Automatic Recovery
- Health checks run periodically
- Automatic reinitialization on connection failures
- Graceful degradation for non-critical operations
#### Manual Recovery
- Force app restart to clear all connections
- Clear app data if persistent issues occur
- Check device storage and permissions
## Security Considerations
### Data Protection
- Encryption can be re-enabled once connection issues are resolved
- Biometric authentication can be restored after stability is confirmed
- Proper error handling prevents data corruption
### Privacy
- Diagnostic information is logged locally only
- No sensitive data is exposed in error messages
- User data remains protected during recovery procedures
## Performance Impact
### Improvements
- Reduced connection initialization time
- Better memory usage through proper cleanup
- Improved app responsiveness with background processing
- Enhanced error recovery reduces user impact
### Monitoring
- Performance metrics are tracked automatically
- Slow operations are logged with warnings
- System resource usage is monitored
## Future Enhancements
### Planned Improvements
1. **Connection Pooling**: Implement proper connection pooling for better performance
2. **Encryption Re-enablement**: Restore encryption once stability is confirmed
3. **Advanced Monitoring**: Add real-time performance dashboards
4. **Automated Recovery**: Implement self-healing mechanisms
### Research Areas
1. **Alternative Storage**: Investigate other storage solutions for specific use cases
2. **Migration Tools**: Develop tools for seamless data migration
3. **Cross-Platform Optimization**: Optimize for different device capabilities
## Conclusion
These fixes address the core database connection issues while maintaining application stability and user experience. The enhanced error handling, monitoring, and recovery mechanisms provide a robust foundation for reliable database operations across all platforms.
## Author
Matthew Raymer - Database Architecture and Mobile Platform Development

View File

@@ -403,7 +403,7 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 37;
CURRENT_PROJECT_VERSION = 30;
DEVELOPMENT_TEAM = GM3FS5JQPH;
ENABLE_APP_SANDBOX = NO;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
@@ -413,7 +413,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0.4;
MARKETING_VERSION = 0.5.4;
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
PRODUCT_BUNDLE_IDENTIFIER = app.timesafari;
PRODUCT_NAME = "$(TARGET_NAME)";
@@ -430,7 +430,7 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 37;
CURRENT_PROJECT_VERSION = 30;
DEVELOPMENT_TEAM = GM3FS5JQPH;
ENABLE_APP_SANDBOX = NO;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
@@ -440,7 +440,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0.4;
MARKETING_VERSION = 0.5.4;
PRODUCT_BUNDLE_IDENTIFIER = app.timesafari;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";

View File

@@ -37,6 +37,8 @@
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
@@ -47,16 +49,5 @@
</array>
<key>UIViewControllerBasedStatusBarAppearance</key>
<true/>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLName</key>
<string>app.timesafari</string>
<key>CFBundleURLSchemes</key>
<array>
<string>timesafari</string>
</array>
</dict>
</array>
</dict>
</plist>

968
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "timesafari",
"version": "1.0.5-beta",
"version": "0.5.4",
"description": "Time Safari Application",
"author": {
"name": "Time Safari Team"

View File

@@ -1,75 +0,0 @@
{
"warning": {
"fillRule": "evenodd",
"d": "M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z",
"clipRule": "evenodd"
},
"spinner": {
"d": "M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
},
"chart": {
"strokeLinecap": "round",
"strokeLinejoin": "round",
"strokeWidth": "2",
"d": "M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"
},
"plus": {
"strokeLinecap": "round",
"strokeLinejoin": "round",
"strokeWidth": "2",
"d": "M12 4v16m8-8H4"
},
"settings": {
"strokeLinecap": "round",
"strokeLinejoin": "round",
"strokeWidth": "2",
"d": "M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"
},
"settingsDot": {
"strokeLinecap": "round",
"strokeLinejoin": "round",
"strokeWidth": "2",
"d": "M15 12a3 3 0 11-6 0 3 3 0 016 0z"
},
"lock": {
"strokeLinecap": "round",
"strokeLinejoin": "round",
"strokeWidth": "2",
"d": "M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"
},
"download": {
"strokeLinecap": "round",
"strokeLinejoin": "round",
"strokeWidth": "2",
"d": "M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
},
"check": {
"strokeLinecap": "round",
"strokeLinejoin": "round",
"strokeWidth": "2",
"d": "M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
},
"edit": {
"strokeLinecap": "round",
"strokeLinejoin": "round",
"strokeWidth": "2",
"d": "M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
},
"trash": {
"strokeLinecap": "round",
"strokeLinejoin": "round",
"strokeWidth": "2",
"d": "M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
},
"plusCircle": {
"strokeLinecap": "round",
"strokeLinejoin": "round",
"strokeWidth": "2",
"d": "M12 6v6m0 0v6m0-6h6m-6 0H6"
},
"info": {
"fillRule": "evenodd",
"d": "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z",
"clipRule": "evenodd"
}
}

View File

@@ -60,11 +60,9 @@ backup and database export, with platform-specific download instructions. * *
<script lang="ts">
import { Component, Prop, Vue } from "vue-facing-decorator";
import * as R from "ramda";
import { AppString, NotificationIface } from "../constants/app";
import { Contact, ContactMaybeWithJsonStrings, ContactMethod } from "../db/tables/contacts";
import { Contact } from "../db/tables/contacts";
import * as databaseUtil from "../db/databaseUtil";
import { logger } from "../utils/logger";
@@ -74,7 +72,6 @@ import {
PlatformCapabilities,
} from "../services/PlatformService";
import { contactsToExportJson } from "../libs/util";
import { parseJsonField } from "../db/databaseUtil";
/**
* @vue-component
@@ -136,13 +133,13 @@ export default class DataExportSection extends Vue {
*/
public async exportDatabase() {
try {
let allDbContacts: ContactMaybeWithJsonStrings[] = [];
let allContacts: Contact[] = [];
const platformService = PlatformServiceFactory.getInstance();
const result = await platformService.dbQuery(`SELECT * FROM contacts`);
if (result) {
allDbContacts = databaseUtil.mapQueryResultToValues(
allContacts = databaseUtil.mapQueryResultToValues(
result,
) as unknown as ContactMaybeWithJsonStrings[];
) as unknown as Contact[];
}
// if (USE_DEXIE_DB) {
// await db.open();
@@ -150,19 +147,6 @@ export default class DataExportSection extends Vue {
// }
// Convert contacts to export format
const allContacts: Contact[] = allDbContacts.map((contact) => {
// first remove the contactMethods field, mostly to cast to a clear type (that will end up with JSON objects)
const exContact: Contact = R.omit(
["contactMethods"],
contact,
);
// now add contactMethods as a true array of ContactMethod objects
exContact.contactMethods = contact.contactMethods
? parseJsonField(contact.contactMethods, [] as Array<ContactMethod>)
: undefined;
return exContact;
});
const exportData = contactsToExportJson(allContacts);
const jsonStr = JSON.stringify(exportData, null, 2);
const blob = new Blob([jsonStr], { type: "application/json" });

View File

@@ -77,7 +77,7 @@
If you'd like an introduction,
<a
class="text-blue-500"
@click="copyToClipboard('A link to this page', deepLinkUrl)"
@click="copyToClipboard('A link to this page', windowLocation)"
>click here to copy this page, paste it into a message, and ask if
they'll tell you more about the {{ roleName }}.</a
>
@@ -104,7 +104,7 @@ import * as R from "ramda";
import { useClipboard } from "@vueuse/core";
import { Contact } from "../db/tables/contacts";
import * as serverUtil from "../libs/endorserServer";
import { APP_SERVER, NotificationIface } from "../constants/app";
import { NotificationIface } from "../constants/app";
@Component
export default class HiddenDidDialog extends Vue {
@@ -117,8 +117,7 @@ export default class HiddenDidDialog extends Vue {
activeDid = "";
allMyDids: Array<string> = [];
canShare = false;
deepLinkPathSuffix = "";
deepLinkUrl = window.location.href; // this is changed to a deep link in the setup
windowLocation = window.location.href;
R = R;
serverUtil = serverUtil;
@@ -130,21 +129,17 @@ export default class HiddenDidDialog extends Vue {
}
open(
deepLinkPathSuffix: string,
roleName: string,
visibleToDids: string[],
allContacts: Array<Contact>,
activeDid: string,
allMyDids: Array<string>,
) {
this.deepLinkPathSuffix = deepLinkPathSuffix;
this.roleName = roleName;
this.visibleToDids = visibleToDids;
this.allContacts = allContacts;
this.activeDid = activeDid;
this.allMyDids = allMyDids;
this.deepLinkUrl = APP_SERVER + "/deep-link/" + this.deepLinkPathSuffix;
this.isOpen = true;
}
@@ -178,11 +173,11 @@ export default class HiddenDidDialog extends Vue {
}
onClickShareClaim() {
this.copyToClipboard("A link to this page", this.deepLinkUrl);
this.copyToClipboard("A link to this page", this.windowLocation);
window.navigator.share({
title: "Help Connect Me",
text: "I'm trying to find the people who recorded this. Can you help me?",
url: this.deepLinkUrl,
url: this.windowLocation,
});
}
}

View File

@@ -1,90 +0,0 @@
<template>
<svg
v-if="iconData"
:class="svgClass"
:fill="fill"
:stroke="stroke"
:viewBox="viewBox"
xmlns="http://www.w3.org/2000/svg"
>
<path v-for="(path, index) in iconData.paths" :key="index" v-bind="path" />
</svg>
</template>
<script lang="ts">
import { Component, Prop, Vue } from "vue-facing-decorator";
import icons from "../assets/icons.json";
import { logger } from "../utils/logger";
/**
* Icon path interface
*/
interface IconPath {
d: string;
fillRule?: string;
clipRule?: string;
strokeLinecap?: string;
strokeLinejoin?: string;
strokeWidth?: string | number;
fill?: string;
stroke?: string;
}
/**
* Icon data interface
*/
interface IconData {
paths: IconPath[];
}
/**
* Icons JSON structure
*/
interface IconsJson {
[key: string]: IconPath | IconData;
}
/**
* Icon Renderer Component
*
* This component loads SVG icon definitions from a JSON file and renders them
* as SVG elements. It provides a clean way to use icons without cluttering
* templates with long SVG path definitions.
*
* @author Matthew Raymer
* @version 1.0.0
* @since 2024
*/
@Component({
name: "IconRenderer",
})
export default class IconRenderer extends Vue {
@Prop({ required: true }) readonly iconName!: string;
@Prop({ default: "h-5 w-5" }) readonly svgClass!: string;
@Prop({ default: "none" }) readonly fill!: string;
@Prop({ default: "currentColor" }) readonly stroke!: string;
@Prop({ default: "0 0 24 24" }) readonly viewBox!: string;
/**
* Get the icon data for the specified icon name
*
* @returns {IconData | null} The icon data object or null if not found
*/
get iconData(): IconData | null {
const icon = (icons as IconsJson)[this.iconName];
if (!icon) {
logger.warn(`Icon "${this.iconName}" not found in icons.json`);
return null;
}
// Convert single path to array format for consistency
if ("d" in icon) {
return {
paths: [icon as IconPath],
};
}
return icon as IconData;
}
}
</script>

View File

@@ -83,7 +83,10 @@
import { Vue, Component, Prop } from "vue-facing-decorator";
import { NotificationIface, USE_DEXIE_DB } from "../constants/app";
import { createAndSubmitOffer } from "../libs/endorserServer";
import {
createAndSubmitOffer,
serverMessageForUser,
} from "../libs/endorserServer";
import * as libsUtil from "../libs/util";
import * as databaseUtil from "../db/databaseUtil";
import { retrieveSettingsForActiveAccount } from "../db/index";

View File

@@ -44,7 +44,8 @@ export default class TopMessage extends Vue {
settings.apiServer === AppString.PROD_ENDORSER_API_SERVER
) {
const didPrefix = settings.activeDid?.slice(11, 15);
this.message = "You are using prod, user " + didPrefix;
this.message =
"You are using prod, user " + didPrefix;
}
} catch (err: unknown) {
this.$notify(

View File

@@ -1,7 +1,4 @@
import {
registerMigration,
runMigrations as runMigrationsService,
} from "../services/migrationService";
import migrationService from "../services/migrationService";
import { DEFAULT_ENDORSER_API_SERVER } from "@/constants/app";
import { arrayBufferToBase64 } from "@/libs/crypto";
@@ -34,6 +31,7 @@ const secretBase64 = arrayBufferToBase64(randomBytes);
const MIGRATIONS = [
{
name: "001_initial",
// see ../db/tables files for explanations of the fields
sql: `
CREATE TABLE IF NOT EXISTS accounts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -118,12 +116,6 @@ const MIGRATIONS = [
);
`,
},
{
name: "002_add_iViewContent_to_contacts",
sql: `
ALTER TABLE contacts ADD COLUMN iViewContent BOOLEAN DEFAULT TRUE;
`,
},
];
/**
@@ -131,12 +123,16 @@ const MIGRATIONS = [
* @param extractMigrationNames - A function that extracts the names (string array) from "select name from migrations"
*/
export async function runMigrations<T>(
sqlExec: (sql: string, params?: unknown[]) => Promise<unknown>,
sqlQuery: (sql: string, params?: unknown[]) => Promise<T>,
sqlExec: (sql: string) => Promise<unknown>,
sqlQuery: (sql: string) => Promise<T>,
extractMigrationNames: (result: T) => Set<string>,
): Promise<void> {
for (const migration of MIGRATIONS) {
registerMigration(migration);
migrationService.registerMigration(migration);
}
await runMigrationsService(sqlExec, sqlQuery, extractMigrationNames);
await migrationService.runMigrations(
sqlExec,
sqlQuery,
extractMigrationNames,
);
}

View File

@@ -219,36 +219,18 @@ export async function logConsoleAndDb(
isError = false,
): Promise<void> {
if (isError) {
logger.error(`${new Date().toISOString()}`, message);
logger.error(`${new Date().toISOString()} ${message}`);
} else {
logger.log(`${new Date().toISOString()}`, message);
logger.log(`${new Date().toISOString()} ${message}`);
}
await logToDb(message);
}
/**
* Generates SQL INSERT statement and parameters from a model object
*
* This helper function creates a parameterized SQL INSERT statement
* from a JavaScript object. It filters out undefined values and
* creates the appropriate SQL syntax with placeholders.
*
* The function is used internally by the migration functions to
* safely insert data into the SQLite database.
*
* @function generateInsertStatement
* @param {Record<string, unknown>} model - The model object containing fields to insert
* @param {string} tableName - The name of the table to insert into
* @returns {Object} Object containing the SQL statement and parameters array
* @returns {string} returns.sql - The SQL INSERT statement
* @returns {unknown[]} returns.params - Array of parameter values
* @example
* ```typescript
* const contact = { did: 'did:example:123', name: 'John Doe' };
* const { sql, params } = generateInsertStatement(contact, 'contacts');
* // sql: "INSERT INTO contacts (did, name) VALUES (?, ?)"
* // params: ['did:example:123', 'John Doe']
* ```
* Generates an SQL INSERT statement and parameters from a model object.
* @param model The model object containing fields to update
* @param tableName The name of the table to update
* @returns Object containing the SQL statement and parameters array
*/
export function generateInsertStatement(
model: Record<string, unknown>,
@@ -266,30 +248,12 @@ export function generateInsertStatement(
}
/**
* Generates SQL UPDATE statement and parameters from a model object
*
* This helper function creates a parameterized SQL UPDATE statement
* from a JavaScript object. It filters out undefined values and
* creates the appropriate SQL syntax with placeholders.
*
* The function is used internally by the migration functions to
* safely update data in the SQLite database.
*
* @function generateUpdateStatement
* @param {Record<string, unknown>} model - The model object containing fields to update
* @param {string} tableName - The name of the table to update
* @param {string} whereClause - The WHERE clause for the update (e.g. "id = ?")
* @param {unknown[]} [whereParams=[]] - Parameters for the WHERE clause
* @returns {Object} Object containing the SQL statement and parameters array
* @returns {string} returns.sql - The SQL UPDATE statement
* @returns {unknown[]} returns.params - Array of parameter values
* @example
* ```typescript
* const contact = { name: 'Jane Doe' };
* const { sql, params } = generateUpdateStatement(contact, 'contacts', 'did = ?', ['did:example:123']);
* // sql: "UPDATE contacts SET name = ? WHERE did = ?"
* // params: ['Jane Doe', 'did:example:123']
* ```
* Generates an SQL UPDATE statement and parameters from a model object.
* @param model The model object containing fields to update
* @param tableName The name of the table to update
* @param whereClause The WHERE clause for the update (e.g. "id = ?")
* @param whereParams Parameters for the WHERE clause
* @returns Object containing the SQL statement and parameters array
*/
export function generateUpdateStatement(
model: Record<string, unknown>,

View File

@@ -1 +1 @@
# Check the contact & settings export to see whether you want your new table to be included in it
Check the contact & settings export to see whether you want your new table to be included in it.

View File

@@ -1,16 +1,15 @@
export type ContactMethod = {
export interface ContactMethod {
label: string;
type: string; // eg. "EMAIL", "SMS", "WHATSAPP", maybe someday "GOOGLE-CONTACT-API"
value: string;
};
}
export type Contact = {
export interface Contact {
//
// When adding a property, consider whether it should be added when exporting & sharing contacts, eg. DataExportSection
// When adding a property, consider whether it should be added when exporting & sharing contacts.
did: string;
contactMethods?: Array<ContactMethod>;
iViewContent?: boolean;
name?: string;
nextPubKeyHashB64?: string; // base64-encoded SHA256 hash of next public key
notes?: string;
@@ -18,28 +17,7 @@ export type Contact = {
publicKeyBase64?: string;
seesMe?: boolean; // cached value of the server setting
registered?: boolean; // cached value of the server setting
};
/**
* This is for those cases (eg. with a DB) where every field is a primitive (and not an object).
*
* This is so that we can reuse most of the type and don't have to maintain another copy.
* Another approach uses typescript conditionals: https://chatgpt.com/share/6855cdc3-ab5c-8007-8525-726612016eb2
*/
export type ContactWithJsonStrings = Omit<Contact, "contactMethods"> & {
contactMethods?: string;
};
/**
* This is for those cases (eg. with a DB) where field values may be all primitives or may be JSON values.
* See src/db/databaseUtil.ts parseJsonField for more details.
*
* This is so that we can reuse most of the type and don't have to maintain another copy.
* Another approach uses typescript conditionals: https://chatgpt.com/share/6855cdc3-ab5c-8007-8525-726612016eb2
*/
export type ContactMaybeWithJsonStrings = Omit<Contact, "contactMethods"> & {
contactMethods?: string | Array<ContactMethod>;
};
}
export const ContactSchema = {
contacts: "&did, name", // no need to key by other things

View File

@@ -64,11 +64,6 @@ export type Settings = {
webPushServer?: string; // Web Push server URL
};
// type of settings where the searchBoxes are JSON strings instead of objects
export type SettingsWithJsonStrings = Settings & {
searchBoxes: string;
};
export function checkIsAnyFeedFilterOn(settings: Settings): boolean {
return !!(settings?.filterFeedByNearby || settings?.filterFeedByVisible);
}

View File

@@ -27,49 +27,21 @@
*/
import { z } from "zod";
// Parameter validation schemas for each route type
export const deepLinkSchemas = {
claim: z.object({
id: z.string(),
}),
"claim-add-raw": z.object({
id: z.string(),
claim: z.string().optional(),
claimJwtId: z.string().optional(),
}),
"claim-cert": z.object({
id: z.string(),
}),
"confirm-gift": z.object({
id: z.string(),
}),
"contact-edit": z.object({
did: z.string(),
}),
"contact-import": z.object({
jwt: z.string(),
}),
contacts: z.object({
contactJwt: z.string().optional(),
inviteJwt: z.string().optional(),
}),
did: z.object({
did: z.string(),
}),
"invite-one-accept": z.object({
// optional because A) it could be a query param, and B) the page displays an input if things go wrong
jwt: z.string().optional(),
}),
"onboard-meeting-members": z.object({
id: z.string(),
}),
project: z.object({
id: z.string(),
}),
"user-profile": z.object({
id: z.string(),
}),
};
// Add a union type of all valid route paths
export const VALID_DEEP_LINK_ROUTES = [
"user-profile",
"project-details",
"onboard-meeting-setup",
"invite-one-accept",
"contact-import",
"confirm-gift",
"claim",
"claim-cert",
"claim-add-raw",
"contact-edit",
"contacts",
"did",
] as const;
// Create a type from the array
export type DeepLinkRoute = (typeof VALID_DEEP_LINK_ROUTES)[number];
@@ -81,10 +53,50 @@ export const baseUrlSchema = z.object({
queryParams: z.record(z.string()).optional(),
});
// Add a union type of all valid route paths
export const VALID_DEEP_LINK_ROUTES = Object.keys(
deepLinkSchemas,
) as readonly (keyof typeof deepLinkSchemas)[];
// Use the type to ensure route validation
export const routeSchema = z.enum(VALID_DEEP_LINK_ROUTES);
// Parameter validation schemas for each route type
export const deepLinkSchemas = {
"user-profile": z.object({
id: z.string(),
}),
"project-details": z.object({
id: z.string(),
}),
"onboard-meeting-setup": z.object({
id: z.string(),
}),
"invite-one-accept": z.object({
id: z.string(),
}),
"contact-import": z.object({
jwt: z.string(),
}),
"confirm-gift": z.object({
id: z.string(),
}),
claim: z.object({
id: z.string(),
}),
"claim-cert": z.object({
id: z.string(),
}),
"claim-add-raw": z.object({
id: z.string(),
claim: z.string().optional(),
claimJwtId: z.string().optional(),
}),
"contact-edit": z.object({
did: z.string(),
}),
contacts: z.object({
contacts: z.string(), // JSON string of contacts array
}),
did: z.object({
did: z.string(),
}),
};
export type DeepLinkParams = {
[K in keyof typeof deepLinkSchemas]: z.infer<(typeof deepLinkSchemas)[K]>;
@@ -94,8 +106,3 @@ export interface DeepLinkError extends Error {
code: string;
details?: unknown;
}
// Use the type to ensure route validation
export const routeSchema = z.enum(
VALID_DEEP_LINK_ROUTES as [string, ...string[]],
);

View File

@@ -1074,8 +1074,7 @@ export async function generateEndorserJwtUrlForAccount(
const vcJwt = await createEndorserJwtForDid(account.did, contactInfo);
const viewPrefix =
APP_SERVER + "/deep-link" + CONTACT_IMPORT_CONFIRM_URL_PATH_TIME_SAFARI;
const viewPrefix = APP_SERVER + CONTACT_IMPORT_CONFIRM_URL_PATH_TIME_SAFARI;
return viewPrefix + vcJwt;
}

View File

@@ -10,8 +10,8 @@ import {
faArrowLeft,
faArrowRight,
faArrowRotateBackward,
faArrowUp,
faArrowUpRightFromSquare,
faArrowUp,
faBan,
faBitcoinSign,
faBurst,
@@ -92,8 +92,8 @@ library.add(
faArrowLeft,
faArrowRight,
faArrowRotateBackward,
faArrowUp,
faArrowUpRightFromSquare,
faArrowUp,
faBan,
faBitcoinSign,
faBurst,

View File

@@ -45,7 +45,6 @@ import { PlatformServiceFactory } from "@/services/PlatformServiceFactory";
import { sha256 } from "ethereum-cryptography/sha256";
import { IIdentifier } from "@veramo/core";
import { insertDidSpecificSettings, parseJsonField } from "../db/databaseUtil";
import { DEFAULT_ROOT_DERIVATION_PATH } from "./crypto";
export interface GiverReceiverInputInfo {
did?: string;
@@ -883,71 +882,6 @@ export const contactToCsvLine = (contact: Contact): string => {
return fields.join(",");
};
/**
* Parses a CSV line into a Contact object. See contactToCsvLine for the format.
* @param lineRaw - The CSV line to parse
* @returns A Contact object
*/
export const csvLineToContact = (lineRaw: string): Contact => {
// Note that Endorser Mobile puts name first, then did, etc.
let line = lineRaw.trim();
let did, publicKeyInput, seesMe, registered;
let name;
let commaPos1 = -1;
if (line.startsWith('"')) {
let doubleDoubleQuotePos = line.lastIndexOf('""') + 2;
if (doubleDoubleQuotePos === -1) {
doubleDoubleQuotePos = 1;
}
const quote2Pos = line.indexOf('"', doubleDoubleQuotePos);
if (quote2Pos > -1) {
commaPos1 = line.indexOf(",", quote2Pos);
name = line.substring(1, quote2Pos).trim();
name = name.replace(/""/g, '"');
} else {
// something is weird with one " to start, so ignore it and start after "
line = line.substring(1);
commaPos1 = line.indexOf(",");
name = line.substring(0, commaPos1).trim();
}
} else {
commaPos1 = line.indexOf(",");
name = line.substring(0, commaPos1).trim();
}
if (commaPos1 > -1) {
did = line.substring(commaPos1 + 1).trim();
const commaPos2 = line.indexOf(",", commaPos1 + 1);
if (commaPos2 > -1) {
did = line.substring(commaPos1 + 1, commaPos2).trim();
publicKeyInput = line.substring(commaPos2 + 1).trim();
const commaPos3 = line.indexOf(",", commaPos2 + 1);
if (commaPos3 > -1) {
publicKeyInput = line.substring(commaPos2 + 1, commaPos3).trim();
seesMe = line.substring(commaPos3 + 1).trim() == "true";
const commaPos4 = line.indexOf(",", commaPos3 + 1);
if (commaPos4 > -1) {
seesMe = line.substring(commaPos3 + 1, commaPos4).trim() == "true";
registered = line.substring(commaPos4 + 1).trim() == "true";
}
}
}
}
// help with potential mistakes while this sharing requires copy-and-paste
let publicKeyBase64 = publicKeyInput;
if (publicKeyBase64 && /^[0-9A-Fa-f]{66}$/i.test(publicKeyBase64)) {
// it must be all hex (compressed public key), so convert
publicKeyBase64 = Buffer.from(publicKeyBase64, "hex").toString("base64");
}
const newContact: Contact = {
did: did || "",
name,
publicKeyBase64,
seesMe,
registered,
};
return newContact;
};
/**
* Interface for the JSON export format of database tables
*/
@@ -966,56 +900,36 @@ export interface DatabaseExport {
}
/**
* Converts an array of contacts to the export JSON format.
* Converts an array of contacts to the standardized database export JSON format.
* This format is used for data migration and backup purposes.
*
* @param contacts - Array of Contact objects to convert
* @returns DatabaseExport object in the standardized format
*/
export const contactsToExportJson = (contacts: Contact[]): DatabaseExport => {
// Convert each contact to a plain object and ensure all fields are included
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: contacts,
rows,
},
],
},
};
};
/**
* Imports an account from a mnemonic phrase
* @param mnemonic - The seed phrase to import from
* @param derivationPath - The derivation path to use (defaults to DEFAULT_ROOT_DERIVATION_PATH)
* @param shouldErase - Whether to erase existing accounts before importing
* @returns Promise that resolves when import is complete
* @throws Error if mnemonic is invalid or import fails
*/
export async function importFromMnemonic(
mnemonic: string,
derivationPath: string = DEFAULT_ROOT_DERIVATION_PATH,
shouldErase: boolean = false,
): Promise<void> {
const mne: string = mnemonic.trim().toLowerCase();
// Derive address and keys from mnemonic
const [address, privateHex, publicHex] = deriveAddress(mne, derivationPath);
// Create new identifier
const newId = newIdentifier(address, publicHex, privateHex, derivationPath);
// Handle erasures
if (shouldErase) {
const platformService = PlatformServiceFactory.getInstance();
await platformService.dbExec("DELETE FROM accounts");
if (USE_DEXIE_DB) {
const accountsDB = await accountsDBPromise;
await accountsDB.accounts.clear();
}
}
// Save the new identity
await saveNewIdentity(newId, mne, derivationPath);
}

View File

@@ -34,7 +34,8 @@ import router from "./router";
import { handleApiError } from "./services/api";
import { AxiosError } from "axios";
import { DeepLinkHandler } from "./services/deepLinks";
import { logger, safeStringify } from "./utils/logger";
import { logConsoleAndDb } from "./db/databaseUtil";
import { logger } from "./utils/logger";
logger.log("[Capacitor] Starting initialization");
logger.log("[Capacitor] Platform:", process.env.VITE_PLATFORM);
@@ -71,13 +72,13 @@ const handleDeepLink = async (data: { url: string }) => {
await router.isReady();
await deepLinkHandler.handleDeepLink(data.url);
} catch (error) {
logger.error("[DeepLink] Error handling deep link: ", error);
let message: string =
error instanceof Error ? error.message : safeStringify(error);
if (data.url) {
message += `\nURL: ${data.url}`;
}
handleApiError({ message } as AxiosError, "deep-link");
logConsoleAndDb("[DeepLink] Error handling deep link: " + error, true);
handleApiError(
{
message: error instanceof Error ? error.message : String(error),
} as AxiosError,
"deep-link",
);
}
};

View File

@@ -10,11 +10,15 @@ import { FontAwesomeIcon } from "./libs/fontawesome";
import Camera from "simple-vue-camera";
import { logger } from "./utils/logger";
// const platform = process.env.VITE_PLATFORM;
// const pwa_enabled = process.env.VITE_PWA_ENABLED === "true";
const platform = process.env.VITE_PLATFORM;
const pwa_enabled = process.env.VITE_PWA_ENABLED === "true";
logger.log("Platform", JSON.stringify({ platform }));
logger.log("PWA enabled", JSON.stringify({ pwa_enabled }));
// Global Error Handler
function setupGlobalErrorHandler(app: VueApp) {
logger.log("[App Init] Setting up global error handler");
app.config.errorHandler = (
err: unknown,
instance: ComponentPublicInstance | null,
@@ -34,13 +38,30 @@ function setupGlobalErrorHandler(app: VueApp) {
// Function to initialize the app
export function initializeApp() {
logger.log("[App Init] Starting app initialization");
logger.log("[App Init] Platform:", process.env.VITE_PLATFORM);
const app = createApp(App);
logger.log("[App Init] Vue app created");
app.component("FontAwesome", FontAwesomeIcon).component("camera", Camera);
logger.log("[App Init] Components registered");
const pinia = createPinia();
app.use(pinia);
logger.log("[App Init] Pinia store initialized");
app.use(VueAxios, axios);
logger.log("[App Init] Axios initialized");
app.use(router);
logger.log("[App Init] Router initialized");
app.use(Notifications);
logger.log("[App Init] Notifications initialized");
setupGlobalErrorHandler(app);
logger.log("[App Init] App initialization complete");
return app;
}

View File

@@ -5,6 +5,9 @@ import { logger } from "./utils/logger";
const platform = process.env.VITE_PLATFORM;
const pwa_enabled = process.env.VITE_PWA_ENABLED === "true";
logger.info("[Web] PWA enabled", { pwa_enabled });
logger.info("[Web] Platform", { platform });
// Only import service worker for web builds
if (platform !== "electron" && pwa_enabled) {
import("./registerServiceWorker"); // Web PWA support
@@ -28,7 +31,7 @@ function sqlInit() {
if (platform === "web" || platform === "development") {
sqlInit();
} else {
logger.warn("[Web] SQL not initialized for platform", { platform });
logger.info("[Web] SQL not initialized for platform", { platform });
}
app.mount("#app");

View File

@@ -73,11 +73,6 @@ const routes: Array<RouteRecordRaw> = [
name: "contacts",
component: () => import("../views/ContactsView.vue"),
},
{
path: "/database-migration",
name: "database-migration",
component: () => import("../views/DatabaseMigration.vue"),
},
{
path: "/did/:did?",
name: "did",
@@ -88,11 +83,6 @@ const routes: Array<RouteRecordRaw> = [
name: "discover",
component: () => import("../views/DiscoverView.vue"),
},
{
path: "/deep-link/:path*",
name: "deep-link",
component: () => import("../views/DeepLinkRedirectView.vue"),
},
{
path: "/gifted-details",
name: "gifted-details",
@@ -144,9 +134,8 @@ const routes: Array<RouteRecordRaw> = [
component: () => import("../views/InviteOneView.vue"),
},
{
// optional because A) it could be a query param, and B) the page displays an input if things go wrong
path: "/invite-one-accept/:jwt?",
name: "invite-one-accept",
name: "InviteOneAcceptView",
component: () => import("../views/InviteOneAcceptView.vue"),
},
{

View File

@@ -6,7 +6,7 @@
*/
import { AxiosError } from "axios";
import { logger, safeStringify } from "../utils/logger";
import { logger } from "../utils/logger";
/**
* Handles API errors with platform-specific logging and error processing.
@@ -37,8 +37,7 @@ import { logger, safeStringify } from "../utils/logger";
*/
export const handleApiError = (error: AxiosError, endpoint: string) => {
if (process.env.VITE_PLATFORM === "capacitor") {
const endpointStr = safeStringify(endpoint); // we've seen this as an object in deep links
logger.error(`[Capacitor API Error] ${endpointStr}:`, {
logger.error(`[Capacitor API Error] ${endpoint}:`, {
message: error.message,
status: error.response?.status,
data: error.response?.data,

View File

@@ -27,16 +27,18 @@
* timesafari://<route>[/<param>][?queryParam1=value1&queryParam2=value2]
*
* Supported Routes:
* - claim: View claim
* - claim-add-raw: Add raw claim
* - claim-cert: View claim certificate
* - confirm-gift
* - contact-import: Import contacts
* - did: View DID
* - invite-one-accept: Accept invitation
* - onboard-meeting-members
* - project: View project details
* - user-profile: View user profile
* - project-details: View project details
* - onboard-meeting-setup: Setup onboarding meeting
* - invite-one-accept: Accept invitation
* - contact-import: Import contacts
* - confirm-gift: Confirm gift
* - claim: View claim
* - claim-cert: View claim certificate
* - claim-add-raw: Add raw claim
* - contact-edit: Edit contact
* - contacts: View contacts
* - did: View DID
*
* @example
* const handler = new DeepLinkHandler(router);
@@ -44,8 +46,6 @@
*/
import { Router } from "vue-router";
import { z } from "zod";
import {
deepLinkSchemas,
baseUrlSchema,
@@ -55,38 +55,6 @@ import {
import { logConsoleAndDb } from "../db/databaseUtil";
import type { DeepLinkError } from "../interfaces/deepLinks";
// Helper function to extract the first key from a Zod object schema
function getFirstKeyFromZodObject(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
schema: z.ZodObject<any>,
): string | undefined {
const shape = schema.shape;
const keys = Object.keys(shape);
return keys.length > 0 ? keys[0] : undefined;
}
/**
* Maps deep link routes to their corresponding Vue router names and optional parameter keys.
*
* It's an object where keys are the deep link routes and values are objects with 'name' and 'paramKey'.
*
* The paramKey is used to extract the parameter from the route path,
* because "router.replace" expects the right parameter name for the route.
*/
export const ROUTE_MAP: Record<string, { name: string; paramKey?: string }> =
Object.entries(deepLinkSchemas).reduce(
(acc, [routeName, schema]) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const paramKey = getFirstKeyFromZodObject(schema as z.ZodObject<any>);
acc[routeName] = {
name: routeName,
paramKey,
};
return acc;
},
{} as Record<string, { name: string; paramKey?: string }>,
);
/**
* Handles processing and routing of deep links in the application.
* Provides validation, error handling, and routing for deep link URLs.
@@ -103,13 +71,35 @@ export class DeepLinkHandler {
}
/**
* Maps deep link routes to their corresponding Vue router names and optional parameter keys.
*
* The paramKey is used to extract the parameter from the route path,
* because "router.replace" expects the right parameter name for the route.
* The default is "id".
*/
private readonly ROUTE_MAP: Record<
string,
{ name: string; paramKey?: string }
> = {
"claim": { name: "claim" },
"claim-add-raw": { name: "claim-add-raw" },
"claim-cert": { name: "claim-cert" },
"confirm-gift": { name: "confirm-gift" },
"did": { name: "did", paramKey: "did" },
"invite-one-accept": { name: "invite-one-accept" },
"onboard-meeting-members": { name: "onboard-meeting-members" },
"onboard-meeting-setup": { name: "onboard-meeting-setup" },
"project": { name: "project" },
"user-profile": { name: "user-profile" },
};
/**
* Parses deep link URL into path, params and query components.
* Validates URL structure using Zod schemas.
*
* @param url - The deep link URL to parse (format: scheme://path[?query])
* @throws {DeepLinkError} If URL format is invalid
* @returns Parsed URL components (path: string, params: {KEY: string}, query: {KEY: string})
* @returns Parsed URL components (path, params, query)
*/
private parseDeepLink(url: string) {
const parts = url.split("://");
@@ -125,10 +115,10 @@ export class DeepLinkHandler {
});
const [path, queryString] = parts[1].split("?");
const [routePath, ...pathParams] = path.split("/");
const [routePath, param] = path.split("/");
// Validate route exists before proceeding
if (!ROUTE_MAP[routePath]) {
if (!this.ROUTE_MAP[routePath]) {
throw {
code: "INVALID_ROUTE",
message: `Invalid route path: ${routePath}`,
@@ -144,19 +134,45 @@ export class DeepLinkHandler {
}
const params: Record<string, string> = {};
if (pathParams) {
if (param) {
// Now we know routePath exists in ROUTE_MAP
const routeConfig = ROUTE_MAP[routePath];
params[routeConfig.paramKey ?? "id"] = pathParams.join("/");
const routeConfig = this.ROUTE_MAP[routePath];
params[routeConfig.paramKey ?? "id"] = param;
}
// logConsoleAndDb(
// `[DeepLink] Debug: Route Path: ${routePath} Path Params: ${JSON.stringify(params)} Query String: ${JSON.stringify(query)}`,
// false,
// );
return { path: routePath, params, query };
}
/**
* Processes incoming deep links and routes them appropriately.
* Handles validation, error handling, and routing to the correct view.
*
* @param url - The deep link URL to process
* @throws {DeepLinkError} If URL processing fails
*/
async handleDeepLink(url: string): Promise<void> {
try {
logConsoleAndDb("[DeepLink] Processing URL: " + url, false);
const { path, params, query } = this.parseDeepLink(url);
// Ensure params is always a Record<string,string> by converting undefined to empty string
const sanitizedParams = Object.fromEntries(
Object.entries(params).map(([key, value]) => [key, value ?? ""]),
);
await this.validateAndRoute(path, sanitizedParams, query);
} catch (error) {
const deepLinkError = error as DeepLinkError;
logConsoleAndDb(
`[DeepLink] Error (${deepLinkError.code}): ${deepLinkError.message}`,
true,
);
throw {
code: deepLinkError.code || "UNKNOWN_ERROR",
message: deepLinkError.message,
details: deepLinkError.details,
};
}
}
/**
* Routes the deep link to appropriate view with validated parameters.
* Validates route and parameters using Zod schemas before routing.
@@ -177,7 +193,7 @@ export class DeepLinkHandler {
try {
// Validate route exists
const validRoute = routeSchema.parse(path) as DeepLinkRoute;
routeName = ROUTE_MAP[validRoute].name;
routeName = this.ROUTE_MAP[validRoute].name;
} catch (error) {
// Log the invalid route attempt
logConsoleAndDb(`[DeepLink] Invalid route path: ${path}`, true);
@@ -185,98 +201,48 @@ export class DeepLinkHandler {
// Redirect to error page with information about the invalid link
await this.router.replace({
name: "deep-link-error",
params,
query: {
originalPath: path,
errorCode: "INVALID_ROUTE",
errorMessage: `The link you followed (${path}) is not supported`,
...query,
message: `The link you followed (${path}) is not supported`,
},
});
// This previously threw an error but we're redirecting so there's no need.
return;
throw {
code: "INVALID_ROUTE",
message: `Unsupported route: ${path}`,
};
}
// Continue with parameter validation as before...
const schema = deepLinkSchemas[path as keyof typeof deepLinkSchemas];
let validatedParams, validatedQuery;
try {
validatedParams = await schema.parseAsync(params);
validatedQuery = await schema.parseAsync(query);
} catch (error) {
// For parameter validation errors, provide specific error feedback
logConsoleAndDb(
`[DeepLink] Invalid parameters for route name ${routeName} for path: ${path}: ${JSON.stringify(error)} ... with params: ${JSON.stringify(params)} ... and query: ${JSON.stringify(query)}`,
true,
);
await this.router.replace({
name: "deep-link-error",
params,
query: {
originalPath: path,
errorCode: "INVALID_PARAMETERS",
errorMessage: `The link parameters are invalid: ${(error as Error).message}`,
...query,
},
const validatedParams = await schema.parseAsync({
...params,
...query,
});
// This previously threw an error but we're redirecting so there's no need.
return;
}
try {
await this.router.replace({
name: routeName,
params: validatedParams,
query: validatedQuery,
query,
});
} catch (error) {
logConsoleAndDb(
`[DeepLink] Error routing to route name ${routeName} for path: ${path}: ${JSON.stringify(error)} ... with validated params: ${JSON.stringify(validatedParams)} ... and validated query: ${JSON.stringify(validatedQuery)}`,
true,
);
// For parameter validation errors, provide specific error feedback
await this.router.replace({
name: "deep-link-error",
params: validatedParams,
query: {
originalPath: path,
errorCode: "ROUTING_ERROR",
errorMessage: `Error routing to ${routeName}: ${JSON.stringify(error)}`,
...validatedQuery,
errorCode: "INVALID_PARAMETERS",
message: `The link parameters are invalid: ${(error as Error).message}`,
},
});
}
}
/**
* Processes incoming deep links and routes them appropriately.
* Handles validation, error handling, and routing to the correct view.
*
* @param url - The deep link URL to process
* @throws {DeepLinkError} If URL processing fails
*/
async handleDeepLink(url: string): Promise<void> {
try {
const { path, params, query } = this.parseDeepLink(url);
// Ensure params is always a Record<string,string> by converting undefined to empty string
const sanitizedParams = Object.fromEntries(
Object.entries(params).map(([key, value]) => [key, value ?? ""]),
);
await this.validateAndRoute(path, sanitizedParams, query);
} catch (error) {
const deepLinkError = error as DeepLinkError;
logConsoleAndDb(
`[DeepLink] Error (${deepLinkError.code}): ${deepLinkError.details}`,
true,
);
throw {
code: deepLinkError.code || "UNKNOWN_ERROR",
message: deepLinkError.message,
details: deepLinkError.details,
code: "INVALID_PARAMETERS",
message: (error as Error).message,
details: error,
};
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,133 +1,60 @@
/**
* Manage database migrations as people upgrade their app over time
*/
import { logger } from "../utils/logger";
/**
* Migration interface for database schema migrations
*/
interface Migration {
name: string;
sql: string;
}
/**
* Migration registry to store and manage database migrations
*/
class MigrationRegistry {
export class MigrationService {
private static instance: MigrationService;
private migrations: Migration[] = [];
/**
* Register a migration with the registry
*
* @param migration - The migration to register
*/
registerMigration(migration: Migration): void {
private constructor() {}
static getInstance(): MigrationService {
if (!MigrationService.instance) {
MigrationService.instance = new MigrationService();
}
return MigrationService.instance;
}
registerMigration(migration: Migration) {
this.migrations.push(migration);
}
/**
* Get all registered migrations
*
* @returns Array of registered migrations
* @param sqlExec - A function that executes a SQL statement and returns some update result
* @param sqlQuery - A function that executes a SQL query and returns the result in some format
* @param extractMigrationNames - A function that extracts the names (string array) from a "select name from migrations" query
*/
getMigrations(): Migration[] {
return this.migrations;
}
/**
* Clear all registered migrations
*/
clearMigrations(): void {
this.migrations = [];
}
}
// Create a singleton instance of the migration registry
const migrationRegistry = new MigrationRegistry();
/**
* Register a migration with the migration service
*
* This function is used by the migration system to register database
* schema migrations that need to be applied to the database.
*
* @param migration - The migration to register
*/
export function registerMigration(migration: Migration): void {
migrationRegistry.registerMigration(migration);
}
/**
* Run all registered migrations against the database
*
* This function executes all registered migrations in order, checking
* which ones have already been applied to avoid duplicate execution.
* It creates a migrations table if it doesn't exist to track applied
* migrations.
*
* @param sqlExec - Function to execute SQL statements
* @param sqlQuery - Function to query SQL data
* @param extractMigrationNames - Function to extract migration names from query results
* @returns Promise that resolves when all migrations are complete
*/
export async function runMigrations<T>(
sqlExec: (sql: string, params?: unknown[]) => Promise<unknown>,
sqlQuery: (sql: string, params?: unknown[]) => Promise<T>,
extractMigrationNames: (result: T) => Set<string>,
): Promise<void> {
try {
async runMigrations<T>(
// note that this does not take parameters because the Capacitor SQLite 'execute' is different
sqlExec: (sql: string) => Promise<unknown>,
sqlQuery: (sql: string) => Promise<T>,
extractMigrationNames: (result: T) => Set<string>,
): Promise<void> {
// Create migrations table if it doesn't exist
await sqlExec(`
CREATE TABLE IF NOT EXISTS migrations (
name TEXT PRIMARY KEY,
applied_at TEXT DEFAULT CURRENT_TIMESTAMP
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
executed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
`);
// Get list of already applied migrations
const appliedMigrationsResult = await sqlQuery(
"SELECT name FROM migrations",
);
const appliedMigrations = extractMigrationNames(appliedMigrationsResult);
// Get list of executed migrations
const result1: T = await sqlQuery("SELECT name FROM migrations;");
const executedMigrations = extractMigrationNames(result1);
// Get all registered migrations
const migrations = migrationRegistry.getMigrations();
if (migrations.length === 0) {
logger.warn("[MigrationService] No migrations registered");
return;
}
// Run each migration that hasn't been applied yet
for (const migration of migrations) {
if (appliedMigrations.has(migration.name)) {
continue;
}
try {
// Execute the migration SQL
// Run pending migrations in order
for (const migration of this.migrations) {
if (!executedMigrations.has(migration.name)) {
await sqlExec(migration.sql);
// Record that the migration was applied
await sqlExec("INSERT INTO migrations (name) VALUES (?)", [
migration.name,
]);
logger.info(
`[MigrationService] Successfully applied migration: ${migration.name}`,
await sqlExec(
`INSERT INTO migrations (name) VALUES ('${migration.name}')`,
);
} catch (error) {
logger.error(
`[MigrationService] Failed to apply migration ${migration.name}:`,
error,
);
throw new Error(`Migration ${migration.name} failed: ${error}`);
}
}
} catch (error) {
logger.error("[MigrationService] Migration process failed:", error);
throw error;
}
}
export default MigrationService.getInstance();

View File

@@ -42,7 +42,7 @@ interface QueuedOperation {
*/
export class CapacitorPlatformService implements PlatformService {
/** Current camera direction */
private currentDirection: CameraDirection = "BACK";
private currentDirection: CameraDirection = CameraDirection.Rear;
private sqlite: SQLiteConnection;
private db: SQLiteDBConnection | null = null;
@@ -54,6 +54,29 @@ export class CapacitorPlatformService implements PlatformService {
constructor() {
this.sqlite = new SQLiteConnection(CapacitorSQLite);
// Set up app lifecycle listeners for proper cleanup
this.setupLifecycleListeners();
}
/**
* Set up app lifecycle listeners for proper database cleanup
*/
private setupLifecycleListeners(): void {
if (typeof window !== 'undefined' && window.addEventListener) {
// Handle app pause/resume events
window.addEventListener('beforeunload', () => {
this.cleanupDatabase();
});
// Handle visibility change (app going to background)
document.addEventListener('visibilitychange', () => {
if (document.hidden) {
// App going to background - ensure database is properly closed
this.cleanupDatabase();
}
});
}
}
private async initializeDatabase(): Promise<void> {
@@ -87,19 +110,14 @@ export class CapacitorPlatformService implements PlatformService {
}
try {
// Create/Open database
this.db = await this.sqlite.createConnection(
this.dbName,
false,
"no-encryption",
1,
false,
);
// Check if database connection already exists and close it
await this.cleanupExistingConnections();
await this.db.open();
// Create/Open database with retry logic
this.db = await this.createDatabaseConnection();
// Set journal mode to WAL for better performance
// await this.db.execute("PRAGMA journal_mode=WAL;");
// Configure database for better performance and stability
await this.configureDatabase();
// Run migrations
await this.runCapacitorMigrations();
@@ -116,6 +134,8 @@ export class CapacitorPlatformService implements PlatformService {
"[CapacitorPlatformService] Error initializing SQLite database:",
error,
);
// Clean up on failure
await this.cleanupDatabase();
throw new Error(
"[CapacitorPlatformService] Failed to initialize database",
);
@@ -702,7 +722,7 @@ export class CapacitorPlatformService implements PlatformService {
* @returns Promise that resolves when the camera is rotated
*/
async rotateCamera(): Promise<void> {
this.currentDirection = this.currentDirection === "BACK" ? "FRONT" : "BACK";
this.currentDirection = this.currentDirection === CameraDirection.Rear ? CameraDirection.Front : CameraDirection.Rear;
logger.debug(`Camera rotated to ${this.currentDirection} camera`);
}
@@ -739,4 +759,179 @@ export class CapacitorPlatformService implements PlatformService {
params || [],
);
}
/**
* Clean up any existing database connections to prevent conflicts
*/
private async cleanupExistingConnections(): Promise<void> {
try {
// Check if we have an existing connection
if (this.db) {
try {
await this.db.close();
} catch (closeError) {
logger.warn(
"[CapacitorPlatformService] Error closing existing connection:",
closeError,
);
}
this.db = null;
}
// Check for existing connections with the same name
const connections = await this.sqlite.checkConnectionsConsistency();
const isConn = await this.sqlite.isConnection(this.dbName, false);
if (isConn.result) {
logger.log(
"[CapacitorPlatformService] Found existing connection, closing it",
);
await this.sqlite.closeConnection(this.dbName, false);
}
} catch (error) {
logger.warn(
"[CapacitorPlatformService] Error during connection cleanup:",
error,
);
}
}
/**
* Create database connection with retry logic
*/
private async createDatabaseConnection(): Promise<SQLiteDBConnection> {
const maxRetries = 3;
let lastError: Error | null = null;
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
logger.log(
`[CapacitorPlatformService] Creating database connection (attempt ${attempt}/${maxRetries})`,
);
const db = await this.sqlite.createConnection(
this.dbName,
false,
"no-encryption",
1,
false,
);
await db.open();
logger.log(
`[CapacitorPlatformService] Database connection created successfully on attempt ${attempt}`,
);
return db;
} catch (error) {
lastError = error as Error;
logger.error(
`[CapacitorPlatformService] Database connection attempt ${attempt} failed:`,
error,
);
if (attempt < maxRetries) {
// Wait before retry with exponential backoff
const delay = Math.min(1000 * Math.pow(2, attempt - 1), 5000);
logger.log(
`[CapacitorPlatformService] Waiting ${delay}ms before retry...`,
);
await new Promise(resolve => setTimeout(resolve, delay));
}
}
}
throw new Error(
`[CapacitorPlatformService] Failed to create database connection after ${maxRetries} attempts: ${lastError?.message}`,
);
}
/**
* Configure database settings for optimal performance and stability
*/
private async configureDatabase(): Promise<void> {
if (!this.db) {
throw new Error("Database not initialized");
}
try {
// Configure for better performance and stability
await this.db.execute("PRAGMA journal_mode=WAL;");
await this.db.execute("PRAGMA synchronous=NORMAL;");
await this.db.execute("PRAGMA cache_size=10000;");
await this.db.execute("PRAGMA temp_store=MEMORY;");
await this.db.execute("PRAGMA mmap_size=268435456;"); // 256MB
logger.log(
"[CapacitorPlatformService] Database configuration applied successfully",
);
} catch (error) {
logger.warn(
"[CapacitorPlatformService] Error applying database configuration:",
error,
);
// Don't throw here as the database is still functional
}
}
/**
* Clean up database resources
*/
private async cleanupDatabase(): Promise<void> {
try {
if (this.db) {
await this.db.close();
this.db = null;
}
this.initialized = false;
this.initializationPromise = null;
logger.log(
"[CapacitorPlatformService] Database cleanup completed",
);
} catch (error) {
logger.error(
"[CapacitorPlatformService] Error during database cleanup:",
error,
);
}
}
/**
* Health check for database connection
*/
async healthCheck(): Promise<{ healthy: boolean; error?: string }> {
try {
if (!this.initialized || !this.db) {
return { healthy: false, error: "Database not initialized" };
}
// Try a simple query to test the connection
await this.db.query("SELECT 1 as test");
return { healthy: true };
} catch (error) {
logger.error("[CapacitorPlatformService] Health check failed:", error);
return {
healthy: false,
error: error instanceof Error ? error.message : String(error)
};
}
}
/**
* Force reinitialize the database connection
*/
async reinitializeDatabase(): Promise<void> {
logger.log("[CapacitorPlatformService] Forcing database reinitialization");
// Clean up existing connection
await this.cleanupDatabase();
// Reset initialization state
this.initialized = false;
this.initializationPromise = null;
// Reinitialize
await this.initializeDatabase();
}
}

View File

@@ -0,0 +1,158 @@
/**
* Database Diagnostics Utility
*
* This utility provides diagnostic tools for troubleshooting database connection
* issues in the TimeSafari application, particularly for Capacitor SQLite.
*
* @author Matthew Raymer
*/
import { logger } from "./logger";
import { PlatformServiceFactory } from "@/services/PlatformServiceFactory";
export interface DatabaseDiagnosticInfo {
platform: string;
timestamp: string;
databaseName: string;
connectionStatus: string;
errorDetails?: string;
performanceMetrics?: {
initializationTime?: number;
queryTime?: number;
};
systemInfo?: {
userAgent: string;
platform: string;
memory?: {
used: number;
total: number;
};
};
}
/**
* Performs comprehensive database diagnostics
*/
export async function runDatabaseDiagnostics(): Promise<DatabaseDiagnosticInfo> {
const startTime = Date.now();
const diagnosticInfo: DatabaseDiagnosticInfo = {
platform: "unknown",
timestamp: new Date().toISOString(),
databaseName: "timesafari.sqlite",
connectionStatus: "unknown",
};
try {
// Get platform service
const platformService = PlatformServiceFactory.getInstance();
const capabilities = platformService.getCapabilities();
diagnosticInfo.platform = capabilities.isIOS ? "iOS" :
capabilities.isMobile ? "Android" : "Web";
// Add system information
diagnosticInfo.systemInfo = {
userAgent: navigator.userAgent,
platform: navigator.platform,
};
// Add memory information if available
if ('memory' in performance) {
const memory = (performance as any).memory;
diagnosticInfo.systemInfo.memory = {
used: memory.usedJSHeapSize,
total: memory.totalJSHeapSize,
};
}
// Test database connection
const initStart = Date.now();
try {
// Test a simple query
const queryStart = Date.now();
const result = await platformService.dbQuery("SELECT 1 as test");
const queryTime = Date.now() - queryStart;
diagnosticInfo.connectionStatus = "healthy";
diagnosticInfo.performanceMetrics = {
queryTime,
};
logger.log("[DatabaseDiagnostics] Database connection test successful");
} catch (error) {
diagnosticInfo.connectionStatus = "error";
diagnosticInfo.errorDetails = error instanceof Error ? error.message : String(error);
logger.error("[DatabaseDiagnostics] Database connection test failed:", error);
}
const totalTime = Date.now() - startTime;
if (diagnosticInfo.performanceMetrics) {
diagnosticInfo.performanceMetrics.initializationTime = totalTime;
}
} catch (error) {
diagnosticInfo.connectionStatus = "critical";
diagnosticInfo.errorDetails = error instanceof Error ? error.message : String(error);
logger.error("[DatabaseDiagnostics] Diagnostic run failed:", error);
}
// Log the complete diagnostic information
logger.log("[DatabaseDiagnostics] Diagnostic results:", diagnosticInfo);
return diagnosticInfo;
}
/**
* Logs database performance metrics
*/
export function logDatabasePerformance(operation: string, duration: number): void {
logger.log(`[DatabasePerformance] ${operation}: ${duration}ms`);
// Log warning for slow operations
if (duration > 1000) {
logger.warn(`[DatabasePerformance] Slow operation detected: ${operation} took ${duration}ms`);
}
}
/**
* Creates a database connection stress test
*/
export async function stressTestDatabase(iterations: number = 10): Promise<void> {
logger.log(`[DatabaseStressTest] Starting stress test with ${iterations} iterations`);
const platformService = PlatformServiceFactory.getInstance();
const results: number[] = [];
for (let i = 0; i < iterations; i++) {
const start = Date.now();
try {
await platformService.dbQuery("SELECT 1 as test");
const duration = Date.now() - start;
results.push(duration);
logger.log(`[DatabaseStressTest] Iteration ${i + 1}: ${duration}ms`);
} catch (error) {
logger.error(`[DatabaseStressTest] Iteration ${i + 1} failed:`, error);
}
// Small delay between iterations
await new Promise(resolve => setTimeout(resolve, 100));
}
if (results.length > 0) {
const avg = results.reduce((a, b) => a + b, 0) / results.length;
const min = Math.min(...results);
const max = Math.max(...results);
logger.log(`[DatabaseStressTest] Results - Avg: ${avg.toFixed(2)}ms, Min: ${min}ms, Max: ${max}ms`);
}
}
/**
* Exports diagnostic information for debugging
*/
export function exportDiagnosticInfo(info: DatabaseDiagnosticInfo): string {
return JSON.stringify(info, null, 2);
}

View File

@@ -1,6 +1,6 @@
import { logToDb } from "../db/databaseUtil";
export function safeStringify(obj: unknown) {
function safeStringify(obj: unknown) {
const seen = new WeakSet();
return JSON.stringify(obj, (_key, value) => {
@@ -52,18 +52,23 @@ export const logger = {
}
},
warn: (message: string, ...args: unknown[]) => {
// eslint-disable-next-line no-console
console.warn(message, ...args);
const argsString = args.length > 0 ? " - " + safeStringify(args) : "";
logToDb(message + argsString);
if (
process.env.NODE_ENV !== "production" ||
process.env.VITE_PLATFORM === "capacitor" ||
process.env.VITE_PLATFORM === "electron"
) {
// eslint-disable-next-line no-console
console.warn(message, ...args);
const argsString = args.length > 0 ? " - " + safeStringify(args) : "";
logToDb(message + argsString);
}
},
error: (message: string, ...args: unknown[]) => {
// Errors will always be logged
// eslint-disable-next-line no-console
console.error(message, ...args);
const messageString = safeStringify(message);
const argsString = args.length > 0 ? safeStringify(args) : "";
logToDb(messageString + argsString);
const argsString = args.length > 0 ? " - " + safeStringify(args) : "";
logToDb(message + argsString);
},
};

View File

@@ -349,9 +349,8 @@
</div>
<div v-if="includeUserProfileLocation" class="mb-4 aspect-video">
<p class="text-sm mb-2 text-slate-500">
The location you choose will be shared with the world until you remove
this checkbox. For your security, choose a location nearby but not
exactly at your true location, like at your town center.
For your security, choose a location nearby but not exactly at your
place.
</p>
<l-map
@@ -436,11 +435,11 @@
<p class="text-sm">
You have done
<b
>{{ endorserLimits?.doneClaimsThisWeek ?? "?" }} claim{{
>{{ endorserLimits?.doneClaimsThisWeek || "?" }} claim{{
endorserLimits?.doneClaimsThisWeek === 1 ? "" : "s"
}}</b
>
out of <b>{{ endorserLimits?.maxClaimsPerWeek ?? "?" }}</b> for this
out of <b>{{ endorserLimits?.maxClaimsPerWeek || "?" }}</b> for this
week. Your claims counter resets at
<b class="whitespace-nowrap">{{
readableDate(endorserLimits?.nextWeekBeginDateTime)
@@ -450,14 +449,14 @@
You have done
<b
>{{
endorserLimits?.doneRegistrationsThisMonth ?? "?"
endorserLimits?.doneRegistrationsThisMonth || "?"
}}
registration{{
endorserLimits?.doneRegistrationsThisMonth === 1 ? "" : "s"
}}</b
>
out of
<b>{{ endorserLimits?.maxRegistrationsPerMonth ?? "?" }}</b> for this
<b>{{ endorserLimits?.maxRegistrationsPerMonth || "?" }}</b> for this
this month.
<i>(You cannot register anyone on your first day.)</i>
Your registration counter resets at
@@ -468,11 +467,11 @@
<p class="mt-3 text-sm">
You have uploaded
<b
>{{ imageLimits?.doneImagesThisWeek ?? "?" }} image{{
>{{ imageLimits?.doneImagesThisWeek || "?" }} image{{
imageLimits?.doneImagesThisWeek === 1 ? "" : "s"
}}</b
>
out of <b>{{ imageLimits?.maxImagesPerWeek ?? "?" }}</b> for this
out of <b>{{ imageLimits?.maxImagesPerWeek || "?" }}</b> for this
week. Your image counter resets at
<b class="whitespace-nowrap">{{
readableDate(imageLimits?.nextWeekBeginDateTime)

View File

@@ -49,32 +49,21 @@
v-if="veriClaim.id"
:to="'/claim-cert/' + encodeURIComponent(veriClaim.id)"
class="text-blue-500 mt-2"
title="View Printable Certificate"
title="Printable Certificate"
>
<font-awesome
icon="square"
class="text-white bg-yellow-500 p-1"
/>
</router-link>
<button
v-if="veriClaim.id"
class="text-blue-500 ml-2 mt-2"
title="Copy Printable Certificate Link"
@click="
copyToClipboard(
'A link to the certificate page',
`${APP_SERVER}/deep-link/claim-cert/${veriClaim.id}`,
)
"
>
<font-awesome icon="link" class="text-yellow-500 p-1" />
</button>
</div>
<!-- show link icon to copy this URL to the clipboard -->
<div class="flex justify-end w-full">
<button
title="Copy Link"
@click="copyToClipboard('A link to this page', windowDeepLink)"
@click="
copyToClipboard('A link to this page', window.location.href)
"
>
<font-awesome icon="link" class="text-slate-500" />
</button>
@@ -416,7 +405,7 @@
contacts can see more details:
<a
class="text-blue-500"
@click="copyToClipboard('A link to this page', windowDeepLink)"
@click="copyToClipboard('A link to this page', windowLocation)"
>click to copy this page info</a
>
and see if they can make an introduction. Someone is connected to
@@ -439,7 +428,7 @@
If you'd like an introduction,
<a
class="text-blue-500"
@click="copyToClipboard('A link to this page', windowDeepLink)"
@click="copyToClipboard('A link to this page', windowLocation)"
>share this page with them and ask if they'll tell you more about
about the participants.</a
>
@@ -557,7 +546,7 @@ import { useClipboard } from "@vueuse/core";
import { GenericVerifiableCredential } from "../interfaces";
import GiftedDialog from "../components/GiftedDialog.vue";
import QuickNav from "../components/QuickNav.vue";
import { APP_SERVER, NotificationIface, USE_DEXIE_DB } from "../constants/app";
import { NotificationIface, USE_DEXIE_DB } from "../constants/app";
import * as databaseUtil from "../db/databaseUtil";
import { db } from "../db/index";
import { logConsoleAndDb } from "../db/databaseUtil";
@@ -604,9 +593,8 @@ export default class ClaimView extends Vue {
veriClaim = serverUtil.BLANK_GENERIC_SERVER_RECORD;
veriClaimDump = "";
veriClaimDidsVisible: { [key: string]: string[] } = {};
windowDeepLink = window.location.href; // changed in the setup for deep linking
windowLocation = window.location.href;
APP_SERVER = APP_SERVER;
R = R;
yaml = yaml;
libsUtil = libsUtil;
@@ -683,7 +671,6 @@ export default class ClaimView extends Vue {
5000,
);
}
this.windowDeepLink = `${APP_SERVER}/deep-link/claim/${claimId}`;
this.canShare = !!navigator.share;
}
@@ -1019,11 +1006,11 @@ export default class ClaimView extends Vue {
}
onClickShareClaim() {
this.copyToClipboard("A link to this page", this.windowDeepLink);
this.copyToClipboard("A link to this page", this.windowLocation);
window.navigator.share({
title: "Help Connect Me",
text: "I'm trying to find the people who recorded this. Can you help me?",
url: this.windowDeepLink,
url: this.windowLocation,
});
}

View File

@@ -436,7 +436,7 @@ import { Component, Vue } from "vue-facing-decorator";
import { useClipboard } from "@vueuse/core";
import { RouteLocationNormalizedLoaded, Router } from "vue-router";
import QuickNav from "../components/QuickNav.vue";
import { APP_SERVER, NotificationIface, USE_DEXIE_DB } from "../constants/app";
import { NotificationIface, USE_DEXIE_DB } from "../constants/app";
import { db, retrieveSettingsForActiveAccount } from "../db/index";
import { Contact } from "../db/tables/contacts";
import * as databaseUtil from "../db/databaseUtil";
@@ -494,7 +494,7 @@ export default class ConfirmGiftView extends Vue {
veriClaim = serverUtil.BLANK_GENERIC_SERVER_RECORD;
veriClaimDump = "";
veriClaimDidsVisible: { [key: string]: string[] } = {};
windowLocation = window.location.href; // this is changed to a deep link in the setup
windowLocation = window.location.href;
R = R;
yaml = yaml;
@@ -566,9 +566,6 @@ export default class ConfirmGiftView extends Vue {
}
const claimId = decodeURIComponent(pathParam);
this.windowLocation = APP_SERVER + "/deep-link/confirm-gift/" + claimId;
await this.loadClaim(claimId, this.activeDid);
}
@@ -679,12 +676,12 @@ export default class ConfirmGiftView extends Vue {
/**
* Add participant (giver/recipient) name & URL info
*/
this.giverName = this.didInfo(this.giveDetails?.agentDid);
if (this.giveDetails?.agentDid) {
this.giverName = this.didInfo(this.giveDetails.agentDid);
this.urlForNewGive += `&giverDid=${encodeURIComponent(this.giveDetails.agentDid)}&giverName=${encodeURIComponent(this.giverName)}`;
}
this.recipientName = this.didInfo(this.giveDetails?.recipientDid);
if (this.giveDetails?.recipientDid) {
this.recipientName = this.didInfo(this.giveDetails.recipientDid);
this.urlForNewGive += `&recipientDid=${encodeURIComponent(this.giveDetails.recipientDid)}&recipientName=${encodeURIComponent(this.recipientName)}`;
}

View File

@@ -566,7 +566,7 @@ export default class ContactImportView extends Vue {
this.checkingImports = true;
try {
const jwt: string = getContactJwtFromJwtUrl(jwtInput) || "";
const jwt: string = getContactJwtFromJwtUrl(jwtInput);
const payload = decodeEndorserJwt(jwt).payload;
if (Array.isArray(payload.contacts)) {

View File

@@ -104,7 +104,6 @@
</template>
<script lang="ts">
import { Buffer } from "buffer/";
import QRCodeVue3 from "qr-code-generator-vue3";
import { Component, Vue } from "vue-facing-decorator";
import { Router } from "vue-router";
@@ -118,20 +117,14 @@ import { db } from "../db/index";
import { Contact } from "../db/tables/contacts";
import { getContactJwtFromJwtUrl } from "../libs/crypto";
import { decodeEndorserJwt, ETHR_DID_PREFIX } from "../libs/crypto/vc";
import * as libsUtil from "../libs/util";
import { retrieveSettingsForActiveAccount } from "../db/index";
import * as databaseUtil from "../db/databaseUtil";
import {
CONTACT_CSV_HEADER,
CONTACT_IMPORT_CONFIRM_URL_PATH_TIME_SAFARI,
generateEndorserJwtUrlForAccount,
setVisibilityUtil,
} from "../libs/endorserServer";
import { setVisibilityUtil } from "../libs/endorserServer";
import UserNameDialog from "../components/UserNameDialog.vue";
import { generateEndorserJwtUrlForAccount } from "../libs/endorserServer";
import { retrieveAccountMetadata } from "../libs/util";
import { PlatformServiceFactory } from "@/services/PlatformServiceFactory";
import { parseJsonField } from "../db/databaseUtil";
import { Account } from "@/db/tables/accounts";
interface QRScanResult {
rawValue?: string;
@@ -149,7 +142,7 @@ interface IUserNameDialog {
UserNameDialog,
},
})
export default class ContactQRScanFull extends Vue {
export default class ContactQRScan extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void;
$router!: Router;
@@ -158,8 +151,6 @@ export default class ContactQRScanFull extends Vue {
activeDid = "";
apiServer = "";
givenName = "";
isRegistered = false;
profileImageUrl = "";
qrValue = "";
ETHR_DID_PREFIX = ETHR_DID_PREFIX;
@@ -181,22 +172,19 @@ export default class ContactQRScanFull extends Vue {
this.activeDid = settings.activeDid || "";
this.apiServer = settings.apiServer || "";
this.givenName = settings.firstName || "";
this.isRegistered = !!settings.isRegistered;
this.profileImageUrl = settings.profileImageUrl || "";
const account = await retrieveAccountMetadata(this.activeDid);
if (account) {
const name =
(settings.firstName || "") +
(settings.lastName ? ` ${settings.lastName}` : "");
const publicKeyBase64 = Buffer.from(
account.publicKeyHex,
"hex",
).toString("base64");
this.qrValue =
CONTACT_CSV_HEADER +
"\n" +
`"${name}",${account.did},${publicKeyBase64},false,${this.isRegistered}`;
this.qrValue = await generateEndorserJwtUrlForAccount(
account,
!!settings.isRegistered,
name,
settings.profileImageUrl || "",
false,
);
}
} catch (error) {
logger.error("Error initializing component:", {
@@ -348,69 +336,57 @@ export default class ContactQRScanFull extends Vue {
logger.info("Processing QR code scan result:", rawValue);
let contact: Contact;
if (rawValue.includes(CONTACT_IMPORT_CONFIRM_URL_PATH_TIME_SAFARI)) {
// Extract JWT
const jwt = getContactJwtFromJwtUrl(rawValue);
if (!jwt) {
logger.warn("Invalid QR code format - no JWT found in URL");
this.$notify({
group: "alert",
type: "danger",
title: "Invalid QR Code",
text: "This QR code does not contain valid contact information. Scan a TimeSafari contact QR code.",
});
return;
}
// Process JWT and contact info
logger.info("Decoding JWT payload from QR code");
const decodedJwt = await decodeEndorserJwt(jwt);
if (!decodedJwt?.payload?.own) {
logger.warn("Invalid JWT payload - missing 'own' field");
this.$notify({
group: "alert",
type: "danger",
title: "Invalid Contact Info",
text: "The contact information is incomplete or invalid.",
});
return;
}
const contactInfo = decodedJwt.payload.own;
const did = contactInfo.did || decodedJwt.payload.iss;
if (!did) {
logger.warn("Invalid contact info - missing DID");
this.$notify({
group: "alert",
type: "danger",
title: "Invalid Contact",
text: "The contact DID is missing.",
});
return;
}
// Create contact object
contact = {
did: did,
name: contactInfo.name || "",
publicKeyBase64: contactInfo.publicKeyBase64 || "",
seesMe: contactInfo.seesMe || false,
registered: contactInfo.registered || false,
};
} else if (rawValue.startsWith(CONTACT_CSV_HEADER)) {
const lines = rawValue.split(/\n/);
contact = libsUtil.csvLineToContact(lines[1]);
} else {
// Extract JWT
const jwt = getContactJwtFromJwtUrl(rawValue);
if (!jwt) {
logger.warn("Invalid QR code format - no JWT found in URL");
this.$notify({
group: "alert",
type: "danger",
title: "Error",
text: "Could not determine the type of contact info. Try again, or tap the QR code to copy it and send it to them.",
title: "Invalid QR Code",
text: "This QR code does not contain valid contact information. Please scan a TimeSafari contact QR code.",
});
return;
}
// Process JWT and contact info
logger.info("Decoding JWT payload from QR code");
const decodedJwt = await decodeEndorserJwt(jwt);
if (!decodedJwt?.payload?.own) {
logger.warn("Invalid JWT payload - missing 'own' field");
this.$notify({
group: "alert",
type: "danger",
title: "Invalid Contact Info",
text: "The contact information is incomplete or invalid.",
});
return;
}
const contactInfo = decodedJwt.payload.own;
const did = contactInfo.did || decodedJwt.payload.iss;
if (!did) {
logger.warn("Invalid contact info - missing DID");
this.$notify({
group: "alert",
type: "danger",
title: "Invalid Contact",
text: "The contact DID is missing.",
});
return;
}
// Create contact object
const contact = {
did: did,
name: contactInfo.name || "",
email: contactInfo.email || "",
phone: contactInfo.phone || "",
company: contactInfo.company || "",
title: contactInfo.title || "",
notes: contactInfo.notes || "",
};
// Add contact but keep scanning
logger.info("Adding new contact to database:", {
did: contact.did,
@@ -492,7 +468,7 @@ export default class ContactQRScanFull extends Vue {
title: "Contact Exists",
text: "This contact has already been added to your list.",
},
5000,
3000,
);
return;
}
@@ -592,19 +568,9 @@ export default class ContactQRScanFull extends Vue {
);
}
async onCopyUrlToClipboard() {
const account = (await libsUtil.retrieveFullyDecryptedAccount(
this.activeDid,
)) as Account;
const jwtUrl = await generateEndorserJwtUrlForAccount(
account,
this.isRegistered,
this.givenName,
this.profileImageUrl,
true,
);
onCopyUrlToClipboard() {
useClipboard()
.copy(jwtUrl)
.copy(this.qrValue)
.then(() => {
this.$notify(
{

View File

@@ -159,7 +159,6 @@
<script lang="ts">
import { AxiosError } from "axios";
import { Buffer } from "buffer/";
import QRCodeVue3 from "qr-code-generator-vue3";
import { Component, Vue } from "vue-facing-decorator";
import { useClipboard } from "@vueuse/core";
@@ -175,20 +174,17 @@ import * as databaseUtil from "../db/databaseUtil";
import { parseJsonField } from "../db/databaseUtil";
import { getContactJwtFromJwtUrl } from "../libs/crypto";
import {
CONTACT_CSV_HEADER,
CONTACT_IMPORT_CONFIRM_URL_PATH_TIME_SAFARI,
generateEndorserJwtUrlForAccount,
register,
setVisibilityUtil,
} from "../libs/endorserServer";
import { decodeEndorserJwt, ETHR_DID_PREFIX } from "../libs/crypto/vc";
import * as libsUtil from "../libs/util";
import { retrieveAccountMetadata } from "../libs/util";
import { Router } from "vue-router";
import { logger } from "../utils/logger";
import { QRScannerFactory } from "@/services/QRScanner/QRScannerFactory";
import { CameraState } from "@/services/QRScanner/types";
import { PlatformServiceFactory } from "@/services/PlatformServiceFactory";
import { Account } from "@/db/tables/accounts";
interface QRScanResult {
rawValue?: string;
@@ -218,7 +214,6 @@ export default class ContactQRScanShow extends Vue {
isRegistered = false;
qrValue = "";
isScanning = false;
profileImageUrl = "";
error: string | null = null;
// QR Scanner properties
@@ -256,21 +251,19 @@ export default class ContactQRScanShow extends Vue {
this.hideRegisterPromptOnNewContact =
!!settings.hideRegisterPromptOnNewContact;
this.isRegistered = !!settings.isRegistered;
this.profileImageUrl = settings.profileImageUrl || "";
const account = await libsUtil.retrieveAccountMetadata(this.activeDid);
const account = await retrieveAccountMetadata(this.activeDid);
if (account) {
const name =
(settings.firstName || "") +
(settings.lastName ? ` ${settings.lastName}` : "");
const publicKeyBase64 = Buffer.from(
account.publicKeyHex,
"hex",
).toString("base64");
this.qrValue =
CONTACT_CSV_HEADER +
"\n" +
`"${name}",${account.did},${publicKeyBase64},false,${this.isRegistered}`;
this.qrValue = await generateEndorserJwtUrlForAccount(
account,
!!settings.isRegistered,
name,
settings.profileImageUrl || "",
false,
);
}
} catch (error) {
logger.error("Error initializing component:", {
@@ -281,7 +274,7 @@ export default class ContactQRScanShow extends Vue {
group: "alert",
type: "danger",
title: "Initialization Error",
text: "Failed to initialize QR renderer or scanner. Please try again.",
text: "Failed to initialize QR scanner. Please try again.",
});
}
}
@@ -468,68 +461,53 @@ export default class ContactQRScanShow extends Vue {
logger.info("Processing QR code scan result:", rawValue);
let contact: Contact;
if (rawValue.includes(CONTACT_IMPORT_CONFIRM_URL_PATH_TIME_SAFARI)) {
const jwt = getContactJwtFromJwtUrl(rawValue);
if (!jwt) {
logger.warn("Invalid QR code format - no JWT found in URL");
this.$notify({
group: "alert",
type: "danger",
title: "Invalid QR Code",
text: "This QR code does not contain valid contact information. Scan a TimeSafari contact QR code.",
});
return;
}
logger.info("Decoding JWT payload from QR code");
const decodedJwt = await decodeEndorserJwt(jwt);
// Process JWT and contact info
if (!decodedJwt?.payload?.own) {
logger.warn("Invalid JWT payload - missing 'own' field");
this.$notify({
group: "alert",
type: "danger",
title: "Invalid Contact Info",
text: "The contact information is incomplete or invalid.",
});
return;
}
const contactInfo = decodedJwt.payload.own;
const did = contactInfo.did || decodedJwt.payload.iss;
if (!did) {
logger.warn("Invalid contact info - missing DID");
this.$notify({
group: "alert",
type: "danger",
title: "Invalid Contact",
text: "The contact DID is missing.",
});
return;
}
// Create contact object
contact = {
did: did,
name: contactInfo.name || "",
publicKeyBase64: contactInfo.publicKeyBase64 || "",
seesMe: contactInfo.seesMe || false,
registered: contactInfo.registered || false,
};
} else if (rawValue.startsWith(CONTACT_CSV_HEADER)) {
const lines = rawValue.split(/\n/);
contact = libsUtil.csvLineToContact(lines[1]);
} else {
// Extract JWT
const jwt = getContactJwtFromJwtUrl(rawValue);
if (!jwt) {
logger.warn("Invalid QR code format - no JWT found in URL");
this.$notify({
group: "alert",
type: "danger",
title: "Error",
text: "Could not determine the type of contact info. Try again, or tap the QR code to copy it and send it to them.",
title: "Invalid QR Code",
text: "This QR code does not contain valid contact information. Please scan a TimeSafari contact QR code.",
});
return;
}
// Process JWT and contact info
logger.info("Decoding JWT payload from QR code");
const decodedJwt = await decodeEndorserJwt(jwt);
if (!decodedJwt?.payload?.own) {
logger.warn("Invalid JWT payload - missing 'own' field");
this.$notify({
group: "alert",
type: "danger",
title: "Invalid Contact Info",
text: "The contact information is incomplete or invalid.",
});
return;
}
const contactInfo = decodedJwt.payload.own;
const did = contactInfo.did || decodedJwt.payload.iss;
if (!did) {
logger.warn("Invalid contact info - missing DID");
this.$notify({
group: "alert",
type: "danger",
title: "Invalid Contact",
text: "The contact DID is missing.",
});
return;
}
// Create contact object
const contact = {
did: did,
name: contactInfo.name || "",
notes: contactInfo.notes || "",
};
// Add contact but keep scanning
logger.info("Adding new contact to database:", {
did: contact.did,
@@ -671,20 +649,12 @@ export default class ContactQRScanShow extends Vue {
});
}
async onCopyUrlToClipboard() {
const account = (await libsUtil.retrieveFullyDecryptedAccount(
this.activeDid,
)) as Account;
const jwtUrl = await generateEndorserJwtUrlForAccount(
account,
this.isRegistered,
this.givenName,
this.profileImageUrl,
true,
);
onCopyUrlToClipboard() {
//this.onScanDetect([{ rawValue: this.qrValue }]); // good for testing
useClipboard()
.copy(jwtUrl)
.copy(this.qrValue)
.then(() => {
// console.log("Contact URL:", this.qrValue);
this.$notify(
{
group: "alert",
@@ -802,7 +772,7 @@ export default class ContactQRScanShow extends Vue {
title: "Contact Exists",
text: "This contact has already been added to your list.",
},
5000,
3000,
);
return;
}

View File

@@ -126,6 +126,7 @@
<div class="flex items-center gap-2">
<button
v-if="showGiveNumbers"
href=""
class="text-md bg-gradient-to-b shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-3 py-1.5 rounded-md"
:class="showGiveAmountsClassNames()"
@click="toggleShowGiveTotals()"
@@ -141,6 +142,7 @@
</button>
<button
href=""
class="text-md bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-3 py-1.5 rounded-md"
@click="toggleShowContactAmounts()"
>
@@ -491,7 +493,7 @@ export default class ContactsView extends Vue {
private async processContactJwt() {
// handle a contact sent via URL
//
// For external links, use /deep-link/contact-import/:jwt with a JWT that has an array of contacts
// For external links, use /contact-import/:jwt with a JWT that has an array of contacts
// because that will do better error checking for things like missing data on iOS platforms.
const importedContactJwt = this.$route.query["contactJwt"] as string;
if (importedContactJwt) {
@@ -617,7 +619,7 @@ export default class ContactsView extends Vue {
title: "Error with Invite",
text: message,
},
-1,
5000,
);
}
// if we're here, they haven't redirected anywhere, so we'll redirect here without a query parameter
@@ -933,9 +935,45 @@ export default class ContactsView extends Vue {
}
private async addContactFromEndorserMobileLine(
lineRaw: string,
line: string,
): Promise<IndexableType> {
const newContact = libsUtil.csvLineToContact(lineRaw);
// Note that Endorser Mobile puts name first, then did, etc.
let name = line;
let did = "";
let publicKeyInput, seesMe, registered;
const commaPos1 = line.indexOf(",");
if (commaPos1 > -1) {
name = line.substring(0, commaPos1).trim();
did = line.substring(commaPos1 + 1).trim();
const commaPos2 = line.indexOf(",", commaPos1 + 1);
if (commaPos2 > -1) {
did = line.substring(commaPos1 + 1, commaPos2).trim();
publicKeyInput = line.substring(commaPos2 + 1).trim();
const commaPos3 = line.indexOf(",", commaPos2 + 1);
if (commaPos3 > -1) {
publicKeyInput = line.substring(commaPos2 + 1, commaPos3).trim();
seesMe = line.substring(commaPos3 + 1).trim() == "true";
const commaPos4 = line.indexOf(",", commaPos3 + 1);
if (commaPos4 > -1) {
seesMe = line.substring(commaPos3 + 1, commaPos4).trim() == "true";
registered = line.substring(commaPos4 + 1).trim() == "true";
}
}
}
}
// help with potential mistakes while this sharing requires copy-and-paste
let publicKeyBase64 = publicKeyInput;
if (publicKeyBase64 && /^[0-9A-Fa-f]{66}$/i.test(publicKeyBase64)) {
// it must be all hex (compressed public key), so convert
publicKeyBase64 = Buffer.from(publicKeyBase64, "hex").toString("base64");
}
const newContact = {
did,
name,
publicKeyBase64,
seesMe,
registered,
};
const platformService = PlatformServiceFactory.getInstance();
const { sql, params } = databaseUtil.generateInsertStatement(
newContact as unknown as Record<string, unknown>,
@@ -1122,7 +1160,7 @@ export default class ContactsView extends Vue {
(regResult.error as string) ||
"Something went wrong during registration.",
},
-1,
5000,
);
}
} catch (error) {
@@ -1156,7 +1194,7 @@ export default class ContactsView extends Vue {
title: "Registration Error",
text: userMessage,
},
-1,
5000,
);
}
}
@@ -1177,6 +1215,7 @@ export default class ContactsView extends Vue {
);
if (result.success) {
//contact.seesMe = visibility; // why doesn't it affect the UI from here?
//console.log("Set result & seesMe", result, contact.seesMe, contact.did);
if (showSuccessAlert) {
this.$notify(
{
@@ -1392,11 +1431,14 @@ export default class ContactsView extends Vue {
}
return contact;
});
// console.log(
// "Array of selected contacts:",
// JSON.stringify(selectedContacts),
// );
const contactsJwt = await createEndorserJwtForDid(this.activeDid, {
contacts: selectedContacts,
});
const contactsJwtUrl =
APP_SERVER + "/deep-link/contact-import/" + contactsJwt;
const contactsJwtUrl = APP_SERVER + "/contact-import/" + contactsJwt;
useClipboard()
.copy(contactsJwtUrl)
.then(() => {

View File

@@ -77,7 +77,6 @@
@click="confirmSetVisibility(contactFromDid, false)"
>
<font-awesome icon="eye" class="fa-fw" />
<font-awesome icon="arrow-up" class="fa-fw" />
</button>
<button
v-else-if="
@@ -88,32 +87,6 @@
@click="confirmSetVisibility(contactFromDid, true)"
>
<font-awesome icon="eye-slash" class="fa-fw" />
<font-awesome icon="arrow-up" class="fa-fw" />
</button>
<button
v-if="
contactFromDid?.iViewContent &&
contactFromDid.did !== activeDid
"
class="text-sm uppercase bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white mx-0.5 my-0.5 px-2 py-1.5 rounded-md"
title="I view their content"
@click="confirmViewContent(contactFromDid, false)"
>
<font-awesome icon="eye" class="fa-fw" />
<font-awesome icon="arrow-down" class="fa-fw" />
</button>
<button
v-else-if="
!contactFromDid?.iViewContent &&
contactFromDid?.did !== activeDid
"
class="text-sm uppercase bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white mx-0.5 my-0.5 px-2 py-1.5 rounded-md"
title="I do not view their content"
@click="confirmViewContent(contactFromDid, true)"
>
<font-awesome icon="eye-slash" class="fa-fw" />
<font-awesome icon="arrow-down" class="fa-fw" />
</button>
<button
@@ -852,9 +825,9 @@ export default class DIDView extends Vue {
title: "Visibility Refreshed",
text:
libsUtil.nameForContact(contact, true) +
" can" +
(visibility ? "" : " not") +
" see your activity.",
" can " +
(visibility ? "" : "not ") +
"see your activity.",
},
3000,
);
@@ -884,64 +857,6 @@ export default class DIDView extends Vue {
);
}
}
/**
* Confirm whether the user want to see/hide the other's content, then execute it
*
* @param contact Contact content to show/hide from user
* @param view whether user wants to view this contact
*/
async confirmViewContent(contact: Contact, view: boolean) {
const contentVisibilityPrompt = view
? "Are you sure you want to see their content?"
: "Are you sure you want to hide their content from you?";
this.$notify(
{
group: "modal",
type: "confirm",
title: "Set Content Visibility",
text: contentVisibilityPrompt,
onYes: async () => {
const success = await this.setViewContent(contact, view);
if (success) {
contact.iViewContent = view; // see visibility note about not working inside setVisibility
}
},
},
-1,
);
}
/**
* Updates contact content visibility for this device
*
* @param contact - Contact to update content visibility for
* @param visibility - New content visibility state
* @returns Boolean indicating success
*/
async setViewContent(contact: Contact, visibility: boolean) {
const platformService = PlatformServiceFactory.getInstance();
await platformService.dbExec(
"UPDATE contacts SET iViewContent = ? WHERE did = ?",
[visibility, contact.did],
);
if (USE_DEXIE_DB) {
db.contacts.update(contact.did, { iViewContent: visibility });
}
this.$notify(
{
group: "alert",
type: "success",
title: "Visibility Set",
text:
"You will" +
(visibility ? "" : " not") +
` see ${contact.name}'s activity.`,
},
3000,
);
return true;
}
}
</script>

File diff suppressed because it is too large Load Diff

View File

@@ -31,11 +31,7 @@
<h2>Supported Deep Links</h2>
<ul>
<li v-for="(routeItem, index) in validRoutes" :key="index">
<code
>timesafari://{{ routeItem }}/:{{
deepLinkSchemaKeys[routeItem]
}}</code
>
<code>timesafari://{{ routeItem }}/:id</code>
</li>
</ul>
</div>
@@ -45,22 +41,12 @@
<script setup lang="ts">
import { computed, onMounted } from "vue";
import { useRoute, useRouter } from "vue-router";
import {
VALID_DEEP_LINK_ROUTES,
deepLinkSchemas,
} from "../interfaces/deepLinks";
import { VALID_DEEP_LINK_ROUTES } from "../interfaces/deepLinks";
import { logConsoleAndDb } from "../db/databaseUtil";
import { logger } from "../utils/logger";
const route = useRoute();
const router = useRouter();
// an object with the route as the key and the first param name as the value
const deepLinkSchemaKeys = Object.fromEntries(
Object.entries(deepLinkSchemas).map(([route, schema]) => {
const param = Object.keys(schema.shape)[0];
return [route, param];
}),
);
// Extract error information from query params
const errorCode = computed(
@@ -68,7 +54,7 @@ const errorCode = computed(
);
const errorMessage = computed(
() =>
(route.query.errorMessage as string) ||
(route.query.message as string) ||
"The deep link you followed is invalid or not supported.",
);
const originalPath = computed(() => route.query.originalPath as string);
@@ -80,14 +66,9 @@ const formattedPath = computed(() => {
const path = originalPath.value.replace(/^\/+/, "");
// Log for debugging
logger.log(
"[DeepLinkError] Original Path:",
originalPath.value,
"Route Params:",
route.params,
"Route Query:",
route.query,
);
logger.log("Original Path:", originalPath.value);
logger.log("Route Params:", route.params);
logger.log("Route Query:", route.query);
return path;
});
@@ -107,7 +88,7 @@ const reportIssue = () => {
// Log the error for analytics
onMounted(() => {
logConsoleAndDb(
`[DeepLinkError] Error page displayed for path: ${originalPath.value}, code: ${errorCode.value}, params: ${JSON.stringify(route.params)}, query: ${JSON.stringify(route.query)}`,
`[DeepLink] Error page displayed for path: ${originalPath.value}, code: ${errorCode.value}, params: ${JSON.stringify(route.params)}`,
true,
);
});

View File

@@ -1,227 +0,0 @@
<template>
<!-- CONTENT -->
<section id="Content" class="relative w-[100vw] h-[100vh]">
<div
class="p-6 bg-white w-full max-w-[calc((100vh-env(safe-area-inset-top)-env(safe-area-inset-bottom))*0.4)] mx-auto"
>
<div class="mb-4">
<h1 class="text-xl text-center font-semibold relative mb-4">
Redirecting to Time Safari
</h1>
<div v-if="destinationUrl" class="space-y-4">
<!-- Platform-specific messaging -->
<div class="text-center text-gray-600 mb-4">
<p v-if="isMobile">
{{
isIOS
? "Opening Time Safari app on your iPhone..."
: "Opening Time Safari app on your Android device..."
}}
</p>
<p v-else>Opening Time Safari app...</p>
<p class="text-sm mt-2">
<span v-if="isMobile"
>If the app doesn't open automatically, use one of these
options:</span
>
<span v-else>Choose how you'd like to open this link:</span>
</p>
</div>
<!-- Deep Link Button -->
<div class="text-center">
<a
:href="deepLinkUrl || '#'"
class="inline-block bg-blue-600 text-white px-6 py-3 rounded-lg font-medium hover:bg-blue-700 transition-colors"
@click="handleDeepLinkClick"
>
<span v-if="isMobile">Open in Time Safari App</span>
<span v-else>Try Opening in Time Safari App</span>
</a>
</div>
<!-- Web Fallback Link -->
<div class="text-center">
<a
:href="webUrl || '#'"
target="_blank"
class="inline-block bg-gray-600 text-white px-6 py-3 rounded-lg font-medium hover:bg-gray-700 transition-colors"
@click="handleWebFallbackClick"
>
<span v-if="isMobile">Open in Web Browser Instead</span>
<span v-else>Open in Web Browser</span>
</a>
</div>
<!-- Manual Instructions -->
<div class="text-center text-sm text-gray-500 mt-4">
<p v-if="isMobile">
Or manually open:
<code class="bg-gray-100 px-2 py-1 rounded">{{
deepLinkUrl
}}</code>
</p>
<p v-else>
If you have the Time Safari app installed, you can also copy this
link:
<code class="bg-gray-100 px-2 py-1 rounded">{{
deepLinkUrl
}}</code>
</p>
</div>
<!-- Platform info for debugging -->
<div
v-if="isDevelopment"
class="text-center text-xs text-gray-400 mt-4"
>
<p>
Platform: {{ isMobile ? (isIOS ? "iOS" : "Android") : "Desktop" }}
</p>
<p>User Agent: {{ userAgent.substring(0, 50) }}...</p>
</div>
</div>
<div v-else-if="pageError" class="text-center text-red-500 mb-4">
{{ pageError }}
</div>
<div v-else class="text-center text-gray-600">
<p>Processing redirect...</p>
</div>
</div>
</div>
</section>
</template>
<script lang="ts">
import { Component, Vue } from "vue-facing-decorator";
import { RouteLocationNormalizedLoaded, Router } from "vue-router";
import { APP_SERVER } from "@/constants/app";
import { logger } from "@/utils/logger";
import { errorStringForLog } from "@/libs/endorserServer";
import { PlatformServiceFactory } from "@/services/PlatformServiceFactory";
@Component({})
export default class DeepLinkRedirectView extends Vue {
$router!: Router;
$route!: RouteLocationNormalizedLoaded;
pageError: string | null = null;
destinationUrl: string | null = null; // full path after "/deep-link/"
deepLinkUrl: string | null = null; // mobile link starting "timesafari://"
webUrl: string | null = null; // web link, eg "https://timesafari.app/..."
isDevelopment: boolean = false;
userAgent: string = "";
private platformService = PlatformServiceFactory.getInstance();
mounted() {
// Get the path from the route parameter (catch-all parameter)
const pathParam = this.$route.params.path;
// If pathParam is an array (catch-all parameter), join it
const fullPath = Array.isArray(pathParam) ? pathParam.join("/") : pathParam;
// Get query parameters from the route
const queryParams = this.$route.query;
// Build query string if there are query parameters
let queryString = "";
if (Object.keys(queryParams).length > 0) {
const searchParams = new URLSearchParams();
Object.entries(queryParams).forEach(([key, value]) => {
if (value !== undefined && value !== null) {
const stringValue = Array.isArray(value) ? value[0] : value;
if (stringValue !== null && stringValue !== undefined) {
searchParams.append(key, stringValue);
}
}
});
queryString = "?" + searchParams.toString();
}
// Combine path with query parameters
const fullPathWithQuery = fullPath + queryString;
this.destinationUrl = fullPathWithQuery;
this.deepLinkUrl = `timesafari://${fullPathWithQuery}`;
this.webUrl = `${APP_SERVER}/${fullPathWithQuery}`;
this.isDevelopment = process.env.NODE_ENV !== "production";
this.userAgent = navigator.userAgent;
this.openDeepLink();
}
private openDeepLink() {
if (!this.deepLinkUrl || !this.webUrl) {
this.pageError =
"No deep link was provided. Check the URL and try again.";
return;
}
try {
// For mobile, try the deep link URL; for desktop, use the web URL
const redirectUrl = this.isMobile ? this.deepLinkUrl : this.webUrl;
// Method 1: Try window.location.href (works on most browsers)
window.location.href = redirectUrl;
// Method 2: Fallback - create and click a link element
setTimeout(() => {
try {
const link = document.createElement("a");
link.href = redirectUrl;
link.style.display = "none";
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
} catch (error) {
logger.error(
"Fallback deep link failed: " + errorStringForLog(error),
);
this.pageError =
"Redirecting to the Time Safari app failed. Please use a manual option below.";
}
}, 100);
} catch (error) {
logger.error("Deep link redirect failed: " + errorStringForLog(error));
this.pageError =
"Unable to open the Time Safari app. Please use a manual option below.";
}
}
private handleDeepLinkClick(event: Event) {
if (!this.deepLinkUrl) return;
// Prevent default to handle the click manually
event.preventDefault();
this.openDeepLink();
}
private handleWebFallbackClick(event: Event) {
if (!this.webUrl) return;
// Get platform capabilities
const capabilities = this.platformService.getCapabilities();
// For mobile, try to open in a new tab/window
if (capabilities.isMobile) {
event.preventDefault();
window.open(this.webUrl, "_blank");
}
// For desktop, let the default behavior happen (opens in same tab)
}
// Computed properties for template
get isMobile(): boolean {
return this.platformService.getCapabilities().isMobile;
}
get isIOS(): boolean {
return this.platformService.getCapabilities().isIOS;
}
}
</script>

View File

@@ -523,7 +523,9 @@ export default class DiscoverView extends Vue {
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (e: any) {
logger.error("Error with search all: " + errorStringForLog(e));
logger.error("Error with search all:", e);
// this sometimes gives different information
logger.error("Error with search all (error added): " + e);
this.$notify(
{
group: "alert",
@@ -615,7 +617,7 @@ export default class DiscoverView extends Vue {
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (e: any) {
logger.error("Error with search local: " + errorStringForLog(e));
logger.error("Error with search local:", e);
this.$notify(
{
group: "alert",

View File

@@ -37,16 +37,14 @@
<h2 class="text-xl font-semibold">What is the idea here?</h2>
<p>
We are building networks of people who want to grow good society from the ground up, using
modern technology that connects people peer-to-peer.
First of all, let's showcase gratitude: see what people have given, and recognize gifts
you've seen. This is done in a way that leaves a permanent record -- one that provably
came from you, and one that the recipient can prove they were mentioned.
This can be personally gratifying, but it extends to broader work: volunteers get
confirmation of activity, and they can selectively show off their contributions and
network.
This is a way to build trust and reputation. It's a way to build a network of people who
are willing to help each other.
We are building networks of people who want to grow good society from the ground up, using modern
technology that connects people peer-to-peer.
First of all, let's showcase gratitude: see what people have given, and recognize
gifts you've seen. This is done in a way that leaves a permanent record -- one that
came from you, and one that the recipient can prove it was for them. This can be
personally gratifying, but it extends to broader work: volunteers get
confirmation of activity, and they can selectively show off their contributions
and network.
</p>
<p class="mt-2">
With this, you highlight giving and you also offer help --

View File

@@ -12,7 +12,6 @@ Raymer * @version 1.0.0 */
<section id="Content" class="p-6 pb-24 max-w-3xl mx-auto">
<h1 id="ViewHeading" class="text-4xl text-center font-light mb-8">
{{ AppString.APP_NAME }}
<span class="text-xs text-gray-500">{{ package.version }}</span>
</h1>
<OnboardingDialog ref="onboardingDialog" />
@@ -107,12 +106,12 @@ Raymer * @version 1.0.0 */
</button>
</div>
<UserNameDialog ref="userNameDialog" />
<div class="flex justify-end w-full">
<div v-if="PASSKEYS_ENABLED" class="flex justify-end w-full">
<router-link
:to="{ name: 'start' }"
class="block text-right text-md font-bold bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white mt-2 px-2 py-3 rounded-md"
>
See advanced options
See all your options first
</router-link>
</div>
</div>
@@ -354,7 +353,6 @@ import * as serverUtil from "../libs/endorserServer";
import { logger } from "../utils/logger";
import { GiveRecordWithContactInfo } from "../interfaces/give";
import { PlatformServiceFactory } from "@/services/PlatformServiceFactory";
import * as Package from "../../package.json";
interface Claim {
claim?: Claim; // For nested claims in Verifiable Credentials
@@ -445,13 +443,11 @@ export default class HomeView extends Vue {
AppString = AppString;
PASSKEYS_ENABLED = PASSKEYS_ENABLED;
package = Package;
activeDid = "";
allContacts: Array<Contact> = [];
allMyDids: Array<string> = [];
apiServer = "";
blockedContactDids: Array<string> = [];
feedData: GiveRecordWithContactInfo[] = [];
feedPreviousOldestId?: string;
feedLastViewedClaimId?: string;
@@ -523,6 +519,7 @@ export default class HomeView extends Vue {
// Retrieve DIDs with better error handling
try {
this.allMyDids = await retrieveAccountDids();
logConsoleAndDb(`[HomeView] Retrieved ${this.allMyDids.length} DIDs`);
} catch (error) {
logConsoleAndDb(`[HomeView] Failed to retrieve DIDs: ${error}`, true);
throw new Error(
@@ -555,6 +552,9 @@ export default class HomeView extends Vue {
if (USE_DEXIE_DB) {
settings = await retrieveSettingsForActiveAccount();
}
logConsoleAndDb(
`[HomeView] Retrieved settings for ${settings.activeDid || "no active DID"}`,
);
} catch (error) {
logConsoleAndDb(
`[HomeView] Failed to retrieve settings: ${error}`,
@@ -571,14 +571,25 @@ export default class HomeView extends Vue {
// Load contacts with graceful fallback
try {
this.loadContacts();
const platformService = PlatformServiceFactory.getInstance();
const dbContacts = await platformService.dbQuery(
"SELECT * FROM contacts",
);
this.allContacts = databaseUtil.mapQueryResultToValues(
dbContacts,
) as Contact[];
if (USE_DEXIE_DB) {
this.allContacts = await db.contacts.toArray();
}
logConsoleAndDb(
`[HomeView] Retrieved ${this.allContacts.length} contacts`,
);
} catch (error) {
logConsoleAndDb(
`[HomeView] Failed to retrieve contacts: ${error}`,
true,
);
this.allContacts = []; // Ensure we have a valid empty array
this.blockedContactDids = [];
this.$notify(
{
group: "alert",
@@ -630,6 +641,9 @@ export default class HomeView extends Vue {
});
}
this.isRegistered = true;
logConsoleAndDb(
`[HomeView] User ${this.activeDid} is now registered`,
);
}
} catch (error) {
logConsoleAndDb(
@@ -671,6 +685,11 @@ export default class HomeView extends Vue {
this.newOffersToUserHitLimit = offersToUser.hitLimit;
this.numNewOffersToUserProjects = offersToProjects.data.length;
this.newOffersToUserProjectsHitLimit = offersToProjects.hitLimit;
logConsoleAndDb(
`[HomeView] Retrieved ${this.numNewOffersToUser} user offers and ` +
`${this.numNewOffersToUserProjects} project offers`,
);
}
} catch (error) {
logConsoleAndDb(
@@ -742,9 +761,6 @@ export default class HomeView extends Vue {
if (USE_DEXIE_DB) {
this.allContacts = await db.contacts.toArray();
}
this.blockedContactDids = this.allContacts
.filter((c) => !c.iViewContent)
.map((c) => c.did);
}
/**
@@ -1012,7 +1028,6 @@ export default class HomeView extends Vue {
);
if (results.data.length > 0) {
endOfResults = false;
// gather any contacts that user has blocked from view
await this.processFeedResults(results.data);
await this.updateFeedLastViewedId(results.data);
}
@@ -1200,7 +1215,7 @@ export default class HomeView extends Vue {
}
/**
* Checks if record should be included based on filters & preferences
* Checks if record should be included based on filters
*
* @internal
* @callGraph
@@ -1226,10 +1241,6 @@ export default class HomeView extends Vue {
record: GiveSummaryRecord,
fulfillsPlan?: FulfillsPlan,
): boolean {
if (this.blockedContactDids.includes(record.issuerDid)) {
return false;
}
if (!this.isAnyFeedFilterOn) {
return true;
}

View File

@@ -51,7 +51,7 @@
<div v-if="numAccounts == 1" class="mt-4">
<input v-model="shouldErase" type="checkbox" class="mr-2" />
<label>Erase previous identifiers.</label>
<label>Erase the previous identifier.</label>
</div>
<div v-if="isNotProdServer()" class="mt-4 text-blue-500">
@@ -88,9 +88,17 @@ import { Router } from "vue-router";
import { AppString, NotificationIface, USE_DEXIE_DB } from "../constants/app";
import * as databaseUtil from "../db/databaseUtil";
import { retrieveSettingsForActiveAccount } from "../db/index";
import { DEFAULT_ROOT_DERIVATION_PATH } from "../libs/crypto";
import { retrieveAccountCount, importFromMnemonic } from "../libs/util";
import {
accountsDBPromise,
retrieveSettingsForActiveAccount,
} from "../db/index";
import {
DEFAULT_ROOT_DERIVATION_PATH,
deriveAddress,
newIdentifier,
} from "../libs/crypto";
import { retrieveAccountCount, saveNewIdentity } from "../libs/util";
import { PlatformServiceFactory } from "@/services/PlatformServiceFactory";
import { logger } from "../utils/logger";
@Component({
@@ -107,9 +115,12 @@ export default class ImportAccountView extends Vue {
$router!: Router;
apiServer = "";
address = "";
derivationPath = DEFAULT_ROOT_DERIVATION_PATH;
mnemonic = "";
numAccounts = 0;
privateHex = "";
publicHex = "";
showAdvanced = false;
shouldErase = false;
@@ -132,16 +143,33 @@ export default class ImportAccountView extends Vue {
}
public async fromMnemonic() {
const mne: string = this.mnemonic.trim().toLowerCase();
try {
await importFromMnemonic(
this.mnemonic,
[this.address, this.privateHex, this.publicHex] = deriveAddress(
mne,
this.derivationPath,
this.shouldErase,
);
const newId = newIdentifier(
this.address,
this.publicHex,
this.privateHex,
this.derivationPath,
);
const accountsDB = await accountsDBPromise;
if (this.shouldErase) {
const platformService = PlatformServiceFactory.getInstance();
await platformService.dbExec("DELETE FROM accounts");
if (USE_DEXIE_DB) {
await accountsDB.accounts.clear();
}
}
await saveNewIdentity(newId, mne, this.derivationPath);
this.$router.push({ name: "account" });
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (err: any) {
logger.error("Error importing from mnemonic:", err);
logger.error("Error saving mnemonic & updating settings:", err);
if (err == "Error: invalid mnemonic") {
this.$notify(
{

View File

@@ -128,10 +128,7 @@ export default class InviteOneAcceptView extends Vue {
}
// Extract JWT from route path
const jwt =
(this.$route.params.jwt as string) ||
(this.$route.query.jwt as string) ||
"";
const jwt = (this.$route.params.jwt as string) || "";
await this.processInvite(jwt, false);
this.checkingInvite = false;

View File

@@ -83,7 +83,7 @@
<span
v-else
class="text-center text-slate-500 cursor-pointer"
:title="invite.inviteIdentifier"
:title="inviteLink(invite.jwt)"
@click="
showInvite(
invite.inviteIdentifier,
@@ -241,7 +241,7 @@ export default class InviteOneView extends Vue {
}
inviteLink(jwt: string): string {
return APP_SERVER + "/deep-link/invite-one-accept/" + jwt;
return APP_SERVER + "/invite-one-accept/" + jwt;
}
copyInviteAndNotify(inviteId: string, jwt: string) {
@@ -324,7 +324,7 @@ export default class InviteOneView extends Vue {
);
await axios.post(
this.apiServer + "/api/userUtil/invite",
{ inviteJwt, notes, expiresAt },
{ inviteIdentifier, inviteJwt, notes, expiresAt },
{ headers },
);
const newInvite = {

View File

@@ -720,7 +720,7 @@ export default class OnboardMeetingView extends Vue {
onboardMeetingMembersLink(): string {
if (this.currentMeeting) {
return `${APP_SERVER}/deep-link/onboard-meeting-members/${this.currentMeeting?.groupId}?password=${encodeURIComponent(
return `${APP_SERVER}/onboard-meeting-members/${this.currentMeeting?.groupId}?password=${encodeURIComponent(
this.currentMeeting?.password || "",
)}`;
}

View File

@@ -27,12 +27,6 @@
>
<font-awesome icon="pen" class="text-sm text-blue-500 ml-2 mb-1" />
</button>
<button title="Copy Link to Project" @click="onCopyLinkClick()">
<font-awesome
icon="link"
class="text-sm text-slate-500 ml-2 mb-1"
/>
</button>
</h2>
</div>
</div>
@@ -61,11 +55,7 @@
<span class="truncate inline-block max-w-[calc(100%-2rem)]">
{{ issuerInfoObject?.displayName }}
</span>
<span
v-if="!serverUtil.isHiddenDid(issuer)"
class="inline-flex items-center"
>
<span class="inline-flex items-center">
<router-link
:to="{
path: '/did/' + encodeURIComponent(issuer),
@@ -123,7 +113,7 @@
class="fa-fw text-slate-400"
></font-awesome>
<a
:href="ensureScheme(url)"
:href="addScheme(url)"
target="_blank"
class="underline text-blue-500"
>
@@ -642,7 +632,7 @@ import TopMessage from "../components/TopMessage.vue";
import QuickNav from "../components/QuickNav.vue";
import EntityIcon from "../components/EntityIcon.vue";
import ProjectIcon from "../components/ProjectIcon.vue";
import { APP_SERVER, NotificationIface, USE_DEXIE_DB } from "../constants/app";
import { NotificationIface, USE_DEXIE_DB } from "../constants/app";
import * as databaseUtil from "../db/databaseUtil";
import {
db,
@@ -656,7 +646,6 @@ import { retrieveAccountDids } from "../libs/util";
import HiddenDidDialog from "../components/HiddenDidDialog.vue";
import { logger } from "../utils/logger";
import { PlatformServiceFactory } from "@/services/PlatformServiceFactory";
import { useClipboard } from "@vueuse/core";
/**
* Project View Component
* @author Matthew Raymer
@@ -853,28 +842,6 @@ export default class ProjectViewView extends Vue {
});
}
onCopyLinkClick() {
const shortestProjectId = this.projectId.startsWith(
serverUtil.ENDORSER_CH_HANDLE_PREFIX,
)
? this.projectId.substring(serverUtil.ENDORSER_CH_HANDLE_PREFIX.length)
: this.projectId;
const deepLink = `${APP_SERVER}/deep-link/project/${shortestProjectId}`;
useClipboard()
.copy(deepLink)
.then(() => {
this.$notify(
{
group: "alert",
type: "toast",
title: "Copied",
text: "A link to this project was copied to the clipboard.",
},
2000,
);
});
}
// Isn't there a better way to make this available to the template?
expandText() {
this.expanded = true;
@@ -1337,7 +1304,7 @@ export default class ProjectViewView extends Vue {
}
// return an HTTPS URL if it's not a global URL
ensureScheme(url: string) {
addScheme(url: string) {
if (!libsUtil.isGlobalUri(url)) {
return "https://" + url;
}
@@ -1498,13 +1465,7 @@ export default class ProjectViewView extends Vue {
}
openHiddenDidDialog() {
const shortestProjectId = this.projectId.startsWith(
serverUtil.ENDORSER_CH_HANDLE_PREFIX,
)
? this.projectId.substring(serverUtil.ENDORSER_CH_HANDLE_PREFIX.length)
: this.projectId;
(this.$refs.hiddenDidDialog as HiddenDidDialog).open(
"project/" + shortestProjectId,
"creator",
this.issuerVisibleToDids,
this.allContacts,

View File

@@ -144,7 +144,7 @@ export default class QuickActionBvcBeginView extends Vue {
"HUR",
BVC_MEETUPS_PROJECT_CLAIM_ID,
);
if (timeResult.success) {
if (timeResult.type === "success") {
timeSuccess = true;
} else {
logger.error("Error sending time:", timeResult);
@@ -153,7 +153,9 @@ export default class QuickActionBvcBeginView extends Vue {
group: "alert",
type: "danger",
title: "Error",
text: timeResult?.error || "There was an error sending the time.",
text:
timeResult?.error?.userMessage ||
"There was an error sending the time.",
},
5000,
);
@@ -169,7 +171,7 @@ export default class QuickActionBvcBeginView extends Vue {
apiServer,
axios,
);
if (attendResult.success) {
if (attendResult.type === "success") {
attendedSuccess = true;
} else {
logger.error("Error sending attendance:", attendResult);
@@ -179,7 +181,7 @@ export default class QuickActionBvcBeginView extends Vue {
type: "danger",
title: "Error",
text:
attendResult?.error ||
attendResult?.error?.userMessage ||
"There was an error sending the attendance.",
},
5000,

View File

@@ -298,25 +298,24 @@ export default class QuickActionBvcBeginView extends Vue {
}
// in parallel, make a confirmation for each selected claim and send them all to the server
const confirmResults: PromiseSettledResult<CreateAndSubmitClaimResult>[] =
await Promise.allSettled(
this.claimsToConfirmSelected.map(async (jwtId) => {
const record = this.claimsToConfirm.find(
(claim) => claim.id === jwtId,
);
if (!record) {
return { success: false, error: "Record not found." };
}
return createAndSubmitConfirmation(
this.activeDid,
record.claim as GenericVerifiableCredential,
record.id,
record.handleId,
this.apiServer,
axios,
);
}),
);
const confirmResults: PromiseSettledResult<CreateAndSubmitClaimResult>[] = await Promise.allSettled(
this.claimsToConfirmSelected.map(async (jwtId) => {
const record = this.claimsToConfirm.find(
(claim) => claim.id === jwtId,
);
if (!record) {
return { success: false, error: "Record not found." };
}
return createAndSubmitConfirmation(
this.activeDid,
record.claim as GenericVerifiableCredential,
record.id,
record.handleId,
this.apiServer,
axios,
);
}),
);
// check for any rejected confirmations
const confirmsSucceeded = confirmResults.filter(
// 'fulfilled' is the status in a successful PromiseFulfilledResult

View File

@@ -105,7 +105,7 @@ export default class ShareMyContactInfoView extends Vue {
group: "alert",
type: "info",
title: "Copied",
text: "Your contact info was copied to the clipboard. Have them click on it, or paste it in the box on their 'Contacts' screen.",
text: "Your contact info was copied to the clipboard. Have them paste it in the box on their 'Contacts' screen.",
},
5000,
);

View File

@@ -82,18 +82,6 @@
Derive new address from existing seed
</a>
</div>
<!-- Database Migration Section -->
<div class="mt-8 pt-6 border-t border-gray-200">
<div class="flex justify-center">
<router-link
:to="{ name: 'database-migration' }"
class="block w-fit text-center text-md bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-4 py-2 rounded-md"
>
Migrate My Old Data
</router-link>
</div>
</div>
</div>
</div>
</section>

View File

@@ -182,15 +182,6 @@
>
Accounts
</button>
<button
class="text-sm text-blue-600 hover:text-blue-800 underline"
@click="
sqlQuery = 'SELECT * FROM contacts;';
executeSql();
"
>
Contacts
</button>
<button
class="text-sm text-blue-600 hover:text-blue-800 underline"
@click="

View File

@@ -16,7 +16,6 @@
</button>
Individual Profile
</h1>
<div class="text-sm text-center text-slate-500"></div>
</div>
<!-- Loading Animation -->
@@ -33,12 +32,6 @@
<div class="text-sm">
<font-awesome icon="user" class="fa-fw text-slate-400"></font-awesome>
{{ didInfo(profile.issuerDid, activeDid, allMyDids, allContacts) }}
<button title="Copy Link to Profile" @click="onCopyLinkClick()">
<font-awesome
icon="link"
class="text-sm text-slate-500 ml-2 mb-1"
/>
</button>
</div>
<p v-if="profile.description" class="mt-4 text-slate-600">
{{ profile.description }}
@@ -107,7 +100,6 @@ import { Router, RouteLocationNormalizedLoaded } from "vue-router";
import QuickNav from "../components/QuickNav.vue";
import TopMessage from "../components/TopMessage.vue";
import {
APP_SERVER,
DEFAULT_PARTNER_API_SERVER,
NotificationIface,
USE_DEXIE_DB,
@@ -121,7 +113,6 @@ import { retrieveAccountDids } from "../libs/util";
import { logger } from "../utils/logger";
import { PlatformServiceFactory } from "@/services/PlatformServiceFactory";
import { Settings } from "@/db/tables/settings";
import { useClipboard } from "@vueuse/core";
@Component({
components: {
LMap,
@@ -195,10 +186,6 @@ export default class UserProfileView extends Vue {
if (response.status === 200) {
const result = await response.json();
this.profile = result.data;
if (this.profile && this.profile.rowId !== profileId) {
// currently the server returns "rowid" with lowercase "i"; remove when that's fixed
this.profile.rowId = profileId;
}
} else {
throw new Error("Failed to load profile");
}
@@ -217,22 +204,5 @@ export default class UserProfileView extends Vue {
this.isLoading = false;
}
}
onCopyLinkClick() {
const deepLink = `${APP_SERVER}/deep-link/user-profile/${this.profile?.rowId}`;
useClipboard()
.copy(deepLink)
.then(() => {
this.$notify(
{
group: "alert",
type: "toast",
title: "Copied",
text: "A link to this profile was copied to the clipboard.",
},
2000,
);
});
}
}
</script>

View File

@@ -6,9 +6,7 @@ import path from "path";
import { fileURLToPath } from 'url';
// Load environment variables
console.log('NODE_ENV:', process.env.NODE_ENV)
dotenv.config({ path: `.env.${process.env.NODE_ENV}` })
dotenv.config();
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);