//! WifiPlugin — WiFi 管理 //! //! 通过 nmcli 实现 WiFi 扫描、连接、状态查询、AP 热点启停。 use crate::core::{message::*, plugin::*}; use anyhow::{anyhow, Context, Result}; use serde::Serialize; use serde_json::json; use std::collections::{HashMap, HashSet}; use std::process::Command; use std::thread; use std::time::Duration; pub struct WifiPlugin { ctx: Option, } #[derive(Serialize)] struct WifiNetwork { ssid: String, signal: i32, security: String, } #[derive(Serialize)] struct DeviceStatus { device: String, device_type: String, state: String, connection: String, ip4_addresses: Vec, } impl WifiPlugin { pub fn new() -> Self { Self { ctx: None } } fn nmcli_args(parts: &[&str]) -> Vec { parts.iter().map(|part| (*part).to_string()).collect() } fn build_connect_args(ssid: &str, password: &str) -> Vec { let mut args = Self::nmcli_args(&["device", "wifi", "connect", ssid]); if !password.trim().is_empty() { args.push("password".to_string()); args.push(password.to_string()); } args } fn build_hotspot_args(ssid: &str, password: &str) -> Vec { vec![ "device".to_string(), "wifi".to_string(), "hotspot".to_string(), "ssid".to_string(), ssid.to_string(), "password".to_string(), password.to_string(), ] } fn run_nmcli(args: &[String]) -> Result { let output = Command::new("nmcli") .args(args) .output() .with_context(|| format!("failed to execute nmcli {:?}", args))?; if output.status.success() { Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) } else { let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); let message = if !stderr.is_empty() { stderr } else { stdout }; Err(anyhow!("nmcli {:?} failed: {}", args, message)) } } fn parse_nmcli_fields(line: &str, expected_fields: usize) -> Vec { let mut fields = Vec::with_capacity(expected_fields.max(1)); let mut current = String::new(); let mut escaped = false; for ch in line.chars() { if escaped { current.push(ch); escaped = false; continue; } match ch { '\\' => escaped = true, ':' if fields.len() + 1 < expected_fields => { fields.push(current); current = String::new(); } _ => current.push(ch), } } if escaped { current.push('\\'); } fields.push(current); while fields.len() < expected_fields { fields.push(String::new()); } fields } fn send_result(&self, payload: String) -> Result<()> { let ctx = self .ctx .as_ref() .context("wifi plugin context is not initialized")?; ctx.tx.send(Envelope { from: "wifi".to_string(), to: Destination::Broadcast, message: Message::WifiResult(payload), })?; Ok(()) } fn handle_wifi_command(&self, cmd: WifiCommand) -> String { let result = match cmd { WifiCommand::Scan => self.scan_networks(), WifiCommand::Connect { ssid, password } => self.connect_network(&ssid, &password), WifiCommand::Status => self.status(), WifiCommand::ApStart { ssid, password } => self.ap_start(&ssid, &password), WifiCommand::ApStop => self.ap_stop(), }; match result { Ok(value) => value.to_string(), Err(err) => json!({ "ok": false, "error": err.to_string(), }) .to_string(), } } fn scan_networks(&self) -> Result { Self::run_nmcli(&Self::nmcli_args(&["device", "wifi", "rescan"]))?; thread::sleep(Duration::from_secs(2)); let output = Self::run_nmcli(&Self::nmcli_args(&[ "--terse", "--escape", "yes", "-f", "SSID,SIGNAL,SECURITY", "device", "wifi", "list", ]))?; let networks = output .lines() .filter(|line| !line.trim().is_empty()) .filter_map(|line| { let parts = Self::parse_nmcli_fields(line, 3); let ssid = parts[0].trim().to_string(); if ssid.is_empty() { return None; } let signal = parts[1].trim().parse::().unwrap_or_default(); let security = parts[2].trim().to_string(); Some(WifiNetwork { ssid, signal, security, }) }) .fold( (HashSet::new(), Vec::new()), |(mut seen, mut networks), network| { if seen.insert(network.ssid.clone()) { networks.push(network); } (seen, networks) }, ) .1; Ok(json!({ "ok": true, "action": "scan", "networks": networks, })) } fn connect_network(&self, ssid: &str, password: &str) -> Result { let output = Self::run_nmcli(&Self::build_connect_args(ssid, password))?; Ok(json!({ "ok": true, "action": "connect", "ssid": ssid, "output": output, })) } fn status(&self) -> Result { let device_output = Self::run_nmcli(&Self::nmcli_args(&[ "--terse", "--escape", "yes", "-f", "DEVICE,TYPE,STATE,CONNECTION", "device", "status", ]))?; let ip_output = Self::run_nmcli(&Self::nmcli_args(&[ "--terse", "--escape", "yes", "-f", "DEVICE,IP4.ADDRESS", "device", "show", ]))?; let mut ip_map: HashMap> = HashMap::new(); for line in ip_output.lines().filter(|line| !line.trim().is_empty()) { let parts = Self::parse_nmcli_fields(line, 2); let device = parts[0].trim(); let address = parts[1].trim(); if device.is_empty() || address.is_empty() { continue; } ip_map .entry(device.to_string()) .or_default() .push(address.to_string()); } let devices = device_output .lines() .filter(|line| !line.trim().is_empty()) .map(|line| { let parts = Self::parse_nmcli_fields(line, 4); let device = parts[0].trim().to_string(); DeviceStatus { ip4_addresses: ip_map.remove(&device).unwrap_or_default(), device, device_type: parts[1].trim().to_string(), state: parts[2].trim().to_string(), connection: parts[3].trim().to_string(), } }) .collect::>(); Ok(json!({ "ok": true, "action": "status", "devices": devices, })) } fn ap_start(&self, ssid: &str, password: &str) -> Result { let output = Self::run_nmcli(&Self::build_hotspot_args(ssid, password))?; Ok(json!({ "ok": true, "action": "ap_start", "ssid": ssid, "output": output, })) } fn ap_stop(&self) -> Result { let active = Self::run_nmcli(&Self::nmcli_args(&[ "--terse", "--escape", "yes", "-f", "NAME", "connection", "show", "--active", ]))?; let hotspot_name = active .lines() .map(str::trim) .find(|name| *name == "hotspot") .ok_or_else(|| anyhow!("active hotspot connection 'hotspot' not found"))?; let output = Self::run_nmcli(&Self::nmcli_args(&["connection", "down", hotspot_name]))?; Ok(json!({ "ok": true, "action": "ap_stop", "connection": hotspot_name, "output": output, })) } } impl Default for WifiPlugin { fn default() -> Self { Self::new() } } impl Plugin for WifiPlugin { fn id(&self) -> &str { "wifi" } fn info(&self) -> PluginInfo { PluginInfo { name: "WiFi Manager".to_string(), version: "0.2.0".to_string(), description: "WiFi 管理 (nmcli)".to_string(), platform: Platform::Linux, } } fn dependencies(&self) -> Vec { vec![] } fn init(&mut self, ctx: PluginContext) -> Result<()> { self.ctx = Some(ctx); Ok(()) } fn start(&mut self) -> Result<()> { Ok(()) } fn handle_message(&mut self, msg: Message) -> Result<()> { if let Message::WifiCommand(cmd) = msg { let payload = self.handle_wifi_command(cmd); self.send_result(payload)?; } Ok(()) } fn stop(&mut self) -> Result<()> { Ok(()) } } #[cfg(test)] mod tests { use super::WifiPlugin; #[test] fn parse_nmcli_fields_unescapes_terse_output() { let fields = WifiPlugin::parse_nmcli_fields(r#"Cafe\:Net:78:WPA2\\Enterprise"#, 3); assert_eq!(fields, vec!["Cafe:Net", "78", r#"WPA2\Enterprise"#]); } #[test] fn parse_nmcli_fields_keeps_colons_in_last_field() { let fields = WifiPlugin::parse_nmcli_fields(r#"wlan0:wifi:connected:Office\:LAN"#, 4); assert_eq!(fields, vec!["wlan0", "wifi", "connected", "Office:LAN"]); } #[test] fn connect_args_preserve_special_characters() { let args = WifiPlugin::build_connect_args(r#"ssid \"qa\" demo"#, r#"p@ss\\word with spaces"#); assert_eq!( args, vec![ "device", "wifi", "connect", r#"ssid \"qa\" demo"#, "password", r#"p@ss\\word with spaces"#, ] ); } #[test] fn hotspot_args_preserve_special_characters() { let args = WifiPlugin::build_hotspot_args("Showen AP", r#"\\quoted pass\\"#); assert_eq!( args, vec![ "device", "wifi", "hotspot", "ssid", "Showen AP", "password", r#"\\quoted pass\\"#, ] ); } }