max / alloy
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) |