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
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|