Version 1.0.0

This commit is contained in:
leviathan
2026-02-07 17:35:27 -05:00
parent c8bff9afd7
commit 4339895b41
43 changed files with 12218 additions and 3 deletions
+362
View File
@@ -0,0 +1,362 @@
//! KAT - Keyfob Analysis Toolkit
//!
//! A terminal UI application for capturing, decoding, and transmitting
//! keyfob signals using HackRF.
mod app;
mod capture;
mod export;
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};
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::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();
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: 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;
}
_ => {}
},
// .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;
}
_ => {}
},
}
}
}
}
// Process any pending radio events
app.process_radio_events()?;
}
}