Skip to main content

max / alloy

Say why there is no wireless device, instead of showing an absence
Author: Max Johnson <me@maxj.phd> · 2026-09-03 19:23 UTC
Signed with PGP, not checked
Commit: 50c2c48eec3798668170f14a60c08df454031319
Parent: ce02bbc
1 file changed, +216 insertions, -2 deletions
@@ -317,6 +317,113 @@
317 317 iface.kind != Kind::Loopback && iface.state != State::Unmanaged
318 318 }
319 319
320 + /// Why this machine is showing no wireless device.
321 + ///
322 + /// An absence is the one state a device list cannot explain by listing. Four
323 + /// different faults render identically as "nothing here", and they want four
324 + /// different repairs, so the screen says which one it is rather than leaving
325 + /// the user to tell them apart.
326 + ///
327 + /// Found the hard way 2026-09-03: fw12 showed loopback alone, and the cause was
328 + /// that no Alloy image had ever carried `NetworkManager-wifi`. Nothing errored,
329 + /// no unit failed, and the console was correct in every respect except that it
330 + /// had nothing to say about the absence.
331 + #[derive(Debug, Clone, PartialEq, Eq)]
332 + pub(crate) enum NoWireless {
333 + /// The image has no NetworkManager wifi plugin, so no wireless device can
334 + /// ever appear, whatever the hardware is.
335 + NoPlugin,
336 + /// The kernel made an interface and NetworkManager is not presenting it.
337 + /// Carries the interface name, because the next command a person runs
338 + /// wants it.
339 + Unmanaged(String),
340 + /// The radio is off. Listed after the two above because NM normally keeps a
341 + /// blocked device visible as `unavailable`, so this explains an absence
342 + /// only when the device went away entirely.
343 + RadioOff,
344 + /// No wireless hardware. The one state that is not a fault.
345 + NoHardware,
346 + }
347 +
348 + impl NoWireless {
349 + /// The line the screen shows. Says what is wrong and what to do about it,
350 + /// in that order, because a diagnosis with no next step is a complaint.
351 + pub(crate) fn message(&self) -> String {
352 + match self {
353 + Self::NoPlugin => "no wireless device: this image ships no NetworkManager wifi \
354 + plugin, so one can never appear. Layer NetworkManager-wifi and wpa_supplicant"
355 + .to_string(),
356 + Self::Unmanaged(iface) => format!(
357 + "no wireless device: the kernel has {iface} and NetworkManager does not \
358 + present it. Check wpa_supplicant and rfkill"
359 + ),
360 + Self::RadioOff => "no wireless device: the radio is off; press w".to_string(),
361 + Self::NoHardware => {
362 + "no wireless device: this machine has no wireless hardware".to_string()
363 + }
364 + }
365 + }
366 + }
367 +
368 + /// Decide which of the four it is, from facts gathered elsewhere.
369 + ///
370 + /// Pure on purpose. The probing is filesystem work and belongs at the edge; the
371 + /// ordering is the part worth testing, and it is an ordering rather than a set
372 + /// because the causes nest: an image with no plugin also has no managed device
373 + /// and would otherwise report the vaguest of the four.
374 + fn no_wireless(
375 + has_wireless: bool,
376 + radio: Option<bool>,
377 + plugin: bool,
378 + kernel_iface: Option<&str>,
379 + ) -> Option<NoWireless> {
380 + if has_wireless {
381 + return None;
382 + }
383 + if !plugin {
384 + return Some(NoWireless::NoPlugin);
385 + }
386 + if let Some(iface) = kernel_iface {
387 + return Some(NoWireless::Unmanaged(iface.to_string()));
388 + }
389 + if radio == Some(false) {
390 + return Some(NoWireless::RadioOff);
391 + }
392 + Some(NoWireless::NoHardware)
393 + }
394 +
395 + /// Is NetworkManager's wifi device plugin installed?
396 + ///
397 + /// The plugin is a separate package on Fedora (`NetworkManager-wifi`) and the
398 + /// base image is a server base that does not carry it. Reading the file rather
399 + /// than asking rpm: this answers what NM can actually load, which is the thing
400 + /// that decides whether a device appears.
401 + fn wifi_plugin_present() -> bool {
402 + let Ok(entries) = std::fs::read_dir("/usr/lib64/NetworkManager") else {
403 + return false;
404 + };
405 + entries
406 + .filter_map(Result::ok)
407 + .any(|entry| entry.path().join("libnm-device-plugin-wifi.so").exists())
408 + }
409 +
410 + /// The first wireless interface the kernel has made, if any.
411 + ///
412 + /// `/sys/class/net/<name>/wireless` exists exactly when the driver registered
413 + /// a wireless device, which is independent of whether NM presents it. That
414 + /// independence is the whole point: it separates "the driver did not load"
415 + /// from "NetworkManager is not showing you what the driver made".
416 + fn kernel_wireless_iface() -> Option<String> {
417 + let entries = std::fs::read_dir("/sys/class/net").ok()?;
418 + let mut names: Vec<String> = entries
419 + .filter_map(Result::ok)
420 + .filter(|entry| entry.path().join("wireless").exists())
421 + .filter_map(|entry| entry.file_name().into_string().ok())
422 + .collect();
423 + names.sort();
424 + names.into_iter().next()
425 + }
426 +
320 427 /// Parse `nmcli radio wifi`, which answers `enabled` or `disabled`.
321 428 ///
322 429 /// Anything else is `None` rather than a guess: `missing` is what nmcli says
@@ -592,6 +699,10 @@
592 699 /// because the mode has moved on by then: the passphrase is gone the moment
593 700 /// it becomes a [`Secret`], which is the point.
594 701 pending: Option<String>,
702 + /// Why there is no wireless device, when there is none. Recomputed on every
703 + /// refresh, since layering the plugin or plugging a dongle both change the
704 + /// answer without the screen being reopened.
705 + no_wireless: Option<NoWireless>,
595 706 }
596 707
597 708 impl NetView {
@@ -604,6 +715,7 @@
604 715 wifi: None,
605 716 mode: Mode::Devices,
606 717 pending: None,
718 + no_wireless: None,
607 719 };
608 720 view.refresh(log);
609 721 view
@@ -789,6 +901,12 @@
789 901 Err(err) => self.error = Some(err.to_string()),
790 902 }
791 903 self.wifi = self.backend.wifi_enabled(log);
904 + self.no_wireless = no_wireless(
905 + self.interfaces.iter().any(|i| i.kind == Kind::Wireless),
906 + self.wifi,
907 + wifi_plugin_present(),
908 + kernel_wireless_iface().as_deref(),
909 + );
792 910 }
793 911
794 912 fn row<'a>(theme: &Theme, iface: &'a Interface) -> Line<'a> {
@@ -1048,8 +1166,31 @@
1048 1166
1049 1167 match &self.mode {
1050 1168 Mode::Devices => {
1169 + // The note takes the last line, so the list is one shorter when
1170 + // there is something to say. Carved before the empty check
1171 + // because "no interfaces at all" is exactly when the
1172 + // explanation matters most.
1173 + let (list_area, note_area) = match (&self.no_wireless, inner.height) {
1174 + (Some(_), h) if h >= 2 => (
1175 + Rect {
1176 + height: h - 1,
1177 + ..inner
1178 + },
1179 + Some(Rect {
1180 + y: inner.y + h - 1,
1181 + height: 1,
1182 + ..inner
1183 + }),
1184 + ),
1185 + _ => (inner, None),
1186 + };
1187 + if let Some(area) = note_area
1188 + && let Some(reason) = &self.no_wireless
1189 + {
1190 + frame.render_widget(Line::from(text::muted(theme, reason.message())), area);
1191 + }
1051 1192 if self.interfaces.is_empty() {
1052 - frame.render_widget(Line::from(text::muted(theme, "no interfaces")), inner);
1193 + frame.render_widget(Line::from(text::muted(theme, "no interfaces")), list_area);
1053 1194 return;
1054 1195 }
1055 1196 let rows: Vec<Line> = self
@@ -1059,7 +1200,7 @@
1059 1200 .collect();
1060 1201 frame.render_widget(
1061 1202 AlloyList::new(theme, rows).selected(self.cursor.selected()),
1062 - inner,
1203 + list_area,
1063 1204 );
1064 1205 }
1065 1206 Mode::Networks { networks, cursor } => {
@@ -1127,6 +1268,77 @@
1127 1268 mod tests {
1128 1269 use super::*;
1129 1270
1271 + #[test]
1272 + fn a_wireless_device_needs_no_explanation() {
1273 + assert_eq!(no_wireless(true, Some(true), true, None), None);
1274 + // Even with every other signal looking wrong: the device is there, so
1275 + // there is nothing to explain and the screen stays quiet.
1276 + assert_eq!(no_wireless(true, Some(false), false, Some("wlan0")), None);
1277 + }
1278 +
1279 + #[test]
1280 + fn a_missing_plugin_outranks_every_other_cause() {
1281 + // The state fw12 was in on 2026-09-03, and the reason the ordering is
1282 + // an ordering: with no plugin there is also no managed device and no
1283 + // radio to read, so the vaguer causes would all match too.
1284 + assert_eq!(
1285 + no_wireless(false, None, false, None),
1286 + Some(NoWireless::NoPlugin)
1287 + );
1288 + assert_eq!(
1289 + no_wireless(false, Some(false), false, Some("wlp1s0")),
1290 + Some(NoWireless::NoPlugin)
1291 + );
1292 + }
1293 +
1294 + #[test]
1295 + fn a_kernel_interface_nm_does_not_show_names_itself() {
1296 + assert_eq!(
1297 + no_wireless(false, Some(true), true, Some("wlp192s0")),
1298 + Some(NoWireless::Unmanaged("wlp192s0".to_string()))
1299 + );
1300 + }
1301 +
1302 + #[test]
1303 + fn a_dark_radio_explains_an_absent_device_only_when_nothing_else_does() {
1304 + assert_eq!(
1305 + no_wireless(false, Some(false), true, None),
1306 + Some(NoWireless::RadioOff)
1307 + );
1308 + }
1309 +
1310 + #[test]
1311 + fn no_hardware_is_the_answer_when_nothing_is_wrong() {
1312 + assert_eq!(
1313 + no_wireless(false, Some(true), true, None),
1314 + Some(NoWireless::NoHardware)
1315 + );
1316 + // A backend that cannot report the radio (the mock) is not evidence of
1317 + // a fault either.
1318 + assert_eq!(
1319 + no_wireless(false, None, true, None),
1320 + Some(NoWireless::NoHardware)
1321 + );
1322 + }
1323 +
1324 + #[test]
1325 + fn every_message_says_what_to_do_next() {
1326 + // A diagnosis with no next step is a complaint. NoHardware is the one
1327 + // exception and is exempt: there is nothing to do about a machine that
1328 + // has no radio.
1329 + for reason in [
1330 + NoWireless::NoPlugin,
1331 + NoWireless::Unmanaged("wlan0".to_string()),
1332 + NoWireless::RadioOff,
1333 + ] {
1334 + let message = reason.message();
1335 + assert!(
1336 + message.contains("Layer") || message.contains("Check") || message.contains("press"),
1337 + "{message}"
1338 + );
1339 + }
1340 + }
1341 +
1130 1342 // Captured verbatim from `nmcli -t -f GENERAL.DEVICE,GENERAL.TYPE,
1131 1343 // GENERAL.STATE,GENERAL.CONNECTION,IP4.ADDRESS,IP6.ADDRESS device show` on
1132 1344 // a NetworkManager 1.5x box, hostname and SSID aside. Kept real rather
@@ -1252,6 +1464,7 @@
1252 1464 wifi: None,
1253 1465 mode: Mode::Devices,
1254 1466 pending: None,
1467 + no_wireless: None,
1255 1468 };
1256 1469 view.refresh(&mut log);
1257 1470 (view, log)
@@ -1563,6 +1776,7 @@
1563 1776 wifi: None,
1564 1777 mode: Mode::Devices,
1565 1778 pending: None,
1779 + no_wireless: None,
1566 1780 };
1567 1781 view.refresh(&mut log);
1568 1782 (view, log)