From fc0c2617311f90501d58895fa25b2f8a3d729ee7 Mon Sep 17 00:00:00 2001 From: Akrm Al-Hakimi Date: Fri, 24 Apr 2026 17:49:28 -0400 Subject: [PATCH 01/14] feat: add WWAN properties to NMProxy and BlueZ adapter proxy --- nmrs/src/dbus/bluez_adapter.rs | 18 ++++++++++++++++++ nmrs/src/dbus/main_nm.rs | 12 ++++++++++++ nmrs/src/dbus/mod.rs | 2 ++ 3 files changed, 32 insertions(+) create mode 100644 nmrs/src/dbus/bluez_adapter.rs diff --git a/nmrs/src/dbus/bluez_adapter.rs b/nmrs/src/dbus/bluez_adapter.rs new file mode 100644 index 00000000..95edf325 --- /dev/null +++ b/nmrs/src/dbus/bluez_adapter.rs @@ -0,0 +1,18 @@ +//! BlueZ Adapter1 proxy for reading and controlling Bluetooth radio power. + +use zbus::proxy; + +/// Proxy for `org.bluez.Adapter1` on a specific adapter path (e.g. `/org/bluez/hci0`). +/// +/// Used to read and toggle the adapter's `Powered` property, which controls +/// whether the Bluetooth radio is software-enabled. +#[proxy(interface = "org.bluez.Adapter1", default_service = "org.bluez")] +pub trait BluezAdapter { + /// Whether the adapter is currently powered on (software-enabled). + #[zbus(property)] + fn powered(&self) -> zbus::Result; + + /// Enable or disable the adapter. + #[zbus(property)] + fn set_powered(&self, value: bool) -> zbus::Result<()>; +} diff --git a/nmrs/src/dbus/main_nm.rs b/nmrs/src/dbus/main_nm.rs index 818877e8..eebe7d67 100644 --- a/nmrs/src/dbus/main_nm.rs +++ b/nmrs/src/dbus/main_nm.rs @@ -62,6 +62,18 @@ pub trait NM { #[zbus(signal, name = "DeviceRemoved")] fn device_removed(&self, device: OwnedObjectPath); + /// Whether WWAN (mobile broadband) is globally enabled. + #[zbus(property)] + fn wwan_enabled(&self) -> zbus::Result; + + /// Enable or disable WWAN globally. + #[zbus(property)] + fn set_wwan_enabled(&self, value: bool) -> zbus::Result<()>; + + /// Whether WWAN hardware is enabled (rfkill state). + #[zbus(property)] + fn wwan_hardware_enabled(&self) -> zbus::Result; + /// Signal emitted when any device changes state. #[zbus(signal, name = "StateChanged")] fn state_changed(&self, state: u32); diff --git a/nmrs/src/dbus/mod.rs b/nmrs/src/dbus/mod.rs index 21374c52..3e30c2a2 100644 --- a/nmrs/src/dbus/mod.rs +++ b/nmrs/src/dbus/mod.rs @@ -7,6 +7,7 @@ mod access_point; mod active_connection; pub(crate) mod agent_manager; mod bluetooth; +mod bluez_adapter; mod device; mod main_nm; mod wired; @@ -16,6 +17,7 @@ pub(crate) use access_point::NMAccessPointProxy; pub(crate) use active_connection::NMActiveConnectionProxy; pub(crate) use agent_manager::AgentManagerProxy; pub(crate) use bluetooth::{BluezDeviceExtProxy, NMBluetoothProxy}; +pub(crate) use bluez_adapter::BluezAdapterProxy; pub(crate) use device::NMDeviceProxy; pub(crate) use main_nm::NMProxy; pub(crate) use wired::NMWiredProxy; From 6e359a239e299a2de33ffc0d59ae5d32d89b5a08 Mon Sep 17 00:00:00 2001 From: Akrm Al-Hakimi Date: Fri, 24 Apr 2026 17:49:32 -0400 Subject: [PATCH 02/14] docs: record airplane-mode surface in changelog and readme --- nmrs/CHANGELOG.md | 6 ++++++ nmrs/README.md | 1 + 2 files changed, 7 insertions(+) diff --git a/nmrs/CHANGELOG.md b/nmrs/CHANGELOG.md index f5511e28..3305622b 100644 --- a/nmrs/CHANGELOG.md +++ b/nmrs/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to the `nmrs` crate will be documented in this file. ## [Unreleased] ### Added - `nmrs::agent` module: NetworkManager secret agent for credential prompting over D-Bus (`SecretAgent`, `SecretAgentBuilder`, `SecretAgentHandle`, `SecretRequest`, `SecretResponder`, `SecretSetting`, `SecretAgentFlags`, `SecretAgentCapabilities`, `CancelReason`, `SecretStoreEvent`) +- Airplane-mode surface: `RadioState`, `AirplaneModeState`, `wifi_state()`, `wwan_state()`, `bluetooth_radio_state()`, `airplane_mode_state()`, `set_wireless_enabled()`, `set_wwan_enabled()`, `set_bluetooth_radio_enabled()`, `set_airplane_mode()` +- Kernel rfkill awareness: hardware kill switch state via `/sys/class/rfkill` +- `HardwareRadioKilled` and `BluezUnavailable` error variants + +### Changed +- Deprecated `wifi_enabled()`, `set_wifi_enabled()`, and `wifi_hardware_enabled()` in favor of `wifi_state()` and `set_wireless_enabled()` - `VpnConfig` trait and `WireGuardConfig`; `NetworkManager::connect_vpn` accepts `VpnConfig` implementors; `VpnCredentials` deprecated with compatibility bridges ([#303](https://github.com/cachebag/nmrs/pull/303)) ### Changed diff --git a/nmrs/README.md b/nmrs/README.md index 9e4da015..4c0f2239 100644 --- a/nmrs/README.md +++ b/nmrs/README.md @@ -19,6 +19,7 @@ Rust bindings for NetworkManager via D-Bus. - **Profile Management**: Create, query, and delete saved connection profiles - **Real-Time Monitoring**: Signal-based network and device state change notifications - **Secret Agent**: Respond to NetworkManager credential prompts via an async stream API +- **Airplane Mode**: Toggle Wi-Fi, WWAN, and Bluetooth radios with rfkill hardware awareness - **Typed Errors**: Structured error types with specific failure reasons - **Fully Async**: Built on `zbus` with async/await throughout From 0b54e37cb3644aed13d2915c20f77a29c743be6c Mon Sep 17 00:00:00 2001 From: Akrm Al-Hakimi Date: Fri, 24 Apr 2026 17:49:35 -0400 Subject: [PATCH 03/14] docs: add airplane_mode example --- nmrs/Cargo.toml | 4 ++++ nmrs/examples/airplane_mode.rs | 18 ++++++++++++++++++ 2 files changed, 22 insertions(+) create mode 100644 nmrs/examples/airplane_mode.rs diff --git a/nmrs/Cargo.toml b/nmrs/Cargo.toml index aca499d4..ee427896 100644 --- a/nmrs/Cargo.toml +++ b/nmrs/Cargo.toml @@ -47,3 +47,7 @@ path = "examples/vpn_connect.rs" [[example]] name = "secret_agent" path = "examples/secret_agent.rs" + +[[example]] +name = "airplane_mode" +path = "examples/airplane_mode.rs" diff --git a/nmrs/examples/airplane_mode.rs b/nmrs/examples/airplane_mode.rs new file mode 100644 index 00000000..be855130 --- /dev/null +++ b/nmrs/examples/airplane_mode.rs @@ -0,0 +1,18 @@ +use nmrs::NetworkManager; + +#[tokio::main] +async fn main() -> nmrs::Result<()> { + let nm = NetworkManager::new().await?; + + println!("before: {:#?}", nm.airplane_mode_state().await?); + + nm.set_airplane_mode(true).await?; + tokio::time::sleep(std::time::Duration::from_secs(2)).await; + + println!("during: {:#?}", nm.airplane_mode_state().await?); + + nm.set_airplane_mode(false).await?; + println!("after: {:#?}", nm.airplane_mode_state().await?); + + Ok(()) +} From 14ce89d8409d9750c586be20321319f1f67c421b Mon Sep 17 00:00:00 2001 From: Akrm Al-Hakimi Date: Fri, 24 Apr 2026 17:49:41 -0400 Subject: [PATCH 04/14] feat: add HardwareRadioKilled and BluezUnavailable error variants --- nmrs/src/api/models/error.rs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/nmrs/src/api/models/error.rs b/nmrs/src/api/models/error.rs index 44abe4a1..9284b244 100644 --- a/nmrs/src/api/models/error.rs +++ b/nmrs/src/api/models/error.rs @@ -188,4 +188,12 @@ pub enum ConnectionError { /// An error occured while parsing a configuration #[error("error while parsing a configuration: {0}")] ParseError(OvpnParseError), + + /// A radio is hardware-disabled via rfkill. + #[error("radio is hardware-disabled (rfkill)")] + HardwareRadioKilled, + + /// The BlueZ Bluetooth stack is unavailable. + #[error("bluetooth stack unavailable: {0}")] + BluezUnavailable(String), } From 0454641cf2a4ad49858d5081cce0606c53e437aa Mon Sep 17 00:00:00 2001 From: Akrm Al-Hakimi Date: Fri, 24 Apr 2026 17:49:44 -0400 Subject: [PATCH 05/14] feat: add RadioState and AirplaneModeState types --- nmrs/src/api/models/error.rs | 2 +- nmrs/src/api/models/mod.rs | 2 + nmrs/src/api/models/radio.rs | 128 +++++++++++++++++++++++++++++++++++ 3 files changed, 131 insertions(+), 1 deletion(-) create mode 100644 nmrs/src/api/models/radio.rs diff --git a/nmrs/src/api/models/error.rs b/nmrs/src/api/models/error.rs index 9284b244..601c1772 100644 --- a/nmrs/src/api/models/error.rs +++ b/nmrs/src/api/models/error.rs @@ -184,7 +184,7 @@ pub enum ConnectionError { /// A secret agent is already registered under this identifier. #[error("secret agent already registered under this identifier")] AgentAlreadyRegistered, - + /// An error occured while parsing a configuration #[error("error while parsing a configuration: {0}")] ParseError(OvpnParseError), diff --git a/nmrs/src/api/models/mod.rs b/nmrs/src/api/models/mod.rs index 64d5ffca..c3e00537 100644 --- a/nmrs/src/api/models/mod.rs +++ b/nmrs/src/api/models/mod.rs @@ -4,6 +4,7 @@ mod connection_state; mod device; mod error; mod openvpn; +mod radio; mod state_reason; mod vpn; mod wifi; @@ -19,6 +20,7 @@ pub use connection_state::*; pub use device::*; pub use error::*; pub use openvpn::*; +pub use radio::*; pub use state_reason::*; pub use vpn::*; pub use wifi::*; diff --git a/nmrs/src/api/models/radio.rs b/nmrs/src/api/models/radio.rs new file mode 100644 index 00000000..99053cad --- /dev/null +++ b/nmrs/src/api/models/radio.rs @@ -0,0 +1,128 @@ +//! Radio and airplane-mode state types. +//! +//! NetworkManager tracks both a software-enabled flag (controlled via D-Bus) +//! and a hardware-enabled flag (reflecting the kernel rfkill state) for each +//! radio. [`RadioState`] captures both, and [`AirplaneModeState`] aggregates +//! Wi-Fi, WWAN, and Bluetooth into a single snapshot. + +/// Software and hardware enabled state for a single radio. +/// +/// `enabled` reflects the user-facing toggle (can be written via D-Bus). +/// `hardware_enabled` reflects the kernel rfkill state and cannot be changed +/// from userspace — if `false`, setting `enabled = true` is accepted by NM +/// but the radio remains off until hardware is unkilled. +#[non_exhaustive] +#[derive(Debug, Clone, Copy, Eq, PartialEq)] +pub struct RadioState { + /// Software-enabled: can the user turn this radio on via NM? + pub enabled: bool, + /// Hardware-enabled: is rfkill allowing this radio? + /// If `false`, `enabled = true` is a no-op until hardware is unkilled. + pub hardware_enabled: bool, +} + +impl RadioState { + /// Creates a new `RadioState`. + #[must_use] + pub fn new(enabled: bool, hardware_enabled: bool) -> Self { + Self { + enabled, + hardware_enabled, + } + } +} + +/// Aggregated radio state for all radios that `nmrs` can control. +/// +/// Returned by [`NetworkManager::airplane_mode_state`](crate::NetworkManager::airplane_mode_state). +#[non_exhaustive] +#[derive(Debug, Clone, Copy, Eq, PartialEq)] +pub struct AirplaneModeState { + /// Wi-Fi radio state. + pub wifi: RadioState, + /// WWAN (mobile broadband) radio state. + pub wwan: RadioState, + /// Bluetooth radio state (sourced from BlueZ + rfkill). + pub bluetooth: RadioState, +} + +impl AirplaneModeState { + /// Creates a new `AirplaneModeState`. + #[must_use] + pub fn new(wifi: RadioState, wwan: RadioState, bluetooth: RadioState) -> Self { + Self { + wifi, + wwan, + bluetooth, + } + } + + /// Returns `true` if every radio `nmrs` can control is software-disabled. + /// + /// This is the "airplane mode is on" state — all radios off. + #[must_use] + pub fn is_airplane_mode(&self) -> bool { + !self.wifi.enabled && !self.wwan.enabled && !self.bluetooth.enabled + } + + /// Returns `true` if any radio has its hardware kill switch active. + #[must_use] + pub fn any_hardware_killed(&self) -> bool { + !self.wifi.hardware_enabled + || !self.wwan.hardware_enabled + || !self.bluetooth.hardware_enabled + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn all_off_is_airplane_mode() { + let state = AirplaneModeState::new( + RadioState::new(false, true), + RadioState::new(false, true), + RadioState::new(false, true), + ); + assert!(state.is_airplane_mode()); + assert!(!state.any_hardware_killed()); + } + + #[test] + fn any_on_is_not_airplane_mode() { + let state = AirplaneModeState::new( + RadioState::new(true, true), + RadioState::new(false, true), + RadioState::new(false, true), + ); + assert!(!state.is_airplane_mode()); + } + + #[test] + fn hardware_killed_detected() { + let state = AirplaneModeState::new( + RadioState::new(true, true), + RadioState::new(true, false), + RadioState::new(true, true), + ); + assert!(state.any_hardware_killed()); + } + + #[test] + fn no_hardware_kill() { + let state = AirplaneModeState::new( + RadioState::new(false, true), + RadioState::new(false, true), + RadioState::new(false, true), + ); + assert!(!state.any_hardware_killed()); + } + + #[test] + fn radio_state_new() { + let rs = RadioState::new(true, false); + assert!(rs.enabled); + assert!(!rs.hardware_enabled); + } +} From e9e0aa8f4004213a87d5f1c949a6d35e96ec4f32 Mon Sep 17 00:00:00 2001 From: Akrm Al-Hakimi Date: Fri, 24 Apr 2026 17:49:47 -0400 Subject: [PATCH 06/14] feat: add kernel rfkill hardware-block reader via sysfs --- nmrs/src/core/rfkill.rs | 59 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 nmrs/src/core/rfkill.rs diff --git a/nmrs/src/core/rfkill.rs b/nmrs/src/core/rfkill.rs new file mode 100644 index 00000000..4f51ab32 --- /dev/null +++ b/nmrs/src/core/rfkill.rs @@ -0,0 +1,59 @@ +//! Kernel rfkill state reader via sysfs. +//! +//! Reads `/sys/class/rfkill/*/type` and `/sys/class/rfkill/*/hard` to detect +//! hardware radio kill switches. This is a fallback for cases where +//! NetworkManager's `*HardwareEnabled` properties disagree with the kernel. + +use std::fs; +use std::path::Path; + +/// Snapshot of hardware (hard-block) rfkill state for each radio type. +#[derive(Debug, Clone, Copy, Default)] +pub(crate) struct RfkillSnapshot { + /// `true` if any WLAN rfkill entry reports a hard block. + pub wlan_hard_block: bool, + /// `true` if any WWAN rfkill entry reports a hard block. + pub wwan_hard_block: bool, + /// `true` if any Bluetooth rfkill entry reports a hard block. + pub bluetooth_hard_block: bool, +} + +/// Reads the current rfkill hardware-block state from sysfs. +/// +/// Returns an all-false snapshot if `/sys/class/rfkill` is unreadable +/// (common in containers and CI environments). +pub(crate) fn read_rfkill() -> RfkillSnapshot { + let rfkill_dir = Path::new("/sys/class/rfkill"); + + let entries = match fs::read_dir(rfkill_dir) { + Ok(e) => e, + Err(_) => return RfkillSnapshot::default(), + }; + + let mut snapshot = RfkillSnapshot::default(); + + for entry in entries.flatten() { + let path = entry.path(); + + let type_str = match fs::read_to_string(path.join("type")) { + Ok(s) => s.trim().to_string(), + Err(_) => continue, + }; + + let hard_blocked = match fs::read_to_string(path.join("hard")) { + Ok(s) => s.trim() == "1", + Err(_) => false, + }; + + if hard_blocked { + match type_str.as_str() { + "wlan" => snapshot.wlan_hard_block = true, + "wwan" => snapshot.wwan_hard_block = true, + "bluetooth" => snapshot.bluetooth_hard_block = true, + _ => {} + } + } + } + + snapshot +} From 84a1ef4e20b1a465f9d07d51db7f3cf10cca705e Mon Sep 17 00:00:00 2001 From: Akrm Al-Hakimi Date: Fri, 24 Apr 2026 17:49:51 -0400 Subject: [PATCH 07/14] feat: add airplane-mode aggregation and radio toggle logic --- nmrs/src/core/airplane.rs | 196 ++++++++++++++++++++++++++++++++++++++ nmrs/src/core/mod.rs | 2 + 2 files changed, 198 insertions(+) create mode 100644 nmrs/src/core/airplane.rs diff --git a/nmrs/src/core/airplane.rs b/nmrs/src/core/airplane.rs new file mode 100644 index 00000000..f27df3d3 --- /dev/null +++ b/nmrs/src/core/airplane.rs @@ -0,0 +1,196 @@ +//! Airplane-mode aggregation logic. +//! +//! Combines radio state from NetworkManager (Wi-Fi, WWAN), BlueZ (Bluetooth +//! adapter power), and kernel rfkill into a single [`AirplaneModeState`]. + +use log::warn; +use zbus::Connection; + +use crate::api::models::{AirplaneModeState, RadioState}; +use crate::core::rfkill::read_rfkill; +use crate::dbus::{BluezAdapterProxy, NMProxy}; +use crate::{ConnectionError, Result}; + +/// Reads Wi-Fi radio state from NetworkManager, cross-referenced with rfkill. +pub(crate) async fn wifi_state(conn: &Connection) -> Result { + let nm = NMProxy::new(conn).await?; + let enabled = nm.wireless_enabled().await?; + let nm_hw = nm.wireless_hardware_enabled().await?; + + let rfkill = read_rfkill(); + let hardware_enabled = reconcile_hardware(nm_hw, rfkill.wlan_hard_block, "wifi"); + + Ok(RadioState::new(enabled, hardware_enabled)) +} + +/// Reads WWAN radio state from NetworkManager, cross-referenced with rfkill. +pub(crate) async fn wwan_state(conn: &Connection) -> Result { + let nm = NMProxy::new(conn).await?; + let enabled = nm.wwan_enabled().await?; + let nm_hw = nm.wwan_hardware_enabled().await?; + + let rfkill = read_rfkill(); + let hardware_enabled = reconcile_hardware(nm_hw, rfkill.wwan_hard_block, "wwan"); + + Ok(RadioState::new(enabled, hardware_enabled)) +} + +/// Reads Bluetooth radio state from BlueZ adapters, cross-referenced with rfkill. +/// +/// If BlueZ is not running or no adapters exist, returns +/// `RadioState { enabled: true, hardware_enabled: false }` — "hardware killed" +/// is the honest answer when there is no Bluetooth stack. +pub(crate) async fn bluetooth_radio_state(conn: &Connection) -> Result { + let adapter_paths = match enumerate_bluetooth_adapters(conn).await { + Ok(paths) if !paths.is_empty() => paths, + Ok(_) | Err(_) => { + return Ok(RadioState::new(true, false)); + } + }; + + let mut any_powered = false; + for path in &adapter_paths { + match BluezAdapterProxy::builder(conn) + .path(path.as_str())? + .build() + .await + { + Ok(proxy) => { + if proxy.powered().await.unwrap_or(false) { + any_powered = true; + break; + } + } + Err(e) => { + warn!("failed to query BlueZ adapter {}: {}", path, e); + } + } + } + + let rfkill = read_rfkill(); + let hardware_enabled = !rfkill.bluetooth_hard_block; + + Ok(RadioState::new(any_powered, hardware_enabled)) +} + +/// Returns the combined airplane mode state for all radios. +pub(crate) async fn airplane_mode_state(conn: &Connection) -> Result { + let (wifi, wwan, bt) = futures::future::join3( + wifi_state(conn), + wwan_state(conn), + bluetooth_radio_state(conn), + ) + .await; + + Ok(AirplaneModeState::new(wifi?, wwan?, bt?)) +} + +/// Enables or disables wireless radio (software toggle). +pub(crate) async fn set_wireless_enabled(conn: &Connection, enabled: bool) -> Result<()> { + let nm = NMProxy::new(conn).await?; + Ok(nm.set_wireless_enabled(enabled).await?) +} + +/// Enables or disables WWAN radio (software toggle). +pub(crate) async fn set_wwan_enabled(conn: &Connection, enabled: bool) -> Result<()> { + let nm = NMProxy::new(conn).await?; + Ok(nm.set_wwan_enabled(enabled).await?) +} + +/// Enables or disables Bluetooth radio by toggling all BlueZ adapters. +/// +/// If BlueZ is not running, returns `BluezUnavailable`. +pub(crate) async fn set_bluetooth_radio_enabled(conn: &Connection, enabled: bool) -> Result<()> { + let adapter_paths = enumerate_bluetooth_adapters(conn).await.map_err(|e| { + ConnectionError::BluezUnavailable(format!("failed to enumerate adapters: {e}")) + })?; + + if adapter_paths.is_empty() { + return Err(ConnectionError::BluezUnavailable( + "no Bluetooth adapters found".to_string(), + )); + } + + let mut first_err: Option = None; + for path in &adapter_paths { + let result: Result<()> = async { + let proxy = BluezAdapterProxy::builder(conn) + .path(path.as_str())? + .build() + .await?; + proxy.set_powered(enabled).await?; + Ok(()) + } + .await; + + if let Err(e) = result { + warn!("failed to set Powered on {}: {}", path, e); + if first_err.is_none() { + first_err = Some(e); + } + } + } + + match first_err { + Some(e) => Err(e), + None => Ok(()), + } +} + +/// Flips all three radios in parallel. +/// +/// `enabled = true` means airplane mode **on** (radios **off**). +/// Does not fail fast — attempts all three and returns the first error. +pub(crate) async fn set_airplane_mode(conn: &Connection, enabled: bool) -> Result<()> { + let radio_on = !enabled; + + let (wifi_res, wwan_res, bt_res) = futures::future::join3( + set_wireless_enabled(conn, radio_on), + set_wwan_enabled(conn, radio_on), + set_bluetooth_radio_enabled(conn, radio_on), + ) + .await; + + // Return the first error, but don't short-circuit — all three have been attempted. + wifi_res?; + wwan_res?; + bt_res?; + Ok(()) +} + +/// Enumerates BlueZ Bluetooth adapters via the ObjectManager interface. +/// +/// Returns adapter object paths (e.g. `/org/bluez/hci0`). +async fn enumerate_bluetooth_adapters(conn: &Connection) -> Result> { + let manager = zbus::fdo::ObjectManagerProxy::builder(conn) + .destination("org.bluez")? + .path("/")? + .build() + .await + .map_err(|e| { + ConnectionError::BluezUnavailable(format!("failed to connect to BlueZ: {e}")) + })?; + + let objects = manager.get_managed_objects().await.map_err(|e| { + ConnectionError::BluezUnavailable(format!("failed to enumerate BlueZ objects: {e}")) + })?; + + let adapters: Vec = objects + .into_iter() + .filter(|(_, ifaces)| ifaces.contains_key("org.bluez.Adapter1")) + .map(|(path, _)| path.to_string()) + .collect(); + + Ok(adapters) +} + +/// Reconciles NM's hardware-enabled flag with rfkill. If they disagree, trust rfkill. +fn reconcile_hardware(nm_hardware_enabled: bool, rfkill_hard_block: bool, radio: &str) -> bool { + if nm_hardware_enabled && rfkill_hard_block { + warn!( + "{radio}: NM reports hardware enabled but rfkill reports hard block — trusting rfkill" + ); + return false; + } + nm_hardware_enabled && !rfkill_hard_block +} diff --git a/nmrs/src/core/mod.rs b/nmrs/src/core/mod.rs index 11d4cfb4..066c7cff 100644 --- a/nmrs/src/core/mod.rs +++ b/nmrs/src/core/mod.rs @@ -3,11 +3,13 @@ //! This module contains the internal implementation details for managing //! network connections, devices, scanning, and state monitoring. +pub(crate) mod airplane; pub(crate) mod bluetooth; pub(crate) mod connection; pub(crate) mod connection_settings; pub(crate) mod device; pub(crate) mod ovpn_parser; +pub(crate) mod rfkill; pub(crate) mod scan; pub(crate) mod state_wait; pub(crate) mod vpn; From 2417c7d7987ea289e5d3181269b07feb53909b52 Mon Sep 17 00:00:00 2001 From: Akrm Al-Hakimi Date: Fri, 24 Apr 2026 17:49:53 -0400 Subject: [PATCH 08/14] feat: add airplane-mode API and deprecate old wifi accessors --- nmrs/src/api/network_manager.rs | 90 +++++++++++++++++++++++++++++---- nmrs/src/core/device.rs | 22 -------- nmrs/src/lib.rs | 17 ++++--- 3 files changed, 90 insertions(+), 39 deletions(-) diff --git a/nmrs/src/api/network_manager.rs b/nmrs/src/api/network_manager.rs index c26e0874..70739039 100644 --- a/nmrs/src/api/network_manager.rs +++ b/nmrs/src/api/network_manager.rs @@ -4,7 +4,10 @@ use tokio::sync::watch; use zbus::Connection; use crate::Result; -use crate::api::models::{Device, Network, NetworkInfo, WifiSecurity}; +use crate::api::models::{ + AirplaneModeState, Device, Network, NetworkInfo, RadioState, WifiSecurity, +}; +use crate::core::airplane; use crate::core::bluetooth::connect_bluetooth; use crate::core::connection::{ connect, connect_wired, disconnect, forget_by_name_and_type, get_device_by_interface, @@ -14,8 +17,7 @@ use crate::core::connection_settings::{ get_saved_connection_path, has_saved_connection, list_saved_connections, }; use crate::core::device::{ - is_connecting, list_bluetooth_devices, list_devices, set_wifi_enabled, wait_for_wifi_ready, - wifi_enabled, wifi_hardware_enabled, + is_connecting, list_bluetooth_devices, list_devices, wait_for_wifi_ready, }; use crate::core::scan::{current_network, list_networks, scan_networks}; use crate::core::vpn::{connect_vpn, disconnect_vpn, get_vpn_info, list_vpn_connections}; @@ -89,8 +91,12 @@ use crate::types::constants::device_type; /// let devices = nm.list_devices().await?; /// /// // Control WiFi -/// nm.set_wifi_enabled(false).await?; // Disable WiFi -/// nm.set_wifi_enabled(true).await?; // Enable WiFi +/// nm.set_wireless_enabled(false).await?; // Disable WiFi +/// nm.set_wireless_enabled(true).await?; // Enable WiFi +/// +/// // Check airplane mode +/// let state = nm.airplane_mode_state().await?; +/// println!("Airplane mode: {}", state.is_airplane_mode()); /// # Ok(()) /// # } /// ``` @@ -491,20 +497,86 @@ impl NetworkManager { get_vpn_info(&self.conn, name).await } + /// Returns the combined software/hardware state of the Wi-Fi radio. + /// + /// See [`RadioState`] for the distinction between `enabled` (software) + /// and `hardware_enabled` (rfkill). + pub async fn wifi_state(&self) -> Result { + airplane::wifi_state(&self.conn).await + } + + /// Returns the combined software/hardware state of the WWAN radio. + pub async fn wwan_state(&self) -> Result { + airplane::wwan_state(&self.conn).await + } + + /// Returns the combined software/hardware state of the Bluetooth radio. + /// + /// Reads power state from all BlueZ adapters and cross-references rfkill. + /// If BlueZ is not running or no adapters exist, returns + /// `RadioState { enabled: true, hardware_enabled: false }`. + pub async fn bluetooth_radio_state(&self) -> Result { + airplane::bluetooth_radio_state(&self.conn).await + } + + /// Returns the aggregated airplane-mode state across all radios. + /// + /// Fans out to Wi-Fi, WWAN, and Bluetooth concurrently and returns + /// an [`AirplaneModeState`] snapshot. + pub async fn airplane_mode_state(&self) -> Result { + airplane::airplane_mode_state(&self.conn).await + } + + /// Enables or disables the Wi-Fi radio (software toggle). + /// + /// This replaces the deprecated [`set_wifi_enabled`](Self::set_wifi_enabled). + /// If the radio is hardware-killed, NM accepts the write but the radio + /// remains off until hardware is unkilled. + pub async fn set_wireless_enabled(&self, enabled: bool) -> Result<()> { + airplane::set_wireless_enabled(&self.conn, enabled).await + } + + /// Enables or disables the WWAN (mobile broadband) radio. + /// + /// Writes the `WwanEnabled` property on NetworkManager. + pub async fn set_wwan_enabled(&self, enabled: bool) -> Result<()> { + airplane::set_wwan_enabled(&self.conn, enabled).await + } + + /// Enables or disables the Bluetooth radio by toggling all BlueZ adapters. + /// + /// Returns [`ConnectionError::BluezUnavailable`] if BlueZ is not running + /// or no adapters exist. + pub async fn set_bluetooth_radio_enabled(&self, enabled: bool) -> Result<()> { + airplane::set_bluetooth_radio_enabled(&self.conn, enabled).await + } + + /// Flips all three radios in one call. + /// + /// **`enabled = true` means airplane mode is on, i.e. radios are off.** + /// + /// Does not fail fast: attempts all three toggles concurrently and + /// returns the first error at the end, if any. + pub async fn set_airplane_mode(&self, enabled: bool) -> Result<()> { + airplane::set_airplane_mode(&self.conn, enabled).await + } + /// Returns whether Wi-Fi is currently enabled. + #[deprecated(since = "2.5.0", note = "use `wifi_state()` instead")] pub async fn wifi_enabled(&self) -> Result { - wifi_enabled(&self.conn).await + Ok(self.wifi_state().await?.enabled) } /// Enables or disables Wi-Fi. + #[deprecated(since = "2.5.0", note = "use `set_wireless_enabled()` instead")] pub async fn set_wifi_enabled(&self, value: bool) -> Result<()> { - set_wifi_enabled(&self.conn, value).await + self.set_wireless_enabled(value).await } /// Returns whether wireless hardware is currently enabled. - /// Reflects rfkill state which helps check if the radio is enabled or blocked. + #[deprecated(since = "2.5.0", note = "use `wifi_state()` instead")] pub async fn wifi_hardware_enabled(&self) -> Result { - wifi_hardware_enabled(&self.conn).await + Ok(self.wifi_state().await?.hardware_enabled) } /// Waits for a Wi-Fi device to become ready (disconnected or activated). diff --git a/nmrs/src/core/device.rs b/nmrs/src/core/device.rs index cda3eed2..debb5635 100644 --- a/nmrs/src/core/device.rs +++ b/nmrs/src/core/device.rs @@ -262,28 +262,6 @@ pub(crate) async fn wait_for_wifi_ready(conn: &Connection) -> Result<()> { Err(ConnectionError::NoWifiDevice) } -/// Enables or disables Wi-Fi globally. -/// -/// This is equivalent to the Wi-Fi toggle in system settings. -/// When disabled, all Wi-Fi connections are terminated and -/// no scanning occurs. -pub(crate) async fn set_wifi_enabled(conn: &Connection, value: bool) -> Result<()> { - let nm = NMProxy::new(conn).await?; - Ok(nm.set_wireless_enabled(value).await?) -} - -/// Returns whether Wi-Fi is currently enabled. -pub(crate) async fn wifi_enabled(conn: &Connection) -> Result { - let nm = NMProxy::new(conn).await?; - Ok(nm.wireless_enabled().await?) -} - -/// Returns whether wireless hardware is enabled. -pub(crate) async fn wifi_hardware_enabled(conn: &Connection) -> Result { - let nm = NMProxy::new(conn).await?; - Ok(nm.wireless_hardware_enabled().await?) -} - #[cfg(test)] mod tests { use super::*; diff --git a/nmrs/src/lib.rs b/nmrs/src/lib.rs index 9d4ca655..da3ced01 100644 --- a/nmrs/src/lib.rs +++ b/nmrs/src/lib.rs @@ -211,8 +211,8 @@ //! } //! //! // Enable/disable WiFi -//! nm.set_wifi_enabled(false).await?; -//! nm.set_wifi_enabled(true).await?; +//! nm.set_wireless_enabled(false).await?; +//! nm.set_wireless_enabled(true).await?; //! # Ok(()) //! # } //! ``` @@ -347,12 +347,13 @@ pub mod models { // Re-export commonly used types at crate root for convenience #[allow(deprecated)] pub use api::models::{ - ActiveConnectionState, BluetoothDevice, BluetoothIdentity, BluetoothNetworkRole, - ConnectionError, ConnectionOptions, ConnectionStateReason, Device, DeviceState, DeviceType, - EapMethod, EapOptions, Network, NetworkInfo, OpenVpnAuthType, OpenVpnCompression, - OpenVpnConfig, OpenVpnProxy, Phase2, StateReason, TimeoutConfig, VpnConfig, VpnConfiguration, - VpnConnection, VpnConnectionInfo, VpnCredentials, VpnDetails, VpnRoute, VpnType, WifiSecurity, - WireGuardConfig, WireGuardPeer, connection_state_reason_to_error, reason_to_error, + ActiveConnectionState, AirplaneModeState, BluetoothDevice, BluetoothIdentity, + BluetoothNetworkRole, ConnectionError, ConnectionOptions, ConnectionStateReason, Device, + DeviceState, DeviceType, EapMethod, EapOptions, Network, NetworkInfo, OpenVpnAuthType, + OpenVpnCompression, OpenVpnConfig, OpenVpnProxy, Phase2, RadioState, StateReason, + TimeoutConfig, VpnConfig, VpnConfiguration, VpnConnection, VpnConnectionInfo, VpnCredentials, + VpnDetails, VpnRoute, VpnType, WifiSecurity, WireGuardConfig, WireGuardPeer, + connection_state_reason_to_error, reason_to_error, }; pub use api::network_manager::NetworkManager; From 9604d775a625a6d5370651c4945e633e6f5984bf Mon Sep 17 00:00:00 2001 From: Akrm Al-Hakimi Date: Fri, 24 Apr 2026 17:56:09 -0400 Subject: [PATCH 09/14] chore: bump nmrs version to 2.5.0 --- nmrs/Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nmrs/Cargo.toml b/nmrs/Cargo.toml index ee427896..ea86b0a5 100644 --- a/nmrs/Cargo.toml +++ b/nmrs/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "nmrs" -version = "2.4.0" +version = "2.5.0" authors = ["Akrm Al-Hakimi "] edition.workspace = true rust-version = "1.94.0" From b5934efb6ba9acd99b910cc77a7bce5d36a4967b Mon Sep 17 00:00:00 2001 From: Akrm Al-Hakimi Date: Fri, 24 Apr 2026 17:56:12 -0400 Subject: [PATCH 10/14] refactor: migrate GUI from deprecated wifi accessors --- nmrs-gui/src/ui/header.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/nmrs-gui/src/ui/header.rs b/nmrs-gui/src/ui/header.rs index a5144f9f..b794e6b2 100644 --- a/nmrs-gui/src/ui/header.rs +++ b/nmrs-gui/src/ui/header.rs @@ -180,7 +180,7 @@ pub fn build_header( ctx.stack.set_visible_child_name("loading"); clear_children(&list_container); - match ctx.nm.wifi_enabled().await { + match ctx.nm.wifi_state().await.map(|s| s.enabled) { Ok(enabled) => { wifi_switch.set_active(enabled); if enabled { @@ -207,7 +207,7 @@ pub fn build_header( glib::MainContext::default().spawn_local(async move { clear_children(&list_container); - if let Err(err) = ctx.nm.set_wifi_enabled(sw.is_active()).await { + if let Err(err) = ctx.nm.set_wireless_enabled(sw.is_active()).await { ctx.status.set_text(&format!("Error setting Wi-Fi: {err}")); return; } From 7eef6a70f8a083f7741708f56bad955cd49d9979 Mon Sep 17 00:00:00 2001 From: Akrm Al-Hakimi Date: Fri, 24 Apr 2026 17:56:15 -0400 Subject: [PATCH 11/14] refactor: migrate integration tests from deprecated wifi accessors --- nmrs/tests/integration_test.rs | 54 ++++++++++++++++++---------------- 1 file changed, 29 insertions(+), 25 deletions(-) diff --git a/nmrs/tests/integration_test.rs b/nmrs/tests/integration_test.rs index 7ba99bc0..b6e75a6b 100644 --- a/nmrs/tests/integration_test.rs +++ b/nmrs/tests/integration_test.rs @@ -98,18 +98,20 @@ async fn test_wifi_enabled_get_set() { require_wifi!(&nm); let initial_state = nm - .wifi_enabled() + .wifi_state() .await - .expect("Failed to get WiFi enabled state"); + .expect("Failed to get WiFi enabled state") + .enabled; - match nm.set_wifi_enabled(!initial_state).await { + match nm.set_wireless_enabled(!initial_state).await { Ok(_) => { sleep(Duration::from_millis(500)).await; let new_state = nm - .wifi_enabled() + .wifi_state() .await - .expect("Failed to get WiFi enabled state after toggle"); + .expect("Failed to get WiFi enabled state after toggle") + .enabled; if new_state == initial_state { eprintln!( @@ -125,16 +127,17 @@ async fn test_wifi_enabled_get_set() { } } - nm.set_wifi_enabled(initial_state) + nm.set_wireless_enabled(initial_state) .await .expect("Failed to restore WiFi enabled state"); sleep(Duration::from_millis(500)).await; let restored_state = nm - .wifi_enabled() + .wifi_state() .await - .expect("Failed to get WiFi enabled state after restore"); + .expect("Failed to get WiFi enabled state after restore") + .enabled; assert_eq!( restored_state, initial_state, "WiFi state should be restored to original" @@ -152,10 +155,11 @@ async fn test_wifi_hardware_enabled() { require_wifi!(&nm); // Read-only property — just verify the call succeeds - let _ = nm - .wifi_hardware_enabled() + let state = nm + .wifi_state() .await - .expect("Failed to get WiFi hardware enabled state"); + .expect("Failed to get WiFi radio state"); + let _ = state.hardware_enabled; } /// Test waiting for WiFi to be ready @@ -169,7 +173,7 @@ async fn test_wait_for_wifi_ready() { require_wifi!(&nm); // Ensure WiFi is enabled - nm.set_wifi_enabled(true) + nm.set_wireless_enabled(true) .await .expect("Failed to enable WiFi"); @@ -200,7 +204,7 @@ async fn test_scan_networks() { require_wifi!(&nm); // Ensure WiFi is enabled - nm.set_wifi_enabled(true) + nm.set_wireless_enabled(true) .await .expect("Failed to enable WiFi"); @@ -233,7 +237,7 @@ async fn test_list_networks() { require_wifi!(&nm); // Ensure WiFi is enabled - nm.set_wifi_enabled(true) + nm.set_wireless_enabled(true) .await .expect("Failed to enable WiFi"); @@ -271,7 +275,7 @@ async fn test_current_ssid() { require_wifi!(&nm); // Ensure WiFi is enabled - nm.set_wifi_enabled(true) + nm.set_wireless_enabled(true) .await .expect("Failed to enable WiFi"); @@ -298,7 +302,7 @@ async fn test_current_connection_info() { require_wifi!(&nm); // Ensure WiFi is enabled - nm.set_wifi_enabled(true) + nm.set_wireless_enabled(true) .await .expect("Failed to enable WiFi"); @@ -325,7 +329,7 @@ async fn test_show_details() { require_wifi!(&nm); // Ensure WiFi is enabled - nm.set_wifi_enabled(true) + nm.set_wireless_enabled(true) .await .expect("Failed to enable WiFi"); @@ -428,7 +432,7 @@ async fn test_connect_open_network() { require_wifi!(&nm); // Ensure WiFi is enabled - nm.set_wifi_enabled(true) + nm.set_wireless_enabled(true) .await .expect("Failed to enable WiFi"); @@ -488,7 +492,7 @@ async fn test_connect_psk_network_with_empty_password() { require_wifi!(&nm); // Ensure WiFi is enabled - nm.set_wifi_enabled(true) + nm.set_wireless_enabled(true) .await .expect("Failed to enable WiFi"); @@ -640,7 +644,7 @@ async fn test_network_properties() { require_wifi!(&nm); // Ensure WiFi is enabled - nm.set_wifi_enabled(true) + nm.set_wireless_enabled(true) .await .expect("Failed to enable WiFi"); @@ -692,7 +696,7 @@ async fn test_multiple_scan_requests() { require_wifi!(&nm); // Ensure WiFi is enabled - nm.set_wifi_enabled(true) + nm.set_wireless_enabled(true) .await .expect("Failed to enable WiFi"); @@ -731,17 +735,17 @@ async fn test_concurrent_operations() { require_wifi!(&nm); // Ensure WiFi is enabled - nm.set_wifi_enabled(true) + nm.set_wireless_enabled(true) .await .expect("Failed to enable WiFi"); // Run multiple operations concurrently - let (devices_result, wifi_enabled_result, networks_result) = - tokio::join!(nm.list_devices(), nm.wifi_enabled(), nm.list_networks()); + let (devices_result, wifi_state_result, networks_result) = + tokio::join!(nm.list_devices(), nm.wifi_state(), nm.list_networks()); // All should succeed assert!(devices_result.is_ok(), "list_devices should succeed"); - assert!(wifi_enabled_result.is_ok(), "wifi_enabled should succeed"); + assert!(wifi_state_result.is_ok(), "wifi_state should succeed"); // networks_result may fail if WiFi is not ready, which is acceptable let _ = networks_result; } From 2002e087bacfa291568685d29c9890d693b5e019 Mon Sep 17 00:00:00 2001 From: Akrm Al-Hakimi Date: Fri, 24 Apr 2026 17:56:17 -0400 Subject: [PATCH 12/14] fix: resolve broken intra-doc link for BluezUnavailable --- nmrs/src/api/network_manager.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nmrs/src/api/network_manager.rs b/nmrs/src/api/network_manager.rs index 70739039..cf159750 100644 --- a/nmrs/src/api/network_manager.rs +++ b/nmrs/src/api/network_manager.rs @@ -545,7 +545,7 @@ impl NetworkManager { /// Enables or disables the Bluetooth radio by toggling all BlueZ adapters. /// - /// Returns [`ConnectionError::BluezUnavailable`] if BlueZ is not running + /// Returns [`BluezUnavailable`](crate::ConnectionError::BluezUnavailable) if BlueZ is not running /// or no adapters exist. pub async fn set_bluetooth_radio_enabled(&self, enabled: bool) -> Result<()> { airplane::set_bluetooth_radio_enabled(&self.conn, enabled).await From 662213d9a915d1acce06e734c81f4b5851fe6d9f Mon Sep 17 00:00:00 2001 From: Akrm Al-Hakimi Date: Fri, 24 Apr 2026 17:56:37 -0400 Subject: [PATCH 13/14] chore(deps): update Cargo.lock --- Cargo.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.lock b/Cargo.lock index f6e3610d..57a54fb5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -963,7 +963,7 @@ dependencies = [ [[package]] name = "nmrs" -version = "2.4.0" +version = "2.5.0" dependencies = [ "async-trait", "base64", From b4501e948f4bd53be053cb0855aae2265a1d6f79 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 24 Apr 2026 21:57:44 +0000 Subject: [PATCH 14/14] fix(nix): update cargoHash --- package.nix | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.nix b/package.nix index c25bf929..925e757a 100644 --- a/package.nix +++ b/package.nix @@ -19,7 +19,7 @@ rustPlatform.buildRustPackage { src = ./.; - cargoHash = "sha256-UvtbiWFTXtccdfTbSWN35WPs/hepCeUcrn1gX2YSOoI="; + cargoHash = "sha256-PCheDQbdYM9IaKmiffg+cNiJ/3IB6/Nce+jNOVkchrE="; nativeBuildInputs = [ pkg-config