diff --git a/Cargo.lock b/Cargo.lock index 6fe9551..a5151ae 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -740,6 +740,12 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "discard" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "212d0f5754cb6769937f4501cc0e67f4f4483c8d2c3e1e922ee9edbe4ab4c7c0" + [[package]] name = "dlib" version = "0.5.2" @@ -1006,6 +1012,22 @@ dependencies = [ "syn 2.0.48", ] +[[package]] +name = "futures-signals" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b175f2f6600dd81d92d20cf10872b03ea9df6b2513ca7f672341260dacb1ab2" +dependencies = [ + "discard", + "futures-channel", + "futures-core", + "futures-util", + "gensym", + "log", + "pin-project", + "serde", +] + [[package]] name = "futures-sink" version = "0.3.30" @@ -1104,6 +1126,18 @@ dependencies = [ "version_check", ] +[[package]] +name = "gensym" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "913dce4c5f06c2ea40fc178c06f777ac89fc6b1383e90c254fafb1abe4ba3c82" +dependencies = [ + "proc-macro2", + "quote 1.0.35", + "syn 2.0.48", + "uuid", +] + [[package]] name = "getrandom" version = "0.2.9" @@ -1614,6 +1648,7 @@ dependencies = [ "ctrlc", "dirs", "futures-lite 2.3.0", + "futures-signals", "futures-util", "glib", "gtk", @@ -3565,6 +3600,15 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "711b9620af191e0cdc7468a8d14e709c3dcdb115b36f838e601583af800a370a" +[[package]] +name = "uuid" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5de17fd2f7da591098415cff336e12965a28061ddace43b59cb3c430179c9439" +dependencies = [ + "getrandom", +] + [[package]] name = "valuable" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 58dfbde..b4fbb0a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,6 +20,7 @@ default = [ "ipc", "launcher", "music+all", + "networkmanager", "notifications", "sys_info", "tray", @@ -61,6 +62,8 @@ music = ["regex"] "music+mpris" = ["music", "mpris"] "music+mpd" = ["music", "mpd-utils"] +networkmanager = ["futures-lite", "futures-signals", "zbus"] + notifications = ["zbus"] sys_info = ["sysinfo", "regex"] @@ -136,6 +139,9 @@ chrono = { version = "0.4.38", optional = true, default-features = false, featur mpd-utils = { version = "0.2.1", optional = true } mpris = { version = "2.0.1", optional = true } +# networkmanager +futures-signals = { version = "0.3.33", optional = true } + # sys_info sysinfo = { version = "0.29.11", optional = true } @@ -154,11 +160,11 @@ hyprland = { version = "0.4.0-alpha.2", features = ["silent"], optional = true } futures-util = { version = "0.3.30", optional = true } # shared -futures-lite = { version = "2.3.0", optional = true } # workspaces, upower +futures-lite = { version = "2.3.0", optional = true } # networkmanager, upower, workspaces regex = { version = "1.10.5", default-features = false, features = [ "std", ], optional = true } # music, sys_info -zbus = { version = "3.15.2", default-features = false, features = ["tokio"], optional = true } # notifications, upower +zbus = { version = "3.15.2", default-features = false, features = ["tokio"], optional = true } # networkmanager, notifications, upower # schema schemars = { version = "0.8.21", optional = true } diff --git a/docs/modules/Networkmanager.md b/docs/modules/Networkmanager.md new file mode 100644 index 0000000..ec3a8fd --- /dev/null +++ b/docs/modules/Networkmanager.md @@ -0,0 +1,74 @@ +Displays the current network connection state of NetworkManager. +Supports wired ethernet, wifi, cellular data and VPN connections among others. + +> [!NOTE] +> This module uses NetworkManager's so-called primary connection, and therefore inherits its limitation of only being able to display the "top-level" connection. +> For example, if we have a VPN connection over a wifi connection it will only display the former, until it is disconnected, at which point it will display the latter. +> A solution to this is currently in the works. + +## Configuration + +> Type: `networkmanager` + +| Name | Type | Default | Description | +|-------------|-----------|---------|-------------------------| +| `icon_size` | `integer` | `24` | Size to render icon at. | + +
+ JSON + + ```json + { + "end": [ + { + "type": "networkmanager", + "icon_size": 32 + } + ] + } + ``` +
+ +
+ TOML + + ```toml + [[end]] + type = "networkmanager" + icon_size = 32 + ``` +
+ +
+ YAML + + ```yaml + end: + - type: "networkmanager" + icon_size: 32 + ``` +
+ +
+ Corn + + ```corn + { + end = [ + { + type = "networkmanager" + icon_size = 32 + } + ] + } + ``` +
+ +## Styling + +| Selector | Description | +|------------------------|----------------------------------| +| `.networkmanager` | NetworkManager widget container. | +| `.networkmanger .icon` | NetworkManager widget icon. | + +For more information on styling, please see the [styling guide](styling-guide). diff --git a/src/clients/mod.rs b/src/clients/mod.rs index b44f0df..d286bdb 100644 --- a/src/clients/mod.rs +++ b/src/clients/mod.rs @@ -12,6 +12,8 @@ pub mod compositor; pub mod lua; #[cfg(feature = "music")] pub mod music; +#[cfg(feature = "networkmanager")] +pub mod networkmanager; #[cfg(feature = "notifications")] pub mod swaync; #[cfg(feature = "tray")] @@ -35,6 +37,8 @@ pub struct Clients { lua: Option>, #[cfg(feature = "music")] music: std::collections::HashMap>, + #[cfg(feature = "networkmanager")] + networkmanager: Option>, #[cfg(feature = "notifications")] notifications: Option>, #[cfg(feature = "tray")] @@ -96,6 +100,18 @@ impl Clients { .clone() } + #[cfg(feature = "networkmanager")] + pub fn networkmanager(&mut self) -> ClientResult { + match &self.networkmanager { + Some(client) => Ok(client.clone()), + None => { + let client = networkmanager::create_client()?; + self.networkmanager = Some(client.clone()); + Ok(client) + } + } + } + #[cfg(feature = "notifications")] pub fn notifications(&mut self) -> ClientResult { let client = match &self.notifications { diff --git a/src/clients/networkmanager.rs b/src/clients/networkmanager.rs new file mode 100644 index 0000000..6257780 --- /dev/null +++ b/src/clients/networkmanager.rs @@ -0,0 +1,169 @@ +use std::sync::Arc; + +use color_eyre::Result; +use futures_signals::signal::{Mutable, MutableSignalCloned}; +use tracing::error; +use zbus::blocking::fdo::PropertiesProxy; +use zbus::blocking::Connection; +use zbus::{ + dbus_proxy, + names::InterfaceName, + zvariant::{ObjectPath, Str}, +}; + +use crate::{register_fallible_client, spawn_blocking}; + +const DBUS_BUS: &str = "org.freedesktop.NetworkManager"; +const DBUS_PATH: &str = "/org/freedesktop/NetworkManager"; +const DBUS_INTERFACE: &str = "org.freedesktop.NetworkManager"; + +#[derive(Debug)] +pub struct Client { + client_state: Mutable, + interface_name: InterfaceName<'static>, + dbus_connection: Connection, + props_proxy: PropertiesProxy<'static>, +} + +#[derive(Clone, Debug)] +pub enum ClientState { + WiredConnected, + WifiConnected, + CellularConnected, + VpnConnected, + WifiDisconnected, + Offline, + Unknown, +} + +#[dbus_proxy( + default_service = "org.freedesktop.NetworkManager", + interface = "org.freedesktop.NetworkManager", + default_path = "/org/freedesktop/NetworkManager" +)] +trait NetworkManagerDbus { + #[dbus_proxy(property)] + fn active_connections(&self) -> Result>; + + #[dbus_proxy(property)] + fn devices(&self) -> Result>; + + #[dbus_proxy(property)] + fn networking_enabled(&self) -> Result; + + #[dbus_proxy(property)] + fn primary_connection(&self) -> Result; + + #[dbus_proxy(property)] + fn primary_connection_type(&self) -> Result; + + #[dbus_proxy(property)] + fn wireless_enabled(&self) -> Result; +} + +impl Client { + fn new() -> Result { + let client_state = Mutable::new(ClientState::Unknown); + let dbus_connection = Connection::system()?; + let interface_name = InterfaceName::from_static_str(DBUS_INTERFACE)?; + let props_proxy = PropertiesProxy::builder(&dbus_connection) + .destination(DBUS_BUS)? + .path(DBUS_PATH)? + .build()?; + + Ok(Self { + client_state, + interface_name, + dbus_connection, + props_proxy, + }) + } + + fn run(&self) -> Result<()> { + let proxy = NetworkManagerDbusProxyBlocking::new(&self.dbus_connection)?; + + let mut primary_connection = proxy.primary_connection()?; + let mut primary_connection_type = proxy.primary_connection_type()?; + let mut wireless_enabled = proxy.wireless_enabled()?; + + self.client_state.set(determine_state( + &primary_connection, + &primary_connection_type, + wireless_enabled, + )); + + for change in self.props_proxy.receive_properties_changed()? { + let args = change.args()?; + if args.interface_name != self.interface_name { + continue; + } + + let changed_props = args.changed_properties; + let mut relevant_prop_changed = false; + + if changed_props.contains_key("PrimaryConnection") { + primary_connection = proxy.primary_connection()?; + relevant_prop_changed = true; + } + if changed_props.contains_key("PrimaryConnectionType") { + primary_connection_type = proxy.primary_connection_type()?; + relevant_prop_changed = true; + } + if changed_props.contains_key("WirelessEnabled") { + wireless_enabled = proxy.wireless_enabled()?; + relevant_prop_changed = true; + } + + if relevant_prop_changed { + self.client_state.set(determine_state( + &primary_connection, + &primary_connection_type, + wireless_enabled, + )); + } + } + + Ok(()) + } + + pub fn subscribe(&self) -> MutableSignalCloned { + self.client_state.signal_cloned() + } +} + +pub fn create_client() -> Result> { + let client = Arc::new(Client::new()?); + { + let client = client.clone(); + spawn_blocking(move || { + if let Err(error) = client.run() { + error!("{}", error); + }; + }); + } + Ok(client) +} + +fn determine_state( + primary_connection: &str, + primary_connection_type: &str, + wireless_enabled: bool, +) -> ClientState { + if primary_connection == "/" { + if wireless_enabled { + ClientState::WifiDisconnected + } else { + ClientState::Offline + } + } else { + match primary_connection_type { + "802-3-ethernet" | "adsl" | "pppoe" => ClientState::WiredConnected, + "802-11-olpc-mesh" | "802-11-wireless" | "wifi-p2p" => ClientState::WifiConnected, + "cdma" | "gsm" | "wimax" => ClientState::CellularConnected, + "vpn" | "wireguard" => ClientState::VpnConnected, + _ => ClientState::Unknown, + } + } +} + +register_fallible_client!(Client, networkmanager); diff --git a/src/config/mod.rs b/src/config/mod.rs index e277bac..12b7164 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -16,6 +16,8 @@ use crate::modules::label::LabelModule; use crate::modules::launcher::LauncherModule; #[cfg(feature = "music")] use crate::modules::music::MusicModule; +#[cfg(feature = "networkmanager")] +use crate::modules::networkmanager::NetworkManagerModule; #[cfg(feature = "notifications")] use crate::modules::notifications::NotificationsModule; use crate::modules::script::ScriptModule; @@ -60,6 +62,8 @@ pub enum ModuleConfig { Launcher(Box), #[cfg(feature = "music")] Music(Box), + #[cfg(feature = "networkmanager")] + NetworkManager(Box), #[cfg(feature = "notifications")] Notifications(Box), Script(Box), @@ -103,6 +107,8 @@ impl ModuleConfig { Self::Launcher(module) => create!(module), #[cfg(feature = "music")] Self::Music(module) => create!(module), + #[cfg(feature = "networkmanager")] + Self::NetworkManager(module) => create!(module), #[cfg(feature = "notifications")] Self::Notifications(module) => create!(module), Self::Script(module) => create!(module), diff --git a/src/modules/mod.rs b/src/modules/mod.rs index 2a15252..98e272d 100644 --- a/src/modules/mod.rs +++ b/src/modules/mod.rs @@ -36,6 +36,8 @@ pub mod label; pub mod launcher; #[cfg(feature = "music")] pub mod music; +#[cfg(feature = "networkmanager")] +pub mod networkmanager; #[cfg(feature = "notifications")] pub mod notifications; pub mod script; diff --git a/src/modules/networkmanager.rs b/src/modules/networkmanager.rs new file mode 100644 index 0000000..1f9debd --- /dev/null +++ b/src/modules/networkmanager.rs @@ -0,0 +1,88 @@ +use color_eyre::Result; +use futures_lite::StreamExt; +use futures_signals::signal::SignalExt; +use gtk::prelude::ContainerExt; +use gtk::{Box as GtkBox, Image, Orientation}; +use serde::Deserialize; +use tokio::sync::mpsc::Receiver; + +use crate::clients::networkmanager::{Client, ClientState}; +use crate::config::CommonConfig; +use crate::gtk_helpers::IronbarGtkExt; +use crate::image::ImageProvider; +use crate::modules::{Module, ModuleInfo, ModuleParts, ModuleUpdateEvent, WidgetContext}; +use crate::{glib_recv, module_impl, send_async, spawn}; + +#[derive(Debug, Deserialize, Clone)] +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +pub struct NetworkManagerModule { + #[serde(default = "default_icon_size")] + icon_size: i32, + + #[serde(flatten)] + pub common: Option, +} + +const fn default_icon_size() -> i32 { + 24 +} + +impl Module for NetworkManagerModule { + type SendMessage = ClientState; + type ReceiveMessage = (); + + fn spawn_controller( + &self, + _: &ModuleInfo, + context: &WidgetContext, + _: Receiver<()>, + ) -> Result<()> { + let client = context.try_client::()?; + let mut client_signal = client.subscribe().to_stream(); + let widget_transmitter = context.tx.clone(); + + spawn(async move { + while let Some(state) = client_signal.next().await { + send_async!(widget_transmitter, ModuleUpdateEvent::Update(state)); + } + }); + + Ok(()) + } + + fn into_widget( + self, + context: WidgetContext, + info: &ModuleInfo, + ) -> Result> { + let container = GtkBox::new(Orientation::Horizontal, 0); + let icon = Image::new(); + icon.add_class("icon"); + container.add(&icon); + + let icon_theme = info.icon_theme.clone(); + + let initial_icon_name = "content-loading-symbolic"; + ImageProvider::parse(initial_icon_name, &icon_theme, false, self.icon_size) + .map(|provider| provider.load_into_image(icon.clone())); + + let widget_receiver = context.subscribe(); + glib_recv!(widget_receiver, state => { + let icon_name = match state { + ClientState::WiredConnected => "network-wired-symbolic", + ClientState::WifiConnected => "network-wireless-symbolic", + ClientState::CellularConnected => "network-cellular-symbolic", + ClientState::VpnConnected => "network-vpn-symbolic", + ClientState::WifiDisconnected => "network-wireless-acquiring-symbolic", + ClientState::Offline => "network-wireless-disabled-symbolic", + ClientState::Unknown => "dialog-question-symbolic", + }; + ImageProvider::parse(icon_name, &icon_theme, false, self.icon_size) + .map(|provider| provider.load_into_image(icon.clone())); + }); + + Ok(ModuleParts::new(container, None)) + } + + module_impl!("networkmanager"); +}