Browse Source

refactor: migrate database operations to PlatformService

- Add account management methods to PlatformService interface
- Implement account operations in all platform services
- Fix PlatformCapabilities interface by adding sqlite property
- Update util.ts to use PlatformService for account operations
- Standardize account and settings management across platforms

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

Note: Some linter errors remain regarding db.accounts access and sqlite
capabilities that need to be addressed in a follow-up commit.
sql-absurd-sql
Matt Raymer 1 month ago
parent
commit
b30c4c8b30
  1. 172
      .cursor/rules/SQLITE.mdc
  2. 26
      doc/secure-storage-implementation.md
  3. 270
      package-lock.json
  4. 2
      package.json
  5. 17
      src/components/FeedFilters.vue
  6. 130
      src/libs/util.ts
  7. 11
      src/main.common.ts
  8. 139
      src/services/CapacitorPlatformService.ts
  9. 295
      src/services/ElectronPlatformService.ts
  10. 207
      src/services/PlatformService.ts
  11. 43
      src/services/WebPlatformService.ts
  12. 34
      src/services/platforms/CapacitorPlatformService.ts
  13. 34
      src/services/platforms/ElectronPlatformService.ts
  14. 34
      src/services/platforms/PyWebViewPlatformService.ts
  15. 67
      src/services/platforms/WebPlatformService.ts
  16. 248
      src/services/sqlite/AbsurdSQLService.ts
  17. 383
      src/services/sqlite/BaseSQLiteService.ts
  18. 176
      src/services/sqlite/CapacitorSQLiteService.ts
  19. 150
      src/services/sqlite/sqlite.worker.ts
  20. 45
      src/types/absurd-sql.d.ts
  21. 6
      src/utils/node-modules/crypto.js
  22. 8
      src/utils/node-modules/fs.js
  23. 14
      src/utils/node-modules/path.js
  24. 37
      src/views/AccountViewView.vue
  25. 11
      src/views/ConfirmGiftView.vue
  26. 3
      src/views/ContactsView.vue
  27. 127
      src/views/HomeView.vue
  28. 21
      src/views/IdentitySwitcherView.vue
  29. 91
      src/views/InviteOneView.vue
  30. 27
      src/views/NewActivityView.vue
  31. 19
      src/views/QuickActionBvcEndView.vue
  32. 15
      src/views/SearchAreaView.vue
  33. 3
      tsconfig.electron.json
  34. 10
      vite.config.capacitor.mts
  35. 2
      vite.config.electron.mts

172
.cursor/rules/SQLITE.mdc

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

26
doc/secure-storage-implementation.md

@ -144,6 +144,32 @@ try {
}
```
## SQLite Database Initialization (Secure Storage)
In our secure storage implementation (using the new PlatformService SQLite interface), the database is not initialized by simply calling `PlatformServiceFactory.getInstance()`. Instead, we initialize the database early in the app startup (for example, in our main entry file) so that it is ready before any component (or other code) tries to use it.
### How It Works
- **PlatformServiceFactory.getInstance()** returns a singleton instance of the appropriate platform service (for example, Web, Capacitor, Electron, or PyWebView) and assigns it (for example, to `app.config.globalProperties.$platform`).
- The platform service (via its `getSQLite()` method) returns a SQLiteOperations interface (which is platform-agnostic).
- **The actual database initialization** (for example, creating/opening the database, running migrations, setting PRAGMAs) is done by calling `initialize(config)` on the SQLiteOperations object.
### Example (in main.common.ts)
Below is an example diff (or “edit”) applied in our main entry (for example, in `src/main.common.ts`) so that the SQLite database is initialized immediately after setting the global property:
```typescript
app.config.globalProperties.$platform = PlatformServiceFactory.getInstance();
(async () => {
const platform = app.config.globalProperties.$platform;
const sqlite = await platform.getSQLite();
const config = { name: "TimeSafariDB", useWAL: true }; // (or your desired config)
await sqlite.initialize(config);
logger.log("[App Init] SQLite database initialized.");
})();
```
### 3. Platform Detection
```typescript

270
package-lock.json

@ -8,6 +8,7 @@
"name": "timesafari",
"version": "0.4.6",
"dependencies": {
"@capacitor-community/sqlite": "6.0.0",
"@capacitor-mlkit/barcode-scanning": "^6.0.0",
"@capacitor/android": "^6.2.0",
"@capacitor/app": "^6.0.0",
@ -77,6 +78,7 @@
"reflect-metadata": "^0.1.14",
"register-service-worker": "^1.7.2",
"simple-vue-camera": "^1.1.3",
"sqlite": "^5.1.1",
"sqlite3": "^5.1.7",
"stream-browserify": "^3.0.0",
"three": "^0.156.1",
@ -2362,6 +2364,21 @@
"node": ">=8.9"
}
},
"node_modules/@capacitor-community/sqlite": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/@capacitor-community/sqlite/-/sqlite-6.0.0.tgz",
"integrity": "sha512-Pm0orp17nkLzqQb7hGD9A1UCt5jbgxthHBSRxxcPOxiI+/6aTSBwa1Eshy+k9d8Arz/H77ISTdoH/HPi0ez3sQ==",
"license": "MIT",
"dependencies": {
"jeep-sqlite": "^2.7.2"
},
"engines": {
"node": ">=16.0.0"
},
"peerDependencies": {
"@capacitor/core": "^6.0.0"
}
},
"node_modules/@capacitor-mlkit/barcode-scanning": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/@capacitor-mlkit/barcode-scanning/-/barcode-scanning-6.2.0.tgz",
@ -8225,6 +8242,133 @@
"@stablelib/xchacha20": "^1.0.1"
}
},
"node_modules/@stencil/core": {
"version": "4.31.0",
"resolved": "https://registry.npmjs.org/@stencil/core/-/core-4.31.0.tgz",
"integrity": "sha512-Ei9MFJ6LPD9BMFs+klkHylbVOOYhG10Jv4bvoFf3GMH15kA41rSYkEdr4DiX84ZdErQE2qtFV/2SUyWoXh0AhA==",
"license": "MIT",
"bin": {
"stencil": "bin/stencil"
},
"engines": {
"node": ">=16.0.0",
"npm": ">=7.10.0"
},
"optionalDependencies": {
"@rollup/rollup-darwin-arm64": "4.34.9",
"@rollup/rollup-darwin-x64": "4.34.9",
"@rollup/rollup-linux-arm64-gnu": "4.34.9",
"@rollup/rollup-linux-arm64-musl": "4.34.9",
"@rollup/rollup-linux-x64-gnu": "4.34.9",
"@rollup/rollup-linux-x64-musl": "4.34.9",
"@rollup/rollup-win32-arm64-msvc": "4.34.9",
"@rollup/rollup-win32-x64-msvc": "4.34.9"
}
},
"node_modules/@stencil/core/node_modules/@rollup/rollup-darwin-arm64": {
"version": "4.34.9",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.34.9.tgz",
"integrity": "sha512-0CY3/K54slrzLDjOA7TOjN1NuLKERBgk9nY5V34mhmuu673YNb+7ghaDUs6N0ujXR7fz5XaS5Aa6d2TNxZd0OQ==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
]
},
"node_modules/@stencil/core/node_modules/@rollup/rollup-darwin-x64": {
"version": "4.34.9",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.34.9.tgz",
"integrity": "sha512-eOojSEAi/acnsJVYRxnMkPFqcxSMFfrw7r2iD9Q32SGkb/Q9FpUY1UlAu1DH9T7j++gZ0lHjnm4OyH2vCI7l7Q==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
]
},
"node_modules/@stencil/core/node_modules/@rollup/rollup-linux-arm64-gnu": {
"version": "4.34.9",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.34.9.tgz",
"integrity": "sha512-6TZjPHjKZUQKmVKMUowF3ewHxctrRR09eYyvT5eFv8w/fXarEra83A2mHTVJLA5xU91aCNOUnM+DWFMSbQ0Nxw==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@stencil/core/node_modules/@rollup/rollup-linux-arm64-musl": {
"version": "4.34.9",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.34.9.tgz",
"integrity": "sha512-LD2fytxZJZ6xzOKnMbIpgzFOuIKlxVOpiMAXawsAZ2mHBPEYOnLRK5TTEsID6z4eM23DuO88X0Tq1mErHMVq0A==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@stencil/core/node_modules/@rollup/rollup-linux-x64-gnu": {
"version": "4.34.9",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.34.9.tgz",
"integrity": "sha512-FwBHNSOjUTQLP4MG7y6rR6qbGw4MFeQnIBrMe161QGaQoBQLqSUEKlHIiVgF3g/mb3lxlxzJOpIBhaP+C+KP2A==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@stencil/core/node_modules/@rollup/rollup-linux-x64-musl": {
"version": "4.34.9",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.34.9.tgz",
"integrity": "sha512-cYRpV4650z2I3/s6+5/LONkjIz8MBeqrk+vPXV10ORBnshpn8S32bPqQ2Utv39jCiDcO2eJTuSlPXpnvmaIgRA==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@stencil/core/node_modules/@rollup/rollup-win32-arm64-msvc": {
"version": "4.34.9",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.34.9.tgz",
"integrity": "sha512-z4mQK9dAN6byRA/vsSgQiPeuO63wdiDxZ9yg9iyX2QTzKuQM7T4xlBoeUP/J8uiFkqxkcWndWi+W7bXdPbt27Q==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
]
},
"node_modules/@stencil/core/node_modules/@rollup/rollup-win32-x64-msvc": {
"version": "4.34.9",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.34.9.tgz",
"integrity": "sha512-AyleYRPU7+rgkMWbEh71fQlrzRfeP6SyMnRf9XX4fCdDPAJumdSBqYEcWPMzVQ4ScAl7E4oFfK0GUVn77xSwbw==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
]
},
"node_modules/@surma/rollup-plugin-off-main-thread": {
"version": "2.2.3",
"resolved": "https://registry.npmjs.org/@surma/rollup-plugin-off-main-thread/-/rollup-plugin-off-main-thread-2.2.3.tgz",
@ -8872,6 +9016,7 @@
"resolved": "https://registry.npmjs.org/@types/sqlite3/-/sqlite3-3.1.11.tgz",
"integrity": "sha512-KYF+QgxAnnAh7DWPdNDroxkDI3/MspH1NMx6m/N/6fT1G6+jvsw4/ZePt8R8cr7ta58aboeTfYFBDxTJ5yv15w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
@ -11289,6 +11434,12 @@
"resolved": "https://registry.npmjs.org/brorand/-/brorand-1.1.0.tgz",
"integrity": "sha512-cKV8tMCEpQs4hK/ik71d6LrPOnpkpGBR0wzxqr68g2m/LB2GxVYQroAjMJZRVM1Y4BCjCKc3vAamxSzOY2RP+w=="
},
"node_modules/browser-fs-access": {
"version": "0.35.0",
"resolved": "https://registry.npmjs.org/browser-fs-access/-/browser-fs-access-0.35.0.tgz",
"integrity": "sha512-sLoadumpRfsjprP8XzVjpQc0jK8yqHBx0PtUTGYj2fftT+P/t+uyDAQdMgGAPKD011in/O+YYGh7fIs0oG/viw==",
"license": "Apache-2.0"
},
"node_modules/browserify-aes": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/browserify-aes/-/browserify-aes-1.2.0.tgz",
@ -12880,8 +13031,7 @@
"node_modules/core-util-is": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz",
"integrity": "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==",
"dev": true
"integrity": "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ=="
},
"node_modules/cosmiconfig": {
"version": "5.2.1",
@ -16777,6 +16927,12 @@
"node": ">=16.x"
}
},
"node_modules/immediate": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
"license": "MIT"
},
"node_modules/import-fresh": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
@ -17645,6 +17801,19 @@
"node": ">=6.4.0"
}
},
"node_modules/jeep-sqlite": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/jeep-sqlite/-/jeep-sqlite-2.8.0.tgz",
"integrity": "sha512-FWNUP6OAmrUHwiW7H1xH5YUQ8tN2O4l4psT1sLd7DQtHd5PfrA1nvNdeKPNj+wQBtu7elJa8WoUibTytNTaaCg==",
"license": "MIT",
"dependencies": {
"@stencil/core": "^4.20.0",
"browser-fs-access": "^0.35.0",
"jszip": "^3.10.1",
"localforage": "^1.10.0",
"sql.js": "^1.11.0"
}
},
"node_modules/jest-environment-node": {
"version": "29.7.0",
"resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-29.7.0.tgz",
@ -18091,6 +18260,60 @@
"resolved": "https://registry.npmjs.org/jsqr/-/jsqr-1.4.0.tgz",
"integrity": "sha512-dxLob7q65Xg2DvstYkRpkYtmKm2sPJ9oFhrhmudT1dZvNFFTlroai3AWSpLey/w5vMcLBXRgOJsbXpdN9HzU/A=="
},
"node_modules/jszip": {
"version": "3.10.1",
"resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz",
"integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==",
"license": "(MIT OR GPL-3.0-or-later)",
"dependencies": {
"lie": "~3.3.0",
"pako": "~1.0.2",
"readable-stream": "~2.3.6",
"setimmediate": "^1.0.5"
}
},
"node_modules/jszip/node_modules/isarray": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
"license": "MIT"
},
"node_modules/jszip/node_modules/pako": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
"license": "(MIT AND Zlib)"
},
"node_modules/jszip/node_modules/readable-stream": {
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
"license": "MIT",
"dependencies": {
"core-util-is": "~1.0.0",
"inherits": "~2.0.3",
"isarray": "~1.0.0",
"process-nextick-args": "~2.0.0",
"safe-buffer": "~5.1.1",
"string_decoder": "~1.1.1",
"util-deprecate": "~1.0.1"
}
},
"node_modules/jszip/node_modules/safe-buffer": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"license": "MIT"
},
"node_modules/jszip/node_modules/string_decoder": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.1.0"
}
},
"node_modules/katex": {
"version": "0.16.22",
"resolved": "https://registry.npmjs.org/katex/-/katex-0.16.22.tgz",
@ -18566,6 +18789,15 @@
"node": ">= 0.8.0"
}
},
"node_modules/lie": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
"integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
"license": "MIT",
"dependencies": {
"immediate": "~3.0.5"
}
},
"node_modules/lighthouse-logger": {
"version": "1.4.2",
"resolved": "https://registry.npmjs.org/lighthouse-logger/-/lighthouse-logger-1.4.2.tgz",
@ -18903,6 +19135,24 @@
"node": ">=4"
}
},
"node_modules/localforage": {
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/localforage/-/localforage-1.10.0.tgz",
"integrity": "sha512-14/H1aX7hzBBmmh7sGPd+AOMkkIrHM3Z1PAyGgZigA1H1p5O5ANnMyWzvpAETtG68/dC4pC0ncy3+PPGzXZHPg==",
"license": "Apache-2.0",
"dependencies": {
"lie": "3.1.1"
}
},
"node_modules/localforage/node_modules/lie": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/lie/-/lie-3.1.1.tgz",
"integrity": "sha512-RiNhHysUjhrDQntfYSfY4MU24coXXdEOgw9WGcKHNeEwffDYbF//u87M1EWaMGzuFoSbqW0C9C6lEEhDOAswfw==",
"license": "MIT",
"dependencies": {
"immediate": "~3.0.5"
}
},
"node_modules/localstorage-slim": {
"version": "2.7.1",
"resolved": "https://registry.npmjs.org/localstorage-slim/-/localstorage-slim-2.7.1.tgz",
@ -22364,8 +22614,7 @@
"node_modules/process-nextick-args": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
"dev": true
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag=="
},
"node_modules/progress": {
"version": "2.0.3",
@ -24992,11 +25241,24 @@
"node": ">=14"
}
},
"node_modules/sql.js": {
"version": "1.13.0",
"resolved": "https://registry.npmjs.org/sql.js/-/sql.js-1.13.0.tgz",
"integrity": "sha512-RJbVP1HRDlUUXahJ7VMTcu9Rm1Nzw+EBpoPr94vnbD4LwR715F3CcxE2G2k45PewcaZ57pjetYa+LoSJLAASgA==",
"license": "MIT"
},
"node_modules/sqlite": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/sqlite/-/sqlite-5.1.1.tgz",
"integrity": "sha512-oBkezXa2hnkfuJwUo44Hl9hS3er+YFtueifoajrgidvqsJRQFpc5fKoAkAor1O5ZnLoa28GBScfHXs8j0K358Q==",
"license": "MIT"
},
"node_modules/sqlite3": {
"version": "5.1.7",
"resolved": "https://registry.npmjs.org/sqlite3/-/sqlite3-5.1.7.tgz",
"integrity": "sha512-GGIyOiFaG+TUra3JIfkI/zGP8yZYLPQ0pl1bH+ODjiX57sPhrLU5sQJn1y9bDKZUFYkX1crlrPfSYt0BKKdkog==",
"hasInstallScript": true,
"license": "BSD-3-Clause",
"dependencies": {
"bindings": "^1.5.0",
"node-addon-api": "^7.0.0",

2
package.json

@ -46,6 +46,7 @@
"electron:build-mac-universal": "npm run build:electron-prod && electron-builder --mac --universal"
},
"dependencies": {
"@capacitor-community/sqlite": "6.0.0",
"@capacitor-mlkit/barcode-scanning": "^6.0.0",
"@capacitor/android": "^6.2.0",
"@capacitor/app": "^6.0.0",
@ -115,6 +116,7 @@
"reflect-metadata": "^0.1.14",
"register-service-worker": "^1.7.2",
"simple-vue-camera": "^1.1.3",
"sqlite": "^5.1.1",
"sqlite3": "^5.1.7",
"stream-browserify": "^3.0.0",
"three": "^0.156.1",

17
src/components/FeedFilters.vue

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

130
src/libs/util.ts

@ -6,29 +6,24 @@ import * as R from "ramda";
import { useClipboard } from "@vueuse/core";
import { DEFAULT_PUSH_SERVER, NotificationIface } from "../constants/app";
import {
accountsDBPromise,
retrieveSettingsForActiveAccount,
updateAccountSettings,
updateDefaultSettings,
} from "../db/index";
import databaseService from "../services/database";
import { retrieveSettingsForActiveAccount } from "../db/index";
import { Account } from "../db/tables/accounts";
import { Contact } from "../db/tables/contacts";
import { DEFAULT_PASSKEY_EXPIRATION_MINUTES } from "../db/tables/settings";
import { deriveAddress, generateSeed, newIdentifier } from "../libs/crypto";
import * as serverUtil from "../libs/endorserServer";
import {
containsHiddenDid,
GenericCredWrapper,
GenericVerifiableCredential,
GiveSummaryRecord,
OfferVerifiableCredential,
} from "../libs/endorserServer";
} from "../interfaces";
import { containsHiddenDid } from "../libs/endorserServer";
import { KeyMeta } from "../libs/crypto/vc";
import { createPeerDid } from "../libs/crypto/vc/didPeer";
import { registerCredential } from "../libs/crypto/vc/passkeyDidPeer";
import { logger } from "../utils/logger";
import type { PlatformService } from "../services/PlatformService";
export interface GiverReceiverInputInfo {
did?: string;
@ -460,45 +455,38 @@ export function findAllVisibleToDids(
export interface AccountKeyInfo extends Account, KeyMeta {}
export const retrieveAccountCount = async (): Promise<number> => {
// one of the few times we use accountsDBPromise directly; try to avoid more usage
const accountsDB = await accountsDBPromise;
return await accountsDB.accounts.count();
export const retrieveAccountCount = async (
platform: PlatformService,
): Promise<number> => {
const accounts = await platform.getAccounts();
return accounts.length;
};
export const retrieveAccountDids = async (): Promise<string[]> => {
// one of the few times we use accountsDBPromise directly; try to avoid more usage
const accountsDB = await accountsDBPromise;
const allAccounts = await accountsDB.accounts.toArray();
const allDids = allAccounts.map((acc) => acc.did);
return allDids;
export const retrieveAccountDids = async (
platform: PlatformService,
): Promise<string[]> => {
const accounts = await platform.getAccounts();
return accounts.map((acc: Account) => acc.did);
};
// This is provided and recommended when the full key is not necessary so that
// future work could separate this info from the sensitive key material.
export const retrieveAccountMetadata = async (
platform: PlatformService,
activeDid: string,
): Promise<AccountKeyInfo | undefined> => {
// 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;
const account = await platform.getAccount(activeDid);
if (account) {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { identity, mnemonic, ...metadata } = account;
return metadata;
} else {
return undefined;
}
return undefined;
};
export const retrieveAllAccountsMetadata = async (): Promise<Account[]> => {
// one of the few times we use accountsDBPromise directly; try to avoid more usage
const accountsDB = await accountsDBPromise;
const array = await accountsDB.accounts.toArray();
return array.map((account) => {
export const retrieveAllAccountsMetadata = async (
platform: PlatformService,
): Promise<Account[]> => {
const accounts = await platform.getAccounts();
return accounts.map((account: Account) => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { identity, mnemonic, ...metadata } = account;
return metadata;
@ -506,43 +494,30 @@ export const retrieveAllAccountsMetadata = async (): Promise<Account[]> => {
};
export const retrieveFullyDecryptedAccount = async (
platform: PlatformService,
activeDid: string,
): Promise<AccountKeyInfo | undefined> => {
// 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;
return account;
return await platform.getAccount(activeDid);
};
// let's try and eliminate this
export const retrieveAllFullyDecryptedAccounts = async (): Promise<
Array<AccountKeyInfo>
> => {
const accountsDB = await accountsDBPromise;
const allAccounts = await accountsDB.accounts.toArray();
return allAccounts;
export const retrieveAllFullyDecryptedAccounts = async (
platform: PlatformService,
): Promise<Array<AccountKeyInfo>> => {
return await platform.getAccounts();
};
/**
* 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
*/
export const generateSaveAndActivateIdentity = async (): Promise<string> => {
export const generateSaveAndActivateIdentity = async (
platform: PlatformService,
): Promise<string> => {
const mnemonic = generateSeed();
// address is 0x... ETH address, without "did:eth:"
const [address, privateHex, publicHex, derivationPath] =
deriveAddress(mnemonic);
const newId = newIdentifier(address, publicHex, privateHex, derivationPath);
const identity = JSON.stringify(newId);
// one of the few times we use accountsDBPromise directly; try to avoid more usage
try {
const accountsDB = await accountsDBPromise;
await accountsDB.accounts.add({
await platform.addAccount({
dateCreated: new Date().toISOString(),
derivationPath: derivationPath,
did: newId.did,
@ -551,32 +526,19 @@ export const generateSaveAndActivateIdentity = async (): Promise<string> => {
publicKeyHex: newId.keys[0].publicKeyHex,
});
// add to the new sql db
await databaseService.run(
`INSERT INTO accounts (dateCreated, derivationPath, did, identity, mnemonic, publicKeyHex)
VALUES (?, ?, ?, ?, ?, ?)`,
[
new Date().toISOString(),
derivationPath,
newId.did,
identity,
mnemonic,
newId.keys[0].publicKeyHex,
],
);
await updateDefaultSettings({ activeDid: newId.did });
await platform.updateMasterSettings({ activeDid: newId.did });
await platform.updateAccountSettings(newId.did, { isRegistered: false });
} catch (error) {
logger.error("Failed to update default settings:", error);
logger.error("Failed to save new identity:", error);
throw new Error(
"Failed to set default settings. Please try again or restart the app.",
"Failed to save new identity. Please try again or restart the app.",
);
}
await updateAccountSettings(newId.did, { isRegistered: false });
return newId.did;
};
export const registerAndSavePasskey = async (
platform: PlatformService,
keyName: string,
): Promise<Account> => {
const cred = await registerCredential(keyName);
@ -590,23 +552,25 @@ export const registerAndSavePasskey = async (
passkeyCredIdHex,
publicKeyHex: Buffer.from(publicKeyBytes).toString("hex"),
};
// one of the few times we use accountsDBPromise directly; try to avoid more usage
const accountsDB = await accountsDBPromise;
await accountsDB.accounts.add(account);
await platform.addAccount(account);
return account;
};
export const registerSaveAndActivatePasskey = async (
platform: PlatformService,
keyName: string,
): Promise<Account> => {
const account = await registerAndSavePasskey(keyName);
await updateDefaultSettings({ activeDid: account.did });
await updateAccountSettings(account.did, { isRegistered: false });
const account = await registerAndSavePasskey(platform, keyName);
await platform.updateMasterSettings({ activeDid: account.did });
await platform.updateAccountSettings(account.did, { isRegistered: false });
return account;
};
export const getPasskeyExpirationSeconds = async (): Promise<number> => {
const settings = await retrieveSettingsForActiveAccount();
export const getPasskeyExpirationSeconds = async (
platform: PlatformService,
): Promise<number> => {
const settings = await platform.getActiveAccountSettings();
return (
(settings?.passkeyExpirationMinutes ?? DEFAULT_PASSKEY_EXPIRATION_MINUTES) *
60

11
src/main.common.ts

@ -9,6 +9,7 @@ import "./assets/styles/tailwind.css";
import { FontAwesomeIcon } from "./libs/fontawesome";
import Camera from "simple-vue-camera";
import { logger } from "./utils/logger";
import { PlatformServiceFactory } from "./services/PlatformServiceFactory";
// Global Error Handler
function setupGlobalErrorHandler(app: VueApp) {
@ -54,6 +55,16 @@ export function initializeApp() {
app.use(Notifications);
logger.log("[App Init] Notifications initialized");
app.config.globalProperties.$platform = PlatformServiceFactory.getInstance();
(async () => {
const platform = app.config.globalProperties.$platform;
const sqlite = await platform.getSQLite();
const config = { name: "TimeSafariDB", useWAL: true }; // (or your desired config)
await sqlite.initialize(config);
logger.log("[App Init] SQLite database initialized.");
})();
setupGlobalErrorHandler(app);
logger.log("[App Init] App initialization complete");

139
src/services/CapacitorPlatformService.ts

@ -0,0 +1,139 @@
import {
PlatformService,
PlatformCapabilities,
SQLiteOperations,
} from "./PlatformService";
import { CapacitorSQLiteService } from "./sqlite/CapacitorSQLiteService";
import { Capacitor } from "@capacitor/core";
import { Camera } from "@capacitor/camera";
import { Filesystem, Directory } from "@capacitor/filesystem";
import { logger } from "../utils/logger";
export class CapacitorPlatformService implements PlatformService {
private sqliteService: CapacitorSQLiteService | null = null;
getCapabilities(): PlatformCapabilities {
const platform = Capacitor.getPlatform();
return {
hasFileSystem: true,
hasCamera: true,
isMobile: true,
isIOS: platform === "ios",
hasFileDownload: true,
needsFileHandlingInstructions: false,
sqlite: {
supported: true,
runsInWorker: false,
hasSharedArrayBuffer: false,
supportsWAL: true,
maxSize: 1024 * 1024 * 1024 * 2, // 2GB limit for mobile SQLite
},
};
}
async getSQLite(): Promise<SQLiteOperations> {
if (!this.sqliteService) {
this.sqliteService = new CapacitorSQLiteService();
}
return this.sqliteService;
}
async readFile(path: string): Promise<string> {
try {
const result = await Filesystem.readFile({
path,
directory: Directory.Data,
});
return result.data;
} catch (error) {
logger.error("Failed to read file:", error);
throw error;
}
}
async writeFile(path: string, content: string): Promise<void> {
try {
await Filesystem.writeFile({
path,
data: content,
directory: Directory.Data,
});
} catch (error) {
logger.error("Failed to write file:", error);
throw error;
}
}
async deleteFile(path: string): Promise<void> {
try {
await Filesystem.deleteFile({
path,
directory: Directory.Data,
});
} catch (error) {
logger.error("Failed to delete file:", error);
throw error;
}
}
async listFiles(directory: string): Promise<string[]> {
try {
const result = await Filesystem.readdir({
path: directory,
directory: Directory.Data,
});
return result.files.map((file) => file.name);
} catch (error) {
logger.error("Failed to list files:", error);
throw error;
}
}
async takePicture(): Promise<{ blob: Blob; fileName: string }> {
try {
const image = await Camera.getPhoto({
quality: 90,
allowEditing: true,
resultType: "base64",
});
const response = await fetch(
`data:image/jpeg;base64,${image.base64String}`,
);
const blob = await response.blob();
const fileName = `photo_${Date.now()}.jpg`;
return { blob, fileName };
} catch (error) {
logger.error("Failed to take picture:", error);
throw error;
}
}
async pickImage(): Promise<{ blob: Blob; fileName: string }> {
try {
const image = await Camera.getPhoto({
quality: 90,
allowEditing: true,
resultType: "base64",
source: "PHOTOLIBRARY",
});
const response = await fetch(
`data:image/jpeg;base64,${image.base64String}`,
);
const blob = await response.blob();
const fileName = `image_${Date.now()}.jpg`;
return { blob, fileName };
} catch (error) {
logger.error("Failed to pick image:", error);
throw error;
}
}
async handleDeepLink(url: string): Promise<void> {
// Implement deep link handling for Capacitor platform
logger.info("Handling deep link:", url);
}
}

295
src/services/ElectronPlatformService.ts

@ -0,0 +1,295 @@
import {
PlatformService,
PlatformCapabilities,
SQLiteOperations,
SQLiteConfig,
PreparedStatement,
} from "./PlatformService";
import { BaseSQLiteService } from "./sqlite/BaseSQLiteService";
import { app } from "electron";
import { dialog } from "electron";
import { promises as fs } from "fs";
import { join } from "path";
import sqlite3 from "sqlite3";
import { open, Database } from "sqlite";
import { logger } from "../utils/logger";
/**
* SQLite implementation for Electron using native sqlite3
*/
class ElectronSQLiteService extends BaseSQLiteService {
private db: Database | null = null;
private config: SQLiteConfig | null = null;
async initialize(config: SQLiteConfig): Promise<void> {
if (this.initialized) {
return;
}
try {
this.config = config;
const dbPath = join(app.getPath("userData"), `${config.name}.db`);
this.db = await open({
filename: dbPath,
driver: sqlite3.Database,
});
// Configure database settings
if (config.useWAL) {
await this.execute("PRAGMA journal_mode = WAL");
this.stats.walMode = true;
}
// Set other pragmas for performance
await this.execute("PRAGMA synchronous = NORMAL");
await this.execute("PRAGMA temp_store = MEMORY");
await this.execute("PRAGMA cache_size = -2000"); // Use 2MB of cache
this.initialized = true;
await this.updateStats();
} catch (error) {
logger.error("Failed to initialize Electron SQLite:", error);
throw error;
}
}
async close(): Promise<void> {
if (!this.initialized || !this.db) {
return;
}
try {
await this.db.close();
this.db = null;
this.initialized = false;
} catch (error) {
logger.error("Failed to close Electron SQLite connection:", error);
throw error;
}
}
protected async _executeQuery<T>(
sql: string,
params: unknown[] = [],
operation: "query" | "execute" = "query",
): Promise<SQLiteResult<T>> {
if (!this.db) {
throw new Error("Database not initialized");
}
try {
if (operation === "query") {
const rows = await this.db.all<T>(sql, params);
const result = await this.db.run("SELECT last_insert_rowid() as id");
return {
rows,
rowsAffected: this.db.changes,
lastInsertId: result.lastID,
executionTime: 0, // Will be set by base class
};
} else {
const result = await this.db.run(sql, params);
return {
rows: [],
rowsAffected: this.db.changes,
lastInsertId: result.lastID,
executionTime: 0, // Will be set by base class
};
}
} catch (error) {
logger.error("Electron SQLite query failed:", {
sql,
params,
error: error instanceof Error ? error.message : String(error),
});
throw error;
}
}
protected async _beginTransaction(): Promise<void> {
if (!this.db) {
throw new Error("Database not initialized");
}
await this.db.run("BEGIN TRANSACTION");
}
protected async _commitTransaction(): Promise<void> {
if (!this.db) {
throw new Error("Database not initialized");
}
await this.db.run("COMMIT");
}
protected async _rollbackTransaction(): Promise<void> {
if (!this.db) {
throw new Error("Database not initialized");
}
await this.db.run("ROLLBACK");
}
protected async _prepareStatement<T>(
sql: string,
): Promise<PreparedStatement<T>> {
if (!this.db) {
throw new Error("Database not initialized");
}
const stmt = await this.db.prepare(sql);
return {
execute: async (params: unknown[] = []) => {
if (!this.db) {
throw new Error("Database not initialized");
}
const rows = await stmt.all<T>(params);
return {
rows,
rowsAffected: this.db.changes,
lastInsertId: (await this.db.run("SELECT last_insert_rowid() as id"))
.lastID,
executionTime: 0, // Will be set by base class
};
},
finalize: async () => {
await stmt.finalize();
},
};
}
protected async _finalizeStatement(_sql: string): Promise<void> {
// Statements are finalized when the PreparedStatement is finalized
}
async getDatabaseSize(): Promise<number> {
if (!this.db || !this.config) {
throw new Error("Database not initialized");
}
try {
const dbPath = join(app.getPath("userData"), `${this.config.name}.db`);
const stats = await fs.stat(dbPath);
return stats.size;
} catch (error) {
logger.error("Failed to get database size:", error);
return 0;
}
}
}
export class ElectronPlatformService implements PlatformService {
private sqliteService: ElectronSQLiteService | null = null;
getCapabilities(): PlatformCapabilities {
return {
hasFileSystem: true,
hasCamera: true,
isMobile: false,
isIOS: false,
hasFileDownload: true,
needsFileHandlingInstructions: false,
sqlite: {
supported: true,
runsInWorker: false,
hasSharedArrayBuffer: true,
supportsWAL: true,
maxSize: 1024 * 1024 * 1024 * 10, // 10GB limit for desktop SQLite
},
};
}
async getSQLite(): Promise<SQLiteOperations> {
if (!this.sqliteService) {
this.sqliteService = new ElectronSQLiteService();
}
return this.sqliteService;
}
async readFile(path: string): Promise<string> {
try {
return await fs.readFile(path, "utf-8");
} catch (error) {
logger.error("Failed to read file:", error);
throw error;
}
}
async writeFile(path: string, content: string): Promise<void> {
try {
await fs.writeFile(path, content, "utf-8");
} catch (error) {
logger.error("Failed to write file:", error);
throw error;
}
}
async deleteFile(path: string): Promise<void> {
try {
await fs.unlink(path);
} catch (error) {
logger.error("Failed to delete file:", error);
throw error;
}
}
async listFiles(directory: string): Promise<string[]> {
try {
const files = await fs.readdir(directory);
return files;
} catch (error) {
logger.error("Failed to list files:", error);
throw error;
}
}
async takePicture(): Promise<{ blob: Blob; fileName: string }> {
try {
const { canceled, filePaths } = await dialog.showOpenDialog({
properties: ["openFile"],
filters: [{ name: "Images", extensions: ["jpg", "png", "gif"] }],
});
if (canceled || !filePaths.length) {
throw new Error("No image selected");
}
const filePath = filePaths[0];
const buffer = await fs.readFile(filePath);
const blob = new Blob([buffer], { type: "image/jpeg" });
const fileName = `photo_${Date.now()}.jpg`;
return { blob, fileName };
} catch (error) {
logger.error("Failed to take picture:", error);
throw error;
}
}
async pickImage(): Promise<{ blob: Blob; fileName: string }> {
try {
const { canceled, filePaths } = await dialog.showOpenDialog({
properties: ["openFile"],
filters: [{ name: "Images", extensions: ["jpg", "png", "gif"] }],
});
if (canceled || !filePaths.length) {
throw new Error("No image selected");
}
const filePath = filePaths[0];
const buffer = await fs.readFile(filePath);
const blob = new Blob([buffer], { type: "image/jpeg" });
const fileName = `image_${Date.now()}.jpg`;
return { blob, fileName };
} catch (error) {
logger.error("Failed to pick image:", error);
throw error;
}
}
async handleDeepLink(url: string): Promise<void> {
// Implement deep link handling for Electron platform
logger.info("Handling deep link:", url);
}
}

207
src/services/PlatformService.ts

@ -1,3 +1,6 @@
import { Settings } from "../db/tables/settings";
import { Account } from "../db/tables/accounts";
/**
* Represents the result of an image capture or selection operation.
* Contains both the image data as a Blob and the associated filename.
@ -26,6 +29,154 @@ export interface PlatformCapabilities {
hasFileDownload: boolean;
/** Whether the platform requires special file handling instructions */
needsFileHandlingInstructions: boolean;
/** SQLite capabilities of the platform */
sqlite: {
/** Whether SQLite is supported on this platform */
supported: boolean;
/** Whether SQLite runs in a Web Worker (browser) */
runsInWorker: boolean;
/** Whether the platform supports SharedArrayBuffer (required for optimal performance) */
hasSharedArrayBuffer: boolean;
/** Whether the platform supports WAL mode */
supportsWAL: boolean;
/** Maximum database size in bytes (if known) */
maxSize?: number;
};
}
/**
* SQLite configuration options
*/
export interface SQLiteConfig {
/** Database name */
name: string;
/** Whether to use WAL mode (if supported) */
useWAL?: boolean;
/** Whether to use memory-mapped I/O (if supported) */
useMMap?: boolean;
/** Size of memory map in bytes (if using mmap) */
mmapSize?: number;
/** Whether to use prepared statements cache */
usePreparedStatements?: boolean;
/** Maximum number of prepared statements to cache */
maxPreparedStatements?: number;
}
/**
* Represents a SQLite query result with typed rows
*/
export interface SQLiteResult<T> {
/** The rows returned by the query */
rows: T[];
/** The number of rows affected by the query */
rowsAffected: number;
/** The last inserted row ID (if applicable) */
lastInsertId?: number;
/** Execution time in milliseconds */
executionTime: number;
}
/**
* SQLite operations interface for platform-agnostic database access
*/
export interface SQLiteOperations {
/**
* Initializes the SQLite database with the given configuration
* @param config - SQLite configuration options
* @returns Promise resolving when initialization is complete
*/
initialize(config: SQLiteConfig): Promise<void>;
/**
* Executes a SQL query and returns typed results
* @param sql - The SQL query to execute
* @param params - Optional parameters for the query
* @returns Promise resolving to the query results
*/
query<T>(sql: string, params?: unknown[]): Promise<SQLiteResult<T>>;
/**
* Executes a SQL query that modifies data (INSERT, UPDATE, DELETE)
* @param sql - The SQL query to execute
* @param params - Optional parameters for the query
* @returns Promise resolving to the number of rows affected
*/
execute(sql: string, params?: unknown[]): Promise<number>;
/**
* Executes multiple SQL statements in a transaction
* @param statements - Array of SQL statements to execute
* @returns Promise resolving when the transaction is complete
*/
transaction(statements: { sql: string; params?: unknown[] }[]): Promise<void>;
/**
* Gets the maximum value of a column for matching rows
* @param table - The table to query
* @param column - The column to find the maximum value of
* @param where - Optional WHERE clause conditions
* @param params - Optional parameters for the WHERE clause
* @returns Promise resolving to the maximum value
*/
getMaxValue<T>(
table: string,
column: string,
where?: string,
params?: unknown[],
): Promise<T | null>;
/**
* Prepares a SQL statement for repeated execution
* @param sql - The SQL statement to prepare
* @returns A prepared statement that can be executed multiple times
*/
prepare<T>(sql: string): Promise<PreparedStatement<T>>;
/**
* Gets the current database size in bytes
* @returns Promise resolving to the database size
*/
getDatabaseSize(): Promise<number>;
/**
* Gets the current database statistics
* @returns Promise resolving to database statistics
*/
getStats(): Promise<SQLiteStats>;
/**
* Closes the database connection
* @returns Promise resolving when the connection is closed
*/
close(): Promise<void>;
}
/**
* Represents a prepared SQL statement
*/
export interface PreparedStatement<T> {
/** Executes the prepared statement with the given parameters */
execute(params?: unknown[]): Promise<SQLiteResult<T>>;
/** Frees the prepared statement */
finalize(): Promise<void>;
}
/**
* Database statistics
*/
export interface SQLiteStats {
/** Total number of queries executed */
totalQueries: number;
/** Average query execution time in milliseconds */
avgExecutionTime: number;
/** Number of prepared statements in cache */
preparedStatements: number;
/** Current database size in bytes */
databaseSize: number;
/** Whether WAL mode is active */
walMode: boolean;
/** Whether memory mapping is active */
mmapActive: boolean;
}
/**
@ -59,11 +210,12 @@ export interface PlatformService {
/**
* Writes content to a file at the specified path and shares it.
* Optional method - not all platforms need to implement this.
* @param fileName - The filename of the file to write
* @param content - The content to write to the file
* @returns Promise that resolves when the write is complete
*/
writeAndShareFile(fileName: string, content: string): Promise<void>;
writeAndShareFile?(fileName: string, content: string): Promise<void>;
/**
* Deletes a file at the specified path.
@ -98,4 +250,57 @@ export interface PlatformService {
* @returns Promise that resolves when the deep link has been handled
*/
handleDeepLink(url: string): Promise<void>;
/**
* Gets the SQLite operations interface for the platform.
* For browsers, this will use absurd-sql with Web Worker support.
* @returns Promise resolving to the SQLite operations interface
*/
getSQLite?(): Promise<SQLiteOperations>;
// Account Management
/**
* Gets all accounts in the database
* @returns Promise resolving to array of accounts
*/
getAccounts(): Promise<Account[]>;
/**
* Gets a specific account by DID
* @param did - The DID of the account to retrieve
* @returns Promise resolving to the account or undefined if not found
*/
getAccount(did: string): Promise<Account | undefined>;
/**
* Adds a new account to the database
* @param account - The account to add
* @returns Promise resolving when the account is added
*/
addAccount(account: Account): Promise<void>;
// Settings Management
/**
* Updates the master settings with the provided changes
* @param settingsChanges - The settings to update
* @returns Promise resolving when the update is complete
*/
updateMasterSettings(settingsChanges: Partial<Settings>): Promise<void>;
/**
* Gets the settings for the active account
* @returns Promise resolving to the active account settings
*/
getActiveAccountSettings(): Promise<Settings>;
/**
* Updates settings for a specific account
* @param accountDid - The DID of the account to update settings for
* @param settingsChanges - The settings to update
* @returns Promise resolving when the update is complete
*/
updateAccountSettings(
accountDid: string,
settingsChanges: Partial<Settings>,
): Promise<void>;
}

43
src/services/WebPlatformService.ts

@ -0,0 +1,43 @@
import {
PlatformService,
PlatformCapabilities,
SQLiteOperations,
} from "./PlatformService";
import { AbsurdSQLService } from "./sqlite/AbsurdSQLService";
import { logger } from "../utils/logger";
export class WebPlatformService implements PlatformService {
private sqliteService: AbsurdSQLService | null = null;
getCapabilities(): PlatformCapabilities {
return {
hasFileSystem: false,
hasCamera: "mediaDevices" in navigator,
isMobile: /iPhone|iPad|iPod|Android/i.test(navigator.userAgent),
isIOS: /iPhone|iPad|iPod/i.test(navigator.userAgent),
hasFileDownload: true,
needsFileHandlingInstructions: true,
sqlite: {
supported: true,
runsInWorker: true,
hasSharedArrayBuffer: typeof SharedArrayBuffer !== "undefined",
supportsWAL: true,
maxSize: 1024 * 1024 * 1024, // 1GB limit for IndexedDB
},
};
}
async getSQLite(): Promise<SQLiteOperations> {
if (!this.sqliteService) {
this.sqliteService = new AbsurdSQLService();
}
return this.sqliteService;
}
// ... existing file system and camera methods ...
async handleDeepLink(url: string): Promise<void> {
// Implement deep link handling for web platform
logger.info("Handling deep link:", url);
}
}

34
src/services/platforms/CapacitorPlatformService.ts

@ -7,6 +7,9 @@ import { Filesystem, Directory, Encoding } from "@capacitor/filesystem";
import { Camera, CameraResultType, CameraSource } from "@capacitor/camera";
import { Share } from "@capacitor/share";
import { logger } from "../../utils/logger";
import { Account } from "../../db/tables/accounts";
import { Settings } from "../../db/tables/settings";
import { db } from "../../db";
/**
* Platform service implementation for Capacitor (mobile) platform.
@ -476,4 +479,35 @@ export class CapacitorPlatformService implements PlatformService {
// This is just a placeholder for the interface
return Promise.resolve();
}
// Account Management
async getAccounts(): Promise<Account[]> {
return await db.accounts.toArray();
}
async getAccount(did: string): Promise<Account | undefined> {
return await db.accounts.where("did").equals(did).first();
}
async addAccount(account: Account): Promise<void> {
await db.accounts.add(account);
}
// Settings Management
async updateMasterSettings(
settingsChanges: Partial<Settings>,
): Promise<void> {
throw new Error("Not implemented");
}
async getActiveAccountSettings(): Promise<Settings> {
throw new Error("Not implemented");
}
async updateAccountSettings(
accountDid: string,
settingsChanges: Partial<Settings>,
): Promise<void> {
throw new Error("Not implemented");
}
}

34
src/services/platforms/ElectronPlatformService.ts

@ -4,6 +4,9 @@ import {
PlatformCapabilities,
} from "../PlatformService";
import { logger } from "../../utils/logger";
import { Account } from "../../db/tables/accounts";
import { Settings } from "../../db/tables/settings";
import { db } from "../../db";
/**
* Platform service implementation for Electron (desktop) platform.
@ -108,4 +111,35 @@ export class ElectronPlatformService implements PlatformService {
logger.error("handleDeepLink not implemented in Electron platform");
throw new Error("Not implemented");
}
// Account Management
async getAccounts(): Promise<Account[]> {
return await db.accounts.toArray();
}
async getAccount(did: string): Promise<Account | undefined> {
return await db.accounts.where("did").equals(did).first();
}
async addAccount(account: Account): Promise<void> {
await db.accounts.add(account);
}
// Settings Management
async updateMasterSettings(
settingsChanges: Partial<Settings>,
): Promise<void> {
throw new Error("Not implemented");
}
async getActiveAccountSettings(): Promise<Settings> {
throw new Error("Not implemented");
}
async updateAccountSettings(
accountDid: string,
settingsChanges: Partial<Settings>,
): Promise<void> {
throw new Error("Not implemented");
}
}

34
src/services/platforms/PyWebViewPlatformService.ts

@ -4,6 +4,9 @@ import {
PlatformCapabilities,
} from "../PlatformService";
import { logger } from "../../utils/logger";
import { Account } from "../../db/tables/accounts";
import { Settings } from "../../db/tables/settings";
import { db } from "../../db";
/**
* Platform service implementation for PyWebView platform.
@ -109,4 +112,35 @@ export class PyWebViewPlatformService implements PlatformService {
logger.error("handleDeepLink not implemented in PyWebView platform");
throw new Error("Not implemented");
}
// Account Management
async getAccounts(): Promise<Account[]> {
return await db.accounts.toArray();
}
async getAccount(did: string): Promise<Account | undefined> {
return await db.accounts.where("did").equals(did).first();
}
async addAccount(account: Account): Promise<void> {
await db.accounts.add(account);
}
// Settings Management
async updateMasterSettings(
settingsChanges: Partial<Settings>,
): Promise<void> {
throw new Error("Not implemented");
}
async getActiveAccountSettings(): Promise<Settings> {
throw new Error("Not implemented");
}
async updateAccountSettings(
accountDid: string,
settingsChanges: Partial<Settings>,
): Promise<void> {
throw new Error("Not implemented");
}
}

67
src/services/platforms/WebPlatformService.ts

@ -3,7 +3,11 @@ import {
PlatformService,
PlatformCapabilities,
} from "../PlatformService";
import { Settings } from "../../db/tables/settings";
import { MASTER_SETTINGS_KEY } from "../../db/tables/settings";
import { db } from "../../db";
import { logger } from "../../utils/logger";
import { Account } from "../../db/tables/accounts";
/**
* Platform service implementation for web browser platform.
@ -359,4 +363,67 @@ export class WebPlatformService implements PlatformService {
async writeAndShareFile(_fileName: string, _content: string): Promise<void> {
throw new Error("File system access not available in web platform");
}
async updateMasterSettings(
settingsChanges: Partial<Settings>,
): Promise<void> {
try {
delete settingsChanges.accountDid; // just in case
delete settingsChanges.id; // ensure there is no "id" that would override the key
await db.settings.update(MASTER_SETTINGS_KEY, settingsChanges);
} catch (error) {
logger.error("Error updating master settings:", error);
throw new Error(
`Failed to update settings. We recommend you try again or restart the app.`,
);
}
}
async getActiveAccountSettings(): Promise<Settings> {
const defaultSettings = (await db.settings.get(MASTER_SETTINGS_KEY)) || {};
if (!defaultSettings.activeDid) {
return defaultSettings;
}
const overrideSettings =
(await db.settings
.where("accountDid")
.equals(defaultSettings.activeDid)
.first()) || {};
return { ...defaultSettings, ...overrideSettings };
}
async updateAccountSettings(
accountDid: string,
settingsChanges: Partial<Settings>,
): Promise<void> {
settingsChanges.accountDid = accountDid;
delete settingsChanges.id; // key off account, not ID
const result = await db.settings
.where("accountDid")
.equals(accountDid)
.modify(settingsChanges);
if (result === 0) {
// If no record was updated, create a new one
settingsChanges.id = (await db.settings.count()) + 1;
await db.settings.add(settingsChanges);
}
}
// Account Management
async getAccounts(): Promise<Account[]> {
return await db.accounts.toArray();
}
async getAccount(did: string): Promise<Account | undefined> {
return await db.accounts.where("did").equals(did).first();
}
async addAccount(account: Account): Promise<void> {
await db.accounts.add(account);
}
}

248
src/services/sqlite/AbsurdSQLService.ts

@ -0,0 +1,248 @@
import initSqlJs, { Database } from "@jlongster/sql.js";
import { SQLiteFS } from "absurd-sql";
import { IndexedDBBackend } from "absurd-sql/dist/indexeddb-backend";
import { BaseSQLiteService } from "./BaseSQLiteService";
import {
SQLiteConfig,
SQLiteResult,
PreparedStatement,
} from "../PlatformService";
import { logger } from "../../utils/logger";
/**
* SQLite implementation using absurd-sql for web browsers.
* Provides SQLite access in the browser using Web Workers and IndexedDB.
*/
export class AbsurdSQLService extends BaseSQLiteService {
private db: Database | null = null;
private worker: Worker | null = null;
private config: SQLiteConfig | null = null;
async initialize(config: SQLiteConfig): Promise<void> {
if (this.initialized) {
return;
}
try {
this.config = config;
const SQL = await initSqlJs({
locateFile: (file) => `/sql-wasm/${file}`,
});
// Initialize the virtual file system
const backend = new IndexedDBBackend(this.config.name);
const fs = new SQLiteFS(SQL.FS, backend);
SQL.register_for_idb(fs);
// Create and initialize the database
this.db = new SQL.Database(this.config.name, {
filename: true,
});
// Configure database settings
if (this.config.useWAL) {
await this.execute("PRAGMA journal_mode = WAL");
this.stats.walMode = true;
}
if (this.config.useMMap) {
const mmapSize = this.config.mmapSize ?? 30000000000;
await this.execute(`PRAGMA mmap_size = ${mmapSize}`);
this.stats.mmapActive = true;
}
// Set other pragmas for performance
await this.execute("PRAGMA synchronous = NORMAL");
await this.execute("PRAGMA temp_store = MEMORY");
await this.execute("PRAGMA cache_size = -2000"); // Use 2MB of cache
// Start the Web Worker for async operations
this.worker = new Worker(new URL("./sqlite.worker.ts", import.meta.url), {
type: "module",
});
this.initialized = true;
await this.updateStats();
} catch (error) {
logger.error("Failed to initialize Absurd SQL:", error);
throw error;
}
}
async close(): Promise<void> {
if (!this.initialized || !this.db) {
return;
}
try {
// Finalize all prepared statements
for (const [_sql, stmt] of this.preparedStatements) {
logger.debug("finalizing statement", _sql);
await stmt.finalize();
}
this.preparedStatements.clear();
// Close the database
this.db.close();
this.db = null;
// Terminate the worker
if (this.worker) {
this.worker.terminate();
this.worker = null;
}
this.initialized = false;
} catch (error) {
logger.error("Failed to close Absurd SQL connection:", error);
throw error;
}
}
protected async _executeQuery<T>(
sql: string,
params: unknown[] = [],
operation: "query" | "execute" = "query",
): Promise<SQLiteResult<T>> {
if (!this.db) {
throw new Error("Database not initialized");
}
try {
let lastInsertId: number | undefined = undefined;
if (operation === "query") {
const stmt = this.db.prepare(sql);
const rows: T[] = [];
try {
while (stmt.step()) {
rows.push(stmt.getAsObject() as T);
}
} finally {
stmt.free();
}
// Get last insert ID safely
const result = this.db.exec("SELECT last_insert_rowid() AS id");
lastInsertId =
(result?.[0]?.values?.[0]?.[0] as number | undefined) ?? undefined;
return {
rows,
rowsAffected: this.db.getRowsModified(),
lastInsertId,
executionTime: 0, // Will be set by base class
};
} else {
this.db.run(sql, params);
// Get last insert ID after execute
const result = this.db.exec("SELECT last_insert_rowid() AS id");
lastInsertId =
(result?.[0]?.values?.[0]?.[0] as number | undefined) ?? undefined;
return {
rows: [],
rowsAffected: this.db.getRowsModified(),
lastInsertId,
executionTime: 0,
};
}
} catch (error) {
logger.error("Absurd SQL query failed:", {
sql,
params,
error: error instanceof Error ? error.message : String(error),
});
throw error;
}
}
protected async _beginTransaction(): Promise<void> {
if (!this.db) {
throw new Error("Database not initialized");
}
this.db.exec("BEGIN TRANSACTION");
}
protected async _commitTransaction(): Promise<void> {
if (!this.db) {
throw new Error("Database not initialized");
}
this.db.exec("COMMIT");
}
protected async _rollbackTransaction(): Promise<void> {
if (!this.db) {
throw new Error("Database not initialized");
}
this.db.exec("ROLLBACK");
}
protected async _prepareStatement<T>(
_sql: string,
): Promise<PreparedStatement<T>> {
if (!this.db) {
throw new Error("Database not initialized");
}
const stmt = this.db.prepare(_sql);
return {
execute: async (params: unknown[] = []) => {
if (!this.db) {
throw new Error("Database not initialized");
}
try {
const rows: T[] = [];
stmt.bind(params);
while (stmt.step()) {
rows.push(stmt.getAsObject() as T);
}
// Safely extract lastInsertId
const result = this.db.exec("SELECT last_insert_rowid()");
const rawId = result?.[0]?.values?.[0]?.[0];
const lastInsertId = typeof rawId === "number" ? rawId : undefined;
return {
rows,
rowsAffected: this.db.getRowsModified(),
lastInsertId,
executionTime: 0, // Will be set by base class
};
} finally {
stmt.reset();
}
},
finalize: async () => {
stmt.free();
},
};
}
protected async _finalizeStatement(_sql: string): Promise<void> {
// Statements are finalized when the PreparedStatement is finalized
}
async getDatabaseSize(): Promise<number> {
if (!this.db) {
throw new Error("Database not initialized");
}
try {
const result = this.db.exec(
"SELECT page_count * page_size as size FROM pragma_page_count(), pragma_page_size()",
);
const rawSize = result?.[0]?.values?.[0]?.[0];
const size = typeof rawSize === "number" ? rawSize : 0;
return size;
} catch (error) {
logger.error("Failed to get database size:", error);
return 0;
}
}
}

383
src/services/sqlite/BaseSQLiteService.ts

@ -0,0 +1,383 @@
import {
SQLiteOperations,
SQLiteConfig,
SQLiteResult,
PreparedStatement,
SQLiteStats,
} from "../PlatformService";
import { Settings, MASTER_SETTINGS_KEY } from "../../db/tables/settings";
import { logger } from "../../utils/logger";
/**
* Base class for SQLite implementations across different platforms.
* Provides common functionality and error handling.
*/
export abstract class BaseSQLiteService implements SQLiteOperations {
protected initialized = false;
protected stats: SQLiteStats = {
totalQueries: 0,
avgExecutionTime: 0,
preparedStatements: 0,
databaseSize: 0,
walMode: false,
mmapActive: false,
};
protected preparedStatements: Map<string, PreparedStatement<unknown>> =
new Map();
abstract initialize(config: SQLiteConfig): Promise<void>;
abstract close(): Promise<void>;
abstract getDatabaseSize(): Promise<number>;
protected async executeQuery<T>(
sql: string,
params: unknown[] = [],
operation: "query" | "execute" = "query",
): Promise<SQLiteResult<T>> {
if (!this.initialized) {
throw new Error("SQLite database not initialized");
}
const startTime = performance.now();
try {
const result = await this._executeQuery<T>(sql, params, operation);
const executionTime = performance.now() - startTime;
// Update stats
this.stats.totalQueries++;
this.stats.avgExecutionTime =
(this.stats.avgExecutionTime * (this.stats.totalQueries - 1) +
executionTime) /
this.stats.totalQueries;
return {
...result,
executionTime,
};
} catch (error) {
logger.error("SQLite query failed:", {
sql,
params,
error: error instanceof Error ? error.message : String(error),
});
throw error;
}
}
protected abstract _executeQuery<T>(
sql: string,
params: unknown[],
operation: "query" | "execute",
): Promise<SQLiteResult<T>>;
async query<T>(
sql: string,
params: unknown[] = [],
): Promise<SQLiteResult<T>> {
return this.executeQuery<T>(sql, params, "query");
}
async execute(sql: string, params: unknown[] = []): Promise<number> {
const result = await this.executeQuery<unknown>(sql, params, "execute");
return result.rowsAffected;
}
async transaction(
statements: { sql: string; params?: unknown[] }[],
): Promise<void> {
if (!this.initialized) {
throw new Error("SQLite database not initialized");
}
try {
await this._beginTransaction();
for (const { sql, params = [] } of statements) {
await this.executeQuery(sql, params, "execute");
}
await this._commitTransaction();
} catch (error) {
await this._rollbackTransaction();
throw error;
}
}
protected abstract _beginTransaction(): Promise<void>;
protected abstract _commitTransaction(): Promise<void>;
protected abstract _rollbackTransaction(): Promise<void>;
async getMaxValue<T>(
table: string,
column: string,
where?: string,
params: unknown[] = [],
): Promise<T | null> {
const sql = `SELECT MAX(${column}) as max_value FROM ${table}${where ? ` WHERE ${where}` : ""}`;
const result = await this.query<{ max_value: T }>(sql, params);
return result.rows[0]?.max_value ?? null;
}
async prepare<T>(sql: string): Promise<PreparedStatement<T>> {
if (!this.initialized) {
throw new Error("SQLite database not initialized");
}
const stmt = await this._prepareStatement<T>(sql);
this.stats.preparedStatements++;
this.preparedStatements.set(sql, stmt);
return {
execute: async (params: unknown[] = []) => {
return this.executeQuery<T>(sql, params, "query");
},
finalize: async () => {
await this._finalizeStatement(sql);
this.preparedStatements.delete(sql);
this.stats.preparedStatements--;
},
};
}
protected abstract _prepareStatement<T>(
sql: string,
): Promise<PreparedStatement<T>>;
protected abstract _finalizeStatement(sql: string): Promise<void>;
async getStats(): Promise<SQLiteStats> {
return {
...this.stats,
databaseSize: await this.getDatabaseSize(),
};
}
protected async updateStats(): Promise<void> {
this.stats.databaseSize = await this.getDatabaseSize();
// Platform-specific stats updates can be implemented in subclasses
}
protected async setupSchema(): Promise<void> {
await this.execute(`
CREATE TABLE IF NOT EXISTS settings (
id INTEGER PRIMARY KEY,
accountDid TEXT,
activeDid TEXT,
apiServer TEXT,
filterFeedByNearby INTEGER,
filterFeedByVisible INTEGER,
finishedOnboarding INTEGER,
firstName TEXT,
hideRegisterPromptOnNewContact INTEGER,
isRegistered INTEGER,
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 INTEGER,
showGeneralAdvanced INTEGER,
showShortcutBvc INTEGER,
vapid TEXT,
warnIfProdServer INTEGER,
warnIfTestServer INTEGER,
webPushServer TEXT
)
`);
}
protected async settingsToRow(
settings: Partial<Settings>,
): Promise<Record<string, unknown>> {
const row: Record<string, unknown> = {};
// Convert boolean values to integers for SQLite
if ("filterFeedByNearby" in settings)
row.filterFeedByNearby = settings.filterFeedByNearby ? 1 : 0;
if ("filterFeedByVisible" in settings)
row.filterFeedByVisible = settings.filterFeedByVisible ? 1 : 0;
if ("finishedOnboarding" in settings)
row.finishedOnboarding = settings.finishedOnboarding ? 1 : 0;
if ("hideRegisterPromptOnNewContact" in settings)
row.hideRegisterPromptOnNewContact =
settings.hideRegisterPromptOnNewContact ? 1 : 0;
if ("isRegistered" in settings)
row.isRegistered = settings.isRegistered ? 1 : 0;
if ("showContactGivesInline" in settings)
row.showContactGivesInline = settings.showContactGivesInline ? 1 : 0;
if ("showGeneralAdvanced" in settings)
row.showGeneralAdvanced = settings.showGeneralAdvanced ? 1 : 0;
if ("showShortcutBvc" in settings)
row.showShortcutBvc = settings.showShortcutBvc ? 1 : 0;
if ("warnIfProdServer" in settings)
row.warnIfProdServer = settings.warnIfProdServer ? 1 : 0;
if ("warnIfTestServer" in settings)
row.warnIfTestServer = settings.warnIfTestServer ? 1 : 0;
// Handle JSON fields
if ("searchBoxes" in settings)
row.searchBoxes = JSON.stringify(settings.searchBoxes);
// Copy all other fields as is
Object.entries(settings).forEach(([key, value]) => {
if (!(key in row)) {
row[key] = value;
}
});
return row;
}
protected async rowToSettings(
row: Record<string, unknown>,
): Promise<Settings> {
const settings: Settings = {};
// Convert integer values back to booleans
if ("filterFeedByNearby" in row)
settings.filterFeedByNearby = !!row.filterFeedByNearby;
if ("filterFeedByVisible" in row)
settings.filterFeedByVisible = !!row.filterFeedByVisible;
if ("finishedOnboarding" in row)
settings.finishedOnboarding = !!row.finishedOnboarding;
if ("hideRegisterPromptOnNewContact" in row)
settings.hideRegisterPromptOnNewContact =
!!row.hideRegisterPromptOnNewContact;
if ("isRegistered" in row) settings.isRegistered = !!row.isRegistered;
if ("showContactGivesInline" in row)
settings.showContactGivesInline = !!row.showContactGivesInline;
if ("showGeneralAdvanced" in row)
settings.showGeneralAdvanced = !!row.showGeneralAdvanced;
if ("showShortcutBvc" in row)
settings.showShortcutBvc = !!row.showShortcutBvc;
if ("warnIfProdServer" in row)
settings.warnIfProdServer = !!row.warnIfProdServer;
if ("warnIfTestServer" in row)
settings.warnIfTestServer = !!row.warnIfTestServer;
// Parse JSON fields
if ("searchBoxes" in row && row.searchBoxes) {
try {
settings.searchBoxes = JSON.parse(row.searchBoxes);
} catch (error) {
logger.error("Error parsing searchBoxes JSON:", error);
}
}
// Copy all other fields as is
Object.entries(row).forEach(([key, value]) => {
if (!(key in settings)) {
(settings as Record<string, unknown>)[key] = value;
}
});
return settings;
}
async updateMasterSettings(
settingsChanges: Partial<Settings>,
): Promise<void> {
try {
const row = await this.settingsToRow(settingsChanges);
row.id = MASTER_SETTINGS_KEY;
delete row.accountDid;
const result = await this.execute(
`UPDATE settings SET ${Object.keys(row)
.map((k) => `${k} = ?`)
.join(", ")} WHERE id = ?`,
[...Object.values(row), MASTER_SETTINGS_KEY],
);
if (result === 0) {
// If no record was updated, create a new one
await this.execute(
`INSERT INTO settings (${Object.keys(row).join(", ")}) VALUES (${Object.keys(
row,
)
.map(() => "?")
.join(", ")})`,
Object.values(row),
);
}
} catch (error) {
logger.error("Error updating master settings:", error);
throw new Error("Failed to update settings");
}
}
async getActiveAccountSettings(): Promise<Settings> {
try {
const defaultSettings = await this.query<Record<string, unknown>>(
"SELECT * FROM settings WHERE id = ?",
[MASTER_SETTINGS_KEY],
);
if (!defaultSettings.rows.length) {
return {};
}
const settings = await this.rowToSettings(defaultSettings.rows[0]);
if (!settings.activeDid) {
return settings;
}
const overrideSettings = await this.query<Record<string, unknown>>(
"SELECT * FROM settings WHERE accountDid = ?",
[settings.activeDid],
);
if (!overrideSettings.rows.length) {
return settings;
}
const override = await this.rowToSettings(overrideSettings.rows[0]);
return { ...settings, ...override };
} catch (error) {
logger.error("Error getting active account settings:", error);
throw new Error("Failed to get settings");
}
}
async updateAccountSettings(
accountDid: string,
settingsChanges: Partial<Settings>,
): Promise<void> {
try {
const row = await this.settingsToRow(settingsChanges);
row.accountDid = accountDid;
const result = await this.execute(
`UPDATE settings SET ${Object.keys(row)
.map((k) => `${k} = ?`)
.join(", ")} WHERE accountDid = ?`,
[...Object.values(row), accountDid],
);
if (result === 0) {
// If no record was updated, create a new one
const idResult = await this.query<{ max: number }>(
"SELECT MAX(id) as max FROM settings",
);
row.id = (idResult.rows[0]?.max || 0) + 1;
await this.execute(
`INSERT INTO settings (${Object.keys(row).join(", ")}) VALUES (${Object.keys(
row,
)
.map(() => "?")
.join(", ")})`,
Object.values(row),
);
}
} catch (error) {
logger.error("Error updating account settings:", error);
throw new Error("Failed to update settings");
}
}
}

176
src/services/sqlite/CapacitorSQLiteService.ts

@ -0,0 +1,176 @@
import {
CapacitorSQLite,
SQLiteConnection,
SQLiteDBConnection,
} from "@capacitor-community/sqlite";
import { BaseSQLiteService } from "./BaseSQLiteService";
import {
SQLiteConfig,
SQLiteResult,
PreparedStatement,
} from "../PlatformService";
import { logger } from "../../utils/logger";
/**
* SQLite implementation using the Capacitor SQLite plugin.
* Provides native SQLite access on mobile platforms.
*/
export class CapacitorSQLiteService extends BaseSQLiteService {
private connection: SQLiteDBConnection | null = null;
private sqlite: SQLiteConnection | null = null;
async initialize(config: SQLiteConfig): Promise<void> {
if (this.initialized) {
return;
}
try {
this.sqlite = new SQLiteConnection(CapacitorSQLite);
const db = await this.sqlite.createConnection(
config.name,
config.useWAL ?? false,
"no-encryption",
1,
false,
);
await db.open();
this.connection = db;
// Configure database settings
if (config.useWAL) {
await this.execute("PRAGMA journal_mode = WAL");
this.stats.walMode = true;
}
// Set other pragmas for performance
await this.execute("PRAGMA synchronous = NORMAL");
await this.execute("PRAGMA temp_store = MEMORY");
await this.execute("PRAGMA mmap_size = 30000000000");
this.stats.mmapActive = true;
// Set up database schema
await this.setupSchema();
this.initialized = true;
await this.updateStats();
} catch (error) {
logger.error("Failed to initialize Capacitor SQLite:", error);
throw error;
}
}
async close(): Promise<void> {
if (!this.initialized || !this.connection || !this.sqlite) {
return;
}
try {
await this.connection.close();
await this.sqlite.closeConnection(this.connection);
this.connection = null;
this.sqlite = null;
this.initialized = false;
} catch (error) {
logger.error("Failed to close Capacitor SQLite connection:", error);
throw error;
}
}
protected async _executeQuery<T>(
sql: string,
params: unknown[] = [],
operation: "query" | "execute" = "query",
): Promise<SQLiteResult<T>> {
if (!this.connection) {
throw new Error("Database connection not initialized");
}
try {
if (operation === "query") {
const result = await this.connection.query(sql, params);
return {
rows: result.values as T[],
rowsAffected: result.changes?.changes ?? 0,
lastInsertId: result.changes?.lastId,
executionTime: 0, // Will be set by base class
};
} else {
const result = await this.connection.run(sql, params);
return {
rows: [],
rowsAffected: result.changes?.changes ?? 0,
lastInsertId: result.changes?.lastId,
executionTime: 0, // Will be set by base class
};
}
} catch (error) {
logger.error("Capacitor SQLite query failed:", {
sql,
params,
error: error instanceof Error ? error.message : String(error),
});
throw error;
}
}
protected async _beginTransaction(): Promise<void> {
if (!this.connection) {
throw new Error("Database connection not initialized");
}
await this.connection.execute("BEGIN TRANSACTION");
}
protected async _commitTransaction(): Promise<void> {
if (!this.connection) {
throw new Error("Database connection not initialized");
}
await this.connection.execute("COMMIT");
}
protected async _rollbackTransaction(): Promise<void> {
if (!this.connection) {
throw new Error("Database connection not initialized");
}
await this.connection.execute("ROLLBACK");
}
protected async _prepareStatement<T>(
sql: string,
): Promise<PreparedStatement<T>> {
if (!this.connection) {
throw new Error("Database connection not initialized");
}
// Capacitor SQLite doesn't support prepared statements directly,
// so we'll simulate it by storing the SQL
return {
execute: async (params: unknown[] = []) => {
return this.executeQuery<T>(sql, params, "query");
},
finalize: async () => {
// No cleanup needed for Capacitor SQLite
},
};
}
protected async _finalizeStatement(_sql: string): Promise<void> {
// No cleanup needed for Capacitor SQLite
}
async getDatabaseSize(): Promise<number> {
if (!this.connection) {
throw new Error("Database connection not initialized");
}
try {
const result = await this.connection.query(
"SELECT page_count * page_size as size FROM pragma_page_count(), pragma_page_size()",
);
return result.values?.[0]?.size ?? 0;
} catch (error) {
logger.error("Failed to get database size:", error);
return 0;
}
}
}

150
src/services/sqlite/sqlite.worker.ts

@ -0,0 +1,150 @@
import initSqlJs, { Database } from "@jlongster/sql.js";
import { SQLiteFS } from "absurd-sql";
import { IndexedDBBackend } from "absurd-sql/dist/indexeddb-backend";
interface WorkerMessage {
type: "init" | "query" | "execute" | "transaction" | "close";
id: string;
dbName?: string;
sql?: string;
params?: unknown[];
statements?: { sql: string; params?: unknown[] }[];
}
interface WorkerResponse {
id: string;
error?: string;
result?: unknown;
}
let db: Database | null = null;
async function initialize(dbName: string): Promise<void> {
if (db) {
return;
}
const SQL = await initSqlJs({
locateFile: (file: string) => `/sql-wasm/${file}`,
});
// Initialize the virtual file system
const backend = new IndexedDBBackend(dbName);
const fs = new SQLiteFS(SQL.FS, backend);
SQL.register_for_idb(fs);
// Create and initialize the database
db = new SQL.Database(dbName, {
filename: true,
});
// Configure database settings
db.exec("PRAGMA synchronous = NORMAL");
db.exec("PRAGMA temp_store = MEMORY");
db.exec("PRAGMA cache_size = -2000"); // Use 2MB of cache
}
async function executeQuery(
sql: string,
params: unknown[] = [],
): Promise<unknown> {
if (!db) {
throw new Error("Database not initialized");
}
const stmt = db.prepare(sql);
try {
const rows: unknown[] = [];
stmt.bind(params);
while (stmt.step()) {
rows.push(stmt.getAsObject());
}
return {
rows,
rowsAffected: db.getRowsModified(),
lastInsertId: db.exec("SELECT last_insert_rowid()")[0]?.values[0]?.[0],
};
} finally {
stmt.free();
}
}
async function executeTransaction(
statements: { sql: string; params?: unknown[] }[],
): Promise<void> {
if (!db) {
throw new Error("Database not initialized");
}
try {
db.exec("BEGIN TRANSACTION");
for (const { sql, params = [] } of statements) {
const stmt = db.prepare(sql);
try {
stmt.bind(params);
stmt.step();
} finally {
stmt.free();
}
}
db.exec("COMMIT");
} catch (error) {
db.exec("ROLLBACK");
throw error;
}
}
async function close(): Promise<void> {
if (db) {
db.close();
db = null;
}
}
self.onmessage = async (event: MessageEvent<WorkerMessage>) => {
const { type, id, dbName, sql, params, statements } = event.data;
const response: WorkerResponse = { id };
try {
switch (type) {
case "init":
if (!dbName) {
throw new Error("Database name is required for initialization");
}
await initialize(dbName);
break;
case "query":
if (!sql) {
throw new Error("SQL query is required");
}
response.result = await executeQuery(sql, params);
break;
case "execute":
if (!sql) {
throw new Error("SQL statement is required");
}
response.result = await executeQuery(sql, params);
break;
case "transaction":
if (!statements?.length) {
throw new Error("Transaction statements are required");
}
await executeTransaction(statements);
break;
case "close":
await close();
break;
default:
throw new Error(`Unknown message type: ${type}`);
}
} catch (error) {
response.error = error instanceof Error ? error.message : String(error);
}
self.postMessage(response);
};

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

@ -0,0 +1,45 @@
declare module "@jlongster/sql.js" {
export interface Database {
exec(
sql: string,
params?: unknown[],
): { columns: string[]; values: unknown[][] }[];
prepare(sql: string): Statement;
run(sql: string, params?: unknown[]): void;
getRowsModified(): number;
close(): void;
}
export interface Statement {
step(): boolean;
getAsObject(): Record<string, unknown>;
bind(params: unknown[]): void;
reset(): void;
free(): void;
}
export interface InitSqlJsStatic {
Database: new (
filename?: string,
options?: { filename: boolean },
) => Database;
FS: unknown;
register_for_idb(fs: unknown): void;
}
export default function initSqlJs(options?: {
locateFile?: (file: string) => string;
}): Promise<InitSqlJsStatic>;
}
declare module "absurd-sql" {
export class SQLiteFS {
constructor(fs: unknown, backend: unknown);
}
}
declare module "absurd-sql/dist/indexeddb-backend" {
export class IndexedDBBackend {
constructor(dbName: string);
}
}

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

@ -9,9 +9,9 @@ const crypto = {
},
createHash: () => ({
update: () => ({
digest: () => new Uint8Array(32) // Return empty hash
})
})
digest: () => new Uint8Array(32), // Return empty hash
}),
}),
};
export default crypto;

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

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

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

@ -1,13 +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(),
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() : '';
}
const parts = p.split(".");
return parts.length > 1 ? "." + parts.pop() : "";
},
};
export default path;

37
src/views/AccountViewView.vue

@ -994,13 +994,6 @@ import {
IMAGE_TYPE_PROFILE,
NotificationIface,
} from "../constants/app";
import {
db,
logConsoleAndDb,
retrieveSettingsForActiveAccount,
updateAccountSettings,
} from "../db/index";
import { Account } from "../db/tables/accounts";
import { Contact } from "../db/tables/contacts";
import {
DEFAULT_PASSKEY_EXPIRATION_MINUTES,
@ -1215,11 +1208,12 @@ export default class AccountViewView extends Vue {
}
/**
* Initializes component state with values from the database or defaults.
* Initializes component state using PlatformService for database operations
* Keeps all endorserServer functionality unchanged
*/
async initializeState() {
await db.open();
const settings = await retrieveSettingsForActiveAccount();
const platform = this.$platform;
const settings = await platform.getActiveAccountSettings();
this.activeDid = settings.activeDid || "";
this.apiServer = settings.apiServer || "";
@ -1252,6 +1246,29 @@ export default class AccountViewView extends Vue {
this.webPushServerInput = settings.webPushServer || this.webPushServerInput;
}
/**
* Updates account settings using PlatformService
* Keeps all endorserServer functionality unchanged
*/
async updateSettings(settingsChanges: Record<string, unknown>) {
try {
const platform = this.$platform;
await platform.updateAccountSettings(this.activeDid, settingsChanges);
await this.initializeState();
} catch (error) {
logger.error("Error updating settings:", error);
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "There was a problem updating your settings.",
},
5000,
);
}
}
// call fn, copy text to the clipboard, then redo fn after 2 seconds
doCopyTwoSecRedo(text: string, fn: () => void) {
fn();

11
src/views/ConfirmGiftView.vue

@ -439,13 +439,11 @@ import { useClipboard } from "@vueuse/core";
import { RouteLocationNormalizedLoaded, Router } from "vue-router";
import QuickNav from "../components/QuickNav.vue";
import { NotificationIface } from "../constants/app";
import { db, retrieveSettingsForActiveAccount } from "../db/index";
import { Contact } from "../db/tables/contacts";
import * as serverUtil from "../libs/endorserServer";
import { GenericVerifiableCredential, GiveSummaryRecord } from "../interfaces";
import { displayAmount } from "../libs/endorserServer";
import * as libsUtil from "../libs/util";
import { retrieveAccountDids } from "../libs/util";
import TopMessage from "../components/TopMessage.vue";
import { logger } from "../utils/logger";
/**
@ -526,14 +524,17 @@ export default class ConfirmGiftView extends Vue {
/**
* Initializes component settings and user data
* Only database operations are migrated to PlatformService
* API-related utilities remain using serverUtil
*/
private async initializeSettings() {
const settings = await retrieveSettingsForActiveAccount();
const platform = this.$platform;
const settings = await platform.getActiveAccountSettings();
this.activeDid = settings.activeDid || "";
this.apiServer = settings.apiServer || "";
this.allContacts = await db.contacts.toArray();
this.allContacts = await platform.getAllContacts();
this.isRegistered = settings.isRegistered || false;
this.allMyDids = await retrieveAccountDids();
this.allMyDids = await platform.getAllAccountDids();
// Check share capability
// When Chrome compatibility is fixed https://developer.mozilla.org/en-US/docs/Web/API/Web_Share_API#api.navigator.canshare

3
src/views/ContactsView.vue

@ -1063,7 +1063,8 @@ export default class ContactsView extends Vue {
);
if (regResult.success) {
contact.registered = true;
await db.contacts.update(contact.did, { registered: true });
const platform = this.$platform;
await platform.updateContact(contact.did, { registered: true });
this.$notify(
{

127
src/views/HomeView.vue

@ -298,57 +298,43 @@ Raymer * @version 1.0.0 */
import { UAParser } from "ua-parser-js";
import { Component, Vue } from "vue-facing-decorator";
import { Router } from "vue-router";
//import App from "../App.vue";
import EntityIcon from "../components/EntityIcon.vue";
import GiftedDialog from "../components/GiftedDialog.vue";
import GiftedPrompts from "../components/GiftedPrompts.vue";
import FeedFilters from "../components/FeedFilters.vue";
import InfiniteScroll from "../components/InfiniteScroll.vue";
import OnboardingDialog from "../components/OnboardingDialog.vue";
import QuickNav from "../components/QuickNav.vue";
import TopMessage from "../components/TopMessage.vue";
import UserNameDialog from "../components/UserNameDialog.vue";
import ChoiceButtonDialog from "../components/ChoiceButtonDialog.vue";
import ImageViewer from "../components/ImageViewer.vue";
import ActivityListItem from "../components/ActivityListItem.vue";
import {
AppString,
NotificationIface,
PASSKEYS_ENABLED,
} from "../constants/app";
import {
db,
logConsoleAndDb,
retrieveSettingsForActiveAccount,
updateAccountSettings,
} from "../db/index";
import { Contact } from "../db/tables/contacts";
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
import { BoundingBox } from "../types/BoundingBox";
import { Contact } from "../types/Contact";
import {
BoundingBox,
checkIsAnyFeedFilterOn,
MASTER_SETTINGS_KEY,
} from "../db/tables/settings";
OnboardingDialog,
OnboardPage,
} from "../components/OnboardingDialog.vue";
import { QuickNav } from "../components/QuickNav.vue";
import { TopMessage } from "../components/TopMessage.vue";
import { EntityIcon } from "../components/EntityIcon.vue";
import { GiftedDialog } from "../components/GiftedDialog.vue";
import { GiftedPrompts } from "../components/GiftedPrompts.vue";
import { FeedFilters } from "../components/FeedFilters.vue";
import { UserNameDialog } from "../components/UserNameDialog.vue";
import { ActivityListItem } from "../components/ActivityListItem.vue";
import { AppString } from "../AppString";
import { PASSKEYS_ENABLED } from "../config";
import { logger } from "../utils/logger";
import { checkIsAnyFeedFilterOn } from "../utils/feedFilters";
import {
contactForDid,
containsNonHiddenDid,
didInfoForContact,
fetchEndorserRateLimits,
getHeaders,
getNewOffersToUser,
getNewOffersToUserProjects,
getPlanFromCache,
} from "../libs/endorserServer";
} from "../api/endorser";
import {
generateSaveAndActivateIdentity,
retrieveAccountDids,
GiverReceiverInputInfo,
OnboardPage,
} from "../libs/util";
} from "../api/identity";
import { NotificationIface } from "../constants/app";
import {
containsNonHiddenDid,
didInfoForContact,
} from "../libs/endorserServer";
import { GiveSummaryRecord } from "../interfaces";
import * as serverUtil from "../libs/endorserServer";
import { logger } from "../utils/logger";
import { GiveRecordWithContactInfo } from "../types";
import { ChoiceButtonDialog, ImageViewer } from "../components/index";
interface Claim {
claim?: Claim; // For nested claims in Verifiable Credentials
@ -419,18 +405,18 @@ interface FeedError {
*/
@Component({
components: {
FontAwesomeIcon,
QuickNav,
TopMessage,
EntityIcon,
FeedFilters,
GiftedDialog,
GiftedPrompts,
InfiniteScroll,
FeedFilters,
UserNameDialog,
ActivityListItem,
OnboardingDialog,
ChoiceButtonDialog,
QuickNav,
TopMessage,
UserNameDialog,
ImageViewer,
ActivityListItem,
},
})
export default class HomeView extends Vue {
@ -520,10 +506,11 @@ export default class HomeView extends Vue {
this.allMyDids = [newDid];
}
const settings = await retrieveSettingsForActiveAccount();
const platform = this.$platform;
const settings = await platform.getActiveAccountSettings();
this.apiServer = settings.apiServer || "";
this.activeDid = settings.activeDid || "";
this.allContacts = await db.contacts.toArray();
this.allContacts = await platform.getAllContacts();
this.feedLastViewedClaimId = settings.lastViewedClaimId;
this.givenName = settings.firstName || "";
this.isFeedFilteredByVisible = !!settings.filterFeedByVisible;
@ -552,9 +539,9 @@ export default class HomeView extends Vue {
this.activeDid,
);
if (resp.status === 200) {
await updateAccountSettings(this.activeDid, {
await platform.updateAccountSettings(this.activeDid, {
isRegistered: true,
...(await retrieveSettingsForActiveAccount()),
...settings,
});
this.isRegistered = true;
}
@ -590,14 +577,14 @@ export default class HomeView extends Vue {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (err: any) {
logConsoleAndDb("Error retrieving settings or feed: " + err, true);
logger.error("Error retrieving settings or feed:", err);
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text:
(err as { userMessage?: string })?.userMessage ||
err?.userMessage ||
"There was an error retrieving your settings or the latest activity.",
},
5000,
@ -618,7 +605,8 @@ export default class HomeView extends Vue {
* Called by mounted() and reloadFeedOnChange()
*/
private async loadSettings() {
const settings = await retrieveSettingsForActiveAccount();
const platform = this.$platform;
const settings = await platform.getActiveAccountSettings();
this.apiServer = settings.apiServer || "";
this.activeDid = settings.activeDid || "";
this.feedLastViewedClaimId = settings.lastViewedClaimId;
@ -642,7 +630,7 @@ export default class HomeView extends Vue {
* Called by mounted() and initializeIdentity()
*/
private async loadContacts() {
this.allContacts = await db.contacts.toArray();
this.allContacts = await this.$platform.getAllContacts();
}
/**
@ -663,10 +651,12 @@ export default class HomeView extends Vue {
this.activeDid,
);
if (resp.status === 200) {
await updateAccountSettings(this.activeDid, {
const platform = this.$platform;
const settings = await platform.getActiveAccountSettings();
await platform.updateAccountSettings(this.activeDid, {
apiServer: this.apiServer,
isRegistered: true,
...(await retrieveSettingsForActiveAccount()),
...settings,
});
this.isRegistered = true;
}
@ -728,7 +718,8 @@ export default class HomeView extends Vue {
* Called by mounted()
*/
private async checkOnboarding() {
const settings = await retrieveSettingsForActiveAccount();
const platform = this.$platform;
const settings = await platform.getActiveAccountSettings();
if (!settings.finishedOnboarding) {
(this.$refs.onboardingDialog as OnboardingDialog).open(OnboardPage.Home);
}
@ -744,7 +735,7 @@ export default class HomeView extends Vue {
* @param err Error object with optional userMessage
*/
private handleError(err: unknown) {
logConsoleAndDb("Error retrieving settings or feed: " + err, true);
logger.error("Error retrieving settings or feed:", err);
this.$notify(
{
group: "alert",
@ -790,7 +781,8 @@ export default class HomeView extends Vue {
* Called by FeedFilters component when filters change
*/
async reloadFeedOnChange() {
const settings = await retrieveSettingsForActiveAccount();
const platform = this.$platform;
const settings = await platform.getActiveAccountSettings();
this.isFeedFilteredByVisible = !!settings.filterFeedByVisible;
this.isFeedFilteredByNearby = !!settings.filterFeedByNearby;
this.isAnyFeedFilterOn = checkIsAnyFeedFilterOn(settings);
@ -1064,7 +1056,7 @@ export default class HomeView extends Vue {
* @returns The fulfills plan object
*/
private async getFulfillsPlan(record: GiveSummaryRecord) {
return await getPlanFromCache(
return await this.$platform.getPlanFromCache(
record.fulfillsPlanHandleId,
this.axios,
this.apiServer,
@ -1142,7 +1134,7 @@ export default class HomeView extends Vue {
* Called by processRecord()
*/
private async getProvidedByPlan(provider: Provider | undefined) {
return await getPlanFromCache(
return await this.$platform.getPlanFromCache(
provider?.identifier as string,
this.axios,
this.apiServer,
@ -1197,14 +1189,14 @@ export default class HomeView extends Vue {
giver: didInfoForContact(
giverDid,
this.activeDid,
contactForDid(giverDid, this.allContacts),
this.$platform.getContactForDid(giverDid, this.allContacts),
this.allMyDids,
),
image: claim.image,
issuer: didInfoForContact(
record.issuerDid,
this.activeDid,
contactForDid(record.issuerDid, this.allContacts),
this.$platform.getContactForDid(record.issuerDid, this.allContacts),
this.allMyDids,
),
providerPlanHandleId: provider?.identifier as string,
@ -1213,7 +1205,7 @@ export default class HomeView extends Vue {
receiver: didInfoForContact(
recipientDid,
this.activeDid,
contactForDid(recipientDid, this.allContacts),
this.$platform.getContactForDid(recipientDid, this.allContacts),
this.allMyDids,
),
} as GiveRecordWithContactInfo;
@ -1230,8 +1222,7 @@ export default class HomeView extends Vue {
this.feedLastViewedClaimId == null ||
this.feedLastViewedClaimId < records[0].jwtId
) {
await db.open();
await db.settings.update(MASTER_SETTINGS_KEY, {
await this.$platform.updateAccountSettings(this.activeDid, {
lastViewedClaimId: records[0].jwtId,
});
}
@ -1264,13 +1255,13 @@ export default class HomeView extends Vue {
* @internal
* Called by updateAllFeed()
* @param endorserApiServer API server URL
* @param beforeId OptioCalled by updateAllFeed()nal ID to fetch earlier results
* @param beforeId Optional ID to fetch earlier results
* @returns claims in reverse chronological order
*/
async retrieveGives(endorserApiServer: string, beforeId?: string) {
const beforeQuery = beforeId == null ? "" : "&beforeId=" + beforeId;
const doNotShowErrorAgain = !!beforeId; // don't show error again if we're loading more
const headers = await getHeaders(
const headers = await this.$platform.getHeaders(
this.activeDid,
doNotShowErrorAgain ? undefined : this.$notify,
);

21
src/views/IdentitySwitcherView.vue

@ -106,14 +106,9 @@ import { Router } from "vue-router";
import QuickNav from "../components/QuickNav.vue";
import { NotificationIface } from "../constants/app";
import {
accountsDBPromise,
db,
retrieveSettingsForActiveAccount,
} from "../db/index";
import { MASTER_SETTINGS_KEY } from "../db/tables/settings";
import { retrieveAllAccountsMetadata } from "../libs/util";
import { logger } from "../utils/logger";
@Component({ components: { QuickNav } })
export default class IdentitySwitcherView extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void;
@ -127,7 +122,8 @@ export default class IdentitySwitcherView extends Vue {
async created() {
try {
const settings = await retrieveSettingsForActiveAccount();
const platform = this.$platform;
const settings = await platform.getActiveAccountSettings();
this.activeDid = settings.activeDid || "";
this.apiServer = settings.apiServer || "";
this.apiServerInput = settings.apiServer || "";
@ -162,10 +158,8 @@ export default class IdentitySwitcherView extends Vue {
if (did === "0") {
did = undefined;
}
await db.open();
await db.settings.update(MASTER_SETTINGS_KEY, {
activeDid: did,
});
const platform = this.$platform;
await platform.updateAccountSettings(this.activeDid, { activeDid: did });
this.$router.push({ name: "account" });
}
@ -177,9 +171,8 @@ export default class IdentitySwitcherView extends Vue {
title: "Delete Identity?",
text: "Are you sure you want to erase this identity? (There is no undo. You may want to select it and back it up just in case.)",
onYes: async () => {
// one of the few times we use accountsDBPromise directly; try to avoid more usage
const accountsDB = await accountsDBPromise;
await accountsDB.accounts.delete(id);
const platform = this.$platform;
await platform.deleteAccount(id);
this.otherIdentities = this.otherIdentities.filter(
(ident) => ident.id !== id,
);

91
src/views/InviteOneView.vue

@ -328,30 +328,81 @@ export default class InviteOneView extends Vue {
);
}
addNewContact(did: string, notes: string) {
async addNewContact(did: string, notes: string) {
(this.$refs.contactNameDialog as ContactNameDialog).open(
"To Whom Did You Send The Invite?",
"Their name will be added to your contact list.",
(name) => {
// the person obviously registered themselves and this user already granted visibility, so we just add them
const contact = {
did: did,
name: name,
registered: true,
};
db.contacts.add(contact);
this.contactsRedeemed[did] = contact;
this.$notify(
{
group: "alert",
type: "success",
title: "Contact Added",
text: `${name} has been added to your contacts.`,
},
3000,
);
async (name) => {
try {
// Get the SQLite interface from the platform service
const sqlite = await this.$platform.getSQLite();
// Create the contact object
const contact = {
did: did,
name: name,
registered: true,
notes: notes,
// Convert contact methods to JSON string as per schema
contactMethods: JSON.stringify([]),
// Other fields can be null/undefined as they're optional
nextPubKeyHashB64: null,
profileImageUrl: null,
publicKeyBase64: null,
seesMe: null,
};
// Insert the contact using a transaction
await sqlite.transaction([
{
sql: `
INSERT INTO contacts (
did, name, registered, notes, contactMethods,
nextPubKeyHashB64, profileImageUrl, publicKeyBase64, seesMe
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`,
params: [
contact.did,
contact.name,
contact.registered ? 1 : 0, // Convert boolean to integer for SQLite
contact.notes,
contact.contactMethods,
contact.nextPubKeyHashB64,
contact.profileImageUrl,
contact.publicKeyBase64,
contact.seesMe ? 1 : 0, // Convert boolean to integer for SQLite
],
},
]);
// Update the local contacts cache
this.contactsRedeemed[did] = contact;
// Notify success
this.$notify(
{
group: "alert",
type: "success",
title: "Contact Added",
text: `${name} has been added to your contacts.`,
},
3000,
);
} catch (error) {
// Handle any errors
this.$notify(
{
group: "alert",
type: "danger",
title: "Error Adding Contact",
text: "Failed to add contact to database.",
},
5000,
);
logger.error("Error adding contact:", error);
}
},
() => {},
() => {}, // onCancel callback
notes,
);
}

27
src/views/NewActivityView.vue

@ -154,11 +154,6 @@ import GiftedDialog from "../components/GiftedDialog.vue";
import QuickNav from "../components/QuickNav.vue";
import EntityIcon from "../components/EntityIcon.vue";
import { NotificationIface } from "../constants/app";
import {
db,
retrieveSettingsForActiveAccount,
updateAccountSettings,
} from "../db/index";
import { Contact } from "../db/tables/contacts";
import { Router } from "vue-router";
import { OfferSummaryRecord, OfferToPlanSummaryRecord } from "../interfaces";
@ -169,6 +164,7 @@ import {
getNewOffersToUserProjects,
} from "../libs/endorserServer";
import { retrieveAccountDids } from "../libs/util";
import { logger } from "../utils/logger";
@Component({
components: { GiftedDialog, QuickNav, EntityIcon },
@ -194,14 +190,15 @@ export default class NewActivityView extends Vue {
async created() {
try {
const settings = await retrieveSettingsForActiveAccount();
const platform = this.$platform;
const settings = await platform.getActiveAccountSettings();
this.apiServer = settings.apiServer || "";
this.activeDid = settings.activeDid || "";
this.lastAckedOfferToUserJwtId = settings.lastAckedOfferToUserJwtId || "";
this.lastAckedOfferToUserProjectsJwtId =
settings.lastAckedOfferToUserProjectsJwtId || "";
this.allContacts = await db.contacts.toArray();
this.allContacts = await platform.getContacts();
this.allMyDids = await retrieveAccountDids();
const offersToUserData = await getNewOffersToUser(
@ -240,7 +237,8 @@ export default class NewActivityView extends Vue {
async expandOffersToUserAndMarkRead() {
this.showOffersDetails = !this.showOffersDetails;
if (this.showOffersDetails) {
await updateAccountSettings(this.activeDid, {
const platform = this.$platform;
await platform.updateAccountSettings(this.activeDid, {
lastAckedOfferToUserJwtId: this.newOffersToUser[0].jwtId,
});
// note that we don't update this.lastAckedOfferToUserJwtId in case they
@ -261,14 +259,15 @@ export default class NewActivityView extends Vue {
const index = this.newOffersToUser.findIndex(
(offer) => offer.jwtId === jwtId,
);
const platform = this.$platform;
if (index !== -1 && index < this.newOffersToUser.length - 1) {
// Set to the next offer's jwtId
await updateAccountSettings(this.activeDid, {
await platform.updateAccountSettings(this.activeDid, {
lastAckedOfferToUserJwtId: this.newOffersToUser[index + 1].jwtId,
});
} else {
// it's the last entry (or not found), so just keep it the same
await updateAccountSettings(this.activeDid, {
await platform.updateAccountSettings(this.activeDid, {
lastAckedOfferToUserJwtId: this.lastAckedOfferToUserJwtId,
});
}
@ -287,7 +286,8 @@ export default class NewActivityView extends Vue {
this.showOffersToUserProjectsDetails =
!this.showOffersToUserProjectsDetails;
if (this.showOffersToUserProjectsDetails) {
await updateAccountSettings(this.activeDid, {
const platform = this.$platform;
await platform.updateAccountSettings(this.activeDid, {
lastAckedOfferToUserProjectsJwtId:
this.newOffersToUserProjects[0].jwtId,
});
@ -309,15 +309,16 @@ export default class NewActivityView extends Vue {
const index = this.newOffersToUserProjects.findIndex(
(offer) => offer.jwtId === jwtId,
);
const platform = this.$platform;
if (index !== -1 && index < this.newOffersToUserProjects.length - 1) {
// Set to the next offer's jwtId
await updateAccountSettings(this.activeDid, {
await platform.updateAccountSettings(this.activeDid, {
lastAckedOfferToUserProjectsJwtId:
this.newOffersToUserProjects[index + 1].jwtId,
});
} else {
// it's the last entry (or not found), so just keep it the same
await updateAccountSettings(this.activeDid, {
await platform.updateAccountSettings(this.activeDid, {
lastAckedOfferToUserProjectsJwtId:
this.lastAckedOfferToUserProjectsJwtId,
});

19
src/views/QuickActionBvcEndView.vue

@ -145,11 +145,6 @@ import { Router } from "vue-router";
import QuickNav from "../components/QuickNav.vue";
import TopMessage from "../components/TopMessage.vue";
import { NotificationIface } from "../constants/app";
import {
accountsDBPromise,
db,
retrieveSettingsForActiveAccount,
} from "../db/index";
import { Contact } from "../db/tables/contacts";
import {
GenericCredWrapper,
@ -165,6 +160,7 @@ import {
getHeaders,
} from "../libs/endorserServer";
import { logger } from "../utils/logger";
@Component({
methods: { claimSpecialDescription },
components: {
@ -172,7 +168,7 @@ import { logger } from "../utils/logger";
TopMessage,
},
})
export default class QuickActionBvcBeginView extends Vue {
export default class QuickActionBvcEndView extends Vue {
$notify!: (notification: NotificationIface, timeout?: number) => void;
activeDid = "";
@ -191,10 +187,11 @@ export default class QuickActionBvcBeginView extends Vue {
async created() {
this.loadingConfirms = true;
const settings = await retrieveSettingsForActiveAccount();
const platform = this.$platform;
const settings = await platform.getActiveAccountSettings();
this.apiServer = settings.apiServer || "";
this.activeDid = settings.activeDid || "";
this.allContacts = await db.contacts.toArray();
this.allContacts = await platform.getContacts();
let currentOrPreviousSat = DateTime.now().setZone("America/Denver");
if (currentOrPreviousSat.weekday < 6) {
@ -213,10 +210,8 @@ export default class QuickActionBvcBeginView extends Vue {
suppressMilliseconds: true,
}) || "";
const accountsDB = await accountsDBPromise;
await accountsDB.open();
const allAccounts = await accountsDB.accounts.toArray();
this.allMyDids = allAccounts.map((acc) => acc.did);
const accounts = await platform.getAllAccounts();
this.allMyDids = accounts.map((acc) => acc.did);
const headers = await getHeaders(this.activeDid);
try {
const response = await fetch(

15
src/views/SearchAreaView.vue

@ -113,9 +113,9 @@ import { Router } from "vue-router";
import QuickNav from "../components/QuickNav.vue";
import { NotificationIface } from "../constants/app";
import { db, retrieveSettingsForActiveAccount } from "../db/index";
import { BoundingBox, MASTER_SETTINGS_KEY } from "../db/tables/settings";
import { BoundingBox } from "../db/tables/settings";
import { logger } from "../utils/logger";
const DEFAULT_LAT_LONG_DIFF = 0.01;
const WORLD_ZOOM = 2;
const DEFAULT_ZOOM = 2;
@ -147,7 +147,8 @@ export default class SearchAreaView extends Vue {
searchBox: { name: string; bbox: BoundingBox } | null = null;
async mounted() {
const settings = await retrieveSettingsForActiveAccount();
const platform = this.$platform;
const settings = await platform.getActiveAccountSettings();
this.searchBox = settings.searchBoxes?.[0] || null;
this.resetLatLong();
}
@ -204,8 +205,8 @@ export default class SearchAreaView extends Vue {
westLong: this.localCenterLong - this.localLongDiff,
},
};
await db.open();
await db.settings.update(MASTER_SETTINGS_KEY, {
const platform = this.$platform;
await platform.updateMasterSettings({
searchBoxes: [newSearchBox],
});
this.searchBox = newSearchBox;
@ -251,8 +252,8 @@ export default class SearchAreaView extends Vue {
public async forgetSearchBox() {
try {
await db.open();
await db.settings.update(MASTER_SETTINGS_KEY, {
const platform = this.$platform;
await platform.updateMasterSettings({
searchBoxes: [],
filterFeedByNearby: false,
});

3
tsconfig.electron.json

@ -21,6 +21,7 @@
"include": [
"src/electron/**/*.ts",
"src/utils/**/*.ts",
"src/constants/**/*.ts"
"src/constants/**/*.ts",
"src/services/**/*.ts"
]
}

10
vite.config.capacitor.mts

@ -1,4 +1,12 @@
import { defineConfig } from "vite";
import { createBuildConfig } from "./vite.config.common.mts";
export default defineConfig(async () => createBuildConfig('capacitor'));
export default defineConfig(
async () => {
const baseConfig = await createBuildConfig('capacitor');
return mergeConfig(baseConfig, {
optimizeDeps: {
include: ['@capacitor-community/sqlite']
}
});
});

2
vite.config.electron.mts

@ -30,7 +30,7 @@ export default defineConfig(async () => {
},
},
optimizeDeps: {
include: ['@/utils/logger']
include: ['@/utils/logger', '@capacitor-community/sqlite']
},
plugins: [
{

Loading…
Cancel
Save