|
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 |
+ |
}
|