From a298c9430c96ecdc91fef881fef1632be065e1ff Mon Sep 17 00:00:00 2001 From: Siavash Sameni Date: Thu, 26 Mar 2026 22:59:08 +0400 Subject: [PATCH] TUI chat interface with real-time E2E encrypted messaging MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `warzone chat [peer-fp] -s ` launches an interactive terminal UI: - Header: your fingerprint, peer fingerprint, server URL - Message area: color-coded (green=you, yellow=peer, cyan=system) - Input bar with cursor at bottom - Background polling every 2s for incoming messages - Full X3DH + Double Ratchet on send/receive - Session persistence across messages Commands in TUI: - /peer — set who you're chatting with - /info — show your fingerprint - /quit or /q or Esc or Ctrl+C — exit Usage: warzone chat "6baf:6d0b:4541:9cae:f06b:83da:69bc:05ee" -s http://localhost:7700 Co-Authored-By: Claude Opus 4.6 (1M context) --- warzone/crates/warzone-client/src/main.rs | 14 +- warzone/crates/warzone-client/src/tui/app.rs | 498 ++++++++++++++++++- warzone/crates/warzone-client/src/tui/mod.rs | 4 +- 3 files changed, 510 insertions(+), 6 deletions(-) diff --git a/warzone/crates/warzone-client/src/main.rs b/warzone/crates/warzone-client/src/main.rs index aacc6e8..ffbbcec 100644 --- a/warzone/crates/warzone-client/src/main.rs +++ b/warzone/crates/warzone-client/src/main.rs @@ -49,6 +49,8 @@ enum Commands { }, /// Launch interactive TUI chat Chat { + /// Peer fingerprint to chat with (optional, can set with /peer in TUI) + peer: Option, /// Server URL #[arg(short, long, default_value = "http://localhost:7700")] server: String, @@ -80,8 +82,16 @@ async fn main() -> anyhow::Result<()> { Commands::Recv { server } => { cli::recv::run(&server).await?; } - Commands::Chat { server } => { - println!("TODO: launch TUI connected to {}", server); + Commands::Chat { peer, server } => { + // Auto-register + let _ = cli::init::register_with_server(&server).await; + + let seed = keystore::load_seed()?; + let identity = seed.derive_identity(); + let our_fp = identity.public_identity().fingerprint.to_string(); + let db = storage::LocalDb::open()?; + + tui::run_tui(our_fp, peer, server, identity, db).await?; } } diff --git a/warzone/crates/warzone-client/src/tui/app.rs b/warzone/crates/warzone-client/src/tui/app.rs index de524f9..bad7b71 100644 --- a/warzone/crates/warzone-client/src/tui/app.rs +++ b/warzone/crates/warzone-client/src/tui/app.rs @@ -1,2 +1,496 @@ -// TUI App struct and event loop. -// TODO: implement in Phase 1 step 10. +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use anyhow::{Context, Result}; +use crossterm::event::{self, Event, KeyCode, KeyModifiers}; +use ratatui::layout::{Constraint, Direction, Layout}; +use ratatui::style::{Color, Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Borders, List, ListItem, Paragraph, Wrap}; +use ratatui::Frame; +use warzone_protocol::identity::IdentityKeyPair; +use warzone_protocol::ratchet::RatchetState; +use warzone_protocol::types::Fingerprint; +use warzone_protocol::x3dh; +use x25519_dalek::PublicKey; + +use crate::cli::send::WireMessage; +use crate::net::ServerClient; +use crate::storage::LocalDb; + +pub struct App { + pub input: String, + pub messages: Arc>>, + pub our_fp: String, + pub peer_fp: Option, + pub server_url: String, + pub should_quit: bool, +} + +#[derive(Clone)] +pub struct ChatLine { + pub sender: String, + pub text: String, + pub is_system: bool, + pub is_self: bool, +} + +impl App { + pub fn new(our_fp: String, peer_fp: Option, server_url: String) -> Self { + let messages = Arc::new(Mutex::new(vec![ChatLine { + sender: "system".into(), + text: format!("You are {}", our_fp), + is_system: true, + is_self: false, + }])); + + if let Some(ref peer) = peer_fp { + messages.lock().unwrap().push(ChatLine { + sender: "system".into(), + text: format!("Chatting with {}", peer), + is_system: true, + is_self: false, + }); + } else { + messages.lock().unwrap().push(ChatLine { + sender: "system".into(), + text: "No peer set. Use /peer to start chatting".into(), + is_system: true, + is_self: false, + }); + } + + messages.lock().unwrap().push(ChatLine { + sender: "system".into(), + text: "Commands: /peer , /info, /quit".into(), + is_system: true, + is_self: false, + }); + + App { + input: String::new(), + messages, + our_fp, + peer_fp, + server_url, + should_quit: false, + } + } + + pub fn add_message(&self, line: ChatLine) { + self.messages.lock().unwrap().push(line); + } + + pub fn draw(&self, frame: &mut Frame) { + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(1), // header + Constraint::Min(5), // messages + Constraint::Length(3), // input + ]) + .split(frame.area()); + + // Header + let peer_str = self + .peer_fp + .as_deref() + .unwrap_or("no peer"); + let header = Paragraph::new(Line::from(vec![ + Span::styled("WZ ", Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)), + Span::styled(&self.our_fp, Style::default().fg(Color::Green)), + Span::raw(" → "), + Span::styled(peer_str, Style::default().fg(Color::Yellow)), + Span::styled( + format!(" [{}]", self.server_url), + Style::default().fg(Color::DarkGray), + ), + ])); + frame.render_widget(header, chunks[0]); + + // Messages + let msgs = self.messages.lock().unwrap(); + let items: Vec = msgs + .iter() + .map(|m| { + let style = if m.is_system { + Style::default().fg(Color::Cyan) + } else if m.is_self { + Style::default().fg(Color::Green) + } else { + Style::default().fg(Color::Yellow) + }; + + let prefix = if m.is_system { + "*** ".to_string() + } else { + format!("{}: ", &m.sender[..m.sender.len().min(12)]) + }; + + ListItem::new(Line::from(vec![ + Span::styled(prefix, style.add_modifier(Modifier::BOLD)), + Span::raw(&m.text), + ])) + }) + .collect(); + + let messages_widget = List::new(items) + .block(Block::default().borders(Borders::TOP)); + frame.render_widget(messages_widget, chunks[1]); + + // Input + let input_widget = Paragraph::new(self.input.as_str()) + .block( + Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::DarkGray)) + .title(" message "), + ) + .wrap(Wrap { trim: false }); + frame.render_widget(input_widget, chunks[2]); + + // Cursor + let x = (self.input.len() as u16 + 1).min(chunks[2].width - 2); + frame.set_cursor_position((chunks[2].x + x, chunks[2].y + 1)); + } + + pub async fn handle_send( + &mut self, + identity: &IdentityKeyPair, + db: &LocalDb, + client: &ServerClient, + ) { + let text = self.input.trim().to_string(); + self.input.clear(); + + if text.is_empty() { + return; + } + + // Commands + if text == "/quit" || text == "/q" { + self.should_quit = true; + return; + } + if text == "/info" { + self.add_message(ChatLine { + sender: "system".into(), + text: format!("Your fingerprint: {}", self.our_fp), + is_system: true, + is_self: false, + }); + return; + } + if text.starts_with("/peer ") { + let fp = text[6..].trim().to_string(); + self.add_message(ChatLine { + sender: "system".into(), + text: format!("Peer set to {}", fp), + is_system: true, + is_self: false, + }); + self.peer_fp = Some(fp); + return; + } + + // Send message + let peer = match &self.peer_fp { + Some(p) => p.clone(), + None => { + self.add_message(ChatLine { + sender: "system".into(), + text: "No peer set. Use /peer ".into(), + is_system: true, + is_self: false, + }); + return; + } + }; + + let peer_fp = match Fingerprint::from_hex(&peer) { + Ok(fp) => fp, + Err(_) => { + self.add_message(ChatLine { + sender: "system".into(), + text: "Invalid peer fingerprint".into(), + is_system: true, + is_self: false, + }); + return; + } + }; + + let our_pub = identity.public_identity(); + let mut ratchet = db.load_session(&peer_fp).ok().flatten(); + + let wire_msg = if let Some(ref mut state) = ratchet { + match state.encrypt(text.as_bytes()) { + Ok(encrypted) => { + let _ = db.save_session(&peer_fp, state); + WireMessage::Message { + sender_fingerprint: our_pub.fingerprint.to_string(), + ratchet_message: encrypted, + } + } + Err(e) => { + self.add_message(ChatLine { + sender: "system".into(), + text: format!("Encrypt failed: {}", e), + is_system: true, + is_self: false, + }); + return; + } + } + } else { + // X3DH + let bundle = match client.fetch_bundle(&peer).await { + Ok(b) => b, + Err(e) => { + self.add_message(ChatLine { + sender: "system".into(), + text: format!("Failed to fetch bundle: {}", e), + is_system: true, + is_self: false, + }); + return; + } + }; + + let x3dh_result = match x3dh::initiate(identity, &bundle) { + Ok(r) => r, + Err(e) => { + self.add_message(ChatLine { + sender: "system".into(), + text: format!("X3DH failed: {}", e), + is_system: true, + is_self: false, + }); + return; + } + }; + + let their_spk = PublicKey::from(bundle.signed_pre_key.public_key); + let mut state = RatchetState::init_alice(x3dh_result.shared_secret, their_spk); + + match state.encrypt(text.as_bytes()) { + Ok(encrypted) => { + let _ = db.save_session(&peer_fp, &state); + WireMessage::KeyExchange { + sender_fingerprint: our_pub.fingerprint.to_string(), + sender_identity_encryption_key: *our_pub.encryption.as_bytes(), + ephemeral_public: *x3dh_result.ephemeral_public.as_bytes(), + used_one_time_pre_key_id: x3dh_result.used_one_time_pre_key_id, + ratchet_message: encrypted, + } + } + Err(e) => { + self.add_message(ChatLine { + sender: "system".into(), + text: format!("Encrypt failed: {}", e), + is_system: true, + is_self: false, + }); + return; + } + } + }; + + let encoded = match bincode::serialize(&wire_msg) { + Ok(e) => e, + Err(e) => { + self.add_message(ChatLine { + sender: "system".into(), + text: format!("Serialize failed: {}", e), + is_system: true, + is_self: false, + }); + return; + } + }; + + match client.send_message(&peer, &encoded).await { + Ok(_) => { + self.add_message(ChatLine { + sender: self.our_fp[..12].to_string(), + text: text.clone(), + is_system: false, + is_self: true, + }); + } + Err(e) => { + self.add_message(ChatLine { + sender: "system".into(), + text: format!("Send failed: {}", e), + is_system: true, + is_self: false, + }); + } + } + } +} + +/// Poll for incoming messages in the background. +pub async fn poll_loop( + messages: Arc>>, + our_fp: String, + identity: IdentityKeyPair, + db: Arc, + client: ServerClient, +) { + loop { + tokio::time::sleep(Duration::from_secs(2)).await; + + let raw_msgs = match client.poll_messages(&our_fp).await { + Ok(m) => m, + Err(_) => continue, + }; + + for raw in &raw_msgs { + match bincode::deserialize::(raw) { + Ok(WireMessage::KeyExchange { + sender_fingerprint, + sender_identity_encryption_key, + ephemeral_public, + used_one_time_pre_key_id, + ratchet_message, + }) => { + let sender_fp = match Fingerprint::from_hex(&sender_fingerprint) { + Ok(fp) => fp, + Err(_) => continue, + }; + + let spk_secret = match db.load_signed_pre_key(1) { + Ok(Some(s)) => s, + _ => continue, + }; + + let otpk_secret = if let Some(id) = used_one_time_pre_key_id { + db.take_one_time_pre_key(id).ok().flatten() + } else { + None + }; + + let their_id_x25519 = PublicKey::from(sender_identity_encryption_key); + let their_eph = PublicKey::from(ephemeral_public); + + let shared_secret = match x3dh::respond( + &identity, + &spk_secret, + otpk_secret.as_ref(), + &their_id_x25519, + &their_eph, + ) { + Ok(s) => s, + Err(_) => continue, + }; + + let mut state = RatchetState::init_bob(shared_secret, spk_secret); + match state.decrypt(&ratchet_message) { + Ok(plaintext) => { + let text = String::from_utf8_lossy(&plaintext).to_string(); + let _ = db.save_session(&sender_fp, &state); + messages.lock().unwrap().push(ChatLine { + sender: sender_fingerprint[..12].to_string(), + text, + is_system: false, + is_self: false, + }); + } + Err(_) => continue, + } + } + Ok(WireMessage::Message { + sender_fingerprint, + ratchet_message, + }) => { + let sender_fp = match Fingerprint::from_hex(&sender_fingerprint) { + Ok(fp) => fp, + Err(_) => continue, + }; + + let mut state = match db.load_session(&sender_fp) { + Ok(Some(s)) => s, + _ => continue, + }; + + match state.decrypt(&ratchet_message) { + Ok(plaintext) => { + let text = String::from_utf8_lossy(&plaintext).to_string(); + let _ = db.save_session(&sender_fp, &state); + messages.lock().unwrap().push(ChatLine { + sender: sender_fingerprint[..12].to_string(), + text, + is_system: false, + is_self: false, + }); + } + Err(_) => continue, + } + } + Err(_) => continue, + } + } + } +} + +/// Run the TUI event loop. +pub async fn run_tui( + our_fp: String, + peer_fp: Option, + server_url: String, + identity: IdentityKeyPair, + db: LocalDb, +) -> Result<()> { + let mut terminal = ratatui::init(); + let client = ServerClient::new(&server_url); + let db = Arc::new(db); + + let mut app = App::new(our_fp.clone(), peer_fp, server_url); + + // Spawn background poll task + // We need a second IdentityKeyPair for the poll loop — re-derive from seed + let poll_identity = crate::keystore::load_seed()?.derive_identity(); + let poll_messages = app.messages.clone(); + let poll_client = client.clone(); + let poll_db = db.clone(); + let poll_fp = our_fp.clone(); + + tokio::spawn(async move { + poll_loop(poll_messages, poll_fp, poll_identity, poll_db, poll_client).await; + }); + + loop { + terminal.draw(|frame| app.draw(frame))?; + + if event::poll(Duration::from_millis(100))? { + if let Event::Key(key) = event::read()? { + match key.code { + KeyCode::Enter => { + app.handle_send(&identity, &db, &client).await; + } + KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => { + app.should_quit = true; + } + KeyCode::Backspace => { + app.input.pop(); + } + KeyCode::Char(c) => { + app.input.push(c); + } + KeyCode::Esc => { + app.should_quit = true; + } + _ => {} + } + } + } + + if app.should_quit { + break; + } + } + + ratatui::restore(); + Ok(()) +} diff --git a/warzone/crates/warzone-client/src/tui/mod.rs b/warzone/crates/warzone-client/src/tui/mod.rs index b34edf8..de3e58d 100644 --- a/warzone/crates/warzone-client/src/tui/mod.rs +++ b/warzone/crates/warzone-client/src/tui/mod.rs @@ -1,3 +1,3 @@ -// TUI scaffold — ratatui app. -// TODO: implement in Phase 1 step 10. pub mod app; + +pub use app::run_tui;