148 lines
5.2 KiB
Rust
148 lines
5.2 KiB
Rust
|
|
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");
|
||
|
|
});
|
||
|
|
});
|
||
|
|
}
|
||
|
|
}
|