From e448d433fc6bd8628438fe9f759c4c46c8eb2a82 Mon Sep 17 00:00:00 2001 From: Samuel Onoja Date: Sun, 27 Sep 2026 07:48:59 +0100 Subject: [PATCH] Read Wi-Fi status through NetworkManager D-Bus --- shell/ferese-shell/src/status.rs | 80 +++++----- shell/ferese-shell/src/status/network.rs | 188 +++++++++++++++++++++++ 2 files changed, 227 insertions(+), 41 deletions(-) create mode 100644 shell/ferese-shell/src/status/network.rs diff --git a/shell/ferese-shell/src/status.rs b/shell/ferese-shell/src/status.rs index 14a6bd0..cb090d7 100644 --- a/shell/ferese-shell/src/status.rs +++ b/shell/ferese-shell/src/status.rs @@ -1,4 +1,6 @@ //! Bounded, off-UI-thread adapters. A missing service is `None`, never a fake state. +mod network; + use std::{ env, fs, path::Path, @@ -334,7 +336,7 @@ impl StatusBus { fn poll(system_bus: &mut StatusBus, session_bus: &mut StatusBus) -> Snapshot { Snapshot { - network: network(), + network: system_bus.query(network::read).flatten(), bluetooth: bluetooth(), audio: audio(), battery: battery(), @@ -346,46 +348,6 @@ fn poll(system_bus: &mut StatusBus, session_bus: &mut StatusBus) -> Snapshot { } } -fn network() -> Option { - // No Wi-Fi device means no Wi-Fi control (wired connectivity isn't called Wi-Fi). - let devices = run("nmcli", &["-t", "-f", "TYPE", "device", "status"]).ok()?; - if !devices.lines().any(|line| line == "wifi") { - return None; - } - let enabled = run("nmcli", &["radio", "wifi"]).ok()? == "enabled"; - let mut connection = None; - let mut signal = 0; - if enabled { - let rows = run( - "nmcli", - &[ - "-t", - "-f", - "IN-USE,SIGNAL,SSID", - "device", - "wifi", - "list", - "--rescan", - "no", - ], - ) - .ok()?; - for line in rows.lines() { - if let Some((strength, ssid)) = line.strip_prefix("*:").and_then(|s| s.split_once(':')) - { - signal = strength.parse::().ok()?.min(100); - connection = Some(ssid.replace("\\:", ":").replace("\\\\", "\\")); - break; - } - } - } - Some(Network { - enabled, - connection, - signal, - }) -} - fn bluetooth() -> Option { let info = run("bluetoothctl", &["show"]).ok()?; let enabled = info @@ -575,6 +537,42 @@ fn execute(action: &Action, settings: Option<&[String]>) -> Result<(), String> { mod tests { use super::*; + pub(super) struct TestBus { + daemon: std::process::Child, + pub(super) address: String, + } + impl TestBus { + pub(super) fn new() -> Self { + use std::io::{BufRead, BufReader}; + let mut daemon = Command::new("dbus-daemon") + .args(["--session", "--nofork", "--nopidfile", "--print-address=1"]) + .stdout(Stdio::piped()) + .spawn() + .unwrap(); + let mut address = String::new(); + BufReader::new(daemon.stdout.take().unwrap()) + .read_line(&mut address) + .unwrap(); + Self { + daemon, + address: address.trim().to_owned(), + } + } + pub(super) fn connect(&self) -> zbus::blocking::Connection { + zbus::blocking::connection::Builder::address(self.address.as_str()) + .unwrap() + .method_timeout(Duration::from_millis(200)) + .build() + .unwrap() + } + } + impl Drop for TestBus { + fn drop(&mut self) { + let _ = self.daemon.kill(); + let _ = self.daemon.wait(); + } + } + struct MockLogin1(Arc); #[zbus::interface(name = "org.freedesktop.login1.Manager")] diff --git a/shell/ferese-shell/src/status/network.rs b/shell/ferese-shell/src/status/network.rs new file mode 100644 index 0000000..9703f35 --- /dev/null +++ b/shell/ferese-shell/src/status/network.rs @@ -0,0 +1,188 @@ +//! Read NetworkManager directly; no scans or network changes are requested. +use super::Network; +use zbus::{blocking::Connection, zvariant::OwnedObjectPath}; + +const SERVICE: &str = "org.freedesktop.NetworkManager"; +const ROOT: &str = "/org/freedesktop/NetworkManager"; + +fn property>( + connection: &Connection, + path: &str, + interface: &str, + name: &str, +) -> zbus::Result +where + T::Error: Into, +{ + // Explicit Get avoids creating short-lived property-cache subscriptions. + let value: zbus::zvariant::OwnedValue = connection + .call_method( + Some(SERVICE), + path, + Some("org.freedesktop.DBus.Properties"), + "Get", + &(interface, name), + )? + .body() + .deserialize()?; + value.try_into().map_err(Into::into) +} + +pub(super) fn read(connection: &Connection) -> zbus::Result> { + let devices: Vec = property(connection, ROOT, SERVICE, "Devices")?; + let mut wifi = Vec::new(); + for device in devices { + let kind: u32 = property( + connection, + device.as_str(), + "org.freedesktop.NetworkManager.Device", + "DeviceType", + )?; + if kind == 2 { + wifi.push(device); + } + } + if wifi.is_empty() { + return Ok(None); + } + // Stable selection when several Wi-Fi adapters have active connections. + wifi.sort_by(|a, b| a.as_str().cmp(b.as_str())); + let enabled = property(connection, ROOT, SERVICE, "WirelessEnabled")?; + let mut result = Network { + enabled, + connection: None, + signal: 0, + }; + if enabled { + for device in wifi { + let ap: OwnedObjectPath = property( + connection, + device.as_str(), + "org.freedesktop.NetworkManager.Device.Wireless", + "ActiveAccessPoint", + )?; + if ap.as_str() == "/" { + continue; + } + let ssid: Vec = property( + connection, + ap.as_str(), + "org.freedesktop.NetworkManager.AccessPoint", + "Ssid", + )?; + let strength: u8 = property( + connection, + ap.as_str(), + "org.freedesktop.NetworkManager.AccessPoint", + "Strength", + )?; + result.connection = Some(String::from_utf8_lossy(&ssid).into_owned()); + result.signal = strength.min(100); + break; + } + } + Ok(Some(result)) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::{Arc, Mutex}; + #[derive(Default)] + struct State { + wifi: bool, + enabled: bool, + active: bool, + strength: u8, + } + struct Manager(Arc>); + #[zbus::interface(name = "org.freedesktop.NetworkManager")] + impl Manager { + #[zbus(property)] + fn devices(&self) -> Vec { + vec![OwnedObjectPath::try_from("/device").unwrap()] + } + #[zbus(property)] + fn wireless_enabled(&self) -> bool { + self.0.lock().unwrap().enabled + } + } + struct Device(Arc>); + #[zbus::interface(name = "org.freedesktop.NetworkManager.Device")] + impl Device { + #[zbus(property)] + fn device_type(&self) -> u32 { + if self.0.lock().unwrap().wifi { 2 } else { 1 } + } + } + struct Wireless(Arc>); + #[zbus::interface(name = "org.freedesktop.NetworkManager.Device.Wireless")] + impl Wireless { + #[zbus(property)] + fn active_access_point(&self) -> OwnedObjectPath { + OwnedObjectPath::try_from(if self.0.lock().unwrap().active { + "/ap" + } else { + "/" + }) + .unwrap() + } + } + struct Ap(Arc>); + #[zbus::interface(name = "org.freedesktop.NetworkManager.AccessPoint")] + impl Ap { + #[zbus(property)] + fn ssid(&self) -> Vec { + b"wifi:with\\escapes".to_vec() + } + #[zbus(property)] + fn strength(&self) -> u8 { + self.0.lock().unwrap().strength + } + } + #[test] + #[ignore = "requires dbus-daemon; uses a private test bus"] + fn fresh_network_state_and_service_recovery() { + let bus = crate::status::tests::TestBus::new(); + let state = Arc::new(Mutex::new(State::default())); + let server = zbus::blocking::connection::Builder::address(bus.address.as_str()) + .unwrap() + .name(SERVICE) + .unwrap() + .serve_at(ROOT, Manager(state.clone())) + .unwrap() + .serve_at("/device", Device(state.clone())) + .unwrap() + .serve_at("/device", Wireless(state.clone())) + .unwrap() + .serve_at("/ap", Ap(state.clone())) + .unwrap() + .build() + .unwrap(); + let client = bus.connect(); + assert!(read(&client).unwrap().is_none()); + state.lock().unwrap().wifi = true; + let value = read(&client).unwrap().unwrap(); + assert!(!value.enabled && value.connection.is_none()); + state.lock().unwrap().enabled = true; + assert!(read(&client).unwrap().unwrap().connection.is_none()); + { + let mut s = state.lock().unwrap(); + s.active = true; + s.strength = 73; + } + let value = read(&client).unwrap().unwrap(); + assert_eq!(value.connection.as_deref(), Some("wifi:with\\escapes")); + assert_eq!(value.signal, 73); + state.lock().unwrap().strength = 250; + assert_eq!(read(&client).unwrap().unwrap().signal, 100); + server.release_name(SERVICE).unwrap(); + assert!(read(&client).is_err()); + server.request_name(SERVICE).unwrap(); + assert_eq!(read(&client).unwrap().unwrap().signal, 100); + server.object_server().remove::("/ap").unwrap(); + assert!(read(&client).is_err()); + state.lock().unwrap().active = false; + assert!(read(&client).unwrap().unwrap().connection.is_none()); + } +}