initial spec doc
This commit is contained in:
parent
5a9378a0cc
commit
de16a4f185
1 changed files with 289 additions and 0 deletions
289
SPEC.md
Normal file
289
SPEC.md
Normal file
|
|
@ -0,0 +1,289 @@
|
|||
# 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)
|
||||
|
||||
- User-draggable panel splitter
|
||||
- Additional map projections (Mercator, Robinson) — trait is ready
|
||||
- System tray integration
|
||||
- Dark/light theme toggle
|
||||
- Keyboard navigation
|
||||
Loading…
Reference in a new issue