diff --git a/electron/src/setup.ts b/electron/src/setup.ts index bb940ef8..fd7ee1a6 100644 --- a/electron/src/setup.ts +++ b/electron/src/setup.ts @@ -12,33 +12,161 @@ 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. +/** + * Reload watcher configuration and state management + * Prevents infinite reload loops and implements rate limiting + * + * @author Matthew Raymer + */ +const RELOAD_CONFIG = { + DEBOUNCE_MS: 1500, + COOLDOWN_MS: 5000, + MAX_RELOADS_PER_MINUTE: 10, + MAX_RELOADS_PER_SESSION: 100 +}; + const reloadWatcher = { - debouncer: null, + debouncer: null as NodeJS.Timeout | null, ready: false, - watcher: null, + watcher: null as chokidar.FSWatcher | null, + lastReloadTime: 0, + reloadCount: 0, + sessionReloadCount: 0, + resetTimeout: null as NodeJS.Timeout | null, + isReloading: false +}; + +/** + * Resets the reload counter after one minute + */ +const resetReloadCounter = () => { + reloadWatcher.reloadCount = 0; + reloadWatcher.resetTimeout = null; +}; + +/** + * Checks if a reload is allowed based on rate limits and cooldown + * @returns {boolean} Whether a reload is allowed + */ +const canReload = (): boolean => { + const now = Date.now(); + + // Check cooldown period + if (now - reloadWatcher.lastReloadTime < RELOAD_CONFIG.COOLDOWN_MS) { + console.warn('[Reload Watcher] Skipping reload - cooldown period active'); + return false; + } + + // Check per-minute limit + if (reloadWatcher.reloadCount >= RELOAD_CONFIG.MAX_RELOADS_PER_MINUTE) { + console.warn('[Reload Watcher] Skipping reload - maximum reloads per minute reached'); + return false; + } + + // Check session limit + if (reloadWatcher.sessionReloadCount >= RELOAD_CONFIG.MAX_RELOADS_PER_SESSION) { + console.error('[Reload Watcher] Maximum reloads per session reached. Please restart the application.'); + return false; + } + + return true; }; + +/** + * Cleans up the current watcher instance + */ +const cleanupWatcher = () => { + if (reloadWatcher.watcher) { + reloadWatcher.watcher.close(); + reloadWatcher.watcher = null; + } + if (reloadWatcher.debouncer) { + clearTimeout(reloadWatcher.debouncer); + reloadWatcher.debouncer = null; + } + if (reloadWatcher.resetTimeout) { + clearTimeout(reloadWatcher.resetTimeout); + reloadWatcher.resetTimeout = null; + } +}; + +/** + * Sets up the file watcher for development mode reloading + * Implements rate limiting and prevents infinite reload loops + * + * @param electronCapacitorApp - The Electron Capacitor app instance + */ export function setupReloadWatcher(electronCapacitorApp: ElectronCapacitorApp): void { + // Cleanup any existing watcher + cleanupWatcher(); + + // Reset state + reloadWatcher.ready = false; + reloadWatcher.isReloading = false; + reloadWatcher.watcher = chokidar .watch(join(app.getAppPath(), 'app'), { ignored: /[/\\]\./, persistent: true, + awaitWriteFinish: { + stabilityThreshold: 1000, + pollInterval: 100 + } }) .on('ready', () => { reloadWatcher.ready = true; + console.log('[Reload Watcher] Ready to watch for changes'); }) .on('all', (_event, _path) => { - if (reloadWatcher.ready) { + if (!reloadWatcher.ready || reloadWatcher.isReloading) { + return; + } + + // Clear existing debouncer + if (reloadWatcher.debouncer) { clearTimeout(reloadWatcher.debouncer); - reloadWatcher.debouncer = setTimeout(async () => { - electronCapacitorApp.getMainWindow().webContents.reload(); + } + + // Set up new debouncer + reloadWatcher.debouncer = setTimeout(async () => { + if (!canReload()) { + return; + } + + try { + reloadWatcher.isReloading = true; + + // Update reload counters + reloadWatcher.lastReloadTime = Date.now(); + reloadWatcher.reloadCount++; + reloadWatcher.sessionReloadCount++; + + // Set up reset timeout for per-minute counter + if (!reloadWatcher.resetTimeout) { + reloadWatcher.resetTimeout = setTimeout(resetReloadCounter, 60000); + } + + // Perform reload + console.log('[Reload Watcher] Reloading window...'); + await electronCapacitorApp.getMainWindow().webContents.reload(); + + // Reset state after reload reloadWatcher.ready = false; - clearTimeout(reloadWatcher.debouncer); - reloadWatcher.debouncer = null; - reloadWatcher.watcher = null; + reloadWatcher.isReloading = false; + + // Re-setup watcher after successful reload setupReloadWatcher(electronCapacitorApp); - }, 1500); - } + + } catch (error) { + console.error('[Reload Watcher] Error during reload:', error); + reloadWatcher.isReloading = false; + reloadWatcher.ready = true; + } + }, RELOAD_CONFIG.DEBOUNCE_MS); + }) + .on('error', (error) => { + console.error('[Reload Watcher] Error:', error); + cleanupWatcher(); }); } diff --git a/src/main.electron.ts b/src/main.electron.ts index 4647350a..18b37dc0 100644 --- a/src/main.electron.ts +++ b/src/main.electron.ts @@ -15,58 +15,100 @@ if (pwa_enabled) { // Initialize app and SQLite const app = initializeApp(); +/** + * SQLite initialization configuration + * Defines timeouts and retry settings for database operations + * + * @author Matthew Raymer + */ +const SQLITE_CONFIG = { + INITIALIZATION: { + TIMEOUT_MS: 10000, // 10 seconds for initial setup + RETRY_ATTEMPTS: 3, + RETRY_DELAY_MS: 1000 + }, + OPERATIONS: { + TIMEOUT_MS: 5000, // 5 seconds for regular operations + RETRY_ATTEMPTS: 2, + RETRY_DELAY_MS: 500 + }, + CONNECTION: { + MAX_CONNECTIONS: 5, + IDLE_TIMEOUT_MS: 30000 + } +}; + // Create a promise that resolves when SQLite is ready const sqliteReady = new Promise((resolve, reject) => { - // Set a timeout to prevent hanging - const timeout = setTimeout(() => { - logger.error("[Main Electron] SQLite initialization timeout"); - reject(new Error("SQLite initialization timeout")); - }, 30000); // 30 second timeout - - // Wait for electron bridge to be available - const checkElectronBridge = () => { - if (window.electron?.ipcRenderer) { - logger.info("[Main Electron] IPC renderer bridge available"); - - // Listen for SQLite ready signal - window.electron.ipcRenderer.once('sqlite-ready', () => { - clearTimeout(timeout); - logger.info("[Main Electron] Received SQLite ready signal"); - resolve(); - }); + let retryCount = 0; + let initializationTimeout: NodeJS.Timeout; + + const attemptInitialization = () => { + // Clear any existing timeout + if (initializationTimeout) { + clearTimeout(initializationTimeout); + } + + // Set timeout for this attempt + initializationTimeout = setTimeout(() => { + if (retryCount < SQLITE_CONFIG.INITIALIZATION.RETRY_ATTEMPTS) { + retryCount++; + logger.warn(`[Main Electron] SQLite initialization attempt ${retryCount} timed out, retrying...`); + setTimeout(attemptInitialization, SQLITE_CONFIG.INITIALIZATION.RETRY_DELAY_MS); + } else { + logger.error("[Main Electron] SQLite initialization failed after all retries"); + reject(new Error("SQLite initialization timeout after all retries")); + } + }, SQLITE_CONFIG.INITIALIZATION.TIMEOUT_MS); - // Also listen for database errors - window.electron.ipcRenderer.once('database-status', (...args: unknown[]) => { - clearTimeout(timeout); - const status = args[0] as { status: string; error?: string }; - if (status.status === 'error') { - logger.error("[Main Electron] Database error:", status.error); - reject(new Error(status.error || 'Database initialization failed')); - } - }); + // Wait for electron bridge to be available + const checkElectronBridge = () => { + if (window.electron?.ipcRenderer) { + logger.info("[Main Electron] IPC renderer bridge available"); + + // Listen for SQLite ready signal + window.electron.ipcRenderer.once('sqlite-ready', () => { + clearTimeout(initializationTimeout); + logger.info("[Main Electron] Received SQLite ready signal"); + resolve(); + }); - // Check if SQLite is already available - window.electron.ipcRenderer.invoke('sqlite-is-available') - .then((result: unknown) => { - const isAvailable = Boolean(result); - if (isAvailable) { - logger.info("[Main Electron] SQLite is already available"); - // Don't resolve here - wait for the ready signal - // This prevents race conditions where the ready signal arrives after this check + // Also listen for database errors + window.electron.ipcRenderer.once('database-status', (...args: unknown[]) => { + clearTimeout(initializationTimeout); + const status = args[0] as { status: string; error?: string }; + if (status.status === 'error') { + logger.error("[Main Electron] Database error:", status.error); + reject(new Error(status.error || 'Database initialization failed')); } - }) - .catch((error: Error) => { - logger.error("[Main Electron] Failed to check SQLite availability:", error); - // Don't reject here - wait for either ready signal or timeout }); - } else { - // Check again in 100ms if bridge isn't ready - setTimeout(checkElectronBridge, 100); - } + + // Check if SQLite is already available + window.electron.ipcRenderer.invoke('sqlite-is-available') + .then((result: unknown) => { + const isAvailable = Boolean(result); + if (isAvailable) { + logger.info("[Main Electron] SQLite is already available"); + // Don't resolve here - wait for the ready signal + // This prevents race conditions where the ready signal arrives after this check + } + }) + .catch((error: Error) => { + logger.error("[Main Electron] Failed to check SQLite availability:", error); + // Don't reject here - wait for either ready signal or timeout + }); + } else { + // Check again in 100ms if bridge isn't ready + setTimeout(checkElectronBridge, 100); + } + }; + + // Start checking for bridge + checkElectronBridge(); }; - // Start checking for bridge - checkElectronBridge(); + // Start first initialization attempt + attemptInitialization(); }); // Wait for SQLite to be ready before mounting