| 1 |
use anyhow::{Context, Result}; |
| 2 |
use crossterm::event::{self, Event, KeyCode}; |
| 3 |
use ratatui::Frame; |
| 4 |
use ratatui::style::Style; |
| 5 |
use ratatui::text::{Line, Span}; |
| 6 |
use ratatui::widgets::Paragraph; |
| 7 |
use std::process::Command; |
| 8 |
use sysop_tui::{Footer, GlobalAction, classify, hint, layout, palette, selection, style}; |
| 9 |
|
| 10 |
const WLAN_DEV: &str = "wlan0"; |
| 11 |
|
| 12 |
#[derive(Debug, Clone, Copy, PartialEq, Eq)] |
| 13 |
pub enum Security { |
| 14 |
Open, |
| 15 |
Psk, |
| 16 |
Enterprise, |
| 17 |
} |
| 18 |
|
| 19 |
impl Security { |
| 20 |
fn parse(s: &str) -> Self { |
| 21 |
match s.to_lowercase().as_str() { |
| 22 |
"open" => Security::Open, |
| 23 |
"psk" => Security::Psk, |
| 24 |
"8021x" | "wpa-eap" | "eap" => Security::Enterprise, |
| 25 |
_ => Security::Psk, |
| 26 |
} |
| 27 |
} |
| 28 |
|
| 29 |
fn label(&self) -> &'static str { |
| 30 |
match self { |
| 31 |
Security::Open => "open", |
| 32 |
Security::Psk => "psk", |
| 33 |
Security::Enterprise => "8021x", |
| 34 |
} |
| 35 |
} |
| 36 |
} |
| 37 |
|
| 38 |
#[derive(Debug, Clone)] |
| 39 |
pub struct Network { |
| 40 |
pub ssid: String, |
| 41 |
pub security: Security, |
| 42 |
pub signal_bars: u8, |
| 43 |
pub known: bool, |
| 44 |
} |
| 45 |
|
| 46 |
pub trait Backend { |
| 47 |
fn list(&self) -> Result<Vec<Network>>; |
| 48 |
fn connect(&self, ssid: &str, password: Option<&str>) -> Result<()>; |
| 49 |
} |
| 50 |
|
| 51 |
fn detect() -> Box<dyn Backend> { |
| 52 |
if Iwd.probe().is_ok() { |
| 53 |
Box::new(Iwd) |
| 54 |
} else { |
| 55 |
Box::new(Mock) |
| 56 |
} |
| 57 |
} |
| 58 |
|
| 59 |
struct Iwd; |
| 60 |
|
| 61 |
impl Iwd { |
| 62 |
fn probe(&self) -> Result<()> { |
| 63 |
let out = Command::new("iwctl") |
| 64 |
.arg("--version") |
| 65 |
.output() |
| 66 |
.context("invoke iwctl")?; |
| 67 |
if out.status.success() { |
| 68 |
Ok(()) |
| 69 |
} else { |
| 70 |
anyhow::bail!("iwctl unresponsive") |
| 71 |
} |
| 72 |
} |
| 73 |
} |
| 74 |
|
| 75 |
impl Backend for Iwd { |
| 76 |
fn list(&self) -> Result<Vec<Network>> { |
| 77 |
let out = Command::new("iwctl") |
| 78 |
.args(["station", WLAN_DEV, "get-networks"]) |
| 79 |
.output() |
| 80 |
.context("iwctl get-networks")?; |
| 81 |
if !out.status.success() { |
| 82 |
anyhow::bail!("iwctl get-networks failed"); |
| 83 |
} |
| 84 |
Ok(parse_networks(&String::from_utf8_lossy(&out.stdout))) |
| 85 |
} |
| 86 |
|
| 87 |
fn connect(&self, ssid: &str, password: Option<&str>) -> Result<()> { |
| 88 |
let mut cmd = Command::new("iwctl"); |
| 89 |
if let Some(psk) = password { |
| 90 |
cmd.arg(format!("--passphrase={psk}")); |
| 91 |
} |
| 92 |
cmd.args(["station", WLAN_DEV, "connect", ssid]); |
| 93 |
let status = cmd.status().context("iwctl connect")?; |
| 94 |
if !status.success() { |
| 95 |
anyhow::bail!("iwctl exited {status}"); |
| 96 |
} |
| 97 |
Ok(()) |
| 98 |
} |
| 99 |
} |
| 100 |
|
| 101 |
fn parse_networks(text: &str) -> Vec<Network> { |
| 102 |
let mut out = Vec::new(); |
| 103 |
for raw in text.lines() { |
| 104 |
let line = raw.trim_end(); |
| 105 |
if line.is_empty() |
| 106 |
|| line.contains("---") |
| 107 |
|| line.contains("Network name") |
| 108 |
|| line.contains("Available networks") |
| 109 |
{ |
| 110 |
continue; |
| 111 |
} |
| 112 |
let trimmed = line.trim_start(); |
| 113 |
let (known, rest) = match trimmed.strip_prefix('>') { |
| 114 |
Some(after) => (true, after.trim_start()), |
| 115 |
None => (false, trimmed), |
| 116 |
}; |
| 117 |
let parts: Vec<&str> = rest.split_whitespace().collect(); |
| 118 |
if parts.len() < 3 { |
| 119 |
continue; |
| 120 |
} |
| 121 |
let signal_str = parts.last().unwrap(); |
| 122 |
if !signal_str.chars().all(|c| c == '*') { |
| 123 |
continue; |
| 124 |
} |
| 125 |
let signal_bars = signal_str.chars().count().min(4) as u8; |
| 126 |
let security_str = parts[parts.len() - 2]; |
| 127 |
let ssid = parts[..parts.len() - 2].join(" "); |
| 128 |
out.push(Network { |
| 129 |
ssid, |
| 130 |
security: Security::parse(security_str), |
| 131 |
signal_bars, |
| 132 |
known, |
| 133 |
}); |
| 134 |
} |
| 135 |
out |
| 136 |
} |
| 137 |
|
| 138 |
struct Mock; |
| 139 |
|
| 140 |
impl Backend for Mock { |
| 141 |
fn list(&self) -> Result<Vec<Network>> { |
| 142 |
Ok(vec![ |
| 143 |
net("home-net", Security::Psk, 4, true), |
| 144 |
net("guest", Security::Open, 3, false), |
| 145 |
net("neighbor", Security::Psk, 2, false), |
| 146 |
net("ANNEX-corp", Security::Enterprise, 3, false), |
| 147 |
net("weak", Security::Psk, 1, false), |
| 148 |
]) |
| 149 |
} |
| 150 |
|
| 151 |
fn connect(&self, _ssid: &str, _password: Option<&str>) -> Result<()> { |
| 152 |
Ok(()) |
| 153 |
} |
| 154 |
} |
| 155 |
|
| 156 |
fn net(ssid: &str, security: Security, signal_bars: u8, known: bool) -> Network { |
| 157 |
Network { |
| 158 |
ssid: ssid.to_string(), |
| 159 |
security, |
| 160 |
signal_bars, |
| 161 |
known, |
| 162 |
} |
| 163 |
} |
| 164 |
|
| 165 |
enum View { |
| 166 |
List, |
| 167 |
Password { ssid: String, input: String }, |
| 168 |
} |
| 169 |
|
| 170 |
enum Outcome { |
| 171 |
Quit, |
| 172 |
Connect { |
| 173 |
ssid: String, |
| 174 |
password: Option<String>, |
| 175 |
}, |
| 176 |
} |
| 177 |
|
| 178 |
pub fn run() -> Result<i32> { |
| 179 |
let backend = detect(); |
| 180 |
let networks = backend.list()?; |
| 181 |
if networks.is_empty() { |
| 182 |
eprintln!("sysop wifi: no networks visible"); |
| 183 |
eprintln!(" try: iwctl station {WLAN_DEV} scan"); |
| 184 |
return Ok(1); |
| 185 |
} |
| 186 |
|
| 187 |
let mut terminal = ratatui::init(); |
| 188 |
let result = event_loop(&mut terminal, &networks); |
| 189 |
ratatui::restore(); |
| 190 |
|
| 191 |
match result? { |
| 192 |
Outcome::Quit => Ok(0), |
| 193 |
Outcome::Connect { ssid, password } => { |
| 194 |
println!("connecting to {ssid}..."); |
| 195 |
match backend.connect(&ssid, password.as_deref()) { |
| 196 |
Ok(()) => { |
| 197 |
println!("connected"); |
| 198 |
Ok(0) |
| 199 |
} |
| 200 |
Err(e) => { |
| 201 |
eprintln!("connect failed: {e}"); |
| 202 |
Ok(1) |
| 203 |
} |
| 204 |
} |
| 205 |
} |
| 206 |
} |
| 207 |
} |
| 208 |
|
| 209 |
fn event_loop(terminal: &mut ratatui::DefaultTerminal, networks: &[Network]) -> Result<Outcome> { |
| 210 |
let mut selected: usize = 0; |
| 211 |
let mut view = View::List; |
| 212 |
loop { |
| 213 |
terminal.draw(|frame| draw(frame, networks, selected, &view))?; |
| 214 |
let Event::Key(key) = event::read()? else { |
| 215 |
continue; |
| 216 |
}; |
| 217 |
|
| 218 |
match &mut view { |
| 219 |
View::List => match classify(key) { |
| 220 |
GlobalAction::Quit => return Ok(Outcome::Quit), |
| 221 |
GlobalAction::Passthrough => match key.code { |
| 222 |
KeyCode::Char('j') | KeyCode::Down if selected + 1 < networks.len() => { |
| 223 |
selected += 1; |
| 224 |
} |
| 225 |
KeyCode::Char('k') | KeyCode::Up => { |
| 226 |
selected = selected.saturating_sub(1); |
| 227 |
} |
| 228 |
KeyCode::Enter => match networks[selected].security { |
| 229 |
Security::Open => { |
| 230 |
return Ok(Outcome::Connect { |
| 231 |
ssid: networks[selected].ssid.clone(), |
| 232 |
password: None, |
| 233 |
}); |
| 234 |
} |
| 235 |
Security::Psk => { |
| 236 |
view = View::Password { |
| 237 |
ssid: networks[selected].ssid.clone(), |
| 238 |
input: String::new(), |
| 239 |
}; |
| 240 |
} |
| 241 |
Security::Enterprise => {} |
| 242 |
}, |
| 243 |
_ => {} |
| 244 |
}, |
| 245 |
_ => {} |
| 246 |
}, |
| 247 |
View::Password { ssid, input } => match classify(key) { |
| 248 |
GlobalAction::Quit => return Ok(Outcome::Quit), |
| 249 |
GlobalAction::Back => { |
| 250 |
view = View::List; |
| 251 |
} |
| 252 |
_ => match key.code { |
| 253 |
KeyCode::Enter => { |
| 254 |
return Ok(Outcome::Connect { |
| 255 |
ssid: ssid.clone(), |
| 256 |
password: Some(input.clone()), |
| 257 |
}); |
| 258 |
} |
| 259 |
KeyCode::Backspace => { |
| 260 |
input.pop(); |
| 261 |
} |
| 262 |
KeyCode::Char(c) => { |
| 263 |
input.push(c); |
| 264 |
} |
| 265 |
_ => {} |
| 266 |
}, |
| 267 |
}, |
| 268 |
} |
| 269 |
} |
| 270 |
} |
| 271 |
|
| 272 |
fn draw(frame: &mut Frame, networks: &[Network], selected: usize, view: &View) { |
| 273 |
let [body, foot] = layout::split(frame.area()); |
| 274 |
match view { |
| 275 |
View::List => { |
| 276 |
frame.render_widget(list_widget(networks, selected), body); |
| 277 |
frame.render_widget( |
| 278 |
&Footer::new([ |
| 279 |
hint("j/k", "move"), |
| 280 |
hint("Enter", "connect"), |
| 281 |
hint("q", "quit"), |
| 282 |
]), |
| 283 |
foot, |
| 284 |
); |
| 285 |
} |
| 286 |
View::Password { ssid, input } => { |
| 287 |
frame.render_widget(password_widget(ssid, input), body); |
| 288 |
frame.render_widget( |
| 289 |
&Footer::new([ |
| 290 |
hint("Enter", "submit"), |
| 291 |
hint("Esc", "back"), |
| 292 |
hint("q", "quit"), |
| 293 |
]), |
| 294 |
foot, |
| 295 |
); |
| 296 |
} |
| 297 |
} |
| 298 |
} |
| 299 |
|
| 300 |
fn list_widget(networks: &[Network], selected: usize) -> Paragraph<'_> { |
| 301 |
let name_w = networks.iter().map(|n| n.ssid.len()).max().unwrap_or(0).max(8); |
| 302 |
let lines: Vec<Line> = networks |
| 303 |
.iter() |
| 304 |
.enumerate() |
| 305 |
.map(|(i, n)| { |
| 306 |
let is_sel = i == selected; |
| 307 |
let marker = if is_sel { selection::MARKER } else { " " }; |
| 308 |
let known_mark = if n.known { "*" } else { " " }; |
| 309 |
let signal = format!( |
| 310 |
"{:<4}", |
| 311 |
"▂▄▆█".chars().take(n.signal_bars as usize).collect::<String>() |
| 312 |
); |
| 313 |
let security_span = match n.security { |
| 314 |
Security::Enterprise => style::inactive(n.security.label()), |
| 315 |
_ => style::dim(n.security.label()), |
| 316 |
}; |
| 317 |
let line = Line::from(vec![ |
| 318 |
Span::raw(format!(" {marker} ")), |
| 319 |
style::dim(known_mark), |
| 320 |
Span::raw(" "), |
| 321 |
Span::raw(format!("{:<name_w$}", n.ssid)), |
| 322 |
Span::raw(" "), |
| 323 |
Span::raw(signal), |
| 324 |
Span::raw(" "), |
| 325 |
security_span, |
| 326 |
]); |
| 327 |
if is_sel { |
| 328 |
line.style(selection::selected_style()) |
| 329 |
} else { |
| 330 |
line |
| 331 |
} |
| 332 |
}) |
| 333 |
.collect(); |
| 334 |
Paragraph::new(lines).style(Style::default().bg(palette::BG).fg(palette::FG)) |
| 335 |
} |
| 336 |
|
| 337 |
fn password_widget<'a>(ssid: &'a str, input: &'a str) -> Paragraph<'a> { |
| 338 |
let mask: String = "●".repeat(input.chars().count()); |
| 339 |
let lines = vec![ |
| 340 |
Line::from(""), |
| 341 |
Line::from(vec![ |
| 342 |
Span::raw(" "), |
| 343 |
style::dim("network "), |
| 344 |
style::fg(ssid.to_string()), |
| 345 |
]), |
| 346 |
Line::from(vec![ |
| 347 |
Span::raw(" "), |
| 348 |
style::dim("password "), |
| 349 |
style::fg(mask), |
| 350 |
Span::styled("▏", Style::default().fg(palette::ACCENT)), |
| 351 |
]), |
| 352 |
]; |
| 353 |
Paragraph::new(lines).style(Style::default().bg(palette::BG).fg(palette::FG)) |
| 354 |
} |
| 355 |
|