Storage: - Detects sled lock contention, shows actionable error: "Database locked by another warzone process" with ps command to find the process and rm command to force unlock TUI: - Poll loop no longer calls load_seed() (was re-prompting passphrase) - Seed passed from main.rs to run_tui to poll_loop - Single passphrase prompt per app launch Warnings fixed: - Removed unused `Context` import in tui/app.rs - Added #[allow(dead_code)] on validate_token (used when auth middleware wired) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
798 lines
30 KiB
Rust
798 lines
30 KiB
Rust
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<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 <fp>, /peer @alias, or /g <group>".into(),
|
|
is_system: true,
|
|
is_self: false,
|
|
});
|
|
}
|
|
|
|
messages.lock().unwrap().push(ChatLine {
|
|
sender: "system".into(),
|
|
text: "Commands: /alias <name>, /peer <fp|@alias>, /g <group>, /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("/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 <fp>".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 <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, 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::<serde_json::Value>().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::<serde_json::Value>().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::<serde_json::Value>().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::<serde_json::Value>().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<String> = 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<serde_json::Value> = 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::<serde_json::Value>().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<String> {
|
|
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::<serde_json::Value>().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::<serde_json::Value>().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::<String>().to_lowercase()
|
|
}
|
|
|
|
/// 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,
|
|
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(())
|
|
}
|