731 lines
20 KiB
Markdown
731 lines
20 KiB
Markdown
# RFCP Phase 2.1: Desktop Application & Installer
|
||
|
||
**Date:** January 31, 2025
|
||
**Type:** Packaging & Distribution
|
||
**Estimated:** 20-30 hours
|
||
**Priority:** After frontend-backend integration (1.5)
|
||
|
||
---
|
||
|
||
## 🎯 Goal
|
||
|
||
Package RFCP as standalone desktop application with installer for Windows and Linux. Fully offline capable after initial setup.
|
||
|
||
---
|
||
|
||
## 📊 Target Specs
|
||
|
||
| Metric | Target |
|
||
|--------|--------|
|
||
| Installer size | 200-300 MB |
|
||
| Installed size | 500MB - 1GB |
|
||
| Platforms | Windows 10/11, Ubuntu 22.04+ |
|
||
| Offline | Full offline after region download |
|
||
| GPU | Optional, configurable in settings |
|
||
|
||
---
|
||
|
||
## 🏗️ Architecture
|
||
|
||
```
|
||
┌─────────────────────────────────────────────┐
|
||
│ RFCP Desktop App │
|
||
├─────────────────────────────────────────────┤
|
||
│ ┌─────────────────────────────────────┐ │
|
||
│ │ Electron Shell │ │
|
||
│ │ (Chromium + Node.js runtime) │ │
|
||
│ └──────────────┬──────────────────────┘ │
|
||
│ │ │
|
||
│ ┌──────────────▼──────────────────────┐ │
|
||
│ │ React Frontend (UI) │ │
|
||
│ │ localhost:5173 (dev) / bundled │ │
|
||
│ └──────────────┬──────────────────────┘ │
|
||
│ │ HTTP API │
|
||
│ ┌──────────────▼──────────────────────┐ │
|
||
│ │ FastAPI Backend (Python) │ │
|
||
│ │ localhost:8888 │ │
|
||
│ │ ┌────────────────────────────┐ │ │
|
||
│ │ │ Propagation Engine │ │ │
|
||
│ │ │ ├── CPU (default) │ │ │
|
||
│ │ │ └── GPU (optional/CUDA) │ │ │
|
||
│ │ └────────────────────────────┘ │ │
|
||
│ └──────────────┬──────────────────────┘ │
|
||
│ │ │
|
||
│ ┌──────────────▼──────────────────────┐ │
|
||
│ │ Local Data Store │ │
|
||
│ │ ├── SQLite (projects, settings) │ │
|
||
│ │ ├── SRTM tiles (elevation) │ │
|
||
│ │ └── OSM cache (buildings, roads) │ │
|
||
│ └─────────────────────────────────────┘ │
|
||
└─────────────────────────────────────────────┘
|
||
```
|
||
|
||
---
|
||
|
||
## 📁 Directory Structure
|
||
|
||
### Development
|
||
```
|
||
rfcp/
|
||
├── electron/
|
||
│ ├── main.js # Electron main process
|
||
│ ├── preload.js # Context bridge
|
||
│ ├── package.json # Electron deps
|
||
│ └── build/ # electron-builder config
|
||
│ ├── icon.ico
|
||
│ ├── icon.png
|
||
│ └── installer.nsh # NSIS customization
|
||
├── frontend/ # React (existing)
|
||
├── backend/ # FastAPI (existing)
|
||
└── scripts/
|
||
├── build-windows.sh
|
||
├── build-linux.sh
|
||
└── package-python.sh
|
||
```
|
||
|
||
### Installed (Windows)
|
||
```
|
||
C:\Program Files\RFCP\
|
||
├── RFCP.exe # Electron app
|
||
├── resources/
|
||
│ ├── app.asar # Frontend bundle
|
||
│ └── backend/
|
||
│ ├── rfcp-server.exe # PyInstaller bundle
|
||
│ └── app/ # Python code
|
||
├── data/
|
||
│ ├── rfcp.db # SQLite database
|
||
│ ├── srtm/ # Elevation tiles
|
||
│ ├── osm/ # OSM cache
|
||
│ └── projects/ # User projects
|
||
├── python/ # Embedded Python (if not PyInstaller)
|
||
└── Uninstall RFCP.exe
|
||
```
|
||
|
||
### Installed (Linux)
|
||
```
|
||
/opt/rfcp/
|
||
├── rfcp # AppImage or binary
|
||
├── resources/
|
||
│ └── ...
|
||
└── data/
|
||
└── ...
|
||
|
||
~/.local/share/rfcp/ # User data
|
||
├── rfcp.db
|
||
├── srtm/
|
||
├── osm/
|
||
└── projects/
|
||
```
|
||
|
||
---
|
||
|
||
## ✅ Tasks
|
||
|
||
### Task 2.1.1: Electron Shell (4-6 hours)
|
||
|
||
**electron/main.js:**
|
||
```javascript
|
||
const { app, BrowserWindow, ipcMain } = require('electron');
|
||
const { spawn } = require('child_process');
|
||
const path = require('path');
|
||
const fs = require('fs');
|
||
|
||
let mainWindow;
|
||
let backendProcess;
|
||
|
||
// Paths
|
||
const isDev = process.env.NODE_ENV === 'development';
|
||
const backendPath = isDev
|
||
? path.join(__dirname, '../backend')
|
||
: path.join(process.resourcesPath, 'backend');
|
||
|
||
const dataPath = isDev
|
||
? path.join(__dirname, '../data')
|
||
: path.join(app.getPath('userData'), 'data');
|
||
|
||
// Ensure data directories exist
|
||
function ensureDataDirs() {
|
||
const dirs = ['srtm', 'osm', 'projects'];
|
||
dirs.forEach(dir => {
|
||
const fullPath = path.join(dataPath, dir);
|
||
if (!fs.existsSync(fullPath)) {
|
||
fs.mkdirSync(fullPath, { recursive: true });
|
||
}
|
||
});
|
||
}
|
||
|
||
// Start Python backend
|
||
function startBackend() {
|
||
const pythonExe = isDev
|
||
? 'python'
|
||
: path.join(process.resourcesPath, 'backend', 'rfcp-server.exe');
|
||
|
||
const env = {
|
||
...process.env,
|
||
RFCP_DATA_PATH: dataPath,
|
||
RFCP_PORT: '8888'
|
||
};
|
||
|
||
if (isDev) {
|
||
backendProcess = spawn(pythonExe, ['-m', 'uvicorn', 'app.main:app', '--port', '8888'], {
|
||
cwd: backendPath,
|
||
env
|
||
});
|
||
} else {
|
||
backendProcess = spawn(pythonExe, [], { env });
|
||
}
|
||
|
||
backendProcess.stdout.on('data', (data) => {
|
||
console.log(`Backend: ${data}`);
|
||
});
|
||
|
||
backendProcess.stderr.on('data', (data) => {
|
||
console.error(`Backend Error: ${data}`);
|
||
});
|
||
|
||
return new Promise((resolve) => {
|
||
// Wait for backend to be ready
|
||
const checkBackend = setInterval(async () => {
|
||
try {
|
||
const response = await fetch('http://localhost:8888/api/health/');
|
||
if (response.ok) {
|
||
clearInterval(checkBackend);
|
||
resolve();
|
||
}
|
||
} catch (e) {
|
||
// Not ready yet
|
||
}
|
||
}, 500);
|
||
|
||
// Timeout after 30s
|
||
setTimeout(() => {
|
||
clearInterval(checkBackend);
|
||
resolve(); // Continue anyway
|
||
}, 30000);
|
||
});
|
||
}
|
||
|
||
// Create window
|
||
async function createWindow() {
|
||
ensureDataDirs();
|
||
|
||
// Show splash while loading
|
||
const splash = new BrowserWindow({
|
||
width: 400,
|
||
height: 300,
|
||
frame: false,
|
||
alwaysOnTop: true,
|
||
transparent: true
|
||
});
|
||
splash.loadFile('splash.html');
|
||
|
||
// Start backend
|
||
await startBackend();
|
||
|
||
// Create main window
|
||
mainWindow = new BrowserWindow({
|
||
width: 1400,
|
||
height: 900,
|
||
minWidth: 1024,
|
||
minHeight: 768,
|
||
webPreferences: {
|
||
nodeIntegration: false,
|
||
contextIsolation: true,
|
||
preload: path.join(__dirname, 'preload.js')
|
||
},
|
||
icon: path.join(__dirname, 'build/icon.png'),
|
||
title: 'RFCP - RF Coverage Planner'
|
||
});
|
||
|
||
// Load frontend
|
||
if (isDev) {
|
||
mainWindow.loadURL('http://localhost:5173');
|
||
mainWindow.webContents.openDevTools();
|
||
} else {
|
||
mainWindow.loadFile(path.join(process.resourcesPath, 'frontend', 'index.html'));
|
||
}
|
||
|
||
// Close splash
|
||
splash.close();
|
||
|
||
mainWindow.on('closed', () => {
|
||
mainWindow = null;
|
||
});
|
||
}
|
||
|
||
// App lifecycle
|
||
app.whenReady().then(createWindow);
|
||
|
||
app.on('window-all-closed', () => {
|
||
if (backendProcess) {
|
||
backendProcess.kill();
|
||
}
|
||
if (process.platform !== 'darwin') {
|
||
app.quit();
|
||
}
|
||
});
|
||
|
||
app.on('activate', () => {
|
||
if (mainWindow === null) {
|
||
createWindow();
|
||
}
|
||
});
|
||
|
||
// IPC handlers
|
||
ipcMain.handle('get-data-path', () => dataPath);
|
||
ipcMain.handle('get-gpu-info', () => {
|
||
// Detect GPU availability
|
||
// Could use node-gpu or check CUDA
|
||
return {
|
||
available: false, // TODO: implement detection
|
||
name: null
|
||
};
|
||
});
|
||
```
|
||
|
||
**electron/preload.js:**
|
||
```javascript
|
||
const { contextBridge, ipcRenderer } = require('electron');
|
||
|
||
contextBridge.exposeInMainWorld('rfcp', {
|
||
getDataPath: () => ipcRenderer.invoke('get-data-path'),
|
||
getGpuInfo: () => ipcRenderer.invoke('get-gpu-info'),
|
||
platform: process.platform,
|
||
version: require('./package.json').version
|
||
});
|
||
```
|
||
|
||
---
|
||
|
||
### Task 2.1.2: Python Packaging (4-6 hours)
|
||
|
||
**PyInstaller spec file (rfcp-server.spec):**
|
||
```python
|
||
# -*- mode: python ; coding: utf-8 -*-
|
||
|
||
block_cipher = None
|
||
|
||
a = Analysis(
|
||
['app/main.py'],
|
||
pathex=[],
|
||
binaries=[],
|
||
datas=[
|
||
('app', 'app'),
|
||
],
|
||
hiddenimports=[
|
||
'uvicorn.logging',
|
||
'uvicorn.protocols.http',
|
||
'uvicorn.protocols.http.auto',
|
||
'uvicorn.protocols.websockets',
|
||
'uvicorn.protocols.websockets.auto',
|
||
'uvicorn.lifespan.on',
|
||
'uvicorn.lifespan.off',
|
||
],
|
||
hookspath=[],
|
||
hooksconfig={},
|
||
runtime_hooks=[],
|
||
excludes=[],
|
||
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=True, # False for production
|
||
disable_windowed_traceback=False,
|
||
argv_emulation=False,
|
||
target_arch=None,
|
||
codesign_identity=None,
|
||
entitlements_file=None,
|
||
icon='../electron/build/icon.ico'
|
||
)
|
||
```
|
||
|
||
**Build script (scripts/package-python.sh):**
|
||
```bash
|
||
#!/bin/bash
|
||
set -e
|
||
|
||
cd backend
|
||
|
||
# Create venv for packaging
|
||
python -m venv build_env
|
||
source build_env/bin/activate # or build_env\Scripts\activate on Windows
|
||
|
||
# Install deps
|
||
pip install -r requirements.txt
|
||
pip install pyinstaller
|
||
|
||
# Build
|
||
pyinstaller rfcp-server.spec --clean
|
||
|
||
# Output in dist/rfcp-server.exe
|
||
echo "Built: dist/rfcp-server.exe"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 2.1.3: Electron Builder Config (3-4 hours)
|
||
|
||
**electron/package.json:**
|
||
```json
|
||
{
|
||
"name": "rfcp",
|
||
"version": "1.0.0",
|
||
"description": "RF Coverage Planner for Tactical Communications",
|
||
"main": "main.js",
|
||
"author": "UMTC Project",
|
||
"license": "MIT",
|
||
"scripts": {
|
||
"start": "electron .",
|
||
"build:win": "electron-builder --win",
|
||
"build:linux": "electron-builder --linux",
|
||
"build:all": "electron-builder --win --linux"
|
||
},
|
||
"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": "build"
|
||
},
|
||
"files": [
|
||
"main.js",
|
||
"preload.js",
|
||
"splash.html"
|
||
],
|
||
"extraResources": [
|
||
{
|
||
"from": "../frontend/dist",
|
||
"to": "frontend"
|
||
},
|
||
{
|
||
"from": "../backend/dist/rfcp-server",
|
||
"to": "backend"
|
||
}
|
||
],
|
||
"win": {
|
||
"target": [
|
||
{
|
||
"target": "nsis",
|
||
"arch": ["x64"]
|
||
}
|
||
],
|
||
"icon": "build/icon.ico"
|
||
},
|
||
"nsis": {
|
||
"oneClick": false,
|
||
"allowToChangeInstallationDirectory": true,
|
||
"installerIcon": "build/icon.ico",
|
||
"uninstallerIcon": "build/icon.ico",
|
||
"installerHeaderIcon": "build/icon.ico",
|
||
"createDesktopShortcut": true,
|
||
"createStartMenuShortcut": true,
|
||
"shortcutName": "RFCP"
|
||
},
|
||
"linux": {
|
||
"target": [
|
||
"AppImage",
|
||
"deb"
|
||
],
|
||
"icon": "build/icon.png",
|
||
"category": "Science"
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
### Task 2.1.4: First Run & Region Selection (4-6 hours)
|
||
|
||
**First run wizard:**
|
||
```
|
||
┌─────────────────────────────────────────┐
|
||
│ Welcome to RFCP │
|
||
│ RF Coverage Planner │
|
||
├─────────────────────────────────────────┤
|
||
│ │
|
||
│ Select your region for offline maps: │
|
||
│ │
|
||
│ ┌─────────────────────────────────┐ │
|
||
│ │ [x] Ukraine (~2.5 GB) │ │
|
||
│ │ [ ] Poland (~1.8 GB) │ │
|
||
│ │ [ ] Germany (~2.1 GB) │ │
|
||
│ │ [ ] Custom bounding box... │ │
|
||
│ └─────────────────────────────────┘ │
|
||
│ │
|
||
│ Data includes: │
|
||
│ • Terrain elevation (SRTM 30m) │
|
||
│ • Building footprints (OSM) │
|
||
│ • Road network (OSM) │
|
||
│ • Base map tiles │
|
||
│ │
|
||
│ [ ] Download now │
|
||
│ [ ] Download later (online mode) │
|
||
│ │
|
||
│ [Continue →] │
|
||
└─────────────────────────────────────────┘
|
||
```
|
||
|
||
**Region download API endpoint:**
|
||
```python
|
||
@router.post("/regions/download")
|
||
async def download_region(region: str, background_tasks: BackgroundTasks):
|
||
"""Start region download in background"""
|
||
|
||
REGIONS = {
|
||
"ukraine": {
|
||
"bbox": [44.0, 22.0, 52.5, 40.5], # S, W, N, E
|
||
"srtm_tiles": ["N44E022", "N44E023", ...], # ~120 tiles
|
||
"estimated_size": 2.5 * 1024 * 1024 * 1024 # 2.5 GB
|
||
},
|
||
# ...
|
||
}
|
||
|
||
if region not in REGIONS:
|
||
raise HTTPException(400, "Unknown region")
|
||
|
||
task_id = str(uuid4())
|
||
background_tasks.add_task(download_region_data, task_id, REGIONS[region])
|
||
|
||
return {"task_id": task_id, "status": "started"}
|
||
|
||
@router.get("/regions/progress/{task_id}")
|
||
async def get_download_progress(task_id: str):
|
||
"""Get download progress"""
|
||
return {
|
||
"task_id": task_id,
|
||
"status": "downloading", # started, downloading, extracting, done, error
|
||
"progress": 45.5, # percentage
|
||
"current_file": "N48E035.hgt",
|
||
"downloaded_mb": 1250,
|
||
"total_mb": 2500
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
### Task 2.1.5: Settings - GPU Configuration (2-3 hours)
|
||
|
||
**Settings panel addition:**
|
||
```typescript
|
||
// frontend/src/components/settings/PerformanceSettings.tsx
|
||
|
||
interface PerformanceSettingsProps {
|
||
gpuInfo: {
|
||
available: boolean;
|
||
name: string | null;
|
||
memory: number | null;
|
||
};
|
||
}
|
||
|
||
export function PerformanceSettings({ gpuInfo }: PerformanceSettingsProps) {
|
||
const [useGpu, setUseGpu] = useState(false);
|
||
const [maxWorkers, setMaxWorkers] = useState(4);
|
||
|
||
return (
|
||
<div className="settings-section">
|
||
<h3>Performance</h3>
|
||
|
||
<div className="setting-item">
|
||
<label>
|
||
<input
|
||
type="checkbox"
|
||
checked={useGpu}
|
||
onChange={(e) => setUseGpu(e.target.checked)}
|
||
disabled={!gpuInfo.available}
|
||
/>
|
||
Use GPU acceleration (CUDA)
|
||
</label>
|
||
{gpuInfo.available ? (
|
||
<span className="hint">Detected: {gpuInfo.name}</span>
|
||
) : (
|
||
<span className="hint warning">No compatible GPU detected</span>
|
||
)}
|
||
</div>
|
||
|
||
<div className="setting-item">
|
||
<label>CPU Workers</label>
|
||
<input
|
||
type="range"
|
||
min="1"
|
||
max="16"
|
||
value={maxWorkers}
|
||
onChange={(e) => setMaxWorkers(Number(e.target.value))}
|
||
/>
|
||
<span>{maxWorkers} threads</span>
|
||
</div>
|
||
|
||
<div className="setting-item">
|
||
<label>Default Propagation Model</label>
|
||
<select>
|
||
<option value="fast">Fast (terrain only)</option>
|
||
<option value="standard">Standard (+ buildings)</option>
|
||
<option value="detailed">Detailed (+ dominant path)</option>
|
||
<option value="full">Full (all models)</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
**Backend GPU detection:**
|
||
```python
|
||
# app/services/gpu_service.py
|
||
|
||
def detect_gpu() -> dict:
|
||
"""Detect available GPU for CUDA"""
|
||
result = {
|
||
"available": False,
|
||
"name": None,
|
||
"memory": None,
|
||
"cuda_version": None
|
||
}
|
||
|
||
try:
|
||
import cupy as cp
|
||
device = cp.cuda.Device(0)
|
||
props = cp.cuda.runtime.getDeviceProperties(0)
|
||
|
||
result["available"] = True
|
||
result["name"] = props["name"].decode()
|
||
result["memory"] = props["totalGlobalMem"] // (1024**3) # GB
|
||
result["cuda_version"] = cp.cuda.runtime.runtimeGetVersion()
|
||
except ImportError:
|
||
pass # CuPy not installed
|
||
except Exception as e:
|
||
print(f"GPU detection error: {e}")
|
||
|
||
return result
|
||
```
|
||
|
||
---
|
||
|
||
### Task 2.1.6: Build & Test (3-4 hours)
|
||
|
||
**Build script (scripts/build-windows.sh):**
|
||
```bash
|
||
#!/bin/bash
|
||
set -e
|
||
|
||
echo "=== RFCP Windows Build ==="
|
||
|
||
# 1. Build frontend
|
||
echo "Building frontend..."
|
||
cd frontend
|
||
npm run build
|
||
cd ..
|
||
|
||
# 2. Build backend
|
||
echo "Building backend..."
|
||
cd backend
|
||
python -m venv build_env
|
||
source build_env/Scripts/activate
|
||
pip install -r requirements.txt
|
||
pip install pyinstaller
|
||
pyinstaller rfcp-server.spec --clean
|
||
cd ..
|
||
|
||
# 3. Build Electron
|
||
echo "Building Electron app..."
|
||
cd electron
|
||
npm install
|
||
npm run build:win
|
||
cd ..
|
||
|
||
echo "=== Build complete ==="
|
||
echo "Installer: electron/dist/RFCP Setup*.exe"
|
||
```
|
||
|
||
**Test checklist:**
|
||
```markdown
|
||
## Install Test
|
||
- [ ] Installer runs without admin (user install)
|
||
- [ ] Installer runs with admin (program files)
|
||
- [ ] Desktop shortcut created
|
||
- [ ] Start menu entry created
|
||
- [ ] Uninstaller works
|
||
|
||
## First Run
|
||
- [ ] Splash screen appears
|
||
- [ ] Backend starts successfully
|
||
- [ ] Main window loads
|
||
- [ ] Region selection dialog shows
|
||
- [ ] Can skip region download
|
||
|
||
## Functionality
|
||
- [ ] Map loads (online tiles)
|
||
- [ ] Can create sites
|
||
- [ ] Coverage calculation works
|
||
- [ ] All presets work
|
||
- [ ] Settings persist
|
||
|
||
## Offline
|
||
- [ ] Works without internet (after region download)
|
||
- [ ] Offline tiles load
|
||
- [ ] Terrain data works
|
||
- [ ] Buildings/roads cached
|
||
|
||
## Performance
|
||
- [ ] GPU toggle appears (if GPU present)
|
||
- [ ] GPU acceleration works
|
||
- [ ] Memory usage reasonable (<1GB idle)
|
||
```
|
||
|
||
---
|
||
|
||
## 📦 Deliverables
|
||
|
||
1. **RFCP-Setup-1.0.0.exe** — Windows installer (~200MB)
|
||
2. **RFCP-1.0.0.AppImage** — Linux portable (~180MB)
|
||
3. **RFCP-1.0.0.deb** — Debian package (~180MB)
|
||
4. **SHA256SUMS** — Checksums file
|
||
5. **README.md** — Installation instructions
|
||
|
||
---
|
||
|
||
## 🔜 Future Enhancements (2.2+)
|
||
|
||
- [ ] Auto-updater (electron-updater)
|
||
- [ ] macOS support (.dmg)
|
||
- [ ] Portable mode (no install, run from USB)
|
||
- [ ] Silent install for deployment
|
||
- [ ] MSI installer for enterprise
|
||
- [ ] Code signing (Windows/macOS)
|
||
|
||
---
|
||
|
||
## 📝 Notes
|
||
|
||
- SQLite замість MongoDB для локального зберігання (простіше, не потребує сервера)
|
||
- SRTM tiles ~25MB кожен, Україна потребує ~120 tiles = ~3GB
|
||
- OSM дані можна завантажити з Geofabrik (ukraine-latest.osm.pbf ~1.5GB)
|
||
- Map tiles можна кешувати з OpenStreetMap або використати MBTiles
|
||
|
||
---
|
||
|
||
**Ready for implementation after 1.5** 🚀
|