Version 1.0.0
This commit is contained in:
+362
@@ -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()?;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user