Skip to main content

max / alloy

net: connect, disconnect, and the wifi radio The tab was read-only because nobody wrote the actions, not because of privilege. NetworkManager's shipped policy grants network-control and enable-disable-wifi to an active session outright, so all three of these need no authorization at all. Checked against the policy files rather than assumed; the table is in wiki note alloy-privilege along with what the rest of the console will need. One key for connect and disconnect, the call alloy pkg already made for boxes: the states are exclusive and the row says which one it is in. Loopback and unmanaged devices take no action and are still listed, because an inventory that hides what it cannot act on stops being an inventory. The radio is read on refresh rather than assumed. A toggle that does not know which way it is pointing is a coin flip, and a radio that is off is the explanation for every wireless device sitting at unavailable, so it gets a footer line even when nothing failed. `missing` is not read as `off`: that is what nmcli says when there is no wifi hardware. Backends default to offering nothing, which is the honest answer for one that only reads, and the footer offers a key only where there is something behind it.
Author: Max Johnson <me@maxj.phd> · 2026-07-24 19:03 UTC
Signed with PGP, not checked
Commit: 5b3676988f25f65f6ed9559fe676803abb6eb29d
Parent: dec79fc
2 files changed, +320 insertions, -6 deletions
M docs/CONSOLE.md +8 -1
@@ -15,7 +15,7 @@
15 15 ## Subcommand shape
16 16
17 17 ```
18 - alloy net # network: nmcli front [shipped]
18 + alloy net # network: nmcli front, connect and radio [shipped]
19 19 alloy audio # audio: pactl front, streams and devices [shipped]
20 20 alloy mesh # mesh VPN: tailscale front (alias: tail) [shipped]
21 21 alloy display # outputs: swaymsg output / wlr-randr front
@@ -54,6 +54,13 @@
54 54 backend builds argv and runs nothing; the view executes through the command log,
55 55 which makes "every action shows its invocation" structural rather than remembered.
56 56
57 + `alloy net` writes as well as reads: connect, disconnect, and the wifi radio. Those three
58 + need no privilege at all, which is a fact rather than a design: NetworkManager's shipped
59 + policy grants `network-control` and `enable-disable-wifi` to an active session outright.
60 + Joining a *new* network is `settings.modify.system` and does need authorization, so it
61 + waits on the ladder in wiki note `alloy-privilege`. Nothing in the console escalates
62 + anything today.
63 +
57 64 Backend picks that differ from the sketch above: `net` fronts `nmcli` alone,
58 65 since Alloy is Fedora and NetworkManager is what is there. `audio` fronts
59 66 `pactl` rather than `wpctl`, because `pactl -f json` is a documented contract
@@ -102,10 +102,44 @@
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,6 +180,54 @@
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,6 +271,11 @@
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,6 +352,10 @@
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,11 +365,80 @@
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,6 +450,7 @@
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,13 +479,29 @@
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,9 +529,12 @@
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,6 +668,7 @@
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,6 +705,132 @@
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;