wg-tui 0.3.3

A terminal user interface for managing WireGuard VPN tunnels
Documentation
mod input;
mod render;
mod wizard;

use ratatui::widgets::ListState;

use crate::{
    types::{Message, Tunnel},
    wireguard::{discover_tunnels, get_interface_info, is_interface_active},
};

use wizard::{NewTunnelWizard, PeerConfigState, PendingImport, PendingPeerConfig};

pub struct App {
    pub(crate) tunnels: Vec<Tunnel>,
    pub(crate) list_state: ListState,
    pub(crate) flags: AppFlags,
    pub(crate) confirm_full_tunnel: Option<String>,
    pub(crate) input_path: Option<String>,
    pub(crate) input_zip: Option<String>,
    pub(crate) export_path: Option<String>,
    pub(crate) pending_import: Option<PendingImport>,
    pub(crate) new_tunnel: Option<NewTunnelWizard>,
    pub(crate) pending_peer: Option<PendingPeerConfig>,
    pub(crate) peer_endpoint_input: Option<String>,
    pub(crate) peer_dns_input: Option<String>,
    pub(crate) peer_config: Option<PeerConfigState>,
    pub(crate) peer_save_path: Option<String>,
    pub(crate) message: Option<Message>,
}

#[derive(Debug, Clone, Copy, Default)]
pub(crate) struct AppFlags {
    bits: u8,
}

impl AppFlags {
    const SHOW_DETAILS: u8 = 1 << 0;
    const SHOW_HELP: u8 = 1 << 1;
    const CONFIRM_DELETE: u8 = 1 << 2;
    const SHOW_ADD_MENU: u8 = 1 << 3;
    const SHOULD_QUIT: u8 = 1 << 4;

    pub fn show_details(self) -> bool {
        self.is_set(Self::SHOW_DETAILS)
    }

    pub fn show_help(self) -> bool {
        self.is_set(Self::SHOW_HELP)
    }

    pub fn confirm_delete(self) -> bool {
        self.is_set(Self::CONFIRM_DELETE)
    }

    pub fn show_add_menu(self) -> bool {
        self.is_set(Self::SHOW_ADD_MENU)
    }

    pub fn should_quit(self) -> bool {
        self.is_set(Self::SHOULD_QUIT)
    }

    pub fn set_show_help(&mut self, value: bool) {
        self.set(Self::SHOW_HELP, value);
    }

    pub fn set_confirm_delete(&mut self, value: bool) {
        self.set(Self::CONFIRM_DELETE, value);
    }

    pub fn set_show_add_menu(&mut self, value: bool) {
        self.set(Self::SHOW_ADD_MENU, value);
    }

    pub fn set_should_quit(&mut self, value: bool) {
        self.set(Self::SHOULD_QUIT, value);
    }

    pub fn toggle_show_details(&mut self) {
        self.toggle(Self::SHOW_DETAILS);
    }

    fn set(&mut self, flag: u8, value: bool) {
        if value {
            self.bits |= flag;
        } else {
            self.bits &= !flag;
        }
    }

    fn toggle(&mut self, flag: u8) {
        self.bits ^= flag;
    }

    fn is_set(self, flag: u8) -> bool {
        self.bits & flag != 0
    }
}

impl Default for App {
    fn default() -> Self {
        Self::new()
    }
}

impl App {
    #[must_use]
    pub fn new() -> Self {
        let mut app = Self {
            tunnels: Vec::new(),
            list_state: ListState::default(),
            flags: AppFlags::default(),
            confirm_full_tunnel: None,
            input_path: None,
            input_zip: None,
            export_path: None,
            pending_import: None,
            new_tunnel: None,
            pending_peer: None,
            peer_endpoint_input: None,
            peer_dns_input: None,
            peer_config: None,
            peer_save_path: None,
            message: None,
        };
        app.refresh_tunnels();
        if !app.tunnels.is_empty() {
            app.list_state.select(Some(0));
        }
        app
    }

    pub fn refresh_tunnels(&mut self) {
        self.tunnels = discover_tunnels();
        for t in &mut self.tunnels {
            t.is_active = is_interface_active(&t.name);
            if t.is_active {
                t.interface = get_interface_info(&t.name);
            }
        }
        self.clamp_selection();
    }

    #[must_use]
    pub fn should_quit(&self) -> bool {
        self.flags.should_quit()
    }

    pub(crate) fn clamp_selection(&mut self) {
        let selected = match (self.list_state.selected(), self.tunnels.len()) {
            (_, 0) => None,
            (None | Some(0), _) => Some(0),
            (Some(i), len) => Some(i.min(len - 1)),
        };
        self.list_state.select(selected);
    }

    pub(crate) fn selected(&self) -> Option<&Tunnel> {
        self.list_state.selected().and_then(|i| self.tunnels.get(i))
    }

    pub(crate) fn move_selection(&mut self, delta: isize) {
        let Some(i) = self.list_state.selected() else {
            return;
        };
        let max_index = self.tunnels.len().saturating_sub(1);
        let max_index = isize::try_from(max_index).unwrap_or(0);
        let current = isize::try_from(i).unwrap_or(0);
        let new = (current + delta).clamp(0, max_index);
        let new = usize::try_from(new).unwrap_or(0);
        self.list_state.select(Some(new));
    }

    pub(crate) fn default_tunnel_name(&self) -> String {
        for i in 0..1000u32 {
            let name = format!("wg{i}");
            if !self.tunnels.iter().any(|t| t.name == name) {
                return name;
            }
        }
        "wg0".into()
    }
}