From fa55fec94a218cd158eefcc3023b8dd4edd0e017 Mon Sep 17 00:00:00 2001 From: mytec Date: Sat, 31 Jan 2026 14:26:11 +0200 Subject: [PATCH] @mytec: iter2.1 ready for testing --- backend/run_server.py | 22 +++ desktop/main.js | 314 +++++++++++++++++++++++++++++++++++ desktop/package.json | 79 +++++++++ desktop/preload.js | 29 ++++ desktop/splash.html | 82 +++++++++ frontend/src/lib/desktop.ts | 47 ++++++ frontend/src/services/api.ts | 3 +- installer/build-linux.sh | 38 +++++ installer/build-mac.sh | 61 +++++++ installer/build-win.sh | 39 +++++ installer/rfcp-server.spec | 67 ++++++++ 11 files changed, 780 insertions(+), 1 deletion(-) create mode 100644 backend/run_server.py create mode 100644 desktop/main.js create mode 100644 desktop/package.json create mode 100644 desktop/preload.js create mode 100644 desktop/splash.html create mode 100644 frontend/src/lib/desktop.ts create mode 100644 installer/build-linux.sh create mode 100644 installer/build-mac.sh create mode 100644 installer/build-win.sh create mode 100644 installer/rfcp-server.spec diff --git a/backend/run_server.py b/backend/run_server.py new file mode 100644 index 0000000..8074d90 --- /dev/null +++ b/backend/run_server.py @@ -0,0 +1,22 @@ +"""Entry point for PyInstaller bundle""" +import os +import sys + +# Set base path for PyInstaller +if getattr(sys, 'frozen', False): + # Running as compiled + os.chdir(os.path.dirname(sys.executable)) + +import uvicorn +from app.main import app + +if __name__ == '__main__': + host = os.environ.get('RFCP_HOST', '127.0.0.1') + port = int(os.environ.get('RFCP_PORT', '8888')) + + uvicorn.run( + app, + host=host, + port=port, + log_level='info', + ) diff --git a/desktop/main.js b/desktop/main.js new file mode 100644 index 0000000..8a784d9 --- /dev/null +++ b/desktop/main.js @@ -0,0 +1,314 @@ +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; + +// Paths +const isDev = process.env.NODE_ENV === 'development'; + +const getResourcePath = (relativePath) => { + if (isDev) { + return path.join(__dirname, '..', relativePath); + } + return path.join(process.resourcesPath, relativePath); +}; + +const getDataPath = () => { + return path.join(app.getPath('userData'), 'data'); +}; + +// Get backend executable name based on platform +const getBackendExeName = () => { + switch (process.platform) { + case 'win32': + return 'rfcp-server.exe'; + case 'darwin': + return 'rfcp-server'; + case 'linux': + return 'rfcp-server'; + default: + return 'rfcp-server'; + } +}; + +// Ensure data directories exist +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 }); + } + }); + + return dataPath; +} + +// Create 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(); +} + +// Start Python backend +async function startBackend() { + const dataPath = ensureDataDirs(); + + const env = { + ...process.env, + RFCP_DATA_PATH: dataPath, + RFCP_DATABASE_URL: `sqlite:///${path.join(dataPath, 'rfcp.db')}`, + RFCP_HOST: '127.0.0.1', + RFCP_PORT: '8888', + }; + + if (isDev) { + // Development: run uvicorn + const pythonCmd = process.platform === 'win32' ? 'python' : 'python3'; + backendProcess = spawn(pythonCmd, [ + '-m', 'uvicorn', + 'app.main:app', + '--host', '127.0.0.1', + '--port', '8888', + '--reload' + ], { + cwd: path.join(__dirname, '..', 'backend'), + env, + stdio: ['ignore', 'pipe', 'pipe'] + }); + } else { + // Production: run PyInstaller bundle + const backendExe = path.join( + getResourcePath('backend'), + getBackendExeName() + ); + + // Make executable on Unix + if (process.platform !== 'win32') { + try { + fs.chmodSync(backendExe, '755'); + } catch (e) { + console.log('chmod failed:', e); + } + } + + backendProcess = spawn(backendExe, [], { + env, + stdio: ['ignore', 'pipe', 'pipe'] + }); + } + + // Log backend output + backendProcess.stdout?.on('data', (data) => { + console.log(`[Backend] ${data}`); + }); + + backendProcess.stderr?.on('data', (data) => { + console.error(`[Backend Error] ${data}`); + }); + + backendProcess.on('error', (err) => { + console.error('Failed to start backend:', err); + dialog.showErrorBox('Backend Error', `Failed to start backend: ${err.message}`); + }); + + // 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); + console.log('Backend ready!'); + resolve(true); + } + } catch (_e) { + // Not ready yet + } + + if (attempts >= maxAttempts) { + clearInterval(checkBackend); + console.error('Backend failed to start in time'); + resolve(false); + } + }, 500); + }); +} + +// Create 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') + }, + icon: path.join(__dirname, 'assets', + process.platform === 'win32' ? 'icon.ico' : 'icon.png' + ), + 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) { + mainWindow.loadURL('http://localhost:5173'); + mainWindow.webContents.openDevTools(); + } else { + mainWindow.loadFile(path.join(getResourcePath('frontend'), 'index.html')); + } + + 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 () => { + createSplashWindow(); + + const backendStarted = await startBackend(); + + if (!backendStarted) { + const result = await dialog.showMessageBox({ + type: 'error', + title: 'Startup Error', + message: 'Failed to start backend server.', + detail: 'Please check logs and try again. Click "Show Logs" to open the log folder.', + buttons: ['Quit', 'Show Logs'] + }); + + if (result.response === 1) { + shell.openPath(app.getPath('logs')); + } + + 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(); + } +}); + +// IPC Handlers +ipcMain.handle('get-data-path', () => getDataPath()); +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)); diff --git a/desktop/package.json b/desktop/package.json new file mode 100644 index 0000000..5481d2f --- /dev/null +++ b/desktop/package.json @@ -0,0 +1,79 @@ +{ + "name": "rfcp-desktop", + "version": "1.6.1", + "description": "RF Coverage Planner - Tactical Communications", + "main": "main.js", + "author": "UMTC Project", + "license": "MIT", + "scripts": { + "start": "electron .", + "dev": "NODE_ENV=development electron .", + "build": "electron-builder", + "build:win": "electron-builder --win", + "build:linux": "electron-builder --linux", + "build:mac": "electron-builder --mac", + "build:all": "electron-builder --win --linux --mac" + }, + "dependencies": { + "electron-store": "^8.1.0" + }, + "devDependencies": { + "electron": "^28.0.0", + "electron-builder": "^24.9.0" + }, + "build": { + "appId": "one.eliah.rfcp", + "productName": "RFCP", + "copyright": "Copyright © 2025 UMTC Project", + "directories": { + "output": "dist", + "buildResources": "assets" + }, + "files": [ + "main.js", + "preload.js", + "splash.html" + ], + "extraResources": [ + { + "from": "../frontend/dist", + "to": "frontend" + }, + { + "from": "./backend-dist/${os}", + "to": "backend" + } + ], + "win": { + "target": ["nsis"], + "icon": "assets/icon.ico" + }, + "nsis": { + "oneClick": false, + "allowToChangeInstallationDirectory": true, + "createDesktopShortcut": true, + "createStartMenuShortcut": true, + "shortcutName": "RFCP" + }, + "linux": { + "target": ["AppImage", "deb"], + "icon": "assets/icon.png", + "category": "Science" + }, + "mac": { + "target": ["dmg", "zip"], + "icon": "assets/icon.icns", + "category": "public.app-category.developer-tools", + "hardenedRuntime": false, + "gatekeeperAssess": false + }, + "dmg": { + "title": "RFCP Installer", + "backgroundColor": "#1a1a2e", + "contents": [ + { "x": 130, "y": 220 }, + { "x": 410, "y": 220, "type": "link", "path": "/Applications" } + ] + } + } +} diff --git a/desktop/preload.js b/desktop/preload.js new file mode 100644 index 0000000..1362a3a --- /dev/null +++ b/desktop/preload.js @@ -0,0 +1,29 @@ +const { contextBridge, ipcRenderer } = require('electron'); + +contextBridge.exposeInMainWorld('rfcp', { + // System info + getDataPath: () => ipcRenderer.invoke('get-data-path'), + getAppVersion: () => ipcRenderer.invoke('get-app-version'), + getPlatform: () => ipcRenderer.invoke('get-platform'), + getGpuInfo: () => ipcRenderer.invoke('get-gpu-info'), + + // Dialogs + selectDirectory: () => ipcRenderer.invoke('select-directory'), + selectFile: (options) => ipcRenderer.invoke('select-file', options), + saveFile: (options) => ipcRenderer.invoke('save-file', options), + + // Settings (persistent) + getSetting: (key) => ipcRenderer.invoke('get-setting', key), + setSetting: (key, value) => ipcRenderer.invoke('set-setting', key, value), + + // Shell + openExternal: (url) => ipcRenderer.invoke('open-external', url), + openPath: (path) => ipcRenderer.invoke('open-path', path), + + // Platform info + platform: process.platform, + isDesktop: true, + isMac: process.platform === 'darwin', + isWindows: process.platform === 'win32', + isLinux: process.platform === 'linux', +}); diff --git a/desktop/splash.html b/desktop/splash.html new file mode 100644 index 0000000..d8e5331 --- /dev/null +++ b/desktop/splash.html @@ -0,0 +1,82 @@ + + + + + RFCP Loading + + + + +
RF Coverage Planner
+
+
+
+
Starting backend server...
+
v1.6.1
+ + diff --git a/frontend/src/lib/desktop.ts b/frontend/src/lib/desktop.ts new file mode 100644 index 0000000..21c70a4 --- /dev/null +++ b/frontend/src/lib/desktop.ts @@ -0,0 +1,47 @@ +interface RFCPDesktop { + getDataPath: () => Promise; + getAppVersion: () => Promise; + getPlatform: () => Promise; + getGpuInfo: () => Promise<{ + available: boolean; + name: string | null; + cudaVersion: string | null; + }>; + selectDirectory: () => Promise; + selectFile: (options?: { filters?: Array<{ name: string; extensions: string[] }> }) => Promise; + saveFile: (options?: { defaultPath?: string; filters?: Array<{ name: string; extensions: string[] }> }) => Promise; + getSetting: (key: string) => Promise; + setSetting: (key: string, value: unknown) => Promise; + openExternal: (url: string) => Promise; + openPath: (path: string) => Promise; + platform: string; + isDesktop: boolean; + isMac: boolean; + isWindows: boolean; + isLinux: boolean; +} + +declare global { + interface Window { + rfcp?: RFCPDesktop; + } +} + +export const isDesktop = (): boolean => { + return window.rfcp?.isDesktop === true; +}; + +export const getDesktopApi = (): RFCPDesktop | null => { + return window.rfcp || null; +}; + +export const getApiBaseUrl = (): string => { + if (isDesktop()) { + return 'http://127.0.0.1:8888'; + } + return import.meta.env.VITE_API_URL || 'https://api.rfcp.eliah.one'; +}; + +export const isMac = (): boolean => { + return window.rfcp?.isMac === true; +}; diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index dc93047..c7c0eb7 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -1,8 +1,9 @@ /** * Backend API client for RFCP coverage calculation */ +import { getApiBaseUrl } from '@/lib/desktop.ts'; -const API_BASE = import.meta.env.VITE_API_URL || 'https://api.rfcp.eliah.one'; +const API_BASE = getApiBaseUrl(); // === Request types === diff --git a/installer/build-linux.sh b/installer/build-linux.sh new file mode 100644 index 0000000..1077298 --- /dev/null +++ b/installer/build-linux.sh @@ -0,0 +1,38 @@ +#!/bin/bash +set -e + +echo "=========================================" +echo " RFCP Desktop Build (Linux)" +echo "=========================================" + +cd "$(dirname "$0")/.." + +# 1. Build frontend +echo "[1/4] Building frontend..." +cd frontend +npm ci +npm run build +cd .. + +# 2. Build backend with PyInstaller +echo "[2/4] Building backend..." +cd backend +python3 -m pip install -r requirements.txt +python3 -m pip install pyinstaller +cd ../installer +python3 -m PyInstaller rfcp-server.spec --clean --noconfirm +mkdir -p ../desktop/backend-dist/linux +cp dist/rfcp-server ../desktop/backend-dist/linux/ +cd .. + +# 3. Build Electron app +echo "[3/4] Building Electron app..." +cd desktop +npm ci +npm run build:linux +cd .. + +# 4. Done +echo "[4/4] Build complete!" +ls -la desktop/dist/*.AppImage 2>/dev/null || echo "AppImage in desktop/dist/" +ls -la desktop/dist/*.deb 2>/dev/null || echo "Deb in desktop/dist/" diff --git a/installer/build-mac.sh b/installer/build-mac.sh new file mode 100644 index 0000000..69e6643 --- /dev/null +++ b/installer/build-mac.sh @@ -0,0 +1,61 @@ +#!/bin/bash +set -e + +echo "=========================================" +echo " RFCP Desktop Build (macOS)" +echo "=========================================" + +cd "$(dirname "$0")/.." + +# Check if running on macOS +if [[ "$(uname)" != "Darwin" ]]; then + echo "Error: This script must run on macOS" + exit 1 +fi + +# 1. Build frontend +echo "[1/4] Building frontend..." +cd frontend +npm ci +npm run build +cd .. + +# 2. Build backend with PyInstaller +echo "[2/4] Building backend..." +cd backend +python3 -m pip install -r requirements.txt +python3 -m pip install pyinstaller +cd ../installer +python3 -m PyInstaller rfcp-server.spec --clean --noconfirm +mkdir -p ../desktop/backend-dist/darwin +cp dist/rfcp-server ../desktop/backend-dist/darwin/ +cd .. + +# 3. Create .icns icon if not exists +if [ ! -f desktop/assets/icon.icns ]; then + echo "Creating macOS icon..." + mkdir -p icon.iconset + sips -z 16 16 desktop/assets/icon.png --out icon.iconset/icon_16x16.png + sips -z 32 32 desktop/assets/icon.png --out icon.iconset/icon_16x16@2x.png + sips -z 32 32 desktop/assets/icon.png --out icon.iconset/icon_32x32.png + sips -z 64 64 desktop/assets/icon.png --out icon.iconset/icon_32x32@2x.png + sips -z 128 128 desktop/assets/icon.png --out icon.iconset/icon_128x128.png + sips -z 256 256 desktop/assets/icon.png --out icon.iconset/icon_128x128@2x.png + sips -z 256 256 desktop/assets/icon.png --out icon.iconset/icon_256x256.png + sips -z 512 512 desktop/assets/icon.png --out icon.iconset/icon_256x256@2x.png + sips -z 512 512 desktop/assets/icon.png --out icon.iconset/icon_512x512.png + sips -z 1024 1024 desktop/assets/icon.png --out icon.iconset/icon_512x512@2x.png + iconutil -c icns icon.iconset -o desktop/assets/icon.icns + rm -rf icon.iconset +fi + +# 4. Build Electron app +echo "[3/4] Building Electron app..." +cd desktop +npm ci +npm run build:mac +cd .. + +# 5. Done +echo "[4/4] Build complete!" +ls -la desktop/dist/*.dmg 2>/dev/null || echo "DMG in desktop/dist/" diff --git a/installer/build-win.sh b/installer/build-win.sh new file mode 100644 index 0000000..42fd391 --- /dev/null +++ b/installer/build-win.sh @@ -0,0 +1,39 @@ +#!/bin/bash +set -e + +echo "=========================================" +echo " RFCP Desktop Build (Windows)" +echo "=========================================" + +cd "$(dirname "$0")/.." + +# 1. Build frontend +echo "[1/4] Building frontend..." +cd frontend +npm ci +npm run build +cd .. + +# 2. Build backend with PyInstaller +echo "[2/4] Building backend..." +cd backend +python -m pip install -r requirements.txt +python -m pip install pyinstaller +cd ../installer +python -m PyInstaller rfcp-server.spec --clean --noconfirm +mkdir -p ../desktop/backend-dist/win32 +cp dist/rfcp-server.exe ../desktop/backend-dist/win32/ +cd .. + +# 3. Build Electron app +echo "[3/4] Building Electron app..." +cd desktop +npm ci +npm run build:win +cd .. + +# 4. Done +echo "[4/4] Build complete!" +echo "" +echo "Output: desktop/dist/" +ls -la desktop/dist/*.exe 2>/dev/null || echo "Build artifacts in desktop/dist/" diff --git a/installer/rfcp-server.spec b/installer/rfcp-server.spec new file mode 100644 index 0000000..7d179ec --- /dev/null +++ b/installer/rfcp-server.spec @@ -0,0 +1,67 @@ +# -*- mode: python ; coding: utf-8 -*- +import sys +from pathlib import Path + +block_cipher = None +backend_path = Path('../backend') + +a = Analysis( + [str(backend_path / 'run_server.py')], + pathex=[str(backend_path)], + binaries=[], + datas=[ + (str(backend_path / 'app'), 'app'), + ], + hiddenimports=[ + 'uvicorn.logging', + 'uvicorn.protocols.http', + 'uvicorn.protocols.http.auto', + 'uvicorn.protocols.http.h11_impl', + 'uvicorn.protocols.websockets', + 'uvicorn.protocols.websockets.auto', + 'uvicorn.lifespan', + 'uvicorn.lifespan.on', + 'uvicorn.lifespan.off', + 'httpx', + 'h11', + 'numpy', + 'scipy', + 'scipy.special', + 'scipy.interpolate', + 'aiosqlite', + 'sqlalchemy', + 'sqlalchemy.dialects.sqlite', + ], + hookspath=[], + hooksconfig={}, + runtime_hooks=[], + excludes=['tkinter', 'matplotlib', 'PIL', 'pandas', 'IPython'], + win_no_prefer_redirects=False, + win_private_assemblies=False, + cipher=block_cipher, + noarchive=False, +) + +pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher) + +exe = EXE( + pyz, + a.scripts, + a.binaries, + a.zipfiles, + a.datas, + [], + name='rfcp-server', + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=True, + upx_exclude=[], + runtime_tmpdir=None, + console=False, # No console window + disable_windowed_traceback=False, + target_arch=None, + codesign_identity=None, + entitlements_file=None, + icon='../desktop/assets/icon.ico' if sys.platform == 'win32' else None, +)