428 lines
18 KiB
Rust
428 lines
18 KiB
Rust
//! KAT - Keyfob Analysis Toolkit
|
|
//!
|
|
//! A terminal UI application for capturing, decoding, and transmitting
|
|
//! keyfob signals using HackRF.
|
|
|
|
mod app;
|
|
mod capture;
|
|
mod export;
|
|
mod keystore;
|
|
mod protocols;
|
|
mod radio;
|
|
mod storage;
|
|
mod ui;
|
|
|
|
use anyhow::Result;
|
|
use crossterm::{
|
|
event::{self, Event, KeyCode, KeyEventKind},
|
|
execute,
|
|
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
|
|
};
|
|
use ratatui::{backend::CrosstermBackend, Terminal};
|
|
use std::io::{self, Write};
|
|
use std::panic;
|
|
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
|
|
|
use app::{App, InputMode, SignalAction, SettingsField, ExportFormat};
|
|
use ui::draw_ui;
|
|
|
|
const VERSION: &str = env!("CARGO_PKG_VERSION");
|
|
|
|
/// Restore the terminal to normal state (for panic handler)
|
|
fn restore_terminal_panic() {
|
|
// Disable raw mode first
|
|
let _ = disable_raw_mode();
|
|
|
|
// Write escape sequences directly to stdout
|
|
let mut stdout = io::stdout();
|
|
|
|
// Leave alternate screen: ESC [ ? 1049 l
|
|
let _ = stdout.write_all(b"\x1b[?1049l");
|
|
|
|
// Show cursor: ESC [ ? 25 h
|
|
let _ = stdout.write_all(b"\x1b[?25h");
|
|
|
|
let _ = stdout.flush();
|
|
}
|
|
|
|
fn main() -> Result<()> {
|
|
// Check if we have a TTY first
|
|
if !atty::is(atty::Stream::Stdout) {
|
|
eprintln!("Error: KAT requires a terminal (TTY) to run.");
|
|
eprintln!("Please run this program in a real terminal, not via a script or IDE runner.");
|
|
std::process::exit(1);
|
|
}
|
|
|
|
// Set up panic hook to restore terminal on panic
|
|
let default_panic = panic::take_hook();
|
|
panic::set_hook(Box::new(move |panic_info| {
|
|
restore_terminal_panic();
|
|
default_panic(panic_info);
|
|
}));
|
|
|
|
// Initialize logging to a file (not stdout, which would corrupt TUI)
|
|
let log_file = crate::storage::resolve_config_dir()
|
|
.unwrap_or_else(|| std::path::PathBuf::from(".").join("KAT"))
|
|
.join("kat.log");
|
|
|
|
// Create log directory if needed
|
|
if let Some(parent) = log_file.parent() {
|
|
let _ = std::fs::create_dir_all(parent);
|
|
}
|
|
|
|
// Set up file-based logging
|
|
if let Ok(file) = std::fs::File::create(&log_file) {
|
|
tracing_subscriber::registry()
|
|
.with(
|
|
tracing_subscriber::EnvFilter::try_from_default_env()
|
|
.unwrap_or_else(|_| "kat=info".into()),
|
|
)
|
|
.with(
|
|
tracing_subscriber::fmt::layer()
|
|
.with_target(false)
|
|
.with_writer(std::sync::Mutex::new(file))
|
|
.with_ansi(false)
|
|
)
|
|
.init();
|
|
}
|
|
|
|
tracing::info!("Starting KAT v{}", VERSION);
|
|
|
|
// Setup terminal
|
|
enable_raw_mode()?;
|
|
let mut stdout = io::stdout();
|
|
execute!(stdout, EnterAlternateScreen)?;
|
|
let backend = CrosstermBackend::new(stdout);
|
|
let mut terminal = Terminal::new(backend)?;
|
|
|
|
// Create app and run
|
|
let mut app = App::new()?;
|
|
let res = run_app(&mut terminal, &mut app);
|
|
|
|
// Restore terminal properly using the terminal's backend
|
|
disable_raw_mode()?;
|
|
execute!(
|
|
terminal.backend_mut(),
|
|
LeaveAlternateScreen
|
|
)?;
|
|
terminal.show_cursor()?;
|
|
|
|
if let Err(err) = res {
|
|
eprintln!("Error: {err:?}");
|
|
return Err(err);
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn run_app<B: ratatui::backend::Backend>(terminal: &mut Terminal<B>, app: &mut App) -> Result<()> {
|
|
loop {
|
|
terminal.draw(|f| draw_ui(f, app))?;
|
|
|
|
if event::poll(std::time::Duration::from_millis(100))? {
|
|
if let Event::Key(key) = event::read()? {
|
|
if key.kind == KeyEventKind::Press {
|
|
match app.input_mode {
|
|
InputMode::Normal => match key.code {
|
|
KeyCode::Char('q') => return Ok(()),
|
|
KeyCode::Char(':') => {
|
|
app.input_mode = InputMode::Command;
|
|
app.command_input.clear();
|
|
}
|
|
KeyCode::Char('j') | KeyCode::Down => {
|
|
app.next_capture();
|
|
}
|
|
KeyCode::Char('k') | KeyCode::Up => {
|
|
app.previous_capture();
|
|
}
|
|
KeyCode::Char('r') => {
|
|
app.toggle_receiving()?;
|
|
}
|
|
KeyCode::Char('d') => {
|
|
let _ = app.delete_selected_capture();
|
|
}
|
|
KeyCode::Enter => {
|
|
// Open signal action menu if a capture is selected
|
|
if app.selected_capture.is_some() && !app.captures.is_empty() {
|
|
app.input_mode = InputMode::SignalMenu;
|
|
app.signal_menu_index = 0;
|
|
}
|
|
}
|
|
KeyCode::Tab => {
|
|
// Open settings selector
|
|
app.input_mode = InputMode::SettingsSelect;
|
|
app.settings_field_index = 0;
|
|
}
|
|
_ => {}
|
|
},
|
|
|
|
InputMode::Command => match key.code {
|
|
KeyCode::Enter => {
|
|
let command = app.command_input.clone();
|
|
app.execute_command(&command)?;
|
|
app.command_input.clear();
|
|
if app.input_mode == InputMode::Command {
|
|
app.input_mode = InputMode::Normal;
|
|
}
|
|
}
|
|
KeyCode::Char(c) => {
|
|
app.command_input.push(c);
|
|
}
|
|
KeyCode::Backspace => {
|
|
app.command_input.pop();
|
|
}
|
|
KeyCode::Esc => {
|
|
app.command_input.clear();
|
|
app.input_mode = InputMode::Normal;
|
|
}
|
|
_ => {}
|
|
},
|
|
|
|
InputMode::SignalMenu => match key.code {
|
|
KeyCode::Up | KeyCode::Char('k') => {
|
|
if app.signal_menu_index > 0 {
|
|
app.signal_menu_index -= 1;
|
|
}
|
|
}
|
|
KeyCode::Down | KeyCode::Char('j') => {
|
|
if app.signal_menu_index < SignalAction::ALL.len() - 1 {
|
|
app.signal_menu_index += 1;
|
|
}
|
|
}
|
|
KeyCode::Enter => {
|
|
app.execute_signal_action()?;
|
|
// Only return to Normal if the action didn't change
|
|
// input mode (e.g. ExportFob sets FobMetaYear)
|
|
if app.input_mode == InputMode::SignalMenu {
|
|
app.input_mode = InputMode::Normal;
|
|
}
|
|
}
|
|
KeyCode::Esc => {
|
|
app.input_mode = InputMode::Normal;
|
|
}
|
|
_ => {}
|
|
},
|
|
|
|
InputMode::SettingsSelect => match key.code {
|
|
KeyCode::Left | KeyCode::Char('h') => {
|
|
if app.settings_field_index > 0 {
|
|
app.settings_field_index -= 1;
|
|
}
|
|
}
|
|
KeyCode::Right | KeyCode::Char('l') => {
|
|
if app.settings_field_index < SettingsField::ALL.len() - 1 {
|
|
app.settings_field_index += 1;
|
|
}
|
|
}
|
|
KeyCode::Tab => {
|
|
// Cycle through fields
|
|
app.settings_field_index =
|
|
(app.settings_field_index + 1) % SettingsField::ALL.len();
|
|
}
|
|
KeyCode::Enter => {
|
|
// Enter edit mode for this field
|
|
app.settings_value_index = app.current_settings_value_index();
|
|
app.input_mode = InputMode::SettingsEdit;
|
|
}
|
|
KeyCode::Esc => {
|
|
app.input_mode = InputMode::Normal;
|
|
}
|
|
_ => {}
|
|
},
|
|
|
|
InputMode::SettingsEdit => match key.code {
|
|
KeyCode::Up | KeyCode::Char('k') => {
|
|
if app.settings_value_index > 0 {
|
|
app.settings_value_index -= 1;
|
|
}
|
|
}
|
|
KeyCode::Down | KeyCode::Char('j') => {
|
|
let max = app.settings_value_count();
|
|
if app.settings_value_index < max - 1 {
|
|
app.settings_value_index += 1;
|
|
}
|
|
}
|
|
KeyCode::Enter => {
|
|
app.apply_settings_value()?;
|
|
app.input_mode = InputMode::SettingsSelect;
|
|
}
|
|
KeyCode::Esc => {
|
|
app.input_mode = InputMode::SettingsSelect;
|
|
}
|
|
_ => {}
|
|
},
|
|
|
|
// Startup: HackRF not detected — any key to dismiss
|
|
InputMode::HackRfNotDetected => {
|
|
app.input_mode = if app.pending_fob_files.is_empty() {
|
|
InputMode::Normal
|
|
} else {
|
|
InputMode::StartupImport
|
|
};
|
|
}
|
|
|
|
// Startup: found .fob files, y/n to import
|
|
InputMode::StartupImport => match key.code {
|
|
KeyCode::Char('y') | KeyCode::Char('Y') => {
|
|
app.import_fob_files()?;
|
|
app.input_mode = InputMode::Normal;
|
|
}
|
|
KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => {
|
|
app.skip_fob_import();
|
|
app.input_mode = InputMode::Normal;
|
|
}
|
|
_ => {}
|
|
},
|
|
|
|
// Export: filename input
|
|
InputMode::ExportFilename => match key.code {
|
|
KeyCode::Enter => {
|
|
if app.export_filename.is_empty() {
|
|
app.last_error = Some("Filename cannot be empty".to_string());
|
|
} else {
|
|
match app.export_format {
|
|
Some(ExportFormat::Fob) => {
|
|
app.input_mode = InputMode::FobMetaYear;
|
|
}
|
|
Some(ExportFormat::Flipper) => {
|
|
app.complete_flipper_export()?;
|
|
app.input_mode = InputMode::Normal;
|
|
}
|
|
None => {
|
|
app.input_mode = InputMode::Normal;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
KeyCode::Char(c) => {
|
|
// Allow filesystem-safe characters
|
|
if c.is_alphanumeric() || c == '_' || c == '-' || c == '.' {
|
|
app.export_filename.push(c);
|
|
}
|
|
}
|
|
KeyCode::Backspace => {
|
|
app.export_filename.pop();
|
|
}
|
|
KeyCode::Esc => {
|
|
app.export_capture_id = None;
|
|
app.export_format = None;
|
|
app.input_mode = InputMode::Normal;
|
|
}
|
|
_ => {}
|
|
},
|
|
|
|
// .fob export metadata: Year
|
|
InputMode::FobMetaYear => match key.code {
|
|
KeyCode::Enter => {
|
|
app.input_mode = InputMode::FobMetaMake;
|
|
}
|
|
KeyCode::Char(c) if c.is_ascii_digit() => {
|
|
if app.fob_meta_year.len() < 4 {
|
|
app.fob_meta_year.push(c);
|
|
}
|
|
}
|
|
KeyCode::Backspace => {
|
|
app.fob_meta_year.pop();
|
|
}
|
|
KeyCode::Esc => {
|
|
app.export_capture_id = None;
|
|
app.input_mode = InputMode::Normal;
|
|
}
|
|
_ => {}
|
|
},
|
|
|
|
// .fob export metadata: Make
|
|
InputMode::FobMetaMake => match key.code {
|
|
KeyCode::Enter => {
|
|
app.input_mode = InputMode::FobMetaModel;
|
|
}
|
|
KeyCode::Char(c) => {
|
|
app.fob_meta_make.push(c);
|
|
}
|
|
KeyCode::Backspace => {
|
|
app.fob_meta_make.pop();
|
|
}
|
|
KeyCode::Esc => {
|
|
app.export_capture_id = None;
|
|
app.input_mode = InputMode::Normal;
|
|
}
|
|
_ => {}
|
|
},
|
|
|
|
// .fob export metadata: Model -> Region
|
|
InputMode::FobMetaModel => match key.code {
|
|
KeyCode::Enter => {
|
|
app.input_mode = InputMode::FobMetaRegion;
|
|
}
|
|
KeyCode::Char(c) => {
|
|
app.fob_meta_model.push(c);
|
|
}
|
|
KeyCode::Backspace => {
|
|
app.fob_meta_model.pop();
|
|
}
|
|
KeyCode::Esc => {
|
|
app.export_capture_id = None;
|
|
app.input_mode = InputMode::Normal;
|
|
}
|
|
_ => {}
|
|
},
|
|
|
|
// .fob export metadata: Region -> Notes
|
|
InputMode::FobMetaRegion => match key.code {
|
|
KeyCode::Enter => {
|
|
app.input_mode = InputMode::FobMetaNotes;
|
|
}
|
|
KeyCode::Char(c) => {
|
|
app.fob_meta_region.push(c);
|
|
}
|
|
KeyCode::Backspace => {
|
|
app.fob_meta_region.pop();
|
|
}
|
|
KeyCode::Esc => {
|
|
app.export_capture_id = None;
|
|
app.input_mode = InputMode::Normal;
|
|
}
|
|
_ => {}
|
|
},
|
|
|
|
// .fob export metadata: Notes -> Export
|
|
InputMode::FobMetaNotes => match key.code {
|
|
KeyCode::Enter => {
|
|
app.complete_fob_export()?;
|
|
app.input_mode = InputMode::Normal;
|
|
}
|
|
KeyCode::Char(c) => {
|
|
app.fob_meta_notes.push(c);
|
|
}
|
|
KeyCode::Backspace => {
|
|
app.fob_meta_notes.pop();
|
|
}
|
|
KeyCode::Esc => {
|
|
app.export_capture_id = None;
|
|
app.input_mode = InputMode::Normal;
|
|
}
|
|
_ => {}
|
|
},
|
|
|
|
InputMode::License | InputMode::Credits => match key.code {
|
|
KeyCode::Esc | KeyCode::Enter => {
|
|
app.input_mode = InputMode::Normal;
|
|
}
|
|
KeyCode::Up | KeyCode::Char('k') => {
|
|
app.overlay_scroll = app.overlay_scroll.saturating_sub(1);
|
|
}
|
|
KeyCode::Down | KeyCode::Char('j') => {
|
|
app.overlay_scroll = app.overlay_scroll.saturating_add(1);
|
|
}
|
|
_ => {}
|
|
},
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Process any pending radio events
|
|
app.process_radio_events()?;
|
|
}
|
|
}
|