From b30c4c8b302faa6c2c4f3ee6f09eea5501f6f71d Mon Sep 17 00:00:00 2001 From: Matt Raymer Date: Mon, 26 May 2025 06:54:10 -0400 Subject: [PATCH] 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. --- .cursor/rules/SQLITE.mdc | 172 ++++++++ doc/secure-storage-implementation.md | 26 ++ package-lock.json | 270 +++++++++++- package.json | 2 + src/components/FeedFilters.vue | 17 +- src/libs/util.ts | 130 +++--- src/main.common.ts | 11 + src/services/CapacitorPlatformService.ts | 139 +++++++ src/services/ElectronPlatformService.ts | 295 ++++++++++++++ src/services/PlatformService.ts | 207 +++++++++- src/services/WebPlatformService.ts | 43 ++ .../platforms/CapacitorPlatformService.ts | 34 ++ .../platforms/ElectronPlatformService.ts | 34 ++ .../platforms/PyWebViewPlatformService.ts | 34 ++ src/services/platforms/WebPlatformService.ts | 67 +++ src/services/sqlite/AbsurdSQLService.ts | 248 ++++++++++++ src/services/sqlite/BaseSQLiteService.ts | 383 ++++++++++++++++++ src/services/sqlite/CapacitorSQLiteService.ts | 176 ++++++++ src/services/sqlite/sqlite.worker.ts | 150 +++++++ src/types/absurd-sql.d.ts | 45 ++ src/utils/empty-module.js | 2 +- src/utils/node-modules/crypto.js | 8 +- src/utils/node-modules/fs.js | 10 +- src/utils/node-modules/path.js | 16 +- src/views/AccountViewView.vue | 37 +- src/views/ConfirmGiftView.vue | 11 +- src/views/ContactsView.vue | 3 +- src/views/HomeView.vue | 127 +++--- src/views/IdentitySwitcherView.vue | 21 +- src/views/InviteOneView.vue | 91 ++++- src/views/NewActivityView.vue | 27 +- src/views/QuickActionBvcEndView.vue | 19 +- src/views/SearchAreaView.vue | 15 +- tsconfig.electron.json | 3 +- vite.config.capacitor.mts | 10 +- vite.config.electron.mts | 2 +- 36 files changed, 2619 insertions(+), 266 deletions(-) create mode 100644 .cursor/rules/SQLITE.mdc create mode 100644 src/services/CapacitorPlatformService.ts create mode 100644 src/services/ElectronPlatformService.ts create mode 100644 src/services/WebPlatformService.ts create mode 100644 src/services/sqlite/AbsurdSQLService.ts create mode 100644 src/services/sqlite/BaseSQLiteService.ts create mode 100644 src/services/sqlite/CapacitorSQLiteService.ts create mode 100644 src/services/sqlite/sqlite.worker.ts create mode 100644 src/types/absurd-sql.d.ts diff --git a/.cursor/rules/SQLITE.mdc b/.cursor/rules/SQLITE.mdc new file mode 100644 index 00000000..63a1e2e5 --- /dev/null +++ b/.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 diff --git a/doc/secure-storage-implementation.md b/doc/secure-storage-implementation.md index 0395a3d1..f49cb92e 100644 --- a/doc/secure-storage-implementation.md +++ b/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 diff --git a/package-lock.json b/package-lock.json index 576328e2..bd990485 100644 --- a/package-lock.json +++ b/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", diff --git a/package.json b/package.json index 8225d000..fe27c829 100644 --- a/package.json +++ b/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", diff --git a/src/components/FeedFilters.vue b/src/components/FeedFilters.vue index 6ca4c55f..29d14d9b 100644 --- a/src/components/FeedFilters.vue +++ b/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, }); diff --git a/src/libs/util.ts b/src/libs/util.ts index bd70fb72..c6689ad1 100644 --- a/src/libs/util.ts +++ b/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 => { - // 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 => { + const accounts = await platform.getAccounts(); + return accounts.length; }; -export const retrieveAccountDids = async (): Promise => { - // 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 => { + 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 => { - // 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 => { - // 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 => { + 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 => { }; export const retrieveFullyDecryptedAccount = async ( + platform: PlatformService, activeDid: string, ): Promise => { - // 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 -> => { - const accountsDB = await accountsDBPromise; - const allAccounts = await accountsDB.accounts.toArray(); - return allAccounts; +export const retrieveAllFullyDecryptedAccounts = async ( + platform: PlatformService, +): Promise> => { + return await platform.getAccounts(); }; -/** - * Generates a new identity, saves it to the database, and sets it as the active identity. - * @return {Promise} with the DID of the new identity - */ -export const generateSaveAndActivateIdentity = async (): Promise => { +export const generateSaveAndActivateIdentity = async ( + platform: PlatformService, +): Promise => { 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 => { 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 => { 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 => { - 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 => { - const settings = await retrieveSettingsForActiveAccount(); +export const getPasskeyExpirationSeconds = async ( + platform: PlatformService, +): Promise => { + const settings = await platform.getActiveAccountSettings(); return ( (settings?.passkeyExpirationMinutes ?? DEFAULT_PASSKEY_EXPIRATION_MINUTES) * 60 diff --git a/src/main.common.ts b/src/main.common.ts index ab3944d3..7781f0ee 100644 --- a/src/main.common.ts +++ b/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"); diff --git a/src/services/CapacitorPlatformService.ts b/src/services/CapacitorPlatformService.ts new file mode 100644 index 00000000..eafd7e47 --- /dev/null +++ b/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 { + if (!this.sqliteService) { + this.sqliteService = new CapacitorSQLiteService(); + } + return this.sqliteService; + } + + async readFile(path: string): Promise { + 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 { + 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 { + try { + await Filesystem.deleteFile({ + path, + directory: Directory.Data, + }); + } catch (error) { + logger.error("Failed to delete file:", error); + throw error; + } + } + + async listFiles(directory: string): Promise { + 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 { + // Implement deep link handling for Capacitor platform + logger.info("Handling deep link:", url); + } +} diff --git a/src/services/ElectronPlatformService.ts b/src/services/ElectronPlatformService.ts new file mode 100644 index 00000000..cdca003d --- /dev/null +++ b/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 { + 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 { + 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( + sql: string, + params: unknown[] = [], + operation: "query" | "execute" = "query", + ): Promise> { + if (!this.db) { + throw new Error("Database not initialized"); + } + + try { + if (operation === "query") { + const rows = await this.db.all(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 { + if (!this.db) { + throw new Error("Database not initialized"); + } + await this.db.run("BEGIN TRANSACTION"); + } + + protected async _commitTransaction(): Promise { + if (!this.db) { + throw new Error("Database not initialized"); + } + await this.db.run("COMMIT"); + } + + protected async _rollbackTransaction(): Promise { + if (!this.db) { + throw new Error("Database not initialized"); + } + await this.db.run("ROLLBACK"); + } + + protected async _prepareStatement( + sql: string, + ): Promise> { + 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(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 { + // Statements are finalized when the PreparedStatement is finalized + } + + async getDatabaseSize(): Promise { + 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 { + if (!this.sqliteService) { + this.sqliteService = new ElectronSQLiteService(); + } + return this.sqliteService; + } + + async readFile(path: string): Promise { + 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 { + try { + await fs.writeFile(path, content, "utf-8"); + } catch (error) { + logger.error("Failed to write file:", error); + throw error; + } + } + + async deleteFile(path: string): Promise { + try { + await fs.unlink(path); + } catch (error) { + logger.error("Failed to delete file:", error); + throw error; + } + } + + async listFiles(directory: string): Promise { + 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 { + // Implement deep link handling for Electron platform + logger.info("Handling deep link:", url); + } +} diff --git a/src/services/PlatformService.ts b/src/services/PlatformService.ts index 574b1a3a..06a0eabe 100644 --- a/src/services/PlatformService.ts +++ b/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 { + /** 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; + + /** + * 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(sql: string, params?: unknown[]): Promise>; + + /** + * 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; + + /** + * 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; + + /** + * 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( + table: string, + column: string, + where?: string, + params?: unknown[], + ): Promise; + + /** + * 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(sql: string): Promise>; + + /** + * Gets the current database size in bytes + * @returns Promise resolving to the database size + */ + getDatabaseSize(): Promise; + + /** + * Gets the current database statistics + * @returns Promise resolving to database statistics + */ + getStats(): Promise; + + /** + * Closes the database connection + * @returns Promise resolving when the connection is closed + */ + close(): Promise; +} + +/** + * Represents a prepared SQL statement + */ +export interface PreparedStatement { + /** Executes the prepared statement with the given parameters */ + execute(params?: unknown[]): Promise>; + /** Frees the prepared statement */ + finalize(): Promise; +} + +/** + * 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; + writeAndShareFile?(fileName: string, content: string): Promise; /** * 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; + + /** + * 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; + + // Account Management + /** + * Gets all accounts in the database + * @returns Promise resolving to array of accounts + */ + getAccounts(): Promise; + + /** + * 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; + + /** + * 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; + + // 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): Promise; + + /** + * Gets the settings for the active account + * @returns Promise resolving to the active account settings + */ + getActiveAccountSettings(): Promise; + + /** + * 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, + ): Promise; } diff --git a/src/services/WebPlatformService.ts b/src/services/WebPlatformService.ts new file mode 100644 index 00000000..8af408b9 --- /dev/null +++ b/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 { + if (!this.sqliteService) { + this.sqliteService = new AbsurdSQLService(); + } + return this.sqliteService; + } + + // ... existing file system and camera methods ... + + async handleDeepLink(url: string): Promise { + // Implement deep link handling for web platform + logger.info("Handling deep link:", url); + } +} diff --git a/src/services/platforms/CapacitorPlatformService.ts b/src/services/platforms/CapacitorPlatformService.ts index ee8f2f82..aac2bf08 100644 --- a/src/services/platforms/CapacitorPlatformService.ts +++ b/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 { + return await db.accounts.toArray(); + } + + async getAccount(did: string): Promise { + return await db.accounts.where("did").equals(did).first(); + } + + async addAccount(account: Account): Promise { + await db.accounts.add(account); + } + + // Settings Management + async updateMasterSettings( + settingsChanges: Partial, + ): Promise { + throw new Error("Not implemented"); + } + + async getActiveAccountSettings(): Promise { + throw new Error("Not implemented"); + } + + async updateAccountSettings( + accountDid: string, + settingsChanges: Partial, + ): Promise { + throw new Error("Not implemented"); + } } diff --git a/src/services/platforms/ElectronPlatformService.ts b/src/services/platforms/ElectronPlatformService.ts index 74fc8290..9b2ca820 100644 --- a/src/services/platforms/ElectronPlatformService.ts +++ b/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 { + return await db.accounts.toArray(); + } + + async getAccount(did: string): Promise { + return await db.accounts.where("did").equals(did).first(); + } + + async addAccount(account: Account): Promise { + await db.accounts.add(account); + } + + // Settings Management + async updateMasterSettings( + settingsChanges: Partial, + ): Promise { + throw new Error("Not implemented"); + } + + async getActiveAccountSettings(): Promise { + throw new Error("Not implemented"); + } + + async updateAccountSettings( + accountDid: string, + settingsChanges: Partial, + ): Promise { + throw new Error("Not implemented"); + } } diff --git a/src/services/platforms/PyWebViewPlatformService.ts b/src/services/platforms/PyWebViewPlatformService.ts index b27aec31..a0d940be 100644 --- a/src/services/platforms/PyWebViewPlatformService.ts +++ b/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 { + return await db.accounts.toArray(); + } + + async getAccount(did: string): Promise { + return await db.accounts.where("did").equals(did).first(); + } + + async addAccount(account: Account): Promise { + await db.accounts.add(account); + } + + // Settings Management + async updateMasterSettings( + settingsChanges: Partial, + ): Promise { + throw new Error("Not implemented"); + } + + async getActiveAccountSettings(): Promise { + throw new Error("Not implemented"); + } + + async updateAccountSettings( + accountDid: string, + settingsChanges: Partial, + ): Promise { + throw new Error("Not implemented"); + } } diff --git a/src/services/platforms/WebPlatformService.ts b/src/services/platforms/WebPlatformService.ts index 7f09c4d3..be8fafb9 100644 --- a/src/services/platforms/WebPlatformService.ts +++ b/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 { throw new Error("File system access not available in web platform"); } + + async updateMasterSettings( + settingsChanges: Partial, + ): Promise { + 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 { + 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, + ): Promise { + 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 { + return await db.accounts.toArray(); + } + + async getAccount(did: string): Promise { + return await db.accounts.where("did").equals(did).first(); + } + + async addAccount(account: Account): Promise { + await db.accounts.add(account); + } } diff --git a/src/services/sqlite/AbsurdSQLService.ts b/src/services/sqlite/AbsurdSQLService.ts new file mode 100644 index 00000000..f1213f49 --- /dev/null +++ b/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 { + 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 { + 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( + sql: string, + params: unknown[] = [], + operation: "query" | "execute" = "query", + ): Promise> { + 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 { + if (!this.db) { + throw new Error("Database not initialized"); + } + this.db.exec("BEGIN TRANSACTION"); + } + + protected async _commitTransaction(): Promise { + if (!this.db) { + throw new Error("Database not initialized"); + } + this.db.exec("COMMIT"); + } + + protected async _rollbackTransaction(): Promise { + if (!this.db) { + throw new Error("Database not initialized"); + } + this.db.exec("ROLLBACK"); + } + + protected async _prepareStatement( + _sql: string, + ): Promise> { + 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 { + // Statements are finalized when the PreparedStatement is finalized + } + + async getDatabaseSize(): Promise { + 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; + } + } +} diff --git a/src/services/sqlite/BaseSQLiteService.ts b/src/services/sqlite/BaseSQLiteService.ts new file mode 100644 index 00000000..e7149a28 --- /dev/null +++ b/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> = + new Map(); + + abstract initialize(config: SQLiteConfig): Promise; + abstract close(): Promise; + abstract getDatabaseSize(): Promise; + + protected async executeQuery( + sql: string, + params: unknown[] = [], + operation: "query" | "execute" = "query", + ): Promise> { + if (!this.initialized) { + throw new Error("SQLite database not initialized"); + } + + const startTime = performance.now(); + try { + const result = await this._executeQuery(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( + sql: string, + params: unknown[], + operation: "query" | "execute", + ): Promise>; + + async query( + sql: string, + params: unknown[] = [], + ): Promise> { + return this.executeQuery(sql, params, "query"); + } + + async execute(sql: string, params: unknown[] = []): Promise { + const result = await this.executeQuery(sql, params, "execute"); + return result.rowsAffected; + } + + async transaction( + statements: { sql: string; params?: unknown[] }[], + ): Promise { + 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; + protected abstract _commitTransaction(): Promise; + protected abstract _rollbackTransaction(): Promise; + + async getMaxValue( + table: string, + column: string, + where?: string, + params: unknown[] = [], + ): Promise { + 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(sql: string): Promise> { + if (!this.initialized) { + throw new Error("SQLite database not initialized"); + } + + const stmt = await this._prepareStatement(sql); + this.stats.preparedStatements++; + this.preparedStatements.set(sql, stmt); + + return { + execute: async (params: unknown[] = []) => { + return this.executeQuery(sql, params, "query"); + }, + finalize: async () => { + await this._finalizeStatement(sql); + this.preparedStatements.delete(sql); + this.stats.preparedStatements--; + }, + }; + } + + protected abstract _prepareStatement( + sql: string, + ): Promise>; + protected abstract _finalizeStatement(sql: string): Promise; + + async getStats(): Promise { + return { + ...this.stats, + databaseSize: await this.getDatabaseSize(), + }; + } + + protected async updateStats(): Promise { + this.stats.databaseSize = await this.getDatabaseSize(); + // Platform-specific stats updates can be implemented in subclasses + } + + protected async setupSchema(): Promise { + 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, + ): Promise> { + const row: Record = {}; + + // 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, + ): Promise { + 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)[key] = value; + } + }); + + return settings; + } + + async updateMasterSettings( + settingsChanges: Partial, + ): Promise { + 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 { + try { + const defaultSettings = await this.query>( + "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>( + "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, + ): Promise { + 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"); + } + } +} diff --git a/src/services/sqlite/CapacitorSQLiteService.ts b/src/services/sqlite/CapacitorSQLiteService.ts new file mode 100644 index 00000000..4ebe7710 --- /dev/null +++ b/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 { + 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 { + 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( + sql: string, + params: unknown[] = [], + operation: "query" | "execute" = "query", + ): Promise> { + 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 { + if (!this.connection) { + throw new Error("Database connection not initialized"); + } + await this.connection.execute("BEGIN TRANSACTION"); + } + + protected async _commitTransaction(): Promise { + if (!this.connection) { + throw new Error("Database connection not initialized"); + } + await this.connection.execute("COMMIT"); + } + + protected async _rollbackTransaction(): Promise { + if (!this.connection) { + throw new Error("Database connection not initialized"); + } + await this.connection.execute("ROLLBACK"); + } + + protected async _prepareStatement( + sql: string, + ): Promise> { + 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(sql, params, "query"); + }, + finalize: async () => { + // No cleanup needed for Capacitor SQLite + }, + }; + } + + protected async _finalizeStatement(_sql: string): Promise { + // No cleanup needed for Capacitor SQLite + } + + async getDatabaseSize(): Promise { + 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; + } + } +} diff --git a/src/services/sqlite/sqlite.worker.ts b/src/services/sqlite/sqlite.worker.ts new file mode 100644 index 00000000..84148075 --- /dev/null +++ b/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 { + 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 { + 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 { + 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 { + if (db) { + db.close(); + db = null; + } +} + +self.onmessage = async (event: MessageEvent) => { + 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); +}; diff --git a/src/types/absurd-sql.d.ts b/src/types/absurd-sql.d.ts new file mode 100644 index 00000000..9a2c44cc --- /dev/null +++ b/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; + 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; +} + +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); + } +} diff --git a/src/utils/empty-module.js b/src/utils/empty-module.js index 2915969c..5e4e544b 100644 --- a/src/utils/empty-module.js +++ b/src/utils/empty-module.js @@ -1,2 +1,2 @@ // Empty module to satisfy Node.js built-in module imports -export default {}; \ No newline at end of file +export default {}; diff --git a/src/utils/node-modules/crypto.js b/src/utils/node-modules/crypto.js index 9d9e58ac..0a25aef9 100644 --- a/src/utils/node-modules/crypto.js +++ b/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; \ No newline at end of file +export default crypto; diff --git a/src/utils/node-modules/fs.js b/src/utils/node-modules/fs.js index 6e342aa0..362022c5 100644 --- a/src/utils/node-modules/fs.js +++ b/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; \ No newline at end of file +export default fs; diff --git a/src/utils/node-modules/path.js b/src/utils/node-modules/path.js index bcee136b..a65090b3 100644 --- a/src/utils/node-modules/path.js +++ b/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; \ No newline at end of file +export default path; diff --git a/src/views/AccountViewView.vue b/src/views/AccountViewView.vue index cda77e1c..cdbd437e 100644 --- a/src/views/AccountViewView.vue +++ b/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) { + 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(); diff --git a/src/views/ConfirmGiftView.vue b/src/views/ConfirmGiftView.vue index 8a33cf92..d0f6e3f9 100644 --- a/src/views/ConfirmGiftView.vue +++ b/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 diff --git a/src/views/ContactsView.vue b/src/views/ContactsView.vue index 8d499ed0..52875cee 100644 --- a/src/views/ContactsView.vue +++ b/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( { diff --git a/src/views/HomeView.vue b/src/views/HomeView.vue index 2f080231..54921e23 100644 --- a/src/views/HomeView.vue +++ b/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, ); diff --git a/src/views/IdentitySwitcherView.vue b/src/views/IdentitySwitcherView.vue index 22be027b..3469618a 100644 --- a/src/views/IdentitySwitcherView.vue +++ b/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, ); diff --git a/src/views/InviteOneView.vue b/src/views/InviteOneView.vue index 563bec4b..23d5aa56 100644 --- a/src/views/InviteOneView.vue +++ b/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, ); } diff --git a/src/views/NewActivityView.vue b/src/views/NewActivityView.vue index 0d94edc4..c235a1fc 100644 --- a/src/views/NewActivityView.vue +++ b/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, }); diff --git a/src/views/QuickActionBvcEndView.vue b/src/views/QuickActionBvcEndView.vue index 7bf5f71a..9af71d77 100644 --- a/src/views/QuickActionBvcEndView.vue +++ b/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( diff --git a/src/views/SearchAreaView.vue b/src/views/SearchAreaView.vue index 2f77c660..492b088f 100644 --- a/src/views/SearchAreaView.vue +++ b/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, }); diff --git a/tsconfig.electron.json b/tsconfig.electron.json index a2277a1c..c7cfe162 100644 --- a/tsconfig.electron.json +++ b/tsconfig.electron.json @@ -21,6 +21,7 @@ "include": [ "src/electron/**/*.ts", "src/utils/**/*.ts", - "src/constants/**/*.ts" + "src/constants/**/*.ts", + "src/services/**/*.ts" ] } \ No newline at end of file diff --git a/vite.config.capacitor.mts b/vite.config.capacitor.mts index b47e5abe..e9eed79a 100644 --- a/vite.config.capacitor.mts +++ b/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')); \ No newline at end of file +export default defineConfig( + async () => { + const baseConfig = await createBuildConfig('capacitor'); + return mergeConfig(baseConfig, { + optimizeDeps: { + include: ['@capacitor-community/sqlite'] + } + }); + }); \ No newline at end of file diff --git a/vite.config.electron.mts b/vite.config.electron.mts index cb7342c0..481f9d90 100644 --- a/vite.config.electron.mts +++ b/vite.config.electron.mts @@ -30,7 +30,7 @@ export default defineConfig(async () => { }, }, optimizeDeps: { - include: ['@/utils/logger'] + include: ['@/utils/logger', '@capacitor-community/sqlite'] }, plugins: [ {