nettui 0.1.11

Unified TUI for Wi-Fi and Ethernet
Documentation
use crate::domain::wifi::{WifiDeviceInfo, WifiNetwork, WifiState};
use anyhow::{Context, Result};
use iwdrs::session::Session;
use std::{collections::HashMap, fs, path::Path};
use tokio::process::Command;

pub struct IwdBackend;

impl IwdBackend {
    pub fn new() -> Self {
        Self
    }

    pub async fn query_state(&self) -> Result<WifiState> {
        let ifaces = list_wifi_ifaces();
        if ifaces.is_empty() {
            return Ok(WifiState::empty());
        }
        let iface = ifaces[0].clone();

        let session = Session::new().await.context("cannot access iwd service")?;
        let station = session
            .stations()
            .await?
            .pop()
            .context("no wifi station found")?;

        let connected_ssid = if let Some(n) = station.connected_network().await? {
            n.name().await.ok()
        } else {
            None
        };

        let known_meta = load_known_meta(&session).await;
        let discovered = station.discovered_networks().await?;

        let mut known_networks = Vec::new();
        let mut new_networks = Vec::new();
        let mut available_names = std::collections::HashSet::new();

        for (network, signal_dbm) in discovered {
            let name = match network.name().await {
                Ok(v) if !v.is_empty() => v,
                _ => continue,
            };
            let security = network
                .network_type()
                .await
                .map(|v| v.to_string())
                .unwrap_or_else(|_| "-".to_string());
            let connected = connected_ssid.as_deref() == Some(name.as_str());
            let signal = percent_signal(signal_dbm);

            if let Some(meta) = known_meta.get(&name) {
                available_names.insert(name.clone());
                known_networks.push(WifiNetwork {
                    ssid: name,
                    security,
                    signal,
                    connected,
                    hidden: Some(meta.hidden),
                    autoconnect: Some(meta.autoconnect),
                    available: true,
                });
            } else {
                new_networks.push(WifiNetwork {
                    ssid: name,
                    security,
                    signal,
                    connected,
                    hidden: None,
                    autoconnect: None,
                    available: true,
                });
            }
        }

        known_networks.sort_by(|a, b| a.ssid.cmp(&b.ssid));
        new_networks.sort_by(|a, b| a.ssid.cmp(&b.ssid));

        let mut unavailable_known_networks = Vec::new();
        for (name, meta) in &known_meta {
            if available_names.contains(name) {
                continue;
            }
            unavailable_known_networks.push(WifiNetwork {
                ssid: name.clone(),
                security: meta.security.clone(),
                signal: "-".to_string(),
                connected: false,
                hidden: Some(meta.hidden),
                autoconnect: Some(meta.autoconnect),
                available: false,
            });
        }
        unavailable_known_networks.sort_by(|a, b| a.ssid.cmp(&b.ssid));

        let mut hidden_networks = Vec::new();
        if let Ok(hidden_list) = station.get_hidden_networks().await {
            for net in hidden_list {
                let security = net
                    .network_type
                    .to_string()
                    .split("::")
                    .last()
                    .unwrap_or("-")
                    .to_string();
                hidden_networks.push(WifiNetwork {
                    ssid: net.address,
                    security,
                    signal: percent_signal(net.signal_strength),
                    connected: false,
                    hidden: Some(true),
                    autoconnect: None,
                    available: false,
                });
            }
            hidden_networks.sort_by(|a, b| a.ssid.cmp(&b.ssid));
        }

        let state = station
            .state()
            .await
            .map(|v| v.to_string())
            .unwrap_or_else(|_| "-".to_string());
        let scanning = station
            .is_scanning()
            .await
            .map(|v| {
                if v {
                    "Yes".to_string()
                } else {
                    "No".to_string()
                }
            })
            .unwrap_or_else(|_| "-".to_string());

        let powered = match session.devices().await {
            Ok(mut devices) => {
                if let Some(device) = devices.pop() {
                    match device.is_powered().await {
                        Ok(true) => "On".to_string(),
                        Ok(false) => "Off".to_string(),
                        Err(_) => "-".to_string(),
                    }
                } else {
                    "-".to_string()
                }
            }
            Err(_) => "-".to_string(),
        };

        let mut frequency = "-".to_string();
        let mut security = "-".to_string();
        if let Ok(mut diagnostics) = session.stations_diagnostics().await
            && let Some(diag) = diagnostics.pop()
            && let Ok(d) = diag.get().await
        {
            frequency = format!("{:.2} GHz", d.frequency_mhz as f32 / 1000.0);
            security = d.security.to_string();
        }

        Ok(WifiState {
            ifaces,
            connected_ssid,
            known_networks,
            unavailable_known_networks,
            new_networks,
            hidden_networks,
            device: Some(WifiDeviceInfo {
                iface,
                mode: "station".to_string(),
                powered,
                state,
                scanning,
                frequency,
                security,
            }),
        })
    }

    pub async fn scan(&self) -> Result<()> {
        let session = Session::new().await.context("cannot access iwd service")?;
        let station = session
            .stations()
            .await?
            .pop()
            .context("no wifi station found")?;
        station.scan().await?;
        Ok(())
    }

    pub async fn disconnect(&self) -> Result<()> {
        let session = Session::new().await.context("cannot access iwd service")?;
        let station = session
            .stations()
            .await?
            .pop()
            .context("no wifi station found")?;
        station.disconnect().await?;
        Ok(())
    }

    pub async fn connect(&self, ssid: &str) -> Result<()> {
        let session = Session::new().await.context("cannot access iwd service")?;
        let station = session
            .stations()
            .await?
            .pop()
            .context("no wifi station found")?;
        let discovered = station.discovered_networks().await?;

        for (network, _) in discovered {
            let name = match network.name().await {
                Ok(v) => v,
                Err(_) => continue,
            };
            if name == ssid {
                network.connect().await?;
                return Ok(());
            }
        }

        Err(std::io::Error::other(format!("network not found: {ssid}")).into())
    }

    pub async fn connect_hidden(&self, ssid: &str) -> Result<()> {
        let session = Session::new().await.context("cannot access iwd service")?;
        let station = session
            .stations()
            .await?
            .pop()
            .context("no wifi station found")?;
        station.connect_hidden_network(ssid.to_string()).await?;
        Ok(())
    }

    pub async fn connect_with_passphrase(&self, ssid: &str, passphrase: &str) -> Result<()> {
        let iface = list_wifi_ifaces()
            .into_iter()
            .next()
            .context("no wifi adapter found")?;

        let out = Command::new("iwctl")
            .arg("--passphrase")
            .arg(passphrase)
            .arg("station")
            .arg(&iface)
            .arg("connect")
            .arg(ssid)
            .output()
            .await
            .context("failed to run iwctl")?;

        if out.status.success() {
            return Ok(());
        }

        let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
        let stdout = String::from_utf8_lossy(&out.stdout).trim().to_string();
        let msg = if !stderr.is_empty() { stderr } else { stdout };
        Err(std::io::Error::other(if msg.is_empty() {
            "iwctl connect failed".to_string()
        } else {
            msg
        })
        .into())
    }

    pub async fn forget_known(&self, ssid: &str) -> Result<()> {
        let session = Session::new().await.context("cannot access iwd service")?;
        let known = session.known_networks().await?;
        for network in known {
            let name = network.name().await.unwrap_or_default();
            if name == ssid {
                network.forget().await?;
                return Ok(());
            }
        }
        Err(std::io::Error::other(format!("known network not found: {ssid}")).into())
    }

    pub async fn toggle_autoconnect(&self, ssid: &str) -> Result<bool> {
        let session = Session::new().await.context("cannot access iwd service")?;
        let known = session.known_networks().await?;
        for network in known {
            let name = network.name().await.unwrap_or_default();
            if name == ssid {
                let current = network.get_autoconnect().await.unwrap_or(false);
                let next = !current;
                network.set_autoconnect(next).await?;
                return Ok(next);
            }
        }
        Err(std::io::Error::other(format!("known network not found: {ssid}")).into())
    }
}

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

#[derive(Debug, Clone)]
struct KnownMeta {
    security: String,
    hidden: bool,
    autoconnect: bool,
}

async fn load_known_meta(session: &Session) -> HashMap<String, KnownMeta> {
    let mut map = HashMap::new();
    let Ok(known) = session.known_networks().await else {
        return map;
    };

    for network in known {
        let name = network.name().await.unwrap_or_default();
        if name.is_empty() {
            continue;
        }
        let security = network
            .network_type()
            .await
            .map(|v| v.to_string())
            .unwrap_or_else(|_| "-".to_string());
        let hidden = network.hidden().await.unwrap_or(false);
        let autoconnect = network.get_autoconnect().await.unwrap_or(false);
        map.insert(
            name,
            KnownMeta {
                security,
                hidden,
                autoconnect,
            },
        );
    }

    map
}

fn percent_signal(signal_dbm: i16) -> String {
    let signal = if signal_dbm / 100 >= -50 {
        100
    } else {
        2 * (100 + signal_dbm / 100)
    };

    match signal {
        n if n >= 75 => format!("{signal:3}% 󰤨"),
        n if (50..75).contains(&n) => format!("{signal:3}% 󰤥"),
        n if (25..50).contains(&n) => format!("{signal:3}% 󰤢"),
        _ => format!("{signal:3}% 󰤟"),
    }
}

fn list_wifi_ifaces() -> Vec<String> {
    let mut out = Vec::new();
    let Ok(entries) = fs::read_dir("/sys/class/net") else {
        return out;
    };

    for entry in entries.flatten() {
        let name = entry.file_name().to_string_lossy().to_string();
        if name == "lo" {
            continue;
        }

        let p = Path::new("/sys/class/net").join(&name);
        if p.join("wireless").is_dir() || p.join("phy80211").exists() {
            out.push(name);
        }
    }

    out.sort();
    out
}