Compare commits

...

32 Commits

Author SHA1 Message Date
Trent Larson 59d711bd90 make fixes to help my Mac build electron 6 days 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 6 days ago
Matthew Raymer 28c114a2c7 fix(sqlite): resolve migration issues and enhance documentation 6 days ago
Trent Larson dabfe33fbe add Python dependency for electron on Mac 6 days ago
Trent Larson d8f2587d1c fix some errors and correct recent type duplications & bloat 7 days ago
Matthew Raymer 3946a8a27a fix(database): improve SQLite connection handling and initialization 7 days ago
Trent Larson 4c40b80718 rename script files that would fail in the prebuild step 1 week ago
Trent Larson 74989c2b64 fix linting 1 week ago
Trent Larson 7e17b41444 rename a js config file to avoid an error running lint 1 week ago
Trent Larson 83acb028c7 fix more logic for tests 1 week ago
Matthew Raymer 786f07e067 feat(electron): Implement SQLite database initialization with proper logging 1 week ago
Matthew Raymer 710cc1683c fix(sqlite): Standardize connection options and improve error handling 1 week ago
Matthew Raymer ebef5d6c8d feat(sqlite): Initialize database with complete schema and PRAGMAs 1 week 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 week ago
Matthew Raymer 57191df416 feat(sqlite): Database file creation working, connection pending 1 week ago
Trent Larson 644593a5f4 fix linting 1 week ago
Matthew Raymer 900c2521c7 WIP: Improve SQLite initialization and error handling 1 week ago
Matthew Raymer 182cff2b16 fix(typescript): resolve linter violations and improve type safety 1 week ago
Matthew Raymer 3b4ef908f3 feat(electron): improve window and database initialization 1 week ago
Matthew Raymer a5a9e15ece WIP: Refactor Electron SQLite initialization and database path handling 1 week ago
Matthew Raymer a6d8f0eb8a fix(electron): assign sqlitePlugin globally and improve error logging 1 week ago
Matthew Raymer 3997a88b44 fix: rename postcss.config.js to .cjs for ES module compatibility 1 week ago
Trent Larson 5eeeae32c6 fix some incorrect logic & things AI hallucinated 1 week ago
Matthew Raymer d9895086e6 experiment(electron): different vite build script for web application 1 week ago
Matthew Raymer fb8d1cb8b2 fix(electron): add null check for devToolsWebContents to prevent TypeScript error 1 week ago
Matthew Raymer 70c0edbed0 fix: SQLite plugin initialization in Electron main process 1 week ago
Matthew Raymer 55cc08d675 chore: linting 1 week 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 week ago
Matthew Raymer 014341f320 fix(electron): simplify SQLite plugin initialization 1 week ago
Matthew Raymer 1d5e062c76 fix(electron): app loads 1 week ago
Matthew Raymer 2c5c15108a debug(electron): missing main.ts 1 week ago
Matthew Raymer 26df0fb671 debug(electron): app index loads but problem with preload script 1 week ago
  1. 101
      -1748433586226.log
  2. 0
      .eslintrc.cjs
  3. 11
      capacitor.config.json
  4. 55
      electron/.gitignore
  5. BIN
      electron/assets/appIcon.ico
  6. BIN
      electron/assets/appIcon.png
  7. BIN
      electron/assets/splash.gif
  8. BIN
      electron/assets/splash.png
  9. 62
      electron/capacitor.config.json
  10. 28
      electron/electron-builder.config.json
  11. 75
      electron/live-runner.js
  12. 5243
      electron/package-lock.json
  13. 51
      electron/package.json
  14. 10
      electron/resources/electron-publisher-custom.js
  15. 110
      electron/src/index.ts
  16. 97
      electron/src/preload.ts
  17. 6
      electron/src/rt/electron-plugins.js
  18. 88
      electron/src/rt/electron-rt.ts
  19. 77
      electron/src/rt/logger.ts
  20. 584
      electron/src/rt/sqlite-init.ts
  21. 950
      electron/src/rt/sqlite-migrations.ts
  22. 244
      electron/src/setup.ts
  23. 18
      electron/tsconfig.json
  24. 186
      experiment.sh
  25. 170
      package-lock.json
  26. 47
      package.json
  27. 0
      postcss.config.cjs
  28. 1
      requirements.txt
  29. 85
      scripts/build-electron.cjs
  30. 165
      scripts/build-electron.js
  31. 0
      scripts/copy-wasm.cjs
  32. 7
      src/App.vue
  33. 14
      src/components/GiftedDialog.vue
  34. 14
      src/components/OfferDialog.vue
  35. 9
      src/components/UserNameDialog.vue
  36. 749
      src/electron/main.ts
  37. 86
      src/electron/preload.js
  38. 108
      src/interfaces/claims.ts
  39. 100
      src/interfaces/common.ts
  40. 6
      src/interfaces/index.ts
  41. 8
      src/interfaces/records.ts
  42. 4
      src/libs/crypto/vc/index.ts
  43. 253
      src/libs/endorserServer.ts
  44. 107
      src/libs/util.ts
  45. 1
      src/main.common.ts
  46. 61
      src/main.electron.ts
  47. 2
      src/services/AbsurdSqlDatabaseService.ts
  48. 116
      src/services/database/ConnectionPool.ts
  49. 227
      src/services/platforms/ElectronPlatformService.ts
  50. 46
      src/types/capacitor-sqlite-electron.d.ts
  51. 32
      src/types/global.d.ts
  52. 1
      src/types/jeepq-sqlite.d.ts
  53. 8
      src/views/ClaimView.vue
  54. 4
      src/views/ContactAmountsView.vue
  55. 20
      src/views/ContactQRScanShowView.vue
  56. 8
      src/views/DIDView.vue
  57. 8
      src/views/GiftedDetailsView.vue
  58. 25
      src/views/IdentitySwitcherView.vue
  59. 2
      src/views/InviteOneView.vue
  60. 10
      src/views/OfferDetailsView.vue
  61. 12
      src/views/ProjectViewView.vue
  62. 4
      src/views/ShareMyContactInfoView.vue
  63. 0
      sw_combine.cjs
  64. 1
      test-playwright/60-new-activity.spec.ts
  65. 42
      vite.config.app.electron.mts
  66. 188
      vite.config.electron.mts
  67. 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", "appName": "TimeSafari",
"webDir": "dist", "webDir": "dist",
"bundledWebRuntime": false, "bundledWebRuntime": false,
"server": { "server": {
"cleartext": true "cleartext": true,
"androidScheme": "https"
}, },
"plugins": { "plugins": {
"App": { "App": {
@ -29,6 +30,12 @@
"biometricAuth": true, "biometricAuth": true,
"biometricTitle": "Biometric login for TimeSafari" "biometricTitle": "Biometric login for TimeSafari"
} }
},
"CapacitorSQLite": {
"electronIsEncryption": false,
"electronMacLocation": "~/Library/Application Support/TimeSafari",
"electronWindowsLocation": "C:\\ProgramData\\TimeSafari",
"electronLinuxLocation": "~/.local/share/TimeSafari"
} }
}, },
"ios": { "ios": {

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();
})();

5243
electron/package-lock.json

File diff suppressed because it is too large

51
electron/package.json

@ -0,0 +1,51 @@
{
"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"
},
"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;

110
electron/src/index.ts

@ -0,0 +1,110 @@
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.
await app.whenReady();
// Security - Set Content-Security-Policy based on whether or not we are in dev mode.
setupContentSecurityPolicy(myCapacitorApp.getCustomURLScheme());
// Initialize SQLite and register handlers BEFORE app initialization
console.log('[Main] Starting SQLite initialization...');
try {
// Register handlers first to prevent "no handler" errors
setupSQLiteHandlers();
console.log('[Main] SQLite handlers registered');
// Then initialize the plugin
await initializeSQLite();
console.log('[Main] SQLite plugin initialized successfully');
} catch (error) {
console.error('[Main] Failed to initialize SQLite:', error);
// Don't proceed with app initialization if SQLite fails
throw new Error(`SQLite initialization failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
// Initialize our app, build windows, and load content.
console.log('[Main] Starting app initialization...');
await myCapacitorApp.init();
console.log('[Main] App initialization complete');
// Check for updates if we are in a packaged app.
if (!electronIsDev) {
console.log('[Main] Checking for updates...');
autoUpdater.checkForUpdatesAndNotify();
}
} catch (error) {
console.error('[Main] Fatal error during app initialization:', error);
// Ensure we notify the user before quitting
const mainWindow = myCapacitorApp.getMainWindow();
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('app-error', {
type: 'initialization',
error: error instanceof Error ? error.message : 'Unknown error'
});
// Give the window time to show the error
setTimeout(() => app.quit(), 5000);
} else {
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

97
electron/src/preload.ts

@ -0,0 +1,97 @@
/**
* Preload script for Electron
* Sets up secure IPC communication between renderer and main process
*
* @author Matthew Raymer
*/
import { contextBridge, ipcRenderer } from 'electron';
// Simple logger for preload script
const logger = {
log: (...args: unknown[]) => console.log('[Preload]', ...args),
error: (...args: unknown[]) => console.error('[Preload]', ...args),
info: (...args: unknown[]) => console.info('[Preload]', ...args),
warn: (...args: unknown[]) => console.warn('[Preload]', ...args),
debug: (...args: unknown[]) => console.debug('[Preload]', ...args),
};
// 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
}
// Create a proxy for the CapacitorSQLite plugin
const createSQLiteProxy = () => {
const MAX_RETRIES = 3;
const RETRY_DELAY = 1000; // 1 second
const withRetry = async <T>(operation: (...args: unknown[]) => Promise<T>, ...args: unknown[]): Promise<T> => {
let lastError: Error | null = null;
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
try {
return await operation(...args);
} catch (error) {
lastError = error instanceof Error ? error : new Error(String(error));
if (attempt < MAX_RETRIES) {
logger.warn(`SQLite operation failed (attempt ${attempt}/${MAX_RETRIES}), retrying...`, error);
await new Promise(resolve => setTimeout(resolve, RETRY_DELAY));
}
}
}
throw new Error(`SQLite operation failed after ${MAX_RETRIES} attempts: ${lastError?.message || 'Unknown error'}`);
};
const wrapOperation = (method: string) => {
return async (...args: unknown[]): Promise<unknown> => {
try {
// For createConnection, ensure readOnly is false
if (method === 'create-connection') {
const options = args[0] as SQLiteConnectionOptions;
if (options && typeof options === 'object') {
// Set readOnly to false and ensure mode is rwc
options.readOnly = false;
options.mode = 'rwc';
// Remove any lowercase readonly property if it exists
delete options.readonly;
}
}
return await withRetry(ipcRenderer.invoke, 'sqlite-' + method, ...args);
} catch (error) {
logger.error(`SQLite ${method} failed:`, error);
throw new Error(`Database operation failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
};
};
// Create a proxy that matches the CapacitorSQLite interface
return {
echo: wrapOperation('echo'),
createConnection: wrapOperation('create-connection'),
closeConnection: wrapOperation('close-connection'),
execute: wrapOperation('execute'),
query: wrapOperation('query'),
run: wrapOperation('run'),
isAvailable: wrapOperation('is-available'),
getPlatform: () => Promise.resolve('electron'),
// Add other methods as needed
};
};
// Expose only the CapacitorSQLite proxy
contextBridge.exposeInMainWorld('CapacitorSQLite', createSQLiteProxy());
// Log startup
logger.log('Script starting...');
// Handle window load
window.addEventListener('load', () => {
logger.log('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,
});
////////////////////////////////////////////////////////

77
electron/src/rt/logger.ts

@ -0,0 +1,77 @@
/**
* Structured logging system for TimeSafari
*
* Provides consistent logging across the application with:
* - Timestamp tracking
* - Log levels (debug, info, warn, error)
* - Structured data support
* - Component tagging
*
* @author Matthew Raymer <matthew.raymer@anomalistdesign.com>
* @version 1.0.0
* @since 2025-06-01
*/
// Log levels
export enum LogLevel {
DEBUG = 'DEBUG',
INFO = 'INFO',
WARN = 'WARN',
ERROR = 'ERROR'
}
// Log entry interface
interface LogEntry {
timestamp: string;
level: LogLevel;
component: string;
message: string;
data?: unknown;
}
// Format log entry
const formatLogEntry = (entry: LogEntry): string => {
const { timestamp, level, component, message, data } = entry;
const dataStr = data ? ` ${JSON.stringify(data, null, 2)}` : '';
return `[${timestamp}] [${level}] [${component}] ${message}${dataStr}`;
};
// Create logger for a specific component
export const createLogger = (component: string) => {
const log = (level: LogLevel, message: string, data?: unknown) => {
const entry: LogEntry = {
timestamp: new Date().toISOString(),
level,
component,
message,
data
};
const formatted = formatLogEntry(entry);
switch (level) {
case LogLevel.DEBUG:
console.debug(formatted);
break;
case LogLevel.INFO:
console.info(formatted);
break;
case LogLevel.WARN:
console.warn(formatted);
break;
case LogLevel.ERROR:
console.error(formatted);
break;
}
};
return {
debug: (message: string, data?: unknown) => log(LogLevel.DEBUG, message, data),
info: (message: string, data?: unknown) => log(LogLevel.INFO, message, data),
warn: (message: string, data?: unknown) => log(LogLevel.WARN, message, data),
error: (message: string, data?: unknown) => log(LogLevel.ERROR, message, data)
};
};
// Create default logger for SQLite operations
export const logger = createLogger('SQLite');

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

@ -0,0 +1,584 @@
/**
* SQLite Initialization and Management for TimeSafari Electron
*
* This module handles the complete lifecycle of SQLite database initialization,
* connection management, and IPC communication in the TimeSafari Electron app.
*
* Key Features:
* - Database path management with proper permissions
* - Plugin initialization and state verification
* - Connection lifecycle management
* - PRAGMA configuration for optimal performance
* - Migration system integration
* - Error handling and recovery
* - IPC communication layer
*
* Database Configuration:
* - Uses WAL journal mode for better concurrency
* - Configures optimal PRAGMA settings
* - Implements connection pooling
* - Handles encryption (when enabled)
*
* State Management:
* - Tracks plugin initialization state
* - Monitors connection health
* - Manages transaction state
* - Implements recovery mechanisms
*
* Error Handling:
* - Custom SQLiteError class for detailed error tracking
* - Comprehensive error logging
* - Automatic recovery attempts
* - State verification before operations
*
* Security:
* - Proper file permissions (0o755)
* - Write access verification
* - Connection state validation
* - Transaction safety
*
* Performance:
* - WAL mode for better concurrency
* - Optimized PRAGMA settings
* - Connection pooling
* - Efficient state management
*
* @author Matthew Raymer <matthew.raymer@anomalistdesign.com>
* @version 1.0.0
* @since 2025-06-01
*/
import { app, ipcMain } from 'electron';
import { CapacitorSQLite } from '@capacitor-community/sqlite/electron/dist/plugin.js';
import * as SQLiteModule from '@capacitor-community/sqlite/electron/dist/plugin.js';
import fs from 'fs';
import path from 'path';
import os from 'os';
import { runMigrations } from './sqlite-migrations';
import { logger } from './logger';
// Types for state management
interface PluginState {
isInitialized: boolean;
isAvailable: boolean;
lastVerified: Date | null;
lastError: Error | null;
instance: any | null;
}
interface TransactionState {
isActive: boolean;
lastVerified: Date | null;
database: string | null;
}
// State tracking
let pluginState: PluginState = {
isInitialized: false,
isAvailable: false,
lastVerified: null,
lastError: null,
instance: null
};
let transactionState: TransactionState = {
isActive: false,
lastVerified: null,
database: null
};
// Constants
const MAX_RECOVERY_ATTEMPTS = 3;
const RECOVERY_DELAY_MS = 1000;
const VERIFICATION_TIMEOUT_MS = 5000;
// Error handling
class SQLiteError extends Error {
constructor(
message: string,
public context: string,
public originalError?: unknown
) {
super(message);
this.name = 'SQLiteError';
}
}
const handleError = (error: unknown, context: string): SQLiteError => {
const errorMessage = error instanceof Error
? error.message
: 'Unknown error occurred';
const errorStack = error instanceof Error
? error.stack
: undefined;
logger.error(`Error in ${context}:`, {
message: errorMessage,
stack: errorStack,
context,
timestamp: new Date().toISOString()
});
return new SQLiteError(`${context} failed: ${errorMessage}`, context, error);
};
// Add delay utility with timeout
const delay = (ms: number, timeoutMs: number = VERIFICATION_TIMEOUT_MS): Promise<void> => {
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
reject(new SQLiteError('Operation timed out', 'delay'));
}, timeoutMs);
setTimeout(() => {
clearTimeout(timeout);
resolve();
}, ms);
});
};
// Plugin state verification
const verifyPluginState = async (): Promise<boolean> => {
if (!pluginState.instance || !pluginState.isInitialized) {
return false;
}
try {
// Test plugin responsiveness
const echoResult = await pluginState.instance.echo({ value: 'test' });
if (!echoResult || echoResult.value !== 'test') {
throw new SQLiteError('Plugin echo test failed', 'verifyPluginState');
}
pluginState.isAvailable = true;
pluginState.lastVerified = new Date();
pluginState.lastError = null;
return true;
} catch (error) {
pluginState.isAvailable = false;
pluginState.lastError = handleError(error, 'verifyPluginState');
return false;
}
};
// Transaction state verification
const verifyTransactionState = async (database: string): Promise<boolean> => {
if (!pluginState.instance || !pluginState.isAvailable) {
return false;
}
try {
// Check if we're in a transaction
const isActive = await pluginState.instance.isTransactionActive({ database });
transactionState.isActive = isActive;
transactionState.lastVerified = new Date();
transactionState.database = database;
return true;
} catch (error) {
transactionState.isActive = false;
transactionState.lastVerified = new Date();
transactionState.database = null;
logger.error('Transaction state verification failed:', error);
return false;
}
};
// Plugin initialization
const initializePlugin = async (): Promise<boolean> => {
logger.info('Starting plugin initialization');
try {
// Create plugin instance
let rawPlugin;
if (SQLiteModule.default?.CapacitorSQLite) {
logger.debug('Using default export CapacitorSQLite');
rawPlugin = new SQLiteModule.default.CapacitorSQLite();
} else {
logger.debug('Using direct CapacitorSQLite class');
rawPlugin = new CapacitorSQLite();
}
// Verify instance
if (!rawPlugin || typeof rawPlugin !== 'object') {
throw new SQLiteError('Invalid plugin instance created', 'initializePlugin');
}
// Test plugin functionality
const echoResult = await rawPlugin.echo({ value: 'test' });
if (!echoResult || echoResult.value !== 'test') {
throw new SQLiteError('Plugin echo test failed', 'initializePlugin');
}
// Update state only after successful verification
pluginState = {
isInitialized: true,
isAvailable: true,
lastVerified: new Date(),
lastError: null,
instance: rawPlugin
};
logger.info('Plugin initialized successfully');
return true;
} catch (error) {
pluginState = {
isInitialized: false,
isAvailable: false,
lastVerified: new Date(),
lastError: handleError(error, 'initializePlugin'),
instance: null
};
logger.error('Plugin initialization failed:', {
error: pluginState.lastError,
timestamp: new Date().toISOString()
});
return false;
}
};
// Recovery mechanism
const recoverPluginState = async (attempt: number = 1): Promise<boolean> => {
logger.info(`Attempting plugin state recovery (attempt ${attempt}/${MAX_RECOVERY_ATTEMPTS})`);
if (attempt > MAX_RECOVERY_ATTEMPTS) {
logger.error('Max recovery attempts reached');
return false;
}
try {
// Cleanup existing connection if any
if (pluginState.instance) {
try {
await pluginState.instance.closeConnection({ database: 'timesafari' });
logger.debug('Closed existing database connection during recovery');
} catch (error) {
logger.warn('Error closing connection during recovery:', error);
}
}
// Reset state
pluginState = {
isInitialized: false,
isAvailable: false,
lastVerified: new Date(),
lastError: null,
instance: null
};
// Wait before retry with exponential backoff
const backoffDelay = RECOVERY_DELAY_MS * Math.pow(2, attempt - 1);
await delay(backoffDelay);
// Reinitialize
const success = await initializePlugin();
if (!success && attempt < MAX_RECOVERY_ATTEMPTS) {
return recoverPluginState(attempt + 1);
}
return success;
} catch (error) {
logger.error('Plugin recovery failed:', error);
if (attempt < MAX_RECOVERY_ATTEMPTS) {
return recoverPluginState(attempt + 1);
}
return false;
}
};
/**
* Initializes database paths and ensures proper permissions
*
* This function:
* 1. Creates the database directory if it doesn't exist
* 2. Sets proper permissions (0o755)
* 3. Verifies write access
* 4. Returns the absolute path to the database directory
*
* @returns {Promise<string>} Absolute path to database directory
* @throws {SQLiteError} If directory creation or permission setting fails
*/
const initializeDatabasePaths = async (): Promise<string> => {
try {
// Get the absolute app data directory
const appDataDir = path.join(os.homedir(), 'Databases', 'TimeSafari');
logger.info('App data directory:', appDataDir);
// Ensure directory exists with proper permissions
if (!fs.existsSync(appDataDir)) {
await fs.promises.mkdir(appDataDir, {
recursive: true,
mode: 0o755
});
} else {
await fs.promises.chmod(appDataDir, 0o755);
}
// Verify directory permissions
const stats = await fs.promises.stat(appDataDir);
logger.info('Directory permissions:', {
mode: stats.mode.toString(8),
uid: stats.uid,
gid: stats.gid,
isDirectory: stats.isDirectory(),
isWritable: !!(stats.mode & 0o200)
});
// Test write access
const testFile = path.join(appDataDir, '.write-test');
await fs.promises.writeFile(testFile, 'test');
await fs.promises.unlink(testFile);
return appDataDir;
} catch (error) {
throw handleError(error, 'initializeDatabasePaths');
}
};
/**
* Main SQLite initialization function
*
* Orchestrates the complete database initialization process:
* 1. Sets up database paths
* 2. Initializes the SQLite plugin
* 3. Creates and verifies database connection
* 4. Configures database PRAGMAs
* 5. Runs database migrations
* 6. Handles errors and recovery
*
* Database Configuration:
* - Uses WAL journal mode
* - Enables foreign keys
* - Sets optimal page size and cache
* - Configures busy timeout
*
* Error Recovery:
* - Implements exponential backoff
* - Verifies plugin state
* - Attempts connection recovery
* - Maintains detailed error logs
*
* @throws {SQLiteError} If initialization fails and recovery is unsuccessful
*/
export async function initializeSQLite(): Promise<void> {
logger.info('Starting SQLite initialization');
try {
// Initialize database paths
const dbDir = await initializeDatabasePaths();
const dbPath = path.join(dbDir, 'timesafariSQLite.db');
// Initialize plugin
if (!await initializePlugin()) {
throw new SQLiteError('Plugin initialization failed', 'initializeSQLite');
}
// Verify plugin state
if (!await verifyPluginState()) {
throw new SQLiteError('Plugin state verification failed', 'initializeSQLite');
}
// Set up database connection
const connectionOptions = {
database: 'timesafari',
version: 1,
readOnly: false,
encryption: 'no-encryption',
useNative: true,
mode: 'rwc'
};
// Create and verify connection
logger.debug('Creating database connection:', connectionOptions);
await pluginState.instance.createConnection(connectionOptions);
await delay(500); // Wait for connection registration
const isRegistered = await pluginState.instance.isDatabase({
database: connectionOptions.database
});
if (!isRegistered) {
throw new SQLiteError('Database not registered', 'initializeSQLite');
}
// Open database
logger.debug('Opening database with options:', connectionOptions);
await pluginState.instance.open({
...connectionOptions,
mode: 'rwc'
});
// Set PRAGMAs with detailed logging
const pragmaStatements = [
'PRAGMA foreign_keys = ON;',
'PRAGMA journal_mode = WAL;', // Changed to WAL for better concurrency
'PRAGMA synchronous = NORMAL;',
'PRAGMA temp_store = MEMORY;',
'PRAGMA page_size = 4096;',
'PRAGMA cache_size = 2000;',
'PRAGMA busy_timeout = 15000;', // Increased to 15 seconds
'PRAGMA wal_autocheckpoint = 1000;' // Added WAL checkpoint setting
];
logger.debug('Setting database PRAGMAs');
for (const statement of pragmaStatements) {
try {
logger.debug('Executing PRAGMA:', statement);
const result = await pluginState.instance.execute({
database: connectionOptions.database,
statements: statement,
transaction: false
});
logger.debug('PRAGMA result:', { statement, result });
} catch (error) {
logger.error('PRAGMA execution failed:', {
statement,
error: error instanceof Error ? {
message: error.message,
stack: error.stack,
name: error.name
} : error
});
throw error;
}
}
// Run migrations with enhanced error logging
logger.info('Starting database migrations');
const migrationResults = await runMigrations(
pluginState.instance,
connectionOptions.database
);
// Check migration results with detailed logging
const failedMigrations = migrationResults.filter(r => !r.success);
if (failedMigrations.length > 0) {
logger.error('Migration failures:', {
totalMigrations: migrationResults.length,
failedCount: failedMigrations.length,
failures: failedMigrations.map(f => ({
version: f.version,
name: f.name,
error: f.error instanceof Error ? {
message: f.error.message,
stack: f.error.stack,
name: f.error.name
} : f.error,
state: f.state
}))
});
throw new SQLiteError(
'Database migrations failed',
'initializeSQLite',
failedMigrations
);
}
logger.info('SQLite initialization completed successfully');
} catch (error) {
const sqliteError = handleError(error, 'initializeSQLite');
logger.error('SQLite initialization failed:', {
error: sqliteError,
pluginState: {
isInitialized: pluginState.isInitialized,
isAvailable: pluginState.isAvailable,
lastVerified: pluginState.lastVerified,
lastError: pluginState.lastError
}
});
// Attempt recovery
if (await recoverPluginState()) {
logger.info('Recovery successful, retrying initialization');
return initializeSQLite();
}
throw sqliteError;
}
}
/**
* Sets up IPC handlers for SQLite operations
*
* Registers handlers for:
* - Plugin availability checks
* - Connection management
* - Query execution
* - Error retrieval
*
* Each handler includes:
* - State verification
* - Error handling
* - Detailed logging
* - Transaction safety
*
* Security:
* - Validates all incoming requests
* - Verifies plugin state
* - Maintains connection isolation
*
* @throws {Error} If handler registration fails
*/
export function setupSQLiteHandlers(): void {
// Remove existing handlers
const handlers = [
'sqlite-is-available',
'sqlite-echo',
'sqlite-create-connection',
'sqlite-execute',
'sqlite-query',
'sqlite-close-connection',
'sqlite-get-error'
];
handlers.forEach(handler => {
try {
ipcMain.removeHandler(handler);
} catch (error) {
logger.warn(`Error removing handler ${handler}:`, error);
}
});
// Register handlers
ipcMain.handle('sqlite-is-available', async () => {
try {
const isAvailable = await verifyPluginState();
logger.debug('Plugin availability check:', { isAvailable });
return isAvailable;
} catch (error) {
logger.error('Error checking plugin availability:', error);
return false;
}
});
ipcMain.handle('sqlite-get-error', async () => {
return pluginState.lastError ? {
message: pluginState.lastError.message,
stack: pluginState.lastError.stack,
name: pluginState.lastError.name,
context: (pluginState.lastError as SQLiteError).context
} : null;
});
// Add other handlers with proper state verification
ipcMain.handle('sqlite-create-connection', async (_event, options) => {
try {
if (!await verifyPluginState()) {
throw new SQLiteError('Plugin not available', 'sqlite-create-connection');
}
// ... rest of connection creation logic ...
} catch (error) {
throw handleError(error, 'sqlite-create-connection');
}
});
// ... other handlers ...
logger.info('SQLite IPC handlers registered successfully');
}

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

@ -0,0 +1,950 @@
/**
* SQLite Migration System for TimeSafari
*
* A robust migration system for managing database schema changes in the TimeSafari
* application. Provides versioned migrations with transaction safety, rollback
* support, and detailed logging.
*
* Core Features:
* - Versioned migrations with tracking
* - Atomic transactions per migration
* - Comprehensive error handling
* - SQL parsing and validation
* - State verification and recovery
* - Detailed logging and debugging
*
* Migration Process:
* 1. Version tracking via schema_version table
* 2. Transaction-based execution
* 3. Automatic rollback on failure
* 4. State verification before/after
* 5. Detailed error logging
*
* SQL Processing:
* - Handles single-line (--) and multi-line comments
* - Validates SQL statements
* - Proper statement separation
* - SQL injection prevention
* - Parameter binding safety
*
* Transaction Management:
* - Single transaction per migration
* - Automatic rollback on failure
* - State verification
* - Deadlock prevention
* - Connection isolation
*
* Error Handling:
* - Detailed error reporting
* - SQL validation
* - Transaction state tracking
* - Recovery mechanisms
* - Debug logging
*
* Security:
* - SQL injection prevention
* - Parameter validation
* - Transaction isolation
* - State verification
* - Error sanitization
*
* Performance:
* - Efficient SQL parsing
* - Optimized transactions
* - Minimal locking
* - Connection pooling
* - Statement reuse
*
* @author Matthew Raymer <matthew.raymer@anomalistdesign.com>
* @version 1.0.0
* @since 2025-06-01
*/
import { CapacitorSQLite } from '@capacitor-community/sqlite/electron/dist/plugin.js';
import { logger } from './logger';
// Types for migration system
interface Migration {
version: number;
name: string;
description: string;
sql: string;
rollback?: string;
}
interface MigrationResult {
success: boolean;
version: number;
name: string;
error?: Error;
state?: {
plugin: {
isAvailable: boolean;
lastChecked: Date;
};
transaction: {
isActive: boolean;
lastVerified: Date;
};
};
}
interface MigrationState {
currentVersion: number;
lastMigration: string;
lastApplied: Date;
isDirty: boolean;
}
// Constants
const MIGRATIONS_TABLE = `
CREATE TABLE IF NOT EXISTS schema_version (
version INTEGER NOT NULL,
name TEXT NOT NULL,
description TEXT,
applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
checksum TEXT,
is_dirty BOOLEAN DEFAULT FALSE,
error_message TEXT,
error_stack TEXT,
error_context TEXT,
PRIMARY KEY (version)
);`;
// Constants for retry logic
const MAX_RETRY_ATTEMPTS = 3;
const RETRY_DELAY_MS = 1000;
const LOCK_TIMEOUT_MS = 10000; // 10 seconds total timeout for locks
/**
* Utility function to delay execution
* @param ms Milliseconds to delay
* @returns Promise that resolves after the delay
*/
const delay = (ms: number): Promise<void> => {
return new Promise(resolve => setTimeout(resolve, ms));
};
// SQL Parsing Utilities
interface ParsedSQL {
statements: string[];
errors: string[];
warnings: string[];
}
/**
* Removes SQL comments from a string while preserving statement structure
* @param sql The SQL string to process
* @returns SQL with comments removed
*/
const removeSQLComments = (sql: string): string => {
let result = '';
let inSingleLineComment = false;
let inMultiLineComment = false;
let inString = false;
let stringChar = '';
let i = 0;
while (i < sql.length) {
const char = sql[i];
const nextChar = sql[i + 1] || '';
// Handle string literals
if ((char === "'" || char === '"') && !inSingleLineComment && !inMultiLineComment) {
if (!inString) {
inString = true;
stringChar = char;
} else if (char === stringChar) {
inString = false;
}
result += char;
i++;
continue;
}
// Handle single-line comments
if (char === '-' && nextChar === '-' && !inString && !inMultiLineComment) {
inSingleLineComment = true;
i += 2;
continue;
}
// Handle multi-line comments
if (char === '/' && nextChar === '*' && !inString && !inSingleLineComment) {
inMultiLineComment = true;
i += 2;
continue;
}
if (char === '*' && nextChar === '/' && inMultiLineComment) {
inMultiLineComment = false;
i += 2;
continue;
}
// Handle newlines in single-line comments
if (char === '\n' && inSingleLineComment) {
inSingleLineComment = false;
result += '\n';
i++;
continue;
}
// Add character if not in any comment
if (!inSingleLineComment && !inMultiLineComment) {
result += char;
}
i++;
}
return result;
};
/**
* Formats a SQL statement for consistent processing
* @param sql The SQL statement to format
* @returns Formatted SQL statement
*/
const formatSQLStatement = (sql: string): string => {
return sql
.trim()
.replace(/\s+/g, ' ') // Replace multiple spaces with single space
.replace(/\s*;\s*$/, ';') // Ensure semicolon at end
.replace(/^\s*;\s*/, ''); // Remove leading semicolon
};
/**
* Validates a SQL statement for common issues
* @param statement The SQL statement to validate
* @returns Array of validation errors, empty if valid
*/
const validateSQLStatement = (statement: string): string[] => {
const errors: string[] = [];
const trimmed = statement.trim().toLowerCase();
// Check for empty statements
if (!trimmed) {
errors.push('Empty SQL statement');
return errors;
}
// Check for valid statement types
const validStarts = [
'create', 'alter', 'drop', 'insert', 'update', 'delete',
'select', 'pragma', 'begin', 'commit', 'rollback'
];
const startsWithValid = validStarts.some(start => trimmed.startsWith(start));
if (!startsWithValid) {
errors.push(`Invalid SQL statement type: ${trimmed.split(' ')[0]}`);
}
// Check for balanced parentheses
let parenCount = 0;
let inString = false;
let stringChar = '';
for (let i = 0; i < statement.length; i++) {
const char = statement[i];
if ((char === "'" || char === '"') && !inString) {
inString = true;
stringChar = char;
} else if (char === stringChar && inString) {
inString = false;
}
if (!inString) {
if (char === '(') parenCount++;
if (char === ')') parenCount--;
}
}
if (parenCount !== 0) {
errors.push('Unbalanced parentheses in SQL statement');
}
return errors;
};
/**
* Parses SQL into individual statements with validation
* @param sql The SQL to parse
* @returns ParsedSQL object containing statements and any errors/warnings
*/
const parseSQL = (sql: string): ParsedSQL => {
const result: ParsedSQL = {
statements: [],
errors: [],
warnings: []
};
try {
// Remove comments first
const cleanSQL = removeSQLComments(sql);
// Split on semicolons and process each statement
const rawStatements = cleanSQL
.split(';')
.map(s => formatSQLStatement(s))
.filter(s => s.length > 0);
// Validate each statement
for (const statement of rawStatements) {
const errors = validateSQLStatement(statement);
if (errors.length > 0) {
result.errors.push(...errors.map(e => `${e} in statement: ${statement.substring(0, 50)}...`));
} else {
result.statements.push(statement);
}
}
// Add warnings for potential issues
if (rawStatements.length === 0) {
result.warnings.push('No SQL statements found after parsing');
}
// Log parsing results
logger.debug('SQL parsing results:', {
statementCount: result.statements.length,
errorCount: result.errors.length,
warningCount: result.warnings.length,
statements: result.statements.map(s => s.substring(0, 50) + '...'),
errors: result.errors,
warnings: result.warnings
});
} catch (error) {
result.errors.push(`SQL parsing failed: ${error instanceof Error ? error.message : String(error)}`);
logger.error('SQL parsing error:', error);
}
return result;
};
// Initial migration for accounts table
const INITIAL_MIGRATION: Migration = {
version: 1,
name: '001_initial_accounts',
description: 'Initial schema with accounts table',
sql: `
/* Create accounts table with required fields */
CREATE TABLE IF NOT EXISTS accounts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
dateCreated TEXT NOT NULL,
derivationPath TEXT,
did TEXT NOT NULL,
identityEncrBase64 TEXT, -- encrypted & base64-encoded
mnemonicEncrBase64 TEXT, -- encrypted & base64-encoded
passkeyCredIdHex TEXT,
publicKeyHex TEXT NOT NULL
);
/* Create index on did for faster lookups */
CREATE INDEX IF NOT EXISTS idx_accounts_did ON accounts(did);
`,
rollback: `
/* Drop index first to avoid foreign key issues */
DROP INDEX IF EXISTS idx_accounts_did;
/* Drop the accounts table */
DROP TABLE IF EXISTS accounts;
`
};
// Migration registry
const MIGRATIONS: Migration[] = [
INITIAL_MIGRATION
];
// Helper functions
const verifyPluginState = async (plugin: any): Promise<boolean> => {
try {
const result = await plugin.echo({ value: 'test' });
return result?.value === 'test';
} catch (error) {
logger.error('Plugin state verification failed:', error);
return false;
}
};
// Helper function to verify transaction state without starting a transaction
const verifyTransactionState = async (
plugin: any,
database: string
): Promise<boolean> => {
try {
// Query SQLite's internal transaction state
const result = await plugin.query({
database,
statement: "SELECT * FROM sqlite_master WHERE type='table' AND name='schema_version';"
});
// If we can query, we're not in a transaction
return false;
} catch (error) {
// If error contains "transaction", we're probably in a transaction
const errorMsg = error instanceof Error ? error.message : String(error);
const inTransaction = errorMsg.toLowerCase().includes('transaction');
logger.debug('Transaction state check:', {
inTransaction,
error: error instanceof Error ? {
message: error.message,
name: error.name
} : error
});
return inTransaction;
}
};
const getCurrentVersion = async (
plugin: any,
database: string
): Promise<number> => {
try {
const result = await plugin.query({
database,
statement: 'SELECT version FROM schema_version ORDER BY version DESC LIMIT 1;'
});
return result?.values?.[0]?.version || 0;
} catch (error) {
logger.error('Error getting current version:', error);
return 0;
}
};
/**
* Helper function to execute SQL with retry logic for locked database
* @param plugin SQLite plugin instance
* @param database Database name
* @param operation Function to execute
* @param context Operation context for logging
*/
const executeWithRetry = async <T>(
plugin: any,
database: string,
operation: () => Promise<T>,
context: string
): Promise<T> => {
let lastError: Error | null = null;
let startTime = Date.now();
for (let attempt = 1; attempt <= MAX_RETRY_ATTEMPTS; attempt++) {
try {
// Check if we've exceeded the total timeout
if (Date.now() - startTime > LOCK_TIMEOUT_MS) {
throw new Error(`Operation timed out after ${LOCK_TIMEOUT_MS}ms`);
}
// Try the operation
return await operation();
} catch (error) {
lastError = error instanceof Error ? error : new Error(String(error));
const errorMsg = lastError.message.toLowerCase();
const isLockError = errorMsg.includes('database is locked') ||
errorMsg.includes('database is busy') ||
errorMsg.includes('database is locked (5)');
if (!isLockError || attempt === MAX_RETRY_ATTEMPTS) {
throw lastError;
}
logger.warn(`Database operation failed, retrying (${attempt}/${MAX_RETRY_ATTEMPTS}):`, {
context,
error: lastError.message,
attempt,
elapsedMs: Date.now() - startTime
});
// Exponential backoff
const backoffDelay = RETRY_DELAY_MS * Math.pow(2, attempt - 1);
await delay(Math.min(backoffDelay, LOCK_TIMEOUT_MS - (Date.now() - startTime)));
}
}
throw lastError || new Error(`Operation failed after ${MAX_RETRY_ATTEMPTS} attempts`);
};
// Helper function to execute a single SQL statement with retry logic
const executeSingleStatement = async (
plugin: any,
database: string,
statement: string,
values: any[] = []
): Promise<any> => {
logger.debug('Executing SQL statement:', {
statement: statement.substring(0, 100) + (statement.length > 100 ? '...' : ''),
values: values.map(v => ({
value: v,
type: typeof v,
isNull: v === null || v === undefined
}))
});
return executeWithRetry(
plugin,
database,
async () => {
// Validate values before execution
if (statement.includes('schema_version') && statement.includes('INSERT')) {
// Find the name parameter index in the SQL statement
const paramIndex = statement.toLowerCase().split(',').findIndex(p =>
p.trim().startsWith('name')
);
if (paramIndex !== -1 && values[paramIndex] !== undefined) {
const nameValue = values[paramIndex];
if (!nameValue || typeof nameValue !== 'string') {
throw new Error(`Invalid migration name type: ${typeof nameValue}`);
}
if (nameValue.trim().length === 0) {
throw new Error('Migration name cannot be empty');
}
// Ensure we're using the actual migration name, not the version
if (nameValue === values[0]?.toString()) {
throw new Error('Migration name cannot be the same as version number');
}
logger.debug('Validated migration name:', {
name: nameValue,
type: typeof nameValue,
length: nameValue.length
});
}
}
const result = await plugin.execute({
database,
statements: statement,
values,
transaction: false
});
logger.debug('SQL execution result:', {
statement: statement.substring(0, 100) + (statement.length > 100 ? '...' : ''),
result
});
return result;
},
'executeSingleStatement'
);
};
// Helper function to create migrations table if it doesn't exist
const ensureMigrationsTable = async (
plugin: any,
database: string
): Promise<void> => {
logger.debug('Ensuring migrations table exists');
try {
// Drop and recreate the table to ensure proper structure
await plugin.execute({
database,
statements: 'DROP TABLE IF EXISTS schema_version;',
transaction: false
});
// Create the table with proper constraints
await plugin.execute({
database,
statements: MIGRATIONS_TABLE,
transaction: false
});
// Verify table creation and structure
const tableInfo = await plugin.query({
database,
statement: "PRAGMA table_info(schema_version);"
});
logger.debug('Schema version table structure:', {
columns: tableInfo?.values?.map((row: any) => ({
name: row.name,
type: row.type,
notnull: row.notnull,
dflt_value: row.dflt_value
}))
});
// Verify table was created
const verifyCheck = await plugin.query({
database,
statement: "SELECT name FROM sqlite_master WHERE type='table' AND name='schema_version';"
});
if (!verifyCheck?.values?.length) {
throw new Error('Failed to create migrations table');
}
logger.debug('Migrations table created successfully');
} catch (error) {
logger.error('Error ensuring migrations table:', {
error: error instanceof Error ? {
message: error.message,
stack: error.stack,
name: error.name
} : error
});
throw error;
}
};
// Update the parseMigrationStatements function to use the new parser
const parseMigrationStatements = (sql: string): string[] => {
const parsed = parseSQL(sql);
if (parsed.errors.length > 0) {
throw new Error(`SQL validation failed:\n${parsed.errors.join('\n')}`);
}
if (parsed.warnings.length > 0) {
logger.warn('SQL parsing warnings:', parsed.warnings);
}
return parsed.statements;
};
// Add debug helper function
const debugTableState = async (
plugin: any,
database: string,
context: string
): Promise<void> => {
try {
const tableInfo = await plugin.query({
database,
statement: "PRAGMA table_info(schema_version);"
});
const tableData = await plugin.query({
database,
statement: "SELECT * FROM schema_version;"
});
logger.debug(`Table state (${context}):`, {
tableInfo: tableInfo?.values?.map((row: any) => ({
name: row.name,
type: row.type,
notnull: row.notnull,
dflt_value: row.dflt_value
})),
tableData: tableData?.values,
rowCount: tableData?.values?.length || 0
});
} catch (error) {
logger.error(`Error getting table state (${context}):`, error);
}
};
/**
* Executes a single migration with full transaction safety
*
* Process:
* 1. Verifies plugin and transaction state
* 2. Parses and validates SQL
* 3. Executes in transaction
* 4. Updates schema version
* 5. Verifies success
*
* Error Handling:
* - Automatic rollback on failure
* - Detailed error logging
* - State verification
* - Recovery attempts
*
* @param plugin SQLite plugin instance
* @param database Database name
* @param migration Migration to execute
* @returns {Promise<MigrationResult>} Result of migration execution
* @throws {Error} If migration fails and cannot be recovered
*/
const executeMigration = async (
plugin: any,
database: string,
migration: Migration
): Promise<MigrationResult> => {
const startTime = Date.now();
const statements = parseMigrationStatements(migration.sql);
let transactionStarted = false;
logger.info(`Starting migration ${migration.version}: ${migration.name}`, {
migration: {
version: migration.version,
name: migration.name,
description: migration.description,
statementCount: statements.length
}
});
try {
// Debug table state before migration
await debugTableState(plugin, database, 'before_migration');
// Ensure migrations table exists with retry
await executeWithRetry(
plugin,
database,
() => ensureMigrationsTable(plugin, database),
'ensureMigrationsTable'
);
// Verify plugin state
const pluginState = await verifyPluginState(plugin);
if (!pluginState) {
throw new Error('Plugin not available');
}
// Start transaction with retry
await executeWithRetry(
plugin,
database,
async () => {
await plugin.beginTransaction({ database });
transactionStarted = true;
},
'beginTransaction'
);
try {
// Execute each statement with retry
for (let i = 0; i < statements.length; i++) {
const statement = statements[i];
await executeWithRetry(
plugin,
database,
() => executeSingleStatement(plugin, database, statement),
`executeStatement_${i + 1}`
);
}
// Commit transaction before updating schema version
await executeWithRetry(
plugin,
database,
async () => {
await plugin.commitTransaction({ database });
transactionStarted = false;
},
'commitTransaction'
);
// Update schema version outside of transaction with enhanced debugging
await executeWithRetry(
plugin,
database,
async () => {
logger.debug('Preparing schema version update:', {
version: migration.version,
name: migration.name.trim(),
description: migration.description,
nameType: typeof migration.name,
nameLength: migration.name.length,
nameTrimmedLength: migration.name.trim().length,
nameIsEmpty: migration.name.trim().length === 0
});
// Use direct SQL with properly escaped values
const escapedName = migration.name.trim().replace(/'/g, "''");
const escapedDesc = (migration.description || '').replace(/'/g, "''");
const insertSql = `INSERT INTO schema_version (version, name, description) VALUES (${migration.version}, '${escapedName}', '${escapedDesc}')`;
logger.debug('Executing schema version update:', {
sql: insertSql,
originalValues: {
version: migration.version,
name: migration.name.trim(),
description: migration.description
}
});
// Debug table state before insert
await debugTableState(plugin, database, 'before_insert');
const result = await plugin.execute({
database,
statements: insertSql,
transaction: false
});
logger.debug('Schema version update result:', {
result,
sql: insertSql
});
// Debug table state after insert
await debugTableState(plugin, database, 'after_insert');
// Verify the insert
const verifyQuery = await plugin.query({
database,
statement: `SELECT * FROM schema_version WHERE version = ${migration.version} AND name = '${escapedName}'`
});
logger.debug('Schema version verification:', {
found: verifyQuery?.values?.length > 0,
rowCount: verifyQuery?.values?.length || 0,
data: verifyQuery?.values
});
},
'updateSchemaVersion'
);
const duration = Date.now() - startTime;
logger.info(`Migration ${migration.version} completed in ${duration}ms`);
return {
success: true,
version: migration.version,
name: migration.name,
state: {
plugin: { isAvailable: true, lastChecked: new Date() },
transaction: { isActive: false, lastVerified: new Date() }
}
};
} catch (error) {
// Rollback with retry
if (transactionStarted) {
try {
await executeWithRetry(
plugin,
database,
async () => {
// Record error in schema_version before rollback
await executeSingleStatement(
plugin,
database,
`INSERT INTO schema_version (
version, name, description, applied_at,
error_message, error_stack, error_context
) VALUES (?, ?, ?, CURRENT_TIMESTAMP, ?, ?, ?);`,
[
migration.version,
migration.name,
migration.description,
error instanceof Error ? error.message : String(error),
error instanceof Error ? error.stack : null,
'migration_execution'
]
);
await plugin.rollbackTransaction({ database });
},
'rollbackTransaction'
);
} catch (rollbackError) {
logger.error('Error during rollback:', {
originalError: error,
rollbackError
});
}
}
throw error;
}
} catch (error) {
// Debug table state on error
await debugTableState(plugin, database, 'on_error');
logger.error('Migration execution failed:', {
error: error instanceof Error ? {
message: error.message,
stack: error.stack,
name: error.name
} : error,
migration: {
version: migration.version,
name: migration.name,
nameType: typeof migration.name,
nameLength: migration.name.length,
nameTrimmedLength: migration.name.trim().length
}
});
return {
success: false,
version: migration.version,
name: migration.name,
error: error instanceof Error ? error : new Error(String(error)),
state: {
plugin: { isAvailable: true, lastChecked: new Date() },
transaction: { isActive: false, lastVerified: new Date() }
}
};
}
};
/**
* Main migration runner
*
* Orchestrates the complete migration process:
* 1. Verifies plugin state
* 2. Ensures migrations table
* 3. Determines pending migrations
* 4. Executes migrations in order
* 5. Verifies results
*
* Features:
* - Version-based ordering
* - Transaction safety
* - Error recovery
* - State verification
* - Detailed logging
*
* @param plugin SQLite plugin instance
* @param database Database name
* @returns {Promise<MigrationResult[]>} Results of all migrations
* @throws {Error} If migration process fails
*/
export async function runMigrations(
plugin: any,
database: string
): Promise<MigrationResult[]> {
logger.info('Starting migration process');
// Verify plugin is available
if (!await verifyPluginState(plugin)) {
throw new Error('SQLite plugin not available');
}
// Ensure migrations table exists before any migrations
try {
await ensureMigrationsTable(plugin, database);
} catch (error) {
logger.error('Failed to ensure migrations table:', error);
throw new Error('Failed to initialize migrations system');
}
// Get current version
const currentVersion = await getCurrentVersion(plugin, database);
logger.info(`Current database version: ${currentVersion}`);
// Find pending migrations
const pendingMigrations = MIGRATIONS.filter(m => m.version > currentVersion);
if (pendingMigrations.length === 0) {
logger.info('No pending migrations');
return [];
}
logger.info(`Found ${pendingMigrations.length} pending migrations`);
// Execute each migration
const results: MigrationResult[] = [];
for (const migration of pendingMigrations) {
const result = await executeMigration(plugin, database, migration);
results.push(result);
if (!result.success) {
logger.error(`Migration failed at version ${migration.version}`);
break;
}
}
return results;
}
// Export types for use in other modules
export type { Migration, MigrationResult, MigrationState };

244
electron/src/setup.ts

@ -0,0 +1,244 @@
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';
// Define components for a watcher to detect when the webapp is changed so we can reload in Dev mode.
const reloadWatcher = {
debouncer: null,
ready: false,
watcher: null,
};
export function setupReloadWatcher(electronCapacitorApp: ElectronCapacitorApp): void {
reloadWatcher.watcher = chokidar
.watch(join(app.getAppPath(), 'app'), {
ignored: /[/\\]\./,
persistent: true,
})
.on('ready', () => {
reloadWatcher.ready = true;
})
.on('all', (_event, _path) => {
if (reloadWatcher.ready) {
clearTimeout(reloadWatcher.debouncer);
reloadWatcher.debouncer = setTimeout(async () => {
electronCapacitorApp.getMainWindow().webContents.reload();
reloadWatcher.ready = false;
clearTimeout(reloadWatcher.debouncer);
reloadWatcher.debouncer = null;
reloadWatcher.watcher = null;
setupReloadWatcher(electronCapacitorApp);
}, 1500);
}
});
}
// 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 and construct our main window.
const preloadPath = join(app.getAppPath(), 'build', 'src', 'preload.js');
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: true,
contextIsolation: true,
// Use preload to inject the electron varriant overrides for capacitor plugins.
// preload: join(app.getAppPath(), "node_modules", "@capacitor-community", "electron", "dist", "runtime", "electron-rt.js"),
preload: preloadPath,
},
});
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();
}
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}://* 'unsafe-inline' data:;`,
// Allow Google Fonts
`style-src ${customScheme}://* 'unsafe-inline' https://fonts.googleapis.com;`,
`font-src ${customScheme}://* https://fonts.gstatic.com;`,
// Allow images and media
`img-src ${customScheme}://* data: https:;`,
// Allow connections to HTTPS resources
`connect-src ${customScheme}://* https:;`,
// Add dev-specific policies
...(electronIsDev ? [
`script-src ${customScheme}://* 'unsafe-inline' 'unsafe-eval' devtools://*;`,
`default-src ${customScheme}://* 'unsafe-inline' devtools://* 'unsafe-eval' data:;`
] : [])
].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
}
}

186
experiment.sh

@ -0,0 +1,186 @@
#!/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, TypeScript compilation, and AppImage packaging.
# It ensures all dependencies are available and provides detailed build feedback.
#
# Build Process:
# 1. Environment setup and dependency checks
# 2. Web asset compilation (Vite)
# 3. TypeScript compilation
# 4. Electron main process build
# 5. AppImage packaging
#
# Dependencies:
# - Node.js and npm
# - TypeScript
# - Vite
# - electron-builder
#
# Usage: ./experiment.sh
#
# Exit Codes:
# 1 - Required command not found
# 2 - TypeScript installation failed
# 3 - TypeScript compilation failed
# 4 - Build process failed
# 5 - AppImage build 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"
}
# Function to find the AppImage
find_appimage() {
local appimage_path
appimage_path=$(find dist-electron-packages -name "*.AppImage" -type f -print -quit)
if [ -n "$appimage_path" ]; then
echo "$appimage_path"
else
log_warn "AppImage not found in expected location"
echo "dist-electron-packages/*.AppImage"
fi
}
# 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
# 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
# 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 4
fi
# TypeScript compilation
log_info "Compiling TypeScript..."
if ! measure_time ./node_modules/.bin/tsc -p tsconfig.electron.json; then
log_error "TypeScript compilation failed!"
exit 3
fi
# Build electron main process
log_info "Building electron main process..."
if ! measure_time env VITE_GIT_HASH="$GIT_HASH" npx vite build --config vite.config.electron.mts --mode electron; then
log_error "Electron main process build failed!"
exit 4
fi
# Organize files
log_info "Organizing build artifacts..."
mkdir -p dist-electron/www
cp -r dist/* dist-electron/www/ || log_error "Failed to copy web assets"
mkdir -p dist-electron/resources
cp src/electron/preload.js dist-electron/resources/preload.js || log_error "Failed to copy preload script"
# Build the AppImage
log_info "Building AppImage package..."
if ! measure_time npx electron-builder --linux AppImage; then
log_error "AppImage build failed!"
exit 5
fi
# Print build summary
echo -e "\n${GREEN}=== Build Summary ===${NC}"
log_success "Build completed successfully!"
log_info "Build artifacts location: $(pwd)/dist-electron"
log_info "AppImage location: $(find_appimage)"
# Check for build warnings
if grep -q "default Electron icon is used" dist-electron-packages/builder-effective-config.yaml; then
log_warn "Using default Electron icon - consider adding a custom icon"
fi
if grep -q "chunks are larger than 1000 kB" dist-electron-packages/builder-effective-config.yaml; then
log_warn "Large chunks detected - consider implementing code splitting"
fi
echo -e "\n${GREEN}=== End of Build Process ===${NC}\n"
# Exit with success
exit 0

170
package-lock.json

@ -19,8 +19,8 @@
"@capacitor/ios": "^6.2.0", "@capacitor/ios": "^6.2.0",
"@capacitor/share": "^6.0.3", "@capacitor/share": "^6.0.3",
"@capawesome/capacitor-file-picker": "^6.2.0", "@capawesome/capacitor-file-picker": "^6.2.0",
"@dicebear/collection": "^5.4.1", "@dicebear/collection": "^5.4.3",
"@dicebear/core": "^5.4.1", "@dicebear/core": "^5.4.3",
"@ethersproject/hdnode": "^5.7.0", "@ethersproject/hdnode": "^5.7.0",
"@ethersproject/wallet": "^5.8.0", "@ethersproject/wallet": "^5.8.0",
"@fortawesome/fontawesome-svg-core": "^6.5.1", "@fortawesome/fontawesome-svg-core": "^6.5.1",
@ -31,7 +31,7 @@
"@peculiar/asn1-schema": "^2.3.8", "@peculiar/asn1-schema": "^2.3.8",
"@pvermeer/dexie-encrypted-addon": "^3.0.0", "@pvermeer/dexie-encrypted-addon": "^3.0.0",
"@simplewebauthn/browser": "^10.0.0", "@simplewebauthn/browser": "^10.0.0",
"@simplewebauthn/server": "^10.0.0", "@simplewebauthn/server": "^10.0.1",
"@tweenjs/tween.js": "^21.1.1", "@tweenjs/tween.js": "^21.1.1",
"@types/qrcode": "^1.5.5", "@types/qrcode": "^1.5.5",
"@veramo/core": "^5.6.0", "@veramo/core": "^5.6.0",
@ -48,6 +48,7 @@
"absurd-sql": "^0.0.54", "absurd-sql": "^0.0.54",
"asn1-ber": "^1.2.2", "asn1-ber": "^1.2.2",
"axios": "^1.6.8", "axios": "^1.6.8",
"better-sqlite3-multiple-ciphers": "^11.10.0",
"cbor-x": "^1.5.9", "cbor-x": "^1.5.9",
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
"dexie": "^3.2.7", "dexie": "^3.2.7",
@ -55,22 +56,23 @@
"did-jwt": "^7.4.7", "did-jwt": "^7.4.7",
"did-resolver": "^4.1.0", "did-resolver": "^4.1.0",
"dotenv": "^16.0.3", "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", "ethereumjs-util": "^7.1.5",
"jdenticon": "^3.2.0", "jdenticon": "^3.3.0",
"js-generate-password": "^0.1.9", "js-generate-password": "^0.1.9",
"js-yaml": "^4.1.0", "js-yaml": "^4.1.0",
"jsqr": "^1.4.0", "jsqr": "^1.4.0",
"leaflet": "^1.9.4", "leaflet": "^1.9.4",
"localstorage-slim": "^2.7.0", "localstorage-slim": "^2.7.0",
"lru-cache": "^10.2.0", "lru-cache": "^10.4.3",
"luxon": "^3.4.4", "luxon": "^3.4.4",
"merkletreejs": "^0.3.11", "merkletreejs": "^0.3.11",
"nostr-tools": "^2.10.4", "nostr-tools": "^2.13.1",
"notiwind": "^2.0.2", "notiwind": "^2.0.2",
"papaparse": "^5.4.1", "papaparse": "^5.4.1",
"pina": "^0.20.2204228", "pina": "^0.20.2204228",
"pinia-plugin-persistedstate": "^3.2.1", "pinia-plugin-persistedstate": "^3.2.3",
"qr-code-generator-vue3": "^1.4.21", "qr-code-generator-vue3": "^1.4.21",
"qrcode": "^1.5.4", "qrcode": "^1.5.4",
"ramda": "^0.29.1", "ramda": "^0.29.1",
@ -86,12 +88,13 @@
"vue-axios": "^3.5.2", "vue-axios": "^3.5.2",
"vue-facing-decorator": "^3.0.4", "vue-facing-decorator": "^3.0.4",
"vue-picture-cropper": "^0.7.0", "vue-picture-cropper": "^0.7.0",
"vue-qrcode-reader": "^5.5.3", "vue-qrcode-reader": "^5.7.2",
"vue-router": "^4.5.0", "vue-router": "^4.5.0",
"web-did-resolver": "^2.0.27", "web-did-resolver": "^2.0.30",
"zod": "^3.24.2" "zod": "^3.24.2"
}, },
"devDependencies": { "devDependencies": {
"@capacitor-community/electron": "^5.0.1",
"@capacitor/assets": "^3.0.5", "@capacitor/assets": "^3.0.5",
"@playwright/test": "^1.45.2", "@playwright/test": "^1.45.2",
"@types/dom-webcodecs": "^0.1.7", "@types/dom-webcodecs": "^0.1.7",
@ -106,7 +109,7 @@
"@types/ua-parser-js": "^0.7.39", "@types/ua-parser-js": "^0.7.39",
"@typescript-eslint/eslint-plugin": "^6.21.0", "@typescript-eslint/eslint-plugin": "^6.21.0",
"@typescript-eslint/parser": "^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", "@vue/eslint-config-typescript": "^11.0.3",
"autoprefixer": "^10.4.19", "autoprefixer": "^10.4.19",
"browserify-fs": "^1.0.0", "browserify-fs": "^1.0.0",
@ -126,6 +129,7 @@
"postcss": "^8.4.38", "postcss": "^8.4.38",
"prettier": "^3.2.5", "prettier": "^3.2.5",
"rimraf": "^6.0.1", "rimraf": "^6.0.1",
"source-map-support": "^0.5.21",
"tailwindcss": "^3.4.1", "tailwindcss": "^3.4.1",
"typescript": "~5.2.2", "typescript": "~5.2.2",
"vite": "^5.2.0", "vite": "^5.2.0",
@ -2512,6 +2516,40 @@
"node": ">=8.9" "node": ">=8.9"
} }
}, },
"node_modules/@capacitor-community/electron": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/@capacitor-community/electron/-/electron-5.0.1.tgz",
"integrity": "sha512-4/x12ycTq0Kq8JIn/BmIBdFVP5Cqw8iA6SU6YfFjmONfjW3OELwsB3zwLxOwAjLxnjyCMOBHl4ci9E5jLgZgAQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@capacitor/cli": ">=5.4.0",
"@capacitor/core": ">=5.4.0",
"@ionic/utils-fs": "~3.1.6",
"chalk": "^4.1.2",
"electron-is-dev": "~2.0.0",
"events": "~3.3.0",
"fs-extra": "~11.1.1",
"keyv": "^4.5.2",
"mime-types": "~2.1.35",
"ora": "^5.4.1"
}
},
"node_modules/@capacitor-community/electron/node_modules/fs-extra": {
"version": "11.1.1",
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.1.1.tgz",
"integrity": "sha512-MGIE4HOvQCeUCzmlHs0vXpih4ysz4wg9qiSAu6cd42lVwPbTM1TjV7RusoyQqMmk/95gdQZX72u+YW+c3eEpFQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"graceful-fs": "^4.2.0",
"jsonfile": "^6.0.1",
"universalify": "^2.0.0"
},
"engines": {
"node": ">=14.14"
}
},
"node_modules/@capacitor-community/sqlite": { "node_modules/@capacitor-community/sqlite": {
"version": "6.0.2", "version": "6.0.2",
"resolved": "https://registry.npmjs.org/@capacitor-community/sqlite/-/sqlite-6.0.2.tgz", "resolved": "https://registry.npmjs.org/@capacitor-community/sqlite/-/sqlite-6.0.2.tgz",
@ -11998,6 +12036,17 @@
"node": ">=12.0.0" "node": ">=12.0.0"
} }
}, },
"node_modules/better-sqlite3-multiple-ciphers": {
"version": "11.10.0",
"resolved": "https://registry.npmjs.org/better-sqlite3-multiple-ciphers/-/better-sqlite3-multiple-ciphers-11.10.0.tgz",
"integrity": "sha512-/dKO3lKuJFbmuzh80uN2cmMsz8iyTskGB2l/fd9X6rt1P3EPIOvRUIxD7Qim8gLygUPB/u+db8byZGumOOdp3g==",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"bindings": "^1.5.0",
"prebuild-install": "^7.1.1"
}
},
"node_modules/big-integer": { "node_modules/big-integer": {
"version": "1.6.52", "version": "1.6.52",
"resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.52.tgz", "resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.52.tgz",
@ -13418,7 +13467,6 @@
"version": "0.0.1", "version": "0.0.1",
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
"integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
"devOptional": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/concat-stream": { "node_modules/concat-stream": {
@ -15249,6 +15297,75 @@
"node": ">=12" "node": ">=12"
} }
}, },
"node_modules/electron-is-dev": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/electron-is-dev/-/electron-is-dev-2.0.0.tgz",
"integrity": "sha512-3X99K852Yoqu9AcW50qz3ibYBWY79/pBhlMCab8ToEWS48R0T9tyxRiQhwylE7zQdXrMnx2JKqUJyMPmt5FBqA==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/electron-json-storage": {
"version": "4.6.0",
"resolved": "https://registry.npmjs.org/electron-json-storage/-/electron-json-storage-4.6.0.tgz",
"integrity": "sha512-gAgNsnA7tEtV9LzzOnZTyVIb3cQtCva+bEBVT5pbRGU8ZSZTVKPBrTxIAYjeVfdSjyNXgfb1mr/CZrOJgeHyqg==",
"license": "MIT",
"dependencies": {
"async": "^2.0.0",
"lockfile": "^1.0.4",
"lodash": "^4.0.1",
"mkdirp": "^0.5.1",
"rimraf": "^2.5.1",
"write-file-atomic": "^2.4.2"
}
},
"node_modules/electron-json-storage/node_modules/async": {
"version": "2.6.4",
"resolved": "https://registry.npmjs.org/async/-/async-2.6.4.tgz",
"integrity": "sha512-mzo5dfJYwAn29PeiJ0zvwTo04zj8HDJj0Mn8TD7sno7q12prdbnasKJHhkm2c1LgrhlJ0teaea8860oxi51mGA==",
"license": "MIT",
"dependencies": {
"lodash": "^4.17.14"
}
},
"node_modules/electron-json-storage/node_modules/mkdirp": {
"version": "0.5.6",
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz",
"integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==",
"license": "MIT",
"dependencies": {
"minimist": "^1.2.6"
},
"bin": {
"mkdirp": "bin/cmd.js"
}
},
"node_modules/electron-json-storage/node_modules/rimraf": {
"version": "2.7.1",
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz",
"integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==",
"deprecated": "Rimraf versions prior to v4 are no longer supported",
"license": "ISC",
"dependencies": {
"glob": "^7.1.3"
},
"bin": {
"rimraf": "bin.js"
}
},
"node_modules/electron-json-storage/node_modules/write-file-atomic": {
"version": "2.4.3",
"resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-2.4.3.tgz",
"integrity": "sha512-GaETH5wwsX+GcnzhPgKcKjJ6M2Cq3/iZp1WyY/X1CSqrW+jVNM9Y7D8EC2sM4ZG/V8wZlSniJnCKWPmBYAucRQ==",
"license": "ISC",
"dependencies": {
"graceful-fs": "^4.1.11",
"imurmurhash": "^0.1.4",
"signal-exit": "^3.0.2"
}
},
"node_modules/electron-publish": { "node_modules/electron-publish": {
"version": "25.1.7", "version": "25.1.7",
"resolved": "https://registry.npmjs.org/electron-publish/-/electron-publish-25.1.7.tgz", "resolved": "https://registry.npmjs.org/electron-publish/-/electron-publish-25.1.7.tgz",
@ -17520,7 +17637,6 @@
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
"integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
"deprecated": "Glob versions prior to v9 are no longer supported", "deprecated": "Glob versions prior to v9 are no longer supported",
"devOptional": true,
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"fs.realpath": "^1.0.0", "fs.realpath": "^1.0.0",
@ -17554,7 +17670,6 @@
"version": "1.1.11", "version": "1.1.11",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
"integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
"devOptional": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"balanced-match": "^1.0.0", "balanced-match": "^1.0.0",
@ -17565,7 +17680,6 @@
"version": "3.1.2", "version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
"devOptional": true,
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"brace-expansion": "^1.1.7" "brace-expansion": "^1.1.7"
@ -18168,7 +18282,6 @@
"version": "0.1.4", "version": "0.1.4",
"resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz",
"integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==",
"devOptional": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=0.8.19" "node": ">=0.8.19"
@ -18202,7 +18315,6 @@
"resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
"integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==",
"deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.",
"devOptional": true,
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"once": "^1.3.0", "once": "^1.3.0",
@ -20562,11 +20674,19 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/lockfile": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/lockfile/-/lockfile-1.0.4.tgz",
"integrity": "sha512-cvbTwETRfsFh4nHsL1eGWapU1XFi5Ot9E85sWAwia7Y7EgB7vfqcZhTKZ+l7hCGxSPoushMv5GKhT5PdLv03WA==",
"license": "ISC",
"dependencies": {
"signal-exit": "^3.0.2"
}
},
"node_modules/lodash": { "node_modules/lodash": {
"version": "4.17.21", "version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/lodash.clonedeep": { "node_modules/lodash.clonedeep": {
@ -23007,9 +23127,9 @@
} }
}, },
"node_modules/nostr-tools": { "node_modules/nostr-tools": {
"version": "2.13.0", "version": "2.13.1",
"resolved": "https://registry.npmjs.org/nostr-tools/-/nostr-tools-2.13.0.tgz", "resolved": "https://registry.npmjs.org/nostr-tools/-/nostr-tools-2.13.1.tgz",
"integrity": "sha512-A1arGsvpULqVK0NmZQqK1imwaCiPm8gcG/lo+cTax2NbNqBEYsuplbqAFdVqcGHEopmkByYbTwF76x25+oEbew==", "integrity": "sha512-EKcicym1ree14m8lU0b6sZIf46NcxOHvGwUWWAuxjhutOmJM5Pc9ylR1YqxbgpqDQpkEYQwv/d3GupK5CJj9ow==",
"license": "Unlicense", "license": "Unlicense",
"dependencies": { "dependencies": {
"@noble/ciphers": "^0.5.1", "@noble/ciphers": "^0.5.1",
@ -23017,9 +23137,7 @@
"@noble/hashes": "1.3.1", "@noble/hashes": "1.3.1",
"@scure/base": "1.1.1", "@scure/base": "1.1.1",
"@scure/bip32": "1.3.1", "@scure/bip32": "1.3.1",
"@scure/bip39": "1.2.1" "@scure/bip39": "1.2.1",
},
"optionalDependencies": {
"nostr-wasm": "0.1.0" "nostr-wasm": "0.1.0"
}, },
"peerDependencies": { "peerDependencies": {
@ -23131,8 +23249,7 @@
"version": "0.1.0", "version": "0.1.0",
"resolved": "https://registry.npmjs.org/nostr-wasm/-/nostr-wasm-0.1.0.tgz", "resolved": "https://registry.npmjs.org/nostr-wasm/-/nostr-wasm-0.1.0.tgz",
"integrity": "sha512-78BTryCLcLYv96ONU8Ws3Q1JzjlAt+43pWQhIl86xZmWeegYCNLPml7yQ+gG3vR6V5h4XGj+TxO+SS5dsThQIA==", "integrity": "sha512-78BTryCLcLYv96ONU8Ws3Q1JzjlAt+43pWQhIl86xZmWeegYCNLPml7yQ+gG3vR6V5h4XGj+TxO+SS5dsThQIA==",
"license": "MIT", "license": "MIT"
"optional": true
}, },
"node_modules/notiwind": { "node_modules/notiwind": {
"version": "2.1.0", "version": "2.1.0",
@ -23729,7 +23846,6 @@
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
"integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==",
"devOptional": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"

47
package.json

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

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

1
requirements.txt

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

85
scripts/build-electron.cjs

@ -0,0 +1,85 @@
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.js");
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)) {
fs.copyFileSync(preloadSrc, preloadDest);
console.log("Preload script copied to resources directory");
} else {
console.error("Preload script not found at:", preloadSrc);
}
// 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; return true;
} }
const serverSubscription = { const serverSubscription =
...subscription, typeof subscription === "object" && subscription !== null
}; ? { ...subscription }
: {};
if (!allGoingOff) { if (!allGoingOff) {
serverSubscription["notifyType"] = notification.title; serverSubscription["notifyType"] = notification.title;
logger.log( logger.log(

14
src/components/GiftedDialog.vue

@ -320,10 +320,7 @@ export default class GiftedDialog extends Vue {
this.fromProjectId, this.fromProjectId,
); );
if ( if (!result.success) {
result.type === "error" ||
this.isGiveCreationError(result.response)
) {
const errorMessage = this.getGiveCreationErrorMessage(result); const errorMessage = this.getGiveCreationErrorMessage(result);
logger.error("Error with give creation result:", result); logger.error("Error with give creation result:", result);
this.$notify( this.$notify(
@ -370,15 +367,6 @@ export default class GiftedDialog extends Vue {
// Helper functions for readability // Helper functions for readability
/**
* @param result response "data" from the server
* @returns true if the result indicates an error
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
isGiveCreationError(result: any) {
return result.status !== 201 || result.data?.error;
}
/** /**
* @param result direct response eg. ErrorResult or SuccessResult (potentially with embedded "data") * @param result direct response eg. ErrorResult or SuccessResult (potentially with embedded "data")
* @returns best guess at an error message * @returns best guess at an error message

14
src/components/OfferDialog.vue

@ -249,10 +249,7 @@ export default class OfferDialog extends Vue {
this.projectId, this.projectId,
); );
if ( if (!result.success) {
result.type === "error" ||
this.isOfferCreationError(result.response)
) {
const errorMessage = this.getOfferCreationErrorMessage(result); const errorMessage = this.getOfferCreationErrorMessage(result);
logger.error("Error with offer creation result:", result); logger.error("Error with offer creation result:", result);
this.$notify( this.$notify(
@ -296,15 +293,6 @@ export default class OfferDialog extends Vue {
// Helper functions for readability // Helper functions for readability
/**
* @param result response "data" from the server
* @returns true if the result indicates an error
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
isOfferCreationError(result: any) {
return result.status !== 201 || result.data?.error;
}
/** /**
* @param result direct response eg. ErrorResult or SuccessResult (potentially with embedded "data") * @param result direct response eg. ErrorResult or SuccessResult (potentially with embedded "data")
* @returns best guess at an error message * @returns best guess at an error message

9
src/components/UserNameDialog.vue

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

749
src/electron/main.ts

@ -1,35 +1,394 @@
import { app, BrowserWindow } from "electron"; import { app, BrowserWindow, ipcMain } from "electron";
import path from "path"; import { Capacitor } from "@capacitor/core";
import fs from "fs"; import fs from "fs";
import path from "path";
import { fileURLToPath } from "url";
// Get __dirname equivalent in ES modules
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// Set global variables that the plugin needs
global.__dirname = __dirname;
global.__filename = __filename;
// Now import the plugin after setting up globals
import { CapacitorSQLite } from "@capacitor-community/sqlite/electron/dist/plugin.js";
// Simple logger implementation // Simple logger implementation
const logger = { const logger = {
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
log: (...args: unknown[]) => console.log(...args), log: (...args: unknown[]) => console.log("[Main]", ...args),
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
error: (...args: unknown[]) => console.error(...args), error: (...args: unknown[]) => console.error("[Main]", ...args),
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
info: (...args: unknown[]) => console.info(...args), info: (...args: unknown[]) => console.info("[Main]", ...args),
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
warn: (...args: unknown[]) => console.warn(...args), warn: (...args: unknown[]) => console.warn("[Main]", ...args),
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
debug: (...args: unknown[]) => console.debug(...args), debug: (...args: unknown[]) => console.debug("[Main]", ...args),
};
logger.info("Starting main process initialization...");
// Initialize Capacitor for Electron in main process
try {
logger.info("About to initialize Capacitor...");
logger.info("Capacitor before init:", {
hasPlatform: "platform" in Capacitor,
hasIsNativePlatform: "isNativePlatform" in Capacitor,
platformType: typeof Capacitor.platform,
isNativePlatformType: typeof Capacitor.isNativePlatform,
});
// Try direct assignment first
try {
(Capacitor as unknown as { platform: string }).platform = "electron";
(Capacitor as unknown as { isNativePlatform: boolean }).isNativePlatform =
true;
logger.info("Direct assignment successful");
} catch (e) {
logger.warn("Direct assignment failed, trying defineProperty:", e);
Object.defineProperty(Capacitor, "isNativePlatform", {
get: () => true,
configurable: true,
});
Object.defineProperty(Capacitor, "platform", {
get: () => "electron",
configurable: true,
});
}
logger.info("Capacitor after init:", {
platform: Capacitor.platform,
isNativePlatform: Capacitor.isNativePlatform,
platformType: typeof Capacitor.platform,
isNativePlatformType: typeof Capacitor.isNativePlatform,
});
} catch (error) {
logger.error("Failed to initialize Capacitor:", error);
throw error;
}
// Database path resolution utilities
const getAppDataPath = async (): Promise<string> => {
try {
// Read config file directly
const configPath = path.join(__dirname, "..", "capacitor.config.json");
const configContent = await fs.promises.readFile(configPath, "utf-8");
const config = JSON.parse(configContent);
const linuxPath = config?.plugins?.CapacitorSQLite?.electronLinuxLocation;
if (linuxPath) {
// Expand ~ to home directory
const expandedPath = linuxPath.replace(/^~/, process.env.HOME || "");
logger.info("[Electron] Using configured database path:", expandedPath);
return expandedPath;
}
// Fallback to app.getPath if config path is not available
const userDataPath = app.getPath("userData");
logger.info("[Electron] Using fallback user data path:", userDataPath);
return userDataPath;
} catch (error) {
logger.error("[Electron] Error getting app data path:", error);
// Fallback to app.getPath if anything fails
const userDataPath = app.getPath("userData");
logger.info(
"[Electron] Using fallback user data path after error:",
userDataPath,
);
return userDataPath;
}
};
const validateAndNormalizePath = async (filePath: string): Promise<string> => {
// Resolve any relative paths
const resolvedPath = path.resolve(filePath);
// Ensure it's an absolute path
if (!path.isAbsolute(resolvedPath)) {
throw new Error(`Database path must be absolute: ${resolvedPath}`);
}
// Ensure it's within the app data directory
const appDataPath = await getAppDataPath();
if (!resolvedPath.startsWith(appDataPath)) {
throw new Error(
`Database path must be within app data directory: ${resolvedPath}`,
);
}
// Normalize the path
const normalizedPath = path.normalize(resolvedPath);
logger.info("[Electron] Validated database path:", {
original: filePath,
resolved: resolvedPath,
normalized: normalizedPath,
appDataPath,
isAbsolute: path.isAbsolute(normalizedPath),
isWithinAppData: normalizedPath.startsWith(appDataPath),
});
return normalizedPath;
};
const ensureDirectoryExists = async (dirPath: string): Promise<void> => {
try {
// Normalize the path first
const normalizedPath = path.normalize(dirPath);
// Check if directory exists
if (!fs.existsSync(normalizedPath)) {
logger.info("[Electron] Creating database directory:", normalizedPath);
await fs.promises.mkdir(normalizedPath, { recursive: true });
}
// Verify directory permissions
try {
await fs.promises.access(
normalizedPath,
fs.constants.R_OK | fs.constants.W_OK,
);
logger.info(
"[Electron] Database directory permissions verified:",
normalizedPath,
);
} catch (error) {
logger.error("[Electron] Database directory permission error:", error);
throw new Error(`Database directory not accessible: ${normalizedPath}`);
}
// Test write permissions
const testFile = path.join(normalizedPath, ".write-test");
try {
await fs.promises.writeFile(testFile, "test");
await fs.promises.unlink(testFile);
logger.info(
"[Electron] Database directory write test passed:",
normalizedPath,
);
} catch (error) {
logger.error("[Electron] Database directory write test failed:", error);
throw new Error(`Database directory not writable: ${normalizedPath}`);
}
} catch (error) {
logger.error(
"[Electron] Failed to ensure database directory exists:",
error,
);
throw error;
}
}; };
// Initialize database paths
let dbPath: string | undefined;
let dbDir: string | undefined;
let dbPathInitialized = false;
let dbPathInitializationPromise: Promise<void> | null = null;
const initializeDatabasePaths = async (): Promise<void> => {
// Prevent multiple simultaneous initializations
if (dbPathInitializationPromise) {
return dbPathInitializationPromise;
}
if (dbPathInitialized) {
return;
}
dbPathInitializationPromise = (async () => {
try {
// Get the base directory from config
dbDir = await getAppDataPath();
logger.info("[Electron] Database directory:", dbDir);
// Ensure the directory exists and is writable
await ensureDirectoryExists(dbDir);
// Construct the database path
dbPath = await validateAndNormalizePath(
path.join(dbDir, "timesafari.db"),
);
logger.info("[Electron] Database path initialized:", dbPath);
// Verify the database file if it exists
if (fs.existsSync(dbPath)) {
try {
await fs.promises.access(
dbPath,
fs.constants.R_OK | fs.constants.W_OK,
);
logger.info(
"[Electron] Existing database file permissions verified:",
dbPath,
);
} catch (error) {
logger.error("[Electron] Database file permission error:", error);
throw new Error(`Database file not accessible: ${dbPath}`);
}
}
dbPathInitialized = true;
} catch (error) {
logger.error("[Electron] Failed to initialize database paths:", error);
throw error;
} finally {
dbPathInitializationPromise = null;
}
})();
return dbPathInitializationPromise;
};
// Initialize SQLite plugin
let sqlitePlugin: any = null;
let sqliteInitialized = false;
let sqliteInitializationPromise: Promise<void> | null = null;
async function initializeSQLite() {
// Prevent multiple simultaneous initializations
if (sqliteInitializationPromise) {
return sqliteInitializationPromise;
}
if (sqliteInitialized) {
return;
}
sqliteInitializationPromise = (async () => {
try {
logger.info("[Electron] Initializing SQLite plugin...");
sqlitePlugin = new CapacitorSQLite();
// Initialize database paths first
await initializeDatabasePaths();
if (!dbPath) {
throw new Error("Database path not initialized");
}
// Test the plugin
const echoResult = await sqlitePlugin.echo({ value: "test" });
logger.info("[Electron] SQLite plugin echo test:", echoResult);
// Initialize database connection using validated dbPath
const connectionOptions = {
database: dbPath,
version: 1,
readOnly: false,
encryption: "no-encryption",
useNative: true,
mode: "rwc", // Force read-write-create mode
};
logger.info(
"[Electron] Creating initial connection with options:",
connectionOptions,
);
// Log the actual path being used
logger.info("[Electron] Using database path:", dbPath);
logger.info("[Electron] Path exists:", fs.existsSync(dbPath));
logger.info("[Electron] Path is absolute:", path.isAbsolute(dbPath));
const db = await sqlitePlugin.createConnection(connectionOptions);
if (!db || typeof db !== "object") {
throw new Error(
`Failed to create database connection - invalid response. Path used: ${dbPath}`,
);
}
// Wait a moment for the connection to be fully established
await new Promise((resolve) => setTimeout(resolve, 100));
// Verify the connection is working
try {
const result = await db.query("PRAGMA journal_mode;");
logger.info("[Electron] Database connection verified:", result);
} catch (error) {
logger.error(
"[Electron] Database connection verification failed:",
error,
);
throw error;
}
sqliteInitialized = true;
logger.info("[Electron] SQLite plugin initialized successfully");
} catch (error) {
logger.error("[Electron] Failed to initialize SQLite plugin:", error);
throw error;
} finally {
sqliteInitializationPromise = null;
}
})();
return sqliteInitializationPromise;
}
// Initialize app when ready
app.whenReady().then(async () => {
logger.info("App is ready, starting initialization...");
// Create window first
const mainWindow = createWindow();
// Initialize database in background
initializeSQLite().catch((error) => {
logger.error(
"[Electron] Database initialization failed, but continuing:",
error,
);
// Notify renderer about database status
if (!mainWindow.isDestroyed()) {
mainWindow.webContents.send("database-status", {
status: "error",
error: error.message,
});
}
});
// Handle window close
mainWindow.on("closed", () => {
logger.info("[Electron] Main window closed");
});
// Handle window close request
mainWindow.on("close", (event) => {
logger.info("[Electron] Window close requested");
// Prevent immediate close if we're in the middle of something
if (mainWindow.webContents.isLoading()) {
event.preventDefault();
logger.info("[Electron] Deferring window close due to loading state");
mainWindow.webContents.once("did-finish-load", () => {
mainWindow.close();
});
}
});
});
// Check if running in dev mode // Check if running in dev mode
const isDev = process.argv.includes("--inspect"); // const isDev = process.argv.includes("--inspect");
function createWindow(): void { function createWindow(): BrowserWindow {
// Add before createWindow function // Resolve preload path based on environment
const preloadPath = path.join(__dirname, "preload.js"); const preloadPath = app.isPackaged
logger.log("Checking preload path:", preloadPath); ? path.join(process.resourcesPath, "preload.js")
logger.log("Preload exists:", fs.existsSync(preloadPath)); : path.join(__dirname, "preload.js");
logger.log("[Electron] Preload path:", preloadPath);
logger.log("[Electron] Preload exists:", fs.existsSync(preloadPath));
// Log environment and paths // Log environment and paths
logger.log("process.cwd():", process.cwd()); logger.log("[Electron] process.cwd():", process.cwd());
logger.log("__dirname:", __dirname); logger.log("[Electron] __dirname:", __dirname);
logger.log("app.getAppPath():", app.getAppPath()); logger.log("[Electron] app.getAppPath():", app.getAppPath());
logger.log("app.isPackaged:", app.isPackaged); logger.log("[Electron] app.isPackaged:", app.isPackaged);
logger.log("[Electron] process.resourcesPath:", process.resourcesPath);
// List files in __dirname and __dirname/www // List files in __dirname and __dirname/www
try { try {
@ -44,153 +403,148 @@ function createWindow(): void {
logger.error("Error reading directories:", e); logger.error("Error reading directories:", e);
} }
// Create the browser window. // Create the browser window with proper error handling
const mainWindow = new BrowserWindow({ const mainWindow = new BrowserWindow({
width: 1200, width: 1200,
height: 800, height: 800,
show: false, // Don't show until ready
webPreferences: { webPreferences: {
nodeIntegration: false, nodeIntegration: false,
contextIsolation: true, contextIsolation: true,
sandbox: false,
preload: preloadPath,
webSecurity: true, webSecurity: true,
allowRunningInsecureContent: false, allowRunningInsecureContent: false,
preload: path.join(__dirname, "preload.js"),
}, },
}); });
// Always open DevTools for now // Show window when ready
mainWindow.webContents.openDevTools(); mainWindow.once("ready-to-show", () => {
logger.info("[Electron] Window ready to show");
// Intercept requests to fix asset paths mainWindow.show();
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/ // Handle window errors
if (url.includes("/assets/") && !url.includes("/www/assets/")) { mainWindow.webContents.on("render-process-gone", (_event, details) => {
const baseDir = url.includes("dist-electron") logger.error("[Electron] Render process gone:", details);
? 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 mainWindow.webContents.on(
"did-fail-load",
(_event, errorCode, errorDescription) => {
logger.error(
"[Electron] Page failed to load:",
errorCode,
errorDescription,
);
logger.error("[Electron] Failed URL:", mainWindow.webContents.getURL());
}, },
); );
if (isDev) { // Load the index.html
// Debug info let indexPath: string;
logger.log("Debug Info:"); let fileUrl: string;
logger.log("Running in dev mode:", isDev);
logger.log("App is packaged:", app.isPackaged); if (app.isPackaged) {
logger.log("Process resource path:", process.resourcesPath); indexPath = path.join(process.resourcesPath, "www", "index.html");
logger.log("App path:", app.getAppPath()); fileUrl = `file://${indexPath}`;
logger.log("__dirname:", __dirname); logger.info(
logger.log("process.cwd():", process.cwd()); "[Electron] App is packaged. Using process.resourcesPath for index.html",
} );
} else {
let indexPath = path.resolve(__dirname, "dist-electron", "www", "index.html"); indexPath = path.resolve(__dirname, "www", "index.html");
if (!fs.existsSync(indexPath)) { fileUrl = `file://${indexPath}`;
// Fallback for dev mode logger.info(
indexPath = path.resolve( "[Electron] App is not packaged. Using __dirname for index.html",
process.cwd(),
"dist-electron",
"www",
"index.html",
); );
} }
if (isDev) { logger.info("[Electron] Resolved index.html path:", indexPath);
logger.log("Loading index from:", indexPath); logger.info("[Electron] Using file URL:", fileUrl);
logger.log("www path:", path.join(__dirname, "www"));
logger.log("www assets path:", path.join(__dirname, "www", "assets")); // Load the index.html with retry logic
} const loadIndexHtml = async (retryCount = 0): Promise<void> => {
try {
if (!fs.existsSync(indexPath)) { if (mainWindow.isDestroyed()) {
logger.error(`Index file not found at: ${indexPath}`); logger.error(
throw new Error("Index file not found"); "[Electron] Window was destroyed before loading index.html",
} );
return;
// Add CSP headers to allow API connections }
mainWindow.webContents.session.webRequest.onHeadersReceived(
(details, callback) => { const exists = fs.existsSync(indexPath);
callback({ logger.info(`[Electron] fs.existsSync for index.html: ${exists}`);
responseHeaders: {
...details.responseHeaders, if (!exists) {
"Content-Security-Policy": [ throw new Error(`index.html not found at path: ${indexPath}`);
"default-src 'self';" + }
"connect-src 'self' https://api.endorser.ch https://*.timesafari.app;" +
"img-src 'self' data: https: blob:;" + // Try to read the file to verify it's accessible
"script-src 'self' 'unsafe-inline' 'unsafe-eval';" + const stats = fs.statSync(indexPath);
"style-src 'self' 'unsafe-inline';" + logger.info("[Electron] index.html stats:", {
"font-src 'self' data:;", size: stats.size,
], mode: stats.mode,
}, uid: stats.uid,
gid: stats.gid,
}); });
},
);
// Load the index.html // Try loadURL first
mainWindow try {
.loadFile(indexPath) logger.info("[Electron] Attempting to load index.html via loadURL");
.then(() => { await mainWindow.loadURL(fileUrl);
logger.log("Successfully loaded index.html"); logger.info("[Electron] Successfully loaded index.html via loadURL");
if (isDev) { } catch (loadUrlError) {
mainWindow.webContents.openDevTools(); logger.warn(
logger.log("DevTools opened - running in dev mode"); "[Electron] loadURL failed, trying loadFile:",
loadUrlError,
);
// Fallback to loadFile
await mainWindow.loadFile(indexPath);
logger.info("[Electron] Successfully loaded index.html via loadFile");
} }
}) } catch (error: unknown) {
.catch((err) => { const errorMessage =
logger.error("Failed to load index.html:", err); error instanceof Error ? error.message : "Unknown error occurred";
logger.error("Attempted path:", indexPath); logger.error("[Electron] Error loading index.html:", errorMessage);
});
// Listen for console messages from the renderer // Retry logic
mainWindow.webContents.on("console-message", (_event, _level, message) => { if (retryCount < 3 && !mainWindow.isDestroyed()) {
logger.log("Renderer Console:", message); logger.info(
}); `[Electron] Retrying index.html load (attempt ${retryCount + 1})`,
);
await new Promise((resolve) => setTimeout(resolve, 1000)); // Wait 1 second
return loadIndexHtml(retryCount + 1);
}
// Add right after creating the BrowserWindow // If we've exhausted retries, show error in window
mainWindow.webContents.on( if (!mainWindow.isDestroyed()) {
"did-fail-load", const errorHtml = `
(_event, errorCode, errorDescription) => { <html>
logger.error("Page failed to load:", errorCode, errorDescription); <body style="font-family: sans-serif; padding: 20px;">
}, <h1>Error Loading Application</h1>
); <p>Failed to load the application after multiple attempts.</p>
<pre style="background: #f0f0f0; padding: 10px; border-radius: 4px;">${errorMessage}</pre>
</body>
</html>
`;
await mainWindow.loadURL(
`data:text/html,${encodeURIComponent(errorHtml)}`,
);
}
}
};
mainWindow.webContents.on("preload-error", (_event, preloadPath, error) => { // Start loading the index.html
logger.error("Preload script error:", preloadPath, error); loadIndexHtml().catch((error: unknown) => {
logger.error("[Electron] Fatal error loading index.html:", error);
}); });
mainWindow.webContents.on( // Only open DevTools if not in production
"console-message", if (!app.isPackaged) {
(_event, _level, message, line, sourceId) => { mainWindow.webContents.openDevTools({ mode: "detach" });
logger.log("Renderer Console:", line, sourceId, message);
},
);
// Enable remote debugging when in dev mode
if (isDev) {
mainWindow.webContents.openDevTools();
} }
return mainWindow;
} }
// Handle app ready // Handle app ready
@ -213,3 +567,126 @@ app.on("activate", () => {
process.on("uncaughtException", (error) => { process.on("uncaughtException", (error) => {
logger.error("Uncaught Exception:", error); logger.error("Uncaught Exception:", error);
}); });
// Set up IPC handlers for SQLite operations
ipcMain.handle("check-sqlite-availability", () => {
return sqlitePlugin !== null;
});
ipcMain.handle("sqlite-echo", async (_event, value) => {
try {
return await sqlitePlugin.echo({ value });
} catch (error) {
logger.error(
"Error in sqlite-echo:",
error,
JSON.stringify(error),
(error as any)?.stack,
);
throw error;
}
});
ipcMain.handle("sqlite-create-connection", async (_event, options) => {
try {
// Ensure database is initialized
await initializeSQLite();
if (!dbPath) {
throw new Error("Database path not initialized");
}
// Override any provided database path with our resolved path
const connectionOptions = {
...options,
database: dbPath,
readOnly: false,
mode: "rwc", // Force read-write-create mode
encryption: "no-encryption",
useNative: true,
};
logger.info(
"[Electron] Creating database connection with options:",
connectionOptions,
);
const result = await sqlitePlugin.createConnection(connectionOptions);
if (!result || typeof result !== "object") {
throw new Error(
"Failed to create database connection - invalid response",
);
}
// Wait a moment for the connection to be fully established
await new Promise((resolve) => setTimeout(resolve, 100));
try {
// Verify connection is not read-only
const testResult = await result.query({
statement: "PRAGMA journal_mode;",
});
if (testResult?.values?.[0]?.journal_mode === "off") {
logger.error(
"[Electron] Connection opened in read-only mode despite options",
);
throw new Error("Database connection opened in read-only mode");
}
} catch (queryError) {
logger.error("[Electron] Error verifying connection:", queryError);
throw queryError;
}
logger.info("[Electron] Database connection created successfully");
return result;
} catch (error) {
logger.error("[Electron] Error in sqlite-create-connection:", error);
throw error;
}
});
ipcMain.handle("sqlite-execute", async (_event, options) => {
try {
return await sqlitePlugin.execute(options);
} catch (error) {
logger.error(
"Error in sqlite-execute:",
error,
JSON.stringify(error),
(error as any)?.stack,
);
throw error;
}
});
ipcMain.handle("sqlite-query", async (_event, options) => {
try {
return await sqlitePlugin.query(options);
} catch (error) {
logger.error(
"Error in sqlite-query:",
error,
JSON.stringify(error),
(error as any)?.stack,
);
throw error;
}
});
ipcMain.handle("sqlite-close-connection", async (_event, options) => {
try {
return await sqlitePlugin.closeConnection(options);
} catch (error) {
logger.error(
"Error in sqlite-close-connection:",
error,
JSON.stringify(error),
(error as any)?.stack,
);
throw error;
}
});
ipcMain.handle("sqlite-is-available", async () => {
return sqlitePlugin !== null;
});

86
src/electron/preload.js

@ -85,6 +85,92 @@ try {
}, },
}); });
// Create a proxy for the CapacitorSQLite plugin
const createSQLiteProxy = () => {
const MAX_RETRIES = 3;
const RETRY_DELAY = 1000; // 1 second
const withRetry = async (operation, ...args) => {
let lastError;
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
try {
return await operation(...args);
} catch (error) {
lastError = error;
if (attempt < MAX_RETRIES) {
logger.warn(
`SQLite operation failed (attempt ${attempt}/${MAX_RETRIES}), retrying...`,
error,
);
await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY));
}
}
}
throw new Error(
`SQLite operation failed after ${MAX_RETRIES} attempts: ${lastError?.message || "Unknown error"}`,
);
};
const wrapOperation = (method) => {
return async (...args) => {
try {
return await withRetry(
ipcRenderer.invoke,
"sqlite-" + method,
...args,
);
} catch (error) {
logger.error(`SQLite ${method} failed:`, error);
throw new Error(
`Database operation failed: ${error.message || "Unknown error"}`,
);
}
};
};
// Create a proxy that matches the CapacitorSQLite interface
return {
echo: wrapOperation("echo"),
createConnection: wrapOperation("create-connection"),
closeConnection: wrapOperation("close-connection"),
execute: wrapOperation("execute"),
query: wrapOperation("query"),
run: wrapOperation("run"),
isAvailable: wrapOperation("is-available"),
getPlatform: () => Promise.resolve("electron"),
// Add other methods as needed
};
};
// Expose only the CapacitorSQLite proxy
contextBridge.exposeInMainWorld("CapacitorSQLite", createSQLiteProxy());
// Remove the duplicate electron.sqlite bridge
contextBridge.exposeInMainWorld("electron", {
// Keep other electron APIs but remove sqlite
getPath,
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));
}
},
env: {
isElectron: true,
isDev: process.env.NODE_ENV === "development",
platform: "electron",
},
getBasePath: () => {
return process.env.NODE_ENV === "development" ? "/" : "./";
},
});
logger.info("Preload script completed successfully"); logger.info("Preload script completed successfully");
} catch (error) { } catch (error) {
logger.error("Error in preload script:", error); logger.error("Error in preload script:", error);

108
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 { import { ClaimObject } from "./common";
"@context": string;
export interface AgreeActionClaim extends ClaimObject {
"@context": "https://schema.org";
"@type": string; "@type": string;
object: Record<string, unknown>; object: Record<string, unknown>;
} }
// Note that previous VCs may have additional fields. // Note that previous VCs may have additional fields.
// https://endorser.ch/doc/html/transactions.html#id4 // https://endorser.ch/doc/html/transactions.html#id4
export interface GiveVerifiableCredential extends GenericVerifiableCredential { export interface GiveActionClaim extends ClaimObject {
// context is optional because it might be embedded in another claim, eg. an AgreeAction
"@context"?: "https://schema.org";
"@type": "GiveAction"; "@type": "GiveAction";
agent?: { identifier: string }; agent?: { identifier: string };
description?: string; description?: string;
@ -16,43 +26,21 @@ export interface GiveVerifiableCredential extends GenericVerifiableCredential {
identifier?: string; identifier?: string;
image?: string; image?: string;
object?: { amountOfThisGood: number; unitCode: string }; object?: { amountOfThisGood: number; unitCode: string };
provider?: GenericVerifiableCredential; provider?: ClaimObject;
recipient?: { identifier: string }; recipient?: { identifier: string };
type: string[]; }
issuer: string;
issuanceDate: string; export interface JoinActionClaim extends ClaimObject {
credentialSubject: { agent?: { identifier: string };
id: string; event?: { organizer?: { name: string }; name?: string; startTime?: string };
type: "GiveAction";
offeredBy?: {
type: "Person";
identifier: string;
};
offeredTo?: {
type: "Person";
identifier: string;
};
offeredToProject?: {
type: "Project";
identifier: string;
};
offeredToProjectVisibleToDids?: string[];
offeredToVisibleToDids?: string[];
offeredByVisibleToDids?: string[];
amount: {
type: "QuantitativeValue";
value: number;
unitCode: string;
};
startTime?: string;
endTime?: string;
};
} }
// Note that previous VCs may have additional fields. // Note that previous VCs may have additional fields.
// https://endorser.ch/doc/html/transactions.html#id8 // https://endorser.ch/doc/html/transactions.html#id8
export interface OfferVerifiableCredential extends GenericVerifiableCredential { export interface OfferClaim extends ClaimObject {
"@context": "https://schema.org";
"@type": "Offer"; "@type": "Offer";
agent?: { identifier: string };
description?: string; description?: string;
fulfills?: { "@type": string; identifier?: string; lastClaimId?: string }[]; fulfills?: { "@type": string; identifier?: string; lastClaimId?: string }[];
identifier?: string; identifier?: string;
@ -67,43 +55,18 @@ export interface OfferVerifiableCredential extends GenericVerifiableCredential {
name?: string; name?: string;
}; };
}; };
provider?: GenericVerifiableCredential; offeredBy?: {
type?: "Person";
identifier: string;
};
provider?: ClaimObject;
recipient?: { identifier: string }; recipient?: { identifier: string };
validThrough?: string; validThrough?: string;
type: string[];
issuer: string;
issuanceDate: string;
credentialSubject: {
id: string;
type: "Offer";
offeredBy?: {
type: "Person";
identifier: string;
};
offeredTo?: {
type: "Person";
identifier: string;
};
offeredToProject?: {
type: "Project";
identifier: string;
};
offeredToProjectVisibleToDids?: string[];
offeredToVisibleToDids?: string[];
offeredByVisibleToDids?: string[];
amount: {
type: "QuantitativeValue";
value: number;
unitCode: string;
};
startTime?: string;
endTime?: string;
};
} }
// Note that previous VCs may have additional fields. // Note that previous VCs may have additional fields.
// https://endorser.ch/doc/html/transactions.html#id7 // https://endorser.ch/doc/html/transactions.html#id7
export interface PlanVerifiableCredential extends GenericVerifiableCredential { export interface PlanActionClaim extends ClaimObject {
"@context": "https://schema.org"; "@context": "https://schema.org";
"@type": "PlanAction"; "@type": "PlanAction";
name: string; name: string;
@ -117,11 +80,18 @@ export interface PlanVerifiableCredential extends GenericVerifiableCredential {
} }
// AKA Registration & RegisterAction // AKA Registration & RegisterAction
export interface RegisterVerifiableCredential { export interface RegisterActionClaim extends ClaimObject {
"@context": string; "@context": "https://schema.org";
"@type": "RegisterAction"; "@type": "RegisterAction";
agent: { identifier: string }; agent: { identifier: string };
identifier?: string; identifier?: string;
object: string; object?: string;
participant?: { identifier: 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 // similar to VerifiableCredentialSubject... maybe rename this
export interface GenericVerifiableCredential { export interface GenericVerifiableCredential {
"@context": string | string[]; "@context"?: string;
"@type": string; "@type": string;
[key: string]: unknown; [key: string]: unknown;
} }
@ -37,23 +37,26 @@ export interface ErrorResult extends ResultWithType {
export interface KeyMeta { export interface KeyMeta {
did: string; did: string;
name?: string;
publicKeyHex: string; publicKeyHex: string;
mnemonic: string; derivationPath?: string;
derivationPath: string;
registered?: boolean;
profileImageUrl?: string;
identity?: string; // Stringified IIdentifier object from Veramo
passkeyCredIdHex?: string; // The Webauthn credential ID in hex, if this is from a passkey 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 { export interface QuantitativeValue extends GenericVerifiableCredential {
"@type": "QuantitativeValue"; "@type": "QuantitativeValue";
"@context": string | string[]; "@context"?: string;
amountOfThisGood: number; amountOfThisGood: number;
unitCode: string; unitCode: string;
[key: string]: unknown;
} }
export interface AxiosErrorResponse { export interface AxiosErrorResponse {
@ -87,94 +90,21 @@ export interface CreateAndSubmitClaimResult {
handleId?: string; handleId?: string;
} }
export interface PlanSummaryRecord {
handleId: string;
issuer: string;
claim: GenericVerifiableCredential;
[key: string]: unknown;
}
export interface Agent { export interface Agent {
identifier?: string; identifier?: string;
did?: string; did?: string;
[key: string]: unknown;
} }
export interface ClaimObject { export interface ClaimObject {
"@type": string; "@type": string;
"@context"?: string | string[]; "@context"?: string;
fulfills?: Array<{
"@type": string;
identifier?: string;
[key: string]: unknown;
}>;
object?: GenericVerifiableCredential;
agent?: Agent;
participant?: {
identifier?: string;
[key: string]: unknown;
};
identifier?: string;
[key: string]: unknown; [key: string]: unknown;
} }
export interface VerifiableCredentialClaim { export interface VerifiableCredentialClaim {
"@context": string | string[]; "@context"?: string;
"@type": string; "@type": string;
type: string[]; type: string[];
credentialSubject: ClaimObject; credentialSubject: ClaimObject;
[key: string]: unknown; [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;
}

6
src/interfaces/index.ts

@ -13,9 +13,9 @@ export type {
export type { export type {
// From claims.ts // From claims.ts
GiveVerifiableCredential, GiveActionClaim,
OfferVerifiableCredential, OfferClaim,
RegisterVerifiableCredential, RegisterActionClaim,
} from "./claims"; } from "./claims";
export type { 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 // a summary record; the VC is found the fullClaim field
export interface GiveSummaryRecord { export interface GiveSummaryRecord {
[x: string]: PropertyKey | undefined | GiveVerifiableCredential; [x: string]: PropertyKey | undefined | GiveActionClaim;
type?: string; type?: string;
agentDid: string; agentDid: string;
amount: number; amount: number;
amountConfirmed: number; amountConfirmed: number;
description: string; description: string;
fullClaim: GiveVerifiableCredential; fullClaim: GiveActionClaim;
fulfillsHandleId: string; fulfillsHandleId: string;
fulfillsPlanHandleId?: string; fulfillsPlanHandleId?: string;
fulfillsType?: string; fulfillsType?: string;
@ -26,7 +26,7 @@ export interface OfferSummaryRecord {
amount: number; amount: number;
amountGiven: number; amountGiven: number;
amountGivenConfirmed: number; amountGivenConfirmed: number;
fullClaim: OfferVerifiableCredential; fullClaim: OfferClaim;
fulfillsPlanHandleId: string; fulfillsPlanHandleId: string;
handleId: string; handleId: string;
issuerDid: 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 { PEER_DID_PREFIX, verifyPeerSignature } from "./didPeer";
import { base64urlDecodeString, createDidPeerJwt } from "./passkeyDidPeer"; import { base64urlDecodeString, createDidPeerJwt } from "./passkeyDidPeer";
import { urlBase64ToUint8Array } from "./util"; import { urlBase64ToUint8Array } from "./util";
import { KeyMeta } from "../../../interfaces/common"; import { KeyMeta, KeyMetaWithPrivate } from "../../../interfaces/common";
export const ETHR_DID_PREFIX = "did:ethr:"; export const ETHR_DID_PREFIX = "did:ethr:";
export const JWT_VERIFY_FAILED_CODE = "JWT_VERIFY_FAILED"; export const JWT_VERIFY_FAILED_CODE = "JWT_VERIFY_FAILED";
@ -34,7 +34,7 @@ export function isFromPasskey(keyMeta?: KeyMeta): boolean {
} }
export async function createEndorserJwtForKey( export async function createEndorserJwtForKey(
account: KeyMeta, account: KeyMetaWithPrivate,
payload: object, payload: object,
expiresIn?: number, expiresIn?: number,
) { ) {

253
src/libs/endorserServer.ts

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

107
src/libs/util.ts

@ -34,10 +34,10 @@ import { containsHiddenDid } from "../libs/endorserServer";
import { import {
GenericCredWrapper, GenericCredWrapper,
GenericVerifiableCredential, GenericVerifiableCredential,
KeyMetaWithPrivate,
} from "../interfaces/common"; } from "../interfaces/common";
import { GiveSummaryRecord } from "../interfaces/records"; import { GiveSummaryRecord } from "../interfaces/records";
import { OfferVerifiableCredential } from "../interfaces/claims"; import { OfferClaim } from "../interfaces/claims";
import { KeyMeta } from "../interfaces/common";
import { createPeerDid } from "../libs/crypto/vc/didPeer"; import { createPeerDid } from "../libs/crypto/vc/didPeer";
import { registerCredential } from "../libs/crypto/vc/passkeyDidPeer"; import { registerCredential } from "../libs/crypto/vc/passkeyDidPeer";
import { logger } from "../utils/logger"; import { logger } from "../utils/logger";
@ -378,17 +378,19 @@ export function base64ToBlob(base64DataUrl: string, sliceSize = 512) {
* @param veriClaim is expected to have fields: claim and issuer * @param veriClaim is expected to have fields: claim and issuer
*/ */
export function offerGiverDid( export function offerGiverDid(
veriClaim: GenericCredWrapper<GenericVerifiableCredential>, veriClaim: GenericCredWrapper<OfferClaim>,
): string | undefined { ): string | undefined {
let giver; const innerClaim = veriClaim.claim as OfferClaim;
const claim = veriClaim.claim as OfferVerifiableCredential; let giver: string | undefined = undefined;
if (
claim.credentialSubject.offeredBy?.identifier && giver = innerClaim.offeredBy?.identifier;
!serverUtil.isHiddenDid(claim.credentialSubject.offeredBy.identifier) if (giver && !serverUtil.isHiddenDid(giver)) {
) { return giver;
giver = claim.credentialSubject.offeredBy.identifier; }
} else if (veriClaim.issuer && !serverUtil.isHiddenDid(veriClaim.issuer)) {
giver = veriClaim.issuer; giver = veriClaim.issuer;
if (giver && !serverUtil.isHiddenDid(giver)) {
return giver;
} }
return giver; return giver;
} }
@ -400,7 +402,10 @@ export function offerGiverDid(
export const canFulfillOffer = ( export const canFulfillOffer = (
veriClaim: GenericCredWrapper<GenericVerifiableCredential>, 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" // return object with paths and arrays of DIDs for any keys ending in "VisibleToDid"
@ -469,11 +474,7 @@ export function findAllVisibleToDids(
* *
**/ **/
export interface AccountKeyInfo export type AccountKeyInfo = Account & KeyMetaWithPrivate;
extends Omit<Account, "derivationPath">,
Omit<KeyMeta, "derivationPath"> {
derivationPath?: string; // Make it optional to match Account type
}
export const retrieveAccountCount = async (): Promise<number> => { export const retrieveAccountCount = async (): Promise<number> => {
let result = 0; let result = 0;
@ -510,12 +511,16 @@ export const retrieveAccountDids = async (): Promise<string[]> => {
return allDids; 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 ( export const retrieveAccountMetadata = async (
activeDid: string, activeDid: string,
): Promise<AccountKeyInfo | undefined> => { ): Promise<Account | undefined> => {
let result: AccountKeyInfo | undefined = undefined; let result: Account | undefined = undefined;
const platformService = PlatformServiceFactory.getInstance(); const platformService = PlatformServiceFactory.getInstance();
const dbAccount = await platformService.dbQuery( const dbAccount = await platformService.dbQuery(
`SELECT * FROM accounts WHERE did = ?`, `SELECT * FROM accounts WHERE did = ?`,
@ -547,32 +552,16 @@ export const retrieveAccountMetadata = async (
return result; return result;
}; };
export const retrieveAllAccountsMetadata = async (): Promise<Account[]> => { /**
const platformService = PlatformServiceFactory.getInstance(); * This contains sensitive data. If possible, use retrieveAccountMetadata instead.
const dbAccounts = await platformService.dbQuery(`SELECT * FROM accounts`); *
const accounts = databaseUtil.mapQueryResultToValues(dbAccounts) as Account[]; * @param activeDid
let result = accounts.map((account) => { * @returns account info with private key data decrypted
// 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;
};
export const retrieveFullyDecryptedAccount = async ( export const retrieveFullyDecryptedAccount = async (
activeDid: string, activeDid: string,
): Promise<AccountKeyInfo | undefined> => { ): Promise<Account | undefined> => {
let result: AccountKeyInfo | undefined = undefined; let result: Account | undefined = undefined;
const platformService = PlatformServiceFactory.getInstance(); const platformService = PlatformServiceFactory.getInstance();
const dbSecrets = await platformService.dbQuery( const dbSecrets = await platformService.dbQuery(
`SELECT secretBase64 from secret`, `SELECT secretBase64 from secret`,
@ -620,20 +609,26 @@ export const retrieveFullyDecryptedAccount = async (
return result; return result;
}; };
// let's try and eliminate this export const retrieveAllAccountsMetadata = async (): Promise<Account[]> => {
export const retrieveAllFullyDecryptedAccounts = async (): Promise<
Array<AccountEncrypted>
> => {
const platformService = PlatformServiceFactory.getInstance(); const platformService = PlatformServiceFactory.getInstance();
const queryResult = await platformService.dbQuery("SELECT * FROM accounts"); const dbAccounts = await platformService.dbQuery(`SELECT * FROM accounts`);
let allAccounts = databaseUtil.mapQueryResultToValues( const accounts = databaseUtil.mapQueryResultToValues(dbAccounts) as Account[];
queryResult, let result = accounts.map((account) => {
) as unknown as AccountEncrypted[]; // eslint-disable-next-line @typescript-eslint/no-unused-vars
const { identity, mnemonic, ...metadata } = account;
return metadata as Account;
});
if (USE_DEXIE_DB) { if (USE_DEXIE_DB) {
// one of the few times we use accountsDBPromise directly; try to avoid more usage
const accountsDB = await accountsDBPromise; 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;
}; };
/** /**

1
src/main.common.ts

@ -2,6 +2,7 @@ import { createPinia } from "pinia";
import { App as VueApp, ComponentPublicInstance, createApp } from "vue"; import { App as VueApp, ComponentPublicInstance, createApp } from "vue";
import App from "./App.vue"; import App from "./App.vue";
import router from "./router"; import router from "./router";
// Use the browser version of axios for web builds
import axios from "axios"; import axios from "axios";
import VueAxios from "vue-axios"; import VueAxios from "vue-axios";
import Notifications from "notiwind"; import Notifications from "notiwind";

61
src/main.electron.ts

@ -1,6 +1,45 @@
import { initializeApp } from "./main.common"; import { initializeApp } from "./main.common";
import { logger } from "./utils/logger"; import { logger } from "./utils/logger";
async function initializeSQLite() {
try {
// Wait for SQLite to be available in the main process
let retries = 0;
const maxRetries = 5;
const retryDelay = 1000; // 1 second
while (retries < maxRetries) {
try {
const isAvailable = await window.CapacitorSQLite.isAvailable();
if (isAvailable) {
logger.info(
"[Electron] SQLite plugin bridge initialized successfully",
);
return true;
}
} catch (error) {
logger.warn(
`[Electron] SQLite not available yet (attempt ${retries + 1}/${maxRetries}):`,
error,
);
}
retries++;
if (retries < maxRetries) {
await new Promise((resolve) => setTimeout(resolve, retryDelay));
}
}
throw new Error("SQLite plugin not available after maximum retries");
} catch (error) {
logger.error(
"[Electron] Failed to initialize SQLite plugin bridge:",
error,
);
throw error;
}
}
const platform = process.env.VITE_PLATFORM; const platform = process.env.VITE_PLATFORM;
const pwa_enabled = process.env.VITE_PWA_ENABLED === "true"; const pwa_enabled = process.env.VITE_PWA_ENABLED === "true";
@ -12,5 +51,25 @@ if (pwa_enabled) {
logger.warn("[Electron] PWA is enabled, but not supported in electron"); logger.warn("[Electron] PWA is enabled, but not supported in electron");
} }
// Initialize app and SQLite
const app = initializeApp(); const app = initializeApp();
app.mount("#app");
// Initialize SQLite first, then mount the app
initializeSQLite()
.then(() => {
logger.info("[Electron] SQLite initialized, mounting app...");
app.mount("#app");
})
.catch((error) => {
logger.error("[Electron] Failed to initialize app:", error);
// Show error to user
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%;";
errorDiv.innerHTML = `
<h2>Failed to Initialize Database</h2>
<p>There was an error initializing the database. Please try restarting the application.</p>
<p>Error details: ${error.message}</p>
`;
document.body.appendChild(errorDiv);
});

2
src/services/AbsurdSqlDatabaseService.ts

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

116
src/services/database/ConnectionPool.ts

@ -0,0 +1,116 @@
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();
}
}

227
src/services/platforms/ElectronPlatformService.ts

@ -4,14 +4,10 @@ import {
PlatformCapabilities, PlatformCapabilities,
} from "../PlatformService"; } from "../PlatformService";
import { logger } from "../../utils/logger"; import { logger } from "../../utils/logger";
import { QueryExecResult, SqlValue } from "@/interfaces/database"; import { QueryExecResult } from "@/interfaces/database";
import { import { SQLiteDBConnection } from "@capacitor-community/sqlite";
SQLiteConnection,
SQLiteDBConnection,
CapacitorSQLite,
Changes,
} from "@capacitor-community/sqlite";
import { DEFAULT_ENDORSER_API_SERVER } from "@/constants/app"; import { DEFAULT_ENDORSER_API_SERVER } from "@/constants/app";
import { DatabaseConnectionPool } from "../database/ConnectionPool";
interface Migration { interface Migration {
name: string; name: string;
@ -27,53 +23,109 @@ interface Migration {
* - System-level features (TODO) * - System-level features (TODO)
*/ */
export class ElectronPlatformService implements PlatformService { export class ElectronPlatformService implements PlatformService {
private sqlite: SQLiteConnection; private sqlite: any;
private db: SQLiteDBConnection | null = null; private connection: SQLiteDBConnection | null = null;
private dbName = "timesafari.db"; private connectionPool: DatabaseConnectionPool;
private initialized = false; private initializationPromise: Promise<void> | null = null;
private dbFatalError = false;
constructor() { constructor() {
this.sqlite = new SQLiteConnection(CapacitorSQLite); this.connectionPool = DatabaseConnectionPool.getInstance();
if (!window.CapacitorSQLite) {
throw new Error("CapacitorSQLite not initialized in Electron");
}
this.sqlite = window.CapacitorSQLite;
} }
private async initializeDatabase(): Promise<void> { private async initializeDatabase(): Promise<void> {
if (this.initialized) { // If we already have a connection, return immediately
if (this.connection) {
return; return;
} }
try { // If initialization is in progress, wait for it
// Create/Open database if (this.initializationPromise) {
this.db = await this.sqlite.createConnection( return this.initializationPromise;
this.dbName, }
false,
"no-encryption",
1,
false,
);
await this.db.open();
// Set journal mode to WAL for better performance
await this.db.execute("PRAGMA journal_mode=WAL;");
// Run migrations // Start initialization
await this.runMigrations(); this.initializationPromise = (async () => {
try {
if (!this.sqlite) {
logger.debug("[ElectronPlatformService] SQLite plugin not available, checking...");
this.sqlite = await import("@capacitor-community/sqlite");
}
if (!this.sqlite) {
throw new Error("SQLite plugin not available");
}
// Get connection from pool
this.connection = await this.connectionPool.getConnection("timesafari", async () => {
// Create the connection
const connection = await this.sqlite.createConnection({
database: "timesafari",
encrypted: false,
mode: "no-encryption",
readonly: false,
});
// Wait for the connection to be fully initialized
await new Promise<void>((resolve, reject) => {
const checkConnection = async () => {
try {
// Try a simple query to verify the connection is ready
const result = await connection.query("SELECT 1");
if (result && result.values) {
resolve();
} else {
reject(new Error("Connection query returned invalid result"));
}
} catch (error) {
// If the error is that query is not a function, the connection isn't ready yet
if (error instanceof Error && error.message.includes("query is not a function")) {
setTimeout(checkConnection, 100);
} else {
reject(error);
}
}
};
checkConnection();
});
// Verify write access
const result = await connection.query("PRAGMA journal_mode");
const journalMode = result.values?.[0]?.journal_mode;
if (journalMode !== "wal") {
throw new Error(`Database is not writable. Journal mode: ${journalMode}`);
}
return connection;
});
// Run migrations if needed
await this.runMigrations();
logger.info("[ElectronPlatformService] Database initialized successfully");
} catch (error) {
logger.error("[ElectronPlatformService] Database initialization failed:", error);
this.connection = null;
throw error;
} finally {
this.initializationPromise = null;
}
})();
this.initialized = true; return this.initializationPromise;
logger.log("SQLite database initialized successfully");
} catch (error) {
logger.error("Error initializing SQLite database:", error);
throw new Error("Failed to initialize database");
}
} }
private async runMigrations(): Promise<void> { private async runMigrations(): Promise<void> {
if (!this.db) { if (!this.connection) {
throw new Error("Database not initialized"); throw new Error("Database not initialized");
} }
// Create migrations table if it doesn't exist // Create migrations table if it doesn't exist
await this.db.execute(` await this.connection.execute(`
CREATE TABLE IF NOT EXISTS migrations ( CREATE TABLE IF NOT EXISTS migrations (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE, name TEXT NOT NULL UNIQUE,
@ -82,7 +134,7 @@ export class ElectronPlatformService implements PlatformService {
`); `);
// Get list of executed migrations // Get list of executed migrations
const result = await this.db.query("SELECT name FROM migrations;"); const result = await this.connection.query("SELECT name FROM migrations;");
const executedMigrations = new Set( const executedMigrations = new Set(
result.values?.map((row) => row[0]) || [], result.values?.map((row) => row[0]) || [],
); );
@ -177,8 +229,8 @@ export class ElectronPlatformService implements PlatformService {
for (const migration of migrations) { for (const migration of migrations) {
if (!executedMigrations.has(migration.name)) { if (!executedMigrations.has(migration.name)) {
await this.db.execute(migration.sql); await this.connection.execute(migration.sql);
await this.db.run("INSERT INTO migrations (name) VALUES (?)", [ await this.connection.run("INSERT INTO migrations (name) VALUES (?)", [
migration.name, migration.name,
]); ]);
logger.log(`Migration ${migration.name} executed successfully`); logger.log(`Migration ${migration.name} executed successfully`);
@ -291,25 +343,24 @@ export class ElectronPlatformService implements PlatformService {
/** /**
* @see PlatformService.dbQuery * @see PlatformService.dbQuery
*/ */
async dbQuery(sql: string, params?: unknown[]): Promise<QueryExecResult> { async dbQuery(
sql: string,
params?: unknown[],
): Promise<QueryExecResult | undefined> {
if (this.dbFatalError) {
throw new Error("Database is in a fatal error state. Please restart the app.");
}
await this.initializeDatabase(); await this.initializeDatabase();
if (!this.db) { if (!this.connection) {
throw new Error("Database not initialized"); throw new Error("Database not initialized");
} }
try { const result = await this.connection.query(sql, params);
const result = await this.db.query(sql, params || []); return {
const values = result.values || []; columns: [], // SQLite plugin doesn't provide column names
return { values: result.values || [],
columns: [], // SQLite plugin doesn't provide column names in query result };
values: values as SqlValue[][],
};
} catch (error) {
logger.error("Error executing query:", error);
throw new Error(
`Database query failed: ${error instanceof Error ? error.message : String(error)}`,
);
}
} }
/** /**
@ -319,23 +370,67 @@ export class ElectronPlatformService implements PlatformService {
sql: string, sql: string,
params?: unknown[], params?: unknown[],
): Promise<{ changes: number; lastId?: number }> { ): Promise<{ changes: number; lastId?: number }> {
if (this.dbFatalError) {
throw new Error("Database is in a fatal error state. Please restart the app.");
}
await this.initializeDatabase();
if (!this.connection) {
throw new Error("Database not initialized");
}
const result = await this.connection.run(sql, params);
return {
changes: result.changes?.changes || 0,
lastId: result.changes?.lastId,
};
}
async initialize(): Promise<void> {
if (this.dbFatalError) {
throw new Error("Database is in a fatal error state. Please restart the app.");
}
await this.initializeDatabase(); await this.initializeDatabase();
if (!this.db) { }
async query<T>(sql: string, params: any[] = []): Promise<T[]> {
if (this.dbFatalError) {
throw new Error("Database is in a fatal error state. Please restart the app.");
}
await this.initializeDatabase();
if (!this.connection) {
throw new Error("Database not initialized"); throw new Error("Database not initialized");
} }
const result = await this.connection.query(sql, params);
return (result.values || []) as T[];
}
async execute(sql: string, params: any[] = []): Promise<void> {
if (this.dbFatalError) {
throw new Error("Database is in a fatal error state. Please restart the app.");
}
await this.initializeDatabase();
if (!this.connection) {
throw new Error("Database not initialized");
}
await this.connection.run(sql, params);
}
async close(): Promise<void> {
if (!this.connection) {
return;
}
try { try {
const result = await this.db.run(sql, params || []); await this.connectionPool.releaseConnection("timesafari");
const changes = result.changes as Changes; this.connection = null;
return {
changes: changes?.changes || 0,
lastId: changes?.lastId,
};
} catch (error) { } catch (error) {
logger.error("Error executing statement:", error); logger.error("Failed to close database:", error);
throw new Error( throw error;
`Database execution failed: ${error instanceof Error ? error.message : String(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>;
}
}

32
src/types/global.d.ts

@ -1,4 +1,36 @@
import type { QueryExecResult, SqlValue } from "./database"; 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: (method: string, ...args: unknown[]) => 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' { declare module '@jlongster/sql.js' {
interface SQL { interface SQL {

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

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

8
src/views/ClaimView.vue

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

4
src/views/ContactAmountsView.vue

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

20
src/views/ContactQRScanShowView.vue

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

8
src/views/DIDView.vue

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

8
src/views/GiftedDetailsView.vue

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

25
src/views/IdentitySwitcherView.vue

@ -115,6 +115,7 @@ import { MASTER_SETTINGS_KEY } from "../db/tables/settings";
import * as databaseUtil from "../db/databaseUtil"; import * as databaseUtil from "../db/databaseUtil";
import { retrieveAllAccountsMetadata } from "../libs/util"; import { retrieveAllAccountsMetadata } from "../libs/util";
import { logger } from "../utils/logger"; import { logger } from "../utils/logger";
import { PlatformServiceFactory } from "@/services/PlatformServiceFactory";
@Component({ components: { QuickNav } }) @Component({ components: { QuickNav } })
export default class IdentitySwitcherView extends Vue { export default class IdentitySwitcherView extends Vue {
@ -167,10 +168,13 @@ export default class IdentitySwitcherView extends Vue {
if (did === "0") { if (did === "0") {
did = undefined; did = undefined;
} }
await db.open(); await databaseUtil.updateDefaultSettings({ activeDid: did });
await db.settings.update(MASTER_SETTINGS_KEY, { if (USE_DEXIE_DB) {
activeDid: did, await db.open();
}); await db.settings.update(MASTER_SETTINGS_KEY, {
activeDid: did,
});
}
this.$router.push({ name: "account" }); this.$router.push({ name: "account" });
} }
@ -182,9 +186,16 @@ export default class IdentitySwitcherView extends Vue {
title: "Delete Identity?", 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.)", 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 () => { onYes: async () => {
// one of the few times we use accountsDBPromise directly; try to avoid more usage const platformService = PlatformServiceFactory.getInstance();
const accountsDB = await accountsDBPromise; await platformService.dbExec(
await accountsDB.accounts.delete(id); `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( this.otherIdentities = this.otherIdentities.filter(
(ident) => ident.id !== id, (ident) => ident.id !== id,
); );

2
src/views/InviteOneView.vue

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

10
src/views/OfferDetailsView.vue

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

12
src/views/ProjectViewView.vue

@ -613,9 +613,9 @@ import {
GenericVerifiableCredential, GenericVerifiableCredential,
GenericCredWrapper, GenericCredWrapper,
GiveSummaryRecord, GiveSummaryRecord,
GiveVerifiableCredential, GiveActionClaim,
OfferSummaryRecord, OfferSummaryRecord,
OfferVerifiableCredential, OfferClaim,
PlanSummaryRecord, PlanSummaryRecord,
} from "../interfaces"; } from "../interfaces";
import GiftedDialog from "../components/GiftedDialog.vue"; import GiftedDialog from "../components/GiftedDialog.vue";
@ -1269,7 +1269,7 @@ export default class ProjectViewView extends Vue {
} }
checkIsFulfillable(offer: OfferSummaryRecord) { checkIsFulfillable(offer: OfferSummaryRecord) {
const offerRecord: GenericCredWrapper<OfferVerifiableCredential> = { const offerRecord: GenericCredWrapper<OfferClaim> = {
...serverUtil.BLANK_GENERIC_SERVER_RECORD, ...serverUtil.BLANK_GENERIC_SERVER_RECORD,
claim: offer.fullClaim, claim: offer.fullClaim,
claimType: "Offer", claimType: "Offer",
@ -1279,13 +1279,13 @@ export default class ProjectViewView extends Vue {
} }
onClickFulfillGiveToOffer(offer: OfferSummaryRecord) { onClickFulfillGiveToOffer(offer: OfferSummaryRecord) {
const offerRecord: GenericCredWrapper<OfferVerifiableCredential> = { const offerClaimCred: GenericCredWrapper<OfferClaim> = {
...serverUtil.BLANK_GENERIC_SERVER_RECORD, ...serverUtil.BLANK_GENERIC_SERVER_RECORD,
claim: offer.fullClaim, claim: offer.fullClaim,
issuer: offer.offeredByDid, issuer: offer.offeredByDid,
}; };
const giver: libsUtil.GiverReceiverInputInfo = { const giver: libsUtil.GiverReceiverInputInfo = {
did: libsUtil.offerGiverDid(offerRecord), did: libsUtil.offerGiverDid(offerClaimCred),
}; };
(this.$refs.giveDialogToThis as GiftedDialog).open( (this.$refs.giveDialogToThis as GiftedDialog).open(
giver, 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 * @param confirmerIdList optional list of DIDs who confirmed; if missing, doesn't do a full server check
*/ */
checkIsConfirmable(give: GiveSummaryRecord, confirmerIdList?: string[]) { checkIsConfirmable(give: GiveSummaryRecord, confirmerIdList?: string[]) {
const giveDetails: GenericCredWrapper<GiveVerifiableCredential> = { const giveDetails: GenericCredWrapper<GiveActionClaim> = {
...serverUtil.BLANK_GENERIC_SERVER_RECORD, ...serverUtil.BLANK_GENERIC_SERVER_RECORD,
claim: give.fullClaim, claim: give.fullClaim,
claimType: "GiveAction", 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 { NotificationIface, APP_SERVER, USE_DEXIE_DB } from "../constants/app";
import * as databaseUtil from "../db/databaseUtil"; import * as databaseUtil from "../db/databaseUtil";
import { db, retrieveSettingsForActiveAccount } from "../db/index"; import { db, retrieveSettingsForActiveAccount } from "../db/index";
import { retrieveAccountMetadata } from "../libs/util"; import { retrieveFullyDecryptedAccount } from "../libs/util";
import { generateEndorserJwtUrlForAccount } from "../libs/endorserServer"; import { generateEndorserJwtUrlForAccount } from "../libs/endorserServer";
import { PlatformServiceFactory } from "@/services/PlatformServiceFactory"; import { PlatformServiceFactory } from "@/services/PlatformServiceFactory";
import { logger } from "../utils/logger"; import { logger } from "../utils/logger";
@ -75,7 +75,7 @@ export default class ShareMyContactInfoView extends Vue {
const isRegistered = !!settings.isRegistered; const isRegistered = !!settings.isRegistered;
const profileImageUrl = settings.profileImageUrl || ""; const profileImageUrl = settings.profileImageUrl || "";
const account = await retrieveAccountMetadata(activeDid); const account = await retrieveFullyDecryptedAccount(activeDid);
const platformService = PlatformServiceFactory.getInstance(); const platformService = PlatformServiceFactory.getInstance();
const contactQueryResult = await platformService.dbQuery( 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 // as user 1, go to the home page and check that two offers are shown as new
await switchToUser(page, user01Did); await switchToUser(page, user01Did);
await page.goto('./'); await page.goto('./');
// await page.getByTestId('closeOnboardingAndFinish').click();
let offerNumElem = page.getByTestId('newDirectOffersActivityNumber'); let offerNumElem = page.getByTestId('newDirectOffersActivityNumber');
await expect(offerNumElem).toHaveText('2'); await expect(offerNumElem).toHaveText('2');

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),
},
});
});

188
vite.config.electron.mts

@ -1,104 +1,102 @@
import { defineConfig, mergeConfig } from "vite"; import { defineConfig } from "vite";
import { createBuildConfig } from "./vite.config.common.mts";
import path from 'path'; import path from 'path';
export default defineConfig(async () => { export default defineConfig({
const baseConfig = await createBuildConfig('electron'); build: {
outDir: 'dist-electron',
return mergeConfig(baseConfig, { rollupOptions: {
build: { input: {
outDir: 'dist-electron', main: path.resolve(__dirname, 'src/electron/main.ts'),
rollupOptions: { preload: path.resolve(__dirname, 'src/electron/preload.js'),
input: {
main: path.resolve(__dirname, 'src/electron/main.ts'),
preload: path.resolve(__dirname, 'src/electron/preload.js'),
},
external: ['electron'],
output: {
format: 'cjs',
entryFileNames: '[name].js',
assetFileNames: 'assets/[name].[ext]',
},
}, },
target: 'node18', external: [
minify: false, // Node.js built-ins
sourcemap: true, 'stream',
}, 'path',
resolve: { 'fs',
alias: { 'crypto',
'@': path.resolve(__dirname, 'src'), '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: 'es',
entryFileNames: '[name].mjs',
assetFileNames: 'assets/[name].[ext]',
}, },
}, },
optimizeDeps: { target: 'node18',
include: ['@/utils/logger'] minify: false,
sourcemap: true,
},
resolve: {
alias: {
'@': path.resolve(__dirname, 'src'),
// Use Node.js version of axios in electron
'axios': 'axios/dist/node/axios.cjs'
}, },
plugins: [ },
{ optimizeDeps: {
name: 'typescript-transform', exclude: [
transform(code: string, id: string) { 'stream',
if (id.endsWith('main.ts')) { 'path',
// Replace the logger import with inline logger 'fs',
return code.replace( 'crypto',
/import\s*{\s*logger\s*}\s*from\s*['"]@\/utils\/logger['"];?/, 'buffer',
`const logger = { 'util',
log: (...args) => console.log(...args), 'events',
error: (...args) => console.error(...args), 'url',
info: (...args) => console.info(...args), 'assert',
warn: (...args) => console.warn(...args), 'os',
debug: (...args) => console.debug(...args), 'net',
};` 'http',
); 'https',
} 'zlib',
return code; 'child_process',
} 'axios',
}, 'axios/dist/axios',
{ 'axios/dist/node/axios.cjs'
name: 'remove-sw-imports', ]
transform(code: string, id: string) { },
// Remove service worker imports and registrations plugins: [
if (id.includes('registerServiceWorker') || {
id.includes('register-service-worker') || name: 'typescript-transform',
id.includes('sw_scripts') || transform(code: string, id: string) {
id.includes('PushNotificationPermission') || if (id.endsWith('main.ts')) {
code.includes('navigator.serviceWorker')) { return code.replace(
return { /import\s*{\s*logger\s*}\s*from\s*['"]@\/utils\/logger['"];?/,
code: code `const logger = {
.replace(/import.*registerServiceWorker.*$/mg, '') log: (...args) => console.log(...args),
.replace(/import.*register-service-worker.*$/mg, '') error: (...args) => console.error(...args),
.replace(/navigator\.serviceWorker/g, 'undefined') info: (...args) => console.info(...args),
.replace(/if\s*\([^)]*serviceWorker[^)]*\)\s*{[^}]*}/g, '') warn: (...args) => console.warn(...args),
.replace(/import.*workbox.*$/mg, '') debug: (...args) => console.debug(...args),
.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;
} }
return code;
} }
], }
ssr: { ],
noExternal: ['@/utils/logger'] base: './',
}, publicDir: 'public',
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