From ac799872d64915595881cf080ee60d1bd5d625a9 Mon Sep 17 00:00:00 2001 From: Lightbar contributors Date: Tue, 29 Sep 2026 13:51:21 +0200 Subject: [PATCH] Add native NetworkManager popup and saved internet priority --- README.md | 49 ++ TOOD.md | 10 +- examples/config.toml | 2 +- src/app.rs | 155 ++++- src/config.rs | 2 +- src/model.rs | 4 + src/model/network.rs | 701 +++++++++++++++++++++ src/modules/mod.rs | 34 +- src/modules/network.rs | 1109 ++++++++++++++++++++++++++++++++++ src/modules/network/tests.rs | 984 ++++++++++++++++++++++++++++++ src/modules/process.rs | 252 +------- src/render.rs | 101 ++++ tests/network_popup.rs | 220 +++++++ 13 files changed, 3360 insertions(+), 263 deletions(-) create mode 100644 src/model/network.rs create mode 100644 src/modules/network.rs create mode 100644 src/modules/network/tests.rs create mode 100644 tests/network_popup.rs diff --git a/README.md b/README.md index e90ad85..a453e28 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,8 @@ and the deliberately deferred stress tests are tracked in [TOOD.md](TOOD.md). - Sway workspaces, binding mode, scratchpad list and direct IPC actions; - exact-boundary clock and calendar popup; - NetworkManager, PipeWire audio, battery, backlight, and hwmon temperature; +- wired, Wi-Fi, and mobile broadband connections, Wi-Fi scanning and passwords, + and saved internet priority through NetworkManager; - default output and microphone selection, volume and mute controls, backlight sliders, and external advanced settings launchers; - interval and streaming custom commands with limits, timeout, stale state, and @@ -124,6 +126,53 @@ See [examples/config.toml](examples/config.toml) and [examples/theme.toml](examples/theme.toml) for every built-in used by the current layout. +### Network + +The network module uses NetworkManager's system D-Bus service. NetworkManager +1.44 or newer is required for guarded profile updates. The bar follows its +primary connection; the popup reports the actual IPv4 and IPv6 defaults +separately, including defaults provided by a VPN. Internet connectivity status +comes from NetworkManager's connectivity check, when enabled. + +Left-click the network module to see wired LAN, Wi-Fi, and mobile broadband +devices. Select a device to see its addresses, saved connections, and controls: + +- **Scan for Wi-Fi networks** requests a scan and updates the list when it + completes. Networks show signal strength, security, and saved/connected state. +- Select a saved connection to activate it. New open, enhanced-open, WPA/WPA2 + Personal, and WPA3 Personal networks can be joined directly. Password entry + is masked and sent over D-Bus to NetworkManager for normal profile storage. +- **Disconnect** disconnects that device. Wi-Fi and mobile broadband radio + switches appear in the overview; hardware blocks are shown separately. +- **Prefer for internet** saves route metric `1` for the selected profile's + eligible IPv4/IPv6 settings and reapplies it to active connections. Conflicting + wired, Wi-Fi, or mobile profiles with metric `0`/`1` are moved to `2` as needed; + other priorities are preserved. IPv6 metric `0` retains its kernel meaning. + +This preference uses NetworkManager configuration, without a separate Lightbar +preference file. It does not change autoconnect priority. Default indicators +continue to reflect NetworkManager's current routes: an unavailable gateway, +IPv6 availability, or VPN policy can produce a different default. VPNs, virtual +connections, and `never-default` settings are preserved. Explicit default routes +and policy routing require the connection editor. Failed priority updates attempt +to restore the previous metrics and report any rollback failure in the popup. + +Use **Open connection editor** (`nm-connection-editor`) for enterprise Wi-Fi, +hidden networks, legacy WEP, password changes on saved profiles, SIM/APN setup, +and advanced routing. Existing mobile broadband profiles can be activated in +the popup. NetworkManager's permissions and secret-agent policies still apply; +authorization failures and connection failures appear in the popup. + +Use Tab/Shift+Tab or Up/Down to move focus, Enter/Space to activate controls, +Backspace to edit a password, and Escape to close. Lists support scrolling and +Previous/More buttons. Devices, defaults, and saved settings update from D-Bus +events without idle polling. Controls remain available while disconnected. + +`format_connected` accepts `{icon}`, `{kind}`, `{connection}`, `{interface}`, +and `{ssid}`. An optional `interface` limits the bar's displayed connection; +the popup continues to show all supported devices. The example uses +`{icon} {connection}` so wired and mobile defaults have appropriate icons. + ### Audio Audio requires PipeWire, WirePlumber (`wpctl`), and `pw-dump` on `PATH`. diff --git a/TOOD.md b/TOOD.md index 783369f..566f8d0 100644 --- a/TOOD.md +++ b/TOOD.md @@ -58,8 +58,16 @@ StatusNotifier tray. Sway IPC disconnects instead of replacing it with an empty snapshot. - [x] Implement a navigable scratchpad window popover and reveal/move actions. - [x] Implement an exact-boundary clock and navigable calendar popover. -- [ ] Implement NetworkManager status and detail through D-Bus, with an +- [x] Implement NetworkManager status and detail through D-Bus, with an `nm-connection-editor` launcher for advanced management. +- [x] Add a keyboard-accessible network popup for LAN, Wi-Fi scans and + connections, WWAN profiles, and actual IPv4/IPv6 default connections. +- [x] Set internet preference through saved NetworkManager route metrics, + preserve VPN routing, and report authorization or activation failures. +- [x] Verify network controls with isolated services and popup tests, document + supported connection types, and validate the installed build. + Verified with 54 automated tests, a read-only comparison with live + NetworkManager, and native popup controls in an isolated Sway session. - [ ] Implement UPower battery status and detail through D-Bus. - [x] Implement PulseAudio/PipeWire-Pulse volume events, mute/volume controls, and a `pavucontrol` launcher. diff --git a/examples/config.toml b/examples/config.toml index 811bc4c..8eed54c 100644 --- a/examples/config.toml +++ b/examples/config.toml @@ -38,7 +38,7 @@ format = "{mode}" [modules.network] kind = "network" -format_connected = " {ssid}" +format_connected = "{icon} {connection}" format_disconnected = "睊 Disconnected" [modules.network.common.actions.left] diff --git a/src/app.rs b/src/app.rs index 0f88db2..8bb5eef 100644 --- a/src/app.rs +++ b/src/app.rs @@ -58,8 +58,8 @@ use wayland_protocols::xdg::shell::client::xdg_positioner; use crate::{ config::{Action, BarConfig, BuiltinAction, ConfigBundle, Layer, ModuleConfig, Position}, model::{ - AudioView, CalendarModel, Interaction, ModuleEvent, ModuleStore, PointerButton, - PopupContent, PopupModel, ScrollDirection, + AudioView, CalendarModel, Interaction, ModuleEvent, ModuleStore, NetworkView, + PointerButton, PopupContent, PopupModel, ScrollDirection, }, modules::{ AudioAction, AudioTarget, ModuleRuntime, action_for_button, action_for_scroll, @@ -403,6 +403,16 @@ impl App { new.focused.clone_from(&old.focused); new.offset = old.offset.min(new.controls().len().saturating_sub(1)); } + if let (PopupContent::Network(old), PopupContent::Network(new)) = + (&popup.model.content, &mut model.content) + { + new.preserve_ui(old); + let available = (f64::from(popup.height) + - 2.0 * f64::from(self.bundle.theme.popup.padding) + - 58.0) + .max(0.0); + new.ensure_focus_visible(available); + } popup.model = model; popup.dirty = true; } @@ -737,12 +747,16 @@ impl App { &self.shm, )?; // Establish keyboard focus before the popup grab. Release it on dismissal. - bar.layer - .set_keyboard_interactivity(if matches!(model.content, PopupContent::Audio(_)) { + bar.layer.set_keyboard_interactivity( + if matches!( + model.content, + PopupContent::Audio(_) | PopupContent::Network(_) + ) { KeyboardInteractivity::Exclusive } else { KeyboardInteractivity::OnDemand - }); + }, + ); bar.layer.commit(); bar.layer.get_popup(popup.xdg_popup()); if let Some(seat) = &self.seat { @@ -904,6 +918,10 @@ impl App { let Some(id) = hit_box.segment.as_deref() else { return; }; + if id.starts_with("network-") { + self.handle_network_control(&hit_box, qh); + return; + } if id.starts_with("audio-") { self.handle_audio_control(&hit_box, x, qh); return; @@ -991,6 +1009,106 @@ impl App { } } + fn handle_network_control(&mut self, hit: &HitBox, qh: &QueueHandle) { + let Some(popup) = self.popup.as_mut() else { + return; + }; + let PopupContent::Network(network) = &mut popup.model.content else { + return; + }; + let Some(id) = hit.segment.as_deref() else { + return; + }; + let action = match id { + "network-scroll-up" => { + network.offset = network.offset.saturating_sub(1); + None + } + "network-scroll-down" => { + network.offset = + (network.offset + 1).min(network.controls().len().saturating_sub(1)); + None + } + _ => network.activate(id), + }; + if let Some(action) = action { + match self.module_runtime.network_action(&popup.module, action) { + Ok(()) => { + network.state.busy = true; + network.state.notice = Some("Applying network control…".into()); + } + Err(error) => { + network.state.notice = + Some(format!("Could not queue network control: {error:#}")); + } + } + } + let available = + (f64::from(popup.height) - 2.0 * f64::from(self.bundle.theme.popup.padding) - 58.0) + .max(0.0); + if !id.starts_with("network-scroll-") { + network.ensure_focus_visible(available); + } + popup.dirty = true; + self.invalidate_all(qh); + } + + fn handle_network_key(&mut self, event: &KeyEvent, qh: &QueueHandle) -> bool { + let Some(popup) = self.popup.as_mut() else { + return false; + }; + let PopupContent::Network(network) = &mut popup.model.content else { + return false; + }; + let available = + (f64::from(popup.height) - 2.0 * f64::from(self.bundle.theme.popup.padding) - 58.0) + .max(0.0); + if !network.state.busy + && network.focused.as_deref() == Some("network-password") + && matches!(network.view, NetworkView::Password { .. }) + && !matches!( + event.keysym, + Keysym::Tab + | Keysym::ISO_Left_Tab + | Keysym::Up + | Keysym::Down + | Keysym::Return + | Keysym::KP_Enter + ) + { + if event.keysym == Keysym::BackSpace { + network.password.backspace(); + } else if let Some(text) = &event.utf8 { + network.password.push(text); + } + } else { + match event.keysym { + Keysym::Tab | Keysym::Down => network.move_focus(false, available), + Keysym::ISO_Left_Tab | Keysym::Up => network.move_focus(true, available), + Keysym::Return | Keysym::KP_Enter | Keysym::space => { + let id = if network.focused.as_deref() == Some("network-password") { + Some("network-submit".to_owned()) + } else { + network.focused.clone() + }; + if let Some(id) = id { + let hit = HitBox { + rect: crate::render::Rect::default(), + module: popup.module.clone(), + segment: Some(id), + }; + self.handle_popup_click(hit, 0.0, qh); + return true; + } + } + _ => {} + } + } + popup.dirty = true; + self.invalidate_all(qh); + true + } + fn handle_audio_control(&mut self, hit: &HitBox, x: f64, qh: &QueueHandle) { let Some(popup) = self.popup.as_mut() else { return; @@ -1533,6 +1651,32 @@ impl PointerHandler for App { self.handle_popup_click(hit, event.position.0, qh); } } + PointerEventKind::Axis { vertical, .. } + if matches!(popup.model.content, PopupContent::Network(_)) => + { + let amount = if vertical.value120 != 0 { + f64::from(vertical.value120) + } else if vertical.discrete != 0 { + f64::from(vertical.discrete) + } else { + vertical.absolute + }; + if amount != 0.0 { + let hit = HitBox { + rect: crate::render::Rect::default(), + module: popup.module.clone(), + segment: Some( + if amount < 0.0 { + "network-scroll-up" + } else { + "network-scroll-down" + } + .into(), + ), + }; + self.handle_network_control(&hit, qh); + } + } PointerEventKind::Axis { vertical, .. } if matches!(popup.model.content, PopupContent::Audio(_)) => { @@ -1619,6 +1763,7 @@ impl KeyboardHandler for App { ) { if event.keysym == Keysym::Escape { self.close_popup(); + } else if self.handle_network_key(&event, qh) { } else if self.handle_audio_key(event.keysym, qh) { } else if event.keysym == Keysym::Left { self.change_calendar_month(-1, qh); diff --git a/src/config.rs b/src/config.rs index 5d6cfdb..065e394 100644 --- a/src/config.rs +++ b/src/config.rs @@ -514,7 +514,7 @@ impl Default for NetworkModule { fn default() -> Self { Self { kind: "network".to_owned(), - format_connected: " {ssid}".to_owned(), + format_connected: "{icon} {connection}".to_owned(), format_disconnected: "睊 Disconnected".to_owned(), interface: None, common: ModuleCommon::default(), diff --git a/src/model.rs b/src/model.rs index 11f0d49..5f00c70 100644 --- a/src/model.rs +++ b/src/model.rs @@ -1,5 +1,8 @@ use std::collections::BTreeMap; +mod network; +pub use network::*; + #[derive(Debug, Clone, Default, PartialEq)] pub struct ModuleSnapshot { pub segments: Vec, @@ -88,6 +91,7 @@ pub enum PopupContent { Calendar(CalendarModel), Slider(SliderModel), Audio(AudioModel), + Network(NetworkModel), } #[derive(Debug, Copy, Clone, Default, Eq, PartialEq)] diff --git a/src/model/network.rs b/src/model/network.rs new file mode 100644 index 0000000..c78be0e --- /dev/null +++ b/src/model/network.rs @@ -0,0 +1,701 @@ +use std::fmt; + +/// UI-owned credential, never formatted or included in module snapshots by the worker. +#[derive(Clone, Default, PartialEq, Eq)] +pub struct NetworkSecret(Vec); + +impl fmt::Debug for NetworkSecret { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("") + } +} +impl Drop for NetworkSecret { + fn drop(&mut self) { + self.0.fill(0); + } +} +impl NetworkSecret { + /// # Panics + /// Panics if the private buffer violates its UTF-8 invariant. + #[must_use] + pub fn text(&self) -> &str { + std::str::from_utf8(&self.0).expect("credential contains typed UTF-8") + } + pub fn push(&mut self, text: &str) { + for c in text.chars().filter(|c| !c.is_control()) { + if self.0.len() + c.len_utf8() <= 128 { + let mut bytes = [0; 4]; + self.0 + .extend_from_slice(c.encode_utf8(&mut bytes).as_bytes()); + } + } + } + pub fn backspace(&mut self) { + if let Some((index, _)) = self.text().char_indices().next_back() { + self.0[index..].fill(0); + self.0.truncate(index); + } + } + #[must_use] + pub fn masked(&self) -> String { + "•".repeat(self.text().chars().count()) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum NetworkKind { + Wired, + Wifi, + Wwan, +} +impl NetworkKind { + #[must_use] + pub fn label(self) -> &'static str { + match self { + Self::Wired => "Wired LAN", + Self::Wifi => "Wi-Fi", + Self::Wwan => "Mobile broadband", + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WifiSecurity { + Open, + Personal, + Sae, + Owe, + Enterprise, + Unsupported, +} +impl WifiSecurity { + #[must_use] + pub fn label(self) -> &'static str { + match self { + Self::Open => "Open", + Self::Personal => "WPA/WPA2", + Self::Sae => "WPA3", + Self::Owe => "Enhanced open", + Self::Enterprise => "Enterprise", + Self::Unsupported => "Configure in editor", + } + } + #[must_use] + pub fn password_valid(self, password: &str) -> bool { + match self { + Self::Personal => { + (8..=63).contains(&password.len()) + || (password.len() == 64 && password.bytes().all(|b| b.is_ascii_hexdigit())) + } + Self::Sae => (1..=63).contains(&password.len()), + _ => false, + } + } +} + +#[derive(Debug, Clone, PartialEq)] +pub struct WifiNetwork { + pub path: String, + pub ssid: Vec, + pub label: String, + pub strength: u8, + pub security: WifiSecurity, + pub active: bool, + pub profile: Option, +} + +#[derive(Debug, Clone, Default, PartialEq)] +pub struct NetworkProfile { + pub path: String, + pub name: String, + pub uuid: String, + pub kind: Option, + pub ssid: Vec, + pub security: String, + pub metrics: [i64; 2], +} + +#[derive(Debug, Clone, PartialEq)] +pub struct NetworkDevice { + pub path: String, + pub interface: String, + pub kind: NetworkKind, + pub state: u32, + pub state_label: String, + pub managed: bool, + pub carrier: Option, + pub active: Option, + pub profile: Option, + pub connection_name: String, + pub defaults: [bool; 2], + pub addresses: Vec, + pub profiles: Vec, + pub networks: Vec, + pub last_scan: i64, +} + +#[derive(Debug, Clone, Default, PartialEq)] +#[allow(clippy::struct_excessive_bools)] // Independent radio, hardware, and operation states. +pub struct NetworkState { + pub devices: Vec, + pub defaults: [Vec; 2], + pub primary: String, + pub primary_interface: String, + pub primary_kind: String, + pub connectivity: String, + pub wifi_enabled: bool, + pub wifi_hardware: bool, + pub wwan_enabled: bool, + pub wwan_hardware: bool, + pub notice: Option, + pub busy: bool, +} + +#[derive(Debug, Clone, Default, PartialEq)] +pub enum NetworkView { + #[default] + Overview, + Device(String), + Password { + device: String, + ap: String, + label: String, + security: WifiSecurity, + }, +} + +/// Commands are owned by the network worker after enqueueing; callers never wait on D-Bus. +#[derive(Debug, Clone, PartialEq)] +pub enum NetworkAction { + Scan(String), + Radio { + wifi: bool, + enabled: bool, + }, + Connect { + device: String, + profile: String, + ap: String, + }, + Join { + device: String, + ap: String, + password: NetworkSecret, + }, + Disconnect(String), + Prefer(String), +} + +#[derive(Debug, Clone, Default, PartialEq)] +pub struct NetworkModel { + pub state: NetworkState, + pub view: NetworkView, + pub focused: Option, + pub offset: usize, + pub password: NetworkSecret, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct NetworkControl { + pub id: Option, + pub label: String, + pub detail: String, + pub active: bool, +} +impl NetworkControl { + #[must_use] + pub fn height(&self) -> f64 { + if self.detail.is_empty() { 32.0 } else { 52.0 } + } +} + +impl NetworkModel { + pub fn set_view(&mut self, view: NetworkView) { + self.view = view; + self.focused = None; + self.offset = 0; + self.password = NetworkSecret::default(); + } + + pub fn preserve_ui(&mut self, old: &Self) { + self.view = old.view.clone(); + let valid = match &self.view { + NetworkView::Overview => true, + NetworkView::Device(path) => self.state.devices.iter().any(|d| d.path == *path), + NetworkView::Password { device, ap, .. } => self + .state + .devices + .iter() + .any(|d| d.path == *device && d.networks.iter().any(|n| n.path == *ap)), + }; + if !valid { + self.set_view(NetworkView::Overview); + return; + } + self.password = old.password.clone(); + let controls = self.controls(); + self.focused = if self.state.busy { + old.focused.clone() + } else { + old.focused + .clone() + .filter(|id| controls.iter().any(|c| c.id.as_ref() == Some(id))) + }; + self.offset = old.offset.min(controls.len().saturating_sub(1)); + } + + #[must_use] + #[allow(clippy::too_many_lines)] // The three popup views share one ordered control list. + pub fn controls(&self) -> Vec { + let mut rows = Vec::new(); + let mut row = |id: Option, label: String, detail: String, active| { + rows.push(NetworkControl { + id, + label, + detail, + active, + }); + }; + if let Some(notice) = &self.state.notice { + // Split long errors into bounded lines so the actionable detail remains readable. + for line in wrap_notice(notice, 42) { + row(None, line, String::new(), false); + } + } + match &self.view { + NetworkView::Overview => { + for (family, defaults) in ["IPv4 default", "IPv6 default"] + .into_iter() + .zip(&self.state.defaults) + { + let names = if defaults.is_empty() { + "None".into() + } else { + defaults.join(", ") + }; + row(None, format!("{family}: {names}"), String::new(), false); + } + row(None, self.state.connectivity.clone(), String::new(), false); + for (wifi, enabled, hardware, label) in [ + ( + true, + self.state.wifi_enabled, + self.state.wifi_hardware, + "Wi-Fi", + ), + ( + false, + self.state.wwan_enabled, + self.state.wwan_hardware, + "Mobile broadband", + ), + ] { + if self.state.devices.iter().any(|d| { + d.kind + == if wifi { + NetworkKind::Wifi + } else { + NetworkKind::Wwan + } + }) { + row( + hardware.then(|| format!("network-radio:{wifi}")), + format!( + "{label}: {}", + if !hardware { + "hardware blocked" + } else if enabled { + "On · Turn off" + } else { + "Off · Turn on" + } + ), + String::new(), + enabled, + ); + } + } + for kind in [NetworkKind::Wired, NetworkKind::Wifi, NetworkKind::Wwan] { + let devices: Vec<_> = self + .state + .devices + .iter() + .filter(|d| d.kind == kind) + .collect(); + if devices.is_empty() { + row( + None, + format!("{} · No device", kind.label()), + String::new(), + false, + ); + } + for device in devices { + row( + Some(format!("network-device:{}", device.path)), + format!("{} · {} ›", kind.label(), device.interface), + device.summary(), + device.defaults.iter().any(|v| *v), + ); + } + } + } + NetworkView::Device(path) => { + row( + Some("network-back".into()), + "‹ All connections".into(), + String::new(), + false, + ); + if let Some(device) = self.state.devices.iter().find(|d| d.path == *path) { + row( + None, + format!("{} · {}", device.kind.label(), device.interface), + device.summary(), + false, + ); + for address in &device.addresses { + row(None, address.clone(), String::new(), false); + } + if let Some(profile) = device + .profiles + .iter() + .find(|p| Some(&p.path) == device.profile.as_ref()) + { + row( + None, + "Saved route metrics (lower wins)".into(), + format!( + "IPv4 {} · IPv6 {}", + metric_label(profile.metrics[0]), + metric_label(profile.metrics[1]) + ), + false, + ); + } + if device.active.is_some() && device.managed { + if device.state == 100 && device.profile.is_some() { + row( + Some("network-prefer".into()), + "Prefer for internet".into(), + "Save route priority in NetworkManager".into(), + false, + ); + } + row( + Some("network-disconnect".into()), + "Disconnect".into(), + String::new(), + false, + ); + } + if device.kind == NetworkKind::Wifi { + row( + (device.managed && self.state.wifi_enabled && self.state.wifi_hardware) + .then(|| "network-scan".into()), + "Scan for Wi-Fi networks".into(), + String::new(), + false, + ); + for network in &device.networks { + row( + (device.managed + && self.state.wifi_enabled + && self.state.wifi_hardware + && !network.active) + .then(|| format!("network-ap:{}", network.path)), + format!( + "{}{}", + if network.active { "✓ " } else { "" }, + network.label + ), + format!( + "{}% · {}{}", + network.strength, + network.security.label(), + if network.profile.is_some() { + " · Saved" + } else { + "" + } + ), + network.active, + ); + } + if device.networks.is_empty() { + row( + None, + "No networks found".into(), + "Turn on Wi-Fi, then scan".into(), + false, + ); + } + } + for profile in &device.profiles { + if Some(&profile.path) != device.profile.as_ref() + && (device.kind != NetworkKind::Wifi + || !device + .networks + .iter() + .any(|n| n.profile.as_ref() == Some(&profile.path))) + { + row( + (device.managed && device.state >= 30) + .then(|| format!("network-profile:{}", profile.path)), + format!("Connect: {}", profile.name), + "Saved connection".into(), + false, + ); + } + } + if device.kind == NetworkKind::Wired + && device.profiles.is_empty() + && device.managed + && device.carrier == Some(true) + { + row( + Some("network-auto-connect".into()), + "Connect wired network".into(), + "Create an automatic IP profile".into(), + false, + ); + } + if device.kind == NetworkKind::Wwan && device.profiles.is_empty() { + row( + None, + "Set up mobile broadband in the editor".into(), + "Choose the provider and APN".into(), + false, + ); + } + } + } + NetworkView::Password { + label, security, .. + } => { + row( + Some("network-back".into()), + "‹ Back to Wi-Fi networks".into(), + String::new(), + false, + ); + row( + None, + format!("Connect to {label}"), + security.label().into(), + false, + ); + row( + Some("network-password".into()), + "Password".into(), + if self.password.text().is_empty() { + "Type the Wi-Fi password".into() + } else { + self.password.masked() + }, + false, + ); + row( + security + .password_valid(self.password.text()) + .then(|| "network-submit".into()), + "Connect".into(), + String::new(), + false, + ); + row( + None, + if *security == WifiSecurity::Sae { + "1–63 bytes" + } else { + "8–63 bytes or 64 hexadecimal digits" + } + .into(), + String::new(), + false, + ); + } + } + row( + Some("open-settings".into()), + "Open connection editor…".into(), + String::new(), + false, + ); + if self.state.busy { + for row in &mut rows { + row.id = None; + } + } + rows + } + + pub fn activate(&mut self, id: &str) -> Option { + if !self.controls().iter().any(|c| c.id.as_deref() == Some(id)) { + return None; + } + self.focused = Some(id.into()); + if let Some(path) = id.strip_prefix("network-device:") { + self.set_view(NetworkView::Device(path.into())); + return None; + } + if id == "network-back" { + let view = match &self.view { + NetworkView::Password { device, .. } => NetworkView::Device(device.clone()), + _ => NetworkView::Overview, + }; + self.set_view(view); + return None; + } + if let Some(wifi) = id.strip_prefix("network-radio:") { + let wifi = wifi == "true"; + return Some(NetworkAction::Radio { + wifi, + enabled: if wifi { + !self.state.wifi_enabled + } else { + !self.state.wwan_enabled + }, + }); + } + if let NetworkView::Password { device, ap, .. } = &self.view { + if id == "network-submit" { + let action = NetworkAction::Join { + device: device.clone(), + ap: ap.clone(), + password: std::mem::take(&mut self.password), + }; + self.set_view(NetworkView::Device(device.clone())); + return Some(action); + } + return None; + } + let NetworkView::Device(path) = &self.view else { + return None; + }; + let device = self.state.devices.iter().find(|d| d.path == *path)?; + match id { + "network-scan" => Some(NetworkAction::Scan(path.clone())), + "network-prefer" => device.profile.clone().map(NetworkAction::Prefer), + "network-disconnect" => Some(NetworkAction::Disconnect(path.clone())), + "network-auto-connect" => Some(NetworkAction::Connect { + device: path.clone(), + profile: "/".into(), + ap: "/".into(), + }), + _ => { + if let Some(profile) = id.strip_prefix("network-profile:") { + return Some(NetworkAction::Connect { + device: path.clone(), + profile: profile.into(), + ap: "/".into(), + }); + } + let ap = id.strip_prefix("network-ap:")?; + let network = device.networks.iter().find(|n| n.path == ap)?; + if let Some(profile) = &network.profile { + return Some(NetworkAction::Connect { + device: path.clone(), + profile: profile.clone(), + ap: ap.into(), + }); + } + match network.security { + WifiSecurity::Personal | WifiSecurity::Sae => { + self.set_view(NetworkView::Password { + device: path.clone(), + ap: ap.into(), + label: network.label.clone(), + security: network.security, + }); + self.focused = Some("network-password".into()); + None + } + WifiSecurity::Open | WifiSecurity::Owe => Some(NetworkAction::Join { + device: path.clone(), + ap: ap.into(), + password: NetworkSecret::default(), + }), + _ => { + self.state.notice = + Some("Configure this network in the connection editor.".into()); + None + } + } + } + } + } + + pub fn ensure_focus_visible(&mut self, available: f64) { + let controls = self.controls(); + let Some(index) = controls + .iter() + .position(|c| c.id.is_some() && c.id == self.focused) + else { + return; + }; + self.offset = self.offset.min(index); + while self.offset < index + && controls[self.offset..=index] + .iter() + .map(NetworkControl::height) + .sum::() + > available + { + self.offset += 1; + } + } + + pub fn move_focus(&mut self, backwards: bool, available: f64) { + let controls = self.controls(); + let indices: Vec<_> = controls + .iter() + .enumerate() + .filter_map(|(i, c)| c.id.as_ref().map(|_| i)) + .collect(); + if indices.is_empty() { + return; + } + let current = indices.iter().position(|&i| controls[i].id == self.focused); + let next = match (current, backwards) { + (Some(i), true) => (i + indices.len() - 1) % indices.len(), + (Some(i), false) => (i + 1) % indices.len(), + (None, true) => indices.len() - 1, + (None, false) => 0, + }; + let index = indices[next]; + self.focused.clone_from(&controls[index].id); + self.ensure_focus_visible(available); + } +} + +impl NetworkDevice { + #[must_use] + pub fn summary(&self) -> String { + let mut parts = vec![self.state_label.clone()]; + if !self.connection_name.is_empty() { + parts.push(self.connection_name.clone()); + } + if self.defaults[0] { + parts.push("IPv4 default".into()); + } + if self.defaults[1] { + parts.push("IPv6 default".into()); + } + parts.join(" · ") + } +} +fn metric_label(value: i64) -> String { + if value < 0 { + "Auto".into() + } else { + value.to_string() + } +} +fn wrap_notice(text: &str, width: usize) -> Vec { + let chars: Vec<_> = text.chars().filter(|c| !c.is_control()).take(504).collect(); + chars + .chunks(width) + .map(|chunk| chunk.iter().collect()) + .collect() +} diff --git a/src/modules/mod.rs b/src/modules/mod.rs index 8dabfa8..974ce81 100644 --- a/src/modules/mod.rs +++ b/src/modules/mod.rs @@ -1,6 +1,7 @@ mod audio; mod clock; mod command; +mod network; mod process; mod sway; mod sysfs; @@ -24,7 +25,7 @@ use crate::{ Action, AudioModule, BacklightModule, BuiltinAction, Config, ModuleConfig, UnavailableBehavior, }, - model::{ModuleEvent, ModuleSnapshot}, + model::{ModuleEvent, ModuleSnapshot, NetworkAction}, }; pub use audio::{AudioAction, AudioTarget}; @@ -39,6 +40,7 @@ pub struct ModuleRuntime { children: Arc>>>>, module_kinds: BTreeMap, audio_actions: BTreeMap>, + network_actions: BTreeMap>, } impl ModuleRuntime { @@ -54,6 +56,7 @@ impl ModuleRuntime { let mut threads = Vec::new(); let mut module_kinds = BTreeMap::new(); let mut audio_actions = BTreeMap::new(); + let mut network_actions = BTreeMap::new(); let sway_modules = sway::ConfiguredSwayModules::from_config(config); if !sway_modules.is_empty() { @@ -91,13 +94,16 @@ impl ModuleRuntime { sender.clone(), Arc::clone(&stop), )), - ModuleConfig::Network(settings) => threads.push(process::spawn_network( - name.clone(), - settings.clone(), - sender.clone(), - Arc::clone(&stop), - Arc::clone(&children), - )), + ModuleConfig::Network(settings) => { + let (worker, actions) = network::spawn( + name.clone(), + settings.clone(), + sender.clone(), + Arc::clone(&stop), + ); + threads.push(worker); + network_actions.insert(name.clone(), actions); + } ModuleConfig::Audio(settings) => { let (worker, actions) = audio::spawn( name.clone(), @@ -133,6 +139,7 @@ impl ModuleRuntime { children, module_kinds, audio_actions, + network_actions, }) } @@ -140,6 +147,16 @@ impl ModuleRuntime { self.module_kinds.get(module).copied() } + /// Queues an owned network command; execution and its result belong to the worker. + /// Returns an error if the module stopped or the bounded queue is full. + pub fn network_action(&self, module: &str, action: NetworkAction) -> Result<()> { + self.network_actions + .get(module) + .context("network module is not running")? + .try_send(action) + .context("could not queue network control") + } + /// Queues a control without blocking the UI; the module worker owns execution. /// /// # Errors @@ -157,6 +174,7 @@ impl Drop for ModuleRuntime { fn drop(&mut self) { self.stop.store(true, Ordering::Release); self.audio_actions.clear(); + self.network_actions.clear(); for thread in &self.threads { thread.thread().unpark(); } diff --git a/src/modules/network.rs b/src/modules/network.rs new file mode 100644 index 0000000..a3fb8ab --- /dev/null +++ b/src/modules/network.rs @@ -0,0 +1,1109 @@ +use std::{ + collections::{BTreeMap, HashMap}, + sync::{ + Arc, + atomic::{AtomicBool, Ordering}, + }, + thread::{self, JoinHandle}, + time::Duration, +}; + +use anyhow::{Context, Result, bail, ensure}; +use calloop::channel::Sender; +use futures_lite::StreamExt; +use tokio::sync::mpsc; +use zbus::zvariant::{OwnedObjectPath, OwnedValue, Value as BusValue}; +use zbus::{Connection, MatchRule, MessageStream, Proxy, message::Type}; + +use crate::{ + config::{NetworkModule, UnavailableBehavior}, + format::{Value, expand}, + model::{ + ModuleEvent, ModuleSnapshot, NetworkAction, NetworkDevice, NetworkKind, NetworkModel, + NetworkProfile, NetworkState, PopupContent, PopupModel, WifiNetwork, WifiSecurity, + }, +}; + +const NM: &str = "org.freedesktop.NetworkManager"; +const ROOT: &str = "/org/freedesktop/NetworkManager"; +const DEVICE: &str = "org.freedesktop.NetworkManager.Device"; +const WIRELESS: &str = "org.freedesktop.NetworkManager.Device.Wireless"; +const WIRED: &str = "org.freedesktop.NetworkManager.Device.Wired"; +const ACTIVE: &str = "org.freedesktop.NetworkManager.Connection.Active"; +const AP: &str = "org.freedesktop.NetworkManager.AccessPoint"; +const PROFILE: &str = "org.freedesktop.NetworkManager.Settings.Connection"; +const MAX_OBJECTS: usize = 8192; +const MAX_PROFILES: usize = 512; +const CALL_TIMEOUT: Duration = Duration::from_secs(5); +type Properties = HashMap; +type Settings = HashMap; +type Objects = HashMap; + +struct SavedProfile { + settings: Settings, + version: u64, +} +struct Snapshot { + state: NetworkState, + objects: Objects, + saved: BTreeMap, +} + +async fn proxy<'a>( + connection: &'a Connection, + path: &'a str, + interface: &'a str, +) -> Result> { + Ok(Proxy::new(connection, NM, path, interface).await?) +} + +fn get(properties: &Properties, key: &str, fallback: T) -> Result +where + T: TryFrom, + T::Error: std::error::Error + Send + Sync + 'static, +{ + properties.get(key).map_or(Ok(fallback), |value| { + T::try_from(value.try_clone()?) + .with_context(|| format!("invalid NetworkManager property {key}")) + }) +} +fn properties<'a>(objects: &'a Objects, path: &str, interface: &str) -> Option<&'a Properties> { + objects.get(path)?.get(interface) +} +fn paths(properties: &Properties, key: &str) -> Result> { + Ok(get::>(properties, key, Vec::new())? + .into_iter() + .map(|p| p.to_string()) + .collect()) +} +fn path(properties: &Properties, key: &str) -> Result { + Ok(get::(properties, key, OwnedObjectPath::try_from("/")?)?.to_string()) +} +fn text(properties: &Properties, key: &str) -> Result { + get(properties, key, String::new()) +} +fn clean_label(text: &str) -> String { + text.chars().filter(|c| !c.is_control()).take(160).collect() +} +fn string(value: &str) -> OwnedValue { + BusValue::from(value) + .try_to_owned() + .expect("string value is owned") +} +fn clone_settings(settings: &Settings) -> Result { + settings + .iter() + .map(|(k, v)| { + Ok(( + k.clone(), + v.iter() + .map(|(k, v)| Ok((k.clone(), v.try_clone()?))) + .collect::>()?, + )) + }) + .collect() +} +fn kind(value: &str) -> Option { + match value { + "802-3-ethernet" => Some(NetworkKind::Wired), + "802-11-wireless" => Some(NetworkKind::Wifi), + "gsm" | "cdma" => Some(NetworkKind::Wwan), + _ => None, + } +} +fn device_kind(value: u32) -> Option { + match value { + 1 => Some(NetworkKind::Wired), + 2 => Some(NetworkKind::Wifi), + 8 => Some(NetworkKind::Wwan), + _ => None, + } +} + +fn profile_model(path: &str, settings: &Settings) -> Result { + let connection = settings + .get("connection") + .context("profile has no connection settings")?; + let mut profile = NetworkProfile { + path: path.into(), + name: clean_label(&text(connection, "id")?), + uuid: text(connection, "uuid")?, + kind: kind(&text(connection, "type")?), + metrics: [-1, -1], + ..NetworkProfile::default() + }; + if let Some(wifi) = settings.get("802-11-wireless") { + profile.ssid = get(wifi, "ssid", Vec::new())?; + } + if let Some(security) = settings.get("802-11-wireless-security") { + profile.security = text(security, "key-mgmt")?; + } + for (i, family) in ["ipv4", "ipv6"].iter().enumerate() { + if let Some(ip) = settings.get(*family) { + profile.metrics[i] = get(ip, "route-metric", -1_i64)?; + } + } + Ok(profile) +} + +fn security(props: &Properties) -> Result { + let flags = get(props, "Flags", 0_u32)?; + let wpa = get(props, "WpaFlags", 0_u32)?; + let rsn = get(props, "RsnFlags", 0_u32)?; + Ok(if (wpa | rsn) & (0x200 | 0x2000) != 0 { + WifiSecurity::Enterprise + } else if rsn & 0x400 != 0 { + WifiSecurity::Sae + } else if (wpa | rsn) & 0x100 != 0 { + WifiSecurity::Personal + } else if rsn & (0x800 | 0x1000) != 0 { + WifiSecurity::Owe + } else if flags & 1 == 0 { + WifiSecurity::Open + } else { + WifiSecurity::Unsupported + }) +} +fn compatible(profile: &NetworkProfile, ssid: &[u8], security: WifiSecurity) -> bool { + profile.ssid == ssid + && match security { + WifiSecurity::Open => profile.security.is_empty() || profile.security == "none", + WifiSecurity::Personal => profile.security == "wpa-psk", + WifiSecurity::Sae => profile.security == "sae" || profile.security == "wpa-psk", + WifiSecurity::Owe => profile.security == "owe", + WifiSecurity::Enterprise => { + profile.security == "wpa-eap" || profile.security == "wpa-eap-suite-b-192" + } + WifiSecurity::Unsupported => profile.security == "none", + } +} + +fn state_label(state: u32, reason: u32, managed: bool, carrier: Option) -> String { + if !managed { + return "Unmanaged".into(); + } + match state { + 10 => "Unmanaged".into(), + 20 if carrier == Some(false) => "Cable unplugged".into(), + 20 => "Unavailable".into(), + 30 => "Disconnected".into(), + 40..=90 => if state == 60 { + "Authentication required" + } else { + "Connecting…" + } + .into(), + 100 => "Connected".into(), + 110 => "Disconnecting…".into(), + 120 => match reason { + 7 => "Failed: password or secrets required".into(), + 8..=11 => "Failed: Wi-Fi authentication".into(), + 5 | 6 | 15..=17 => "Failed: IP configuration".into(), + _ => format!("Connection failed (reason {reason})"), + }, + _ => "Unknown state".into(), + } +} + +#[allow(clippy::too_many_lines)] // Decode one coherent NetworkManager object snapshot. +fn build_state(objects: &Objects, profiles: &[NetworkProfile]) -> Result { + let root = properties(objects, ROOT, NM).context("NetworkManager is unavailable")?; + let mut state = NetworkState { + wifi_enabled: get(root, "WirelessEnabled", false)?, + wifi_hardware: get(root, "WirelessHardwareEnabled", false)?, + wwan_enabled: get(root, "WwanEnabled", false)?, + wwan_hardware: get(root, "WwanHardwareEnabled", false)?, + connectivity: match get(root, "Connectivity", 0_u32)? { + 1 => "No internet connection", + 2 => "Sign-in portal detected", + 3 => "Limited internet connectivity", + 4 => "Internet connected", + _ => "Internet access not checked", + } + .into(), + ..NetworkState::default() + }; + let active_paths = paths(root, "ActiveConnections")?; + let primary = path(root, "PrimaryConnection")?; + for active_path in &active_paths { + let Some(active) = properties(objects, active_path, ACTIVE) else { + continue; + }; // Object vanished during the snapshot. + if get(active, "State", 0_u32)? != 2 { + continue; + } + let name = clean_label(&text(active, "Id")?); + for (index, property) in ["Default", "Default6"].iter().enumerate() { + if get(active, property, false)? { + state.defaults[index].push(name.clone()); + } + } + if *active_path == primary { + state.primary = name; + state.primary_kind = text(active, "Type")?; + if let Some(device_path) = paths(active, "Devices")?.first() + && let Some(device) = properties(objects, device_path, DEVICE) + { + state.primary_interface = text(device, "IpInterface")?; + if state.primary_interface.is_empty() { + state.primary_interface = text(device, "Interface")?; + } + } + } + } + for device_path in paths(root, "Devices")? { + let Some(props) = properties(objects, &device_path, DEVICE) else { + continue; + }; + let Some(kind) = device_kind(get(props, "DeviceType", 0_u32)?) else { + continue; + }; + let interface = clean_label(&text(props, "Interface")?); + let managed = get(props, "Managed", false)?; + let status = get(props, "State", 0_u32)?; + let reason = get(props, "StateReason", (status, 0_u32))?.1; + let carrier = properties(objects, &device_path, WIRED) + .map(|p| get(p, "Carrier", false)) + .transpose()?; + let active_path = path(props, "ActiveConnection")?; + let active = properties(objects, &active_path, ACTIVE); + let profile_path = active + .map(|p| path(p, "Connection")) + .transpose()? + .filter(|p| p != "/"); + let available = paths(props, "AvailableConnections")?; + let mut device = NetworkDevice { + path: device_path.clone(), + interface, + kind, + state: status, + state_label: state_label(status, reason, managed, carrier), + managed, + carrier, + active: (active_path != "/").then_some(active_path), + profile: profile_path, + connection_name: active + .map(|p| text(p, "Id")) + .transpose()? + .map_or_else(String::new, |s| clean_label(&s)), + defaults: [ + active + .map(|p| get(p, "Default", false)) + .transpose()? + .unwrap_or(false), + active + .map(|p| get(p, "Default6", false)) + .transpose()? + .unwrap_or(false), + ], + addresses: Vec::new(), + profiles: profiles + .iter() + .filter(|p| available.contains(&p.path)) + .cloned() + .collect(), + networks: Vec::new(), + last_scan: -1, + }; + // Active profiles can temporarily disappear from AvailableConnections while disconnecting. + if let Some(profile) = profiles + .iter() + .find(|p| Some(&p.path) == device.profile.as_ref()) + && !device.profiles.iter().any(|p| p.path == profile.path) + { + device.profiles.push(profile.clone()); + } + for (key, interface) in [ + ("Ip4Config", "org.freedesktop.NetworkManager.IP4Config"), + ("Ip6Config", "org.freedesktop.NetworkManager.IP6Config"), + ] { + if let Some(ip) = properties(objects, &path(props, key)?, interface) { + for address in get::>(ip, "AddressData", Vec::new())? { + device.addresses.push(format!( + "{}/{}", + text(&address, "address")?, + get(&address, "prefix", 0_u32)? + )); + } + } + } + if let Some(wifi) = properties(objects, &device_path, WIRELESS) { + device.last_scan = get(wifi, "LastScan", -1_i64)?; + let active_ap = path(wifi, "ActiveAccessPoint")?; + for ap_path in paths(wifi, "AccessPoints")? { + let Some(ap) = properties(objects, &ap_path, AP) else { + continue; + }; + let ssid: Vec = get(ap, "Ssid", Vec::new())?; + let mut security = security(ap)?; + if ssid.is_empty() { + security = WifiSecurity::Unsupported; + } + let label = if ssid.is_empty() { + "Hidden network · Use editor".into() + } else { + clean_label(&String::from_utf8_lossy(&ssid)) + }; + let profile = device + .profiles + .iter() + .filter(|p| compatible(p, &ssid, security)) + .min_by_key(|p| (Some(&p.path) != device.profile.as_ref(), &p.name, &p.path)) + .map(|p| p.path.clone()); + let network = WifiNetwork { + path: ap_path.clone(), + ssid, + label, + strength: get(ap, "Strength", 0_u8)?, + security, + active: active_ap == ap_path, + profile, + }; + if let Some(existing) = device.networks.iter_mut().find(|n| { + !network.ssid.is_empty() + && n.ssid == network.ssid + && n.security == network.security + }) { + if (network.active, network.strength) > (existing.active, existing.strength) { + *existing = network; + } + } else { + device.networks.push(network); + } + } + device.networks.sort_by(|a, b| { + b.active + .cmp(&a.active) + .then_with(|| b.strength.cmp(&a.strength)) + .then_with(|| a.label.cmp(&b.label)) + .then_with(|| a.path.cmp(&b.path)) + }); + } + device + .profiles + .sort_by(|a, b| a.name.cmp(&b.name).then_with(|| a.path.cmp(&b.path))); + state.devices.push(device); + } + state.devices.sort_by(|a, b| a.interface.cmp(&b.interface)); + for defaults in &mut state.defaults { + defaults.sort(); + defaults.dedup(); + } + Ok(state) +} + +async fn read_snapshot(connection: &Connection) -> Result { + tokio::time::timeout(Duration::from_secs(8), read_snapshot_inner(connection)) + .await + .context("NetworkManager snapshot timed out")? +} + +async fn read_snapshot_inner(connection: &Connection) -> Result { + let manager = proxy( + connection, + "/org/freedesktop", + "org.freedesktop.DBus.ObjectManager", + ) + .await?; + let objects: HashMap = manager + .call("GetManagedObjects", &()) + .await + .context("could not read NetworkManager objects")?; + ensure!( + objects.len() <= MAX_OBJECTS, + "NetworkManager object limit exceeded" + ); + let objects: Objects = objects + .into_iter() + .map(|(p, v)| (p.to_string(), v)) + .collect(); + let mut saved = BTreeMap::new(); + let mut profiles = Vec::new(); + for (path, interfaces) in &objects { + if let Some(props) = interfaces.get(PROFILE) { + ensure!( + saved.len() < MAX_PROFILES, + "NetworkManager profile limit exceeded" + ); + let settings: Settings = proxy(connection, path, PROFILE) + .await? + .call("GetSettings", &()) + .await + .context("could not read saved network profile")?; + profiles.push(profile_model(path, &settings)?); + saved.insert( + path.clone(), + SavedProfile { + settings, + version: get(props, "VersionId", 0_u64)?, + }, + ); + } + } + let state = build_state(&objects, &profiles)?; + Ok(Snapshot { + state, + objects, + saved, + }) +} + +fn module_snapshot(settings: &NetworkModule, state: NetworkState) -> ModuleSnapshot { + let mut connection = state.primary.clone(); + let mut interface = state.primary_interface.clone(); + let mut device_kind = kind(&state.primary_kind); + let mut ssid = connection.clone(); + if let Some(device) = state.devices.iter().find(|d| { + settings + .interface + .as_ref() + .map_or(d.interface == interface, |i| d.interface == *i) + }) { + if settings.interface.is_some() { + connection = if device.state == 100 { + device.connection_name.clone() + } else { + String::new() + }; + interface.clone_from(&device.interface); + device_kind = Some(device.kind); + } + if let Some(ap) = device.networks.iter().find(|n| n.active) { + ssid.clone_from(&ap.label); + } else { + ssid.clone_from(&connection); + } + } else if settings.interface.is_some() { + connection.clear(); + } + let icon = match device_kind { + Some(NetworkKind::Wired) => "󰈀", + Some(NetworkKind::Wifi) => "", + Some(NetworkKind::Wwan) => "󰏲", + None => "󰖟", + }; + let values = BTreeMap::from([ + ("ssid", Value::from(ssid)), + ("connection", Value::from(connection.clone())), + ("interface", Value::from(interface)), + ("icon", Value::from(icon)), + ( + "kind", + Value::from(device_kind.map_or("Network", NetworkKind::label)), + ), + ]); + let mut snapshot = if connection.is_empty() { + ModuleSnapshot::text(settings.format_disconnected.clone()).with_state("disconnected") + } else { + ModuleSnapshot::text(expand(&settings.format_connected, &values)) + }; + snapshot.tooltip = Some(format!( + "{}\nIPv4 default: {}\nIPv6 default: {}", + state.connectivity, + if state.defaults[0].is_empty() { + "None".into() + } else { + state.defaults[0].join(", ") + }, + if state.defaults[1].is_empty() { + "None".into() + } else { + state.defaults[1].join(", ") + } + )); + snapshot.popup = Some(PopupModel { + title: "Network".into(), + content: PopupContent::Network(NetworkModel { + state, + ..NetworkModel::default() + }), + }); + snapshot +} + +pub fn spawn( + name: String, + settings: NetworkModule, + sender: Sender, + stop: Arc, +) -> (JoinHandle<()>, mpsc::Sender) { + let (actions, mut requests) = mpsc::channel(16); + let worker = thread::Builder::new().name(format!("lightbar-network-{name}")).spawn(move || { + let runtime = match tokio::runtime::Builder::new_current_thread().enable_all().build() { + Ok(runtime) => runtime, Err(error) => { tracing::error!(%error,"could not start network runtime"); return; } + }; + runtime.block_on(async { + let mut failures = 0; + while !stop.load(Ordering::Acquire) { + let result = async { + let connection = zbus::connection::Builder::system()?.method_timeout(CALL_TIMEOUT).build().await?; + monitor(&connection,&name,&settings,&sender,&stop,&mut requests).await + }.await; + if stop.load(Ordering::Acquire) || requests.is_closed() { break; } + if let Err(error) = result { tracing::warn!(module = %name, error = %format_args!("{error:#}"),"network monitor stopped; reconnecting"); } + let placeholder = match settings.common.unavailable { UnavailableBehavior::Hide => None, UnavailableBehavior::Placeholder => Some("Network unavailable") }; + if sender.send(ModuleEvent { module: name.clone(),snapshot: ModuleSnapshot::unavailable(placeholder) }).is_err() { break; } + while requests.try_recv().is_ok() { tracing::warn!("discarded network control after service disconnect"); } + failures = (failures+1).min(5); + let deadline = tokio::time::Instant::now()+Duration::from_secs(1 << failures); + loop { tokio::select! { + () = tokio::time::sleep_until(deadline) => break, + action = requests.recv() => { if action.is_none() { return; } tracing::warn!("discarded network control while service is unavailable"); } + } } + } + }); + }).expect("network worker thread"); + (worker, actions) +} + +fn publish( + name: &str, + settings: &NetworkModule, + state: &NetworkState, + sender: &Sender, + last: &mut Option, +) -> Result<()> { + let snapshot = module_snapshot(settings, state.clone()); + if last.as_ref() != Some(&snapshot) { + sender + .send(ModuleEvent { + module: name.into(), + snapshot: snapshot.clone(), + }) + .map_err(|_| anyhow::anyhow!("UI event channel closed"))?; + *last = Some(snapshot); + } + Ok(()) +} + +async fn monitor( + connection: &Connection, + name: &str, + settings: &NetworkModule, + sender: &Sender, + stop: &AtomicBool, + requests: &mut mpsc::Receiver, +) -> Result<()> { + let rule = MatchRule::builder() + .msg_type(Type::Signal) + .sender(NM)? + .path_namespace("/org/freedesktop")? + .build(); + let owner_rule = MatchRule::builder() + .msg_type(Type::Signal) + .sender("org.freedesktop.DBus")? + .interface("org.freedesktop.DBus")? + .member("NameOwnerChanged")? + .add_arg(NM)? + .build(); + let mut signals = MessageStream::for_match_rule(rule, connection, Some(256)).await?; + let mut owners = MessageStream::for_match_rule(owner_rule, connection, Some(8)).await?; + let mut snapshot = read_snapshot(connection).await?; + let mut last = None; + let mut refresh = None; + let mut scan: Option<(String, i64)> = None; + publish(name, settings, &snapshot.state, sender, &mut last)?; + while !stop.load(Ordering::Acquire) { + let deadline = + refresh.unwrap_or_else(|| tokio::time::Instant::now() + Duration::from_secs(86400)); + tokio::select! { + event = signals.next() => { + let event = event.context("NetworkManager event stream closed")??; + // Traffic counters do not alter connection state and must not cause redraws. + if event.header().interface().is_some_and(|i| i.as_str() == "org.freedesktop.DBus.Properties") { + let (interface,_,_): (String,Properties,Vec) = event.body().deserialize()?; + if interface.ends_with(".Statistics") { continue; } + } + refresh.get_or_insert_with(|| tokio::time::Instant::now()+Duration::from_millis(150)); + } + _ = owners.next() => bail!("NetworkManager service owner changed"), + () = tokio::time::sleep_until(deadline), if refresh.is_some() => { + refresh = None; + let mut next = read_snapshot(connection).await?; + next.state.notice = snapshot.state.notice.take(); + if let Some((device,last_scan)) = &scan + && next.state.devices.iter().any(|d| d.path == *device && d.last_scan != *last_scan) { + next.state.notice = Some("Wi-Fi scan complete.".into()); scan = None; + } + snapshot = next; + publish(name,settings,&snapshot.state,sender,&mut last)?; + } + action = requests.recv() => { + let Some(action) = action else { break; }; + snapshot.state.busy = true; + snapshot.state.notice = Some("Applying network control…".into()); + publish(name,settings,&snapshot.state,sender,&mut last)?; + let result = async { + let current = read_snapshot(connection).await?; + if let NetworkAction::Scan(path) = &action { + scan = current.state.devices.iter().find(|d| d.path == *path).map(|d| (path.clone(),d.last_scan)); + } + run_action(connection,action,¤t,stop).await + }.await; + if stop.load(Ordering::Acquire) { return Ok(()); } + snapshot = read_snapshot(connection).await?; + snapshot.state.notice = Some(match result { + Ok(message) => message, + Err(error) => { tracing::warn!(module = name,error = %format_args!("{error:#}"),"network control failed"); format!("Network control failed: {error:#}") } + }); + publish(name,settings,&snapshot.state,sender,&mut last)?; + } + } + } + Ok(()) +} + +#[allow(clippy::too_many_lines)] // Keep each D-Bus action and its validation together. +async fn run_action( + connection: &Connection, + action: NetworkAction, + snapshot: &Snapshot, + stop: &AtomicBool, +) -> Result { + ensure!(!stop.load(Ordering::Acquire), "network worker is stopping"); + let manager = proxy(connection, ROOT, NM).await?; + match action { + NetworkAction::Radio { wifi, enabled } => { + manager + .set_property( + if wifi { + "WirelessEnabled" + } else { + "WwanEnabled" + }, + enabled, + ) + .await + .context("radio change was denied")?; + Ok("Radio setting updated.".into()) + } + NetworkAction::Scan(path) => { + let device = current_device(snapshot, &path)?; + ensure!(device.kind == NetworkKind::Wifi, "device is not Wi-Fi"); + proxy(connection, &path, WIRELESS) + .await? + .call::<_, _, ()>("RequestScan", &(Properties::new(),)) + .await + .context("Wi-Fi scan was rejected")?; + Ok("Wi-Fi scan requested…".into()) + } + NetworkAction::Disconnect(path) => { + current_device(snapshot, &path)?; + proxy(connection, &path, DEVICE) + .await? + .call::<_, _, ()>("Disconnect", &()) + .await + .context("disconnect was rejected")?; + Ok("Disconnect requested.".into()) + } + NetworkAction::Connect { + device, + profile, + ap, + } => { + let target = current_device(snapshot, &device)?; + if profile == "/" { + ensure!( + target.kind == NetworkKind::Wired, + "choose a saved connection first" + ); + let _: (OwnedObjectPath, OwnedObjectPath) = manager + .call( + "AddAndActivateConnection", + &( + Settings::new(), + OwnedObjectPath::try_from(device.as_str())?, + OwnedObjectPath::try_from("/")?, + ), + ) + .await + .context("wired connection failed")?; + } else { + ensure!( + target.profiles.iter().any(|p| p.path == profile), + "saved connection is no longer available on this device" + ); + let _: OwnedObjectPath = manager + .call( + "ActivateConnection", + &( + OwnedObjectPath::try_from(profile.as_str())?, + OwnedObjectPath::try_from(device.as_str())?, + OwnedObjectPath::try_from(ap.as_str())?, + ), + ) + .await + .context( + "connection activation failed; check authorization and saved secrets", + )?; + } + Ok("Connection request sent.".into()) + } + NetworkAction::Join { + device, + ap, + password, + } => { + let target = current_device(snapshot, &device)?; + let network = target + .networks + .iter() + .find(|n| n.path == ap) + .context("Wi-Fi network disappeared; scan again")?; + let settings = wifi_settings(network, password.text())?; + // Credentials travel only over the system bus, never argv, logs, or bar configuration. + let _: (OwnedObjectPath, OwnedObjectPath) = manager + .call( + "AddAndActivateConnection", + &( + settings, + OwnedObjectPath::try_from(device.as_str())?, + OwnedObjectPath::try_from(ap.as_str())?, + ), + ) + .await + .context("Wi-Fi connection activation failed")?; + Ok("Wi-Fi connection request sent.".into()) + } + NetworkAction::Prefer(profile) => { + prefer(connection, snapshot, &profile, stop).await?; + Ok("Internet preference saved. Default indicators follow NetworkManager; VPN routes keep their policy.".into()) + } + } +} +fn current_device<'a>(snapshot: &'a Snapshot, path: &str) -> Result<&'a NetworkDevice> { + let device = snapshot + .state + .devices + .iter() + .find(|d| d.path == path) + .context("network device disappeared")?; + ensure!(device.managed, "device is not managed by NetworkManager"); + Ok(device) +} +fn wifi_settings(network: &WifiNetwork, password: &str) -> Result { + ensure!( + !network.ssid.is_empty(), + "configure hidden networks in the connection editor" + ); + let mut settings = Settings::from([( + "802-11-wireless".into(), + Properties::from([( + "ssid".into(), + BusValue::from(network.ssid.clone()).try_to_owned()?, + )]), + )]); + let key = match network.security { + WifiSecurity::Open => return Ok(settings), + WifiSecurity::Owe => "owe", + WifiSecurity::Personal | WifiSecurity::Sae => { + ensure!( + network.security.password_valid(password), + "invalid Wi-Fi password length or format" + ); + if network.security == WifiSecurity::Sae { + "sae" + } else { + "wpa-psk" + } + } + _ => bail!("configure this Wi-Fi security type in the connection editor"), + }; + let mut security = Properties::from([("key-mgmt".into(), string(key))]); + if key != "owe" { + security.insert("psk".into(), string(password)); + } + settings.insert("802-11-wireless-security".into(), security); + Ok(settings) +} + +#[derive(Debug, Clone, PartialEq)] +struct RouteEdit { + path: String, + metrics: [Option; 2], +} +fn enabled_ip(settings: &Settings, family: &str) -> Result { + let Some(ip) = settings.get(family) else { + return Ok(false); + }; + Ok(!matches!( + text(ip, "method")?.as_str(), + "disabled" | "ignore" | "link-local" | "shared" + ) && !get(ip, "never-default", false)?) +} +fn validate_routes(settings: &Settings, family: &str) -> Result<()> { + let ip = settings.get(family).context("IP settings missing")?; + ensure!( + matches!(get(ip, "route-table", 0_u32)?, 0 | 254), + "custom routing tables require the connection editor" + ); + ensure!( + get::>(ip, "routing-rules", Vec::new())?.is_empty(), + "policy routing requires the connection editor" + ); + for route in get::>(ip, "route-data", Vec::new())? { + ensure!( + get(&route, "prefix", 32_u32)? != 0, + "explicit default routes require the connection editor" + ); + } + Ok(()) +} +fn route_plan(snapshot: &Snapshot, selected: &str) -> Result> { + let target = snapshot + .state + .devices + .iter() + .find(|d| d.profile.as_deref() == Some(selected) && d.state == 100 && d.managed) + .context("connect this network before choosing its internet priority")?; + let selected_settings = &snapshot + .saved + .get(selected) + .context("selected profile disappeared")? + .settings; + let mut chosen = RouteEdit { + path: selected.into(), + metrics: [None, None], + }; + for (i, family) in ["ipv4", "ipv6"].iter().enumerate() { + if enabled_ip(selected_settings, family)? { + validate_routes(selected_settings, family)?; + chosen.metrics[i] = Some(1); + } + } + ensure!( + chosen.metrics.iter().any(Option::is_some), + "this profile cannot provide a default route; check IP settings in the editor" + ); + let mut plan = Vec::new(); + for (path, saved) in &snapshot.saved { + if path == selected { + continue; + } + let profile = profile_model(path, &saved.settings)?; + if profile.kind.is_none() { + continue; + } // Never change VPNs, tunnels, bridges, or virtual routing. + let mut edit = RouteEdit { + path: path.clone(), + metrics: [None, None], + }; + for (i, family) in ["ipv4", "ipv6"].iter().enumerate() { + if chosen.metrics[i].is_none() || !enabled_ip(&saved.settings, family)? { + continue; + } + let metric = profile.metrics[i]; + let mut conflicts = metric == 1 || (i == 0 && metric == 0); + // Automatic metrics may be overridden by system policy. Inspect active default routes too. + for device in snapshot + .state + .devices + .iter() + .filter(|d| d.profile.as_ref() == Some(path)) + { + let props = properties(&snapshot.objects, &device.path, DEVICE) + .context("device vanished")?; + let (key, interface) = if i == 0 { + ("Ip4Config", "org.freedesktop.NetworkManager.IP4Config") + } else { + ("Ip6Config", "org.freedesktop.NetworkManager.IP6Config") + }; + if let Some(ip) = properties(&snapshot.objects, &self::path(props, key)?, interface) + { + for route in get::>(ip, "RouteData", Vec::new())? { + if get(&route, "prefix", 32_u32)? == 0 + && get(&route, "metric", u32::MAX)? <= 1 + { + conflicts = true; + } + } + } + } + if conflicts { + validate_routes(&saved.settings, family)?; + edit.metrics[i] = Some(2); + } + } + if edit.metrics.iter().any(Option::is_some) { + plan.push(edit); + } + } + // Apply the chosen connection last, after ties are removed. + plan.push(chosen); + ensure!( + plan.len() <= 32, + "too many conflicting profiles; set priorities in the connection editor" + ); + let _ = target; + Ok(plan) +} +fn set_metrics(settings: &mut Settings, metrics: &[Option; 2]) -> Result<()> { + for (i, family) in ["ipv4", "ipv6"].iter().enumerate() { + if let Some(metric) = metrics[i] { + settings + .get_mut(*family) + .context("IP settings disappeared")? + .insert("route-metric".into(), metric.into()); + } + } + Ok(()) +} +async fn update_profile( + connection: &Connection, + path: &str, + settings: &Settings, + version: u64, +) -> Result<()> { + let args = Properties::from([("version-id".into(), version.into())]); + // GetSettings excludes secrets; NetworkManager preserves its existing secrets when + // Update2 receives a settings map without any secrets (NM update_auth_cb contract). + let _: Properties = proxy(connection, path, PROFILE) + .await? + .call("Update2", &(settings, 1_u32, args)) + .await + .context("could not save network priority")?; + Ok(()) +} + +async fn prefer( + connection: &Connection, + snapshot: &Snapshot, + selected: &str, + stop: &AtomicBool, +) -> Result<()> { + let plan = route_plan(snapshot, selected)?; + let mut saved_attempts = Vec::new(); + let mut applied_attempts: Vec<(String, Settings, [Option; 2])> = Vec::new(); + let result: Result<()> = async { + for edit in &plan { + ensure!( + !stop.load(Ordering::Acquire), + "network priority update interrupted" + ); + let saved = &snapshot.saved[&edit.path]; + let mut settings = clone_settings(&saved.settings)?; + set_metrics(&mut settings, &edit.metrics)?; + saved_attempts.push(edit); + update_profile(connection, &edit.path, &settings, saved.version).await?; + for device in snapshot + .state + .devices + .iter() + .filter(|d| d.profile.as_ref() == Some(&edit.path) && d.state == 100) + { + ensure!( + !stop.load(Ordering::Acquire), + "network priority update interrupted" + ); + let proxy = proxy(connection, &device.path, DEVICE).await?; + let (original, version): (Settings, u64) = + proxy.call("GetAppliedConnection", &(0_u32,)).await?; + ensure!( + profile_model("/", &original)?.uuid + == profile_model(&edit.path, &saved.settings)?.uuid, + "active connection changed while setting priority" + ); + for (i, family) in ["ipv4", "ipv6"].iter().enumerate() { + if edit.metrics[i].is_some() { + validate_routes(&original, family)?; + } + } + let mut applied = clone_settings(&original)?; + set_metrics(&mut applied, &edit.metrics)?; + applied_attempts.push((device.path.clone(), original, edit.metrics)); + proxy + .call::<_, _, ()>("Reapply", &(applied, version, 1_u32)) + .await + .context("could not apply route priority without reconnecting")?; + } + } + Ok(()) + } + .await; + if let Err(error) = result { + let mut rollback_errors = Vec::new(); + for (device, original, metrics) in applied_attempts.into_iter().rev() { + let restore: Result<()> = async { + let proxy = proxy(connection, &device, DEVICE).await?; + let (mut current, version): (Settings, u64) = + proxy.call("GetAppliedConnection", &(0_u32,)).await?; + let before = clone_settings(¤t)?; + restore_metrics(&mut current, &original, &metrics)?; + if current != before { + proxy + .call::<_, _, ()>("Reapply", &(current, version, 1_u32)) + .await?; + } + Ok(()) + } + .await; + if let Err(error) = restore { + rollback_errors.push(format!("active priority: {error:#}")); + } + } + for edit in saved_attempts.into_iter().rev() { + let restore: Result<()> = async { + let proxy = proxy(connection, &edit.path, PROFILE).await?; + let version: u64 = proxy.get_property("VersionId").await?; + let mut current: Settings = proxy.call("GetSettings", &()).await?; + let before = clone_settings(¤t)?; + restore_metrics( + &mut current, + &snapshot.saved[&edit.path].settings, + &edit.metrics, + )?; + if current != before { + update_profile(connection, &edit.path, ¤t, version).await?; + } + Ok(()) + } + .await; + if let Err(error) = restore { + rollback_errors.push(format!("saved priority: {error:#}")); + } + } + if rollback_errors.is_empty() { + bail!("{error:#}; previous route priorities restored"); + } + bail!( + "{error:#}; some priorities could not be restored: {}. Review them in the connection editor", + rollback_errors.join("; ") + ); + } + Ok(()) +} +fn restore_metrics( + current: &mut Settings, + original: &Settings, + expected: &[Option; 2], +) -> Result<()> { + ensure!( + profile_model("/", current)?.uuid == profile_model("/", original)?.uuid, + "connection changed; refusing to overwrite new settings" + ); + for (i, family) in ["ipv4", "ipv6"].iter().enumerate() { + if let Some(expected) = expected[i] { + let now = current + .get_mut(*family) + .context("IP settings removed during priority update")?; + let before = original + .get(*family) + .context("original IP settings missing")?; + let old_metric = get(before, "route-metric", -1_i64)?; + let current_metric = get(now, "route-metric", -1_i64)?; + ensure!( + current_metric == expected || current_metric == old_metric, + "priority changed externally; refusing to overwrite it" + ); + if let Some(value) = before.get("route-metric") { + now.insert("route-metric".into(), value.try_clone()?); + } else { + now.remove("route-metric"); + } + } + } + Ok(()) +} + +#[cfg(test)] +mod tests; diff --git a/src/modules/network/tests.rs b/src/modules/network/tests.rs new file mode 100644 index 0000000..1a59cce --- /dev/null +++ b/src/modules/network/tests.rs @@ -0,0 +1,984 @@ +#![allow(clippy::needless_pass_by_value)] // D-Bus mock methods own deserialized arguments. +use super::*; +use std::{ + io::{BufRead, BufReader}, + process::{Child, Command, Stdio}, + sync::Mutex, +}; + +const WIFI: &str = "/org/freedesktop/NetworkManager/Devices/2"; +const MOBILE: &str = "/org/freedesktop/NetworkManager/Devices/3"; +const ETHERNET: &str = "/org/freedesktop/NetworkManager/Devices/1"; +const WIFI_PROFILE: &str = "/org/freedesktop/NetworkManager/Settings/2"; +const MOBILE_PROFILE: &str = "/org/freedesktop/NetworkManager/Settings/3"; +const WIFI_ACTIVE: &str = "/org/freedesktop/NetworkManager/ActiveConnection/2"; +const MOBILE_ACTIVE: &str = "/org/freedesktop/NetworkManager/ActiveConnection/3"; +const ACCESS_POINT: &str = "/org/freedesktop/NetworkManager/AccessPoint/1"; + +fn object_path(path: &str) -> OwnedValue { + BusValue::from(OwnedObjectPath::try_from(path).unwrap()) + .try_to_owned() + .unwrap() +} +fn array(value: Vec) -> OwnedValue +where + Vec: Into>, +{ + value.into().try_to_owned().unwrap() +} +fn object_paths(paths: &[&str]) -> OwnedValue { + array( + paths + .iter() + .map(|p| OwnedObjectPath::try_from(*p).unwrap()) + .collect(), + ) +} +fn profile(name: &str, uuid: &str, connection_type: &str, metric: i64) -> Settings { + let mut settings = Settings::from([ + ( + "connection".into(), + Properties::from([ + ("id".into(), string(name)), + ("uuid".into(), string(uuid)), + ("type".into(), string(connection_type)), + ]), + ), + ( + "ipv4".into(), + Properties::from([ + ("method".into(), string("auto")), + ("route-metric".into(), metric.into()), + ]), + ), + ( + "ipv6".into(), + Properties::from([ + ("method".into(), string("auto")), + ("route-metric".into(), metric.into()), + ]), + ), + ]); + if connection_type == "802-11-wireless" { + settings.insert( + "802-11-wireless".into(), + Properties::from([("ssid".into(), array(b"Cafe: Guest".to_vec()))]), + ); + settings.insert( + "802-11-wireless-security".into(), + Properties::from([("key-mgmt".into(), string("wpa-psk"))]), + ); + } + settings +} +#[allow(clippy::too_many_lines)] +fn fixture() -> Snapshot { + let mut objects = Objects::new(); + objects.insert( + ROOT.into(), + Settings::from([( + NM.into(), + Properties::from([ + ("Devices".into(), object_paths(&[ETHERNET, WIFI, MOBILE])), + ( + "ActiveConnections".into(), + object_paths(&[WIFI_ACTIVE, MOBILE_ACTIVE]), + ), + ("PrimaryConnection".into(), object_path(MOBILE_ACTIVE)), + ("Connectivity".into(), 4_u32.into()), + ("WirelessEnabled".into(), true.into()), + ("WirelessHardwareEnabled".into(), true.into()), + ("WwanEnabled".into(), true.into()), + ("WwanHardwareEnabled".into(), true.into()), + ]), + )]), + ); + for (path, profile, name, ty, device, default, default6) in [ + ( + WIFI_ACTIVE, + WIFI_PROFILE, + "Cafe: Guest", + "802-11-wireless", + WIFI, + false, + true, + ), + ( + MOBILE_ACTIVE, + MOBILE_PROFILE, + "Mobile plan", + "gsm", + MOBILE, + true, + false, + ), + ] { + objects.insert( + path.into(), + Settings::from([( + ACTIVE.into(), + Properties::from([ + ("Id".into(), string(name)), + ("Type".into(), string(ty)), + ("State".into(), 2_u32.into()), + ("Connection".into(), object_path(profile)), + ("Devices".into(), object_paths(&[device])), + ("Default".into(), default.into()), + ("Default6".into(), default6.into()), + ]), + )]), + ); + } + for (device, interface, ty, status, active, profile) in [ + (ETHERNET, "lan0", 1_u32, 20_u32, "/", "/"), + (WIFI, "wifi0", 2, 100, WIFI_ACTIVE, WIFI_PROFILE), + (MOBILE, "cell0", 8, 100, MOBILE_ACTIVE, MOBILE_PROFILE), + ] { + objects.insert( + device.into(), + Settings::from([( + DEVICE.into(), + Properties::from([ + ("Interface".into(), string(interface)), + ("IpInterface".into(), string(interface)), + ("DeviceType".into(), ty.into()), + ("State".into(), status.into()), + ("Managed".into(), true.into()), + ("ActiveConnection".into(), object_path(active)), + ( + "AvailableConnections".into(), + if profile == "/" { + object_paths(&[]) + } else { + object_paths(&[profile]) + }, + ), + ]), + )]), + ); + } + objects.get_mut(ETHERNET).unwrap().insert( + WIRED.into(), + Properties::from([("Carrier".into(), false.into())]), + ); + objects.get_mut(WIFI).unwrap().insert( + WIRELESS.into(), + Properties::from([ + ("LastScan".into(), 100_i64.into()), + ("ActiveAccessPoint".into(), object_path(ACCESS_POINT)), + ("AccessPoints".into(), object_paths(&[ACCESS_POINT])), + ]), + ); + objects.insert( + ACCESS_POINT.into(), + Settings::from([( + AP.into(), + Properties::from([ + ("Ssid".into(), array(b"Cafe: Guest".to_vec())), + ("Strength".into(), 70_u8.into()), + ("Flags".into(), 1_u32.into()), + ("RsnFlags".into(), 0x100_u32.into()), + ]), + )]), + ); + let saved: BTreeMap = BTreeMap::from([ + ( + WIFI_PROFILE.into(), + SavedProfile { + settings: profile("Cafe: Guest", "wifi-uuid", "802-11-wireless", 600), + version: 1, + }, + ), + ( + MOBILE_PROFILE.into(), + SavedProfile { + settings: profile("Mobile plan", "mobile-uuid", "gsm", 100), + version: 1, + }, + ), + ]); + for path in saved.keys() { + objects.insert( + path.clone(), + Settings::from([( + PROFILE.into(), + Properties::from([("VersionId".into(), 1_u64.into())]), + )]), + ); + } + let profiles: Vec<_> = saved + .iter() + .map(|(p, s)| profile_model(p, &s.settings).unwrap()) + .collect(); + let state = build_state(&objects, &profiles).unwrap(); + Snapshot { + state, + objects, + saved, + } +} + +#[test] +fn reports_actual_mobile_default_and_separate_ipv6_wifi_default() { + let snapshot = fixture(); + assert_eq!( + snapshot.state.defaults, + [ + vec!["Mobile plan".to_string()], + vec!["Cafe: Guest".to_string()] + ] + ); + let view = module_snapshot( + &NetworkModule { + format_connected: "{kind}: {connection} on {interface}".into(), + ..NetworkModule::default() + }, + snapshot.state, + ); + assert_eq!( + view.segments[0].text, + "Mobile broadband: Mobile plan on cell0" + ); + let PopupContent::Network(popup) = view.popup.unwrap().content else { + panic!() + }; + assert_eq!(popup.state.devices.len(), 3); + assert!( + popup + .state + .devices + .iter() + .any(|d| d.state_label == "Cable unplugged") + ); + assert_eq!( + popup + .state + .devices + .iter() + .find(|d| d.kind == NetworkKind::Wifi) + .unwrap() + .networks[0] + .label, + "Cafe: Guest" + ); +} + +#[test] +fn disconnected_state_still_exposes_connection_controls_and_clears_defaults() { + let mut snapshot = fixture(); + let root = snapshot.objects.get_mut(ROOT).unwrap().get_mut(NM).unwrap(); + root.insert("ActiveConnections".into(), object_paths(&[])); + root.insert("PrimaryConnection".into(), object_path("/")); + let state = build_state(&snapshot.objects, &[]).unwrap(); + assert!(state.primary.is_empty()); + assert!(state.defaults.iter().all(Vec::is_empty)); + let view = module_snapshot(&NetworkModule::default(), state); + assert!(view.popup.is_some()); + assert_eq!(view.segments[0].state, "disconnected"); +} + +#[test] +fn deduplicates_same_ssid_and_security_but_keeps_active_access_point() { + let mut snapshot = fixture(); + let other = "/org/freedesktop/NetworkManager/AccessPoint/2"; + let mut ap = clone_settings(&snapshot.objects[ACCESS_POINT]).unwrap(); + ap.get_mut(AP) + .unwrap() + .insert("Strength".into(), 99_u8.into()); + snapshot.objects.insert(other.into(), ap); + snapshot + .objects + .get_mut(WIFI) + .unwrap() + .get_mut(WIRELESS) + .unwrap() + .insert("AccessPoints".into(), object_paths(&[other, ACCESS_POINT])); + let state = build_state(&snapshot.objects, &[]).unwrap(); + let networks = &state + .devices + .iter() + .find(|d| d.path == WIFI) + .unwrap() + .networks; + assert_eq!(networks.len(), 1); + assert_eq!(networks[0].path, ACCESS_POINT); +} + +#[test] +fn preference_uses_saved_metrics_and_preserves_vpn_and_never_default() { + let mut snapshot = fixture(); + snapshot + .saved + .get_mut(MOBILE_PROFILE) + .unwrap() + .settings + .get_mut("ipv4") + .unwrap() + .insert("route-metric".into(), 1_i64.into()); + snapshot + .saved + .get_mut(MOBILE_PROFILE) + .unwrap() + .settings + .get_mut("ipv6") + .unwrap() + .insert("never-default".into(), true.into()); + snapshot.saved.insert( + "/vpn".into(), + SavedProfile { + settings: profile("VPN", "vpn-uuid", "vpn", 0), + version: 1, + }, + ); + let plan = route_plan(&snapshot, WIFI_PROFILE).unwrap(); + assert_eq!( + plan, + vec![ + RouteEdit { + path: MOBILE_PROFILE.into(), + metrics: [Some(2), None] + }, + RouteEdit { + path: WIFI_PROFILE.into(), + metrics: [Some(1), Some(1)] + } + ] + ); + let mut settings = clone_settings(&snapshot.saved[WIFI_PROFILE].settings).unwrap(); + let original_security = clone_settings(&settings) + .unwrap() + .remove("802-11-wireless-security") + .unwrap(); + set_metrics(&mut settings, &plan[1].metrics).unwrap(); + assert_eq!(settings["802-11-wireless-security"], original_security); + assert_eq!(text(&settings["connection"], "id").unwrap(), "Cafe: Guest"); +} + +#[test] +fn refuses_custom_route_tables_and_explicit_default_routes_before_any_write() { + let mut snapshot = fixture(); + let ip = snapshot + .saved + .get_mut(WIFI_PROFILE) + .unwrap() + .settings + .get_mut("ipv4") + .unwrap(); + ip.insert("route-table".into(), 200_u32.into()); + assert!( + route_plan(&snapshot, WIFI_PROFILE) + .unwrap_err() + .to_string() + .contains("routing tables") + ); + let ip = snapshot + .saved + .get_mut(WIFI_PROFILE) + .unwrap() + .settings + .get_mut("ipv4") + .unwrap(); + ip.remove("route-table"); + ip.insert( + "route-data".into(), + array(vec![Properties::from([ + ("dest".into(), string("0.0.0.0")), + ("prefix".into(), 0_u32.into()), + ("metric".into(), 100_u32.into()), + ])]), + ); + assert!( + route_plan(&snapshot, WIFI_PROFILE) + .unwrap_err() + .to_string() + .contains("explicit default routes") + ); +} + +#[test] +fn rollback_changes_only_metrics_and_does_not_overwrite_external_edits() { + let old = profile("Wi-Fi", "uuid", "802-11-wireless", 600); + let mut current = clone_settings(&old).unwrap(); + set_metrics(&mut current, &[Some(1), Some(1)]).unwrap(); + current + .get_mut("connection") + .unwrap() + .insert("id".into(), string("Externally renamed")); + restore_metrics(&mut current, &old, &[Some(1), Some(1)]).unwrap(); + assert_eq!(profile_model("/", ¤t).unwrap().metrics, [600, 600]); + assert_eq!( + text(¤t["connection"], "id").unwrap(), + "Externally renamed" + ); + set_metrics(&mut current, &[Some(42), Some(42)]).unwrap(); + assert!(restore_metrics(&mut current, &old, &[Some(1), Some(1)]).is_err()); +} + +#[test] +fn wifi_credentials_preserve_raw_ssid_and_never_appear_in_debug_output() { + let snapshot = fixture(); + let mut network = snapshot + .state + .devices + .iter() + .find(|d| d.path == WIFI) + .unwrap() + .networks[0] + .clone(); + network.ssid = vec![0xff, b':', b'\\']; + let mut secret = crate::model::NetworkSecret::default(); + secret.push("example-password"); + let settings = wifi_settings(&network, secret.text()).unwrap(); + assert_eq!( + get::>(&settings["802-11-wireless"], "ssid", Vec::new()).unwrap(), + network.ssid + ); + assert!( + !format!( + "{:?}", + NetworkAction::Join { + device: WIFI.into(), + ap: ACCESS_POINT.into(), + password: secret + } + ) + .contains("example-password") + ); + assert!(wifi_settings(&network, "short").is_err()); + network.security = WifiSecurity::Enterprise; + assert!(wifi_settings(&network, "example-password").is_err()); +} + +struct TestBus(Child, String); +impl TestBus { + fn start() -> Self { + let mut child = Command::new("dbus-daemon") + .args(["--session", "--nofork", "--nopidfile", "--print-address=1"]) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::inherit()) + .spawn() + .expect("test requires dbus-daemon"); + let mut address = String::new(); + BufReader::new(child.stdout.take().unwrap()) + .read_line(&mut address) + .unwrap(); + assert!(!address.trim().is_empty()); + Self(child, address.trim().into()) + } +} +impl Drop for TestBus { + fn drop(&mut self) { + if self.0.try_wait().expect("inspect test bus").is_none() { + self.0.kill().expect("stop test bus"); + } + self.0.wait().expect("reap test bus"); + } +} +struct FakeState { + snapshot: Snapshot, + calls: Vec, + fail_reapply: bool, + applied: HashMap, + scans: u32, +} +type Shared = Arc>; +struct FakeObjects(Shared); +#[zbus::interface(name = "org.freedesktop.DBus.ObjectManager")] +impl FakeObjects { + fn get_managed_objects(&self) -> HashMap { + self.0 + .lock() + .unwrap() + .snapshot + .objects + .iter() + .map(|(p, v)| { + ( + OwnedObjectPath::try_from(p.as_str()).unwrap(), + clone_settings(v).unwrap(), + ) + }) + .collect() + } +} +struct FakeProfile { + shared: Shared, + path: String, +} +#[zbus::interface(name = "org.freedesktop.NetworkManager.Settings.Connection")] +impl FakeProfile { + fn get_settings(&self) -> Settings { + clone_settings(&self.shared.lock().unwrap().snapshot.saved[&self.path].settings).unwrap() + } + #[zbus(property)] + fn version_id(&self) -> u64 { + self.shared.lock().unwrap().snapshot.saved[&self.path].version + } + fn update2( + &self, + settings: Settings, + flags: u32, + args: Properties, + ) -> zbus::fdo::Result { + assert_eq!(flags, 1); + let mut shared = self.shared.lock().unwrap(); + let saved = shared.snapshot.saved.get_mut(&self.path).unwrap(); + if get(&args, "version-id", 0_u64).unwrap() != saved.version { + return Err(zbus::fdo::Error::Failed("profile changed".into())); + } + assert!(!settings.values().any(|s| s.contains_key("psk"))); + saved.settings = settings; + saved.version += 1; + let version = saved.version; + shared + .snapshot + .objects + .get_mut(&self.path) + .unwrap() + .get_mut(PROFILE) + .unwrap() + .insert("VersionId".into(), version.into()); + shared.calls.push(format!("save:{}", self.path)); + Ok(Properties::new()) + } +} +struct FakeDevice { + shared: Shared, + path: String, +} +#[zbus::interface(name = "org.freedesktop.NetworkManager.Device")] +impl FakeDevice { + fn get_applied_connection(&self, flags: u32) -> (Settings, u64) { + assert_eq!(flags, 0); + ( + clone_settings(&self.shared.lock().unwrap().applied[&self.path]).unwrap(), + 1, + ) + } + fn reapply(&self, settings: Settings, version: u64, flags: u32) -> zbus::fdo::Result<()> { + assert_eq!(version, 1); + assert_eq!(flags, 1); + let mut shared = self.shared.lock().unwrap(); + if shared.fail_reapply { + shared.fail_reapply = false; + return Err(zbus::fdo::Error::Failed("simulated reapply failure".into())); + } + shared.applied.insert(self.path.clone(), settings); + shared.calls.push(format!("apply:{}", self.path)); + Ok(()) + } + fn disconnect(&self) { + self.shared + .lock() + .unwrap() + .calls + .push(format!("disconnect:{}", self.path)); + } +} +struct FakeWireless(Shared); +#[zbus::interface(name = "org.freedesktop.NetworkManager.Device.Wireless")] +impl FakeWireless { + fn request_scan(&self, options: Properties) { + assert!(options.is_empty()); + self.0.lock().unwrap().scans += 1; + } +} +struct FakeManager(Shared); +#[zbus::interface(name = "org.freedesktop.NetworkManager")] +impl FakeManager { + fn activate_connection( + &self, + profile: OwnedObjectPath, + device: OwnedObjectPath, + ap: OwnedObjectPath, + ) -> OwnedObjectPath { + self.0 + .lock() + .unwrap() + .calls + .push(format!("connect:{profile}:{device}:{ap}")); + OwnedObjectPath::try_from(WIFI_ACTIVE).unwrap() + } + fn add_and_activate_connection( + &self, + settings: Settings, + device: OwnedObjectPath, + ap: OwnedObjectPath, + ) -> (OwnedObjectPath, OwnedObjectPath) { + assert_eq!(device.as_str(), WIFI); + assert_eq!(ap.as_str(), ACCESS_POINT); + assert_eq!( + text(&settings["802-11-wireless-security"], "psk").unwrap(), + "example-password" + ); + self.0.lock().unwrap().calls.push("join".into()); + ( + OwnedObjectPath::try_from(WIFI_PROFILE).unwrap(), + OwnedObjectPath::try_from(WIFI_ACTIVE).unwrap(), + ) + } + #[zbus(property)] + fn wireless_enabled(&self) -> bool { + get( + &self.0.lock().unwrap().snapshot.objects[ROOT][NM], + "WirelessEnabled", + false, + ) + .unwrap() + } + #[zbus(property)] + fn set_wireless_enabled(&mut self, enabled: bool) { + self.0 + .lock() + .unwrap() + .snapshot + .objects + .get_mut(ROOT) + .unwrap() + .get_mut(NM) + .unwrap() + .insert("WirelessEnabled".into(), enabled.into()); + } +} + +async fn fake_service(bus: &TestBus) -> (Connection, Connection, Shared) { + let fixture = fixture(); + let applied = HashMap::from([ + ( + WIFI.into(), + clone_settings(&fixture.saved[WIFI_PROFILE].settings).unwrap(), + ), + ( + MOBILE.into(), + clone_settings(&fixture.saved[MOBILE_PROFILE].settings).unwrap(), + ), + ]); + let shared = Arc::new(Mutex::new(FakeState { + snapshot: fixture, + calls: Vec::new(), + fail_reapply: false, + applied, + scans: 0, + })); + let builder = zbus::connection::Builder::address(bus.1.as_str()) + .unwrap() + .name(NM) + .unwrap() + .serve_at("/org/freedesktop", FakeObjects(shared.clone())) + .unwrap() + .serve_at(ROOT, FakeManager(shared.clone())) + .unwrap() + .serve_at( + WIFI_PROFILE, + FakeProfile { + shared: shared.clone(), + path: WIFI_PROFILE.into(), + }, + ) + .unwrap() + .serve_at( + MOBILE_PROFILE, + FakeProfile { + shared: shared.clone(), + path: MOBILE_PROFILE.into(), + }, + ) + .unwrap() + .serve_at( + WIFI, + FakeDevice { + shared: shared.clone(), + path: WIFI.into(), + }, + ) + .unwrap() + .serve_at( + MOBILE, + FakeDevice { + shared: shared.clone(), + path: MOBILE.into(), + }, + ) + .unwrap() + .serve_at(WIFI, FakeWireless(shared.clone())) + .unwrap(); + let service = builder.build().await.unwrap(); + let client = zbus::connection::Builder::address(bus.1.as_str()) + .unwrap() + .method_timeout(Duration::from_secs(2)) + .build() + .await + .unwrap(); + (service, client, shared) +} + +#[tokio::test(flavor = "current_thread")] +async fn dbus_controls_scan_connect_disconnect_and_save_live_priority() { + let bus = TestBus::start(); + let (_service, client, shared) = fake_service(&bus).await; + let snapshot = read_snapshot(&client).await.unwrap(); + let stop = AtomicBool::new(false); + for action in [ + NetworkAction::Scan(WIFI.into()), + NetworkAction::Connect { + device: WIFI.into(), + profile: WIFI_PROFILE.into(), + ap: ACCESS_POINT.into(), + }, + NetworkAction::Disconnect(WIFI.into()), + NetworkAction::Radio { + wifi: true, + enabled: false, + }, + NetworkAction::Prefer(WIFI_PROFILE.into()), + ] { + run_action(&client, action, &snapshot, &stop).await.unwrap(); + } + let state = shared.lock().unwrap(); + assert_eq!(state.scans, 1); + assert!(state.calls.iter().any(|c| c.starts_with("connect:"))); + assert!(state.calls.iter().any(|c| c.starts_with("disconnect:"))); + assert_eq!( + profile_model("/", &state.snapshot.saved[WIFI_PROFILE].settings) + .unwrap() + .metrics, + [1, 1] + ); + assert_eq!( + profile_model("/", &state.applied[WIFI]).unwrap().metrics, + [1, 1] + ); + assert_eq!( + profile_model("/", &state.snapshot.saved[MOBILE_PROFILE].settings) + .unwrap() + .metrics, + [100, 100] + ); + assert!(!get(&state.snapshot.objects[ROOT][NM], "WirelessEnabled", true).unwrap()); +} + +#[tokio::test(flavor = "current_thread")] +async fn dbus_failed_reapply_restores_saved_and_active_priorities() { + let bus = TestBus::start(); + let (_service, client, shared) = fake_service(&bus).await; + shared.lock().unwrap().fail_reapply = true; + let snapshot = read_snapshot(&client).await.unwrap(); + let error = prefer(&client, &snapshot, WIFI_PROFILE, &AtomicBool::new(false)) + .await + .unwrap_err(); + assert!( + error + .to_string() + .contains("previous route priorities restored"), + "{error:#}" + ); + let shared = shared.lock().unwrap(); + assert_eq!( + profile_model("/", &shared.snapshot.saved[WIFI_PROFILE].settings) + .unwrap() + .metrics, + [600, 600] + ); + assert_eq!( + profile_model("/", &shared.applied[WIFI]).unwrap().metrics, + [600, 600] + ); +} + +#[tokio::test(flavor = "current_thread")] +async fn dbus_new_wifi_password_is_passed_to_networkmanager_without_a_process() { + let bus = TestBus::start(); + let (_service, client, shared) = fake_service(&bus).await; + let snapshot = read_snapshot(&client).await.unwrap(); + let mut password = crate::model::NetworkSecret::default(); + password.push("example-password"); + run_action( + &client, + NetworkAction::Join { + device: WIFI.into(), + ap: ACCESS_POINT.into(), + password, + }, + &snapshot, + &AtomicBool::new(false), + ) + .await + .unwrap(); + assert!(shared.lock().unwrap().calls.contains(&"join".into())); +} + +#[tokio::test(flavor = "current_thread")] +async fn monitor_follows_external_defaults_and_stops_when_command_owner_drops() { + let bus = TestBus::start(); + let (service, client, shared) = fake_service(&bus).await; + let (sender, receiver) = calloop::channel::channel(); + let (actions, mut requests) = mpsc::channel(4); + let stop = Arc::new(AtomicBool::new(false)); + let worker_stop = stop.clone(); + let worker = tokio::spawn(async move { + monitor( + &client, + "network", + &NetworkModule::default(), + &sender, + &worker_stop, + &mut requests, + ) + .await + }); + let first = tokio::time::timeout(Duration::from_secs(3), async { + loop { + if let Ok(event) = receiver.try_recv() { + break event; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .unwrap(); + assert!(first.snapshot.segments[0].text.contains("Mobile plan")); + shared + .lock() + .unwrap() + .snapshot + .objects + .get_mut(ROOT) + .unwrap() + .get_mut(NM) + .unwrap() + .insert("PrimaryConnection".into(), object_path(WIFI_ACTIVE)); + service + .emit_signal( + None::<&str>, + ROOT, + "org.freedesktop.DBus.Properties", + "PropertiesChanged", + &( + NM, + Properties::from([("PrimaryConnection".into(), object_path(WIFI_ACTIVE))]), + Vec::::new(), + ), + ) + .await + .unwrap(); + let event = tokio::time::timeout(Duration::from_secs(3), async { + loop { + if let Ok(event) = receiver.try_recv() { + break event; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .unwrap(); + assert!(event.snapshot.segments[0].text.contains("Cafe: Guest")); + stop.store(true, Ordering::Release); + drop(actions); + tokio::time::timeout(Duration::from_secs(1), worker) + .await + .unwrap() + .unwrap() + .unwrap(); +} + +#[tokio::test(flavor = "current_thread")] +#[ignore = "read-only comparison with the running NetworkManager service"] +async fn live_networkmanager_defaults_match_nmcli() { + let connection = zbus::connection::Builder::system() + .unwrap() + .method_timeout(CALL_TIMEOUT) + .build() + .await + .unwrap(); + let snapshot = read_snapshot(&connection).await.unwrap(); + let primary = proxy(&connection, ROOT, NM) + .await + .unwrap() + .get_property::("PrimaryConnection") + .await + .unwrap(); + if primary.as_str() != "/" { + let name: String = proxy(&connection, primary.as_str(), ACTIVE) + .await + .unwrap() + .get_property("Id") + .await + .unwrap(); + assert_eq!(snapshot.state.primary, clean_label(&name)); + } + for device in &snapshot.state.devices { + if device.state != 100 { + continue; + } + let active = + properties(&snapshot.objects, device.active.as_ref().unwrap(), ACTIVE).unwrap(); + let uuid = text(active, "Uuid").unwrap(); + let output = Command::new("nmcli") + .args([ + "-g", + "GENERAL.DEFAULT,GENERAL.DEFAULT6", + "connection", + "show", + "uuid", + &uuid, + ]) + .output() + .unwrap(); + assert!(output.status.success()); + let values: Vec<_> = String::from_utf8(output.stdout) + .unwrap() + .lines() + .map(|s| s == "yes") + .collect(); + assert_eq!(values, device.defaults); + } +} + +#[tokio::test(flavor = "current_thread")] +#[ignore = "fixture server for an isolated native Wayland UI smoke test"] +async fn serve_network_ui_fixture() { + let directory = std::path::PathBuf::from( + std::env::var_os("LIGHTBAR_NETWORK_UI_FIXTURE") + .expect("set the fixture artifact directory"), + ); + let bus = TestBus::start(); + let (_service, _client, shared) = fake_service(&bus).await; + { + let mut state = shared.lock().unwrap(); + // The visible AP is a new network while the saved active profile has another SSID. + state + .snapshot + .saved + .get_mut(WIFI_PROFILE) + .unwrap() + .settings + .get_mut("802-11-wireless") + .unwrap() + .insert("ssid".into(), array(b"Previous network".to_vec())); + state + .snapshot + .objects + .get_mut(WIFI) + .unwrap() + .get_mut(WIRELESS) + .unwrap() + .insert("ActiveAccessPoint".into(), object_path("/")); + } + std::fs::write(directory.join("bus-address"), &bus.1).unwrap(); + let deadline = tokio::time::Instant::now() + Duration::from_secs(90); + while !directory.join("stop-fixture").exists() { + assert!( + tokio::time::Instant::now() < deadline, + "native UI fixture was not stopped" + ); + tokio::time::sleep(Duration::from_millis(100)).await; + } + let shared = shared.lock().unwrap(); + std::fs::write( + directory.join("actions.json"), + serde_json::to_vec(&serde_json::json!({"calls": shared.calls,"scans":shared.scans})) + .unwrap(), + ) + .unwrap(); +} diff --git a/src/modules/process.rs b/src/modules/process.rs index b0623dd..c135d7d 100644 --- a/src/modules/process.rs +++ b/src/modules/process.rs @@ -1,200 +1,19 @@ use std::{ - collections::BTreeMap, - io::{BufRead, BufReader}, - process::{Child, Command, ExitStatus, Stdio}, + process::{Child, Command, ExitStatus}, sync::{ Arc, Mutex, atomic::{AtomicBool, Ordering}, }, - thread::{self, JoinHandle}, - time::{Duration, Instant}, + thread, + time::Duration, }; -use anyhow::{Context, Result, bail}; -use calloop::channel::Sender; +use anyhow::{Result, bail}; -use crate::{ - config::{AudioModule, NetworkModule}, - format::{Value, expand}, - model::{ModuleEvent, ModuleSnapshot, PopupContent, PopupModel, PopupRow}, -}; +use crate::config::AudioModule; type ChildRegistry = Arc>>>>; -pub fn spawn_network( - name: String, - settings: NetworkModule, - sender: Sender, - stop: Arc, - children: ChildRegistry, -) -> JoinHandle<()> { - thread::Builder::new() - .name(format!("lightbar-network-{name}")) - .spawn(move || { - let mut failures = 0_u32; - while !stop.load(Ordering::Acquire) { - if publish_network(&name, &settings, &sender).is_ok() { - failures = 0; - } else { - failures = failures.saturating_add(1); - } - if stop.load(Ordering::Acquire) { - break; - } - match monitored_lines( - "nmcli", - &["monitor"], - &stop, - &children, - Duration::from_millis(250), - || publish_network(&name, &settings, &sender), - ) { - Ok(()) if stop.load(Ordering::Acquire) => break, - Ok(()) => {} - Err(error) => { - tracing::warn!(module = %name, %error, "network monitor stopped"); - failures = failures.saturating_add(1); - } - } - thread::park_timeout(backoff(failures)); - } - }) - .expect("network worker thread") -} - -fn publish_network( - name: &str, - settings: &NetworkModule, - sender: &Sender, -) -> Result<()> { - let output = Command::new("nmcli") - .args([ - "--terse", - "--escape", - "no", - "--fields", - "DEVICE,TYPE,STATE,CONNECTION", - "device", - "status", - ]) - .output() - .context("could not run nmcli")?; - if !output.status.success() { - bail!("nmcli exited with {}", output.status); - } - let text = String::from_utf8(output.stdout)?; - let mut candidates = text.lines().filter_map(parse_network_line).filter(|entry| { - settings - .interface - .as_deref() - .is_none_or(|interface| entry.device == interface) - }); - let selected = candidates - .clone() - .find(|entry| entry.connected && entry.kind == "wifi") - .or_else(|| candidates.find(|entry| entry.connected)); - - let mut snapshot = if let Some(entry) = selected { - let values = BTreeMap::from([ - ("ssid", Value::from(entry.connection.clone())), - ("connection", Value::from(entry.connection.clone())), - ("interface", Value::from(entry.device.clone())), - ]); - let mut snapshot = ModuleSnapshot::text(expand(&settings.format_connected, &values)); - let addresses = network_addresses(&entry.device).unwrap_or_default(); - snapshot.tooltip = Some(format!( - "{} on {}{}", - entry.connection, - entry.device, - if addresses.is_empty() { - String::new() - } else { - format!(" · {}", addresses.join(", ")) - } - )); - snapshot.popup = Some(PopupModel { - title: "Network".to_owned(), - content: PopupContent::Rows(vec![ - PopupRow { - id: None, - label: "Connection".to_owned(), - detail: Some(entry.connection.clone()), - state: "normal".to_owned(), - enabled: false, - }, - PopupRow { - id: None, - label: "Interface".to_owned(), - detail: Some(entry.device.clone()), - state: "normal".to_owned(), - enabled: false, - }, - PopupRow { - id: Some("open-settings".to_owned()), - label: "Open connection editor".to_owned(), - detail: None, - state: "normal".to_owned(), - enabled: true, - }, - ]), - }); - snapshot - } else { - ModuleSnapshot::text(settings.format_disconnected.clone()).with_state("disconnected") - }; - snapshot.visible = true; - sender - .send(ModuleEvent { - module: name.to_owned(), - snapshot, - }) - .map_err(|_| anyhow::anyhow!("UI event channel closed")) -} - -#[derive(Debug, Clone)] -struct NetworkEntry { - device: String, - kind: String, - connected: bool, - connection: String, -} - -fn parse_network_line(line: &str) -> Option { - let mut fields = line.splitn(4, ':'); - let device = fields.next()?.to_owned(); - let kind = fields.next()?.to_owned(); - let state = fields.next()?.to_owned(); - let connection = fields.next().unwrap_or_default().to_owned(); - Some(NetworkEntry { - device, - kind, - connected: state.starts_with("connected"), - connection, - }) -} - -fn network_addresses(device: &str) -> Result> { - let output = Command::new("nmcli") - .args([ - "--terse", - "--get-values", - "IP4.ADDRESS", - "device", - "show", - device, - ]) - .output()?; - if !output.status.success() { - return Ok(Vec::new()); - } - Ok(String::from_utf8(output.stdout)? - .lines() - .map(str::trim) - .filter(|line| !line.is_empty()) - .map(str::to_owned) - .collect()) -} - pub fn wpctl(args: &[&str]) -> Result<()> { let status = Command::new("wpctl").args(args).status()?; if !status.success() { @@ -217,59 +36,6 @@ pub fn change_volume(settings: &AudioModule, increase: bool) -> Result<()> { wpctl(&args) } -fn monitored_lines( - program: &str, - args: &[&str], - stop: &AtomicBool, - children: &ChildRegistry, - debounce: Duration, - mut callback: F, -) -> Result<()> -where - F: FnMut() -> Result<()>, -{ - let mut child = Command::new(program) - .args(args) - .stdin(Stdio::null()) - .stdout(Stdio::piped()) - .stderr(Stdio::null()) - .spawn() - .with_context(|| format!("could not start {program}"))?; - let stdout = child - .stdout - .take() - .context("monitor stdout was not piped")?; - let child = Arc::new(Mutex::new(child)); - register_child(children, &child); - if stop.load(Ordering::Acquire) - && let Ok(mut process) = child.lock() - { - let _ = process.kill(); - } - - let mut last_update = Instant::now() - .checked_sub(debounce) - .unwrap_or_else(Instant::now); - for line in BufReader::new(stdout).lines() { - if stop.load(Ordering::Acquire) { - break; - } - line.with_context(|| format!("could not read {program} output"))?; - if last_update.elapsed() >= debounce { - if let Err(error) = callback() { - tracing::debug!(%error, program, "monitor refresh failed"); - } - last_update = Instant::now(); - } - } - let status = wait_child(&child, stop)?; - if !status.success() && !stop.load(Ordering::Acquire) { - bail!("{program} monitor exited with {status}"); - } - unregister_child(children, &child); - Ok(()) -} - pub(super) fn wait_child(child: &Arc>, stop: &AtomicBool) -> Result { loop { let status = { @@ -308,14 +74,6 @@ pub(super) fn backoff(failures: u32) -> Duration { mod tests { use super::*; - #[test] - fn parses_network_status() { - let parsed = parse_network_line("wlan0:wifi:connected:home").unwrap(); - assert_eq!(parsed.device, "wlan0"); - assert!(parsed.connected); - assert_eq!(parsed.connection, "home"); - } - #[test] fn stopped_child_wait_is_interruptible() { let child = Command::new("sleep").arg("30").spawn().unwrap(); diff --git a/src/render.rs b/src/render.rs index 30ef19c..357290e 100644 --- a/src/render.rs +++ b/src/render.rs @@ -173,6 +173,7 @@ impl Renderer { ), // Reserve the same viewport when switching between mixer and device lists. PopupContent::Audio(_) => 420, + PopupContent::Network(_) => 480, }; (configured_width, content_height.min(maximum_height).max(80)) } @@ -226,6 +227,106 @@ impl Renderer { let mut result = RenderResult::default(); let mut y = padding + 34.0; match &model.content { + PopupContent::Network(network) => { + let controls = network.controls(); + let available = (logical_height - y - padding - 24.0).max(0.0); + let offset = network.offset.min(controls.len().saturating_sub(1)); + let mut used = 0.0; + let mut shown = 0; + for control in controls.iter().skip(offset) { + let height = control.height(); + if used + height > available { + break; + } + let rect = Rect { + x: padding, + y: y + used, + width: (logical_width - 2.0 * padding).max(1.0), + height: height - 2.0, + }; + let focused = control.id.is_some() && control.id == network.focused; + self.draw_popup_text( + canvas, + physical_width, + physical_height, + scale, + module, + &control.label, + if focused || control.active { + "focused" + } else { + "normal" + }, + Rect { + height: 28.0, + ..rect + }, + theme, + ); + if !control.detail.is_empty() { + self.draw_popup_text( + canvas, + physical_width, + physical_height, + scale, + module, + &control.detail, + if focused { "focused" } else { "normal" }, + Rect { + y: rect.y + 26.0, + height: 24.0, + ..rect + }, + theme, + ); + } + if control.id.is_some() { + result.hit_boxes.push(HitBox { + rect, + module: module.into(), + segment: control.id.clone(), + }); + } + used += height; + shown += 1; + } + if offset > 0 || offset + shown < controls.len() { + for (id, text, x, enabled) in [ + ("network-scroll-up", "↑ Previous", padding, offset > 0), + ( + "network-scroll-down", + "↓ More", + logical_width / 2.0, + offset + shown < controls.len(), + ), + ] { + let rect = Rect { + x, + y: logical_height - padding - 24.0, + width: (logical_width / 2.0 - padding).max(1.0), + height: 24.0, + }; + self.draw_popup_text( + canvas, + physical_width, + physical_height, + scale, + module, + text, + if enabled { "normal" } else { "disabled" }, + rect, + theme, + ); + if enabled { + result.hit_boxes.push(HitBox { + rect, + module: module.into(), + segment: Some(id.into()), + }); + } + } + } + } PopupContent::Audio(audio) => { let controls = audio.controls(); let available = (logical_height - y - padding - 24.0).max(0.0); diff --git a/tests/network_popup.rs b/tests/network_popup.rs new file mode 100644 index 0000000..88ac12c --- /dev/null +++ b/tests/network_popup.rs @@ -0,0 +1,220 @@ +use lightbar::{ + config::ConfigBundle, + model::{ + NetworkAction, NetworkDevice, NetworkKind, NetworkModel, NetworkSecret, NetworkState, + NetworkView, PopupContent, PopupModel, WifiNetwork, WifiSecurity, + }, + render::{Renderer, hit_test}, +}; +fn fixture() -> NetworkModel { + NetworkModel { + state: NetworkState { + wifi_enabled: true, + wifi_hardware: true, + connectivity: "Internet connected".into(), + defaults: [vec!["Wired connection".into()], vec![]], + devices: vec![NetworkDevice { + path: "/wifi".into(), + interface: "wifi0".into(), + kind: NetworkKind::Wifi, + state: 30, + state_label: "Disconnected".into(), + managed: true, + carrier: None, + active: None, + profile: None, + connection_name: String::new(), + defaults: [false, false], + addresses: vec![], + profiles: vec![], + last_scan: 100, + networks: (0..24) + .map(|i| WifiNetwork { + path: format!("/ap/{i}"), + ssid: format!("Network {i}").into_bytes(), + label: format!("Network {i}"), + strength: 80, + security: WifiSecurity::Personal, + active: false, + profile: None, + }) + .collect(), + }], + ..NetworkState::default() + }, + ..NetworkModel::default() + } +} + +#[test] +fn keyboard_reaches_scan_and_all_networks_with_visible_hitboxes_at_both_scales() { + let bundle = ConfigBundle::load(Some(std::path::Path::new("examples/config.toml"))).unwrap(); + let mut renderer = Renderer::default(); + for scale in [1, 2] { + let mut network = fixture(); + network.set_view(NetworkView::Device("/wifi".into())); + let expected: Vec<_> = network + .controls() + .into_iter() + .filter_map(|c| c.id) + .collect(); + let mut visited = Vec::new(); + for _ in 0..expected.len() { + network.move_focus( + false, + 180.0 - 2.0 * f64::from(bundle.theme.popup.padding) - 58.0, + ); + let popup = PopupModel { + title: "Network".into(), + content: PopupContent::Network(network.clone()), + }; + let mut pixels = vec![0; (320 * 180 * scale * scale * 4) as usize]; + let result = renderer.render_popup( + &mut pixels, + 320 * scale, + 180 * scale, + scale, + "network", + &popup, + &bundle.theme, + ); + let focused = result + .hit_boxes + .iter() + .find(|h| h.segment == network.focused) + .expect("focused control must be visible"); + assert_eq!( + hit_test( + &result.hit_boxes, + focused.rect.x + 1.0, + focused.rect.y + 1.0 + ), + Some(focused) + ); + assert!(focused.rect.y + focused.rect.height <= 180.0); + visited.push(network.focused.clone().unwrap()); + } + assert_eq!(visited, expected); + assert!(visited.iter().any(|id| id == "network-scan")); + assert!(network.offset > 0); + network.move_focus(true, 102.0); + assert_eq!(network.focused.as_deref(), Some("network-ap:/ap/23")); + } +} + +#[test] +fn unsaved_wifi_opens_a_masked_password_form_and_emits_a_typed_command() { + let mut network = fixture(); + network.set_view(NetworkView::Device("/wifi".into())); + assert!(network.activate("network-ap:/ap/2").is_none()); + assert!(matches!(network.view, NetworkView::Password { .. })); + assert_eq!(network.focused.as_deref(), Some("network-password")); + assert!( + !network + .controls() + .iter() + .any(|c| c.id.as_deref() == Some("network-submit")) + ); + network.password.push("example-password"); + assert!(!format!("{:?}", network.controls()).contains("example-password")); + let command = network.activate("network-submit").unwrap(); + let NetworkAction::Join { + device, + ap, + password, + } = command + else { + panic!() + }; + assert_eq!( + (device.as_str(), ap.as_str(), password.text()), + ("/wifi", "/ap/2", "example-password") + ); + assert!(network.password.text().is_empty()); + assert_eq!(network.view, NetworkView::Device("/wifi".into())); +} + +#[test] +fn updates_keep_input_and_navigation_but_remove_disappeared_device_state() { + let mut network = fixture(); + network.set_view(NetworkView::Device("/wifi".into())); + network.activate("network-ap:/ap/0"); + network.password.push("example-password"); + let mut update = fixture(); + update.preserve_ui(&network); + assert_eq!(update.password.text(), "example-password"); + assert_eq!(update.focused, network.focused); + update.state.devices.clear(); + update.preserve_ui(&network); + assert_eq!(update.view, NetworkView::Overview); + assert!(update.password.text().is_empty()); +} + +#[test] +fn busy_controls_and_stale_clicks_cannot_issue_network_actions() { + let mut network = fixture(); + network.set_view(NetworkView::Device("/wifi".into())); + assert!(matches!( + network.activate("network-scan"), + Some(NetworkAction::Scan(_)) + )); + network.state.busy = true; + assert!(network.activate("network-scan").is_none()); + assert!(network.controls().iter().all(|c| c.id.is_none())); + network.state.busy = false; + network.state.devices.clear(); + assert!(network.activate("network-ap:/ap/0").is_none()); +} + +#[test] +fn password_backspace_is_unicode_safe_and_debug_is_redacted() { + let mut secret = NetworkSecret::default(); + secret.push("é🔑x\n"); + secret.backspace(); + assert_eq!(secret.text(), "é🔑"); + assert_eq!(secret.masked(), "••"); + secret.backspace(); + secret.backspace(); + secret.backspace(); + assert!(secret.text().is_empty()); + assert_eq!(format!("{secret:?}"), ""); +} + +#[test] +fn asynchronous_controls_preserve_focus_while_disabled_and_after_completion() { + let mut old = fixture(); + old.set_view(NetworkView::Device("/wifi".into())); + old.activate("network-scan"); + old.state.busy = true; + let mut busy = fixture(); + busy.state.busy = true; + busy.preserve_ui(&old); + assert_eq!(busy.focused.as_deref(), Some("network-scan")); + let mut finished = fixture(); + finished.preserve_ui(&busy); + assert_eq!(finished.focused.as_deref(), Some("network-scan")); + finished.move_focus(false, 400.0); + assert_eq!(finished.focused.as_deref(), Some("network-ap:/ap/0")); +} + +#[test] +fn newly_opened_password_input_is_visible_in_a_short_popup() { + let bundle = ConfigBundle::load(Some(std::path::Path::new("examples/config.toml"))).unwrap(); + let mut network = fixture(); + network.set_view(NetworkView::Device("/wifi".into())); + network.activate("network-ap:/ap/0"); + network.ensure_focus_visible(180.0 - 2.0 * f64::from(bundle.theme.popup.padding) - 58.0); + let popup = PopupModel { + title: "Network".into(), + content: PopupContent::Network(network), + }; + let mut renderer = Renderer::default(); + let mut pixels = vec![0; 320 * 180 * 4]; + let result = renderer.render_popup(&mut pixels, 320, 180, 1, "network", &popup, &bundle.theme); + assert!( + result + .hit_boxes + .iter() + .any(|h| h.segment.as_deref() == Some("network-password")) + ); +}