M1.1 收尾: - 24项 P0/P1/P2 bug 修复 (Rust 107 tests + Flutter 15 tests) - Flutter App v0.3: cupertino_icons 修复, 单元测试, 调试面板, APK 52.6MB - 示例插件完善: manifest.json + 请求/响应示范 + 7个测试 - API 文档重写 (以 routes.rs 为唯一权威) - MILESTONES.md 更新至 100% M1.2 启动: - P0: 插件管理 API 闭环 (handle_manager_message Custom 分支 + broadcast_plugin_states) - ServiceManager 集成测试 8/8 (tests/m1_2_service_manager.rs) - M1.2 测试计划 (docs/M1.2_TEST_PLAN.md, 18个E2E场景) - 动态插件系统: auto_rollback + version_manager GC + 路径穿越防护 总计: Rust 115/115 测试, Flutter 15/15 测试, 零 warning Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
408 lines
11 KiB
Rust
408 lines
11 KiB
Rust
//! 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<PluginContext>,
|
|
}
|
|
|
|
#[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<String>,
|
|
}
|
|
|
|
impl WifiPlugin {
|
|
pub fn new() -> Self {
|
|
Self { ctx: None }
|
|
}
|
|
|
|
fn nmcli_args(parts: &[&str]) -> Vec<String> {
|
|
parts.iter().map(|part| (*part).to_string()).collect()
|
|
}
|
|
|
|
fn build_connect_args(ssid: &str, password: &str) -> Vec<String> {
|
|
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<String> {
|
|
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<String> {
|
|
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<String> {
|
|
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<serde_json::Value> {
|
|
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::<i32>().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<serde_json::Value> {
|
|
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<serde_json::Value> {
|
|
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<String, Vec<String>> = 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::<Vec<_>>();
|
|
|
|
Ok(json!({
|
|
"ok": true,
|
|
"action": "status",
|
|
"devices": devices,
|
|
}))
|
|
}
|
|
|
|
fn ap_start(&self, ssid: &str, password: &str) -> Result<serde_json::Value> {
|
|
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<serde_json::Value> {
|
|
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<String> {
|
|
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\\"#,
|
|
]
|
|
);
|
|
}
|
|
}
|