use anyhow::{Context, Result}; use crossterm::event::{self, Event, KeyCode}; use ratatui::Frame; use ratatui::style::Style; use ratatui::text::{Line, Span}; use ratatui::widgets::Paragraph; use std::process::Command; use sysop_tui::{Footer, GlobalAction, classify, hint, layout, palette, selection, style}; const WLAN_DEV: &str = "wlan0"; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Security { Open, Psk, Enterprise, } impl Security { fn parse(s: &str) -> Self { match s.to_lowercase().as_str() { "open" => Security::Open, "psk" => Security::Psk, "8021x" | "wpa-eap" | "eap" => Security::Enterprise, _ => Security::Psk, } } fn label(&self) -> &'static str { match self { Security::Open => "open", Security::Psk => "psk", Security::Enterprise => "8021x", } } } #[derive(Debug, Clone)] pub struct Network { pub ssid: String, pub security: Security, pub signal_bars: u8, pub known: bool, } pub trait Backend { fn list(&self) -> Result>; fn connect(&self, ssid: &str, password: Option<&str>) -> Result<()>; } fn detect() -> Box { if Iwd.probe().is_ok() { Box::new(Iwd) } else { Box::new(Mock) } } struct Iwd; impl Iwd { fn probe(&self) -> Result<()> { let out = Command::new("iwctl") .arg("--version") .output() .context("invoke iwctl")?; if out.status.success() { Ok(()) } else { anyhow::bail!("iwctl unresponsive") } } } impl Backend for Iwd { fn list(&self) -> Result> { let out = Command::new("iwctl") .args(["station", WLAN_DEV, "get-networks"]) .output() .context("iwctl get-networks")?; if !out.status.success() { anyhow::bail!("iwctl get-networks failed"); } Ok(parse_networks(&String::from_utf8_lossy(&out.stdout))) } fn connect(&self, ssid: &str, password: Option<&str>) -> Result<()> { let mut cmd = Command::new("iwctl"); if let Some(psk) = password { cmd.arg(format!("--passphrase={psk}")); } cmd.args(["station", WLAN_DEV, "connect", ssid]); let status = cmd.status().context("iwctl connect")?; if !status.success() { anyhow::bail!("iwctl exited {status}"); } Ok(()) } } fn parse_networks(text: &str) -> Vec { let mut out = Vec::new(); for raw in text.lines() { let line = raw.trim_end(); if line.is_empty() || line.contains("---") || line.contains("Network name") || line.contains("Available networks") { continue; } let trimmed = line.trim_start(); let (known, rest) = match trimmed.strip_prefix('>') { Some(after) => (true, after.trim_start()), None => (false, trimmed), }; let parts: Vec<&str> = rest.split_whitespace().collect(); if parts.len() < 3 { continue; } let signal_str = parts.last().unwrap(); if !signal_str.chars().all(|c| c == '*') { continue; } let signal_bars = signal_str.chars().count().min(4) as u8; let security_str = parts[parts.len() - 2]; let ssid = parts[..parts.len() - 2].join(" "); out.push(Network { ssid, security: Security::parse(security_str), signal_bars, known, }); } out } struct Mock; impl Backend for Mock { fn list(&self) -> Result> { Ok(vec![ net("home-net", Security::Psk, 4, true), net("guest", Security::Open, 3, false), net("neighbor", Security::Psk, 2, false), net("ANNEX-corp", Security::Enterprise, 3, false), net("weak", Security::Psk, 1, false), ]) } fn connect(&self, _ssid: &str, _password: Option<&str>) -> Result<()> { Ok(()) } } fn net(ssid: &str, security: Security, signal_bars: u8, known: bool) -> Network { Network { ssid: ssid.to_string(), security, signal_bars, known, } } enum View { List, Password { ssid: String, input: String }, } enum Outcome { Quit, Connect { ssid: String, password: Option, }, } pub fn run() -> Result { let backend = detect(); let networks = backend.list()?; if networks.is_empty() { eprintln!("sysop wifi: no networks visible"); eprintln!(" try: iwctl station {WLAN_DEV} scan"); return Ok(1); } let mut terminal = ratatui::init(); let result = event_loop(&mut terminal, &networks); ratatui::restore(); match result? { Outcome::Quit => Ok(0), Outcome::Connect { ssid, password } => { println!("connecting to {ssid}..."); match backend.connect(&ssid, password.as_deref()) { Ok(()) => { println!("connected"); Ok(0) } Err(e) => { eprintln!("connect failed: {e}"); Ok(1) } } } } } fn event_loop(terminal: &mut ratatui::DefaultTerminal, networks: &[Network]) -> Result { let mut selected: usize = 0; let mut view = View::List; loop { terminal.draw(|frame| draw(frame, networks, selected, &view))?; let Event::Key(key) = event::read()? else { continue; }; match &mut view { View::List => match classify(key) { GlobalAction::Quit => return Ok(Outcome::Quit), GlobalAction::Passthrough => match key.code { KeyCode::Char('j') | KeyCode::Down if selected + 1 < networks.len() => { selected += 1; } KeyCode::Char('k') | KeyCode::Up => { selected = selected.saturating_sub(1); } KeyCode::Enter => match networks[selected].security { Security::Open => { return Ok(Outcome::Connect { ssid: networks[selected].ssid.clone(), password: None, }); } Security::Psk => { view = View::Password { ssid: networks[selected].ssid.clone(), input: String::new(), }; } Security::Enterprise => {} // unsupported, ignore }, _ => {} }, _ => {} }, View::Password { ssid, input } => match classify(key) { GlobalAction::Quit => return Ok(Outcome::Quit), GlobalAction::Back => { view = View::List; } _ => match key.code { KeyCode::Enter => { return Ok(Outcome::Connect { ssid: ssid.clone(), password: Some(input.clone()), }); } KeyCode::Backspace => { input.pop(); } KeyCode::Char(c) => { input.push(c); } _ => {} }, }, } } } fn draw(frame: &mut Frame, networks: &[Network], selected: usize, view: &View) { let [body, foot] = layout::split(frame.area()); match view { View::List => { frame.render_widget(list_widget(networks, selected), body); frame.render_widget( &Footer::new([ hint("j/k", "move"), hint("Enter", "connect"), hint("q", "quit"), ]), foot, ); } View::Password { ssid, input } => { frame.render_widget(password_widget(ssid, input), body); frame.render_widget( &Footer::new([ hint("Enter", "submit"), hint("Esc", "back"), hint("q", "quit"), ]), foot, ); } } } fn list_widget(networks: &[Network], selected: usize) -> Paragraph<'_> { let name_w = networks.iter().map(|n| n.ssid.len()).max().unwrap_or(0).max(8); let lines: Vec = networks .iter() .enumerate() .map(|(i, n)| { let is_sel = i == selected; let marker = if is_sel { selection::MARKER } else { " " }; let known_mark = if n.known { "*" } else { " " }; let signal = format!( "{:<4}", "▂▄▆█".chars().take(n.signal_bars as usize).collect::() ); let security_span = match n.security { Security::Enterprise => style::inactive(n.security.label()), _ => style::dim(n.security.label()), }; let line = Line::from(vec![ Span::raw(format!(" {marker} ")), style::dim(known_mark), Span::raw(" "), Span::raw(format!("{:(ssid: &'a str, input: &'a str) -> Paragraph<'a> { let mask: String = "●".repeat(input.chars().count()); let lines = vec![ Line::from(""), Line::from(vec![ Span::raw(" "), style::dim("network "), style::fg(ssid.to_string()), ]), Line::from(vec![ Span::raw(" "), style::dim("password "), style::fg(mask), Span::styled("▏", Style::default().fg(palette::ACCENT)), ]), ]; Paragraph::new(lines).style(Style::default().bg(palette::BG).fg(palette::FG)) }