nordvpn-ui/SPEC.md
2026-03-30 22:14:11 +09:00

12 KiB
Raw Blame History

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 <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&lt;Mutex&lt;StatusInfo&gt;&gt;]
    CH[mpsc::Receiver&lt;CmdResult&gt;]

    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

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.svg etc.), strip <!DOCTYPE …> 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