2026-03-20 02:17:22 +09:00
|
|
|
|
# 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)
|
|
|
|
|
|
|
2026-03-30 22:14:11 +09:00
|
|
|
|
- **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.
|
2026-03-20 02:17:22 +09:00
|
|
|
|
- User-draggable panel splitter
|
|
|
|
|
|
- Additional map projections (Mercator, Robinson) — trait is ready
|
|
|
|
|
|
- System tray integration
|
|
|
|
|
|
- Dark/light theme toggle
|
|
|
|
|
|
- Keyboard navigation
|