| 5 |
5 |
|
use ratatui::text::{Line, Span};
|
| 6 |
6 |
|
use ratatui::widgets::Paragraph;
|
| 7 |
7 |
|
use std::process::Command;
|
|
8 |
+ |
use std::thread;
|
|
9 |
+ |
use std::time::Duration;
|
| 8 |
10 |
|
use sysop_tui::{Footer, GlobalAction, classify, hint, layout, palette, selection, style};
|
| 9 |
11 |
|
|
| 10 |
|
- |
const WLAN_DEV: &str = "wlan0";
|
|
12 |
+ |
const FALLBACK_DEV: &str = "wlan0";
|
|
13 |
+ |
const SCAN_SETTLE: Duration = Duration::from_millis(1500);
|
| 11 |
14 |
|
|
| 12 |
15 |
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
| 13 |
16 |
|
pub enum Security {
|
| 44 |
47 |
|
}
|
| 45 |
48 |
|
|
| 46 |
49 |
|
pub trait Backend {
|
| 47 |
|
- |
fn list(&self) -> Result<Vec<Network>>;
|
| 48 |
|
- |
fn connect(&self, ssid: &str, password: Option<&str>) -> Result<()>;
|
|
50 |
+ |
fn device(&self) -> Result<String>;
|
|
51 |
+ |
fn scan(&self, dev: &str) -> Result<()>;
|
|
52 |
+ |
fn list(&self, dev: &str) -> Result<Vec<Network>>;
|
|
53 |
+ |
fn connect(&self, dev: &str, ssid: &str, password: Option<&str>) -> Result<()>;
|
|
54 |
+ |
fn disconnect(&self, dev: &str) -> Result<()>;
|
| 49 |
55 |
|
}
|
| 50 |
56 |
|
|
| 51 |
57 |
|
fn detect() -> Box<dyn Backend> {
|
| 73 |
79 |
|
}
|
| 74 |
80 |
|
|
| 75 |
81 |
|
impl Backend for Iwd {
|
| 76 |
|
- |
fn list(&self) -> Result<Vec<Network>> {
|
|
82 |
+ |
fn device(&self) -> Result<String> {
|
| 77 |
83 |
|
let out = Command::new("iwctl")
|
| 78 |
|
- |
.args(["station", WLAN_DEV, "get-networks"])
|
|
84 |
+ |
.args(["device", "list"])
|
|
85 |
+ |
.output()
|
|
86 |
+ |
.context("iwctl device list")?;
|
|
87 |
+ |
if !out.status.success() {
|
|
88 |
+ |
anyhow::bail!("iwctl device list failed");
|
|
89 |
+ |
}
|
|
90 |
+ |
Ok(parse_device(&String::from_utf8_lossy(&out.stdout))
|
|
91 |
+ |
.unwrap_or_else(|| FALLBACK_DEV.to_string()))
|
|
92 |
+ |
}
|
|
93 |
+ |
|
|
94 |
+ |
fn scan(&self, dev: &str) -> Result<()> {
|
|
95 |
+ |
let status = Command::new("iwctl")
|
|
96 |
+ |
.args(["station", dev, "scan"])
|
|
97 |
+ |
.status()
|
|
98 |
+ |
.context("iwctl scan")?;
|
|
99 |
+ |
if !status.success() {
|
|
100 |
+ |
anyhow::bail!("iwctl scan exited {status}");
|
|
101 |
+ |
}
|
|
102 |
+ |
thread::sleep(SCAN_SETTLE);
|
|
103 |
+ |
Ok(())
|
|
104 |
+ |
}
|
|
105 |
+ |
|
|
106 |
+ |
fn list(&self, dev: &str) -> Result<Vec<Network>> {
|
|
107 |
+ |
let out = Command::new("iwctl")
|
|
108 |
+ |
.args(["station", dev, "get-networks"])
|
| 79 |
109 |
|
.output()
|
| 80 |
110 |
|
.context("iwctl get-networks")?;
|
| 81 |
111 |
|
if !out.status.success() {
|
| 84 |
114 |
|
Ok(parse_networks(&String::from_utf8_lossy(&out.stdout)))
|
| 85 |
115 |
|
}
|
| 86 |
116 |
|
|
| 87 |
|
- |
fn connect(&self, ssid: &str, password: Option<&str>) -> Result<()> {
|
|
117 |
+ |
fn connect(&self, dev: &str, ssid: &str, password: Option<&str>) -> Result<()> {
|
| 88 |
118 |
|
let mut cmd = Command::new("iwctl");
|
| 89 |
119 |
|
if let Some(psk) = password {
|
| 90 |
120 |
|
cmd.arg(format!("--passphrase={psk}"));
|
| 91 |
121 |
|
}
|
| 92 |
|
- |
cmd.args(["station", WLAN_DEV, "connect", ssid]);
|
|
122 |
+ |
cmd.args(["station", dev, "connect", ssid]);
|
| 93 |
123 |
|
let status = cmd.status().context("iwctl connect")?;
|
| 94 |
124 |
|
if !status.success() {
|
| 95 |
125 |
|
anyhow::bail!("iwctl exited {status}");
|
| 96 |
126 |
|
}
|
| 97 |
127 |
|
Ok(())
|
| 98 |
128 |
|
}
|
|
129 |
+ |
|
|
130 |
+ |
fn disconnect(&self, dev: &str) -> Result<()> {
|
|
131 |
+ |
let status = Command::new("iwctl")
|
|
132 |
+ |
.args(["station", dev, "disconnect"])
|
|
133 |
+ |
.status()
|
|
134 |
+ |
.context("iwctl disconnect")?;
|
|
135 |
+ |
if !status.success() {
|
|
136 |
+ |
anyhow::bail!("iwctl disconnect exited {status}");
|
|
137 |
+ |
}
|
|
138 |
+ |
Ok(())
|
|
139 |
+ |
}
|
|
140 |
+ |
}
|
|
141 |
+ |
|
|
142 |
+ |
fn parse_device(text: &str) -> Option<String> {
|
|
143 |
+ |
for raw in text.lines() {
|
|
144 |
+ |
let line = raw.trim();
|
|
145 |
+ |
if line.is_empty() || line.contains("---") || line.contains("Name") {
|
|
146 |
+ |
continue;
|
|
147 |
+ |
}
|
|
148 |
+ |
let parts: Vec<&str> = line.split_whitespace().collect();
|
|
149 |
+ |
if parts.len() < 5 {
|
|
150 |
+ |
continue;
|
|
151 |
+ |
}
|
|
152 |
+ |
let mode = parts[parts.len() - 1];
|
|
153 |
+ |
if mode == "station" {
|
|
154 |
+ |
return Some(parts[0].to_string());
|
|
155 |
+ |
}
|
|
156 |
+ |
}
|
|
157 |
+ |
None
|
| 99 |
158 |
|
}
|
| 100 |
159 |
|
|
| 101 |
160 |
|
fn parse_networks(text: &str) -> Vec<Network> {
|
| 138 |
197 |
|
struct Mock;
|
| 139 |
198 |
|
|
| 140 |
199 |
|
impl Backend for Mock {
|
| 141 |
|
- |
fn list(&self) -> Result<Vec<Network>> {
|
|
200 |
+ |
fn device(&self) -> Result<String> {
|
|
201 |
+ |
Ok(FALLBACK_DEV.to_string())
|
|
202 |
+ |
}
|
|
203 |
+ |
|
|
204 |
+ |
fn scan(&self, _dev: &str) -> Result<()> {
|
|
205 |
+ |
Ok(())
|
|
206 |
+ |
}
|
|
207 |
+ |
|
|
208 |
+ |
fn list(&self, _dev: &str) -> Result<Vec<Network>> {
|
| 142 |
209 |
|
Ok(vec![
|
| 143 |
210 |
|
net("home-net", Security::Psk, 4, true),
|
| 144 |
211 |
|
net("guest", Security::Open, 3, false),
|
| 148 |
215 |
|
])
|
| 149 |
216 |
|
}
|
| 150 |
217 |
|
|
| 151 |
|
- |
fn connect(&self, _ssid: &str, _password: Option<&str>) -> Result<()> {
|
|
218 |
+ |
fn connect(&self, _dev: &str, _ssid: &str, _password: Option<&str>) -> Result<()> {
|
|
219 |
+ |
Ok(())
|
|
220 |
+ |
}
|
|
221 |
+ |
|
|
222 |
+ |
fn disconnect(&self, _dev: &str) -> Result<()> {
|
| 152 |
223 |
|
Ok(())
|
| 153 |
224 |
|
}
|
| 154 |
225 |
|
}
|
| 177 |
248 |
|
|
| 178 |
249 |
|
pub fn run() -> Result<i32> {
|
| 179 |
250 |
|
let backend = detect();
|
| 180 |
|
- |
let networks = backend.list()?;
|
|
251 |
+ |
let dev = backend.device()?;
|
|
252 |
+ |
backend.scan(&dev).ok();
|
|
253 |
+ |
let networks = backend.list(&dev)?;
|
| 181 |
254 |
|
if networks.is_empty() {
|
| 182 |
|
- |
eprintln!("sysop wifi: no networks visible");
|
| 183 |
|
- |
eprintln!(" try: iwctl station {WLAN_DEV} scan");
|
|
255 |
+ |
eprintln!("sysop wifi: no networks visible on {dev}");
|
|
256 |
+ |
eprintln!(" try: iwctl station {dev} scan");
|
| 184 |
257 |
|
return Ok(1);
|
| 185 |
258 |
|
}
|
| 186 |
259 |
|
|
| 187 |
260 |
|
let mut terminal = ratatui::init();
|
| 188 |
|
- |
let result = event_loop(&mut terminal, &networks);
|
|
261 |
+ |
let result = event_loop(&mut terminal, backend.as_ref(), &dev, networks);
|
| 189 |
262 |
|
ratatui::restore();
|
| 190 |
263 |
|
|
| 191 |
264 |
|
match result? {
|
| 192 |
265 |
|
Outcome::Quit => Ok(0),
|
| 193 |
266 |
|
Outcome::Connect { ssid, password } => {
|
| 194 |
267 |
|
println!("connecting to {ssid}...");
|
| 195 |
|
- |
match backend.connect(&ssid, password.as_deref()) {
|
|
268 |
+ |
match backend.connect(&dev, &ssid, password.as_deref()) {
|
| 196 |
269 |
|
Ok(()) => {
|
| 197 |
270 |
|
println!("connected");
|
| 198 |
271 |
|
Ok(0)
|
| 206 |
279 |
|
}
|
| 207 |
280 |
|
}
|
| 208 |
281 |
|
|
| 209 |
|
- |
fn event_loop(terminal: &mut ratatui::DefaultTerminal, networks: &[Network]) -> Result<Outcome> {
|
|
282 |
+ |
pub fn disconnect() -> Result<i32> {
|
|
283 |
+ |
let backend = detect();
|
|
284 |
+ |
let dev = backend.device()?;
|
|
285 |
+ |
match backend.disconnect(&dev) {
|
|
286 |
+ |
Ok(()) => {
|
|
287 |
+ |
println!("disconnected {dev}");
|
|
288 |
+ |
Ok(0)
|
|
289 |
+ |
}
|
|
290 |
+ |
Err(e) => {
|
|
291 |
+ |
eprintln!("disconnect failed: {e}");
|
|
292 |
+ |
Ok(1)
|
|
293 |
+ |
}
|
|
294 |
+ |
}
|
|
295 |
+ |
}
|
|
296 |
+ |
|
|
297 |
+ |
fn event_loop(
|
|
298 |
+ |
terminal: &mut ratatui::DefaultTerminal,
|
|
299 |
+ |
backend: &dyn Backend,
|
|
300 |
+ |
dev: &str,
|
|
301 |
+ |
initial: Vec<Network>,
|
|
302 |
+ |
) -> Result<Outcome> {
|
|
303 |
+ |
let mut networks = initial;
|
| 210 |
304 |
|
let mut selected: usize = 0;
|
| 211 |
305 |
|
let mut view = View::List;
|
|
306 |
+ |
let mut scanning = false;
|
| 212 |
307 |
|
loop {
|
| 213 |
|
- |
terminal.draw(|frame| draw(frame, networks, selected, &view))?;
|
|
308 |
+ |
terminal.draw(|frame| draw(frame, &networks, selected, &view, scanning))?;
|
|
309 |
+ |
|
|
310 |
+ |
if scanning {
|
|
311 |
+ |
scanning = false;
|
|
312 |
+ |
backend.scan(dev).ok();
|
|
313 |
+ |
networks = backend.list(dev).unwrap_or(networks);
|
|
314 |
+ |
selected = selected.min(networks.len().saturating_sub(1));
|
|
315 |
+ |
continue;
|
|
316 |
+ |
}
|
|
317 |
+ |
|
| 214 |
318 |
|
let Event::Key(key) = event::read()? else {
|
| 215 |
319 |
|
continue;
|
| 216 |
320 |
|
};
|
| 225 |
329 |
|
KeyCode::Char('k') | KeyCode::Up => {
|
| 226 |
330 |
|
selected = selected.saturating_sub(1);
|
| 227 |
331 |
|
}
|
| 228 |
|
- |
KeyCode::Enter => match networks[selected].security {
|
|
332 |
+ |
KeyCode::Char('r') => {
|
|
333 |
+ |
scanning = true;
|
|
334 |
+ |
}
|
|
335 |
+ |
KeyCode::Enter if !networks.is_empty() => match networks[selected].security {
|
| 229 |
336 |
|
Security::Open => {
|
| 230 |
337 |
|
return Ok(Outcome::Connect {
|
| 231 |
338 |
|
ssid: networks[selected].ssid.clone(),
|
| 269 |
376 |
|
}
|
| 270 |
377 |
|
}
|
| 271 |
378 |
|
|
| 272 |
|
- |
fn draw(frame: &mut Frame, networks: &[Network], selected: usize, view: &View) {
|
|
379 |
+ |
fn draw(frame: &mut Frame, networks: &[Network], selected: usize, view: &View, scanning: bool) {
|
| 273 |
380 |
|
let [body, foot] = layout::split(frame.area());
|
| 274 |
381 |
|
match view {
|
| 275 |
382 |
|
View::List => {
|
| 276 |
|
- |
frame.render_widget(list_widget(networks, selected), body);
|
|
383 |
+ |
frame.render_widget(list_widget(networks, selected, scanning), body);
|
| 277 |
384 |
|
frame.render_widget(
|
| 278 |
385 |
|
&Footer::new([
|
| 279 |
386 |
|
hint("j/k", "move"),
|
| 280 |
387 |
|
hint("Enter", "connect"),
|
|
388 |
+ |
hint("r", "rescan"),
|
| 281 |
389 |
|
hint("q", "quit"),
|
| 282 |
390 |
|
]),
|
| 283 |
391 |
|
foot,
|
| 297 |
405 |
|
}
|
| 298 |
406 |
|
}
|
| 299 |
407 |
|
|
| 300 |
|
- |
fn list_widget(networks: &[Network], selected: usize) -> Paragraph<'_> {
|
|
408 |
+ |
fn list_widget(networks: &[Network], selected: usize, scanning: bool) -> Paragraph<'_> {
|
| 301 |
409 |
|
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();
|
|
410 |
+ |
let mut lines: Vec<Line> = Vec::with_capacity(networks.len() + 2);
|
|
411 |
+ |
|
|
412 |
+ |
if scanning {
|
|
413 |
+ |
lines.push(Line::from(vec![
|
|
414 |
+ |
Span::raw(" "),
|
|
415 |
+ |
style::dim("scanning..."),
|
|
416 |
+ |
]));
|
|
417 |
+ |
lines.push(Line::from(""));
|
|
418 |
+ |
}
|
|
419 |
+ |
|
|
420 |
+ |
for (i, n) in networks.iter().enumerate() {
|
|
421 |
+ |
let is_sel = i == selected;
|
|
422 |
+ |
let marker = if is_sel { selection::MARKER } else { " " };
|
|
423 |
+ |
let known_mark = if n.known { "*" } else { " " };
|
|
424 |
+ |
let signal = format!(
|
|
425 |
+ |
"{:<4}",
|
|
426 |
+ |
"▂▄▆█".chars().take(n.signal_bars as usize).collect::<String>()
|
|
427 |
+ |
);
|
|
428 |
+ |
let security_span = match n.security {
|
|
429 |
+ |
Security::Enterprise => style::inactive(n.security.label()),
|
|
430 |
+ |
_ => style::dim(n.security.label()),
|
|
431 |
+ |
};
|
|
432 |
+ |
let line = Line::from(vec![
|
|
433 |
+ |
Span::raw(format!(" {marker} ")),
|
|
434 |
+ |
style::dim(known_mark),
|
|
435 |
+ |
Span::raw(" "),
|
|
436 |
+ |
Span::raw(format!("{:<name_w$}", n.ssid)),
|
|
437 |
+ |
Span::raw(" "),
|
|
438 |
+ |
Span::raw(signal),
|
|
439 |
+ |
Span::raw(" "),
|
|
440 |
+ |
security_span,
|
|
441 |
+ |
]);
|
|
442 |
+ |
let line = if is_sel {
|
|
443 |
+ |
line.style(selection::selected_style())
|
|
444 |
+ |
} else {
|
|
445 |
+ |
line
|
|
446 |
+ |
};
|
|
447 |
+ |
lines.push(line);
|
|
448 |
+ |
}
|
| 334 |
449 |
|
Paragraph::new(lines).style(Style::default().bg(palette::BG).fg(palette::FG))
|
| 335 |
450 |
|
}
|
| 336 |
451 |
|
|