nordvpn-ui/SPEC.md
2026-03-20 02:17:22 +09:00

289 lines
No EOL
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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)
- User-draggable panel splitter
- Additional map projections (Mercator, Robinson) — trait is ready
- System tray integration
- Dark/light theme toggle
- Keyboard navigation