Download
440 lines 16.4 KiBRustModified 2026-07-26 raw
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]

use serde::Serialize;
use std::process::Command;

// ── Types ─────────────────────────────────────────────────────────────────────
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct Network {
    ssid:    String,
    signal:  u8,
    secured: bool,
    in_use:  bool,
    known:   bool,
}

#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct WireguardStatus {
    installed: bool,
    up:        bool,
    interface: String,
}

#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct InfoState {
    ssid:        String,
    has_profile: bool,
    password:    String,
    autoconnect: Option<bool>,
    bssid:       String,
    channel:     String,
    band:        String,
    rate:        String,
    security:    String,
    ip4_address: String,
    ip4_gateway: String,
    ip4_dns:     String,
}

// ── nmcli helpers ─────────────────────────────────────────────────────────────
fn nmcli(args: &[&str]) -> Result<String, String> {
    let out = Command::new("nmcli")
        .args(args)
        .output()
        .map_err(|e| format!("nmcli: {e}"))?;
    if out.status.success() {
        Ok(String::from_utf8_lossy(&out.stdout).into_owned())
    } else {
        let err = String::from_utf8_lossy(&out.stderr).trim().to_string();
        Err(if err.is_empty() {
            String::from_utf8_lossy(&out.stdout).trim().to_string()
        } else {
            err
        })
    }
}

// Names of saved Wi-Fi connection profiles (profile name == SSID for
// profiles created by `nmcli device wifi connect`, which is all this app makes).
fn saved_wifi_names() -> std::collections::HashSet<String> {
    let mut names = std::collections::HashSet::new();
    if let Ok(out) = nmcli(&["-t", "-f", "NAME,TYPE", "connection", "show"]) {
        for line in out.lines() {
            let p = split_nmcli(line, 2);
            if p.len() == 2 && p[1] == "802-11-wireless" {
                names.insert(p[0].clone());
            }
        }
    }
    names
}

// nmcli's SIGNAL is NetworkManager's nl80211 mapping (percent = dBm + 120,
// capped at −20 dBm), so an excellent −48 dBm link reads as only 72%. Recover
// the dBm and apply the conventional quality curve, 2·(dBm + 100), which rates
// −50 dBm and better as 100%.
fn signal_percent(nm_signal: u8) -> u8 {
    let dbm = nm_signal as i32 - 120;
    (2 * (dbm + 100)).clamp(0, 100) as u8
}

fn parse_networks(output: &str, saved: &std::collections::HashSet<String>) -> Vec<Network> {
    let mut networks: Vec<Network> = Vec::new();
    for line in output.lines() {
        let parts: Vec<&str> = line.splitn(5, ':').collect();
        if parts.len() < 4 { continue; }
        let in_use  = parts[0].trim() == "*";
        let ssid    = parts[1].replace("\\:", ":").trim().to_string();
        let signal: u8 = parts[2].trim().parse().map(signal_percent).unwrap_or(0);
        let secured = { let s = parts[3].trim(); !s.is_empty() && s != "--" };
        if ssid.is_empty() || ssid == "--" { continue; }
        if let Some(ex) = networks.iter_mut().find(|n| n.ssid == ssid) {
            if in_use || signal > ex.signal { ex.signal = signal; ex.in_use = in_use; }
            continue;
        }
        let known = saved.contains(&ssid);
        networks.push(Network { ssid, signal, secured, in_use, known });
    }
    networks.sort_by(|a, b| b.in_use.cmp(&a.in_use).then(b.signal.cmp(&a.signal)));
    networks
}

// Split a nmcli -t output line on unescaped ':' into at most max_parts fields.
fn split_nmcli(line: &str, max_parts: usize) -> Vec<String> {
    let mut parts = Vec::new();
    let mut cur   = String::new();
    let mut chars = line.chars().peekable();
    while let Some(c) = chars.next() {
        if c == '\\' && chars.peek() == Some(&':') {
            chars.next();
            cur.push(':');
        } else if c == ':' && parts.len() + 1 < max_parts {
            parts.push(std::mem::take(&mut cur));
        } else {
            cur.push(c);
        }
    }
    parts.push(cur);
    parts
}

// ── Commands ──────────────────────────────────────────────────────────────────
#[tauri::command]
async fn scan() -> Result<Vec<Network>, String> {
    let saved = saved_wifi_names();
    nmcli(&["-t", "-f", "IN-USE,SSID,SIGNAL,SECURITY",
            "device", "wifi", "list", "--rescan", "yes"])
        .map(|out| parse_networks(&out, &saved))
}

#[tauri::command]
async fn quick_scan() -> Result<Vec<Network>, String> {
    let saved = saved_wifi_names();
    nmcli(&["-t", "-f", "IN-USE,SSID,SIGNAL,SECURITY",
            "device", "wifi", "list", "--rescan", "no"])
        .map(|out| parse_networks(&out, &saved))
}

#[tauri::command]
async fn get_wifi_enabled() -> bool {
    nmcli(&["radio", "wifi"])
        .map(|out| out.trim() == "enabled")
        .unwrap_or(true)
}

#[tauri::command]
async fn set_wifi_enabled(enabled: bool) -> Result<(), String> {
    nmcli(&["radio", "wifi", if enabled { "on" } else { "off" }]).map(|_| ())
}

#[tauri::command]
async fn connect(ssid: String, password: Option<String>) -> Result<(), String> {
    let mut args = vec!["device", "wifi", "connect", ssid.as_str()];
    if let Some(pw) = password.as_deref() {
        args.extend(["password", pw]);
    }
    nmcli(&args).map(|_| ())
}

// Bring up an existing saved profile (no password prompt needed).
#[tauri::command]
async fn connect_saved(ssid: String) -> Result<(), String> {
    nmcli(&["connection", "up", "id", &ssid]).map(|_| ())
}

#[tauri::command]
async fn connect_hidden(ssid: String, password: Option<String>) -> Result<(), String> {
    let mut args = vec!["device", "wifi", "connect", ssid.as_str()];
    if let Some(pw) = password.as_deref() {
        args.extend(["password", pw]);
    }
    args.extend(["hidden", "yes"]);
    nmcli(&args).map(|_| ())
}

#[tauri::command]
async fn set_autoconnect(ssid: String, enabled: bool) -> Result<(), String> {
    nmcli(&["connection", "modify", "id", &ssid, "connection.autoconnect",
            if enabled { "yes" } else { "no" }]).map(|_| ())
}

#[tauri::command]
async fn disconnect(ssid: String) -> Result<(), String> {
    nmcli(&["connection", "down", "id", &ssid]).map(|_| ())
}

#[tauri::command]
async fn forget(ssid: String) -> Result<(), String> {
    nmcli(&["connection", "delete", "id", &ssid]).map(|_| ())
}

#[tauri::command]
async fn save_password(ssid: String, password: String) -> Result<(), String> {
    nmcli(&["connection", "modify", "id", &ssid, "wifi-sec.psk", &password]).map(|_| ())
}

#[tauri::command]
async fn fetch_info(ssid: String, in_use: bool) -> InfoState {
    let mut s = InfoState {
        ssid:        ssid.clone(),
        has_profile: false,
        password:    String::new(),
        autoconnect: None,
        bssid:       String::new(),
        channel:     String::new(),
        band:        String::new(),
        rate:        String::new(),
        security:    String::new(),
        ip4_address: String::new(),
        ip4_gateway: String::new(),
        ip4_dns:     String::new(),
    };

    // 1. Saved profile: autoconnect + PSK
    if let Ok(out) = nmcli(&["--show-secrets", "-t", "-f",
                             "connection.autoconnect,802-11-wireless-security.psk",
                             "connection", "show", "id", &ssid]) {
        s.has_profile = true;
        for line in out.lines() {
            let p = split_nmcli(line, 2);
            if p.len() < 2 { continue; }
            match p[0].as_str() {
                "connection.autoconnect" => {
                    s.autoconnect = match p[1].as_str() {
                        "no" => Some(false),
                        _    => Some(true),   // "yes" or "-1" (default = yes)
                    };
                }
                "802-11-wireless-security.psk" => {
                    let v = p[1].trim().to_string();
                    if !v.is_empty() && v != "--" { s.password = v; }
                }
                _ => {}
            }
        }
    }

    // 2. Scan cache: BSSID, channel, band, rate, security
    if let Ok(out) = nmcli(&["-t", "-f", "SSID,BSSID,CHAN,FREQ,RATE,SECURITY",
                             "device", "wifi", "list"]) {
        for line in out.lines() {
            let p = split_nmcli(line, 6);
            if p.len() < 6 || p[0].trim() != ssid { continue; }
            let bssid = p[1].trim().to_string();
            let chan  = p[2].trim().to_string();
            let freq  = p[3].trim();
            let rate  = p[4].trim();
            let sec   = p[5].trim();
            if bssid != "--" && !bssid.is_empty() { s.bssid = bssid; }
            if chan  != "--" && !chan.is_empty()  { s.channel = chan; }
            let mhz: u32 = freq.split_whitespace().next()
                .and_then(|x| x.parse().ok()).unwrap_or(0);
            s.band = if mhz >= 5925 { "6 GHz".into() }
                     else if mhz >= 4900 { "5 GHz".into() }
                     else if mhz > 0    { "2.4 GHz".into() }
                     else { String::new() };
            if rate != "--" && !rate.is_empty() { s.rate = rate.to_string(); }
            if !sec.is_empty() && sec != "--" {
                s.security = sec.split_whitespace().collect::<Vec<_>>().join("/");
            }
            break;
        }
    }

    // 3. Active IP info (only when connected)
    if in_use {
        if let Ok(out) = nmcli(&["-t", "-f", "IP4.ADDRESS,IP4.GATEWAY,IP4.DNS",
                                 "connection", "show", "--active", "id", &ssid]) {
            for line in out.lines() {
                let p = split_nmcli(line, 2);
                if p.len() < 2 { continue; }
                let field = p[0].as_str();
                let val   = p[1].trim().to_string();
                if val.is_empty() || val == "--" { continue; }
                // Field names may carry an index suffix: IP4.ADDRESS[1]
                if field.starts_with("IP4.ADDRESS") && s.ip4_address.is_empty() {
                    s.ip4_address = val;
                } else if field.starts_with("IP4.GATEWAY") {
                    s.ip4_gateway = val;
                } else if field.starts_with("IP4.DNS") && s.ip4_dns.is_empty() {
                    s.ip4_dns = val;
                }
            }
        }
    }

    s
}

// ── WireGuard ─────────────────────────────────────────────────────────────────
// First WireGuard-type NetworkManager profile, if any: (name, currently active).
// Preferred over wg-quick — NM needs no root and applies DNS itself instead of
// going through resolvconf.
fn nm_wg_profile() -> Option<(String, bool)> {
    let out = nmcli(&["-t", "-f", "NAME,TYPE,DEVICE", "connection", "show"]).ok()?;
    for line in out.lines() {
        let p = split_nmcli(line, 3);
        if p.len() == 3 && p[1] == "wireguard" {
            return Some((p[0].clone(), !p[2].is_empty()));
        }
    }
    None
}

// The interface a fresh `up` should bring up; active interfaces are detected.
fn wg_default_iface() -> String {
    std::env::var("WIFI_GUI_WG_IFACE").unwrap_or_else(|_| "wg0".into())
}

// First active WireGuard interface, via `ip link` (works unprivileged, unlike
// `wg show` or reading /etc/wireguard). Lines look like "9: wg0: <UP,...>".
fn active_wg_iface() -> Option<String> {
    let out = Command::new("ip")
        .args(["-o", "link", "show", "type", "wireguard"])
        .output()
        .ok()?;
    if !out.status.success() {
        return None;
    }
    String::from_utf8_lossy(&out.stdout).lines().find_map(|line| {
        let name = line.split(':').nth(1)?.trim();
        let name = name.split('@').next().unwrap_or(name);
        (!name.is_empty()).then(|| name.to_string())
    })
}

#[tauri::command]
async fn wireguard_status() -> WireguardStatus {
    if let Some((name, active)) = nm_wg_profile() {
        return WireguardStatus { installed: true, up: active, interface: name };
    }
    let installed = Command::new("which")
        .arg("wg-quick")
        .output()
        .map(|o| o.status.success())
        .unwrap_or(false);
    match active_wg_iface() {
        Some(iface) => WireguardStatus { installed, up: true, interface: iface },
        None => WireguardStatus { installed, up: false, interface: wg_default_iface() },
    }
}

// wg-quick needs root: try passwordless doas/sudo first (non-interactive, so
// they fail fast if a password would be needed), then fall back to pkexec.
// --disable-internal-agent stops pkexec from prompting on the controlling
// terminal when no graphical polkit agent is around; it errors out instead.
#[tauri::command]
async fn set_wireguard(up: bool, interface: String) -> Result<(), String> {
    let action = if up { "up" } else { "down" };
    // An NM-managed profile needs no privilege escalation at all.
    if let Some((name, _)) = nm_wg_profile() {
        if name == interface {
            return nmcli(&["connection", action, "id", &name]).map(|_| ());
        }
    }
    let mut errs: Vec<String> = Vec::new();
    for runner in [&["doas", "-n"][..], &["sudo", "-n"], &["pkexec", "--disable-internal-agent"]] {
        let out = Command::new(runner[0])
            .args(&runner[1..])
            .args(["wg-quick", action, &interface])
            .output();
        match out {
            Ok(o) if o.status.success() => return Ok(()),
            Ok(o) => {
                let err = String::from_utf8_lossy(&o.stderr).trim().to_string();
                errs.push(if err.is_empty() {
                    format!("{}: {}", runner[0], o.status)
                } else {
                    err
                });
            }
            Err(e) => errs.push(format!("{}: {e}", runner[0])),
        }
    }
    Err(errs.join(" · "))
}

// ── UI scale ──────────────────────────────────────────────────────────────────
// WebKitGTK only honors integer GTK scale factors, unlike winit (used by the
// old egui build), which derives a fractional scale from the monitor's
// physical DPI on X11. Recompute that scale here so the app looks the same.
#[cfg(target_os = "linux")]
fn ui_scale() -> f64 {
    use gdk::prelude::MonitorExt;
    if let Some(s) = std::env::var("WIFI_GUI_SCALE").ok().and_then(|v| v.parse::<f64>().ok()) {
        return s.clamp(0.5, 4.0);
    }
    if let Some(monitor) = gdk::Display::default().and_then(|d| d.monitor(0)) {
        let px = (monitor.geometry().width() * monitor.scale_factor()) as f64;
        let mm = monitor.width_mm() as f64;
        if px > 0.0 && mm > 0.0 {
            let scale = (px * 25.4 / mm) / 96.0;
            if (0.75..=4.0).contains(&scale) {
                return scale;
            }
        }
    }
    1.0
}

// ── main ──────────────────────────────────────────────────────────────────────
fn main() {
    tauri::Builder::default()
        .setup(|app| {
            #[cfg(target_os = "linux")]
            {
                use tauri::Manager;
                let win = app.get_webview_window("main").expect("main window");
                let scale = ui_scale();
                if (scale - 1.0).abs() > 0.02 {
                    // set_size is ignored on non-resizable GTK windows
                    let _ = win.set_resizable(true);
                    let _ = win.set_size(tauri::LogicalSize::new(360.0 * scale, 500.0 * scale));
                    let _ = win.set_resizable(false);
                    let _ = win.set_zoom(scale);
                }
            }
            Ok(())
        })
        .invoke_handler(tauri::generate_handler![
            scan,
            quick_scan,
            get_wifi_enabled,
            set_wifi_enabled,
            connect,
            connect_saved,
            connect_hidden,
            set_autoconnect,
            disconnect,
            forget,
            save_password,
            fetch_info,
            wireguard_status,
            set_wireguard,
        ])
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}