# nordvpn-ui — Design Specification A Rust/egui NordVPN GUI wrapper. --- ## Goals - Single self-contained binary, no runtime dependencies beyond the `nordvpn` CLI - 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 ` 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 ```toml 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 ` (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 ```mermaid 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_rate` from 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 ```mermaid classDiagram class MapProjection { <> +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`. Adding a new projection = new struct + trait impl, no other changes required. ### Module structure ```mermaid 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 ```mermaid 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.svg` etc.), strip `` declarations (roxmltree/resvg rejects them), and rename to match nordvpn keys (`United_States.svg`) so `flags/{nordvpn_key}.svg` works 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