//! 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(terminal: &mut Terminal, 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)?; if app.quit_requested { return Ok(()); } 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()?; } }