Skip to main content

max / mountaineer-sysop

10.5 KB · 355 lines History Blame Raw
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 => {} // unsupported, ignore
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