nordvpn-ui/src/app.rs

194 lines
7.3 KiB
Rust
Raw Normal View History

2026-03-20 02:18:13 +09:00
use std::sync::mpsc::{self, Sender};
use std::time::Duration;
use eframe::egui;
2026-03-30 22:14:11 +09:00
use std::collections::HashMap;
2026-03-20 02:18:13 +09:00
use crate::config::Config;
use crate::countries::{get_countries, Country};
2026-03-30 22:14:11 +09:00
use crate::flags;
2026-03-20 02:18:13 +09:00
use crate::nordvpn::{self, CmdResult, Poller};
2026-03-31 22:28:07 +09:00
use crate::ui::config_dialog::{ConfigDialog, DialogResult};
2026-03-20 02:18:13 +09:00
use crate::ui::country_selector::CountrySelector;
2026-03-31 22:28:07 +09:00
use crate::world_map::WorldMap;
2026-03-20 02:18:13 +09:00
pub struct App {
config: Config,
countries: Vec<Country>,
poller: Poller,
cmd_tx: Sender<CmdResult>,
cmd_rx: mpsc::Receiver<CmdResult>,
2026-03-30 22:14:11 +09:00
/// Receives rasterized flag images from the background loader thread.
flag_rx: mpsc::Receiver<(String, egui::ColorImage)>,
/// Fully loaded flag textures, keyed by nordvpn_key.
flag_textures: HashMap<String, egui::TextureHandle>,
2026-03-20 02:18:13 +09:00
selector: CountrySelector,
2026-03-31 22:28:07 +09:00
world_map: WorldMap,
config_dialog: Option<ConfigDialog>,
2026-03-20 02:18:13 +09:00
/// Last connect/disconnect result shown inline below the status bar.
notification: Option<String>,
}
impl App {
2026-03-30 22:14:11 +09:00
pub fn new(cc: &eframe::CreationContext<'_>) -> Self {
2026-03-20 02:18:13 +09:00
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();
2026-03-30 22:14:11 +09:00
// Kick off background flag rasterization.
let (flag_tx, flag_rx) = mpsc::channel();
flags::spawn_loader(flags::flag_paths(&countries), flag_tx);
2026-03-20 02:18:13 +09:00
Self {
config,
countries,
poller,
cmd_tx,
cmd_rx,
2026-03-30 22:14:11 +09:00
flag_rx,
flag_textures: HashMap::new(),
2026-03-20 02:18:13 +09:00
selector: CountrySelector::default(),
2026-03-31 22:28:07 +09:00
world_map: WorldMap::new(),
config_dialog: None,
2026-03-20 02:18:13 +09:00
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) {
2026-03-30 22:14:11 +09:00
// Upload any newly rasterized flag textures.
while let Ok((key, image)) = self.flag_rx.try_recv() {
let handle = ctx.load_texture(&key, image, egui::TextureOptions::LINEAR);
self.flag_textures.insert(key, handle);
}
2026-03-20 02:18:13 +09:00
// 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() {
2026-03-31 22:28:07 +09:00
self.config_dialog = Some(ConfigDialog::new(&self.config));
2026-03-20 02:18:13 +09:00
}
});
});
});
2026-03-31 22:28:07 +09:00
// ── Config dialog ────────────────────────────────────────────────────
if let Some(dialog) = &mut self.config_dialog {
match dialog.show(ctx, &self.countries) {
DialogResult::Apply(new_cfg) => {
self.poller.update_config(
new_cfg.external_app.clone(),
new_cfg.refresh_rate,
);
self.config = new_cfg;
if let Err(e) = self.config.save() {
self.notification = Some(format!("Failed to save config: {e}"));
}
self.config_dialog = None;
}
DialogResult::Cancel => self.config_dialog = None,
DialogResult::Open => {}
}
}
2026-03-20 02:18:13 +09:00
// ── 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(),
2026-03-30 22:14:11 +09:00
&self.flag_textures,
2026-03-20 02:18:13 +09:00
)
})
.inner;
if let Some(key) = connect_key {
self.notification = None;
self.spawn_connect(key);
}
2026-03-31 22:28:07 +09:00
// ── Central panel — world map ────────────────────────────────────────
2026-03-20 02:18:13 +09:00
egui::CentralPanel::default().show(ctx, |ui| {
2026-03-31 22:28:07 +09:00
if let Some(key) = self.world_map.show(ui, &self.countries, status.country.as_deref()) {
self.notification = None;
self.spawn_connect(key);
}
2026-03-20 02:18:13 +09:00
});
}
}