293 lines
No EOL
12 KiB
Markdown
293 lines
No EOL
12 KiB
Markdown
# 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
|
||
|
||
```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 <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
|
||
|
||
```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 {
|
||
<<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
|
||
|
||
```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 `<!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 |