Skip to main content

max / alloy

25.6 KB · 646 lines History Blame Raw
1 //! The `alloy net` screen: the device inventory and the join flow over it.
2
3 use alloy_tui::keys::Action;
4 use alloy_tui::{
5 AlloyBlock, AlloyList, Cursor, Hint, KeyGroup, Severity, TextField, Theme, binding, hint, text,
6 unavailable,
7 };
8 use anyhow::Result;
9 use ratatui::Frame;
10 use ratatui::crossterm::event::{KeyCode, KeyEvent};
11 use ratatui::layout::Rect;
12 use ratatui::text::{Line, Span};
13
14 use super::backend::{Backend, actionable, detect};
15 use super::diagnose::{NoWireless, kernel_wireless_iface, no_wireless, wifi_plugin_present};
16 use super::model::{Interface, Kind, Network, State};
17 use crate::cli::{CommandLog, Secret};
18 use crate::shell::{Flow, View, block_title};
19
20 /// What the screen is showing, and what its keys mean.
21 ///
22 /// A mode rather than a tab, and rather than a second view. The three are one
23 /// question asked in three steps — which device, which network, what is the
24 /// passphrase — so Esc walking back through them is the whole navigation model,
25 /// and [`View::cancel`] gives that for free. Tabs would put a passphrase field
26 /// on a tab someone can page away from mid-word.
27 ///
28 /// The passphrase lives in a [`TextField`] here for as long as the user is
29 /// typing it, and moves into a [`Secret`] at the moment the command is built.
30 /// That is the same arrangement the installer's account pane settled on: a
31 /// scrubbing buffer helps only once there is a buffer to scrub, and until then
32 /// the value is a `String` that the widget owns.
33 enum Mode {
34 /// The interface inventory. What this screen was before the join flow.
35 Devices,
36 /// The networks in range, from the last scan.
37 Networks {
38 networks: Vec<Network>,
39 cursor: Cursor,
40 },
41 /// Asking for the passphrase of a network already chosen.
42 Passphrase { ssid: String, field: TextField },
43 }
44
45 /// Redacted, and hand-written for that reason.
46 ///
47 /// A derived `Debug` would print the passphrase, and the places a `{:?}` ends
48 /// up are exactly the ones nobody audits: a test failure message, a panic, a
49 /// log line added in a hurry. [`Secret`] makes the same choice for the same
50 /// reason, and this is the buffer that feeds it.
51 impl std::fmt::Debug for Mode {
52 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
53 match self {
54 Mode::Devices => f.write_str("Devices"),
55 Mode::Networks { networks, .. } => write!(f, "Networks({})", networks.len()),
56 Mode::Passphrase { ssid, .. } => write!(f, "Passphrase({ssid}, <redacted>)"),
57 }
58 }
59 }
60
61 /// The `alloy net` screen.
62 pub(crate) struct NetView {
63 backend: Box<dyn Backend>,
64 interfaces: Vec<Interface>,
65 cursor: Cursor,
66 error: Option<String>,
67 /// Wifi radio state, or `None` when the backend cannot say. Re-read on
68 /// every refresh, since toggling it is one of the two things this screen
69 /// does.
70 wifi: Option<bool>,
71 mode: Mode,
72 /// The network a join is in flight for, so its outcome can be reported
73 /// against a name.
74 ///
75 /// Held here rather than read back out of [`Mode`] when the answer arrives,
76 /// because the mode has moved on by then: the passphrase is gone the moment
77 /// it becomes a [`Secret`], which is the point.
78 pending: Option<String>,
79 /// Why there is no wireless device, when there is none. Recomputed on every
80 /// refresh, since layering the plugin or plugging a dongle both change the
81 /// answer without the screen being reopened.
82 no_wireless: Option<NoWireless>,
83 }
84
85 impl NetView {
86 pub(crate) fn new(log: &mut CommandLog) -> Self {
87 let mut view = Self {
88 backend: detect(),
89 interfaces: Vec::new(),
90 cursor: Cursor::new(),
91 error: None,
92 wifi: None,
93 mode: Mode::Devices,
94 pending: None,
95 no_wireless: None,
96 };
97 view.refresh(log);
98 view
99 }
100
101 /// Whether a scan is worth offering: the backend can do one, and there is a
102 /// wireless device for it to happen on.
103 ///
104 /// The radio being off is deliberately *not* part of this. A key that
105 /// disappears when the radio is switched off teaches that the console
106 /// cannot scan; a key that says "wifi radio is off; press w" teaches which
107 /// key to press.
108 fn can_scan(&self) -> bool {
109 self.wifi.is_some() && self.interfaces.iter().any(|i| i.kind == Kind::Wireless)
110 }
111
112 /// Look for networks, and show them if any came back.
113 fn scan(&mut self, log: &mut CommandLog) {
114 if self.wifi == Some(false) {
115 self.error = Some("wifi radio is off; press w".to_string());
116 return;
117 }
118 let Some(result) = self.backend.networks(log) else {
119 self.error = Some(format!("{} cannot scan", self.backend.name()));
120 return;
121 };
122 match result {
123 Ok(networks) if networks.is_empty() => {
124 // Not an error: a scan that finds nothing is a true answer, and
125 // an empty list behind a mode switch reads as a broken screen.
126 self.error = Some("no networks in range".to_string());
127 }
128 Ok(networks) => {
129 let mut cursor = Cursor::new();
130 cursor.resize(networks.len());
131 self.mode = Mode::Networks { networks, cursor };
132 self.error = None;
133 }
134 Err(err) => self.error = Some(err.to_string()),
135 }
136 }
137
138 /// Act on the selected network: join an open one, ask about a secured one.
139 fn choose(&mut self) -> Flow {
140 let Mode::Networks { networks, cursor } = &self.mode else {
141 return Flow::Continue;
142 };
143 let Some(network) = cursor.selected().and_then(|index| networks.get(index)) else {
144 return Flow::Continue;
145 };
146
147 if network.security.is_none() {
148 let ssid = network.ssid.clone();
149 return self.join(&ssid, None);
150 }
151 self.mode = Mode::Passphrase {
152 ssid: network.ssid.clone(),
153 field: TextField::new(),
154 };
155 Flow::Continue
156 }
157
158 /// Join with what has been typed.
159 ///
160 /// The passphrase leaves the [`TextField`] here and does not go back: the
161 /// field is emptied in the same breath as the [`Secret`] is built, so a
162 /// prompt that fails and is asked again starts from nothing rather than
163 /// from a value still sitting in a widget.
164 fn submit(&mut self) -> Flow {
165 let Mode::Passphrase { ssid, field } = &mut self.mode else {
166 return Flow::Continue;
167 };
168 let ssid = ssid.clone();
169 let secret = Secret::new(field.value().as_bytes().to_vec());
170 field.set("");
171 self.join(&ssid, Some(secret))
172 }
173
174 /// Hand the join to the shell, which runs it with an agent to answer polkit.
175 ///
176 /// [`Flow::AuthorizeInline`] rather than running it here, and this screen is
177 /// the reason that flow exists. Joining a network NM has not saved is
178 /// `settings.modify.system`, which `50-alloy-settings.rules` deliberately
179 /// does not grant ("saving a new connection is a real administrative act"),
180 /// so it always wants an answer. Tier 2 cannot give one: it suspends, a
181 /// suspended child inherits the terminal's stdio, and there is then no pipe
182 /// for the passphrase. So the command runs beside the event loop with the
183 /// console's own polkit agent registered, and both questions — polkit's and
184 /// the passphrase — are asked on screen.
185 fn join(&mut self, ssid: &str, passphrase: Option<Secret>) -> Flow {
186 let Some(invocation) = self.backend.join(ssid, passphrase) else {
187 self.error = Some(format!("{} cannot join a network", self.backend.name()));
188 return Flow::Continue;
189 };
190 self.pending = Some(ssid.to_string());
191 Flow::AuthorizeInline(invocation)
192 }
193
194 /// Whether the selected interface has a connect action behind it, which on
195 /// the mock and on loopback it does not.
196 fn can_connect(&self) -> bool {
197 self.selected()
198 .is_some_and(|iface| self.backend.connect(iface).is_some())
199 }
200
201 fn selected(&self) -> Option<&Interface> {
202 self.interfaces.get(self.cursor.selected()?)
203 }
204
205 /// Connect a disconnected device, disconnect a connected one.
206 ///
207 /// One key rather than two, the same call `alloy pkg` made for boxes: the
208 /// states are exclusive and the row already says which one it is in.
209 fn toggle(&mut self, log: &mut CommandLog) {
210 let Some(iface) = self.selected().cloned() else {
211 return;
212 };
213 let invocation = if iface.state == State::Connected {
214 self.backend.disconnect(&iface)
215 } else {
216 self.backend.connect(&iface)
217 };
218
219 let Some(invocation) = invocation else {
220 // Says which of the two reasons it is. "Nothing happened" is the
221 // one outcome a console must never produce.
222 self.error = Some(if actionable(&iface) {
223 format!("{} cannot act on {}", self.backend.name(), iface.name)
224 } else {
225 format!("{} is {}", iface.name, iface.state.label())
226 });
227 return;
228 };
229
230 // A wireless device cannot come up while the radio is off, and nmcli's
231 // own error for it names neither the radio nor the key that fixes it.
232 if iface.kind == Kind::Wireless && self.wifi == Some(false) {
233 self.error = Some("wifi radio is off; press w".to_string());
234 return;
235 }
236
237 self.finish(invocation.run(log).map(drop), log);
238 }
239
240 /// Flip the wifi radio.
241 fn toggle_wifi(&mut self, log: &mut CommandLog) {
242 let Some(on) = self.wifi else {
243 self.error = Some("no wifi radio to switch".to_string());
244 return;
245 };
246 let Some(invocation) = self.backend.set_wifi(!on) else {
247 self.error = Some(format!("{} cannot switch the radio", self.backend.name()));
248 return;
249 };
250 self.finish(invocation.run(log).map(drop), log);
251 }
252
253 /// Record how an action went, then re-read.
254 ///
255 /// The re-read is the console confirming what it did rather than the user
256 /// asking, so it is quiet: without that a single keypress writes its action
257 /// plus two reads into a two-row pane and scrolls the thing the user
258 /// pressed a key for off the top.
259 fn finish(&mut self, result: Result<()>, log: &mut CommandLog) {
260 match result {
261 Ok(()) => {
262 self.error = None;
263 log.quiet(|log| self.refresh(log));
264 }
265 Err(err) => self.error = Some(err.to_string()),
266 }
267 }
268
269 fn refresh(&mut self, log: &mut CommandLog) {
270 match self.backend.list(log) {
271 Ok(interfaces) => {
272 self.interfaces = interfaces;
273 // Refresh can shrink the list (an interface went away); the
274 // cursor clamps itself back into range.
275 self.cursor.resize(self.interfaces.len());
276 self.error = None;
277 }
278 Err(err) => self.error = Some(err.to_string()),
279 }
280 self.wifi = self.backend.wifi_enabled(log);
281 self.no_wireless = no_wireless(
282 self.interfaces.iter().any(|i| i.kind == Kind::Wireless),
283 self.wifi,
284 wifi_plugin_present(),
285 kernel_wireless_iface().as_deref(),
286 );
287 }
288
289 fn row<'a>(theme: &Theme, iface: &'a Interface) -> Line<'a> {
290 let address = iface
291 .addresses
292 .first()
293 .cloned()
294 .or_else(|| iface.connection.clone())
295 .unwrap_or_default();
296
297 Line::from(vec![
298 text::bold(theme, format!("{:<12}", iface.name)),
299 text::muted(theme, format!("{:<10}", iface.kind.label())),
300 Span::styled(
301 format!("{:<14}", iface.state.label()),
302 iface.state.severity().style(theme),
303 ),
304 text::secondary(theme, address),
305 ])
306 }
307
308 /// One network in the scan list.
309 ///
310 /// The security column says `open` rather than staying blank, because blank
311 /// is what a missing value looks like and this one is a warning: an open
312 /// network is the row where nothing will be asked for and nothing will be
313 /// encrypted.
314 fn network_row<'a>(theme: &Theme, network: &'a Network) -> Line<'a> {
315 let security = network.security.clone().unwrap_or_else(|| "open".into());
316 Line::from(vec![
317 text::bold(
318 theme,
319 format!("{:<3}", if network.in_use { "*" } else { "" }),
320 ),
321 text::primary(theme, format!("{:<32}", network.ssid)),
322 text::muted(theme, format!("{:>3}% ", network.signal)),
323 Span::styled(
324 security,
325 if network.security.is_some() {
326 Severity::Healthy.style(theme)
327 } else {
328 Severity::Warn.style(theme)
329 },
330 ),
331 ])
332 }
333
334 /// The passphrase field: dots, and a caret on the one under it.
335 ///
336 /// Masked here rather than in [`TextField`] for the reason the installer
337 /// gives for its own copy of this: a widget that knows how to hide itself
338 /// has to be trusted to do it everywhere, and a plain buffer only has to be
339 /// drawn carefully in the places that draw it.
340 fn passphrase_line<'a>(theme: &Theme, field: &TextField) -> Line<'a> {
341 let (before, under, after) = field.split();
342 Line::from(vec![
343 text::muted(theme, " passphrase "),
344 text::primary(theme, "".repeat(before.chars().count())),
345 Span::styled(
346 under.map_or(' ', |_| '').to_string(),
347 Severity::Healthy.style(theme),
348 ),
349 text::primary(theme, "".repeat(after.chars().count())),
350 ])
351 }
352 }
353
354 impl View for NetView {
355 fn title(&self) -> String {
356 match &self.mode {
357 Mode::Devices => format!("network ({})", self.backend.name()),
358 Mode::Networks { .. } => format!("networks in range ({})", self.backend.name()),
359 Mode::Passphrase { ssid, .. } => format!("join {ssid}"),
360 }
361 }
362
363 fn hints(&self) -> Vec<Hint> {
364 match &self.mode {
365 Mode::Devices => {
366 let mut hints = vec![hint("j/k", "select")];
367 // The footer has one row and shows what is live. What the pane
368 // *can* do, including the parts it cannot do right now, is
369 // `?`'s job: see `keys`.
370 if self.can_connect() {
371 hints.push(hint("s", "connect/disconnect"));
372 }
373 if self.can_scan() {
374 hints.push(hint("n", "join a network"));
375 }
376 if self.wifi.is_some() {
377 hints.push(hint("w", "wifi radio"));
378 }
379 hints.push(hint("r", "refresh"));
380 hints
381 }
382 Mode::Networks { .. } => vec![
383 hint("j/k", "select"),
384 hint("enter", "join"),
385 hint("n", "scan again"),
386 hint("esc", "back"),
387 ],
388 Mode::Passphrase { .. } => vec![hint("enter", "join"), hint("esc", "back")],
389 }
390 }
391
392 /// Every key this pane has, including the ones that are unavailable on the
393 /// current selection.
394 ///
395 /// The footer drops those; this must not. A key that vanishes takes its own
396 /// existence with it, so a user on loopback never learns the pane can
397 /// connect anything at all, and the rows they *can* use shift under them
398 /// each time the selection moves.
399 fn keys(&self) -> Vec<KeyGroup<'static>> {
400 match &self.mode {
401 Mode::Devices => {
402 let connect = if self.can_connect() {
403 binding("s", "connect/disconnect")
404 } else {
405 unavailable("s", "connect/disconnect", "nothing to connect here")
406 };
407 let scan = if self.can_scan() {
408 binding("n", "join a network")
409 } else {
410 unavailable("n", "join a network", "no wifi device")
411 };
412 let wifi = if self.wifi.is_some() {
413 binding("w", "wifi radio")
414 } else {
415 unavailable("w", "wifi radio", "no wifi device")
416 };
417 vec![KeyGroup::new(
418 "this pane",
419 vec![
420 binding("j/k", "select"),
421 connect,
422 scan,
423 wifi,
424 binding("r", "refresh"),
425 ],
426 )]
427 }
428 Mode::Networks { .. } => vec![KeyGroup::new(
429 "networks in range",
430 vec![
431 binding("j/k", "select"),
432 binding("enter", "join"),
433 binding("n", "scan again"),
434 binding("esc", "back to devices"),
435 ],
436 )],
437 // No `j/k` here, and no listing of them as unavailable either: they
438 // are letters someone is typing into a passphrase, and naming them
439 // in the overlay would say they do something.
440 Mode::Passphrase { .. } => vec![KeyGroup::new(
441 "passphrase",
442 vec![
443 binding("enter", "join"),
444 binding("esc", "back to the network list"),
445 ],
446 )],
447 }
448 }
449
450 /// One screen, no tabs.
451 fn unanswered(&self) -> &'static [Action] {
452 &[Action::NextTab, Action::PrevTab]
453 }
454
455 fn status(&self) -> Option<(Severity, String)> {
456 if let Some(message) = &self.error {
457 return Some((Severity::Error, message.clone()));
458 }
459 // A radio that is off explains every wireless device sitting at
460 // `unavailable`, so it is worth a line even when nothing failed.
461 (self.wifi == Some(false)).then(|| (Severity::Warn, "wifi radio off".to_string()))
462 }
463
464 /// The join finished, one way or the other.
465 ///
466 /// Success returns to the device list, because that is where the answer is:
467 /// the interface the user was looking at now says `connected` and names the
468 /// network. Staying on the scan would mean reporting the outcome in a
469 /// sentence beside a list that has not changed.
470 fn authorized(&mut self, outcome: Result<String>, log: &mut CommandLog) {
471 // Named "the network" rather than left blank when there is no name to
472 // hand. Nothing reaches this without a join in flight, and a sentence
473 // with a hole in it is what that assumption looks like when it stops
474 // being true.
475 let ssid = self
476 .pending
477 .take()
478 .unwrap_or_else(|| "the network".to_string());
479 match outcome {
480 // The output is dropped rather than reported, and that is not
481 // tidiness. Fed a pipe, nmcli's `--ask` prompt echoes what it reads,
482 // so the passphrase can be in the stdout of a command that carried
483 // it privately in every other respect. Failures are reported out of
484 // stderr, which the echo does not reach.
485 Ok(_) => {
486 self.mode = Mode::Devices;
487 self.error = None;
488 log.quiet(|log| self.refresh(log));
489 }
490 Err(err) => {
491 // Reaching this with an authentication message means the agent
492 // could not be registered or could not answer — the fallback
493 // path in `shell::authorize_inline`, which says so in the log
494 // pane. Worth its own sentence, because "not authorized" and
495 // "nobody could be asked" send someone to different places.
496 self.error = Some(if crate::cli::wants_authentication(&err) {
497 format!("joining {ssid} was not authorized")
498 } else {
499 err.to_string()
500 });
501 }
502 }
503 }
504
505 /// True while the passphrase field is open, so `q` and `?` are letters.
506 ///
507 /// [`View::text_entry`]'s obligation, and this is the first shipped view to
508 /// have one: a passphrase can contain both characters, and a console that
509 /// quit itself partway through one would be losing the thing it asked for.
510 fn text_entry(&self) -> bool {
511 matches!(self.mode, Mode::Passphrase { .. })
512 }
513
514 /// Esc steps back one question rather than closing the screen.
515 ///
516 /// Exactly the shape [`View::cancel`]'s own note describes: back out until
517 /// there is nothing left to back out of, and only then leave. The
518 /// passphrase is dropped on the way out, which matters more here than the
519 /// navigation does.
520 fn cancel(&mut self) -> Flow {
521 match &self.mode {
522 Mode::Devices => Flow::Exit,
523 Mode::Networks { .. } => {
524 self.mode = Mode::Devices;
525 self.error = None;
526 Flow::Continue
527 }
528 Mode::Passphrase { .. } => {
529 self.mode = Mode::Devices;
530 self.error = None;
531 Flow::Continue
532 }
533 }
534 }
535
536 fn render(&self, frame: &mut Frame, area: Rect, theme: &Theme) {
537 let block = AlloyBlock::new(theme)
538 .focused(true)
539 .build()
540 .title(block_title(&self.title()));
541 let inner = block.inner(area);
542 frame.render_widget(block, area);
543
544 match &self.mode {
545 Mode::Devices => {
546 // The note takes the last line, so the list is one shorter when
547 // there is something to say. Carved before the empty check
548 // because "no interfaces at all" is exactly when the
549 // explanation matters most.
550 let (list_area, note_area) = match (&self.no_wireless, inner.height) {
551 (Some(_), h) if h >= 2 => (
552 Rect {
553 height: h - 1,
554 ..inner
555 },
556 Some(Rect {
557 y: inner.y + h - 1,
558 height: 1,
559 ..inner
560 }),
561 ),
562 _ => (inner, None),
563 };
564 if let Some(area) = note_area
565 && let Some(reason) = &self.no_wireless
566 {
567 frame.render_widget(Line::from(text::muted(theme, reason.message())), area);
568 }
569 if self.interfaces.is_empty() {
570 frame.render_widget(Line::from(text::muted(theme, "no interfaces")), list_area);
571 return;
572 }
573 let rows: Vec<Line> = self
574 .interfaces
575 .iter()
576 .map(|iface| Self::row(theme, iface))
577 .collect();
578 frame.render_widget(
579 AlloyList::new(theme, rows).selected(self.cursor.selected()),
580 list_area,
581 );
582 }
583 Mode::Networks { networks, cursor } => {
584 let rows: Vec<Line> = networks
585 .iter()
586 .map(|network| Self::network_row(theme, network))
587 .collect();
588 frame.render_widget(
589 AlloyList::new(theme, rows).selected(cursor.selected()),
590 inner,
591 );
592 }
593 Mode::Passphrase { ssid, field } => {
594 let lines = vec![
595 Line::from(text::muted(
596 theme,
597 format!("passphrase for {ssid}, then enter"),
598 )),
599 Line::default(),
600 Self::passphrase_line(theme, field),
601 ];
602 frame.render_widget(ratatui::widgets::Paragraph::new(lines), inner);
603 }
604 }
605 }
606
607 fn handle(&mut self, key: KeyEvent, log: &mut CommandLog) -> Flow {
608 self.error = None;
609 match &mut self.mode {
610 Mode::Devices => match key.code {
611 KeyCode::Char('j') | KeyCode::Down => self.cursor.next(),
612 KeyCode::Char('k') | KeyCode::Up => self.cursor.prev(),
613 KeyCode::Char('s') => self.toggle(log),
614 KeyCode::Char('n') => self.scan(log),
615 KeyCode::Char('w') => self.toggle_wifi(log),
616 KeyCode::Char('r') => self.refresh(log),
617 _ => {}
618 },
619 Mode::Networks { cursor, .. } => match key.code {
620 KeyCode::Char('j') | KeyCode::Down => cursor.next(),
621 KeyCode::Char('k') | KeyCode::Up => cursor.prev(),
622 KeyCode::Enter => return self.choose(),
623 KeyCode::Char('n') => self.scan(log),
624 _ => {}
625 },
626 // Every printable key is text here, which is why this arm names no
627 // letters. Enter submits, Esc is the shell's and never arrives.
628 Mode::Passphrase { field, .. } => match key.code {
629 KeyCode::Char(c) => field.insert(c),
630 KeyCode::Backspace => field.backspace(),
631 KeyCode::Delete => field.delete(),
632 KeyCode::Left => field.left(),
633 KeyCode::Right => field.right(),
634 KeyCode::Home => field.home(),
635 KeyCode::End => field.end(),
636 KeyCode::Enter => return self.submit(),
637 _ => {}
638 },
639 }
640 Flow::Continue
641 }
642 }
643
644 #[cfg(test)]
645 mod tests;
646