Browse Source

Merge branch 'sql-absurd-sql-back'

master
Trent Larson 22 hours ago
parent
commit
dbeb1c6b4b
  1. 153
      .cursor/rules/absurd-sql.mdc
  2. 2
      .cursor/rules/crowd-funder-for-time-pwa/docs/camera-implementation.mdc
  3. 1
      .env.development
  4. 6
      .env.example
  5. 1
      .env.production
  6. 1
      .env.staging
  7. 6
      .eslintrc.js
  8. 17
      BUILDING.md
  9. 7
      CHANGELOG.md
  10. 4
      android/app/build.gradle
  11. 1
      android/app/capacitor.build.gradle
  12. 35
      android/app/src/main/assets/capacitor.config.json
  13. 4
      android/app/src/main/assets/capacitor.plugins.json
  14. 10
      android/app/src/main/java/app/timesafari/MainActivity.java
  15. 5
      android/app/src/main/java/timesafari/app/MainActivity.java
  16. 3
      android/capacitor.settings.gradle
  17. 35
      capacitor.config.json
  18. 399
      doc/dexie-to-sqlite-mapping.md
  19. 95
      doc/migration-to-wa-sqlite.md
  20. 339
      doc/secure-storage-implementation.md
  21. 329
      doc/storage-implementation-checklist.md
  22. 1310
      docs/secure-storage-implementation.md
  23. 74
      ios/App/App.xcodeproj/project.pbxproj
  24. 5
      ios/App/App/AppDelegate.swift
  25. 6
      ios/App/Podfile
  26. 20
      ios/App/Podfile.lock
  27. 29
      main.js
  28. 4773
      package-lock.json
  29. 14
      package.json
  30. 1
      pkgx.yaml
  31. 344
      scripts/build-electron.js
  32. 15
      scripts/copy-wasm.js
  33. 13
      src/App.vue
  34. 20
      src/components/ActivityListItem.vue
  35. 57
      src/components/DataExportSection.vue
  36. 55
      src/components/FeedFilters.vue
  37. 20
      src/components/GiftedDialog.vue
  38. 36
      src/components/GiftedPrompts.vue
  39. 13
      src/components/ImageMethodDialog.vue
  40. 38
      src/components/MembersList.vue
  41. 8
      src/components/OfferDialog.vue
  42. 46
      src/components/OnboardingDialog.vue
  43. 14
      src/components/PhotoDialog.vue
  44. 12
      src/components/PushNotificationPermission.vue
  45. 8
      src/components/TopMessage.vue
  46. 22
      src/components/UserNameDialog.vue
  47. 7
      src/components/World/components/objects/landmarks.js
  48. 5
      src/constants/app.ts
  49. 138
      src/db-sql/migration.ts
  50. 330
      src/db/databaseUtil.ts
  51. 46
      src/db/index.ts
  52. 6
      src/db/tables/accounts.ts
  53. 30
      src/electron/main.ts
  54. 31
      src/electron/preload.js
  55. 59
      src/interfaces/absurd-sql.d.ts
  56. 65
      src/interfaces/claims.ts
  57. 146
      src/interfaces/common.ts
  58. 15
      src/interfaces/database.ts
  59. 40
      src/interfaces/index.ts
  60. 116
      src/libs/crypto/index.ts
  61. 19
      src/libs/crypto/vc/index.ts
  62. 157
      src/libs/crypto/vc/passkeyDidPeer.ts
  63. 481
      src/libs/endorserServer.ts
  64. 437
      src/libs/util.ts
  65. 2
      src/main.capacitor.ts
  66. 6
      src/main.common.ts
  67. 12
      src/main.electron.ts
  68. 215
      src/main.ts
  69. 34
      src/main.web.ts
  70. 6
      src/registerSQLWorker.js
  71. 20
      src/registerServiceWorker.ts
  72. 25
      src/router/index.ts
  73. 29
      src/services/AbsurdSqlDatabaseService.d.ts
  74. 231
      src/services/AbsurdSqlDatabaseService.ts
  75. 24
      src/services/PlatformService.ts
  76. 41
      src/services/QRScanner/CapacitorQRScanner.ts
  77. 29
      src/services/QRScanner/WebInlineQRScanner.ts
  78. 19
      src/services/deepLinks.ts
  79. 60
      src/services/migrationService.ts
  80. 251
      src/services/platforms/CapacitorPlatformService.ts
  81. 255
      src/services/platforms/ElectronPlatformService.ts
  82. 23
      src/services/platforms/PyWebViewPlatformService.ts
  83. 31
      src/services/platforms/WebPlatformService.ts
  84. 8
      src/test/index.ts
  85. 45
      src/types/absurd-sql.d.ts
  86. 36
      src/types/global.d.ts
  87. 67
      src/types/modules.d.ts
  88. 57
      src/types/sql.js.d.ts
  89. 2
      src/utils/empty-module.js
  90. 12
      src/utils/logger.ts
  91. 17
      src/utils/node-modules/crypto.js
  92. 18
      src/utils/node-modules/fs.js
  93. 13
      src/utils/node-modules/path.js
  94. 339
      src/views/AccountViewView.vue
  95. 11
      src/views/ClaimAddRawView.vue
  96. 23
      src/views/ClaimCertificateView.vue
  97. 23
      src/views/ClaimReportCertificateView.vue
  98. 32
      src/views/ClaimView.vue
  99. 21
      src/views/ConfirmGiftView.vue
  100. 22
      src/views/ContactAmountsView.vue

153
.cursor/rules/absurd-sql.mdc

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

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

@ -1,7 +1,7 @@
--- ---
description: description:
globs: globs:
alwaysApply: true alwaysApply: false
--- ---
# Camera Implementation Documentation # Camera Implementation Documentation

1
.env.development

@ -9,4 +9,5 @@ VITE_DEFAULT_ENDORSER_API_SERVER=http://localhost:3000
# Using shared server by default to ease setup, which works for shared test users. # Using shared server by default to ease setup, which works for shared test users.
VITE_DEFAULT_IMAGE_API_SERVER=https://test-image-api.timesafari.app VITE_DEFAULT_IMAGE_API_SERVER=https://test-image-api.timesafari.app
VITE_DEFAULT_PARTNER_API_SERVER=http://localhost:3000 VITE_DEFAULT_PARTNER_API_SERVER=http://localhost:3000
#VITE_DEFAULT_PUSH_SERVER... can't be set up with localhost domain
VITE_PASSKEYS_ENABLED=true VITE_PASSKEYS_ENABLED=true

6
.env.example

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

1
.env.production

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

1
.env.staging

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

6
.eslintrc.js

@ -4,6 +4,12 @@ module.exports = {
node: true, node: true,
es2022: true, es2022: true,
}, },
ignorePatterns: [
'node_modules/',
'dist/',
'dist-electron/',
'*.d.ts'
],
extends: [ extends: [
"plugin:vue/vue3-recommended", "plugin:vue/vue3-recommended",
"eslint:recommended", "eslint:recommended",

17
BUILDING.md

@ -84,7 +84,7 @@ Install dependencies:
* For test, build the app (because test server is not yet set up to build): * For test, build the app (because test server is not yet set up to build):
```bash ```bash
TIME_SAFARI_APP_TITLE="TimeSafari_Test" VITE_APP_SERVER=https://test.timesafari.app VITE_BVC_MEETUPS_PROJECT_CLAIM_ID=https://endorser.ch/entity/01HWE8FWHQ1YGP7GFZYYPS272F VITE_DEFAULT_ENDORSER_API_SERVER=https://test-api.endorser.ch VITE_DEFAULT_IMAGE_API_SERVER=https://test-image-api.timesafari.app VITE_DEFAULT_PARTNER_API_SERVER=https://test-partner-api.endorser.ch VITE_PASSKEYS_ENABLED=true npm run build TIME_SAFARI_APP_TITLE="TimeSafari_Test" VITE_APP_SERVER=https://test.timesafari.app VITE_BVC_MEETUPS_PROJECT_CLAIM_ID=https://endorser.ch/entity/01HWE8FWHQ1YGP7GFZYYPS272F VITE_DEFAULT_ENDORSER_API_SERVER=https://test-api.endorser.ch VITE_DEFAULT_IMAGE_API_SERVER=https://test-image-api.timesafari.app VITE_DEFAULT_PARTNER_API_SERVER=https://test-partner-api.endorser.ch VITE_DEFAULT_PUSH_SERVER=https://test.timesafari.app VITE_PASSKEYS_ENABLED=true npm run build
``` ```
... and transfer to the test server: ... and transfer to the test server:
@ -241,7 +241,9 @@ docker run -d \
1. Build the electron app in production mode: 1. Build the electron app in production mode:
```bash ```bash
npm run build:electron-prod npm run build:web
npm run build:electron
npm run electron:build-mac
``` ```
2. Package the Electron app for macOS: 2. Package the Electron app for macOS:
@ -355,10 +357,10 @@ Prerequisites: macOS with Xcode installed
``` ```
cd ios/App cd ios/App
xcrun agvtool new-version 15 xcrun agvtool new-version 21
# Unfortunately this edits Info.plist directly. # Unfortunately this edits Info.plist directly.
#xcrun agvtool new-marketing-version 0.4.5 #xcrun agvtool new-marketing-version 0.4.5
cat App.xcodeproj/project.pbxproj | sed "s/MARKETING_VERSION = .*;/MARKETING_VERSION = 0.4.5;/g" > temp cat App.xcodeproj/project.pbxproj | sed "s/MARKETING_VERSION = .*;/MARKETING_VERSION = 0.4.7;/g" > temp
mv temp App.xcodeproj/project.pbxproj mv temp App.xcodeproj/project.pbxproj
cd - cd -
``` ```
@ -371,15 +373,18 @@ Prerequisites: macOS with Xcode installed
6. Use Xcode to build and run on simulator or device. 6. Use Xcode to build and run on simulator or device.
* Select Product -> Destination with some Simulator version. Then click the run arrow.
7. Release 7. Release
* Under "General" renamed a bunch of things to "Time Safari" * Under "General" renamed a bunch of things to "Time Safari"
* Choose Product -> Destination -> Build Any iOS * Choose Product -> Destination -> Any iOS Device
* Choose Product -> Archive * Choose Product -> Archive
* This will trigger a build and take time, needing user's "login" keychain password which is just their login password, repeatedly. * This will trigger a build and take time, needing user's "login" keychain password (user's login password), repeatedly.
* If it fails with `building for 'iOS', but linking in dylib (.../.pkgx/zlib.net/v1.3.0/lib/libz.1.3.dylib) built for 'macOS'` then run XCode outside that terminal (ie. not with `npx cap open ios`). * If it fails with `building for 'iOS', but linking in dylib (.../.pkgx/zlib.net/v1.3.0/lib/libz.1.3.dylib) built for 'macOS'` then run XCode outside that terminal (ie. not with `npx cap open ios`).
* Click Distribute -> App Store Connect * Click Distribute -> App Store Connect
* In AppStoreConnect, add the build to the distribution: remove the current build with the "-" when you hover over it, then "Add Build" with the new build. * In AppStoreConnect, add the build to the distribution: remove the current build with the "-" when you hover over it, then "Add Build" with the new build.
* May have to go to App Review, click Submission, then hover over the build and click "-".
* It can take 15 minutes for the build to show up in the list of builds. * It can take 15 minutes for the build to show up in the list of builds.
* You'll probably have to "Manage" something about encryption, disallowed in France. * You'll probably have to "Manage" something about encryption, disallowed in France.
* Then "Save" and "Add to Review" and "Resubmit to App Review". * Then "Save" and "Add to Review" and "Resubmit to App Review".

7
CHANGELOG.md

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

4
android/app/build.gradle

@ -31,7 +31,7 @@ android {
applicationId "app.timesafari.app" applicationId "app.timesafari.app"
minSdkVersion rootProject.ext.minSdkVersion minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 18 versionCode 19
versionName "0.4.7" versionName "0.4.7"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions { aaptOptions {
@ -91,6 +91,8 @@ dependencies {
implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion" implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion"
implementation "androidx.core:core-splashscreen:$coreSplashScreenVersion" implementation "androidx.core:core-splashscreen:$coreSplashScreenVersion"
implementation project(':capacitor-android') implementation project(':capacitor-android')
implementation project(':capacitor-community-sqlite')
implementation "androidx.biometric:biometric:1.2.0-alpha05"
testImplementation "junit:junit:$junitVersion" testImplementation "junit:junit:$junitVersion"
androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion" androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion"
androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion" androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion"

1
android/app/capacitor.build.gradle

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

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

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

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

@ -1,4 +1,8 @@
[ [
{
"pkg": "@capacitor-community/sqlite",
"classpath": "com.getcapacitor.community.database.sqlite.CapacitorSQLitePlugin"
},
{ {
"pkg": "@capacitor-mlkit/barcode-scanning", "pkg": "@capacitor-mlkit/barcode-scanning",
"classpath": "io.capawesome.capacitorjs.plugins.mlkit.barcodescanning.BarcodeScannerPlugin" "classpath": "io.capawesome.capacitorjs.plugins.mlkit.barcodescanning.BarcodeScannerPlugin"

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

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

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

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

3
android/capacitor.settings.gradle

@ -2,6 +2,9 @@
include ':capacitor-android' include ':capacitor-android'
project(':capacitor-android').projectDir = new File('../node_modules/@capacitor/android/capacitor') project(':capacitor-android').projectDir = new File('../node_modules/@capacitor/android/capacitor')
include ':capacitor-community-sqlite'
project(':capacitor-community-sqlite').projectDir = new File('../node_modules/@capacitor-community/sqlite/android')
include ':capacitor-mlkit-barcode-scanning' include ':capacitor-mlkit-barcode-scanning'
project(':capacitor-mlkit-barcode-scanning').projectDir = new File('../node_modules/@capacitor-mlkit/barcode-scanning/android') project(':capacitor-mlkit-barcode-scanning').projectDir = new File('../node_modules/@capacitor-mlkit/barcode-scanning/android')

35
capacitor.config.json

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

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

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

95
docs/migration-to-wa-sqlite.md → doc/migration-to-wa-sqlite.md

@ -1,8 +1,8 @@
# Migration Guide: Dexie to wa-sqlite # Migration Guide: Dexie to absurd-sql
## Overview ## Overview
This document outlines the migration process from Dexie.js to wa-sqlite for the TimeSafari app's storage implementation. The migration aims to provide a consistent SQLite-based storage solution across all platforms while maintaining data integrity and ensuring a smooth transition for users. This document outlines the migration process from Dexie.js to absurd-sql for the TimeSafari app's storage implementation. The migration aims to provide a consistent SQLite-based storage solution across all platforms while maintaining data integrity and ensuring a smooth transition for users.
## Migration Goals ## Migration Goals
@ -43,12 +43,20 @@ This document outlines the migration process from Dexie.js to wa-sqlite for the
} }
``` ```
2. **Storage Requirements** 2. **Dependencies**
```json
{
"@jlongster/sql.js": "^1.8.0",
"absurd-sql": "^1.8.0"
}
```
3. **Storage Requirements**
- Sufficient IndexedDB quota - Sufficient IndexedDB quota
- Available disk space for SQLite - Available disk space for SQLite
- Backup storage space - Backup storage space
3. **Platform Support** 4. **Platform Support**
- Web: Modern browser with IndexedDB support - Web: Modern browser with IndexedDB support
- iOS: iOS 13+ with SQLite support - iOS: iOS 13+ with SQLite support
- Android: Android 5+ with SQLite support - Android: Android 5+ with SQLite support
@ -60,9 +68,15 @@ This document outlines the migration process from Dexie.js to wa-sqlite for the
```typescript ```typescript
// src/services/storage/migration/MigrationService.ts // src/services/storage/migration/MigrationService.ts
import initSqlJs from '@jlongster/sql.js';
import { SQLiteFS } from 'absurd-sql';
import IndexedDBBackend from 'absurd-sql/dist/indexeddb-backend';
export class MigrationService { export class MigrationService {
private static instance: MigrationService; private static instance: MigrationService;
private backup: MigrationBackup | null = null; private backup: MigrationBackup | null = null;
private sql: any = null;
private db: any = null;
async prepare(): Promise<void> { async prepare(): Promise<void> {
try { try {
@ -75,8 +89,8 @@ export class MigrationService {
// 3. Verify backup integrity // 3. Verify backup integrity
await this.verifyBackup(); await this.verifyBackup();
// 4. Initialize wa-sqlite // 4. Initialize absurd-sql
await this.initializeWaSqlite(); await this.initializeAbsurdSql();
} catch (error) { } catch (error) {
throw new StorageError( throw new StorageError(
'Migration preparation failed', 'Migration preparation failed',
@ -86,6 +100,42 @@ export class MigrationService {
} }
} }
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> { private async checkPrerequisites(): Promise<void> {
// Check IndexedDB availability // Check IndexedDB availability
if (!window.indexedDB) { if (!window.indexedDB) {
@ -160,12 +210,11 @@ export class DataMigration {
} }
private async migrateAccounts(accounts: Account[]): Promise<void> { private async migrateAccounts(accounts: Account[]): Promise<void> {
const db = await this.getWaSqliteConnection();
// Use transaction for atomicity // Use transaction for atomicity
await db.transaction(async (tx) => { await this.db.exec('BEGIN TRANSACTION;');
try {
for (const account of accounts) { for (const account of accounts) {
await tx.execute(` await this.db.run(`
INSERT INTO accounts (did, public_key_hex, created_at, updated_at) INSERT INTO accounts (did, public_key_hex, created_at, updated_at)
VALUES (?, ?, ?, ?) VALUES (?, ?, ?, ?)
`, [ `, [
@ -175,16 +224,18 @@ export class DataMigration {
account.updatedAt account.updatedAt
]); ]);
} }
}); await this.db.exec('COMMIT;');
} catch (error) {
await this.db.exec('ROLLBACK;');
throw error;
}
} }
private async verifyMigration(backup: MigrationBackup): Promise<void> { private async verifyMigration(backup: MigrationBackup): Promise<void> {
const db = await this.getWaSqliteConnection();
// Verify account count // Verify account count
const accountCount = await db.selectValue( const result = await this.db.exec('SELECT COUNT(*) as count FROM accounts');
'SELECT COUNT(*) FROM accounts' const accountCount = result[0].values[0][0];
);
if (accountCount !== backup.accounts.length) { if (accountCount !== backup.accounts.length) {
throw new StorageError( throw new StorageError(
'Account count mismatch', 'Account count mismatch',
@ -214,8 +265,8 @@ export class RollbackService {
// 3. Verify restoration // 3. Verify restoration
await this.verifyRestoration(backup); await this.verifyRestoration(backup);
// 4. Clean up wa-sqlite // 4. Clean up absurd-sql
await this.cleanupWaSqlite(); await this.cleanupAbsurdSql();
} catch (error) { } catch (error) {
throw new StorageError( throw new StorageError(
'Rollback failed', 'Rollback failed',
@ -371,6 +422,14 @@ button:hover {
```typescript ```typescript
// src/services/storage/migration/__tests__/MigrationService.spec.ts // src/services/storage/migration/__tests__/MigrationService.spec.ts
describe('MigrationService', () => { describe('MigrationService', () => {
it('should initialize absurd-sql correctly', async () => {
const service = MigrationService.getInstance();
await service.initializeAbsurdSql();
expect(service.isInitialized()).toBe(true);
expect(service.getDatabase()).toBeDefined();
});
it('should create valid backup', async () => { it('should create valid backup', async () => {
const service = MigrationService.getInstance(); const service = MigrationService.getInstance();
const backup = await service.createBackup(); const backup = await service.createBackup();

339
doc/secure-storage-implementation.md

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

329
doc/storage-implementation-checklist.md

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

1310
docs/secure-storage-implementation.md

File diff suppressed because it is too large

74
ios/App/App.xcodeproj/project.pbxproj

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

5
ios/App/App/AppDelegate.swift

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

6
ios/App/Podfile

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

20
ios/App/Podfile.lock

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

29
main.js

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

4773
package-lock.json

File diff suppressed because it is too large

14
package.json

@ -1,6 +1,6 @@
{ {
"name": "timesafari", "name": "timesafari",
"version": "0.4.6", "version": "0.4.7",
"description": "Time Safari Application", "description": "Time Safari Application",
"author": { "author": {
"name": "Time Safari Team" "name": "Time Safari Team"
@ -11,7 +11,7 @@
"build": "VITE_GIT_HASH=`git log -1 --pretty=format:%h` vite build --config vite.config.mts", "build": "VITE_GIT_HASH=`git log -1 --pretty=format:%h` vite build --config vite.config.mts",
"lint": "eslint --ext .js,.ts,.vue --ignore-path .gitignore src", "lint": "eslint --ext .js,.ts,.vue --ignore-path .gitignore src",
"lint-fix": "eslint --ext .js,.ts,.vue --ignore-path .gitignore --fix src", "lint-fix": "eslint --ext .js,.ts,.vue --ignore-path .gitignore --fix src",
"prebuild": "eslint --ext .js,.ts,.vue --ignore-path .gitignore src && node sw_combine.js", "prebuild": "eslint --ext .js,.ts,.vue --ignore-path .gitignore src && node sw_combine.js && node scripts/copy-wasm.js",
"test:all": "npm run test:prerequisites && npm run build && npm run test:web && npm run test:mobile", "test:all": "npm run test:prerequisites && npm run build && npm run test:web && npm run test:mobile",
"test:prerequisites": "node scripts/check-prerequisites.js", "test:prerequisites": "node scripts/check-prerequisites.js",
"test:web": "npx playwright test -c playwright.config-local.ts --trace on", "test:web": "npx playwright test -c playwright.config-local.ts --trace on",
@ -46,6 +46,7 @@
"electron:build-mac-universal": "npm run build:electron-prod && electron-builder --mac --universal" "electron:build-mac-universal": "npm run build:electron-prod && electron-builder --mac --universal"
}, },
"dependencies": { "dependencies": {
"@capacitor-community/sqlite": "6.0.2",
"@capacitor-mlkit/barcode-scanning": "^6.0.0", "@capacitor-mlkit/barcode-scanning": "^6.0.0",
"@capacitor/android": "^6.2.0", "@capacitor/android": "^6.2.0",
"@capacitor/app": "^6.0.0", "@capacitor/app": "^6.0.0",
@ -63,6 +64,7 @@
"@fortawesome/fontawesome-svg-core": "^6.5.1", "@fortawesome/fontawesome-svg-core": "^6.5.1",
"@fortawesome/free-solid-svg-icons": "^6.5.1", "@fortawesome/free-solid-svg-icons": "^6.5.1",
"@fortawesome/vue-fontawesome": "^3.0.6", "@fortawesome/vue-fontawesome": "^3.0.6",
"@jlongster/sql.js": "^1.6.7",
"@peculiar/asn1-ecc": "^2.3.8", "@peculiar/asn1-ecc": "^2.3.8",
"@peculiar/asn1-schema": "^2.3.8", "@peculiar/asn1-schema": "^2.3.8",
"@pvermeer/dexie-encrypted-addon": "^3.0.0", "@pvermeer/dexie-encrypted-addon": "^3.0.0",
@ -81,6 +83,7 @@
"@vue-leaflet/vue-leaflet": "^0.10.1", "@vue-leaflet/vue-leaflet": "^0.10.1",
"@vueuse/core": "^12.3.0", "@vueuse/core": "^12.3.0",
"@zxing/text-encoding": "^0.9.0", "@zxing/text-encoding": "^0.9.0",
"absurd-sql": "^0.0.54",
"asn1-ber": "^1.2.2", "asn1-ber": "^1.2.2",
"axios": "^1.6.8", "axios": "^1.6.8",
"cbor-x": "^1.5.9", "cbor-x": "^1.5.9",
@ -144,7 +147,9 @@
"@vitejs/plugin-vue": "^5.2.1", "@vitejs/plugin-vue": "^5.2.1",
"@vue/eslint-config-typescript": "^11.0.3", "@vue/eslint-config-typescript": "^11.0.3",
"autoprefixer": "^10.4.19", "autoprefixer": "^10.4.19",
"browserify-fs": "^1.0.0",
"concurrently": "^8.2.2", "concurrently": "^8.2.2",
"crypto-browserify": "^3.12.1",
"electron": "^33.2.1", "electron": "^33.2.1",
"electron-builder": "^25.1.8", "electron-builder": "^25.1.8",
"eslint": "^8.57.0", "eslint": "^8.57.0",
@ -155,13 +160,14 @@
"markdownlint": "^0.37.4", "markdownlint": "^0.37.4",
"markdownlint-cli": "^0.44.0", "markdownlint-cli": "^0.44.0",
"npm-check-updates": "^17.1.13", "npm-check-updates": "^17.1.13",
"path-browserify": "^1.0.1",
"postcss": "^8.4.38", "postcss": "^8.4.38",
"prettier": "^3.2.5", "prettier": "^3.2.5",
"rimraf": "^6.0.1", "rimraf": "^6.0.1",
"tailwindcss": "^3.4.1", "tailwindcss": "^3.4.1",
"typescript": "~5.2.2", "typescript": "~5.2.2",
"vite": "^5.2.0", "vite": "^5.2.0",
"vite-plugin-pwa": "^0.19.8" "vite-plugin-pwa": "^1.0.0"
}, },
"main": "./dist-electron/main.js", "main": "./dist-electron/main.js",
"build": { "build": {
@ -176,7 +182,7 @@
], ],
"extraResources": [ "extraResources": [
{ {
"from": "dist", "from": "dist-electron/www",
"to": "www" "to": "www"
} }
], ],

1
pkgx.yaml

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

344
scripts/build-electron.js

@ -1,10 +1,9 @@
const fs = require('fs'); const fs = require('fs');
const path = require('path'); const path = require('path');
console.log('Starting electron build process...'); console.log('Starting electron build process...');
// Copy web files // Define paths
const webDistPath = path.join(__dirname, '..', 'dist');
const electronDistPath = path.join(__dirname, '..', 'dist-electron'); const electronDistPath = path.join(__dirname, '..', 'dist-electron');
const wwwPath = path.join(electronDistPath, 'www'); const wwwPath = path.join(electronDistPath, 'www');
@ -13,231 +12,154 @@ if (!fs.existsSync(wwwPath)) {
fs.mkdirSync(wwwPath, { recursive: true }); fs.mkdirSync(wwwPath, { recursive: true });
} }
// Copy web files to www directory // Create a platform-specific index.html for Electron
fs.cpSync(webDistPath, wwwPath, { recursive: true }); const initialIndexContent = `<!DOCTYPE html>
<html lang="">
// Fix asset paths in index.html <head>
const indexPath = path.join(wwwPath, 'index.html'); <meta charset="utf-8">
let indexContent = fs.readFileSync(indexPath, 'utf8'); <meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width,initial-scale=1.0,viewport-fit=cover">
// Fix asset paths <link rel="icon" href="/favicon.ico">
indexContent = indexContent <title>TimeSafari</title>
.replace(/\/assets\//g, './assets/') </head>
.replace(/href="\//g, 'href="./') <body>
.replace(/src="\//g, 'src="./'); <noscript>
<strong>We're sorry but TimeSafari doesn't work properly without JavaScript enabled. Please enable it to continue.</strong>
fs.writeFileSync(indexPath, indexContent); </noscript>
<div id="app"></div>
// Check for remaining /assets/ paths <script type="module">
console.log('After path fixing, checking for remaining /assets/ paths:', indexContent.includes('/assets/')); // Force electron platform
console.log('Sample of fixed content:', indexContent.substring(0, 500)); window.process = { env: { VITE_PLATFORM: 'electron' } };
import('./src/main.electron.ts');
console.log('Copied and fixed web files in:', wwwPath); </script>
</body>
// Copy main process files </html>`;
console.log('Copying main process files...');
// Write the Electron-specific index.html
// Create the main process file with inlined logger fs.writeFileSync(path.join(wwwPath, 'index.html'), initialIndexContent);
const mainContent = `const { app, BrowserWindow } = require("electron");
const path = require("path"); // Copy only necessary assets from web build
const fs = require("fs"); const webDistPath = path.join(__dirname, '..', 'dist');
if (fs.existsSync(webDistPath)) {
// Inline logger implementation // Copy assets directory
const logger = { const assetsSrc = path.join(webDistPath, 'assets');
log: (...args) => console.log(...args), const assetsDest = path.join(wwwPath, 'assets');
error: (...args) => console.error(...args), if (fs.existsSync(assetsSrc)) {
info: (...args) => console.info(...args), fs.cpSync(assetsSrc, assetsDest, { recursive: true });
warn: (...args) => console.warn(...args),
debug: (...args) => console.debug(...args),
};
// Check if running in dev mode
const isDev = process.argv.includes("--inspect");
function createWindow() {
// Add before createWindow function
const preloadPath = path.join(__dirname, "preload.js");
logger.log("Checking preload path:", preloadPath);
logger.log("Preload exists:", fs.existsSync(preloadPath));
// Create the browser window.
const mainWindow = new BrowserWindow({
width: 1200,
height: 800,
webPreferences: {
nodeIntegration: false,
contextIsolation: true,
webSecurity: true,
allowRunningInsecureContent: false,
preload: path.join(__dirname, "preload.js"),
},
});
// Always open DevTools for now
mainWindow.webContents.openDevTools();
// Intercept requests to fix asset paths
mainWindow.webContents.session.webRequest.onBeforeRequest(
{
urls: [
"file://*/*/assets/*",
"file://*/assets/*",
"file:///assets/*", // Catch absolute paths
"<all_urls>", // Catch all URLs as a fallback
],
},
(details, callback) => {
let url = details.url;
// Handle paths that don't start with file://
if (!url.startsWith("file://") && url.includes("/assets/")) {
url = \`file://\${path.join(__dirname, "www", url)}\`;
}
// Handle absolute paths starting with /assets/
if (url.includes("/assets/") && !url.includes("/www/assets/")) {
const baseDir = url.includes("dist-electron")
? url.substring(
0,
url.indexOf("/dist-electron") + "/dist-electron".length,
)
: \`file://\${__dirname}\`;
const assetPath = url.split("/assets/")[1];
const newUrl = \`\${baseDir}/www/assets/\${assetPath}\`;
callback({ redirectURL: newUrl });
return;
}
callback({}); // No redirect for other URLs
},
);
if (isDev) {
// Debug info
logger.log("Debug Info:");
logger.log("Running in dev mode:", isDev);
logger.log("App is packaged:", app.isPackaged);
logger.log("Process resource path:", process.resourcesPath);
logger.log("App path:", app.getAppPath());
logger.log("__dirname:", __dirname);
logger.log("process.cwd():", process.cwd());
}
const indexPath = path.join(__dirname, "www", "index.html");
if (isDev) {
logger.log("Loading index from:", indexPath);
logger.log("www path:", path.join(__dirname, "www"));
logger.log("www assets path:", path.join(__dirname, "www", "assets"));
} }
if (!fs.existsSync(indexPath)) { // Copy favicon
logger.error(\`Index file not found at: \${indexPath}\`); const faviconSrc = path.join(webDistPath, 'favicon.ico');
throw new Error("Index file not found"); if (fs.existsSync(faviconSrc)) {
fs.copyFileSync(faviconSrc, path.join(wwwPath, 'favicon.ico'));
} }
}
// Add CSP headers to allow API connections, Google Fonts, and zxing-wasm // Remove service worker files
mainWindow.webContents.session.webRequest.onHeadersReceived( const swFilesToRemove = [
(details, callback) => { 'sw.js',
callback({ 'sw.js.map',
responseHeaders: { 'workbox-*.js',
...details.responseHeaders, 'workbox-*.js.map',
"Content-Security-Policy": [ 'registerSW.js',
"default-src 'self';" + 'manifest.webmanifest',
"connect-src 'self' https://api.endorser.ch https://*.timesafari.app https://*.jsdelivr.net;" + '**/workbox-*.js',
"img-src 'self' data: https: blob:;" + '**/workbox-*.js.map',
"script-src 'self' 'unsafe-inline' 'unsafe-eval' https://*.jsdelivr.net;" + '**/sw.js',
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com;" + '**/sw.js.map',
"font-src 'self' data: https://fonts.gstatic.com;" + '**/registerSW.js',
"style-src-elem 'self' 'unsafe-inline' https://fonts.googleapis.com;" + '**/manifest.webmanifest'
"worker-src 'self' blob:;", ];
],
}, console.log('Removing service worker files...');
}); swFilesToRemove.forEach(pattern => {
}, const files = fs.readdirSync(wwwPath).filter(file =>
file.match(new RegExp(pattern.replace(/\*/g, '.*')))
); );
files.forEach(file => {
const filePath = path.join(wwwPath, file);
console.log(`Removing ${filePath}`);
try {
fs.unlinkSync(filePath);
} catch (err) {
console.warn(`Could not remove ${filePath}:`, err.message);
}
});
});
// Load the index.html // Also check and remove from assets directory
mainWindow const assetsPath = path.join(wwwPath, 'assets');
.loadFile(indexPath) if (fs.existsSync(assetsPath)) {
.then(() => { swFilesToRemove.forEach(pattern => {
logger.log("Successfully loaded index.html"); const files = fs.readdirSync(assetsPath).filter(file =>
if (isDev) { file.match(new RegExp(pattern.replace(/\*/g, '.*')))
mainWindow.webContents.openDevTools(); );
logger.log("DevTools opened - running in dev mode"); files.forEach(file => {
const filePath = path.join(assetsPath, file);
console.log(`Removing ${filePath}`);
try {
fs.unlinkSync(filePath);
} catch (err) {
console.warn(`Could not remove ${filePath}:`, err.message);
} }
})
.catch((err) => {
logger.error("Failed to load index.html:", err);
logger.error("Attempted path:", indexPath);
}); });
// Listen for console messages from the renderer
mainWindow.webContents.on("console-message", (_event, _level, message) => {
logger.log("Renderer Console:", message);
}); });
}
// Add right after creating the BrowserWindow // Modify index.html to remove service worker registration
mainWindow.webContents.on( const indexPath = path.join(wwwPath, 'index.html');
"did-fail-load", if (fs.existsSync(indexPath)) {
(_event, errorCode, errorDescription) => { console.log('Modifying index.html to remove service worker registration...');
logger.error("Page failed to load:", errorCode, errorDescription); let indexContent = fs.readFileSync(indexPath, 'utf8');
},
); // Remove service worker registration script
indexContent = indexContent
.replace(/<script[^>]*id="vite-plugin-pwa:register-sw"[^>]*><\/script>/g, '')
.replace(/<script[^>]*registerServiceWorker[^>]*><\/script>/g, '')
.replace(/<link[^>]*rel="manifest"[^>]*>/g, '')
.replace(/<link[^>]*rel="serviceworker"[^>]*>/g, '')
.replace(/navigator\.serviceWorker\.register\([^)]*\)/g, '')
.replace(/if\s*\(\s*['"]serviceWorker['"]\s*in\s*navigator\s*\)\s*{[^}]*}/g, '');
fs.writeFileSync(indexPath, indexContent);
console.log('Successfully modified index.html');
}
mainWindow.webContents.on("preload-error", (_event, preloadPath, error) => { // Fix asset paths
logger.error("Preload script error:", preloadPath, error); console.log('Fixing asset paths in index.html...');
}); let modifiedIndexContent = fs.readFileSync(indexPath, 'utf8');
modifiedIndexContent = modifiedIndexContent
.replace(/\/assets\//g, './assets/')
.replace(/href="\//g, 'href="./')
.replace(/src="\//g, 'src="./');
mainWindow.webContents.on( fs.writeFileSync(indexPath, modifiedIndexContent);
"console-message",
(_event, _level, message, line, sourceId) => {
logger.log("Renderer Console:", line, sourceId, message);
},
);
// Enable remote debugging when in dev mode // Verify no service worker references remain
if (isDev) { const finalContent = fs.readFileSync(indexPath, 'utf8');
mainWindow.webContents.openDevTools(); if (finalContent.includes('serviceWorker') || finalContent.includes('workbox')) {
} console.warn('Warning: Service worker references may still exist in index.html');
} }
// Handle app ready // Check for remaining /assets/ paths
app.whenReady().then(createWindow); console.log('After path fixing, checking for remaining /assets/ paths:', finalContent.includes('/assets/'));
console.log('Sample of fixed content:', finalContent.substring(0, 500));
// Handle all windows closed console.log('Copied and fixed web files in:', wwwPath);
app.on("window-all-closed", () => {
if (process.platform !== "darwin") {
app.quit();
}
});
app.on("activate", () => { // Copy main process files
if (BrowserWindow.getAllWindows().length === 0) { console.log('Copying main process files...');
createWindow();
}
});
// Handle any errors // Copy the main process file instead of creating a template
process.on("uncaughtException", (error) => { const mainSrcPath = path.join(__dirname, '..', 'dist-electron', 'main.js');
logger.error("Uncaught Exception:", error); const mainDestPath = path.join(electronDistPath, 'main.js');
});
`;
// Write the main process file
const mainDest = path.join(electronDistPath, 'main.js');
fs.writeFileSync(mainDest, mainContent);
// Copy preload script if it exists
const preloadSrc = path.join(__dirname, '..', 'src', 'electron', 'preload.js');
const preloadDest = path.join(electronDistPath, 'preload.js');
if (fs.existsSync(preloadSrc)) {
console.log(`Copying ${preloadSrc} to ${preloadDest}`);
fs.copyFileSync(preloadSrc, preloadDest);
}
// Verify build structure if (fs.existsSync(mainSrcPath)) {
console.log('\nVerifying build structure:'); fs.copyFileSync(mainSrcPath, mainDestPath);
console.log('Files in dist-electron:', fs.readdirSync(electronDistPath)); console.log('Copied main process file successfully');
} else {
console.error('Main process file not found at:', mainSrcPath);
process.exit(1);
}
console.log('Build completed successfully!'); console.log('Electron build process completed successfully');

15
scripts/copy-wasm.js

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

13
src/App.vue

@ -330,8 +330,11 @@
<script lang="ts"> <script lang="ts">
import { Vue, Component } from "vue-facing-decorator"; import { Vue, Component } from "vue-facing-decorator";
import { logConsoleAndDb, retrieveSettingsForActiveAccount } from "./db/index";
import { NotificationIface } from "./constants/app"; import { NotificationIface, USE_DEXIE_DB } from "./constants/app";
import * as databaseUtil from "./db/databaseUtil";
import { retrieveSettingsForActiveAccount } from "./db/index";
import { logConsoleAndDb } from "./db/databaseUtil";
import { logger } from "./utils/logger"; import { logger } from "./utils/logger";
interface Settings { interface Settings {
@ -396,7 +399,11 @@ export default class App extends Vue {
try { try {
logger.log("Retrieving settings for the active account..."); logger.log("Retrieving settings for the active account...");
const settings: Settings = await retrieveSettingsForActiveAccount(); let settings: Settings =
await databaseUtil.retrieveSettingsForActiveAccount();
if (USE_DEXIE_DB) {
settings = await retrieveSettingsForActiveAccount();
}
logger.log("Retrieved settings:", settings); logger.log("Retrieved settings:", settings);
const notifyingNewActivity = !!settings?.notifyingNewActivityTime; const notifyingNewActivity = !!settings?.notifyingNewActivityTime;

20
src/components/ActivityListItem.vue

@ -61,7 +61,7 @@
<!-- Record Image --> <!-- Record Image -->
<div <div
v-if="record.image" v-if="record.image"
class="bg-cover mb-4 -mt-3 sm:-mt-4 -mx-3 sm:-mx-4" class="bg-cover mb-2 -mt-3 sm:-mt-4 -mx-3 sm:-mx-4"
:style="`background-image: url(${record.image});`" :style="`background-image: url(${record.image});`"
> >
<a <a
@ -77,8 +77,15 @@
</a> </a>
</div> </div>
<!-- Description -->
<p class="font-medium">
<a class="cursor-pointer" @click="$emit('loadClaim', record.jwtId)">
{{ description }}
</a>
</p>
<div <div
class="relative flex justify-between gap-4 max-w-[40rem] mx-auto mb-3" class="relative flex justify-between gap-4 max-w-[40rem] mx-auto mt-4"
> >
<!-- Source --> <!-- Source -->
<div <div
@ -229,13 +236,6 @@
</div> </div>
</div> </div>
</div> </div>
<!-- Description -->
<p class="font-medium">
<a class="cursor-pointer" @click="$emit('loadClaim', record.jwtId)">
{{ description }}
</a>
</p>
</div> </div>
</li> </li>
</template> </template>
@ -309,7 +309,7 @@ export default class ActivityListItem extends Vue {
const claim = const claim =
(this.record.fullClaim as unknown).claim || this.record.fullClaim; (this.record.fullClaim as unknown).claim || this.record.fullClaim;
return `${claim.description}`; return `${claim?.description || ""}`;
} }
private displayAmount(code: string, amt: number) { private displayAmount(code: string, amt: number) {

57
src/components/DataExportSection.vue

@ -24,9 +24,7 @@ backup and database export, with platform-specific download instructions. * *
class="block w-full text-center text-md bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md" class="block w-full text-center text-md bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md"
@click="exportDatabase()" @click="exportDatabase()"
> >
Download Settings & Contacts Download Contacts
<br />
(excluding Identifier Data)
</button> </button>
<a <a
ref="downloadLink" ref="downloadLink"
@ -62,14 +60,18 @@ backup and database export, with platform-specific download instructions. * *
<script lang="ts"> <script lang="ts">
import { Component, Prop, Vue } from "vue-facing-decorator"; import { Component, Prop, Vue } from "vue-facing-decorator";
import { NotificationIface } from "../constants/app"; import { AppString, NotificationIface } from "../constants/app";
import { db } from "../db/index";
import { Contact } from "../db/tables/contacts";
import * as databaseUtil from "../db/databaseUtil";
import { logger } from "../utils/logger"; import { logger } from "../utils/logger";
import { PlatformServiceFactory } from "../services/PlatformServiceFactory"; import { PlatformServiceFactory } from "../services/PlatformServiceFactory";
import { import {
PlatformService, PlatformService,
PlatformCapabilities, PlatformCapabilities,
} from "../services/PlatformService"; } from "../services/PlatformService";
import { contactsToExportJson } from "../libs/util";
/** /**
* @vue-component * @vue-component
@ -131,21 +133,25 @@ export default class DataExportSection extends Vue {
*/ */
public async exportDatabase() { public async exportDatabase() {
try { try {
const blob = await db.export({ let allContacts: Contact[] = [];
prettyJson: true, const platformService = PlatformServiceFactory.getInstance();
transform: (table, value, key) => { const result = await platformService.dbQuery(`SELECT * FROM contacts`);
if (table === "contacts") { if (result) {
// Dexie inserts a number 0 when some are undefined, so we need to totally remove them. allContacts = databaseUtil.mapQueryResultToValues(
Object.keys(value).forEach((prop) => { result,
if (value[prop] === undefined) { ) as unknown as Contact[];
delete value[prop]; }
} // if (USE_DEXIE_DB) {
}); // await db.open();
} // allContacts = await db.contacts.toArray();
return { value, key }; // }
},
}); // Convert contacts to export format
const fileName = `${db.name}-backup.json`; const exportData = contactsToExportJson(allContacts);
const jsonStr = JSON.stringify(exportData, null, 2);
const blob = new Blob([jsonStr], { type: "application/json" });
const fileName = `${AppString.APP_NAME_NO_SPACES}-backup-contacts.json`;
if (this.platformCapabilities.hasFileDownload) { if (this.platformCapabilities.hasFileDownload) {
// Web platform: Use download link // Web platform: Use download link
@ -157,8 +163,9 @@ export default class DataExportSection extends Vue {
setTimeout(() => URL.revokeObjectURL(this.downloadUrl), 1000); setTimeout(() => URL.revokeObjectURL(this.downloadUrl), 1000);
} else if (this.platformCapabilities.hasFileSystem) { } else if (this.platformCapabilities.hasFileSystem) {
// Native platform: Write to app directory // Native platform: Write to app directory
const content = await blob.text(); await this.platformService.writeAndShareFile(fileName, jsonStr);
await this.platformService.writeAndShareFile(fileName, content); } else {
throw new Error("This platform does not support file downloads.");
} }
this.$notify( this.$notify(
@ -167,10 +174,10 @@ export default class DataExportSection extends Vue {
type: "success", type: "success",
title: "Export Successful", title: "Export Successful",
text: this.platformCapabilities.hasFileDownload text: this.platformCapabilities.hasFileDownload
? "See your downloads directory for the backup. It is in the Dexie format." ? "See your downloads directory for the backup."
: "You should have been prompted to save your backup file.", : "The backup file has been saved.",
}, },
-1, 3000,
); );
} catch (error) { } catch (error) {
logger.error("Export Error:", error); logger.error("Export Error:", error);

55
src/components/FeedFilters.vue

@ -99,8 +99,12 @@ import {
LTileLayer, LTileLayer,
} from "@vue-leaflet/vue-leaflet"; } from "@vue-leaflet/vue-leaflet";
import { Router } from "vue-router"; import { Router } from "vue-router";
import { USE_DEXIE_DB } from "@/constants/app";
import * as databaseUtil from "../db/databaseUtil";
import { MASTER_SETTINGS_KEY } from "../db/tables/settings"; import { MASTER_SETTINGS_KEY } from "../db/tables/settings";
import { db, retrieveSettingsForActiveAccount } from "../db/index"; import { db, retrieveSettingsForActiveAccount } from "../db/index";
import { PlatformServiceFactory } from "@/services/PlatformServiceFactory";
@Component({ @Component({
components: { components: {
@ -122,7 +126,10 @@ export default class FeedFilters extends Vue {
async open(onCloseIfChanged: () => void) { async open(onCloseIfChanged: () => void) {
this.onCloseIfChanged = onCloseIfChanged; this.onCloseIfChanged = onCloseIfChanged;
const settings = await retrieveSettingsForActiveAccount(); let settings = await databaseUtil.retrieveSettingsForActiveAccount();
if (USE_DEXIE_DB) {
settings = await retrieveSettingsForActiveAccount();
}
this.hasVisibleDid = !!settings.filterFeedByVisible; this.hasVisibleDid = !!settings.filterFeedByVisible;
this.isNearby = !!settings.filterFeedByNearby; this.isNearby = !!settings.filterFeedByNearby;
if (settings.searchBoxes && settings.searchBoxes.length > 0) { if (settings.searchBoxes && settings.searchBoxes.length > 0) {
@ -144,9 +151,17 @@ export default class FeedFilters extends Vue {
async toggleNearby() { async toggleNearby() {
this.settingChanged = true; this.settingChanged = true;
this.isNearby = !this.isNearby; this.isNearby = !this.isNearby;
await db.settings.update(MASTER_SETTINGS_KEY, { const platformService = PlatformServiceFactory.getInstance();
filterFeedByNearby: this.isNearby, await platformService.dbExec(
}); `UPDATE settings SET filterFeedByNearby = ? WHERE id = ?`,
[this.isNearby, MASTER_SETTINGS_KEY],
);
if (USE_DEXIE_DB) {
await db.settings.update(MASTER_SETTINGS_KEY, {
filterFeedByNearby: this.isNearby,
});
}
} }
async clearAll() { async clearAll() {
@ -154,10 +169,18 @@ export default class FeedFilters extends Vue {
this.settingChanged = true; this.settingChanged = true;
} }
await db.settings.update(MASTER_SETTINGS_KEY, { const platformService = PlatformServiceFactory.getInstance();
filterFeedByNearby: false, await platformService.dbExec(
filterFeedByVisible: false, `UPDATE settings SET filterFeedByNearby = ? AND filterFeedByVisible = ? WHERE id = ?`,
}); [false, false, MASTER_SETTINGS_KEY],
);
if (USE_DEXIE_DB) {
await db.settings.update(MASTER_SETTINGS_KEY, {
filterFeedByNearby: false,
filterFeedByVisible: false,
});
}
this.hasVisibleDid = false; this.hasVisibleDid = false;
this.isNearby = false; this.isNearby = false;
@ -168,10 +191,18 @@ export default class FeedFilters extends Vue {
this.settingChanged = true; this.settingChanged = true;
} }
await db.settings.update(MASTER_SETTINGS_KEY, { const platformService = PlatformServiceFactory.getInstance();
filterFeedByNearby: true, await platformService.dbExec(
filterFeedByVisible: true, `UPDATE settings SET filterFeedByNearby = ? AND filterFeedByVisible = ? WHERE id = ?`,
}); [true, true, MASTER_SETTINGS_KEY],
);
if (USE_DEXIE_DB) {
await db.settings.update(MASTER_SETTINGS_KEY, {
filterFeedByNearby: true,
filterFeedByVisible: true,
});
}
this.hasVisibleDid = true; this.hasVisibleDid = true;
this.isNearby = true; this.isNearby = true;

20
src/components/GiftedDialog.vue

@ -89,7 +89,7 @@
<script lang="ts"> <script lang="ts">
import { Vue, Component, Prop } from "vue-facing-decorator"; import { Vue, Component, Prop } from "vue-facing-decorator";
import { NotificationIface } from "../constants/app"; import { NotificationIface, USE_DEXIE_DB } from "../constants/app";
import { import {
createAndSubmitGive, createAndSubmitGive,
didInfo, didInfo,
@ -98,8 +98,10 @@ import {
import * as libsUtil from "../libs/util"; import * as libsUtil from "../libs/util";
import { db, retrieveSettingsForActiveAccount } from "../db/index"; import { db, retrieveSettingsForActiveAccount } from "../db/index";
import { Contact } from "../db/tables/contacts"; import { Contact } from "../db/tables/contacts";
import * as databaseUtil from "../db/databaseUtil";
import { retrieveAccountDids } from "../libs/util"; import { retrieveAccountDids } from "../libs/util";
import { logger } from "../utils/logger"; import { logger } from "../utils/logger";
import { PlatformServiceFactory } from "@/services/PlatformServiceFactory";
@Component @Component
export default class GiftedDialog extends Vue { export default class GiftedDialog extends Vue {
@ -144,11 +146,23 @@ export default class GiftedDialog extends Vue {
this.offerId = offerId || ""; this.offerId = offerId || "";
try { try {
const settings = await retrieveSettingsForActiveAccount(); let settings = await databaseUtil.retrieveSettingsForActiveAccount();
if (USE_DEXIE_DB) {
settings = await retrieveSettingsForActiveAccount();
}
this.apiServer = settings.apiServer || ""; this.apiServer = settings.apiServer || "";
this.activeDid = settings.activeDid || ""; this.activeDid = settings.activeDid || "";
this.allContacts = await db.contacts.toArray(); const platformService = PlatformServiceFactory.getInstance();
const result = await platformService.dbQuery(`SELECT * FROM contacts`);
if (result) {
this.allContacts = databaseUtil.mapQueryResultToValues(
result,
) as unknown as Contact[];
}
if (USE_DEXIE_DB) {
this.allContacts = await db.contacts.toArray();
}
this.allMyDids = await retrieveAccountDids(); this.allMyDids = await retrieveAccountDids();

36
src/components/GiftedPrompts.vue

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

13
src/components/ImageMethodDialog.vue

@ -256,11 +256,16 @@ import axios from "axios";
import { ref } from "vue"; import { ref } from "vue";
import { Component, Vue } from "vue-facing-decorator"; import { Component, Vue } from "vue-facing-decorator";
import VuePictureCropper, { cropper } from "vue-picture-cropper"; import VuePictureCropper, { cropper } from "vue-picture-cropper";
import { DEFAULT_IMAGE_API_SERVER, NotificationIface } from "../constants/app"; import {
DEFAULT_IMAGE_API_SERVER,
NotificationIface,
USE_DEXIE_DB,
} from "../constants/app";
import { retrieveSettingsForActiveAccount } from "../db/index"; import { retrieveSettingsForActiveAccount } from "../db/index";
import { accessToken } from "../libs/crypto"; import { accessToken } from "../libs/crypto";
import { logger } from "../utils/logger"; import { logger } from "../utils/logger";
import { PlatformServiceFactory } from "../services/PlatformServiceFactory"; import { PlatformServiceFactory } from "../services/PlatformServiceFactory";
import * as databaseUtil from "../db/databaseUtil";
const inputImageFileNameRef = ref<Blob>(); const inputImageFileNameRef = ref<Blob>();
@ -350,9 +355,11 @@ export default class ImageMethodDialog extends Vue {
* @throws {Error} When settings retrieval fails * @throws {Error} When settings retrieval fails
*/ */
async mounted() { async mounted() {
logger.log("ImageMethodDialog mounted");
try { try {
const settings = await retrieveSettingsForActiveAccount(); let settings = await databaseUtil.retrieveSettingsForActiveAccount();
if (USE_DEXIE_DB) {
settings = await retrieveSettingsForActiveAccount();
}
this.activeDid = settings.activeDid || ""; this.activeDid = settings.activeDid || "";
} catch (error: unknown) { } catch (error: unknown) {
logger.error("Error retrieving settings from database:", error); logger.error("Error retrieving settings from database:", error);

38
src/components/MembersList.vue

@ -172,8 +172,10 @@ import {
} from "../libs/endorserServer"; } from "../libs/endorserServer";
import { decryptMessage } from "../libs/crypto"; import { decryptMessage } from "../libs/crypto";
import { Contact } from "../db/tables/contacts"; import { Contact } from "../db/tables/contacts";
import * as databaseUtil from "../db/databaseUtil";
import * as libsUtil from "../libs/util"; import * as libsUtil from "../libs/util";
import { NotificationIface } from "../constants/app"; import { NotificationIface, USE_DEXIE_DB } from "../constants/app";
import { PlatformServiceFactory } from "@/services/PlatformServiceFactory";
interface Member { interface Member {
admitted: boolean; admitted: boolean;
@ -209,7 +211,10 @@ export default class MembersList extends Vue {
contacts: Array<Contact> = []; contacts: Array<Contact> = [];
async created() { async created() {
const settings = await retrieveSettingsForActiveAccount(); let settings = await databaseUtil.retrieveSettingsForActiveAccount();
if (USE_DEXIE_DB) {
settings = await retrieveSettingsForActiveAccount();
}
this.activeDid = settings.activeDid || ""; this.activeDid = settings.activeDid || "";
this.apiServer = settings.apiServer || ""; this.apiServer = settings.apiServer || "";
this.firstName = settings.firstName || ""; this.firstName = settings.firstName || "";
@ -355,7 +360,16 @@ export default class MembersList extends Vue {
} }
async loadContacts() { async loadContacts() {
this.contacts = await db.contacts.toArray(); const platformService = PlatformServiceFactory.getInstance();
const result = await platformService.dbQuery("SELECT * FROM contacts");
if (result) {
this.contacts = databaseUtil.mapQueryResultToValues(
result,
) as unknown as Contact[];
}
if (USE_DEXIE_DB) {
this.contacts = await db.contacts.toArray();
}
} }
getContactFor(did: string): Contact | undefined { getContactFor(did: string): Contact | undefined {
@ -439,7 +453,14 @@ export default class MembersList extends Vue {
if (result.success) { if (result.success) {
decrMember.isRegistered = true; decrMember.isRegistered = true;
if (oldContact) { if (oldContact) {
await db.contacts.update(decrMember.did, { registered: true }); const platformService = PlatformServiceFactory.getInstance();
await platformService.dbExec(
"UPDATE contacts SET registered = ? WHERE did = ?",
[true, decrMember.did],
);
if (USE_DEXIE_DB) {
await db.contacts.update(decrMember.did, { registered: true });
}
oldContact.registered = true; oldContact.registered = true;
} }
this.$notify( this.$notify(
@ -492,7 +513,14 @@ export default class MembersList extends Vue {
name: member.name, name: member.name,
}; };
await db.contacts.add(newContact); const platformService = PlatformServiceFactory.getInstance();
await platformService.dbExec(
"INSERT INTO contacts (did, name) VALUES (?, ?)",
[member.did, member.name],
);
if (USE_DEXIE_DB) {
await db.contacts.add(newContact);
}
this.contacts.push(newContact); this.contacts.push(newContact);
this.$notify( this.$notify(

8
src/components/OfferDialog.vue

@ -82,12 +82,13 @@
<script lang="ts"> <script lang="ts">
import { Vue, Component, Prop } from "vue-facing-decorator"; import { Vue, Component, Prop } from "vue-facing-decorator";
import { NotificationIface } from "../constants/app"; import { NotificationIface, USE_DEXIE_DB } from "../constants/app";
import { import {
createAndSubmitOffer, createAndSubmitOffer,
serverMessageForUser, serverMessageForUser,
} from "../libs/endorserServer"; } from "../libs/endorserServer";
import * as libsUtil from "../libs/util"; import * as libsUtil from "../libs/util";
import * as databaseUtil from "../db/databaseUtil";
import { retrieveSettingsForActiveAccount } from "../db/index"; import { retrieveSettingsForActiveAccount } from "../db/index";
import { logger } from "../utils/logger"; import { logger } from "../utils/logger";
@ -116,7 +117,10 @@ export default class OfferDialog extends Vue {
this.recipientDid = recipientDid; this.recipientDid = recipientDid;
this.recipientName = recipientName; this.recipientName = recipientName;
const settings = await retrieveSettingsForActiveAccount(); let settings = await databaseUtil.retrieveSettingsForActiveAccount();
if (USE_DEXIE_DB) {
settings = await retrieveSettingsForActiveAccount();
}
this.apiServer = settings.apiServer || ""; this.apiServer = settings.apiServer || "";
this.activeDid = settings.activeDid || ""; this.activeDid = settings.activeDid || "";

46
src/components/OnboardingDialog.vue

@ -201,13 +201,16 @@
import { Component, Vue } from "vue-facing-decorator"; import { Component, Vue } from "vue-facing-decorator";
import { Router } from "vue-router"; import { Router } from "vue-router";
import { NotificationIface } from "../constants/app"; import { NotificationIface, USE_DEXIE_DB } from "../constants/app";
import { import {
db, db,
retrieveSettingsForActiveAccount, retrieveSettingsForActiveAccount,
updateAccountSettings, updateAccountSettings,
} from "../db/index"; } from "../db/index";
import * as databaseUtil from "../db/databaseUtil";
import { OnboardPage } from "../libs/util"; import { OnboardPage } from "../libs/util";
import { PlatformServiceFactory } from "@/services/PlatformServiceFactory";
import { Contact } from "@/db/tables/contacts";
@Component({ @Component({
computed: { computed: {
@ -222,7 +225,7 @@ export default class OnboardingDialog extends Vue {
$router!: Router; $router!: Router;
activeDid = ""; activeDid = "";
firstContactName = null; firstContactName = "";
givenName = ""; givenName = "";
isRegistered = false; isRegistered = false;
numContacts = 0; numContacts = 0;
@ -231,29 +234,54 @@ export default class OnboardingDialog extends Vue {
async open(page: OnboardPage) { async open(page: OnboardPage) {
this.page = page; this.page = page;
const settings = await retrieveSettingsForActiveAccount(); let settings = await databaseUtil.retrieveSettingsForActiveAccount();
if (USE_DEXIE_DB) {
settings = await retrieveSettingsForActiveAccount();
}
this.activeDid = settings.activeDid || ""; this.activeDid = settings.activeDid || "";
this.isRegistered = !!settings.isRegistered; this.isRegistered = !!settings.isRegistered;
const contacts = await db.contacts.toArray(); const platformService = PlatformServiceFactory.getInstance();
this.numContacts = contacts.length; const dbContacts = await platformService.dbQuery("SELECT * FROM contacts");
if (this.numContacts > 0) { if (dbContacts) {
this.firstContactName = contacts[0].name; this.numContacts = dbContacts.values.length;
const firstContact = dbContacts.values[0];
const fullContact = databaseUtil.mapColumnsToValues(dbContacts.columns, [
firstContact,
]) as unknown as Contact;
this.firstContactName = fullContact.name || "";
}
if (USE_DEXIE_DB) {
const contacts = await db.contacts.toArray();
this.numContacts = contacts.length;
if (this.numContacts > 0) {
this.firstContactName = contacts[0].name || "";
}
} }
this.visible = true; this.visible = true;
if (this.page === OnboardPage.Create) { if (this.page === OnboardPage.Create) {
// we'll assume that they've been through all the other pages // we'll assume that they've been through all the other pages
await updateAccountSettings(this.activeDid, { await databaseUtil.updateAccountSettings(this.activeDid, {
finishedOnboarding: true, finishedOnboarding: true,
}); });
if (USE_DEXIE_DB) {
await updateAccountSettings(this.activeDid, {
finishedOnboarding: true,
});
}
} }
} }
async onClickClose(done?: boolean, goHome?: boolean) { async onClickClose(done?: boolean, goHome?: boolean) {
this.visible = false; this.visible = false;
if (done) { if (done) {
await updateAccountSettings(this.activeDid, { await databaseUtil.updateAccountSettings(this.activeDid, {
finishedOnboarding: true, finishedOnboarding: true,
}); });
if (USE_DEXIE_DB) {
await updateAccountSettings(this.activeDid, {
finishedOnboarding: true,
});
}
if (goHome) { if (goHome) {
this.$router.push({ name: "home" }); this.$router.push({ name: "home" });
} }

14
src/components/PhotoDialog.vue

@ -119,7 +119,12 @@ PhotoDialog.vue */
import axios from "axios"; import axios from "axios";
import { Component, Vue } from "vue-facing-decorator"; import { Component, Vue } from "vue-facing-decorator";
import VuePictureCropper, { cropper } from "vue-picture-cropper"; import VuePictureCropper, { cropper } from "vue-picture-cropper";
import { DEFAULT_IMAGE_API_SERVER, NotificationIface } from "../constants/app"; import {
DEFAULT_IMAGE_API_SERVER,
NotificationIface,
USE_DEXIE_DB,
} from "../constants/app";
import * as databaseUtil from "../db/databaseUtil";
import { retrieveSettingsForActiveAccount } from "../db/index"; import { retrieveSettingsForActiveAccount } from "../db/index";
import { accessToken } from "../libs/crypto"; import { accessToken } from "../libs/crypto";
import { logger } from "../utils/logger"; import { logger } from "../utils/logger";
@ -173,9 +178,12 @@ export default class PhotoDialog extends Vue {
* @throws {Error} When settings retrieval fails * @throws {Error} When settings retrieval fails
*/ */
async mounted() { async mounted() {
logger.log("PhotoDialog mounted"); // logger.log("PhotoDialog mounted");
try { try {
const settings = await retrieveSettingsForActiveAccount(); let settings = await databaseUtil.retrieveSettingsForActiveAccount();
if (USE_DEXIE_DB) {
settings = await retrieveSettingsForActiveAccount();
}
this.activeDid = settings.activeDid || ""; this.activeDid = settings.activeDid || "";
this.isRegistered = !!settings.isRegistered; this.isRegistered = !!settings.isRegistered;
logger.log("isRegistered:", this.isRegistered); logger.log("isRegistered:", this.isRegistered);

12
src/components/PushNotificationPermission.vue

@ -102,7 +102,12 @@
<script lang="ts"> <script lang="ts">
import { Component, Vue } from "vue-facing-decorator"; import { Component, Vue } from "vue-facing-decorator";
import { DEFAULT_PUSH_SERVER, NotificationIface } from "../constants/app"; import {
DEFAULT_PUSH_SERVER,
NotificationIface,
USE_DEXIE_DB,
} from "../constants/app";
import * as databaseUtil from "../db/databaseUtil";
import { import {
logConsoleAndDb, logConsoleAndDb,
retrieveSettingsForActiveAccount, retrieveSettingsForActiveAccount,
@ -169,7 +174,10 @@ export default class PushNotificationPermission extends Vue {
this.isVisible = true; this.isVisible = true;
this.pushType = pushType; this.pushType = pushType;
try { try {
const settings = await retrieveSettingsForActiveAccount(); let settings = await databaseUtil.retrieveSettingsForActiveAccount();
if (USE_DEXIE_DB) {
settings = await retrieveSettingsForActiveAccount();
}
let pushUrl = DEFAULT_PUSH_SERVER; let pushUrl = DEFAULT_PUSH_SERVER;
if (settings?.webPushServer) { if (settings?.webPushServer) {
pushUrl = settings.webPushServer; pushUrl = settings.webPushServer;

8
src/components/TopMessage.vue

@ -15,7 +15,8 @@
<script lang="ts"> <script lang="ts">
import { Component, Vue, Prop } from "vue-facing-decorator"; import { Component, Vue, Prop } from "vue-facing-decorator";
import { AppString, NotificationIface } from "../constants/app"; import { AppString, NotificationIface, USE_DEXIE_DB } from "../constants/app";
import * as databaseUtil from "../db/databaseUtil";
import { retrieveSettingsForActiveAccount } from "../db/index"; import { retrieveSettingsForActiveAccount } from "../db/index";
@Component @Component
@ -28,7 +29,10 @@ export default class TopMessage extends Vue {
async mounted() { async mounted() {
try { try {
const settings = await retrieveSettingsForActiveAccount(); let settings = await databaseUtil.retrieveSettingsForActiveAccount();
if (USE_DEXIE_DB) {
settings = await retrieveSettingsForActiveAccount();
}
if ( if (
settings.warnIfTestServer && settings.warnIfTestServer &&
settings.apiServer !== AppString.PROD_ENDORSER_API_SERVER settings.apiServer !== AppString.PROD_ENDORSER_API_SERVER

22
src/components/UserNameDialog.vue

@ -37,9 +37,11 @@
<script lang="ts"> <script lang="ts">
import { Vue, Component, Prop } from "vue-facing-decorator"; import { Vue, Component, Prop } from "vue-facing-decorator";
import { NotificationIface } from "../constants/app"; import { NotificationIface, USE_DEXIE_DB } from "../constants/app";
import { db, retrieveSettingsForActiveAccount } from "../db/index"; import { db, retrieveSettingsForActiveAccount } from "../db/index";
import * as databaseUtil from "../db/databaseUtil";
import { MASTER_SETTINGS_KEY } from "../db/tables/settings"; import { MASTER_SETTINGS_KEY } from "../db/tables/settings";
import { PlatformServiceFactory } from "@/services/PlatformServiceFactory";
@Component @Component
export default class UserNameDialog extends Vue { export default class UserNameDialog extends Vue {
@ -61,15 +63,25 @@ export default class UserNameDialog extends Vue {
*/ */
async open(aCallback?: (name?: string) => void) { async open(aCallback?: (name?: string) => void) {
this.callback = aCallback || this.callback; this.callback = aCallback || this.callback;
const settings = await retrieveSettingsForActiveAccount(); let settings = await databaseUtil.retrieveSettingsForActiveAccount();
if (USE_DEXIE_DB) {
settings = await retrieveSettingsForActiveAccount();
}
this.givenName = settings.firstName || ""; this.givenName = settings.firstName || "";
this.visible = true; this.visible = true;
} }
async onClickSaveChanges() { async onClickSaveChanges() {
await db.settings.update(MASTER_SETTINGS_KEY, { const platformService = PlatformServiceFactory.getInstance();
firstName: this.givenName, await platformService.dbExec(
}); "UPDATE settings SET firstName = ? WHERE id = ?",
[this.givenName, MASTER_SETTINGS_KEY],
);
if (USE_DEXIE_DB) {
await db.settings.update(MASTER_SETTINGS_KEY, {
firstName: this.givenName,
});
}
this.visible = false; this.visible = false;
this.callback(this.givenName); this.callback(this.givenName);
} }

7
src/components/World/components/objects/landmarks.js

@ -3,6 +3,8 @@ import * as THREE from "three";
import { GLTFLoader } from "three/addons/loaders/GLTFLoader"; import { GLTFLoader } from "three/addons/loaders/GLTFLoader";
import * as SkeletonUtils from "three/addons/utils/SkeletonUtils"; import * as SkeletonUtils from "three/addons/utils/SkeletonUtils";
import * as TWEEN from "@tweenjs/tween.js"; import * as TWEEN from "@tweenjs/tween.js";
import { USE_DEXIE_DB } from "../../../../constants/app";
import * as databaseUtil from "../../../../db/databaseUtil";
import { retrieveSettingsForActiveAccount } from "../../../../db"; import { retrieveSettingsForActiveAccount } from "../../../../db";
import { getHeaders } from "../../../../libs/endorserServer"; import { getHeaders } from "../../../../libs/endorserServer";
import { logger } from "../../../../utils/logger"; import { logger } from "../../../../utils/logger";
@ -14,7 +16,10 @@ export async function loadLandmarks(vue, world, scene, loop) {
vue.setWorldProperty("animationDurationSeconds", ANIMATION_DURATION_SECS); vue.setWorldProperty("animationDurationSeconds", ANIMATION_DURATION_SECS);
try { try {
const settings = await retrieveSettingsForActiveAccount(); let settings = await databaseUtil.retrieveSettingsForActiveAccount();
if (USE_DEXIE_DB) {
settings = await retrieveSettingsForActiveAccount();
}
const activeDid = settings.activeDid || ""; const activeDid = settings.activeDid || "";
const apiServer = settings.apiServer; const apiServer = settings.apiServer;
const headers = await getHeaders(activeDid); const headers = await getHeaders(activeDid);

5
src/constants/app.ts

@ -7,6 +7,7 @@ export enum AppString {
// This is used in titles and verbiage inside the app. // This is used in titles and verbiage inside the app.
// There is also an app name without spaces, for packaging in the package.json file used in the manifest. // There is also an app name without spaces, for packaging in the package.json file used in the manifest.
APP_NAME = "Time Safari", APP_NAME = "Time Safari",
APP_NAME_NO_SPACES = "TimeSafari",
PROD_ENDORSER_API_SERVER = "https://api.endorser.ch", PROD_ENDORSER_API_SERVER = "https://api.endorser.ch",
TEST_ENDORSER_API_SERVER = "https://test-api.endorser.ch", TEST_ENDORSER_API_SERVER = "https://test-api.endorser.ch",
@ -43,13 +44,15 @@ export const DEFAULT_PARTNER_API_SERVER =
AppString.TEST_PARTNER_API_SERVER; AppString.TEST_PARTNER_API_SERVER;
export const DEFAULT_PUSH_SERVER = export const DEFAULT_PUSH_SERVER =
window.location.protocol + "//" + window.location.host; import.meta.env.VITE_DEFAULT_PUSH_SERVER || "https://timesafari.app";
export const IMAGE_TYPE_PROFILE = "profile"; export const IMAGE_TYPE_PROFILE = "profile";
export const PASSKEYS_ENABLED = export const PASSKEYS_ENABLED =
!!import.meta.env.VITE_PASSKEYS_ENABLED || false; !!import.meta.env.VITE_PASSKEYS_ENABLED || false;
export const USE_DEXIE_DB = false;
/** /**
* The possible values for "group" and "type" are in App.vue. * The possible values for "group" and "type" are in App.vue.
* Some of this comes from the notiwind package, some is custom. * Some of this comes from the notiwind package, some is custom.

138
src/db-sql/migration.ts

@ -0,0 +1,138 @@
import migrationService from "../services/migrationService";
import { DEFAULT_ENDORSER_API_SERVER } from "@/constants/app";
import { arrayBufferToBase64 } from "@/libs/crypto";
// Generate a random secret for the secret table
// It's not really secure to maintain the secret next to the user's data.
// However, until we have better hooks into a real wallet or reliable secure
// storage, we'll do this for user convenience. As they sign more records
// and integrate with more people, they'll value it more and want to be more
// secure, so we'll prompt them to take steps to back it up, properly encrypt,
// etc. At the beginning, we'll prompt for a password, then we'll prompt for a
// PWA so it's not in a browser... and then we hope to be integrated with a
// real wallet or something else more secure.
// One might ask: why encrypt at all? We figure a basic encryption is better
// than none. Plus, we expect to support their own password or keystore or
// external wallet as better signing options in the future, so it's gonna be
// important to have the structure where each account access might require
// user action.
// (Once upon a time we stored the secret in localStorage, but it frequently
// got erased, even though the IndexedDB still had the identity data. This
// ended up throwing lots of errors to the user... and they'd end up in a state
// where they couldn't take action because they couldn't unlock that identity.)
const randomBytes = crypto.getRandomValues(new Uint8Array(32));
const secretBase64 = arrayBufferToBase64(randomBytes);
// Each migration can include multiple SQL statements (with semicolons)
const MIGRATIONS = [
{
name: "001_initial",
// see ../db/tables files for explanations of the fields
sql: `
CREATE TABLE IF NOT EXISTS accounts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
dateCreated TEXT NOT NULL,
derivationPath TEXT,
did TEXT NOT NULL,
identityEncrBase64 TEXT, -- encrypted & base64-encoded
mnemonicEncrBase64 TEXT, -- encrypted & base64-encoded
passkeyCredIdHex TEXT,
publicKeyHex TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_accounts_did ON accounts(did);
CREATE TABLE IF NOT EXISTS secret (
id INTEGER PRIMARY KEY AUTOINCREMENT,
secretBase64 TEXT NOT NULL
);
INSERT INTO secret (id, secretBase64) VALUES (1, '${secretBase64}');
CREATE TABLE IF NOT EXISTS settings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
accountDid TEXT,
activeDid TEXT,
apiServer TEXT,
filterFeedByNearby BOOLEAN,
filterFeedByVisible BOOLEAN,
finishedOnboarding BOOLEAN,
firstName TEXT,
hideRegisterPromptOnNewContact BOOLEAN,
isRegistered BOOLEAN,
lastName TEXT,
lastAckedOfferToUserJwtId TEXT,
lastAckedOfferToUserProjectsJwtId TEXT,
lastNotifiedClaimId TEXT,
lastViewedClaimId TEXT,
notifyingNewActivityTime TEXT,
notifyingReminderMessage TEXT,
notifyingReminderTime TEXT,
partnerApiServer TEXT,
passkeyExpirationMinutes INTEGER,
profileImageUrl TEXT,
searchBoxes TEXT, -- Stored as JSON string
showContactGivesInline BOOLEAN,
showGeneralAdvanced BOOLEAN,
showShortcutBvc BOOLEAN,
vapid TEXT,
warnIfProdServer BOOLEAN,
warnIfTestServer BOOLEAN,
webPushServer TEXT
);
CREATE INDEX IF NOT EXISTS idx_settings_accountDid ON settings(accountDid);
INSERT INTO settings (id, apiServer) VALUES (1, '${DEFAULT_ENDORSER_API_SERVER}');
CREATE TABLE IF NOT EXISTS contacts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
did TEXT NOT NULL,
name TEXT,
contactMethods TEXT, -- Stored as JSON string
nextPubKeyHashB64 TEXT,
notes TEXT,
profileImageUrl TEXT,
publicKeyBase64 TEXT,
seesMe BOOLEAN,
registered BOOLEAN
);
CREATE INDEX IF NOT EXISTS idx_contacts_did ON contacts(did);
CREATE INDEX IF NOT EXISTS idx_contacts_name ON contacts(name);
CREATE TABLE IF NOT EXISTS logs (
date TEXT NOT NULL,
message TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS temp (
id TEXT PRIMARY KEY,
blobB64 TEXT
);
`,
},
];
/**
* @param sqlExec - A function that executes a SQL statement and returns the result
* @param extractMigrationNames - A function that extracts the names (string array) from "select name from migrations"
*/
export async function runMigrations<T>(
sqlExec: (sql: string) => Promise<unknown>,
sqlQuery: (sql: string) => Promise<T>,
extractMigrationNames: (result: T) => Set<string>,
): Promise<void> {
for (const migration of MIGRATIONS) {
migrationService.registerMigration(migration);
}
await migrationService.runMigrations(
sqlExec,
sqlQuery,
extractMigrationNames,
);
}

330
src/db/databaseUtil.ts

@ -0,0 +1,330 @@
/**
* This file is the SQL replacement of the index.ts file in the db directory.
* That file will eventually be deleted.
*/
import { PlatformServiceFactory } from "@/services/PlatformServiceFactory";
import { MASTER_SETTINGS_KEY, Settings } from "./tables/settings";
import { logger } from "@/utils/logger";
import { DEFAULT_ENDORSER_API_SERVER } from "@/constants/app";
import { QueryExecResult } from "@/interfaces/database";
export async function updateDefaultSettings(
settingsChanges: Settings,
): Promise<boolean> {
delete settingsChanges.accountDid; // just in case
// ensure there is no "id" that would override the key
delete settingsChanges.id;
try {
const platformService = PlatformServiceFactory.getInstance();
const { sql, params } = generateUpdateStatement(
settingsChanges,
"settings",
"id = ?",
[MASTER_SETTINGS_KEY],
);
const result = await platformService.dbExec(sql, params);
return result.changes === 1;
} catch (error) {
logger.error("Error updating default settings:", error);
if (error instanceof Error) {
throw error; // Re-throw if it's already an Error with a message
} else {
throw new Error(
`Failed to update settings. We recommend you try again or restart the app.`,
);
}
}
}
export async function updateAccountSettings(
accountDid: string,
settingsChanges: Settings,
): Promise<boolean> {
settingsChanges.accountDid = accountDid;
delete settingsChanges.id; // key off account, not ID
const platform = PlatformServiceFactory.getInstance();
// First try to update existing record
const { sql: updateSql, params: updateParams } = generateUpdateStatement(
settingsChanges,
"settings",
"accountDid = ?",
[accountDid],
);
const updateResult = await platform.dbExec(updateSql, updateParams);
// If no record was updated, insert a new one
if (updateResult.changes === 1) {
return true;
} else {
const columns = Object.keys(settingsChanges);
const values = Object.values(settingsChanges);
const placeholders = values.map(() => "?").join(", ");
const insertSql = `INSERT INTO settings (${columns.join(", ")}) VALUES (${placeholders})`;
const result = await platform.dbExec(insertSql, values);
return result.changes === 1;
}
}
const DEFAULT_SETTINGS: Settings = {
id: MASTER_SETTINGS_KEY,
activeDid: undefined,
apiServer: DEFAULT_ENDORSER_API_SERVER,
};
// retrieves default settings
export async function retrieveSettingsForDefaultAccount(): Promise<Settings> {
const platform = PlatformServiceFactory.getInstance();
const sql = "SELECT * FROM settings WHERE id = ?";
const result = await platform.dbQuery(sql, [MASTER_SETTINGS_KEY]);
if (!result) {
return DEFAULT_SETTINGS;
} else {
const settings = mapColumnsToValues(
result.columns,
result.values,
)[0] as Settings;
if (settings.searchBoxes) {
// @ts-expect-error - the searchBoxes field is a string in the DB
settings.searchBoxes = JSON.parse(settings.searchBoxes);
}
return settings;
}
}
/**
* Retrieves settings for the active account, merging with default settings
*
* @returns Promise<Settings> Combined settings with account-specific overrides
* @throws Will log specific errors for debugging but returns default settings on failure
*/
export async function retrieveSettingsForActiveAccount(): Promise<Settings> {
try {
// Get default settings first
const defaultSettings = await retrieveSettingsForDefaultAccount();
// If no active DID, return defaults
if (!defaultSettings.activeDid) {
logConsoleAndDb(
"[databaseUtil] No active DID found, returning default settings",
);
return defaultSettings;
}
// Get account-specific settings
try {
const platform = PlatformServiceFactory.getInstance();
const result = await platform.dbQuery(
"SELECT * FROM settings WHERE accountDid = ?",
[defaultSettings.activeDid],
);
if (!result?.values?.length) {
logConsoleAndDb(
`[databaseUtil] No account-specific settings found for ${defaultSettings.activeDid}`,
);
return defaultSettings;
}
// Map and filter settings
const overrideSettings = mapColumnsToValues(
result.columns,
result.values,
)[0] as Settings;
const overrideSettingsFiltered = Object.fromEntries(
Object.entries(overrideSettings).filter(([_, v]) => v !== null),
);
// Merge settings
const settings = { ...defaultSettings, ...overrideSettingsFiltered };
// Handle searchBoxes parsing
if (settings.searchBoxes) {
try {
// @ts-expect-error - the searchBoxes field is a string in the DB
settings.searchBoxes = JSON.parse(settings.searchBoxes);
} catch (error) {
logConsoleAndDb(
`[databaseUtil] Failed to parse searchBoxes for ${defaultSettings.activeDid}: ${error}`,
true,
);
// Reset to empty array on parse failure
settings.searchBoxes = [];
}
}
return settings;
} catch (error) {
logConsoleAndDb(
`[databaseUtil] Failed to retrieve account settings for ${defaultSettings.activeDid}: ${error}`,
true,
);
// Return defaults on error
return defaultSettings;
}
} catch (error) {
logConsoleAndDb(
`[databaseUtil] Failed to retrieve default settings: ${error}`,
true,
);
// Return minimal default settings on complete failure
return {
id: MASTER_SETTINGS_KEY,
activeDid: undefined,
apiServer: DEFAULT_ENDORSER_API_SERVER,
};
}
}
let lastCleanupDate: string | null = null;
export let memoryLogs: string[] = [];
/**
* Logs a message to the database with proper handling of concurrent writes
* @param message - The message to log
* @author Matthew Raymer
*/
export async function logToDb(message: string): Promise<void> {
const platform = PlatformServiceFactory.getInstance();
const todayKey = new Date().toDateString();
const nowKey = new Date().toISOString();
try {
memoryLogs.push(`${new Date().toISOString()} ${message}`);
// Try to insert first, if it fails due to UNIQUE constraint, update instead
await platform.dbExec("INSERT INTO logs (date, message) VALUES (?, ?)", [
nowKey,
message,
]);
// Clean up old logs (keep only last 7 days) - do this less frequently
// Only clean up if the date is different from the last cleanup
if (!lastCleanupDate || lastCleanupDate !== todayKey) {
const sevenDaysAgo = new Date(
new Date().getTime() - 7 * 24 * 60 * 60 * 1000,
);
memoryLogs = memoryLogs.filter(
(log) => log.split(" ")[0] > sevenDaysAgo.toDateString(),
);
await platform.dbExec("DELETE FROM logs WHERE date < ?", [
sevenDaysAgo.toDateString(),
]);
lastCleanupDate = todayKey;
}
} catch (error) {
// Log to console as fallback
// eslint-disable-next-line no-console
console.error(
"Error logging to database:",
error,
" ... for original message:",
message,
);
}
}
// similar method is in the sw_scripts/additional-scripts.js file
export async function logConsoleAndDb(
message: string,
isError = false,
): Promise<void> {
if (isError) {
logger.error(`${new Date().toISOString()} ${message}`);
} else {
logger.log(`${new Date().toISOString()} ${message}`);
}
await logToDb(message);
}
/**
* 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>,
tableName: string,
): { sql: string; params: unknown[] } {
const columns = Object.keys(model).filter((key) => model[key] !== undefined);
const values = Object.values(model).filter((value) => value !== undefined);
const placeholders = values.map(() => "?").join(", ");
const insertSql = `INSERT INTO ${tableName} (${columns.join(", ")}) VALUES (${placeholders})`;
return {
sql: insertSql,
params: values,
};
}
/**
* 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>,
tableName: string,
whereClause: string,
whereParams: unknown[] = [],
): { sql: string; params: unknown[] } {
// Filter out undefined/null values and create SET clause
const setClauses: string[] = [];
const params: unknown[] = [];
Object.entries(model).forEach(([key, value]) => {
if (value !== undefined) {
setClauses.push(`${key} = ?`);
params.push(value);
}
});
if (setClauses.length === 0) {
throw new Error("No valid fields to update");
}
const sql = `UPDATE ${tableName} SET ${setClauses.join(", ")} WHERE ${whereClause}`;
return {
sql,
params: [...params, ...whereParams],
};
}
export function mapQueryResultToValues(
record: QueryExecResult | undefined,
): Array<Record<string, unknown>> {
if (!record) {
return [];
}
return mapColumnsToValues(record.columns, record.values) as Array<
Record<string, unknown>
>;
}
/**
* Maps an array of column names to an array of value arrays, creating objects where each column name
* is mapped to its corresponding value.
* @param columns Array of column names to use as object keys
* @param values Array of value arrays, where each inner array corresponds to one row of data
* @returns Array of objects where each object maps column names to their corresponding values
*/
export function mapColumnsToValues(
columns: string[],
values: unknown[][],
): Array<Record<string, unknown>> {
return values.map((row) => {
const obj: Record<string, unknown> = {};
columns.forEach((column, index) => {
obj[column] = row[index];
});
return obj;
});
}

46
src/db/index.ts

@ -1,3 +1,9 @@
/**
* This is the original IndexedDB version of the database.
* It will eventually be replaced fully by the SQL version in databaseUtil.ts.
* Turn this on or off with the USE_DEXIE_DB constant in constants/app.ts.
*/
import BaseDexie, { Table } from "dexie"; import BaseDexie, { Table } from "dexie";
import { encrypted, Encryption } from "@pvermeer/dexie-encrypted-addon"; import { encrypted, Encryption } from "@pvermeer/dexie-encrypted-addon";
import * as R from "ramda"; import * as R from "ramda";
@ -26,8 +32,8 @@ type NonsensitiveTables = {
}; };
// Using 'unknown' instead of 'any' for stricter typing and to avoid TypeScript warnings // Using 'unknown' instead of 'any' for stricter typing and to avoid TypeScript warnings
export type SecretDexie<T extends unknown = SecretTable> = BaseDexie & T; type SecretDexie<T extends unknown = SecretTable> = BaseDexie & T;
export type SensitiveDexie<T extends unknown = SensitiveTables> = BaseDexie & T; type SensitiveDexie<T extends unknown = SensitiveTables> = BaseDexie & T;
export type NonsensitiveDexie<T extends unknown = NonsensitiveTables> = export type NonsensitiveDexie<T extends unknown = NonsensitiveTables> =
BaseDexie & T; BaseDexie & T;
@ -90,7 +96,7 @@ db.on("populate", async () => {
try { try {
await db.settings.add(DEFAULT_SETTINGS); await db.settings.add(DEFAULT_SETTINGS);
} catch (error) { } catch (error) {
console.error( logger.error(
"Error populating the database with default settings:", "Error populating the database with default settings:",
error, error,
); );
@ -99,12 +105,12 @@ db.on("populate", async () => {
// Helper function to safely open the database with retries // Helper function to safely open the database with retries
async function safeOpenDatabase(retries = 1, delay = 500): Promise<void> { async function safeOpenDatabase(retries = 1, delay = 500): Promise<void> {
// console.log("Starting safeOpenDatabase with retries:", retries); // logger.log("Starting safeOpenDatabase with retries:", retries);
for (let i = 0; i < retries; i++) { for (let i = 0; i < retries; i++) {
try { try {
// console.log(`Attempt ${i + 1}: Checking if database is open...`); // logger.log(`Attempt ${i + 1}: Checking if database is open...`);
if (!db.isOpen()) { if (!db.isOpen()) {
// console.log(`Attempt ${i + 1}: Database is closed, attempting to open...`); // logger.log(`Attempt ${i + 1}: Database is closed, attempting to open...`);
// Create a promise that rejects after 5 seconds // Create a promise that rejects after 5 seconds
const timeoutPromise = new Promise((_, reject) => { const timeoutPromise = new Promise((_, reject) => {
@ -113,19 +119,19 @@ async function safeOpenDatabase(retries = 1, delay = 500): Promise<void> {
// Race between the open operation and the timeout // Race between the open operation and the timeout
const openPromise = db.open(); const openPromise = db.open();
// console.log(`Attempt ${i + 1}: Waiting for db.open() promise...`); // logger.log(`Attempt ${i + 1}: Waiting for db.open() promise...`);
await Promise.race([openPromise, timeoutPromise]); await Promise.race([openPromise, timeoutPromise]);
// If we get here, the open succeeded // If we get here, the open succeeded
// console.log(`Attempt ${i + 1}: Database opened successfully`); // logger.log(`Attempt ${i + 1}: Database opened successfully`);
return; return;
} }
// console.log(`Attempt ${i + 1}: Database was already open`); // logger.log(`Attempt ${i + 1}: Database was already open`);
return; return;
} catch (error) { } catch (error) {
console.error(`Attempt ${i + 1}: Database open failed:`, error); logger.error(`Attempt ${i + 1}: Database open failed:`, error);
if (i < retries - 1) { if (i < retries - 1) {
console.log(`Attempt ${i + 1}: Waiting ${delay}ms before retry...`); logger.log(`Attempt ${i + 1}: Waiting ${delay}ms before retry...`);
await new Promise((resolve) => setTimeout(resolve, delay)); await new Promise((resolve) => setTimeout(resolve, delay));
} else { } else {
throw error; throw error;
@ -142,16 +148,14 @@ export async function updateDefaultSettings(
delete settingsChanges.id; delete settingsChanges.id;
try { try {
try { try {
// console.log("Database state before open:", db.isOpen() ? "open" : "closed"); // logger.log("Database state before open:", db.isOpen() ? "open" : "closed");
// console.log("Database name:", db.name); // logger.log("Database name:", db.name);
// console.log("Database version:", db.verno); // logger.log("Database version:", db.verno);
await safeOpenDatabase(); await safeOpenDatabase();
} catch (openError: unknown) { } catch (openError: unknown) {
console.error("Failed to open database:", openError); logger.error("Failed to open database:", openError, String(openError));
const errorMessage =
openError instanceof Error ? openError.message : String(openError);
throw new Error( throw new Error(
`Database connection failed: ${errorMessage}. Please try again or restart the app.`, `The database connection failed. We recommend you try again or restart the app.`,
); );
} }
const result = await db.settings.update( const result = await db.settings.update(
@ -160,11 +164,13 @@ export async function updateDefaultSettings(
); );
return result; return result;
} catch (error) { } catch (error) {
console.error("Error updating default settings:", error); logger.error("Error updating default settings:", error);
if (error instanceof Error) { if (error instanceof Error) {
throw error; // Re-throw if it's already an Error with a message throw error; // Re-throw if it's already an Error with a message
} else { } else {
throw new Error(`Failed to update settings: ${error}`); throw new Error(
`Failed to update settings. We recommend you try again or restart the app.`,
);
} }
} }
} }

6
src/db/tables/accounts.ts

@ -45,6 +45,12 @@ export type Account = {
publicKeyHex: string; publicKeyHex: string;
}; };
// When finished with USE_DEXIE_DB, move these fields to Account and move identity and mnemonic here.
export type AccountEncrypted = Account & {
identityEncrBase64: string;
mnemonicEncrBase64: string;
};
/** /**
* Schema for the accounts table in the database. * Schema for the accounts table in the database.
* Fields starting with a $ character are encrypted. * Fields starting with a $ character are encrypted.

30
src/electron/main.ts

@ -25,6 +25,25 @@ function createWindow(): void {
logger.log("Checking preload path:", preloadPath); logger.log("Checking preload path:", preloadPath);
logger.log("Preload exists:", fs.existsSync(preloadPath)); logger.log("Preload exists:", fs.existsSync(preloadPath));
// Log environment and paths
logger.log("process.cwd():", process.cwd());
logger.log("__dirname:", __dirname);
logger.log("app.getAppPath():", app.getAppPath());
logger.log("app.isPackaged:", app.isPackaged);
// List files in __dirname and __dirname/www
try {
logger.log("Files in __dirname:", fs.readdirSync(__dirname));
const wwwDir = path.join(__dirname, "www");
if (fs.existsSync(wwwDir)) {
logger.log("Files in www:", fs.readdirSync(wwwDir));
} else {
logger.log("www directory does not exist in __dirname");
}
} catch (e) {
logger.error("Error reading directories:", e);
}
// Create the browser window. // Create the browser window.
const mainWindow = new BrowserWindow({ const mainWindow = new BrowserWindow({
width: 1200, width: 1200,
@ -88,7 +107,16 @@ function createWindow(): void {
logger.log("process.cwd():", process.cwd()); logger.log("process.cwd():", process.cwd());
} }
const indexPath = path.join(__dirname, "www", "index.html"); let indexPath = path.resolve(__dirname, "dist-electron", "www", "index.html");
if (!fs.existsSync(indexPath)) {
// Fallback for dev mode
indexPath = path.resolve(
process.cwd(),
"dist-electron",
"www",
"index.html",
);
}
if (isDev) { if (isDev) {
logger.log("Loading index from:", indexPath); logger.log("Loading index from:", indexPath);

31
src/electron/preload.js

@ -2,24 +2,33 @@ const { contextBridge, ipcRenderer } = require("electron");
const logger = { const logger = {
log: (message, ...args) => { log: (message, ...args) => {
// Always log in development, log with context in production
if (process.env.NODE_ENV !== "production") { if (process.env.NODE_ENV !== "production") {
/* eslint-disable no-console */ /* eslint-disable no-console */
console.log(message, ...args); console.log(`[Preload] ${message}`, ...args);
/* eslint-enable no-console */ /* eslint-enable no-console */
} }
}, },
warn: (message, ...args) => { warn: (message, ...args) => {
if (process.env.NODE_ENV !== "production") { // Always log warnings
/* eslint-disable no-console */ /* eslint-disable no-console */
console.warn(message, ...args); console.warn(`[Preload] ${message}`, ...args);
/* eslint-enable no-console */ /* eslint-enable no-console */
}
}, },
error: (message, ...args) => { error: (message, ...args) => {
// Always log errors
/* eslint-disable no-console */ /* eslint-disable no-console */
console.error(message, ...args); // Errors should always be logged console.error(`[Preload] ${message}`, ...args);
/* eslint-enable no-console */ /* eslint-enable no-console */
}, },
info: (message, ...args) => {
// Always log info in development, log with context in production
if (process.env.NODE_ENV !== "production") {
/* eslint-disable no-console */
console.info(`[Preload] ${message}`, ...args);
/* eslint-enable no-console */
}
},
}; };
// Use a more direct path resolution approach // Use a more direct path resolution approach
@ -41,7 +50,10 @@ const getPath = (pathType) => {
} }
}; };
logger.log("Preload script starting..."); logger.info("Preload script starting...");
// Force electron platform in the renderer process
window.process = { env: { VITE_PLATFORM: "electron" } };
try { try {
contextBridge.exposeInMainWorld("electronAPI", { contextBridge.exposeInMainWorld("electronAPI", {
@ -65,6 +77,7 @@ try {
env: { env: {
isElectron: true, isElectron: true,
isDev: process.env.NODE_ENV === "development", isDev: process.env.NODE_ENV === "development",
platform: "electron", // Explicitly set platform
}, },
// Path utilities // Path utilities
getBasePath: () => { getBasePath: () => {
@ -72,7 +85,7 @@ try {
}, },
}); });
logger.log("Preload script completed successfully"); logger.info("Preload script completed successfully");
} catch (error) { } catch (error) {
logger.error("Error in preload script:", error); logger.error("Error in preload script:", error);
} }

59
src/interfaces/absurd-sql.d.ts

@ -0,0 +1,59 @@
import type { QueryExecResult, SqlValue } from "./database";
declare module "@jlongster/sql.js" {
interface SQL {
Database: new (path: string, options?: { filename: boolean }) => AbsurdSqlDatabase;
FS: {
mkdir: (path: string) => void;
mount: (fs: any, options: any, path: string) => void;
open: (path: string, flags: string) => any;
close: (stream: any) => void;
};
register_for_idb: (fs: any) => void;
}
interface AbsurdSqlDatabase {
exec: (sql: string, params?: unknown[]) => Promise<QueryExecResult[]>;
run: (
sql: string,
params?: unknown[],
) => Promise<{ changes: number; lastId?: number }>;
}
const initSqlJs: (options?: {
locateFile?: (file: string) => string;
}) => Promise<SQL>;
export default initSqlJs;
}
declare module "absurd-sql" {
import type { SQL } from "@jlongster/sql.js";
export class SQLiteFS {
constructor(fs: any, backend: any);
}
}
declare module "absurd-sql/dist/indexeddb-backend" {
export default class IndexedDBBackend {
constructor();
}
}
declare module "absurd-sql/dist/indexeddb-main-thread" {
export interface SQLiteOptions {
filename?: string;
autoLoad?: boolean;
debug?: boolean;
}
export interface SQLiteDatabase {
exec: (sql: string, params?: unknown[]) => Promise<QueryExecResult[]>;
close: () => Promise<void>;
}
export function initSqlJs(options?: any): Promise<any>;
export function createDatabase(options?: SQLiteOptions): Promise<SQLiteDatabase>;
export function openDatabase(options?: SQLiteOptions): Promise<SQLiteDatabase>;
}

65
src/interfaces/claims.ts

@ -9,7 +9,6 @@ export interface AgreeVerifiableCredential {
// Note that previous VCs may have additional fields. // Note that previous VCs may have additional fields.
// https://endorser.ch/doc/html/transactions.html#id4 // https://endorser.ch/doc/html/transactions.html#id4
export interface GiveVerifiableCredential extends GenericVerifiableCredential { export interface GiveVerifiableCredential extends GenericVerifiableCredential {
"@context"?: string;
"@type": "GiveAction"; "@type": "GiveAction";
agent?: { identifier: string }; agent?: { identifier: string };
description?: string; description?: string;
@ -19,14 +18,45 @@ export interface GiveVerifiableCredential extends GenericVerifiableCredential {
object?: { amountOfThisGood: number; unitCode: string }; object?: { amountOfThisGood: number; unitCode: string };
provider?: GenericVerifiableCredential; provider?: GenericVerifiableCredential;
recipient?: { identifier: string }; recipient?: { identifier: string };
type: string[];
issuer: string;
issuanceDate: string;
credentialSubject: {
id: string;
type: "GiveAction";
offeredBy?: {
type: "Person";
identifier: string;
};
offeredTo?: {
type: "Person";
identifier: string;
};
offeredToProject?: {
type: "Project";
identifier: string;
};
offeredToProjectVisibleToDids?: string[];
offeredToVisibleToDids?: string[];
offeredByVisibleToDids?: string[];
amount: {
type: "QuantitativeValue";
value: number;
unitCode: string;
};
startTime?: string;
endTime?: string;
};
} }
// Note that previous VCs may have additional fields. // Note that previous VCs may have additional fields.
// https://endorser.ch/doc/html/transactions.html#id8 // https://endorser.ch/doc/html/transactions.html#id8
export interface OfferVerifiableCredential extends GenericVerifiableCredential { export interface OfferVerifiableCredential extends GenericVerifiableCredential {
"@context"?: string;
"@type": "Offer"; "@type": "Offer";
description?: string; description?: string;
fulfills?: { "@type": string; identifier?: string; lastClaimId?: string }[];
identifier?: string;
image?: string;
includesObject?: { amountOfThisGood: number; unitCode: string }; includesObject?: { amountOfThisGood: number; unitCode: string };
itemOffered?: { itemOffered?: {
description?: string; description?: string;
@ -37,9 +67,38 @@ export interface OfferVerifiableCredential extends GenericVerifiableCredential {
name?: string; name?: string;
}; };
}; };
offeredBy?: { identifier: string }; provider?: GenericVerifiableCredential;
recipient?: { identifier: string }; recipient?: { identifier: string };
validThrough?: string; validThrough?: string;
type: string[];
issuer: string;
issuanceDate: string;
credentialSubject: {
id: string;
type: "Offer";
offeredBy?: {
type: "Person";
identifier: string;
};
offeredTo?: {
type: "Person";
identifier: string;
};
offeredToProject?: {
type: "Project";
identifier: string;
};
offeredToProjectVisibleToDids?: string[];
offeredToVisibleToDids?: string[];
offeredByVisibleToDids?: string[];
amount: {
type: "QuantitativeValue";
value: number;
unitCode: string;
};
startTime?: string;
endTime?: string;
};
} }
// Note that previous VCs may have additional fields. // Note that previous VCs may have additional fields.

146
src/interfaces/common.ts

@ -1,6 +1,6 @@
// similar to VerifiableCredentialSubject... maybe rename this // similar to VerifiableCredentialSubject... maybe rename this
export interface GenericVerifiableCredential { export interface GenericVerifiableCredential {
"@context"?: string; "@context": string | string[];
"@type": string; "@type": string;
[key: string]: unknown; [key: string]: unknown;
} }
@ -34,3 +34,147 @@ export interface ErrorResult extends ResultWithType {
type: "error"; type: "error";
error: InternalError; error: InternalError;
} }
export interface KeyMeta {
did: string;
name?: string;
publicKeyHex: string;
mnemonic: string;
derivationPath: string;
registered?: boolean;
profileImageUrl?: string;
identity?: string; // Stringified IIdentifier object from Veramo
passkeyCredIdHex?: string; // The Webauthn credential ID in hex, if this is from a passkey
[key: string]: unknown;
}
export interface QuantitativeValue extends GenericVerifiableCredential {
"@type": "QuantitativeValue";
"@context": string | string[];
amountOfThisGood: number;
unitCode: string;
[key: string]: unknown;
}
export interface AxiosErrorResponse {
message?: string;
response?: {
data?: {
error?: {
message?: string;
};
[key: string]: unknown;
};
status?: number;
config?: unknown;
};
config?: unknown;
[key: string]: unknown;
}
export interface UserInfo {
did: string;
name: string;
publicEncKey: string;
registered: boolean;
profileImageUrl?: string;
nextPublicEncKeyHash?: string;
}
export interface CreateAndSubmitClaimResult {
success: boolean;
error?: string;
handleId?: string;
}
export interface PlanSummaryRecord {
handleId: string;
issuer: string;
claim: GenericVerifiableCredential;
[key: string]: unknown;
}
export interface Agent {
identifier?: string;
did?: string;
[key: string]: unknown;
}
export interface ClaimObject {
"@type": string;
"@context"?: string | string[];
fulfills?: Array<{
"@type": string;
identifier?: string;
[key: string]: unknown;
}>;
object?: GenericVerifiableCredential;
agent?: Agent;
participant?: {
identifier?: string;
[key: string]: unknown;
};
identifier?: string;
[key: string]: unknown;
}
export interface VerifiableCredentialClaim {
"@context": string | string[];
"@type": string;
type: string[];
credentialSubject: ClaimObject;
[key: string]: unknown;
}
export interface GiveVerifiableCredential extends GenericVerifiableCredential {
"@type": "GiveAction";
"@context": string | string[];
object?: GenericVerifiableCredential;
agent?: Agent;
participant?: {
identifier?: string;
[key: string]: unknown;
};
fulfills?: Array<{
"@type": string;
identifier?: string;
[key: string]: unknown;
}>;
[key: string]: unknown;
}
export interface OfferVerifiableCredential extends GenericVerifiableCredential {
"@type": "OfferAction";
"@context": string | string[];
object?: GenericVerifiableCredential;
agent?: Agent;
participant?: {
identifier?: string;
[key: string]: unknown;
};
itemOffered?: {
description?: string;
isPartOf?: {
"@type": string;
identifier: string;
[key: string]: unknown;
};
[key: string]: unknown;
};
[key: string]: unknown;
}
export interface RegisterVerifiableCredential
extends GenericVerifiableCredential {
"@type": "RegisterAction";
"@context": string | string[];
agent: {
identifier: string;
};
object: string;
participant?: {
identifier: string;
};
identifier?: string;
[key: string]: unknown;
}

15
src/interfaces/database.ts

@ -0,0 +1,15 @@
export type SqlValue = string | number | null | Uint8Array;
export interface QueryExecResult {
columns: Array<string>;
values: Array<Array<SqlValue>>;
}
export interface DatabaseService {
initialize(): Promise<void>;
query(sql: string, params?: unknown[]): Promise<QueryExecResult[]>;
run(
sql: string,
params?: unknown[],
): Promise<{ changes: number; lastId?: number }>;
}

40
src/interfaces/index.ts

@ -1,7 +1,37 @@
export * from "./claims"; export type {
export * from "./claims-result"; // From common.ts
export * from "./common"; GenericCredWrapper,
GenericVerifiableCredential,
KeyMeta,
// Exclude types that are also exported from other files
// GiveVerifiableCredential,
// OfferVerifiableCredential,
// RegisterVerifiableCredential,
// PlanSummaryRecord,
// UserInfo,
} from "./common";
export type {
// From claims.ts
GiveVerifiableCredential,
OfferVerifiableCredential,
RegisterVerifiableCredential,
} from "./claims";
export type {
// From claims-result.ts
CreateAndSubmitClaimResult,
} from "./claims-result";
export type {
// From records.ts
PlanSummaryRecord,
} from "./records";
export type {
// From user.ts
UserInfo,
} from "./user";
export * from "./limits"; export * from "./limits";
export * from "./records";
export * from "./user";
export * from "./deepLinks"; export * from "./deepLinks";

116
src/libs/crypto/index.ts

@ -159,7 +159,7 @@ export const nextDerivationPath = (origDerivPath: string) => {
}; };
// Base64 encoding/decoding utilities for browser // Base64 encoding/decoding utilities for browser
function base64ToArrayBuffer(base64: string): Uint8Array { export function base64ToArrayBuffer(base64: string): Uint8Array {
const binaryString = atob(base64); const binaryString = atob(base64);
const bytes = new Uint8Array(binaryString.length); const bytes = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) { for (let i = 0; i < binaryString.length; i++) {
@ -168,7 +168,7 @@ function base64ToArrayBuffer(base64: string): Uint8Array {
return bytes; return bytes;
} }
function arrayBufferToBase64(buffer: ArrayBuffer): string { export function arrayBufferToBase64(buffer: ArrayBuffer): string {
const binary = String.fromCharCode(...new Uint8Array(buffer)); const binary = String.fromCharCode(...new Uint8Array(buffer));
return btoa(binary); return btoa(binary);
} }
@ -178,7 +178,7 @@ const IV_LENGTH = 12;
const KEY_LENGTH = 256; const KEY_LENGTH = 256;
const ITERATIONS = 100000; const ITERATIONS = 100000;
// Encryption helper function // Message encryption helper function, used for onboarding meeting messages
export async function encryptMessage(message: string, password: string) { export async function encryptMessage(message: string, password: string) {
const encoder = new TextEncoder(); const encoder = new TextEncoder();
const salt = crypto.getRandomValues(new Uint8Array(SALT_LENGTH)); const salt = crypto.getRandomValues(new Uint8Array(SALT_LENGTH));
@ -226,7 +226,7 @@ export async function encryptMessage(message: string, password: string) {
return btoa(JSON.stringify(result)); return btoa(JSON.stringify(result));
} }
// Decryption helper function // Message decryption helper function, used for onboarding meeting messages
export async function decryptMessage(encryptedJson: string, password: string) { export async function decryptMessage(encryptedJson: string, password: string) {
const decoder = new TextDecoder(); const decoder = new TextDecoder();
const { salt, iv, encrypted } = JSON.parse(atob(encryptedJson)); const { salt, iv, encrypted } = JSON.parse(atob(encryptedJson));
@ -273,7 +273,7 @@ export async function decryptMessage(encryptedJson: string, password: string) {
} }
// Test function to verify encryption/decryption // Test function to verify encryption/decryption
export async function testEncryptionDecryption() { export async function testMessageEncryptionDecryption() {
try { try {
const testMessage = "Hello, this is a test message! 🚀"; const testMessage = "Hello, this is a test message! 🚀";
const testPassword = "myTestPassword123"; const testPassword = "myTestPassword123";
@ -299,9 +299,111 @@ export async function testEncryptionDecryption() {
logger.log("\nTesting with wrong password..."); logger.log("\nTesting with wrong password...");
try { try {
await decryptMessage(encrypted, "wrongPassword"); await decryptMessage(encrypted, "wrongPassword");
logger.log("Should not reach here"); logger.log("Incorrectly decrypted with wrong password ❌");
} catch (error) { } catch (error) {
logger.log("Correctly failed with wrong password ✅"); logger.log("Correctly failed to decrypt with wrong password ✅");
}
return success;
} catch (error) {
logger.error("Test failed with error:", error);
return false;
}
}
// Simple encryption using Node's crypto, used for the initial encryption of the identity and mnemonic
export async function simpleEncrypt(
text: string,
secret: ArrayBuffer,
): Promise<ArrayBuffer> {
const iv = crypto.getRandomValues(new Uint8Array(16));
// Derive a 256-bit key from the secret using SHA-256
const keyData = await crypto.subtle.digest("SHA-256", secret);
const key = await crypto.subtle.importKey(
"raw",
keyData,
{ name: "AES-GCM" },
false,
["encrypt"],
);
const encrypted = await crypto.subtle.encrypt(
{ name: "AES-GCM", iv },
key,
new TextEncoder().encode(text),
);
// Combine IV and encrypted data
const result = new Uint8Array(iv.length + encrypted.byteLength);
result.set(iv);
result.set(new Uint8Array(encrypted), iv.length);
return result.buffer;
}
// Simple decryption using Node's crypto, used for the default decryption of identity and mnemonic
export async function simpleDecrypt(
encryptedText: ArrayBuffer,
secret: ArrayBuffer,
): Promise<string> {
const data = new Uint8Array(encryptedText);
// Extract IV and encrypted data
const iv = data.slice(0, 16);
const encrypted = data.slice(16);
// Derive the same 256-bit key from the secret using SHA-256
const keyData = await crypto.subtle.digest("SHA-256", secret);
const key = await crypto.subtle.importKey(
"raw",
keyData,
{ name: "AES-GCM" },
false,
["decrypt"],
);
const decrypted = await crypto.subtle.decrypt(
{ name: "AES-GCM", iv },
key,
encrypted,
);
return new TextDecoder().decode(decrypted);
}
// Test function for simple encryption/decryption
export async function testSimpleEncryptionDecryption() {
try {
const testMessage = "Hello, this is a test message! 🚀";
const testSecret = crypto.getRandomValues(new Uint8Array(32));
logger.log("Original message:", testMessage);
// Test encryption
logger.log("Encrypting...");
const encrypted = await simpleEncrypt(testMessage, testSecret);
const encryptedBase64 = arrayBufferToBase64(encrypted);
logger.log("Encrypted result:", encryptedBase64);
// Test decryption
logger.log("Decrypting...");
const encryptedArrayBuffer = base64ToArrayBuffer(encryptedBase64);
const decrypted = await simpleDecrypt(encryptedArrayBuffer, testSecret);
logger.log("Decrypted result:", decrypted);
// Verify
const success = testMessage === decrypted;
logger.log("Test " + (success ? "PASSED ✅" : "FAILED ❌"));
logger.log("Messages match:", success);
// Test with wrong secret
logger.log("\nTesting with wrong secret...");
try {
await simpleDecrypt(encryptedArrayBuffer, new Uint8Array(32));
logger.log("Incorrectly decrypted with wrong secret ❌");
} catch (error) {
logger.log("Correctly failed to decrypt with wrong secret ✅");
} }
return success; return success;

19
src/libs/crypto/vc/index.ts

@ -17,29 +17,12 @@ import { didEthLocalResolver } from "./did-eth-local-resolver";
import { PEER_DID_PREFIX, verifyPeerSignature } from "./didPeer"; import { PEER_DID_PREFIX, verifyPeerSignature } from "./didPeer";
import { base64urlDecodeString, createDidPeerJwt } from "./passkeyDidPeer"; import { base64urlDecodeString, createDidPeerJwt } from "./passkeyDidPeer";
import { urlBase64ToUint8Array } from "./util"; import { urlBase64ToUint8Array } from "./util";
import { KeyMeta } from "../../../interfaces/common";
export const ETHR_DID_PREFIX = "did:ethr:"; export const ETHR_DID_PREFIX = "did:ethr:";
export const JWT_VERIFY_FAILED_CODE = "JWT_VERIFY_FAILED"; export const JWT_VERIFY_FAILED_CODE = "JWT_VERIFY_FAILED";
export const UNSUPPORTED_DID_METHOD_CODE = "UNSUPPORTED_DID_METHOD"; export const UNSUPPORTED_DID_METHOD_CODE = "UNSUPPORTED_DID_METHOD";
/**
* Meta info about a key
*/
export interface KeyMeta {
/**
* Decentralized ID for the key
*/
did: string;
/**
* Stringified IIDentifier object from Veramo
*/
identity?: string;
/**
* The Webauthn credential ID in hex, if this is from a passkey
*/
passkeyCredIdHex?: string;
}
const ethLocalResolver = new Resolver({ ethr: didEthLocalResolver }); const ethLocalResolver = new Resolver({ ethr: didEthLocalResolver });
/** /**

157
src/libs/crypto/vc/passkeyDidPeer.ts

@ -1,7 +1,6 @@
import { Buffer } from "buffer/"; import { Buffer } from "buffer/";
import { JWTPayload } from "did-jwt"; import { JWTPayload } from "did-jwt";
import { DIDResolutionResult } from "did-resolver"; import { p256 } from "@noble/curves/p256";
import { sha256 } from "ethereum-cryptography/sha256.js";
import { import {
startAuthentication, startAuthentication,
startRegistration, startRegistration,
@ -11,12 +10,13 @@ import {
generateRegistrationOptions, generateRegistrationOptions,
verifyAuthenticationResponse, verifyAuthenticationResponse,
verifyRegistrationResponse, verifyRegistrationResponse,
VerifyAuthenticationResponseOpts,
} from "@simplewebauthn/server"; } from "@simplewebauthn/server";
import { VerifyAuthenticationResponseOpts } from "@simplewebauthn/server/esm/authentication/verifyAuthenticationResponse";
import { import {
Base64URLString, Base64URLString,
PublicKeyCredentialCreationOptionsJSON, PublicKeyCredentialCreationOptionsJSON,
PublicKeyCredentialRequestOptionsJSON, PublicKeyCredentialRequestOptionsJSON,
AuthenticatorAssertionResponse,
} from "@simplewebauthn/types"; } from "@simplewebauthn/types";
import { AppString } from "../../../constants/app"; import { AppString } from "../../../constants/app";
@ -194,16 +194,19 @@ export class PeerSetup {
}, },
}; };
const credential = await navigator.credentials.get(options); const credential = (await navigator.credentials.get(
options,
)) as PublicKeyCredential;
// console.log("nav credential get", credential); // console.log("nav credential get", credential);
this.authenticatorData = credential?.response.authenticatorData; const response = credential?.response as AuthenticatorAssertionResponse;
this.authenticatorData = response?.authenticatorData;
const authenticatorDataBase64Url = arrayBufferToBase64URLString( const authenticatorDataBase64Url = arrayBufferToBase64URLString(
this.authenticatorData as ArrayBuffer, this.authenticatorData as ArrayBuffer,
); );
this.clientDataJsonBase64Url = arrayBufferToBase64URLString( this.clientDataJsonBase64Url = arrayBufferToBase64URLString(
credential?.response.clientDataJSON, response?.clientDataJSON,
); );
// Our custom type of JWANT means the signature is based on a concatenation of the two Webauthn properties // Our custom type of JWANT means the signature is based on a concatenation of the two Webauthn properties
@ -228,9 +231,7 @@ export class PeerSetup {
.replace(/\//g, "_") .replace(/\//g, "_")
.replace(/=+$/, ""); .replace(/=+$/, "");
const origSignature = Buffer.from(credential?.response.signature).toString( const origSignature = Buffer.from(response?.signature).toString("base64");
"base64",
);
this.signature = origSignature this.signature = origSignature
.replace(/\+/g, "-") .replace(/\+/g, "-")
.replace(/\//g, "_") .replace(/\//g, "_")
@ -315,24 +316,18 @@ export async function createDidPeerJwt(
// ... and this import: // ... and this import:
// import { p256 } from "@noble/curves/p256"; // import { p256 } from "@noble/curves/p256";
export async function verifyJwtP256( export async function verifyJwtP256(
credIdHex: string,
issuerDid: string, issuerDid: string,
authenticatorData: ArrayBuffer, authenticatorData: ArrayBuffer,
challenge: Uint8Array, challenge: Uint8Array,
clientDataJsonBase64Url: Base64URLString,
signature: Base64URLString, signature: Base64URLString,
) { ) {
const authDataFromBase = Buffer.from(authenticatorData); const authDataFromBase = Buffer.from(authenticatorData);
const clientDataFromBase = Buffer.from(clientDataJsonBase64Url, "base64");
const sigBuffer = Buffer.from(signature, "base64"); const sigBuffer = Buffer.from(signature, "base64");
const finalSigBuffer = unwrapEC2Signature(sigBuffer); const finalSigBuffer = unwrapEC2Signature(sigBuffer);
const publicKeyBytes = peerDidToPublicKeyBytes(issuerDid); const publicKeyBytes = peerDidToPublicKeyBytes(issuerDid);
// Hash the client data // Use challenge in preimage construction
const hash = sha256(clientDataFromBase); const preimage = Buffer.concat([authDataFromBase, Buffer.from(challenge)]);
// Construct the preimage
const preimage = Buffer.concat([authDataFromBase, hash]);
const isValid = p256.verify( const isValid = p256.verify(
finalSigBuffer, finalSigBuffer,
@ -383,122 +378,37 @@ export async function verifyJwtSimplewebauthn(
// similar code is in endorser-ch util-crypto.ts verifyPeerSignature // similar code is in endorser-ch util-crypto.ts verifyPeerSignature
export async function verifyJwtWebCrypto( export async function verifyJwtWebCrypto(
credId: Base64URLString,
issuerDid: string, issuerDid: string,
authenticatorData: ArrayBuffer, authenticatorData: ArrayBuffer,
challenge: Uint8Array, challenge: Uint8Array,
clientDataJsonBase64Url: Base64URLString,
signature: Base64URLString, signature: Base64URLString,
) { ) {
const authDataFromBase = Buffer.from(authenticatorData); const authDataFromBase = Buffer.from(authenticatorData);
const clientDataFromBase = Buffer.from(clientDataJsonBase64Url, "base64");
const sigBuffer = Buffer.from(signature, "base64"); const sigBuffer = Buffer.from(signature, "base64");
const finalSigBuffer = unwrapEC2Signature(sigBuffer); const finalSigBuffer = unwrapEC2Signature(sigBuffer);
// Hash the client data // Use challenge in preimage construction
const hash = sha256(clientDataFromBase); const preimage = Buffer.concat([authDataFromBase, Buffer.from(challenge)]);
// Construct the preimage
const preimage = Buffer.concat([authDataFromBase, hash]);
return verifyPeerSignature(preimage, issuerDid, finalSigBuffer); return verifyPeerSignature(preimage, issuerDid, finalSigBuffer);
} }
// eslint-disable-next-line @typescript-eslint/no-unused-vars // Remove unused functions:
async function peerDidToDidDocument(did: string): Promise<DIDResolutionResult> { // - peerDidToDidDocument
if (!did.startsWith("did:peer:0z")) { // - COSEtoPEM
throw new Error( // - base64urlDecodeArrayBuffer
"This only verifies a peer DID, method 0, encoded base58btc.", // - base64urlEncodeArrayBuffer
); // - pemToCryptoKey
}
// this is basically hard-coded from https://www.w3.org/TR/did-core/#example-various-verification-method-types
// (another reference is the @aviarytech/did-peer resolver)
/**
* Looks like JsonWebKey2020 isn't too difficult:
* - change context security/suites link to jws-2020/v1
* - change publicKeyMultibase to publicKeyJwk generated with cborToKeys
* - change type to JsonWebKey2020
*/
const id = did.split(":")[2];
const multibase = id.slice(1);
const encnumbasis = multibase.slice(1);
const didDocument = {
"@context": [
"https://www.w3.org/ns/did/v1",
"https://w3id.org/security/suites/secp256k1-2019/v1",
],
assertionMethod: [did + "#" + encnumbasis],
authentication: [did + "#" + encnumbasis],
capabilityDelegation: [did + "#" + encnumbasis],
capabilityInvocation: [did + "#" + encnumbasis],
id: did,
keyAgreement: undefined,
service: undefined,
verificationMethod: [
{
controller: did,
id: did + "#" + encnumbasis,
publicKeyMultibase: multibase,
type: "EcdsaSecp256k1VerificationKey2019",
},
],
};
return {
didDocument,
didDocumentMetadata: {},
didResolutionMetadata: { contentType: "application/did+ld+json" },
};
}
// convert COSE public key to PEM format
// eslint-disable-next-line @typescript-eslint/no-unused-vars
function COSEtoPEM(cose: Buffer) {
// const alg = cose.get(3); // Algorithm
const x = cose[-2]; // x-coordinate
const y = cose[-3]; // y-coordinate
// Ensure the coordinates are in the correct format
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error because it complains about the type of x and y
const pubKeyBuffer = Buffer.concat([Buffer.from([0x04]), x, y]);
// Convert to PEM format // Keep only the used functions:
const pem = `-----BEGIN PUBLIC KEY-----
${pubKeyBuffer.toString("base64")}
-----END PUBLIC KEY-----`;
return pem;
}
// tried the base64url library but got an error using their Buffer
export function base64urlDecodeString(input: string) { export function base64urlDecodeString(input: string) {
return atob(input.replace(/-/g, "+").replace(/_/g, "/")); return atob(input.replace(/-/g, "+").replace(/_/g, "/"));
} }
// tried the base64url library but got an error using their Buffer
export function base64urlEncodeString(input: string) { export function base64urlEncodeString(input: string) {
return btoa(input).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); return btoa(input).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
} }
// eslint-disable-next-line @typescript-eslint/no-unused-vars
function base64urlDecodeArrayBuffer(input: string) {
input = input.replace(/-/g, "+").replace(/_/g, "/");
const pad = input.length % 4 === 0 ? "" : "====".slice(input.length % 4);
const str = atob(input + pad);
const bytes = new Uint8Array(str.length);
for (let i = 0; i < str.length; i++) {
bytes[i] = str.charCodeAt(i);
}
return bytes.buffer;
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
function base64urlEncodeArrayBuffer(buffer: ArrayBuffer) {
const str = String.fromCharCode(...new Uint8Array(buffer));
return base64urlEncodeString(str);
}
// from @simplewebauthn/browser // from @simplewebauthn/browser
function arrayBufferToBase64URLString(buffer: ArrayBuffer) { function arrayBufferToBase64URLString(buffer: ArrayBuffer) {
const bytes = new Uint8Array(buffer); const bytes = new Uint8Array(buffer);
@ -523,28 +433,3 @@ function base64URLStringToArrayBuffer(base64URLString: string) {
} }
return buffer; return buffer;
} }
// eslint-disable-next-line @typescript-eslint/no-unused-vars
async function pemToCryptoKey(pem: string) {
const binaryDerString = atob(
pem
.split("\n")
.filter((x) => !x.includes("-----"))
.join(""),
);
const binaryDer = new Uint8Array(binaryDerString.length);
for (let i = 0; i < binaryDerString.length; i++) {
binaryDer[i] = binaryDerString.charCodeAt(i);
}
// console.log("binaryDer", binaryDer.buffer);
return await window.crypto.subtle.importKey(
"spki",
binaryDer.buffer,
{
name: "RSASSA-PKCS1-v1_5",
hash: "SHA-256",
},
true,
["verify"],
);
}

481
src/libs/endorserServer.ts

@ -26,29 +26,37 @@ import {
DEFAULT_IMAGE_API_SERVER, DEFAULT_IMAGE_API_SERVER,
NotificationIface, NotificationIface,
APP_SERVER, APP_SERVER,
USE_DEXIE_DB,
} from "../constants/app"; } from "../constants/app";
import { Contact } from "../db/tables/contacts"; import { Contact } from "../db/tables/contacts";
import { accessToken, deriveAddress, nextDerivationPath } from "../libs/crypto"; import { accessToken, deriveAddress, nextDerivationPath } from "../libs/crypto";
import { logConsoleAndDb, NonsensitiveDexie } from "../db/index"; import { NonsensitiveDexie } from "../db/index";
import { logConsoleAndDb } from "../db/databaseUtil";
import { import {
retrieveAccountMetadata, retrieveAccountMetadata,
retrieveFullyDecryptedAccount, retrieveFullyDecryptedAccount,
getPasskeyExpirationSeconds, getPasskeyExpirationSeconds,
} from "../libs/util"; } from "../libs/util";
import { createEndorserJwtForKey, KeyMeta } from "../libs/crypto/vc"; import { createEndorserJwtForKey } from "../libs/crypto/vc";
import { KeyMeta } from "../interfaces/common";
import { import {
GiveVerifiableCredential,
OfferVerifiableCredential,
RegisterVerifiableCredential,
GenericVerifiableCredential,
GenericCredWrapper, GenericCredWrapper,
PlanSummaryRecord, GenericVerifiableCredential,
AxiosErrorResponse,
UserInfo, UserInfo,
CreateAndSubmitClaimResult, CreateAndSubmitClaimResult,
} from "../interfaces"; PlanSummaryRecord,
GiveVerifiableCredential,
OfferVerifiableCredential,
RegisterVerifiableCredential,
ClaimObject,
VerifiableCredentialClaim,
Agent,
QuantitativeValue,
} from "../interfaces/common";
import { logger } from "../utils/logger"; import { logger } from "../utils/logger";
import { PlatformServiceFactory } from "@/services/PlatformServiceFactory";
/** /**
* Standard context for schema.org data * Standard context for schema.org data
@ -100,7 +108,10 @@ export const ENDORSER_CH_HANDLE_PREFIX = "https://endorser.ch/entity/";
export const BLANK_GENERIC_SERVER_RECORD: GenericCredWrapper<GenericVerifiableCredential> = export const BLANK_GENERIC_SERVER_RECORD: GenericCredWrapper<GenericVerifiableCredential> =
{ {
claim: { "@type": "" }, claim: {
"@context": SCHEMA_ORG_CONTEXT,
"@type": "",
},
handleId: "", handleId: "",
id: "", id: "",
issuedAt: "", issuedAt: "",
@ -125,7 +136,7 @@ export function isDid(did: string): boolean {
* @param {string} did - The DID to check * @param {string} did - The DID to check
* @returns {boolean} True if DID is hidden * @returns {boolean} True if DID is hidden
*/ */
export function isHiddenDid(did: string): boolean { export function isHiddenDid(did: string | undefined): boolean {
return did === HIDDEN_DID; return did === HIDDEN_DID;
} }
@ -180,37 +191,21 @@ export function isEmptyOrHiddenDid(did?: string): boolean {
* }; * };
* testRecursivelyOnStrings(isHiddenDid, obj); // Returns: true * testRecursivelyOnStrings(isHiddenDid, obj); // Returns: true
*/ */
function testRecursivelyOnStrings( const testRecursivelyOnStrings = (
func: (arg0: unknown) => boolean,
input: unknown, input: unknown,
): boolean { test: (s: string) => boolean,
// Test direct string values ): boolean => {
if (Object.prototype.toString.call(input) === "[object String]") { if (typeof input === "string") {
return func(input); return test(input);
} } else if (Array.isArray(input)) {
// Recursively test objects and arrays return input.some((item) => testRecursivelyOnStrings(item, test));
else if (input instanceof Object) { } else if (input && typeof input === "object") {
if (!Array.isArray(input)) { return Object.values(input as Record<string, unknown>).some((value) =>
// Handle plain objects testRecursivelyOnStrings(value, test),
for (const key in input) { );
if (testRecursivelyOnStrings(func, input[key])) {
return true;
}
}
} else {
// Handle arrays
for (const value of input) {
if (testRecursivelyOnStrings(func, value)) {
return true;
}
}
}
return false;
} else {
// Non-string, non-object values can't contain strings
return false;
} }
} return false;
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
export function containsHiddenDid(obj: any) { export function containsHiddenDid(obj: any) {
@ -551,7 +546,11 @@ export async function setPlanInCache(
* @returns {string|undefined} User-friendly message or undefined if none found * @returns {string|undefined} User-friendly message or undefined if none found
*/ */
export function serverMessageForUser(error: unknown): string | undefined { export function serverMessageForUser(error: unknown): string | undefined {
return error?.response?.data?.error?.message; if (error && typeof error === "object" && "response" in error) {
const err = error as AxiosErrorResponse;
return err.response?.data?.error?.message;
}
return undefined;
} }
/** /**
@ -573,18 +572,27 @@ export function errorStringForLog(error: unknown) {
// --- property '_value' closes the circle // --- property '_value' closes the circle
} }
let fullError = "" + error + " - JSON: " + stringifiedError; let fullError = "" + error + " - JSON: " + stringifiedError;
const errorResponseText = JSON.stringify(error.response);
// for some reason, error.response is not included in stringify result (eg. for 400 errors on invite redemptions) if (error && typeof error === "object" && "response" in error) {
if (!R.empty(errorResponseText) && !fullError.includes(errorResponseText)) { const err = error as AxiosErrorResponse;
// add error.response stuff const errorResponseText = JSON.stringify(err.response);
if (R.equals(error?.config, error?.response?.config)) { // for some reason, error.response is not included in stringify result (eg. for 400 errors on invite redemptions)
// but exclude "config" because it's already in there if (!R.empty(errorResponseText) && !fullError.includes(errorResponseText)) {
const newErrorResponseText = JSON.stringify( // add error.response stuff
R.omit(["config"] as never[], error.response), if (
); err.response?.config &&
fullError += " - .response w/o same config JSON: " + newErrorResponseText; err.config &&
} else { R.equals(err.config, err.response.config)
fullError += " - .response JSON: " + errorResponseText; ) {
// but exclude "config" because it's already in there
const newErrorResponseText = JSON.stringify(
R.omit(["config"] as never[], err.response),
);
fullError +=
" - .response w/o same config JSON: " + newErrorResponseText;
} else {
fullError += " - .response JSON: " + errorResponseText;
}
} }
} }
return fullError; return fullError;
@ -650,70 +658,89 @@ export function hydrateGive(
unitCode?: string, unitCode?: string,
fulfillsProjectHandleId?: string, fulfillsProjectHandleId?: string,
fulfillsOfferHandleId?: string, fulfillsOfferHandleId?: string,
isTrade: boolean = false, // remove, because this app is all for gifting isTrade: boolean = false,
imageUrl?: string, imageUrl?: string,
providerPlanHandleId?: string, providerPlanHandleId?: string,
lastClaimId?: string, lastClaimId?: string,
): GiveVerifiableCredential { ): GiveVerifiableCredential {
// Remember: replace values or erase if it's null
const vcClaim: GiveVerifiableCredential = vcClaimOrig const vcClaim: GiveVerifiableCredential = vcClaimOrig
? R.clone(vcClaimOrig) ? R.clone(vcClaimOrig)
: { : {
"@context": SCHEMA_ORG_CONTEXT, "@context": SCHEMA_ORG_CONTEXT,
"@type": "GiveAction", "@type": "GiveAction",
object: undefined,
agent: undefined,
fulfills: [],
}; };
if (lastClaimId) { if (lastClaimId) {
// this is an edit
vcClaim.lastClaimId = lastClaimId; vcClaim.lastClaimId = lastClaimId;
delete vcClaim.identifier; delete vcClaim.identifier;
} }
vcClaim.agent = fromDid ? { identifier: fromDid } : undefined; if (fromDid) {
vcClaim.recipient = toDid ? { identifier: toDid } : undefined; vcClaim.agent = { identifier: fromDid };
}
if (toDid) {
vcClaim.recipient = { identifier: toDid };
}
vcClaim.description = description || undefined; vcClaim.description = description || undefined;
vcClaim.object =
amount && !isNaN(amount)
? { amountOfThisGood: amount, unitCode: unitCode || "HUR" }
: undefined;
// ensure fulfills is an array if (amount && !isNaN(amount)) {
const quantitativeValue: QuantitativeValue = {
"@context": SCHEMA_ORG_CONTEXT,
"@type": "QuantitativeValue",
amountOfThisGood: amount,
unitCode: unitCode || "HUR",
};
vcClaim.object = quantitativeValue;
}
// Initialize fulfills array if not present
if (!Array.isArray(vcClaim.fulfills)) { if (!Array.isArray(vcClaim.fulfills)) {
vcClaim.fulfills = vcClaim.fulfills ? [vcClaim.fulfills] : []; vcClaim.fulfills = [];
} }
// ... and replace or add each element, ending with Trade or Donate
// I realize the following doesn't change any elements that are not PlanAction or Offer or Trade/Action. // Filter and add fulfills elements
vcClaim.fulfills = vcClaim.fulfills.filter( vcClaim.fulfills = vcClaim.fulfills.filter(
(elem) => elem["@type"] !== "PlanAction", (elem: { "@type": string }) => elem["@type"] !== "PlanAction",
); );
if (fulfillsProjectHandleId) { if (fulfillsProjectHandleId) {
vcClaim.fulfills.push({ vcClaim.fulfills.push({
"@type": "PlanAction", "@type": "PlanAction",
identifier: fulfillsProjectHandleId, identifier: fulfillsProjectHandleId,
}); });
} }
vcClaim.fulfills = vcClaim.fulfills.filter( vcClaim.fulfills = vcClaim.fulfills.filter(
(elem) => elem["@type"] !== "Offer", (elem: { "@type": string }) => elem["@type"] !== "Offer",
); );
if (fulfillsOfferHandleId) { if (fulfillsOfferHandleId) {
vcClaim.fulfills.push({ vcClaim.fulfills.push({
"@type": "Offer", "@type": "Offer",
identifier: fulfillsOfferHandleId, identifier: fulfillsOfferHandleId,
}); });
} }
// do Trade/Donate last because current endorser.ch only looks at the first for plans & offers
vcClaim.fulfills = vcClaim.fulfills.filter( vcClaim.fulfills = vcClaim.fulfills.filter(
(elem) => (elem: { "@type": string }) =>
elem["@type"] !== "DonateAction" && elem["@type"] !== "TradeAction", elem["@type"] !== "DonateAction" && elem["@type"] !== "TradeAction",
); );
vcClaim.fulfills.push({ "@type": isTrade ? "TradeAction" : "DonateAction" });
vcClaim.fulfills.push({
"@type": isTrade ? "TradeAction" : "DonateAction",
});
vcClaim.image = imageUrl || undefined; vcClaim.image = imageUrl || undefined;
vcClaim.provider = providerPlanHandleId if (providerPlanHandleId) {
? { "@type": "PlanAction", identifier: providerPlanHandleId } vcClaim.provider = {
: undefined; "@type": "PlanAction",
identifier: providerPlanHandleId,
};
}
return vcClaim; return vcClaim;
} }
@ -826,29 +853,38 @@ export function hydrateOffer(
validThrough?: string, validThrough?: string,
lastClaimId?: string, lastClaimId?: string,
): OfferVerifiableCredential { ): OfferVerifiableCredential {
// Remember: replace values or erase if it's null
const vcClaim: OfferVerifiableCredential = vcClaimOrig const vcClaim: OfferVerifiableCredential = vcClaimOrig
? R.clone(vcClaimOrig) ? R.clone(vcClaimOrig)
: { : {
"@context": SCHEMA_ORG_CONTEXT, "@context": SCHEMA_ORG_CONTEXT,
"@type": "Offer", "@type": "OfferAction",
object: undefined,
agent: undefined,
itemOffered: {},
}; };
if (lastClaimId) { if (lastClaimId) {
// this is an edit
vcClaim.lastClaimId = lastClaimId; vcClaim.lastClaimId = lastClaimId;
delete vcClaim.identifier; delete vcClaim.identifier;
} }
vcClaim.offeredBy = fromDid ? { identifier: fromDid } : undefined; if (fromDid) {
vcClaim.recipient = toDid ? { identifier: toDid } : undefined; vcClaim.agent = { identifier: fromDid };
}
if (toDid) {
vcClaim.recipient = { identifier: toDid };
}
vcClaim.description = conditionDescription || undefined; vcClaim.description = conditionDescription || undefined;
vcClaim.includesObject = if (amount && !isNaN(amount)) {
amount && !isNaN(amount) const quantitativeValue: QuantitativeValue = {
? { amountOfThisGood: amount, unitCode: unitCode || "HUR" } "@context": SCHEMA_ORG_CONTEXT,
: undefined; "@type": "QuantitativeValue",
amountOfThisGood: amount,
unitCode: unitCode || "HUR",
};
vcClaim.object = quantitativeValue;
}
if (itemDescription || fulfillsProjectHandleId) { if (itemDescription || fulfillsProjectHandleId) {
vcClaim.itemOffered = vcClaim.itemOffered || {}; vcClaim.itemOffered = vcClaim.itemOffered || {};
@ -860,6 +896,7 @@ export function hydrateOffer(
}; };
} }
} }
vcClaim.validThrough = validThrough || undefined; vcClaim.validThrough = validThrough || undefined;
return vcClaim; return vcClaim;
@ -988,20 +1025,19 @@ export async function createAndSubmitClaim(
}, },
}); });
return { type: "success", response }; return { success: true, handleId: response.data?.handleId };
// eslint-disable-next-line @typescript-eslint/no-explicit-any } catch (error: unknown) {
} catch (error: any) {
logger.error("Error submitting claim:", error); logger.error("Error submitting claim:", error);
const errorMessage: string = const errorMessage: string =
serverMessageForUser(error) || serverMessageForUser(error) ||
error.message || (error && typeof error === "object" && "message" in error
? String(error.message)
: undefined) ||
"Got some error submitting the claim. Check your permissions, network, and error logs."; "Got some error submitting the claim. Check your permissions, network, and error logs.";
return { return {
type: "error", success: false,
error: { error: errorMessage,
error: errorMessage,
},
}; };
} }
} }
@ -1031,12 +1067,9 @@ export async function generateEndorserJwtUrlForAccount(
} }
// Add the next key -- not recommended for the QR code for such a high resolution // Add the next key -- not recommended for the QR code for such a high resolution
if (isContact && account?.mnemonic && account?.derivationPath) { if (isContact) {
const newDerivPath = nextDerivationPath(account.derivationPath as string); const newDerivPath = nextDerivationPath(account.derivationPath);
const nextPublicHex = deriveAddress( const nextPublicHex = deriveAddress(account.mnemonic, newDerivPath)[2];
account.mnemonic as string,
newDerivPath,
)[2];
const nextPublicEncKey = Buffer.from(nextPublicHex, "hex"); const nextPublicEncKey = Buffer.from(nextPublicHex, "hex");
const nextPublicEncKeyHash = sha256(nextPublicEncKey); const nextPublicEncKeyHash = sha256(nextPublicEncKey);
const nextPublicEncKeyHashBase64 = const nextPublicEncKeyHashBase64 =
@ -1104,21 +1137,21 @@ export const capitalizeAndInsertSpacesBeforeCaps = (text: string) => {
similar code is also contained in endorser-mobile similar code is also contained in endorser-mobile
**/ **/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const claimSummary = ( const claimSummary = (
claim: GenericCredWrapper<GenericVerifiableCredential>, claim:
| GenericVerifiableCredential
| GenericCredWrapper<GenericVerifiableCredential>,
) => { ) => {
if (!claim) { if (!claim) {
// to differentiate from "something" above
return "something"; return "something";
} }
let specificClaim: let specificClaim: GenericVerifiableCredential;
| GenericVerifiableCredential if ("claim" in claim) {
| GenericCredWrapper<GenericVerifiableCredential> = claim; // It's a GenericCredWrapper
if (claim.claim) { specificClaim = claim.claim as GenericVerifiableCredential;
// probably a Verified Credential } else {
// eslint-disable-next-line @typescript-eslint/no-explicit-any // It's already a GenericVerifiableCredential
specificClaim = claim.claim; specificClaim = claim;
} }
if (Array.isArray(specificClaim)) { if (Array.isArray(specificClaim)) {
if (specificClaim.length === 1) { if (specificClaim.length === 1) {
@ -1154,99 +1187,103 @@ export const claimSpecialDescription = (
contacts: Array<Contact>, contacts: Array<Contact>,
) => { ) => {
let claim = record.claim; let claim = record.claim;
if (claim.claim) { if ("claim" in claim) {
// it's probably a Verified Credential claim = claim.claim as GenericVerifiableCredential;
claim = claim.claim;
} }
const issuer = didInfo(record.issuer, activeDid, identifiers, contacts); const issuer = didInfo(record.issuer, activeDid, identifiers, contacts);
const type = claim["@type"] || "UnknownType"; const claimObj = claim as ClaimObject;
const type = claimObj["@type"] || "UnknownType";
if (type === "AgreeAction") { if (type === "AgreeAction") {
return issuer + " agreed with " + claimSummary(claim.object); return (
issuer +
" agreed with " +
claimSummary(claimObj.object as GenericVerifiableCredential)
);
} else if (isAccept(claim)) { } else if (isAccept(claim)) {
return issuer + " accepted " + claimSummary(claim.object); return (
issuer +
" accepted " +
claimSummary(claimObj.object as GenericVerifiableCredential)
);
} else if (type === "GiveAction") { } else if (type === "GiveAction") {
// agent.did is for legacy data, before March 2023 const giveClaim = claim as GiveVerifiableCredential;
const giver = claim.agent?.identifier || claim.agent?.did; const agent: Agent = giveClaim.agent || {
const giverInfo = didInfo(giver, activeDid, identifiers, contacts); identifier: undefined,
let gaveAmount = claim.object?.amountOfThisGood did: undefined,
? displayAmount(claim.object.unitCode, claim.object.amountOfThisGood) };
const agentDid = agent.did || agent.identifier;
const contactInfo = agentDid
? didInfo(agentDid, activeDid, identifiers, contacts)
: "someone";
const offering = giveClaim.object
? " " + claimSummary(giveClaim.object)
: ""; : "";
if (claim.description) { const recipient = giveClaim.participant?.identifier;
if (gaveAmount) { const recipientInfo = recipient
gaveAmount = gaveAmount + ", and also: "; ? " to " + didInfo(recipient, activeDid, identifiers, contacts)
}
gaveAmount = gaveAmount + claim.description;
}
if (!gaveAmount) {
gaveAmount = "something not described";
}
// recipient.did is for legacy data, before March 2023
const gaveRecipientId = claim.recipient?.identifier || claim.recipient?.did;
const gaveRecipientInfo = gaveRecipientId
? " to " + didInfo(gaveRecipientId, activeDid, identifiers, contacts)
: ""; : "";
return giverInfo + " gave" + gaveRecipientInfo + ": " + gaveAmount; return contactInfo + " gave" + offering + recipientInfo;
} else if (type === "JoinAction") { } else if (type === "JoinAction") {
// agent.did is for legacy data, before March 2023 const joinClaim = claim as ClaimObject;
const agent = claim.agent?.identifier || claim.agent?.did; const agent: Agent = joinClaim.agent || {
const contactInfo = didInfo(agent, activeDid, identifiers, contacts); identifier: undefined,
did: undefined,
let eventOrganizer = };
claim.event && claim.event.organizer && claim.event.organizer.name; const agentDid = agent.did || agent.identifier;
eventOrganizer = eventOrganizer || ""; const contactInfo = agentDid
let eventName = claim.event && claim.event.name; ? didInfo(agentDid, activeDid, identifiers, contacts)
eventName = eventName ? " " + eventName : ""; : "someone";
let fullEvent = eventOrganizer + eventName; const object = joinClaim.object as GenericVerifiableCredential;
fullEvent = fullEvent ? " attended the " + fullEvent : ""; const objectInfo = object ? " " + claimSummary(object) : "";
return contactInfo + " joined" + objectInfo;
let eventDate = claim.event && claim.event.startTime;
eventDate = eventDate ? " at " + eventDate : "";
return contactInfo + fullEvent + eventDate;
} else if (isOffer(claim)) { } else if (isOffer(claim)) {
const offerer = claim.offeredBy?.identifier; const offerClaim = claim as OfferVerifiableCredential;
const contactInfo = didInfo(offerer, activeDid, identifiers, contacts); const agent: Agent = offerClaim.agent || {
let offering = ""; identifier: undefined,
if (claim.includesObject) { did: undefined,
offering += };
" " + const agentDid = agent.did || agent.identifier;
displayAmount( const contactInfo = agentDid
claim.includesObject.unitCode, ? didInfo(agentDid, activeDid, identifiers, contacts)
claim.includesObject.amountOfThisGood, : "someone";
); const offering = offerClaim.object
} ? " " + claimSummary(offerClaim.object)
if (claim.itemOffered?.description) { : "";
offering += ", saying: " + claim.itemOffered?.description; const offerRecipientId = offerClaim.participant?.identifier;
}
// recipient.did is for legacy data, before March 2023
const offerRecipientId =
claim.recipient?.identifier || claim.recipient?.did;
const offerRecipientInfo = offerRecipientId const offerRecipientInfo = offerRecipientId
? " to " + didInfo(offerRecipientId, activeDid, identifiers, contacts) ? " to " + didInfo(offerRecipientId, activeDid, identifiers, contacts)
: ""; : "";
return contactInfo + " offered" + offering + offerRecipientInfo; return contactInfo + " offered" + offering + offerRecipientInfo;
} else if (type === "PlanAction") { } else if (type === "PlanAction") {
const claimer = claim.agent?.identifier || record.issuer; const planClaim = claim as ClaimObject;
const claimerInfo = didInfo(claimer, activeDid, identifiers, contacts); const agent: Agent = planClaim.agent || {
return claimerInfo + " announced a project: " + claim.name; identifier: undefined,
did: undefined,
};
const agentDid = agent.did || agent.identifier;
const contactInfo = agentDid
? didInfo(agentDid, activeDid, identifiers, contacts)
: "someone";
const object = planClaim.object as GenericVerifiableCredential;
const objectInfo = object ? " " + claimSummary(object) : "";
return contactInfo + " planned" + objectInfo;
} else if (type === "Tenure") { } else if (type === "Tenure") {
// party.did is for legacy data, before March 2023 const tenureClaim = claim as ClaimObject;
const claimer = claim.party?.identifier || claim.party?.did; const agent: Agent = tenureClaim.agent || {
const contactInfo = didInfo(claimer, activeDid, identifiers, contacts); identifier: undefined,
const polygon = claim.spatialUnit?.geo?.polygon || ""; did: undefined,
return ( };
contactInfo + const agentDid = agent.did || agent.identifier;
" possesses [" + const contactInfo = agentDid
polygon.substring(0, polygon.indexOf(" ")) + ? didInfo(agentDid, activeDid, identifiers, contacts)
"...]" : "someone";
); const object = tenureClaim.object as GenericVerifiableCredential;
const objectInfo = object ? " " + claimSummary(object) : "";
return contactInfo + " has tenure" + objectInfo;
} else { } else {
return ( return issuer + " declared " + claimSummary(claim);
issuer +
" declared " +
claimSummary(claim as GenericCredWrapper<GenericVerifiableCredential>)
);
} }
}; };
@ -1288,9 +1325,7 @@ export async function createEndorserJwtVcFromClaim(
export async function createInviteJwt( export async function createInviteJwt(
activeDid: string, activeDid: string,
contact?: Contact, contact: Contact,
inviteId?: string,
expiresIn?: number,
): Promise<string> { ): Promise<string> {
const vcClaim: RegisterVerifiableCredential = { const vcClaim: RegisterVerifiableCredential = {
"@context": SCHEMA_ORG_CONTEXT, "@context": SCHEMA_ORG_CONTEXT,
@ -1301,19 +1336,19 @@ export async function createInviteJwt(
if (contact) { if (contact) {
vcClaim.participant = { identifier: contact.did }; vcClaim.participant = { identifier: contact.did };
} }
if (inviteId) {
vcClaim.identifier = inviteId;
}
// Make a payload for the claim // Make a payload for the claim
const vcPayload = { const vcPayload: { vc: VerifiableCredentialClaim } = {
vc: { vc: {
"@context": ["https://www.w3.org/2018/credentials/v1"], "@context": ["https://www.w3.org/2018/credentials/v1"],
"@type": "VerifiableCredential",
type: ["VerifiableCredential"], type: ["VerifiableCredential"],
credentialSubject: vcClaim, credentialSubject: vcClaim as unknown as ClaimObject, // Type assertion needed due to object being string
}, },
}; };
// Create a signature using private key of identity // Create a signature using private key of identity
const vcJwt = await createEndorserJwtForDid(activeDid, vcPayload, expiresIn); const vcJwt = await createEndorserJwtForDid(activeDid, vcPayload);
return vcJwt; return vcJwt;
} }
@ -1323,21 +1358,44 @@ export async function register(
axios: Axios, axios: Axios,
contact: Contact, contact: Contact,
): Promise<{ success?: boolean; error?: string }> { ): Promise<{ success?: boolean; error?: string }> {
const vcJwt = await createInviteJwt(activeDid, contact); try {
const vcJwt = await createInviteJwt(activeDid, contact);
const url = apiServer + "/api/v2/claim"; const url = apiServer + "/api/v2/claim";
const resp = await axios.post(url, { jwtEncoded: vcJwt }); const resp = await axios.post<{
if (resp.data?.success?.handleId) { success?: {
return { success: true }; handleId?: string;
} else if (resp.data?.success?.embeddedRecordError) { embeddedRecordError?: string;
let message = };
"There was some problem with the registration and so it may not be complete."; error?: string;
if (typeof resp.data.success.embeddedRecordError == "string") { message?: string;
message += " " + resp.data.success.embeddedRecordError; }>(url, { jwtEncoded: vcJwt });
if (resp.data?.success?.handleId) {
return { success: true };
} else if (resp.data?.success?.embeddedRecordError) {
let message =
"There was some problem with the registration and so it may not be complete.";
if (typeof resp.data.success.embeddedRecordError === "string") {
message += " " + resp.data.success.embeddedRecordError;
}
return { error: message };
} else {
logger.error("Registration error:", JSON.stringify(resp.data));
return { error: "Got a server error when registering." };
}
} catch (error: unknown) {
if (error && typeof error === "object") {
const err = error as AxiosErrorResponse;
const errorMessage =
err.message ||
(err.response?.data &&
typeof err.response.data === "object" &&
"message" in err.response.data
? (err.response.data as { message: string }).message
: undefined);
logger.error("Registration error:", errorMessage || JSON.stringify(err));
return { error: errorMessage || "Got a server error when registering." };
} }
return { error: message };
} else {
logger.error(resp);
return { error: "Got a server error when registering." }; return { error: "Got a server error when registering." };
} }
} }
@ -1363,7 +1421,14 @@ export async function setVisibilityUtil(
if (resp.status === 200) { if (resp.status === 200) {
const success = resp.data.success; const success = resp.data.success;
if (success) { if (success) {
db.contacts.update(contact.did, { seesMe: visibility }); const platformService = PlatformServiceFactory.getInstance();
await platformService.dbExec(
"UPDATE contacts SET seesMe = ? WHERE did = ?",
[visibility, contact.did],
);
if (USE_DEXIE_DB) {
db.contacts.update(contact.did, { seesMe: visibility });
}
} }
return { success }; return { success };
} else { } else {

437
src/libs/util.ts

@ -5,29 +5,43 @@ import { Buffer } from "buffer";
import * as R from "ramda"; import * as R from "ramda";
import { useClipboard } from "@vueuse/core"; import { useClipboard } from "@vueuse/core";
import { DEFAULT_PUSH_SERVER, NotificationIface } from "../constants/app"; import {
DEFAULT_PUSH_SERVER,
NotificationIface,
USE_DEXIE_DB,
} from "../constants/app";
import { import {
accountsDBPromise, accountsDBPromise,
retrieveSettingsForActiveAccount, retrieveSettingsForActiveAccount,
updateAccountSettings, updateAccountSettings,
updateDefaultSettings, updateDefaultSettings,
} from "../db/index"; } from "../db/index";
import { Account } from "../db/tables/accounts"; import { Account, AccountEncrypted } from "../db/tables/accounts";
import { Contact } from "../db/tables/contacts"; import { Contact } from "../db/tables/contacts";
import * as databaseUtil from "../db/databaseUtil";
import { DEFAULT_PASSKEY_EXPIRATION_MINUTES } from "../db/tables/settings"; import { DEFAULT_PASSKEY_EXPIRATION_MINUTES } from "../db/tables/settings";
import { deriveAddress, generateSeed, newIdentifier } from "../libs/crypto"; import {
arrayBufferToBase64,
base64ToArrayBuffer,
deriveAddress,
generateSeed,
newIdentifier,
simpleDecrypt,
simpleEncrypt,
} from "../libs/crypto";
import * as serverUtil from "../libs/endorserServer"; import * as serverUtil from "../libs/endorserServer";
import { containsHiddenDid } from "../libs/endorserServer";
import { import {
containsHiddenDid,
GenericCredWrapper, GenericCredWrapper,
GenericVerifiableCredential, GenericVerifiableCredential,
GiveSummaryRecord, } from "../interfaces/common";
OfferVerifiableCredential, import { GiveSummaryRecord } from "../interfaces/records";
} from "../libs/endorserServer"; import { OfferVerifiableCredential } from "../interfaces/claims";
import { KeyMeta } from "../libs/crypto/vc"; import { KeyMeta } from "../interfaces/common";
import { createPeerDid } from "../libs/crypto/vc/didPeer"; import { createPeerDid } from "../libs/crypto/vc/didPeer";
import { registerCredential } from "../libs/crypto/vc/passkeyDidPeer"; import { registerCredential } from "../libs/crypto/vc/passkeyDidPeer";
import { logger } from "../utils/logger"; import { logger } from "../utils/logger";
import { PlatformServiceFactory } from "@/services/PlatformServiceFactory";
export interface GiverReceiverInputInfo { export interface GiverReceiverInputInfo {
did?: string; did?: string;
@ -66,18 +80,24 @@ export const UNIT_LONG: Record<string, string> = {
}; };
/* eslint-enable prettier/prettier */ /* eslint-enable prettier/prettier */
const UNIT_CODES: Record<string, Record<string, string>> = { const UNIT_CODES: Record<
string,
{ name: string; faIcon: string; decimals: number }
> = {
BTC: { BTC: {
name: "Bitcoin", name: "Bitcoin",
faIcon: "bitcoin-sign", faIcon: "bitcoin-sign",
decimals: 4,
}, },
HUR: { HUR: {
name: "hours", name: "hours",
faIcon: "clock", faIcon: "clock",
decimals: 0,
}, },
USD: { USD: {
name: "US Dollars", name: "US Dollars",
faIcon: "dollar", faIcon: "dollar",
decimals: 2,
}, },
}; };
@ -85,6 +105,13 @@ export function iconForUnitCode(unitCode: string) {
return UNIT_CODES[unitCode]?.faIcon || "question"; return UNIT_CODES[unitCode]?.faIcon || "question";
} }
export function formattedAmount(amount: number, unitCode: string) {
const unit = UNIT_CODES[unitCode];
const amountStr = amount.toFixed(unit?.decimals ?? 4);
const unitName = unit?.name || "?";
return amountStr + " " + unitName;
}
// from https://stackoverflow.com/a/175787/845494 // from https://stackoverflow.com/a/175787/845494
// ... though it appears even this isn't precisely right so keep doing "|| 0" or something in sensitive places // ... though it appears even this isn't precisely right so keep doing "|| 0" or something in sensitive places
// //
@ -364,14 +391,15 @@ export function base64ToBlob(base64DataUrl: string, sliceSize = 512) {
* @param veriClaim is expected to have fields: claim and issuer * @param veriClaim is expected to have fields: claim and issuer
*/ */
export function offerGiverDid( export function offerGiverDid(
veriClaim: GenericCredWrapper<OfferVerifiableCredential>, veriClaim: GenericCredWrapper<GenericVerifiableCredential>,
): string | undefined { ): string | undefined {
let giver; let giver;
if ( const claim = veriClaim.claim as OfferVerifiableCredential;
veriClaim.claim.offeredBy?.identifier && const offeredBy: { identifier?: string } | undefined =
!serverUtil.isHiddenDid(veriClaim.claim.offeredBy.identifier as string) claim.offeredBy || claim.credentialSubject?.offeredBy;
) { const offeredById = offeredBy?.identifier;
giver = veriClaim.claim.offeredBy.identifier; if (offeredById && !serverUtil.isHiddenDid(offeredById)) {
giver = offeredById;
} else if (veriClaim.issuer && !serverUtil.isHiddenDid(veriClaim.issuer)) { } else if (veriClaim.issuer && !serverUtil.isHiddenDid(veriClaim.issuer)) {
giver = veriClaim.issuer; giver = veriClaim.issuer;
} }
@ -385,10 +413,7 @@ export function offerGiverDid(
export const canFulfillOffer = ( export const canFulfillOffer = (
veriClaim: GenericCredWrapper<GenericVerifiableCredential>, veriClaim: GenericCredWrapper<GenericVerifiableCredential>,
) => { ) => {
return ( return veriClaim.claimType === "Offer" && !!offerGiverDid(veriClaim);
veriClaim.claimType === "Offer" &&
!!offerGiverDid(veriClaim as GenericCredWrapper<OfferVerifiableCredential>)
);
}; };
// return object with paths and arrays of DIDs for any keys ending in "VisibleToDid" // return object with paths and arrays of DIDs for any keys ending in "VisibleToDid"
@ -457,19 +482,44 @@ export function findAllVisibleToDids(
* *
**/ **/
export interface AccountKeyInfo extends Account, KeyMeta {} export interface AccountKeyInfo
extends Omit<Account, "derivationPath">,
Omit<KeyMeta, "derivationPath"> {
derivationPath?: string; // Make it optional to match Account type
}
export const retrieveAccountCount = async (): Promise<number> => { export const retrieveAccountCount = async (): Promise<number> => {
// one of the few times we use accountsDBPromise directly; try to avoid more usage let result = 0;
const accountsDB = await accountsDBPromise; const platformService = PlatformServiceFactory.getInstance();
return await accountsDB.accounts.count(); const dbResult = await platformService.dbQuery(
`SELECT COUNT(*) FROM accounts`,
);
if (dbResult?.values?.[0]?.[0]) {
result = dbResult.values[0][0] as number;
}
if (USE_DEXIE_DB) {
// one of the few times we use accountsDBPromise directly; try to avoid more usage
const accountsDB = await accountsDBPromise;
result = await accountsDB.accounts.count();
}
return result;
}; };
export const retrieveAccountDids = async (): Promise<string[]> => { export const retrieveAccountDids = async (): Promise<string[]> => {
// one of the few times we use accountsDBPromise directly; try to avoid more usage const platformService = PlatformServiceFactory.getInstance();
const accountsDB = await accountsDBPromise; const dbAccounts = await platformService.dbQuery(`SELECT did FROM accounts`);
const allAccounts = await accountsDB.accounts.toArray(); let allDids =
const allDids = allAccounts.map((acc) => acc.did); databaseUtil
.mapQueryResultToValues(dbAccounts)
?.map((row) => row[0] as string) || [];
if (USE_DEXIE_DB) {
// this is the old way
// one of the few times we use accountsDBPromise directly; try to avoid more usage
const accountsDB = await accountsDBPromise;
const allAccounts = await accountsDB.accounts.toArray();
allDids = allAccounts.map((acc) => acc.did);
}
return allDids; return allDids;
}; };
@ -478,53 +528,188 @@ export const retrieveAccountDids = async (): Promise<string[]> => {
export const retrieveAccountMetadata = async ( export const retrieveAccountMetadata = async (
activeDid: string, activeDid: string,
): Promise<AccountKeyInfo | undefined> => { ): Promise<AccountKeyInfo | undefined> => {
// one of the few times we use accountsDBPromise directly; try to avoid more usage let result: AccountKeyInfo | undefined = undefined;
const accountsDB = await accountsDBPromise; const platformService = PlatformServiceFactory.getInstance();
const account = (await accountsDB.accounts const dbAccount = await platformService.dbQuery(
.where("did") `SELECT * FROM accounts WHERE did = ?`,
.equals(activeDid) [activeDid],
.first()) as Account; );
const account = databaseUtil.mapQueryResultToValues(dbAccount)[0] as Account;
if (account) { if (account) {
// eslint-disable-next-line @typescript-eslint/no-unused-vars // eslint-disable-next-line @typescript-eslint/no-unused-vars
const { identity, mnemonic, ...metadata } = account; const { identity, mnemonic, ...metadata } = account;
return metadata; result = metadata;
} else { } else {
return undefined; result = undefined;
}
if (USE_DEXIE_DB) {
// one of the few times we use accountsDBPromise directly; try to avoid more usage
const accountsDB = await accountsDBPromise;
const account = (await accountsDB.accounts
.where("did")
.equals(activeDid)
.first()) as Account;
if (account) {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { identity, mnemonic, ...metadata } = account;
result = metadata;
} else {
result = undefined;
}
} }
return result;
}; };
export const retrieveAllAccountsMetadata = async (): Promise<Account[]> => { export const retrieveAllAccountsMetadata = async (): Promise<Account[]> => {
// one of the few times we use accountsDBPromise directly; try to avoid more usage const platformService = PlatformServiceFactory.getInstance();
const accountsDB = await accountsDBPromise; const sql = `SELECT * FROM accounts`;
const array = await accountsDB.accounts.toArray(); const dbAccounts = await platformService.dbQuery(sql);
return array.map((account) => { const accounts = databaseUtil.mapQueryResultToValues(dbAccounts) as Account[];
let result = accounts.map((account) => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars // eslint-disable-next-line @typescript-eslint/no-unused-vars
const { identity, mnemonic, ...metadata } = account; const { identity, mnemonic, ...metadata } = account;
return metadata; return metadata as Account;
}); });
if (USE_DEXIE_DB) {
// one of the few times we use accountsDBPromise directly; try to avoid more usage
const accountsDB = await accountsDBPromise;
const array = await accountsDB.accounts.toArray();
result = array.map((account) => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { identity, mnemonic, ...metadata } = account;
return metadata as Account;
});
}
return result;
}; };
export const retrieveFullyDecryptedAccount = async ( export const retrieveFullyDecryptedAccount = async (
activeDid: string, activeDid: string,
): Promise<AccountKeyInfo | undefined> => { ): Promise<AccountKeyInfo | undefined> => {
// one of the few times we use accountsDBPromise directly; try to avoid more usage let result: AccountKeyInfo | undefined = undefined;
const accountsDB = await accountsDBPromise; const platformService = PlatformServiceFactory.getInstance();
const account = (await accountsDB.accounts const dbSecrets = await platformService.dbQuery(
.where("did") `SELECT secretBase64 from secret`,
.equals(activeDid) );
.first()) as Account; if (
return account; !dbSecrets ||
dbSecrets.values.length === 0 ||
dbSecrets.values[0].length === 0
) {
throw new Error(
"No secret found. We recommend you clear your data and start over.",
);
}
const secretBase64 = dbSecrets.values[0][0] as string;
const secret = base64ToArrayBuffer(secretBase64);
const dbAccount = await platformService.dbQuery(
`SELECT * FROM accounts WHERE did = ?`,
[activeDid],
);
if (
!dbAccount ||
dbAccount.values.length === 0 ||
dbAccount.values[0].length === 0
) {
throw new Error("Account not found.");
}
const fullAccountData = databaseUtil.mapQueryResultToValues(
dbAccount,
)[0] as AccountEncrypted;
const identityEncr = base64ToArrayBuffer(fullAccountData.identityEncrBase64);
const mnemonicEncr = base64ToArrayBuffer(fullAccountData.mnemonicEncrBase64);
fullAccountData.identity = await simpleDecrypt(identityEncr, secret);
fullAccountData.mnemonic = await simpleDecrypt(mnemonicEncr, secret);
result = fullAccountData;
if (USE_DEXIE_DB) {
// one of the few times we use accountsDBPromise directly; try to avoid more usage
const accountsDB = await accountsDBPromise;
const account = (await accountsDB.accounts
.where("did")
.equals(activeDid)
.first()) as Account;
result = account;
}
return result;
}; };
// let's try and eliminate this // let's try and eliminate this
export const retrieveAllFullyDecryptedAccounts = async (): Promise< export const retrieveAllFullyDecryptedAccounts = async (): Promise<
Array<AccountKeyInfo> Array<AccountEncrypted>
> => { > => {
const accountsDB = await accountsDBPromise; const platformService = PlatformServiceFactory.getInstance();
const allAccounts = await accountsDB.accounts.toArray(); const queryResult = await platformService.dbQuery("SELECT * FROM accounts");
let allAccounts = databaseUtil.mapQueryResultToValues(
queryResult,
) as unknown as AccountEncrypted[];
if (USE_DEXIE_DB) {
const accountsDB = await accountsDBPromise;
allAccounts = (await accountsDB.accounts.toArray()) as AccountEncrypted[];
}
return allAccounts; return allAccounts;
}; };
/**
* Saves a new identity to both SQL and Dexie databases
*/
export async function saveNewIdentity(
identity: string,
mnemonic: string,
newId: { did: string; keys: Array<{ publicKeyHex: string }> },
derivationPath: string,
): Promise<void> {
try {
// add to the new sql db
const platformService = PlatformServiceFactory.getInstance();
const secrets = await platformService.dbQuery(
`SELECT secretBase64 FROM secret`,
);
if (!secrets?.values?.length || !secrets.values[0]?.length) {
throw new Error(
"No initial encryption supported. We recommend you clear your data and start over.",
);
}
const secretBase64 = secrets.values[0][0] as string;
const secret = base64ToArrayBuffer(secretBase64);
const encryptedIdentity = await simpleEncrypt(identity, secret);
const encryptedMnemonic = await simpleEncrypt(mnemonic, secret);
const encryptedIdentityBase64 = arrayBufferToBase64(encryptedIdentity);
const encryptedMnemonicBase64 = arrayBufferToBase64(encryptedMnemonic);
const sql = `INSERT INTO accounts (dateCreated, derivationPath, did, identityEncrBase64, mnemonicEncrBase64, publicKeyHex)
VALUES (?, ?, ?, ?, ?, ?)`;
const params = [
new Date().toISOString(),
derivationPath,
newId.did,
encryptedIdentityBase64,
encryptedMnemonicBase64,
newId.keys[0].publicKeyHex,
];
await platformService.dbExec(sql, params);
await databaseUtil.updateDefaultSettings({ activeDid: newId.did });
if (USE_DEXIE_DB) {
// one of the few times we use accountsDBPromise directly; try to avoid more usage
const accountsDB = await accountsDBPromise;
await accountsDB.accounts.add({
dateCreated: new Date().toISOString(),
derivationPath: derivationPath,
did: newId.did,
identity: identity,
mnemonic: mnemonic,
publicKeyHex: newId.keys[0].publicKeyHex,
});
await updateDefaultSettings({ activeDid: newId.did });
}
} catch (error) {
logger.error("Failed to update default settings:", error);
throw new Error(
"Failed to set default settings. Please try again or restart the app.",
);
}
}
/** /**
* Generates a new identity, saves it to the database, and sets it as the active identity. * Generates a new identity, saves it to the database, and sets it as the active identity.
* @return {Promise<string>} with the DID of the new identity * @return {Promise<string>} with the DID of the new identity
@ -538,26 +723,11 @@ export const generateSaveAndActivateIdentity = async (): Promise<string> => {
const newId = newIdentifier(address, publicHex, privateHex, derivationPath); const newId = newIdentifier(address, publicHex, privateHex, derivationPath);
const identity = JSON.stringify(newId); const identity = JSON.stringify(newId);
// one of the few times we use accountsDBPromise directly; try to avoid more usage await saveNewIdentity(identity, mnemonic, newId, derivationPath);
try { await databaseUtil.updateAccountSettings(newId.did, { isRegistered: false });
const accountsDB = await accountsDBPromise; if (USE_DEXIE_DB) {
await accountsDB.accounts.add({ await updateAccountSettings(newId.did, { isRegistered: false });
dateCreated: new Date().toISOString(),
derivationPath: derivationPath,
did: newId.did,
identity: identity,
mnemonic: mnemonic,
publicKeyHex: newId.keys[0].publicKeyHex,
});
await updateDefaultSettings({ activeDid: newId.did });
} catch (error) {
console.error("Failed to update default settings:", error);
throw new Error(
"Failed to set default settings. Please try again or restart the app.",
);
} }
await updateAccountSettings(newId.did, { isRegistered: false });
return newId.did; return newId.did;
}; };
@ -575,9 +745,19 @@ export const registerAndSavePasskey = async (
passkeyCredIdHex, passkeyCredIdHex,
publicKeyHex: Buffer.from(publicKeyBytes).toString("hex"), publicKeyHex: Buffer.from(publicKeyBytes).toString("hex"),
}; };
// one of the few times we use accountsDBPromise directly; try to avoid more usage const insertStatement = databaseUtil.generateInsertStatement(
const accountsDB = await accountsDBPromise; account,
await accountsDB.accounts.add(account); "accounts",
);
await PlatformServiceFactory.getInstance().dbExec(
insertStatement.sql,
insertStatement.params,
);
if (USE_DEXIE_DB) {
// one of the few times we use accountsDBPromise directly; try to avoid more usage
const accountsDB = await accountsDBPromise;
await accountsDB.accounts.add(account);
}
return account; return account;
}; };
@ -585,13 +765,22 @@ export const registerSaveAndActivatePasskey = async (
keyName: string, keyName: string,
): Promise<Account> => { ): Promise<Account> => {
const account = await registerAndSavePasskey(keyName); const account = await registerAndSavePasskey(keyName);
await updateDefaultSettings({ activeDid: account.did }); await databaseUtil.updateDefaultSettings({ activeDid: account.did });
await updateAccountSettings(account.did, { isRegistered: false }); await databaseUtil.updateAccountSettings(account.did, {
isRegistered: false,
});
if (USE_DEXIE_DB) {
await updateDefaultSettings({ activeDid: account.did });
await updateAccountSettings(account.did, { isRegistered: false });
}
return account; return account;
}; };
export const getPasskeyExpirationSeconds = async (): Promise<number> => { export const getPasskeyExpirationSeconds = async (): Promise<number> => {
const settings = await retrieveSettingsForActiveAccount(); let settings = await databaseUtil.retrieveSettingsForActiveAccount();
if (USE_DEXIE_DB) {
settings = await retrieveSettingsForActiveAccount();
}
return ( return (
(settings?.passkeyExpirationMinutes ?? DEFAULT_PASSKEY_EXPIRATION_MINUTES) * (settings?.passkeyExpirationMinutes ?? DEFAULT_PASSKEY_EXPIRATION_MINUTES) *
60 60
@ -607,7 +796,10 @@ export const sendTestThroughPushServer = async (
subscriptionJSON: PushSubscriptionJSON, subscriptionJSON: PushSubscriptionJSON,
skipFilter: boolean, skipFilter: boolean,
): Promise<AxiosResponse> => { ): Promise<AxiosResponse> => {
const settings = await retrieveSettingsForActiveAccount(); let settings = await databaseUtil.retrieveSettingsForActiveAccount();
if (USE_DEXIE_DB) {
settings = await retrieveSettingsForActiveAccount();
}
let pushUrl: string = DEFAULT_PUSH_SERVER as string; let pushUrl: string = DEFAULT_PUSH_SERVER as string;
if (settings?.webPushServer) { if (settings?.webPushServer) {
pushUrl = settings.webPushServer; pushUrl = settings.webPushServer;
@ -635,3 +827,96 @@ export const sendTestThroughPushServer = async (
logger.log("Got response from web push server:", response); logger.log("Got response from web push server:", response);
return response; return response;
}; };
/**
* Converts a Contact object to a CSV line string following the established format.
* The format matches CONTACT_CSV_HEADER: "name,did,pubKeyBase64,seesMe,registered,contactMethods"
* where contactMethods is stored as a stringified JSON array.
*
* @param contact - The Contact object to convert
* @returns A CSV-formatted string representing the contact
* @throws {Error} If the contact object is missing required fields
*/
export const contactToCsvLine = (contact: Contact): string => {
if (!contact.did) {
throw new Error("Contact must have a did field");
}
// Escape fields that might contain commas or quotes
const escapeField = (field: string | boolean | undefined): string => {
if (field === undefined) return "";
const str = String(field);
if (str.includes(",") || str.includes('"')) {
return `"${str.replace(/"/g, '""')}"`;
}
return str;
};
// Handle contactMethods array by stringifying it
const contactMethodsStr = contact.contactMethods
? escapeField(JSON.stringify(contact.contactMethods))
: "";
const fields = [
escapeField(contact.name),
escapeField(contact.did),
escapeField(contact.publicKeyBase64),
escapeField(contact.seesMe),
escapeField(contact.registered),
contactMethodsStr,
];
return fields.join(",");
};
/**
* Interface for the JSON export format of database tables
*/
export interface TableExportData {
tableName: string;
rows: Array<Record<string, unknown>>;
}
/**
* Interface for the complete database export format
*/
export interface DatabaseExport {
data: {
data: Array<TableExportData>;
};
}
/**
* 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(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,
},
],
},
};
};

2
src/main.capacitor.ts

@ -34,7 +34,7 @@ import router from "./router";
import { handleApiError } from "./services/api"; import { handleApiError } from "./services/api";
import { AxiosError } from "axios"; import { AxiosError } from "axios";
import { DeepLinkHandler } from "./services/deepLinks"; import { DeepLinkHandler } from "./services/deepLinks";
import { logConsoleAndDb } from "./db"; import { logConsoleAndDb } from "./db/databaseUtil";
import { logger } from "./utils/logger"; import { logger } from "./utils/logger";
logger.log("[Capacitor] Starting initialization"); logger.log("[Capacitor] Starting initialization");

6
src/main.common.ts

@ -10,6 +10,12 @@ import { FontAwesomeIcon } from "./libs/fontawesome";
import Camera from "simple-vue-camera"; import Camera from "simple-vue-camera";
import { logger } from "./utils/logger"; import { logger } from "./utils/logger";
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 // Global Error Handler
function setupGlobalErrorHandler(app: VueApp) { function setupGlobalErrorHandler(app: VueApp) {
logger.log("[App Init] Setting up global error handler"); logger.log("[App Init] Setting up global error handler");

12
src/main.electron.ts

@ -1,4 +1,16 @@
import { initializeApp } from "./main.common"; import { initializeApp } from "./main.common";
import { logger } from "./utils/logger";
const platform = process.env.VITE_PLATFORM;
const pwa_enabled = process.env.VITE_PWA_ENABLED === "true";
logger.info("[Electron] Initializing app");
logger.info("[Electron] Platform:", { platform });
logger.info("[Electron] PWA enabled:", { pwa_enabled });
if (pwa_enabled) {
logger.warn("[Electron] PWA is enabled, but not supported in electron");
}
const app = initializeApp(); const app = initializeApp();
app.mount("#app"); app.mount("#app");

215
src/main.ts

@ -1,215 +0,0 @@
import { createPinia } from "pinia";
import { App as VueApp, ComponentPublicInstance, createApp } from "vue";
import App from "./App.vue";
import "./registerServiceWorker";
import router from "./router";
import axios from "axios";
import VueAxios from "vue-axios";
import Notifications from "notiwind";
import "./assets/styles/tailwind.css";
import { library } from "@fortawesome/fontawesome-svg-core";
import {
faArrowDown,
faArrowLeft,
faArrowRight,
faArrowRotateBackward,
faArrowUpRightFromSquare,
faArrowUp,
faBan,
faBitcoinSign,
faBurst,
faCalendar,
faCamera,
faCameraRotate,
faCaretDown,
faChair,
faCheck,
faChevronDown,
faChevronLeft,
faChevronRight,
faChevronUp,
faCircle,
faCircleCheck,
faCircleInfo,
faCircleQuestion,
faCircleUser,
faClock,
faCoins,
faComment,
faCopy,
faDollar,
faEllipsis,
faEllipsisVertical,
faEnvelopeOpenText,
faEraser,
faEye,
faEyeSlash,
faFileContract,
faFileLines,
faFilter,
faFloppyDisk,
faFolderOpen,
faForward,
faGift,
faGlobe,
faHammer,
faHand,
faHandHoldingDollar,
faHandHoldingHeart,
faHouseChimney,
faImage,
faImagePortrait,
faLeftRight,
faLightbulb,
faLink,
faLocationDot,
faLongArrowAltLeft,
faLongArrowAltRight,
faMagnifyingGlass,
faMessage,
faMinus,
faPen,
faPersonCircleCheck,
faPersonCircleQuestion,
faPlus,
faQuestion,
faQrcode,
faRightFromBracket,
faRotate,
faShareNodes,
faSpinner,
faSquare,
faSquareCaretDown,
faSquareCaretUp,
faSquarePlus,
faTrashCan,
faTriangleExclamation,
faUser,
faUsers,
faXmark,
} from "@fortawesome/free-solid-svg-icons";
library.add(
faArrowDown,
faArrowLeft,
faArrowRight,
faArrowRotateBackward,
faArrowUpRightFromSquare,
faArrowUp,
faBan,
faBitcoinSign,
faBurst,
faCalendar,
faCamera,
faCameraRotate,
faCaretDown,
faChair,
faCheck,
faChevronDown,
faChevronLeft,
faChevronRight,
faChevronUp,
faCircle,
faCircleCheck,
faCircleInfo,
faCircleQuestion,
faCircleUser,
faClock,
faCoins,
faComment,
faCopy,
faDollar,
faEllipsis,
faEllipsisVertical,
faEnvelopeOpenText,
faEraser,
faEye,
faEyeSlash,
faFileContract,
faFileLines,
faFilter,
faFloppyDisk,
faFolderOpen,
faForward,
faGift,
faGlobe,
faHammer,
faHand,
faHandHoldingDollar,
faHandHoldingHeart,
faHouseChimney,
faImage,
faImagePortrait,
faLeftRight,
faLightbulb,
faLink,
faLocationDot,
faLongArrowAltLeft,
faLongArrowAltRight,
faMagnifyingGlass,
faMessage,
faMinus,
faPen,
faPersonCircleCheck,
faPersonCircleQuestion,
faPlus,
faQrcode,
faQuestion,
faRotate,
faRightFromBracket,
faShareNodes,
faSpinner,
faSquare,
faSquareCaretDown,
faSquareCaretUp,
faSquarePlus,
faTrashCan,
faTriangleExclamation,
faUser,
faUsers,
faXmark,
);
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
import Camera from "simple-vue-camera";
import { logger } from "./utils/logger";
// Can trigger this with a 'throw' inside some top-level function, eg. on the HomeView
function setupGlobalErrorHandler(app: VueApp) {
// @ts-expect-error 'cause we cannot see why config is not defined
app.config.errorHandler = (
err: Error,
instance: ComponentPublicInstance | null,
info: string,
) => {
logger.error(
"Ouch! Global Error Handler.",
"Error:",
err,
"- Error toString:",
err.toString(),
"- Info:",
info,
"- Instance:",
instance,
);
// Want to show a nice notiwind notification but can't figure out how.
alert(
(err.message || "Something bad happened") +
" - Try reloading or restarting the app.",
);
};
}
const app = createApp(App)
.component("fa", FontAwesomeIcon)
.component("camera", Camera)
.use(createPinia())
.use(VueAxios, axios)
.use(router)
.use(Notifications);
setupGlobalErrorHandler(app);
app.mount("#app");

34
src/main.web.ts

@ -1,5 +1,37 @@
import { initBackend } from "absurd-sql/dist/indexeddb-main-thread";
import { initializeApp } from "./main.common"; import { initializeApp } from "./main.common";
import "./registerServiceWorker"; // Web PWA support 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
}
const app = initializeApp(); const app = initializeApp();
function sqlInit() {
// see https://github.com/jlongster/absurd-sql
const worker = new Worker(
new URL("./registerSQLWorker.js", import.meta.url),
{
type: "module",
},
);
// This is only required because Safari doesn't support nested
// workers. This installs a handler that will proxy creating web
// workers through the main thread
initBackend(worker);
}
if (platform === "web" || platform === "development") {
sqlInit();
} else {
logger.info("[Web] SQL not initialized for platform", { platform });
}
app.mount("#app"); app.mount("#app");

6
src/registerSQLWorker.js

@ -0,0 +1,6 @@
import databaseService from "./services/AbsurdSqlDatabaseService";
async function run() {
await databaseService.initialize();
}
run();

20
src/registerServiceWorker.ts

@ -2,8 +2,18 @@
import { register } from "register-service-worker"; import { register } from "register-service-worker";
// Only register service worker if explicitly enabled and in production // Check if we're in an Electron environment
const isElectron =
process.env.VITE_PLATFORM === "electron" ||
process.env.VITE_DISABLE_PWA === "true" ||
window.navigator.userAgent.toLowerCase().includes("electron");
// Only register service worker if:
// 1. Not in Electron
// 2. PWA is explicitly enabled
// 3. In production mode
if ( if (
!isElectron &&
process.env.VITE_PWA_ENABLED === "true" && process.env.VITE_PWA_ENABLED === "true" &&
process.env.NODE_ENV === "production" process.env.NODE_ENV === "production"
) { ) {
@ -34,6 +44,12 @@ if (
}); });
} else { } else {
console.log( console.log(
"Service worker registration skipped - not enabled or not in production", `Service worker registration skipped - ${
isElectron
? "running in Electron"
: process.env.VITE_PWA_ENABLED !== "true"
? "PWA not enabled"
: "not in production mode"
}`,
); );
} }

25
src/router/index.ts

@ -2,35 +2,11 @@ import {
createRouter, createRouter,
createWebHistory, createWebHistory,
createMemoryHistory, createMemoryHistory,
NavigationGuardNext,
RouteLocationNormalized, RouteLocationNormalized,
RouteRecordRaw, RouteRecordRaw,
} from "vue-router"; } from "vue-router";
import { accountsDBPromise } from "../db/index";
import { logger } from "../utils/logger"; import { logger } from "../utils/logger";
/**
*
* @param to :RouteLocationNormalized
* @param from :RouteLocationNormalized
* @param next :NavigationGuardNext
*/
const enterOrStart = async (
to: RouteLocationNormalized,
from: RouteLocationNormalized,
next: NavigationGuardNext,
) => {
// one of the few times we use accountsDBPromise directly; try to avoid more usage
const accountsDB = await accountsDBPromise;
const num_accounts = await accountsDB.accounts.count();
if (num_accounts > 0) {
next();
} else {
next({ name: "start" });
}
};
const routes: Array<RouteRecordRaw> = [ const routes: Array<RouteRecordRaw> = [
{ {
path: "/account", path: "/account",
@ -216,7 +192,6 @@ const routes: Array<RouteRecordRaw> = [
path: "/projects", path: "/projects",
name: "projects", name: "projects",
component: () => import("../views/ProjectsView.vue"), component: () => import("../views/ProjectsView.vue"),
beforeEnter: enterOrStart,
}, },
{ {
path: "/quick-action-bvc", path: "/quick-action-bvc",

29
src/services/AbsurdSqlDatabaseService.d.ts

@ -0,0 +1,29 @@
import { DatabaseService } from "../interfaces/database";
declare module "@jlongster/sql.js" {
interface SQL {
Database: unknown;
FS: unknown;
register_for_idb: (fs: unknown) => void;
}
function initSqlJs(config: {
locateFile: (file: string) => string;
}): Promise<SQL>;
export default initSqlJs;
}
declare module "absurd-sql" {
export class SQLiteFS {
constructor(fs: unknown, backend: unknown);
}
}
declare module "absurd-sql/dist/indexeddb-backend" {
export default class IndexedDBBackend {
constructor();
}
}
declare const databaseService: DatabaseService;
export default databaseService;

231
src/services/AbsurdSqlDatabaseService.ts

@ -0,0 +1,231 @@
import initSqlJs from "@jlongster/sql.js";
import { SQLiteFS } from "absurd-sql";
import IndexedDBBackend from "absurd-sql/dist/indexeddb-backend";
import { runMigrations } from "../db-sql/migration";
import type { DatabaseService, QueryExecResult } from "../interfaces/database";
import { logger } from "@/utils/logger";
interface QueuedOperation {
type: "run" | "query";
sql: string;
params: unknown[];
resolve: (value: unknown) => void;
reject: (reason: unknown) => void;
}
interface AbsurdSqlDatabase {
exec: (sql: string, params?: unknown[]) => Promise<QueryExecResult[]>;
run: (
sql: string,
params?: unknown[],
) => Promise<{ changes: number; lastId?: number }>;
}
class AbsurdSqlDatabaseService implements DatabaseService {
private static instance: AbsurdSqlDatabaseService | null = null;
private db: AbsurdSqlDatabase | null;
private initialized: boolean;
private initializationPromise: Promise<void> | null = null;
private operationQueue: Array<QueuedOperation> = [];
private isProcessingQueue: boolean = false;
private constructor() {
this.db = null;
this.initialized = false;
}
static getInstance(): AbsurdSqlDatabaseService {
if (!AbsurdSqlDatabaseService.instance) {
AbsurdSqlDatabaseService.instance = new AbsurdSqlDatabaseService();
}
return AbsurdSqlDatabaseService.instance;
}
async initialize(): Promise<void> {
// If already initialized, return immediately
if (this.initialized) {
return;
}
// If initialization is in progress, wait for it
if (this.initializationPromise) {
return this.initializationPromise;
}
// Start initialization
this.initializationPromise = this._initialize();
try {
await this.initializationPromise;
} catch (error) {
logger.error(`AbsurdSqlDatabaseService initialize method failed:`, error);
this.initializationPromise = null; // Reset on failure
throw error;
}
}
private async _initialize(): Promise<void> {
if (this.initialized) {
return;
}
const SQL = await initSqlJs({
locateFile: (file: string) => {
return new URL(
`/node_modules/@jlongster/sql.js/dist/${file}`,
import.meta.url,
).href;
},
});
const sqlFS = new SQLiteFS(SQL.FS, new IndexedDBBackend());
SQL.register_for_idb(sqlFS);
SQL.FS.mkdir("/sql");
SQL.FS.mount(sqlFS, {}, "/sql");
const path = "/sql/timesafari.absurd-sql";
if (typeof SharedArrayBuffer === "undefined") {
const stream = SQL.FS.open(path, "a+");
await stream.node.contents.readIfFallback();
SQL.FS.close(stream);
}
this.db = new SQL.Database(path, { filename: true });
if (!this.db) {
throw new Error(
"The database initialization failed. We recommend you restart or reinstall.",
);
}
// An error is thrown without this pragma: "File has invalid page size. (the first block of a new file must be written first)"
await this.db.exec(`PRAGMA journal_mode=MEMORY;`);
const sqlExec = this.db.run.bind(this.db);
const sqlQuery = this.db.exec.bind(this.db);
// Extract the migration names for the absurd-sql format
const extractMigrationNames: (result: QueryExecResult[]) => Set<string> = (
result,
) => {
// Even with the "select name" query, the QueryExecResult may be [] (which doesn't make sense to me).
const names = result?.[0]?.values.map((row) => row[0] as string) || [];
return new Set(names);
};
// Run migrations
await runMigrations(sqlExec, sqlQuery, extractMigrationNames);
this.initialized = true;
// Start processing the queue after initialization
this.processQueue();
}
private async processQueue(): Promise<void> {
if (this.isProcessingQueue || !this.initialized || !this.db) {
return;
}
this.isProcessingQueue = true;
while (this.operationQueue.length > 0) {
const operation = this.operationQueue.shift();
if (!operation) continue;
try {
let result: unknown;
switch (operation.type) {
case "run":
result = await this.db.run(operation.sql, operation.params);
break;
case "query":
result = await this.db.exec(operation.sql, operation.params);
break;
}
operation.resolve(result);
} catch (error) {
logger.error(
"Error while processing SQL queue:",
error,
" ... for sql:",
operation.sql,
" ... with params:",
operation.params,
);
operation.reject(error);
}
}
this.isProcessingQueue = false;
}
private async queueOperation<R>(
type: QueuedOperation["type"],
sql: string,
params: unknown[] = [],
): Promise<R> {
return new Promise<R>((resolve, reject) => {
const operation: QueuedOperation = {
type,
sql,
params,
resolve: (value: unknown) => resolve(value as R),
reject,
};
this.operationQueue.push(operation);
// If we're already initialized, start processing the queue
if (this.initialized && this.db) {
this.processQueue();
}
});
}
private async waitForInitialization(): Promise<void> {
// If we have an initialization promise, wait for it
if (this.initializationPromise) {
await this.initializationPromise;
return;
}
// If not initialized and no promise, start initialization
if (!this.initialized) {
await this.initialize();
return;
}
// If initialized but no db, something went wrong
if (!this.db) {
logger.error(
`Database not properly initialized after await waitForInitialization() - initialized flag is true but db is null`,
);
throw new Error(
`The database could not be initialized. We recommend you restart or reinstall.`,
);
}
}
// Used for inserts, updates, and deletes
async run(
sql: string,
params: unknown[] = [],
): Promise<{ changes: number; lastId?: number }> {
await this.waitForInitialization();
return this.queueOperation<{ changes: number; lastId?: number }>(
"run",
sql,
params,
);
}
// Note that the resulting array may be empty if there are no results from the query
async query(sql: string, params: unknown[] = []): Promise<QueryExecResult[]> {
await this.waitForInitialization();
return this.queueOperation<QueryExecResult[]>("query", sql, params);
}
}
// Create a singleton instance
const databaseService = AbsurdSqlDatabaseService.getInstance();
export default databaseService;

24
src/services/PlatformService.ts

@ -1,3 +1,5 @@
import { QueryExecResult } from "@/interfaces/database";
/** /**
* Represents the result of an image capture or selection operation. * Represents the result of an image capture or selection operation.
* Contains both the image data as a Blob and the associated filename. * Contains both the image data as a Blob and the associated filename.
@ -106,4 +108,26 @@ export interface PlatformService {
* @returns Promise that resolves when the deep link has been handled * @returns Promise that resolves when the deep link has been handled
*/ */
handleDeepLink(url: string): Promise<void>; handleDeepLink(url: string): Promise<void>;
/**
* Executes a SQL query on the database.
* @param sql - The SQL query to execute
* @param params - The parameters to pass to the query
* @returns Promise resolving to the query result
*/
dbQuery(
sql: string,
params?: unknown[],
): Promise<QueryExecResult | undefined>;
/**
* Executes a create/update/delete on the database.
* @param sql - The SQL statement to execute
* @param params - The parameters to pass to the statement
* @returns Promise resolving to the result of the statement
*/
dbExec(
sql: string,
params?: unknown[],
): Promise<{ changes: number; lastId?: number }>;
} }

41
src/services/QRScanner/CapacitorQRScanner.ts

@ -4,7 +4,13 @@ import {
StartScanOptions, StartScanOptions,
LensFacing, LensFacing,
} from "@capacitor-mlkit/barcode-scanning"; } from "@capacitor-mlkit/barcode-scanning";
import { QRScannerService, ScanListener, QRScannerOptions } from "./types"; import {
QRScannerService,
ScanListener,
QRScannerOptions,
CameraStateListener,
CameraState,
} from "./types";
import { logger } from "@/utils/logger"; import { logger } from "@/utils/logger";
export class CapacitorQRScanner implements QRScannerService { export class CapacitorQRScanner implements QRScannerService {
@ -12,6 +18,9 @@ export class CapacitorQRScanner implements QRScannerService {
private isScanning = false; private isScanning = false;
private listenerHandles: Array<() => Promise<void>> = []; private listenerHandles: Array<() => Promise<void>> = [];
private cleanupPromise: Promise<void> | null = null; private cleanupPromise: Promise<void> | null = null;
private cameraStateListeners: Set<CameraStateListener> = new Set();
private currentState: CameraState = "off";
private currentStateMessage?: string;
async checkPermissions(): Promise<boolean> { async checkPermissions(): Promise<boolean> {
try { try {
@ -79,8 +88,11 @@ export class CapacitorQRScanner implements QRScannerService {
} }
try { try {
this.updateCameraState("initializing", "Starting camera...");
// Ensure we have permissions before starting // Ensure we have permissions before starting
if (!(await this.checkPermissions())) { if (!(await this.checkPermissions())) {
this.updateCameraState("permission_denied", "Camera permission denied");
logger.debug("Requesting camera permissions"); logger.debug("Requesting camera permissions");
const granted = await this.requestPermissions(); const granted = await this.requestPermissions();
if (!granted) { if (!granted) {
@ -90,11 +102,16 @@ export class CapacitorQRScanner implements QRScannerService {
// Check if scanning is supported // Check if scanning is supported
if (!(await this.isSupported())) { if (!(await this.isSupported())) {
this.updateCameraState(
"error",
"QR scanning not supported on this device",
);
throw new Error("QR scanning not supported on this device"); throw new Error("QR scanning not supported on this device");
} }
logger.info("Starting MLKit scanner"); logger.info("Starting MLKit scanner");
this.isScanning = true; this.isScanning = true;
this.updateCameraState("active", "Camera is active");
const scanOptions: StartScanOptions = { const scanOptions: StartScanOptions = {
formats: [BarcodeFormat.QrCode], formats: [BarcodeFormat.QrCode],
@ -126,6 +143,7 @@ export class CapacitorQRScanner implements QRScannerService {
stack: wrappedError.stack, stack: wrappedError.stack,
}); });
this.isScanning = false; this.isScanning = false;
this.updateCameraState("error", wrappedError.message);
await this.cleanup(); await this.cleanup();
this.scanListener?.onError?.(wrappedError); this.scanListener?.onError?.(wrappedError);
throw wrappedError; throw wrappedError;
@ -140,6 +158,7 @@ export class CapacitorQRScanner implements QRScannerService {
try { try {
logger.debug("Stopping QR scanner"); logger.debug("Stopping QR scanner");
this.updateCameraState("off", "Camera stopped");
await BarcodeScanner.stopScan(); await BarcodeScanner.stopScan();
logger.info("QR scanner stopped successfully"); logger.info("QR scanner stopped successfully");
} catch (error) { } catch (error) {
@ -149,6 +168,7 @@ export class CapacitorQRScanner implements QRScannerService {
error: wrappedError.message, error: wrappedError.message,
stack: wrappedError.stack, stack: wrappedError.stack,
}); });
this.updateCameraState("error", wrappedError.message);
this.scanListener?.onError?.(wrappedError); this.scanListener?.onError?.(wrappedError);
throw wrappedError; throw wrappedError;
} finally { } finally {
@ -207,4 +227,23 @@ export class CapacitorQRScanner implements QRScannerService {
// No-op for native scanner // No-op for native scanner
callback(null); callback(null);
} }
addCameraStateListener(listener: CameraStateListener): void {
this.cameraStateListeners.add(listener);
// Immediately notify the new listener of current state
listener.onStateChange(this.currentState, this.currentStateMessage);
}
removeCameraStateListener(listener: CameraStateListener): void {
this.cameraStateListeners.delete(listener);
}
private updateCameraState(state: CameraState, message?: string): void {
this.currentState = state;
this.currentStateMessage = message;
// Notify all listeners of state change
for (const listener of this.cameraStateListeners) {
listener.onStateChange(state, message);
}
}
} }

29
src/services/QRScanner/WebInlineQRScanner.ts

@ -30,14 +30,16 @@ export class WebInlineQRScanner implements QRScannerService {
private cameraStateListeners: Set<CameraStateListener> = new Set(); private cameraStateListeners: Set<CameraStateListener> = new Set();
private currentState: CameraState = "off"; private currentState: CameraState = "off";
private currentStateMessage?: string; private currentStateMessage?: string;
private options: QRScannerOptions;
constructor(private options?: QRScannerOptions) { constructor(options?: QRScannerOptions) {
// Generate a short random ID for this scanner instance // Generate a short random ID for this scanner instance
this.id = Math.random().toString(36).substring(2, 8).toUpperCase(); this.id = Math.random().toString(36).substring(2, 8).toUpperCase();
this.options = options ?? {};
logger.error( logger.error(
`[WebInlineQRScanner:${this.id}] Initializing scanner with options:`, `[WebInlineQRScanner:${this.id}] Initializing scanner with options:`,
{ {
...options, ...this.options,
buildId: BUILD_ID, buildId: BUILD_ID,
targetFps: this.TARGET_FPS, targetFps: this.TARGET_FPS,
}, },
@ -494,26 +496,34 @@ export class WebInlineQRScanner implements QRScannerService {
} }
} }
async startScan(): Promise<void> { async startScan(options?: QRScannerOptions): Promise<void> {
if (this.isScanning) { if (this.isScanning) {
logger.error(`[WebInlineQRScanner:${this.id}] Scanner already running`); logger.error(`[WebInlineQRScanner:${this.id}] Scanner already running`);
return; return;
} }
// Update options if provided
if (options) {
this.options = { ...this.options, ...options };
}
try { try {
this.isScanning = true; this.isScanning = true;
this.scanAttempts = 0; this.scanAttempts = 0;
this.lastScanTime = Date.now(); this.lastScanTime = Date.now();
this.updateCameraState("initializing", "Starting camera..."); this.updateCameraState("initializing", "Starting camera...");
logger.error(`[WebInlineQRScanner:${this.id}] Starting scan`); logger.error(
`[WebInlineQRScanner:${this.id}] Starting scan with options:`,
this.options,
);
// Get camera stream // Get camera stream with options
logger.error( logger.error(
`[WebInlineQRScanner:${this.id}] Requesting camera stream...`, `[WebInlineQRScanner:${this.id}] Requesting camera stream...`,
); );
this.stream = await navigator.mediaDevices.getUserMedia({ this.stream = await navigator.mediaDevices.getUserMedia({
video: { video: {
facingMode: "environment", facingMode: this.options.camera === "front" ? "user" : "environment",
width: { ideal: 1280 }, width: { ideal: 1280 },
height: { ideal: 720 }, height: { ideal: 720 },
}, },
@ -527,11 +537,18 @@ export class WebInlineQRScanner implements QRScannerService {
label: t.label, label: t.label,
readyState: t.readyState, readyState: t.readyState,
})), })),
options: this.options,
}); });
// Set up video element // Set up video element
if (this.video) { if (this.video) {
this.video.srcObject = this.stream; this.video.srcObject = this.stream;
// Only show preview if showPreview is true
if (this.options.showPreview) {
this.video.style.display = "block";
} else {
this.video.style.display = "none";
}
await this.video.play(); await this.video.play();
logger.error( logger.error(
`[WebInlineQRScanner:${this.id}] Video element started playing`, `[WebInlineQRScanner:${this.id}] Video element started playing`,

19
src/services/deepLinks.ts

@ -52,7 +52,7 @@ import {
routeSchema, routeSchema,
DeepLinkRoute, DeepLinkRoute,
} from "../interfaces/deepLinks"; } from "../interfaces/deepLinks";
import { logConsoleAndDb } from "../db"; import { logConsoleAndDb } from "../db/databaseUtil";
import type { DeepLinkError } from "../interfaces/deepLinks"; import type { DeepLinkError } from "../interfaces/deepLinks";
/** /**
@ -119,6 +119,15 @@ export class DeepLinkHandler {
const [path, queryString] = parts[1].split("?"); const [path, queryString] = parts[1].split("?");
const [routePath, param] = path.split("/"); const [routePath, param] = path.split("/");
// Validate route exists before proceeding
if (!this.ROUTE_MAP[routePath]) {
throw {
code: "INVALID_ROUTE",
message: `Invalid route path: ${routePath}`,
details: { routePath },
};
}
const query: Record<string, string> = {}; const query: Record<string, string> = {};
if (queryString) { if (queryString) {
new URLSearchParams(queryString).forEach((value, key) => { new URLSearchParams(queryString).forEach((value, key) => {
@ -128,11 +137,9 @@ export class DeepLinkHandler {
const params: Record<string, string> = {}; const params: Record<string, string> = {};
if (param) { if (param) {
if (this.ROUTE_MAP[routePath].paramKey) { // Now we know routePath exists in ROUTE_MAP
params[this.ROUTE_MAP[routePath].paramKey] = param; const routeConfig = this.ROUTE_MAP[routePath];
} else { params[routeConfig.paramKey ?? "id"] = param;
params["id"] = param;
}
} }
return { path: routePath, params, query }; return { path: routePath, params, query };
} }

60
src/services/migrationService.ts

@ -0,0 +1,60 @@
interface Migration {
name: string;
sql: string;
}
export class MigrationService {
private static instance: MigrationService;
private migrations: Migration[] = [];
private constructor() {}
static getInstance(): MigrationService {
if (!MigrationService.instance) {
MigrationService.instance = new MigrationService();
}
return MigrationService.instance;
}
registerMigration(migration: Migration) {
this.migrations.push(migration);
}
/**
* @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
*/
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 (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
executed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
`);
// Get list of executed migrations
const result1: T = await sqlQuery("SELECT name FROM migrations;");
const executedMigrations = extractMigrationNames(result1);
// Run pending migrations in order
for (const migration of this.migrations) {
if (!executedMigrations.has(migration.name)) {
await sqlExec(migration.sql);
await sqlExec(
`INSERT INTO migrations (name) VALUES ('${migration.name}')`,
);
}
}
}
}
export default MigrationService.getInstance();

251
src/services/platforms/CapacitorPlatformService.ts

@ -1,23 +1,237 @@
import { Filesystem, Directory, Encoding } from "@capacitor/filesystem";
import { Camera, CameraResultType, CameraSource, CameraDirection } from "@capacitor/camera";
import { Share } from "@capacitor/share";
import {
SQLiteConnection,
SQLiteDBConnection,
CapacitorSQLite,
capSQLiteChanges,
DBSQLiteValues,
} from "@capacitor-community/sqlite";
import { runMigrations } from "@/db-sql/migration";
import { QueryExecResult } from "@/interfaces/database";
import { import {
ImageResult, ImageResult,
PlatformService, PlatformService,
PlatformCapabilities, PlatformCapabilities,
} from "../PlatformService"; } from "../PlatformService";
import { Filesystem, Directory, Encoding } from "@capacitor/filesystem";
import { Camera, CameraResultType, CameraSource, CameraDirection } from "@capacitor/camera";
import { Share } from "@capacitor/share";
import { logger } from "../../utils/logger"; import { logger } from "../../utils/logger";
interface QueuedOperation {
type: "run" | "query";
sql: string;
params: unknown[];
resolve: (value: unknown) => void;
reject: (reason: unknown) => void;
}
/** /**
* Platform service implementation for Capacitor (mobile) platform. * Platform service implementation for Capacitor (mobile) platform.
* Provides native mobile functionality through Capacitor plugins for: * Provides native mobile functionality through Capacitor plugins for:
* - File system operations * - File system operations
* - Camera and image picker * - Camera and image picker
* - Platform-specific features * - Platform-specific features
* - SQLite database operations
*/ */
export class CapacitorPlatformService implements PlatformService { export class CapacitorPlatformService implements PlatformService {
/** Current camera direction */ /** Current camera direction */
private currentDirection: CameraDirection = 'BACK'; private currentDirection: CameraDirection = "BACK";
private sqlite: SQLiteConnection;
private db: SQLiteDBConnection | null = null;
private dbName = "timesafari.sqlite";
private initialized = false;
private initializationPromise: Promise<void> | null = null;
private operationQueue: Array<QueuedOperation> = [];
private isProcessingQueue: boolean = false;
constructor() {
this.sqlite = new SQLiteConnection(CapacitorSQLite);
}
private async initializeDatabase(): Promise<void> {
// If already initialized, return immediately
if (this.initialized) {
return;
}
// If initialization is in progress, wait for it
if (this.initializationPromise) {
return this.initializationPromise;
}
// Start initialization
this.initializationPromise = this._initialize();
try {
await this.initializationPromise;
} catch (error) {
logger.error(
"[CapacitorPlatformService] Initialize method failed:",
error,
);
this.initializationPromise = null; // Reset on failure
throw error;
}
}
private async _initialize(): Promise<void> {
if (this.initialized) {
return;
}
try {
// Create/Open database
this.db = await this.sqlite.createConnection(
this.dbName,
false,
"no-encryption",
1,
false,
);
await this.db.open();
// Set journal mode to WAL for better performance
// await this.db.execute("PRAGMA journal_mode=WAL;");
// Run migrations
await this.runCapacitorMigrations();
this.initialized = true;
logger.log(
"[CapacitorPlatformService] SQLite database initialized successfully",
);
// Start processing the queue after initialization
this.processQueue();
} catch (error) {
logger.error(
"[CapacitorPlatformService] Error initializing SQLite database:",
error,
);
throw new Error(
"[CapacitorPlatformService] Failed to initialize database",
);
}
}
private async processQueue(): Promise<void> {
if (this.isProcessingQueue || !this.initialized || !this.db) {
return;
}
this.isProcessingQueue = true;
while (this.operationQueue.length > 0) {
const operation = this.operationQueue.shift();
if (!operation) continue;
try {
let result: unknown;
switch (operation.type) {
case "run": {
const runResult = await this.db.run(
operation.sql,
operation.params,
);
result = {
changes: runResult.changes?.changes || 0,
lastId: runResult.changes?.lastId,
};
break;
}
case "query": {
const queryResult = await this.db.query(
operation.sql,
operation.params,
);
result = {
columns: Object.keys(queryResult.values?.[0] || {}),
values: (queryResult.values || []).map((row) =>
Object.values(row),
),
};
break;
}
}
operation.resolve(result);
} catch (error) {
logger.error(
"[CapacitorPlatformService] Error while processing SQL queue:",
error,
);
operation.reject(error);
}
}
this.isProcessingQueue = false;
}
private async queueOperation<R>(
type: QueuedOperation["type"],
sql: string,
params: unknown[] = [],
): Promise<R> {
return new Promise<R>((resolve, reject) => {
const operation: QueuedOperation = {
type,
sql,
params,
resolve: (value: unknown) => resolve(value as R),
reject,
};
this.operationQueue.push(operation);
// If we're already initialized, start processing the queue
if (this.initialized && this.db) {
this.processQueue();
}
});
}
private async waitForInitialization(): Promise<void> {
// If we have an initialization promise, wait for it
if (this.initializationPromise) {
await this.initializationPromise;
return;
}
// If not initialized and no promise, start initialization
if (!this.initialized) {
await this.initializeDatabase();
return;
}
// If initialized but no db, something went wrong
if (!this.db) {
logger.error(
"[CapacitorPlatformService] Database not properly initialized after await waitForInitialization() - initialized flag is true but db is null",
);
throw new Error(
"[CapacitorPlatformService] The database could not be initialized. We recommend you restart or reinstall.",
);
}
}
private async runCapacitorMigrations(): Promise<void> {
if (!this.db) {
throw new Error("Database not initialized");
}
const sqlExec: (sql: string) => Promise<capSQLiteChanges> =
this.db.execute.bind(this.db);
const sqlQuery: (sql: string) => Promise<DBSQLiteValues> =
this.db.query.bind(this.db);
const extractMigrationNames: (result: DBSQLiteValues) => Set<string> = (
result,
) => {
const names =
result.values?.map((row: { name: string }) => row.name) || [];
return new Set(names);
};
runMigrations(sqlExec, sqlQuery, extractMigrationNames);
}
/** /**
* Gets the capabilities of the Capacitor platform * Gets the capabilities of the Capacitor platform
@ -189,6 +403,9 @@ export class CapacitorPlatformService implements PlatformService {
*/ */
async writeFile(fileName: string, content: string): Promise<void> { async writeFile(fileName: string, content: string): Promise<void> {
try { try {
// Check storage permissions before proceeding
await this.checkStoragePermissions();
const logData = { const logData = {
targetFileName: fileName, targetFileName: fileName,
contentLength: content.length, contentLength: content.length,
@ -330,6 +547,9 @@ export class CapacitorPlatformService implements PlatformService {
logger.log("[CapacitorPlatformService]", JSON.stringify(logData, null, 2)); logger.log("[CapacitorPlatformService]", JSON.stringify(logData, null, 2));
try { try {
// Check storage permissions before proceeding
await this.checkStoragePermissions();
const { uri } = await Filesystem.writeFile({ const { uri } = await Filesystem.writeFile({
path: fileName, path: fileName,
data: content, data: content,
@ -490,4 +710,27 @@ export class CapacitorPlatformService implements PlatformService {
// This is just a placeholder for the interface // This is just a placeholder for the interface
return Promise.resolve(); return Promise.resolve();
} }
/**
* @see PlatformService.dbQuery
*/
async dbQuery(sql: string, params?: unknown[]): Promise<QueryExecResult> {
await this.waitForInitialization();
return this.queueOperation<QueryExecResult>("query", sql, params || []);
}
/**
* @see PlatformService.dbExec
*/
async dbExec(
sql: string,
params?: unknown[],
): Promise<{ changes: number; lastId?: number }> {
await this.waitForInitialization();
return this.queueOperation<{ changes: number; lastId?: number }>(
"run",
sql,
params || [],
);
}
} }

255
src/services/platforms/ElectronPlatformService.ts

@ -4,20 +4,195 @@ import {
PlatformCapabilities, PlatformCapabilities,
} from "../PlatformService"; } from "../PlatformService";
import { logger } from "../../utils/logger"; import { logger } from "../../utils/logger";
import { QueryExecResult, SqlValue } from "@/interfaces/database";
import {
SQLiteConnection,
SQLiteDBConnection,
CapacitorSQLite,
Changes,
} from "@capacitor-community/sqlite";
import { DEFAULT_ENDORSER_API_SERVER } from "@/constants/app";
interface Migration {
name: string;
sql: string;
}
/** /**
* Platform service implementation for Electron (desktop) platform. * Platform service implementation for Electron (desktop) platform.
* Note: This is a placeholder implementation with most methods currently unimplemented. * Provides native desktop functionality through Electron and Capacitor plugins for:
* Implements the PlatformService interface but throws "Not implemented" errors for most operations. * - File system operations (TODO)
* * - Camera integration (TODO)
* @remarks * - SQLite database operations
* This service is intended for desktop application functionality through Electron. * - System-level features (TODO)
* Future implementations should provide:
* - Native file system access
* - Desktop camera integration
* - System-level features
*/ */
export class ElectronPlatformService implements PlatformService { export class ElectronPlatformService implements PlatformService {
private sqlite: SQLiteConnection;
private db: SQLiteDBConnection | null = null;
private dbName = "timesafari.db";
private initialized = false;
constructor() {
this.sqlite = new SQLiteConnection(CapacitorSQLite);
}
private async initializeDatabase(): Promise<void> {
if (this.initialized) {
return;
}
try {
// Create/Open database
this.db = await this.sqlite.createConnection(
this.dbName,
false,
"no-encryption",
1,
false,
);
await this.db.open();
// Set journal mode to WAL for better performance
await this.db.execute("PRAGMA journal_mode=WAL;");
// Run migrations
await this.runMigrations();
this.initialized = true;
logger.log(
"[ElectronPlatformService] SQLite database initialized successfully",
);
} catch (error) {
logger.error(
"[ElectronPlatformService] Error initializing SQLite database:",
error,
);
throw new Error(
"[ElectronPlatformService] Failed to initialize database",
);
}
}
private async runMigrations(): Promise<void> {
if (!this.db) {
throw new Error("Database not initialized");
}
// Create migrations table if it doesn't exist
await this.db.execute(`
CREATE TABLE IF NOT EXISTS migrations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
executed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
`);
// Get list of executed migrations
const result = await this.db.query("SELECT name FROM migrations;");
const executedMigrations = new Set(
result.values?.map((row) => row[0]) || [],
);
// Run pending migrations in order
const migrations: Migration[] = [
{
name: "001_initial",
sql: `
CREATE TABLE IF NOT EXISTS accounts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
dateCreated TEXT NOT NULL,
derivationPath TEXT,
did TEXT NOT NULL,
identityEncrBase64 TEXT,
mnemonicEncrBase64 TEXT,
passkeyCredIdHex TEXT,
publicKeyHex TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_accounts_did ON accounts(did);
CREATE TABLE IF NOT EXISTS secret (
id INTEGER PRIMARY KEY AUTOINCREMENT,
secretBase64 TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS settings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
accountDid TEXT,
activeDid TEXT,
apiServer TEXT,
filterFeedByNearby BOOLEAN,
filterFeedByVisible BOOLEAN,
finishedOnboarding BOOLEAN,
firstName TEXT,
hideRegisterPromptOnNewContact BOOLEAN,
isRegistered BOOLEAN,
lastName TEXT,
lastAckedOfferToUserJwtId TEXT,
lastAckedOfferToUserProjectsJwtId TEXT,
lastNotifiedClaimId TEXT,
lastViewedClaimId TEXT,
notifyingNewActivityTime TEXT,
notifyingReminderMessage TEXT,
notifyingReminderTime TEXT,
partnerApiServer TEXT,
passkeyExpirationMinutes INTEGER,
profileImageUrl TEXT,
searchBoxes TEXT,
showContactGivesInline BOOLEAN,
showGeneralAdvanced BOOLEAN,
showShortcutBvc BOOLEAN,
vapid TEXT,
warnIfProdServer BOOLEAN,
warnIfTestServer BOOLEAN,
webPushServer TEXT
);
CREATE INDEX IF NOT EXISTS idx_settings_accountDid ON settings(accountDid);
INSERT INTO settings (id, apiServer) VALUES (1, '${DEFAULT_ENDORSER_API_SERVER}');
CREATE TABLE IF NOT EXISTS contacts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
did TEXT NOT NULL,
name TEXT,
contactMethods TEXT,
nextPubKeyHashB64 TEXT,
notes TEXT,
profileImageUrl TEXT,
publicKeyBase64 TEXT,
seesMe BOOLEAN,
registered BOOLEAN
);
CREATE INDEX IF NOT EXISTS idx_contacts_did ON contacts(did);
CREATE INDEX IF NOT EXISTS idx_contacts_name ON contacts(name);
CREATE TABLE IF NOT EXISTS logs (
date TEXT PRIMARY KEY,
message TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS temp (
id TEXT PRIMARY KEY,
blobB64 TEXT
);
`,
},
];
for (const migration of migrations) {
if (!executedMigrations.has(migration.name)) {
await this.db.execute(migration.sql);
await this.db.run("INSERT INTO migrations (name) VALUES (?)", [
migration.name,
]);
logger.log(`Migration ${migration.name} executed successfully`);
}
}
}
/** /**
* Gets the capabilities of the Electron platform * Gets the capabilities of the Electron platform
* @returns Platform capabilities object * @returns Platform capabilities object
@ -55,6 +230,17 @@ export class ElectronPlatformService implements PlatformService {
throw new Error("Not implemented"); throw new Error("Not implemented");
} }
/**
* Writes content to a file and opens the system share dialog.
* @param _fileName - Name of the file to create
* @param _content - Content to write to the file
* @throws Error with "Not implemented" message
* @todo Implement using Electron's dialog and file system APIs
*/
async writeAndShareFile(_fileName: string, _content: string): Promise<void> {
throw new Error("Not implemented");
}
/** /**
* Deletes a file from the filesystem. * Deletes a file from the filesystem.
* @param _path - Path to the file to delete * @param _path - Path to the file to delete
@ -108,4 +294,55 @@ export class ElectronPlatformService implements PlatformService {
logger.error("handleDeepLink not implemented in Electron platform"); logger.error("handleDeepLink not implemented in Electron platform");
throw new Error("Not implemented"); throw new Error("Not implemented");
} }
/**
* @see PlatformService.dbQuery
*/
async dbQuery(sql: string, params?: unknown[]): Promise<QueryExecResult> {
await this.initializeDatabase();
if (!this.db) {
throw new Error("Database not initialized");
}
try {
const result = await this.db.query(sql, params || []);
const values = result.values || [];
return {
columns: [], // SQLite plugin doesn't provide column names in query result
values: values as SqlValue[][],
};
} catch (error) {
logger.error("Error executing query:", error);
throw new Error(
`Database query failed: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
/**
* @see PlatformService.dbExec
*/
async dbExec(
sql: string,
params?: unknown[],
): Promise<{ changes: number; lastId?: number }> {
await this.initializeDatabase();
if (!this.db) {
throw new Error("Database not initialized");
}
try {
const result = await this.db.run(sql, params || []);
const changes = result.changes as Changes;
return {
changes: changes?.changes || 0,
lastId: changes?.lastId,
};
} catch (error) {
logger.error("Error executing statement:", error);
throw new Error(
`Database execution failed: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
} }

23
src/services/platforms/PyWebViewPlatformService.ts

@ -4,6 +4,7 @@ import {
PlatformCapabilities, PlatformCapabilities,
} from "../PlatformService"; } from "../PlatformService";
import { logger } from "../../utils/logger"; import { logger } from "../../utils/logger";
import { QueryExecResult } from "@/interfaces/database";
/** /**
* Platform service implementation for PyWebView platform. * Platform service implementation for PyWebView platform.
@ -109,4 +110,26 @@ export class PyWebViewPlatformService implements PlatformService {
logger.error("handleDeepLink not implemented in PyWebView platform"); logger.error("handleDeepLink not implemented in PyWebView platform");
throw new Error("Not implemented"); throw new Error("Not implemented");
} }
dbQuery(sql: string, params?: unknown[]): Promise<QueryExecResult> {
throw new Error("Not implemented for " + sql + " with params " + params);
}
dbExec(
sql: string,
params?: unknown[],
): Promise<{ changes: number; lastId?: number }> {
throw new Error("Not implemented for " + sql + " with params " + params);
}
/**
* Should write and share a file using the Python backend.
* @param _fileName - Name of the file to write and share
* @param _content - Content to write to the file
* @throws Error with "Not implemented" message
* @todo Implement file writing and sharing through pywebview's Python-JavaScript bridge
*/
async writeAndShareFile(_fileName: string, _content: string): Promise<void> {
logger.error("writeAndShareFile not implemented in PyWebView platform");
throw new Error("Not implemented");
}
} }

31
src/services/platforms/WebPlatformService.ts

@ -4,6 +4,8 @@ import {
PlatformCapabilities, PlatformCapabilities,
} from "../PlatformService"; } from "../PlatformService";
import { logger } from "../../utils/logger"; import { logger } from "../../utils/logger";
import { QueryExecResult } from "@/interfaces/database";
import databaseService from "../AbsurdSqlDatabaseService";
/** /**
* Platform service implementation for web browser platform. * Platform service implementation for web browser platform.
@ -359,4 +361,33 @@ export class WebPlatformService implements PlatformService {
async writeAndShareFile(_fileName: string, _content: string): Promise<void> { async writeAndShareFile(_fileName: string, _content: string): Promise<void> {
throw new Error("File system access not available in web platform"); throw new Error("File system access not available in web platform");
} }
/**
* @see PlatformService.dbQuery
*/
dbQuery(
sql: string,
params?: unknown[],
): Promise<QueryExecResult | undefined> {
return databaseService.query(sql, params).then((result) => result[0]);
}
/**
* @see PlatformService.dbExec
*/
dbExec(
sql: string,
params?: unknown[],
): Promise<{ changes: number; lastId?: number }> {
return databaseService.run(sql, params);
}
async dbGetOneRow(
sql: string,
params?: unknown[],
): Promise<unknown[] | undefined> {
return databaseService
.query(sql, params)
.then((result: QueryExecResult[]) => result[0]?.values[0]);
}
} }

8
src/test/index.ts

@ -1,6 +1,7 @@
import axios from "axios"; import axios from "axios";
import * as didJwt from "did-jwt"; import * as didJwt from "did-jwt";
import { AppString } from "../constants/app"; import { AppString, USE_DEXIE_DB } from "../constants/app";
import * as databaseUtil from "../db/databaseUtil";
import { retrieveSettingsForActiveAccount } from "../db"; import { retrieveSettingsForActiveAccount } from "../db";
import { SERVICE_ID } from "../libs/endorserServer"; import { SERVICE_ID } from "../libs/endorserServer";
import { deriveAddress, newIdentifier } from "../libs/crypto"; import { deriveAddress, newIdentifier } from "../libs/crypto";
@ -16,7 +17,10 @@ export async function testServerRegisterUser() {
const identity0 = newIdentifier(addr, publicHex, privateHex, deriPath); const identity0 = newIdentifier(addr, publicHex, privateHex, deriPath);
const settings = await retrieveSettingsForActiveAccount(); let settings = await databaseUtil.retrieveSettingsForActiveAccount();
if (USE_DEXIE_DB) {
settings = await retrieveSettingsForActiveAccount();
}
// Make a claim // Make a claim
const vcClaim = { const vcClaim = {

45
src/types/absurd-sql.d.ts

@ -0,0 +1,45 @@
declare module 'absurd-sql/dist/indexeddb-backend' {
export default class IndexedDBBackend {
constructor(options?: {
dbName?: string;
storeName?: string;
onReady?: () => void;
onError?: (error: Error) => void;
});
init(): Promise<void>;
exec(sql: string, params?: any[]): Promise<any>;
close(): Promise<void>;
}
}
declare module 'absurd-sql/dist/indexeddb-main-thread' {
export function initBackend(worker: Worker): Promise<void>;
export default class IndexedDBMainThread {
constructor(options?: {
dbName?: string;
storeName?: string;
onReady?: () => void;
onError?: (error: Error) => void;
});
init(): Promise<void>;
exec(sql: string, params?: any[]): Promise<any>;
close(): Promise<void>;
}
}
declare module 'absurd-sql' {
export class SQLiteFS {
constructor(fs: unknown, backend: IndexedDBBackend);
init(): Promise<void>;
close(): Promise<void>;
exec(sql: string, params?: any[]): Promise<any>;
prepare(sql: string): Promise<any>;
run(sql: string, params?: any[]): Promise<any>;
get(sql: string, params?: any[]): Promise<any>;
all(sql: string, params?: any[]): Promise<any[]>;
}
export * from 'absurd-sql/dist/indexeddb-backend';
export * from 'absurd-sql/dist/indexeddb-main-thread';
}

36
src/types/global.d.ts

@ -0,0 +1,36 @@
import type { QueryExecResult, SqlValue } from "./database";
declare module '@jlongster/sql.js' {
interface SQL {
Database: new (path: string, options?: { filename: boolean }) => Database;
FS: {
mkdir: (path: string) => void;
mount: (fs: any, options: any, path: string) => void;
open: (path: string, flags: string) => any;
close: (stream: any) => void;
};
register_for_idb: (fs: any) => void;
}
interface Database {
exec: (sql: string, params?: unknown[]) => Promise<QueryExecResult[]>;
run: (sql: string, params?: unknown[]) => Promise<{ changes: number; lastId?: number }>;
get: (sql: string, params?: unknown[]) => Promise<SqlValue[]>;
all: (sql: string, params?: unknown[]) => Promise<SqlValue[][]>;
prepare: (sql: string) => Promise<Statement>;
close: () => void;
}
interface Statement {
run: (params?: unknown[]) => Promise<{ changes: number; lastId?: number }>;
get: (params?: unknown[]) => Promise<SqlValue[]>;
all: (params?: unknown[]) => Promise<SqlValue[][]>;
finalize: () => void;
}
const initSqlJs: (options?: {
locateFile?: (file: string) => string;
}) => Promise<SQL>;
export default initSqlJs;
}

67
src/types/modules.d.ts

@ -0,0 +1,67 @@
import type { QueryExecResult, SqlValue } from "./database";
declare module '@jlongster/sql.js' {
interface SQL {
Database: new (path: string, options?: { filename: boolean }) => Database;
FS: {
mkdir: (path: string) => void;
mount: (fs: any, options: any, path: string) => void;
open: (path: string, flags: string) => any;
close: (stream: any) => void;
};
register_for_idb: (fs: any) => void;
}
interface Database {
exec: (sql: string, params?: unknown[]) => Promise<QueryExecResult[]>;
run: (sql: string, params?: unknown[]) => Promise<{ changes: number; lastId?: number }>;
get: (sql: string, params?: unknown[]) => Promise<SqlValue[]>;
all: (sql: string, params?: unknown[]) => Promise<SqlValue[][]>;
prepare: (sql: string) => Promise<Statement>;
close: () => void;
}
interface Statement {
run: (params?: unknown[]) => Promise<{ changes: number; lastId?: number }>;
get: (params?: unknown[]) => Promise<SqlValue[]>;
all: (params?: unknown[]) => Promise<SqlValue[][]>;
finalize: () => void;
}
const initSqlJs: (options?: {
locateFile?: (file: string) => string;
}) => Promise<SQL>;
export default initSqlJs;
}
declare module 'absurd-sql' {
import type { SQL } from '@jlongster/sql.js';
export class SQLiteFS {
constructor(fs: any, backend: any);
}
}
declare module 'absurd-sql/dist/indexeddb-backend' {
export default class IndexedDBBackend {
constructor();
}
}
declare module 'absurd-sql/dist/indexeddb-main-thread' {
import type { QueryExecResult } from './database';
export interface SQLiteOptions {
filename?: string;
autoLoad?: boolean;
debug?: boolean;
}
export interface SQLiteDatabase {
exec: (sql: string, params?: unknown[]) => Promise<QueryExecResult[]>;
close: () => Promise<void>;
}
export function initSqlJs(options?: any): Promise<any>;
export function createDatabase(options?: SQLiteOptions): Promise<SQLiteDatabase>;
export function openDatabase(options?: SQLiteOptions): Promise<SQLiteDatabase>;
}

57
src/types/sql.js.d.ts

@ -0,0 +1,57 @@
/**
* Type definitions for @jlongster/sql.js
* @author Matthew Raymer
* @description TypeScript declaration file for the SQL.js WASM module with filesystem support
*/
declare module '@jlongster/sql.js' {
export interface FileSystem {
mkdir(path: string): void;
mount(fs: any, opts: any, mountpoint: string): void;
open(path: string, flags: string): FileStream;
close(stream: FileStream): void;
}
export interface FileStream {
node: {
contents: {
readIfFallback(): Promise<void>;
};
};
}
export interface Database {
exec(sql: string, params?: any[]): Promise<QueryExecResult[]>;
prepare(sql: string): Statement;
run(sql: string, params?: any[]): Promise<{ changes: number; lastId?: number }>;
close(): void;
}
export interface QueryExecResult {
columns: string[];
values: any[][];
}
export interface Statement {
bind(params: any[]): void;
step(): boolean;
get(): any[];
getColumnNames(): string[];
reset(): void;
free(): void;
}
export interface InitSqlJsStatic {
(config?: {
locateFile?: (file: string) => string;
wasmBinary?: ArrayBuffer;
}): Promise<{
Database: new (path?: string | Uint8Array, opts?: { filename?: boolean }) => Database;
FS: FileSystem;
register_for_idb: (fs: any) => void;
}>;
}
const initSqlJs: InitSqlJsStatic;
export default initSqlJs;
}

2
src/utils/empty-module.js

@ -0,0 +1,2 @@
// Empty module to satisfy Node.js built-in module imports
export default {};

12
src/utils/logger.ts

@ -1,4 +1,4 @@
import { logToDb } from "../db"; import { logToDb } from "../db/databaseUtil";
function safeStringify(obj: unknown) { function safeStringify(obj: unknown) {
const seen = new WeakSet(); const seen = new WeakSet();
@ -24,8 +24,8 @@ export const logger = {
if (process.env.NODE_ENV !== "production") { if (process.env.NODE_ENV !== "production") {
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
console.debug(message, ...args); console.debug(message, ...args);
const argsString = args.length > 0 ? " - " + safeStringify(args) : ""; // const argsString = args.length > 0 ? " - " + safeStringify(args) : "";
logToDb(message + argsString); // logToDb(message + argsString);
} }
}, },
log: (message: string, ...args: unknown[]) => { log: (message: string, ...args: unknown[]) => {
@ -42,7 +42,8 @@ export const logger = {
info: (message: string, ...args: unknown[]) => { info: (message: string, ...args: unknown[]) => {
if ( if (
process.env.NODE_ENV !== "production" || process.env.NODE_ENV !== "production" ||
process.env.VITE_PLATFORM === "capacitor" process.env.VITE_PLATFORM === "capacitor" ||
process.env.VITE_PLATFORM === "electron"
) { ) {
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
console.info(message, ...args); console.info(message, ...args);
@ -53,7 +54,8 @@ export const logger = {
warn: (message: string, ...args: unknown[]) => { warn: (message: string, ...args: unknown[]) => {
if ( if (
process.env.NODE_ENV !== "production" || process.env.NODE_ENV !== "production" ||
process.env.VITE_PLATFORM === "capacitor" process.env.VITE_PLATFORM === "capacitor" ||
process.env.VITE_PLATFORM === "electron"
) { ) {
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
console.warn(message, ...args); console.warn(message, ...args);

17
src/utils/node-modules/crypto.js

@ -0,0 +1,17 @@
// Minimal crypto module implementation for browser using Web Crypto API
const crypto = {
...window.crypto,
// Add any Node.js crypto methods that might be needed
randomBytes: (size) => {
const buffer = new Uint8Array(size);
window.crypto.getRandomValues(buffer);
return buffer;
},
createHash: () => ({
update: () => ({
digest: () => new Uint8Array(32), // Return empty hash
}),
}),
};
export default crypto;

18
src/utils/node-modules/fs.js

@ -0,0 +1,18 @@
// Minimal fs module implementation for browser
const fs = {
readFileSync: () => {
throw new Error("fs.readFileSync is not supported in browser");
},
writeFileSync: () => {
throw new Error("fs.writeFileSync is not supported in browser");
},
existsSync: () => false,
mkdirSync: () => {},
readdirSync: () => [],
statSync: () => ({
isDirectory: () => false,
isFile: () => false,
}),
};
export default fs;

13
src/utils/node-modules/path.js

@ -0,0 +1,13 @@
// Minimal path module implementation for browser
const path = {
resolve: (...parts) => parts.join("/"),
join: (...parts) => parts.join("/"),
dirname: (p) => p.split("/").slice(0, -1).join("/"),
basename: (p) => p.split("/").pop(),
extname: (p) => {
const parts = p.split(".");
return parts.length > 1 ? "." + parts.pop() : "";
},
};
export default path;

339
src/views/AccountViewView.vue

@ -76,7 +76,8 @@
Set Your Name Set Your Name
</button> </button>
<p class="text-xs text-slate-500 mt-1"> <p class="text-xs text-slate-500 mt-1">
(Don't worry: this is not visible to anyone until you share it with them. It's not sent to any servers.) (Don't worry: this is not visible to anyone until you share it with
them. It's not sent to any servers.)
</p> </p>
<UserNameDialog ref="userNameDialog" /> <UserNameDialog ref="userNameDialog" />
</span> </span>
@ -110,7 +111,10 @@
<font-awesome icon="camera" class="fa-fw" /> <font-awesome icon="camera" class="fa-fw" />
</div> </div>
</template> </template>
<!-- If not registered, they don't need to see this at all. We show a prompt to register below. --> <!--
If not registered, they don't need to see this at all. We show a prompt
to register below.
-->
</div> </div>
<ImageMethodDialog <ImageMethodDialog
ref="imageMethodDialog" ref="imageMethodDialog"
@ -427,12 +431,16 @@
<div class="mb-4 text-center"> <div class="mb-4 text-center">
{{ limitsMessage }} {{ limitsMessage }}
</div> </div>
<div> <div v-if="endorserLimits">
<p class="text-sm"> <p class="text-sm">
You have done You have done
<b>{{ endorserLimits?.doneClaimsThisWeek || "?" }} claims</b> out of <b
<b>{{ endorserLimits?.maxClaimsPerWeek || "?" }}</b> for this week. >{{ endorserLimits?.doneClaimsThisWeek || "?" }} claim{{
Your claims counter resets at endorserLimits?.doneClaimsThisWeek === 1 ? "" : "s"
}}</b
>
out of <b>{{ endorserLimits?.maxClaimsPerWeek || "?" }}</b> for this
week. Your claims counter resets at
<b class="whitespace-nowrap">{{ <b class="whitespace-nowrap">{{
readableDate(endorserLimits?.nextWeekBeginDateTime) readableDate(endorserLimits?.nextWeekBeginDateTime)
}}</b> }}</b>
@ -443,7 +451,9 @@
>{{ >{{
endorserLimits?.doneRegistrationsThisMonth || "?" endorserLimits?.doneRegistrationsThisMonth || "?"
}} }}
registrations</b registration{{
endorserLimits?.doneRegistrationsThisMonth === 1 ? "" : "s"
}}</b
> >
out of out of
<b>{{ endorserLimits?.maxRegistrationsPerMonth || "?" }}</b> for this <b>{{ endorserLimits?.maxRegistrationsPerMonth || "?" }}</b> for this
@ -456,9 +466,13 @@
</p> </p>
<p class="mt-3 text-sm"> <p class="mt-3 text-sm">
You have uploaded You have uploaded
<b>{{ imageLimits?.doneImagesThisWeek || "?" }} images</b> out of <b
<b>{{ imageLimits?.maxImagesPerWeek || "?" }}</b> for this week. Your >{{ imageLimits?.doneImagesThisWeek || "?" }} image{{
image counter resets at imageLimits?.doneImagesThisWeek === 1 ? "" : "s"
}}</b
>
out of <b>{{ imageLimits?.maxImagesPerWeek || "?" }}</b> for this
week. Your image counter resets at
<b class="whitespace-nowrap">{{ <b class="whitespace-nowrap">{{
readableDate(imageLimits?.nextWeekBeginDateTime) readableDate(imageLimits?.nextWeekBeginDateTime)
}}</b> }}</b>
@ -495,7 +509,7 @@
<!-- Deep Identity Details --> <!-- Deep Identity Details -->
<span class="text-slate-500 text-sm font-bold mb-2"> <span class="text-slate-500 text-sm font-bold mb-2">
Deep Identifier Details Identifier Details
</span> </span>
<div <div
id="sectionDeepIdentifier" id="sectionDeepIdentifier"
@ -578,20 +592,9 @@
Switch Identifier Switch Identifier
</router-link> </router-link>
<div class="flex mt-4">
<button>
<router-link
:to="{ name: 'statistics' }"
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 mb-2"
>
See Global Animated History of Giving
</router-link>
</button>
</div>
<div id="sectionImportContactsSettings" class="mt-4"> <div id="sectionImportContactsSettings" class="mt-4">
<h2 class="text-slate-500 text-sm font-bold"> <h2 class="text-slate-500 text-sm font-bold">
Import Contacts & Settings Database Import Contacts
</h2> </h2>
<div class="ml-4 mt-2"> <div class="ml-4 mt-2">
@ -622,9 +625,7 @@
class="block text-center text-md bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md mb-6" class="block text-center text-md bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md mb-6"
@click="checkContactImports()" @click="checkContactImports()"
> >
Import Only Contacts Import Contacts
<br />
after comparing
</button> </button>
</div> </div>
</div> </div>
@ -902,9 +903,9 @@
</div> </div>
</label> </label>
<div id="sectionPasskeyExpiration" class="flex justify-between"> <div id="sectionPasskeyExpiration" class="flex mt-4 justify-between">
<span> <span>
<span class="text-slate-500 text-sm font-bold mb-2"> <span class="text-slate-500 text-sm font-bold">
Passkey Expiration Minutes Passkey Expiration Minutes
</span> </span>
<br /> <br />
@ -950,10 +951,27 @@
<router-link <router-link
:to="{ name: 'logs' }" :to="{ name: 'logs' }"
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 mb-2" 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 mt-2"
> >
View Logs Logs
</router-link> </router-link>
<router-link
:to="{ name: 'test' }"
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 mt-2"
>
Test Page
</router-link>
<div class="flex mt-2">
<button>
<router-link
:to="{ name: 'statistics' }"
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"
>
See Global Animated History of Giving
</router-link>
</button>
</div>
</section> </section>
</main> </main>
</template> </template>
@ -965,7 +983,7 @@ import { AxiosError } from "axios";
import { Buffer } from "buffer/"; import { Buffer } from "buffer/";
import Dexie from "dexie"; import Dexie from "dexie";
import "dexie-export-import"; import "dexie-export-import";
// @ts-ignore - they aren't exporting it but it's there // @ts-expect-error - they aren't exporting it but it's there
import { ImportProgress } from "dexie-export-import"; import { ImportProgress } from "dexie-export-import";
import { LeafletMouseEvent } from "leaflet"; import { LeafletMouseEvent } from "leaflet";
import * as R from "ramda"; import * as R from "ramda";
@ -990,6 +1008,7 @@ import {
DEFAULT_PUSH_SERVER, DEFAULT_PUSH_SERVER,
IMAGE_TYPE_PROFILE, IMAGE_TYPE_PROFILE,
NotificationIface, NotificationIface,
USE_DEXIE_DB,
} from "../constants/app"; } from "../constants/app";
import { import {
db, db,
@ -1003,6 +1022,7 @@ import {
DEFAULT_PASSKEY_EXPIRATION_MINUTES, DEFAULT_PASSKEY_EXPIRATION_MINUTES,
MASTER_SETTINGS_KEY, MASTER_SETTINGS_KEY,
} from "../db/tables/settings"; } from "../db/tables/settings";
import * as databaseUtil from "../db/databaseUtil";
import { import {
clearPasskeyToken, clearPasskeyToken,
EndorserRateLimits, EndorserRateLimits,
@ -1021,6 +1041,7 @@ import {
} from "../libs/util"; } from "../libs/util";
import { UserProfile } from "@/libs/partnerServer"; import { UserProfile } from "@/libs/partnerServer";
import { logger } from "../utils/logger"; import { logger } from "../utils/logger";
import { PlatformServiceFactory } from "@/services/PlatformServiceFactory";
const inputImportFileNameRef = ref<Blob>(); const inputImportFileNameRef = ref<Blob>();
@ -1136,9 +1157,14 @@ export default class AccountViewView extends Vue {
if (error.status === 404) { if (error.status === 404) {
// this is ok: the profile is not yet created // this is ok: the profile is not yet created
} else { } else {
logConsoleAndDb( databaseUtil.logConsoleAndDb(
"Error loading profile: " + errorStringForLog(error), "Error loading profile: " + errorStringForLog(error),
); );
if (USE_DEXIE_DB) {
logConsoleAndDb(
"Error loading profile: " + errorStringForLog(error),
);
}
this.$notify( this.$notify(
{ {
group: "alert", group: "alert",
@ -1187,7 +1213,6 @@ export default class AccountViewView extends Vue {
this.turnOffNotifyingFlags(); this.turnOffNotifyingFlags();
} }
} }
// console.log("Got to the end of 'mounted' call in AccountViewView.");
/** /**
* Beware! I've seen where we never get to this point because "ready" never resolves. * Beware! I've seen where we never get to this point because "ready" never resolves.
*/ */
@ -1215,8 +1240,11 @@ export default class AccountViewView extends Vue {
* Initializes component state with values from the database or defaults. * Initializes component state with values from the database or defaults.
*/ */
async initializeState() { async initializeState() {
await db.open(); let settings = await databaseUtil.retrieveSettingsForActiveAccount();
const settings = await retrieveSettingsForActiveAccount(); if (USE_DEXIE_DB) {
await db.open();
settings = await retrieveSettingsForActiveAccount();
}
this.activeDid = settings.activeDid || ""; this.activeDid = settings.activeDid || "";
this.apiServer = settings.apiServer || ""; this.apiServer = settings.apiServer || "";
@ -1259,42 +1287,67 @@ export default class AccountViewView extends Vue {
async toggleShowContactAmounts() { async toggleShowContactAmounts() {
this.showContactGives = !this.showContactGives; this.showContactGives = !this.showContactGives;
await db.open(); await databaseUtil.updateDefaultSettings({
await db.settings.update(MASTER_SETTINGS_KEY, {
showContactGivesInline: this.showContactGives, showContactGivesInline: this.showContactGives,
}); });
if (USE_DEXIE_DB) {
await db.open();
await db.settings.update(MASTER_SETTINGS_KEY, {
showContactGivesInline: this.showContactGives,
});
}
} }
async toggleShowGeneralAdvanced() { async toggleShowGeneralAdvanced() {
this.showGeneralAdvanced = !this.showGeneralAdvanced; this.showGeneralAdvanced = !this.showGeneralAdvanced;
await db.open(); await databaseUtil.updateDefaultSettings({
await db.settings.update(MASTER_SETTINGS_KEY, {
showGeneralAdvanced: this.showGeneralAdvanced, showGeneralAdvanced: this.showGeneralAdvanced,
}); });
if (USE_DEXIE_DB) {
await db.open();
await db.settings.update(MASTER_SETTINGS_KEY, {
showGeneralAdvanced: this.showGeneralAdvanced,
});
}
} }
async toggleProdWarning() { async toggleProdWarning() {
this.warnIfProdServer = !this.warnIfProdServer; this.warnIfProdServer = !this.warnIfProdServer;
await db.open(); await databaseUtil.updateDefaultSettings({
await db.settings.update(MASTER_SETTINGS_KEY, {
warnIfProdServer: this.warnIfProdServer, warnIfProdServer: this.warnIfProdServer,
}); });
if (USE_DEXIE_DB) {
await db.open();
await db.settings.update(MASTER_SETTINGS_KEY, {
warnIfProdServer: this.warnIfProdServer,
});
}
} }
async toggleTestWarning() { async toggleTestWarning() {
this.warnIfTestServer = !this.warnIfTestServer; this.warnIfTestServer = !this.warnIfTestServer;
await db.open(); await databaseUtil.updateDefaultSettings({
await db.settings.update(MASTER_SETTINGS_KEY, {
warnIfTestServer: this.warnIfTestServer, warnIfTestServer: this.warnIfTestServer,
}); });
if (USE_DEXIE_DB) {
await db.open();
await db.settings.update(MASTER_SETTINGS_KEY, {
warnIfTestServer: this.warnIfTestServer,
});
}
} }
async toggleShowShortcutBvc() { async toggleShowShortcutBvc() {
this.showShortcutBvc = !this.showShortcutBvc; this.showShortcutBvc = !this.showShortcutBvc;
await db.open(); await databaseUtil.updateDefaultSettings({
await db.settings.update(MASTER_SETTINGS_KEY, {
showShortcutBvc: this.showShortcutBvc, showShortcutBvc: this.showShortcutBvc,
}); });
if (USE_DEXIE_DB) {
await db.open();
await db.settings.update(MASTER_SETTINGS_KEY, {
showShortcutBvc: this.showShortcutBvc,
});
}
} }
readableDate(timeStr: string) { readableDate(timeStr: string) {
@ -1305,9 +1358,18 @@ export default class AccountViewView extends Vue {
* Processes the identity and updates the component's state. * Processes the identity and updates the component's state.
*/ */
async processIdentity() { async processIdentity() {
const account: Account | undefined = await retrieveAccountMetadata( let account: Account | undefined = undefined;
this.activeDid, const platformService = PlatformServiceFactory.getInstance();
const dbAccount = await platformService.dbQuery(
"SELECT * FROM accounts WHERE did = ?",
[this.activeDid],
); );
if (dbAccount) {
account = databaseUtil.mapQueryResultToValues(dbAccount)[0] as Account;
}
if (USE_DEXIE_DB) {
account = await retrieveAccountMetadata(this.activeDid);
}
if (account?.identity) { if (account?.identity) {
const identity = JSON.parse(account.identity as string) as IIdentifier; const identity = JSON.parse(account.identity as string) as IIdentifier;
this.publicHex = identity.keys[0].publicKeyHex; this.publicHex = identity.keys[0].publicKeyHex;
@ -1350,9 +1412,14 @@ export default class AccountViewView extends Vue {
this.$refs.pushNotificationPermission as PushNotificationPermission this.$refs.pushNotificationPermission as PushNotificationPermission
).open(DAILY_CHECK_TITLE, async (success: boolean, timeText: string) => { ).open(DAILY_CHECK_TITLE, async (success: boolean, timeText: string) => {
if (success) { if (success) {
await db.settings.update(MASTER_SETTINGS_KEY, { await databaseUtil.updateDefaultSettings({
notifyingNewActivityTime: timeText, notifyingNewActivityTime: timeText,
}); });
if (USE_DEXIE_DB) {
await db.settings.update(MASTER_SETTINGS_KEY, {
notifyingNewActivityTime: timeText,
});
}
this.notifyingNewActivity = true; this.notifyingNewActivity = true;
this.notifyingNewActivityTime = timeText; this.notifyingNewActivityTime = timeText;
} }
@ -1366,9 +1433,14 @@ export default class AccountViewView extends Vue {
text: "", // unused, only here to satisfy type check text: "", // unused, only here to satisfy type check
callback: async (success) => { callback: async (success) => {
if (success) { if (success) {
await db.settings.update(MASTER_SETTINGS_KEY, { await databaseUtil.updateDefaultSettings({
notifyingNewActivityTime: "", notifyingNewActivityTime: "",
}); });
if (USE_DEXIE_DB) {
await db.settings.update(MASTER_SETTINGS_KEY, {
notifyingNewActivityTime: "",
});
}
this.notifyingNewActivity = false; this.notifyingNewActivity = false;
this.notifyingNewActivityTime = ""; this.notifyingNewActivityTime = "";
} }
@ -1410,10 +1482,16 @@ export default class AccountViewView extends Vue {
DIRECT_PUSH_TITLE, DIRECT_PUSH_TITLE,
async (success: boolean, timeText: string, message?: string) => { async (success: boolean, timeText: string, message?: string) => {
if (success) { if (success) {
await db.settings.update(MASTER_SETTINGS_KEY, { await databaseUtil.updateDefaultSettings({
notifyingReminderMessage: message, notifyingReminderMessage: message,
notifyingReminderTime: timeText, notifyingReminderTime: timeText,
}); });
if (USE_DEXIE_DB) {
await db.settings.update(MASTER_SETTINGS_KEY, {
notifyingReminderMessage: message,
notifyingReminderTime: timeText,
});
}
this.notifyingReminder = true; this.notifyingReminder = true;
this.notifyingReminderMessage = message || ""; this.notifyingReminderMessage = message || "";
this.notifyingReminderTime = timeText; this.notifyingReminderTime = timeText;
@ -1429,10 +1507,16 @@ export default class AccountViewView extends Vue {
text: "", // unused, only here to satisfy type check text: "", // unused, only here to satisfy type check
callback: async (success) => { callback: async (success) => {
if (success) { if (success) {
await db.settings.update(MASTER_SETTINGS_KEY, { await databaseUtil.updateDefaultSettings({
notifyingReminderMessage: "", notifyingReminderMessage: "",
notifyingReminderTime: "", notifyingReminderTime: "",
}); });
if (USE_DEXIE_DB) {
await db.settings.update(MASTER_SETTINGS_KEY, {
notifyingReminderMessage: "",
notifyingReminderTime: "",
});
}
this.notifyingReminder = false; this.notifyingReminder = false;
this.notifyingReminderMessage = ""; this.notifyingReminderMessage = "";
this.notifyingReminderTime = ""; this.notifyingReminderTime = "";
@ -1446,30 +1530,47 @@ export default class AccountViewView extends Vue {
public async toggleHideRegisterPromptOnNewContact() { public async toggleHideRegisterPromptOnNewContact() {
const newSetting = !this.hideRegisterPromptOnNewContact; const newSetting = !this.hideRegisterPromptOnNewContact;
await db.open(); await databaseUtil.updateDefaultSettings({
await db.settings.update(MASTER_SETTINGS_KEY, {
hideRegisterPromptOnNewContact: newSetting, hideRegisterPromptOnNewContact: newSetting,
}); });
if (USE_DEXIE_DB) {
await db.open();
await db.settings.update(MASTER_SETTINGS_KEY, {
hideRegisterPromptOnNewContact: newSetting,
});
}
this.hideRegisterPromptOnNewContact = newSetting; this.hideRegisterPromptOnNewContact = newSetting;
} }
public async updatePasskeyExpiration() { public async updatePasskeyExpiration() {
await db.open(); await databaseUtil.updateDefaultSettings({
await db.settings.update(MASTER_SETTINGS_KEY, {
passkeyExpirationMinutes: this.passkeyExpirationMinutes, passkeyExpirationMinutes: this.passkeyExpirationMinutes,
}); });
if (USE_DEXIE_DB) {
await db.open();
await db.settings.update(MASTER_SETTINGS_KEY, {
passkeyExpirationMinutes: this.passkeyExpirationMinutes,
});
}
clearPasskeyToken(); clearPasskeyToken();
this.passkeyExpirationDescription = tokenExpiryTimeDescription(); this.passkeyExpirationDescription = tokenExpiryTimeDescription();
} }
public async turnOffNotifyingFlags() { public async turnOffNotifyingFlags() {
// should tell the push server as well // should tell the push server as well
await db.open(); await databaseUtil.updateDefaultSettings({
await db.settings.update(MASTER_SETTINGS_KEY, {
notifyingNewActivityTime: "", notifyingNewActivityTime: "",
notifyingReminderMessage: "", notifyingReminderMessage: "",
notifyingReminderTime: "", notifyingReminderTime: "",
}); });
if (USE_DEXIE_DB) {
await db.open();
await db.settings.update(MASTER_SETTINGS_KEY, {
notifyingNewActivityTime: "",
notifyingReminderMessage: "",
notifyingReminderTime: "",
});
}
this.notifyingNewActivity = false; this.notifyingNewActivity = false;
this.notifyingNewActivityTime = ""; this.notifyingNewActivityTime = "";
this.notifyingReminder = false; this.notifyingReminder = false;
@ -1509,7 +1610,10 @@ export default class AccountViewView extends Vue {
* @returns {Promise<Blob>} The generated blob object. * @returns {Promise<Blob>} The generated blob object.
*/ */
private async generateDatabaseBlob(): Promise<Blob> { private async generateDatabaseBlob(): Promise<Blob> {
return await db.export({ prettyJson: true }); if (USE_DEXIE_DB) {
return await db.export({ prettyJson: true });
}
throw new Error("Not implemented");
} }
/** /**
@ -1530,7 +1634,7 @@ export default class AccountViewView extends Vue {
private downloadDatabaseBackup(url: string) { private downloadDatabaseBackup(url: string) {
const downloadAnchor = this.$refs.downloadLink as HTMLAnchorElement; const downloadAnchor = this.$refs.downloadLink as HTMLAnchorElement;
downloadAnchor.href = url; downloadAnchor.href = url;
downloadAnchor.download = `${db.name}-backup.json`; downloadAnchor.download = `${AppString.APP_NAME_NO_SPACES}-backup.json`;
downloadAnchor.click(); // doesn't work for some browsers, eg. DuckDuckGo downloadAnchor.click(); // doesn't work for some browsers, eg. DuckDuckGo
} }
@ -1611,25 +1715,30 @@ export default class AccountViewView extends Vue {
*/ */
async submitImportFile() { async submitImportFile() {
if (inputImportFileNameRef.value != null) { if (inputImportFileNameRef.value != null) {
await db.delete() if (USE_DEXIE_DB) {
.then(async () => { await db
// BulkError: settings.bulkAdd(): 1 of 21 operations failed. Errors: ConstraintError: Key already exists in the object store. .delete()
await Dexie.import(inputImportFileNameRef.value as Blob, { .then(async () => {
progressCallback: this.progressCallback, // BulkError: settings.bulkAdd(): 1 of 21 operations failed. Errors: ConstraintError: Key already exists in the object store.
await Dexie.import(inputImportFileNameRef.value as Blob, {
progressCallback: this.progressCallback,
});
}) })
}) .catch((error) => {
.catch((error) => { logger.error("Error importing file:", error);
logger.error("Error importing file:", error); this.$notify(
this.$notify( {
{ group: "alert",
group: "alert", type: "danger",
type: "danger", title: "Error Importing",
title: "Error Importing", text: "There was an error in the import. Your identities and contacts may have been affected, so you may have to restore your identifier and use the contact import method.",
text: "There was an error in the import. Your identities and contacts may have been affected, so you may have to restore your identifier and use the contact import method.", },
}, -1,
-1, );
); });
}); } else {
throw new Error("Not implemented");
}
} }
} }
@ -1717,7 +1826,15 @@ export default class AccountViewView extends Vue {
if (!this.isRegistered) { if (!this.isRegistered) {
// the user was not known to be registered, but now they are (because we got no error) so let's record it // the user was not known to be registered, but now they are (because we got no error) so let's record it
try { try {
await updateAccountSettings(did, { isRegistered: true }); await databaseUtil.updateAccountSettings(did, {
isRegistered: true,
});
if (USE_DEXIE_DB) {
await db.open();
await db.settings.update(MASTER_SETTINGS_KEY, {
isRegistered: true,
});
}
this.isRegistered = true; this.isRegistered = true;
} catch (err) { } catch (err) {
logger.error("Got an error updating settings:", err); logger.error("Got an error updating settings:", err);
@ -1777,26 +1894,41 @@ export default class AccountViewView extends Vue {
} }
async onClickSaveApiServer() { async onClickSaveApiServer() {
await db.open(); await databaseUtil.updateDefaultSettings({
await db.settings.update(MASTER_SETTINGS_KEY, {
apiServer: this.apiServerInput, apiServer: this.apiServerInput,
}); });
if (USE_DEXIE_DB) {
await db.open();
await db.settings.update(MASTER_SETTINGS_KEY, {
apiServer: this.apiServerInput,
});
}
this.apiServer = this.apiServerInput; this.apiServer = this.apiServerInput;
} }
async onClickSavePartnerServer() { async onClickSavePartnerServer() {
await db.open(); await databaseUtil.updateDefaultSettings({
await db.settings.update(MASTER_SETTINGS_KEY, {
partnerApiServer: this.partnerApiServerInput, partnerApiServer: this.partnerApiServerInput,
}); });
if (USE_DEXIE_DB) {
await db.open();
await db.settings.update(MASTER_SETTINGS_KEY, {
partnerApiServer: this.partnerApiServerInput,
});
}
this.partnerApiServer = this.partnerApiServerInput; this.partnerApiServer = this.partnerApiServerInput;
} }
async onClickSavePushServer() { async onClickSavePushServer() {
await db.open(); await databaseUtil.updateDefaultSettings({
await db.settings.update(MASTER_SETTINGS_KEY, {
webPushServer: this.webPushServerInput, webPushServer: this.webPushServerInput,
}); });
if (USE_DEXIE_DB) {
await db.open();
await db.settings.update(MASTER_SETTINGS_KEY, {
webPushServer: this.webPushServerInput,
});
}
this.webPushServer = this.webPushServerInput; this.webPushServer = this.webPushServerInput;
this.$notify( this.$notify(
{ {
@ -1812,10 +1944,15 @@ export default class AccountViewView extends Vue {
openImageDialog() { openImageDialog() {
(this.$refs.imageMethodDialog as ImageMethodDialog).open( (this.$refs.imageMethodDialog as ImageMethodDialog).open(
async (imgUrl) => { async (imgUrl) => {
await db.open(); await databaseUtil.updateDefaultSettings({
await db.settings.update(MASTER_SETTINGS_KEY, {
profileImageUrl: imgUrl, profileImageUrl: imgUrl,
}); });
if (USE_DEXIE_DB) {
await db.open();
await db.settings.update(MASTER_SETTINGS_KEY, {
profileImageUrl: imgUrl,
});
}
this.profileImageUrl = imgUrl; this.profileImageUrl = imgUrl;
//console.log("Got image URL:", imgUrl); //console.log("Got image URL:", imgUrl);
}, },
@ -1876,10 +2013,15 @@ export default class AccountViewView extends Vue {
// keep the imageUrl in localStorage so the user can try again if they want // keep the imageUrl in localStorage so the user can try again if they want
} }
await db.open(); await databaseUtil.updateDefaultSettings({
await db.settings.update(MASTER_SETTINGS_KEY, {
profileImageUrl: undefined, profileImageUrl: undefined,
}); });
if (USE_DEXIE_DB) {
await db.open();
await db.settings.update(MASTER_SETTINGS_KEY, {
profileImageUrl: undefined,
});
}
this.profileImageUrl = undefined; this.profileImageUrl = undefined;
} catch (error) { } catch (error) {
@ -1888,9 +2030,14 @@ export default class AccountViewView extends Vue {
if ((error as any).response.status === 404) { if ((error as any).response.status === 404) {
logger.error("The image was already deleted:", error); logger.error("The image was already deleted:", error);
await updateAccountSettings(this.activeDid, { await databaseUtil.updateAccountSettings(this.activeDid, {
profileImageUrl: undefined, profileImageUrl: undefined,
}); });
if (USE_DEXIE_DB) {
await updateAccountSettings(this.activeDid, {
profileImageUrl: undefined,
});
}
this.profileImageUrl = undefined; this.profileImageUrl = undefined;
@ -1968,7 +2115,12 @@ export default class AccountViewView extends Vue {
throw Error("Profile not saved"); throw Error("Profile not saved");
} }
} catch (error) { } catch (error) {
logConsoleAndDb("Error saving profile: " + errorStringForLog(error)); databaseUtil.logConsoleAndDb(
"Error saving profile: " + errorStringForLog(error),
);
if (USE_DEXIE_DB) {
logConsoleAndDb("Error saving profile: " + errorStringForLog(error));
}
const errorMessage: string = const errorMessage: string =
error.response?.data?.error?.message || error.response?.data?.error?.message ||
error.response?.data?.error || error.response?.data?.error ||
@ -2058,7 +2210,12 @@ export default class AccountViewView extends Vue {
throw Error("Profile not deleted"); throw Error("Profile not deleted");
} }
} catch (error) { } catch (error) {
logConsoleAndDb("Error deleting profile: " + errorStringForLog(error)); databaseUtil.logConsoleAndDb(
"Error deleting profile: " + errorStringForLog(error),
);
if (USE_DEXIE_DB) {
logConsoleAndDb("Error deleting profile: " + errorStringForLog(error));
}
const errorMessage: string = const errorMessage: string =
error.response?.data?.error?.message || error.response?.data?.error?.message ||
error.response?.data?.error || error.response?.data?.error ||

11
src/views/ClaimAddRawView.vue

@ -33,8 +33,10 @@ import { Component, Vue } from "vue-facing-decorator";
import { AxiosInstance } from "axios"; import { AxiosInstance } from "axios";
import QuickNav from "../components/QuickNav.vue"; import QuickNav from "../components/QuickNav.vue";
import { NotificationIface } from "../constants/app"; import { NotificationIface, USE_DEXIE_DB } from "../constants/app";
import { logConsoleAndDb, retrieveSettingsForActiveAccount } from "../db/index"; import * as databaseUtil from "../db/databaseUtil";
import { retrieveSettingsForActiveAccount } from "../db/index";
import { logConsoleAndDb } from "../db/databaseUtil";
import * as serverUtil from "../libs/endorserServer"; import * as serverUtil from "../libs/endorserServer";
import * as libsUtil from "../libs/util"; import * as libsUtil from "../libs/util";
import { errorStringForLog } from "../libs/endorserServer"; import { errorStringForLog } from "../libs/endorserServer";
@ -77,7 +79,10 @@ export default class ClaimAddRawView extends Vue {
* Initialize settings from active account * Initialize settings from active account
*/ */
private async initializeSettings() { private async initializeSettings() {
const settings = await retrieveSettingsForActiveAccount(); let settings = await databaseUtil.retrieveSettingsForActiveAccount();
if (USE_DEXIE_DB) {
settings = await retrieveSettingsForActiveAccount();
}
this.activeDid = settings.activeDid || ""; this.activeDid = settings.activeDid || "";
this.apiServer = settings.apiServer || ""; this.apiServer = settings.apiServer || "";
} }

23
src/views/ClaimCertificateView.vue

@ -14,11 +14,14 @@
import { Component, Vue } from "vue-facing-decorator"; import { Component, Vue } from "vue-facing-decorator";
import { nextTick } from "vue"; import { nextTick } from "vue";
import QRCode from "qrcode"; import QRCode from "qrcode";
import { APP_SERVER, NotificationIface } from "../constants/app"; import { APP_SERVER, NotificationIface, USE_DEXIE_DB } from "../constants/app";
import { db, retrieveSettingsForActiveAccount } from "../db/index"; import { db, retrieveSettingsForActiveAccount } from "../db/index";
import * as databaseUtil from "../db/databaseUtil";
import * as serverUtil from "../libs/endorserServer"; import * as serverUtil from "../libs/endorserServer";
import { GenericCredWrapper, GenericVerifiableCredential } from "../interfaces"; import { GenericCredWrapper, GenericVerifiableCredential } from "../interfaces";
import { logger } from "../utils/logger"; import { logger } from "../utils/logger";
import { PlatformServiceFactory } from "@/services/PlatformServiceFactory";
import { Contact } from "@/db/tables/contacts";
@Component @Component
export default class ClaimCertificateView extends Vue { export default class ClaimCertificateView extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void; $notify!: (notification: NotificationIface, timeout?: number) => void;
@ -32,7 +35,10 @@ export default class ClaimCertificateView extends Vue {
serverUtil = serverUtil; serverUtil = serverUtil;
async created() { async created() {
const settings = await retrieveSettingsForActiveAccount(); let settings = await databaseUtil.retrieveSettingsForActiveAccount();
if (USE_DEXIE_DB) {
settings = await retrieveSettingsForActiveAccount();
}
this.activeDid = settings.activeDid || ""; this.activeDid = settings.activeDid || "";
this.apiServer = settings.apiServer || ""; this.apiServer = settings.apiServer || "";
const pathParams = window.location.pathname.substring( const pathParams = window.location.pathname.substring(
@ -84,8 +90,17 @@ export default class ClaimCertificateView extends Vue {
claimData: GenericCredWrapper<GenericVerifiableCredential>, claimData: GenericCredWrapper<GenericVerifiableCredential>,
confirmerIds: Array<string>, confirmerIds: Array<string>,
) { ) {
await db.open(); const platformService = PlatformServiceFactory.getInstance();
const allContacts = await db.contacts.toArray(); const dbAllContacts = await platformService.dbQuery(
"SELECT * FROM contacts",
);
let allContacts = databaseUtil.mapQueryResultToValues(
dbAllContacts,
) as unknown as Contact[];
if (USE_DEXIE_DB) {
await db.open();
allContacts = await db.contacts.toArray();
}
const canvas = this.$refs.claimCanvas as HTMLCanvasElement; const canvas = this.$refs.claimCanvas as HTMLCanvasElement;
if (canvas) { if (canvas) {

23
src/views/ClaimReportCertificateView.vue

@ -11,10 +11,13 @@ import { Component, Vue } from "vue-facing-decorator";
import { nextTick } from "vue"; import { nextTick } from "vue";
import QRCode from "qrcode"; import QRCode from "qrcode";
import { APP_SERVER, NotificationIface } from "../constants/app"; import { APP_SERVER, NotificationIface, USE_DEXIE_DB } from "../constants/app";
import { db, retrieveSettingsForActiveAccount } from "../db/index"; import { db, retrieveSettingsForActiveAccount } from "../db/index";
import * as databaseUtil from "../db/databaseUtil";
import * as endorserServer from "../libs/endorserServer"; import * as endorserServer from "../libs/endorserServer";
import { logger } from "../utils/logger"; import { logger } from "../utils/logger";
import { PlatformServiceFactory } from "@/services/PlatformServiceFactory";
import { Contact } from "@/db/tables/contacts";
@Component @Component
export default class ClaimReportCertificateView extends Vue { export default class ClaimReportCertificateView extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void; $notify!: (notification: NotificationIface, timeout?: number) => void;
@ -28,7 +31,10 @@ export default class ClaimReportCertificateView extends Vue {
endorserServer = endorserServer; endorserServer = endorserServer;
async created() { async created() {
const settings = await retrieveSettingsForActiveAccount(); let settings = await databaseUtil.retrieveSettingsForActiveAccount();
if (USE_DEXIE_DB) {
settings = await retrieveSettingsForActiveAccount();
}
this.activeDid = settings.activeDid || ""; this.activeDid = settings.activeDid || "";
this.apiServer = settings.apiServer || ""; this.apiServer = settings.apiServer || "";
const pathParams = window.location.pathname.substring( const pathParams = window.location.pathname.substring(
@ -66,8 +72,17 @@ export default class ClaimReportCertificateView extends Vue {
async drawCanvas( async drawCanvas(
claimData: endorserServer.GenericCredWrapper<endorserServer.GenericVerifiableCredential>, claimData: endorserServer.GenericCredWrapper<endorserServer.GenericVerifiableCredential>,
) { ) {
await db.open(); const platformService = PlatformServiceFactory.getInstance();
const allContacts = await db.contacts.toArray(); const dbAllContacts = await platformService.dbQuery(
"SELECT * FROM contacts",
);
let allContacts = databaseUtil.mapQueryResultToValues(
dbAllContacts,
) as unknown as Contact[];
if (USE_DEXIE_DB) {
await db.open();
allContacts = await db.contacts.toArray();
}
const canvas = this.$refs.claimCanvas as HTMLCanvasElement; const canvas = this.$refs.claimCanvas as HTMLCanvasElement;
if (canvas) { if (canvas) {

32
src/views/ClaimView.vue

@ -542,12 +542,10 @@ import { useClipboard } from "@vueuse/core";
import { GenericVerifiableCredential } from "../interfaces"; import { GenericVerifiableCredential } from "../interfaces";
import GiftedDialog from "../components/GiftedDialog.vue"; import GiftedDialog from "../components/GiftedDialog.vue";
import QuickNav from "../components/QuickNav.vue"; import QuickNav from "../components/QuickNav.vue";
import { NotificationIface } from "../constants/app"; import { NotificationIface, USE_DEXIE_DB } from "../constants/app";
import { import * as databaseUtil from "../db/databaseUtil";
db, import { db } from "../db/index";
logConsoleAndDb, import { logConsoleAndDb } from "../db/databaseUtil";
retrieveSettingsForActiveAccount,
} from "../db/index";
import { Contact } from "../db/tables/contacts"; import { Contact } from "../db/tables/contacts";
import * as serverUtil from "../libs/endorserServer"; import * as serverUtil from "../libs/endorserServer";
import { import {
@ -557,6 +555,7 @@ import {
} from "../interfaces"; } from "../interfaces";
import * as libsUtil from "../libs/util"; import * as libsUtil from "../libs/util";
import { logger } from "../utils/logger"; import { logger } from "../utils/logger";
import { PlatformServiceFactory } from "@/services/PlatformServiceFactory";
@Component({ @Component({
components: { GiftedDialog, QuickNav }, components: { GiftedDialog, QuickNav },
@ -620,11 +619,24 @@ export default class ClaimView extends Vue {
} }
async created() { async created() {
logger.log("ClaimView created"); let settings = await databaseUtil.retrieveSettingsForActiveAccount();
const settings = await retrieveSettingsForActiveAccount(); if (USE_DEXIE_DB) {
settings = await databaseUtil.retrieveSettingsForActiveAccount();
}
this.activeDid = settings.activeDid || ""; this.activeDid = settings.activeDid || "";
this.apiServer = settings.apiServer || ""; this.apiServer = settings.apiServer || "";
this.allContacts = await db.contacts.toArray(); const platformService = PlatformServiceFactory.getInstance();
const dbAllContacts = await platformService.dbQuery(
"SELECT * FROM contacts",
);
this.allContacts = databaseUtil.mapQueryResultToValues(
dbAllContacts,
) as unknown as Contact[];
if (USE_DEXIE_DB) {
await db.open();
this.allContacts = await db.contacts.toArray();
}
this.isRegistered = settings.isRegistered || false; this.isRegistered = settings.isRegistered || false;
try { try {
@ -690,7 +702,6 @@ export default class ClaimView extends Vue {
} }
async loadClaim(claimId: string, userDid: string) { async loadClaim(claimId: string, userDid: string) {
logger.log("[ClaimView] loadClaim called with claimId:", claimId);
const urlPath = libsUtil.isGlobalUri(claimId) const urlPath = libsUtil.isGlobalUri(claimId)
? "/api/claim/byHandle/" ? "/api/claim/byHandle/"
: "/api/claim/"; : "/api/claim/";
@ -698,7 +709,6 @@ export default class ClaimView extends Vue {
const headers = await serverUtil.getHeaders(userDid); const headers = await serverUtil.getHeaders(userDid);
try { try {
logger.log("[ClaimView] Making API request to:", url);
const resp = await this.axios.get(url, { headers }); const resp = await this.axios.get(url, { headers });
if (resp.status === 200) { if (resp.status === 200) {
this.veriClaim = resp.data; this.veriClaim = resp.data;

21
src/views/ConfirmGiftView.vue

@ -438,9 +438,10 @@ import { Component, Vue } from "vue-facing-decorator";
import { useClipboard } from "@vueuse/core"; import { useClipboard } from "@vueuse/core";
import { RouteLocationNormalizedLoaded, Router } from "vue-router"; import { RouteLocationNormalizedLoaded, Router } from "vue-router";
import QuickNav from "../components/QuickNav.vue"; import QuickNav from "../components/QuickNav.vue";
import { NotificationIface } from "../constants/app"; import { NotificationIface, USE_DEXIE_DB } from "../constants/app";
import { db, retrieveSettingsForActiveAccount } from "../db/index"; import { db, retrieveSettingsForActiveAccount } from "../db/index";
import { Contact } from "../db/tables/contacts"; import { Contact } from "../db/tables/contacts";
import * as databaseUtil from "../db/databaseUtil";
import * as serverUtil from "../libs/endorserServer"; import * as serverUtil from "../libs/endorserServer";
import { GenericVerifiableCredential, GiveSummaryRecord } from "../interfaces"; import { GenericVerifiableCredential, GiveSummaryRecord } from "../interfaces";
import { displayAmount } from "../libs/endorserServer"; import { displayAmount } from "../libs/endorserServer";
@ -448,6 +449,7 @@ import * as libsUtil from "../libs/util";
import { retrieveAccountDids } from "../libs/util"; import { retrieveAccountDids } from "../libs/util";
import TopMessage from "../components/TopMessage.vue"; import TopMessage from "../components/TopMessage.vue";
import { logger } from "../utils/logger"; import { logger } from "../utils/logger";
import { PlatformServiceFactory } from "@/services/PlatformServiceFactory";
/** /**
* ConfirmGiftView Component * ConfirmGiftView Component
* *
@ -528,10 +530,23 @@ export default class ConfirmGiftView extends Vue {
* Initializes component settings and user data * Initializes component settings and user data
*/ */
private async initializeSettings() { private async initializeSettings() {
const settings = await retrieveSettingsForActiveAccount(); let settings = await databaseUtil.retrieveSettingsForActiveAccount();
if (USE_DEXIE_DB) {
settings = await retrieveSettingsForActiveAccount();
}
this.activeDid = settings.activeDid || ""; this.activeDid = settings.activeDid || "";
this.apiServer = settings.apiServer || ""; this.apiServer = settings.apiServer || "";
this.allContacts = await db.contacts.toArray(); const platformService = PlatformServiceFactory.getInstance();
const dbAllContacts = await platformService.dbQuery(
"SELECT * FROM contacts",
);
this.allContacts = databaseUtil.mapQueryResultToValues(
dbAllContacts,
) as unknown as Contact[];
if (USE_DEXIE_DB) {
await db.open();
this.allContacts = await db.contacts.toArray();
}
this.isRegistered = settings.isRegistered || false; this.isRegistered = settings.isRegistered || false;
this.allMyDids = await retrieveAccountDids(); this.allMyDids = await retrieveAccountDids();

22
src/views/ContactAmountsView.vue

@ -117,9 +117,10 @@ import { Component, Vue } from "vue-facing-decorator";
import { RouteLocationNormalizedLoaded, Router } from "vue-router"; import { RouteLocationNormalizedLoaded, Router } from "vue-router";
import QuickNav from "../components/QuickNav.vue"; import QuickNav from "../components/QuickNav.vue";
import { NotificationIface } from "../constants/app"; import { NotificationIface, USE_DEXIE_DB } from "../constants/app";
import { db, retrieveSettingsForActiveAccount } from "../db/index"; import { db, retrieveSettingsForActiveAccount } from "../db/index";
import { Contact } from "../db/tables/contacts"; import { Contact } from "../db/tables/contacts";
import * as databaseUtil from "../db/databaseUtil";
import { import {
AgreeVerifiableCredential, AgreeVerifiableCredential,
GiveSummaryRecord, GiveSummaryRecord,
@ -133,6 +134,7 @@ import {
} from "../libs/endorserServer"; } from "../libs/endorserServer";
import { retrieveAccountCount } from "../libs/util"; import { retrieveAccountCount } from "../libs/util";
import { logger } from "../utils/logger"; import { logger } from "../utils/logger";
import { PlatformServiceFactory } from "@/services/PlatformServiceFactory";
@Component({ components: { QuickNav } }) @Component({ components: { QuickNav } })
export default class ContactAmountssView extends Vue { export default class ContactAmountssView extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void; $notify!: (notification: NotificationIface, timeout?: number) => void;
@ -154,9 +156,23 @@ export default class ContactAmountssView extends Vue {
async created() { async created() {
try { try {
const contactDid = this.$route.query["contactDid"] as string; const contactDid = this.$route.query["contactDid"] as string;
this.contact = (await db.contacts.get(contactDid)) || null; const platformService = PlatformServiceFactory.getInstance();
const dbContact = await platformService.dbQuery(
"SELECT * FROM contacts WHERE did = ?",
[contactDid],
);
this.contact = databaseUtil.mapQueryResultToValues(
dbContact,
)[0] as unknown as Contact;
if (USE_DEXIE_DB) {
await db.open();
this.contact = (await db.contacts.get(contactDid)) || null;
}
const settings = await retrieveSettingsForActiveAccount(); let settings = await databaseUtil.retrieveSettingsForActiveAccount();
if (USE_DEXIE_DB) {
settings = await retrieveSettingsForActiveAccount();
}
this.activeDid = settings?.activeDid || ""; this.activeDid = settings?.activeDid || "";
this.apiServer = settings?.apiServer || ""; this.apiServer = settings?.apiServer || "";

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

Loading…
Cancel
Save