TUI chat interface with real-time E2E encrypted messaging
`warzone chat [peer-fp] -s <server>` 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 <fingerprint> — 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) <noreply@anthropic.com>
This commit is contained in:
@@ -49,6 +49,8 @@ enum Commands {
|
|||||||
},
|
},
|
||||||
/// Launch interactive TUI chat
|
/// Launch interactive TUI chat
|
||||||
Chat {
|
Chat {
|
||||||
|
/// Peer fingerprint to chat with (optional, can set with /peer in TUI)
|
||||||
|
peer: Option<String>,
|
||||||
/// Server URL
|
/// Server URL
|
||||||
#[arg(short, long, default_value = "http://localhost:7700")]
|
#[arg(short, long, default_value = "http://localhost:7700")]
|
||||||
server: String,
|
server: String,
|
||||||
@@ -80,8 +82,16 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
Commands::Recv { server } => {
|
Commands::Recv { server } => {
|
||||||
cli::recv::run(&server).await?;
|
cli::recv::run(&server).await?;
|
||||||
}
|
}
|
||||||
Commands::Chat { server } => {
|
Commands::Chat { peer, server } => {
|
||||||
println!("TODO: launch TUI connected to {}", 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?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,2 +1,496 @@
|
|||||||
// TUI App struct and event loop.
|
use std::sync::{Arc, Mutex};
|
||||||
// TODO: implement in Phase 1 step 10.
|
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<Mutex<Vec<ChatLine>>>,
|
||||||
|
pub our_fp: String,
|
||||||
|
pub peer_fp: Option<String>,
|
||||||
|
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<String>, 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 <fingerprint> to start chatting".into(),
|
||||||
|
is_system: true,
|
||||||
|
is_self: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
messages.lock().unwrap().push(ChatLine {
|
||||||
|
sender: "system".into(),
|
||||||
|
text: "Commands: /peer <fp>, /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<ListItem> = 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 <fingerprint>".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<Mutex<Vec<ChatLine>>>,
|
||||||
|
our_fp: String,
|
||||||
|
identity: IdentityKeyPair,
|
||||||
|
db: Arc<LocalDb>,
|
||||||
|
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::<WireMessage>(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<String>,
|
||||||
|
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(())
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
// TUI scaffold — ratatui app.
|
|
||||||
// TODO: implement in Phase 1 step 10.
|
|
||||||
pub mod app;
|
pub mod app;
|
||||||
|
|
||||||
|
pub use app::run_tui;
|
||||||
|
|||||||
Reference in New Issue
Block a user