Browse Source
- Implement XDG Base Directory Specification for data storage - Use $XDG_DATA_HOME (defaults to ~/.local/share) for data files - Add proper directory permissions (700) for security - Fallback to ~/.timesafari if XDG paths fail - Add graceful degradation for SQLite failures - Allow app to boot even if SQLite initialization fails - Track and expose initialization errors via IPC - Add availability checks to all SQLite operations - Improve error reporting and logging - Security improvements - Set secure permissions (700) on data directories - Verify directory permissions on existing paths - Add proper error handling for permission issues TODO: - Fix database creation - Add retry logic for initialization - Add reinitialization capability - Add more detailed error reporting - Consider fallback storage optionspull/134/head
20 changed files with 6498 additions and 30 deletions
@ -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,63 @@ |
|||
{ |
|||
"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", |
|||
"electronLinuxLocation": "~/.local/share/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,76 @@ |
|||
/** |
|||
* Preload script for Electron |
|||
* Sets up context bridge and handles security policies |
|||
* @author Matthew Raymer |
|||
*/ |
|||
|
|||
import { contextBridge, ipcRenderer } from 'electron'; |
|||
|
|||
// Enable source maps in development
|
|||
if (process.env.NODE_ENV === 'development') { |
|||
require('source-map-support').install(); |
|||
} |
|||
|
|||
// Log that preload is running
|
|||
console.log('[Preload] Script starting...'); |
|||
|
|||
// Create a proxy for the CapacitorSQLite plugin
|
|||
const createSQLiteProxy = () => { |
|||
const MAX_RETRIES = 5; |
|||
const RETRY_DELAY = 1000; // 1 second
|
|||
|
|||
const withRetry = async (operation: string, ...args: any[]) => { |
|||
let lastError; |
|||
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) { |
|||
try { |
|||
return await ipcRenderer.invoke(`sqlite-${operation}`, ...args); |
|||
} catch (error) { |
|||
lastError = error; |
|||
console.warn(`[Preload] SQLite operation ${operation} failed (attempt ${attempt}/${MAX_RETRIES}), retrying...`, error); |
|||
if (attempt < MAX_RETRIES) { |
|||
await new Promise(resolve => setTimeout(resolve, RETRY_DELAY)); |
|||
} |
|||
} |
|||
} |
|||
throw new Error(`SQLite operation ${operation} failed after ${MAX_RETRIES} attempts: ${lastError?.message || 'Unknown error'}`); |
|||
}; |
|||
|
|||
return { |
|||
echo: (value: string) => withRetry('echo', value), |
|||
createConnection: (options: any) => withRetry('create-connection', options), |
|||
closeConnection: (options: any) => withRetry('close-connection', options), |
|||
execute: (options: any) => withRetry('execute', options), |
|||
query: (options: any) => withRetry('query', options), |
|||
isAvailable: () => withRetry('is-available'), |
|||
getPlatform: () => Promise.resolve('electron') |
|||
}; |
|||
}; |
|||
|
|||
// Set up context bridge for secure IPC communication
|
|||
contextBridge.exposeInMainWorld('electronAPI', { |
|||
// SQLite operations
|
|||
sqlite: createSQLiteProxy(), |
|||
|
|||
// Database status events
|
|||
onDatabaseStatus: (callback: (status: { status: string; error?: string }) => void) => { |
|||
ipcRenderer.on('database-status', (_event, status) => callback(status)); |
|||
return () => { |
|||
ipcRenderer.removeAllListeners('database-status'); |
|||
}; |
|||
} |
|||
}); |
|||
|
|||
// Expose CapacitorSQLite globally for the plugin system
|
|||
contextBridge.exposeInMainWorld('CapacitorSQLite', createSQLiteProxy()); |
|||
|
|||
// Handle uncaught errors
|
|||
window.addEventListener('unhandledrejection', (event) => { |
|||
console.error('[Preload] Unhandled promise rejection:', event.reason); |
|||
}); |
|||
|
|||
window.addEventListener('error', (event) => { |
|||
console.error('[Preload] Unhandled error:', event.error); |
|||
}); |
|||
|
|||
// Log that preload is complete
|
|||
console.log('[Preload] 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,356 @@ |
|||
/** |
|||
* SQLite initialization for Capacitor Electron |
|||
* Handles database path setup, plugin initialization, and IPC handlers |
|||
* |
|||
* Database Path Handling: |
|||
* - Uses XDG Base Directory Specification for data storage |
|||
* - Uses modern plugin conventions (Capacitor 6+ / Plugin 6.x+) |
|||
* - Supports custom database names without enforced extensions |
|||
* - Maintains backward compatibility with legacy paths |
|||
* |
|||
* XDG Base Directory Specification: |
|||
* - Uses $XDG_DATA_HOME (defaults to ~/.local/share) for data files |
|||
* - Falls back to legacy path if XDG environment variables are not set |
|||
* |
|||
* @author Matthew Raymer |
|||
*/ |
|||
|
|||
import { app, ipcMain } from 'electron'; |
|||
import { CapacitorSQLite } from '@capacitor-community/sqlite/electron/dist/plugin.js'; |
|||
import fs from 'fs'; |
|||
import path from 'path'; |
|||
import os from 'os'; |
|||
|
|||
// Simple logger implementation with structured logging
|
|||
const logger = { |
|||
log: (...args: unknown[]) => console.log('[SQLite]', ...args), |
|||
error: (...args: unknown[]) => console.error('[SQLite]', ...args), |
|||
info: (...args: unknown[]) => console.info('[SQLite]', ...args), |
|||
warn: (...args: unknown[]) => console.warn('[SQLite]', ...args), |
|||
debug: (...args: unknown[]) => console.debug('[SQLite]', ...args), |
|||
}; |
|||
|
|||
// Database path resolution utilities following XDG Base Directory Specification
|
|||
const getAppDataPath = async (): Promise<string> => { |
|||
try { |
|||
// Get XDG_DATA_HOME or fallback to default
|
|||
const xdgDataHome = process.env.XDG_DATA_HOME || path.join(os.homedir(), '.local', 'share'); |
|||
logger.info('XDG_DATA_HOME:', xdgDataHome); |
|||
|
|||
// Create app data directory following XDG spec
|
|||
const appDataDir = path.join(xdgDataHome, 'timesafari'); |
|||
logger.info('App data directory:', appDataDir); |
|||
|
|||
// Ensure directory exists with proper permissions (700)
|
|||
if (!fs.existsSync(appDataDir)) { |
|||
await fs.promises.mkdir(appDataDir, { |
|||
recursive: true, |
|||
mode: 0o700 // rwx------ for security
|
|||
}); |
|||
} else { |
|||
// Ensure existing directory has correct permissions
|
|||
await fs.promises.chmod(appDataDir, 0o700); |
|||
} |
|||
|
|||
return appDataDir; |
|||
} catch (error) { |
|||
logger.error('Error getting app data path:', error); |
|||
// Fallback to legacy path in user's home
|
|||
const fallbackDir = path.join(os.homedir(), '.timesafari'); |
|||
logger.warn('Using fallback app data directory:', fallbackDir); |
|||
|
|||
try { |
|||
if (!fs.existsSync(fallbackDir)) { |
|||
await fs.promises.mkdir(fallbackDir, { |
|||
recursive: true, |
|||
mode: 0o700 |
|||
}); |
|||
} else { |
|||
await fs.promises.chmod(fallbackDir, 0o700); |
|||
} |
|||
} catch (mkdirError) { |
|||
logger.error('Failed to create fallback directory:', mkdirError); |
|||
throw new Error('Could not create any suitable data directory'); |
|||
} |
|||
|
|||
return fallbackDir; |
|||
} |
|||
}; |
|||
|
|||
// Initialize database paths
|
|||
let dbPath: string | undefined; |
|||
let dbDir: string | undefined; |
|||
let dbPathInitialized = false; |
|||
let dbPathInitializationPromise: Promise<void> | null = null; |
|||
|
|||
const initializeDatabasePaths = async (): Promise<void> => { |
|||
if (dbPathInitializationPromise) return dbPathInitializationPromise; |
|||
if (dbPathInitialized) return; |
|||
|
|||
dbPathInitializationPromise = (async () => { |
|||
try { |
|||
// Get the app data directory in user's home
|
|||
const absolutePath = await getAppDataPath(); |
|||
logger.info('Absolute database path:', absolutePath); |
|||
|
|||
// For the plugin, we need to use a path relative to the electron folder
|
|||
// So we'll use a relative path from the electron folder to the home directory
|
|||
const electronPath = app.getAppPath(); |
|||
const relativeToElectron = path.relative(electronPath, absolutePath); |
|||
dbDir = relativeToElectron; |
|||
|
|||
logger.info('Database directory (relative to electron):', dbDir); |
|||
|
|||
// Ensure directory exists using absolute path
|
|||
if (!fs.existsSync(absolutePath)) { |
|||
await fs.promises.mkdir(absolutePath, { recursive: true }); |
|||
} |
|||
|
|||
// Use modern plugin conventions - no enforced extension
|
|||
const baseName = 'timesafariSQLite'; |
|||
dbPath = path.join(dbDir, baseName); |
|||
logger.info('Database path initialized (relative to electron):', dbPath); |
|||
|
|||
// Verify we can write to the directory using absolute path
|
|||
const testFile = path.join(absolutePath, '.write-test'); |
|||
try { |
|||
await fs.promises.writeFile(testFile, 'test'); |
|||
await fs.promises.unlink(testFile); |
|||
} catch (error) { |
|||
throw new Error(`Cannot write to database directory: ${error instanceof Error ? error.message : 'Unknown error'}`); |
|||
} |
|||
|
|||
// Set environment variable for the plugin using relative path
|
|||
process.env.CAPACITOR_SQLITE_DB_PATH = dbDir; |
|||
logger.info('Set CAPACITOR_SQLITE_DB_PATH:', process.env.CAPACITOR_SQLITE_DB_PATH); |
|||
|
|||
dbPathInitialized = true; |
|||
} catch (error) { |
|||
logger.error('Failed to initialize database paths:', error); |
|||
throw error; |
|||
} finally { |
|||
dbPathInitializationPromise = null; |
|||
} |
|||
})(); |
|||
|
|||
return dbPathInitializationPromise; |
|||
}; |
|||
|
|||
// Track initialization state
|
|||
let initializationError: Error | null = null; |
|||
|
|||
// Initialize SQLite plugin
|
|||
let sqlitePlugin: any = null; |
|||
let sqliteInitialized = false; |
|||
let sqliteInitializationPromise: Promise<void> | null = null; |
|||
|
|||
export async function initializeSQLite(): Promise<void> { |
|||
if (sqliteInitializationPromise) { |
|||
logger.info('SQLite initialization already in progress, waiting...'); |
|||
return sqliteInitializationPromise; |
|||
} |
|||
|
|||
if (sqliteInitialized) { |
|||
logger.info('SQLite already initialized'); |
|||
return; |
|||
} |
|||
|
|||
sqliteInitializationPromise = (async () => { |
|||
try { |
|||
logger.info('Starting SQLite plugin initialization...'); |
|||
|
|||
// Initialize database paths first
|
|||
await initializeDatabasePaths(); |
|||
|
|||
if (!dbPath || !dbDir) { |
|||
throw new Error('Database path not initialized'); |
|||
} |
|||
|
|||
// Create plugin instance
|
|||
logger.info('Creating SQLite plugin instance...'); |
|||
sqlitePlugin = new CapacitorSQLite(); |
|||
|
|||
if (!sqlitePlugin) { |
|||
throw new Error('Failed to create SQLite plugin instance'); |
|||
} |
|||
|
|||
// Test the plugin
|
|||
logger.info('Testing SQLite plugin...'); |
|||
const echoResult = await sqlitePlugin.echo({ value: 'test' }); |
|||
if (!echoResult || echoResult.value !== 'test') { |
|||
throw new Error('SQLite plugin echo test failed'); |
|||
} |
|||
logger.info('SQLite plugin echo test successful'); |
|||
|
|||
// Initialize database connection using modern plugin conventions
|
|||
const connectionOptions = { |
|||
database: 'timesafariSQLite', |
|||
version: 1, |
|||
readOnly: false, |
|||
encryption: 'no-encryption', |
|||
useNative: true, |
|||
mode: 'rwc', |
|||
location: dbDir // Use path relative to electron folder
|
|||
}; |
|||
|
|||
logger.info('Creating initial database connection with options:', { |
|||
...connectionOptions, |
|||
expectedPath: dbPath // Path relative to electron folder
|
|||
}); |
|||
|
|||
const db = await sqlitePlugin.createConnection(connectionOptions); |
|||
|
|||
if (!db || typeof db !== 'object') { |
|||
throw new Error(`Failed to create database connection - invalid response. Path: ${dbPath}`); |
|||
} |
|||
|
|||
// Verify connection with a simple query
|
|||
logger.info('Verifying database connection...'); |
|||
try { |
|||
const result = await db.query({ statement: 'SELECT 1 as test;' }); |
|||
if (!result || !result.values || result.values.length === 0) { |
|||
throw new Error('Database connection test query returned no results'); |
|||
} |
|||
logger.info('Database connection verified successfully'); |
|||
} catch (error) { |
|||
throw new Error(`Database connection test failed: ${error instanceof Error ? error.message : 'Unknown error'}`); |
|||
} |
|||
|
|||
sqliteInitialized = true; |
|||
logger.info('SQLite plugin initialization completed successfully'); |
|||
} catch (error) { |
|||
logger.error('SQLite plugin initialization failed:', error); |
|||
// Store the error but don't throw
|
|||
initializationError = error instanceof Error ? error : new Error(String(error)); |
|||
// Reset state on failure but allow app to continue
|
|||
sqlitePlugin = null; |
|||
sqliteInitialized = false; |
|||
} finally { |
|||
sqliteInitializationPromise = null; |
|||
} |
|||
})(); |
|||
|
|||
return sqliteInitializationPromise; |
|||
} |
|||
|
|||
// Set up IPC handlers
|
|||
export function setupSQLiteHandlers(): void { |
|||
// Remove any existing handlers to prevent duplicates
|
|||
try { |
|||
ipcMain.removeHandler('sqlite-is-available'); |
|||
ipcMain.removeHandler('sqlite-echo'); |
|||
ipcMain.removeHandler('sqlite-create-connection'); |
|||
ipcMain.removeHandler('sqlite-execute'); |
|||
ipcMain.removeHandler('sqlite-query'); |
|||
ipcMain.removeHandler('sqlite-close-connection'); |
|||
ipcMain.removeHandler('sqlite-get-error'); |
|||
} catch (error) { |
|||
logger.warn('Error removing existing handlers:', error); |
|||
} |
|||
|
|||
// Register all handlers before any are called
|
|||
ipcMain.handle('sqlite-is-available', async () => { |
|||
try { |
|||
// Check both plugin instance and initialization state
|
|||
return sqlitePlugin !== null && sqliteInitialized; |
|||
} catch (error) { |
|||
logger.error('Error in sqlite-is-available:', error); |
|||
return false; |
|||
} |
|||
}); |
|||
|
|||
// Add handler to get initialization error
|
|||
ipcMain.handle('sqlite-get-error', async () => { |
|||
return initializationError ? { |
|||
message: initializationError.message, |
|||
stack: initializationError.stack, |
|||
name: initializationError.name |
|||
} : null; |
|||
}); |
|||
|
|||
// Update other handlers to handle unavailable state gracefully
|
|||
ipcMain.handle('sqlite-echo', async (_event, value) => { |
|||
try { |
|||
if (!sqlitePlugin || !sqliteInitialized) { |
|||
throw new Error('SQLite plugin not available'); |
|||
} |
|||
return await sqlitePlugin.echo({ value }); |
|||
} catch (error) { |
|||
logger.error('Error in sqlite-echo:', error); |
|||
throw error; |
|||
} |
|||
}); |
|||
|
|||
ipcMain.handle('sqlite-create-connection', async (_event, options) => { |
|||
try { |
|||
if (!sqlitePlugin || !sqliteInitialized) { |
|||
throw new Error('SQLite plugin not available'); |
|||
} |
|||
|
|||
if (!dbPath || !dbDir) { |
|||
throw new Error('Database path not initialized'); |
|||
} |
|||
|
|||
// Use modern plugin conventions for connection options
|
|||
const connectionOptions = { |
|||
...options, |
|||
database: 'timesafariSQLite', |
|||
readOnly: false, |
|||
mode: 'rwc', |
|||
encryption: 'no-encryption', |
|||
useNative: true, |
|||
location: dbDir // Use path relative to electron folder
|
|||
}; |
|||
|
|||
logger.info('Creating database connection with options:', connectionOptions); |
|||
const result = await sqlitePlugin.createConnection(connectionOptions); |
|||
|
|||
if (!result || typeof result !== 'object') { |
|||
throw new Error('Failed to create database connection - invalid response'); |
|||
} |
|||
|
|||
return result; |
|||
} catch (error) { |
|||
logger.error('Error in sqlite-create-connection:', error); |
|||
throw error; |
|||
} |
|||
}); |
|||
|
|||
ipcMain.handle('sqlite-execute', async (_event, options) => { |
|||
try { |
|||
if (!sqlitePlugin || !sqliteInitialized) { |
|||
throw new Error('SQLite plugin not available'); |
|||
} |
|||
return await sqlitePlugin.execute(options); |
|||
} catch (error) { |
|||
logger.error('Error in sqlite-execute:', error); |
|||
throw error; |
|||
} |
|||
}); |
|||
|
|||
ipcMain.handle('sqlite-query', async (_event, options) => { |
|||
try { |
|||
if (!sqlitePlugin || !sqliteInitialized) { |
|||
throw new Error('SQLite plugin not available'); |
|||
} |
|||
return await sqlitePlugin.query(options); |
|||
} catch (error) { |
|||
logger.error('Error in sqlite-query:', error); |
|||
throw error; |
|||
} |
|||
}); |
|||
|
|||
ipcMain.handle('sqlite-close-connection', async (_event, options) => { |
|||
try { |
|||
if (!sqlitePlugin || !sqliteInitialized) { |
|||
throw new Error('SQLite plugin not available'); |
|||
} |
|||
return await sqlitePlugin.closeConnection(options); |
|||
} catch (error) { |
|||
logger.error('Error in sqlite-close-connection:', error); |
|||
throw error; |
|||
} |
|||
}); |
|||
|
|||
logger.info('SQLite IPC handlers registered successfully'); |
|||
} |
@ -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 |
|||
} |
|||
} |
|||
|
Loading…
Reference in new issue