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

28 KiB

RFCP Phase 2.1: Desktop Application (Windows, Linux, macOS)

Date: January 31, 2025
Type: Packaging & Distribution
Estimated: 16-20 hours
Priority: HIGH — main product delivery


🎯 Goal

Package RFCP as standalone desktop application for Windows, Linux, and macOS. Fully offline after initial region download. No VPS, no MongoDB, no internet required.


📊 Target Specs

Metric Target
Installer size 200-300 MB
Installed size 500MB - 1GB (without map data)
With Ukraine region ~3.5 GB
Platforms Windows 10/11, Ubuntu 22.04+, macOS 12+
Offline Full offline after region download
GPU Optional CUDA acceleration (Win/Linux)

🏗️ Architecture

┌─────────────────────────────────────────────────┐
│              RFCP Desktop App                   │
├─────────────────────────────────────────────────┤
│  ┌─────────────────────────────────────────┐    │
│  │         Electron Shell                  │    │
│  │    (Chromium + Node.js runtime)         │    │
│  └──────────────────┬──────────────────────┘    │
│                     │                           │
│  ┌──────────────────▼──────────────────────┐    │
│  │       React Frontend (bundled)          │    │
│  └──────────────────┬──────────────────────┘    │
│                     │ http://localhost:8888     │
│  ┌──────────────────▼──────────────────────┐    │
│  │    FastAPI Backend (PyInstaller exe)    │    │
│  │    ┌────────────────────────────────┐   │    │
│  │    │  Propagation Engine            │   │    │
│  │    │  ├── CPU (NumPy/SciPy)         │   │    │
│  │    │  └── GPU (CuPy) [optional]     │   │    │
│  │    └────────────────────────────────┘   │    │
│  └──────────────────┬──────────────────────┘    │
│                     │                           │
│  ┌──────────────────▼──────────────────────┐    │
│  │         Local Data Store                │    │
│  │  ├── SQLite (projects, settings)        │    │
│  │  ├── SRTM tiles (~25MB each)            │    │
│  │  ├── OSM cache (buildings, water, veg)  │    │
│  │  └── Map tiles (optional offline maps)  │    │
│  └─────────────────────────────────────────┘    │
└─────────────────────────────────────────────────┘

📁 Project Structure

rfcp/
├── frontend/                 # React (existing)
├── backend/                  # FastAPI (existing)
├── desktop/                  # NEW — Electron app
│   ├── main.js               # Electron main process
│   ├── preload.js            # Context bridge
│   ├── splash.html           # Loading screen
│   ├── package.json          # Electron deps + build config
│   └── assets/
│       ├── icon.ico          # Windows icon (256x256)
│       ├── icon.png          # Linux icon (512x512)
│       └── icon.icns         # macOS icon
├── installer/                # NEW — Build scripts
│   ├── build-win.sh          # Windows build script
│   ├── build-linux.sh        # Linux build script
│   ├── build-mac.sh          # macOS build script
│   ├── build-all.sh          # Build all platforms
│   └── rfcp-server.spec      # PyInstaller spec
├── docs/
├── scripts/
└── CLAUDE.md

📁 Installed Directory Structure

Windows

C:\Program Files\RFCP\
├── RFCP.exe                 # Electron app
├── resources/
│   ├── app.asar             # Frontend bundle
│   └── backend/
│       └── rfcp-server.exe  # PyInstaller bundle
└── Uninstall RFCP.exe

%APPDATA%\RFCP\              # User data
├── rfcp.db                  # SQLite database
├── terrain/                 # SRTM tiles
├── osm/                     # OSM cache
└── projects/                # User projects

Linux

/opt/RFCP/                   # AppImage extracts here
├── rfcp                     # Main binary
└── resources/
    └── ...

~/.local/share/RFCP/         # User data
├── rfcp.db
├── terrain/
├── osm/
└── projects/

macOS

/Applications/RFCP.app/      # App bundle
└── Contents/
    ├── MacOS/rfcp           # Main binary
    ├── Resources/
    │   ├── backend/
    │   └── frontend/
    └── Info.plist

~/Library/Application Support/RFCP/  # User data
├── rfcp.db
├── terrain/
├── osm/
└── projects/

Tasks

Task 2.1.1: Project Setup (1-2 hours)

Create desktop/package.json:

{
  "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" }
      ]
    }
  }
}

Task 2.1.2: Electron Main Process (3-4 hours)

desktop/main.js:

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;

// 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, path) => shell.openPath(path));

Task 2.1.3: Preload Script (1 hour)

desktop/preload.js:

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',
});

Task 2.1.4: Splash Screen (30 min)

desktop/splash.html:

<!DOCTYPE html>
<html>
<head>
  <meta charset="UTF-8">
  <title>RFCP Loading</title>
  <style>
    * { margin: 0; padding: 0; box-sizing: border-box; }
    
    body {
      font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
      background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
      color: white;
      height: 100vh;
      display: flex;
      flex-direction: column;
      align-items: center;
      justify-content: center;
      -webkit-app-region: drag;
      user-select: none;
      border-radius: 12px;
    }
    
    .logo {
      font-size: 48px;
      font-weight: bold;
      margin-bottom: 8px;
      background: linear-gradient(90deg, #00d4ff, #00ff88);
      -webkit-background-clip: text;
      -webkit-text-fill-color: transparent;
    }
    
    .subtitle {
      font-size: 14px;
      color: #888;
      margin-bottom: 30px;
    }
    
    .loader {
      width: 200px;
      height: 4px;
      background: #333;
      border-radius: 2px;
      overflow: hidden;
    }
    
    .loader-bar {
      height: 100%;
      width: 30%;
      background: linear-gradient(90deg, #00d4ff, #00ff88);
      border-radius: 2px;
      animation: loading 1.5s ease-in-out infinite;
    }
    
    @keyframes loading {
      0% { transform: translateX(-100%); }
      100% { transform: translateX(400%); }
    }
    
    .status {
      margin-top: 15px;
      font-size: 12px;
      color: #666;
    }
    
    .version {
      position: absolute;
      bottom: 15px;
      font-size: 11px;
      color: #444;
    }
  </style>
</head>
<body>
  <div class="logo">RFCP</div>
  <div class="subtitle">RF Coverage Planner</div>
  <div class="loader">
    <div class="loader-bar"></div>
  </div>
  <div class="status">Starting backend server...</div>
  <div class="version">v1.6.1</div>
</body>
</html>

Task 2.1.5: PyInstaller Backend Bundle (3-4 hours)

backend/run_server.py (new entry point):

"""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',
    )

installer/rfcp-server.spec:

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

Task 2.1.6: Build Scripts (2-3 hours)

installer/build-win.sh:

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

installer/build-linux.sh:

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

installer/build-mac.sh:

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

Task 2.1.7: Frontend Desktop Detection (1 hour)

frontend/src/lib/desktop.ts:

interface RFCPDesktop {
  getDataPath: () => Promise<string>;
  getAppVersion: () => Promise<string>;
  getPlatform: () => Promise<string>;
  getGpuInfo: () => Promise<{
    available: boolean;
    name: string | null;
    cudaVersion: string | null;
  }>;
  selectDirectory: () => Promise<string | null>;
  selectFile: (options?: { filters?: Array<{ name: string; extensions: string[] }> }) => Promise<string | null>;
  saveFile: (options?: { defaultPath?: string; filters?: Array<{ name: string; extensions: string[] }> }) => Promise<string | null>;
  getSetting: (key: string) => Promise<any>;
  setSetting: (key: string, value: any) => Promise<void>;
  openExternal: (url: string) => Promise<void>;
  openPath: (path: string) => Promise<void>;
  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;
};

Update frontend/src/services/api.ts:

import { getApiBaseUrl } from '../lib/desktop';

const API_BASE = getApiBaseUrl();
// ... rest remains same

🧪 Testing

Development Testing

# Terminal 1: Backend
cd backend
uvicorn app.main:app --host 127.0.0.1 --port 8888 --reload

# Terminal 2: Frontend  
cd frontend
npm run dev

# Terminal 3: Electron
cd desktop
npm run dev

Build Testing

Windows:

cd installer
./build-win.sh
# Test: desktop/dist/RFCP Setup 1.6.1.exe

Linux:

cd installer
./build-linux.sh
# Test: desktop/dist/RFCP-1.6.1.AppImage

macOS (run on Mac):

cd installer
./build-mac.sh
# Test: desktop/dist/RFCP-1.6.1.dmg

Checklist

  • Installer runs without errors
  • Backend starts automatically (check splash → main window)
  • Frontend loads in Electron window
  • Coverage calculation works
  • Data persists between sessions (SQLite)
  • Window state persists (size, position)
  • File dialogs work (import/export)
  • External links open in browser

📦 Deliverables

Platform File Size
Windows RFCP-Setup-1.6.1.exe ~250MB
Linux RFCP-1.6.1.AppImage ~200MB
Linux RFCP-1.6.1.deb ~200MB
macOS RFCP-1.6.1.dmg ~220MB
macOS RFCP-1.6.1-mac.zip ~210MB

🔜 Future (2.2+)

  • First-run region download wizard
  • GPU acceleration toggle in settings
  • Auto-updater (electron-updater)
  • Portable mode (no install, run from USB)
  • Code signing (Windows/macOS)
  • Notarization (macOS)

📝 Notes

  • macOS signing: Without Apple Developer account ($99/yr), users see "unverified developer" warning. They can bypass via System Preferences → Security.
  • SQLite replaces MongoDB — simpler, no server needed
  • SRTM tiles ~25MB each, Ukraine needs ~120 tiles = ~3GB
  • Icons: Need 512x512 PNG source, scripts generate .ico and .icns

Ready for Claude Code 🚀