use std::sync::{Arc, Mutex}; use std::time::Duration; use anyhow::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 , /peer @alias, or /g ".into(), is_system: true, is_self: false, }); } messages.lock().unwrap().push(ChatLine { sender: "system".into(), text: "Commands: /alias , /peer , /g , /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("/alias ") { let name = text[7..].trim(); self.register_alias(name, client).await; return; } if text == "/aliases" { self.list_aliases(client).await; return; } if text.starts_with("/peer ") { let raw = text[6..].trim().to_string(); let fp = if raw.starts_with('@') { match self.resolve_alias(&raw[1..], client).await { Some(resolved) => resolved, None => return, } } else { raw }; 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; } if text.starts_with("/gcreate ") { let name = text[9..].trim(); self.group_create(name, client).await; return; } if text.starts_with("/gjoin ") { let name = text[7..].trim(); self.group_join(name, client).await; return; } if text.starts_with("/g ") { let name = text[3..].trim().to_string(); // Auto-join self.group_join(&name, client).await; self.add_message(ChatLine { sender: "system".into(), text: format!("Switched to group #{}", name), is_system: true, is_self: false, }); self.peer_fp = Some(format!("#{}", name)); return; } if text == "/dm" { self.add_message(ChatLine { sender: "system".into(), text: "Switched to DM mode. Use /peer ".into(), is_system: true, is_self: false, }); self.peer_fp = None; return; } if text == "/glist" { self.group_list(client).await; return; } // Send message (group or DM) let peer = match &self.peer_fp { Some(p) if p.starts_with('#') => { // Group mode let group_name = p[1..].to_string(); self.group_send(&group_name, &text, identity, db, client).await; return; } 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, Some(&self.our_fp), &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, }); } } } async fn group_create(&self, name: &str, client: &ServerClient) { let url = format!("{}/v1/groups/create", client.base_url); match client.client.post(&url) .json(&serde_json::json!({"name": name, "creator": normfp(&self.our_fp)})) .send().await { Ok(resp) => { if let Ok(data) = resp.json::().await { if let Some(err) = data.get("error") { self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", err), is_system: true, is_self: false }); } else { self.add_message(ChatLine { sender: "system".into(), text: format!("Group '{}' created", name), is_system: true, is_self: false }); } } } Err(e) => self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false }), } } async fn group_join(&self, name: &str, client: &ServerClient) { let url = format!("{}/v1/groups/{}/join", client.base_url, name); match client.client.post(&url) .json(&serde_json::json!({"fingerprint": normfp(&self.our_fp)})) .send().await { Ok(resp) => { if let Ok(data) = resp.json::().await { if let Some(err) = data.get("error") { self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", err), is_system: true, is_self: false }); } else { let members = data.get("members").and_then(|v| v.as_u64()).unwrap_or(0); self.add_message(ChatLine { sender: "system".into(), text: format!("Joined '{}' ({} members)", name, members), is_system: true, is_self: false }); } } } Err(e) => self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false }), } } async fn group_list(&self, client: &ServerClient) { let url = format!("{}/v1/groups", client.base_url); match client.client.get(&url).send().await { Ok(resp) => { if let Ok(data) = resp.json::().await { if let Some(groups) = data.get("groups").and_then(|v| v.as_array()) { if groups.is_empty() { self.add_message(ChatLine { sender: "system".into(), text: "No groups".into(), is_system: true, is_self: false }); } else { for g in groups { let name = g.get("name").and_then(|v| v.as_str()).unwrap_or("?"); let members = g.get("members").and_then(|v| v.as_u64()).unwrap_or(0); self.add_message(ChatLine { sender: "system".into(), text: format!(" #{} ({} members)", name, members), is_system: true, is_self: false }); } } } } } Err(e) => self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false }), } } async fn group_send( &self, group_name: &str, text: &str, identity: &IdentityKeyPair, db: &LocalDb, client: &ServerClient, ) { // Get members let url = format!("{}/v1/groups/{}", client.base_url, group_name); let group_data = match client.client.get(&url).send().await { Ok(resp) => match resp.json::().await { Ok(d) => d, Err(e) => { self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false }); return; } }, Err(e) => { self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false }); return; } }; let my_fp = normfp(&self.our_fp); let members: Vec = group_data.get("members") .and_then(|v| v.as_array()) .map(|arr| arr.iter().filter_map(|v| v.as_str().map(String::from)).collect()) .unwrap_or_default(); let our_pub = identity.public_identity(); let mut wire_messages: Vec = Vec::new(); for member in &members { if *member == my_fp { continue; } let member_fp = match Fingerprint::from_hex(member) { Ok(fp) => fp, Err(_) => continue, }; let mut ratchet = db.load_session(&member_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(&member_fp, state); WireMessage::Message { sender_fingerprint: our_pub.fingerprint.to_string(), ratchet_message: encrypted, } } Err(_) => continue, } } else { // Need X3DH — fetch bundle let bundle = match client.fetch_bundle(member).await { Ok(b) => b, Err(_) => continue, }; let x3dh_result = match x3dh::initiate(identity, &bundle) { Ok(r) => r, Err(_) => continue, }; 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(&member_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(_) => continue, } }; let encoded = match bincode::serialize(&wire_msg) { Ok(e) => e, Err(_) => continue, }; wire_messages.push(serde_json::json!({ "to": member, "message": encoded, })); } if wire_messages.is_empty() { self.add_message(ChatLine { sender: "system".into(), text: "No members to send to".into(), is_system: true, is_self: false }); return; } let send_url = format!("{}/v1/groups/{}/send", client.base_url, group_name); match client.client.post(&send_url) .json(&serde_json::json!({ "from": my_fp, "messages": wire_messages, })) .send().await { Ok(_) => { self.add_message(ChatLine { sender: format!("{} [#{}]", &self.our_fp[..12], group_name), text: text.to_string(), 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 }); } } } async fn register_alias(&self, name: &str, client: &ServerClient) { let url = format!("{}/v1/alias/register", client.base_url); match client.client.post(&url) .json(&serde_json::json!({"alias": name, "fingerprint": normfp(&self.our_fp)})) .send().await { Ok(resp) => { if let Ok(data) = resp.json::().await { if let Some(err) = data.get("error") { self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", err), is_system: true, is_self: false }); } else { let alias = data.get("alias").and_then(|v| v.as_str()).unwrap_or(name); self.add_message(ChatLine { sender: "system".into(), text: format!("Alias @{} registered", alias), is_system: true, is_self: false }); } } } Err(e) => self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false }), } } async fn resolve_alias(&self, name: &str, client: &ServerClient) -> Option { let url = format!("{}/v1/alias/resolve/{}", client.base_url, name); match client.client.get(&url).send().await { Ok(resp) => { if let Ok(data) = resp.json::().await { if let Some(fp) = data.get("fingerprint").and_then(|v| v.as_str()) { self.add_message(ChatLine { sender: "system".into(), text: format!("@{} → {}", name, fp), is_system: true, is_self: false }); return Some(fp.to_string()); } if let Some(err) = data.get("error") { self.add_message(ChatLine { sender: "system".into(), text: format!("Unknown alias @{}: {}", name, err), is_system: true, is_self: false }); } } None } Err(e) => { self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false }); None } } } async fn list_aliases(&self, client: &ServerClient) { let url = format!("{}/v1/alias/list", client.base_url); match client.client.get(&url).send().await { Ok(resp) => { if let Ok(data) = resp.json::().await { if let Some(aliases) = data.get("aliases").and_then(|v| v.as_array()) { if aliases.is_empty() { self.add_message(ChatLine { sender: "system".into(), text: "No aliases".into(), is_system: true, is_self: false }); } else { for a in aliases { let name = a.get("alias").and_then(|v| v.as_str()).unwrap_or("?"); let fp = a.get("fingerprint").and_then(|v| v.as_str()).unwrap_or("?"); self.add_message(ChatLine { sender: "system".into(), text: format!(" @{} → {}...", name, &fp[..fp.len().min(16)]), is_system: true, is_self: false }); } } } } } Err(e) => self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false }), } } } fn normfp(fp: &str) -> String { fp.chars().filter(|c| c.is_ascii_hexdigit()).collect::().to_lowercase() } /// 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, poll_seed: warzone_protocol::identity::Seed, 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); // Derive a second identity for the poll loop (can't clone IdentityKeyPair) let poll_identity = poll_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(()) }