Files
rfcp/desktop/main.js
mytec defa3ad440 @mytec: feat: Phase 3.0 Architecture Refactor
Major refactoring of RFCP backend:
- Modular propagation models (8 models)
- SharedMemoryManager for terrain data
- ProcessPoolExecutor parallel processing
- WebSocket progress streaming
- Building filtering pipeline (351k → 15k)
- 82 unit tests

Performance: Standard preset 38s → 5s (7.6x speedup)

Known issue: Detailed preset timeout (fix in 3.1.0)
2026-02-01 23:12:26 +02:00

707 lines
21 KiB
JavaScript

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;
gracefulShutdown().then(() => {
app.quit();
}).catch(() => {
killAllRfcpProcesses();
app.quit();
});
}
});
// 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;
gracefulShutdown().then(() => {
app.quit();
}).catch(() => {
killAllRfcpProcesses();
app.quit();
});
}
});
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}` };
}
});