407 lines
11 KiB
JavaScript
407 lines
11 KiB
JavaScript
const { app, BrowserWindow, ipcMain, dialog, shell } = require('electron');
|
|
const { spawn } = 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 backendLogStream;
|
|
|
|
// ── 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']
|
|
});
|
|
}
|
|
|
|
// 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
|
|
mainWindow.on('close', () => {
|
|
const bounds = mainWindow.getBounds();
|
|
store.set('windowState', bounds);
|
|
});
|
|
|
|
// 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' };
|
|
});
|
|
}
|
|
|
|
// ── 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', () => {
|
|
if (backendProcess) {
|
|
backendProcess.kill();
|
|
backendProcess = null;
|
|
}
|
|
|
|
if (process.platform !== 'darwin') {
|
|
app.quit();
|
|
}
|
|
});
|
|
|
|
app.on('activate', () => {
|
|
if (mainWindow === null) {
|
|
createMainWindow();
|
|
}
|
|
});
|
|
|
|
app.on('before-quit', () => {
|
|
if (backendProcess) {
|
|
backendProcess.kill();
|
|
}
|
|
if (backendLogStream) {
|
|
backendLogStream.end();
|
|
}
|
|
});
|
|
|
|
// ── 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));
|