Advance netctl and networking tools

Red Bear OS Team
This commit is contained in:
2026-04-16 12:44:35 +01:00
parent 91323523a3
commit 4b76deaa60
18 changed files with 3511 additions and 36 deletions
@@ -0,0 +1,586 @@
use termion::event::Key;
use crate::backend::{ConsoleBackend, IpMode, Profile, ScanResult, SecurityKind, WifiRuntimeState};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Focus {
Profiles,
Scan,
Fields,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Field {
Name,
Description,
Interface,
Ssid,
Security,
Key,
IpMode,
Address,
Gateway,
Dns,
}
impl Field {
pub fn label(&self) -> &'static str {
match self {
Self::Name => "Profile",
Self::Description => "Description",
Self::Interface => "Interface",
Self::Ssid => "SSID",
Self::Security => "Security",
Self::Key => "Key",
Self::IpMode => "IP",
Self::Address => "Address",
Self::Gateway => "Gateway",
Self::Dns => "DNS",
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct EditorState {
pub field: Field,
pub buffer: String,
}
pub struct App<B: ConsoleBackend> {
backend: B,
pub profiles: Vec<String>,
pub selected_profile: usize,
pub draft: Profile,
pub scans: Vec<ScanResult>,
pub selected_scan: usize,
pub selected_field: usize,
pub focus: Focus,
pub editor: Option<EditorState>,
pub active_profile: Option<String>,
pub status: WifiRuntimeState,
pub message: String,
pub dirty: bool,
pub should_quit: bool,
}
impl<B: ConsoleBackend> App<B> {
pub fn new(backend: B) -> Result<Self, String> {
let mut app = Self {
backend,
profiles: Vec::new(),
selected_profile: 0,
draft: Profile::default(),
scans: Vec::new(),
selected_scan: 0,
selected_field: 0,
focus: Focus::Profiles,
editor: None,
active_profile: None,
status: WifiRuntimeState::default(),
message: "Tab switches panes. r scans. s saves. c connects. d disconnects.".to_string(),
dirty: false,
should_quit: false,
};
app.reload_profiles()?;
app.refresh_status();
Ok(app)
}
pub fn visible_fields(&self) -> Vec<Field> {
let mut fields = vec![
Field::Name,
Field::Description,
Field::Interface,
Field::Ssid,
Field::Security,
];
if matches!(self.draft.security, SecurityKind::Wpa2Psk) {
fields.push(Field::Key);
}
fields.push(Field::IpMode);
if matches!(self.draft.ip_mode, IpMode::Static) {
fields.extend([Field::Address, Field::Gateway, Field::Dns]);
}
fields
}
pub fn selected_field(&self) -> Field {
let fields = self.visible_fields();
fields[self.selected_field.min(fields.len().saturating_sub(1))]
}
pub fn field_value(&self, field: Field) -> String {
match field {
Field::Name => self.draft.name.clone(),
Field::Description => self.draft.description.clone(),
Field::Interface => self.draft.interface.clone(),
Field::Ssid => self.draft.ssid.clone(),
Field::Security => self.draft.security.as_str().to_string(),
Field::Key => mask_secret(&self.draft.key),
Field::IpMode => self.draft.ip_mode.as_str().to_string(),
Field::Address => self.draft.address.clone(),
Field::Gateway => self.draft.gateway.clone(),
Field::Dns => self.draft.dns.clone(),
}
}
pub fn handle_key(&mut self, key: Key) {
if self.editor.is_some() {
self.handle_editor_key(key);
return;
}
match key {
Key::Char('q') => self.should_quit = true,
Key::Char('\t') => self.cycle_focus(true),
Key::BackTab => self.cycle_focus(false),
Key::Char('r') => self.scan(),
Key::Char('s') => self.save_current_profile(),
Key::Char('a') => self.activate_current_profile(),
Key::Char('c') => self.connect_current_profile(),
Key::Char('d') => self.disconnect_current_profile(),
Key::Char('n') => self.start_new_profile(),
Key::Up | Key::Char('k') => self.move_selection(-1),
Key::Down | Key::Char('j') => self.move_selection(1),
Key::Left | Key::Char('h') => self.adjust_field_enum(false),
Key::Right | Key::Char('l') => self.adjust_field_enum(true),
Key::Char('\n') => self.activate_current_focus(),
_ => {}
}
}
pub fn reload_profiles(&mut self) -> Result<(), String> {
self.active_profile = self.backend.active_profile_name()?;
self.profiles = self.backend.list_wifi_profiles()?;
if let Some(active) = &self.active_profile
&& let Some(index) = self.profiles.iter().position(|name| name == active)
{
self.selected_profile = index;
self.load_selected_profile()?;
return Ok(());
}
if let Some(index) = self
.profiles
.iter()
.position(|name| name == &self.draft.name)
{
self.selected_profile = index;
return Ok(());
}
if !self.profiles.is_empty() {
self.selected_profile = 0;
self.load_selected_profile()?;
}
Ok(())
}
pub fn refresh_status(&mut self) {
self.status = self.backend.read_status(&self.draft.interface);
}
fn cycle_focus(&mut self, forward: bool) {
self.focus = match (self.focus, forward) {
(Focus::Profiles, true) => Focus::Scan,
(Focus::Scan, true) => Focus::Fields,
(Focus::Fields, true) => Focus::Profiles,
(Focus::Profiles, false) => Focus::Fields,
(Focus::Scan, false) => Focus::Profiles,
(Focus::Fields, false) => Focus::Scan,
};
}
fn move_selection(&mut self, delta: isize) {
match self.focus {
Focus::Profiles => adjust_index(&mut self.selected_profile, self.profiles.len(), delta),
Focus::Scan => adjust_index(&mut self.selected_scan, self.scans.len(), delta),
Focus::Fields => {
let field_count = self.visible_fields().len();
adjust_index(&mut self.selected_field, field_count, delta)
}
}
}
fn activate_current_focus(&mut self) {
match self.focus {
Focus::Profiles => {
if let Err(err) = self.load_selected_profile() {
self.message = format!("Error: {err}");
}
}
Focus::Scan => self.apply_selected_scan(),
Focus::Fields => self.activate_selected_field(),
}
}
fn activate_selected_field(&mut self) {
match self.selected_field() {
Field::Security => {
self.draft.security = self.draft.security.next();
self.selected_field = self
.selected_field
.min(self.visible_fields().len().saturating_sub(1));
self.dirty = true;
}
Field::IpMode => {
self.draft.ip_mode = self.draft.ip_mode.next();
self.selected_field = self
.selected_field
.min(self.visible_fields().len().saturating_sub(1));
self.dirty = true;
}
field => {
self.editor = Some(EditorState {
field,
buffer: self.raw_field_value(field),
});
}
}
}
fn adjust_field_enum(&mut self, forward: bool) {
if self.focus != Focus::Fields {
return;
}
match self.selected_field() {
Field::Security => {
self.draft.security = if forward {
self.draft.security.next()
} else {
self.draft.security.previous()
};
self.selected_field = self
.selected_field
.min(self.visible_fields().len().saturating_sub(1));
self.dirty = true;
}
Field::IpMode => {
self.draft.ip_mode = if forward {
self.draft.ip_mode.next()
} else {
self.draft.ip_mode.previous()
};
self.selected_field = self
.selected_field
.min(self.visible_fields().len().saturating_sub(1));
self.dirty = true;
}
_ => {}
}
}
fn handle_editor_key(&mut self, key: Key) {
let Some(editor) = self.editor.as_mut() else {
return;
};
match key {
Key::Esc => self.editor = None,
Key::Backspace => {
editor.buffer.pop();
}
Key::Char('\n') => {
let field = editor.field;
let buffer = editor.buffer.clone();
self.editor = None;
self.commit_field(field, buffer);
}
Key::Char(ch) if !ch.is_control() => editor.buffer.push(ch),
_ => {}
}
}
fn commit_field(&mut self, field: Field, value: String) {
match field {
Field::Name => self.draft.name = value,
Field::Description => self.draft.description = value,
Field::Interface => {
self.draft.interface = value;
self.refresh_status();
}
Field::Ssid => self.draft.ssid = value,
Field::Key => self.draft.key = value,
Field::Address => self.draft.address = value,
Field::Gateway => self.draft.gateway = value,
Field::Dns => self.draft.dns = value,
Field::Security | Field::IpMode => {}
}
self.dirty = true;
}
fn load_selected_profile(&mut self) -> Result<(), String> {
let Some(name) = self.profiles.get(self.selected_profile).cloned() else {
return Ok(());
};
self.draft = self.backend.load_profile(&name)?;
self.editor = None;
self.selected_field = 0;
self.selected_scan = 0;
self.scans.clear();
self.refresh_status();
self.message = format!("Loaded {name}");
self.dirty = false;
Ok(())
}
fn apply_selected_scan(&mut self) {
let Some(scan) = self.scans.get(self.selected_scan).cloned() else {
self.message = "No scan result selected".to_string();
return;
};
self.draft.ssid = scan.ssid.clone();
if let Some(security_hint) = scan.security_hint {
self.draft.security = security_hint;
}
self.dirty = true;
self.message = format!("Selected SSID {}", scan.ssid);
}
fn save_current_profile(&mut self) {
match self.backend.save_profile(&self.draft) {
Ok(()) => {
self.dirty = false;
self.message = format!("Saved {}", self.draft.name);
if let Err(err) = self.reload_profiles() {
self.message = format!("Saved {}, but reload failed: {err}", self.draft.name);
}
}
Err(err) => self.message = format!("Error: {err}"),
}
}
fn activate_current_profile(&mut self) {
match self.backend.save_profile(&self.draft) {
Ok(()) => match self.backend.set_active_profile(&self.draft.name) {
Ok(()) => {
self.active_profile = Some(self.draft.name.clone());
self.dirty = false;
self.message = format!("Activated {} for boot/profile reuse", self.draft.name);
let _ = self.reload_profiles();
}
Err(err) => self.message = format!("Error: {err}"),
},
Err(err) => self.message = format!("Error: {err}"),
}
}
fn connect_current_profile(&mut self) {
match self.backend.connect(&self.draft) {
Ok(message) => {
self.active_profile = Some(self.draft.name.clone());
self.dirty = false;
self.message = message;
let _ = self.reload_profiles();
self.refresh_status();
}
Err(err) => {
self.refresh_status();
self.message = format!("Error: {err}");
}
}
}
fn disconnect_current_profile(&mut self) {
match self
.backend
.disconnect(Some(&self.draft.name), &self.draft.interface)
{
Ok(message) => {
if self.active_profile.as_deref() == Some(self.draft.name.as_str()) {
self.active_profile = None;
}
self.message = message;
let _ = self.reload_profiles();
self.refresh_status();
}
Err(err) => {
self.refresh_status();
self.message = format!("Error: {err}");
}
}
}
fn scan(&mut self) {
match self.backend.scan(&self.draft.interface) {
Ok(scans) => {
self.scans = scans;
self.selected_scan = 0;
self.refresh_status();
self.message = if self.scans.is_empty() {
format!(
"Scan completed for {} with no visible results",
self.draft.interface
)
} else {
format!(
"Scan completed for {} with {} result(s)",
self.draft.interface,
self.scans.len()
)
};
}
Err(err) => {
self.refresh_status();
self.message = format!("Error: {err}");
}
}
}
fn start_new_profile(&mut self) {
self.draft = Profile::default();
self.scans.clear();
self.selected_scan = 0;
self.selected_field = 0;
self.focus = Focus::Fields;
self.editor = None;
self.dirty = true;
self.refresh_status();
self.message = "Started a new Wi-Fi profile draft".to_string();
}
fn raw_field_value(&self, field: Field) -> String {
match field {
Field::Name => self.draft.name.clone(),
Field::Description => self.draft.description.clone(),
Field::Interface => self.draft.interface.clone(),
Field::Ssid => self.draft.ssid.clone(),
Field::Key => self.draft.key.clone(),
Field::Address => self.draft.address.clone(),
Field::Gateway => self.draft.gateway.clone(),
Field::Dns => self.draft.dns.clone(),
Field::Security => self.draft.security.as_str().to_string(),
Field::IpMode => self.draft.ip_mode.as_str().to_string(),
}
}
}
fn adjust_index(index: &mut usize, len: usize, delta: isize) {
if len == 0 {
*index = 0;
return;
}
if delta < 0 {
*index = index.saturating_sub(delta.unsigned_abs());
} else {
*index = (*index + delta as usize).min(len.saturating_sub(1));
}
}
fn mask_secret(secret: &str) -> String {
if secret.is_empty() {
"<empty>".to_string()
} else {
"".repeat(secret.chars().count())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::backend::ScanResult;
#[derive(Default)]
struct MockBackend {
profiles: Vec<String>,
active_profile: Option<String>,
loaded_profile: Profile,
scans: Vec<ScanResult>,
}
impl ConsoleBackend for MockBackend {
fn list_wifi_profiles(&self) -> Result<Vec<String>, String> {
Ok(self.profiles.clone())
}
fn active_profile_name(&self) -> Result<Option<String>, String> {
Ok(self.active_profile.clone())
}
fn load_profile(&self, _name: &str) -> Result<Profile, String> {
Ok(self.loaded_profile.clone())
}
fn save_profile(&self, _profile: &Profile) -> Result<(), String> {
Ok(())
}
fn set_active_profile(&self, _profile_name: &str) -> Result<(), String> {
Ok(())
}
fn clear_active_profile(&self) -> Result<(), String> {
Ok(())
}
fn read_status(&self, interface: &str) -> WifiRuntimeState {
WifiRuntimeState {
interface: interface.to_string(),
status: "unknown".to_string(),
address: "unconfigured".to_string(),
..WifiRuntimeState::default()
}
}
fn scan(&self, _interface: &str) -> Result<Vec<ScanResult>, String> {
Ok(self.scans.clone())
}
fn connect(&self, _profile: &Profile) -> Result<String, String> {
Ok("applied wifi-profile via wlan0".to_string())
}
fn disconnect(
&self,
_profile_name: Option<&str>,
_interface: &str,
) -> Result<String, String> {
Ok("disconnected wlan0".to_string())
}
}
#[test]
fn visible_fields_follow_security_and_ip_mode() {
let backend = MockBackend::default();
let mut app = App::new(backend).unwrap();
assert!(!app.visible_fields().contains(&Field::Key));
assert!(!app.visible_fields().contains(&Field::Address));
app.draft.security = SecurityKind::Wpa2Psk;
app.draft.ip_mode = IpMode::Static;
assert!(app.visible_fields().contains(&Field::Key));
assert!(app.visible_fields().contains(&Field::Address));
assert!(app.visible_fields().contains(&Field::Gateway));
assert!(app.visible_fields().contains(&Field::Dns));
}
#[test]
fn selecting_scan_applies_ssid_and_security_hint() {
let backend = MockBackend {
scans: vec![ScanResult {
raw: "ssid=demo-open security=open".to_string(),
ssid: "demo-open".to_string(),
security_hint: Some(SecurityKind::Open),
}],
..MockBackend::default()
};
let mut app = App::new(backend).unwrap();
app.handle_key(Key::Char('r'));
app.focus = Focus::Scan;
app.handle_key(Key::Char('\n'));
assert_eq!(app.draft.ssid, "demo-open");
assert_eq!(app.draft.security, SecurityKind::Open);
}
}