diff --git a/Readme.md b/Readme.md index c60c7b0..a213daf 100644 --- a/Readme.md +++ b/Readme.md @@ -5,7 +5,7 @@ [![CI](https://github.com/aashish-thapa/wlctl/actions/workflows/ci.yaml/badge.svg)](https://github.com/aashish-thapa/wlctl/actions/workflows/ci.yaml) [![Crates.io](https://img.shields.io/crates/v/wlctl.svg)](https://crates.io/crates/wlctl) - [![Downloads](https://img.shields.io/crates/d/wlctl.svg)](https://crates.io/crates/impala-nm) + [![Downloads](https://img.shields.io/crates/d/wlctl.svg)](https://crates.io/crates/wlctl) [![License](https://img.shields.io/crates/l/wlctl.svg)](https://github.com/aashish-thapa/wlctl/blob/main/LICENSE) @@ -92,6 +92,10 @@ This will produce an executable file at `target/release/wlctl` that you can copy `Space or Enter`: Connect/Disconnect the network. +### New Networks + +`h`: Connect to a hidden network. + ### Known Networks `t`: Enable/Disable auto-connect. @@ -132,6 +136,10 @@ stop = 'x' [station] toggle_scanning = "s" +[station.new_network] +show_all = "a" +connect_hidden = "h" + [station.known_network] toggle_autoconnect = "t" remove = "d" diff --git a/src/app.rs b/src/app.rs index d765d25..b6c65dc 100644 --- a/src/app.rs +++ b/src/app.rs @@ -26,6 +26,7 @@ pub enum FocusedBlock { RequestUsernameAndPassword, ShareNetwork, SpeedTest, + HiddenSsidInput, } pub struct App { diff --git a/src/config.rs b/src/config.rs index 07b2bfe..8b65338 100644 --- a/src/config.rs +++ b/src/config.rs @@ -118,14 +118,23 @@ fn default_station_remove_known_network() -> char { #[derive(Deserialize, Debug)] pub struct NewNetwork { pub show_all: char, + #[serde(default = "default_connect_hidden")] + pub connect_hidden: char, } impl Default for NewNetwork { fn default() -> Self { - Self { show_all: 'a' } + Self { + show_all: 'a', + connect_hidden: 'h', + } } } +fn default_connect_hidden() -> char { + 'h' +} + // Access Point #[derive(Deserialize, Debug)] pub struct AccessPoint { diff --git a/src/handler.rs b/src/handler.rs index f81b14d..7ffc0f1 100644 --- a/src/handler.rs +++ b/src/handler.rs @@ -242,6 +242,113 @@ pub async fn handle_key_events( Mode::Station => { if let Some(station) = &mut app.device.station { match app.focused_block { + FocusedBlock::HiddenSsidInput => match key_event.code { + KeyCode::Enter => { + let ssid: String = app.auth.hidden.ssid.value().into(); + let password: String = app.auth.hidden.password.value().into(); + + if ssid.is_empty() { + Notification::send( + "SSID cannot be empty".to_string(), + notification::NotificationLevel::Warning, + &sender, + )?; + } else if app.auth.hidden.requires_password() && password.is_empty() { + Notification::send( + "Password cannot be empty".to_string(), + notification::NotificationLevel::Warning, + &sender, + )?; + } else { + let security = app.auth.hidden.security; + let password = if app.auth.hidden.requires_password() { + Some(password) + } else { + None + }; + + let station_client = station.client.clone(); + let device_path = station.device_path.clone(); + let sender_clone = sender.clone(); + app.auth.hidden.reset(); + app.focused_block = FocusedBlock::NewNetworks; + tokio::spawn(async move { + let _ = station_client + .add_and_activate_hidden_connection( + &device_path, + &ssid, + security, + password.as_deref(), + ) + .await + .map(|_| { + let _ = Notification::send( + format!("Connecting to hidden network: {}", ssid), + notification::NotificationLevel::Info, + &sender_clone, + ); + }) + .map_err(|e| { + let _ = Notification::send( + format!("Failed to connect to {}: {}", ssid, e), + notification::NotificationLevel::Error, + &sender_clone, + ); + }); + }); + } + } + + KeyCode::Tab => { + app.auth.hidden.next_field(); + } + + KeyCode::BackTab => { + app.auth.hidden.prev_field(); + } + + KeyCode::Right => { + if app.auth.hidden.focused_field + == crate::mode::station::auth::hidden::HiddenField::Security + { + app.auth.hidden.cycle_security_next(); + } + } + + KeyCode::Left => { + if app.auth.hidden.focused_field + == crate::mode::station::auth::hidden::HiddenField::Security + { + app.auth.hidden.cycle_security_prev(); + } + } + + KeyCode::Esc => { + app.auth.hidden.reset(); + app.focused_block = FocusedBlock::NewNetworks; + } + + KeyCode::Char('h') if key_event.modifiers == KeyModifiers::CONTROL => { + app.auth.hidden.show_password = !app.auth.hidden.show_password; + } + + _ => match app.auth.hidden.focused_field { + crate::mode::station::auth::hidden::HiddenField::Ssid => { + app.auth + .hidden + .ssid + .handle_event(&crossterm::event::Event::Key(key_event)); + } + crate::mode::station::auth::hidden::HiddenField::Password => { + app.auth + .hidden + .password + .handle_event(&crossterm::event::Event::Key(key_event)); + } + _ => {} + }, + }, + FocusedBlock::PskAuthKey => match key_event.code { KeyCode::Enter => { // Get the password before submit() resets it @@ -641,6 +748,12 @@ pub async fn handle_key_events( station.show_hidden_networks = !station.show_hidden_networks; } + // Connect to hidden network + KeyCode::Char(c) + if c == config.station.new_network.connect_hidden => + { + app.focused_block = FocusedBlock::HiddenSsidInput; + } KeyCode::Enter | KeyCode::Char(' ') => { toggle_connect(app, sender).await? } diff --git a/src/mode/station.rs b/src/mode/station.rs index ca2ecf2..c585ced 100644 --- a/src/mode/station.rs +++ b/src/mode/station.rs @@ -179,19 +179,6 @@ impl Station { }) } - pub async fn connect_hidden_network( - &self, - _ssid: String, - _password: Option<&str>, - ) -> Result<()> { - // For hidden networks, we need to create a connection with the hidden flag - // This is handled by add_and_activate_connection with special settings - // For now, we'll return an error - full hidden network support needs more work - Err(anyhow::anyhow!( - "Hidden network connection not yet implemented for NetworkManager" - )) - } - #[allow(clippy::collapsible_if)] pub async fn refresh(&mut self) -> Result<()> { let device_state = self.client.get_device_state(&self.device_path).await?; @@ -872,6 +859,10 @@ impl Station { Span::from("󱁐 or ↵ ").bold(), Span::from(" Connect"), Span::from(" | "), + Span::from(config.station.new_network.connect_hidden.to_string()) + .bold(), + Span::from(" Hidden"), + Span::from(" | "), Span::from(config.station.start_scanning.to_string()).bold(), Span::from(" Scan"), ]), @@ -900,6 +891,9 @@ impl Station { Span::from("󱁐 or ↵ ").bold(), Span::from(" Connect"), Span::from(" | "), + Span::from(config.station.new_network.connect_hidden.to_string()).bold(), + Span::from(" Hidden"), + Span::from(" | "), Span::from(config.station.new_network.show_all.to_string()).bold(), Span::from(" Show All"), Span::from(" | "), diff --git a/src/mode/station/auth.rs b/src/mode/station/auth.rs index 6419987..76a4d91 100644 --- a/src/mode/station/auth.rs +++ b/src/mode/station/auth.rs @@ -1,4 +1,5 @@ pub mod entreprise; +pub mod hidden; pub mod psk; use std::sync::Arc; @@ -11,6 +12,7 @@ use crate::mode::station::auth::{ username_and_password::RequestUsernameAndPassword, }, }, + hidden::HiddenSsidDialog, psk::Psk, }; use crate::nm::NMClient; @@ -18,6 +20,7 @@ use crate::nm::NMClient; #[derive(Debug, Default)] pub struct Auth { pub psk: Psk, + pub hidden: HiddenSsidDialog, pub eap: Option, pub request_key_passphrase: Option, pub request_password: Option, @@ -31,6 +34,7 @@ impl Auth { pub fn reset(&mut self) { self.psk = Psk::default(); + self.hidden.reset(); self.eap = None; } diff --git a/src/mode/station/auth/hidden.rs b/src/mode/station/auth/hidden.rs new file mode 100644 index 0000000..b5e2925 --- /dev/null +++ b/src/mode/station/auth/hidden.rs @@ -0,0 +1,335 @@ +use crate::nm::SecurityType; + +use ratatui::{ + Frame, + layout::{Constraint, Direction, Layout}, + style::{Color, Style, Stylize}, + text::{Line, Span}, + widgets::{Block, BorderType, Borders, Clear, Padding, Paragraph}, +}; +use tui_input::Input; + +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum HiddenField { + Ssid, + Security, + Password, +} + +#[derive(Debug)] +pub struct HiddenSsidDialog { + pub ssid: Input, + pub password: Input, + pub security: SecurityType, + pub focused_field: HiddenField, + pub show_password: bool, +} + +impl Default for HiddenSsidDialog { + fn default() -> Self { + Self { + ssid: Input::default(), + password: Input::default(), + security: SecurityType::WPA2, + focused_field: HiddenField::Ssid, + show_password: true, + } + } +} + +impl HiddenSsidDialog { + pub fn reset(&mut self) { + self.ssid.reset(); + self.password.reset(); + self.security = SecurityType::WPA2; + self.focused_field = HiddenField::Ssid; + self.show_password = true; + } + + pub fn cycle_security_next(&mut self) { + self.security = match self.security { + SecurityType::Open => SecurityType::WPA2, + SecurityType::WPA2 => SecurityType::WPA3, + SecurityType::WPA3 => SecurityType::Open, + _ => SecurityType::WPA2, + }; + } + + pub fn cycle_security_prev(&mut self) { + self.security = match self.security { + SecurityType::Open => SecurityType::WPA3, + SecurityType::WPA2 => SecurityType::Open, + SecurityType::WPA3 => SecurityType::WPA2, + _ => SecurityType::WPA2, + }; + } + + pub fn next_field(&mut self) { + self.focused_field = match self.focused_field { + HiddenField::Ssid => HiddenField::Security, + HiddenField::Security => { + if self.security == SecurityType::Open { + HiddenField::Ssid + } else { + HiddenField::Password + } + } + HiddenField::Password => HiddenField::Ssid, + }; + } + + pub fn prev_field(&mut self) { + self.focused_field = match self.focused_field { + HiddenField::Ssid => { + if self.security == SecurityType::Open { + HiddenField::Security + } else { + HiddenField::Password + } + } + HiddenField::Security => HiddenField::Ssid, + HiddenField::Password => HiddenField::Security, + }; + } + + pub fn requires_password(&self) -> bool { + self.security != SecurityType::Open + } + + pub fn render(&self, frame: &mut Frame) { + let has_password = self.requires_password(); + let popup_height: u16 = if has_password { 16 } else { 12 }; + + let popup_layout = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Fill(1), + Constraint::Length(popup_height), + Constraint::Fill(1), + ]) + .flex(ratatui::layout::Flex::SpaceBetween) + .split(frame.area()); + + let area = Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Fill(1), + Constraint::Length(60), + Constraint::Fill(1), + ]) + .flex(ratatui::layout::Flex::SpaceBetween) + .split(popup_layout[1])[1]; + + frame.render_widget(Clear, area); + + frame.render_widget( + Block::new() + .borders(Borders::ALL) + .border_type(BorderType::Thick) + .title(" Connect to Hidden Network ") + .title_style(Style::default().bold().fg(Color::White)) + .style(Style::default()) + .border_style(Style::default().fg(Color::Green)) + .padding(Padding::new(2, 2, 1, 0)), + area, + ); + + // Inner area (inside border + padding) + let inner = Layout::default() + .direction(Direction::Vertical) + .constraints(if has_password { + vec![ + Constraint::Length(1), // SSID label + Constraint::Length(1), // SSID input + Constraint::Length(1), // spacer + Constraint::Length(1), // Security label + Constraint::Length(1), // Security selector + Constraint::Length(1), // spacer + Constraint::Length(1), // Password label + Constraint::Length(1), // Password input + Constraint::Length(1), // spacer + Constraint::Length(1), // show password toggle + Constraint::Length(1), // spacer + Constraint::Length(1), // hints + ] + } else { + vec![ + Constraint::Length(1), // SSID label + Constraint::Length(1), // SSID input + Constraint::Length(1), // spacer + Constraint::Length(1), // Security label + Constraint::Length(1), // Security selector + Constraint::Length(1), // spacer + Constraint::Length(1), // spacer + Constraint::Length(1), // hints + ] + }) + .split(Block::new().padding(Padding::new(2, 2, 1, 0)).inner(area)); + + // SSID label + let ssid_label = Paragraph::new(Line::from(vec![ + Span::raw("SSID").bold(), + if self.focused_field == HiddenField::Ssid { + Span::raw(" *").fg(Color::Green) + } else { + Span::raw("") + }, + ])); + frame.render_widget(ssid_label, inner[0]); + + // SSID input + let ssid_str = self.ssid.value().to_string(); + let ssid_style = if self.focused_field == HiddenField::Ssid { + Style::default().fg(Color::White).bg(Color::DarkGray) + } else { + Style::default().fg(Color::Gray).bg(Color::DarkGray) + }; + let ssid_input = Paragraph::new( + if ssid_str.is_empty() && self.focused_field != HiddenField::Ssid { + Line::from(Span::raw("Network name").dim()) + } else { + Line::from(ssid_str.clone()) + }, + ) + .style(ssid_style); + frame.render_widget(ssid_input, inner[1]); + + // Security label + let security_label = Paragraph::new(Line::from(vec![ + Span::raw("Security").bold(), + if self.focused_field == HiddenField::Security { + Span::raw(" *").fg(Color::Green) + } else { + Span::raw("") + }, + ])); + frame.render_widget(security_label, inner[3]); + + // Security selector + let security_options = [SecurityType::Open, SecurityType::WPA2, SecurityType::WPA3]; + let security_spans: Vec = security_options + .iter() + .enumerate() + .flat_map(|(i, sec)| { + let label = match sec { + SecurityType::Open => "Open", + SecurityType::WPA2 => "WPA2", + SecurityType::WPA3 => "WPA3", + _ => "", + }; + let styled = if *sec == self.security { + Span::raw(format!(" {} ", label)) + .bold() + .fg(Color::Black) + .bg(Color::Green) + } else { + Span::raw(format!(" {} ", label)).dim() + }; + if i > 0 { + vec![Span::raw(" "), styled] + } else { + vec![styled] + } + }) + .collect(); + + let security_line = if self.focused_field == HiddenField::Security { + let mut spans = security_spans; + spans.push(Span::raw(" ←/→ to change").dim()); + spans + } else { + security_spans + }; + + frame.render_widget(Paragraph::new(Line::from(security_line)), inner[4]); + + if has_password { + // Password label + let password_label = Paragraph::new(Line::from(vec![ + Span::raw("Password").bold(), + if self.focused_field == HiddenField::Password { + Span::raw(" *").fg(Color::Green) + } else { + Span::raw("") + }, + ])); + frame.render_widget(password_label, inner[6]); + + // Password input + let password_str = if self.show_password { + self.password.value().to_string() + } else { + "*".repeat(self.password.value().len()) + }; + let password_style = if self.focused_field == HiddenField::Password { + Style::default().fg(Color::White).bg(Color::DarkGray) + } else { + Style::default().fg(Color::Gray).bg(Color::DarkGray) + }; + let password_input = Paragraph::new( + if password_str.is_empty() && self.focused_field != HiddenField::Password { + Line::from(Span::raw("Enter password").dim()) + } else { + Line::from(password_str.clone()) + }, + ) + .style(password_style); + frame.render_widget(password_input, inner[7]); + + // Show password toggle + let toggle = Paragraph::new(Line::from(vec![ + if self.show_password { + Span::raw("󰈈 Visible") + } else { + Span::raw("󰈉 Hidden") + }, + Span::raw(" (ctrl+h to toggle)").dim(), + ])); + frame.render_widget(toggle, inner[9]); + + // Hints + let hints = Paragraph::new( + Line::from(vec![ + Span::raw("Tab").bold(), + Span::raw(" Next "), + Span::raw("Enter").bold(), + Span::raw(" Connect "), + Span::raw("Esc").bold(), + Span::raw(" Cancel"), + ]) + .centered(), + ) + .dim(); + frame.render_widget(hints, inner[11]); + } else { + // Hints (no password) + let hints = Paragraph::new( + Line::from(vec![ + Span::raw("Tab").bold(), + Span::raw(" Next "), + Span::raw("Enter").bold(), + Span::raw(" Connect "), + Span::raw("Esc").bold(), + Span::raw(" Cancel"), + ]) + .centered(), + ) + .dim(); + frame.render_widget(hints, inner[7]); + } + + // Set cursor on active input field + match self.focused_field { + HiddenField::Ssid => { + let cursor_x = inner[1].x + self.ssid.visual_cursor().min(ssid_str.len()) as u16; + frame.set_cursor_position((cursor_x, inner[1].y)); + } + HiddenField::Password if has_password => { + let pwd_len = self.password.value().len(); + let cursor_x = inner[7].x + self.password.visual_cursor().min(pwd_len) as u16; + frame.set_cursor_position((cursor_x, inner[7].y)); + } + _ => {} + } + } +} diff --git a/src/nm/mod.rs b/src/nm/mod.rs index ceadcdd..b60e85c 100644 --- a/src/nm/mod.rs +++ b/src/nm/mod.rs @@ -472,12 +472,18 @@ impl NMClient { security.insert("wep-key0", Value::from(pwd)); } } - SecurityType::WPA | SecurityType::WPA2 | SecurityType::WPA3 => { + SecurityType::WPA | SecurityType::WPA2 => { security.insert("key-mgmt", Value::from("wpa-psk")); if let Some(pwd) = password { security.insert("psk", Value::from(pwd)); } } + SecurityType::WPA3 => { + security.insert("key-mgmt", Value::from("sae")); + if let Some(pwd) = password { + security.insert("psk", Value::from(pwd)); + } + } SecurityType::Enterprise => { security.insert("key-mgmt", Value::from("wpa-eap")); // Enterprise auth needs additional 802-1x settings @@ -511,6 +517,87 @@ impl NMClient { Ok(result.1) // Return active connection path } + /// Connect to a hidden network (creates connection profile with hidden flag) + pub async fn add_and_activate_hidden_connection( + &self, + device_path: &str, + ssid: &str, + security: SecurityType, + password: Option<&str>, + ) -> Result { + let proxy = Proxy::new( + &self.connection, + NM_BUS_NAME, + NM_PATH, + "org.freedesktop.NetworkManager", + ) + .await?; + + let mut connection_settings: HashMap<&str, HashMap<&str, Value>> = HashMap::new(); + + // Connection section + let mut conn: HashMap<&str, Value> = HashMap::new(); + conn.insert("type", Value::from("802-11-wireless")); + conn.insert("id", Value::from(ssid)); + connection_settings.insert("connection", conn); + + // Wireless section with hidden flag + let mut wireless: HashMap<&str, Value> = HashMap::new(); + wireless.insert("ssid", Value::from(ssid.as_bytes().to_vec())); + wireless.insert("hidden", Value::from(true)); + connection_settings.insert("802-11-wireless", wireless); + + // Security section (if needed) + if security != SecurityType::Open { + let mut sec: HashMap<&str, Value> = HashMap::new(); + match security { + SecurityType::WEP => { + sec.insert("key-mgmt", Value::from("none")); + if let Some(pwd) = password { + sec.insert("wep-key0", Value::from(pwd)); + } + } + SecurityType::WPA | SecurityType::WPA2 => { + sec.insert("key-mgmt", Value::from("wpa-psk")); + if let Some(pwd) = password { + sec.insert("psk", Value::from(pwd)); + } + } + SecurityType::WPA3 => { + sec.insert("key-mgmt", Value::from("sae")); + if let Some(pwd) = password { + sec.insert("psk", Value::from(pwd)); + } + } + _ => {} + } + connection_settings.insert("802-11-wireless-security", sec); + } + + // IPv4 section (auto) + let mut ipv4: HashMap<&str, Value> = HashMap::new(); + ipv4.insert("method", Value::from("auto")); + connection_settings.insert("ipv4", ipv4); + + // IPv6 section (auto) + let mut ipv6: HashMap<&str, Value> = HashMap::new(); + ipv6.insert("method", Value::from("auto")); + connection_settings.insert("ipv6", ipv6); + + let result: (OwnedObjectPath, OwnedObjectPath) = proxy + .call( + "AddAndActivateConnection", + &( + connection_settings, + ObjectPath::try_from(device_path)?, + ObjectPath::try_from("/")?, + ), + ) + .await?; + + Ok(result.1) // Return active connection path + } + /// Disconnect from current network pub async fn disconnect_device(&self, device_path: &str) -> Result<()> { let proxy = Proxy::new( diff --git a/src/ui.rs b/src/ui.rs index 1d9949b..c308957 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -38,6 +38,10 @@ pub fn render(app: &mut App, frame: &mut Frame) { app.adapter.render(frame, app.device.address.clone()); } + if app.focused_block == FocusedBlock::HiddenSsidInput { + app.auth.hidden.render(frame); + } + if app.agent.psk_required.load(Ordering::Relaxed) { app.focused_block = FocusedBlock::PskAuthKey;