12 KiB
12 KiB
nordvpn-ui — Design Specification
A Rust/egui NordVPN GUI wrapper.
Goals
- Single self-contained binary, no runtime dependencies beyond the
nordvpnCLI - UX: Two-panel layout with interactive world map
- Clean architecture enabling future extension (projections, themes, etc.)
Functional Requirements
Core features
| Feature | Detail |
|---|---|
| Connect | Run nordvpn connect <alpha2_code> when user selects a country |
| Disconnect | Run nordvpn disconnect via Disconnect button |
| Status | Poll nordvpn status on a configurable interval; parse and display key fields |
| Country list | Fetch from nordvpn countries, map names → ISO alpha-2; static fallback if CLI unavailable |
| Favorites | User-defined list of country codes, shown first/highlighted in selector |
| Config persistence | Load/save config.toml next to the binary |
| Config dialog | Edit nordvpn CLI path, favorites, refresh rate |
Status fields displayed
Status · Server · IP · City · Current protocol · Transfer · Uptime
Config schema
external_app = "nordvpn" # path to nordvpn CLI
refresh_rate = 5 # seconds between status polls (1–60)
favorites = ["US", "GB", "JP", "DE", "AU", "KR", "CA", "FR"]
UX / Layout
┌──────────────────────────────────────────────────────────────┐
│ Status: Connected · United States · 1.2.3.4 · Uptime: 4h │ ← status panel
├─────────────────────┬────────────────────────────────────────┤
│ [Grid] [List] │ │
│ 🔍 Filter... │ │
│ ── Favorites ── │ Interactive │
│ 🇺🇸 🇬🇧 🇩🇪 🇯🇵 │ World Map │
│ ── All ───────── │ (click country to connect) │
│ 🇦🇱 🇩🇿 🇦🇩 🇦🇴 │ │
│ 🇦🇷 🇦🇲 🇦🇺 🇦🇹 │ │
│ ... │ │
├─────────────────────┴────────────────────────────────────────┤
│ [Disconnect] [Configure] │
└──────────────────────────────────────────────────────────────┘
Panel split
- Fixed 30/70 ratio (left selector / right map)
- Future: user-draggable splitter
Left panel — country selector
Two modes toggled by [Grid] / [List] buttons at the top:
| Mode | Behavior |
|---|---|
| Grid | Flag SVG icons (40×40 px), responsive columns, favorites section first |
| List | Flag icon (24×24) + country name per row, favorites marked with ★ |
- Search/filter input at top; filters both modes live
- Favorites always appear before the full list, with a visual separator
Right panel — world map
| Interaction | Behavior |
|---|---|
| Hover country | Highlight + tooltip with country name |
| Click country | nordvpn connect <code> (if country is available) |
| Scroll wheel | Zoom in/out |
| Click + drag | Pan |
Country color states
| State | Color |
|---|---|
| Not available in nordvpn | Dark grey |
| Available | Muted teal |
| Hovered | Light teal |
| Connected | Bright green |
Bottom bar
[Disconnect] on the left · [Configure] on the right
Connect feedback
Inline — the status panel reflects the new connection state within one polling interval. No modal dialogs for connect/disconnect results.
Config dialog
Opened via [Configure] button. Rendered as an egui::Window modal (not an OS dialog).
Fields:
- External app path (text input)
- Favorites (list with Add / Remove; country code entry)
- Refresh rate (drag value or spinner, 1–60 s)
Architecture
Threading model
graph TD
UI[UI Thread\nApp struct]
SP[Status Poller Thread\nsleeps refresh_rate s]
CT[Connect/Disconnect Thread\none-shot per action]
SM[Arc<Mutex<StatusInfo>>]
CH[mpsc::Receiver<CmdResult>]
SP -->|writes| SM
UI -->|reads each frame| SM
CT -->|sends result| CH
UI -->|drains each frame| CH
UI -->|spawns| CT
- Status poller: long-lived background thread; re-reads
refresh_ratefrom config after each sleep - Connect/Disconnect: short-lived thread spawned on button click; result sent back via channel
- UI thread: reads shared state each frame — no blocking calls on the render thread
Map projection abstraction
classDiagram
class MapProjection {
<<trait>>
+project(lon: f64, lat: f64, rect: Rect) Pos2
+unproject(pos: Pos2, rect: Rect) (f64, f64)
+name() &'static str
}
class Equirectangular
class Mercator["Mercator (future)"]
class Robinson["Robinson (future)"]
MapProjection <|-- Equirectangular
MapProjection <|-- Mercator
MapProjection <|-- Robinson
WorldMap holds a Box<dyn MapProjection>. Adding a new projection = new struct + trait impl, no other changes required.
Module structure
graph LR
main --> app
app --> config
app --> nordvpn
app --> world_map
app --> ui
world_map --> projection
world_map --> countries
ui --> status_panel
ui --> country_selector
ui --> config_dialog
config_dialog --> config
country_selector --> countries
File Layout
nordvpn-ui/
├── Cargo.toml
├── SPEC.md # this document
├── config.toml # user config (gitignored)
├── flags/ # SVG flag files named by nordvpn key e.g. United_States.svg
├── assets/
│ └── world.geojson # Natural Earth 110m countries, bundled via include_bytes!
└── src/
├── main.rs # eframe setup, window config
├── app.rs # App struct + eframe::App impl, top-level layout
├── config.rs # Config struct, load/save (serde + toml)
├── countries.rs # parse nordvpn countries output; Country { name, nordvpn_key }
├── nordvpn.rs # CLI wrapper: connect, disconnect, status, parse_status
├── projection.rs # MapProjection trait + Equirectangular impl
├── world_map.rs # GeoJSON parse, earcutr tessellation, render, hit-test
└── ui/
├── mod.rs
├── status_panel.rs # top bar rendering
├── country_selector.rs # left panel: grid/list toggle, search, favorites
└── config_dialog.rs # modal config window
Dependencies
All added via cargo add (latest stable at time of addition).
| Crate | Features | Purpose | Phase |
|---|---|---|---|
eframe |
default | egui application framework | 1 |
serde |
derive | serialization | 1 |
toml |
config file format | 1 | |
egui_extras |
image, svg | flag SVG rendering | 4 |
image |
png (no-default-features) | image loading support | 4 |
serde_json |
GeoJSON parsing | 5 | |
earcutr |
polygon tessellation for map | 5 |
No async runtime. Threading via std::thread + std::sync::{mpsc, Arc, Mutex}.
Implementation Phases
gantt
title Implementation Phases
dateFormat X
axisFormat Phase %s
section Foundation
Config struct + TOML load/save + window opens :1, 2
nordvpn CLI wrapper + status polling thread :2, 3
section Left Panel
Country list (text labels) + connect/disconnect :3, 4
Flag SVGs + grid/list toggle + search/filter :4, 5
section World Map
GeoJSON parse + projection trait + flat render :5, 6
Pan/zoom + hover + click-to-connect + colors :6, 7
section Config & Polish
Config dialog modal :7, 8
Unit tests :8, 9
Polish: status formatting, edge cases :9, 10
Phase detail
| # | Deliverable |
|---|---|
| 1 | config.rs, main.rs skeleton, window opens, config.toml round-trips |
| 2 | nordvpn.rs: connect(), disconnect(), status(), parse_status(), polling thread |
| 3 | country_selector.rs (text only), favorites ordering, connect/disconnect wired |
| 4 | Flag SVG loading + texture cache, grid/list toggle, live search filter |
| 5 | projection.rs trait + Equirectangular, world_map.rs GeoJSON + tessellation + flat render |
| 6 | Map pan/zoom, hover highlight, click-to-connect, color states by availability |
| 7 | config_dialog.rs modal, full settings flow, apply restarts poller |
| 8 | Unit tests (see below) |
| 9 | Polish pass |
Unit Tests (Phase 8)
| Module | Test cases |
|---|---|
config.rs |
Roundtrip serialize/deserialize; missing fields receive defaults |
countries.rs |
Known name → alpha-2 mappings; edge cases (Turkey, Lao Peoples Democratic Republic); fallback list parses without errors |
nordvpn.rs |
parse_status() with well-formed output; empty output returns "Not Connected"; partial/malformed output doesn't panic |
projection.rs |
project → unproject roundtrip within float epsilon; corner coords (±180 lon, ±90 lat); rect scaling |
world_map.rs |
Point-in-polygon: inside, outside, on edge; multi-polygon country hit-test |
Future Enhancements (out of scope)
- Flags setup script: download SVGs from https://github.com/emcrisostomo/flags (public domain,
Wikimedia Commons mirror; files named
US.svgetc.), strip<!DOCTYPE …>declarations (roxmltree/resvg rejects them), and rename to match nordvpn keys (United_States.svg) soflags/{nordvpn_key}.svgworks with no runtime lookup. Run once at dev setup; not part of the binary. - User-draggable panel splitter
- Additional map projections (Mercator, Robinson) — trait is ready
- System tray integration
- Dark/light theme toggle
- Keyboard navigation