From 8765fbb7484eef5e045cbc8923c1ad11aedfd3cd Mon Sep 17 00:00:00 2001 From: Akrm Al-Hakimi Date: Sun, 14 Dec 2025 13:45:09 -0500 Subject: [PATCH 1/3] feat(#92): support wired (ethernet) devices --- nmrs-gui/src/style.css | 27 ++++ nmrs-gui/src/ui/header.rs | 251 +++++++++++++++++++++++++++---- nmrs-gui/src/ui/mod.rs | 148 +++++++++++++++--- nmrs-gui/src/ui/networks.rs | 16 +- nmrs-gui/src/ui/wired_devices.rs | 187 +++++++++++++++++++++++ nmrs-gui/src/ui/wired_page.rs | 136 +++++++++++++++++ nmrs/src/connection.rs | 88 ++++++++++- nmrs/src/constants.rs | 6 +- nmrs/src/device.rs | 12 +- nmrs/src/device_monitor.rs | 83 ++++++++++ nmrs/src/lib.rs | 1 + nmrs/src/models.rs | 16 +- nmrs/src/network_manager.rs | 66 +++++++- nmrs/src/proxies/device.rs | 4 +- nmrs/src/proxies/main_nm.rs | 12 ++ nmrs/src/proxies/mod.rs | 1 + nmrs/src/proxies/wired.rs | 17 +++ nmrs/src/wifi_builders.rs | 60 ++++++++ nmrs/tests/integration_test.rs | 82 +++++++++- 19 files changed, 1137 insertions(+), 76 deletions(-) create mode 100644 nmrs-gui/src/ui/wired_devices.rs create mode 100644 nmrs-gui/src/ui/wired_page.rs create mode 100644 nmrs/src/device_monitor.rs create mode 100644 nmrs/src/proxies/wired.rs diff --git a/nmrs-gui/src/style.css b/nmrs-gui/src/style.css index d06fe8ac..10d3eaa0 100644 --- a/nmrs-gui/src/style.css +++ b/nmrs-gui/src/style.css @@ -285,3 +285,30 @@ popover row label { color: var(--text-primary); } +/* Wired device styles */ +.wired-section-header, +.wireless-section-header { + font-weight: 700; + font-size: 14px; + text-transform: uppercase; + color: var(--text-secondary); + letter-spacing: 0.8px; + opacity: 0.8; +} + +.wired-devices-list { + background: var(--bg-primary); + margin-bottom: 8px; +} + +.wired-icon { + color: var(--success-color); + opacity: 0.8; + margin-left: 6px; +} + +.device-separator { + background: var(--border-color); + opacity: 0.5; +} + diff --git a/nmrs-gui/src/ui/header.rs b/nmrs-gui/src/ui/header.rs index a2dbab46..a9fcb88e 100644 --- a/nmrs-gui/src/ui/header.rs +++ b/nmrs-gui/src/ui/header.rs @@ -1,13 +1,16 @@ use glib::clone; use gtk::STYLE_PROVIDER_PRIORITY_USER; use gtk::prelude::*; -use gtk::{Box as GtkBox, HeaderBar, Label, ListBox, Orientation, Switch, glib}; +use gtk::{Align, Box as GtkBox, HeaderBar, Label, ListBox, Orientation, Switch, glib}; use std::cell::Cell; use std::collections::HashSet; use std::rc::Rc; +use nmrs::models; + use crate::ui::networks; use crate::ui::networks::NetworksContext; +use crate::ui::wired_devices; pub struct ThemeDef { pub key: &'static str, @@ -103,6 +106,7 @@ pub fn build_header( let refresh_btn = gtk::Button::from_icon_name("view-refresh-symbolic"); refresh_btn.add_css_class("refresh-btn"); + refresh_btn.set_tooltip_text(Some("Refresh networks and devices")); header.pack_end(&refresh_btn); refresh_btn.connect_clicked(clone!( #[weak] @@ -233,6 +237,86 @@ pub async fn refresh_networks( clear_children(list_container); ctx.status.set_text("Scanning..."); + // Fetch wired devices first + match ctx.nm.list_wired_devices().await { + Ok(wired_devices) => { + // eprintln!("Found {} wired devices total", wired_devices.len()); + + let available_devices: Vec<_> = wired_devices + .into_iter() + .filter(|dev| { + let show = matches!( + dev.state, + models::DeviceState::Activated + | models::DeviceState::Disconnected + | models::DeviceState::Prepare + | models::DeviceState::Config + ); + /* eprintln!( + " - {} ({}): {} -> {}", + dev.interface, + dev.device_type, + dev.state, + if show { "SHOW" } else { "HIDE" } + ); */ + show + }) + .collect(); + + /* eprintln!( + "Showing {} available wired devices", + available_devices.len() + ); */ + + if !available_devices.is_empty() { + let wired_header = Label::new(Some("Wired")); + wired_header.add_css_class("section-header"); + wired_header.add_css_class("wired-section-header"); + wired_header.set_halign(Align::Start); + wired_header.set_margin_top(8); + wired_header.set_margin_bottom(4); + wired_header.set_margin_start(12); + list_container.append(&wired_header); + + // Create a wired devices context + let wired_ctx = wired_devices::WiredDevicesContext { + nm: ctx.nm.clone(), + on_success: ctx.on_success.clone(), + status: ctx.status.clone(), + stack: ctx.stack.clone(), + parent_window: ctx.parent_window.clone(), + }; + let wired_ctx = Rc::new(wired_ctx); + + let wired_list = wired_devices::wired_devices_view( + wired_ctx, + &available_devices, + ctx.wired_details_page.clone(), + ); + wired_list.add_css_class("wired-devices-list"); + list_container.append(&wired_list); + + let separator = gtk::Separator::new(Orientation::Horizontal); + separator.add_css_class("device-separator"); + separator.set_margin_top(12); + separator.set_margin_bottom(12); + list_container.append(&separator); + } + } + Err(e) => { + eprintln!("Failed to list wired devices: {}", e); + } + } + + let wireless_header = Label::new(Some("Wireless")); + wireless_header.add_css_class("section-header"); + wireless_header.add_css_class("wireless-section-header"); + wireless_header.set_halign(Align::Start); + wireless_header.set_margin_top(8); + wireless_header.set_margin_bottom(4); + wireless_header.set_margin_start(12); + list_container.append(&wireless_header); + if let Err(err) = ctx.nm.scan_networks().await { ctx.status.set_text(&format!("Scan failed: {err}")); is_scanning.set(false); @@ -254,41 +338,19 @@ pub async fn refresh_networks( let current_conn = ctx.nm.current_connection_info().await; let (current_ssid, current_band) = if let Some((ssid, freq)) = current_conn { let ssid_str = ssid.clone(); - let band: Option = freq.map(|f| { - if (2400..=2500).contains(&f) { - "2.4GHz".to_string() - } else if (5000..=6000).contains(&f) { - "5GHz".to_string() - } else if (5925..=7125).contains(&f) { - "6GHz".to_string() - } else { - "unknown".to_string() - } - }); + let band: Option = freq + .and_then(crate::ui::freq_to_band) + .map(|s| s.to_string()); (Some(ssid_str), band) } else { (None, None) }; - // Sort by signal strength (descending) nets.sort_by(|a, b| b.strength.unwrap_or(0).cmp(&a.strength.unwrap_or(0))); - // Deduplicate by SSID + frequency band (not exact frequency) - // This matches how networks are displayed (2.4GHz, 5GHz, 6GHz) let mut seen_combinations = HashSet::new(); nets.retain(|net| { - // Normalize frequency to band, matching the display logic - let band = net.frequency.map(|freq| { - if (2400..=2500).contains(&freq) { - "2.4GHz" - } else if (5000..=6000).contains(&freq) { - "5GHz" - } else if (5925..=7125).contains(&freq) { - "6GHz" - } else { - "unknown" - } - }); + let band = net.frequency.and_then(crate::ui::freq_to_band); let key = (net.ssid.clone(), band); seen_combinations.insert(key) }); @@ -319,3 +381,138 @@ pub fn clear_children(container: >k::Box) { container.remove(&widget); } } + +/// Refresh the network list WITHOUT triggering a new scan. +/// This is useful for live updates when the network list changes +/// (e.g., wired device state changes, AP added/removed). +pub async fn refresh_networks_no_scan( + ctx: Rc, + list_container: &GtkBox, + is_scanning: &Rc>, +) { + if is_scanning.get() { + // Don't interfere with an ongoing scan or refresh + return; + } + + // Set flag to prevent concurrent refreshes + is_scanning.set(true); + + clear_children(list_container); + + // Fetch wired devices first + if let Ok(wired_devices) = ctx.nm.list_wired_devices().await { + // eprintln!("Found {} wired devices total", wired_devices.len()); + + // Filter out unavailable devices to reduce clutter + let available_devices: Vec<_> = wired_devices + .into_iter() + .filter(|dev| { + let show = matches!( + dev.state, + models::DeviceState::Activated + | models::DeviceState::Disconnected + | models::DeviceState::Prepare + | models::DeviceState::Config + | models::DeviceState::Unmanaged + ); + /* eprintln!( + " - {} ({}): {} -> {}", + dev.interface, + dev.device_type, + dev.state, + if show { "SHOW" } else { "HIDE" } + ); */ + show + }) + .collect(); + + /* eprintln!( + "Showing {} available wired devices", + available_devices.len() + );*/ + + if !available_devices.is_empty() { + let wired_header = Label::new(Some("Wired")); + wired_header.add_css_class("section-header"); + wired_header.add_css_class("wired-section-header"); + wired_header.set_halign(Align::Start); + wired_header.set_margin_top(8); + wired_header.set_margin_bottom(4); + wired_header.set_margin_start(12); + list_container.append(&wired_header); + + let wired_ctx = wired_devices::WiredDevicesContext { + nm: ctx.nm.clone(), + on_success: ctx.on_success.clone(), + status: ctx.status.clone(), + stack: ctx.stack.clone(), + parent_window: ctx.parent_window.clone(), + }; + let wired_ctx = Rc::new(wired_ctx); + + let wired_list = wired_devices::wired_devices_view( + wired_ctx, + &available_devices, + ctx.wired_details_page.clone(), + ); + wired_list.add_css_class("wired-devices-list"); + list_container.append(&wired_list); + + let separator = gtk::Separator::new(Orientation::Horizontal); + separator.add_css_class("device-separator"); + separator.set_margin_top(12); + separator.set_margin_bottom(12); + list_container.append(&separator); + } + } + + let wireless_header = Label::new(Some("Wireless")); + wireless_header.add_css_class("section-header"); + wireless_header.add_css_class("wireless-section-header"); + wireless_header.set_halign(Align::Start); + wireless_header.set_margin_top(8); + wireless_header.set_margin_bottom(4); + wireless_header.set_margin_start(12); + list_container.append(&wireless_header); + + match ctx.nm.list_networks().await { + Ok(mut nets) => { + let current_conn = ctx.nm.current_connection_info().await; + let (current_ssid, current_band) = if let Some((ssid, freq)) = current_conn { + let ssid_str = ssid.clone(); + let band: Option = freq + .and_then(crate::ui::freq_to_band) + .map(|s| s.to_string()); + (Some(ssid_str), band) + } else { + (None, None) + }; + + nets.sort_by(|a, b| b.strength.unwrap_or(0).cmp(&a.strength.unwrap_or(0))); + + let mut seen_combinations = HashSet::new(); + nets.retain(|net| { + let band = net.frequency.and_then(crate::ui::freq_to_band); + let key = (net.ssid.clone(), band); + seen_combinations.insert(key) + }); + + let list: ListBox = networks::networks_view( + ctx.clone(), + &nets, + current_ssid.as_deref(), + current_band.as_deref(), + ); + list_container.append(&list); + ctx.stack.set_visible_child_name("networks"); + } + Err(err) => { + ctx.status + .set_text(&format!("Error fetching networks: {err}")); + } + } + + // Release the lock + is_scanning.set(false); +} diff --git a/nmrs-gui/src/ui/mod.rs b/nmrs-gui/src/ui/mod.rs index ced4f584..1ab669be 100644 --- a/nmrs-gui/src/ui/mod.rs +++ b/nmrs-gui/src/ui/mod.rs @@ -2,6 +2,8 @@ pub mod connect; pub mod header; pub mod network_page; pub mod networks; +pub mod wired_devices; +pub mod wired_page; use gtk::prelude::*; use gtk::{ @@ -16,6 +18,15 @@ use crate::ui::header::THEMES; type Callback = Rc; type CallbackCell = Rc>>; +pub fn freq_to_band(freq: u32) -> Option<&'static str> { + match freq { + 2400..=2500 => Some("2.4GHz"), + 5150..=5925 => Some("5GHz"), + 5926..=7125 => Some("6GHz"), + _ => None, + } +} + pub fn build_ui(app: &Application) { let win = ApplicationWindow::new(app); win.set_title(Some("")); @@ -72,6 +83,13 @@ pub fn build_ui(app: &Application) { details_scroller.set_child(Some(details_page.widget())); stack_clone.add_named(&details_scroller, Some("details")); + let wired_details_page = Rc::new(wired_page::WiredPage::new(&stack_clone)); + let wired_details_scroller = ScrolledWindow::new(); + wired_details_scroller + .set_policy(gtk::PolicyType::Never, gtk::PolicyType::Automatic); + wired_details_scroller.set_child(Some(wired_details_page.widget())); + stack_clone.add_named(&wired_details_scroller, Some("wired-details")); + let on_success: Rc = { let list_container = list_container_clone.clone(); let is_scanning = is_scanning_clone.clone(); @@ -80,6 +98,7 @@ pub fn build_ui(app: &Application) { let stack = stack_clone.clone(); let parent_window = win_clone.clone(); let details_page = details_page.clone(); + let wired_details_page = wired_details_page.clone(); let on_success_cell: CallbackCell = Rc::new(std::cell::RefCell::new(None)); let on_success_cell_clone = on_success_cell.clone(); @@ -93,6 +112,7 @@ pub fn build_ui(app: &Application) { let parent_window = parent_window.clone(); let on_success_cell = on_success_cell.clone(); let details_page = details_page.clone(); + let wired_details_page = wired_details_page.clone(); glib::MainContext::default().spawn_local(async move { let callback = on_success_cell.borrow().as_ref().map(|cb| cb.clone()); @@ -102,7 +122,8 @@ pub fn build_ui(app: &Application) { status, stack, parent_window, - details_page, + details_page: details_page.clone(), + wired_details_page: wired_details_page.clone(), }); header::refresh_networks(refresh_ctx, &list_container, &is_scanning) .await; @@ -121,6 +142,7 @@ pub fn build_ui(app: &Application) { stack: stack_clone.clone(), parent_window: win_clone.clone(), details_page, + wired_details_page, }); let header = header::build_header( @@ -131,29 +153,111 @@ pub fn build_ui(app: &Application) { ); vbox_clone.prepend(&header); - // TODO: Re-enable network monitoring with proper UI integration - // Currently disabled because it needs access to full context for row creation - /* - // Start background network monitoring for live updates - let nm_monitor = nm.clone(); - let list_container_monitor = list_container_clone.clone(); - let is_scanning_monitor = is_scanning_clone; - let ctx_monitor = ctx.clone(); - - glib::MainContext::default().spawn_local(async move { - let _ = nm_monitor.monitor_network_changes(move || { - let ctx = ctx_monitor.clone(); - let list_container = list_container_monitor.clone(); - let is_scanning = is_scanning_monitor.clone(); + // Start background device monitoring for live updates (wired devices) + // Uses debouncing to avoid too many rapid refreshes + { + let nm_device_monitor = nm.clone(); + let list_container_device = list_container_clone.clone(); + let is_scanning_device = is_scanning_clone.clone(); + let ctx_device = ctx.clone(); - glib::MainContext::default().spawn_local(async move { - if !is_scanning.get() { - header::refresh_networks(ctx, &list_container, &is_scanning).await; + glib::MainContext::default().spawn_local(async move { + // eprintln!("Starting device state monitoring..."); + loop { + let ctx_device_clone = ctx_device.clone(); + let list_container_clone = list_container_device.clone(); + let is_scanning_clone = is_scanning_device.clone(); + + let result = nm_device_monitor + .monitor_device_changes(move || { + // eprintln!("Device state change detected!"); + let ctx = ctx_device_clone.clone(); + let list_container = list_container_clone.clone(); + let is_scanning = is_scanning_clone.clone(); + + glib::MainContext::default().spawn_local(async move { + if !is_scanning.get() { + // eprintln!("Refreshing UI after device change"); + header::refresh_networks_no_scan( + ctx, + &list_container, + &is_scanning, + ) + .await; + } else { + // eprintln!("Skipping refresh (scan in progress)"); + } + }); + }) + .await; + + match result { + Ok(_) => eprintln!("Device monitoring ended normally"), + Err(e) => { + eprintln!("Device monitoring error: {}, restarting in 5s...", e) + } } - }); - }).await; - }); - */ + glib::timeout_future_seconds(5).await; + } + }); + } + + { + let nm_network_monitor = nm.clone(); + let list_container_network = list_container_clone.clone(); + let is_scanning_network = is_scanning_clone.clone(); + let ctx_network = ctx.clone(); + + let last_refresh = Rc::new(std::cell::RefCell::new( + std::time::Instant::now() - std::time::Duration::from_secs(10), + )); + + glib::MainContext::default().spawn_local(async move { + loop { + let ctx_network_clone = ctx_network.clone(); + let list_container_clone = list_container_network.clone(); + let is_scanning_clone = is_scanning_network.clone(); + let last_refresh_clone = last_refresh.clone(); + + let result = nm_network_monitor + .monitor_network_changes(move || { + let ctx = ctx_network_clone.clone(); + let list_container = list_container_clone.clone(); + let is_scanning = is_scanning_clone.clone(); + let last_refresh = last_refresh_clone.clone(); + + glib::MainContext::default().spawn_local(async move { + if !is_scanning.get() { + let now = std::time::Instant::now(); + let should_refresh = now + .duration_since(*last_refresh.borrow()) + >= std::time::Duration::from_secs(3); + + if should_refresh { + *last_refresh.borrow_mut() = now; + header::refresh_networks_no_scan( + ctx, + &list_container, + &is_scanning, + ) + .await; + } + } + }); + }) + .await; + + match result { + Ok(_) => eprintln!("Network monitoring ended normally"), + Err(e) => eprintln!( + "Network monitoring error: {}, restarting in 5s...", + e + ), + } + glib::timeout_future_seconds(5).await; + } + }); + } } Err(err) => { status_clone.set_text(&format!("Failed to initialize: {err}")); diff --git a/nmrs-gui/src/ui/networks.rs b/nmrs-gui/src/ui/networks.rs index 1eb5f518..8de7f5a3 100644 --- a/nmrs-gui/src/ui/networks.rs +++ b/nmrs-gui/src/ui/networks.rs @@ -25,6 +25,7 @@ pub struct NetworksContext { pub stack: gtk::Stack, pub parent_window: gtk::ApplicationWindow, pub details_page: Rc, + pub wired_details_page: Rc, } impl NetworksContext { @@ -34,6 +35,7 @@ impl NetworksContext { stack: >k::Stack, parent_window: >k::ApplicationWindow, details_page: Rc, + wired_details_page: Rc, ) -> Result { let nm = Rc::new(NetworkManager::new().await?); @@ -44,6 +46,7 @@ impl NetworksContext { stack: stack.clone(), parent_window: parent_window.clone(), details_page, + wired_details_page, }) } } @@ -204,7 +207,7 @@ pub fn networks_view( row.add_css_class("connected"); } - let display_name = match net.frequency.and_then(freq_to_band) { + let display_name = match net.frequency.and_then(crate::ui::freq_to_band) { Some(band) => format!("{} ({band})", net.ssid), None => net.ssid.clone(), }; @@ -271,15 +274,6 @@ pub fn networks_view( list } -fn freq_to_band(freq: u32) -> Option<&'static str> { - match freq { - 2400..=2500 => Some("2.4GHz"), - 5000..=5900 => Some("5GHz"), - 5901..=7125 => Some("6GHz"), - _ => None, - } -} - fn is_current_network( net: &models::Network, current_ssid: Option<&str>, @@ -295,7 +289,7 @@ fn is_current_network( } if let Some(band) = current_band { - let net_band = net.frequency.and_then(freq_to_band); + let net_band = net.frequency.and_then(crate::ui::freq_to_band); return net_band == Some(band); } diff --git a/nmrs-gui/src/ui/wired_devices.rs b/nmrs-gui/src/ui/wired_devices.rs new file mode 100644 index 00000000..302519fa --- /dev/null +++ b/nmrs-gui/src/ui/wired_devices.rs @@ -0,0 +1,187 @@ +use gtk::Align; +use gtk::GestureClick; +use gtk::prelude::*; +use gtk::{Box, Image, Label, ListBox, ListBoxRow, Orientation}; +use nmrs::{NetworkManager, models}; +use std::rc::Rc; + +use crate::ui::wired_page::WiredPage; + +pub struct WiredDeviceRowController { + pub row: gtk::ListBoxRow, + pub arrow: gtk::Image, + pub ctx: Rc, + pub device: models::Device, + pub details_page: Rc, +} + +pub struct WiredDevicesContext { + pub nm: Rc, + pub on_success: Rc, + pub status: Label, + pub stack: gtk::Stack, + pub parent_window: gtk::ApplicationWindow, +} + +impl WiredDeviceRowController { + pub fn new( + row: gtk::ListBoxRow, + arrow: gtk::Image, + ctx: Rc, + device: models::Device, + details_page: Rc, + ) -> Self { + Self { + row, + arrow, + ctx, + device, + details_page, + } + } + + pub fn attach(&self) { + self.attach_arrow(); + self.attach_row_double(); + } + + fn attach_arrow(&self) { + let click = GestureClick::new(); + + let device = self.device.clone(); + let stack = self.ctx.stack.clone(); + let page = self.details_page.clone(); + + click.connect_pressed(move |_, _, _, _| { + let device_c = device.clone(); + let stack_c = stack.clone(); + let page_c = page.clone(); + + glib::MainContext::default().spawn_local(async move { + page_c.update(&device_c); + stack_c.set_visible_child_name("wired-details"); + }); + }); + + self.arrow.add_controller(click); + } + + fn attach_row_double(&self) { + let click = GestureClick::new(); + + let ctx = self.ctx.clone(); + let device = self.device.clone(); + let interface = device.interface.clone(); + + let status = ctx.status.clone(); + let window = ctx.parent_window.clone(); + let on_success = ctx.on_success.clone(); + + click.connect_pressed(move |_, n, _, _| { + if n != 2 { + return; + } + + status.set_text(&format!("Connecting to {interface}...")); + + let nm_c = ctx.nm.clone(); + let status_c = status.clone(); + let window_c = window.clone(); + let on_success_c = on_success.clone(); + + glib::MainContext::default().spawn_local(async move { + window_c.set_sensitive(false); + match nm_c.connect_wired().await { + Ok(_) => { + status_c.set_text(""); + on_success_c(); + } + Err(e) => status_c.set_text(&format!("Failed to connect: {e}")), + } + window_c.set_sensitive(true); + status_c.set_text(""); + }); + }); + + self.row.add_controller(click); + } +} + +pub fn wired_devices_view( + ctx: Rc, + devices: &[models::Device], + details_page: Rc, +) -> ListBox { + let list = ListBox::new(); + + for device in devices { + let row = ListBoxRow::new(); + let hbox = Box::new(Orientation::Horizontal, 6); + + row.add_css_class("network-selection"); + + if device.state == models::DeviceState::Activated { + row.add_css_class("connected"); + } + + let display_name = format!("{} ({})", device.interface, device.device_type); + hbox.append(&Label::new(Some(&display_name))); + + if device.state == models::DeviceState::Activated { + let connected_label = Label::new(Some("Connected")); + connected_label.add_css_class("connected-label"); + hbox.append(&connected_label); + } + + let spacer = Box::new(Orientation::Horizontal, 0); + spacer.set_hexpand(true); + hbox.append(&spacer); + + // Only show state for meaningful states (not transitional ones) + let state_text = match device.state { + models::DeviceState::Activated => Some("Connected"), + models::DeviceState::Disconnected => Some("Disconnected"), + models::DeviceState::Unavailable => Some("Unavailable"), + models::DeviceState::Failed => Some("Failed"), + // Hide transitional states (Unmanaged, Prepare, Config, etc) + _ => None, + }; + + if let Some(text) = state_text { + let state_label = Label::new(Some(text)); + state_label.add_css_class(match device.state { + models::DeviceState::Activated => "network-good", + models::DeviceState::Unavailable + | models::DeviceState::Disconnected + | models::DeviceState::Failed => "network-poor", + _ => "network-okay", + }); + hbox.append(&state_label); + } + + let icon = Image::from_icon_name("network-wired-symbolic"); + icon.add_css_class("wired-icon"); + hbox.append(&icon); + + let arrow = Image::from_icon_name("go-next-symbolic"); + arrow.set_halign(Align::End); + arrow.add_css_class("network-arrow"); + arrow.set_cursor_from_name(Some("pointer")); + hbox.append(&arrow); + + row.set_child(Some(&hbox)); + + let controller = WiredDeviceRowController::new( + row.clone(), + arrow.clone(), + ctx.clone(), + device.clone(), + details_page.clone(), + ); + + controller.attach(); + + list.append(&row); + } + list +} diff --git a/nmrs-gui/src/ui/wired_page.rs b/nmrs-gui/src/ui/wired_page.rs new file mode 100644 index 00000000..92b6bbff --- /dev/null +++ b/nmrs-gui/src/ui/wired_page.rs @@ -0,0 +1,136 @@ +use glib::clone; +use gtk::prelude::*; +use gtk::{Align, Box, Button, Image, Label, Orientation}; +use nmrs::models::Device; + +pub struct WiredPage { + root: gtk::Box, + + title: gtk::Label, + state_label: gtk::Label, + + interface: gtk::Label, + device_type: gtk::Label, + mac_address: gtk::Label, + driver: gtk::Label, + managed: gtk::Label, +} + +impl WiredPage { + pub fn new(stack: >k::Stack) -> Self { + let root = Box::new(Orientation::Vertical, 12); + root.add_css_class("network-page"); + + let back = Button::with_label("← Back"); + back.add_css_class("back-button"); + back.set_halign(Align::Start); + back.set_cursor_from_name(Some("pointer")); + back.connect_clicked(clone![ + #[weak] + stack, + move |_| { + stack.set_visible_child_name("networks"); + } + ]); + root.append(&back); + + let header = Box::new(Orientation::Horizontal, 6); + let icon = Image::from_icon_name("network-wired-symbolic"); + icon.set_pixel_size(24); + + let title = Label::new(None); + title.add_css_class("network-title"); + + let spacer = Box::new(Orientation::Horizontal, 0); + spacer.set_hexpand(true); + + header.append(&icon); + header.append(&title); + header.append(&spacer); + root.append(&header); + + let basic_box = Box::new(Orientation::Vertical, 6); + basic_box.add_css_class("basic-section"); + + let basic_header = Label::new(Some("Basic")); + basic_header.add_css_class("section-header"); + basic_box.append(&basic_header); + + let state_label = Label::new(None); + let interface = Label::new(None); + + Self::add_row(&basic_box, "Connection State", &state_label); + Self::add_row(&basic_box, "Interface", &interface); + + root.append(&basic_box); + + let advanced_box = Box::new(Orientation::Vertical, 8); + advanced_box.add_css_class("advanced-section"); + + let advanced_header = Label::new(Some("Advanced")); + advanced_header.add_css_class("section-header"); + advanced_box.append(&advanced_header); + + let device_type = Label::new(None); + let mac_address = Label::new(None); + let driver = Label::new(None); + let managed = Label::new(None); + + Self::add_row(&advanced_box, "Device Type", &device_type); + Self::add_row(&advanced_box, "MAC Address", &mac_address); + Self::add_row(&advanced_box, "Driver", &driver); + Self::add_row(&advanced_box, "Managed", &managed); + + root.append(&advanced_box); + + Self { + root, + title, + state_label, + + interface, + device_type, + mac_address, + driver, + managed, + } + } + + fn add_row(parent: >k::Box, key_text: &str, val_widget: >k::Label) { + let row = Box::new(Orientation::Vertical, 3); + row.set_halign(Align::Start); + + let key = Label::new(Some(key_text)); + key.add_css_class("info-label"); + key.set_halign(Align::Start); + + val_widget.add_css_class("info-value"); + val_widget.set_halign(Align::Start); + + row.append(&key); + row.append(val_widget); + parent.append(&row); + } + + pub fn update(&self, device: &Device) { + self.title + .set_text(&format!("Wired Device: {}", device.interface)); + self.state_label.set_text(&format!("{}", device.state)); + self.interface.set_text(&device.interface); + self.device_type + .set_text(&format!("{}", device.device_type)); + self.mac_address.set_text(&device.identity.current_mac); + self.driver + .set_text(&device.driver.clone().unwrap_or_else(|| "-".into())); + self.managed.set_text( + device + .managed + .map(|m| if m { "Yes" } else { "No" }) + .unwrap_or("-"), + ); + } + + pub fn widget(&self) -> >k::Box { + &self.root + } +} diff --git a/nmrs/src/connection.rs b/nmrs/src/connection.rs index 53a45048..038063d9 100644 --- a/nmrs/src/connection.rs +++ b/nmrs/src/connection.rs @@ -12,7 +12,7 @@ use crate::network_info::current_ssid; use crate::proxies::{NMAccessPointProxy, NMDeviceProxy, NMProxy, NMWirelessProxy}; use crate::state_wait::{wait_for_connection_activation, wait_for_device_disconnect}; use crate::utils::decode_ssid_or_empty; -use crate::wifi_builders::build_wifi_connection; +use crate::wifi_builders::{build_ethernet_connection, build_wifi_connection}; /// Decision on whether to reuse a saved connection or create a fresh one. enum SavedDecision { @@ -84,6 +84,70 @@ pub(crate) async fn connect(conn: &Connection, ssid: &str, creds: WifiSecurity) Ok(()) } +/// Connects to a wired (Ethernet) device. +/// +/// This is the main entry point for establishing a wired connection. The flow: +/// 1. Find a wired device +/// 2. Check for an existing saved connection +/// 3. Either activate the saved connection or create and activate a new one +/// 4. Wait for the connection to reach the activated state +/// +/// Ethernet connections are typically simpler than Wi-Fi - no scanning or +/// access points needed. The connection will activate when a cable is plugged in. +pub(crate) async fn connect_wired(conn: &Connection) -> Result<()> { + debug!("Connecting to wired device"); + + let nm = NMProxy::new(conn).await?; + + let wired_device = find_wired_device(conn, &nm).await?; + debug!("Found wired device: {}", wired_device.as_str()); + + // Check if already connected + let dev = NMDeviceProxy::builder(conn) + .path(wired_device.clone())? + .build() + .await?; + let current_state = dev.state().await?; + if current_state == device_state::ACTIVATED { + debug!("Wired device already activated, skipping connect()"); + return Ok(()); + } + + // Check for saved connection (by interface name) + let interface = dev.interface().await?; + let saved = get_saved_connection_path(conn, &interface).await?; + + // For Ethernet, we use "/" as the specific_object (no access point needed) + let specific_object = OwnedObjectPath::try_from("/").unwrap(); + + match saved { + Some(saved_path) => { + debug!("Activating saved wired connection: {}", saved_path.as_str()); + let active_conn = nm + .activate_connection(saved_path, wired_device.clone(), specific_object) + .await?; + wait_for_connection_activation(conn, &active_conn).await?; + } + None => { + debug!("No saved connection found, creating new wired connection"); + let opts = ConnectionOptions { + autoconnect: true, + autoconnect_priority: None, + autoconnect_retries: None, + }; + + let settings = build_ethernet_connection(&interface, &opts); + let (_, active_conn) = nm + .add_and_activate_connection(settings, wired_device.clone(), specific_object) + .await?; + wait_for_connection_activation(conn, &active_conn).await?; + } + } + + info!("Successfully connected to wired device"); + Ok(()) +} + /// Forgets (deletes) all saved connections for a network. /// /// If currently connected to this network, disconnects first, then deletes @@ -268,6 +332,28 @@ pub(crate) async fn disconnect_wifi_and_wait( Ok(()) } +/// Find the first wired (Ethernet) device on the system. +/// +/// Iterates through all NetworkManager devices and returns the first one +/// with device type `ETHERNET`. Returns `NoWiredDevice` if none found. +pub(crate) async fn find_wired_device( + conn: &Connection, + nm: &NMProxy<'_>, +) -> Result { + let devices = nm.get_devices().await?; + + for dp in devices { + let dev = NMDeviceProxy::builder(conn) + .path(dp.clone())? + .build() + .await?; + if dev.device_type().await? == device_type::ETHERNET { + return Ok(dp); + } + } + Err(ConnectionError::NoWiredDevice) +} + /// Finds the first Wi-Fi device on the system. /// /// Iterates through all NetworkManager devices and returns the first one diff --git a/nmrs/src/constants.rs b/nmrs/src/constants.rs index 1bc06df6..04c5d3ff 100644 --- a/nmrs/src/constants.rs +++ b/nmrs/src/constants.rs @@ -5,7 +5,7 @@ /// NetworkManager device type constants. pub mod device_type { - // pub const ETHERNET: u32 = 1; + pub const ETHERNET: u32 = 1; pub const WIFI: u32 = 2; // pub const WIFI_P2P: u32 = 30; // pub const LOOPBACK: u32 = 32; @@ -83,8 +83,8 @@ pub mod frequency { pub const BAND_2_4_START: u32 = 2412; pub const BAND_2_4_END: u32 = 2472; pub const BAND_2_4_CH14: u32 = 2484; - pub const BAND_5_START: u32 = 5000; - pub const BAND_5_END: u32 = 5900; + pub const BAND_5_START: u32 = 5150; + pub const BAND_5_END: u32 = 5925; pub const BAND_6_START: u32 = 5955; pub const BAND_6_END: u32 = 7115; pub const CHANNEL_SPACING: u32 = 5; diff --git a/nmrs/src/device.rs b/nmrs/src/device.rs index bc9ebd61..8d5dfa1d 100644 --- a/nmrs/src/device.rs +++ b/nmrs/src/device.rs @@ -30,8 +30,16 @@ pub(crate) async fn list_devices(conn: &Connection) -> Result> { let interface = d_proxy.interface().await?; let raw_type = d_proxy.device_type().await?; - let perm_mac = d_proxy.perm_hw_address().await?; - let current_mac = d_proxy.hw_address().await?; + let current_mac = d_proxy + .hw_address() + .await + .unwrap_or_else(|_| String::from("00:00:00:00:00:00")); + // PermHwAddress may not be available on all systems/devices + // If not available, fall back to HwAddress + let perm_mac = d_proxy + .perm_hw_address() + .await + .unwrap_or_else(|_| current_mac.clone()); let device_type = raw_type.into(); let raw_state = d_proxy.state().await?; let state = raw_state.into(); diff --git a/nmrs/src/device_monitor.rs b/nmrs/src/device_monitor.rs new file mode 100644 index 00000000..104dc15d --- /dev/null +++ b/nmrs/src/device_monitor.rs @@ -0,0 +1,83 @@ +//! Real-time device state monitoring using D-Bus signals. +//! +//! Provides functionality to monitor device state changes (e.g., ethernet cable +//! plugged in/out, device activation/deactivation) in real-time without needing +//! to poll. This enables live UI updates for both wired and wireless devices. + +use futures::stream::{Stream, StreamExt}; +use log::{debug, warn}; +use std::pin::Pin; +use zbus::Connection; + +use crate::Result; +use crate::models::ConnectionError; +use crate::proxies::{NMDeviceProxy, NMProxy}; + +/// Monitors device state changes on all network devices. +/// +/// Subscribes to `StateChanged` signals on all network devices. When any signal +/// is received (device activated, disconnected, cable plugged in, etc.), invokes +/// the callback to notify the caller that device states have changed. +/// +/// This function runs indefinitely until an error occurs or the connection +/// is lost. Run it in a background task. +/// +/// # Example +/// +/// ```ignore +/// let nm = NetworkManager::new().await?; +/// nm.monitor_device_changes(|| { +/// println!("Device state changed, refresh UI!"); +/// }).await?; +/// ``` +pub async fn monitor_device_changes(conn: &Connection, callback: F) -> Result<()> +where + F: Fn() + 'static, +{ + let nm = NMProxy::new(conn).await?; + + // Use dynamic dispatch to handle different signal stream types + let mut streams: Vec>>> = Vec::new(); + + // Subscribe to DeviceAdded and DeviceRemoved signals from main NetworkManager + // This is more reliable than subscribing to individual devices + let device_added_stream = nm.receive_device_added().await?; + let device_removed_stream = nm.receive_device_removed().await?; + let state_changed_stream = nm.receive_state_changed().await?; + + streams.push(Box::pin(device_added_stream.map(|_| ()))); + streams.push(Box::pin(device_removed_stream.map(|_| ()))); + streams.push(Box::pin(state_changed_stream.map(|_| ()))); + + debug!("Subscribed to NetworkManager device signals"); + + // Also subscribe to individual device state changes for existing devices + let devices = nm.get_devices().await?; + for dev_path in devices { + if let Ok(dev) = NMDeviceProxy::builder(conn) + .path(dev_path.clone())? + .build() + .await + && let Ok(state_stream) = dev.receive_device_state_changed().await + { + streams.push(Box::pin(state_stream.map(|_| ()))); + debug!("Subscribed to state change signals on device: {dev_path}"); + } + } + + debug!( + "Monitoring {} signal streams for device changes", + streams.len() + ); + + // Merge all streams and listen for any signal + let mut merged = futures::stream::select_all(streams); + + while let Some(_signal) = merged.next().await { + debug!("Device change detected"); + callback(); + } + + warn!("Device monitoring stream ended unexpectedly"); + Err(ConnectionError::Stuck("monitoring stream ended".into())) +} diff --git a/nmrs/src/lib.rs b/nmrs/src/lib.rs index 3adc1c83..0743c882 100644 --- a/nmrs/src/lib.rs +++ b/nmrs/src/lib.rs @@ -61,6 +61,7 @@ mod connection; mod connection_settings; mod constants; mod device; +mod device_monitor; mod network_info; mod network_monitor; mod proxies; diff --git a/nmrs/src/models.rs b/nmrs/src/models.rs index 7bc5e4fd..8046373c 100644 --- a/nmrs/src/models.rs +++ b/nmrs/src/models.rs @@ -305,7 +305,7 @@ pub enum WifiSecurity { WpaEap { opts: EapOptions }, } -/// Errors that can occur during network operations. +/// NetworkManager device types. #[derive(Debug, Clone, PartialEq)] pub enum DeviceType { Ethernet, @@ -329,6 +329,16 @@ pub enum DeviceState { Other(u32), } +impl Device { + pub fn is_wired(&self) -> bool { + matches!(self.device_type, DeviceType::Ethernet) + } + + pub fn is_wireless(&self) -> bool { + matches!(self.device_type, DeviceType::Wifi) + } +} + /// Errors that can occur during network operations. #[derive(Debug, Error)] pub enum ConnectionError { @@ -368,6 +378,10 @@ pub enum ConnectionError { #[error("no Wi-Fi device found")] NoWifiDevice, + /// No wired (ethernet) device was found on the system. + #[error("no wired device was found")] + NoWiredDevice, + /// Wi-Fi device did not become ready in time. #[error("Wi-Fi device not ready")] WifiNotReady, diff --git a/nmrs/src/network_manager.rs b/nmrs/src/network_manager.rs index 1dd11cba..60501ae9 100644 --- a/nmrs/src/network_manager.rs +++ b/nmrs/src/network_manager.rs @@ -1,9 +1,10 @@ use zbus::Connection; use crate::Result; -use crate::connection::{connect, forget}; +use crate::connection::{connect, connect_wired, forget}; use crate::connection_settings::{get_saved_connection_path, has_saved_connection}; use crate::device::{list_devices, set_wifi_enabled, wait_for_wifi_ready, wifi_enabled}; +use crate::device_monitor; use crate::models::{Device, Network, NetworkInfo, WifiSecurity}; use crate::network_info::{current_connection_info, current_ssid, show_details}; use crate::network_monitor; @@ -25,11 +26,23 @@ impl NetworkManager { Ok(Self { conn }) } - /// Lists all network devices managed by NetworkManager. + /// List all network devices managed by NetworkManager. pub async fn list_devices(&self) -> Result> { list_devices(&self.conn).await } + /// Lists all network devices managed by NetworkManager. + pub async fn list_wireless_devices(&self) -> Result> { + let devices = list_devices(&self.conn).await?; + Ok(devices.into_iter().filter(|d| d.is_wireless()).collect()) + } + + /// List all wired (Ethernet) devices. + pub async fn list_wired_devices(&self) -> Result> { + let devices = list_devices(&self.conn).await?; + Ok(devices.into_iter().filter(|d| d.is_wired()).collect()) + } + /// Lists all visible Wi-Fi networks. pub async fn list_networks(&self) -> Result> { list_networks(&self.conn).await @@ -46,6 +59,19 @@ impl NetworkManager { connect(&self.conn, ssid, creds).await } + /// Connects to a wired (Ethernet) device. + /// + /// Finds the first available wired device and either activates an existing + /// saved connection or creates a new one. The connection will activate + /// when a cable is plugged in. + /// + /// # Errors + /// + /// Returns `ConnectionError::NoWiredDevice` if no wired device is found. + pub async fn connect_wired(&self) -> Result<()> { + connect_wired(&self.conn).await + } + /// Returns whether Wi-Fi is currently enabled. pub async fn wifi_enabled(&self) -> Result { wifi_enabled(&self.conn).await @@ -135,4 +161,40 @@ impl NetworkManager { { network_monitor::monitor_network_changes(&self.conn, callback).await } + + /// Monitors device state changes in real-time. + /// + /// Subscribes to D-Bus signals for device state changes on all network + /// devices (both wired and wireless). Invokes the callback whenever a + /// device state changes (e.g., cable plugged in, device activated), + /// enabling live UI updates without polling. + /// + /// This function runs indefinitely until an error occurs. Run it in a + /// background task. + /// + /// # Example + /// + /// ```ignore + /// # use nmrs::NetworkManager; + /// # async fn example() -> nmrs::Result<()> { + /// let nm = NetworkManager::new().await?; + /// + /// // Spawn monitoring task + /// glib::MainContext::default().spawn_local({ + /// let nm = nm.clone(); + /// async move { + /// nm.monitor_device_changes(|| { + /// println!("Device state changed!"); + /// }).await + /// } + /// }); + /// # Ok(()) + /// # } + /// ``` + pub async fn monitor_device_changes(&self, callback: F) -> Result<()> + where + F: Fn() + 'static, + { + device_monitor::monitor_device_changes(&self.conn, callback).await + } } diff --git a/nmrs/src/proxies/device.rs b/nmrs/src/proxies/device.rs index 74159cb8..7e619c47 100644 --- a/nmrs/src/proxies/device.rs +++ b/nmrs/src/proxies/device.rs @@ -53,7 +53,9 @@ pub trait NMDevice { #[zbus(property)] fn hw_address(&self) -> Result; - #[zbus(property)] + /// Permanent hardware (MAC) address of the device. + /// Note: This property may not be available on all device types or systems. + #[zbus(property, name = "PermHwAddress")] fn perm_hw_address(&self) -> Result; /// Signal emitted when device state changes. diff --git a/nmrs/src/proxies/main_nm.rs b/nmrs/src/proxies/main_nm.rs index 677a79b1..b9ead63d 100644 --- a/nmrs/src/proxies/main_nm.rs +++ b/nmrs/src/proxies/main_nm.rs @@ -49,4 +49,16 @@ pub trait NM { /// Deactivates an active connection. fn deactivate_connection(&self, active_connection: OwnedObjectPath) -> zbus::Result<()>; + + /// Signal emitted when a device is added to NetworkManager. + #[zbus(signal, name = "DeviceAdded")] + fn device_added(&self, device: OwnedObjectPath); + + /// Signal emitted when a device is removed from NetworkManager. + #[zbus(signal, name = "DeviceRemoved")] + fn device_removed(&self, device: OwnedObjectPath); + + /// Signal emitted when any device changes state. + #[zbus(signal, name = "StateChanged")] + fn state_changed(&self, state: u32); } diff --git a/nmrs/src/proxies/mod.rs b/nmrs/src/proxies/mod.rs index 0a526b2b..bba96534 100644 --- a/nmrs/src/proxies/mod.rs +++ b/nmrs/src/proxies/mod.rs @@ -25,6 +25,7 @@ mod access_point; mod active_connection; mod device; mod main_nm; +mod wired; mod wireless; pub use access_point::NMAccessPointProxy; diff --git a/nmrs/src/proxies/wired.rs b/nmrs/src/proxies/wired.rs new file mode 100644 index 00000000..798bfb5b --- /dev/null +++ b/nmrs/src/proxies/wired.rs @@ -0,0 +1,17 @@ +//! NetworkManager Wired (Ethernet) Device Proxy + +use zbus::Result; +use zbus::proxy; + +/// Proxy for wired devices (Ethernet). +/// +/// Provides access to wired-specific properties like carrier status. +#[proxy( + interface = "org.freedesktop.NetworkManager.Device.Wired", + default_service = "org.freedesktop.NetworkManager" +)] +pub trait NMWired { + /// Design speed of the device, in megabits/second (Mb/s). + #[zbus(property)] + fn speed(&self) -> Result; +} diff --git a/nmrs/src/wifi_builders.rs b/nmrs/src/wifi_builders.rs index a94a1d6e..5bd19efa 100644 --- a/nmrs/src/wifi_builders.rs +++ b/nmrs/src/wifi_builders.rs @@ -60,6 +60,28 @@ fn base_connection_section( s } +/// Builds the `connection` section for Ethernet connections. +fn base_ethernet_connection_section( + connection_id: &str, + opts: &ConnectionOptions, +) -> HashMap<&'static str, Value<'static>> { + let mut s = HashMap::new(); + s.insert("type", Value::from("802-3-ethernet")); + s.insert("id", Value::from(connection_id.to_string())); + s.insert("uuid", Value::from(uuid::Uuid::new_v4().to_string())); + s.insert("autoconnect", Value::from(opts.autoconnect)); + + if let Some(p) = opts.autoconnect_priority { + s.insert("autoconnect-priority", Value::from(p)); + } + + if let Some(r) = opts.autoconnect_retries { + s.insert("autoconnect-retries", Value::from(r)); + } + + s +} + /// Builds the `802-11-wireless-security` section for WPA-PSK networks. /// /// Uses WPA2 (RSN) with CCMP encryption. The `psk-flags` of 0 means the @@ -192,6 +214,44 @@ pub fn build_wifi_connection( conn } +/// Builds a complete Ethernet connection settings dictionary. +/// +/// Constructs all required sections for NetworkManager. The returned dictionary +/// can be passed directly to `AddAndActivateConnection`. +/// +/// # Sections Created +/// +/// - `connection`: Always present (type: "802-3-ethernet") +/// - `802-3-ethernet`: Ethernet-specific settings (currently empty, can be extended) +/// - `ipv4` / `ipv6`: Always present (set to "auto" for DHCP) +pub fn build_ethernet_connection( + connection_id: &str, + opts: &ConnectionOptions, +) -> HashMap<&'static str, HashMap<&'static str, Value<'static>>> { + let mut conn: HashMap<&'static str, HashMap<&'static str, Value<'static>>> = HashMap::new(); + + // Base connection section + conn.insert( + "connection", + base_ethernet_connection_section(connection_id, opts), + ); + + // Ethernet section (minimal - can be extended for MAC address, MTU, etc.) + let ethernet = HashMap::new(); + conn.insert("802-3-ethernet", ethernet); + + // Add IPv4 and IPv6 configuration + let mut ipv4 = HashMap::new(); + ipv4.insert("method", Value::from("auto")); + conn.insert("ipv4", ipv4); + + let mut ipv6 = HashMap::new(); + ipv6.insert("method", Value::from("auto")); + conn.insert("ipv6", ipv6); + + conn +} + #[cfg(test)] mod tests { use super::*; diff --git a/nmrs/tests/integration_test.rs b/nmrs/tests/integration_test.rs index de487b49..16f7848a 100644 --- a/nmrs/tests/integration_test.rs +++ b/nmrs/tests/integration_test.rs @@ -13,12 +13,18 @@ async fn is_networkmanager_available() -> bool { /// Check if WiFi is available async fn has_wifi_device(nm: &NetworkManager) -> bool { - match nm.list_devices().await { - Ok(devices) => devices - .iter() - .any(|d| matches!(d.device_type, DeviceType::Wifi)), - Err(_) => false, - } + nm.list_wireless_devices() + .await + .map(|d| !d.is_empty()) + .unwrap_or(false) +} + +/// Check if Ethernet is available +async fn has_ethernet_device(nm: &NetworkManager) -> bool { + nm.list_wired_devices() + .await + .map(|d| !d.is_empty()) + .unwrap_or(false) } /// Skip tests if NetworkManager is not available @@ -41,6 +47,16 @@ macro_rules! require_wifi { }; } +/// Skip tests if Ethernet device is not available +macro_rules! require_ethernet { + ($nm:expr) => { + if !has_ethernet_device($nm).await { + eprintln!("Skipping test: No Ethernet device available"); + return; + } + }; +} + /// Test NetworkManager initialization #[tokio::test] async fn test_networkmanager_initialization() { @@ -767,3 +783,57 @@ async fn forget_returns_no_saved_connection_error() { } } } + +/// Test listing wired devices +#[tokio::test] +async fn test_list_wired_devices() { + require_networkmanager!(); + + let nm = NetworkManager::new() + .await + .expect("Failed to create NetworkManager"); + + let devices = nm + .list_wired_devices() + .await + .expect("Failed to list wired devices"); + + // Verify device structure for wired devices + for device in &devices { + assert!(!device.path.is_empty(), "Device path should not be empty"); + assert!( + !device.interface.is_empty(), + "Device interface should not be empty" + ); + assert_eq!( + device.device_type, + DeviceType::Ethernet, + "Device type should be Ethernet" + ); + } +} + +/// Test connecting to wired device +#[tokio::test] +async fn test_connect_wired() { + require_networkmanager!(); + + let nm = NetworkManager::new() + .await + .expect("Failed to create NetworkManager"); + require_ethernet!(&nm); + + // Try to connect to wired device + let result = nm.connect_wired().await; + + match result { + Ok(_) => { + // Connection succeeded or is waiting for cable + eprintln!("Wired connection initiated successfully"); + } + Err(e) => { + // Connection failed - this is acceptable in test environments + eprintln!("Wired connection failed (may be expected): {}", e); + } + } +} From 44fff75cc0b6051f9c007e5b69fda9ee67b854d2 Mon Sep 17 00:00:00 2001 From: Akrm Al-Hakimi Date: Sun, 14 Dec 2025 14:16:11 -0500 Subject: [PATCH 2/3] chore: update styles and documentation for them --- nmrs-gui/src/themes/catppuccin.css | 84 +++++++++++++++++++++ nmrs-gui/src/themes/dracula.css | 84 +++++++++++++++++++++ nmrs-gui/src/themes/gruvbox.css | 115 +++++++++++++++++++++++++++++ nmrs-gui/src/themes/nord.css | 84 +++++++++++++++++++++ nmrs-gui/src/themes/tokyo.css | 84 +++++++++++++++++++++ 5 files changed, 451 insertions(+) diff --git a/nmrs-gui/src/themes/catppuccin.css b/nmrs-gui/src/themes/catppuccin.css index 0a4962a3..4a4869cb 100644 --- a/nmrs-gui/src/themes/catppuccin.css +++ b/nmrs-gui/src/themes/catppuccin.css @@ -1,3 +1,21 @@ +/* + * Catppuccin Theme for nmrs-gui + * + * Customizable CSS Variables: + * --bg-primary: Main background color + * --bg-secondary: Secondary elements (headerbar, cards) + * --bg-tertiary: Hover states + * --text-primary: Primary text color + * --text-secondary: Secondary text (labels, headers) + * --text-tertiary: Tertiary text (back button, toggle) + * --border-color: Default border color + * --border-color-hover: Border color on hover + * --accent-color: Accent color (selected items, switches) + * --success-color: Success indicators (connected, good signal) + * --warning-color: Warning indicators (okay signal) + * --error-color: Error indicators (poor signal) + */ + /* Light theme */ window.light-theme { --bg-primary: #f1f5f9; @@ -265,3 +283,69 @@ popover row:selected { popover row label { color: var(--text-primary); } + +/* Wired Device Styles */ +.wired-section-header { + color: var(--text-secondary); +} + +.wireless-section-header { + color: var(--text-secondary); +} + +.wired-devices-list { + background: var(--bg-primary); + border: none; +} + +.wired-device-row { + padding: 6px 10px; + margin: 2px 0; + background: var(--bg-secondary); + border: 1px solid var(--border-color); +} + +.wired-device-row:hover { + background: var(--bg-tertiary); + border-color: var(--border-color-hover); + transition: background 150ms ease, border-color 150ms ease; +} + +.wired-device-row.connected { + background: color-mix(in srgb, var(--success-color) 15%, transparent); + border-color: color-mix(in srgb, var(--success-color) 30%, transparent); +} + +.wired-device-row.connected:hover { + background: color-mix(in srgb, var(--success-color) 20%, transparent); + border-color: color-mix(in srgb, var(--success-color) 40%, transparent); +} + +.wired-device-row label { + font-size: 14px; + color: var(--text-primary); +} + +.device-separator { + background: var(--border-color); + opacity: 0.3; +} + +.wired-page { + background: var(--bg-primary); + padding: 16px 20px; + color: var(--text-primary); + border: none; +} + +.wired-device-icon { + color: var(--text-primary); +} + +.wired-device-title { + font-size: 18px; + font-weight: 600; + color: var(--text-primary); + margin-bottom: 4px; +} + diff --git a/nmrs-gui/src/themes/dracula.css b/nmrs-gui/src/themes/dracula.css index 0dd7999d..48c71102 100644 --- a/nmrs-gui/src/themes/dracula.css +++ b/nmrs-gui/src/themes/dracula.css @@ -1,3 +1,21 @@ +/* + * Dracula Theme for nmrs-gui + * + * Customizable CSS Variables: + * --bg-primary: Main background color + * --bg-secondary: Secondary elements (headerbar, cards) + * --bg-tertiary: Hover states + * --text-primary: Primary text color + * --text-secondary: Secondary text (labels, headers) + * --text-tertiary: Tertiary text (back button, toggle) + * --border-color: Default border color + * --border-color-hover: Border color on hover + * --accent-color: Accent color (selected items, switches) + * --success-color: Success indicators (connected, good signal) + * --warning-color: Warning indicators (okay signal) + * --error-color: Error indicators (poor signal) + */ + /* Light theme */ window.light-theme { --bg-primary: #f8f8f2; @@ -265,3 +283,69 @@ popover row:selected { popover row label { color: var(--text-primary); } + +/* Wired Device Styles */ +.wired-section-header { + color: var(--text-secondary); +} + +.wireless-section-header { + color: var(--text-secondary); +} + +.wired-devices-list { + background: var(--bg-primary); + border: none; +} + +.wired-device-row { + padding: 6px 10px; + margin: 2px 0; + background: var(--bg-secondary); + border: 1px solid var(--border-color); +} + +.wired-device-row:hover { + background: var(--bg-tertiary); + border-color: var(--border-color-hover); + transition: background 150ms ease, border-color 150ms ease; +} + +.wired-device-row.connected { + background: color-mix(in srgb, var(--success-color) 15%, transparent); + border-color: color-mix(in srgb, var(--success-color) 30%, transparent); +} + +.wired-device-row.connected:hover { + background: color-mix(in srgb, var(--success-color) 20%, transparent); + border-color: color-mix(in srgb, var(--success-color) 40%, transparent); +} + +.wired-device-row label { + font-size: 14px; + color: var(--text-primary); +} + +.device-separator { + background: var(--border-color); + opacity: 0.3; +} + +.wired-page { + background: var(--bg-primary); + padding: 16px 20px; + color: var(--text-primary); + border: none; +} + +.wired-device-icon { + color: var(--text-primary); +} + +.wired-device-title { + font-size: 18px; + font-weight: 600; + color: var(--text-primary); + margin-bottom: 4px; +} + diff --git a/nmrs-gui/src/themes/gruvbox.css b/nmrs-gui/src/themes/gruvbox.css index 2511e851..076d55fa 100644 --- a/nmrs-gui/src/themes/gruvbox.css +++ b/nmrs-gui/src/themes/gruvbox.css @@ -1,3 +1,39 @@ +/* + * Gruvbox Theme for nmrs-gui + * + * Customizable CSS Variables: + * + * Colors: + * --bg-primary: Main background color + * --bg-secondary: Secondary elements (headerbar, cards) + * --bg-tertiary: Hover states + * --text-primary: Primary text color + * --text-secondary: Secondary text (labels, headers) + * --text-tertiary: Tertiary text (back button, toggle) + * --border-color: Default border color + * --border-color-hover: Border color on hover + * --accent-color: Accent color (selected items, switches) + * --success-color: Success indicators (connected, good signal) + * --warning-color: Warning indicators (okay signal) + * --error-color: Error indicators (poor signal) + * + * Layout & Spacing: + * --network-row-padding: Padding for network/device rows + * --network-row-margin: Margin between rows + * --section-header-spacing: Bottom margin for section headers + * --page-padding: Padding for detail pages + * + * Typography: + * --font-size-base: Base font size (14px) + * --font-size-small: Small text (12px) + * --font-size-large: Large headings (18px) + * --font-size-header: Section headers (13px) + * + * Borders: + * --border-width: Border width for elements + * --border-radius: Border radius (currently 0 for sharp look) + */ + /* Light theme */ window.light-theme { --bg-primary: #fbf1c7; @@ -12,6 +48,19 @@ window.light-theme { --success-color: #98971a; --warning-color: #d65d0e; --error-color: #cc241d; + + --network-row-padding: 6px 10px; + --network-row-margin: 2px 0; + --section-header-spacing: 6px; + --page-padding: 16px 20px; + + --font-size-base: 14px; + --font-size-small: 12px; + --font-size-large: 18px; + --font-size-header: 13px; + + --border-width: 1px; + --border-radius: 0; } /* Dark theme */ @@ -265,3 +314,69 @@ popover row:selected { popover row label { color: var(--text-primary); } + +/* Wired Device Styles */ +.wired-section-header { + color: var(--text-secondary); +} + +.wireless-section-header { + color: var(--text-secondary); +} + +.wired-devices-list { + background: var(--bg-primary); + border: none; +} + +.wired-device-row { + padding: 6px 10px; + margin: 2px 0; + background: var(--bg-secondary); + border: 1px solid var(--border-color); +} + +.wired-device-row:hover { + background: var(--bg-tertiary); + border-color: var(--border-color-hover); + transition: background 150ms ease, border-color 150ms ease; +} + +.wired-device-row.connected { + background: color-mix(in srgb, var(--success-color) 15%, transparent); + border-color: color-mix(in srgb, var(--success-color) 30%, transparent); +} + +.wired-device-row.connected:hover { + background: color-mix(in srgb, var(--success-color) 20%, transparent); + border-color: color-mix(in srgb, var(--success-color) 40%, transparent); +} + +.wired-device-row label { + font-size: 14px; + color: var(--text-primary); +} + +.device-separator { + background: var(--border-color); + opacity: 0.3; +} + +.wired-page { + background: var(--bg-primary); + padding: 16px 20px; + color: var(--text-primary); + border: none; +} + +.wired-device-icon { + color: var(--text-primary); +} + +.wired-device-title { + font-size: 18px; + font-weight: 600; + color: var(--text-primary); + margin-bottom: 4px; +} + diff --git a/nmrs-gui/src/themes/nord.css b/nmrs-gui/src/themes/nord.css index c5d81adb..5e7d3ac8 100644 --- a/nmrs-gui/src/themes/nord.css +++ b/nmrs-gui/src/themes/nord.css @@ -1,3 +1,21 @@ +/* + * Nord Theme for nmrs-gui + * + * Customizable CSS Variables: + * --bg-primary: Main background color + * --bg-secondary: Secondary elements (headerbar, cards) + * --bg-tertiary: Hover states + * --text-primary: Primary text color + * --text-secondary: Secondary text (labels, headers) + * --text-tertiary: Tertiary text (back button, toggle) + * --border-color: Default border color + * --border-color-hover: Border color on hover + * --accent-color: Accent color (selected items, switches) + * --success-color: Success indicators (connected, good signal) + * --warning-color: Warning indicators (okay signal) + * --error-color: Error indicators (poor signal) + */ + /* Light theme */ window.light-theme { --bg-primary: #eceff4; @@ -265,3 +283,69 @@ popover row:selected { popover row label { color: var(--text-primary); } + +/* Wired Device Styles */ +.wired-section-header { + color: var(--text-secondary); +} + +.wireless-section-header { + color: var(--text-secondary); +} + +.wired-devices-list { + background: var(--bg-primary); + border: none; +} + +.wired-device-row { + padding: 6px 10px; + margin: 2px 0; + background: var(--bg-secondary); + border: 1px solid var(--border-color); +} + +.wired-device-row:hover { + background: var(--bg-tertiary); + border-color: var(--border-color-hover); + transition: background 150ms ease, border-color 150ms ease; +} + +.wired-device-row.connected { + background: color-mix(in srgb, var(--success-color) 15%, transparent); + border-color: color-mix(in srgb, var(--success-color) 30%, transparent); +} + +.wired-device-row.connected:hover { + background: color-mix(in srgb, var(--success-color) 20%, transparent); + border-color: color-mix(in srgb, var(--success-color) 40%, transparent); +} + +.wired-device-row label { + font-size: 14px; + color: var(--text-primary); +} + +.device-separator { + background: var(--border-color); + opacity: 0.3; +} + +.wired-page { + background: var(--bg-primary); + padding: 16px 20px; + color: var(--text-primary); + border: none; +} + +.wired-device-icon { + color: var(--text-primary); +} + +.wired-device-title { + font-size: 18px; + font-weight: 600; + color: var(--text-primary); + margin-bottom: 4px; +} + diff --git a/nmrs-gui/src/themes/tokyo.css b/nmrs-gui/src/themes/tokyo.css index 93316b6e..e1617b9a 100644 --- a/nmrs-gui/src/themes/tokyo.css +++ b/nmrs-gui/src/themes/tokyo.css @@ -1,3 +1,21 @@ +/* + * Tokyo Night Theme for nmrs-gui + * + * Customizable CSS Variables: + * --bg-primary: Main background color + * --bg-secondary: Secondary elements (headerbar, cards) + * --bg-tertiary: Hover states + * --text-primary: Primary text color + * --text-secondary: Secondary text (labels, headers) + * --text-tertiary: Tertiary text (back button, toggle) + * --border-color: Default border color + * --border-color-hover: Border color on hover + * --accent-color: Accent color (selected items, switches) + * --success-color: Success indicators (connected, good signal) + * --warning-color: Warning indicators (okay signal) + * --error-color: Error indicators (poor signal) + */ + /* Light theme */ window.light-theme { --bg-primary: #dfe1e8; @@ -265,3 +283,69 @@ popover row:selected { popover row label { color: var(--text-primary); } + +/* Wired Device Styles */ +.wired-section-header { + color: var(--text-secondary); +} + +.wireless-section-header { + color: var(--text-secondary); +} + +.wired-devices-list { + background: var(--bg-primary); + border: none; +} + +.wired-device-row { + padding: 6px 10px; + margin: 2px 0; + background: var(--bg-secondary); + border: 1px solid var(--border-color); +} + +.wired-device-row:hover { + background: var(--bg-tertiary); + border-color: var(--border-color-hover); + transition: background 150ms ease, border-color 150ms ease; +} + +.wired-device-row.connected { + background: color-mix(in srgb, var(--success-color) 15%, transparent); + border-color: color-mix(in srgb, var(--success-color) 30%, transparent); +} + +.wired-device-row.connected:hover { + background: color-mix(in srgb, var(--success-color) 20%, transparent); + border-color: color-mix(in srgb, var(--success-color) 40%, transparent); +} + +.wired-device-row label { + font-size: 14px; + color: var(--text-primary); +} + +.device-separator { + background: var(--border-color); + opacity: 0.3; +} + +.wired-page { + background: var(--bg-primary); + padding: 16px 20px; + color: var(--text-primary); + border: none; +} + +.wired-device-icon { + color: var(--text-primary); +} + +.wired-device-title { + font-size: 18px; + font-weight: 600; + color: var(--text-primary); + margin-bottom: 4px; +} + From f746b5c8a7327d613262793de86526cfb2473639 Mon Sep 17 00:00:00 2001 From: Akrm Al-Hakimi Date: Sun, 14 Dec 2025 14:24:05 -0500 Subject: [PATCH 3/3] fix(#56): supply users with option to provide cert path (WPA-EAP) --- nmrs-gui/src/themes/catppuccin.css | 13 +++++ nmrs-gui/src/themes/dracula.css | 13 +++++ nmrs-gui/src/themes/gruvbox.css | 13 +++++ nmrs-gui/src/themes/nord.css | 13 +++++ nmrs-gui/src/themes/tokyo.css | 13 +++++ nmrs-gui/src/ui/connect.rs | 85 ++++++++++++++++++++++++++++-- nmrs/src/utils.rs | 2 +- 7 files changed, 147 insertions(+), 5 deletions(-) diff --git a/nmrs-gui/src/themes/catppuccin.css b/nmrs-gui/src/themes/catppuccin.css index 4a4869cb..5abf5395 100644 --- a/nmrs-gui/src/themes/catppuccin.css +++ b/nmrs-gui/src/themes/catppuccin.css @@ -349,3 +349,16 @@ popover row label { margin-bottom: 4px; } +.cert-browse-btn { + background: var(--bg-tertiary); + border: 1px solid var(--border-color); + color: var(--text-primary); + padding: 6px 12px; + font-size: 13px; +} + +.cert-browse-btn:hover { + background: var(--bg-secondary); + border-color: var(--border-color-hover); +} + diff --git a/nmrs-gui/src/themes/dracula.css b/nmrs-gui/src/themes/dracula.css index 48c71102..363d5d79 100644 --- a/nmrs-gui/src/themes/dracula.css +++ b/nmrs-gui/src/themes/dracula.css @@ -349,3 +349,16 @@ popover row label { margin-bottom: 4px; } +.cert-browse-btn { + background: var(--bg-tertiary); + border: 1px solid var(--border-color); + color: var(--text-primary); + padding: 6px 12px; + font-size: 13px; +} + +.cert-browse-btn:hover { + background: var(--bg-secondary); + border-color: var(--border-color-hover); +} + diff --git a/nmrs-gui/src/themes/gruvbox.css b/nmrs-gui/src/themes/gruvbox.css index 076d55fa..8b08bdd6 100644 --- a/nmrs-gui/src/themes/gruvbox.css +++ b/nmrs-gui/src/themes/gruvbox.css @@ -380,3 +380,16 @@ popover row label { margin-bottom: 4px; } +.cert-browse-btn { + background: var(--bg-tertiary); + border: 1px solid var(--border-color); + color: var(--text-primary); + padding: 6px 12px; + font-size: 13px; +} + +.cert-browse-btn:hover { + background: var(--bg-secondary); + border-color: var(--border-color-hover); +} + diff --git a/nmrs-gui/src/themes/nord.css b/nmrs-gui/src/themes/nord.css index 5e7d3ac8..5ec7a32a 100644 --- a/nmrs-gui/src/themes/nord.css +++ b/nmrs-gui/src/themes/nord.css @@ -349,3 +349,16 @@ popover row label { margin-bottom: 4px; } +.cert-browse-btn { + background: var(--bg-tertiary); + border: 1px solid var(--border-color); + color: var(--text-primary); + padding: 6px 12px; + font-size: 13px; +} + +.cert-browse-btn:hover { + background: var(--bg-secondary); + border-color: var(--border-color-hover); +} + diff --git a/nmrs-gui/src/themes/tokyo.css b/nmrs-gui/src/themes/tokyo.css index e1617b9a..b4df0be6 100644 --- a/nmrs-gui/src/themes/tokyo.css +++ b/nmrs-gui/src/themes/tokyo.css @@ -349,3 +349,16 @@ popover row label { margin-bottom: 4px; } +.cert-browse-btn { + background: var(--bg-tertiary); + border: 1px solid var(--border-color); + color: var(--text-primary); + padding: 6px 12px; + font-size: 13px; +} + +.cert-browse-btn:hover { + background: var(--bg-secondary); + border-color: var(--border-color-hover); +} + diff --git a/nmrs-gui/src/ui/connect.rs b/nmrs-gui/src/ui/connect.rs index 6a682568..0b046733 100644 --- a/nmrs-gui/src/ui/connect.rs +++ b/nmrs-gui/src/ui/connect.rs @@ -1,7 +1,7 @@ use glib::Propagation; use gtk::{ - ApplicationWindow, Box as GtkBox, Dialog, Entry, EventControllerKey, Label, Orientation, - prelude::*, + ApplicationWindow, Box as GtkBox, Button, CheckButton, Dialog, Entry, EventControllerKey, + FileChooserAction, FileChooserDialog, Label, Orientation, ResponseType, prelude::*, }; use log::{debug, error}; use nmrs::{ @@ -74,6 +74,32 @@ fn draw_connect_modal( vbox.append(&label); vbox.append(&entry); + let (cert_entry, use_system_certs, browse_btn) = if is_eap { + let cert_label = Label::new(Some("CA Certificate (optional):")); + cert_label.set_margin_top(8); + let cert_entry = Entry::new(); + cert_entry.add_css_class("pw-entry"); + cert_entry.set_placeholder_text(Some("/path/to/ca-cert.pem")); + + let cert_hbox = GtkBox::new(Orientation::Horizontal, 8); + let browse_btn = Button::with_label("Browse..."); + browse_btn.add_css_class("cert-browse-btn"); + cert_hbox.append(&cert_entry); + cert_hbox.append(&browse_btn); + + vbox.append(&cert_label); + vbox.append(&cert_hbox); + + let system_certs_check = CheckButton::with_label("Use system CA certificates"); + system_certs_check.set_active(true); + system_certs_check.set_margin_top(4); + vbox.append(&system_certs_check); + + (Some(cert_entry), Some(system_certs_check), Some(browse_btn)) + } else { + (None, None, None) + }; + content_area.append(&vbox); let dialog_rc = Rc::new(dialog); @@ -84,11 +110,47 @@ fn draw_connect_modal( status_label.add_css_class("status-label"); vbox.append(&status_label); + if let Some(browse_btn) = browse_btn { + let cert_entry_for_browse = cert_entry.clone(); + let dialog_weak = dialog_rc.downgrade(); + browse_btn.connect_clicked(move |_| { + if let Some(parent_dialog) = dialog_weak.upgrade() { + let file_dialog = FileChooserDialog::new( + Some("Select CA Certificate"), + Some(&parent_dialog), + FileChooserAction::Open, + &[ + ("Cancel", ResponseType::Cancel), + ("Open", ResponseType::Accept), + ], + ); + + let cert_entry = cert_entry_for_browse.clone(); + file_dialog.connect_response(move |dialog, response| { + if response == ResponseType::Accept + && let Some(file) = dialog.file() + && let Some(path) = file.path() + { + cert_entry + .as_ref() + .unwrap() + .set_text(&path.to_string_lossy()); + } + dialog.close(); + }); + + file_dialog.show(); + } + }); + } + { let dialog_rc = dialog_rc.clone(); let status_label = status_label.clone(); let refresh_callback = on_connection_success.clone(); let nm = nm.clone(); + let cert_entry_clone = cert_entry.clone(); + let use_system_certs_clone = use_system_certs.clone(); entry.connect_activate(move |entry| { let pwd = entry.text().to_string(); @@ -97,6 +159,21 @@ fn draw_connect_modal( .as_ref() .map(|e| e.text().to_string()) .unwrap_or_default(); + + let cert_path = cert_entry_clone.as_ref().and_then(|e| { + let text = e.text().to_string(); + if text.trim().is_empty() { + None + } else { + Some(text) + } + }); + + let use_system_ca = use_system_certs_clone + .as_ref() + .map(|cb| cb.is_active()) + .unwrap_or(true); + let ssid = ssid_owned.clone(); let dialog = dialog_rc.clone(); let status = status_label.clone(); @@ -119,8 +196,8 @@ fn draw_connect_modal( password: pwd, anonymous_identity: None, domain_suffix_match: None, - ca_cert_path: None, - system_ca_certs: true, + ca_cert_path: cert_path, + system_ca_certs: use_system_ca, method: EapMethod::Peap, phase2: Phase2::Mschapv2, }, diff --git a/nmrs/src/utils.rs b/nmrs/src/utils.rs index 6e4eee7b..070b6bc0 100644 --- a/nmrs/src/utils.rs +++ b/nmrs/src/utils.rs @@ -23,7 +23,7 @@ pub(crate) fn channel_from_freq(mhz: u32) -> Option { } frequency::BAND_2_4_CH14 => Some(14), frequency::BAND_5_START..=frequency::BAND_5_END => { - Some(((mhz - frequency::BAND_5_START) / frequency::CHANNEL_SPACING) as u16) + Some(((mhz - 5000) / frequency::CHANNEL_SPACING) as u16) } frequency::BAND_6_START..=frequency::BAND_6_END => { Some(((mhz - frequency::BAND_6_START) / frequency::CHANNEL_SPACING + 1) as u16)