Compare commits

...

66 Commits

Author SHA1 Message Date
Matthew Raymer 981920dd7a feat(electron): enhance SQLite operation logging and debugging 4 weeks ago
Matthew Raymer d189c39062 chore(linting): more linting and debugging 4 weeks ago
Matthew Raymer 8edddb1a57 chore(linting): some linting problems fixed 4 weeks ago
Matthew Raymer 9eb07b3258 fix: improve API server update reliability and logging 1 month ago
Matthew Raymer e5dffc30ff fix(sqlite): centralize database connection management 1 month ago
Matthew Raymer 0b4e885edd fix(electron): correct SQLite IPC bridge implementation 1 month ago
Matthew Raymer b6d9b29720 refactor(sqlite): align database implementation with sacred-sql 1 month ago
Matthew Raymer b5348e42a7 chore(config): revert encrypted sqlite db 1 month ago
Matthew Raymer a4fb3eea2d chore(config): add encryption settings for SQLite 1 month ago
Matthew Raymer 5d12c76693 fix(sqlite): enable database encryption in Electron app 1 month ago
Matthew Raymer d426f9c4ac refactor(experiment.sh): streamline build process for Capacitor-based Electron app 1 month ago
Trent Larson 340a574325 adjust timeout length for startup 1 month ago
Matthew Raymer 98b3a35e3c refactor: consolidate Electron API type definitions 1 month ago
Matthew Raymer 409de21fc4 fix(db): resolve SQLite channel and initialization issues 1 month ago
Matthew Raymer 17c9d32f49 feat(db): temporarily mock dbQuery for connection testing 1 month ago
Matthew Raymer 25e4db395a refactor(sqlite): enhance dbQuery with robust connection lifecycle 1 month ago
Matthew Raymer b6ee30892f feat(sqlite): enhance SQLite initialization and IPC handlers 1 month ago
Matthew Raymer b01a450733 debug(ipc): HomeView errors added function level labels noting that we're catching a function level exception but we're also logging it globally. 1 month ago
Matthew Raymer 596f3355bf chore(logging): turn off logToDB since it was blowing up and hiding real errors in noise. 1 month ago
Matthew Raymer e1f9a6fa08 refactor(sqlite): disable verbose logging in migration system 1 month ago
Matthew Raymer 340e718199 feat(logging): enhance SQLite logging and IPC handler management 1 month ago
Matthew Raymer 5d97c98ae8 fix(electron): improve SQLite initialization and timing handling 1 month ago
Matthew Raymer ec74fff892 refactor: enhance SQLite error handling and type safety 1 month ago
Matthew Raymer 1e88c0e26f refactor(electron): enhance SQLite integration and debug logging 1 month ago
Matthew Raymer 3ec2364394 refactor: update electron preload script and type definitions 1 month ago
Matthew Raymer 8b215c909d refactor: remove electron preload script and update database handling 1 month ago
Matthew Raymer 91a1c05473 fix(electron): consolidate SQLite initialization and IPC handling 1 month ago
Matthew Raymer 66929d9b14 refactor(electron): WIP - use window.CapacitorSQLite API for all DB ops in ElectronPlatformService 1 month ago
Matthew Raymer 1e63ddcb6e feat(sqlite): enhance migration system and database initialization 1 month ago
Matthew Raymer 51f5755f5c Merge branch 'elec-tweak' into sql-absurd-sql-further 1 month ago
Matthew Raymer e5a3d622b6 Merge branch 'elec-tweak' of ssh://173.199.124.46:222/trent_larson/crowd-funder-for-time-pwa into elec-tweak 1 month ago
Matthew Raymer a6edcd6269 feat(db): add secure secret generation and initial data setup 1 month ago
Matthew Raymer b7b6be5831 fix(sqlite): resolve duplicate table creation in migrations 1 month ago
Matthew Raymer cbaca0304d feat(sqlite): implement initial database migrations 1 month ago
Trent Larson 59d711bd90 make fixes to help my Mac build electron 1 month ago
Matthew Raymer c355de6e33 Merge branch 'sql-absurd-sql-further' of ssh://173.199.124.46:222/trent_larson/crowd-funder-for-time-pwa into sql-absurd-sql-further 1 month ago
Matthew Raymer 28c114a2c7 fix(sqlite): resolve migration issues and enhance documentation 1 month ago
Trent Larson dabfe33fbe add Python dependency for electron on Mac 1 month ago
Trent Larson d8f2587d1c fix some errors and correct recent type duplications & bloat 1 month ago
Matthew Raymer 3946a8a27a fix(database): improve SQLite connection handling and initialization 1 month ago
Trent Larson 4c40b80718 rename script files that would fail in the prebuild step 1 month ago
Trent Larson 74989c2b64 fix linting 1 month ago
Trent Larson 7e17b41444 rename a js config file to avoid an error running lint 1 month ago
Trent Larson 83acb028c7 fix more logic for tests 1 month ago
Matthew Raymer 786f07e067 feat(electron): Implement SQLite database initialization with proper logging 1 month ago
Matthew Raymer 710cc1683c fix(sqlite): Standardize connection options and improve error handling 1 month ago
Matthew Raymer ebef5d6c8d feat(sqlite): Initialize database with complete schema and PRAGMAs 1 month ago
Matthew Raymer 43ea7ee610 Merge branch 'sql-absurd-sql-further' of ssh://173.199.124.46:222/trent_larson/crowd-funder-for-time-pwa into sql-absurd-sql-further 1 month ago
Matthew Raymer 57191df416 feat(sqlite): Database file creation working, connection pending 1 month ago
Trent Larson 644593a5f4 fix linting 1 month ago
Matthew Raymer 900c2521c7 WIP: Improve SQLite initialization and error handling 1 month ago
Matthew Raymer 182cff2b16 fix(typescript): resolve linter violations and improve type safety 1 month ago
Matthew Raymer 3b4ef908f3 feat(electron): improve window and database initialization 1 month ago
Matthew Raymer a5a9e15ece WIP: Refactor Electron SQLite initialization and database path handling 1 month ago
Matthew Raymer a6d8f0eb8a fix(electron): assign sqlitePlugin globally and improve error logging 1 month ago
Matthew Raymer 3997a88b44 fix: rename postcss.config.js to .cjs for ES module compatibility 1 month ago
Trent Larson 5eeeae32c6 fix some incorrect logic & things AI hallucinated 1 month ago
Matthew Raymer d9895086e6 experiment(electron): different vite build script for web application 1 month ago
Matthew Raymer fb8d1cb8b2 fix(electron): add null check for devToolsWebContents to prevent TypeScript error 1 month ago
Matthew Raymer 70c0edbed0 fix: SQLite plugin initialization in Electron main process 1 month ago
Matthew Raymer 55cc08d675 chore: linting 1 month ago
Matthew Raymer 688a5be76e Merge branch 'sql-absurd-sql-further' of ssh://173.199.124.46:222/trent_larson/crowd-funder-for-time-pwa into sql-absurd-sql-further 1 month ago
Matthew Raymer 014341f320 fix(electron): simplify SQLite plugin initialization 1 month ago
Matthew Raymer 1d5e062c76 fix(electron): app loads 1 month ago
Matthew Raymer 2c5c15108a debug(electron): missing main.ts 1 month ago
Matthew Raymer 26df0fb671 debug(electron): app index loads but problem with preload script 1 month ago
  1. 101
      -1748433586226.log
  2. 0
      .eslintrc.cjs
  3. 11
      capacitor.config.json
  4. 270
      doc/electron-migration.md
  5. 55
      electron/.gitignore
  6. BIN
      electron/assets/appIcon.ico
  7. BIN
      electron/assets/appIcon.png
  8. BIN
      electron/assets/splash.gif
  9. BIN
      electron/assets/splash.png
  10. 62
      electron/capacitor.config.json
  11. 28
      electron/electron-builder.config.json
  12. 75
      electron/live-runner.js
  13. 5460
      electron/package-lock.json
  14. 52
      electron/package.json
  15. 10
      electron/resources/electron-publisher-custom.js
  16. 140
      electron/src/index.ts
  17. 303
      electron/src/preload.ts
  18. 6
      electron/src/rt/electron-plugins.js
  19. 88
      electron/src/rt/electron-rt.ts
  20. 188
      electron/src/rt/logger.ts
  21. 14
      electron/src/rt/sqlite-error.ts
  22. 1147
      electron/src/rt/sqlite-init.ts
  23. 1261
      electron/src/rt/sqlite-migrations.ts
  24. 442
      electron/src/setup.ts
  25. 18
      electron/tsconfig.json
  26. 155
      experiment.sh
  27. 948
      package-lock.json
  28. 47
      package.json
  29. 0
      postcss.config.cjs
  30. 1
      requirements.txt
  31. 96
      scripts/build-electron.cjs
  32. 165
      scripts/build-electron.js
  33. 0
      scripts/copy-wasm.cjs
  34. 7
      src/App.vue
  35. 14
      src/components/GiftedDialog.vue
  36. 14
      src/components/OfferDialog.vue
  37. 9
      src/components/UserNameDialog.vue
  38. 89
      src/db/databaseUtil.ts
  39. 174
      src/electron/main.js
  40. 215
      src/electron/main.ts
  41. 91
      src/electron/preload.js
  42. 106
      src/interfaces/claims.ts
  43. 100
      src/interfaces/common.ts
  44. 7
      src/interfaces/index.ts
  45. 8
      src/interfaces/records.ts
  46. 4
      src/libs/crypto/vc/index.ts
  47. 253
      src/libs/endorserServer.ts
  48. 123
      src/libs/util.ts
  49. 5
      src/main.common.ts
  50. 295
      src/main.electron.ts
  51. 17
      src/router/index.ts
  52. 2
      src/services/AbsurdSqlDatabaseService.ts
  53. 20
      src/services/PlatformService.ts
  54. 132
      src/services/database/ConnectionPool.ts
  55. 707
      src/services/platforms/ElectronPlatformService.ts
  56. 46
      src/types/capacitor-sqlite-electron.d.ts
  57. 87
      src/types/electron.d.ts
  58. 32
      src/types/global.d.ts
  59. 1
      src/types/jeepq-sqlite.d.ts
  60. 169
      src/utils/debug-electron.ts
  61. 113
      src/views/AccountViewView.vue
  62. 8
      src/views/ClaimView.vue
  63. 4
      src/views/ContactAmountsView.vue
  64. 20
      src/views/ContactQRScanShowView.vue
  65. 8
      src/views/DIDView.vue
  66. 8
      src/views/GiftedDetailsView.vue
  67. 11
      src/views/HomeView.vue
  68. 10
      src/views/IdentitySwitcherView.vue
  69. 2
      src/views/InviteOneView.vue
  70. 10
      src/views/OfferDetailsView.vue
  71. 12
      src/views/ProjectViewView.vue
  72. 4
      src/views/ShareMyContactInfoView.vue
  73. 0
      sw_combine.cjs
  74. 1
      test-playwright/60-new-activity.spec.ts
  75. 7
      tsconfig.electron.json
  76. 42
      vite.config.app.electron.mts
  77. 114
      vite.config.electron.mts
  78. 21
      vite.config.renderer.mts

101
-1748433586226.log

@ -1,101 +0,0 @@
VM5:29 [Preload] Preload script starting...
VM5:29 [Preload] Preload script completed successfully
main.common-DiOUyXe7.js:27 Platform Object
error @ main.common-DiOUyXe7.js:27
main.common-DiOUyXe7.js:27 PWA enabled Object
error @ main.common-DiOUyXe7.js:27
main.common-DiOUyXe7.js:27 [Web] PWA enabled Object
error @ main.common-DiOUyXe7.js:27
main.common-DiOUyXe7.js:27 [Web] Platform Object
error @ main.common-DiOUyXe7.js:27
main.common-DiOUyXe7.js:29 Opened!
main.common-DiOUyXe7.js:2552 Failed to log to database: Error: no such column: value
at E.handleError (main.common-DiOUyXe7.js:27:21133)
at E.exec (main.common-DiOUyXe7.js:27:19785)
at Rc.processQueue (main.common-DiOUyXe7.js:2379:2368)
F7 @ main.common-DiOUyXe7.js:2552
main.common-DiOUyXe7.js:2552 Original message: PWA enabled - [{"pwa_enabled":false}]
F7 @ main.common-DiOUyXe7.js:2552
main.common-DiOUyXe7.js:2552 Failed to log to database: Error: no such column: value
at E.handleError (main.common-DiOUyXe7.js:27:21133)
at E.exec (main.common-DiOUyXe7.js:27:19785)
at Rc.processQueue (main.common-DiOUyXe7.js:2379:2368)
at main.common-DiOUyXe7.js:2379:2816
at new Promise (<anonymous>)
at Rc.queueOperation (main.common-DiOUyXe7.js:2379:2685)
at Rc.query (main.common-DiOUyXe7.js:2379:3378)
at async F7 (main.common-DiOUyXe7.js:2552:117)
F7 @ main.common-DiOUyXe7.js:2552
main.common-DiOUyXe7.js:2552 Original message: [Web] PWA enabled - [{"pwa_enabled":false}]
F7 @ main.common-DiOUyXe7.js:2552
main.common-DiOUyXe7.js:2552 Failed to log to database: Error: no such column: value
at E.handleError (main.common-DiOUyXe7.js:27:21133)
at E.exec (main.common-DiOUyXe7.js:27:19785)
at Rc.processQueue (main.common-DiOUyXe7.js:2379:2368)
at main.common-DiOUyXe7.js:2379:2816
at new Promise (<anonymous>)
at Rc.queueOperation (main.common-DiOUyXe7.js:2379:2685)
at Rc.query (main.common-DiOUyXe7.js:2379:3378)
at async F7 (main.common-DiOUyXe7.js:2552:117)
F7 @ main.common-DiOUyXe7.js:2552
main.common-DiOUyXe7.js:2552 Original message: [Web] Platform - [{"platform":"web"}]
F7 @ main.common-DiOUyXe7.js:2552
main.common-DiOUyXe7.js:2552 Failed to log to database: Error: no such column: value
at E.handleError (main.common-DiOUyXe7.js:27:21133)
at E.exec (main.common-DiOUyXe7.js:27:19785)
at Rc.processQueue (main.common-DiOUyXe7.js:2379:2368)
at main.common-DiOUyXe7.js:2379:2816
at new Promise (<anonymous>)
at Rc.queueOperation (main.common-DiOUyXe7.js:2379:2685)
at Rc.query (main.common-DiOUyXe7.js:2379:3378)
at async F7 (main.common-DiOUyXe7.js:2552:117)
F7 @ main.common-DiOUyXe7.js:2552
main.common-DiOUyXe7.js:2552 Original message: Platform - [{"platform":"web"}]
F7 @ main.common-DiOUyXe7.js:2552
main.common-DiOUyXe7.js:2100
GET https://api.endorser.ch/api/report/rateLimits 400 (Bad Request)
(anonymous) @ main.common-DiOUyXe7.js:2100
xhr @ main.common-DiOUyXe7.js:2100
p6 @ main.common-DiOUyXe7.js:2102
_request @ main.common-DiOUyXe7.js:2103
request @ main.common-DiOUyXe7.js:2102
Yc.<computed> @ main.common-DiOUyXe7.js:2103
(anonymous) @ main.common-DiOUyXe7.js:2098
dJ @ main.common-DiOUyXe7.js:2295
main.common-DiOUyXe7.js:2100
GET https://api.endorser.ch/api/report/rateLimits 400 (Bad Request)
(anonymous) @ main.common-DiOUyXe7.js:2100
xhr @ main.common-DiOUyXe7.js:2100
p6 @ main.common-DiOUyXe7.js:2102
_request @ main.common-DiOUyXe7.js:2103
request @ main.common-DiOUyXe7.js:2102
Yc.<computed> @ main.common-DiOUyXe7.js:2103
(anonymous) @ main.common-DiOUyXe7.js:2098
dJ @ main.common-DiOUyXe7.js:2295
await in dJ
checkRegistrationStatus @ HomeView-DJMSCuMg.js:1
mounted @ HomeView-DJMSCuMg.js:1
XMLHttpRequest.send
(anonymous) @ main.common-DiOUyXe7.js:2100
xhr @ main.common-DiOUyXe7.js:2100
p6 @ main.common-DiOUyXe7.js:2102
_request @ main.common-DiOUyXe7.js:2103
request @ main.common-DiOUyXe7.js:2102
Yc.<computed> @ main.common-DiOUyXe7.js:2103
(anonymous) @ main.common-DiOUyXe7.js:2098
ZG @ main.common-DiOUyXe7.js:2295
await in ZG
initializeIdentity @ HomeView-DJMSCuMg.js:1
XMLHttpRequest.send
(anonymous) @ main.common-DiOUyXe7.js:2100
xhr @ main.common-DiOUyXe7.js:2100
p6 @ main.common-DiOUyXe7.js:2102
_request @ main.common-DiOUyXe7.js:2103
request @ main.common-DiOUyXe7.js:2102
Yc.<computed> @ main.common-DiOUyXe7.js:2103
(anonymous) @ main.common-DiOUyXe7.js:2098
dJ @ main.common-DiOUyXe7.js:2295

0
.eslintrc.js → .eslintrc.cjs

11
capacitor.config.json

@ -1,10 +1,11 @@
{
"appId": "app.timesafari",
"appId": "com.timesafari.app",
"appName": "TimeSafari",
"webDir": "dist",
"bundledWebRuntime": false,
"server": {
"cleartext": true
"cleartext": true,
"androidScheme": "https"
},
"plugins": {
"App": {
@ -29,6 +30,12 @@
"biometricAuth": true,
"biometricTitle": "Biometric login for TimeSafari"
}
},
"CapacitorSQLite": {
"electronIsEncryption": false,
"electronMacLocation": "~/Library/Application Support/TimeSafari",
"electronWindowsLocation": "C:\\ProgramData\\TimeSafari",
"electronLinuxLocation": "~/.local/share/TimeSafari"
}
},
"ios": {

270
doc/electron-migration.md

@ -0,0 +1,270 @@
# Electron App Migration Strategy
## Overview
This document outlines the migration strategy for the TimeSafari Electron app, focusing on the transition from web-based storage to native SQLite implementation while maintaining cross-platform compatibility.
## Current Architecture
### 1. Platform Services
- `ElectronPlatformService`: Implements platform-specific features for desktop
- Uses `@capacitor-community/sqlite` for database operations
- Maintains compatibility with web/mobile platforms through shared interfaces
### 2. Database Implementation
- SQLite with native Node.js backend
- WAL journal mode for better concurrency
- Connection pooling for performance
- Migration system for schema updates
- Secure file permissions (0o755)
### 3. Build Process
```bash
# Development
npm run dev:electron
# Production Build
npm run build:web
npm run build:electron
npm run electron:build-linux # or electron:build-mac
```
## Migration Goals
1. **Data Integrity**
- Preserve existing data during migration
- Maintain data relationships
- Ensure ACID compliance
- Implement proper backup/restore
2. **Performance**
- Optimize SQLite configuration
- Implement connection pooling
- Use WAL journal mode
- Configure optimal PRAGMA settings
3. **Security**
- Secure file permissions
- Proper IPC communication
- Context isolation
- Safe preload scripts
4. **User Experience**
- Zero data loss
- Automatic migration
- Progress indicators
- Error recovery
## Implementation Details
### 1. Database Initialization
```typescript
// electron/src/rt/sqlite-init.ts
export async function initializeSQLite() {
// Set up database path with proper permissions
const dbPath = path.join(app.getPath('userData'), 'timesafari.db');
// Initialize SQLite plugin
const sqlite = new CapacitorSQLite();
// Configure database
await sqlite.createConnection({
database: 'timesafari',
path: dbPath,
encrypted: false,
mode: 'no-encryption'
});
// Set optimal PRAGMA settings
await sqlite.execute({
database: 'timesafari',
statements: [
'PRAGMA journal_mode = WAL;',
'PRAGMA synchronous = NORMAL;',
'PRAGMA foreign_keys = ON;'
]
});
}
```
### 2. Migration System
```typescript
// electron/src/rt/sqlite-migrations.ts
interface Migration {
version: number;
name: string;
description: string;
sql: string;
rollback?: string;
}
async function runMigrations(plugin: any, database: string) {
// Track migration state
const state = await getMigrationState(plugin, database);
// Execute migrations in transaction
for (const migration of pendingMigrations) {
await executeMigration(plugin, database, migration);
}
}
```
### 3. Platform Service Implementation
```typescript
// src/services/platforms/ElectronPlatformService.ts
export class ElectronPlatformService implements PlatformService {
private sqlite: any;
async dbQuery(sql: string, params: any[]): Promise<QueryExecResult> {
return await this.sqlite.execute({
database: 'timesafari',
statements: [{ statement: sql, values: params }]
});
}
}
```
### 4. Preload Script
```typescript
// electron/preload.ts
contextBridge.exposeInMainWorld('electron', {
sqlite: {
isAvailable: () => ipcRenderer.invoke('sqlite:isAvailable'),
execute: (method: string, ...args: unknown[]) =>
ipcRenderer.invoke('sqlite:execute', method, ...args)
},
getPath: (pathType: string) => ipcRenderer.invoke('get-path', pathType),
env: {
platform: 'electron'
}
});
```
## Build Configuration
### 1. Vite Configuration
```typescript
// vite.config.app.electron.mts
export default defineConfig({
build: {
outDir: 'dist',
emptyOutDir: true
},
define: {
'process.env.VITE_PLATFORM': JSON.stringify('electron'),
'process.env.VITE_PWA_ENABLED': JSON.stringify(false)
}
});
```
### 2. Package Scripts
```json
{
"scripts": {
"dev:electron": "vite build --watch --config vite.config.app.electron.mts",
"build:electron": "vite build --config vite.config.app.electron.mts",
"electron:build-linux": "electron-builder --linux",
"electron:build-mac": "electron-builder --mac"
}
}
```
## Testing Strategy
1. **Unit Tests**
- Database operations
- Migration system
- Platform service methods
- IPC communication
2. **Integration Tests**
- Full migration process
- Data integrity verification
- Cross-platform compatibility
- Error recovery
3. **End-to-End Tests**
- User workflows
- Data persistence
- UI interactions
- Platform-specific features
## Error Handling
1. **Database Errors**
- Connection failures
- Migration errors
- Query execution errors
- Transaction failures
2. **Platform Errors**
- File system errors
- IPC communication errors
- Permission issues
- Resource constraints
3. **Recovery Mechanisms**
- Automatic retry logic
- Transaction rollback
- State verification
- User notifications
## Security Considerations
1. **File System**
- Secure file permissions
- Path validation
- Access control
- Data encryption
2. **IPC Communication**
- Context isolation
- Channel validation
- Data sanitization
- Error handling
3. **Preload Scripts**
- Minimal API exposure
- Type safety
- Input validation
- Error boundaries
## Future Improvements
1. **Performance**
- Query optimization
- Index tuning
- Connection management
- Cache implementation
2. **Features**
- Offline support
- Sync capabilities
- Backup/restore
- Data export/import
3. **Security**
- Database encryption
- Secure storage
- Access control
- Audit logging
## Maintenance
1. **Regular Tasks**
- Database optimization
- Log rotation
- Error monitoring
- Performance tracking
2. **Updates**
- Dependency updates
- Security patches
- Feature additions
- Bug fixes
3. **Documentation**
- API documentation
- Migration guides
- Troubleshooting
- Best practices

55
electron/.gitignore

@ -0,0 +1,55 @@
# NPM renames .gitignore to .npmignore
# In order to prevent that, we remove the initial "."
# And the CLI then renames it
app
node_modules
build
dist
logs
# Node.js dependencies
node_modules/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# Capacitor build outputs
web/
ios/
android/
electron/app/
# Capacitor SQLite plugin data (important!)
capacitor-sqlite/
# TypeScript / build output
dist/
build/
*.log
# Development / IDE files
.env.local
.env.development.local
.env.test.local
.env.production.local
# VS Code
.vscode/
!.vscode/extensions.json
# JetBrains IDEs (IntelliJ, WebStorm, etc.)
.idea/
*.iml
*.iws
# macOS specific
.DS_Store
*.swp
*~
*.tmp
# Windows specific
Thumbs.db
ehthumbs.db
Desktop.ini
$RECYCLE.BIN/

BIN
electron/assets/appIcon.ico

Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

BIN
electron/assets/appIcon.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 121 KiB

BIN
electron/assets/splash.gif

Binary file not shown.

After

Width:  |  Height:  |  Size: 159 KiB

BIN
electron/assets/splash.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

62
electron/capacitor.config.json

@ -0,0 +1,62 @@
{
"appId": "com.timesafari.app",
"appName": "TimeSafari",
"webDir": "dist",
"bundledWebRuntime": false,
"server": {
"cleartext": true,
"androidScheme": "https"
},
"plugins": {
"App": {
"appUrlOpen": {
"handlers": [
{
"url": "timesafari://*",
"autoVerify": true
}
]
}
},
"SQLite": {
"iosDatabaseLocation": "Library/CapacitorDatabase",
"iosIsEncryption": true,
"iosBiometric": {
"biometricAuth": true,
"biometricTitle": "Biometric login for TimeSafari"
},
"androidIsEncryption": true,
"androidBiometric": {
"biometricAuth": true,
"biometricTitle": "Biometric login for TimeSafari"
}
},
"CapacitorSQLite": {
"electronIsEncryption": false,
"electronMacLocation": "~/Library/Application Support/TimeSafari",
"electronWindowsLocation": "C:\\ProgramData\\TimeSafari"
}
},
"ios": {
"contentInset": "always",
"allowsLinkPreview": true,
"scrollEnabled": true,
"limitsNavigationsToAppBoundDomains": true,
"backgroundColor": "#ffffff",
"allowNavigation": [
"*.timesafari.app",
"*.jsdelivr.net",
"api.endorser.ch"
]
},
"android": {
"allowMixedContent": false,
"captureInput": true,
"webContentsDebuggingEnabled": false,
"allowNavigation": [
"*.timesafari.app",
"*.jsdelivr.net",
"api.endorser.ch"
]
}
}

28
electron/electron-builder.config.json

@ -0,0 +1,28 @@
{
"appId": "com.yourdoamnin.yourapp",
"directories": {
"buildResources": "resources"
},
"files": [
"assets/**/*",
"build/**/*",
"capacitor.config.*",
"app/**/*"
],
"publish": {
"provider": "github"
},
"nsis": {
"allowElevation": true,
"oneClick": false,
"allowToChangeInstallationDirectory": true
},
"win": {
"target": "nsis",
"icon": "assets/appIcon.ico"
},
"mac": {
"category": "your.app.category.type",
"target": "dmg"
}
}

75
electron/live-runner.js

@ -0,0 +1,75 @@
/* eslint-disable no-undef */
/* eslint-disable @typescript-eslint/no-var-requires */
const cp = require('child_process');
const chokidar = require('chokidar');
const electron = require('electron');
let child = null;
const npmCmd = process.platform === 'win32' ? 'npm.cmd' : 'npm';
const reloadWatcher = {
debouncer: null,
ready: false,
watcher: null,
restarting: false,
};
///*
function runBuild() {
return new Promise((resolve, _reject) => {
let tempChild = cp.spawn(npmCmd, ['run', 'build']);
tempChild.once('exit', () => {
resolve();
});
tempChild.stdout.pipe(process.stdout);
});
}
//*/
async function spawnElectron() {
if (child !== null) {
child.stdin.pause();
child.kill();
child = null;
await runBuild();
}
child = cp.spawn(electron, ['--inspect=5858', './']);
child.on('exit', () => {
if (!reloadWatcher.restarting) {
process.exit(0);
}
});
child.stdout.pipe(process.stdout);
}
function setupReloadWatcher() {
reloadWatcher.watcher = chokidar
.watch('./src/**/*', {
ignored: /[/\\]\./,
persistent: true,
})
.on('ready', () => {
reloadWatcher.ready = true;
})
.on('all', (_event, _path) => {
if (reloadWatcher.ready) {
clearTimeout(reloadWatcher.debouncer);
reloadWatcher.debouncer = setTimeout(async () => {
console.log('Restarting');
reloadWatcher.restarting = true;
await spawnElectron();
reloadWatcher.restarting = false;
reloadWatcher.ready = false;
clearTimeout(reloadWatcher.debouncer);
reloadWatcher.debouncer = null;
reloadWatcher.watcher = null;
setupReloadWatcher();
}, 500);
}
});
}
(async () => {
await runBuild();
await spawnElectron();
setupReloadWatcher();
})();

5460
electron/package-lock.json

File diff suppressed because it is too large

52
electron/package.json

@ -0,0 +1,52 @@
{
"name": "TimeSafari",
"version": "1.0.0",
"description": "TimeSafari Electron App",
"author": {
"name": "",
"email": ""
},
"repository": {
"type": "git",
"url": ""
},
"license": "MIT",
"main": "build/src/index.js",
"scripts": {
"build": "tsc && electron-rebuild",
"electron:start-live": "node ./live-runner.js",
"electron:start": "npm run build && electron --inspect=5858 ./",
"electron:pack": "npm run build && electron-builder build --dir -c ./electron-builder.config.json",
"electron:make": "npm run build && electron-builder build -c ./electron-builder.config.json -p always"
},
"dependencies": {
"@capacitor-community/electron": "^5.0.0",
"@capacitor-community/sqlite": "^6.0.2",
"better-sqlite3-multiple-ciphers": "^11.10.0",
"chokidar": "~3.5.3",
"crypto": "^1.0.1",
"crypto-js": "^4.2.0",
"electron-is-dev": "~2.0.0",
"electron-json-storage": "^4.6.0",
"electron-serve": "~1.1.0",
"electron-unhandled": "~4.0.1",
"electron-updater": "^5.3.0",
"electron-window-state": "^5.0.3",
"jszip": "^3.10.1",
"node-fetch": "^2.6.7",
"winston": "^3.17.0"
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.13",
"@types/crypto-js": "^4.2.2",
"@types/electron-json-storage": "^4.5.4",
"electron": "^26.2.2",
"electron-builder": "~23.6.0",
"source-map-support": "^0.5.21",
"typescript": "^5.0.4"
},
"keywords": [
"capacitor",
"electron"
]
}

10
electron/resources/electron-publisher-custom.js

@ -0,0 +1,10 @@
/* eslint-disable no-undef */
/* eslint-disable @typescript-eslint/no-var-requires */
const electronPublish = require('electron-publish');
class Publisher extends electronPublish.Publisher {
async upload(task) {
console.log('electron-publisher-custom', task.file);
}
}
module.exports = Publisher;

140
electron/src/index.ts

@ -0,0 +1,140 @@
import type { CapacitorElectronConfig } from '@capacitor-community/electron';
import { getCapacitorElectronConfig, setupElectronDeepLinking } from '@capacitor-community/electron';
import type { MenuItemConstructorOptions } from 'electron';
import { app, MenuItem } from 'electron';
import electronIsDev from 'electron-is-dev';
import unhandled from 'electron-unhandled';
import { autoUpdater } from 'electron-updater';
import { ElectronCapacitorApp, setupContentSecurityPolicy, setupReloadWatcher } from './setup';
import { initializeSQLite, setupSQLiteHandlers } from './rt/sqlite-init';
// Graceful handling of unhandled errors.
unhandled();
// Define our menu templates (these are optional)
const trayMenuTemplate: (MenuItemConstructorOptions | MenuItem)[] = [new MenuItem({ label: 'Quit App', role: 'quit' })];
const appMenuBarMenuTemplate: (MenuItemConstructorOptions | MenuItem)[] = [
{ role: process.platform === 'darwin' ? 'appMenu' : 'fileMenu' },
{ role: 'viewMenu' },
];
// Get Config options from capacitor.config
const capacitorFileConfig: CapacitorElectronConfig = getCapacitorElectronConfig();
// Initialize our app. You can pass menu templates into the app here.
const myCapacitorApp = new ElectronCapacitorApp(capacitorFileConfig, trayMenuTemplate, appMenuBarMenuTemplate);
// If deeplinking is enabled then we will set it up here.
if (capacitorFileConfig.electron?.deepLinkingEnabled) {
setupElectronDeepLinking(myCapacitorApp, {
customProtocol: capacitorFileConfig.electron.deepLinkingCustomProtocol ?? 'mycapacitorapp',
});
}
// If we are in Dev mode, use the file watcher components.
if (electronIsDev) {
setupReloadWatcher(myCapacitorApp);
}
// Run Application
(async () => {
try {
// Wait for electron app to be ready first
await app.whenReady();
console.log('[Electron Main Process] App is ready');
// Initialize SQLite plugin and handlers BEFORE creating any windows
console.log('[Electron Main Process] Initializing SQLite...');
setupSQLiteHandlers();
await initializeSQLite();
console.log('[Electron Main Process] SQLite initialization complete');
// Security - Set Content-Security-Policy
setupContentSecurityPolicy(myCapacitorApp.getCustomURLScheme());
// Initialize our app and create window
console.log('[Electron Main Process] Starting app initialization...');
await myCapacitorApp.init();
console.log('[Electron Main Process] App initialization complete');
// Get the main window
const mainWindow = myCapacitorApp.getMainWindow();
if (!mainWindow) {
throw new Error('Main window not available after app initialization');
}
// Wait for window to be ready and loaded
await new Promise<void>((resolve) => {
const handleReady = () => {
console.log('[Electron Main Process] Window ready to show');
mainWindow.show();
// Wait for window to finish loading
mainWindow.webContents.once('did-finish-load', () => {
console.log('[Electron Main Process] Window finished loading');
// Send SQLite ready signal after window is fully loaded
if (!mainWindow.isDestroyed()) {
mainWindow.webContents.send('sqlite-ready');
console.log('[Electron Main Process] Sent SQLite ready signal to renderer');
} else {
console.warn('[Electron Main Process] Window was destroyed before sending SQLite ready signal');
}
resolve();
});
};
// Always use the event since isReadyToShow is not reliable
mainWindow.once('ready-to-show', handleReady);
});
// Check for updates if we are in a packaged app
if (!electronIsDev) {
console.log('[Electron Main Process] Checking for updates...');
autoUpdater.checkForUpdatesAndNotify();
}
// Handle window close
mainWindow.on('closed', () => {
console.log('[Electron Main Process] Main window closed');
});
// Handle window close request
mainWindow.on('close', (event) => {
console.log('[Electron Main Process] Window close requested');
if (mainWindow.webContents.isLoading()) {
event.preventDefault();
console.log('[Electron Main Process] Deferring window close due to loading state');
mainWindow.webContents.once('did-finish-load', () => {
mainWindow.close();
});
}
});
} catch (error) {
console.error('[Electron Main Process] Fatal error during initialization:', error);
app.quit();
}
})();
// Handle when all of our windows are close (platforms have their own expectations).
app.on('window-all-closed', function () {
// On OS X it is common for applications and their menu bar
// to stay active until the user quits explicitly with Cmd + Q
if (process.platform !== 'darwin') {
app.quit();
}
});
// When the dock icon is clicked.
app.on('activate', async function () {
// On OS X it's common to re-create a window in the app when the
// dock icon is clicked and there are no other windows open.
if (myCapacitorApp.getMainWindow().isDestroyed()) {
await myCapacitorApp.init();
}
});
// Place all ipc or other electron api calls and custom functionality under this line

303
electron/src/preload.ts

@ -0,0 +1,303 @@
/**
* Preload script for Electron
* Sets up secure IPC communication between renderer and main process
*
* @author Matthew Raymer
*/
import { contextBridge, ipcRenderer } from 'electron';
// Enhanced logger for preload script that forwards to main process
const logger = {
log: (...args: unknown[]) => {
console.log('[Preload]', ...args);
ipcRenderer.send('renderer-log', { level: 'log', args });
},
error: (...args: unknown[]) => {
console.error('[Preload]', ...args);
ipcRenderer.send('renderer-log', { level: 'error', args });
},
info: (...args: unknown[]) => {
console.info('[Preload]', ...args);
ipcRenderer.send('renderer-log', { level: 'info', args });
},
warn: (...args: unknown[]) => {
console.warn('[Preload]', ...args);
ipcRenderer.send('renderer-log', { level: 'warn', args });
},
debug: (...args: unknown[]) => {
console.debug('[Preload]', ...args);
ipcRenderer.send('renderer-log', { level: 'debug', args });
},
sqlite: {
log: (operation: string, ...args: unknown[]) => {
const message = ['[Preload][SQLite]', operation, ...args];
console.log(...message);
ipcRenderer.send('renderer-log', {
level: 'log',
args: message,
source: 'sqlite',
operation
});
},
error: (operation: string, error: unknown) => {
const message = ['[Preload][SQLite]', operation, 'failed:', error];
console.error(...message);
ipcRenderer.send('renderer-log', {
level: 'error',
args: message,
source: 'sqlite',
operation,
error: error instanceof Error ? {
name: error.name,
message: error.message,
stack: error.stack
} : error
});
},
debug: (operation: string, ...args: unknown[]) => {
const message = ['[Preload][SQLite]', operation, ...args];
console.debug(...message);
ipcRenderer.send('renderer-log', {
level: 'debug',
args: message,
source: 'sqlite',
operation
});
}
}
};
// Types for SQLite connection options
interface SQLiteConnectionOptions {
database: string;
version?: number;
readOnly?: boolean;
readonly?: boolean; // Handle both cases
encryption?: string;
mode?: string;
useNative?: boolean;
[key: string]: unknown; // Allow other properties
}
// Define valid channels for security
const VALID_CHANNELS = {
send: ['toMain'] as const,
receive: ['fromMain', 'sqlite-ready', 'database-status'] as const,
invoke: [
'sqlite-is-available',
'sqlite-echo',
'sqlite-create-connection',
'sqlite-execute',
'sqlite-query',
'sqlite-run',
'sqlite-close-connection',
'sqlite-open',
'sqlite-close',
'sqlite-is-db-open',
'sqlite-status',
'get-path',
'get-base-path'
] as const
};
type ValidSendChannel = typeof VALID_CHANNELS.send[number];
type ValidReceiveChannel = typeof VALID_CHANNELS.receive[number];
type ValidInvokeChannel = typeof VALID_CHANNELS.invoke[number];
// Create a secure IPC bridge
const createSecureIPCBridge = () => {
return {
send: (channel: string, data: unknown) => {
if (VALID_CHANNELS.send.includes(channel as ValidSendChannel)) {
logger.debug('IPC Send:', channel, data);
ipcRenderer.send(channel, data);
} else {
logger.warn(`[Preload] Attempted to send on invalid channel: ${channel}`);
}
},
receive: (channel: string, func: (...args: unknown[]) => void) => {
if (VALID_CHANNELS.receive.includes(channel as ValidReceiveChannel)) {
logger.debug('IPC Receive:', channel);
ipcRenderer.on(channel, (_event, ...args) => {
logger.debug('IPC Received:', channel, args);
func(...args);
});
} else {
logger.warn(`[Preload] Attempted to receive on invalid channel: ${channel}`);
}
},
once: (channel: string, func: (...args: unknown[]) => void) => {
if (VALID_CHANNELS.receive.includes(channel as ValidReceiveChannel)) {
logger.debug('IPC Once:', channel);
ipcRenderer.once(channel, (_event, ...args) => {
logger.debug('IPC Received Once:', channel, args);
func(...args);
});
} else {
logger.warn(`[Preload] Attempted to receive once on invalid channel: ${channel}`);
}
},
invoke: async (channel: string, ...args: unknown[]) => {
if (VALID_CHANNELS.invoke.includes(channel as ValidInvokeChannel)) {
logger.debug('IPC Invoke:', channel, args);
try {
const result = await ipcRenderer.invoke(channel, ...args);
logger.debug('IPC Invoke Result:', channel, result);
return result;
} catch (error) {
logger.error('IPC Invoke Error:', channel, error);
throw error;
}
} else {
logger.warn(`[Preload] Attempted to invoke on invalid channel: ${channel}`);
throw new Error(`Invalid channel: ${channel}`);
}
}
};
};
// Create SQLite proxy with retry logic
const createSQLiteProxy = () => {
const MAX_RETRIES = 3;
const RETRY_DELAY = 1000;
const withRetry = async <T>(operation: string, ...args: unknown[]): Promise<T> => {
let lastError: Error | undefined;
const operationId = `${operation}_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
const startTime = Date.now();
logger.sqlite.debug(operation, 'starting with args:', {
operationId,
args,
timestamp: new Date().toISOString()
});
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
try {
logger.sqlite.debug(operation, `attempt ${attempt}/${MAX_RETRIES}`, {
operationId,
attempt,
args,
timestamp: new Date().toISOString()
});
// Log the exact IPC call
logger.sqlite.debug(operation, 'invoking IPC', {
operationId,
channel: `sqlite-${operation}`,
args,
timestamp: new Date().toISOString()
});
const result = await ipcRenderer.invoke(`sqlite-${operation}`, ...args);
const duration = Date.now() - startTime;
logger.sqlite.log(operation, 'success', {
operationId,
attempt,
result,
duration: `${duration}ms`,
timestamp: new Date().toISOString()
});
return result as T;
} catch (error) {
const duration = Date.now() - startTime;
lastError = error instanceof Error ? error : new Error(String(error));
logger.sqlite.error(operation, {
operationId,
attempt,
error: {
name: lastError.name,
message: lastError.message,
stack: lastError.stack
},
args,
duration: `${duration}ms`,
timestamp: new Date().toISOString()
});
if (attempt < MAX_RETRIES) {
const backoffDelay = RETRY_DELAY * Math.pow(2, attempt - 1);
logger.warn(`[Preload] SQLite ${operation} failed (attempt ${attempt}/${MAX_RETRIES}), retrying in ${backoffDelay}ms...`, {
operationId,
error: lastError,
args,
nextAttemptIn: `${backoffDelay}ms`,
timestamp: new Date().toISOString()
});
await new Promise(resolve => setTimeout(resolve, backoffDelay));
}
}
}
const finalError = new Error(
`SQLite ${operation} failed after ${MAX_RETRIES} attempts: ${lastError?.message || "Unknown error"}`
);
logger.error('[Preload] SQLite operation failed permanently:', {
operation,
operationId,
error: {
name: finalError.name,
message: finalError.message,
stack: finalError.stack,
originalError: lastError
},
args,
attempts: MAX_RETRIES,
timestamp: new Date().toISOString()
});
throw finalError;
};
return {
isAvailable: () => withRetry('is-available'),
echo: (value: string) => withRetry('echo', { value }),
createConnection: (options: SQLiteConnectionOptions) => withRetry('create-connection', options),
closeConnection: (options: { database: string }) => withRetry('close-connection', options),
query: (options: { statement: string; values?: unknown[] }) => withRetry('query', options),
run: (options: { statement: string; values?: unknown[] }) => withRetry('run', options),
execute: (options: { statements: { statement: string; values?: unknown[] }[] }) => withRetry('execute', options),
getPlatform: () => Promise.resolve('electron')
};
};
try {
// Expose the secure IPC bridge and SQLite proxy
const electronAPI = {
ipcRenderer: createSecureIPCBridge(),
sqlite: createSQLiteProxy(),
env: {
platform: 'electron',
isDev: process.env.NODE_ENV === 'development'
}
};
// Log the exposed API for debugging
logger.debug('Exposing Electron API:', {
hasIpcRenderer: !!electronAPI.ipcRenderer,
hasSqlite: !!electronAPI.sqlite,
sqliteMethods: Object.keys(electronAPI.sqlite),
env: electronAPI.env
});
contextBridge.exposeInMainWorld('electron', electronAPI);
logger.info('[Preload] IPC bridge and SQLite proxy initialized successfully');
} catch (error) {
logger.error('[Preload] Failed to initialize IPC bridge:', error);
}
// Log startup
logger.log('[CapacitorSQLite] Preload script starting...');
// Handle window load
window.addEventListener('load', () => {
logger.log('[CapacitorSQLite] Preload script complete');
});

6
electron/src/rt/electron-plugins.js

@ -0,0 +1,6 @@
/* eslint-disable @typescript-eslint/no-var-requires */
const CapacitorCommunitySqlite = require('../../../node_modules/@capacitor-community/sqlite/electron/dist/plugin.js');
module.exports = {
CapacitorCommunitySqlite,
}

88
electron/src/rt/electron-rt.ts

@ -0,0 +1,88 @@
import { randomBytes } from 'crypto';
import { ipcRenderer, contextBridge } from 'electron';
import { EventEmitter } from 'events';
////////////////////////////////////////////////////////
// eslint-disable-next-line @typescript-eslint/no-var-requires
const plugins = require('./electron-plugins');
const randomId = (length = 5) => randomBytes(length).toString('hex');
const contextApi: {
[plugin: string]: { [functionName: string]: () => Promise<any> };
} = {};
Object.keys(plugins).forEach((pluginKey) => {
Object.keys(plugins[pluginKey])
.filter((className) => className !== 'default')
.forEach((classKey) => {
const functionList = Object.getOwnPropertyNames(plugins[pluginKey][classKey].prototype).filter(
(v) => v !== 'constructor'
);
if (!contextApi[classKey]) {
contextApi[classKey] = {};
}
functionList.forEach((functionName) => {
if (!contextApi[classKey][functionName]) {
contextApi[classKey][functionName] = (...args) => ipcRenderer.invoke(`${classKey}-${functionName}`, ...args);
}
});
// Events
if (plugins[pluginKey][classKey].prototype instanceof EventEmitter) {
const listeners: { [key: string]: { type: string; listener: (...args: any[]) => void } } = {};
const listenersOfTypeExist = (type) =>
!!Object.values(listeners).find((listenerObj) => listenerObj.type === type);
Object.assign(contextApi[classKey], {
addListener(type: string, callback: (...args) => void) {
const id = randomId();
// Deduplicate events
if (!listenersOfTypeExist(type)) {
ipcRenderer.send(`event-add-${classKey}`, type);
}
const eventHandler = (_, ...args) => callback(...args);
ipcRenderer.addListener(`event-${classKey}-${type}`, eventHandler);
listeners[id] = { type, listener: eventHandler };
return id;
},
removeListener(id: string) {
if (!listeners[id]) {
throw new Error('Invalid id');
}
const { type, listener } = listeners[id];
ipcRenderer.removeListener(`event-${classKey}-${type}`, listener);
delete listeners[id];
if (!listenersOfTypeExist(type)) {
ipcRenderer.send(`event-remove-${classKey}-${type}`);
}
},
removeAllListeners(type: string) {
Object.entries(listeners).forEach(([id, listenerObj]) => {
if (!type || listenerObj.type === type) {
ipcRenderer.removeListener(`event-${classKey}-${listenerObj.type}`, listenerObj.listener);
ipcRenderer.send(`event-remove-${classKey}-${listenerObj.type}`);
delete listeners[id];
}
});
},
});
}
});
});
contextBridge.exposeInMainWorld('CapacitorCustomPlatform', {
name: 'electron',
plugins: contextApi,
});
////////////////////////////////////////////////////////

188
electron/src/rt/logger.ts

@ -0,0 +1,188 @@
/**
* Enhanced logging system for TimeSafari Electron
* Provides structured logging with proper levels and formatting
* Supports both console and file output with different verbosity levels
*
* @author Matthew Raymer
*/
import { app, ipcMain } from 'electron';
import winston from 'winston';
import path from 'path';
import os from 'os';
import fs from 'fs';
// Extend Winston Logger type with our custom loggers
declare module 'winston' {
interface Logger {
sqlite: {
debug: (message: string, ...args: unknown[]) => void;
info: (message: string, ...args: unknown[]) => void;
warn: (message: string, ...args: unknown[]) => void;
error: (message: string, ...args: unknown[]) => void;
};
migration: {
debug: (message: string, ...args: unknown[]) => void;
info: (message: string, ...args: unknown[]) => void;
warn: (message: string, ...args: unknown[]) => void;
error: (message: string, ...args: unknown[]) => void;
};
}
}
// Create logs directory if it doesn't exist
const logsDir = path.join(app.getPath('userData'), 'logs');
if (!fs.existsSync(logsDir)) {
fs.mkdirSync(logsDir, { recursive: true });
}
// Custom format for console output with migration filtering
const consoleFormat = winston.format.combine(
winston.format.timestamp(),
winston.format.colorize(),
winston.format.printf(({ level, message, timestamp, ...metadata }) => {
// Skip migration logs unless DEBUG_MIGRATIONS is set
if (level === 'info' &&
typeof message === 'string' &&
message.includes('[Migration]') &&
!process.env.DEBUG_MIGRATIONS) {
return '';
}
let msg = `${timestamp} [${level}] ${message}`;
if (Object.keys(metadata).length > 0) {
msg += ` ${JSON.stringify(metadata, null, 2)}`;
}
return msg;
})
);
// Custom format for file output
const fileFormat = winston.format.combine(
winston.format.timestamp(),
winston.format.json()
);
// Create logger instance
const logger = winston.createLogger({
level: process.env.NODE_ENV === 'development' ? 'debug' : 'info',
format: fileFormat,
defaultMeta: { service: 'timesafari-electron' },
transports: [
// Console transport with custom format and migration filtering
new winston.transports.Console({
format: consoleFormat,
level: process.env.NODE_ENV === 'development' ? 'debug' : 'info',
silent: false // Ensure we can still see non-migration logs
}),
// File transport for all logs
new winston.transports.File({
filename: path.join(logsDir, 'error.log'),
level: 'error',
maxsize: 5242880, // 5MB
maxFiles: 5
}),
// File transport for all logs including debug
new winston.transports.File({
filename: path.join(logsDir, 'combined.log'),
maxsize: 5242880, // 5MB
maxFiles: 5
})
]
}) as winston.Logger & {
sqlite: {
debug: (message: string, ...args: unknown[]) => void;
info: (message: string, ...args: unknown[]) => void;
warn: (message: string, ...args: unknown[]) => void;
error: (message: string, ...args: unknown[]) => void;
};
migration: {
debug: (message: string, ...args: unknown[]) => void;
info: (message: string, ...args: unknown[]) => void;
warn: (message: string, ...args: unknown[]) => void;
error: (message: string, ...args: unknown[]) => void;
};
};
// Add SQLite specific logger
logger.sqlite = {
debug: (message: string, ...args: unknown[]) => {
logger.debug(`[SQLite] ${message}`, ...args);
},
info: (message: string, ...args: unknown[]) => {
logger.info(`[SQLite] ${message}`, ...args);
},
warn: (message: string, ...args: unknown[]) => {
logger.warn(`[SQLite] ${message}`, ...args);
},
error: (message: string, ...args: unknown[]) => {
logger.error(`[SQLite] ${message}`, ...args);
}
};
// Add migration specific logger with debug filtering
logger.migration = {
debug: (message: string, ...args: unknown[]) => {
if (process.env.DEBUG_MIGRATIONS) {
//logger.debug(`[Migration] ${message}`, ...args);
}
},
info: (message: string, ...args: unknown[]) => {
// Always log to file, but only log to console if DEBUG_MIGRATIONS is set
if (process.env.DEBUG_MIGRATIONS) {
//logger.info(`[Migration] ${message}`, ...args);
} else {
// Use a separate transport for migration logs to file only
const metadata = args[0] as Record<string, unknown>;
logger.write({
level: 'info',
message: `[Migration] ${message}`,
...(metadata || {})
});
}
},
warn: (message: string, ...args: unknown[]) => {
// Always log warnings to both console and file
//logger.warn(`[Migration] ${message}`, ...args);
},
error: (message: string, ...args: unknown[]) => {
// Always log errors to both console and file
//logger.error(`[Migration] ${message}`, ...args);
}
};
// Add renderer log handler
ipcMain.on('renderer-log', (_event, { level, args, source, operation, error }) => {
const message = args.map((arg: unknown) =>
typeof arg === 'object' ? JSON.stringify(arg, null, 2) : String(arg)
).join(' ');
const meta = {
source: source || 'renderer',
...(operation && { operation }),
...(error && { error })
};
switch (level) {
case 'error':
logger.error(message, meta);
break;
case 'warn':
logger.warn(message, meta);
break;
case 'info':
logger.info(message, meta);
break;
case 'debug':
logger.debug(message, meta);
break;
default:
logger.log(level, message, meta);
}
});
// Export logger instance
export { logger };
// Export a function to get the logs directory
export const getLogsDirectory = () => logsDir;

14
electron/src/rt/sqlite-error.ts

@ -0,0 +1,14 @@
/**
* Custom error class for SQLite operations
* Provides additional context and error tracking for SQLite operations
*/
export class SQLiteError extends Error {
constructor(
message: string,
public operation: string,
public cause?: unknown
) {
super(message);
this.name = 'SQLiteError';
}
}

1147
electron/src/rt/sqlite-init.ts

File diff suppressed because it is too large

1261
electron/src/rt/sqlite-migrations.ts

File diff suppressed because it is too large

442
electron/src/setup.ts

@ -0,0 +1,442 @@
import type { CapacitorElectronConfig } from '@capacitor-community/electron';
import {
CapElectronEventEmitter,
CapacitorSplashScreen,
setupCapacitorElectronPlugins,
} from '@capacitor-community/electron';
import chokidar from 'chokidar';
import type { MenuItemConstructorOptions } from 'electron';
import { app, BrowserWindow, Menu, MenuItem, nativeImage, Tray, session } from 'electron';
import electronIsDev from 'electron-is-dev';
import electronServe from 'electron-serve';
import windowStateKeeper from 'electron-window-state';
import { join } from 'path';
/**
* Reload watcher configuration and state management
* Prevents infinite reload loops and implements rate limiting
* Also prevents reloads during critical database operations
*
* @author Matthew Raymer
*/
const RELOAD_CONFIG = {
DEBOUNCE_MS: 1500,
COOLDOWN_MS: 5000,
MAX_RELOADS_PER_MINUTE: 10,
MAX_RELOADS_PER_SESSION: 100,
DATABASE_OPERATION_TIMEOUT_MS: 10000 // 10 second timeout for database operations
};
// Track database operation state
let isDatabaseOperationInProgress = false;
let lastDatabaseOperationTime = 0;
/**
* Checks if a database operation is in progress or recently completed
* @returns {boolean} Whether a database operation is active
*/
const isDatabaseOperationActive = (): boolean => {
const now = Date.now();
return isDatabaseOperationInProgress ||
(now - lastDatabaseOperationTime < RELOAD_CONFIG.DATABASE_OPERATION_TIMEOUT_MS);
};
/**
* Marks the start of a database operation
*/
export const startDatabaseOperation = (): void => {
isDatabaseOperationInProgress = true;
lastDatabaseOperationTime = Date.now();
};
/**
* Marks the end of a database operation
*/
export const endDatabaseOperation = (): void => {
isDatabaseOperationInProgress = false;
lastDatabaseOperationTime = Date.now();
};
const reloadWatcher = {
debouncer: null as NodeJS.Timeout | null,
ready: false,
watcher: null as chokidar.FSWatcher | null,
lastReloadTime: 0,
reloadCount: 0,
sessionReloadCount: 0,
resetTimeout: null as NodeJS.Timeout | null,
isReloading: false
};
/**
* Resets the reload counter after one minute
*/
const resetReloadCounter = () => {
reloadWatcher.reloadCount = 0;
reloadWatcher.resetTimeout = null;
};
/**
* Checks if a reload is allowed based on rate limits, cooldown, and database state
* @returns {boolean} Whether a reload is allowed
*/
const canReload = (): boolean => {
const now = Date.now();
// Check if database operation is active
if (isDatabaseOperationActive()) {
console.warn('[Reload Watcher] Skipping reload - database operation in progress');
return false;
}
// Check cooldown period
if (now - reloadWatcher.lastReloadTime < RELOAD_CONFIG.COOLDOWN_MS) {
console.warn('[Reload Watcher] Skipping reload - cooldown period active');
return false;
}
// Check per-minute limit
if (reloadWatcher.reloadCount >= RELOAD_CONFIG.MAX_RELOADS_PER_MINUTE) {
console.warn('[Reload Watcher] Skipping reload - maximum reloads per minute reached');
return false;
}
// Check session limit
if (reloadWatcher.sessionReloadCount >= RELOAD_CONFIG.MAX_RELOADS_PER_SESSION) {
console.error('[Reload Watcher] Maximum reloads per session reached. Please restart the application.');
return false;
}
return true;
};
/**
* Cleans up the current watcher instance
*/
const cleanupWatcher = () => {
if (reloadWatcher.watcher) {
reloadWatcher.watcher.close();
reloadWatcher.watcher = null;
}
if (reloadWatcher.debouncer) {
clearTimeout(reloadWatcher.debouncer);
reloadWatcher.debouncer = null;
}
if (reloadWatcher.resetTimeout) {
clearTimeout(reloadWatcher.resetTimeout);
reloadWatcher.resetTimeout = null;
}
};
/**
* Sets up the file watcher for development mode reloading
* Implements rate limiting and prevents infinite reload loops
*
* @param electronCapacitorApp - The Electron Capacitor app instance
*/
export function setupReloadWatcher(electronCapacitorApp: ElectronCapacitorApp): void {
// Cleanup any existing watcher
cleanupWatcher();
// Reset state
reloadWatcher.ready = false;
reloadWatcher.isReloading = false;
reloadWatcher.watcher = chokidar
.watch(join(app.getAppPath(), 'app'), {
ignored: /[/\\]\./,
persistent: true,
awaitWriteFinish: {
stabilityThreshold: 1000,
pollInterval: 100
}
})
.on('ready', () => {
reloadWatcher.ready = true;
console.log('[Reload Watcher] Ready to watch for changes');
})
.on('all', (_event, _path) => {
if (!reloadWatcher.ready || reloadWatcher.isReloading) {
return;
}
// Clear existing debouncer
if (reloadWatcher.debouncer) {
clearTimeout(reloadWatcher.debouncer);
}
// Set up new debouncer
reloadWatcher.debouncer = setTimeout(async () => {
if (!canReload()) {
return;
}
try {
reloadWatcher.isReloading = true;
// Update reload counters
reloadWatcher.lastReloadTime = Date.now();
reloadWatcher.reloadCount++;
reloadWatcher.sessionReloadCount++;
// Set up reset timeout for per-minute counter
if (!reloadWatcher.resetTimeout) {
reloadWatcher.resetTimeout = setTimeout(resetReloadCounter, 60000);
}
// Perform reload
console.log('[Reload Watcher] Reloading window...');
await electronCapacitorApp.getMainWindow().webContents.reload();
// Reset state after reload
reloadWatcher.ready = false;
reloadWatcher.isReloading = false;
// Re-setup watcher after successful reload
setupReloadWatcher(electronCapacitorApp);
} catch (error) {
console.error('[Reload Watcher] Error during reload:', error);
reloadWatcher.isReloading = false;
reloadWatcher.ready = true;
}
}, RELOAD_CONFIG.DEBOUNCE_MS);
})
.on('error', (error) => {
console.error('[Reload Watcher] Error:', error);
cleanupWatcher();
});
}
// Define our class to manage our app.
export class ElectronCapacitorApp {
private MainWindow: BrowserWindow | null = null;
private SplashScreen: CapacitorSplashScreen | null = null;
private TrayIcon: Tray | null = null;
private CapacitorFileConfig: CapacitorElectronConfig;
private TrayMenuTemplate: (MenuItem | MenuItemConstructorOptions)[] = [
new MenuItem({ label: 'Quit App', role: 'quit' }),
];
private AppMenuBarMenuTemplate: (MenuItem | MenuItemConstructorOptions)[] = [
{ role: process.platform === 'darwin' ? 'appMenu' : 'fileMenu' },
{ role: 'viewMenu' },
];
private mainWindowState;
private loadWebApp;
private customScheme: string;
constructor(
capacitorFileConfig: CapacitorElectronConfig,
trayMenuTemplate?: (MenuItemConstructorOptions | MenuItem)[],
appMenuBarMenuTemplate?: (MenuItemConstructorOptions | MenuItem)[]
) {
this.CapacitorFileConfig = capacitorFileConfig;
this.customScheme = this.CapacitorFileConfig.electron?.customUrlScheme ?? 'capacitor-electron';
if (trayMenuTemplate) {
this.TrayMenuTemplate = trayMenuTemplate;
}
if (appMenuBarMenuTemplate) {
this.AppMenuBarMenuTemplate = appMenuBarMenuTemplate;
}
// Setup our web app loader, this lets us load apps like react, vue, and angular without changing their build chains.
this.loadWebApp = electronServe({
directory: join(app.getAppPath(), 'app'),
scheme: this.customScheme,
});
}
// Helper function to load in the app.
private async loadMainWindow(thisRef: any) {
await thisRef.loadWebApp(thisRef.MainWindow);
}
// Expose the mainWindow ref for use outside of the class.
getMainWindow(): BrowserWindow {
return this.MainWindow;
}
getCustomURLScheme(): string {
return this.customScheme;
}
async init(): Promise<void> {
const icon = nativeImage.createFromPath(
join(app.getAppPath(), 'assets', process.platform === 'win32' ? 'appIcon.ico' : 'appIcon.png')
);
this.mainWindowState = windowStateKeeper({
defaultWidth: 1000,
defaultHeight: 800,
});
// Setup preload script path based on environment
const preloadPath = app.isPackaged
? join(process.resourcesPath, 'preload.js')
: join(__dirname, 'preload.js');
console.log('[Electron Main Process] Preload path:', preloadPath);
console.log('[Electron Main Process] Preload exists:', require('fs').existsSync(preloadPath));
this.MainWindow = new BrowserWindow({
icon,
show: false,
x: this.mainWindowState.x,
y: this.mainWindowState.y,
width: this.mainWindowState.width,
height: this.mainWindowState.height,
webPreferences: {
nodeIntegration: false,
contextIsolation: true,
sandbox: false,
preload: preloadPath,
webSecurity: true,
allowRunningInsecureContent: false,
},
});
this.mainWindowState.manage(this.MainWindow);
if (this.CapacitorFileConfig.backgroundColor) {
this.MainWindow.setBackgroundColor(this.CapacitorFileConfig.electron.backgroundColor);
}
// If we close the main window with the splashscreen enabled we need to destory the ref.
this.MainWindow.on('closed', () => {
if (this.SplashScreen?.getSplashWindow() && !this.SplashScreen.getSplashWindow().isDestroyed()) {
this.SplashScreen.getSplashWindow().close();
}
});
// When the tray icon is enabled, setup the options.
if (this.CapacitorFileConfig.electron?.trayIconAndMenuEnabled) {
this.TrayIcon = new Tray(icon);
this.TrayIcon.on('double-click', () => {
if (this.MainWindow) {
if (this.MainWindow.isVisible()) {
this.MainWindow.hide();
} else {
this.MainWindow.show();
this.MainWindow.focus();
}
}
});
this.TrayIcon.on('click', () => {
if (this.MainWindow) {
if (this.MainWindow.isVisible()) {
this.MainWindow.hide();
} else {
this.MainWindow.show();
this.MainWindow.focus();
}
}
});
this.TrayIcon.setToolTip(app.getName());
this.TrayIcon.setContextMenu(Menu.buildFromTemplate(this.TrayMenuTemplate));
}
// Setup the main manu bar at the top of our window.
Menu.setApplicationMenu(Menu.buildFromTemplate(this.AppMenuBarMenuTemplate));
// If the splashscreen is enabled, show it first while the main window loads then switch it out for the main window, or just load the main window from the start.
if (this.CapacitorFileConfig.electron?.splashScreenEnabled) {
this.SplashScreen = new CapacitorSplashScreen({
imageFilePath: join(
app.getAppPath(),
'assets',
this.CapacitorFileConfig.electron?.splashScreenImageName ?? 'splash.png'
),
windowWidth: 400,
windowHeight: 400,
});
this.SplashScreen.init(this.loadMainWindow, this);
} else {
this.loadMainWindow(this);
}
// Security
this.MainWindow.webContents.setWindowOpenHandler((details) => {
if (!details.url.includes(this.customScheme)) {
return { action: 'deny' };
} else {
return { action: 'allow' };
}
});
this.MainWindow.webContents.on('will-navigate', (event, _newURL) => {
if (!this.MainWindow.webContents.getURL().includes(this.customScheme)) {
event.preventDefault();
}
});
// Link electron plugins into the system.
setupCapacitorElectronPlugins();
// When the web app is loaded we hide the splashscreen if needed and show the mainwindow.
this.MainWindow.webContents.on('dom-ready', () => {
if (this.CapacitorFileConfig.electron?.splashScreenEnabled) {
this.SplashScreen.getSplashWindow().hide();
}
if (!this.CapacitorFileConfig.electron?.hideMainWindowOnLaunch) {
this.MainWindow.show();
}
// Re-register SQLite handlers after reload
if (electronIsDev) {
console.log('[Electron Main Process] Re-registering SQLite handlers after reload');
const { setupSQLiteHandlers } = require('./rt/sqlite-init');
setupSQLiteHandlers();
}
setTimeout(() => {
if (electronIsDev) {
this.MainWindow.webContents.openDevTools();
}
CapElectronEventEmitter.emit('CAPELECTRON_DeeplinkListenerInitialized', '');
}, 400);
});
}
}
// Set a CSP up for our application based on the custom scheme
export function setupContentSecurityPolicy(customScheme: string): void {
session.defaultSession.webRequest.onHeadersReceived((details, callback) => {
callback({
responseHeaders: {
...details.responseHeaders,
'Content-Security-Policy': [
// Base CSP for both dev and prod
`default-src ${customScheme}://*;`,
// Script sources
`script-src ${customScheme}://* 'self' 'unsafe-inline'${electronIsDev ? " 'unsafe-eval'" : ''};`,
// Style sources
`style-src ${customScheme}://* 'self' 'unsafe-inline' https://fonts.googleapis.com;`,
// Font sources
`font-src ${customScheme}://* 'self' https://fonts.gstatic.com;`,
// Image sources
`img-src ${customScheme}://* 'self' data: https:;`,
// Connect sources (for API calls)
`connect-src ${customScheme}://* 'self' https:;`,
// Worker sources
`worker-src ${customScheme}://* 'self' blob:;`,
// Frame sources
`frame-src ${customScheme}://* 'self';`,
// Media sources
`media-src ${customScheme}://* 'self' data:;`,
// Object sources
`object-src 'none';`,
// Base URI
`base-uri 'self';`,
// Form action
`form-action ${customScheme}://* 'self';`,
// Frame ancestors
`frame-ancestors 'none';`,
// Upgrade insecure requests
'upgrade-insecure-requests;',
// Block mixed content
'block-all-mixed-content;'
].join(' ')
},
});
});
}

18
electron/tsconfig.json

@ -0,0 +1,18 @@
{
"compileOnSave": true,
"include": ["./src/**/*", "./capacitor.config.ts", "./capacitor.config.js"],
"compilerOptions": {
"outDir": "./build",
"importHelpers": true,
"target": "ES2020",
"module": "CommonJS",
"moduleResolution": "node",
"esModuleInterop": true,
"typeRoots": ["./node_modules/@types"],
"allowJs": true,
"rootDir": ".",
"skipLibCheck": true,
"resolveJsonModule": true
}
}

155
experiment.sh

@ -0,0 +1,155 @@
#!/bin/bash
# experiment.sh
# Author: Matthew Raymer
# Description: Build script for TimeSafari Electron application
# This script handles the complete build process for the TimeSafari Electron app,
# including web asset compilation and Capacitor sync.
#
# Build Process:
# 1. Environment setup and dependency checks
# 2. Web asset compilation (Vite)
# 3. Capacitor sync
# 4. Electron start
#
# Dependencies:
# - Node.js and npm
# - TypeScript
# - Vite
# - @capacitor-community/electron
#
# Usage: ./experiment.sh
#
# Exit Codes:
# 1 - Required command not found
# 2 - TypeScript installation failed
# 3 - Build process failed
# 4 - Capacitor sync failed
# 5 - Electron start failed
# Exit on any error
set -e
# ANSI color codes for better output formatting
readonly RED='\033[0;31m'
readonly GREEN='\033[0;32m'
readonly YELLOW='\033[1;33m'
readonly BLUE='\033[0;34m'
readonly NC='\033[0m' # No Color
# Logging functions
log_info() {
echo -e "${BLUE}[$(date '+%Y-%m-%d %H:%M:%S')] [INFO]${NC} $1"
}
log_success() {
echo -e "${GREEN}[$(date '+%Y-%m-%d %H:%M:%S')] [SUCCESS]${NC} $1"
}
log_warn() {
echo -e "${YELLOW}[$(date '+%Y-%m-%d %H:%M:%S')] [WARN]${NC} $1"
}
log_error() {
echo -e "${RED}[$(date '+%Y-%m-%d %H:%M:%S')] [ERROR]${NC} $1"
}
# Function to check if a command exists
check_command() {
if ! command -v "$1" &> /dev/null; then
log_error "$1 is required but not installed."
exit 1
fi
log_info "Found $1: $(command -v "$1")"
}
# Function to measure and log execution time
measure_time() {
local start_time=$(date +%s)
"$@"
local end_time=$(date +%s)
local duration=$((end_time - start_time))
log_success "Completed in ${duration} seconds"
}
# Print build header
echo -e "\n${BLUE}=== TimeSafari Electron Build Process ===${NC}\n"
log_info "Starting build process at $(date)"
# Check required commands
log_info "Checking required dependencies..."
check_command node
check_command npm
check_command git
# Create application data directory
log_info "Setting up application directories..."
mkdir -p ~/.local/share/TimeSafari/timesafari
# Clean up previous builds
log_info "Cleaning previous builds..."
rm -rf dist* || log_warn "No previous builds to clean"
# Set environment variables for the build
log_info "Configuring build environment..."
export VITE_PLATFORM=electron
export VITE_PWA_ENABLED=false
export VITE_DISABLE_PWA=true
export DEBUG_MIGRATIONS=0
# Ensure TypeScript is installed
log_info "Verifying TypeScript installation..."
if [ ! -f "./node_modules/.bin/tsc" ]; then
log_info "Installing TypeScript..."
if ! npm install --save-dev typescript@~5.2.2; then
log_error "TypeScript installation failed!"
exit 2
fi
# Verify installation
if [ ! -f "./node_modules/.bin/tsc" ]; then
log_error "TypeScript installation verification failed!"
exit 2
fi
log_success "TypeScript installed successfully"
else
log_info "TypeScript already installed"
fi
# Get git hash for versioning
GIT_HASH=$(git log -1 --pretty=format:%h)
log_info "Using git hash: ${GIT_HASH}"
# Build web assets
log_info "Building web assets with Vite..."
if ! measure_time env VITE_GIT_HASH="$GIT_HASH" npx vite build --config vite.config.app.electron.mts --mode electron; then
log_error "Web asset build failed!"
exit 3
fi
# Sync with Capacitor
log_info "Syncing with Capacitor..."
if ! measure_time npx cap sync electron; then
log_error "Capacitor sync failed!"
exit 4
fi
# Restore capacitor config
log_info "Restoring capacitor config..."
if ! git checkout electron/capacitor.config.json; then
log_error "Failed to restore capacitor config!"
exit 4
fi
# Start Electron
log_info "Starting Electron..."
cd electron/
if ! measure_time npm run electron:start; then
log_error "Electron start failed!"
exit 5
fi
# Print build summary
log_success "Build and start completed successfully!"
echo -e "\n${GREEN}=== End of Build Process ===${NC}\n"
# Exit with success
exit 0

948
package-lock.json

File diff suppressed because it is too large

47
package.json

@ -11,7 +11,7 @@
"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-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.cjs && node scripts/copy-wasm.cjs",
"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:web": "npx playwright test -c playwright.config-local.ts --trace on",
@ -22,14 +22,15 @@
"check:ios-device": "xcrun xctrace list devices 2>&1 | grep -w 'Booted' || (echo 'No iOS simulator running' && exit 1)",
"clean:electron": "rimraf dist-electron",
"build:pywebview": "vite build --config vite.config.pywebview.mts",
"build:electron": "npm run clean:electron && tsc -p tsconfig.electron.json && vite build --config vite.config.electron.mts && node scripts/build-electron.js",
"build:capacitor": "vite build --mode capacitor --config vite.config.capacitor.mts",
"build:web": "VITE_GIT_HASH=`git log -1 --pretty=format:%h` vite build --config vite.config.web.mts",
"build:web:electron": "VITE_GIT_HASH=`git log -1 --pretty=format:%h` vite build --config vite.config.web.mts && VITE_GIT_HASH=`git log -1 --pretty=format:%h` vite build --config vite.config.electron.mts --mode electron",
"build:electron": "npm run clean:electron && npm run build:web:electron && tsc -p tsconfig.electron.json && vite build --config vite.config.electron.mts && node scripts/build-electron.cjs",
"build:capacitor": "vite build --mode capacitor --config vite.config.capacitor.mts",
"electron:dev": "npm run build && electron .",
"electron:start": "electron .",
"clean:android": "adb uninstall app.timesafari.app || true",
"build:android": "npm run clean:android && rm -rf dist && npm run build:web && npm run build:capacitor && cd android && ./gradlew clean && ./gradlew assembleDebug && cd .. && npx cap sync android && npx capacitor-assets generate --android && npx cap open android",
"electron:build-linux": "npm run build:electron && electron-builder --linux AppImage",
"electron:build-linux": "electron-builder --linux AppImage",
"electron:build-linux-deb": "npm run build:electron && electron-builder --linux deb",
"electron:build-linux-prod": "NODE_ENV=production npm run build:electron && electron-builder --linux AppImage",
"build:electron-prod": "NODE_ENV=production npm run build:electron",
@ -57,8 +58,8 @@
"@capacitor/ios": "^6.2.0",
"@capacitor/share": "^6.0.3",
"@capawesome/capacitor-file-picker": "^6.2.0",
"@dicebear/collection": "^5.4.1",
"@dicebear/core": "^5.4.1",
"@dicebear/collection": "^5.4.3",
"@dicebear/core": "^5.4.3",
"@ethersproject/hdnode": "^5.7.0",
"@ethersproject/wallet": "^5.8.0",
"@fortawesome/fontawesome-svg-core": "^6.5.1",
@ -69,7 +70,7 @@
"@peculiar/asn1-schema": "^2.3.8",
"@pvermeer/dexie-encrypted-addon": "^3.0.0",
"@simplewebauthn/browser": "^10.0.0",
"@simplewebauthn/server": "^10.0.0",
"@simplewebauthn/server": "^10.0.1",
"@tweenjs/tween.js": "^21.1.1",
"@types/qrcode": "^1.5.5",
"@veramo/core": "^5.6.0",
@ -86,6 +87,7 @@
"absurd-sql": "^0.0.54",
"asn1-ber": "^1.2.2",
"axios": "^1.6.8",
"better-sqlite3-multiple-ciphers": "^11.10.0",
"cbor-x": "^1.5.9",
"class-transformer": "^0.5.1",
"dexie": "^3.2.7",
@ -93,22 +95,23 @@
"did-jwt": "^7.4.7",
"did-resolver": "^4.1.0",
"dotenv": "^16.0.3",
"ethereum-cryptography": "^2.1.3",
"electron-json-storage": "^4.6.0",
"ethereum-cryptography": "^2.2.1",
"ethereumjs-util": "^7.1.5",
"jdenticon": "^3.2.0",
"jdenticon": "^3.3.0",
"js-generate-password": "^0.1.9",
"js-yaml": "^4.1.0",
"jsqr": "^1.4.0",
"leaflet": "^1.9.4",
"localstorage-slim": "^2.7.0",
"lru-cache": "^10.2.0",
"lru-cache": "^10.4.3",
"luxon": "^3.4.4",
"merkletreejs": "^0.3.11",
"nostr-tools": "^2.10.4",
"nostr-tools": "^2.13.1",
"notiwind": "^2.0.2",
"papaparse": "^5.4.1",
"pina": "^0.20.2204228",
"pinia-plugin-persistedstate": "^3.2.1",
"pinia-plugin-persistedstate": "^3.2.3",
"qr-code-generator-vue3": "^1.4.21",
"qrcode": "^1.5.4",
"ramda": "^0.29.1",
@ -124,12 +127,13 @@
"vue-axios": "^3.5.2",
"vue-facing-decorator": "^3.0.4",
"vue-picture-cropper": "^0.7.0",
"vue-qrcode-reader": "^5.5.3",
"vue-qrcode-reader": "^5.7.2",
"vue-router": "^4.5.0",
"web-did-resolver": "^2.0.27",
"web-did-resolver": "^2.0.30",
"zod": "^3.24.2"
},
"devDependencies": {
"@capacitor-community/electron": "^5.0.1",
"@capacitor/assets": "^3.0.5",
"@playwright/test": "^1.45.2",
"@types/dom-webcodecs": "^0.1.7",
@ -144,7 +148,7 @@
"@types/ua-parser-js": "^0.7.39",
"@typescript-eslint/eslint-plugin": "^6.21.0",
"@typescript-eslint/parser": "^6.21.0",
"@vitejs/plugin-vue": "^5.2.1",
"@vitejs/plugin-vue": "^5.2.4",
"@vue/eslint-config-typescript": "^11.0.3",
"autoprefixer": "^10.4.19",
"browserify-fs": "^1.0.0",
@ -164,12 +168,13 @@
"postcss": "^8.4.38",
"prettier": "^3.2.5",
"rimraf": "^6.0.1",
"source-map-support": "^0.5.21",
"tailwindcss": "^3.4.1",
"typescript": "~5.2.2",
"vite": "^5.2.0",
"vite-plugin-pwa": "^1.0.0"
},
"main": "./dist-electron/main.js",
"main": "./dist-electron/main.mjs",
"build": {
"appId": "app.timesafari",
"productName": "TimeSafari",
@ -178,12 +183,17 @@
},
"files": [
"dist-electron/**/*",
"dist/**/*"
"dist/**/*",
"capacitor.config.json"
],
"extraResources": [
{
"from": "dist-electron/www",
"to": "www"
},
{
"from": "dist-electron/resources/preload.js",
"to": "preload.js"
}
],
"linux": {
@ -221,5 +231,6 @@
}
]
}
}
},
"type": "module"
}

0
postcss.config.js → postcss.config.cjs

1
requirements.txt

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

96
scripts/build-electron.cjs

@ -0,0 +1,96 @@
const fs = require("fs");
const fse = require("fs-extra");
const path = require("path");
const { execSync } = require('child_process');
console.log("Starting Electron build finalization...");
// Define paths
const distPath = path.join(__dirname, "..", "dist");
const electronDistPath = path.join(__dirname, "..", "dist-electron");
const wwwPath = path.join(electronDistPath, "www");
const builtIndexPath = path.join(distPath, "index.html");
const finalIndexPath = path.join(wwwPath, "index.html");
// Ensure target directory exists
if (!fs.existsSync(wwwPath)) {
fs.mkdirSync(wwwPath, { recursive: true });
}
// Copy assets directory
const assetsSrc = path.join(distPath, "assets");
const assetsDest = path.join(wwwPath, "assets");
if (fs.existsSync(assetsSrc)) {
fse.copySync(assetsSrc, assetsDest, { overwrite: true });
}
// Copy favicon.ico
const faviconSrc = path.join(distPath, "favicon.ico");
if (fs.existsSync(faviconSrc)) {
fs.copyFileSync(faviconSrc, path.join(wwwPath, "favicon.ico"));
}
// Copy manifest.webmanifest
const manifestSrc = path.join(distPath, "manifest.webmanifest");
if (fs.existsSync(manifestSrc)) {
fs.copyFileSync(manifestSrc, path.join(wwwPath, "manifest.webmanifest"));
}
// Load and modify index.html from Vite output
let indexContent = fs.readFileSync(builtIndexPath, "utf-8");
// Inject the window.process shim after the first <script> block
indexContent = indexContent.replace(
/<script[^>]*type="module"[^>]*>/,
match => `${match}\n window.process = { env: { VITE_PLATFORM: 'electron' } };`
);
// Write the modified index.html to dist-electron/www
fs.writeFileSync(finalIndexPath, indexContent);
// Copy preload script to resources
const preloadSrc = path.join(electronDistPath, "preload.mjs");
const preloadDest = path.join(electronDistPath, "resources", "preload.js");
// Ensure resources directory exists
const resourcesDir = path.join(electronDistPath, "resources");
if (!fs.existsSync(resourcesDir)) {
fs.mkdirSync(resourcesDir, { recursive: true });
}
if (fs.existsSync(preloadSrc)) {
// Read the preload script
let preloadContent = fs.readFileSync(preloadSrc, 'utf-8');
// Convert ESM to CommonJS if needed
preloadContent = preloadContent
.replace(/import\s*{\s*([^}]+)\s*}\s*from\s*['"]electron['"];?/g, 'const { $1 } = require("electron");')
.replace(/export\s*{([^}]+)};?/g, '')
.replace(/export\s+default\s+([^;]+);?/g, 'module.exports = $1;');
// Write the modified preload script
fs.writeFileSync(preloadDest, preloadContent);
console.log("Preload script copied and converted to resources directory");
} else {
console.error("Preload script not found at:", preloadSrc);
process.exit(1);
}
// Copy capacitor.config.json to dist-electron
try {
console.log("Copying capacitor.config.json to dist-electron...");
const configPath = path.join(process.cwd(), 'capacitor.config.json');
const targetPath = path.join(process.cwd(), 'dist-electron', 'capacitor.config.json');
if (!fs.existsSync(configPath)) {
throw new Error('capacitor.config.json not found in project root');
}
fs.copyFileSync(configPath, targetPath);
console.log("Successfully copied capacitor.config.json");
} catch (error) {
console.error("Failed to copy capacitor.config.json:", error);
throw error;
}
console.log("Electron index.html copied and patched for Electron context.");

165
scripts/build-electron.js

@ -1,165 +0,0 @@
const fs = require('fs');
const path = require('path');
console.log('Starting electron build process...');
// Define paths
const electronDistPath = path.join(__dirname, '..', 'dist-electron');
const wwwPath = path.join(electronDistPath, 'www');
// Create www directory if it doesn't exist
if (!fs.existsSync(wwwPath)) {
fs.mkdirSync(wwwPath, { recursive: true });
}
// Create a platform-specific index.html for Electron
const initialIndexContent = `<!DOCTYPE html>
<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
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');
if (fs.existsSync(indexPath)) {
console.log('Modifying index.html to remove service worker registration...');
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
console.log('Fixing asset paths in index.html...');
let modifiedIndexContent = fs.readFileSync(indexPath, 'utf8');
modifiedIndexContent = modifiedIndexContent
.replace(/\/assets\//g, './assets/')
.replace(/href="\//g, 'href="./')
.replace(/src="\//g, 'src="./');
fs.writeFileSync(indexPath, modifiedIndexContent);
// 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
console.log('After path fixing, checking for remaining /assets/ paths:', finalContent.includes('/assets/'));
console.log('Sample of fixed content:', finalContent.substring(0, 500));
console.log('Copied and fixed web files in:', wwwPath);
// Copy main process files
console.log('Copying main process files...');
// Copy the main process file instead of creating a template
const mainSrcPath = path.join(__dirname, '..', 'dist-electron', 'main.js');
const mainDestPath = path.join(electronDistPath, 'main.js');
if (fs.existsSync(mainSrcPath)) {
fs.copyFileSync(mainSrcPath, mainDestPath);
console.log('Copied main process file successfully');
} else {
console.error('Main process file not found at:', mainSrcPath);
process.exit(1);
}
console.log('Electron build process completed successfully');

0
scripts/copy-wasm.js → scripts/copy-wasm.cjs

7
src/App.vue

@ -459,9 +459,10 @@ export default class App extends Vue {
return true;
}
const serverSubscription = {
...subscription,
};
const serverSubscription =
typeof subscription === "object" && subscription !== null
? { ...subscription }
: {};
if (!allGoingOff) {
serverSubscription["notifyType"] = notification.title;
logger.log(

14
src/components/GiftedDialog.vue

@ -320,10 +320,7 @@ export default class GiftedDialog extends Vue {
this.fromProjectId,
);
if (
result.type === "error" ||
this.isGiveCreationError(result.response)
) {
if (!result.success) {
const errorMessage = this.getGiveCreationErrorMessage(result);
logger.error("Error with give creation result:", result);
this.$notify(
@ -370,15 +367,6 @@ export default class GiftedDialog extends Vue {
// 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")
* @returns best guess at an error message

14
src/components/OfferDialog.vue

@ -249,10 +249,7 @@ export default class OfferDialog extends Vue {
this.projectId,
);
if (
result.type === "error" ||
this.isOfferCreationError(result.response)
) {
if (!result.success) {
const errorMessage = this.getOfferCreationErrorMessage(result);
logger.error("Error with offer creation result:", result);
this.$notify(
@ -296,15 +293,6 @@ export default class OfferDialog extends Vue {
// 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")
* @returns best guess at an error message

9
src/components/UserNameDialog.vue

@ -41,7 +41,6 @@ import { NotificationIface, USE_DEXIE_DB } from "../constants/app";
import { db, retrieveSettingsForActiveAccount } from "../db/index";
import * as databaseUtil from "../db/databaseUtil";
import { MASTER_SETTINGS_KEY } from "../db/tables/settings";
import { PlatformServiceFactory } from "@/services/PlatformServiceFactory";
@Component
export default class UserNameDialog extends Vue {
@ -72,11 +71,9 @@ export default class UserNameDialog extends Vue {
}
async onClickSaveChanges() {
const platformService = PlatformServiceFactory.getInstance();
await platformService.dbExec(
"UPDATE settings SET firstName = ? WHERE key = ?",
[this.givenName, MASTER_SETTINGS_KEY],
);
await databaseUtil.updateDefaultSettings({
firstName: this.givenName,
});
if (USE_DEXIE_DB) {
await db.settings.update(MASTER_SETTINGS_KEY, {
firstName: this.givenName,

89
src/db/databaseUtil.ts

@ -3,11 +3,19 @@
* That file will eventually be deleted.
*/
import { PlatformServiceFactory } from "@/services/PlatformServiceFactory";
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";
import { logger } from "../utils/logger";
import { DEFAULT_ENDORSER_API_SERVER } from "../constants/app";
import { QueryExecResult } from "../interfaces/database";
const formatLogObject = (obj: unknown): string => {
try {
return JSON.stringify(obj, null, 2);
} catch (error) {
return `[Object could not be stringified: ${error instanceof Error ? error.message : String(error)}]`;
}
};
export async function updateDefaultSettings(
settingsChanges: Settings,
@ -23,10 +31,13 @@ export async function updateDefaultSettings(
"id = ?",
[MASTER_SETTINGS_KEY],
);
console.log("[databaseUtil] updateDefaultSettings", { sql, params });
const result = await platformService.dbExec(sql, params);
console.log("[databaseUtil] updateDefaultSettings result", { result });
return result.changes === 1;
} catch (error) {
logger.error("Error updating default settings:", error);
console.log("[databaseUtil] updateDefaultSettings error", { error });
if (error instanceof Error) {
throw error; // Re-throw if it's already an Error with a message
} else {
@ -79,48 +90,78 @@ const DEFAULT_SETTINGS: Settings = {
// retrieves default settings
export async function retrieveSettingsForDefaultAccount(): Promise<Settings> {
console.log('[DatabaseUtil] Retrieving default account settings');
const platform = PlatformServiceFactory.getInstance();
console.log('[DatabaseUtil] Platform service state:', {
platformType: platform.constructor.name,
capabilities: platform.getCapabilities(),
timestamp: new Date().toISOString()
});
const result = await platform.dbQuery("SELECT * FROM settings WHERE id = ?", [
MASTER_SETTINGS_KEY,
]);
if (!result) {
console.log('[DatabaseUtil] No settings found, returning defaults');
return DEFAULT_SETTINGS;
} else {
const settings = mapColumnsToValues(
result.columns,
result.values,
)[0] as Settings;
}
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);
}
console.log('[DatabaseUtil] Retrieved settings:', {
settings: formatLogObject(settings),
timestamp: new Date().toISOString()
});
return settings;
}
}
export async function retrieveSettingsForActiveAccount(): Promise<Settings> {
console.log('[DatabaseUtil] Retrieving active account settings');
const defaultSettings = await retrieveSettingsForDefaultAccount();
console.log('[DatabaseUtil] Default settings retrieved:', {
defaultSettings: formatLogObject(defaultSettings),
timestamp: new Date().toISOString()
});
if (!defaultSettings.activeDid) {
console.log('[DatabaseUtil] No active DID, returning default settings');
return defaultSettings;
} else {
}
const platform = PlatformServiceFactory.getInstance();
const result = await platform.dbQuery(
"SELECT * FROM settings WHERE accountDid = ?",
[defaultSettings.activeDid],
);
const overrideSettings = result
? (mapColumnsToValues(result.columns, result.values)[0] as Settings)
: {};
const overrideSettingsFiltered = Object.fromEntries(
Object.entries(overrideSettings).filter(([_, v]) => v !== null),
);
const settings = { ...defaultSettings, ...overrideSettingsFiltered };
if (settings.searchBoxes) {
// @ts-expect-error - the searchBoxes field is a string in the DB
settings.searchBoxes = JSON.parse(settings.searchBoxes);
}
console.log('[DatabaseUtil] Final active account settings:', {
settings: formatLogObject(settings),
timestamp: new Date().toISOString()
});
return settings;
}
}
let lastCleanupDate: string | null = null;
@ -131,26 +172,26 @@ let lastCleanupDate: string | null = null;
* @author Matthew Raymer
*/
export async function logToDb(message: string): Promise<void> {
const platform = PlatformServiceFactory.getInstance();
//const platform = PlatformServiceFactory.getInstance();
const todayKey = new Date().toDateString();
const nowKey = new Date().toISOString();
//const nowKey = new Date().toISOString();
try {
// Try to insert first, if it fails due to UNIQUE constraint, update instead
await platform.dbExec("INSERT INTO logs (date, message) VALUES (?, ?)", [
nowKey,
message,
]);
// 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,
);
await platform.dbExec("DELETE FROM logs WHERE date < ?", [
sevenDaysAgo.toDateString(),
]);
// const sevenDaysAgo = new Date(
// new Date().getTime() - 7 * 24 * 60 * 60 * 1000,
// );
// await platform.dbExec("DELETE FROM logs WHERE date < ?", [
// sevenDaysAgo.toDateString(),
// ]);
lastCleanupDate = todayKey;
}
} catch (error) {

174
src/electron/main.js

@ -1,174 +0,0 @@
const { app, BrowserWindow } = require("electron");
const path = require("path");
const fs = require("fs");
const logger = require("../utils/logger");
// Check if running in dev mode
const isDev = process.argv.includes("--inspect");
function createWindow() {
// Add before createWindow function
const preloadPath = path.join(__dirname, "preload.js");
logger.log("Checking preload path:", preloadPath);
logger.log("Preload exists:", fs.existsSync(preloadPath));
// Create the browser window.
const mainWindow = new BrowserWindow({
width: 1200,
height: 800,
webPreferences: {
nodeIntegration: false,
contextIsolation: true,
webSecurity: true,
allowRunningInsecureContent: false,
preload: path.join(__dirname, "preload.js"),
},
});
// Always open DevTools for now
mainWindow.webContents.openDevTools();
// Intercept requests to fix asset paths
mainWindow.webContents.session.webRequest.onBeforeRequest(
{
urls: [
"file://*/*/assets/*",
"file://*/assets/*",
"file:///assets/*", // Catch absolute paths
"<all_urls>", // Catch all URLs as a fallback
],
},
(details, callback) => {
let url = details.url;
// Handle paths that don't start with file://
if (!url.startsWith("file://") && url.includes("/assets/")) {
url = `file://${path.join(__dirname, "www", url)}`;
}
// Handle absolute paths starting with /assets/
if (url.includes("/assets/") && !url.includes("/www/assets/")) {
const baseDir = url.includes("dist-electron")
? url.substring(
0,
url.indexOf("/dist-electron") + "/dist-electron".length,
)
: `file://${__dirname}`;
const assetPath = url.split("/assets/")[1];
const newUrl = `${baseDir}/www/assets/${assetPath}`;
callback({ redirectURL: newUrl });
return;
}
callback({}); // No redirect for other URLs
},
);
if (isDev) {
// Debug info
logger.log("Debug Info:");
logger.log("Running in dev mode:", isDev);
logger.log("App is packaged:", app.isPackaged);
logger.log("Process resource path:", process.resourcesPath);
logger.log("App path:", app.getAppPath());
logger.log("__dirname:", __dirname);
logger.log("process.cwd():", process.cwd());
}
const indexPath = path.join(__dirname, "www", "index.html");
if (isDev) {
logger.log("Loading index from:", indexPath);
logger.log("www path:", path.join(__dirname, "www"));
logger.log("www assets path:", path.join(__dirname, "www", "assets"));
}
if (!fs.existsSync(indexPath)) {
logger.error(`Index file not found at: ${indexPath}`);
throw new Error("Index file not found");
}
// Add CSP headers to allow API connections
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;" +
"img-src 'self' data: https: blob:;" +
"script-src 'self' 'unsafe-inline' 'unsafe-eval';" +
"style-src 'self' 'unsafe-inline';" +
"font-src 'self' data:;",
],
},
});
},
);
// 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);
});

215
src/electron/main.ts

@ -1,215 +0,0 @@
import { app, BrowserWindow } from "electron";
import path from "path";
import fs from "fs";
// Simple logger implementation
const logger = {
// eslint-disable-next-line no-console
log: (...args: unknown[]) => console.log(...args),
// eslint-disable-next-line no-console
error: (...args: unknown[]) => console.error(...args),
// eslint-disable-next-line no-console
info: (...args: unknown[]) => console.info(...args),
// eslint-disable-next-line no-console
warn: (...args: unknown[]) => console.warn(...args),
// eslint-disable-next-line no-console
debug: (...args: unknown[]) => console.debug(...args),
};
// Check if running in dev mode
const isDev = process.argv.includes("--inspect");
function createWindow(): void {
// Add before createWindow function
const preloadPath = path.join(__dirname, "preload.js");
logger.log("Checking preload path:", preloadPath);
logger.log("Preload exists:", fs.existsSync(preloadPath));
// Log environment and paths
logger.log("process.cwd():", process.cwd());
logger.log("__dirname:", __dirname);
logger.log("app.getAppPath():", app.getAppPath());
logger.log("app.isPackaged:", app.isPackaged);
// List files in __dirname and __dirname/www
try {
logger.log("Files in __dirname:", fs.readdirSync(__dirname));
const wwwDir = path.join(__dirname, "www");
if (fs.existsSync(wwwDir)) {
logger.log("Files in www:", fs.readdirSync(wwwDir));
} else {
logger.log("www directory does not exist in __dirname");
}
} catch (e) {
logger.error("Error reading directories:", e);
}
// Create the browser window.
const mainWindow = new BrowserWindow({
width: 1200,
height: 800,
webPreferences: {
nodeIntegration: false,
contextIsolation: true,
webSecurity: true,
allowRunningInsecureContent: false,
preload: path.join(__dirname, "preload.js"),
},
});
// Always open DevTools for now
mainWindow.webContents.openDevTools();
// Intercept requests to fix asset paths
mainWindow.webContents.session.webRequest.onBeforeRequest(
{
urls: [
"file://*/*/assets/*",
"file://*/assets/*",
"file:///assets/*", // Catch absolute paths
"<all_urls>", // Catch all URLs as a fallback
],
},
(details, callback) => {
let url = details.url;
// Handle paths that don't start with file://
if (!url.startsWith("file://") && url.includes("/assets/")) {
url = `file://${path.join(__dirname, "www", url)}`;
}
// Handle absolute paths starting with /assets/
if (url.includes("/assets/") && !url.includes("/www/assets/")) {
const baseDir = url.includes("dist-electron")
? url.substring(
0,
url.indexOf("/dist-electron") + "/dist-electron".length,
)
: `file://${__dirname}`;
const assetPath = url.split("/assets/")[1];
const newUrl = `${baseDir}/www/assets/${assetPath}`;
callback({ redirectURL: newUrl });
return;
}
callback({}); // No redirect for other URLs
},
);
if (isDev) {
// Debug info
logger.log("Debug Info:");
logger.log("Running in dev mode:", isDev);
logger.log("App is packaged:", app.isPackaged);
logger.log("Process resource path:", process.resourcesPath);
logger.log("App path:", app.getAppPath());
logger.log("__dirname:", __dirname);
logger.log("process.cwd():", process.cwd());
}
let indexPath = path.resolve(__dirname, "dist-electron", "www", "index.html");
if (!fs.existsSync(indexPath)) {
// Fallback for dev mode
indexPath = path.resolve(
process.cwd(),
"dist-electron",
"www",
"index.html",
);
}
if (isDev) {
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
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;" +
"img-src 'self' data: https: blob:;" +
"script-src 'self' 'unsafe-inline' 'unsafe-eval';" +
"style-src 'self' 'unsafe-inline';" +
"font-src 'self' data:;",
],
},
});
},
);
// 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);
});

91
src/electron/preload.js

@ -1,91 +0,0 @@
const { contextBridge, ipcRenderer } = require("electron");
const logger = {
log: (message, ...args) => {
// Always log in development, log with context in production
if (process.env.NODE_ENV !== "production") {
/* eslint-disable no-console */
console.log(`[Preload] ${message}`, ...args);
/* eslint-enable no-console */
}
},
warn: (message, ...args) => {
// Always log warnings
/* eslint-disable no-console */
console.warn(`[Preload] ${message}`, ...args);
/* eslint-enable no-console */
},
error: (message, ...args) => {
// Always log errors
/* eslint-disable no-console */
console.error(`[Preload] ${message}`, ...args);
/* eslint-enable no-console */
},
info: (message, ...args) => {
// Always log info in development, log with context in production
if (process.env.NODE_ENV !== "production") {
/* eslint-disable no-console */
console.info(`[Preload] ${message}`, ...args);
/* eslint-enable no-console */
}
},
};
// Use a more direct path resolution approach
const getPath = (pathType) => {
switch (pathType) {
case "userData":
return (
process.env.APPDATA ||
(process.platform === "darwin"
? `${process.env.HOME}/Library/Application Support`
: `${process.env.HOME}/.local/share`)
);
case "home":
return process.env.HOME;
case "appPath":
return process.resourcesPath;
default:
return "";
}
};
logger.info("Preload script starting...");
// Force electron platform in the renderer process
window.process = { env: { VITE_PLATFORM: "electron" } };
try {
contextBridge.exposeInMainWorld("electronAPI", {
// Path utilities
getPath,
// IPC functions
send: (channel, data) => {
const validChannels = ["toMain"];
if (validChannels.includes(channel)) {
ipcRenderer.send(channel, data);
}
},
receive: (channel, func) => {
const validChannels = ["fromMain"];
if (validChannels.includes(channel)) {
ipcRenderer.on(channel, (event, ...args) => func(...args));
}
},
// Environment info
env: {
isElectron: true,
isDev: process.env.NODE_ENV === "development",
platform: "electron", // Explicitly set platform
},
// Path utilities
getBasePath: () => {
return process.env.NODE_ENV === "development" ? "/" : "./";
},
});
logger.info("Preload script completed successfully");
} catch (error) {
logger.error("Error in preload script:", error);
}

106
src/interfaces/claims.ts

@ -1,14 +1,24 @@
import { GenericVerifiableCredential } from "./common";
/**
* Types of Claims
*
* Note that these are for the claims that get signed.
* Records that are the latest edited entities are in the records.ts file.
*
*/
export interface AgreeVerifiableCredential {
"@context": string;
import { ClaimObject } from "./common";
export interface AgreeActionClaim extends ClaimObject {
"@context": "https://schema.org";
"@type": string;
object: Record<string, unknown>;
}
// Note that previous VCs may have additional fields.
// https://endorser.ch/doc/html/transactions.html#id4
export interface GiveVerifiableCredential extends GenericVerifiableCredential {
export interface GiveActionClaim extends ClaimObject {
// context is optional because it might be embedded in another claim, eg. an AgreeAction
"@context"?: "https://schema.org";
"@type": "GiveAction";
agent?: { identifier: string };
description?: string;
@ -16,43 +26,21 @@ export interface GiveVerifiableCredential extends GenericVerifiableCredential {
identifier?: string;
image?: string;
object?: { amountOfThisGood: number; unitCode: string };
provider?: GenericVerifiableCredential;
provider?: ClaimObject;
recipient?: { identifier: string };
type: string[];
issuer: string;
issuanceDate: string;
credentialSubject: {
id: string;
type: "GiveAction";
offeredBy?: {
type: "Person";
identifier: string;
};
offeredTo?: {
type: "Person";
identifier: string;
};
offeredToProject?: {
type: "Project";
identifier: string;
};
offeredToProjectVisibleToDids?: string[];
offeredToVisibleToDids?: string[];
offeredByVisibleToDids?: string[];
amount: {
type: "QuantitativeValue";
value: number;
unitCode: string;
};
startTime?: string;
endTime?: string;
};
}
export interface JoinActionClaim extends ClaimObject {
agent?: { identifier: string };
event?: { organizer?: { name: string }; name?: string; startTime?: string };
}
// Note that previous VCs may have additional fields.
// https://endorser.ch/doc/html/transactions.html#id8
export interface OfferVerifiableCredential extends GenericVerifiableCredential {
export interface OfferClaim extends ClaimObject {
"@context": "https://schema.org";
"@type": "Offer";
agent?: { identifier: string };
description?: string;
fulfills?: { "@type": string; identifier?: string; lastClaimId?: string }[];
identifier?: string;
@ -67,43 +55,18 @@ export interface OfferVerifiableCredential extends GenericVerifiableCredential {
name?: string;
};
};
provider?: GenericVerifiableCredential;
recipient?: { identifier: string };
validThrough?: string;
type: string[];
issuer: string;
issuanceDate: string;
credentialSubject: {
id: string;
type: "Offer";
offeredBy?: {
type: "Person";
identifier: string;
};
offeredTo?: {
type: "Person";
identifier: string;
};
offeredToProject?: {
type: "Project";
type?: "Person";
identifier: string;
};
offeredToProjectVisibleToDids?: string[];
offeredToVisibleToDids?: string[];
offeredByVisibleToDids?: string[];
amount: {
type: "QuantitativeValue";
value: number;
unitCode: string;
};
startTime?: string;
endTime?: string;
};
provider?: ClaimObject;
recipient?: { identifier: string };
validThrough?: string;
}
// Note that previous VCs may have additional fields.
// https://endorser.ch/doc/html/transactions.html#id7
export interface PlanVerifiableCredential extends GenericVerifiableCredential {
export interface PlanActionClaim extends ClaimObject {
"@context": "https://schema.org";
"@type": "PlanAction";
name: string;
@ -117,11 +80,18 @@ export interface PlanVerifiableCredential extends GenericVerifiableCredential {
}
// AKA Registration & RegisterAction
export interface RegisterVerifiableCredential {
"@context": string;
export interface RegisterActionClaim extends ClaimObject {
"@context": "https://schema.org";
"@type": "RegisterAction";
agent: { identifier: string };
identifier?: string;
object: string;
object?: string;
participant?: { identifier: string };
}
export interface TenureClaim extends ClaimObject {
"@context": "https://endorser.ch";
"@type": "Tenure";
party?: { identifier: string };
spatialUnit?: { geo?: { polygon?: string } };
}

100
src/interfaces/common.ts

@ -1,6 +1,6 @@
// similar to VerifiableCredentialSubject... maybe rename this
export interface GenericVerifiableCredential {
"@context": string | string[];
"@context"?: string;
"@type": string;
[key: string]: unknown;
}
@ -37,23 +37,26 @@ export interface ErrorResult extends ResultWithType {
export interface KeyMeta {
did: string;
name?: string;
publicKeyHex: string;
mnemonic: string;
derivationPath: string;
registered?: boolean;
profileImageUrl?: string;
identity?: string; // Stringified IIdentifier object from Veramo
derivationPath?: string;
passkeyCredIdHex?: string; // The Webauthn credential ID in hex, if this is from a passkey
[key: string]: unknown;
}
export interface KeyMetaMaybeWithPrivate extends KeyMeta {
mnemonic?: string; // 12 or 24 words encoding the seed
identity?: string; // Stringified IIdentifier object from Veramo
}
export interface KeyMetaWithPrivate extends KeyMeta {
mnemonic: string; // 12 or 24 words encoding the seed
identity: string; // Stringified IIdentifier object from Veramo
}
export interface QuantitativeValue extends GenericVerifiableCredential {
"@type": "QuantitativeValue";
"@context": string | string[];
"@context"?: string;
amountOfThisGood: number;
unitCode: string;
[key: string]: unknown;
}
export interface AxiosErrorResponse {
@ -87,94 +90,21 @@ export interface CreateAndSubmitClaimResult {
handleId?: string;
}
export interface PlanSummaryRecord {
handleId: string;
issuer: string;
claim: GenericVerifiableCredential;
[key: string]: unknown;
}
export interface Agent {
identifier?: string;
did?: string;
[key: string]: unknown;
}
export interface ClaimObject {
"@type": string;
"@context"?: string | string[];
fulfills?: Array<{
"@type": string;
identifier?: string;
[key: string]: unknown;
}>;
object?: GenericVerifiableCredential;
agent?: Agent;
participant?: {
identifier?: string;
[key: string]: unknown;
};
identifier?: string;
"@context"?: string;
[key: string]: unknown;
}
export interface VerifiableCredentialClaim {
"@context": string | string[];
"@context"?: string;
"@type": string;
type: string[];
credentialSubject: ClaimObject;
[key: string]: unknown;
}
export interface GiveVerifiableCredential extends GenericVerifiableCredential {
"@type": "GiveAction";
"@context": string | string[];
object?: GenericVerifiableCredential;
agent?: Agent;
participant?: {
identifier?: string;
[key: string]: unknown;
};
fulfills?: Array<{
"@type": string;
identifier?: string;
[key: string]: unknown;
}>;
[key: string]: unknown;
}
export interface OfferVerifiableCredential extends GenericVerifiableCredential {
"@type": "OfferAction";
"@context": string | string[];
object?: GenericVerifiableCredential;
agent?: Agent;
participant?: {
identifier?: string;
[key: string]: unknown;
};
itemOffered?: {
description?: string;
isPartOf?: {
"@type": string;
identifier: string;
[key: string]: unknown;
};
[key: string]: unknown;
};
[key: string]: unknown;
}
export interface RegisterVerifiableCredential
extends GenericVerifiableCredential {
"@type": "RegisterAction";
"@context": string | string[];
agent: {
identifier: string;
};
object: string;
participant?: {
identifier: string;
};
identifier?: string;
[key: string]: unknown;
}

7
src/interfaces/index.ts

@ -13,9 +13,9 @@ export type {
export type {
// From claims.ts
GiveVerifiableCredential,
OfferVerifiableCredential,
RegisterVerifiableCredential,
GiveActionClaim,
OfferClaim,
RegisterActionClaim,
} from "./claims";
export type {
@ -26,6 +26,7 @@ export type {
export type {
// From records.ts
PlanSummaryRecord,
GiveSummaryRecord,
} from "./records";
export type {

8
src/interfaces/records.ts

@ -1,14 +1,14 @@
import { GiveVerifiableCredential, OfferVerifiableCredential } from "./claims";
import { GiveActionClaim, OfferClaim } from "./claims";
// a summary record; the VC is found the fullClaim field
export interface GiveSummaryRecord {
[x: string]: PropertyKey | undefined | GiveVerifiableCredential;
[x: string]: PropertyKey | undefined | GiveActionClaim;
type?: string;
agentDid: string;
amount: number;
amountConfirmed: number;
description: string;
fullClaim: GiveVerifiableCredential;
fullClaim: GiveActionClaim;
fulfillsHandleId: string;
fulfillsPlanHandleId?: string;
fulfillsType?: string;
@ -26,7 +26,7 @@ export interface OfferSummaryRecord {
amount: number;
amountGiven: number;
amountGivenConfirmed: number;
fullClaim: OfferVerifiableCredential;
fullClaim: OfferClaim;
fulfillsPlanHandleId: string;
handleId: string;
issuerDid: string;

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

@ -17,7 +17,7 @@ import { didEthLocalResolver } from "./did-eth-local-resolver";
import { PEER_DID_PREFIX, verifyPeerSignature } from "./didPeer";
import { base64urlDecodeString, createDidPeerJwt } from "./passkeyDidPeer";
import { urlBase64ToUint8Array } from "./util";
import { KeyMeta } from "../../../interfaces/common";
import { KeyMeta, KeyMetaWithPrivate } from "../../../interfaces/common";
export const ETHR_DID_PREFIX = "did:ethr:";
export const JWT_VERIFY_FAILED_CODE = "JWT_VERIFY_FAILED";
@ -34,7 +34,7 @@ export function isFromPasskey(keyMeta?: KeyMeta): boolean {
}
export async function createEndorserJwtForKey(
account: KeyMeta,
account: KeyMetaWithPrivate,
payload: object,
expiresIn?: number,
) {

253
src/libs/endorserServer.ts

@ -38,7 +38,14 @@ import {
getPasskeyExpirationSeconds,
} from "../libs/util";
import { createEndorserJwtForKey } from "../libs/crypto/vc";
import { KeyMeta } from "../interfaces/common";
import {
GiveActionClaim,
JoinActionClaim,
OfferClaim,
PlanActionClaim,
RegisterActionClaim,
TenureClaim,
} from "../interfaces/claims";
import {
GenericCredWrapper,
@ -46,15 +53,13 @@ import {
AxiosErrorResponse,
UserInfo,
CreateAndSubmitClaimResult,
PlanSummaryRecord,
GiveVerifiableCredential,
OfferVerifiableCredential,
RegisterVerifiableCredential,
ClaimObject,
VerifiableCredentialClaim,
Agent,
QuantitativeValue,
KeyMetaWithPrivate,
KeyMetaMaybeWithPrivate,
} from "../interfaces/common";
import { PlanSummaryRecord } from "../interfaces/records";
import { logger } from "../utils/logger";
import { PlatformServiceFactory } from "@/services/PlatformServiceFactory";
@ -650,7 +655,7 @@ export async function getNewOffersToUserProjects(
* @param lastClaimId supplied when editing a previous claim
*/
export function hydrateGive(
vcClaimOrig?: GiveVerifiableCredential,
vcClaimOrig?: GiveActionClaim,
fromDid?: string,
toDid?: string,
description?: string,
@ -662,15 +667,12 @@ export function hydrateGive(
imageUrl?: string,
providerPlanHandleId?: string,
lastClaimId?: string,
): GiveVerifiableCredential {
const vcClaim: GiveVerifiableCredential = vcClaimOrig
): GiveActionClaim {
const vcClaim: GiveActionClaim = vcClaimOrig
? R.clone(vcClaimOrig)
: {
"@context": SCHEMA_ORG_CONTEXT,
"@type": "GiveAction",
object: undefined,
agent: undefined,
fulfills: [],
};
if (lastClaimId) {
@ -688,7 +690,6 @@ export function hydrateGive(
if (amount && !isNaN(amount)) {
const quantitativeValue: QuantitativeValue = {
"@context": SCHEMA_ORG_CONTEXT,
"@type": "QuantitativeValue",
amountOfThisGood: amount,
unitCode: unitCode || "HUR",
@ -698,7 +699,7 @@ export function hydrateGive(
// Initialize fulfills array if not present
if (!Array.isArray(vcClaim.fulfills)) {
vcClaim.fulfills = [];
vcClaim.fulfills = vcClaim.fulfills ? [vcClaim.fulfills] : [];
}
// Filter and add fulfills elements
@ -801,7 +802,7 @@ export async function createAndSubmitGive(
export async function editAndSubmitGive(
axios: Axios,
apiServer: string,
fullClaim: GenericCredWrapper<GiveVerifiableCredential>,
fullClaim: GenericCredWrapper<GiveActionClaim>,
issuerDid: string,
fromDid?: string,
toDid?: string,
@ -842,7 +843,7 @@ export async function editAndSubmitGive(
* @param lastClaimId supplied when editing a previous claim
*/
export function hydrateOffer(
vcClaimOrig?: OfferVerifiableCredential,
vcClaimOrig?: OfferClaim,
fromDid?: string,
toDid?: string,
itemDescription?: string,
@ -852,24 +853,22 @@ export function hydrateOffer(
fulfillsProjectHandleId?: string,
validThrough?: string,
lastClaimId?: string,
): OfferVerifiableCredential {
const vcClaim: OfferVerifiableCredential = vcClaimOrig
): OfferClaim {
const vcClaim: OfferClaim = vcClaimOrig
? R.clone(vcClaimOrig)
: {
"@context": SCHEMA_ORG_CONTEXT,
"@type": "OfferAction",
object: undefined,
agent: undefined,
itemOffered: {},
"@type": "Offer",
};
if (lastClaimId) {
// this is an edit
vcClaim.lastClaimId = lastClaimId;
delete vcClaim.identifier;
}
if (fromDid) {
vcClaim.agent = { identifier: fromDid };
vcClaim.offeredBy = { identifier: fromDid };
}
if (toDid) {
vcClaim.recipient = { identifier: toDid };
@ -877,13 +876,10 @@ export function hydrateOffer(
vcClaim.description = conditionDescription || undefined;
if (amount && !isNaN(amount)) {
const quantitativeValue: QuantitativeValue = {
"@context": SCHEMA_ORG_CONTEXT,
"@type": "QuantitativeValue",
vcClaim.includesObject = {
amountOfThisGood: amount,
unitCode: unitCode || "HUR",
};
vcClaim.object = quantitativeValue;
}
if (itemDescription || fulfillsProjectHandleId) {
@ -936,7 +932,7 @@ export async function createAndSubmitOffer(
undefined,
);
return createAndSubmitClaim(
vcClaim as OfferVerifiableCredential,
vcClaim as OfferClaim,
issuerDid,
apiServer,
axios,
@ -946,7 +942,7 @@ export async function createAndSubmitOffer(
export async function editAndSubmitOffer(
axios: Axios,
apiServer: string,
fullClaim: GenericCredWrapper<OfferVerifiableCredential>,
fullClaim: GenericCredWrapper<OfferClaim>,
issuerDid: string,
itemDescription: string,
amount?: number,
@ -969,7 +965,7 @@ export async function editAndSubmitOffer(
fullClaim.id,
);
return createAndSubmitClaim(
vcClaim as OfferVerifiableCredential,
vcClaim as OfferClaim,
issuerDid,
apiServer,
axios,
@ -1005,11 +1001,12 @@ export async function createAndSubmitClaim(
axios: Axios,
): Promise<CreateAndSubmitClaimResult> {
try {
const vcPayload = {
const vcPayload: { vc: VerifiableCredentialClaim } = {
vc: {
"@context": ["https://www.w3.org/2018/credentials/v1"],
"@context": "https://www.w3.org/2018/credentials/v1",
"@type": "VerifiableCredential",
type: ["VerifiableCredential"],
credentialSubject: vcClaim,
credentialSubject: vcClaim as unknown as ClaimObject,
},
};
@ -1043,7 +1040,7 @@ export async function createAndSubmitClaim(
}
export async function generateEndorserJwtUrlForAccount(
account: KeyMeta,
account: KeyMetaMaybeWithPrivate,
isRegistered: boolean,
givenName: string,
profileImageUrl: string,
@ -1067,7 +1064,7 @@ export async function generateEndorserJwtUrlForAccount(
}
// Add the next key -- not recommended for the QR code for such a high resolution
if (isContact) {
if (isContact && account.derivationPath && account.mnemonic) {
const newDerivPath = nextDerivationPath(account.derivationPath);
const nextPublicHex = deriveAddress(account.mnemonic, newDerivPath)[2];
const nextPublicEncKey = Buffer.from(nextPublicHex, "hex");
@ -1089,7 +1086,11 @@ export async function createEndorserJwtForDid(
expiresIn?: number,
) {
const account = await retrieveFullyDecryptedAccount(issuerDid);
return createEndorserJwtForKey(account as KeyMeta, payload, expiresIn);
return createEndorserJwtForKey(
account as KeyMetaWithPrivate,
payload,
expiresIn,
);
}
/**
@ -1186,102 +1187,118 @@ export const claimSpecialDescription = (
identifiers: Array<string>,
contacts: Array<Contact>,
) => {
let claim = record.claim;
let claim:
| GenericVerifiableCredential
| GenericCredWrapper<GenericVerifiableCredential> = record.claim;
if ("claim" in claim) {
// it's a nested GenericCredWrapper
claim = claim.claim as GenericVerifiableCredential;
}
const issuer = didInfo(record.issuer, activeDid, identifiers, contacts);
const claimObj = claim as ClaimObject;
const type = claimObj["@type"] || "UnknownType";
const type = claim["@type"] || "UnknownType";
if (type === "AgreeAction") {
return (
issuer +
" agreed with " +
claimSummary(claimObj.object as GenericVerifiableCredential)
claimSummary(claim.object as GenericVerifiableCredential)
);
} else if (isAccept(claim)) {
return (
issuer +
" accepted " +
claimSummary(claimObj.object as GenericVerifiableCredential)
claimSummary(claim.object as GenericVerifiableCredential)
);
} else if (type === "GiveAction") {
const giveClaim = claim as GiveVerifiableCredential;
const agent: Agent = giveClaim.agent || {
identifier: undefined,
did: undefined,
};
const agentDid = agent.did || agent.identifier;
const contactInfo = agentDid
? didInfo(agentDid, activeDid, identifiers, contacts)
: "someone";
const offering = giveClaim.object
? " " + claimSummary(giveClaim.object)
const giveClaim = claim as GiveActionClaim;
// @ts-expect-error because .did may be found in legacy data, before March 2023
const legacyGiverDid = giveClaim.agent?.did;
const giver = giveClaim.agent?.identifier || legacyGiverDid;
const giverInfo = didInfo(giver, activeDid, identifiers, contacts);
let gaveAmount = giveClaim.object?.amountOfThisGood
? displayAmount(
giveClaim.object.unitCode as string,
giveClaim.object.amountOfThisGood as number,
)
: "";
const recipient = giveClaim.participant?.identifier;
const recipientInfo = recipient
? " to " + didInfo(recipient, activeDid, identifiers, contacts)
if (giveClaim.description) {
if (gaveAmount) {
gaveAmount = gaveAmount + ", and also: ";
}
gaveAmount = gaveAmount + giveClaim.description;
}
if (!gaveAmount) {
gaveAmount = "something not described";
}
// @ts-expect-error because .did may be found in legacy data, before March 2023
const legacyRecipDid = giveClaim.recipient?.did;
const gaveRecipientId = giveClaim.recipient?.identifier || legacyRecipDid;
const gaveRecipientInfo = gaveRecipientId
? " to " + didInfo(gaveRecipientId, activeDid, identifiers, contacts)
: "";
return contactInfo + " gave" + offering + recipientInfo;
return giverInfo + " gave" + gaveRecipientInfo + ": " + gaveAmount;
} else if (type === "JoinAction") {
const joinClaim = claim as ClaimObject;
const agent: Agent = joinClaim.agent || {
identifier: undefined,
did: undefined,
};
const agentDid = agent.did || agent.identifier;
const contactInfo = agentDid
? didInfo(agentDid, activeDid, identifiers, contacts)
: "someone";
const object = joinClaim.object as GenericVerifiableCredential;
const objectInfo = object ? " " + claimSummary(object) : "";
return contactInfo + " joined" + objectInfo;
const joinClaim = claim as JoinActionClaim;
// @ts-expect-error because .did may be found in legacy data, before March 2023
const legacyDid = joinClaim.agent?.did;
const agent = joinClaim.agent?.identifier || legacyDid;
const contactInfo = didInfo(agent, activeDid, identifiers, contacts);
let eventOrganizer =
joinClaim.event &&
joinClaim.event.organizer &&
joinClaim.event.organizer.name;
eventOrganizer = eventOrganizer || "";
let eventName = joinClaim.event && joinClaim.event.name;
eventName = eventName ? " " + eventName : "";
let fullEvent = eventOrganizer + eventName;
fullEvent = fullEvent ? " attended the " + fullEvent : "";
let eventDate = joinClaim.event && joinClaim.event.startTime;
eventDate = eventDate ? " at " + eventDate : "";
return contactInfo + fullEvent + eventDate;
} else if (isOffer(claim)) {
const offerClaim = claim as OfferVerifiableCredential;
const agent: Agent = offerClaim.agent || {
identifier: undefined,
did: undefined,
};
const agentDid = agent.did || agent.identifier;
const contactInfo = agentDid
? didInfo(agentDid, activeDid, identifiers, contacts)
: "someone";
const offering = offerClaim.object
? " " + claimSummary(offerClaim.object)
: "";
const offerRecipientId = offerClaim.participant?.identifier;
const offerClaim = claim as OfferClaim;
const offerer = offerClaim.offeredBy?.identifier;
const contactInfo = didInfo(offerer, activeDid, identifiers, contacts);
let offering = "";
if (offerClaim.includesObject) {
offering +=
" " +
displayAmount(
offerClaim.includesObject.unitCode,
offerClaim.includesObject.amountOfThisGood,
);
}
if (offerClaim.itemOffered?.description) {
offering += ", saying: " + offerClaim.itemOffered?.description;
}
// @ts-expect-error because .did may be found in legacy data, before March 2023
const legacyDid = offerClaim.recipient?.did;
const offerRecipientId = offerClaim.recipient?.identifier || legacyDid;
const offerRecipientInfo = offerRecipientId
? " to " + didInfo(offerRecipientId, activeDid, identifiers, contacts)
: "";
return contactInfo + " offered" + offering + offerRecipientInfo;
} else if (type === "PlanAction") {
const planClaim = claim as ClaimObject;
const agent: Agent = planClaim.agent || {
identifier: undefined,
did: undefined,
};
const agentDid = agent.did || agent.identifier;
const contactInfo = agentDid
? didInfo(agentDid, activeDid, identifiers, contacts)
: "someone";
const object = planClaim.object as GenericVerifiableCredential;
const objectInfo = object ? " " + claimSummary(object) : "";
return contactInfo + " planned" + objectInfo;
const planClaim = claim as PlanActionClaim;
const claimer = planClaim.agent?.identifier || record.issuer;
const claimerInfo = didInfo(claimer, activeDid, identifiers, contacts);
return claimerInfo + " announced a project: " + planClaim.name;
} else if (type === "Tenure") {
const tenureClaim = claim as ClaimObject;
const agent: Agent = tenureClaim.agent || {
identifier: undefined,
did: undefined,
};
const agentDid = agent.did || agent.identifier;
const contactInfo = agentDid
? didInfo(agentDid, activeDid, identifiers, contacts)
: "someone";
const object = tenureClaim.object as GenericVerifiableCredential;
const objectInfo = object ? " " + claimSummary(object) : "";
return contactInfo + " has tenure" + objectInfo;
const tenureClaim = claim as TenureClaim;
// @ts-expect-error because .did may be found in legacy data, before March 2023
const legacyDid = tenureClaim.party?.did;
const claimer = tenureClaim.party?.identifier || legacyDid;
const contactInfo = didInfo(claimer, activeDid, identifiers, contacts);
const polygon = tenureClaim.spatialUnit?.geo?.polygon || "";
return (
contactInfo +
" possesses [" +
polygon.substring(0, polygon.indexOf(" ")) +
"...]"
);
} else {
return issuer + " declared " + claimSummary(claim);
}
@ -1315,7 +1332,7 @@ export async function createEndorserJwtVcFromClaim(
// Make a payload for the claim
const vcPayload = {
vc: {
"@context": ["https://www.w3.org/2018/credentials/v1"],
"@context": "https://www.w3.org/2018/credentials/v1",
type: ["VerifiableCredential"],
credentialSubject: claim,
},
@ -1323,32 +1340,44 @@ export async function createEndorserJwtVcFromClaim(
return createEndorserJwtForDid(issuerDid, vcPayload);
}
/**
* Create a JWT for a RegisterAction claim.
*
* @param activeDid - The DID of the user creating the invite
* @param contact - The contact to register, with a 'did' field (all optional for invites)
* @param identifier - The identifier for the invite, usually random
* @param expiresIn - The number of seconds until the invite expires
* @returns The JWT for the RegisterAction claim
*/
export async function createInviteJwt(
activeDid: string,
contact: Contact,
contact?: Contact,
identifier?: string,
expiresIn?: number, // in seconds
): Promise<string> {
const vcClaim: RegisterVerifiableCredential = {
const vcClaim: RegisterActionClaim = {
"@context": SCHEMA_ORG_CONTEXT,
"@type": "RegisterAction",
agent: { identifier: activeDid },
object: SERVICE_ID,
identifier: identifier,
};
if (contact) {
if (contact?.did) {
vcClaim.participant = { identifier: contact.did };
}
// Make a payload for the claim
const vcPayload: { vc: VerifiableCredentialClaim } = {
vc: {
"@context": ["https://www.w3.org/2018/credentials/v1"],
"@context": "https://www.w3.org/2018/credentials/v1",
"@type": "VerifiableCredential",
type: ["VerifiableCredential"],
credentialSubject: vcClaim as unknown as ClaimObject, // Type assertion needed due to object being string
credentialSubject: vcClaim as unknown as ClaimObject,
},
};
// Create a signature using private key of identity
const vcJwt = await createEndorserJwtForDid(activeDid, vcPayload);
const vcJwt = await createEndorserJwtForDid(activeDid, vcPayload, expiresIn);
return vcJwt;
}

123
src/libs/util.ts

@ -34,10 +34,10 @@ import { containsHiddenDid } from "../libs/endorserServer";
import {
GenericCredWrapper,
GenericVerifiableCredential,
KeyMetaWithPrivate,
} from "../interfaces/common";
import { GiveSummaryRecord } from "../interfaces/records";
import { OfferVerifiableCredential } from "../interfaces/claims";
import { KeyMeta } from "../interfaces/common";
import { OfferClaim } from "../interfaces/claims";
import { createPeerDid } from "../libs/crypto/vc/didPeer";
import { registerCredential } from "../libs/crypto/vc/passkeyDidPeer";
import { logger } from "../utils/logger";
@ -378,17 +378,19 @@ export function base64ToBlob(base64DataUrl: string, sliceSize = 512) {
* @param veriClaim is expected to have fields: claim and issuer
*/
export function offerGiverDid(
veriClaim: GenericCredWrapper<GenericVerifiableCredential>,
veriClaim: GenericCredWrapper<OfferClaim>,
): string | undefined {
let giver;
const claim = veriClaim.claim as OfferVerifiableCredential;
if (
claim.credentialSubject.offeredBy?.identifier &&
!serverUtil.isHiddenDid(claim.credentialSubject.offeredBy.identifier)
) {
giver = claim.credentialSubject.offeredBy.identifier;
} else if (veriClaim.issuer && !serverUtil.isHiddenDid(veriClaim.issuer)) {
const innerClaim = veriClaim.claim as OfferClaim;
let giver: string | undefined = undefined;
giver = innerClaim.offeredBy?.identifier;
if (giver && !serverUtil.isHiddenDid(giver)) {
return giver;
}
giver = veriClaim.issuer;
if (giver && !serverUtil.isHiddenDid(giver)) {
return giver;
}
return giver;
}
@ -400,7 +402,10 @@ export function offerGiverDid(
export const canFulfillOffer = (
veriClaim: GenericCredWrapper<GenericVerifiableCredential>,
) => {
return veriClaim.claimType === "Offer" && !!offerGiverDid(veriClaim);
return (
veriClaim.claimType === "Offer" &&
!!offerGiverDid(veriClaim as GenericCredWrapper<OfferClaim>)
);
};
// return object with paths and arrays of DIDs for any keys ending in "VisibleToDid"
@ -469,11 +474,7 @@ export function findAllVisibleToDids(
*
**/
export interface AccountKeyInfo
extends Omit<Account, "derivationPath">,
Omit<KeyMeta, "derivationPath"> {
derivationPath?: string; // Make it optional to match Account type
}
export type AccountKeyInfo = Account & KeyMetaWithPrivate;
export const retrieveAccountCount = async (): Promise<number> => {
let result = 0;
@ -510,12 +511,16 @@ export const retrieveAccountDids = async (): Promise<string[]> => {
return allDids;
};
// This is provided and recommended when the full key is not necessary so that
// future work could separate this info from the sensitive key material.
/**
* This is provided and recommended when the full key is not necessary so that
* future work could separate this info from the sensitive key material.
*
* If you need the private key data, use retrieveFullyDecryptedAccount instead.
*/
export const retrieveAccountMetadata = async (
activeDid: string,
): Promise<AccountKeyInfo | undefined> => {
let result: AccountKeyInfo | undefined = undefined;
): Promise<Account | undefined> => {
let result: Account | undefined = undefined;
const platformService = PlatformServiceFactory.getInstance();
const dbAccount = await platformService.dbQuery(
`SELECT * FROM accounts WHERE did = ?`,
@ -547,32 +552,16 @@ export const retrieveAccountMetadata = async (
return result;
};
export const retrieveAllAccountsMetadata = async (): Promise<Account[]> => {
const platformService = PlatformServiceFactory.getInstance();
const dbAccounts = await platformService.dbQuery(`SELECT * FROM accounts`);
const accounts = databaseUtil.mapQueryResultToValues(dbAccounts) as Account[];
let result = accounts.map((account) => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { identity, mnemonic, ...metadata } = account;
return metadata as Account;
});
if (USE_DEXIE_DB) {
// one of the few times we use accountsDBPromise directly; try to avoid more usage
const accountsDB = await accountsDBPromise;
const array = await accountsDB.accounts.toArray();
result = array.map((account) => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { identity, mnemonic, ...metadata } = account;
return metadata as Account;
});
}
return result;
};
/**
* This contains sensitive data. If possible, use retrieveAccountMetadata instead.
*
* @param activeDid
* @returns account info with private key data decrypted
*/
export const retrieveFullyDecryptedAccount = async (
activeDid: string,
): Promise<AccountKeyInfo | undefined> => {
let result: AccountKeyInfo | undefined = undefined;
): Promise<Account | undefined> => {
let result: Account | undefined = undefined;
const platformService = PlatformServiceFactory.getInstance();
const dbSecrets = await platformService.dbQuery(
`SELECT secretBase64 from secret`,
@ -620,20 +609,26 @@ export const retrieveFullyDecryptedAccount = async (
return result;
};
// let's try and eliminate this
export const retrieveAllFullyDecryptedAccounts = async (): Promise<
Array<AccountEncrypted>
> => {
export const retrieveAllAccountsMetadata = async (): Promise<Account[]> => {
const platformService = PlatformServiceFactory.getInstance();
const queryResult = await platformService.dbQuery("SELECT * FROM accounts");
let allAccounts = databaseUtil.mapQueryResultToValues(
queryResult,
) as unknown as AccountEncrypted[];
const dbAccounts = await platformService.dbQuery(`SELECT * FROM accounts`);
const accounts = databaseUtil.mapQueryResultToValues(dbAccounts) as Account[];
let result = accounts.map((account) => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { identity, mnemonic, ...metadata } = account;
return metadata as Account;
});
if (USE_DEXIE_DB) {
// one of the few times we use accountsDBPromise directly; try to avoid more usage
const accountsDB = await accountsDBPromise;
allAccounts = (await accountsDB.accounts.toArray()) as AccountEncrypted[];
const array = await accountsDB.accounts.toArray();
result = array.map((account) => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { identity, mnemonic, ...metadata } = account;
return metadata as Account;
});
}
return allAccounts;
return result;
};
/**
@ -651,17 +646,29 @@ export async function saveNewIdentity(
const secrets = await platformService.dbQuery(
`SELECT secretBase64 FROM secret`,
);
// If no secret exists, create one
let secretBase64: string;
if (!secrets?.values?.length || !secrets.values[0]?.length) {
throw new Error(
"No initial encryption supported. We recommend you clear your data and start over.",
// Generate a new secret
const randomBytes = crypto.getRandomValues(new Uint8Array(32));
secretBase64 = arrayBufferToBase64(randomBytes);
// Store the new secret
await platformService.dbExec(
`INSERT INTO secret (id, secretBase64) VALUES (1, ?)`,
[secretBase64],
);
} else {
secretBase64 = secrets.values[0][0] as string;
}
const secretBase64 = secrets.values[0][0] as string;
const secret = base64ToArrayBuffer(secretBase64);
const encryptedIdentity = await simpleEncrypt(identity, secret);
const encryptedMnemonic = await simpleEncrypt(mnemonic, secret);
const encryptedIdentityBase64 = arrayBufferToBase64(encryptedIdentity);
const encryptedMnemonicBase64 = arrayBufferToBase64(encryptedMnemonic);
await platformService.dbExec(
`INSERT INTO accounts (dateCreated, derivationPath, did, identityEncrBase64, mnemonicEncrBase64, publicKeyHex)
VALUES (?, ?, ?, ?, ?, ?)`,

5
src/main.common.ts

@ -2,6 +2,7 @@ import { createPinia } from "pinia";
import { App as VueApp, ComponentPublicInstance, createApp } from "vue";
import App from "./App.vue";
import router from "./router";
// Use the browser version of axios for web builds
import axios from "axios";
import VueAxios from "vue-axios";
import Notifications from "notiwind";
@ -13,8 +14,8 @@ import { logger } from "./utils/logger";
const platform = process.env.VITE_PLATFORM;
const pwa_enabled = process.env.VITE_PWA_ENABLED === "true";
logger.error("Platform", { platform });
logger.error("PWA enabled", { pwa_enabled });
logger.log("Platform", { platform });
logger.log("PWA enabled", { pwa_enabled });
// Global Error Handler
function setupGlobalErrorHandler(app: VueApp) {

295
src/main.electron.ts

@ -1,16 +1,301 @@
import { initializeApp } from "./main.common";
import { logger } from "./utils/logger";
import { SQLiteQueryResult } from "./services/platforms/ElectronPlatformService";
const platform = process.env.VITE_PLATFORM;
const pwa_enabled = process.env.VITE_PWA_ENABLED === "true";
logger.info("[Electron] Initializing app");
logger.info("[Electron] Platform:", { platform });
logger.info("[Electron] PWA enabled:", { pwa_enabled });
logger.info("[Main Electron] Initializing app");
logger.info("[Main Electron] Platform:", { platform });
logger.info("[Main Electron] PWA enabled:", { pwa_enabled });
if (pwa_enabled) {
logger.warn("[Electron] PWA is enabled, but not supported in electron");
logger.warn("[Main Electron] PWA is enabled, but not supported in electron");
}
// Initialize app and SQLite
const app = initializeApp();
app.mount("#app");
// Create a promise that resolves when SQLite is ready
const sqliteReady = new Promise<void>((resolve, reject) => {
let retryCount = 0;
let initializationTimeout: NodeJS.Timeout;
const attemptInitialization = () => {
// Clear any existing timeout
if (initializationTimeout) {
clearTimeout(initializationTimeout);
}
// Set timeout for this attempt
initializationTimeout = setTimeout(() => {
if (retryCount < 3) {
// Use same retry count as ElectronPlatformService
retryCount++;
logger.warn(
`[Main Electron] SQLite initialization attempt ${retryCount} timed out, retrying...`,
);
setTimeout(attemptInitialization, 1000); // Use same delay as ElectronPlatformService
} else {
logger.error(
"[Main Electron] SQLite initialization failed after all retries",
);
reject(new Error("SQLite initialization timeout after all retries"));
}
}, 10000); // Use same timeout as ElectronPlatformService
// Wait for electron bridge to be available
const checkElectronBridge = () => {
if (!window.electron?.ipcRenderer) {
// Check again in 100ms if bridge isn't ready
setTimeout(checkElectronBridge, 100);
return;
}
// At this point we know ipcRenderer exists
const ipcRenderer = window.electron.ipcRenderer;
logger.info("[Main Electron] [IPC:bridge] IPC renderer bridge available");
// Listen for SQLite ready signal
logger.debug(
"[Main Electron] [IPC:sqlite-ready] Registering listener for SQLite ready signal",
);
ipcRenderer.once("sqlite-ready", () => {
clearTimeout(initializationTimeout);
logger.info(
"[Main Electron] [IPC:sqlite-ready] Received SQLite ready signal",
);
resolve();
});
// Also listen for database errors
logger.debug(
"[Main Electron] [IPC:database-status] Registering listener for database status",
);
ipcRenderer.once("database-status", (...args: unknown[]) => {
clearTimeout(initializationTimeout);
const status = args[0] as { status: string; error?: string };
if (status.status === "error") {
logger.error(
"[Main Electron] [IPC:database-status] Database error:",
{
error: status.error,
channel: "database-status",
},
);
reject(new Error(status.error || "Database initialization failed"));
}
});
// Check if SQLite is already available
logger.debug(
"[Main Electron] [IPC:sqlite-is-available] Checking SQLite availability",
);
ipcRenderer
.invoke("sqlite-is-available")
.then(async (result: unknown) => {
const isAvailable = Boolean(result);
if (isAvailable) {
logger.info(
"[Main Electron] [IPC:sqlite-is-available] SQLite is available",
);
try {
// First create a database connection
logger.debug(
"[Main Electron] [IPC:get-path] Requesting database path",
);
const dbPath = await ipcRenderer.invoke("get-path");
logger.info(
"[Main Electron] [IPC:get-path] Database path received:",
{ dbPath },
);
// Create the database connection
logger.debug(
"[Main Electron] [IPC:sqlite-create-connection] Creating database connection",
);
await ipcRenderer.invoke("sqlite-create-connection", {
database: "timesafari",
version: 1,
});
logger.info(
"[Main Electron] [IPC:sqlite-create-connection] Database connection created",
);
// Explicitly open the database
logger.debug(
"[Main Electron] [IPC:sqlite-open] Opening database",
);
await ipcRenderer.invoke("sqlite-open", {
database: "timesafari",
});
logger.info(
"[Main Electron] [IPC:sqlite-open] Database opened successfully",
);
// Verify the database is open
logger.debug(
"[Main Electron] [IPC:sqlite-is-db-open] Verifying database is open",
);
const isOpen = await ipcRenderer.invoke("sqlite-is-db-open", {
database: "timesafari",
});
logger.info(
"[Main Electron] [IPC:sqlite-is-db-open] Database open status:",
{ isOpen },
);
if (!isOpen) {
throw new Error("Database failed to open");
}
// Now execute the test query
logger.debug(
"[Main Electron] [IPC:sqlite-query] Executing test query",
);
const testQuery = (await ipcRenderer.invoke("sqlite-query", {
database: "timesafari",
statement: "SELECT 1 as test;", // Safe test query
})) as SQLiteQueryResult;
logger.info(
"[Main Electron] [IPC:sqlite-query] Test query successful:",
{
hasResults: Boolean(testQuery?.values),
resultCount: testQuery?.values?.length,
},
);
// Signal that SQLite is ready - database stays open
logger.debug(
"[Main Electron] [IPC:sqlite-status] Sending SQLite ready status",
);
await ipcRenderer.invoke("sqlite-status", {
status: "ready",
database: "timesafari",
timestamp: Date.now(),
});
logger.info(
"[Main Electron] SQLite ready status sent, database connection maintained",
);
// Remove the close operations - database stays open for component use
// Database will be closed during app shutdown
} catch (error) {
logger.error(
"[Main Electron] [IPC:*] SQLite test operation failed:",
{
error,
lastOperation: "sqlite-test-query",
database: "timesafari",
},
);
// Try to close everything if anything was opened
try {
logger.debug(
"[Main Electron] [IPC:cleanup] Attempting database cleanup after error",
);
await ipcRenderer
.invoke("sqlite-close", {
database: "timesafari",
})
.catch((closeError) => {
logger.warn(
"[Main Electron] [IPC:sqlite-close] Failed to close database during cleanup:",
closeError,
);
});
await ipcRenderer
.invoke("sqlite-close-connection", {
database: "timesafari",
})
.catch((closeError) => {
logger.warn(
"[Main Electron] [IPC:sqlite-close-connection] Failed to close connection during cleanup:",
closeError,
);
});
logger.info(
"[Main Electron] [IPC:cleanup] Database cleanup completed after error",
);
} catch (closeError) {
logger.error(
"[Main Electron] [IPC:cleanup] Failed to cleanup database:",
{
error: closeError,
database: "timesafari",
},
);
}
// Don't reject here - we still want to wait for the ready signal
}
}
})
.catch((error: Error) => {
logger.error(
"[Main Electron] [IPC:sqlite-is-available] Failed to check SQLite availability:",
{
error,
channel: "sqlite-is-available",
},
);
// Don't reject here - wait for either ready signal or timeout
});
};
// Start checking for bridge
checkElectronBridge();
};
// Start first initialization attempt
attemptInitialization();
});
// Wait for SQLite to be ready before initializing router and mounting app
sqliteReady
.then(async () => {
logger.info("[Main Electron] SQLite ready, initializing router...");
// Initialize router after SQLite is ready
const router = await import("./router").then((m) => m.default);
app.use(router);
logger.info("[Main Electron] Router initialized");
// Now mount the app
logger.info("[Main Electron] Mounting app...");
app.mount("#app");
logger.info("[Main Electron] App mounted successfully");
})
.catch((error) => {
logger.error(
"[Main Electron] Failed to initialize SQLite:",
error instanceof Error ? error.message : "Unknown error",
);
// Show error to user with retry option
const errorDiv = document.createElement("div");
errorDiv.style.cssText =
"position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); background: #ffebee; color: #c62828; padding: 20px; border-radius: 4px; text-align: center; max-width: 80%; z-index: 9999;";
errorDiv.innerHTML = `
<h2>Failed to Initialize Application</h2>
<p>There was an error initializing the database. This could be due to:</p>
<ul style="text-align: left; margin: 10px 0;">
<li>Database file is locked by another process</li>
<li>Insufficient permissions to access the database</li>
<li>Database file is corrupted</li>
</ul>
<p>Error details: ${error instanceof Error ? error.message : "Unknown error"}</p>
<div style="margin-top: 15px;">
<button onclick="window.location.reload()" style="margin: 0 5px; padding: 8px 16px; background: #c62828; color: white; border: none; border-radius: 4px; cursor: pointer;">
Retry
</button>
<button onclick="window.electron.ipcRenderer.send('sqlite-status', { action: 'reset' })" style="margin: 0 5px; padding: 8px 16px; background: #f57c00; color: white; border: none; border-radius: 4px; cursor: pointer;">
Reset Database
</button>
</div>
`;
document.body.appendChild(errorDiv);
});

17
src/router/index.ts

@ -277,18 +277,31 @@ const initialPath = isElectron
? window.location.pathname.split("/dist-electron/www/")[1] || "/"
: window.location.pathname;
logger.info("[Router] Initializing router", { isElectron, initialPath });
const history = isElectron
? createMemoryHistory() // Memory history for Electron
: createWebHistory("/"); // Add base path for web apps
/** @type {*} */
const router = createRouter({
history,
routes,
});
// Set initial route
router.beforeEach((to, from, next) => {
logger.info("[Router] Navigation", { to: to.path, from: from.path });
next();
});
// Replace initial URL to start at `/` if necessary
router.replace(initialPath || "/");
if (initialPath === "/" || !initialPath) {
logger.info("[Router] Setting initial route to /");
router.replace("/");
} else {
logger.info("[Router] Setting initial route to", initialPath);
router.replace(initialPath);
}
const errorHandler = (
// eslint-disable-next-line @typescript-eslint/no-explicit-any

2
src/services/AbsurdSqlDatabaseService.ts

@ -84,7 +84,7 @@ class AbsurdSqlDatabaseService implements DatabaseService {
SQL.FS.mkdir("/sql");
SQL.FS.mount(sqlFS, {}, "/sql");
const path = "/sql/timesafari.sqlite";
const path = "/sql/timesafari.absurd-sql";
if (typeof SharedArrayBuffer === "undefined") {
const stream = SQL.FS.open(path, "a+");
await stream.node.contents.readIfFallback();

20
src/services/PlatformService.ts

@ -1,5 +1,13 @@
import { QueryExecResult } from "@/interfaces/database";
/**
* Query execution result interface
*/
export interface QueryExecResult<T = unknown> {
columns: string[];
values: T[];
}
/**
* Represents the result of an image capture or selection operation.
* Contains both the image data as a Blob and the associated filename.
@ -102,15 +110,15 @@ export interface PlatformService {
handleDeepLink(url: string): Promise<void>;
/**
* Executes a SQL query on the database.
* @param sql - The SQL query to execute
* @param params - The parameters to pass to the query
* @returns Promise resolving to the query result
* Execute a database query and return the results
* @param sql SQL query to execute
* @param params Query parameters
* @returns Query results with columns and values
*/
dbQuery(
dbQuery<T = unknown>(
sql: string,
params?: unknown[],
): Promise<QueryExecResult | undefined>;
): Promise<QueryExecResult<T>>;
/**
* Executes a create/update/delete on the database.

132
src/services/database/ConnectionPool.ts

@ -0,0 +1,132 @@
import { logger } from "../../utils/logger";
import { SQLiteDBConnection } from "@capacitor-community/sqlite";
interface ConnectionState {
connection: SQLiteDBConnection;
lastUsed: number;
inUse: boolean;
}
export class DatabaseConnectionPool {
private static instance: DatabaseConnectionPool | null = null;
private connections: Map<string, ConnectionState> = new Map();
private readonly MAX_CONNECTIONS = 1; // We only need one connection for SQLite
private readonly MAX_IDLE_TIME = 5 * 60 * 1000; // 5 minutes
private readonly CLEANUP_INTERVAL = 60 * 1000; // 1 minute
private cleanupInterval: NodeJS.Timeout | null = null;
private constructor() {
// Start cleanup interval
this.cleanupInterval = setInterval(
() => this.cleanup(),
this.CLEANUP_INTERVAL,
);
}
public static getInstance(): DatabaseConnectionPool {
if (!DatabaseConnectionPool.instance) {
DatabaseConnectionPool.instance = new DatabaseConnectionPool();
}
return DatabaseConnectionPool.instance;
}
public async getConnection(
dbName: string,
createConnection: () => Promise<SQLiteDBConnection>,
): Promise<SQLiteDBConnection> {
// Check if we have an existing connection
const existing = this.connections.get(dbName);
if (existing && !existing.inUse) {
existing.inUse = true;
existing.lastUsed = Date.now();
logger.debug(
`[ConnectionPool] Reusing existing connection for ${dbName}`,
);
return existing.connection;
}
// If we have too many connections, wait for one to be released
if (this.connections.size >= this.MAX_CONNECTIONS) {
logger.debug(`[ConnectionPool] Waiting for connection to be released...`);
await this.waitForConnection();
}
// Create new connection
try {
const connection = await createConnection();
this.connections.set(dbName, {
connection,
lastUsed: Date.now(),
inUse: true,
});
logger.debug(`[ConnectionPool] Created new connection for ${dbName}`);
return connection;
} catch (error) {
logger.error(
`[ConnectionPool] Failed to create connection for ${dbName}:`,
error,
);
throw error;
}
}
public async releaseConnection(dbName: string): Promise<void> {
const connection = this.connections.get(dbName);
if (connection) {
connection.inUse = false;
connection.lastUsed = Date.now();
logger.debug(`[ConnectionPool] Released connection for ${dbName}`);
}
}
private async waitForConnection(): Promise<void> {
return new Promise((resolve) => {
const checkInterval = setInterval(() => {
if (this.connections.size < this.MAX_CONNECTIONS) {
clearInterval(checkInterval);
resolve();
}
}, 100);
});
}
private async cleanup(): Promise<void> {
const now = Date.now();
for (const [dbName, state] of this.connections.entries()) {
if (!state.inUse && now - state.lastUsed > this.MAX_IDLE_TIME) {
try {
await state.connection.close();
this.connections.delete(dbName);
logger.debug(
`[ConnectionPool] Cleaned up idle connection for ${dbName}`,
);
} catch (error) {
logger.warn(
`[ConnectionPool] Error closing idle connection for ${dbName}:`,
error,
);
}
}
}
}
public async closeAll(): Promise<void> {
if (this.cleanupInterval) {
clearInterval(this.cleanupInterval);
this.cleanupInterval = null;
}
for (const [dbName, state] of this.connections.entries()) {
try {
await state.connection.close();
logger.debug(`[ConnectionPool] Closed connection for ${dbName}`);
} catch (error) {
logger.warn(
`[ConnectionPool] Error closing connection for ${dbName}:`,
error,
);
}
}
this.connections.clear();
}
}

707
src/services/platforms/ElectronPlatformService.ts

@ -2,22 +2,97 @@ import {
ImageResult,
PlatformService,
PlatformCapabilities,
QueryExecResult,
} from "../PlatformService";
import { logger } from "../../utils/logger";
import { QueryExecResult, SqlValue } from "@/interfaces/database";
import {
SQLiteConnection,
SQLiteDBConnection,
CapacitorSQLite,
Changes,
} from "@capacitor-community/sqlite";
import { DEFAULT_ENDORSER_API_SERVER } from "@/constants/app";
import { ElectronAPI } from "../../utils/debug-electron";
import {
verifyElectronAPI,
testSQLiteOperations,
} from "../../utils/debug-electron";
// Extend the global Window interface
declare global {
interface Window {
electron: ElectronAPI;
}
}
interface Migration {
name: string;
sql: string;
}
// Define the SQLite query result type
interface SQLiteQueryResult {
changes: number;
lastId: number;
rows?: unknown[];
values?: Record<string, unknown>[];
}
// Update the QueryExecResult type to include success and changes
interface ElectronQueryExecResult {
success: boolean;
changes: number;
lastId?: number;
rows?: unknown[];
}
/**
* Shared SQLite initialization state
* Used to coordinate initialization between main and service
*
* @author Matthew Raymer
*/
export interface SQLiteInitState {
isReady: boolean;
isInitializing: boolean;
error?: Error;
lastReadyCheck?: number;
}
// Singleton instance for shared state
const sqliteInitState: SQLiteInitState = {
isReady: false,
isInitializing: false,
lastReadyCheck: 0,
};
/**
* Interface defining SQLite database operations
* @author Matthew Raymer
*/
interface SQLiteOperations {
createConnection: (options: {
database: string;
encrypted: boolean;
mode: string;
}) => Promise<void>;
query: (options: {
database: string;
statement: string;
values?: unknown[];
}) => Promise<{ values?: unknown[] }>;
execute: (options: { database: string; statements: string }) => Promise<void>;
run: (options: {
database: string;
statement: string;
values?: unknown[];
}) => Promise<{ changes?: { changes: number; lastId?: number } }>;
}
// Add at the top of the file after imports
const formatLogObject = (obj: unknown): string => {
try {
return JSON.stringify(obj, null, 2);
} catch (error) {
return `[Object could not be stringified: ${error instanceof Error ? error.message : String(error)}]`;
}
};
/**
* Platform service implementation for Electron (desktop) platform.
* Provides native desktop functionality through Electron and Capacitor plugins for:
@ -25,68 +100,299 @@ interface Migration {
* - Camera integration (TODO)
* - SQLite database operations
* - System-level features (TODO)
*
* @author Matthew Raymer
*/
export class ElectronPlatformService implements PlatformService {
private sqlite: SQLiteConnection;
private db: SQLiteDBConnection | null = null;
private dbName = "timesafari.db";
private initialized = false;
private sqlite: SQLiteOperations | null = null;
private dbName = "timesafari";
private isInitialized = false;
private dbFatalError = false;
private sqliteReadyPromise: Promise<void> | null = null;
private initializationTimeout: NodeJS.Timeout | null = null;
private isConnectionOpen = false;
private operationQueue: Promise<unknown> = Promise.resolve();
private queueLock = false;
private connectionState:
| "disconnected"
| "connecting"
| "connected"
| "error" = "disconnected";
private connectionPromise: Promise<void> | null = null;
// SQLite initialization configuration
private static readonly SQLITE_CONFIG = {
INITIALIZATION: {
TIMEOUT_MS: 5000, // Increase timeout to 5 seconds
RETRY_ATTEMPTS: 3,
RETRY_DELAY_MS: 1000,
READY_CHECK_INTERVAL_MS: 100,
},
};
constructor() {
this.sqlite = new SQLiteConnection(CapacitorSQLite);
this.sqliteReadyPromise = new Promise<void>((resolve, reject) => {
let retryCount = 0;
const cleanup = () => {
if (this.initializationTimeout) {
clearTimeout(this.initializationTimeout);
this.initializationTimeout = null;
}
};
private async initializeDatabase(): Promise<void> {
if (this.initialized) {
const checkExistingReadiness = async (): Promise<boolean> => {
try {
if (!window.electron?.ipcRenderer) {
return false;
}
// Check if SQLite is already available
const isAvailable = await window.electron.ipcRenderer.invoke(
"sqlite-is-available",
);
if (!isAvailable) {
return false;
}
// Check if database is already open
const isOpen = await window.electron.ipcRenderer.invoke(
"sqlite-is-db-open",
{
database: this.dbName,
},
);
if (isOpen) {
logger.info(
"[ElectronPlatformService] SQLite is already ready and database is open",
);
sqliteInitState.isReady = true;
sqliteInitState.isInitializing = false;
sqliteInitState.lastReadyCheck = Date.now();
return true;
}
return false;
} catch (error) {
logger.warn(
"[ElectronPlatformService] Error checking existing readiness:",
error,
);
return false;
}
};
const attemptInitialization = async () => {
cleanup();
// Check if SQLite is already ready
if (await checkExistingReadiness()) {
this.isInitialized = true;
resolve();
return;
}
// If someone else is initializing, wait for them
if (sqliteInitState.isInitializing) {
logger.info(
"[ElectronPlatformService] Another initialization in progress, waiting...",
);
setTimeout(
attemptInitialization,
ElectronPlatformService.SQLITE_CONFIG.INITIALIZATION
.READY_CHECK_INTERVAL_MS,
);
return;
}
try {
// Create/Open database
this.db = await this.sqlite.createConnection(
this.dbName,
false,
"no-encryption",
1,
false,
sqliteInitState.isInitializing = true;
// Verify Electron API exposure first
await verifyElectronAPI();
logger.info(
"[ElectronPlatformService] Electron API verification successful",
);
await this.db.open();
if (!window.electron?.ipcRenderer) {
logger.warn("[ElectronPlatformService] IPC renderer not available");
reject(new Error("IPC renderer not available"));
return;
}
// Set journal mode to WAL for better performance
await this.db.execute("PRAGMA journal_mode=WAL;");
// Set up ready signal handler BEFORE setting timeout
window.electron.ipcRenderer.once("sqlite-ready", async () => {
cleanup();
logger.info(
"[ElectronPlatformService] Received SQLite ready signal",
);
// Run migrations
await this.runMigrations();
try {
// Test SQLite operations after receiving ready signal
await testSQLiteOperations();
logger.info(
"[ElectronPlatformService] SQLite operations test successful",
);
this.initialized = true;
logger.log("SQLite database initialized successfully");
this.isInitialized = true;
sqliteInitState.isReady = true;
sqliteInitState.isInitializing = false;
sqliteInitState.lastReadyCheck = Date.now();
resolve();
} catch (error) {
logger.error("Error initializing SQLite database:", error);
throw new Error("Failed to initialize database");
sqliteInitState.error = error as Error;
sqliteInitState.isInitializing = false;
logger.error(
"[ElectronPlatformService] SQLite operations test failed:",
error,
);
reject(error);
}
});
// Set up error handler
window.electron.ipcRenderer.once(
"database-status",
(...args: unknown[]) => {
cleanup();
const status = args[0] as { status: string; error?: string };
if (status.status === "error") {
this.dbFatalError = true;
sqliteInitState.error = new Error(
status.error || "Database initialization failed",
);
sqliteInitState.isInitializing = false;
reject(sqliteInitState.error);
}
},
);
// Set timeout for this attempt AFTER setting up handlers
this.initializationTimeout = setTimeout(() => {
if (
retryCount <
ElectronPlatformService.SQLITE_CONFIG.INITIALIZATION
.RETRY_ATTEMPTS
) {
retryCount++;
logger.warn(
`[ElectronPlatformService] SQLite initialization attempt ${retryCount} timed out, retrying...`,
);
setTimeout(
attemptInitialization,
ElectronPlatformService.SQLITE_CONFIG.INITIALIZATION
.RETRY_DELAY_MS,
);
} else {
cleanup();
sqliteInitState.isInitializing = false;
sqliteInitState.error = new Error(
"SQLite initialization timeout after all retries",
);
logger.error(
"[ElectronPlatformService] SQLite initialization failed after all retries",
);
reject(sqliteInitState.error);
}
}, ElectronPlatformService.SQLITE_CONFIG.INITIALIZATION.TIMEOUT_MS);
} catch (error) {
cleanup();
sqliteInitState.error = error as Error;
sqliteInitState.isInitializing = false;
logger.error(
"[ElectronPlatformService] Initialization failed:",
error,
);
reject(error);
}
};
// Start first initialization attempt
attemptInitialization();
});
}
private async initializeDatabase(): Promise<void> {
if (this.isInitialized) return;
if (this.sqliteReadyPromise) await this.sqliteReadyPromise;
if (!window.electron?.sqlite) {
throw new Error("SQLite IPC bridge not available");
}
// Use IPC bridge with specific methods
this.sqlite = {
createConnection: async (options) => {
await window.electron.ipcRenderer.invoke("sqlite-create-connection", {
...options,
database: this.dbName,
});
},
query: async (options) => {
return await window.electron.ipcRenderer.invoke("sqlite-query", {
...options,
database: this.dbName,
});
},
run: async (options) => {
return await window.electron.ipcRenderer.invoke("sqlite-run", {
...options,
database: this.dbName,
});
},
execute: async (options) => {
await window.electron.ipcRenderer.invoke("sqlite-execute", {
...options,
database: this.dbName,
statements: [{ statement: options.statements }],
});
},
} as SQLiteOperations;
// Create the connection (idempotent)
await this.sqlite!.createConnection({
database: this.dbName,
encrypted: false,
mode: "no-encryption",
});
// Optionally, test the connection
await this.sqlite!.query({
database: this.dbName,
statement: "SELECT 1",
});
// Run migrations if needed
await this.runMigrations();
logger.info("[ElectronPlatformService] Database initialized successfully");
}
private async runMigrations(): Promise<void> {
if (!this.db) {
throw new Error("Database not initialized");
if (!this.sqlite) {
throw new Error("SQLite not initialized");
}
// Create migrations table if it doesn't exist
await this.db.execute(`
CREATE TABLE IF NOT EXISTS migrations (
await this.sqlite.execute({
database: this.dbName,
statements: `CREATE TABLE IF NOT EXISTS migrations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
executed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
`);
);`,
});
// Get list of executed migrations
const result = await this.db.query("SELECT name FROM migrations;");
const result = await this.sqlite.query({
database: this.dbName,
statement: "SELECT name FROM migrations;",
});
const executedMigrations = new Set(
result.values?.map((row) => row[0]) || [],
(result.values as unknown[][])?.map(
(row: unknown[]) => row[0] as string,
) || [],
);
// Run pending migrations in order
const migrations: Migration[] = [
{
@ -174,13 +480,17 @@ export class ElectronPlatformService implements PlatformService {
`,
},
];
for (const migration of migrations) {
if (!executedMigrations.has(migration.name)) {
await this.db.execute(migration.sql);
await this.db.run("INSERT INTO migrations (name) VALUES (?)", [
migration.name,
]);
await this.sqlite.execute({
database: this.dbName,
statements: migration.sql,
});
await this.sqlite.run({
database: this.dbName,
statement: "INSERT INTO migrations (name) VALUES (?)",
values: [migration.name],
});
logger.log(`Migration ${migration.name} executed successfully`);
}
}
@ -288,28 +598,194 @@ export class ElectronPlatformService implements PlatformService {
throw new Error("Not implemented");
}
private async enqueueOperation<T>(operation: () => Promise<T>): Promise<T> {
// Wait for any existing operations to complete
await this.operationQueue;
// Create a new promise for this operation
const operationPromise = (async () => {
try {
// Acquire lock
while (this.queueLock) {
await new Promise((resolve) => setTimeout(resolve, 50));
}
this.queueLock = true;
// Execute operation
return await operation();
} finally {
// Release lock
this.queueLock = false;
}
})();
// Update the queue
this.operationQueue = operationPromise;
return operationPromise;
}
private async getConnection(): Promise<void> {
// If we already have a connection promise, return it
if (this.connectionPromise) {
return this.connectionPromise;
}
// If we're already connected, return immediately
if (this.connectionState === "connected") {
return Promise.resolve();
}
// Create new connection promise
this.connectionPromise = (async () => {
try {
this.connectionState = "connecting";
// Wait for any existing operations
await this.operationQueue;
// Create connection
await window.electron!.ipcRenderer.invoke("sqlite-create-connection", {
database: this.dbName,
encrypted: false,
mode: "no-encryption",
});
logger.debug("[ElectronPlatformService] Database connection created");
// Open database
await window.electron!.ipcRenderer.invoke("sqlite-open", {
database: this.dbName,
});
logger.debug("[ElectronPlatformService] Database opened");
// Verify database is open
const isOpen = await window.electron!.ipcRenderer.invoke(
"sqlite-is-db-open",
{
database: this.dbName,
},
);
if (!isOpen) {
throw new Error("[ElectronPlatformService] Database failed to open");
}
this.connectionState = "connected";
this.isConnectionOpen = true;
} catch (error) {
this.connectionState = "error";
this.connectionPromise = null;
throw error;
}
})();
return this.connectionPromise;
}
private async releaseConnection(): Promise<void> {
if (this.connectionState !== "connected") {
return;
}
try {
// Close database
await window.electron!.ipcRenderer.invoke("sqlite-close", {
database: this.dbName,
});
logger.debug("[ElectronPlatformService] Database closed");
// Close connection
await window.electron!.ipcRenderer.invoke("sqlite-close-connection", {
database: this.dbName,
});
logger.debug("[ElectronPlatformService] Database connection closed");
this.connectionState = "disconnected";
this.isConnectionOpen = false;
} catch (error) {
logger.error(
"[ElectronPlatformService] Failed to close database:",
error,
);
this.connectionState = "error";
} finally {
this.connectionPromise = null;
}
}
/**
* @see PlatformService.dbQuery
* Executes a database query with proper connection lifecycle management.
* Opens connection, executes query, and ensures proper cleanup.
*
* @param sql - SQL query to execute
* @param params - Optional parameters for the query
* @returns Promise resolving to query results
* @throws Error if database operations fail
*/
async dbQuery(sql: string, params?: unknown[]): Promise<QueryExecResult> {
await this.initializeDatabase();
if (!this.db) {
throw new Error("Database not initialized");
async dbQuery<T = unknown>(
sql: string,
params: unknown[] = [],
): Promise<QueryExecResult<T>> {
if (this.dbFatalError) {
throw new Error(
"Database is in a fatal error state. Please restart the app.",
);
}
logger.debug('[ElectronPlatformService] [dbQuery] Enqueuing operation', {
sql: sql.substring(0, 100) + (sql.length > 100 ? '...' : ''),
paramCount: params.length,
timestamp: new Date().toISOString()
});
return this.enqueueOperation(async () => {
try {
const result = await this.db.query(sql, params || []);
const values = result.values || [];
return {
columns: [], // SQLite plugin doesn't provide column names in query result
values: values as SqlValue[][],
// Get connection (will wait for existing connection if any)
console.log("[ElectronPlatformService] [dbQuery] Getting connection");
await this.getConnection();
console.log("[ElectronPlatformService] [dbQuery] Connection acquired");
// Execute query
console.log(
"[ElectronPlatformService] [dbQuery] Executing query",
{ sql, params },
);
const result = (await window.electron!.ipcRenderer.invoke(
"sqlite-query",
{
database: this.dbName,
statement: sql,
values: params,
},
)) as SQLiteQueryResult;
console.log(
"[ElectronPlatformService] [dbQuery] Query executed successfully",
{ result },
);
// Process results
const columns = result.values?.[0] ? Object.keys(result.values[0]) : [];
const processedResult = {
columns,
values: (result.values || []).map(
(row: Record<string, unknown>) => row as T,
),
};
console.log(
"[ElectronPlatformService] [dbQuery] Query processed successfully",
{ processedResult },
);
return processedResult;
} catch (error) {
logger.error("Error executing query:", error);
throw new Error(
`Database query failed: ${error instanceof Error ? error.message : String(error)}`,
console.error(
"[ElectronPlatformService] [dbQuery] Query failed:",
error,
);
throw error;
} finally {
// Release connection after query
await this.releaseConnection();
}
});
}
/**
@ -319,23 +795,122 @@ export class ElectronPlatformService implements PlatformService {
sql: string,
params?: unknown[],
): Promise<{ changes: number; lastId?: number }> {
await this.initializeDatabase();
if (!this.db) {
throw new Error("Database not initialized");
console.log("[ElectronPlatformService] [dbExec] Executing query", {
sql,
params,
});
if (this.dbFatalError) {
throw new Error(
"Database is in a fatal error state. Please restart the app.",
);
}
return this.enqueueOperation(async () => {
try {
const result = await this.db.run(sql, params || []);
const changes = result.changes as Changes;
// Get connection (will wait for existing connection if any)
console.log("[ElectronPlatformService] [dbExec] Getting connection");
await this.getConnection();
console.log("[ElectronPlatformService] [dbExec] Connection acquired");
// Execute query
console.log(
"[ElectronPlatformService] [dbExec] Executing query",
{ sql, params },
);
const result = (await window.electron!.ipcRenderer.invoke(
"sqlite-run",
{
database: this.dbName,
statement: sql,
values: params,
},
)) as SQLiteQueryResult;
console.log(
"[ElectronPlatformService] [dbExec] Query executed successfully",
{ result },
);
return {
changes: changes?.changes || 0,
lastId: changes?.lastId,
changes: result.changes,
lastId: result.lastId,
};
} catch (error) {
logger.error("Error executing statement:", error);
console.error(
"[ElectronPlatformService] [dbExec] Query failed:",
error,
);
throw error;
} finally {
// Release connection after query
await this.releaseConnection();
}
});
}
async initialize(): Promise<void> {
await this.initializeDatabase();
}
async execute(sql: string, params: unknown[] = []): Promise<void> {
await this.initializeDatabase();
if (this.dbFatalError) {
throw new Error(
`Database execution failed: ${error instanceof Error ? error.message : String(error)}`,
"Database is in a fatal error state. Please restart the app.",
);
}
if (!this.sqlite) {
throw new Error("SQLite not initialized");
}
await this.sqlite.run({
database: this.dbName,
statement: sql,
values: params,
});
}
async close(): Promise<void> {
// Optionally implement close logic if needed
}
async run(sql: string, params: unknown[] = []): Promise<ElectronQueryExecResult> {
logger.debug('[ElectronPlatformService] [dbRun] Executing SQL:', {
sql: sql.substring(0, 100) + (sql.length > 100 ? '...' : ''),
params: formatLogObject(params),
timestamp: new Date().toISOString()
});
try {
const result = (await window.electron!.ipcRenderer.invoke(
'sqlite-run',
{
database: this.dbName,
statement: sql,
values: params,
},
)) as SQLiteQueryResult;
logger.debug('[ElectronPlatformService] [dbRun] SQL execution result:', {
result: formatLogObject(result),
timestamp: new Date().toISOString()
});
return {
success: true,
changes: result.changes,
lastId: result.lastId,
};
} catch (error) {
logger.error('[ElectronPlatformService] [dbRun] SQL execution failed:', {
error: error instanceof Error ? {
name: error.name,
message: error.message,
stack: error.stack
} : formatLogObject(error),
sql,
params: formatLogObject(params),
timestamp: new Date().toISOString()
});
throw error;
}
}
}

46
src/types/capacitor-sqlite-electron.d.ts

@ -0,0 +1,46 @@
declare module '@capacitor-community/sqlite/electron/dist/plugin.js' {
export class CapacitorSQLite {
constructor();
handle(event: Electron.IpcMainInvokeEvent, ...args: any[]): Promise<any>;
createConnection(options: any): Promise<any>;
closeConnection(options: any): Promise<any>;
echo(options: any): Promise<any>;
open(options: any): Promise<any>;
close(options: any): Promise<any>;
beginTransaction(options: any): Promise<any>;
commitTransaction(options: any): Promise<any>;
rollbackTransaction(options: any): Promise<any>;
isTransactionActive(options: any): Promise<any>;
getVersion(options: any): Promise<any>;
getTableList(options: any): Promise<any>;
execute(options: any): Promise<any>;
executeSet(options: any): Promise<any>;
run(options: any): Promise<any>;
query(options: any): Promise<any>;
isDBExists(options: any): Promise<any>;
isDBOpen(options: any): Promise<any>;
isDatabase(options: any): Promise<any>;
isTableExists(options: any): Promise<any>;
deleteDatabase(options: any): Promise<any>;
isJsonValid(options: any): Promise<any>;
importFromJson(options: any): Promise<any>;
exportToJson(options: any): Promise<any>;
createSyncTable(options: any): Promise<any>;
setSyncDate(options: any): Promise<any>;
getSyncDate(options: any): Promise<any>;
deleteExportedRows(options: any): Promise<any>;
addUpgradeStatement(options: any): Promise<any>;
copyFromAssets(options: any): Promise<any>;
getFromHTTPRequest(options: any): Promise<any>;
getDatabaseList(): Promise<any>;
checkConnectionsConsistency(options: any): Promise<any>;
isSecretStored(): Promise<any>;
isPassphraseValid(options: any): Promise<any>;
setEncryptionSecret(options: any): Promise<any>;
changeEncryptionSecret(options: any): Promise<any>;
clearEncryptionSecret(): Promise<any>;
isInConfigEncryption(): Promise<any>;
isDatabaseEncrypted(options: any): Promise<any>;
checkEncryptionSecret(options: any): Promise<any>;
}
}

87
src/types/electron.d.ts

@ -0,0 +1,87 @@
interface ElectronAPI {
sqlite: {
isAvailable: () => Promise<boolean>;
echo: (value: string) => Promise<{ value: string }>;
createConnection: (options: {
database: string;
version?: number;
readOnly?: boolean;
readonly?: boolean;
encryption?: string;
mode?: string;
useNative?: boolean;
[key: string]: unknown;
}) => Promise<void>;
closeConnection: (options: { database: string }) => Promise<void>;
query: (options: { statement: string; values?: unknown[] }) => Promise<{
values?: Record<string, unknown>[];
changes?: { changes: number; lastId?: number };
}>;
run: (options: { statement: string; values?: unknown[] }) => Promise<{
changes?: { changes: number; lastId?: number };
}>;
execute: (options: { statements: { statement: string; values?: unknown[] }[] }) => Promise<{
changes?: { changes: number; lastId?: number }[];
}>;
getPlatform: () => Promise<string>;
};
ipcRenderer: {
on: (channel: string, func: (...args: unknown[]) => void) => void;
once: (channel: string, func: (...args: unknown[]) => void) => void;
send: (channel: string, data: unknown) => void;
invoke: (channel: string, ...args: unknown[]) => Promise<unknown>;
};
env: {
platform: string;
isDev: boolean;
};
getPath: (pathType: string) => Promise<string>;
getBasePath: () => Promise<string>;
}
declare global {
interface Window {
electron: {
ipcRenderer: {
on: (channel: string, func: (...args: unknown[]) => void) => void;
once: (channel: string, func: (...args: unknown[]) => void) => void;
send: (channel: string, data: unknown) => void;
invoke: (channel: string, ...args: unknown[]) => Promise<unknown>;
};
sqlite: {
isAvailable: () => Promise<boolean>;
echo: (value: string) => Promise<{ value: string }>;
createConnection: (options: {
database: string;
version?: number;
readOnly?: boolean;
readonly?: boolean;
encryption?: string;
mode?: string;
useNative?: boolean;
[key: string]: unknown;
}) => Promise<void>;
closeConnection: (options: { database: string }) => Promise<void>;
query: (options: { statement: string; values?: unknown[] }) => Promise<{
values?: Record<string, unknown>[];
changes?: { changes: number; lastId?: number };
}>;
run: (options: { statement: string; values?: unknown[] }) => Promise<{
changes?: { changes: number; lastId?: number };
}>;
execute: (options: { statements: { statement: string; values?: unknown[] }[] }) => Promise<{
changes?: { changes: number; lastId?: number }[];
}>;
getPlatform: () => Promise<string>;
};
env: {
platform: string;
isDev: boolean;
};
getPath: (pathType: string) => Promise<string>;
getBasePath: () => Promise<string>;
};
}
}
export {};

32
src/types/global.d.ts

@ -1,4 +1,36 @@
import type { QueryExecResult, SqlValue } from "./database";
import type { CapacitorSQLite } from '@capacitor-community/sqlite';
declare global {
interface Window {
CapacitorSQLite: {
echo: (options: { value: string }) => Promise<{ value: string }>;
createConnection: (options: any) => Promise<any>;
closeConnection: (options: any) => Promise<any>;
execute: (options: any) => Promise<any>;
query: (options: any) => Promise<any>;
run: (options: any) => Promise<any>;
isAvailable: () => Promise<boolean>;
getPlatform: () => Promise<string>;
};
electron: {
sqlite: {
isAvailable: () => Promise<boolean>;
execute: (options: { method: string; database?: string; statement?: string; values?: unknown[]; statements?: string; encrypted?: boolean; mode?: string }) => Promise<unknown>;
};
// Add other electron IPC methods as needed
getPath: (pathType: string) => string;
send: (channel: string, data: any) => void;
receive: (channel: string, func: (...args: any[]) => void) => void;
env: {
isElectron: boolean;
isDev: boolean;
platform: string;
};
getBasePath: () => string;
}
}
}
declare module '@jlongster/sql.js' {
interface SQL {

1
src/types/jeepq-sqlite.d.ts

@ -0,0 +1 @@
declare module '@jeepq/sqlite/loader';

169
src/utils/debug-electron.ts

@ -0,0 +1,169 @@
/**
* Debug utilities for Electron integration
* Helps verify the context bridge and SQLite functionality
*
* @author Matthew Raymer
*/
import { logger } from "./logger";
// Define the SQLite interface
export interface SQLiteAPI {
isAvailable: () => Promise<boolean>;
echo: (value: string) => Promise<string>;
createConnection: (options: {
database: string;
version: number;
readOnly: boolean;
}) => Promise<void>;
closeConnection: (options: { database: string }) => Promise<void>;
query: (options: { statement: string }) => Promise<unknown>;
run: (options: { statement: string }) => Promise<unknown>;
execute: (options: {
statements: Array<{ statement: string }>;
}) => Promise<unknown>;
getPlatform: () => Promise<string>;
}
// Define the IPC renderer interface
export interface IPCRenderer {
on: (channel: string, func: (...args: unknown[]) => void) => void;
once: (channel: string, func: (...args: unknown[]) => void) => void;
send: (channel: string, ...args: unknown[]) => void;
invoke: (channel: string, ...args: unknown[]) => Promise<unknown>;
}
// Define the environment interface
export interface ElectronEnv {
platform: string;
isDev: boolean;
}
// Define the complete electron interface
export interface ElectronAPI {
sqlite: SQLiteAPI;
ipcRenderer: IPCRenderer;
env: ElectronEnv;
getPath: (pathType: string) => Promise<string>;
getBasePath: () => Promise<string>;
}
// Define the window.electron interface
declare global {
interface Window {
electron: ElectronAPI;
}
}
export async function verifyElectronAPI(): Promise<void> {
logger.info("[Debug] Verifying Electron API exposure...");
// Check if window.electron exists
if (!window.electron) {
throw new Error("window.electron is not defined");
}
logger.info("[Debug] window.electron is available");
// Verify IPC renderer
if (!window.electron.ipcRenderer) {
throw new Error("IPC renderer is not available");
}
logger.info("[Debug] IPC renderer is available with methods:", {
hasOn: typeof window.electron.ipcRenderer.on === "function",
hasOnce: typeof window.electron.ipcRenderer.once === "function",
hasSend: typeof window.electron.ipcRenderer.send === "function",
hasInvoke: typeof window.electron.ipcRenderer.invoke === "function",
});
// Verify SQLite API
if (!window.electron.sqlite) {
throw new Error("SQLite API is not available");
}
logger.info("[Debug] SQLite API is available with methods:", {
hasIsAvailable: typeof window.electron.sqlite.isAvailable === "function",
hasEcho: typeof window.electron.sqlite.echo === "function",
hasCreateConnection:
typeof window.electron.sqlite.createConnection === "function",
hasCloseConnection:
typeof window.electron.sqlite.closeConnection === "function",
hasQuery: typeof window.electron.sqlite.query === "function",
hasRun: typeof window.electron.sqlite.run === "function",
hasExecute: typeof window.electron.sqlite.execute === "function",
hasGetPlatform: typeof window.electron.sqlite.getPlatform === "function",
});
// Test SQLite availability
try {
const isAvailable = await window.electron.sqlite.isAvailable();
logger.info("[Debug] SQLite availability check:", { isAvailable });
} catch (error) {
logger.error("[Debug] SQLite availability check failed:", error);
}
// Test echo functionality
try {
const echoResult = await window.electron.sqlite.echo("test");
logger.info("[Debug] SQLite echo test:", echoResult);
} catch (error) {
logger.error("[Debug] SQLite echo test failed:", error);
}
// Verify environment
logger.info("[Debug] Environment:", {
platform: window.electron.env.platform,
isDev: window.electron.env.isDev,
});
logger.info("[Debug] Electron API verification complete");
}
// Export a function to test SQLite operations
export async function testSQLiteOperations(): Promise<void> {
logger.info("[Debug] Testing SQLite operations...");
try {
// Test connection creation
logger.info("[Debug] Creating test connection...");
await window.electron.sqlite.createConnection({
database: "test",
version: 1,
readOnly: false,
});
logger.info("[Debug] Test connection created successfully");
// Test query
logger.info("[Debug] Testing query operation...");
const queryResult = await window.electron.sqlite.query({
statement: "SELECT 1 as test",
});
logger.info("[Debug] Query test result:", queryResult);
// Test run
logger.info("[Debug] Testing run operation...");
const runResult = await window.electron.sqlite.run({
statement:
"CREATE TABLE IF NOT EXISTS test_table (id INTEGER PRIMARY KEY)",
});
logger.info("[Debug] Run test result:", runResult);
// Test execute
logger.info("[Debug] Testing execute operation...");
const executeResult = await window.electron.sqlite.execute({
statements: [
{ statement: "INSERT INTO test_table (id) VALUES (1)" },
{ statement: "SELECT * FROM test_table" },
],
});
logger.info("[Debug] Execute test result:", executeResult);
// Clean up
logger.info("[Debug] Closing test connection...");
await window.electron.sqlite.closeConnection({ database: "test" });
logger.info("[Debug] Test connection closed");
} catch (error) {
logger.error("[Debug] SQLite operation test failed:", error);
throw error;
}
logger.info("[Debug] SQLite operations test complete");
}

113
src/views/AccountViewView.vue

@ -68,9 +68,9 @@
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="
() =>
($refs.userNameDialog as UserNameDialog).open(
(name) => (givenName = name),
)
($refs.userNameDialog as UserNameDialog).open((name) => {
if (name) givenName = name;
})
"
>
Set Your Name
@ -437,7 +437,7 @@
<b>{{ endorserLimits?.maxClaimsPerWeek || "?" }}</b> for this week.
Your claims counter resets at
<b class="whitespace-nowrap">{{
readableDate(endorserLimits?.nextWeekBeginDateTime)
readableDate(endorserLimits?.nextWeekBeginDateTime ?? "")
}}</b>
</p>
<p class="mt-3 text-sm">
@ -454,7 +454,7 @@
<i>(You cannot register anyone on your first day.)</i>
Your registration counter resets at
<b class="whitespace-nowrap">
{{ readableDate(endorserLimits?.nextMonthBeginDateTime) }}
{{ readableDate(endorserLimits?.nextMonthBeginDateTime ?? "") }}
</b>
</p>
<p class="mt-3 text-sm">
@ -463,7 +463,7 @@
<b>{{ imageLimits?.maxImagesPerWeek || "?" }}</b> for this week. Your
image counter resets at
<b class="whitespace-nowrap">{{
readableDate(imageLimits?.nextWeekBeginDateTime)
readableDate(imageLimits?.nextWeekBeginDateTime ?? "")
}}</b>
</p>
</div>
@ -976,11 +976,10 @@ import Dexie from "dexie";
import "dexie-export-import";
// @ts-expect-error - they aren't exporting it but it's there
import { ImportProgress } from "dexie-export-import";
import { LeafletMouseEvent } from "leaflet";
import * as R from "ramda";
import { IIdentifier } from "@veramo/core";
import { ref } from "vue";
import { Component, Vue } from "vue-facing-decorator";
import { Component, Vue, Hook } from "vue-facing-decorator";
import { RouteLocationNormalizedLoaded, Router } from "vue-router";
import { useClipboard } from "@vueuse/core";
import { LMap, LMarker, LTileLayer } from "@vue-leaflet/vue-leaflet";
@ -1016,15 +1015,14 @@ import {
import * as databaseUtil from "../db/databaseUtil";
import {
clearPasskeyToken,
EndorserRateLimits,
ErrorResponse,
errorStringForLog,
fetchEndorserRateLimits,
fetchImageRateLimits,
getHeaders,
ImageRateLimits,
tokenExpiryTimeDescription,
} from "../libs/endorserServer";
import { EndorserRateLimits, ImageRateLimits } from "../interfaces/limits";
import { ErrorResponse } from "../interfaces/common";
import {
DAILY_CHECK_TITLE,
DIRECT_PUSH_TITLE,
@ -1040,7 +1038,6 @@ const inputImportFileNameRef = ref<Blob>();
components: {
EntityIcon,
ImageMethodDialog,
LeafletMouseEvent,
LMap,
LMarker,
LTileLayer,
@ -1117,8 +1114,11 @@ export default class AccountViewView extends Vue {
*
* @throws Will display specific messages to the user based on different errors.
*/
@Hook("mounted")
async mounted() {
try {
// eslint-disable-next-line no-console
console.log("[AccountViewView] -- mounted", process.env.VITE_PLATFORM);
// Initialize component state with values from the database or defaults
await this.initializeState();
await this.processIdentity();
@ -1145,7 +1145,12 @@ export default class AccountViewView extends Vue {
throw Error("Unable to load profile.");
}
} catch (error) {
if (error.status === 404) {
if (
error &&
typeof error === "object" &&
"status" in error &&
error.status === 404
) {
// this is ok: the profile is not yet created
} else {
databaseUtil.logConsoleAndDb(
@ -1232,7 +1237,9 @@ export default class AccountViewView extends Vue {
* Initializes component state with values from the database or defaults.
*/
async initializeState() {
console.log("[AccountViewView] initializeState");
let settings = await databaseUtil.retrieveSettingsForActiveAccount();
console.log("[AccountViewView] initializeState", { settings });
if (USE_DEXIE_DB) {
await db.open();
settings = await retrieveSettingsForActiveAccount();
@ -1676,7 +1683,9 @@ export default class AccountViewView extends Vue {
}
async uploadImportFile(event: Event) {
inputImportFileNameRef.value = (event.target as EventTarget).files[0];
inputImportFileNameRef.value = (
event.target as HTMLInputElement
).files?.[0];
}
showContactImport() {
@ -1886,16 +1895,77 @@ export default class AccountViewView extends Vue {
}
async onClickSaveApiServer() {
console.log("[AccountViewView] -- Starting API server update", {
current: this.apiServer,
new: this.apiServerInput,
component: "AccountViewView",
});
logger.debug("[AccountViewView] Starting API server update", {
current: this.apiServer,
new: this.apiServerInput,
component: "AccountViewView",
});
try {
logger.debug("[AccountViewView] Calling updateDefaultSettings");
await databaseUtil.updateDefaultSettings({
apiServer: this.apiServerInput,
});
logger.debug("[AccountViewView] updateDefaultSettings completed");
if (USE_DEXIE_DB) {
logger.debug("[AccountViewView] Updating Dexie DB");
await db.open();
await db.settings.update(MASTER_SETTINGS_KEY, {
apiServer: this.apiServerInput,
});
logger.debug("[AccountViewView] Dexie DB update completed");
}
this.apiServer = this.apiServerInput;
logger.debug("[AccountViewView] Local state updated", {
apiServer: this.apiServer,
});
// Verify the update
const settings = await databaseUtil.retrieveSettingsForActiveAccount();
logger.debug("[AccountViewView] Settings verification", {
retrieved: settings.apiServer,
expected: this.apiServerInput,
match: settings.apiServer === this.apiServerInput,
});
// Show success notification
this.$notify(
{
group: "alert",
type: "success",
title: "API Server Updated",
text: "API server settings saved successfully.",
},
3000,
);
} catch (error) {
logger.error("[AccountViewView] API server update failed", {
error,
current: this.apiServer,
attempted: this.apiServerInput,
});
console.log("[AccountViewView] API server update failed", {
error,
current: this.apiServer,
attempted: this.apiServerInput,
});
this.$notify(
{
group: "alert",
type: "danger",
title: "Error Saving API Server",
text: "Failed to save API server settings. Please try again.",
},
5000,
);
}
}
async onClickSavePartnerServer() {
@ -2072,6 +2142,7 @@ export default class AccountViewView extends Vue {
const headers = await getHeaders(this.activeDid);
const payload: UserProfile = {
description: this.userProfileDesc,
issuerDid: this.activeDid,
};
if (this.userProfileLatitude && this.userProfileLongitude) {
payload.locLat = this.userProfileLatitude;
@ -2113,11 +2184,14 @@ export default class AccountViewView extends Vue {
if (USE_DEXIE_DB) {
logConsoleAndDb("Error saving profile: " + errorStringForLog(error));
}
const errorMessage: string =
let errorMessage = "There was an error saving your profile.";
if (error instanceof AxiosError) {
errorMessage =
error.response?.data?.error?.message ||
error.response?.data?.error ||
error.message ||
"There was an error saving your profile.";
errorMessage;
}
this.$notify(
{
group: "alert",
@ -2208,11 +2282,14 @@ export default class AccountViewView extends Vue {
if (USE_DEXIE_DB) {
logConsoleAndDb("Error deleting profile: " + errorStringForLog(error));
}
const errorMessage: string =
let errorMessage = "There was an error deleting your profile.";
if (error instanceof AxiosError) {
errorMessage =
error.response?.data?.error?.message ||
error.response?.data?.error ||
error.message ||
"There was an error deleting your profile.";
errorMessage;
}
this.$notify(
{
group: "alert",

8
src/views/ClaimView.vue

@ -548,11 +548,7 @@ import { db } from "../db/index";
import { logConsoleAndDb } from "../db/databaseUtil";
import { Contact } from "../db/tables/contacts";
import * as serverUtil from "../libs/endorserServer";
import {
GenericCredWrapper,
OfferVerifiableCredential,
ProviderInfo,
} from "../interfaces";
import { GenericCredWrapper, OfferClaim, ProviderInfo } from "../interfaces";
import * as libsUtil from "../libs/util";
import { logger } from "../utils/logger";
import { PlatformServiceFactory } from "@/services/PlatformServiceFactory";
@ -978,7 +974,7 @@ export default class ClaimView extends Vue {
openFulfillGiftDialog() {
const giver: libsUtil.GiverReceiverInputInfo = {
did: libsUtil.offerGiverDid(
this.veriClaim as GenericCredWrapper<OfferVerifiableCredential>,
this.veriClaim as GenericCredWrapper<OfferClaim>,
),
};
(this.$refs.customGiveDialog as GiftedDialog).open(

4
src/views/ContactAmountsView.vue

@ -124,7 +124,7 @@ import * as databaseUtil from "../db/databaseUtil";
import {
AgreeVerifiableCredential,
GiveSummaryRecord,
GiveVerifiableCredential,
GiveActionClaim,
} from "../interfaces";
import {
createEndorserJwtVcFromClaim,
@ -276,7 +276,7 @@ export default class ContactAmountssView extends Vue {
// Make claim
// I use clone here because otherwise it gets a Proxy object.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const origClaim: GiveVerifiableCredential = R.clone(record.fullClaim);
const origClaim: GiveActionClaim = R.clone(record.fullClaim);
if (record.fullClaim["@context"] == SCHEMA_ORG_CONTEXT) {
delete origClaim["@context"];
}

20
src/views/ContactQRScanShowView.vue

@ -526,10 +526,6 @@ export default class ContactQRScanShow extends Vue {
const contact = {
did: contactInfo.did,
name: contactInfo.name || "",
email: contactInfo.email || "",
phone: contactInfo.phone || "",
company: contactInfo.company || "",
title: contactInfo.title || "",
notes: contactInfo.notes || "",
};
@ -846,11 +842,9 @@ export default class ContactQRScanShow extends Vue {
text: "Do you want to register them?",
onCancel: async (stopAsking?: boolean) => {
if (stopAsking) {
const platformService = PlatformServiceFactory.getInstance();
await platformService.dbExec(
"UPDATE settings SET hideRegisterPromptOnNewContact = ? WHERE key = ?",
[stopAsking, MASTER_SETTINGS_KEY],
);
await databaseUtil.updateAccountSettings(this.activeDid, {
hideRegisterPromptOnNewContact: stopAsking,
});
if (USE_DEXIE_DB) {
await db.settings.update(MASTER_SETTINGS_KEY, {
hideRegisterPromptOnNewContact: stopAsking,
@ -861,11 +855,9 @@ export default class ContactQRScanShow extends Vue {
},
onNo: async (stopAsking?: boolean) => {
if (stopAsking) {
const platformService = PlatformServiceFactory.getInstance();
await platformService.dbExec(
"UPDATE settings SET hideRegisterPromptOnNewContact = ? WHERE key = ?",
[stopAsking, MASTER_SETTINGS_KEY],
);
await databaseUtil.updateAccountSettings(this.activeDid, {
hideRegisterPromptOnNewContact: stopAsking,
});
if (USE_DEXIE_DB) {
await db.settings.update(MASTER_SETTINGS_KEY, {
hideRegisterPromptOnNewContact: stopAsking,

8
src/views/DIDView.vue

@ -240,8 +240,8 @@ import * as databaseUtil from "../db/databaseUtil";
import {
GenericCredWrapper,
GenericVerifiableCredential,
GiveVerifiableCredential,
OfferVerifiableCredential,
GiveActionClaim,
OfferClaim,
} from "../interfaces";
import {
capitalizeAndInsertSpacesBeforeCaps,
@ -657,7 +657,7 @@ export default class DIDView extends Vue {
*/
public claimAmount(claim: GenericVerifiableCredential) {
if (claim.claimType === "GiveAction") {
const giveClaim = claim.claim as GiveVerifiableCredential;
const giveClaim = claim.claim as GiveActionClaim;
if (giveClaim.object?.unitCode && giveClaim.object?.amountOfThisGood) {
return displayAmount(
giveClaim.object.unitCode,
@ -667,7 +667,7 @@ export default class DIDView extends Vue {
return "";
}
} else if (claim.claimType === "Offer") {
const offerClaim = claim.claim as OfferVerifiableCredential;
const offerClaim = claim.claim as OfferClaim;
if (
offerClaim.includesObject?.unitCode &&
offerClaim.includesObject?.amountOfThisGood

8
src/views/GiftedDetailsView.vue

@ -268,7 +268,7 @@ import {
} from "../constants/app";
import { db, retrieveSettingsForActiveAccount } from "../db/index";
import * as databaseUtil from "../db/databaseUtil";
import { GenericCredWrapper, GiveVerifiableCredential } from "../interfaces";
import { GenericCredWrapper, GiveActionClaim } from "../interfaces";
import {
createAndSubmitGive,
didInfo,
@ -311,7 +311,7 @@ export default class GiftedDetails extends Vue {
imageUrl = "";
message = "";
offerId = "";
prevCredToEdit?: GenericCredWrapper<GiveVerifiableCredential>;
prevCredToEdit?: GenericCredWrapper<GiveActionClaim>;
providerProjectId = "";
providerProjectName = "a project";
providedByProject = false; // basically static, based on input; if we allow changing then let's fix things (see below)
@ -328,7 +328,7 @@ export default class GiftedDetails extends Vue {
this.prevCredToEdit = (this.$route.query["prevCredToEdit"] as string)
? (JSON.parse(
this.$route.query["prevCredToEdit"] as string,
) as GenericCredWrapper<GiveVerifiableCredential>)
) as GenericCredWrapper<GiveActionClaim>)
: undefined;
} catch (error) {
this.$notify(
@ -883,7 +883,7 @@ export default class GiftedDetails extends Vue {
? this.fulfillsProjectId
: undefined;
const giveClaim = hydrateGive(
this.prevCredToEdit?.claim as GiveVerifiableCredential,
this.prevCredToEdit?.claim as GiveActionClaim,
giverDid,
recipientDid,
this.description,

11
src/views/HomeView.vue

@ -350,7 +350,7 @@ import {
import { GiveSummaryRecord } from "../interfaces";
import * as serverUtil from "../libs/endorserServer";
import { logger } from "../utils/logger";
import { GiveRecordWithContactInfo } from "../types";
import { GiveRecordWithContactInfo } from "../interfaces/give";
import { PlatformServiceFactory } from "@/services/PlatformServiceFactory";
interface Claim {
@ -610,7 +610,10 @@ export default class HomeView extends Vue {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (err: any) {
logConsoleAndDb("Error retrieving settings or feed: " + err, true);
logConsoleAndDb(
"[initializeIdentity] Error retrieving settings or feed: " + err,
true,
);
this.$notify(
{
group: "alert",
@ -1687,7 +1690,7 @@ export default class HomeView extends Vue {
* @param event Event object
* @param imageUrl URL of image to cache
*/
async cacheImageData(event: Event, imageUrl: string) {
async cacheImageData(_event: Event, imageUrl: string) {
try {
// For images that might fail CORS, just store the URL
// The Web Share API will handle sharing the URL appropriately
@ -1748,7 +1751,7 @@ export default class HomeView extends Vue {
this.axios,
);
if (result.type === "success") {
if (result.success) {
this.$notify(
{
group: "alert",

10
src/views/IdentitySwitcherView.vue

@ -115,6 +115,7 @@ import { MASTER_SETTINGS_KEY } from "../db/tables/settings";
import * as databaseUtil from "../db/databaseUtil";
import { retrieveAllAccountsMetadata } from "../libs/util";
import { logger } from "../utils/logger";
import { PlatformServiceFactory } from "@/services/PlatformServiceFactory";
@Component({ components: { QuickNav } })
export default class IdentitySwitcherView extends Vue {
@ -167,10 +168,13 @@ export default class IdentitySwitcherView extends Vue {
if (did === "0") {
did = undefined;
}
await databaseUtil.updateDefaultSettings({ activeDid: did });
if (USE_DEXIE_DB) {
await db.open();
await db.settings.update(MASTER_SETTINGS_KEY, {
activeDid: did,
});
}
this.$router.push({ name: "account" });
}
@ -182,9 +186,15 @@ export default class IdentitySwitcherView extends Vue {
title: "Delete Identity?",
text: "Are you sure you want to erase this identity? (There is no undo. You may want to select it and back it up just in case.)",
onYes: async () => {
const platformService = PlatformServiceFactory.getInstance();
await platformService.dbExec(`DELETE FROM accounts WHERE id = ?`, [
id,
]);
if (USE_DEXIE_DB) {
// one of the few times we use accountsDBPromise directly; try to avoid more usage
const accountsDB = await accountsDBPromise;
await accountsDB.accounts.delete(id);
}
this.otherIdentities = this.otherIdentities.filter(
(ident) => ident.id !== id,
);

2
src/views/InviteOneView.vue

@ -324,7 +324,7 @@ export default class InviteOneView extends Vue {
);
await axios.post(
this.apiServer + "/api/userUtil/invite",
{ inviteJwt: inviteJwt, notes: notes },
{ inviteIdentifier, inviteJwt, notes, expiresAt },
{ headers },
);
const newInvite = {

10
src/views/OfferDetailsView.vue

@ -182,7 +182,7 @@ import QuickNav from "../components/QuickNav.vue";
import TopMessage from "../components/TopMessage.vue";
import { NotificationIface, USE_DEXIE_DB } from "../constants/app";
import { db, retrieveSettingsForActiveAccount } from "../db/index";
import { GenericCredWrapper, OfferVerifiableCredential } from "../interfaces";
import { GenericCredWrapper, OfferClaim } from "../interfaces";
import {
createAndSubmitOffer,
didInfo,
@ -268,7 +268,7 @@ export default class OfferDetailsView extends Vue {
/** Offer ID for editing */
offerId = "";
/** Previous offer data for editing */
prevCredToEdit?: GenericCredWrapper<OfferVerifiableCredential>;
prevCredToEdit?: GenericCredWrapper<OfferClaim>;
/** Project ID if offer is for project */
projectId = "";
/** Project name display */
@ -330,7 +330,7 @@ export default class OfferDetailsView extends Vue {
this.prevCredToEdit = (this.$route.query["prevCredToEdit"] as string)
? (JSON.parse(
this.$route.query["prevCredToEdit"] as string,
) as GenericCredWrapper<OfferVerifiableCredential>)
) as GenericCredWrapper<OfferClaim>)
: undefined;
} catch (error: unknown) {
this.$notify(
@ -703,7 +703,7 @@ export default class OfferDetailsView extends Vue {
);
}
if (result.type === "error" || this.isCreationError(result.response)) {
if (!result.success) {
const errorMessage = this.getCreationErrorMessage(result);
logger.error("Error with offer creation result:", result);
this.$notify(
@ -768,7 +768,7 @@ export default class OfferDetailsView extends Vue {
: undefined;
const projectId = this.offeredToProject ? this.projectId : undefined;
const offerClaim = hydrateOffer(
this.prevCredToEdit?.claim as OfferVerifiableCredential,
this.prevCredToEdit?.claim as OfferClaim,
this.activeDid,
recipientDid,
this.descriptionOfItem,

12
src/views/ProjectViewView.vue

@ -613,9 +613,9 @@ import {
GenericVerifiableCredential,
GenericCredWrapper,
GiveSummaryRecord,
GiveVerifiableCredential,
GiveActionClaim,
OfferSummaryRecord,
OfferVerifiableCredential,
OfferClaim,
PlanSummaryRecord,
} from "../interfaces";
import GiftedDialog from "../components/GiftedDialog.vue";
@ -1269,7 +1269,7 @@ export default class ProjectViewView extends Vue {
}
checkIsFulfillable(offer: OfferSummaryRecord) {
const offerRecord: GenericCredWrapper<OfferVerifiableCredential> = {
const offerRecord: GenericCredWrapper<OfferClaim> = {
...serverUtil.BLANK_GENERIC_SERVER_RECORD,
claim: offer.fullClaim,
claimType: "Offer",
@ -1279,13 +1279,13 @@ export default class ProjectViewView extends Vue {
}
onClickFulfillGiveToOffer(offer: OfferSummaryRecord) {
const offerRecord: GenericCredWrapper<OfferVerifiableCredential> = {
const offerClaimCred: GenericCredWrapper<OfferClaim> = {
...serverUtil.BLANK_GENERIC_SERVER_RECORD,
claim: offer.fullClaim,
issuer: offer.offeredByDid,
};
const giver: libsUtil.GiverReceiverInputInfo = {
did: libsUtil.offerGiverDid(offerRecord),
did: libsUtil.offerGiverDid(offerClaimCred),
};
(this.$refs.giveDialogToThis as GiftedDialog).open(
giver,
@ -1327,7 +1327,7 @@ export default class ProjectViewView extends Vue {
* @param confirmerIdList optional list of DIDs who confirmed; if missing, doesn't do a full server check
*/
checkIsConfirmable(give: GiveSummaryRecord, confirmerIdList?: string[]) {
const giveDetails: GenericCredWrapper<GiveVerifiableCredential> = {
const giveDetails: GenericCredWrapper<GiveActionClaim> = {
...serverUtil.BLANK_GENERIC_SERVER_RECORD,
claim: give.fullClaim,
claimType: "GiveAction",

4
src/views/ShareMyContactInfoView.vue

@ -49,7 +49,7 @@ import TopMessage from "../components/TopMessage.vue";
import { NotificationIface, APP_SERVER, USE_DEXIE_DB } from "../constants/app";
import * as databaseUtil from "../db/databaseUtil";
import { db, retrieveSettingsForActiveAccount } from "../db/index";
import { retrieveAccountMetadata } from "../libs/util";
import { retrieveFullyDecryptedAccount } from "../libs/util";
import { generateEndorserJwtUrlForAccount } from "../libs/endorserServer";
import { PlatformServiceFactory } from "@/services/PlatformServiceFactory";
import { logger } from "../utils/logger";
@ -75,7 +75,7 @@ export default class ShareMyContactInfoView extends Vue {
const isRegistered = !!settings.isRegistered;
const profileImageUrl = settings.profileImageUrl || "";
const account = await retrieveAccountMetadata(activeDid);
const account = await retrieveFullyDecryptedAccount(activeDid);
const platformService = PlatformServiceFactory.getInstance();
const contactQueryResult = await platformService.dbQuery(

0
sw_combine.js → sw_combine.cjs

1
test-playwright/60-new-activity.spec.ts

@ -44,7 +44,6 @@ test('New offers for another user', async ({ page }) => {
// as user 1, go to the home page and check that two offers are shown as new
await switchToUser(page, user01Did);
await page.goto('./');
// await page.getByTestId('closeOnboardingAndFinish').click();
let offerNumElem = page.getByTestId('newDirectOffersActivityNumber');
await expect(offerNumElem).toHaveText('2');

7
tsconfig.electron.json

@ -5,7 +5,7 @@
"moduleResolution": "bundler",
"target": "ES2020",
"outDir": "dist-electron",
"rootDir": "src",
"rootDir": ".",
"sourceMap": true,
"esModuleInterop": true,
"allowJs": true,
@ -13,7 +13,7 @@
"isolatedModules": true,
"noEmit": true,
"allowImportingTsExtensions": true,
"types": ["vite/client"],
"types": ["vite/client", "electron"],
"paths": {
"@/*": ["./src/*"]
},
@ -23,6 +23,7 @@
"src/**/*.ts",
"src/**/*.d.ts",
"src/**/*.tsx",
"src/**/*.vue"
"src/**/*.vue",
"electron/src/**/*.ts"
]
}

42
vite.config.app.electron.mts

@ -0,0 +1,42 @@
/**
* vite.config.app.electron.mts
*
* Vite configuration for building the web application for Electron.
* This config outputs to 'dist/' (like the web build), sets VITE_PLATFORM to 'electron',
* and disables PWA plugins and web-only features. Use this when you want to package
* the web app for Electron but keep the output structure identical to the web build.
*
* Author: Matthew Raymer
*/
import { defineConfig, mergeConfig } from 'vite';
import { createBuildConfig } from './vite.config.common.mts';
import { loadAppConfig } from './vite.config.utils.mts';
import path from 'path';
export default defineConfig(async () => {
// Set mode to 'electron' for platform-specific config
const mode = 'electron';
const baseConfig = await createBuildConfig(mode);
const appConfig = await loadAppConfig();
// Override build output directory to 'dist/'
const buildConfig = {
outDir: path.resolve(__dirname, 'dist'),
emptyOutDir: true,
rollupOptions: {
input: path.resolve(__dirname, 'index.html'),
},
};
// No PWA plugins or web-only plugins for Electron
return mergeConfig(baseConfig, {
build: buildConfig,
plugins: [],
define: {
'process.env.VITE_PLATFORM': JSON.stringify('electron'),
'process.env.VITE_PWA_ENABLED': JSON.stringify(false),
'process.env.VITE_DISABLE_PWA': JSON.stringify(true),
},
});
});

114
vite.config.electron.mts

@ -1,22 +1,46 @@
import { defineConfig, mergeConfig } from "vite";
import { createBuildConfig } from "./vite.config.common.mts";
import { defineConfig } from "vite";
import path from 'path';
export default defineConfig(async () => {
const baseConfig = await createBuildConfig('electron');
return mergeConfig(baseConfig, {
export default defineConfig({
build: {
outDir: 'dist-electron',
rollupOptions: {
input: {
main: path.resolve(__dirname, 'src/electron/main.ts'),
preload: path.resolve(__dirname, 'src/electron/preload.js'),
preload: path.resolve(__dirname, 'electron/src/preload.ts')
},
external: ['electron'],
external: [
// Node.js built-ins
'stream',
'path',
'fs',
'crypto',
'buffer',
'util',
'events',
'url',
'assert',
'os',
'net',
'http',
'https',
'zlib',
'child_process',
// Electron and Capacitor
'electron',
'@capacitor/core',
'@capacitor-community/sqlite',
'@capacitor-community/sqlite/electron',
'@capacitor-community/sqlite/electron/dist/plugin',
'better-sqlite3-multiple-ciphers',
// HTTP clients
'axios',
'axios/dist/axios',
'axios/dist/node/axios.cjs'
],
output: {
format: 'cjs',
entryFileNames: '[name].js',
format: 'es',
entryFileNames: '[name].mjs',
assetFileNames: 'assets/[name].[ext]',
},
},
@ -27,17 +51,37 @@ export default defineConfig(async () => {
resolve: {
alias: {
'@': path.resolve(__dirname, 'src'),
// Use Node.js version of axios in electron
'axios': 'axios/dist/node/axios.cjs'
},
},
optimizeDeps: {
include: ['@/utils/logger']
exclude: [
'stream',
'path',
'fs',
'crypto',
'buffer',
'util',
'events',
'url',
'assert',
'os',
'net',
'http',
'https',
'zlib',
'child_process',
'axios',
'axios/dist/axios',
'axios/dist/node/axios.cjs'
]
},
plugins: [
{
name: 'typescript-transform',
transform(code: string, id: string) {
if (id.endsWith('main.ts')) {
// Replace the logger import with inline logger
return code.replace(
/import\s*{\s*logger\s*}\s*from\s*['"]@\/utils\/logger['"];?/,
`const logger = {
@ -51,54 +95,8 @@ export default defineConfig(async () => {
}
return code;
}
},
{
name: 'remove-sw-imports',
transform(code: string, id: string) {
// Remove service worker imports and registrations
if (id.includes('registerServiceWorker') ||
id.includes('register-service-worker') ||
id.includes('sw_scripts') ||
id.includes('PushNotificationPermission') ||
code.includes('navigator.serviceWorker')) {
return {
code: code
.replace(/import.*registerServiceWorker.*$/mg, '')
.replace(/import.*register-service-worker.*$/mg, '')
.replace(/navigator\.serviceWorker/g, 'undefined')
.replace(/if\s*\([^)]*serviceWorker[^)]*\)\s*{[^}]*}/g, '')
.replace(/import.*workbox.*$/mg, '')
.replace(/importScripts\([^)]*\)/g, '')
};
}
return code;
}
},
{
name: 'remove-sw-files',
enforce: 'pre',
resolveId(id: string) {
// Prevent service worker files from being included
if (id.includes('sw.js') ||
id.includes('workbox') ||
id.includes('registerSW.js') ||
id.includes('manifest.webmanifest')) {
return '\0empty';
}
return null;
},
load(id: string) {
if (id === '\0empty') {
return 'export default {}';
}
return null;
}
}
],
ssr: {
noExternal: ['@/utils/logger']
},
base: './',
publicDir: 'public',
});
});

21
vite.config.renderer.mts

@ -0,0 +1,21 @@
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
import path from 'path';
export default defineConfig({
root: path.resolve(__dirname, '.'),
base: './',
build: {
outDir: path.resolve(__dirname, 'dist-electron/www'),
emptyOutDir: false,
rollupOptions: {
input: path.resolve(__dirname, 'dist/www/index.html'),
},
},
plugins: [vue()],
resolve: {
alias: {
'@': path.resolve(__dirname, 'src'),
},
},
});
Loading…
Cancel
Save