use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::time::Instant;
use ratatui::{
prelude::*,
widgets::{BarChart, Block, Borders, Cell, Paragraph, Row, Scrollbar, ScrollbarOrientation,
ScrollbarState, Table, TableState},
};
use crate::alerts::AlertEngine;
use crate::deauth::DEAUTH_REASONS;
use crate::types::*;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ViewMode {
APs,
Probes,
ChannelGraph,
}
impl ViewMode {
pub fn next(&self) -> Self {
match self {
Self::APs => Self::Probes,
Self::Probes => Self::ChannelGraph,
Self::ChannelGraph => Self::APs,
}
}
pub fn label(&self) -> &str {
match self {
Self::APs => "APs",
Self::Probes => "Probes",
Self::ChannelGraph => "Channels",
}
}
}
pub struct App {
pub ap_map: ApMap,
pub table_state: TableState,
pub scroll_state: ScrollbarState,
pub sort_field: String,
pub sort_ascending: bool,
pub show_help: bool,
pub filter: String,
pub start_time: Instant,
pub paused: bool,
pub interface: String,
pub original_interface: String,
pub monitor_mode: bool,
pub deauth_state: DeauthState,
pub handshake_count: HandshakeCount,
pub current_channel: CurrentChannel,
pub cached_aps: Vec<AccessPoint>,
pub status_msg: Option<(String, Instant)>,
pub deauth_burst: u32,
pub hop_pause: Arc<AtomicBool>,
pub detail_view: Option<String>,
pub detail_client_scroll: usize,
pub channel_stats: ChannelStats,
pub probe_map: ProbeMap,
pub deauth_dwell: u64,
pub hs_timeout: u64,
pub band_filter: BandFilter,
pub view_mode: ViewMode,
pub alert_engine: AlertEngine,
pub auto_expire: u64,
pub min_signal: i8,
pub handshake_map: HandshakeMap,
pub detail_client_selected: Option<usize>,
pub show_alerts: bool,
pub beacon_flood_state: BeaconFloodState,
}
impl App {
#[allow(clippy::too_many_arguments)]
pub fn new(
ap_map: ApMap, sort: String, interface: String, original_interface: String,
monitor_mode: bool, handshake_count: HandshakeCount, current_channel: CurrentChannel,
deauth_burst: u32, hop_pause: Arc<AtomicBool>, channel_stats: ChannelStats,
probe_map: ProbeMap, deauth_dwell: u64, hs_timeout: u64,
band_filter: BandFilter, auto_expire: u64, min_signal: i8,
handshake_map: HandshakeMap,
) -> Self {
Self {
ap_map, table_state: TableState::default().with_selected(Some(0)),
scroll_state: ScrollbarState::default(), sort_field: sort, sort_ascending: false,
show_help: false, filter: String::new(), start_time: Instant::now(),
paused: false, interface, original_interface, monitor_mode,
deauth_state: DeauthState::Idle, handshake_count, current_channel,
cached_aps: Vec::new(), status_msg: None, deauth_burst, hop_pause,
detail_view: None, detail_client_scroll: 0, channel_stats, probe_map,
deauth_dwell, hs_timeout, band_filter, view_mode: ViewMode::APs,
alert_engine: AlertEngine::new(), auto_expire, min_signal,
handshake_map, detail_client_selected: None, show_alerts: false,
beacon_flood_state: BeaconFloodState::Idle,
}
}
pub fn refresh_cache(&mut self) {
if self.auto_expire > 0 {
let expire = self.auto_expire;
let mut map = self.ap_map.lock().unwrap();
map.retain(|_, ap| ap.age_secs() < expire);
drop(map);
}
let map = self.ap_map.lock().unwrap();
let mut aps: Vec<AccessPoint> = map.values().cloned().collect();
drop(map);
if self.band_filter != BandFilter::All {
aps.retain(|ap| self.band_filter.matches(ap.frequency_mhz));
}
if self.min_signal > -100 {
aps.retain(|ap| ap.signal_dbm >= self.min_signal);
}
if !self.filter.is_empty() {
let f = self.filter.to_lowercase();
aps.retain(|ap| {
ap.essid.to_lowercase().contains(&f)
|| ap.bssid.to_lowercase().contains(&f)
|| ap.encryption.display.to_lowercase().contains(&f)
|| ap.channel.to_string().contains(&f)
|| ap.signal_dbm.to_string().contains(&f)
|| ap.vendor.to_lowercase().contains(&f)
});
}
let asc = self.sort_ascending;
match self.sort_field.as_str() {
"ssid" | "essid" => aps.sort_by(|a, b| {
let c = a.essid.to_lowercase().cmp(&b.essid.to_lowercase());
if asc { c } else { c.reverse() }
}),
"bssid" => aps.sort_by(|a, b| { let c = a.bssid.cmp(&b.bssid); if asc { c } else { c.reverse() } }),
"channel" | "ch" => aps.sort_by(|a, b| { let c = a.channel.cmp(&b.channel); if asc { c } else { c.reverse() } }),
"encryption" | "enc" => aps.sort_by(|a, b| { let c = a.encryption.display.cmp(&b.encryption.display); if asc { c } else { c.reverse() } }),
"seen" => aps.sort_by(|a, b| { let c = a.age_secs().cmp(&b.age_secs()); if asc { c } else { c.reverse() } }),
"beacons" => aps.sort_by(|a, b| { let c = a.beacon_count.cmp(&b.beacon_count); if asc { c } else { c.reverse() } }),
"data" => aps.sort_by(|a, b| { let c = a.data_count.cmp(&b.data_count); if asc { c } else { c.reverse() } }),
"clients" => aps.sort_by(|a, b| { let c = a.clients.len().cmp(&b.clients.len()); if asc { c } else { c.reverse() } }),
"security" => aps.sort_by(|a, b| {
let c = a.encryption.security_score().cmp(&b.encryption.security_score());
if asc { c } else { c.reverse() }
}),
_ => aps.sort_by(|a, b| { let c = a.signal_dbm.cmp(&b.signal_dbm); if asc { c } else { c.reverse() } }),
}
self.cached_aps = aps;
self.alert_engine.scan(&self.ap_map);
}
pub fn next_sort(&mut self) {
let fields = ["signal","ssid","bssid","channel","encryption","security","beacons","data","clients","seen"];
if let Some(idx) = fields.iter().position(|&f| f == self.sort_field) {
self.sort_field = fields[(idx + 1) % fields.len()].to_string();
} else {
self.sort_field = "signal".to_string();
}
}
pub fn set_status(&mut self, msg: String) {
self.status_msg = Some((msg, Instant::now()));
}
pub fn get_detail_ap(&self) -> Option<AccessPoint> {
if let Some(ref bssid) = self.detail_view {
let map = self.ap_map.lock().unwrap();
map.get(bssid).cloned()
} else {
None
}
}
}
pub fn ui_detail(f: &mut ratatui::Frame, app: &mut App, ap: &AccessPoint) {
let area = f.area();
let outer = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(3), Constraint::Min(10), Constraint::Length(3)])
.split(area);
let essid_display = if ap.essid.is_empty() { "<hidden>" } else { &ap.essid };
let wifi_gen = ap.wifi_gen_str();
let pmf_str = if ap.encryption.pmf_required { " PMF" } else if ap.encryption.pmf_capable { " pmf" } else { "" };
let band = ap.band_str();
let sec_score = ap.encryption.security_score();
let header_spans = vec![
Span::styled(" \u{2190} ", Style::default().fg(Color::DarkGray)),
Span::styled(essid_display, Style::default().fg(Color::White).bold()),
Span::styled(format!(" ({})", ap.bssid), Style::default().fg(Color::Rgb(180, 180, 220))),
Span::styled(
format!(" | CH:{} {}MHz {} | {} dBm | {}{} | Wi-Fi {} | {} clients | {} | Sec:{}",
ap.channel, ap.channel_width, band, ap.signal_dbm,
ap.encryption.display, pmf_str, wifi_gen, ap.clients.len(), ap.vendor, sec_score),
Style::default().fg(Color::Cyan),
),
];
let header = Paragraph::new(Line::from(header_spans)).block(
Block::default().borders(Borders::ALL).border_style(Style::default().fg(Color::DarkGray))
.title(Span::styled(" AP Detail ", Style::default().fg(Color::Magenta).bold()))
.title_alignment(Alignment::Center),
);
f.render_widget(header, outer[0]);
let body = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
.split(outer[1]);
let left = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(15), Constraint::Min(4)])
.split(body[0]);
let uptime_secs = ap.uptime_secs();
let last_secs = ap.age_secs();
let noise_str = ap.noise_dbm.map(|n| format!("{} dBm", n)).unwrap_or_else(|| "--".to_string());
let snr_str = ap.snr().map(|s| format!("{} dB", s)).unwrap_or_else(|| "--".to_string());
let freq_band = if ap.frequency_mhz >= 5900 { "6 GHz" }
else if ap.frequency_mhz >= 5000 { "5 GHz" } else { "2.4 GHz" };
let bss_color_str = ap.bss_color.map(|c| format!("{}", c)).unwrap_or_else(|| "--".to_string());
let deauth_str = if ap.deauth_sent > 0 { format!("{} frames", ap.deauth_sent) } else { "none".to_string() };
let hs_progress = {
let hs_map = app.handshake_map.lock().unwrap();
let active: Vec<String> = hs_map.iter()
.filter(|(k, hs)| k.bssid == ap.bssid && !hs.saved)
.map(|(k, hs)| format!("{}: {}", &k.client[..8.min(k.client.len())], hs.progress_str()))
.collect();
if active.is_empty() { "none active".to_string() } else { active.join(", ") }
};
let info_lines = vec![
Line::from(vec![
Span::styled(" ESSID: ", Style::default().fg(Color::DarkGray)),
Span::styled(essid_display, Style::default().fg(Color::White).bold()),
]),
Line::from(vec![
Span::styled(" BSSID: ", Style::default().fg(Color::DarkGray)),
Span::styled(&ap.bssid, Style::default().fg(Color::Rgb(180, 180, 220))),
Span::styled(format!(" {}", ap.vendor), Style::default().fg(Color::Rgb(150, 150, 180))),
]),
Line::from(vec![
Span::styled(" Channel: ", Style::default().fg(Color::DarkGray)),
Span::styled(format!("{} ({} MHz, {}, {}MHz wide)", ap.channel, ap.frequency_mhz, freq_band, ap.channel_width),
Style::default().fg(Color::LightYellow)),
]),
Line::from(vec![
Span::styled(" Signal: ", Style::default().fg(Color::DarkGray)),
Span::styled(format!("{} dBm {}", ap.signal_dbm, ap.signal_bar()), Style::default().fg(ap.signal_color())),
]),
Line::from(vec![
Span::styled(" Noise/SNR: ", Style::default().fg(Color::DarkGray)),
Span::styled(format!("{} SNR: {}", noise_str, snr_str), Style::default().fg(Color::DarkGray)),
]),
Line::from(vec![
Span::styled(" Encryption: ", Style::default().fg(Color::DarkGray)),
Span::styled(format!("{}{}", ap.encryption.display, pmf_str), Style::default().fg(
match ap.encryption.display.as_str() {
s if s.starts_with("Open") => Color::Red,
s if s.starts_with("WEP") => Color::LightRed,
s if s.starts_with("WPA3") => Color::LightGreen,
_ => Color::Green,
}
)),
Span::styled(format!(" (score: {})", sec_score), Style::default().fg(Color::DarkGray)),
]),
Line::from(vec![
Span::styled(" WiFi Gen: ", Style::default().fg(Color::DarkGray)),
Span::styled(wifi_gen, Style::default().fg(Color::Cyan)),
Span::styled(" BSS Color: ", Style::default().fg(Color::DarkGray)),
Span::styled(bss_color_str, Style::default().fg(Color::Cyan)),
]),
Line::from(vec![
Span::styled(" Beacons: ", Style::default().fg(Color::DarkGray)),
Span::styled(format!("{}", ap.beacon_count), Style::default().fg(Color::Rgb(100, 150, 200))),
Span::styled(" Data: ", Style::default().fg(Color::DarkGray)),
Span::styled(format!("{}", ap.data_count), Style::default().fg(Color::Rgb(100, 200, 150))),
]),
Line::from(vec![
Span::styled(" Handshakes: ", Style::default().fg(Color::DarkGray)),
Span::styled(format!("{}", ap.handshakes),
if ap.handshakes > 0 { Style::default().fg(Color::LightGreen).bold() }
else { Style::default().fg(Color::DarkGray) }),
Span::styled(" PMKID: ", Style::default().fg(Color::DarkGray)),
Span::styled(if ap.pmkid_captured { "Yes" } else { "No" },
if ap.pmkid_captured { Style::default().fg(Color::LightGreen).bold() }
else { Style::default().fg(Color::DarkGray) }),
]),
Line::from(vec![
Span::styled(" HS Progress: ", Style::default().fg(Color::DarkGray)),
Span::styled(hs_progress, Style::default().fg(Color::Yellow)),
]),
Line::from(vec![
Span::styled(" Deauths: ", Style::default().fg(Color::DarkGray)),
Span::styled(deauth_str, Style::default().fg(if ap.deauth_sent > 0 { Color::Red } else { Color::DarkGray })),
]),
Line::from(vec![
Span::styled(" First seen: ", Style::default().fg(Color::DarkGray)),
Span::styled(format!("{}m {}s ago", uptime_secs / 60, uptime_secs % 60), Style::default().fg(Color::Cyan)),
]),
Line::from(vec![
Span::styled(" Last seen: ", Style::default().fg(Color::DarkGray)),
Span::styled(format!("{}s ago", last_secs),
if last_secs > 30 { Style::default().fg(Color::Red) }
else { Style::default().fg(Color::Green) }),
]),
];
let info_widget = Paragraph::new(info_lines).block(
Block::default().borders(Borders::ALL).border_style(Style::default().fg(Color::DarkGray))
.title(Span::styled(" Network Info ", Style::default().fg(Color::Cyan).bold())),
);
f.render_widget(info_widget, left[0]);
let mut clients: Vec<_> = ap.clients.values().cloned().collect();
clients.sort_by(|a, b| b.data_count.cmp(&a.data_count));
let client_lines: Vec<Line> = if clients.is_empty() {
vec![Line::from(Span::styled(" No clients discovered", Style::default().fg(Color::DarkGray)))]
} else {
clients.iter().enumerate().skip(app.detail_client_scroll).map(|(i, c)| {
let vendor_str = if c.vendor.is_empty() { String::new() } else { format!(" ({})", c.vendor) };
let probes_str = if c.probed_ssids.is_empty() { String::new() }
else { format!(" probes:[{}]", c.probed_ssids.join(",")) };
let rand_marker = if c.is_randomized { " R" } else { "" };
let stale = c.age_secs() > 30;
let sel_marker = if app.detail_client_selected == Some(i) { "> " } else { " " };
let mac_style = if stale {
Style::default().fg(Color::DarkGray)
} else if app.detail_client_selected == Some(i) {
Style::default().fg(Color::LightCyan).bold()
} else {
Style::default().fg(Color::LightMagenta)
};
Line::from(vec![
Span::styled(format!("{}{:>2}. ", sel_marker, i + 1), Style::default().fg(Color::DarkGray)),
Span::styled(&c.mac, mac_style),
Span::styled(format!(" {}dBm", c.signal_dbm), Style::default().fg(Color::Rgb(150,150,180))),
Span::styled(format!(" d:{}", c.data_count), Style::default().fg(Color::DarkGray)),
Span::styled(rand_marker, Style::default().fg(Color::Yellow)),
Span::styled(vendor_str, Style::default().fg(Color::Rgb(120,120,160))),
Span::styled(probes_str, Style::default().fg(Color::Yellow)),
])
}).collect()
};
let client_widget = Paragraph::new(client_lines).block(
Block::default().borders(Borders::ALL).border_style(Style::default().fg(Color::DarkGray))
.title(Span::styled(format!(" Clients ({}) ", clients.len()), Style::default().fg(Color::LightMagenta).bold())),
);
f.render_widget(client_widget, left[1]);
let right = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Percentage(60), Constraint::Percentage(40)])
.split(body[1]);
let hist = &ap.signal_history;
if !hist.is_empty() {
let graph_width = right[0].width.saturating_sub(2) as usize;
let start = if hist.len() > graph_width { hist.len() - graph_width } else { 0 };
let bars: Vec<(&str, u64)> = hist.iter().skip(start)
.map(|&s| ("", (s as i16 + 100).max(0) as u64)).collect();
let min_sig = hist.iter().copied().min().unwrap_or(-100);
let max_sig = hist.iter().copied().max().unwrap_or(0);
let avg_sig = hist.iter().map(|&s| s as i32).sum::<i32>() / hist.len() as i32;
let bar_chart = BarChart::default()
.block(Block::default().borders(Borders::ALL).border_style(Style::default().fg(Color::DarkGray))
.title(Span::styled(
format!(" Signal (min:{} avg:{} max:{} dBm, {} samples) ", min_sig, avg_sig, max_sig, hist.len()),
Style::default().fg(Color::Yellow).bold())))
.data(&bars).bar_width(1).bar_gap(0)
.bar_style(Style::default().fg(Color::Green))
.value_style(Style::default().fg(Color::Black).bg(Color::Green))
.max(80);
f.render_widget(bar_chart, right[0]);
} else {
let empty = Paragraph::new(" Waiting for beacons...").style(Style::default().fg(Color::DarkGray))
.block(Block::default().borders(Borders::ALL).border_style(Style::default().fg(Color::DarkGray))
.title(Span::styled(" Signal History ", Style::default().fg(Color::Yellow).bold())));
f.render_widget(empty, right[0]);
}
let total_frames = ap.beacon_count + ap.data_count;
let data_pct = if total_frames > 0 { (ap.data_count as f64 / total_frames as f64 * 100.0) as u16 } else { 0 };
let beacon_pct = 100u16.saturating_sub(data_pct);
let active_c = ap.active_clients();
let traffic_lines = vec![
Line::from(""),
Line::from(vec![
Span::styled(" Total frames: ", Style::default().fg(Color::DarkGray)),
Span::styled(format!("{}", total_frames), Style::default().fg(Color::White)),
]),
Line::from(vec![
Span::styled(" Beacons: ", Style::default().fg(Color::DarkGray)),
Span::styled(format!("{} ({}%)", ap.beacon_count, beacon_pct), Style::default().fg(Color::Rgb(100, 150, 200))),
]),
Line::from(vec![
Span::styled(" Data: ", Style::default().fg(Color::DarkGray)),
Span::styled(format!("{} ({}%)", ap.data_count, data_pct), Style::default().fg(Color::Rgb(100, 200, 150))),
]),
Line::from(vec![
Span::styled(" Data rate: ", Style::default().fg(Color::DarkGray)),
Span::styled(
if uptime_secs > 0 { format!("{:.1} f/s", ap.data_count as f64 / uptime_secs as f64) }
else { "-- f/s".to_string() },
Style::default().fg(Color::Cyan)),
]),
Line::from(vec![
Span::styled(" Active cli: ", Style::default().fg(Color::DarkGray)),
Span::styled(format!("{} / {}", active_c, ap.clients.len()), Style::default().fg(Color::LightMagenta)),
]),
];
let traffic_widget = Paragraph::new(traffic_lines).block(
Block::default().borders(Borders::ALL).border_style(Style::default().fg(Color::DarkGray))
.title(Span::styled(" Traffic ", Style::default().fg(Color::Rgb(100, 200, 150)).bold())),
);
f.render_widget(traffic_widget, right[1]);
let detail_hint = if app.monitor_mode { "d:Deauth D:Deauth client " } else { "" };
let footer = Paragraph::new(format!(" Esc:Back {}j/k:Scroll Tab:Select client", detail_hint))
.style(Style::default().fg(Color::DarkGray))
.block(Block::default().borders(Borders::ALL).border_style(Style::default().fg(Color::DarkGray)));
f.render_widget(footer, outer[2]);
}
fn render_probe_view(f: &mut ratatui::Frame, area: Rect, app: &App) {
let pm = app.probe_map.lock().unwrap();
let mut entries: Vec<(String, Vec<String>)> = pm.iter()
.map(|(mac, ssids)| (mac.clone(), ssids.iter().cloned().collect::<Vec<_>>()))
.collect();
entries.sort_by(|a, b| b.1.len().cmp(&a.1.len()));
let rows: Vec<Row> = entries.iter().map(|(mac, ssids)| {
let is_random = MacAddr::from_str_hex(mac).map(|m| m.is_randomized()).unwrap_or(false);
let rand_marker = if is_random { " (R)" } else { "" };
Row::new(vec![
Cell::from(format!("{}{}", mac, rand_marker)).style(Style::default().fg(Color::LightMagenta)),
Cell::from(format!("{}", ssids.len())).style(Style::default().fg(Color::Cyan)),
Cell::from(ssids.join(", ")).style(Style::default().fg(Color::Yellow)),
])
}).collect();
let header_cells = ["Client MAC", "#", "Probed SSIDs"]
.iter().map(|h| Cell::from(*h).style(Style::default().fg(Color::Cyan).bold().underlined()));
let header_row = Row::new(header_cells).height(1);
let widths = [Constraint::Length(22), Constraint::Length(4), Constraint::Min(30)];
let table = Table::new(rows, widths)
.header(header_row)
.row_highlight_style(Style::default().bg(Color::Rgb(40, 40, 60)))
.block(Block::default().borders(Borders::ALL).border_style(Style::default().fg(Color::DarkGray))
.title(Span::styled(format!(" Probe Requests ({} clients) ", entries.len()),
Style::default().fg(Color::Yellow).bold())));
f.render_widget(table, area);
}
fn render_channel_graph(f: &mut ratatui::Frame, area: Rect, app: &App) {
let cs = app.channel_stats.lock().unwrap();
let mut channels: Vec<(u8, u64)> = cs.iter().map(|(&ch, &cnt)| (ch, cnt)).collect();
channels.sort_by_key(|&(ch, _)| ch);
let bars: Vec<(String, u64)> = channels.iter()
.map(|(ch, cnt)| (format!("{}", ch), *cnt))
.collect();
let bar_refs: Vec<(&str, u64)> = bars.iter().map(|(s, c)| (s.as_str(), *c)).collect();
let mut ap_per_ch: std::collections::HashMap<u8, usize> = std::collections::HashMap::new();
for ap in &app.cached_aps {
if ap.channel > 0 {
*ap_per_ch.entry(ap.channel).or_default() += 1;
}
}
let chart = BarChart::default()
.block(Block::default().borders(Borders::ALL).border_style(Style::default().fg(Color::DarkGray))
.title(Span::styled(
format!(" Channel Utilization ({} channels active) ", channels.len()),
Style::default().fg(Color::Yellow).bold())))
.data(&bar_refs)
.bar_width(3).bar_gap(1)
.bar_style(Style::default().fg(Color::Cyan))
.value_style(Style::default().fg(Color::White).bg(Color::Cyan));
let inner = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Percentage(60), Constraint::Percentage(40)])
.split(area);
f.render_widget(chart, inner[0]);
let mut ch_entries: Vec<(u8, usize)> = ap_per_ch.into_iter().collect();
ch_entries.sort_by(|a, b| b.1.cmp(&a.1));
let ch_rows: Vec<Row> = ch_entries.iter().map(|(ch, cnt)| {
let congestion = if *cnt >= 10 { Color::Red } else if *cnt >= 5 { Color::Yellow } else { Color::Green };
Row::new(vec![
Cell::from(format!("CH {}", ch)).style(Style::default().fg(Color::LightYellow)),
Cell::from(format!("{} APs", cnt)).style(Style::default().fg(congestion)),
])
}).collect();
let ch_table = Table::new(ch_rows, [Constraint::Length(8), Constraint::Length(10)])
.block(Block::default().borders(Borders::ALL).border_style(Style::default().fg(Color::DarkGray))
.title(Span::styled(" APs per Channel ", Style::default().fg(Color::Cyan).bold())));
f.render_widget(ch_table, inner[1]);
}
pub fn ui(f: &mut ratatui::Frame, app: &mut App) {
if app.detail_view.is_some() {
if let Some(ap) = app.get_detail_ap() {
ui_detail(f, app, &ap);
return;
} else {
app.detail_view = None;
}
}
let active_alerts = app.alert_engine.active_alerts();
let alert_count = active_alerts.len();
let alert_height = if app.show_alerts && alert_count > 0 { 4u16 } else { 1 };
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3),
Constraint::Length(alert_height),
Constraint::Min(10),
Constraint::Length(3),
])
.split(f.area());
let elapsed = app.start_time.elapsed();
let ap_count = app.cached_aps.len();
let total_clients: usize = app.cached_aps.iter().map(|ap| ap.clients.len()).sum();
let hs_count = *app.handshake_count.lock().unwrap();
let cur_ch = *app.current_channel.lock().unwrap();
let mode_indicator = if app.monitor_mode {
Span::styled("MON", Style::default().fg(Color::Green).bold())
} else {
Span::styled("MANAGED", Style::default().fg(Color::Red).bold())
};
let ch_display = if cur_ch > 0 { format!("CH:{}", cur_ch) } else { "CH:--".to_string() };
let eapol_found = FRAMES_EAPOL_FOUND.load(Ordering::Relaxed);
let m1 = FRAMES_EAPOL_MSG[0].load(Ordering::Relaxed);
let m2 = FRAMES_EAPOL_MSG[1].load(Ordering::Relaxed);
let m3 = FRAMES_EAPOL_MSG[2].load(Ordering::Relaxed);
let m4 = FRAMES_EAPOL_MSG[3].load(Ordering::Relaxed);
let probes = FRAMES_PROBE_REQ.load(Ordering::Relaxed);
let eapol_info = if eapol_found > 0 {
format!(" | EAPOL:{} M1:{}/M2:{}/M3:{}/M4:{}", eapol_found, m1, m2, m3, m4)
} else {
" | EAPOL:0".to_string()
};
let band_label = app.band_filter.label();
let view_label = app.view_mode.label();
let header_spans = vec![
Span::raw(" "),
mode_indicator,
Span::raw(format!(
" {} | {} | APs:{} | Cli:{} | HS:{} | Prb:{} | Sort:{} {} | Band:{} | View:{} | {}m{}s",
chrono::Local::now().format("%H:%M:%S"), ch_display,
ap_count, total_clients, hs_count, probes,
app.sort_field, if app.sort_ascending { "\u{25b2}" } else { "\u{25bc}" },
band_label, view_label,
elapsed.as_secs() / 60, elapsed.as_secs() % 60,
)),
Span::styled(eapol_info, Style::default().fg(
if eapol_found > 0 { Color::LightGreen } else { Color::DarkGray }
)),
if app.paused { Span::styled(" PAUSED", Style::default().fg(Color::Red).bold()) }
else { Span::raw("") },
];
let header = Paragraph::new(Line::from(header_spans))
.style(Style::default().fg(Color::Cyan))
.block(Block::default().borders(Borders::ALL).border_style(Style::default().fg(Color::DarkGray))
.title(Span::styled(" wifiscan ", Style::default().fg(Color::Magenta).bold()))
.title_alignment(Alignment::Center));
f.render_widget(header, chunks[0]);
if app.show_alerts && alert_count > 0 {
let lines: Vec<Line> = active_alerts.iter().take(3).map(|a| {
Line::from(Span::styled(format!(" {} ({}s ago)", a.message, a.age_secs()),
Style::default().fg(Color::Yellow)))
}).collect();
let alert_widget = Paragraph::new(lines).block(
Block::default().borders(Borders::ALL).border_style(Style::default().fg(Color::Yellow))
.title(Span::styled(format!(" Alerts ({}) - a:close ", alert_count),
Style::default().fg(Color::Yellow).bold())));
f.render_widget(alert_widget, chunks[1]);
} else if alert_count > 0 {
let bar = Paragraph::new(Span::styled(
format!(" {} active alert{} - a:view", alert_count, if alert_count == 1 { "" } else { "s" }),
Style::default().fg(Color::Yellow).bold()));
f.render_widget(bar, chunks[1]);
} else {
let bar = Paragraph::new(Span::styled(
" No alerts", Style::default().fg(Color::DarkGray)));
f.render_widget(bar, chunks[1]);
}
let content_area = chunks[2];
if let DeauthState::Confirm { ref ap, ref target } = app.deauth_state {
render_deauth_confirm(f, content_area, ap, target, app.deauth_burst);
} else if let DeauthState::Running { ref ap, ref progress } = app.deauth_state {
let sent = progress.sent.load(Ordering::Relaxed);
let failed = progress.failed.load(Ordering::Relaxed);
let total = progress.total.load(Ordering::Relaxed);
let phase = progress.phase.load(Ordering::Relaxed);
let dwell_elapsed = progress.dwell_elapsed.load(Ordering::Relaxed);
let dwell_total = progress.dwell_total.load(Ordering::Relaxed);
let hs_captured = progress.hs_captured.load(Ordering::Relaxed);
let pct = if total > 0 { (sent as f64 / total as f64 * 100.0) as u32 } else { 0 };
let bar_width = 30;
let filled = (bar_width as f64 * sent as f64 / total.max(1) as f64) as usize;
let bar: String = format!("[{}{}]",
"\u{2588}".repeat(filled.min(bar_width)),
"\u{2591}".repeat(bar_width.saturating_sub(filled)));
let mut lines = vec![
Line::from(""),
Line::from(vec![
Span::styled(" Target: ", Style::default().fg(Color::DarkGray)),
Span::styled(&ap.essid, Style::default().fg(Color::White).bold()),
Span::styled(format!(" ({})", ap.bssid), Style::default().fg(Color::Rgb(180, 180, 220))),
]),
Line::from(""),
];
match phase {
0 => {
lines.push(Line::from(vec![
Span::styled(" Injecting: ", Style::default().fg(Color::Yellow)),
Span::styled(format!("{} / {} frames ({}%)", sent, total, pct), Style::default().fg(Color::Cyan)),
]));
lines.push(Line::from(Span::styled(format!(" {}", bar), Style::default().fg(Color::Yellow))));
if failed > 0 {
lines.push(Line::from(Span::styled(
format!(" {} injection failures - adapter may not support TX", failed),
Style::default().fg(Color::Red))));
}
lines.push(Line::from(Span::styled(" Esc to cancel", Style::default().fg(Color::DarkGray))));
}
1 => {
let fail_note = if failed > 0 { format!(" ({} failed)", failed) } else { String::new() };
lines.push(Line::from(vec![
Span::styled(format!(" Injected {} frames.{}", sent, fail_note), Style::default().fg(Color::Green)),
]));
lines.push(Line::from(vec![
Span::styled(" Dwelling on channel: ", Style::default().fg(Color::Yellow)),
Span::styled(format!("{}s / {}s", dwell_elapsed, dwell_total), Style::default().fg(Color::Cyan)),
Span::styled(" waiting for handshake...", Style::default().fg(Color::DarkGray)),
]));
if hs_captured {
lines.push(Line::from(Span::styled(" HANDSHAKE CAPTURED!", Style::default().fg(Color::LightGreen).bold())));
}
lines.push(Line::from(Span::styled(" Esc to cancel", Style::default().fg(Color::DarkGray))));
}
_ => {
let msg = if hs_captured { "Handshake captured!" } else { "No handshake (try again or lock channel)" };
lines.push(Line::from(vec![
Span::styled(" Done: ", Style::default().fg(Color::Green)),
Span::styled(format!("{} frames sent. {}", sent, msg), Style::default().fg(Color::Cyan)),
]));
if failed > 0 && sent == 0 {
lines.push(Line::from(Span::styled(
" ERROR: No frames injected. Adapter may not support TX in monitor mode.",
Style::default().fg(Color::Red).bold())));
}
}
}
let border_color = if hs_captured { Color::Green } else { Color::Yellow };
let title = match phase {
0 => " Injecting... ",
1 => " Waiting for handshake... ",
_ => " Complete ",
};
let dialog = Paragraph::new(lines).block(
Block::default().borders(Borders::ALL).border_style(Style::default().fg(border_color))
.title(Span::styled(title, Style::default().fg(border_color).bold()))
.title_alignment(Alignment::Center));
f.render_widget(dialog, content_area);
} else if let BeaconFloodState::Confirm { ref ap } = app.beacon_flood_state {
render_beacon_confirm(f, content_area, ap);
} else if let BeaconFloodState::Running { ref ssid, ref bssid, channel, ref progress } = app.beacon_flood_state {
let sent = progress.beacons_sent.load(Ordering::Relaxed);
let failed = progress.failed.load(Ordering::Relaxed);
let lines = vec![
Line::from(""),
Line::from(vec![
Span::styled(" Broadcasting SSID: ", Style::default().fg(Color::DarkGray)),
Span::styled(ssid.as_str(), Style::default().fg(Color::White).bold()),
]),
Line::from(vec![
Span::styled(" Rogue BSSID: ", Style::default().fg(Color::DarkGray)),
Span::styled(bssid.as_str(), Style::default().fg(Color::Rgb(180, 180, 220))),
]),
Line::from(vec![
Span::styled(" Channel: ", Style::default().fg(Color::DarkGray)),
Span::styled(format!("{}", channel), Style::default().fg(Color::LightYellow)),
]),
Line::from(""),
Line::from(vec![
Span::styled(" Beacons sent: ", Style::default().fg(Color::Yellow)),
Span::styled(format!("{}", sent), Style::default().fg(Color::Cyan)),
if failed > 0 {
Span::styled(format!(" ({} failed)", failed), Style::default().fg(Color::Red))
} else {
Span::raw("")
},
]),
Line::from(""),
Line::from(vec![
Span::styled(" Press ", Style::default().fg(Color::DarkGray)),
Span::styled("t", Style::default().fg(Color::Green).bold()),
Span::styled(" or ", Style::default().fg(Color::DarkGray)),
Span::styled("Esc", Style::default().fg(Color::Green).bold()),
Span::styled(" to stop", Style::default().fg(Color::DarkGray)),
]),
];
let dialog = Paragraph::new(lines).block(
Block::default().borders(Borders::ALL).border_style(Style::default().fg(Color::Magenta))
.title(Span::styled(" Evil Twin Active ", Style::default().fg(Color::Magenta).bold()))
.title_alignment(Alignment::Center));
f.render_widget(dialog, content_area);
} else if app.show_help {
render_help(f, content_area, app.monitor_mode);
} else {
match app.view_mode {
ViewMode::APs => render_ap_table(f, content_area, app),
ViewMode::Probes => render_probe_view(f, content_area, app),
ViewMode::ChannelGraph => render_channel_graph(f, content_area, app),
}
}
let filter_display = if app.filter.is_empty() { String::new() } else { format!(" | Filter: {}", app.filter) };
let status_display = if let Some((ref msg, ref when)) = app.status_msg {
if when.elapsed().as_secs() < 5 { format!(" | {}", msg) } else { String::new() }
} else { String::new() };
let deauth_hint = if app.monitor_mode { "d:Deauth t:EvilTwin " } else { "" };
let footer_text = format!(
" q:Quit s:Sort r:Reverse /:Filter {}b:Band Tab:View e/E:Export p:Pause c:Clear a:Alerts ?:Help{}{}",
deauth_hint, filter_display, status_display
);
let footer = Paragraph::new(footer_text)
.style(Style::default().fg(Color::DarkGray))
.block(Block::default().borders(Borders::ALL).border_style(Style::default().fg(Color::DarkGray)));
f.render_widget(footer, chunks[3]);
}
fn render_deauth_confirm(f: &mut ratatui::Frame, area: Rect, ap: &AccessPoint, target: &DeauthTarget, burst: u32) {
let target_desc = match target {
DeauthTarget::All => "ALL clients + broadcast".to_string(),
DeauthTarget::SingleClient(mac) => format!("Client: {}", mac),
};
let client_list: String = if ap.clients.is_empty() {
" (none discovered, broadcast only)".to_string()
} else {
ap.clients.keys().enumerate()
.map(|(i, c)| format!(" {}. {}", i + 1, c))
.collect::<Vec<_>>().join("\n")
};
let pmf_warn = if ap.encryption.pmf_required {
Line::from(Span::styled(" WARNING: PMF required. Deauth may be ignored by clients.",
Style::default().fg(Color::Red).bold()))
} else {
Line::from("")
};
let mut lines = vec![
Line::from(""),
Line::from(vec![
Span::styled(" Target AP: ", Style::default().fg(Color::DarkGray)),
Span::styled(&ap.essid, Style::default().fg(Color::White).bold()),
Span::styled(format!(" ({})", ap.bssid), Style::default().fg(Color::Rgb(180, 180, 220))),
]),
Line::from(vec![
Span::styled(" Scope: ", Style::default().fg(Color::DarkGray)),
Span::styled(target_desc, Style::default().fg(Color::LightCyan)),
]),
Line::from(vec![Span::styled(
format!(" CH:{} | Clients:{} | {} | {}",
ap.channel, ap.clients.len(), ap.encryption.display, ap.vendor),
Style::default().fg(Color::DarkGray))]),
pmf_warn,
Line::from(""),
];
for cl in client_list.lines() {
lines.push(Line::from(Span::styled(cl.to_string(), Style::default().fg(Color::LightMagenta))));
}
lines.push(Line::from(""));
lines.push(Line::from(vec![
Span::styled(format!(" {} reason codes x deauth+disassoc x {} bursts", DEAUTH_REASONS.len(), burst),
Style::default().fg(Color::DarkGray)),
]));
lines.push(Line::from(""));
lines.push(Line::from(vec![
Span::styled(" y", Style::default().fg(Color::Red).bold()),
Span::raw(" = send "),
Span::styled("n/Esc", Style::default().fg(Color::Green).bold()),
Span::raw(" = cancel"),
]));
let dialog = Paragraph::new(lines).block(
Block::default().borders(Borders::ALL).border_style(Style::default().fg(Color::Red))
.title(Span::styled(" Deauth ", Style::default().fg(Color::Red).bold()))
.title_alignment(Alignment::Center));
f.render_widget(dialog, area);
}
fn render_beacon_confirm(f: &mut ratatui::Frame, area: Rect, ap: &AccessPoint) {
let lines = vec![
Line::from(""),
Line::from(vec![
Span::styled(" Clone SSID: ", Style::default().fg(Color::DarkGray)),
Span::styled(&ap.essid, Style::default().fg(Color::White).bold()),
]),
Line::from(vec![
Span::styled(" Original: ", Style::default().fg(Color::DarkGray)),
Span::styled(format!("{} CH:{} {}", ap.bssid, ap.channel, ap.encryption.display),
Style::default().fg(Color::Rgb(180, 180, 220))),
]),
Line::from(""),
Line::from(Span::styled(
" A rogue AP will broadcast the same SSID on the same channel",
Style::default().fg(Color::Yellow))),
Line::from(Span::styled(
" with a random BSSID. Nearby clients may see duplicate networks.",
Style::default().fg(Color::Yellow))),
Line::from(""),
Line::from(vec![
Span::styled(" y", Style::default().fg(Color::Red).bold()),
Span::raw(" = start "),
Span::styled("n/Esc", Style::default().fg(Color::Green).bold()),
Span::raw(" = cancel"),
]),
];
let dialog = Paragraph::new(lines).block(
Block::default().borders(Borders::ALL).border_style(Style::default().fg(Color::Magenta))
.title(Span::styled(" Evil Twin ", Style::default().fg(Color::Magenta).bold()))
.title_alignment(Alignment::Center));
f.render_widget(dialog, area);
}
fn render_help(f: &mut ratatui::Frame, area: Rect, monitor_mode: bool) {
let mut help = vec![
Line::from(vec![Span::styled(" q ", Style::default().fg(Color::Yellow).bold()), Span::raw("Quit")]),
Line::from(vec![Span::styled(" \u{2191}/\u{2193}/j/k ", Style::default().fg(Color::Yellow).bold()), Span::raw("Scroll")]),
Line::from(vec![Span::styled(" Enter ", Style::default().fg(Color::Yellow).bold()), Span::raw("View AP details")]),
Line::from(vec![Span::styled(" Tab ", Style::default().fg(Color::Yellow).bold()), Span::raw("Switch view: APs / Probes / Channels")]),
Line::from(vec![Span::styled(" s ", Style::default().fg(Color::Yellow).bold()), Span::raw("Cycle sort (signal/ssid/channel/enc/security/data/clients)")]),
Line::from(vec![Span::styled(" r ", Style::default().fg(Color::Yellow).bold()), Span::raw("Reverse sort")]),
Line::from(vec![Span::styled(" b ", Style::default().fg(Color::Yellow).bold()), Span::raw("Cycle band filter: All/2.4G/5G/6G")]),
Line::from(vec![Span::styled(" / ", Style::default().fg(Color::Yellow).bold()), Span::raw("Filter (Enter=apply, Esc=clear)")]),
Line::from(vec![Span::styled(" p ", Style::default().fg(Color::Yellow).bold()), Span::raw("Pause/Resume")]),
Line::from(vec![Span::styled(" c ", Style::default().fg(Color::Yellow).bold()), Span::raw("Clear stale APs (>60s)")]),
Line::from(vec![Span::styled(" e ", Style::default().fg(Color::Yellow).bold()), Span::raw("Export CSV")]),
Line::from(vec![Span::styled(" E ", Style::default().fg(Color::Yellow).bold()), Span::raw("Export JSON")]),
Line::from(vec![Span::styled(" P ", Style::default().fg(Color::Yellow).bold()), Span::raw("Export probe requests CSV")]),
Line::from(vec![Span::styled(" H ", Style::default().fg(Color::Yellow).bold()), Span::raw("Export PMKIDs (hashcat format)")]),
Line::from(vec![Span::styled(" W ", Style::default().fg(Color::Yellow).bold()), Span::raw("Export WiGLE CSV")]),
Line::from(vec![Span::styled(" a ", Style::default().fg(Color::Yellow).bold()), Span::raw("Toggle alerts panel")]),
];
if monitor_mode {
help.push(Line::from(vec![Span::styled(" d ", Style::default().fg(Color::Red).bold()), Span::raw("Deauth selected AP (all clients)")]));
help.push(Line::from(vec![Span::styled(" D ", Style::default().fg(Color::Red).bold()), Span::raw("Deauth single client (in detail view)")]));
help.push(Line::from(vec![Span::styled(" t ", Style::default().fg(Color::Magenta).bold()), Span::raw("Evil twin: broadcast cloned SSID")]));
}
help.push(Line::from(vec![Span::styled(" Esc ", Style::default().fg(Color::Yellow).bold()), Span::raw("Close help / back / clear filter")]));
help.push(Line::from(vec![Span::styled(" ?/h ", Style::default().fg(Color::Yellow).bold()), Span::raw("Toggle help")]));
let w = Paragraph::new(help).block(
Block::default().borders(Borders::ALL).border_style(Style::default().fg(Color::Yellow))
.title(" Keys ").title_alignment(Alignment::Center));
f.render_widget(w, area);
}
fn render_ap_table(f: &mut ratatui::Frame, area: Rect, app: &mut App) {
let aps = &app.cached_aps;
let header_cells = ["ESSID","BSSID","CH","W","Sig","dBm","Enc","Gen","Vendor","Bcn","Data","Cli","HS","Last"]
.iter().map(|h| Cell::from(*h).style(Style::default().fg(Color::Cyan).bold().underlined()));
let header_row = Row::new(header_cells).height(1);
let rows: Vec<Row> = aps.iter().map(|ap| {
let sig_color = ap.signal_color();
let stale = ap.age_secs() > 30;
let dim = if stale { Style::default().fg(Color::DarkGray) } else { Style::default() };
let enc_color = match ap.encryption.display.as_str() {
s if s.starts_with("Open") => Color::Red,
s if s.starts_with("WEP") => Color::LightRed,
s if s.starts_with("WPA3") => Color::LightGreen,
s if s.starts_with("WPA") => Color::Green,
_ => Color::Green,
};
let essid_display = if ap.essid.is_empty() { "<hidden>".to_string() } else { ap.essid.clone() };
let hs_style = if ap.handshakes > 0 { Style::default().fg(Color::LightGreen).bold() }
else { dim.fg(Color::DarkGray) };
let vendor_short = if ap.vendor.len() > 8 { &ap.vendor[..8] } else { &ap.vendor };
Row::new(vec![
Cell::from(essid_display).style(if stale { dim } else { Style::default().fg(Color::White).bold() }),
Cell::from(ap.bssid.clone()).style(dim.fg(Color::Rgb(180, 180, 220))),
Cell::from(format!("{:>3}", ap.channel)).style(dim.fg(Color::LightYellow)),
Cell::from(format!("{:>3}", ap.channel_width)).style(dim.fg(Color::DarkGray)),
Cell::from(ap.signal_bar()).style(Style::default().fg(sig_color)),
Cell::from(format!("{:>4}", ap.signal_dbm)).style(Style::default().fg(sig_color)),
Cell::from(ap.encryption.display.clone()).style(Style::default().fg(enc_color)),
Cell::from(ap.wifi_gen_str()).style(dim.fg(Color::Cyan)),
Cell::from(vendor_short.to_string()).style(dim.fg(Color::Rgb(150,150,180))),
Cell::from(format!("{}", ap.beacon_count)).style(dim.fg(Color::Rgb(100, 150, 200))),
Cell::from(format!("{}", ap.data_count)).style(dim.fg(Color::Rgb(100, 200, 150))),
Cell::from(format!("{}", ap.clients.len())).style(dim.fg(Color::LightMagenta)),
Cell::from(format!("{}", ap.handshakes)).style(hs_style),
Cell::from(format!("{}s", ap.age_secs())).style(
if stale { Style::default().fg(Color::Red) } else { Style::default().fg(Color::Green) }),
])
}).collect();
let widths = [
Constraint::Min(16), Constraint::Length(17), Constraint::Length(3),
Constraint::Length(3), Constraint::Length(5), Constraint::Length(5),
Constraint::Length(10), Constraint::Length(3), Constraint::Length(8),
Constraint::Length(5), Constraint::Length(5), Constraint::Length(3),
Constraint::Length(3), Constraint::Length(5),
];
let table = Table::new(rows, widths)
.header(header_row)
.row_highlight_style(Style::default().bg(Color::Rgb(40, 40, 60)).add_modifier(Modifier::BOLD))
.block(Block::default().borders(Borders::ALL).border_style(Style::default().fg(Color::DarkGray))
.title(Span::styled(format!(" Access Points ({}) ", aps.len()), Style::default().fg(Color::Green).bold())));
app.scroll_state = app.scroll_state.content_length(aps.len());
f.render_stateful_widget(table, area, &mut app.table_state);
f.render_stateful_widget(
Scrollbar::new(ScrollbarOrientation::VerticalRight)
.begin_symbol(Some("\u{25b2}")).end_symbol(Some("\u{25bc}"))
.track_symbol(Some("\u{2502}")).thumb_symbol("\u{2588}"),
area, &mut app.scroll_state);
}