| 102 |
102 |
|
pub addresses: Vec<String>,
|
| 103 |
103 |
|
}
|
| 104 |
104 |
|
|
| 105 |
|
- |
/// A source of interface state.
|
|
105 |
+ |
/// A source of interface state, and the actions on it.
|
|
106 |
+ |
///
|
|
107 |
+ |
/// The actions are `Option<Invocation>` for the same reason `alloy pkg`'s
|
|
108 |
+ |
/// start/stop are: a backend that cannot do the thing says so by returning
|
|
109 |
+ |
/// nothing, and the view offers the key only where there is something behind
|
|
110 |
+ |
/// it. Backends build argv and run nothing; the view executes through the
|
|
111 |
+ |
/// command log, which is what keeps "every action shows its invocation"
|
|
112 |
+ |
/// structural rather than remembered.
|
| 106 |
113 |
|
pub(crate) trait Backend {
|
| 107 |
114 |
|
fn name(&self) -> &'static str;
|
| 108 |
115 |
|
fn list(&self, log: &mut CommandLog) -> Result<Vec<Interface>>;
|
|
116 |
+ |
|
|
117 |
+ |
/// Bring a device up on whichever saved connection NM picks for it.
|
|
118 |
+ |
///
|
|
119 |
+ |
/// Defaults to nothing, which is the honest answer for a backend that only
|
|
120 |
+ |
/// reads. The view offers the key only where there is something behind it.
|
|
121 |
+ |
fn connect(&self, _iface: &Interface) -> Option<Invocation> {
|
|
122 |
+ |
None
|
|
123 |
+ |
}
|
|
124 |
+ |
|
|
125 |
+ |
/// Take a device down without touching what it is configured to use.
|
|
126 |
+ |
fn disconnect(&self, _iface: &Interface) -> Option<Invocation> {
|
|
127 |
+ |
None
|
|
128 |
+ |
}
|
|
129 |
+ |
|
|
130 |
+ |
/// Turn the wifi radio on or off.
|
|
131 |
+ |
fn set_wifi(&self, _on: bool) -> Option<Invocation> {
|
|
132 |
+ |
None
|
|
133 |
+ |
}
|
|
134 |
+ |
|
|
135 |
+ |
/// Whether the wifi radio is on, or `None` when the backend cannot say.
|
|
136 |
+ |
///
|
|
137 |
+ |
/// Read on refresh rather than assumed, because a radio that is off is the
|
|
138 |
+ |
/// explanation for every wireless device sitting at `unavailable`, and a
|
|
139 |
+ |
/// toggle that does not know which way it is pointing is a coin flip.
|
|
140 |
+ |
fn wifi_enabled(&self, _log: &mut CommandLog) -> Option<bool> {
|
|
141 |
+ |
None
|
|
142 |
+ |
}
|
| 109 |
143 |
|
}
|
| 110 |
144 |
|
|
| 111 |
145 |
|
/// Pick a backend: the real one when `nmcli` answers, the mock otherwise.
|
| 146 |
180 |
|
fn list(&self, log: &mut CommandLog) -> Result<Vec<Interface>> {
|
| 147 |
181 |
|
Ok(parse_device_show(&Self::invocation().run(log)?))
|
| 148 |
182 |
|
}
|
|
183 |
+ |
|
|
184 |
+ |
// `device connect` and not `connection up`: the user selected a device, and
|
|
185 |
+ |
// NM picks the connection it is configured for. Naming a connection here
|
|
186 |
+ |
// would mean the console deciding which profile a device should use, which
|
|
187 |
+ |
// is a different screen and a different polkit action.
|
|
188 |
+ |
fn connect(&self, iface: &Interface) -> Option<Invocation> {
|
|
189 |
+ |
actionable(iface).then(|| Invocation::new("nmcli").args(["device", "connect", &iface.name]))
|
|
190 |
+ |
}
|
|
191 |
+ |
|
|
192 |
+ |
fn disconnect(&self, iface: &Interface) -> Option<Invocation> {
|
|
193 |
+ |
actionable(iface)
|
|
194 |
+ |
.then(|| Invocation::new("nmcli").args(["device", "disconnect", &iface.name]))
|
|
195 |
+ |
}
|
|
196 |
+ |
|
|
197 |
+ |
fn set_wifi(&self, on: bool) -> Option<Invocation> {
|
|
198 |
+ |
Some(Invocation::new("nmcli").args(["radio", "wifi", if on { "on" } else { "off" }]))
|
|
199 |
+ |
}
|
|
200 |
+ |
|
|
201 |
+ |
fn wifi_enabled(&self, log: &mut CommandLog) -> Option<bool> {
|
|
202 |
+ |
let raw = Invocation::new("nmcli")
|
|
203 |
+ |
.args(["radio", "wifi"])
|
|
204 |
+ |
.run(log)
|
|
205 |
+ |
.ok()?;
|
|
206 |
+ |
parse_radio(&raw)
|
|
207 |
+ |
}
|
|
208 |
+ |
}
|
|
209 |
+ |
|
|
210 |
+ |
/// Whether connect and disconnect mean anything for a device.
|
|
211 |
+ |
///
|
|
212 |
+ |
/// Loopback is never brought up or down, and an unmanaged device is one NM has
|
|
213 |
+ |
/// been told to keep its hands off — asking it to connect one is asking for an
|
|
214 |
+ |
/// error the user cannot act on. Both are still listed, because an inventory
|
|
215 |
+ |
/// that hides what it cannot act on stops being an inventory.
|
|
216 |
+ |
fn actionable(iface: &Interface) -> bool {
|
|
217 |
+ |
iface.kind != Kind::Loopback && iface.state != State::Unmanaged
|
|
218 |
+ |
}
|
|
219 |
+ |
|
|
220 |
+ |
/// Parse `nmcli radio wifi`, which answers `enabled` or `disabled`.
|
|
221 |
+ |
///
|
|
222 |
+ |
/// Anything else is `None` rather than a guess: `missing` is what nmcli says
|
|
223 |
+ |
/// when there is no wifi hardware, and reading that as "off" would offer a
|
|
224 |
+ |
/// toggle for a radio that is not there.
|
|
225 |
+ |
fn parse_radio(raw: &str) -> Option<bool> {
|
|
226 |
+ |
match raw.trim() {
|
|
227 |
+ |
"enabled" => Some(true),
|
|
228 |
+ |
"disabled" => Some(false),
|
|
229 |
+ |
_ => None,
|
|
230 |
+ |
}
|
| 149 |
231 |
|
}
|
| 150 |
232 |
|
|
| 151 |
233 |
|
/// Fixed sample state, for machines without NetworkManager.
|
| 189 |
271 |
|
},
|
| 190 |
272 |
|
])
|
| 191 |
273 |
|
}
|
|
274 |
+ |
|
|
275 |
+ |
// Every action takes the trait's default of nothing. The mock exists so the
|
|
276 |
+ |
// console can be developed on a machine with no NetworkManager, and
|
|
277 |
+ |
// inventing a command for it would put a line in the log pane that is not a
|
|
278 |
+ |
// thing anyone could run.
|
| 192 |
279 |
|
}
|
| 193 |
280 |
|
|
| 194 |
281 |
|
/// Parse `nmcli -t -f ... device show` output.
|
| 265 |
352 |
|
interfaces: Vec<Interface>,
|
| 266 |
353 |
|
cursor: Cursor,
|
| 267 |
354 |
|
error: Option<String>,
|
|
355 |
+ |
/// Wifi radio state, or `None` when the backend cannot say. Re-read on
|
|
356 |
+ |
/// every refresh, since toggling it is one of the two things this screen
|
|
357 |
+ |
/// does.
|
|
358 |
+ |
wifi: Option<bool>,
|
| 268 |
359 |
|
}
|
| 269 |
360 |
|
|
| 270 |
361 |
|
impl NetView {
|
| 274 |
365 |
|
interfaces: Vec::new(),
|
| 275 |
366 |
|
cursor: Cursor::new(),
|
| 276 |
367 |
|
error: None,
|
|
368 |
+ |
wifi: None,
|
| 277 |
369 |
|
};
|
| 278 |
370 |
|
view.refresh(log);
|
| 279 |
371 |
|
view
|
| 280 |
372 |
|
}
|
| 281 |
373 |
|
|
|
374 |
+ |
fn selected(&self) -> Option<&Interface> {
|
|
375 |
+ |
self.interfaces.get(self.cursor.selected()?)
|
|
376 |
+ |
}
|
|
377 |
+ |
|
|
378 |
+ |
/// Connect a disconnected device, disconnect a connected one.
|
|
379 |
+ |
///
|
|
380 |
+ |
/// One key rather than two, the same call `alloy pkg` made for boxes: the
|
|
381 |
+ |
/// states are exclusive and the row already says which one it is in.
|
|
382 |
+ |
fn toggle(&mut self, log: &mut CommandLog) {
|
|
383 |
+ |
let Some(iface) = self.selected().cloned() else {
|
|
384 |
+ |
return;
|
|
385 |
+ |
};
|
|
386 |
+ |
let invocation = if iface.state == State::Connected {
|
|
387 |
+ |
self.backend.disconnect(&iface)
|
|
388 |
+ |
} else {
|
|
389 |
+ |
self.backend.connect(&iface)
|
|
390 |
+ |
};
|
|
391 |
+ |
|
|
392 |
+ |
let Some(invocation) = invocation else {
|
|
393 |
+ |
// Says which of the two reasons it is. "Nothing happened" is the
|
|
394 |
+ |
// one outcome a console must never produce.
|
|
395 |
+ |
self.error = Some(if actionable(&iface) {
|
|
396 |
+ |
format!("{} cannot act on {}", self.backend.name(), iface.name)
|
|
397 |
+ |
} else {
|
|
398 |
+ |
format!("{} is {}", iface.name, iface.state.label())
|
|
399 |
+ |
});
|
|
400 |
+ |
return;
|
|
401 |
+ |
};
|
|
402 |
+ |
|
|
403 |
+ |
// A wireless device cannot come up while the radio is off, and nmcli's
|
|
404 |
+ |
// own error for it names neither the radio nor the key that fixes it.
|
|
405 |
+ |
if iface.kind == Kind::Wireless && self.wifi == Some(false) {
|
|
406 |
+ |
self.error = Some("wifi radio is off; press w".to_string());
|
|
407 |
+ |
return;
|
|
408 |
+ |
}
|
|
409 |
+ |
|
|
410 |
+ |
self.finish(invocation.run(log).map(drop), log);
|
|
411 |
+ |
}
|
|
412 |
+ |
|
|
413 |
+ |
/// Flip the wifi radio.
|
|
414 |
+ |
fn toggle_wifi(&mut self, log: &mut CommandLog) {
|
|
415 |
+ |
let Some(on) = self.wifi else {
|
|
416 |
+ |
self.error = Some("no wifi radio to switch".to_string());
|
|
417 |
+ |
return;
|
|
418 |
+ |
};
|
|
419 |
+ |
let Some(invocation) = self.backend.set_wifi(!on) else {
|
|
420 |
+ |
self.error = Some(format!("{} cannot switch the radio", self.backend.name()));
|
|
421 |
+ |
return;
|
|
422 |
+ |
};
|
|
423 |
+ |
self.finish(invocation.run(log).map(drop), log);
|
|
424 |
+ |
}
|
|
425 |
+ |
|
|
426 |
+ |
/// Record how an action went, then re-read.
|
|
427 |
+ |
///
|
|
428 |
+ |
/// The re-read is the console confirming what it did rather than the user
|
|
429 |
+ |
/// asking, so it is quiet: without that a single keypress writes its action
|
|
430 |
+ |
/// plus two reads into a two-row pane and scrolls the thing the user
|
|
431 |
+ |
/// pressed a key for off the top.
|
|
432 |
+ |
fn finish(&mut self, result: Result<()>, log: &mut CommandLog) {
|
|
433 |
+ |
match result {
|
|
434 |
+ |
Ok(()) => {
|
|
435 |
+ |
self.error = None;
|
|
436 |
+ |
log.quiet(|log| self.refresh(log));
|
|
437 |
+ |
}
|
|
438 |
+ |
Err(err) => self.error = Some(err.to_string()),
|
|
439 |
+ |
}
|
|
440 |
+ |
}
|
|
441 |
+ |
|
| 282 |
442 |
|
fn refresh(&mut self, log: &mut CommandLog) {
|
| 283 |
443 |
|
match self.backend.list(log) {
|
| 284 |
444 |
|
Ok(interfaces) => {
|
| 290 |
450 |
|
}
|
| 291 |
451 |
|
Err(err) => self.error = Some(err.to_string()),
|
| 292 |
452 |
|
}
|
|
453 |
+ |
self.wifi = self.backend.wifi_enabled(log);
|
| 293 |
454 |
|
}
|
| 294 |
455 |
|
|
| 295 |
456 |
|
fn row<'a>(theme: &Theme, iface: &'a Interface) -> Line<'a> {
|
| 318 |
479 |
|
}
|
| 319 |
480 |
|
|
| 320 |
481 |
|
fn hints(&self) -> Vec<Hint> {
|
| 321 |
|
- |
vec![hint("j/k", "select"), hint("r", "refresh")]
|
|
482 |
+ |
let mut hints = vec![hint("j/k", "select")];
|
|
483 |
+ |
// The connect key is offered only where there is something behind it,
|
|
484 |
+ |
// which on the mock and on loopback is nothing.
|
|
485 |
+ |
if self
|
|
486 |
+ |
.selected()
|
|
487 |
+ |
.is_some_and(|iface| self.backend.connect(iface).is_some())
|
|
488 |
+ |
{
|
|
489 |
+ |
hints.push(hint("s", "connect/disconnect"));
|
|
490 |
+ |
}
|
|
491 |
+ |
if self.wifi.is_some() {
|
|
492 |
+ |
hints.push(hint("w", "wifi radio"));
|
|
493 |
+ |
}
|
|
494 |
+ |
hints.push(hint("r", "refresh"));
|
|
495 |
+ |
hints
|
| 322 |
496 |
|
}
|
| 323 |
497 |
|
|
| 324 |
498 |
|
fn status(&self) -> Option<(Severity, String)> {
|
| 325 |
|
- |
self.error
|
| 326 |
|
- |
.as_ref()
|
| 327 |
|
- |
.map(|message| (Severity::Error, message.clone()))
|
|
499 |
+ |
if let Some(message) = &self.error {
|
|
500 |
+ |
return Some((Severity::Error, message.clone()));
|
|
501 |
+ |
}
|
|
502 |
+ |
// A radio that is off explains every wireless device sitting at
|
|
503 |
+ |
// `unavailable`, so it is worth a line even when nothing failed.
|
|
504 |
+ |
(self.wifi == Some(false)).then(|| (Severity::Warn, "wifi radio off".to_string()))
|
| 328 |
505 |
|
}
|
| 329 |
506 |
|
|
| 330 |
507 |
|
fn render(&self, frame: &mut Frame, area: Rect, theme: &Theme) {
|
| 352 |
529 |
|
}
|
| 353 |
530 |
|
|
| 354 |
531 |
|
fn handle(&mut self, key: KeyEvent, log: &mut CommandLog) -> Flow {
|
|
532 |
+ |
self.error = None;
|
| 355 |
533 |
|
match key.code {
|
| 356 |
534 |
|
KeyCode::Char('j') | KeyCode::Down => self.cursor.next(),
|
| 357 |
535 |
|
KeyCode::Char('k') | KeyCode::Up => self.cursor.prev(),
|
|
536 |
+ |
KeyCode::Char('s') => self.toggle(log),
|
|
537 |
+ |
KeyCode::Char('w') => self.toggle_wifi(log),
|
| 358 |
538 |
|
KeyCode::Char('r') => self.refresh(log),
|
| 359 |
539 |
|
_ => {}
|
| 360 |
540 |
|
}
|
| 488 |
668 |
|
interfaces: Vec::new(),
|
| 489 |
669 |
|
cursor: Cursor::new(),
|
| 490 |
670 |
|
error: None,
|
|
671 |
+ |
wifi: None,
|
| 491 |
672 |
|
};
|
| 492 |
673 |
|
view.refresh(&mut log);
|
| 493 |
674 |
|
(view, log)
|
| 524 |
705 |
|
assert!(view.error.is_some(), "the failure is surfaced");
|
| 525 |
706 |
|
}
|
| 526 |
707 |
|
|
|
708 |
+ |
fn iface(name: &str, kind: Kind, state: State) -> Interface {
|
|
709 |
+ |
Interface {
|
|
710 |
+ |
name: name.into(),
|
|
711 |
+ |
kind,
|
|
712 |
+ |
state,
|
|
713 |
+ |
connection: None,
|
|
714 |
+ |
addresses: Vec::new(),
|
|
715 |
+ |
}
|
|
716 |
+ |
}
|
|
717 |
+ |
|
|
718 |
+ |
// The argv the log pane shows and the user can paste. `device connect` and
|
|
719 |
+ |
// not `connection up`: the user picked a device, and which profile it uses
|
|
720 |
+ |
// is a different screen and a different polkit action.
|
|
721 |
+ |
#[test]
|
|
722 |
+ |
fn connect_and_disconnect_name_the_device() {
|
|
723 |
+ |
let wifi = iface("wlp1s0", Kind::Wireless, State::Disconnected);
|
|
724 |
+ |
assert_eq!(
|
|
725 |
+ |
NmCli.connect(&wifi).unwrap().display(),
|
|
726 |
+ |
"nmcli device connect wlp1s0",
|
|
727 |
+ |
);
|
|
728 |
+ |
assert_eq!(
|
|
729 |
+ |
NmCli.disconnect(&wifi).unwrap().display(),
|
|
730 |
+ |
"nmcli device disconnect wlp1s0",
|
|
731 |
+ |
);
|
|
732 |
+ |
}
|
|
733 |
+ |
|
|
734 |
+ |
#[test]
|
|
735 |
+ |
fn the_radio_switch_names_the_direction() {
|
|
736 |
+ |
assert_eq!(
|
|
737 |
+ |
NmCli.set_wifi(true).unwrap().display(),
|
|
738 |
+ |
"nmcli radio wifi on"
|
|
739 |
+ |
);
|
|
740 |
+ |
assert_eq!(
|
|
741 |
+ |
NmCli.set_wifi(false).unwrap().display(),
|
|
742 |
+ |
"nmcli radio wifi off",
|
|
743 |
+ |
);
|
|
744 |
+ |
}
|
|
745 |
+ |
|
|
746 |
+ |
// Loopback is never brought up or down and an unmanaged device is one NM
|
|
747 |
+ |
// has been told to leave alone. Both are still listed; neither takes the
|
|
748 |
+ |
// key.
|
|
749 |
+ |
#[test]
|
|
750 |
+ |
fn loopback_and_unmanaged_devices_take_no_action() {
|
|
751 |
+ |
let lo = iface("lo", Kind::Loopback, State::Unmanaged);
|
|
752 |
+ |
assert!(NmCli.connect(&lo).is_none());
|
|
753 |
+ |
assert!(NmCli.disconnect(&lo).is_none());
|
|
754 |
+ |
|
|
755 |
+ |
let bridge = iface("br0", Kind::Other, State::Unmanaged);
|
|
756 |
+ |
assert!(NmCli.connect(&bridge).is_none());
|
|
757 |
+ |
|
|
758 |
+ |
let wired = iface("enp2s0", Kind::Wired, State::Disconnected);
|
|
759 |
+ |
assert!(NmCli.connect(&wired).is_some(), "an ordinary device does");
|
|
760 |
+ |
}
|
|
761 |
+ |
|
|
762 |
+ |
// `missing` is what nmcli says when there is no wifi hardware. Reading it
|
|
763 |
+ |
// as "off" would offer a toggle for a radio that is not there.
|
|
764 |
+ |
#[test]
|
|
765 |
+ |
fn the_radio_reads_only_the_two_answers_it_understands() {
|
|
766 |
+ |
assert_eq!(parse_radio("enabled\n"), Some(true));
|
|
767 |
+ |
assert_eq!(parse_radio("disabled\n"), Some(false));
|
|
768 |
+ |
assert_eq!(parse_radio("missing\n"), None);
|
|
769 |
+ |
assert_eq!(parse_radio(""), None);
|
|
770 |
+ |
}
|
|
771 |
+ |
|
|
772 |
+ |
// One key, whichever way the device is pointing, because the row already
|
|
773 |
+ |
// says which state it is in.
|
|
774 |
+ |
#[test]
|
|
775 |
+ |
fn the_key_picks_the_action_the_row_is_not_already_in() {
|
|
776 |
+ |
let connected = iface("wlp1s0", Kind::Wireless, State::Connected);
|
|
777 |
+ |
let down = iface("wlp1s0", Kind::Wireless, State::Disconnected);
|
|
778 |
+ |
assert!(NmCli.disconnect(&connected).is_some());
|
|
779 |
+ |
assert!(NmCli.connect(&down).is_some());
|
|
780 |
+ |
}
|
|
781 |
+ |
|
|
782 |
+ |
// A backend that only reads offers no keys, and the footer must not
|
|
783 |
+ |
// advertise one that does nothing.
|
|
784 |
+ |
#[test]
|
|
785 |
+ |
fn a_read_only_backend_offers_no_action_keys() {
|
|
786 |
+ |
let (view, _log) = mock_view();
|
|
787 |
+ |
let labels: Vec<&str> = view.hints().iter().map(|hint| hint.label).collect();
|
|
788 |
+ |
assert!(labels.contains(&"select"), "{labels:?}");
|
|
789 |
+ |
assert!(labels.contains(&"refresh"), "{labels:?}");
|
|
790 |
+ |
assert!(!labels.contains(&"connect/disconnect"), "{labels:?}");
|
|
791 |
+ |
assert!(!labels.contains(&"wifi radio"), "{labels:?}");
|
|
792 |
+ |
}
|
|
793 |
+ |
|
|
794 |
+ |
// Pressing the key on a device nothing can act on must say so. "Nothing
|
|
795 |
+ |
// happened" is the one outcome a console must never produce.
|
|
796 |
+ |
#[test]
|
|
797 |
+ |
fn acting_on_a_device_with_no_action_reports_why() {
|
|
798 |
+ |
let (mut view, mut log) = mock_view();
|
|
799 |
+ |
// Row 2 of the mock is `lo`, unmanaged loopback.
|
|
800 |
+ |
view.cursor.move_by(2);
|
|
801 |
+ |
view.toggle(&mut log);
|
|
802 |
+ |
let message = view.error.as_ref().expect("something was said");
|
|
803 |
+ |
assert!(message.contains("lo"), "{message}");
|
|
804 |
+ |
}
|
|
805 |
+ |
|
|
806 |
+ |
#[test]
|
|
807 |
+ |
fn switching_a_radio_that_is_not_there_reports_why() {
|
|
808 |
+ |
let (mut view, mut log) = mock_view();
|
|
809 |
+ |
assert_eq!(view.wifi, None);
|
|
810 |
+ |
view.toggle_wifi(&mut log);
|
|
811 |
+ |
assert!(
|
|
812 |
+ |
view.error
|
|
813 |
+ |
.as_ref()
|
|
814 |
+ |
.is_some_and(|m| m.contains("no wifi radio")),
|
|
815 |
+ |
"{:?}",
|
|
816 |
+ |
view.error,
|
|
817 |
+ |
);
|
|
818 |
+ |
}
|
|
819 |
+ |
|
|
820 |
+ |
// The radio being off is the explanation for every wireless device sitting
|
|
821 |
+ |
// at `unavailable`, so it is worth the footer line even when nothing failed.
|
|
822 |
+ |
#[test]
|
|
823 |
+ |
fn a_radio_that_is_off_is_reported_without_an_error() {
|
|
824 |
+ |
let (mut view, _log) = mock_view();
|
|
825 |
+ |
view.wifi = Some(false);
|
|
826 |
+ |
let (severity, message) = view.status().expect("the footer says so");
|
|
827 |
+ |
assert_eq!(severity, Severity::Warn);
|
|
828 |
+ |
assert!(message.contains("wifi radio off"), "{message}");
|
|
829 |
+ |
|
|
830 |
+ |
view.wifi = Some(true);
|
|
831 |
+ |
assert!(view.status().is_none(), "a radio that is on says nothing");
|
|
832 |
+ |
}
|
|
833 |
+ |
|
| 527 |
834 |
|
struct EmptyBackend;
|
| 528 |
835 |
|
|
| 529 |
836 |
|
struct FailingBackend;
|