const { app, BrowserWindow, ipcMain, dialog, shell } = require('electron'); const { spawn, execSync } = require('child_process'); const path = require('path'); const fs = require('fs'); const Store = require('electron-store'); const store = new Store(); let mainWindow; let splashWindow; let backendProcess; let backendPid = null; // Store PID separately — survives even if backendProcess ref is lost let backendLogStream; let isQuitting = false; // ── Paths ────────────────────────────────────────────────────────── const isDev = process.env.NODE_ENV === 'development'; /** * Get path to a bundled resource (extraResources). * In production, extraResources live under process.resourcesPath. * In dev, they live in the repo root. */ const getResourcePath = (...segments) => { if (isDev) { return path.join(__dirname, '..', ...segments); } return path.join(process.resourcesPath, ...segments); }; /** * User data directory — writable, persists across updates. * Windows: %APPDATA%\RFCP\data\ * Linux: ~/.config/RFCP/data/ * macOS: ~/Library/Application Support/RFCP/data/ */ const getDataPath = () => { return path.join(app.getPath('userData'), 'data'); }; /** * Log directory for backend output and crash logs. * Windows: %APPDATA%\RFCP\logs\ * Linux: ~/.config/RFCP/logs/ * macOS: ~/Library/Logs/RFCP/ */ const getLogPath = () => { return app.getPath('logs'); }; /** Backend executable path */ const getBackendExePath = () => { const exeName = process.platform === 'win32' ? 'rfcp-server.exe' : 'rfcp-server'; if (isDev) { return path.join(__dirname, '..', 'backend', exeName); } return getResourcePath('backend', exeName); }; /** Frontend index.html path (production only) */ const getFrontendPath = () => { return getResourcePath('frontend', 'index.html'); }; // ── Logging ──────────────────────────────────────────────────────── function log(msg) { const line = `[${new Date().toISOString()}] ${msg}`; console.log(line); if (backendLogStream) { backendLogStream.write(line + '\n'); } } function initLogFile() { const logDir = getLogPath(); if (!fs.existsSync(logDir)) { fs.mkdirSync(logDir, { recursive: true }); } const logFile = path.join(logDir, 'rfcp-main.log'); backendLogStream = fs.createWriteStream(logFile, { flags: 'w' }); log(`Log file: ${logFile}`); log(`Platform: ${process.platform}, Electron: ${process.versions.electron}`); log(`isDev: ${isDev}`); log(`userData: ${app.getPath('userData')}`); log(`resourcesPath: ${isDev ? '(dev mode)' : process.resourcesPath}`); } // ── Data directories ─────────────────────────────────────────────── function ensureDataDirs() { const dirs = ['terrain', 'osm', 'projects', 'cache']; const dataPath = getDataPath(); dirs.forEach(dir => { const fullPath = path.join(dataPath, dir); if (!fs.existsSync(fullPath)) { fs.mkdirSync(fullPath, { recursive: true }); } }); log(`Data path: ${dataPath}`); return dataPath; } // ── Splash window ────────────────────────────────────────────────── function createSplashWindow() { splashWindow = new BrowserWindow({ width: 400, height: 300, frame: false, transparent: true, alwaysOnTop: true, resizable: false, webPreferences: { nodeIntegration: true, contextIsolation: false } }); splashWindow.loadFile(path.join(__dirname, 'splash.html')); splashWindow.center(); } // ── Backend lifecycle ────────────────────────────────────────────── async function startBackend() { const dataPath = ensureDataDirs(); const logDir = getLogPath(); // SQLite URL — normalize to forward slashes for cross-platform compatibility const dbPath = path.join(dataPath, 'rfcp.db').replace(/\\/g, '/'); const env = { ...process.env, RFCP_DATA_PATH: dataPath, RFCP_DATABASE_URL: `sqlite:///${dbPath}`, RFCP_HOST: '127.0.0.1', RFCP_PORT: '8888', RFCP_LOG_PATH: logDir, }; if (isDev) { const pythonCmd = process.platform === 'win32' ? 'python' : 'python3'; const backendCwd = path.join(__dirname, '..', 'backend'); log(`Starting dev backend: ${pythonCmd} -m uvicorn ... (cwd: ${backendCwd})`); backendProcess = spawn(pythonCmd, [ '-m', 'uvicorn', 'app.main:app', '--host', '127.0.0.1', '--port', '8888', '--reload' ], { cwd: backendCwd, env, stdio: ['ignore', 'pipe', 'pipe'] }); } else { const backendExe = getBackendExePath(); const backendDir = path.dirname(backendExe); log(`Starting production backend: ${backendExe}`); log(`Backend cwd: ${backendDir}`); // Verify the exe exists if (!fs.existsSync(backendExe)) { log(`FATAL: Backend exe not found at ${backendExe}`); return false; } // Make executable on Unix if (process.platform !== 'win32') { try { fs.chmodSync(backendExe, '755'); } catch (e) { log(`chmod warning: ${e.message}`); } } backendProcess = spawn(backendExe, [], { cwd: backendDir, env, stdio: ['ignore', 'pipe', 'pipe'], detached: process.platform !== 'win32' // Unix: create process group for clean kill }); } // Store PID immediately backendPid = backendProcess.pid; log(`Backend PID: ${backendPid}`); // Pipe backend output to log const backendLogFile = path.join(logDir, 'rfcp-backend.log'); const backendLog = fs.createWriteStream(backendLogFile, { flags: 'w' }); backendProcess.stdout?.on('data', (data) => { const text = data.toString().trim(); log(`[Backend] ${text}`); backendLog.write(text + '\n'); }); backendProcess.stderr?.on('data', (data) => { const text = data.toString().trim(); log(`[Backend:err] ${text}`); backendLog.write(`[err] ${text}\n`); }); backendProcess.on('error', (err) => { log(`Failed to start backend: ${err.message}`); dialog.showErrorBox('Backend Error', `Failed to start backend: ${err.message}`); }); backendProcess.on('exit', (code, signal) => { log(`Backend exited: code=${code}, signal=${signal}`); }); // Wait for backend to be ready return new Promise((resolve) => { const maxAttempts = 60; // 30 seconds let attempts = 0; const checkBackend = setInterval(async () => { attempts++; try { const response = await fetch('http://127.0.0.1:8888/api/health/'); if (response.ok) { clearInterval(checkBackend); log(`Backend ready after ${attempts * 0.5}s`); resolve(true); } } catch (_e) { // Not ready yet } if (attempts >= maxAttempts) { clearInterval(checkBackend); log('Backend failed to start within 30s'); resolve(false); } }, 500); }); } // ── Main window ──────────────────────────────────────────────────── function createMainWindow() { const windowState = store.get('windowState', { width: 1400, height: 900 }); mainWindow = new BrowserWindow({ width: windowState.width, height: windowState.height, x: windowState.x, y: windowState.y, minWidth: 1024, minHeight: 768, show: false, webPreferences: { nodeIntegration: false, contextIsolation: true, preload: path.join(__dirname, 'preload.js') }, title: 'RFCP - RF Coverage Planner', titleBarStyle: process.platform === 'darwin' ? 'hiddenInset' : 'default', }); // Save window state on close and trigger shutdown mainWindow.on('close', (event) => { log('[CLOSE] Window close event fired, isQuitting=' + isQuitting); try { const bounds = mainWindow.getBounds(); store.set('windowState', bounds); } catch (_e) {} if (!isQuitting) { event.preventDefault(); isQuitting = true; // Hard timeout: force exit after 5 seconds no matter what const forceExitTimer = setTimeout(() => { log('[CLOSE] Force exit after 5s timeout'); killAllRfcpProcesses(); process.exit(0); }, 5000); gracefulShutdown().then(() => { clearTimeout(forceExitTimer); app.exit(0); }).catch(() => { clearTimeout(forceExitTimer); killAllRfcpProcesses(); app.exit(0); }); } }); // Load frontend if (isDev) { log('Loading frontend from dev server: http://localhost:5173'); mainWindow.loadURL('http://localhost:5173'); mainWindow.webContents.openDevTools(); } else { const frontendIndex = getFrontendPath(); log(`Loading frontend from: ${frontendIndex}`); if (!fs.existsSync(frontendIndex)) { log(`FATAL: Frontend not found at ${frontendIndex}`); dialog.showErrorBox('Startup Error', `Frontend not found at:\n${frontendIndex}`); app.quit(); return; } mainWindow.loadFile(frontendIndex); } mainWindow.once('ready-to-show', () => { if (splashWindow) { splashWindow.close(); splashWindow = null; } mainWindow.show(); }); mainWindow.on('closed', () => { mainWindow = null; }); // Handle external links mainWindow.webContents.setWindowOpenHandler(({ url }) => { shell.openExternal(url); return { action: 'deny' }; }); } // ── Backend cleanup ─────────────────────────────────────────────── function killBackend() { const pid = backendPid || backendProcess?.pid; if (!pid) { log('[KILL] killBackend() called — no backend PID to kill'); return; } log(`[KILL] killBackend() called, platform=${process.platform}, PID=${pid}`); try { if (process.platform === 'win32') { // Windows: taskkill with /F (force) /T (tree — kills child processes too) log(`[KILL] Running: taskkill /F /T /PID ${pid}`); execSync(`taskkill /F /T /PID ${pid}`, { stdio: 'ignore' }); log('[KILL] taskkill completed successfully'); } else { // Unix: kill process group try { log(`[KILL] Sending SIGTERM to process group -${pid}`); process.kill(-pid, 'SIGTERM'); } catch (_e) { log(`[KILL] Process group kill failed, sending SIGTERM to PID ${pid}`); process.kill(pid, 'SIGTERM'); } } } catch (e) { log(`[KILL] Primary kill failed: ${e.message}, trying SIGKILL fallback`); // Fallback: try normal kill via process handle try { backendProcess?.kill('SIGKILL'); log('[KILL] Fallback SIGKILL sent via process handle'); } catch (_e2) { log('[KILL] Fallback also failed — process likely already dead'); } } backendPid = null; backendProcess = null; log(`[KILL] Backend cleanup complete (PID was ${pid})`); } /** * Aggressive kill: multiple strategies to ensure ALL rfcp-server processes die. * Uses 4 strategies on Windows for maximum reliability. */ function killAllRfcpProcesses() { log('[KILL] === Starting aggressive kill ==='); if (process.platform === 'win32') { // Strategy 1: Kill by image name (most reliable) try { log('[KILL] Strategy 1: taskkill /F /IM'); execSync('taskkill /F /IM rfcp-server.exe', { stdio: 'pipe', timeout: 5000, windowsHide: true }); log('[KILL] Strategy 1: SUCCESS'); } catch (_e) { log('[KILL] Strategy 1: No processes or already killed'); } // Strategy 2: Kill by PID tree if we have PID if (backendPid) { try { log(`[KILL] Strategy 2: taskkill /F /T /PID ${backendPid}`); execSync(`taskkill /F /T /PID ${backendPid}`, { stdio: 'pipe', timeout: 5000, windowsHide: true }); log('[KILL] Strategy 2: SUCCESS'); } catch (_e) { log('[KILL] Strategy 2: PID not found'); } } // Strategy 3: PowerShell kill (backup) try { log('[KILL] Strategy 3: PowerShell Stop-Process'); execSync('powershell -Command "Get-Process rfcp-server -ErrorAction SilentlyContinue | Stop-Process -Force"', { stdio: 'pipe', timeout: 5000, windowsHide: true }); log('[KILL] Strategy 3: SUCCESS'); } catch (_e) { log('[KILL] Strategy 3: PowerShell failed or no processes'); } // Strategy 4: PowerShell CimInstance terminate (modern replacement for wmic) try { log('[KILL] Strategy 4: PowerShell CimInstance Terminate'); execSync('powershell -NoProfile -Command "Get-CimInstance Win32_Process -Filter \\"name=\'rfcp-server.exe\'\\" | Invoke-CimMethod -MethodName Terminate"', { stdio: 'pipe', timeout: 5000, windowsHide: true }); log('[KILL] Strategy 4: SUCCESS'); } catch (_e) { log('[KILL] Strategy 4: No processes or failed'); } } else { // Unix: pkill try { execSync('pkill -9 -f rfcp-server', { stdio: 'pipe', timeout: 5000 }); log('[KILL] pkill rfcp-server completed'); } catch (_e) { log('[KILL] No rfcp-server processes found'); } } backendPid = null; backendProcess = null; log('[KILL] === Kill sequence complete ==='); } /** * Graceful shutdown: API call first, then PID-tree kill, then name-based kill. * * The backend's /shutdown endpoint kills workers by name and schedules * os._exit(3s) as a safety net. We then do PID-tree kill (most reliable * on Windows — catches all child processes) while the main PID is still * alive, followed by name-based kill as final sweep. */ async function gracefulShutdown() { log('[SHUTDOWN] Starting graceful shutdown...'); // Step 1: Ask backend to clean up workers (pool shutdown + name kill) try { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 2000); await fetch('http://127.0.0.1:8888/api/system/shutdown', { method: 'POST', signal: controller.signal }); clearTimeout(timeout); log('[SHUTDOWN] Backend acknowledged shutdown'); // Brief wait for pool.shutdown() to take effect await new Promise(r => setTimeout(r, 500)); } catch (_e) { log('[SHUTDOWN] Backend did not respond — force killing'); } // Step 2: PID-tree kill — most reliable, catches all child processes // Must run while main backend PID is still alive (before os._exit safety net) killBackend(); // Step 3: Name-based kill — catches any orphans not in the process tree killAllRfcpProcesses(); // Step 4: Wait and verify await new Promise(r => setTimeout(r, 500)); log('[SHUTDOWN] Shutdown complete'); } // ── App lifecycle ────────────────────────────────────────────────── app.whenReady().then(async () => { initLogFile(); createSplashWindow(); const backendStarted = await startBackend(); if (!backendStarted) { const logDir = getLogPath(); const result = await dialog.showMessageBox({ type: 'error', title: 'Startup Error', message: 'Failed to start backend server.', detail: `Check logs at:\n${logDir}\n\nClick "Show Logs" to open the folder.`, buttons: ['Quit', 'Show Logs'] }); if (result.response === 1) { shell.openPath(logDir); } app.quit(); return; } createMainWindow(); }); app.on('window-all-closed', () => { log('[CLOSE] window-all-closed fired'); isQuitting = true; killAllRfcpProcesses(); if (process.platform !== 'darwin') { app.quit(); } }); app.on('activate', () => { if (mainWindow === null) { createMainWindow(); } }); app.on('before-quit', (event) => { log('[CLOSE] before-quit fired, isQuitting=' + isQuitting); if (!isQuitting) { event.preventDefault(); isQuitting = true; const forceExitTimer = setTimeout(() => { log('[CLOSE] Force exit from before-quit after 5s'); killAllRfcpProcesses(); process.exit(0); }, 5000); gracefulShutdown().then(() => { clearTimeout(forceExitTimer); app.exit(0); }).catch(() => { clearTimeout(forceExitTimer); killAllRfcpProcesses(); app.exit(0); }); } }); app.on('will-quit', () => { log('[CLOSE] will-quit fired'); killAllRfcpProcesses(); if (backendLogStream) { try { backendLogStream.end(); } catch (_e) {} backendLogStream = null; } }); // Last resort: ensure backend is killed when Node process exits process.on('exit', () => { try { console.log(`[KILL] process.exit handler, backendPid=${backendPid}`); } catch (_e) { /* log stream may be closed */ } // PID-based kill if (backendPid) { try { if (process.platform === 'win32') { execSync(`taskkill /F /T /PID ${backendPid}`, { stdio: 'ignore' }); } else { process.kill(backendPid, 'SIGKILL'); } } catch (_e) { // Best effort } } // Name-based kill — catches orphaned workers killAllRfcpProcesses(); }); // Handle SIGINT/SIGTERM (Ctrl+C, system shutdown) process.on('SIGINT', () => { try { log('[SIGNAL] SIGINT received'); } catch (_e) {} killAllRfcpProcesses(); process.exit(0); }); process.on('SIGTERM', () => { try { log('[SIGNAL] SIGTERM received'); } catch (_e) {} killAllRfcpProcesses(); process.exit(0); }); // ── IPC Handlers ─────────────────────────────────────────────────── ipcMain.handle('get-data-path', () => getDataPath()); ipcMain.handle('get-log-path', () => getLogPath()); ipcMain.handle('get-app-version', () => app.getVersion()); ipcMain.handle('get-platform', () => process.platform); ipcMain.handle('get-gpu-info', async () => { // TODO: Implement GPU detection return { available: false, name: null, cudaVersion: null }; }); ipcMain.handle('select-directory', async () => { const result = await dialog.showOpenDialog(mainWindow, { properties: ['openDirectory'] }); return result.filePaths[0] || null; }); ipcMain.handle('select-file', async (_event, options) => { const result = await dialog.showOpenDialog(mainWindow, { properties: ['openFile'], filters: options?.filters || [] }); return result.filePaths[0] || null; }); ipcMain.handle('save-file', async (_event, options) => { const result = await dialog.showSaveDialog(mainWindow, { defaultPath: options?.defaultPath, filters: options?.filters || [] }); return result.filePath || null; }); ipcMain.handle('get-setting', (_event, key) => store.get(key)); ipcMain.handle('set-setting', (_event, key, value) => store.set(key, value)); ipcMain.handle('open-external', (_event, url) => shell.openExternal(url)); ipcMain.handle('open-path', (_event, filePath) => shell.openPath(filePath)); // ── Import Region Data ──────────────────────────────────────────── ipcMain.handle('import-region-data', async () => { const result = await dialog.showOpenDialog(mainWindow, { title: 'Select folder with region data', properties: ['openDirectory'] }); const srcDir = result.filePaths[0]; if (!srcDir) return { success: false, message: 'Cancelled' }; const dataPath = getDataPath(); let terrainCount = 0; let osmCount = 0; try { // Copy terrain/*.hgt files const terrainSrc = path.join(srcDir, 'terrain'); if (fs.existsSync(terrainSrc)) { const terrainDest = path.join(dataPath, 'terrain'); if (!fs.existsSync(terrainDest)) { fs.mkdirSync(terrainDest, { recursive: true }); } const hgtFiles = fs.readdirSync(terrainSrc).filter(f => f.endsWith('.hgt')); for (const file of hgtFiles) { fs.copyFileSync(path.join(terrainSrc, file), path.join(terrainDest, file)); terrainCount++; } } // Copy osm/**/*.json files const osmSrc = path.join(srcDir, 'osm'); if (fs.existsSync(osmSrc)) { const osmDest = path.join(dataPath, 'osm'); const subdirs = fs.readdirSync(osmSrc).filter(d => fs.statSync(path.join(osmSrc, d)).isDirectory() ); for (const subdir of subdirs) { const subSrc = path.join(osmSrc, subdir); const subDest = path.join(osmDest, subdir); if (!fs.existsSync(subDest)) { fs.mkdirSync(subDest, { recursive: true }); } const jsonFiles = fs.readdirSync(subSrc).filter(f => f.endsWith('.json')); for (const file of jsonFiles) { fs.copyFileSync(path.join(subSrc, file), path.join(subDest, file)); osmCount++; } } } if (terrainCount === 0 && osmCount === 0) { return { success: false, message: 'No data files found. Expected terrain/*.hgt or osm/**/*.json' }; } log(`Imported ${terrainCount} terrain tiles, ${osmCount} OSM files from ${srcDir}`); return { success: true, terrainCount, osmCount, message: `Imported ${terrainCount} terrain tiles and ${osmCount} OSM cache files` }; } catch (e) { log(`Import error: ${e.message}`); return { success: false, message: `Import failed: ${e.message}` }; } });