Compare commits

..

1 Commits

Author SHA1 Message Date
61f28f9add add hints to implementation instructions 2025-05-25 15:52:18 -06:00
199 changed files with 5627 additions and 11076 deletions

View File

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

View File

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

View File

@@ -2,12 +2,11 @@
# iOS doesn't like spaces in the app title. # iOS doesn't like spaces in the app title.
TIME_SAFARI_APP_TITLE="TimeSafari_Dev" TIME_SAFARI_APP_TITLE="TimeSafari_Dev"
VITE_APP_SERVER=http://localhost:8080 VITE_APP_SERVER=http://localhost:3000
# This is the claim ID for actions in the BVC project, with the JWT ID on this environment (not production). # This is the claim ID for actions in the BVC project, with the JWT ID on this environment (not production).
VITE_BVC_MEETUPS_PROJECT_CLAIM_ID=https://endorser.ch/entity/01HWE8FWHQ1YGP7GFZYYPS272F VITE_BVC_MEETUPS_PROJECT_CLAIM_ID=https://endorser.ch/entity/01HWE8FWHQ1YGP7GFZYYPS272F
VITE_DEFAULT_ENDORSER_API_SERVER=http://localhost:3000 VITE_DEFAULT_ENDORSER_API_SERVER=http://localhost:3000
# Using shared server by default to ease setup, which works for shared test users. # Using shared server by default to ease setup, which works for shared test users.
VITE_DEFAULT_IMAGE_API_SERVER=https://test-image-api.timesafari.app VITE_DEFAULT_IMAGE_API_SERVER=https://test-image-api.timesafari.app
VITE_DEFAULT_PARTNER_API_SERVER=http://localhost:3000 VITE_DEFAULT_PARTNER_API_SERVER=http://localhost:3000
#VITE_DEFAULT_PUSH_SERVER... can't be set up with localhost domain
VITE_PASSKEYS_ENABLED=true VITE_PASSKEYS_ENABLED=true

6
.env.example Normal file
View File

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

View File

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

View File

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

View File

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

5
.gitignore vendored
View File

@@ -51,7 +51,6 @@ vendor/
# Build logs # Build logs
build_logs/ build_logs/
# PWA icon files generated by capacitor-assets android/app/src/main/assets/public
icons android/app/src/main/res

View File

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

View File

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

View File

@@ -31,8 +31,8 @@ android {
applicationId "app.timesafari.app" applicationId "app.timesafari.app"
minSdkVersion rootProject.ext.minSdkVersion minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 25 versionCode 18
versionName "0.5.0" versionName "0.4.7"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions { aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps. // Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
@@ -91,8 +91,6 @@ dependencies {
implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion" implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion"
implementation "androidx.core:core-splashscreen:$coreSplashScreenVersion" implementation "androidx.core:core-splashscreen:$coreSplashScreenVersion"
implementation project(':capacitor-android') implementation project(':capacitor-android')
implementation project(':capacitor-community-sqlite')
implementation "androidx.biometric:biometric:1.2.0-alpha05"
testImplementation "junit:junit:$junitVersion" testImplementation "junit:junit:$junitVersion"
androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion" androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion"
androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion" androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion"

View File

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

View File

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

View File

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

View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

View File

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

BIN
assets/icon-only.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 279 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 MiB

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
# Dexie to absurd-sql Mapping Guide # Dexie to SQLite Mapping Guide
## Schema Mapping ## Schema Mapping
@@ -54,11 +54,10 @@ CREATE INDEX idx_settings_updated_at ON settings(updated_at);
// Dexie // Dexie
const account = await db.accounts.get(did); const account = await db.accounts.get(did);
// absurd-sql // SQLite
const result = await db.exec(` const account = await db.selectOne(`
SELECT * FROM accounts WHERE did = ? SELECT * FROM accounts WHERE did = ?
`, [did]); `, [did]);
const account = result[0]?.values[0];
``` ```
#### Get All Accounts #### Get All Accounts
@@ -66,11 +65,10 @@ const account = result[0]?.values[0];
// Dexie // Dexie
const accounts = await db.accounts.toArray(); const accounts = await db.accounts.toArray();
// absurd-sql // SQLite
const result = await db.exec(` const accounts = await db.selectAll(`
SELECT * FROM accounts ORDER BY created_at DESC SELECT * FROM accounts ORDER BY created_at DESC
`); `);
const accounts = result[0]?.values || [];
``` ```
#### Add Account #### Add Account
@@ -83,8 +81,8 @@ await db.accounts.add({
updatedAt: Date.now() updatedAt: Date.now()
}); });
// absurd-sql // SQLite
await db.run(` await db.execute(`
INSERT INTO accounts (did, public_key_hex, created_at, updated_at) INSERT INTO accounts (did, public_key_hex, created_at, updated_at)
VALUES (?, ?, ?, ?) VALUES (?, ?, ?, ?)
`, [did, publicKeyHex, Date.now(), Date.now()]); `, [did, publicKeyHex, Date.now(), Date.now()]);
@@ -98,8 +96,8 @@ await db.accounts.update(did, {
updatedAt: Date.now() updatedAt: Date.now()
}); });
// absurd-sql // SQLite
await db.run(` await db.execute(`
UPDATE accounts UPDATE accounts
SET public_key_hex = ?, updated_at = ? SET public_key_hex = ?, updated_at = ?
WHERE did = ? WHERE did = ?
@@ -113,11 +111,10 @@ await db.run(`
// Dexie // Dexie
const setting = await db.settings.get(key); const setting = await db.settings.get(key);
// absurd-sql // SQLite
const result = await db.exec(` const setting = await db.selectOne(`
SELECT * FROM settings WHERE key = ? SELECT * FROM settings WHERE key = ?
`, [key]); `, [key]);
const setting = result[0]?.values[0];
``` ```
#### Set Setting #### Set Setting
@@ -129,8 +126,8 @@ await db.settings.put({
updatedAt: Date.now() updatedAt: Date.now()
}); });
// absurd-sql // SQLite
await db.run(` await db.execute(`
INSERT INTO settings (key, value, updated_at) INSERT INTO settings (key, value, updated_at)
VALUES (?, ?, ?) VALUES (?, ?, ?)
ON CONFLICT(key) DO UPDATE SET ON CONFLICT(key) DO UPDATE SET
@@ -149,13 +146,12 @@ const contacts = await db.contacts
.equals(accountDid) .equals(accountDid)
.toArray(); .toArray();
// absurd-sql // SQLite
const result = await db.exec(` const contacts = await db.selectAll(`
SELECT * FROM contacts SELECT * FROM contacts
WHERE did = ? WHERE did = ?
ORDER BY created_at DESC ORDER BY created_at DESC
`, [accountDid]); `, [accountDid]);
const contacts = result[0]?.values || [];
``` ```
#### Add Contact #### Add Contact
@@ -169,8 +165,8 @@ await db.contacts.add({
updatedAt: Date.now() updatedAt: Date.now()
}); });
// absurd-sql // SQLite
await db.run(` await db.execute(`
INSERT INTO contacts (id, did, name, created_at, updated_at) INSERT INTO contacts (id, did, name, created_at, updated_at)
VALUES (?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?)
`, [generateId(), accountDid, name, Date.now(), Date.now()]); `, [generateId(), accountDid, name, Date.now(), Date.now()]);
@@ -186,25 +182,20 @@ await db.transaction('rw', [db.accounts, db.contacts], async () => {
await db.contacts.bulkAdd(contacts); await db.contacts.bulkAdd(contacts);
}); });
// absurd-sql // SQLite
await db.exec('BEGIN TRANSACTION;'); await db.transaction(async (tx) => {
try { await tx.execute(`
await db.run(`
INSERT INTO accounts (did, public_key_hex, created_at, updated_at) INSERT INTO accounts (did, public_key_hex, created_at, updated_at)
VALUES (?, ?, ?, ?) VALUES (?, ?, ?, ?)
`, [account.did, account.publicKeyHex, account.createdAt, account.updatedAt]); `, [account.did, account.publicKeyHex, account.createdAt, account.updatedAt]);
for (const contact of contacts) { for (const contact of contacts) {
await db.run(` await tx.execute(`
INSERT INTO contacts (id, did, name, created_at, updated_at) INSERT INTO contacts (id, did, name, created_at, updated_at)
VALUES (?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?)
`, [contact.id, contact.did, contact.name, contact.createdAt, contact.updatedAt]); `, [contact.id, contact.did, contact.name, contact.createdAt, contact.updatedAt]);
} }
await db.exec('COMMIT;'); });
} catch (error) {
await db.exec('ROLLBACK;');
throw error;
}
``` ```
## Migration Helper Functions ## Migration Helper Functions
@@ -227,14 +218,15 @@ async function exportDexieData(): Promise<MigrationData> {
} }
``` ```
### 2. Data Import (JSON to absurd-sql) ### 2. Data Import (JSON to SQLite)
```typescript ```typescript
async function importToAbsurdSql(data: MigrationData): Promise<void> { async function importToSQLite(data: MigrationData): Promise<void> {
await db.exec('BEGIN TRANSACTION;'); const db = await getSQLiteConnection();
try {
await db.transaction(async (tx) => {
// Import accounts // Import accounts
for (const account of data.accounts) { for (const account of data.accounts) {
await db.run(` await tx.execute(`
INSERT INTO accounts (did, public_key_hex, created_at, updated_at) INSERT INTO accounts (did, public_key_hex, created_at, updated_at)
VALUES (?, ?, ?, ?) VALUES (?, ?, ?, ?)
`, [account.did, account.publicKeyHex, account.createdAt, account.updatedAt]); `, [account.did, account.publicKeyHex, account.createdAt, account.updatedAt]);
@@ -242,7 +234,7 @@ async function importToAbsurdSql(data: MigrationData): Promise<void> {
// Import settings // Import settings
for (const setting of data.settings) { for (const setting of data.settings) {
await db.run(` await tx.execute(`
INSERT INTO settings (key, value, updated_at) INSERT INTO settings (key, value, updated_at)
VALUES (?, ?, ?) VALUES (?, ?, ?)
`, [setting.key, setting.value, setting.updatedAt]); `, [setting.key, setting.value, setting.updatedAt]);
@@ -250,52 +242,52 @@ async function importToAbsurdSql(data: MigrationData): Promise<void> {
// Import contacts // Import contacts
for (const contact of data.contacts) { for (const contact of data.contacts) {
await db.run(` await tx.execute(`
INSERT INTO contacts (id, did, name, created_at, updated_at) INSERT INTO contacts (id, did, name, created_at, updated_at)
VALUES (?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?)
`, [contact.id, contact.did, contact.name, contact.createdAt, contact.updatedAt]); `, [contact.id, contact.did, contact.name, contact.createdAt, contact.updatedAt]);
} }
await db.exec('COMMIT;'); });
} catch (error) {
await db.exec('ROLLBACK;');
throw error;
}
} }
``` ```
### 3. Verification ### 3. Verification
```typescript ```typescript
async function verifyMigration(dexieData: MigrationData): Promise<boolean> { async function verifyMigration(dexieData: MigrationData): Promise<boolean> {
const db = await getSQLiteConnection();
// Verify account count // Verify account count
const accountResult = await db.exec('SELECT COUNT(*) as count FROM accounts'); const accountCount = await db.selectValue(
const accountCount = accountResult[0].values[0][0]; 'SELECT COUNT(*) FROM accounts'
);
if (accountCount !== dexieData.accounts.length) { if (accountCount !== dexieData.accounts.length) {
return false; return false;
} }
// Verify settings count // Verify settings count
const settingsResult = await db.exec('SELECT COUNT(*) as count FROM settings'); const settingsCount = await db.selectValue(
const settingsCount = settingsResult[0].values[0][0]; 'SELECT COUNT(*) FROM settings'
);
if (settingsCount !== dexieData.settings.length) { if (settingsCount !== dexieData.settings.length) {
return false; return false;
} }
// Verify contacts count // Verify contacts count
const contactsResult = await db.exec('SELECT COUNT(*) as count FROM contacts'); const contactsCount = await db.selectValue(
const contactsCount = contactsResult[0].values[0][0]; 'SELECT COUNT(*) FROM contacts'
);
if (contactsCount !== dexieData.contacts.length) { if (contactsCount !== dexieData.contacts.length) {
return false; return false;
} }
// Verify data integrity // Verify data integrity
for (const account of dexieData.accounts) { for (const account of dexieData.accounts) {
const result = await db.exec( const migratedAccount = await db.selectOne(
'SELECT * FROM accounts WHERE did = ?', 'SELECT * FROM accounts WHERE did = ?',
[account.did] [account.did]
); );
const migratedAccount = result[0]?.values[0];
if (!migratedAccount || if (!migratedAccount ||
migratedAccount[1] !== account.publicKeyHex) { // public_key_hex is second column migratedAccount.public_key_hex !== account.publicKeyHex) {
return false; return false;
} }
} }
@@ -308,21 +300,18 @@ async function verifyMigration(dexieData: MigrationData): Promise<boolean> {
### 1. Indexing ### 1. Indexing
- Dexie automatically creates indexes based on the schema - Dexie automatically creates indexes based on the schema
- absurd-sql requires explicit index creation - SQLite requires explicit index creation
- Added indexes for frequently queried fields - Added indexes for frequently queried fields
- Use `PRAGMA journal_mode=MEMORY;` for better performance
### 2. Batch Operations ### 2. Batch Operations
- Dexie has built-in bulk operations - Dexie has built-in bulk operations
- absurd-sql uses transactions for batch operations - SQLite uses transactions for batch operations
- Consider chunking large datasets - Consider chunking large datasets
- Use prepared statements for repeated queries
### 3. Query Optimization ### 3. Query Optimization
- Dexie uses IndexedDB's native indexing - Dexie uses IndexedDB's native indexing
- absurd-sql requires explicit query optimization - SQLite requires explicit query optimization
- Use prepared statements for repeated queries - Use prepared statements for repeated queries
- Consider using `PRAGMA synchronous=NORMAL;` for better performance
## Error Handling ## Error Handling
@@ -337,14 +326,14 @@ try {
} }
} }
// absurd-sql errors // SQLite errors
try { try {
await db.run(` await db.execute(`
INSERT INTO accounts (did, public_key_hex, created_at, updated_at) INSERT INTO accounts (did, public_key_hex, created_at, updated_at)
VALUES (?, ?, ?, ?) VALUES (?, ?, ?, ?)
`, [account.did, account.publicKeyHex, account.createdAt, account.updatedAt]); `, [account.did, account.publicKeyHex, account.createdAt, account.updatedAt]);
} catch (error) { } catch (error) {
if (error.message.includes('UNIQUE constraint failed')) { if (error.code === 'SQLITE_CONSTRAINT') {
// Handle duplicate key // Handle duplicate key
} }
} }
@@ -361,14 +350,15 @@ try {
// Dexie automatically rolls back // Dexie automatically rolls back
} }
// absurd-sql transaction // SQLite transaction
const db = await getSQLiteConnection();
try { try {
await db.exec('BEGIN TRANSACTION;'); await db.transaction(async (tx) => {
// Operations // Operations
await db.exec('COMMIT;'); });
} catch (error) { } catch (error) {
await db.exec('ROLLBACK;'); // SQLite automatically rolls back
throw error; await db.execute('ROLLBACK');
} }
``` ```

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -3,46 +3,45 @@
## Core Services ## Core Services
### 1. Storage Service Layer ### 1. Storage Service Layer
- [x] Create base `PlatformService` interface - [ ] Create base `StorageService` interface
- [x] Define common methods for all platforms - [ ] Define common methods for all platforms
- [x] Add platform-specific method signatures - [ ] Add platform-specific method signatures
- [x] Include error handling types - [ ] Include error handling types
- [x] Add migration support methods - [ ] Add migration support methods
- [x] Implement platform-specific services - [ ] Implement platform-specific services
- [x] `AbsurdSqlDatabaseService` (web) - [ ] `WebSQLiteService` (wa-sqlite)
- [x] Database initialization - [ ] Database initialization
- [x] VFS setup with IndexedDB backend - [ ] VFS setup
- [x] Connection management - [ ] Connection management
- [x] Operation queuing - [ ] Query builder
- [ ] `NativeSQLiteService` (iOS/Android) (planned) - [ ] `NativeSQLiteService` (iOS/Android)
- [ ] SQLCipher integration - [ ] SQLCipher integration
- [ ] Native bridge setup - [ ] Native bridge setup
- [ ] File system access - [ ] File system access
- [ ] `ElectronSQLiteService` (planned) - [ ] `ElectronSQLiteService`
- [ ] Node SQLite integration - [ ] Node SQLite integration
- [ ] IPC communication - [ ] IPC communication
- [ ] File system access - [ ] File system access
### 2. Migration Services ### 2. Migration Services
- [x] Implement basic migration support - [ ] Implement `MigrationService`
- [x] Dual-storage pattern (SQLite + Dexie) - [ ] Backup creation
- [x] Basic data verification - [ ] Data verification
- [ ] Rollback procedures (planned) - [ ] Rollback procedures
- [ ] Progress tracking (planned) - [ ] Progress tracking
- [ ] Create `MigrationUI` components (planned) - [ ] Create `MigrationUI` components
- [ ] Progress indicators - [ ] Progress indicators
- [ ] Error handling - [ ] Error handling
- [ ] User notifications - [ ] User notifications
- [ ] Manual triggers - [ ] Manual triggers
### 3. Security Layer ### 3. Security Layer
- [x] Basic data integrity - [ ] Implement `EncryptionService`
- [ ] Implement `EncryptionService` (planned)
- [ ] Key management - [ ] Key management
- [ ] Encryption/decryption - [ ] Encryption/decryption
- [ ] Secure storage - [ ] Secure storage
- [ ] Add `BiometricService` (planned) - [ ] Add `BiometricService`
- [ ] Platform detection - [ ] Platform detection
- [ ] Authentication flow - [ ] Authentication flow
- [ ] Fallback mechanisms - [ ] Fallback mechanisms
@@ -50,39 +49,31 @@
## Platform-Specific Implementation ## Platform-Specific Implementation
### Web Platform ### Web Platform
- [x] Setup absurd-sql - [ ] Setup wa-sqlite
- [x] Install dependencies - [ ] Install dependencies
```json ```json
{ {
"@jlongster/sql.js": "^1.8.0", "@wa-sqlite/sql.js": "^0.8.12",
"absurd-sql": "^1.8.0" "@wa-sqlite/sql.js-httpvfs": "^0.8.12"
} }
``` ```
- [x] Configure VFS with IndexedDB backend (May not need httpvfs. Here's one recommended install method: `npm add github:rhashimoto/wa-sqlite`)
- [x] Setup worker threads - [ ] Configure VFS for IndexedDB (eg. IDBBatchAtomicVFS)
- [x] Implement operation queuing - [ ] Setup worker threads
- [x] Configure database pragmas - [ ] Implement connection pooling
```sql - [ ] Update build configuration
PRAGMA journal_mode=MEMORY; - [ ] Modify `vite.config.ts`
PRAGMA synchronous=NORMAL; - [ ] Add worker configuration
PRAGMA foreign_keys=ON; - [ ] Update chunk splitting
PRAGMA busy_timeout=5000; - [ ] Configure asset handling
```
- [x] Update build configuration - [ ] Implement IndexedDB fallback
- [x] Modify `vite.config.ts` - [ ] Create fallback service
- [x] Add worker configuration - [ ] Add data synchronization
- [x] Update chunk splitting - [ ] Handle quota exceeded
- [x] Configure asset handling
- [x] Implement IndexedDB backend ### iOS Platform
- [x] Create database service
- [x] Add operation queuing
- [x] Handle initialization
- [x] Implement atomic operations
### iOS Platform (Planned)
- [ ] Setup SQLCipher - [ ] Setup SQLCipher
- [ ] Install pod dependencies - [ ] Install pod dependencies
- [ ] Configure encryption - [ ] Configure encryption
@@ -95,7 +86,7 @@
- [ ] Configure backup - [ ] Configure backup
- [ ] Setup app groups - [ ] Setup app groups
### Android Platform (Planned) ### Android Platform
- [ ] Setup SQLCipher - [ ] Setup SQLCipher
- [ ] Add Gradle dependencies - [ ] Add Gradle dependencies
- [ ] Configure encryption - [ ] Configure encryption
@@ -108,7 +99,7 @@
- [ ] Configure backup - [ ] Configure backup
- [ ] Setup file provider - [ ] Setup file provider
### Electron Platform (Planned) ### Electron Platform
- [ ] Setup Node SQLite - [ ] Setup Node SQLite
- [ ] Install dependencies - [ ] Install dependencies
- [ ] Configure IPC - [ ] Configure IPC
@@ -124,8 +115,7 @@
## Data Models and Types ## Data Models and Types
### 1. Database Schema ### 1. Database Schema
- [x] Define tables - [ ] Define tables
```sql ```sql
-- Accounts table -- Accounts table
CREATE TABLE accounts ( CREATE TABLE accounts (
@@ -151,21 +141,15 @@
updated_at INTEGER NOT NULL, updated_at INTEGER NOT NULL,
FOREIGN KEY (did) REFERENCES accounts(did) FOREIGN KEY (did) REFERENCES accounts(did)
); );
-- Indexes for performance
CREATE INDEX idx_accounts_created_at ON accounts(created_at);
CREATE INDEX idx_contacts_did ON contacts(did);
CREATE INDEX idx_settings_updated_at ON settings(updated_at);
``` ```
- [x] Create indexes - [ ] Create indexes
- [x] Define constraints - [ ] Define constraints
- [ ] Add triggers (planned) - [ ] Add triggers
- [ ] Setup migrations (planned) - [ ] Setup migrations
### 2. Type Definitions ### 2. Type Definitions
- [ ] Create interfaces
- [x] Create interfaces
```typescript ```typescript
interface Account { interface Account {
did: string; did: string;
@@ -189,28 +173,28 @@
} }
``` ```
- [x] Add validation - [ ] Add validation
- [x] Create DTOs - [ ] Create DTOs
- [x] Define enums - [ ] Define enums
- [x] Add type guards - [ ] Add type guards
## UI Components ## UI Components
### 1. Migration UI (Planned) ### 1. Migration UI
- [ ] Create components - [ ] Create components
- [ ] `MigrationProgress.vue` - [ ] `MigrationProgress.vue`
- [ ] `MigrationError.vue` - [ ] `MigrationError.vue`
- [ ] `MigrationSettings.vue` - [ ] `MigrationSettings.vue`
- [ ] `MigrationStatus.vue` - [ ] `MigrationStatus.vue`
### 2. Settings UI (Planned) ### 2. Settings UI
- [ ] Update components - [ ] Update components
- [ ] Add storage settings - [ ] Add storage settings
- [ ] Add migration controls - [ ] Add migration controls
- [ ] Add backup options - [ ] Add backup options
- [ ] Add security settings - [ ] Add security settings
### 3. Error Handling UI (Planned) ### 3. Error Handling UI
- [ ] Create components - [ ] Create components
- [ ] `StorageError.vue` - [ ] `StorageError.vue`
- [ ] `QuotaExceeded.vue` - [ ] `QuotaExceeded.vue`
@@ -220,20 +204,20 @@
## Testing ## Testing
### 1. Unit Tests ### 1. Unit Tests
- [x] Basic service tests - [ ] Test services
- [x] Platform service tests - [ ] Storage service tests
- [x] Database operation tests - [ ] Migration service tests
- [ ] Security service tests (planned) - [ ] Security service tests
- [ ] Platform detection tests (planned) - [ ] Platform detection tests
### 2. Integration Tests (Planned) ### 2. Integration Tests
- [ ] Test migrations - [ ] Test migrations
- [ ] Web platform tests - [ ] Web platform tests
- [ ] iOS platform tests - [ ] iOS platform tests
- [ ] Android platform tests - [ ] Android platform tests
- [ ] Electron platform tests - [ ] Electron platform tests
### 3. E2E Tests (Planned) ### 3. E2E Tests
- [ ] Test workflows - [ ] Test workflows
- [ ] Account management - [ ] Account management
- [ ] Settings management - [ ] Settings management
@@ -243,12 +227,12 @@
## Documentation ## Documentation
### 1. Technical Documentation ### 1. Technical Documentation
- [x] Update architecture docs - [ ] Update architecture docs
- [x] Add API documentation - [ ] Add API documentation
- [ ] Create migration guides (planned) - [ ] Create migration guides
- [ ] Document security measures (planned) - [ ] Document security measures
### 2. User Documentation (Planned) ### 2. User Documentation
- [ ] Update user guides - [ ] Update user guides
- [ ] Add troubleshooting guides - [ ] Add troubleshooting guides
- [ ] Create FAQ - [ ] Create FAQ
@@ -257,18 +241,18 @@
## Deployment ## Deployment
### 1. Build Process ### 1. Build Process
- [x] Update build scripts - [ ] Update build scripts
- [x] Add platform-specific builds - [ ] Add platform-specific builds
- [ ] Configure CI/CD (planned) - [ ] Configure CI/CD
- [ ] Setup automated testing (planned) - [ ] Setup automated testing
### 2. Release Process (Planned) ### 2. Release Process
- [ ] Create release checklist - [ ] Create release checklist
- [ ] Add version management - [ ] Add version management
- [ ] Setup rollback procedures - [ ] Setup rollback procedures
- [ ] Configure monitoring - [ ] Configure monitoring
## Monitoring and Analytics (Planned) ## Monitoring and Analytics
### 1. Error Tracking ### 1. Error Tracking
- [ ] Setup error logging - [ ] Setup error logging
@@ -282,7 +266,7 @@
- [ ] Monitor performance - [ ] Monitor performance
- [ ] Collect user feedback - [ ] Collect user feedback
## Security Audit (Planned) ## Security Audit
### 1. Code Review ### 1. Code Review
- [ ] Review encryption - [ ] Review encryption
@@ -299,31 +283,25 @@
## Success Criteria ## Success Criteria
### 1. Performance ### 1. Performance
- [x] Query response time < 100ms - [ ] Query response time < 100ms
- [x] Operation queuing for thread safety - [ ] Migration time < 5s per 1000 records
- [x] Proper initialization handling - [ ] Storage overhead < 10%
- [ ] Migration time < 5s per 1000 records (planned) - [ ] Memory usage < 50MB
- [ ] Storage overhead < 10% (planned)
- [ ] Memory usage < 50MB (planned)
### 2. Reliability ### 2. Reliability
- [x] Basic data integrity - [ ] 99.9% uptime
- [x] Operation queuing - [ ] Zero data loss
- [ ] Automatic recovery (planned) - [ ] Automatic recovery
- [ ] Backup verification (planned) - [ ] Backup verification
- [ ] Transaction atomicity (planned)
- [ ] Data consistency (planned)
### 3. Security ### 3. Security
- [x] Basic data integrity - [ ] AES-256 encryption
- [ ] AES-256 encryption (planned) - [ ] Secure key storage
- [ ] Secure key storage (planned) - [ ] Access control
- [ ] Access control (planned) - [ ] Audit logging
- [ ] Audit logging (planned)
### 4. User Experience ### 4. User Experience
- [x] Basic database operations - [ ] Smooth migration
- [ ] Smooth migration (planned) - [ ] Clear error messages
- [ ] Clear error messages (planned) - [ ] Progress indicators
- [ ] Progress indicators (planned) - [ ] Recovery options
- [ ] Recovery options (planned)

13
ios/.gitignore vendored
View File

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

View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

View File

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

View File

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

4749
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@@ -1,6 +1,5 @@
eth_keys eth_keys
pywebview pywebview
pyinstaller>=6.12.0 pyinstaller>=6.12.0
setuptools>=69.0.0 # Required for distutils for electron-builder on macOS
# For development # For development
watchdog>=3.0.0 # For file watching support watchdog>=3.0.0 # For file watching support

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,7 +4,7 @@
<!-- Messages in the upper-right - https://github.com/emmanuelsw/notiwind --> <!-- Messages in the upper-right - https://github.com/emmanuelsw/notiwind -->
<NotificationGroup group="alert"> <NotificationGroup group="alert">
<div <div
class="fixed z-[90] top-[max(1rem,env(safe-area-inset-top))] right-4 left-4 sm:left-auto sm:w-full sm:max-w-sm flex flex-col items-start justify-end" class="fixed top-[calc(env(safe-area-inset-top)+1rem)] right-4 left-4 sm:left-auto sm:w-full sm:max-w-sm flex flex-col items-start justify-end"
> >
<Notification <Notification
v-slot="{ notifications, close }" v-slot="{ notifications, close }"
@@ -330,11 +330,8 @@
<script lang="ts"> <script lang="ts">
import { Vue, Component } from "vue-facing-decorator"; import { Vue, Component } from "vue-facing-decorator";
import { logConsoleAndDb, retrieveSettingsForActiveAccount } from "./db/index";
import { NotificationIface, USE_DEXIE_DB } from "./constants/app"; import { NotificationIface } from "./constants/app";
import * as databaseUtil from "./db/databaseUtil";
import { retrieveSettingsForActiveAccount } from "./db/index";
import { logConsoleAndDb } from "./db/databaseUtil";
import { logger } from "./utils/logger"; import { logger } from "./utils/logger";
interface Settings { interface Settings {
@@ -399,11 +396,7 @@ export default class App extends Vue {
try { try {
logger.log("Retrieving settings for the active account..."); logger.log("Retrieving settings for the active account...");
let settings: Settings = const settings: Settings = await retrieveSettingsForActiveAccount();
await databaseUtil.retrieveSettingsForActiveAccount();
if (USE_DEXIE_DB) {
settings = await retrieveSettingsForActiveAccount();
}
logger.log("Retrieved settings:", settings); logger.log("Retrieved settings:", settings);
const notifyingNewActivity = !!settings?.notifyingNewActivityTime; const notifyingNewActivity = !!settings?.notifyingNewActivityTime;
@@ -548,13 +541,13 @@ export default class App extends Vue {
<style> <style>
#Content { #Content {
padding-left: max(1.5rem, env(safe-area-inset-left)); padding-left: 1.5rem;
padding-right: max(1.5rem, env(safe-area-inset-right)); padding-right: 1.5rem;
padding-top: max(1.5rem, env(safe-area-inset-top)); padding-top: calc(env(safe-area-inset-top) + 1.5rem);
padding-bottom: max(1.5rem, env(safe-area-inset-bottom)); padding-bottom: calc(env(safe-area-inset-bottom) + 1.5rem);
} }
#QuickNav ~ #Content { #QuickNav ~ #Content {
padding-bottom: calc(env(safe-area-inset-bottom) + 6.333rem); padding-bottom: calc(env(safe-area-inset-bottom) + 6rem);
} }
</style> </style>

View File

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

View File

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

View File

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

View File

@@ -89,7 +89,7 @@
<script lang="ts"> <script lang="ts">
import { Vue, Component, Prop } from "vue-facing-decorator"; import { Vue, Component, Prop } from "vue-facing-decorator";
import { NotificationIface, USE_DEXIE_DB } from "../constants/app"; import { NotificationIface } from "../constants/app";
import { import {
createAndSubmitGive, createAndSubmitGive,
didInfo, didInfo,
@@ -98,10 +98,8 @@ import {
import * as libsUtil from "../libs/util"; import * as libsUtil from "../libs/util";
import { db, retrieveSettingsForActiveAccount } from "../db/index"; import { db, retrieveSettingsForActiveAccount } from "../db/index";
import { Contact } from "../db/tables/contacts"; import { Contact } from "../db/tables/contacts";
import * as databaseUtil from "../db/databaseUtil";
import { retrieveAccountDids } from "../libs/util"; import { retrieveAccountDids } from "../libs/util";
import { logger } from "../utils/logger"; import { logger } from "../utils/logger";
import { PlatformServiceFactory } from "@/services/PlatformServiceFactory";
@Component @Component
export default class GiftedDialog extends Vue { export default class GiftedDialog extends Vue {
@@ -146,23 +144,11 @@ export default class GiftedDialog extends Vue {
this.offerId = offerId || ""; this.offerId = offerId || "";
try { try {
let settings = await databaseUtil.retrieveSettingsForActiveAccount(); const settings = await retrieveSettingsForActiveAccount();
if (USE_DEXIE_DB) {
settings = await retrieveSettingsForActiveAccount();
}
this.apiServer = settings.apiServer || ""; this.apiServer = settings.apiServer || "";
this.activeDid = settings.activeDid || ""; this.activeDid = settings.activeDid || "";
const platformService = PlatformServiceFactory.getInstance();
const result = await platformService.dbQuery(`SELECT * FROM contacts`);
if (result) {
this.allContacts = databaseUtil.mapQueryResultToValues(
result,
) as unknown as Contact[];
}
if (USE_DEXIE_DB) {
this.allContacts = await db.contacts.toArray(); this.allContacts = await db.contacts.toArray();
}
this.allMyDids = await retrieveAccountDids(); this.allMyDids = await retrieveAccountDids();
@@ -320,7 +306,10 @@ export default class GiftedDialog extends Vue {
this.fromProjectId, this.fromProjectId,
); );
if (!result.success) { if (
result.type === "error" ||
this.isGiveCreationError(result.response)
) {
const errorMessage = this.getGiveCreationErrorMessage(result); const errorMessage = this.getGiveCreationErrorMessage(result);
logger.error("Error with give creation result:", result); logger.error("Error with give creation result:", result);
this.$notify( this.$notify(
@@ -367,6 +356,15 @@ export default class GiftedDialog extends Vue {
// Helper functions for readability // Helper functions for readability
/**
* @param result response "data" from the server
* @returns true if the result indicates an error
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
isGiveCreationError(result: any) {
return result.status !== 201 || result.data?.error;
}
/** /**
* @param result direct response eg. ErrorResult or SuccessResult (potentially with embedded "data") * @param result direct response eg. ErrorResult or SuccessResult (potentially with embedded "data")
* @returns best guess at an error message * @returns best guess at an error message

View File

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

View File

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

View File

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

View File

@@ -172,10 +172,8 @@ import {
} from "../libs/endorserServer"; } from "../libs/endorserServer";
import { decryptMessage } from "../libs/crypto"; import { decryptMessage } from "../libs/crypto";
import { Contact } from "../db/tables/contacts"; import { Contact } from "../db/tables/contacts";
import * as databaseUtil from "../db/databaseUtil";
import * as libsUtil from "../libs/util"; import * as libsUtil from "../libs/util";
import { NotificationIface, USE_DEXIE_DB } from "../constants/app"; import { NotificationIface } from "../constants/app";
import { PlatformServiceFactory } from "@/services/PlatformServiceFactory";
interface Member { interface Member {
admitted: boolean; admitted: boolean;
@@ -211,10 +209,7 @@ export default class MembersList extends Vue {
contacts: Array<Contact> = []; contacts: Array<Contact> = [];
async created() { async created() {
let settings = await databaseUtil.retrieveSettingsForActiveAccount(); const settings = await retrieveSettingsForActiveAccount();
if (USE_DEXIE_DB) {
settings = await retrieveSettingsForActiveAccount();
}
this.activeDid = settings.activeDid || ""; this.activeDid = settings.activeDid || "";
this.apiServer = settings.apiServer || ""; this.apiServer = settings.apiServer || "";
this.firstName = settings.firstName || ""; this.firstName = settings.firstName || "";
@@ -301,7 +296,7 @@ export default class MembersList extends Vue {
this.decryptedMembers.length === 0 || this.decryptedMembers.length === 0 ||
this.decryptedMembers[0].member.memberId !== this.members[0].memberId this.decryptedMembers[0].member.memberId !== this.members[0].memberId
) { ) {
return "Your password is not the same as the organizer. Retry or have them check their password."; return "Your password is not the same as the organizer. Reload or have them check their password.";
} else { } else {
// the first (organizer) member was decrypted OK // the first (organizer) member was decrypted OK
return ""; return "";
@@ -342,7 +337,7 @@ export default class MembersList extends Vue {
group: "alert", group: "alert",
type: "info", type: "info",
title: "Contact Exists", title: "Contact Exists",
text: "They are in your contacts. To remove them, use the contacts page.", text: "They are in your contacts. If you want to remove them, you must do that from the contacts screen.",
}, },
10000, 10000,
); );
@@ -352,7 +347,7 @@ export default class MembersList extends Vue {
group: "alert", group: "alert",
type: "info", type: "info",
title: "Contact Available", title: "Contact Available",
text: "This is to add them to your contacts. To remove them later, use the contacts page.", text: "This is to add them to your contacts. If you want to remove them later, you must do that from the contacts screen.",
}, },
10000, 10000,
); );
@@ -360,17 +355,8 @@ export default class MembersList extends Vue {
} }
async loadContacts() { async loadContacts() {
const platformService = PlatformServiceFactory.getInstance();
const result = await platformService.dbQuery("SELECT * FROM contacts");
if (result) {
this.contacts = databaseUtil.mapQueryResultToValues(
result,
) as unknown as Contact[];
}
if (USE_DEXIE_DB) {
this.contacts = await db.contacts.toArray(); this.contacts = await db.contacts.toArray();
} }
}
getContactFor(did: string): Contact | undefined { getContactFor(did: string): Contact | undefined {
return this.contacts.find((contact) => contact.did === did); return this.contacts.find((contact) => contact.did === did);
@@ -453,14 +439,7 @@ export default class MembersList extends Vue {
if (result.success) { if (result.success) {
decrMember.isRegistered = true; decrMember.isRegistered = true;
if (oldContact) { if (oldContact) {
const platformService = PlatformServiceFactory.getInstance();
await platformService.dbExec(
"UPDATE contacts SET registered = ? WHERE did = ?",
[true, decrMember.did],
);
if (USE_DEXIE_DB) {
await db.contacts.update(decrMember.did, { registered: true }); await db.contacts.update(decrMember.did, { registered: true });
}
oldContact.registered = true; oldContact.registered = true;
} }
this.$notify( this.$notify(
@@ -513,14 +492,7 @@ export default class MembersList extends Vue {
name: member.name, name: member.name,
}; };
const platformService = PlatformServiceFactory.getInstance();
await platformService.dbExec(
"INSERT INTO contacts (did, name) VALUES (?, ?)",
[member.did, member.name],
);
if (USE_DEXIE_DB) {
await db.contacts.add(newContact); await db.contacts.add(newContact);
}
this.contacts.push(newContact); this.contacts.push(newContact);
this.$notify( this.$notify(

View File

@@ -82,13 +82,12 @@
<script lang="ts"> <script lang="ts">
import { Vue, Component, Prop } from "vue-facing-decorator"; import { Vue, Component, Prop } from "vue-facing-decorator";
import { NotificationIface, USE_DEXIE_DB } from "../constants/app"; import { NotificationIface } from "../constants/app";
import { import {
createAndSubmitOffer, createAndSubmitOffer,
serverMessageForUser, serverMessageForUser,
} from "../libs/endorserServer"; } from "../libs/endorserServer";
import * as libsUtil from "../libs/util"; import * as libsUtil from "../libs/util";
import * as databaseUtil from "../db/databaseUtil";
import { retrieveSettingsForActiveAccount } from "../db/index"; import { retrieveSettingsForActiveAccount } from "../db/index";
import { logger } from "../utils/logger"; import { logger } from "../utils/logger";
@@ -117,10 +116,7 @@ export default class OfferDialog extends Vue {
this.recipientDid = recipientDid; this.recipientDid = recipientDid;
this.recipientName = recipientName; this.recipientName = recipientName;
let settings = await databaseUtil.retrieveSettingsForActiveAccount(); const settings = await retrieveSettingsForActiveAccount();
if (USE_DEXIE_DB) {
settings = await retrieveSettingsForActiveAccount();
}
this.apiServer = settings.apiServer || ""; this.apiServer = settings.apiServer || "";
this.activeDid = settings.activeDid || ""; this.activeDid = settings.activeDid || "";
@@ -249,7 +245,10 @@ export default class OfferDialog extends Vue {
this.projectId, this.projectId,
); );
if (!result.success) { if (
result.type === "error" ||
this.isOfferCreationError(result.response)
) {
const errorMessage = this.getOfferCreationErrorMessage(result); const errorMessage = this.getOfferCreationErrorMessage(result);
logger.error("Error with offer creation result:", result); logger.error("Error with offer creation result:", result);
this.$notify( this.$notify(
@@ -293,6 +292,15 @@ export default class OfferDialog extends Vue {
// Helper functions for readability // Helper functions for readability
/**
* @param result response "data" from the server
* @returns true if the result indicates an error
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
isOfferCreationError(result: any) {
return result.status !== 201 || result.data?.error;
}
/** /**
* @param result direct response eg. ErrorResult or SuccessResult (potentially with embedded "data") * @param result direct response eg. ErrorResult or SuccessResult (potentially with embedded "data")
* @returns best guess at an error message * @returns best guess at an error message

View File

@@ -5,7 +5,7 @@
<h1 class="text-xl font-bold text-center mb-4 relative"> <h1 class="text-xl font-bold text-center mb-4 relative">
Welcome to Time Safari Welcome to Time Safari
<br /> <br />
- Showcase Impact & Magnify Time - Showcasing Gratitude & Magnifying Time
<div <div
class="text-lg text-center leading-none absolute right-0 -top-1" class="text-lg text-center leading-none absolute right-0 -top-1"
@click="onClickClose(true)" @click="onClickClose(true)"
@@ -14,9 +14,6 @@
</div> </div>
</h1> </h1>
The feed underneath this pop-up shows the latest contributions, some from
people and some from projects.
<p v-if="isRegistered" class="mt-4"> <p v-if="isRegistered" class="mt-4">
You can now log things that you've seen: You can now log things that you've seen:
<span v-if="numContacts > 0"> <span v-if="numContacts > 0">
@@ -26,10 +23,14 @@
<span class="bg-green-600 text-white rounded-full"> <span class="bg-green-600 text-white rounded-full">
<font-awesome icon="plus" class="fa-fw" /> <font-awesome icon="plus" class="fa-fw" />
</span> </span>
button to express your appreciation for... whatever. button to express your appreciation for... whatever -- maybe thanks for
showing you all these fascinating stories of
<em>gratitude</em>.
</p> </p>
<p class="mt-4"> <p v-else class="mt-4">
Once someone registers you, you can log your appreciation, too. The feed underneath this pop-up shows the latest gifts that others have
recognized. Once someone registers you, you can log your appreciation,
too.
</p> </p>
<p class="mt-4"> <p class="mt-4">
@@ -200,16 +201,13 @@
import { Component, Vue } from "vue-facing-decorator"; import { Component, Vue } from "vue-facing-decorator";
import { Router } from "vue-router"; import { Router } from "vue-router";
import { NotificationIface, USE_DEXIE_DB } from "../constants/app"; import { NotificationIface } from "../constants/app";
import { import {
db, db,
retrieveSettingsForActiveAccount, retrieveSettingsForActiveAccount,
updateAccountSettings, updateAccountSettings,
} from "../db/index"; } from "../db/index";
import * as databaseUtil from "../db/databaseUtil";
import { OnboardPage } from "../libs/util"; import { OnboardPage } from "../libs/util";
import { PlatformServiceFactory } from "@/services/PlatformServiceFactory";
import { Contact } from "@/db/tables/contacts";
@Component({ @Component({
computed: { computed: {
@@ -224,7 +222,7 @@ export default class OnboardingDialog extends Vue {
$router!: Router; $router!: Router;
activeDid = ""; activeDid = "";
firstContactName = ""; firstContactName = null;
givenName = ""; givenName = "";
isRegistered = false; isRegistered = false;
numContacts = 0; numContacts = 0;
@@ -233,54 +231,29 @@ export default class OnboardingDialog extends Vue {
async open(page: OnboardPage) { async open(page: OnboardPage) {
this.page = page; this.page = page;
let settings = await databaseUtil.retrieveSettingsForActiveAccount(); const settings = await retrieveSettingsForActiveAccount();
if (USE_DEXIE_DB) {
settings = await retrieveSettingsForActiveAccount();
}
this.activeDid = settings.activeDid || ""; this.activeDid = settings.activeDid || "";
this.isRegistered = !!settings.isRegistered; this.isRegistered = !!settings.isRegistered;
const platformService = PlatformServiceFactory.getInstance();
const dbContacts = await platformService.dbQuery("SELECT * FROM contacts");
if (dbContacts) {
this.numContacts = dbContacts.values.length;
const firstContact = dbContacts.values[0];
const fullContact = databaseUtil.mapColumnsToValues(dbContacts.columns, [
firstContact,
]) as unknown as Contact;
this.firstContactName = fullContact.name || "";
}
if (USE_DEXIE_DB) {
const contacts = await db.contacts.toArray(); const contacts = await db.contacts.toArray();
this.numContacts = contacts.length; this.numContacts = contacts.length;
if (this.numContacts > 0) { if (this.numContacts > 0) {
this.firstContactName = contacts[0].name || ""; this.firstContactName = contacts[0].name;
}
} }
this.visible = true; this.visible = true;
if (this.page === OnboardPage.Create) { if (this.page === OnboardPage.Create) {
// we'll assume that they've been through all the other pages // we'll assume that they've been through all the other pages
await databaseUtil.updateAccountSettings(this.activeDid, {
finishedOnboarding: true,
});
if (USE_DEXIE_DB) {
await updateAccountSettings(this.activeDid, { await updateAccountSettings(this.activeDid, {
finishedOnboarding: true, finishedOnboarding: true,
}); });
} }
} }
}
async onClickClose(done?: boolean, goHome?: boolean) { async onClickClose(done?: boolean, goHome?: boolean) {
this.visible = false; this.visible = false;
if (done) { if (done) {
await databaseUtil.updateAccountSettings(this.activeDid, {
finishedOnboarding: true,
});
if (USE_DEXIE_DB) {
await updateAccountSettings(this.activeDid, { await updateAccountSettings(this.activeDid, {
finishedOnboarding: true, finishedOnboarding: true,
}); });
}
if (goHome) { if (goHome) {
this.$router.push({ name: "home" }); this.$router.push({ name: "home" });
} }

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
<template> <template>
<div class="absolute right-5 top-[max(0.75rem,env(safe-area-inset-top))]"> <div class="absolute right-5 top-[calc(env(safe-area-inset-top)+0.75rem)]">
<span class="align-center text-red-500 mr-2">{{ message }}</span> <span class="align-center text-red-500 mr-2">{{ message }}</span>
<span class="ml-2"> <span class="ml-2">
<router-link <router-link
@@ -15,8 +15,7 @@
<script lang="ts"> <script lang="ts">
import { Component, Vue, Prop } from "vue-facing-decorator"; import { Component, Vue, Prop } from "vue-facing-decorator";
import { AppString, NotificationIface, USE_DEXIE_DB } from "../constants/app"; import { AppString, NotificationIface } from "../constants/app";
import * as databaseUtil from "../db/databaseUtil";
import { retrieveSettingsForActiveAccount } from "../db/index"; import { retrieveSettingsForActiveAccount } from "../db/index";
@Component @Component
@@ -29,10 +28,7 @@ export default class TopMessage extends Vue {
async mounted() { async mounted() {
try { try {
let settings = await databaseUtil.retrieveSettingsForActiveAccount(); const settings = await retrieveSettingsForActiveAccount();
if (USE_DEXIE_DB) {
settings = await retrieveSettingsForActiveAccount();
}
if ( if (
settings.warnIfTestServer && settings.warnIfTestServer &&
settings.apiServer !== AppString.PROD_ENDORSER_API_SERVER settings.apiServer !== AppString.PROD_ENDORSER_API_SERVER

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,9 +1,3 @@
/**
* This is the original IndexedDB version of the database.
* It will eventually be replaced fully by the SQL version in databaseUtil.ts.
* Turn this on or off with the USE_DEXIE_DB constant in constants/app.ts.
*/
import BaseDexie, { Table } from "dexie"; import BaseDexie, { Table } from "dexie";
import { encrypted, Encryption } from "@pvermeer/dexie-encrypted-addon"; import { encrypted, Encryption } from "@pvermeer/dexie-encrypted-addon";
import * as R from "ramda"; import * as R from "ramda";
@@ -32,8 +26,8 @@ type NonsensitiveTables = {
}; };
// Using 'unknown' instead of 'any' for stricter typing and to avoid TypeScript warnings // Using 'unknown' instead of 'any' for stricter typing and to avoid TypeScript warnings
type SecretDexie<T extends unknown = SecretTable> = BaseDexie & T; export type SecretDexie<T extends unknown = SecretTable> = BaseDexie & T;
type SensitiveDexie<T extends unknown = SensitiveTables> = BaseDexie & T; export type SensitiveDexie<T extends unknown = SensitiveTables> = BaseDexie & T;
export type NonsensitiveDexie<T extends unknown = NonsensitiveTables> = export type NonsensitiveDexie<T extends unknown = NonsensitiveTables> =
BaseDexie & T; BaseDexie & T;
@@ -96,40 +90,40 @@ db.on("populate", async () => {
try { try {
await db.settings.add(DEFAULT_SETTINGS); await db.settings.add(DEFAULT_SETTINGS);
} catch (error) { } catch (error) {
logger.error("Error populating the database with default settings:", error); console.error("Error populating the database with default settings:", error);
} }
}); });
// Helper function to safely open the database with retries // Helper function to safely open the database with retries
async function safeOpenDatabase(retries = 1, delay = 500): Promise<void> { async function safeOpenDatabase(retries = 1, delay = 500): Promise<void> {
// logger.log("Starting safeOpenDatabase with retries:", retries); // console.log("Starting safeOpenDatabase with retries:", retries);
for (let i = 0; i < retries; i++) { for (let i = 0; i < retries; i++) {
try { try {
// logger.log(`Attempt ${i + 1}: Checking if database is open...`); // console.log(`Attempt ${i + 1}: Checking if database is open...`);
if (!db.isOpen()) { if (!db.isOpen()) {
// logger.log(`Attempt ${i + 1}: Database is closed, attempting to open...`); // console.log(`Attempt ${i + 1}: Database is closed, attempting to open...`);
// Create a promise that rejects after 5 seconds // Create a promise that rejects after 5 seconds
const timeoutPromise = new Promise((_, reject) => { const timeoutPromise = new Promise((_, reject) => {
setTimeout(() => reject(new Error("Database open timed out")), 500); setTimeout(() => reject(new Error('Database open timed out')), 500);
}); });
// Race between the open operation and the timeout // Race between the open operation and the timeout
const openPromise = db.open(); const openPromise = db.open();
// logger.log(`Attempt ${i + 1}: Waiting for db.open() promise...`); // console.log(`Attempt ${i + 1}: Waiting for db.open() promise...`);
await Promise.race([openPromise, timeoutPromise]); await Promise.race([openPromise, timeoutPromise]);
// If we get here, the open succeeded // If we get here, the open succeeded
// logger.log(`Attempt ${i + 1}: Database opened successfully`); // console.log(`Attempt ${i + 1}: Database opened successfully`);
return; return;
} }
// logger.log(`Attempt ${i + 1}: Database was already open`); // console.log(`Attempt ${i + 1}: Database was already open`);
return; return;
} catch (error) { } catch (error) {
logger.error(`Attempt ${i + 1}: Database open failed:`, error); console.error(`Attempt ${i + 1}: Database open failed:`, error);
if (i < retries - 1) { if (i < retries - 1) {
logger.log(`Attempt ${i + 1}: Waiting ${delay}ms before retry...`); console.log(`Attempt ${i + 1}: Waiting ${delay}ms before retry...`);
await new Promise((resolve) => setTimeout(resolve, delay)); await new Promise(resolve => setTimeout(resolve, delay));
} else { } else {
throw error; throw error;
} }
@@ -145,29 +139,23 @@ export async function updateDefaultSettings(
delete settingsChanges.id; delete settingsChanges.id;
try { try {
try { try {
// logger.log("Database state before open:", db.isOpen() ? "open" : "closed"); // console.log("Database state before open:", db.isOpen() ? "open" : "closed");
// logger.log("Database name:", db.name); // console.log("Database name:", db.name);
// logger.log("Database version:", db.verno); // console.log("Database version:", db.verno);
await safeOpenDatabase(); await safeOpenDatabase();
} catch (openError: unknown) { } catch (openError: unknown) {
logger.error("Failed to open database:", openError, String(openError)); console.error("Failed to open database:", openError);
throw new Error( const errorMessage = openError instanceof Error ? openError.message : String(openError);
`The database connection failed. We recommend you try again or restart the app.`, throw new Error(`Database connection failed: ${errorMessage}. Please try again or restart the app.`);
);
} }
const result = await db.settings.update( const result = await db.settings.update(MASTER_SETTINGS_KEY, settingsChanges);
MASTER_SETTINGS_KEY,
settingsChanges,
);
return result; return result;
} catch (error) { } catch (error) {
logger.error("Error updating default settings:", error); console.error("Error updating default settings:", error);
if (error instanceof Error) { if (error instanceof Error) {
throw error; // Re-throw if it's already an Error with a message throw error; // Re-throw if it's already an Error with a message
} else { } else {
throw new Error( throw new Error(`Failed to update settings: ${error}`);
`Failed to update settings. We recommend you try again or restart the app.`,
);
} }
} }
} }
@@ -265,43 +253,6 @@ export async function retrieveSettingsForActiveAccount(): Promise<Settings> {
} }
} }
/**
* Retrieves settings for a specific account by DID
* @param accountDid - The DID of the account to retrieve settings for
* @returns Promise<Settings> Combined settings for the specified account
*/
export async function getSettingsForAccount(accountDid: string): Promise<Settings> {
const defaultSettings = await retrieveSettingsForDefaultAccount();
const overrideSettings =
(await db.settings
.where("accountDid")
.equals(accountDid)
.first()) || {};
return R.mergeDeepRight(defaultSettings, overrideSettings);
}
/**
* Retrieves only account-specific settings for a given DID (without merging with defaults)
* @param accountDid - The DID of the account to retrieve settings for
* @returns Promise<Settings> Account-specific settings only
*/
export async function getAccountSpecificSettings(accountDid: string): Promise<Settings> {
return (await db.settings
.where("accountDid")
.equals(accountDid)
.first()) || {};
}
/**
* Merges default settings with account-specific settings using consistent logic
* @param defaultSettings - The default/master settings
* @param accountSettings - The account-specific settings to merge
* @returns Settings - Merged settings with account-specific overrides
*/
export function mergeSettings(defaultSettings: Settings, accountSettings: Settings): Settings {
return R.mergeDeepRight(defaultSettings, accountSettings);
}
export async function updateAccountSettings( export async function updateAccountSettings(
accountDid: string, accountDid: string,
settingsChanges: Settings, settingsChanges: Settings,

293
src/db/sqlite/init.ts Normal file
View File

@@ -0,0 +1,293 @@
/**
* SQLite Database Initialization
*
* This module handles database initialization, including:
* - Database connection management
* - Schema creation and migration
* - Connection pooling and lifecycle
* - Error handling and recovery
*/
import { Database, SQLite3 } from '@wa-sqlite/sql.js';
import { DATABASE_SCHEMA, SQLiteTable } from './types';
import { logger } from '../../utils/logger';
// ============================================================================
// Database Connection Management
// ============================================================================
export interface DatabaseConnection {
db: Database;
sqlite3: SQLite3;
isOpen: boolean;
lastUsed: number;
}
let connection: DatabaseConnection | null = null;
const CONNECTION_TIMEOUT = 5 * 60 * 1000; // 5 minutes
/**
* Initialize the SQLite database connection
*/
export async function initDatabase(): Promise<DatabaseConnection> {
if (connection?.isOpen) {
connection.lastUsed = Date.now();
return connection;
}
try {
const sqlite3 = await import('@wa-sqlite/sql.js');
const db = await sqlite3.open(':memory:'); // TODO: Configure storage location
// Enable foreign keys
await db.exec('PRAGMA foreign_keys = ON;');
// Configure for better performance
await db.exec(`
PRAGMA journal_mode = WAL;
PRAGMA synchronous = NORMAL;
PRAGMA cache_size = -2000; -- Use 2MB of cache
`);
connection = {
db,
sqlite3,
isOpen: true,
lastUsed: Date.now()
};
// Start connection cleanup interval
startConnectionCleanup();
return connection;
} catch (error) {
logger.error('[SQLite] Database initialization failed:', error);
throw new Error('Failed to initialize database');
}
}
/**
* Close the database connection
*/
export async function closeDatabase(): Promise<void> {
if (!connection?.isOpen) return;
try {
await connection.db.close();
connection.isOpen = false;
connection = null;
} catch (error) {
logger.error('[SQLite] Database close failed:', error);
throw new Error('Failed to close database');
}
}
/**
* Cleanup inactive connections
*/
function startConnectionCleanup(): void {
setInterval(() => {
if (connection && Date.now() - connection.lastUsed > CONNECTION_TIMEOUT) {
closeDatabase().catch(error => {
logger.error('[SQLite] Connection cleanup failed:', error);
});
}
}, 60000); // Check every minute
}
// ============================================================================
// Schema Management
// ============================================================================
/**
* Create the database schema
*/
export async function createSchema(): Promise<void> {
const { db } = await initDatabase();
try {
await db.transaction(async () => {
for (const table of DATABASE_SCHEMA) {
await createTable(db, table);
}
});
} catch (error) {
logger.error('[SQLite] Schema creation failed:', error);
throw new Error('Failed to create database schema');
}
}
/**
* Create a single table
*/
async function createTable(db: Database, table: SQLiteTable): Promise<void> {
const columnDefs = table.columns.map(col => {
const constraints = [
col.primaryKey ? 'PRIMARY KEY' : '',
col.unique ? 'UNIQUE' : '',
!col.nullable ? 'NOT NULL' : '',
col.references ? `REFERENCES ${col.references.table}(${col.references.column})` : '',
col.default !== undefined ? `DEFAULT ${formatDefaultValue(col.default)}` : ''
].filter(Boolean).join(' ');
return `${col.name} ${col.type} ${constraints}`.trim();
});
const createTableSQL = `
CREATE TABLE IF NOT EXISTS ${table.name} (
${columnDefs.join(',\n ')}
);
`;
await db.exec(createTableSQL);
// Create indexes
if (table.indexes) {
for (const index of table.indexes) {
const createIndexSQL = `
CREATE INDEX IF NOT EXISTS ${index.name}
ON ${table.name} (${index.columns.join(', ')})
${index.unique ? 'UNIQUE' : ''};
`;
await db.exec(createIndexSQL);
}
}
}
/**
* Format default value for SQL
*/
function formatDefaultValue(value: unknown): string {
if (value === null) return 'NULL';
if (typeof value === 'string') return `'${value.replace(/'/g, "''")}'`;
if (typeof value === 'number') return value.toString();
if (typeof value === 'boolean') return value ? '1' : '0';
throw new Error(`Unsupported default value type: ${typeof value}`);
}
// ============================================================================
// Database Health Checks
// ============================================================================
/**
* Check database health
*/
export async function checkDatabaseHealth(): Promise<{
isHealthy: boolean;
tables: string[];
error?: string;
}> {
try {
const { db } = await initDatabase();
// Check if we can query the database
const tables = await db.selectAll<{ name: string }>(`
SELECT name FROM sqlite_master
WHERE type='table' AND name NOT LIKE 'sqlite_%'
`);
return {
isHealthy: true,
tables: tables.map(t => t.name)
};
} catch (error) {
logger.error('[SQLite] Health check failed:', error);
return {
isHealthy: false,
tables: [],
error: error instanceof Error ? error.message : 'Unknown error'
};
}
}
/**
* Verify database integrity
*/
export async function verifyDatabaseIntegrity(): Promise<{
isIntegrityOk: boolean;
errors: string[];
}> {
const { db } = await initDatabase();
const errors: string[] = [];
try {
// Run integrity check
const result = await db.selectAll<{ integrity_check: string }>('PRAGMA integrity_check;');
if (result[0]?.integrity_check !== 'ok') {
errors.push('Database integrity check failed');
}
// Check foreign key constraints
const fkResult = await db.selectAll<{ table: string; rowid: number; parent: string; fkid: number }>(`
PRAGMA foreign_key_check;
`);
if (fkResult.length > 0) {
errors.push('Foreign key constraint violations found');
}
return {
isIntegrityOk: errors.length === 0,
errors
};
} catch (error) {
logger.error('[SQLite] Integrity check failed:', error);
return {
isIntegrityOk: false,
errors: [error instanceof Error ? error.message : 'Unknown error']
};
}
}
// ============================================================================
// Database Backup and Recovery
// ============================================================================
/**
* Create a database backup
*/
export async function createBackup(): Promise<Uint8Array> {
const { db } = await initDatabase();
try {
// Export the database to a binary array
return await db.export();
} catch (error) {
logger.error('[SQLite] Backup creation failed:', error);
throw new Error('Failed to create database backup');
}
}
/**
* Restore database from backup
*/
export async function restoreFromBackup(backup: Uint8Array): Promise<void> {
const { db } = await initDatabase();
try {
// Close current connection
await closeDatabase();
// Create new connection and import backup
const sqlite3 = await import('@wa-sqlite/sql.js');
const newDb = await sqlite3.open(backup);
// Verify integrity
const { isIntegrityOk, errors } = await verifyDatabaseIntegrity();
if (!isIntegrityOk) {
throw new Error(`Backup integrity check failed: ${errors.join(', ')}`);
}
// Replace current connection
connection = {
db: newDb,
sqlite3,
isOpen: true,
lastUsed: Date.now()
};
} catch (error) {
logger.error('[SQLite] Backup restoration failed:', error);
throw new Error('Failed to restore database from backup');
}
}

374
src/db/sqlite/migration.ts Normal file
View File

@@ -0,0 +1,374 @@
/**
* SQLite Migration Utilities
*
* This module handles the migration of data from Dexie to SQLite,
* including data transformation, validation, and rollback capabilities.
*/
import { Database } from '@wa-sqlite/sql.js';
import { initDatabase, createSchema, createBackup } from './init';
import {
MigrationData,
MigrationResult,
SQLiteAccount,
SQLiteContact,
SQLiteContactMethod,
SQLiteSettings,
SQLiteLog,
SQLiteSecret,
isSQLiteAccount,
isSQLiteContact,
isSQLiteSettings
} from './types';
import { logger } from '../../utils/logger';
// ============================================================================
// Migration Types
// ============================================================================
interface MigrationContext {
db: Database;
startTime: number;
stats: MigrationResult['stats'];
errors: Error[];
}
// ============================================================================
// Migration Functions
// ============================================================================
/**
* Migrate data from Dexie to SQLite
*/
export async function migrateFromDexie(data: MigrationData): Promise<MigrationResult> {
const startTime = Date.now();
const context: MigrationContext = {
db: (await initDatabase()).db,
startTime,
stats: {
accounts: 0,
contacts: 0,
contactMethods: 0,
settings: 0,
logs: 0,
secrets: 0
},
errors: []
};
try {
// Create backup before migration
const backup = await createBackup();
// Create schema if needed
await createSchema();
// Perform migration in a transaction
await context.db.transaction(async () => {
// Migrate in order of dependencies
await migrateAccounts(context, data.accounts);
await migrateContacts(context, data.contacts);
await migrateContactMethods(context, data.contactMethods);
await migrateSettings(context, data.settings);
await migrateLogs(context, data.logs);
await migrateSecrets(context, data.secrets);
});
// Verify migration
const verificationResult = await verifyMigration(context, data);
if (!verificationResult.success) {
throw new Error(`Migration verification failed: ${verificationResult.error}`);
}
return {
success: true,
stats: context.stats,
duration: Date.now() - startTime
};
} catch (error) {
logger.error('[SQLite] Migration failed:', error);
// Attempt rollback
try {
await rollbackMigration(backup);
} catch (rollbackError) {
logger.error('[SQLite] Rollback failed:', rollbackError);
context.errors.push(new Error('Migration and rollback failed'));
}
return {
success: false,
error: error instanceof Error ? error : new Error('Unknown migration error'),
stats: context.stats,
duration: Date.now() - startTime
};
}
}
// ============================================================================
// Migration Helpers
// ============================================================================
/**
* Migrate accounts
*/
async function migrateAccounts(context: MigrationContext, accounts: SQLiteAccount[]): Promise<void> {
for (const account of accounts) {
try {
if (!isSQLiteAccount(account)) {
throw new Error(`Invalid account data: ${JSON.stringify(account)}`);
}
await context.db.exec(`
INSERT INTO accounts (
did, public_key_hex, created_at, updated_at,
identity_json, mnemonic_encrypted, passkey_cred_id_hex, derivation_path
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`, [
account.did,
account.public_key_hex,
account.created_at,
account.updated_at,
account.identity_json || null,
account.mnemonic_encrypted || null,
account.passkey_cred_id_hex || null,
account.derivation_path || null
]);
context.stats.accounts++;
} catch (error) {
context.errors.push(new Error(`Failed to migrate account ${account.did}: ${error}`));
throw error; // Re-throw to trigger transaction rollback
}
}
}
/**
* Migrate contacts
*/
async function migrateContacts(context: MigrationContext, contacts: SQLiteContact[]): Promise<void> {
for (const contact of contacts) {
try {
if (!isSQLiteContact(contact)) {
throw new Error(`Invalid contact data: ${JSON.stringify(contact)}`);
}
await context.db.exec(`
INSERT INTO contacts (
id, did, name, notes, profile_image_url,
public_key_base64, next_pub_key_hash_b64,
sees_me, registered, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, [
contact.id,
contact.did,
contact.name || null,
contact.notes || null,
contact.profile_image_url || null,
contact.public_key_base64 || null,
contact.next_pub_key_hash_b64 || null,
contact.sees_me ? 1 : 0,
contact.registered ? 1 : 0,
contact.created_at,
contact.updated_at
]);
context.stats.contacts++;
} catch (error) {
context.errors.push(new Error(`Failed to migrate contact ${contact.id}: ${error}`));
throw error;
}
}
}
/**
* Migrate contact methods
*/
async function migrateContactMethods(
context: MigrationContext,
methods: SQLiteContactMethod[]
): Promise<void> {
for (const method of methods) {
try {
await context.db.exec(`
INSERT INTO contact_methods (
id, contact_id, label, type, value,
created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?)
`, [
method.id,
method.contact_id,
method.label,
method.type,
method.value,
method.created_at,
method.updated_at
]);
context.stats.contactMethods++;
} catch (error) {
context.errors.push(new Error(`Failed to migrate contact method ${method.id}: ${error}`));
throw error;
}
}
}
/**
* Migrate settings
*/
async function migrateSettings(context: MigrationContext, settings: SQLiteSettings[]): Promise<void> {
for (const setting of settings) {
try {
if (!isSQLiteSettings(setting)) {
throw new Error(`Invalid settings data: ${JSON.stringify(setting)}`);
}
await context.db.exec(`
INSERT INTO settings (
key, account_did, value_json, created_at, updated_at
) VALUES (?, ?, ?, ?, ?)
`, [
setting.key,
setting.account_did || null,
setting.value_json,
setting.created_at,
setting.updated_at
]);
context.stats.settings++;
} catch (error) {
context.errors.push(new Error(`Failed to migrate setting ${setting.key}: ${error}`));
throw error;
}
}
}
/**
* Migrate logs
*/
async function migrateLogs(context: MigrationContext, logs: SQLiteLog[]): Promise<void> {
for (const log of logs) {
try {
await context.db.exec(`
INSERT INTO logs (
id, level, message, metadata_json, created_at
) VALUES (?, ?, ?, ?, ?)
`, [
log.id,
log.level,
log.message,
log.metadata_json || null,
log.created_at
]);
context.stats.logs++;
} catch (error) {
context.errors.push(new Error(`Failed to migrate log ${log.id}: ${error}`));
throw error;
}
}
}
/**
* Migrate secrets
*/
async function migrateSecrets(context: MigrationContext, secrets: SQLiteSecret[]): Promise<void> {
for (const secret of secrets) {
try {
await context.db.exec(`
INSERT INTO secrets (
key, value_encrypted, created_at, updated_at
) VALUES (?, ?, ?, ?)
`, [
secret.key,
secret.value_encrypted,
secret.created_at,
secret.updated_at
]);
context.stats.secrets++;
} catch (error) {
context.errors.push(new Error(`Failed to migrate secret ${secret.key}: ${error}`));
throw error;
}
}
}
// ============================================================================
// Verification and Rollback
// ============================================================================
/**
* Verify migration success
*/
async function verifyMigration(
context: MigrationContext,
data: MigrationData
): Promise<{ success: boolean; error?: string }> {
try {
// Verify counts
const counts = await context.db.selectAll<{ table: string; count: number }>(`
SELECT 'accounts' as table, COUNT(*) as count FROM accounts
UNION ALL
SELECT 'contacts', COUNT(*) FROM contacts
UNION ALL
SELECT 'contact_methods', COUNT(*) FROM contact_methods
UNION ALL
SELECT 'settings', COUNT(*) FROM settings
UNION ALL
SELECT 'logs', COUNT(*) FROM logs
UNION ALL
SELECT 'secrets', COUNT(*) FROM secrets
`);
const countMap = new Map(counts.map(c => [c.table, c.count]));
if (countMap.get('accounts') !== data.accounts.length) {
return { success: false, error: 'Account count mismatch' };
}
if (countMap.get('contacts') !== data.contacts.length) {
return { success: false, error: 'Contact count mismatch' };
}
if (countMap.get('contact_methods') !== data.contactMethods.length) {
return { success: false, error: 'Contact method count mismatch' };
}
if (countMap.get('settings') !== data.settings.length) {
return { success: false, error: 'Settings count mismatch' };
}
if (countMap.get('logs') !== data.logs.length) {
return { success: false, error: 'Log count mismatch' };
}
if (countMap.get('secrets') !== data.secrets.length) {
return { success: false, error: 'Secret count mismatch' };
}
return { success: true };
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown verification error'
};
}
}
/**
* Rollback migration
*/
async function rollbackMigration(backup: Uint8Array): Promise<void> {
const { db } = await initDatabase();
try {
// Close current connection
await db.close();
// Restore from backup
const sqlite3 = await import('@wa-sqlite/sql.js');
await sqlite3.open(backup);
logger.info('[SQLite] Migration rollback successful');
} catch (error) {
logger.error('[SQLite] Migration rollback failed:', error);
throw new Error('Failed to rollback migration');
}
}

449
src/db/sqlite/operations.ts Normal file
View File

@@ -0,0 +1,449 @@
/**
* SQLite Database Operations
*
* This module provides utility functions for common database operations,
* including CRUD operations, queries, and transactions.
*/
import { Database } from '@wa-sqlite/sql.js';
import { initDatabase } from './init';
import {
SQLiteAccount,
SQLiteContact,
SQLiteContactMethod,
SQLiteSettings,
SQLiteLog,
SQLiteSecret,
isSQLiteAccount,
isSQLiteContact,
isSQLiteSettings
} from './types';
import { logger } from '../../utils/logger';
// ============================================================================
// Transaction Helpers
// ============================================================================
/**
* Execute a function within a transaction
*/
export async function withTransaction<T>(
operation: (db: Database) => Promise<T>
): Promise<T> {
const { db } = await initDatabase();
try {
return await db.transaction(operation);
} catch (error) {
logger.error('[SQLite] Transaction failed:', error);
throw error;
}
}
/**
* Execute a function with retries
*/
export async function withRetry<T>(
operation: () => Promise<T>,
maxRetries = 3,
delay = 1000
): Promise<T> {
let lastError: Error | undefined;
for (let i = 0; i < maxRetries; i++) {
try {
return await operation();
} catch (error) {
lastError = error instanceof Error ? error : new Error(String(error));
if (i < maxRetries - 1) {
await new Promise(resolve => setTimeout(resolve, delay * (i + 1)));
}
}
}
throw lastError;
}
// ============================================================================
// Account Operations
// ============================================================================
/**
* Get account by DID
*/
export async function getAccountByDid(did: string): Promise<SQLiteAccount | null> {
const { db } = await initDatabase();
const accounts = await db.selectAll<SQLiteAccount>(
'SELECT * FROM accounts WHERE did = ?',
[did]
);
return accounts[0] || null;
}
/**
* Get all accounts
*/
export async function getAllAccounts(): Promise<SQLiteAccount[]> {
const { db } = await initDatabase();
return db.selectAll<SQLiteAccount>(
'SELECT * FROM accounts ORDER BY created_at DESC'
);
}
/**
* Create or update account
*/
export async function upsertAccount(account: SQLiteAccount): Promise<void> {
if (!isSQLiteAccount(account)) {
throw new Error('Invalid account data');
}
await withTransaction(async (db) => {
const existing = await db.selectOne<{ did: string }>(
'SELECT did FROM accounts WHERE did = ?',
[account.did]
);
if (existing) {
await db.exec(`
UPDATE accounts SET
public_key_hex = ?,
updated_at = ?,
identity_json = ?,
mnemonic_encrypted = ?,
passkey_cred_id_hex = ?,
derivation_path = ?
WHERE did = ?
`, [
account.public_key_hex,
Date.now(),
account.identity_json || null,
account.mnemonic_encrypted || null,
account.passkey_cred_id_hex || null,
account.derivation_path || null,
account.did
]);
} else {
await db.exec(`
INSERT INTO accounts (
did, public_key_hex, created_at, updated_at,
identity_json, mnemonic_encrypted, passkey_cred_id_hex, derivation_path
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`, [
account.did,
account.public_key_hex,
account.created_at,
account.updated_at,
account.identity_json || null,
account.mnemonic_encrypted || null,
account.passkey_cred_id_hex || null,
account.derivation_path || null
]);
}
});
}
// ============================================================================
// Contact Operations
// ============================================================================
/**
* Get contact by ID
*/
export async function getContactById(id: string): Promise<SQLiteContact | null> {
const { db } = await initDatabase();
const contacts = await db.selectAll<SQLiteContact>(
'SELECT * FROM contacts WHERE id = ?',
[id]
);
return contacts[0] || null;
}
/**
* Get contacts by account DID
*/
export async function getContactsByAccountDid(did: string): Promise<SQLiteContact[]> {
const { db } = await initDatabase();
return db.selectAll<SQLiteContact>(
'SELECT * FROM contacts WHERE did = ? ORDER BY created_at DESC',
[did]
);
}
/**
* Get contact methods for a contact
*/
export async function getContactMethods(contactId: string): Promise<SQLiteContactMethod[]> {
const { db } = await initDatabase();
return db.selectAll<SQLiteContactMethod>(
'SELECT * FROM contact_methods WHERE contact_id = ? ORDER BY created_at DESC',
[contactId]
);
}
/**
* Create or update contact with methods
*/
export async function upsertContact(
contact: SQLiteContact,
methods: SQLiteContactMethod[] = []
): Promise<void> {
if (!isSQLiteContact(contact)) {
throw new Error('Invalid contact data');
}
await withTransaction(async (db) => {
const existing = await db.selectOne<{ id: string }>(
'SELECT id FROM contacts WHERE id = ?',
[contact.id]
);
if (existing) {
await db.exec(`
UPDATE contacts SET
did = ?,
name = ?,
notes = ?,
profile_image_url = ?,
public_key_base64 = ?,
next_pub_key_hash_b64 = ?,
sees_me = ?,
registered = ?,
updated_at = ?
WHERE id = ?
`, [
contact.did,
contact.name || null,
contact.notes || null,
contact.profile_image_url || null,
contact.public_key_base64 || null,
contact.next_pub_key_hash_b64 || null,
contact.sees_me ? 1 : 0,
contact.registered ? 1 : 0,
Date.now(),
contact.id
]);
} else {
await db.exec(`
INSERT INTO contacts (
id, did, name, notes, profile_image_url,
public_key_base64, next_pub_key_hash_b64,
sees_me, registered, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, [
contact.id,
contact.did,
contact.name || null,
contact.notes || null,
contact.profile_image_url || null,
contact.public_key_base64 || null,
contact.next_pub_key_hash_b64 || null,
contact.sees_me ? 1 : 0,
contact.registered ? 1 : 0,
contact.created_at,
contact.updated_at
]);
}
// Update contact methods
if (methods.length > 0) {
// Delete existing methods
await db.exec(
'DELETE FROM contact_methods WHERE contact_id = ?',
[contact.id]
);
// Insert new methods
for (const method of methods) {
await db.exec(`
INSERT INTO contact_methods (
id, contact_id, label, type, value,
created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?)
`, [
method.id,
contact.id,
method.label,
method.type,
method.value,
method.created_at,
method.updated_at
]);
}
}
});
}
// ============================================================================
// Settings Operations
// ============================================================================
/**
* Get setting by key
*/
export async function getSetting(key: string): Promise<SQLiteSettings | null> {
const { db } = await initDatabase();
const settings = await db.selectAll<SQLiteSettings>(
'SELECT * FROM settings WHERE key = ?',
[key]
);
return settings[0] || null;
}
/**
* Get settings by account DID
*/
export async function getSettingsByAccountDid(did: string): Promise<SQLiteSettings[]> {
const { db } = await initDatabase();
return db.selectAll<SQLiteSettings>(
'SELECT * FROM settings WHERE account_did = ? ORDER BY updated_at DESC',
[did]
);
}
/**
* Set setting value
*/
export async function setSetting(setting: SQLiteSettings): Promise<void> {
if (!isSQLiteSettings(setting)) {
throw new Error('Invalid settings data');
}
await withTransaction(async (db) => {
const existing = await db.selectOne<{ key: string }>(
'SELECT key FROM settings WHERE key = ?',
[setting.key]
);
if (existing) {
await db.exec(`
UPDATE settings SET
account_did = ?,
value_json = ?,
updated_at = ?
WHERE key = ?
`, [
setting.account_did || null,
setting.value_json,
Date.now(),
setting.key
]);
} else {
await db.exec(`
INSERT INTO settings (
key, account_did, value_json, created_at, updated_at
) VALUES (?, ?, ?, ?, ?)
`, [
setting.key,
setting.account_did || null,
setting.value_json,
setting.created_at,
setting.updated_at
]);
}
});
}
// ============================================================================
// Log Operations
// ============================================================================
/**
* Add log entry
*/
export async function addLog(log: SQLiteLog): Promise<void> {
await withTransaction(async (db) => {
await db.exec(`
INSERT INTO logs (
id, level, message, metadata_json, created_at
) VALUES (?, ?, ?, ?, ?)
`, [
log.id,
log.level,
log.message,
log.metadata_json || null,
log.created_at
]);
});
}
/**
* Get logs by level
*/
export async function getLogsByLevel(
level: string,
limit = 100,
offset = 0
): Promise<SQLiteLog[]> {
const { db } = await initDatabase();
return db.selectAll<SQLiteLog>(
'SELECT * FROM logs WHERE level = ? ORDER BY created_at DESC LIMIT ? OFFSET ?',
[level, limit, offset]
);
}
// ============================================================================
// Secret Operations
// ============================================================================
/**
* Get secret by key
*/
export async function getSecret(key: string): Promise<SQLiteSecret | null> {
const { db } = await initDatabase();
const secrets = await db.selectAll<SQLiteSecret>(
'SELECT * FROM secrets WHERE key = ?',
[key]
);
return secrets[0] || null;
}
/**
* Set secret value
*/
export async function setSecret(secret: SQLiteSecret): Promise<void> {
await withTransaction(async (db) => {
const existing = await db.selectOne<{ key: string }>(
'SELECT key FROM secrets WHERE key = ?',
[secret.key]
);
if (existing) {
await db.exec(`
UPDATE secrets SET
value_encrypted = ?,
updated_at = ?
WHERE key = ?
`, [
secret.value_encrypted,
Date.now(),
secret.key
]);
} else {
await db.exec(`
INSERT INTO secrets (
key, value_encrypted, created_at, updated_at
) VALUES (?, ?, ?, ?)
`, [
secret.key,
secret.value_encrypted,
secret.created_at,
secret.updated_at
]);
}
});
}

349
src/db/sqlite/types.ts Normal file
View File

@@ -0,0 +1,349 @@
/**
* SQLite Type Definitions
*
* This file defines the type system for the SQLite implementation,
* mapping from the existing Dexie types to SQLite-compatible types.
* It includes both the database schema types and the runtime types.
*/
import { SQLiteCompatibleType } from '@jlongster/sql.js';
// ============================================================================
// Base Types and Utilities
// ============================================================================
/**
* SQLite column type mapping
*/
export type SQLiteColumnType =
| 'INTEGER' // For numbers, booleans, dates
| 'TEXT' // For strings, JSON
| 'BLOB' // For binary data
| 'REAL' // For floating point numbers
| 'NULL'; // For null values
/**
* SQLite column definition
*/
export interface SQLiteColumn {
name: string;
type: SQLiteColumnType;
nullable?: boolean;
primaryKey?: boolean;
unique?: boolean;
references?: {
table: string;
column: string;
};
default?: SQLiteCompatibleType;
}
/**
* SQLite table definition
*/
export interface SQLiteTable {
name: string;
columns: SQLiteColumn[];
indexes?: Array<{
name: string;
columns: string[];
unique?: boolean;
}>;
}
// ============================================================================
// Account Types
// ============================================================================
/**
* SQLite-compatible Account type
* Maps from the Dexie Account type
*/
export interface SQLiteAccount {
did: string; // TEXT PRIMARY KEY
public_key_hex: string; // TEXT NOT NULL
created_at: number; // INTEGER NOT NULL
updated_at: number; // INTEGER NOT NULL
identity_json?: string; // TEXT (encrypted JSON)
mnemonic_encrypted?: string; // TEXT (encrypted)
passkey_cred_id_hex?: string; // TEXT
derivation_path?: string; // TEXT
}
export const ACCOUNTS_TABLE: SQLiteTable = {
name: 'accounts',
columns: [
{ name: 'did', type: 'TEXT', primaryKey: true },
{ name: 'public_key_hex', type: 'TEXT', nullable: false },
{ name: 'created_at', type: 'INTEGER', nullable: false },
{ name: 'updated_at', type: 'INTEGER', nullable: false },
{ name: 'identity_json', type: 'TEXT' },
{ name: 'mnemonic_encrypted', type: 'TEXT' },
{ name: 'passkey_cred_id_hex', type: 'TEXT' },
{ name: 'derivation_path', type: 'TEXT' }
],
indexes: [
{ name: 'idx_accounts_created_at', columns: ['created_at'] },
{ name: 'idx_accounts_updated_at', columns: ['updated_at'] }
]
};
// ============================================================================
// Contact Types
// ============================================================================
/**
* SQLite-compatible ContactMethod type
*/
export interface SQLiteContactMethod {
id: string; // TEXT PRIMARY KEY
contact_id: string; // TEXT NOT NULL
label: string; // TEXT NOT NULL
type: string; // TEXT NOT NULL
value: string; // TEXT NOT NULL
created_at: number; // INTEGER NOT NULL
updated_at: number; // INTEGER NOT NULL
}
/**
* SQLite-compatible Contact type
*/
export interface SQLiteContact {
id: string; // TEXT PRIMARY KEY
did: string; // TEXT NOT NULL
name?: string; // TEXT
notes?: string; // TEXT
profile_image_url?: string; // TEXT
public_key_base64?: string; // TEXT
next_pub_key_hash_b64?: string; // TEXT
sees_me?: boolean; // INTEGER (0 or 1)
registered?: boolean; // INTEGER (0 or 1)
created_at: number; // INTEGER NOT NULL
updated_at: number; // INTEGER NOT NULL
}
export const CONTACTS_TABLE: SQLiteTable = {
name: 'contacts',
columns: [
{ name: 'id', type: 'TEXT', primaryKey: true },
{ name: 'did', type: 'TEXT', nullable: false },
{ name: 'name', type: 'TEXT' },
{ name: 'notes', type: 'TEXT' },
{ name: 'profile_image_url', type: 'TEXT' },
{ name: 'public_key_base64', type: 'TEXT' },
{ name: 'next_pub_key_hash_b64', type: 'TEXT' },
{ name: 'sees_me', type: 'INTEGER' },
{ name: 'registered', type: 'INTEGER' },
{ name: 'created_at', type: 'INTEGER', nullable: false },
{ name: 'updated_at', type: 'INTEGER', nullable: false }
],
indexes: [
{ name: 'idx_contacts_did', columns: ['did'] },
{ name: 'idx_contacts_created_at', columns: ['created_at'] }
]
};
export const CONTACT_METHODS_TABLE: SQLiteTable = {
name: 'contact_methods',
columns: [
{ name: 'id', type: 'TEXT', primaryKey: true },
{ name: 'contact_id', type: 'TEXT', nullable: false,
references: { table: 'contacts', column: 'id' } },
{ name: 'label', type: 'TEXT', nullable: false },
{ name: 'type', type: 'TEXT', nullable: false },
{ name: 'value', type: 'TEXT', nullable: false },
{ name: 'created_at', type: 'INTEGER', nullable: false },
{ name: 'updated_at', type: 'INTEGER', nullable: false }
],
indexes: [
{ name: 'idx_contact_methods_contact_id', columns: ['contact_id'] }
]
};
// ============================================================================
// Settings Types
// ============================================================================
/**
* SQLite-compatible Settings type
*/
export interface SQLiteSettings {
key: string; // TEXT PRIMARY KEY
account_did?: string; // TEXT
value_json: string; // TEXT NOT NULL (JSON stringified)
created_at: number; // INTEGER NOT NULL
updated_at: number; // INTEGER NOT NULL
}
export const SETTINGS_TABLE: SQLiteTable = {
name: 'settings',
columns: [
{ name: 'key', type: 'TEXT', primaryKey: true },
{ name: 'account_did', type: 'TEXT' },
{ name: 'value_json', type: 'TEXT', nullable: false },
{ name: 'created_at', type: 'INTEGER', nullable: false },
{ name: 'updated_at', type: 'INTEGER', nullable: false }
],
indexes: [
{ name: 'idx_settings_account_did', columns: ['account_did'] },
{ name: 'idx_settings_updated_at', columns: ['updated_at'] }
]
};
// ============================================================================
// Log Types
// ============================================================================
/**
* SQLite-compatible Log type
*/
export interface SQLiteLog {
id: string; // TEXT PRIMARY KEY
level: string; // TEXT NOT NULL
message: string; // TEXT NOT NULL
metadata_json?: string; // TEXT (JSON stringified)
created_at: number; // INTEGER NOT NULL
}
export const LOGS_TABLE: SQLiteTable = {
name: 'logs',
columns: [
{ name: 'id', type: 'TEXT', primaryKey: true },
{ name: 'level', type: 'TEXT', nullable: false },
{ name: 'message', type: 'TEXT', nullable: false },
{ name: 'metadata_json', type: 'TEXT' },
{ name: 'created_at', type: 'INTEGER', nullable: false }
],
indexes: [
{ name: 'idx_logs_level', columns: ['level'] },
{ name: 'idx_logs_created_at', columns: ['created_at'] }
]
};
// ============================================================================
// Secret Types
// ============================================================================
/**
* SQLite-compatible Secret type
* Note: This table should be encrypted at the database level
*/
export interface SQLiteSecret {
key: string; // TEXT PRIMARY KEY
value_encrypted: string; // TEXT NOT NULL (encrypted)
created_at: number; // INTEGER NOT NULL
updated_at: number; // INTEGER NOT NULL
}
export const SECRETS_TABLE: SQLiteTable = {
name: 'secrets',
columns: [
{ name: 'key', type: 'TEXT', primaryKey: true },
{ name: 'value_encrypted', type: 'TEXT', nullable: false },
{ name: 'created_at', type: 'INTEGER', nullable: false },
{ name: 'updated_at', type: 'INTEGER', nullable: false }
],
indexes: [
{ name: 'idx_secrets_updated_at', columns: ['updated_at'] }
]
};
// ============================================================================
// Database Schema
// ============================================================================
/**
* Complete database schema definition
*/
export const DATABASE_SCHEMA: SQLiteTable[] = [
ACCOUNTS_TABLE,
CONTACTS_TABLE,
CONTACT_METHODS_TABLE,
SETTINGS_TABLE,
LOGS_TABLE,
SECRETS_TABLE
];
// ============================================================================
// Type Guards and Validators
// ============================================================================
/**
* Type guard for SQLiteAccount
*/
export function isSQLiteAccount(value: unknown): value is SQLiteAccount {
return (
typeof value === 'object' &&
value !== null &&
typeof (value as SQLiteAccount).did === 'string' &&
typeof (value as SQLiteAccount).public_key_hex === 'string' &&
typeof (value as SQLiteAccount).created_at === 'number' &&
typeof (value as SQLiteAccount).updated_at === 'number'
);
}
/**
* Type guard for SQLiteContact
*/
export function isSQLiteContact(value: unknown): value is SQLiteContact {
return (
typeof value === 'object' &&
value !== null &&
typeof (value as SQLiteContact).id === 'string' &&
typeof (value as SQLiteContact).did === 'string' &&
typeof (value as SQLiteContact).created_at === 'number' &&
typeof (value as SQLiteContact).updated_at === 'number'
);
}
/**
* Type guard for SQLiteSettings
*/
export function isSQLiteSettings(value: unknown): value is SQLiteSettings {
return (
typeof value === 'object' &&
value !== null &&
typeof (value as SQLiteSettings).key === 'string' &&
typeof (value as SQLiteSettings).value_json === 'string' &&
typeof (value as SQLiteSettings).created_at === 'number' &&
typeof (value as SQLiteSettings).updated_at === 'number'
);
}
// ============================================================================
// Migration Types
// ============================================================================
/**
* Type for migration data from Dexie to SQLite
*/
export interface MigrationData {
accounts: SQLiteAccount[];
contacts: SQLiteContact[];
contactMethods: SQLiteContactMethod[];
settings: SQLiteSettings[];
logs: SQLiteLog[];
secrets: SQLiteSecret[];
metadata: {
version: string;
timestamp: number;
source: 'dexie';
};
}
/**
* Migration result type
*/
export interface MigrationResult {
success: boolean;
error?: Error;
stats: {
accounts: number;
contacts: number;
contactMethods: number;
settings: number;
logs: number;
secrets: number;
};
duration: number;
}

View File

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

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