use std::io; use std::sync::Arc; use ratatui::crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; use ratatui::layout::{Constraint, Layout}; use ratatui::style::{Color, Style}; use ratatui::widgets::{Block, Borders, List, ListItem, Paragraph, Wrap}; use ratatui::{DefaultTerminal, Frame}; use crate::node::Node; use crate::qr::{self, QrPayload}; use crate::store::Friend; pub fn fingerprint_mismatch_banner() -> &'static str { "fingerprint mismatch" } pub fn rotate_screen_text() -> &'static str { "Rotate onion address?\n\ Your identity key stays the same.\n\ Online friends get a signed location update.\n\ Offline friends CANNOT find you until they rescan your QR.\n\ Type ROTATE to confirm Esc to cancel" } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum WipeKind { Messages, All, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum WipeDecision { Pending, Confirm, Cancel, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum AppExit { Quit, WipeAll, } pub fn parse_slash(raw: &str) -> Option { match raw.trim() { "/wipe" => Some(WipeKind::Messages), "/wipe-all" => Some(WipeKind::All), _ => None, } } pub fn wipe_screen_text(kind: WipeKind) -> &'static str { match kind { WipeKind::Messages => { "Wipe message log?\n\ Identity key and friends stay.\n\ Type WIPE to confirm Esc to cancel" } WipeKind::All => { "Wipe ALL local data?\n\ This deletes your identity key. You become a new person.\n\ Type WIPEALL to confirm Esc to cancel" } } } #[derive(Debug)] pub struct WipePrompt { expected: &'static str, buf: String, } impl WipePrompt { pub fn messages() -> Self { Self { expected: "WIPE", buf: String::new(), } } pub fn all() -> Self { Self { expected: "WIPEALL", buf: String::new(), } } pub fn on_esc(&self) -> WipeDecision { WipeDecision::Cancel } pub fn on_char(&mut self, c: char) -> WipeDecision { if c == '\n' { return WipeDecision::Pending; } if c == '\u{8}' { self.buf.pop(); return WipeDecision::Pending; } if !c.is_ascii_alphabetic() { return WipeDecision::Pending; } self.buf.push(c); if self.buf == self.expected { WipeDecision::Confirm } else { WipeDecision::Pending } } pub fn typed(&self) -> &str { &self.buf } } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum RotateDecision { Pending, Confirm, Cancel, } #[derive(Debug, Default)] pub struct RotatePrompt { buf: String, } impl RotatePrompt { pub fn new() -> Self { Self::default() } pub fn on_esc(&self) -> RotateDecision { RotateDecision::Cancel } pub fn on_char(&mut self, c: char) -> RotateDecision { if c == '\n' { return RotateDecision::Pending; } if c == '\u{8}' { self.buf.pop(); return RotateDecision::Pending; } if !c.is_ascii_alphabetic() { return RotateDecision::Pending; } self.buf.push(c); if self.buf == "ROTATE" { RotateDecision::Confirm } else { RotateDecision::Pending } } pub fn typed(&self) -> &str { &self.buf } } pub fn run(node: Arc, rt: tokio::runtime::Handle) -> Result { let mut app = App::new(node, rt)?; let mut terminal = ratatui::init(); let result = app.run(&mut terminal); ratatui::restore(); result } struct App { node: Arc, rt: tokio::runtime::Handle, screen: Screen, friends: Vec, selected: usize, me_fp: String, me_onion: String, alert: Option, status_note: Option, composer: String, } enum Screen { Main, Share { art: String, payload: String }, Paste { buf: String, err: Option }, Approve { payload: QrPayload }, Rotate { prompt: RotatePrompt }, Wipe { kind: WipeKind, prompt: WipePrompt }, } impl App { fn new(node: Arc, rt: tokio::runtime::Handle) -> Result { let friends = node.list_friends()?; Ok(Self { me_fp: to_hex(&node.identity_pk()), me_onion: node.onion(), node, rt, screen: Screen::Main, friends, selected: 0, alert: None, status_note: None, composer: String::new(), }) } fn run(&mut self, terminal: &mut DefaultTerminal) -> Result { loop { terminal.draw(|f| self.draw(f)).map_err(|e| e.to_string())?; let Event::Key(key) = event::read().map_err(io_err)? else { continue; }; if key.kind != KeyEventKind::Press { continue; } if key.code == KeyCode::Char('q') && key.modifiers.contains(KeyModifiers::CONTROL) { return Ok(AppExit::Quit); } if let Some(exit) = self.handle_key(key)? { return Ok(exit); } } } fn handle_key(&mut self, key: KeyEvent) -> Result, String> { let mut wipe_confirm = None; match &mut self.screen { Screen::Main => match key.code { KeyCode::Up => { self.selected = self.selected.saturating_sub(1); } KeyCode::Down => { if !self.friends.is_empty() { self.selected = (self.selected + 1).min(self.friends.len() - 1); } } KeyCode::F(2) => self.open_share()?, KeyCode::F(3) => { self.screen = Screen::Paste { buf: String::new(), err: None, }; } KeyCode::F(4) => { self.screen = Screen::Rotate { prompt: RotatePrompt::new(), }; } KeyCode::Esc => self.composer.clear(), KeyCode::Backspace => { self.composer.pop(); } KeyCode::Enter => { if let Some(kind) = parse_slash(&self.composer) { self.composer.clear(); let prompt = match kind { WipeKind::Messages => WipePrompt::messages(), WipeKind::All => WipePrompt::all(), }; self.screen = Screen::Wipe { kind, prompt }; } } KeyCode::Char(c) if !key.modifiers.contains(KeyModifiers::CONTROL) => { self.composer.push(c); } _ => {} }, Screen::Share { .. } => { if key.code == KeyCode::Esc { self.screen = Screen::Main; } } Screen::Paste { buf, err } => match key.code { KeyCode::Esc => self.screen = Screen::Main, KeyCode::Enter => { let raw = buf.clone(); self.submit_paste(&raw)?; } KeyCode::Backspace => { buf.pop(); *err = None; } KeyCode::Char(c) if !key.modifiers.contains(KeyModifiers::CONTROL) => { buf.push(c); *err = None; } _ => {} }, Screen::Approve { payload } => match key.code { KeyCode::Char('y') | KeyCode::Enter => { let p = payload.clone(); self.accept_friend(&p)?; } KeyCode::Char('n') | KeyCode::Esc => self.screen = Screen::Main, _ => {} }, Screen::Rotate { prompt } => match key.code { KeyCode::Esc => self.screen = Screen::Main, KeyCode::Char(c) if !key.modifiers.contains(KeyModifiers::CONTROL) => { if prompt.on_char(c) == RotateDecision::Confirm { self.confirm_rotate()?; } } KeyCode::Backspace => { prompt.on_char('\u{8}'); } _ => {} }, Screen::Wipe { kind, prompt } => { let kind = *kind; match key.code { KeyCode::Esc => self.screen = Screen::Main, KeyCode::Char(c) if !key.modifiers.contains(KeyModifiers::CONTROL) => { if prompt.on_char(c) == WipeDecision::Confirm { wipe_confirm = Some(kind); } } KeyCode::Backspace => { prompt.on_char('\u{8}'); } _ => {} } } } if let Some(kind) = wipe_confirm { return self.confirm_wipe(kind); } Ok(None) } fn confirm_wipe(&mut self, kind: WipeKind) -> Result, String> { match kind { WipeKind::Messages => { self.node.wipe_messages()?; self.status_note = Some("wiped messages".into()); self.screen = Screen::Main; Ok(None) } WipeKind::All => Ok(Some(AppExit::WipeAll)), } } fn confirm_rotate(&mut self) -> Result<(), String> { let node = Arc::clone(&self.node); match self.rt.block_on(node.rotate()) { Ok(outcome) => { self.me_onion = node.onion(); self.status_note = Some(format!( "rotated · notified {}/{} friends", outcome.notified, outcome.friends )); self.open_share() } Err(e) => { self.alert = Some(e); self.screen = Screen::Main; Ok(()) } } } fn open_share(&mut self) -> Result<(), String> { let payload = self.node.qr_payload()?; let art = qr::render_unicode(&payload).map_err(|e| e.to_string())?; self.screen = Screen::Share { art, payload }; Ok(()) } fn submit_paste(&mut self, raw: &str) -> Result<(), String> { match qr::decode(raw.trim()) { Err(e) => { self.screen = Screen::Paste { buf: raw.to_string(), err: Some(e.to_string()), }; } Ok(payload) => { let known = self.node.get_friend(&payload.pubkey)?.is_some(); if known { self.accept_friend(&payload)?; } else { self.screen = Screen::Approve { payload }; } } } Ok(()) } fn accept_friend(&mut self, payload: &QrPayload) -> Result<(), String> { self.node.add_friend_payload(payload)?; self.reload_friends()?; self.screen = Screen::Main; Ok(()) } fn reload_friends(&mut self) -> Result<(), String> { self.friends = self.node.list_friends()?; if self.friends.is_empty() { self.selected = 0; } else { self.selected = self.selected.min(self.friends.len() - 1); } Ok(()) } fn draw(&self, f: &mut Frame) { match &self.screen { Screen::Main => self.draw_main(f), Screen::Share { art, payload } => draw_share(f, art, payload), Screen::Paste { buf, err } => draw_paste(f, buf, err.as_deref()), Screen::Approve { payload } => draw_approve(f, payload), Screen::Rotate { prompt } => draw_rotate(f, prompt.typed()), Screen::Wipe { kind, prompt } => draw_wipe(f, *kind, prompt.typed()), } } fn draw_main(&self, f: &mut Frame) { let area = f.area(); let cols = Layout::vertical([ Constraint::Min(1), Constraint::Length(1), Constraint::Length(1), ]) .split(area); let body = if self.alert.is_some() { Layout::vertical([Constraint::Length(1), Constraint::Min(1)]).split(cols[0]) } else { Layout::vertical([Constraint::Length(0), Constraint::Min(1)]).split(cols[0]) }; if let Some(msg) = &self.alert { f.render_widget( Paragraph::new(msg.as_str()).style(Style::default().fg(Color::Red)), body[0], ); } let panes = Layout::horizontal([Constraint::Percentage(36), Constraint::Min(10)]).split(body[1]); let items: Vec = self .friends .iter() .enumerate() .map(|(i, friend)| { let name = friend.petname.as_deref().unwrap_or(&friend.fingerprint); let fp = truncate_fp(&friend.fingerprint); let stale = if friend.last_connect_ok { "" } else { " stale" }; let mark = if i == self.selected { ">" } else { " " }; ListItem::new(format!("{mark} {name} {fp}{stale}")) }) .collect(); let roster = List::new(items).block(Block::default().borders(Borders::ALL).title("roster")); f.render_widget(roster, panes[0]); let chat_text = if let Some(friend) = self.friends.get(self.selected) { let name = friend.petname.as_deref().unwrap_or(&friend.fingerprint); format!("{name}\n\nchat arrives in M3") } else { "no friends yet\nF3 paste a QR to add one".into() }; let chat = Paragraph::new(chat_text) .wrap(Wrap { trim: false }) .block(Block::default().borders(Borders::ALL).title("chat")); f.render_widget(chat, panes[1]); let onion = if self.me_onion.is_empty() { "unset" } else { self.me_onion.as_str() }; let note = self .status_note .as_deref() .map(|s| format!("{s} | ")) .unwrap_or_default(); let status = format!( "{note}TOR: up | me: {} | onion: {} F2 share F3 paste F4 rotate Ctrl-Q quit", truncate_fp(&self.me_fp), onion ); let cmd = if self.composer.is_empty() { "/wipe /wipe-all".to_string() } else { self.composer.clone() }; f.render_widget(Paragraph::new(cmd), cols[1]); f.render_widget(Paragraph::new(status), cols[2]); } } fn draw_share(f: &mut Frame, art: &str, payload: &str) { let body = format!("{art}\n{payload}\n\nEsc back"); f.render_widget( Paragraph::new(body) .wrap(Wrap { trim: false }) .block(Block::default().borders(Borders::ALL).title("F2 share")), f.area(), ); } fn draw_paste(f: &mut Frame, buf: &str, err: Option<&str>) { let hint = err.unwrap_or("paste onionwire:v1 payload, Enter to submit"); let body = format!("{hint}\n\n{buf}"); f.render_widget( Paragraph::new(body) .wrap(Wrap { trim: false }) .block(Block::default().borders(Borders::ALL).title("F3 paste QR")), f.area(), ); } fn draw_approve(f: &mut Frame, payload: &QrPayload) { let fp = to_hex(&payload.pubkey); let body = format!( "Unknown key. Add this friend?\n\nfp {}\nonion {}\n\ny accept n / Esc reject", truncate_fp(&fp), payload.onion ); f.render_widget( Paragraph::new(body).block(Block::default().borders(Borders::ALL).title("approve")), f.area(), ); } fn draw_rotate(f: &mut Frame, typed: &str) { let body = format!("{}\n\n{typed}", rotate_screen_text()); f.render_widget( Paragraph::new(body) .wrap(Wrap { trim: false }) .block(Block::default().borders(Borders::ALL).title("F4 rotate")), f.area(), ); } fn draw_wipe(f: &mut Frame, kind: WipeKind, typed: &str) { let title = match kind { WipeKind::Messages => "/wipe", WipeKind::All => "/wipe-all", }; let body = format!("{}\n\n{typed}", wipe_screen_text(kind)); f.render_widget( Paragraph::new(body) .wrap(Wrap { trim: false }) .block(Block::default().borders(Borders::ALL).title(title)), f.area(), ); } fn truncate_fp(fp: &str) -> String { let s: String = fp.chars().take(8).collect(); format!("{s}…") } fn to_hex(bytes: &[u8]) -> String { bytes.iter().map(|b| format!("{b:02x}")).collect() } fn io_err(e: io::Error) -> String { e.to_string() }