diff --git a/pgp-dump/Cargo.toml b/pgp-dump/Cargo.toml index a2de126..fb88abf 100644 --- a/pgp-dump/Cargo.toml +++ b/pgp-dump/Cargo.toml @@ -9,3 +9,10 @@ readme = "README.md" resolver = "2" [dependencies] +color-eyre = "0.6.3" +crossterm = { version = "0.27.0", features = ["event-stream"] } +pgp = "0.16.0-alpha.0" +pretty_env_logger = "0.5.0" +ratatui = "0.26.1" +tokio = { version = "1.37.0", features = ["full"] } +tui-tree-widget = "0.19.0" diff --git a/pgp-dump/src/main.rs b/pgp-dump/src/main.rs index e7a11a9..9e6dc67 100644 --- a/pgp-dump/src/main.rs +++ b/pgp-dump/src/main.rs @@ -1,3 +1,216 @@ -fn main() { - println!("Hello, world!"); +use std::io::BufReader; + +use color_eyre::eyre::Result; +use crossterm::event::KeyCode; +use pgp::packet::PacketTrait; +use ratatui::{prelude::*, widgets::*}; +use tokio::sync::mpsc; +use tui_tree_widget::{Tree, TreeItem, TreeState}; + +pub fn initialize_panic_handler() { + let original_hook = std::panic::take_hook(); + std::panic::set_hook(Box::new(move |panic_info| { + shutdown().unwrap(); + original_hook(panic_info); + })); +} + +fn startup() -> Result<()> { + crossterm::terminal::enable_raw_mode()?; + crossterm::execute!(std::io::stderr(), crossterm::terminal::EnterAlternateScreen)?; + Ok(()) +} + +fn shutdown() -> Result<()> { + crossterm::execute!(std::io::stderr(), crossterm::terminal::LeaveAlternateScreen)?; + crossterm::terminal::disable_raw_mode()?; + Ok(()) +} + +struct App<'a> { + action_tx: mpsc::UnboundedSender, + should_quit: bool, + state: TreeState, + items: Vec>, + packets: Vec, +} + +impl App<'_> { + fn new(action_tx: mpsc::UnboundedSender, packets: Vec) -> Self { + let mut items = Vec::new(); + + for (i, packet) in packets.iter().enumerate() { + let name = format!("{:?}", packet.tag()); + items.push(TreeItem::new_leaf(i, name)); + } + + Self { + should_quit: false, + action_tx, + state: TreeState::default(), + items, + packets, + } + } + + fn draw(&mut self, f: &mut Frame) { + let area = f.size(); + let layout = Layout::default() + .direction(Direction::Horizontal) + // use a 49/51 split instead of 50/50 to ensure that any extra space is on the right + // side of the screen. This is important because the right side of the screen is + // where the borders are collapsed. + .constraints([Constraint::Percentage(49), Constraint::Percentage(51)]) + .split(area); + + let widget = Tree::new(self.items.clone()) + .expect("all item identifiers are unique") + .block( + Block::new() + .title("Packets") + .title_bottom(format!("{:?}", self.state)) + // don't render the right border because it will be rendered by the right block + .border_set(symbols::border::PLAIN) + .borders(Borders::TOP | Borders::LEFT | Borders::BOTTOM), + ) + .experimental_scrollbar(Some( + Scrollbar::new(ScrollbarOrientation::VerticalRight) + .begin_symbol(None) + .track_symbol(None) + .end_symbol(None), + )) + .highlight_style( + Style::new() + .fg(Color::Black) + .bg(Color::LightGreen) + .add_modifier(Modifier::BOLD), + ) + .highlight_symbol(">> "); + f.render_stateful_widget(widget, layout[0], &mut self.state); + + let text = if let Some(i) = self.state.selected().last() { + format!("{:#?}", self.packets[*i]) + } else { + "Nothing selected".to_string() + }; + + f.render_widget( + Paragraph::new(text).block( + Block::new() + // don't render the right border because it will be rendered by the right block + .border_set(symbols::border::PLAIN) + .borders(Borders::TOP | Borders::LEFT | Borders::BOTTOM | Borders::RIGHT) + .title("Details"), + ), + layout[1], + ); + } + + fn update(&mut self, msg: Action) -> Action { + match msg { + Action::Quit => self.should_quit = true, // You can handle cleanup and exit here + Action::Up => { + self.state.key_up(&self.items); + } + Action::Down => { + self.state.key_down(&self.items); + } + Action::Left => { + self.state.key_left(); + } + Action::Right => { + self.state.key_right(); + } + Action::None => {} + }; + Action::None + } + + fn handle_event(&self) -> tokio::task::JoinHandle<()> { + let tick_rate = std::time::Duration::from_millis(250); + let tx = self.action_tx.clone(); + tokio::spawn(async move { + loop { + let action = if crossterm::event::poll(tick_rate).unwrap() { + if let crossterm::event::Event::Key(key) = crossterm::event::read().unwrap() { + if key.kind == crossterm::event::KeyEventKind::Press { + match key.code { + KeyCode::Char('q') => Action::Quit, + KeyCode::Left => Action::Left, + KeyCode::Right => Action::Right, + KeyCode::Down => Action::Down, + KeyCode::Up => Action::Up, + _ => Action::None, + } + } else { + Action::None + } + } else { + Action::None + } + } else { + Action::None + }; + if let Err(_) = tx.send(action) { + break; + } + } + }) + } +} + +#[derive(PartialEq)] +enum Action { + Left, + Right, + Down, + Up, + Quit, + None, +} + +async fn run(packets: Vec) -> Result<()> { + let mut t = Terminal::new(CrosstermBackend::new(std::io::stderr()))?; + + let (action_tx, mut action_rx) = mpsc::unbounded_channel(); + + let mut app = App::new(action_tx, packets); + let task = app.handle_event(); + + loop { + t.draw(|f| { + app.draw(f); + })?; + + if let Some(action) = action_rx.recv().await { + app.update(action); + } + + if app.should_quit { + break; + } + } + + task.abort(); + + Ok(()) +} + +#[tokio::main] +async fn main() -> Result<()> { + initialize_panic_handler(); + pretty_env_logger::init(); + + let file = std::env::args().nth(1).expect("missing file"); + let file = tokio::fs::read_to_string(file).await?; + + let mut dearmor = pgp::armor::Dearmor::new(file.as_bytes()); + dearmor.read_header()?; + let packets = + pgp::packet::PacketParser::new(BufReader::new(dearmor)).collect::>()?; + + startup()?; + run(packets).await?; + shutdown()?; + Ok(()) }