implement phase 1~3
This commit is contained in:
parent
de16a4f185
commit
c60f3b2ab0
7 changed files with 913 additions and 0 deletions
148
src/app.rs
Normal file
148
src/app.rs
Normal file
|
|
@ -0,0 +1,148 @@
|
||||||
|
use std::sync::mpsc::{self, Sender};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use eframe::egui;
|
||||||
|
|
||||||
|
use crate::config::Config;
|
||||||
|
use crate::countries::{get_countries, Country};
|
||||||
|
use crate::nordvpn::{self, CmdResult, Poller};
|
||||||
|
use crate::ui::country_selector::CountrySelector;
|
||||||
|
|
||||||
|
pub struct App {
|
||||||
|
config: Config,
|
||||||
|
countries: Vec<Country>,
|
||||||
|
poller: Poller,
|
||||||
|
cmd_tx: Sender<CmdResult>,
|
||||||
|
cmd_rx: mpsc::Receiver<CmdResult>,
|
||||||
|
selector: CountrySelector,
|
||||||
|
/// Last connect/disconnect result shown inline below the status bar.
|
||||||
|
notification: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl App {
|
||||||
|
pub fn new(_cc: &eframe::CreationContext<'_>) -> Self {
|
||||||
|
let config = Config::load();
|
||||||
|
let countries = get_countries(&config.external_app);
|
||||||
|
let poller = Poller::new(config.external_app.clone(), config.refresh_rate);
|
||||||
|
let (cmd_tx, cmd_rx) = mpsc::channel();
|
||||||
|
|
||||||
|
Self {
|
||||||
|
config,
|
||||||
|
countries,
|
||||||
|
poller,
|
||||||
|
cmd_tx,
|
||||||
|
cmd_rx,
|
||||||
|
selector: CountrySelector::default(),
|
||||||
|
notification: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn spawn_connect(&self, nordvpn_key: String) {
|
||||||
|
let app = self.config.external_app.clone();
|
||||||
|
let tx = self.cmd_tx.clone();
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
let _ = tx.send(nordvpn::connect(&app, &nordvpn_key));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn spawn_disconnect(&self) {
|
||||||
|
let app = self.config.external_app.clone();
|
||||||
|
let tx = self.cmd_tx.clone();
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
let _ = tx.send(nordvpn::disconnect(&app));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
impl eframe::App for App {
|
||||||
|
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
||||||
|
// Drain completed command results.
|
||||||
|
while let Ok(result) = self.cmd_rx.try_recv() {
|
||||||
|
self.notification = Some(match result {
|
||||||
|
CmdResult::Success { output, .. } => output.trim().to_string(),
|
||||||
|
CmdResult::Failure { cmd, message } => {
|
||||||
|
format!("Error running '{cmd}': {message}")
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Repaint often enough to stay live without busy-looping.
|
||||||
|
ctx.request_repaint_after(Duration::from_secs(1));
|
||||||
|
|
||||||
|
let status = self.poller.status.lock().unwrap().clone();
|
||||||
|
|
||||||
|
// ── Status panel ────────────────────────────────────────────────────
|
||||||
|
egui::TopBottomPanel::top("status_panel")
|
||||||
|
.min_height(40.0)
|
||||||
|
.show(ctx, |ui| {
|
||||||
|
ui.add_space(4.0);
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
ui.strong(&status.status);
|
||||||
|
if let Some(country) = &status.country {
|
||||||
|
ui.separator();
|
||||||
|
ui.label(country);
|
||||||
|
}
|
||||||
|
if let Some(ip) = &status.ip {
|
||||||
|
ui.separator();
|
||||||
|
ui.label(ip);
|
||||||
|
}
|
||||||
|
if let Some(uptime) = &status.uptime {
|
||||||
|
ui.separator();
|
||||||
|
ui.weak(uptime);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if let Some(note) = &self.notification {
|
||||||
|
ui.add_space(2.0);
|
||||||
|
ui.weak(note);
|
||||||
|
}
|
||||||
|
ui.add_space(4.0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Bottom bar ──────────────────────────────────────────────────────
|
||||||
|
egui::TopBottomPanel::bottom("bottom_bar")
|
||||||
|
.min_height(36.0)
|
||||||
|
.show(ctx, |ui| {
|
||||||
|
ui.add_space(4.0);
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
if ui.button("Disconnect").clicked() {
|
||||||
|
self.notification = None;
|
||||||
|
self.spawn_disconnect();
|
||||||
|
}
|
||||||
|
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||||
|
if ui.button("Configure").clicked() {
|
||||||
|
// Phase 7
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Left panel — country selector ───────────────────────────────────
|
||||||
|
let panel_width = ctx.screen_rect().width() * 0.3;
|
||||||
|
let connect_key =
|
||||||
|
egui::SidePanel::left("country_selector")
|
||||||
|
.exact_width(panel_width)
|
||||||
|
.resizable(false)
|
||||||
|
.show(ctx, |ui| {
|
||||||
|
self.selector.show(
|
||||||
|
ui,
|
||||||
|
&self.countries,
|
||||||
|
&self.config.favorites,
|
||||||
|
status.country.as_deref(),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.inner;
|
||||||
|
|
||||||
|
if let Some(key) = connect_key {
|
||||||
|
self.notification = None;
|
||||||
|
self.spawn_connect(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Central panel — world map placeholder ───────────────────────────
|
||||||
|
egui::CentralPanel::default().show(ctx, |ui| {
|
||||||
|
ui.centered_and_justified(|ui| {
|
||||||
|
ui.weak("World map — Phase 5");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
93
src/config.rs
Normal file
93
src/config.rs
Normal file
|
|
@ -0,0 +1,93 @@
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
const CONFIG_FILE: &str = "config.toml";
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(default)]
|
||||||
|
pub struct Config {
|
||||||
|
pub external_app: String,
|
||||||
|
pub refresh_rate: u64,
|
||||||
|
pub favorites: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Config {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
external_app: "nordvpn".to_string(),
|
||||||
|
refresh_rate: 5,
|
||||||
|
favorites: vec![
|
||||||
|
"United_States".to_string(),
|
||||||
|
"United_Kingdom".to_string(),
|
||||||
|
"Japan".to_string(),
|
||||||
|
"Germany".to_string(),
|
||||||
|
"Australia".to_string(),
|
||||||
|
"South_Korea".to_string(),
|
||||||
|
"Canada".to_string(),
|
||||||
|
"France".to_string(),
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Config {
|
||||||
|
pub fn load() -> Self {
|
||||||
|
let path = config_path();
|
||||||
|
match std::fs::read_to_string(&path) {
|
||||||
|
Ok(text) => toml::from_str(&text).unwrap_or_else(|e| {
|
||||||
|
eprintln!("Failed to parse {}: {e}. Using defaults.", path.display());
|
||||||
|
Self::default()
|
||||||
|
}),
|
||||||
|
Err(_) => Self::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn save(&self) -> Result<(), String> {
|
||||||
|
let text = toml::to_string_pretty(self).map_err(|e| e.to_string())?;
|
||||||
|
std::fs::write(config_path(), text).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn config_path() -> PathBuf {
|
||||||
|
std::env::current_exe()
|
||||||
|
.ok()
|
||||||
|
.and_then(|p| p.parent().map(|d| d.join(CONFIG_FILE)))
|
||||||
|
.unwrap_or_else(|| PathBuf::from(CONFIG_FILE))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn default_values() {
|
||||||
|
let c = Config::default();
|
||||||
|
assert_eq!(c.external_app, "nordvpn");
|
||||||
|
assert_eq!(c.refresh_rate, 5);
|
||||||
|
assert!(!c.favorites.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn roundtrip() {
|
||||||
|
let original = Config {
|
||||||
|
external_app: "/usr/bin/nordvpn".to_string(),
|
||||||
|
refresh_rate: 10,
|
||||||
|
favorites: vec!["US".to_string(), "DE".to_string()],
|
||||||
|
};
|
||||||
|
let serialized = toml::to_string_pretty(&original).expect("serialize");
|
||||||
|
let restored: Config = toml::from_str(&serialized).expect("deserialize");
|
||||||
|
assert_eq!(restored.external_app, original.external_app);
|
||||||
|
assert_eq!(restored.refresh_rate, original.refresh_rate);
|
||||||
|
assert_eq!(restored.favorites, original.favorites);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn missing_fields_fall_back_to_defaults() {
|
||||||
|
// Only external_app is present — other fields must use their Default values.
|
||||||
|
let partial = r#"external_app = "custom-vpn""#;
|
||||||
|
let c: Config = toml::from_str(partial).expect("partial config should deserialize");
|
||||||
|
assert_eq!(c.external_app, "custom-vpn");
|
||||||
|
assert_eq!(c.refresh_rate, Config::default().refresh_rate);
|
||||||
|
assert_eq!(c.favorites, Config::default().favorites);
|
||||||
|
}
|
||||||
|
}
|
||||||
141
src/countries.rs
Normal file
141
src/countries.rs
Normal file
|
|
@ -0,0 +1,141 @@
|
||||||
|
use crate::nordvpn;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Data
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Country {
|
||||||
|
/// Display name, e.g. "United States"
|
||||||
|
pub name: String,
|
||||||
|
/// Exact token from `nordvpn countries`; used as both the connect arg
|
||||||
|
/// and the flag SVG filename base (`flags/{nordvpn_key}.svg`)
|
||||||
|
pub nordvpn_key: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Country {
|
||||||
|
fn from_key(key: &str) -> Self {
|
||||||
|
Self {
|
||||||
|
name: display_name(key),
|
||||||
|
nordvpn_key: key.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn display_name(key: &str) -> String {
|
||||||
|
// The only country whose common name can't be derived from the key alone.
|
||||||
|
if key == "Lao_Peoples_Democratic_Republic" {
|
||||||
|
return "Lao People's Democratic Republic".to_string();
|
||||||
|
}
|
||||||
|
// Rejoin words, lowercasing connectives that appear mid-name.
|
||||||
|
key.split('_')
|
||||||
|
.enumerate()
|
||||||
|
.map(|(i, word)| {
|
||||||
|
if i > 0 && matches!(word, "And" | "Of" | "The") {
|
||||||
|
word.to_lowercase()
|
||||||
|
} else {
|
||||||
|
word.to_string()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(" ")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Public API
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Fetch the country list from `nordvpn countries`.
|
||||||
|
/// Returns an empty list if the CLI is unavailable — the app won't be
|
||||||
|
/// functional in that case regardless.
|
||||||
|
pub fn get_countries(external_app: &str) -> Vec<Country> {
|
||||||
|
nordvpn::countries(external_app)
|
||||||
|
.map(|output| parse_cli_output(&output))
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Parse `nordvpn countries` stdout.
|
||||||
|
///
|
||||||
|
/// Lines that are not plain country tokens (e.g. the update notice
|
||||||
|
/// "A new version of NordVPN is available!") are skipped — country tokens
|
||||||
|
/// contain only ASCII letters and underscores.
|
||||||
|
fn parse_cli_output(output: &str) -> Vec<Country> {
|
||||||
|
output
|
||||||
|
.lines()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|line| {
|
||||||
|
!line.is_empty() && line.chars().all(|c| c.is_ascii_alphabetic() || c == '_')
|
||||||
|
})
|
||||||
|
.map(Country::from_key)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn display_name_replaces_underscores() {
|
||||||
|
assert_eq!(display_name("United_States"), "United States");
|
||||||
|
assert_eq!(display_name("Germany"), "Germany");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn display_name_lowercases_connectives() {
|
||||||
|
assert_eq!(display_name("Bosnia_And_Herzegovina"), "Bosnia and Herzegovina");
|
||||||
|
assert_eq!(display_name("Trinidad_And_Tobago"), "Trinidad and Tobago");
|
||||||
|
assert_eq!(display_name("Isle_Of_Man"), "Isle of Man");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn display_name_connective_preserved_at_start() {
|
||||||
|
// "And" or "Of" as the first word should stay capitalised (unlikely
|
||||||
|
// in practice, but the rule should be consistent).
|
||||||
|
assert_eq!(display_name("And_Something"), "And Something");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn display_name_exception_apostrophe() {
|
||||||
|
assert_eq!(
|
||||||
|
display_name("Lao_Peoples_Democratic_Republic"),
|
||||||
|
"Lao People's Democratic Republic"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn nordvpn_key_preserved_exactly() {
|
||||||
|
let c = Country::from_key("United_States");
|
||||||
|
assert_eq!(c.nordvpn_key, "United_States");
|
||||||
|
assert_eq!(c.name, "United States");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cli_output_skips_update_notice() {
|
||||||
|
let output = "\
|
||||||
|
A new version of NordVPN is available!
|
||||||
|
Please update the app.
|
||||||
|
Germany
|
||||||
|
Japan
|
||||||
|
United_States";
|
||||||
|
let countries = parse_cli_output(output);
|
||||||
|
assert_eq!(countries.len(), 3);
|
||||||
|
assert_eq!(countries[0].nordvpn_key, "Germany");
|
||||||
|
assert_eq!(countries[1].nordvpn_key, "Japan");
|
||||||
|
assert_eq!(countries[2].nordvpn_key, "United_States");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cli_output_skips_blank_lines() {
|
||||||
|
let countries = parse_cli_output("\n\nGermany\n\nJapan\n");
|
||||||
|
assert_eq!(countries.len(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
24
src/main.rs
Normal file
24
src/main.rs
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
// TODO: remove after phase 7 — dead_code is expected while modules are being built up
|
||||||
|
#![allow(dead_code)]
|
||||||
|
|
||||||
|
mod app;
|
||||||
|
mod config;
|
||||||
|
mod countries;
|
||||||
|
mod nordvpn;
|
||||||
|
mod ui;
|
||||||
|
|
||||||
|
fn main() -> eframe::Result {
|
||||||
|
let options = eframe::NativeOptions {
|
||||||
|
viewport: eframe::egui::ViewportBuilder::default()
|
||||||
|
.with_title("NordVPN")
|
||||||
|
.with_inner_size([1024.0, 640.0])
|
||||||
|
.with_min_inner_size([640.0, 400.0]),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
eframe::run_native(
|
||||||
|
"NordVPN",
|
||||||
|
options,
|
||||||
|
Box::new(|cc| Ok(Box::new(app::App::new(cc)))),
|
||||||
|
)
|
||||||
|
}
|
||||||
283
src/nordvpn.rs
Normal file
283
src/nordvpn.rs
Normal file
|
|
@ -0,0 +1,283 @@
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::process::Command;
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Data types
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct StatusInfo {
|
||||||
|
pub status: String,
|
||||||
|
pub server: Option<String>,
|
||||||
|
pub country: Option<String>,
|
||||||
|
pub ip: Option<String>,
|
||||||
|
pub city: Option<String>,
|
||||||
|
pub protocol: Option<String>,
|
||||||
|
pub transfer: Option<String>,
|
||||||
|
pub uptime: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl StatusInfo {
|
||||||
|
pub fn is_connected(&self) -> bool {
|
||||||
|
self.status.eq_ignore_ascii_case("connected")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Result sent back to the UI thread after a connect/disconnect command.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum CmdResult {
|
||||||
|
Success { cmd: String, output: String },
|
||||||
|
Failure { cmd: String, message: String },
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// CLI wrappers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
pub fn connect(external_app: &str, country_code: &str) -> CmdResult {
|
||||||
|
run_cmd(external_app, &["connect", country_code])
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn disconnect(external_app: &str) -> CmdResult {
|
||||||
|
run_cmd(external_app, &["disconnect"])
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn countries(external_app: &str) -> Result<String, String> {
|
||||||
|
let out = Command::new(external_app)
|
||||||
|
.args(["countries"])
|
||||||
|
.output()
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
if out.status.success() {
|
||||||
|
Ok(String::from_utf8_lossy(&out.stdout).into_owned())
|
||||||
|
} else {
|
||||||
|
Err(String::from_utf8_lossy(&out.stderr).into_owned())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_cmd(external_app: &str, args: &[&str]) -> CmdResult {
|
||||||
|
let cmd = format!("{} {}", external_app, args.join(" "));
|
||||||
|
match Command::new(external_app).args(args).output() {
|
||||||
|
Ok(out) if out.status.success() => CmdResult::Success {
|
||||||
|
cmd,
|
||||||
|
output: String::from_utf8_lossy(&out.stdout).into_owned(),
|
||||||
|
},
|
||||||
|
Ok(out) => CmdResult::Failure {
|
||||||
|
cmd,
|
||||||
|
message: String::from_utf8_lossy(&out.stderr).into_owned(),
|
||||||
|
},
|
||||||
|
Err(e) => CmdResult::Failure { cmd, message: e.to_string() },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Status parsing
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Parse `nordvpn status` stdout into a `StatusInfo`.
|
||||||
|
///
|
||||||
|
/// The output is a series of `Key: Value` lines. Lines without `: ` are ignored.
|
||||||
|
/// ANSI escape sequences (e.g. colour codes) are stripped before parsing.
|
||||||
|
pub fn parse_status(raw: &str) -> StatusInfo {
|
||||||
|
let cleaned = strip_ansi(raw);
|
||||||
|
let map: HashMap<String, String> = cleaned
|
||||||
|
.lines()
|
||||||
|
.filter_map(|line| {
|
||||||
|
let (k, v) = line.split_once(':')?;
|
||||||
|
Some((k.trim().to_lowercase(), v.trim().to_string()))
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
StatusInfo {
|
||||||
|
status: map.get("status").cloned().unwrap_or_else(|| "Unknown".to_string()),
|
||||||
|
server: map.get("current server").cloned(),
|
||||||
|
country: map.get("country").cloned(),
|
||||||
|
ip: map.get("your new ip").or_else(|| map.get("ip")).cloned(),
|
||||||
|
city: map.get("city").cloned(),
|
||||||
|
protocol: map.get("current protocol").cloned(),
|
||||||
|
transfer: map.get("transfer").cloned(),
|
||||||
|
uptime: map.get("uptime").cloned(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove ANSI escape sequences from a string.
|
||||||
|
fn strip_ansi(s: &str) -> String {
|
||||||
|
let mut out = String::with_capacity(s.len());
|
||||||
|
let mut chars = s.chars().peekable();
|
||||||
|
while let Some(c) = chars.next() {
|
||||||
|
if c == '\x1b' {
|
||||||
|
// consume until a letter (end of escape sequence)
|
||||||
|
for ch in chars.by_ref() {
|
||||||
|
if ch.is_ascii_alphabetic() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
out.push(c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fetch_status(external_app: &str) -> StatusInfo {
|
||||||
|
match Command::new(external_app).args(["status"]).output() {
|
||||||
|
Ok(out) if out.status.success() => {
|
||||||
|
parse_status(&String::from_utf8_lossy(&out.stdout))
|
||||||
|
}
|
||||||
|
Ok(out) => StatusInfo {
|
||||||
|
status: format!("Error: {}", String::from_utf8_lossy(&out.stderr).trim()),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
Err(e) => StatusInfo {
|
||||||
|
status: format!("Error: {e}"),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Status poller
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
struct PollConfig {
|
||||||
|
external_app: String,
|
||||||
|
refresh_rate: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Background status poller. Drops cleanly when the owning `App` is closed.
|
||||||
|
pub struct Poller {
|
||||||
|
pub status: Arc<Mutex<StatusInfo>>,
|
||||||
|
poll_config: Arc<Mutex<PollConfig>>,
|
||||||
|
shutdown: Arc<AtomicBool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Poller {
|
||||||
|
pub fn new(external_app: String, refresh_rate: u64) -> Self {
|
||||||
|
let status = Arc::new(Mutex::new(StatusInfo::default()));
|
||||||
|
let poll_config = Arc::new(Mutex::new(PollConfig { external_app, refresh_rate }));
|
||||||
|
let shutdown = Arc::new(AtomicBool::new(false));
|
||||||
|
|
||||||
|
let status_w = Arc::clone(&status);
|
||||||
|
let config_r = Arc::clone(&poll_config);
|
||||||
|
let stop = Arc::clone(&shutdown);
|
||||||
|
|
||||||
|
std::thread::Builder::new()
|
||||||
|
.name("nordvpn-status-poller".into())
|
||||||
|
.spawn(move || {
|
||||||
|
loop {
|
||||||
|
if stop.load(Ordering::Relaxed) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
let (app, rate) = {
|
||||||
|
let cfg = config_r.lock().unwrap();
|
||||||
|
(cfg.external_app.clone(), cfg.refresh_rate)
|
||||||
|
};
|
||||||
|
|
||||||
|
*status_w.lock().unwrap() = fetch_status(&app);
|
||||||
|
|
||||||
|
// Sleep in 100 ms increments so shutdown is responsive.
|
||||||
|
let ticks = (rate * 10).max(1);
|
||||||
|
for _ in 0..ticks {
|
||||||
|
if stop.load(Ordering::Relaxed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
std::thread::sleep(Duration::from_millis(100));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.expect("failed to spawn poller thread");
|
||||||
|
|
||||||
|
Self { status, poll_config, shutdown }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update the poller's config (effective on next poll cycle).
|
||||||
|
pub fn update_config(&self, external_app: String, refresh_rate: u64) {
|
||||||
|
let mut cfg = self.poll_config.lock().unwrap();
|
||||||
|
cfg.external_app = external_app;
|
||||||
|
cfg.refresh_rate = refresh_rate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for Poller {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
self.shutdown.store(true, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
const CONNECTED_OUTPUT: &str = "\
|
||||||
|
Status: Connected
|
||||||
|
Current server: us9999.nordvpn.com
|
||||||
|
Country: United States
|
||||||
|
City: New York
|
||||||
|
Your new IP: 1.2.3.4
|
||||||
|
Current technology: NORDLYNX
|
||||||
|
Current protocol: UDP
|
||||||
|
Transfer: 1.23 GiB received, 234.56 MiB sent
|
||||||
|
Uptime: 4 hours 23 minutes 12 seconds";
|
||||||
|
|
||||||
|
const DISCONNECTED_OUTPUT: &str = "Status: Disconnected";
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_connected() {
|
||||||
|
let s = parse_status(CONNECTED_OUTPUT);
|
||||||
|
assert!(s.is_connected());
|
||||||
|
assert_eq!(s.country.as_deref(), Some("United States"));
|
||||||
|
assert_eq!(s.ip.as_deref(), Some("1.2.3.4"));
|
||||||
|
assert_eq!(s.city.as_deref(), Some("New York"));
|
||||||
|
assert_eq!(s.protocol.as_deref(), Some("UDP"));
|
||||||
|
assert!(s.transfer.is_some());
|
||||||
|
assert!(s.uptime.is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_disconnected() {
|
||||||
|
let s = parse_status(DISCONNECTED_OUTPUT);
|
||||||
|
assert!(!s.is_connected());
|
||||||
|
assert_eq!(s.status, "Disconnected");
|
||||||
|
assert!(s.ip.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_empty_returns_unknown() {
|
||||||
|
let s = parse_status("");
|
||||||
|
assert_eq!(s.status, "Unknown");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_partial_does_not_panic() {
|
||||||
|
let s = parse_status("Status: Connected\ngarbage line\n: empty key");
|
||||||
|
assert!(s.is_connected());
|
||||||
|
assert!(s.ip.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn strip_ansi_removes_escape_sequences() {
|
||||||
|
let raw = "\x1b[1mStatus\x1b[0m: Connected";
|
||||||
|
assert_eq!(strip_ansi(raw), "Status: Connected");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_status_with_ansi() {
|
||||||
|
let raw = "\x1b[32mStatus\x1b[0m: Connected\n\x1b[1mCity\x1b[0m: Berlin";
|
||||||
|
let s = parse_status(raw);
|
||||||
|
assert!(s.is_connected());
|
||||||
|
assert_eq!(s.city.as_deref(), Some("Berlin"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn is_connected_case_insensitive() {
|
||||||
|
let s = parse_status("Status: CONNECTED");
|
||||||
|
assert!(s.is_connected());
|
||||||
|
}
|
||||||
|
}
|
||||||
223
src/ui/country_selector.rs
Normal file
223
src/ui/country_selector.rs
Normal file
|
|
@ -0,0 +1,223 @@
|
||||||
|
use std::collections::HashSet;
|
||||||
|
|
||||||
|
use eframe::egui;
|
||||||
|
|
||||||
|
use crate::countries::Country;
|
||||||
|
|
||||||
|
#[derive(Default, PartialEq)]
|
||||||
|
enum ViewMode {
|
||||||
|
Grid,
|
||||||
|
#[default]
|
||||||
|
List,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct CountrySelector {
|
||||||
|
filter: String,
|
||||||
|
view_mode: ViewMode,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for CountrySelector {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self { filter: String::new(), view_mode: ViewMode::default() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CountrySelector {
|
||||||
|
/// Draw the selector panel. Returns the `nordvpn_key` of the country the
|
||||||
|
/// user clicked, if any.
|
||||||
|
pub fn show(
|
||||||
|
&mut self,
|
||||||
|
ui: &mut egui::Ui,
|
||||||
|
countries: &[Country],
|
||||||
|
favorites: &[String],
|
||||||
|
connected_key: Option<&str>,
|
||||||
|
) -> Option<String> {
|
||||||
|
let mut clicked = None;
|
||||||
|
|
||||||
|
// ── Toggle + search bar ─────────────────────────────────────────────
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
ui.selectable_value(&mut self.view_mode, ViewMode::List, "List");
|
||||||
|
ui.selectable_value(&mut self.view_mode, ViewMode::Grid, "Grid");
|
||||||
|
ui.add(
|
||||||
|
egui::TextEdit::singleline(&mut self.filter)
|
||||||
|
.hint_text("🔍 Filter…")
|
||||||
|
.desired_width(f32::INFINITY),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
ui.add_space(2.0);
|
||||||
|
|
||||||
|
// ── Filter + partition ──────────────────────────────────────────────
|
||||||
|
let filter_lower = self.filter.to_lowercase();
|
||||||
|
let fav_set: HashSet<&str> = favorites.iter().map(String::as_str).collect();
|
||||||
|
|
||||||
|
let matches = |c: &&Country| {
|
||||||
|
filter_lower.is_empty() || c.name.to_lowercase().contains(&filter_lower)
|
||||||
|
};
|
||||||
|
|
||||||
|
let favs: Vec<&Country> = countries.iter().filter(|c| fav_set.contains(c.nordvpn_key.as_str())).filter(matches).collect();
|
||||||
|
let rest: Vec<&Country> = countries.iter().filter(|c| !fav_set.contains(c.nordvpn_key.as_str())).filter(matches).collect();
|
||||||
|
|
||||||
|
// ── Scrollable content ──────────────────────────────────────────────
|
||||||
|
egui::ScrollArea::vertical().show(ui, |ui| {
|
||||||
|
match self.view_mode {
|
||||||
|
ViewMode::List => {
|
||||||
|
clicked = show_list(ui, &favs, &rest, connected_key);
|
||||||
|
}
|
||||||
|
ViewMode::Grid => {
|
||||||
|
clicked = show_grid(ui, &favs, &rest, connected_key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
clicked
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// List view
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
fn show_list(
|
||||||
|
ui: &mut egui::Ui,
|
||||||
|
favs: &[&Country],
|
||||||
|
rest: &[&Country],
|
||||||
|
connected_key: Option<&str>,
|
||||||
|
) -> Option<String> {
|
||||||
|
let mut clicked = None;
|
||||||
|
|
||||||
|
if !favs.is_empty() {
|
||||||
|
ui.label(egui::RichText::new("★ Favorites").weak().small());
|
||||||
|
ui.separator();
|
||||||
|
for c in favs {
|
||||||
|
if country_row(ui, c, connected_key) {
|
||||||
|
clicked = Some(c.nordvpn_key.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ui.add_space(4.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if !rest.is_empty() {
|
||||||
|
if !favs.is_empty() {
|
||||||
|
ui.label(egui::RichText::new("All").weak().small());
|
||||||
|
ui.separator();
|
||||||
|
}
|
||||||
|
for c in rest {
|
||||||
|
if country_row(ui, c, connected_key) {
|
||||||
|
clicked = Some(c.nordvpn_key.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
clicked
|
||||||
|
}
|
||||||
|
|
||||||
|
fn country_row(ui: &mut egui::Ui, country: &Country, connected_key: Option<&str>) -> bool {
|
||||||
|
let is_connected = connected_key.is_some_and(|k| names_match(&country.name, k));
|
||||||
|
let text = if is_connected {
|
||||||
|
egui::RichText::new(format!("▶ {}", country.name)).strong()
|
||||||
|
} else {
|
||||||
|
egui::RichText::new(&country.name)
|
||||||
|
};
|
||||||
|
let resp = ui.add(egui::Button::new(text).frame(false).min_size(egui::vec2(ui.available_width(), 0.0)));
|
||||||
|
resp.clicked()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Grid view (text labels — flags added in phase 4)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
fn show_grid(
|
||||||
|
ui: &mut egui::Ui,
|
||||||
|
favs: &[&Country],
|
||||||
|
rest: &[&Country],
|
||||||
|
connected_key: Option<&str>,
|
||||||
|
) -> Option<String> {
|
||||||
|
let mut clicked = None;
|
||||||
|
|
||||||
|
if !favs.is_empty() {
|
||||||
|
ui.label(egui::RichText::new("★ Favorites").weak().small());
|
||||||
|
ui.separator();
|
||||||
|
clicked = clicked.or(grid_section(ui, favs, connected_key));
|
||||||
|
ui.add_space(4.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if !rest.is_empty() {
|
||||||
|
if !favs.is_empty() {
|
||||||
|
ui.label(egui::RichText::new("All").weak().small());
|
||||||
|
ui.separator();
|
||||||
|
}
|
||||||
|
clicked = clicked.or(grid_section(ui, rest, connected_key));
|
||||||
|
}
|
||||||
|
|
||||||
|
clicked
|
||||||
|
}
|
||||||
|
|
||||||
|
fn grid_section(
|
||||||
|
ui: &mut egui::Ui,
|
||||||
|
countries: &[&Country],
|
||||||
|
connected_key: Option<&str>,
|
||||||
|
) -> Option<String> {
|
||||||
|
let mut clicked = None;
|
||||||
|
|
||||||
|
// Wrap buttons horizontally — egui handles column breaks automatically.
|
||||||
|
ui.horizontal_wrapped(|ui| {
|
||||||
|
for c in countries {
|
||||||
|
let is_connected = connected_key.is_some_and(|k| names_match(&c.name, k));
|
||||||
|
// Truncate to first word so buttons stay compact without flags.
|
||||||
|
let label = first_word(&c.name);
|
||||||
|
let text = if is_connected {
|
||||||
|
egui::RichText::new(label).strong()
|
||||||
|
} else {
|
||||||
|
egui::RichText::new(label)
|
||||||
|
};
|
||||||
|
if ui.add(egui::Button::new(text).min_size(egui::vec2(60.0, 24.0))).clicked() {
|
||||||
|
clicked = Some(c.nordvpn_key.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
clicked
|
||||||
|
}
|
||||||
|
|
||||||
|
fn first_word(s: &str) -> &str {
|
||||||
|
s.split_whitespace().next().unwrap_or(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compare a country display name against the string nordvpn status outputs
|
||||||
|
/// for "Country:", normalising away differences like apostrophes.
|
||||||
|
///
|
||||||
|
/// e.g. our display name "Lao People's Democratic Republic" and the status
|
||||||
|
/// output "Lao Peoples Democratic Republic" both normalise to the same string.
|
||||||
|
fn names_match(display_name: &str, status_country: &str) -> bool {
|
||||||
|
let norm = |s: &str| s.to_lowercase().replace('\'', "");
|
||||||
|
norm(display_name) == norm(status_country)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn names_match_exact() {
|
||||||
|
assert!(names_match("Germany", "Germany"));
|
||||||
|
assert!(names_match("United States", "United States"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn names_match_case_insensitive() {
|
||||||
|
assert!(names_match("Germany", "germany"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn names_match_apostrophe_normalisation() {
|
||||||
|
// Our display name has an apostrophe; nordvpn status output does not.
|
||||||
|
assert!(names_match("Lao People's Democratic Republic", "Lao Peoples Democratic Republic"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn names_match_no_false_positives() {
|
||||||
|
assert!(!names_match("Germany", "France"));
|
||||||
|
assert!(!names_match("United States", "United Kingdom"));
|
||||||
|
}
|
||||||
|
}
|
||||||
1
src/ui/mod.rs
Normal file
1
src/ui/mod.rs
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
pub mod country_selector;
|
||||||
Loading…
Reference in a new issue