Files
rfcp/RFCP-Phase-2.1-Desktop-Installer.md

20 KiB
Raw Blame History

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:

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:

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):

# -*- 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):

#!/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:

{
  "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:

@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:

// 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:

# 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):

#!/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:

## 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 🚀