Compare commits
32 Commits
master
...
elec-tweak
Author | SHA1 | Date |
---|---|---|
|
59d711bd90 | 4 weeks ago |
|
c355de6e33 | 4 weeks ago |
|
28c114a2c7 | 4 weeks ago |
|
dabfe33fbe | 4 weeks ago |
|
d8f2587d1c | 4 weeks ago |
|
3946a8a27a | 4 weeks ago |
|
4c40b80718 | 4 weeks ago |
|
74989c2b64 | 4 weeks ago |
|
7e17b41444 | 4 weeks ago |
|
83acb028c7 | 4 weeks ago |
|
786f07e067 | 4 weeks ago |
|
710cc1683c | 4 weeks ago |
|
ebef5d6c8d | 4 weeks ago |
|
43ea7ee610 | 4 weeks ago |
|
57191df416 | 4 weeks ago |
|
644593a5f4 | 4 weeks ago |
|
900c2521c7 | 4 weeks ago |
|
182cff2b16 | 4 weeks ago |
|
3b4ef908f3 | 4 weeks ago |
|
a5a9e15ece | 4 weeks ago |
|
a6d8f0eb8a | 4 weeks ago |
|
3997a88b44 | 4 weeks ago |
|
5eeeae32c6 | 4 weeks ago |
|
d9895086e6 | 4 weeks ago |
|
fb8d1cb8b2 | 4 weeks ago |
|
70c0edbed0 | 4 weeks ago |
|
55cc08d675 | 4 weeks ago |
|
688a5be76e | 4 weeks ago |
|
014341f320 | 4 weeks ago |
|
1d5e062c76 | 1 month ago |
|
2c5c15108a | 1 month ago |
|
26df0fb671 | 1 month ago |
67 changed files with 9738 additions and 1030 deletions
@ -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,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/ |
After Width: | Height: | Size: 142 KiB |
After Width: | Height: | Size: 121 KiB |
After Width: | Height: | Size: 159 KiB |
After Width: | Height: | Size: 12 KiB |
@ -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" |
|||
] |
|||
} |
|||
} |
@ -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" |
|||
} |
|||
} |
@ -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(); |
|||
})(); |
File diff suppressed because it is too large
@ -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" |
|||
] |
|||
} |
@ -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; |
@ -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
|
@ -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'); |
|||
}); |
@ -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, |
|||
} |
@ -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, |
|||
}); |
|||
////////////////////////////////////////////////////////
|
@ -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'); |
@ -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'); |
|||
} |
@ -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 }; |
@ -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(' ') |
|||
}, |
|||
}); |
|||
}); |
|||
} |
@ -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 |
|||
} |
|||
} |
|||
|
@ -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 |
@ -1,5 +1,6 @@ |
|||
eth_keys |
|||
pywebview |
|||
pyinstaller>=6.12.0 |
|||
setuptools>=69.0.0 # Required for distutils for electron-builder on macOS |
|||
# For development |
|||
watchdog>=3.0.0 # For file watching support |
@ -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."); |
@ -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,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(); |
|||
} |
|||
} |
@ -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>; |
|||
} |
|||
} |
@ -0,0 +1 @@ |
|||
declare module '@jeepq/sqlite/loader'; |
@ -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), |
|||
}, |
|||
}); |
|||
}); |
@ -1,104 +1,102 @@ |
|||
import { defineConfig, mergeConfig } from "vite"; |
|||
import { createBuildConfig } from "./vite.config.common.mts"; |
|||
import { defineConfig } from "vite"; |
|||
import path from 'path'; |
|||
|
|||
export default defineConfig(async () => { |
|||
const baseConfig = await createBuildConfig('electron'); |
|||
|
|||
return mergeConfig(baseConfig, { |
|||
build: { |
|||
outDir: 'dist-electron', |
|||
rollupOptions: { |
|||
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]', |
|||
}, |
|||
export default defineConfig({ |
|||
build: { |
|||
outDir: 'dist-electron', |
|||
rollupOptions: { |
|||
input: { |
|||
main: path.resolve(__dirname, 'src/electron/main.ts'), |
|||
preload: path.resolve(__dirname, 'src/electron/preload.js'), |
|||
}, |
|||
target: 'node18', |
|||
minify: false, |
|||
sourcemap: true, |
|||
}, |
|||
resolve: { |
|||
alias: { |
|||
'@': path.resolve(__dirname, 'src'), |
|||
external: [ |
|||
// Node.js built-ins |
|||
'stream', |
|||
'path', |
|||
'fs', |
|||
'crypto', |
|||
'buffer', |
|||
'util', |
|||
'events', |
|||
'url', |
|||
'assert', |
|||
'os', |
|||
'net', |
|||
'http', |
|||
'https', |
|||
'zlib', |
|||
'child_process', |
|||
// Electron and Capacitor |
|||
'electron', |
|||
'@capacitor/core', |
|||
'@capacitor-community/sqlite', |
|||
'@capacitor-community/sqlite/electron', |
|||
'@capacitor-community/sqlite/electron/dist/plugin', |
|||
'better-sqlite3-multiple-ciphers', |
|||
// HTTP clients |
|||
'axios', |
|||
'axios/dist/axios', |
|||
'axios/dist/node/axios.cjs' |
|||
], |
|||
output: { |
|||
format: 'es', |
|||
entryFileNames: '[name].mjs', |
|||
assetFileNames: 'assets/[name].[ext]', |
|||
}, |
|||
}, |
|||
optimizeDeps: { |
|||
include: ['@/utils/logger'] |
|||
target: 'node18', |
|||
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: [ |
|||
{ |
|||
name: 'typescript-transform', |
|||
transform(code: string, id: string) { |
|||
if (id.endsWith('main.ts')) { |
|||
// Replace the logger import with inline logger |
|||
return code.replace( |
|||
/import\s*{\s*logger\s*}\s*from\s*['"]@\/utils\/logger['"];?/, |
|||
`const logger = { |
|||
log: (...args) => console.log(...args), |
|||
error: (...args) => console.error(...args), |
|||
info: (...args) => console.info(...args), |
|||
warn: (...args) => console.warn(...args), |
|||
debug: (...args) => console.debug(...args), |
|||
};` |
|||
); |
|||
} |
|||
return code; |
|||
} |
|||
}, |
|||
{ |
|||
name: 'remove-sw-imports', |
|||
transform(code: string, id: string) { |
|||
// Remove service worker imports and registrations |
|||
if (id.includes('registerServiceWorker') || |
|||
id.includes('register-service-worker') || |
|||
id.includes('sw_scripts') || |
|||
id.includes('PushNotificationPermission') || |
|||
code.includes('navigator.serviceWorker')) { |
|||
return { |
|||
code: code |
|||
.replace(/import.*registerServiceWorker.*$/mg, '') |
|||
.replace(/import.*register-service-worker.*$/mg, '') |
|||
.replace(/navigator\.serviceWorker/g, 'undefined') |
|||
.replace(/if\s*\([^)]*serviceWorker[^)]*\)\s*{[^}]*}/g, '') |
|||
.replace(/import.*workbox.*$/mg, '') |
|||
.replace(/importScripts\([^)]*\)/g, '') |
|||
}; |
|||
} |
|||
return code; |
|||
} |
|||
}, |
|||
{ |
|||
name: 'remove-sw-files', |
|||
enforce: 'pre', |
|||
resolveId(id: string) { |
|||
// Prevent service worker files from being included |
|||
if (id.includes('sw.js') || |
|||
id.includes('workbox') || |
|||
id.includes('registerSW.js') || |
|||
id.includes('manifest.webmanifest')) { |
|||
return '\0empty'; |
|||
} |
|||
return null; |
|||
}, |
|||
load(id: string) { |
|||
if (id === '\0empty') { |
|||
return 'export default {}'; |
|||
} |
|||
return null; |
|||
}, |
|||
optimizeDeps: { |
|||
exclude: [ |
|||
'stream', |
|||
'path', |
|||
'fs', |
|||
'crypto', |
|||
'buffer', |
|||
'util', |
|||
'events', |
|||
'url', |
|||
'assert', |
|||
'os', |
|||
'net', |
|||
'http', |
|||
'https', |
|||
'zlib', |
|||
'child_process', |
|||
'axios', |
|||
'axios/dist/axios', |
|||
'axios/dist/node/axios.cjs' |
|||
] |
|||
}, |
|||
plugins: [ |
|||
{ |
|||
name: 'typescript-transform', |
|||
transform(code: string, id: string) { |
|||
if (id.endsWith('main.ts')) { |
|||
return code.replace( |
|||
/import\s*{\s*logger\s*}\s*from\s*['"]@\/utils\/logger['"];?/, |
|||
`const logger = { |
|||
log: (...args) => console.log(...args), |
|||
error: (...args) => console.error(...args), |
|||
info: (...args) => console.info(...args), |
|||
warn: (...args) => console.warn(...args), |
|||
debug: (...args) => console.debug(...args), |
|||
};` |
|||
); |
|||
} |
|||
return code; |
|||
} |
|||
], |
|||
ssr: { |
|||
noExternal: ['@/utils/logger'] |
|||
}, |
|||
base: './', |
|||
publicDir: 'public', |
|||
}); |
|||
} |
|||
], |
|||
base: './', |
|||
publicDir: 'public', |
|||
}); |
@ -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…
Reference in new issue