nordvpn-ui/SPEC.md

293 lines
12 KiB
Markdown
Raw Normal View History

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&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
```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