v0.0.21: TUI overhaul, WZP call infrastructure, security hardening, federation

TUI:
- Split 1,756-line app.rs monolith into 7 modules (types, draw, commands, input, file_transfer, network, mod)
- Message timestamps [HH:MM], scrolling (PageUp/Down/arrows), connection status dot, unread badge
- /help command, terminal bell on incoming DM, /devices + /kick commands
- 44 unit tests (types, input, draw with TestBackend)

Server — WZP Call Infrastructure (FC-2/3/5/6/7/10):
- Call state management (CallState, CallStatus, active_calls, calls + missed_calls sled trees)
- WS call signal awareness (Offer/Answer/Hangup update state, missed call on offline)
- Group call endpoint (POST /groups/:name/call with SHA-256 room ID, fan-out)
- Presence API (GET /presence/:fp, POST /presence/batch)
- Missed call flush on WS reconnect
- WZP relay config + CORS

Server — Security (FC-P1):
- Auth enforcement middleware (AuthFingerprint extractor on 13 write handlers)
- Session auto-recovery (delete corrupted ratchet, show [session reset])
- WS connection cap (5/fingerprint) + global concurrency limit (200)
- Device management (GET /devices, POST /devices/:id/kick, POST /devices/revoke-all)

Server — Federation:
- Two-server federation via JSON config (--federation flag)
- Periodic presence sync (every 5s, full-state, self-healing)
- Message forwarding via HTTP POST with SHA-256(secret||body) auth
- Graceful degradation (peer down = queue locally)
- deliver_or_queue() replaces push-or-queue in ws.rs + messages.rs

Client — Group Messaging:
- SenderKeyDistribution storage + GroupSenderKey decryption in TUI
- sender_keys sled tree in LocalDb

WASM:
- All 8 WireMessage variants handled (no more "unsupported")
- decrypt_group_message() + create_sender_key_from_distribution() exports
- CallSignal parsing with signal_type mapping

Docs:
- ARCHITECTURE.md rewritten with Mermaid diagrams
- README.md created
- TASK_PLAN.md with FC-P{phase}-T{task} naming
- PROGRESS.md updated to v0.0.21

WZP submodule updated to 6f4e8eb (IAX2 trunking, adaptive quality, metrics, all S-tasks done)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Siavash Sameni
2026-03-28 16:45:58 +04:00
parent 4a4fa9fab4
commit 3e0889e5dc
36 changed files with 5237 additions and 2232 deletions

View File

@@ -10,6 +10,7 @@ pub struct LocalDb {
pre_keys: sled::Tree,
contacts: sled::Tree,
history: sled::Tree,
sender_keys: sled::Tree,
_db: sled::Db,
}
@@ -39,11 +40,13 @@ impl LocalDb {
let pre_keys = db.open_tree("pre_keys")?;
let contacts = db.open_tree("contacts")?;
let history = db.open_tree("history")?;
let sender_keys = db.open_tree("sender_keys")?;
Ok(LocalDb {
sessions,
pre_keys,
contacts,
history,
sender_keys,
_db: db,
})
}
@@ -57,6 +60,14 @@ impl LocalDb {
Ok(())
}
/// Delete a ratchet session for a peer (used for session recovery).
pub fn delete_session(&self, peer: &Fingerprint) -> Result<()> {
let key = peer.to_hex();
self.sessions.remove(key.as_bytes())?;
self.sessions.flush()?;
Ok(())
}
/// Load a ratchet session for a peer.
pub fn load_session(&self, peer: &Fingerprint) -> Result<Option<RatchetState>> {
let key = peer.to_hex();
@@ -115,6 +126,39 @@ impl LocalDb {
}
}
// ── Sender Keys ──
/// Save a sender key for a (sender, group) pair.
pub fn save_sender_key(
&self,
sender_fp: &str,
group_name: &str,
key: &warzone_protocol::sender_keys::SenderKey,
) -> Result<()> {
let db_key = format!("sk:{}:{}", sender_fp, group_name);
let data = bincode::serialize(key).context("failed to serialize sender key")?;
self.sender_keys.insert(db_key.as_bytes(), data)?;
self.sender_keys.flush()?;
Ok(())
}
/// Load a sender key for a (sender, group) pair.
pub fn load_sender_key(
&self,
sender_fp: &str,
group_name: &str,
) -> Result<Option<warzone_protocol::sender_keys::SenderKey>> {
let db_key = format!("sk:{}:{}", sender_fp, group_name);
match self.sender_keys.get(db_key.as_bytes())? {
Some(data) => {
let key = bincode::deserialize(&data)
.context("failed to deserialize sender key")?;
Ok(Some(key))
}
None => Ok(None),
}
}
// ── Contacts ──
/// Add or update a contact. Called on send/receive.

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,798 @@
use warzone_protocol::identity::IdentityKeyPair;
use warzone_protocol::message::WireMessage;
use warzone_protocol::ratchet::RatchetState;
use warzone_protocol::types::Fingerprint;
use warzone_protocol::x3dh;
use x25519_dalek::PublicKey;
use crate::net::ServerClient;
use crate::storage::LocalDb;
use chrono::Local;
use super::types::{App, ChatLine, ReceiptStatus, normfp};
impl App {
pub async fn handle_send(
&mut self,
identity: &IdentityKeyPair,
db: &LocalDb,
client: &ServerClient,
) {
let text = self.input.trim().to_string();
self.input.clear();
self.cursor_pos = 0;
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,
message_id: None, timestamp: Local::now(),
});
return;
}
if text == "/help" || text == "/?" {
let help_lines = [
"Commands:",
" /help, /? Show this help",
" /info Show your fingerprint",
" /eth Show Ethereum address",
" /peer <fp>, /p Set DM peer by fingerprint",
" /peer @alias Set DM peer by alias",
" /reply, /r Reply to last DM sender",
" /dm Switch to DM mode (clear peer)",
" /contacts, /c List contacts with message counts",
" /history, /h [fp] Show conversation history",
" /alias <name> Register an alias for yourself",
" /aliases List all registered aliases",
" /unalias Remove your alias",
" /devices List your active device sessions",
" /kick <device_id> Kick a specific device session",
" /g <name> Switch to group (auto-join)",
" /gcreate <name> Create a new group",
" /gjoin <name> Join a group",
" /glist List all groups",
" /gleave Leave current group",
" /gkick <fp> Kick member from group",
" /gmembers List group members",
" /file <path> Send a file (max 10MB)",
" /quit, /q Exit",
"",
"Navigation:",
" PageUp/PageDown Scroll messages",
" Up/Down Scroll by 1 (when input empty)",
" Ctrl+C, Esc Quit",
];
for line in &help_lines {
self.add_message(ChatLine {
sender: "system".into(),
text: line.to_string(),
is_system: true,
is_self: false,
message_id: None, timestamp: Local::now(),
});
}
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 == "/unalias" {
let url = format!("{}/v1/alias/unregister", client.base_url);
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, message_id: None, timestamp: Local::now() });
} else {
self.add_message(ChatLine { sender: "system".into(), text: "Alias removed".into(), is_system: true, is_self: false, message_id: None, timestamp: Local::now() });
}
},
Err(e) => self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false, message_id: None, timestamp: Local::now() }),
}
return;
}
if text == "/contacts" || text == "/c" {
match db.list_contacts() {
Ok(contacts) => {
if contacts.is_empty() {
self.add_message(ChatLine { sender: "system".into(), text: "No contacts yet".into(), is_system: true, is_self: false, message_id: None, timestamp: Local::now() });
} else {
self.add_message(ChatLine { sender: "system".into(), text: format!("Contacts ({}):", contacts.len()), is_system: true, is_self: false, message_id: None, timestamp: Local::now() });
for c in &contacts {
let fp = c.get("fingerprint").and_then(|v| v.as_str()).unwrap_or("?");
let alias = c.get("alias").and_then(|v| v.as_str());
let count = c.get("message_count").and_then(|v| v.as_u64()).unwrap_or(0);
let label = match alias {
Some(a) => format!(" @{} ({}) — {} msgs", a, &fp[..fp.len().min(12)], count),
None => format!(" {}{} msgs", &fp[..fp.len().min(16)], count),
};
self.add_message(ChatLine { sender: "system".into(), text: label, is_system: true, is_self: false, message_id: None, timestamp: Local::now() });
}
}
}
Err(e) => self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false, message_id: None, timestamp: Local::now() }),
}
return;
}
if text.starts_with("/history") || text.starts_with("/h ") {
let peer = if text.starts_with("/h ") { text[3..].trim() } else if text.starts_with("/history ") { text[9..].trim() } else { "" };
let fp = if let Some(ref p) = self.peer_fp { if !p.starts_with('#') { p.as_str() } else { peer } } else { peer };
if fp.is_empty() {
self.add_message(ChatLine { sender: "system".into(), text: "Usage: /history or /h <fingerprint> (or set peer first)".into(), is_system: true, is_self: false, message_id: None, timestamp: Local::now() });
} else {
match db.get_history(fp, 50) {
Ok(msgs) => {
if msgs.is_empty() {
self.add_message(ChatLine { sender: "system".into(), text: "No history with this peer".into(), is_system: true, is_self: false, message_id: None, timestamp: Local::now() });
} else {
self.add_message(ChatLine { sender: "system".into(), text: format!("History ({} messages):", msgs.len()), is_system: true, is_self: false, message_id: None, timestamp: Local::now() });
for m in &msgs {
let sender = m.get("sender").and_then(|v| v.as_str()).unwrap_or("?");
let txt = m.get("text").and_then(|v| v.as_str()).unwrap_or("");
let is_self = m.get("is_self").and_then(|v| v.as_bool()).unwrap_or(false);
self.add_message(ChatLine {
sender: sender[..sender.len().min(12)].to_string(),
text: txt.to_string(),
is_system: false,
is_self,
message_id: None, timestamp: Local::now(),
});
}
}
}
Err(e) => self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false, message_id: None, timestamp: Local::now() }),
}
}
return;
}
if text == "/eth" {
// Show ethereum address from seed
if let Ok(seed) = crate::keystore::load_seed_raw() {
let eth = warzone_protocol::ethereum::derive_eth_identity(&seed);
self.add_message(ChatLine { sender: "system".into(), text: format!("ETH: {}", eth.address.to_checksum()), is_system: true, is_self: false, message_id: None, timestamp: Local::now() });
}
return;
}
if text == "/devices" {
let url = format!("{}/v1/devices", client.base_url);
// Try to get bearer token from a recent auth (for now, make unauthenticated GET)
match client.client.get(&url).send().await {
Ok(resp) => {
if let Ok(data) = resp.json::<serde_json::Value>().await {
if let Some(devices) = data.get("devices").and_then(|v| v.as_array()) {
if devices.is_empty() {
self.add_message(ChatLine { sender: "system".into(), text: "No active devices".into(), is_system: true, is_self: false, message_id: None, timestamp: Local::now() });
} else {
self.add_message(ChatLine { sender: "system".into(), text: format!("Active devices ({}):", devices.len()), is_system: true, is_self: false, message_id: None, timestamp: Local::now() });
for d in devices {
let id = d.get("device_id").and_then(|v| v.as_str()).unwrap_or("?");
let connected = d.get("connected_at").and_then(|v| v.as_i64()).unwrap_or(0);
let when = chrono::DateTime::from_timestamp(connected, 0)
.map(|dt| dt.format("%Y-%m-%d %H:%M").to_string())
.unwrap_or_else(|| "?".to_string());
self.add_message(ChatLine { sender: "system".into(), text: format!(" {} — connected {}", id, when), is_system: true, is_self: false, message_id: None, timestamp: Local::now() });
}
}
} else if let Some(err) = data.get("error") {
self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", err), is_system: true, is_self: false, message_id: None, timestamp: Local::now() });
}
}
}
Err(e) => self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false, message_id: None, timestamp: Local::now() }),
}
return;
}
if text.starts_with("/kick ") {
let device_id = text[6..].trim();
if device_id.is_empty() {
self.add_message(ChatLine { sender: "system".into(), text: "Usage: /kick <device_id>".into(), is_system: true, is_self: false, message_id: None, timestamp: Local::now() });
return;
}
let url = format!("{}/v1/devices/{}/kick", client.base_url, device_id);
match client.client.post(&url).json(&serde_json::json!({})).send().await {
Ok(resp) => {
if let Ok(data) = resp.json::<serde_json::Value>().await {
if let Some(kicked) = data.get("kicked").and_then(|v| v.as_str()) {
self.add_message(ChatLine { sender: "system".into(), text: format!("Device {} kicked", kicked), is_system: true, is_self: false, message_id: None, timestamp: Local::now() });
} else if let Some(err) = data.get("error") {
self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", err), is_system: true, is_self: false, message_id: None, timestamp: Local::now() });
}
}
}
Err(e) => self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false, message_id: None, timestamp: Local::now() }),
}
return;
}
if text == "/r" || text == "/reply" {
let last = self.last_dm_peer.lock().unwrap().clone();
if let Some(ref peer) = last {
self.peer_fp = Some(peer.clone());
self.add_message(ChatLine { sender: "system".into(), text: format!("→ switched to {}", &peer[..peer.len().min(16)]), is_system: true, is_self: false, message_id: None, timestamp: Local::now() });
} else {
self.add_message(ChatLine { sender: "system".into(), text: "No one to reply to".into(), is_system: true, is_self: false, message_id: None, timestamp: Local::now() });
}
return;
}
if text.starts_with("/peer ") || text.starts_with("/p ") {
let text = if text.starts_with("/p ") { format!("/peer {}", &text[3..]) } else { text.clone() };
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,
message_id: None, timestamp: Local::now(),
});
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,
message_id: None, timestamp: Local::now(),
});
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,
message_id: None, timestamp: Local::now(),
});
self.peer_fp = None;
return;
}
if text == "/glist" {
self.group_list(client).await;
return;
}
if text == "/gleave" {
if let Some(ref peer) = self.peer_fp {
if peer.starts_with('#') {
let name = peer[1..].to_string();
self.group_leave(&name, client).await;
self.peer_fp = None;
} else {
self.add_message(ChatLine { sender: "system".into(), text: "Not in a group. Use /g <name> first".into(), is_system: true, is_self: false, message_id: None, timestamp: Local::now() });
}
}
return;
}
if text.starts_with("/gkick ") {
if let Some(ref peer) = self.peer_fp {
if peer.starts_with('#') {
let name = peer[1..].to_string();
let target = text[7..].trim().to_string();
self.group_kick(&name, &target, client).await;
} else {
self.add_message(ChatLine { sender: "system".into(), text: "Not in a group".into(), is_system: true, is_self: false, message_id: None, timestamp: Local::now() });
}
}
return;
}
if text == "/gmembers" {
if let Some(ref peer) = self.peer_fp {
if peer.starts_with('#') {
let name = peer[1..].to_string();
self.group_members(&name, client).await;
} else {
self.add_message(ChatLine { sender: "system".into(), text: "Not in a group".into(), is_system: true, is_self: false, message_id: None, timestamp: Local::now() });
}
}
return;
}
if text.starts_with("/file ") {
let path_str = text[6..].trim();
self.handle_file_send(path_str, identity, db, 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,
message_id: None, timestamp: Local::now(),
});
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,
message_id: None, timestamp: Local::now(),
});
return;
}
};
let msg_id = uuid::Uuid::new_v4().to_string();
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 {
id: msg_id.clone(),
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,
message_id: None, timestamp: Local::now(),
});
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,
message_id: None, timestamp: Local::now(),
});
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,
message_id: None, timestamp: Local::now(),
});
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 {
id: msg_id.clone(),
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,
message_id: None, timestamp: Local::now(),
});
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,
message_id: None, timestamp: Local::now(),
});
return;
}
};
match client.send_message(&peer, Some(&self.our_fp), &encoded).await {
Ok(_) => {
// Track receipt status
self.receipts.lock().unwrap().insert(msg_id.clone(), ReceiptStatus::Sent);
// Store in contacts + history
let _ = db.touch_contact(&peer, None);
let _ = db.store_message(&peer, &self.our_fp, &text, true);
self.add_message(ChatLine {
sender: self.our_fp[..12].to_string(),
text: text.clone(),
is_system: false,
is_self: true,
message_id: Some(msg_id), timestamp: Local::now(),
});
}
Err(e) => {
self.add_message(ChatLine {
sender: "system".into(),
text: format!("Send failed: {}", e),
is_system: true,
is_self: false,
message_id: None, timestamp: Local::now(),
});
}
}
}
pub(crate) 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, message_id: None, timestamp: Local::now() });
} else {
self.add_message(ChatLine { sender: "system".into(), text: format!("Group '{}' created", name), is_system: true, is_self: false, message_id: None, timestamp: Local::now() });
}
}
}
Err(e) => self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false, message_id: None, timestamp: Local::now() }),
}
}
pub(crate) 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, message_id: None, timestamp: Local::now() });
} 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, message_id: None, timestamp: Local::now() });
}
}
}
Err(e) => self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false, message_id: None, timestamp: Local::now() }),
}
}
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, message_id: None, timestamp: Local::now() });
} 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, message_id: None, timestamp: Local::now() });
}
}
}
}
}
Err(e) => self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false, message_id: None, timestamp: Local::now() }),
}
}
async fn group_leave(&self, name: &str, client: &ServerClient) {
let url = format!("{}/v1/groups/{}/leave", 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, message_id: None, timestamp: Local::now() });
} else {
self.add_message(ChatLine { sender: "system".into(), text: format!("Left group '{}'", name), is_system: true, is_self: false, message_id: None, timestamp: Local::now() });
}
}
}
Err(e) => self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false, message_id: None, timestamp: Local::now() }),
}
}
async fn group_kick(&self, name: &str, target: &str, client: &ServerClient) {
let url = format!("{}/v1/groups/{}/kick", client.base_url, name);
match client.client.post(&url)
.json(&serde_json::json!({"fingerprint": normfp(&self.our_fp), "target": target}))
.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, message_id: None, timestamp: Local::now() });
} else {
let kicked = data.get("kicked").and_then(|v| v.as_str()).unwrap_or("?");
self.add_message(ChatLine { sender: "system".into(), text: format!("Kicked {} from '{}'", kicked, name), is_system: true, is_self: false, message_id: None, timestamp: Local::now() });
}
}
}
Err(e) => self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false, message_id: None, timestamp: Local::now() }),
}
}
async fn group_members(&self, name: &str, client: &ServerClient) {
let url = format!("{}/v1/groups/{}/members", 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(members) = data.get("members").and_then(|v| v.as_array()) {
self.add_message(ChatLine { sender: "system".into(), text: format!("Members of #{}:", name), is_system: true, is_self: false, message_id: None, timestamp: Local::now() });
for m in members {
let fp = m.get("fingerprint").and_then(|v| v.as_str()).unwrap_or("?");
let alias = m.get("alias").and_then(|v| v.as_str());
let creator = m.get("is_creator").and_then(|v| v.as_bool()).unwrap_or(false);
let label = match alias {
Some(a) => format!(" @{} ({}{})", a, &fp[..fp.len().min(12)], if creator { "" } else { "" }),
None => format!(" {}...{}", &fp[..fp.len().min(12)], if creator { "" } else { "" }),
};
self.add_message(ChatLine { sender: "system".into(), text: label, is_system: true, is_self: false, message_id: None, timestamp: Local::now() });
}
}
}
}
Err(e) => self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false, message_id: None, timestamp: Local::now() }),
}
}
pub(crate) 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, message_id: None, timestamp: Local::now() }); return; }
},
Err(e) => { self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false, message_id: None, timestamp: Local::now() }); 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 {
id: uuid::Uuid::new_v4().to_string(),
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 {
id: uuid::Uuid::new_v4().to_string(),
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, message_id: None, timestamp: Local::now() });
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,
message_id: None, timestamp: Local::now(),
});
}
Err(e) => {
self.add_message(ChatLine { sender: "system".into(), text: format!("Send failed: {}", e), is_system: true, is_self: false, message_id: None, timestamp: Local::now() });
}
}
}
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, message_id: None, timestamp: Local::now() });
} 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, message_id: None, timestamp: Local::now() });
}
}
}
Err(e) => self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false, message_id: None, timestamp: Local::now() }),
}
}
pub(crate) 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, message_id: None, timestamp: Local::now() });
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, message_id: None, timestamp: Local::now() });
}
}
None
}
Err(e) => {
self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false, message_id: None, timestamp: Local::now() });
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, message_id: None, timestamp: Local::now() });
} 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, message_id: None, timestamp: Local::now() });
}
}
}
}
}
Err(e) => self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false, message_id: None, timestamp: Local::now() }),
}
}
}

View File

@@ -0,0 +1,377 @@
use std::sync::atomic::Ordering;
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 super::types::{App, ReceiptStatus};
impl App {
fn receipt_indicator(&self, message_id: &Option<String>) -> &'static str {
match message_id {
Some(id) => {
let receipts = self.receipts.lock().unwrap();
match receipts.get(id) {
Some(ReceiptStatus::Read) => " \u{2713}\u{2713}", // ✓✓ (read)
Some(ReceiptStatus::Delivered) => " \u{2713}\u{2713}", // ✓✓ (delivered)
Some(ReceiptStatus::Sent) | None => " \u{2713}", // ✓ (sent)
}
}
None => "",
}
}
fn receipt_color(&self, message_id: &Option<String>) -> Color {
match message_id {
Some(id) => {
let receipts = self.receipts.lock().unwrap();
match receipts.get(id) {
Some(ReceiptStatus::Read) => Color::Blue,
Some(ReceiptStatus::Delivered) => Color::White,
Some(ReceiptStatus::Sent) | None => Color::DarkGray,
}
}
None => Color::DarkGray,
}
}
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 is_connected = self.connected.load(Ordering::Relaxed);
let (conn_indicator, conn_color) = if is_connected {
(" \u{25CF}", Color::Green) // ●
} else {
(" \u{25CF}", Color::Red) // ●
};
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(" \u{2192} "),
Span::styled(peer_str, Style::default().fg(Color::Yellow)),
Span::styled(
format!(" [{}]", self.server_url),
Style::default().fg(Color::DarkGray),
),
Span::styled(conn_indicator, Style::default().fg(conn_color)),
]));
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 timestamp = format!("[{}] ", m.timestamp.format("%H:%M"));
let prefix = if m.is_system {
"*** ".to_string()
} else {
format!("{}: ", &m.sender[..m.sender.len().min(12)])
};
let receipt_str = if m.is_self && m.message_id.is_some() {
self.receipt_indicator(&m.message_id)
} else {
""
};
let receipt_color = self.receipt_color(&m.message_id);
ListItem::new(Line::from(vec![
Span::styled(timestamp, Style::default().fg(Color::DarkGray)),
Span::styled(prefix, style.add_modifier(Modifier::BOLD)),
Span::raw(&m.text),
Span::styled(receipt_str, Style::default().fg(receipt_color)),
]))
})
.collect();
// Scroll support: compute the visible window of items
let visible_height = chunks[1].height.saturating_sub(1) as usize; // minus top border
let total = items.len();
let end = total.saturating_sub(self.scroll_offset);
let start = end.saturating_sub(visible_height);
let visible_items = if total == 0 {
vec![]
} else {
items[start..end].to_vec()
};
let messages_widget = List::new(visible_items)
.block(Block::default().borders(Borders::TOP));
frame.render_widget(messages_widget, chunks[1]);
// Input
let input_title = if self.scroll_offset > 0 {
format!(" [{} new \u{2193}] ", self.scroll_offset)
} else {
" message ".to_string()
};
let input_widget = Paragraph::new(self.input.as_str())
.block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::DarkGray))
.title(input_title),
)
.wrap(Wrap { trim: false });
frame.render_widget(input_widget, chunks[2]);
// Cursor
let x = (self.cursor_pos as u16 + 1).min(chunks[2].width - 2);
frame.set_cursor_position((chunks[2].x + x, chunks[2].y + 1));
}
}
#[cfg(test)]
mod tests {
use std::sync::atomic::Ordering;
use chrono::Local;
use ratatui::backend::TestBackend;
use ratatui::Terminal;
use super::super::types::{App, ChatLine};
/// Helper: collect the entire terminal buffer into a single String.
fn full_buffer_text(terminal: &Terminal<TestBackend>) -> String {
let buf = terminal.backend().buffer();
(0..buf.area().height)
.flat_map(|y| {
(0..buf.area().width).map(move |x| {
buf.cell((x, y))
.map(|c| c.symbol().chars().next().unwrap_or(' '))
.unwrap_or(' ')
})
})
.collect()
}
/// Helper: check whether the buffer contains `needle`.
fn buffer_contains(terminal: &Terminal<TestBackend>, needle: &str) -> bool {
full_buffer_text(terminal).contains(needle)
}
/// Helper: collect a single row into a String.
fn row_text(terminal: &Terminal<TestBackend>, row: u16) -> String {
let buf = terminal.backend().buffer();
(0..buf.area().width)
.map(|x| {
buf.cell((x, row))
.map(|c| c.symbol().chars().next().unwrap_or(' '))
.unwrap_or(' ')
})
.collect()
}
fn make_app() -> App {
App::new("aabbcc".into(), Some("ddeeff".into()), "localhost:7700".into())
}
fn make_terminal() -> Terminal<TestBackend> {
let backend = TestBackend::new(80, 24);
Terminal::new(backend).expect("terminal creation should succeed")
}
// ----------------------------------------------------------------
// 1. draw_does_not_panic
// ----------------------------------------------------------------
#[test]
fn draw_does_not_panic() {
let app = make_app();
let mut terminal = make_terminal();
terminal.draw(|f| app.draw(f)).expect("draw should not fail");
}
// ----------------------------------------------------------------
// 2. header_contains_fingerprint
// ----------------------------------------------------------------
#[test]
fn header_contains_fingerprint() {
let app = make_app();
let mut terminal = make_terminal();
terminal.draw(|f| app.draw(f)).unwrap();
let header = row_text(&terminal, 0);
assert!(
header.contains("aabbcc"),
"header should contain our fingerprint 'aabbcc', got: {header}"
);
}
// ----------------------------------------------------------------
// 3. connection_indicator_red_when_disconnected
// ----------------------------------------------------------------
#[test]
fn connection_indicator_red_when_disconnected() {
let app = make_app();
// connected defaults to false
assert!(!app.connected.load(Ordering::Relaxed));
let mut terminal = make_terminal();
terminal.draw(|f| app.draw(f)).unwrap();
let header = row_text(&terminal, 0);
assert!(
header.contains('\u{25CF}'),
"header should contain the dot character when disconnected, got: {header}"
);
}
// ----------------------------------------------------------------
// 4. connection_indicator_green_when_connected
// ----------------------------------------------------------------
#[test]
fn connection_indicator_green_when_connected() {
let app = make_app();
app.connected.store(true, Ordering::Relaxed);
let mut terminal = make_terminal();
terminal.draw(|f| app.draw(f)).unwrap();
let header = row_text(&terminal, 0);
assert!(
header.contains('\u{25CF}'),
"header should contain the dot character when connected, got: {header}"
);
}
// ----------------------------------------------------------------
// 5. timestamp_format_in_messages
// ----------------------------------------------------------------
#[test]
fn timestamp_format_in_messages() {
let app = make_app();
app.add_message(ChatLine {
sender: "alice".into(),
text: "hello world".into(),
is_system: false,
is_self: false,
message_id: None,
timestamp: Local::now(),
});
let mut terminal = make_terminal();
terminal.draw(|f| app.draw(f)).unwrap();
let text = full_buffer_text(&terminal);
// Timestamps are rendered as [HH:MM] — look for the bracket pattern.
assert!(
text.contains('[') && text.contains(']'),
"buffer should contain timestamp brackets, got: {text}"
);
}
// ----------------------------------------------------------------
// 6. scroll_offset_zero_shows_latest_messages
// ----------------------------------------------------------------
#[test]
fn scroll_offset_zero_shows_latest_messages() {
let app = make_app();
for i in 0..30 {
app.add_message(ChatLine {
sender: "bot".into(),
text: format!("msg-{i:03}"),
is_system: false,
is_self: false,
message_id: None,
timestamp: Local::now(),
});
}
// scroll_offset defaults to 0 — pinned to bottom.
let mut terminal = make_terminal();
terminal.draw(|f| app.draw(f)).unwrap();
assert!(
buffer_contains(&terminal, "msg-029"),
"the last message should be visible when scroll_offset is 0"
);
}
// ----------------------------------------------------------------
// 7. scroll_offset_hides_latest_messages
// ----------------------------------------------------------------
#[test]
fn scroll_offset_hides_latest_messages() {
let mut app = make_app();
for i in 0..30 {
app.add_message(ChatLine {
sender: "bot".into(),
text: format!("msg-{i:03}"),
is_system: false,
is_self: false,
message_id: None,
timestamp: Local::now(),
});
}
app.scroll_offset = 10;
let mut terminal = make_terminal();
terminal.draw(|f| app.draw(f)).unwrap();
assert!(
!buffer_contains(&terminal, "msg-029"),
"the last message should NOT be visible when scroll_offset=10"
);
}
// ----------------------------------------------------------------
// 8. unread_badge_shows_when_scrolled
// ----------------------------------------------------------------
#[test]
fn unread_badge_shows_when_scrolled() {
let mut app = make_app();
app.scroll_offset = 5;
let mut terminal = make_terminal();
terminal.draw(|f| app.draw(f)).unwrap();
assert!(
buffer_contains(&terminal, "new"),
"buffer should contain 'new' from the unread badge when scrolled"
);
}
// ----------------------------------------------------------------
// 9. no_unread_badge_at_bottom
// ----------------------------------------------------------------
#[test]
fn no_unread_badge_at_bottom() {
let app = make_app();
// scroll_offset is 0 by default
let mut terminal = make_terminal();
terminal.draw(|f| app.draw(f)).unwrap();
assert!(
buffer_contains(&terminal, "message"),
"buffer should contain the default title 'message' when not scrolled"
);
assert!(
!full_buffer_text(&terminal).contains("new \u{2193}"),
"buffer should NOT contain 'new ↓' when scroll_offset is 0"
);
}
}

View File

@@ -0,0 +1,292 @@
use std::path::PathBuf;
use sha2::{Sha256, Digest};
use warzone_protocol::identity::IdentityKeyPair;
use warzone_protocol::message::WireMessage;
use warzone_protocol::types::Fingerprint;
use crate::net::ServerClient;
use crate::storage::LocalDb;
use chrono::Local;
use super::types::{App, ChatLine, normfp, MAX_FILE_SIZE, CHUNK_SIZE};
impl App {
pub async fn handle_file_send(
&mut self,
path_str: &str,
identity: &IdentityKeyPair,
db: &LocalDb,
client: &ServerClient,
) {
let path = PathBuf::from(path_str);
if !path.exists() {
self.add_message(ChatLine {
sender: "system".into(),
text: format!("File not found: {}", path_str),
is_system: true, is_self: false, message_id: None, timestamp: Local::now(),
});
return;
}
let metadata = match std::fs::metadata(&path) {
Ok(m) => m,
Err(e) => {
self.add_message(ChatLine {
sender: "system".into(),
text: format!("Cannot read file: {}", e),
is_system: true, is_self: false, message_id: None, timestamp: Local::now(),
});
return;
}
};
let file_size = metadata.len();
if file_size > MAX_FILE_SIZE {
self.add_message(ChatLine {
sender: "system".into(),
text: format!("File too large: {} bytes (max {} bytes)", file_size, MAX_FILE_SIZE),
is_system: true, is_self: false, message_id: None, timestamp: Local::now(),
});
return;
}
let file_data = match std::fs::read(&path) {
Ok(d) => d,
Err(e) => {
self.add_message(ChatLine {
sender: "system".into(),
text: format!("Failed to read file: {}", e),
is_system: true, is_self: false, message_id: None, timestamp: Local::now(),
});
return;
}
};
let filename = path.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| "unnamed".to_string());
// SHA-256 of the complete file
let mut hasher = Sha256::new();
hasher.update(&file_data);
let sha256 = format!("{:x}", hasher.finalize());
let file_id = uuid::Uuid::new_v4().to_string();
let total_chunks = ((file_data.len() + CHUNK_SIZE - 1) / CHUNK_SIZE) as u32;
// Resolve peer (or group members)
let peer = match &self.peer_fp {
Some(p) => p.clone(),
None => {
self.add_message(ChatLine {
sender: "system".into(),
text: "Set a peer or group first".into(),
is_system: true, is_self: false, message_id: None, timestamp: Local::now(),
});
return;
}
};
// Group file transfer: send to each member
if peer.starts_with('#') {
let group_name = &peer[1..];
self.add_message(ChatLine {
sender: "system".into(),
text: format!("Sending '{}' to group #{}...", filename, group_name),
is_system: true, is_self: false, message_id: None, timestamp: Local::now(),
});
// 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(_) => return,
},
Err(_) => 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();
for member in &members {
if *member == my_fp { continue; }
// Send file header + chunks to each member via HTTP
let header = WireMessage::FileHeader {
id: file_id.clone(),
sender_fingerprint: self.our_fp.clone(),
filename: filename.clone(),
file_size,
total_chunks,
sha256: sha256.clone(),
};
if let Ok(encoded) = bincode::serialize(&header) {
let _ = client.send_message(member, Some(&self.our_fp), &encoded).await;
}
for i in 0..total_chunks {
let start = i as usize * CHUNK_SIZE;
let end = ((i as usize + 1) * CHUNK_SIZE).min(file_data.len());
let chunk_msg = WireMessage::FileChunk {
id: file_id.clone(),
sender_fingerprint: self.our_fp.clone(),
filename: filename.clone(),
chunk_index: i,
total_chunks,
data: file_data[start..end].to_vec(),
};
if let Ok(encoded) = bincode::serialize(&chunk_msg) {
let _ = client.send_message(member, Some(&self.our_fp), &encoded).await;
}
}
}
self.add_message(ChatLine {
sender: "system".into(),
text: format!("File '{}' sent to group #{}", filename, group_name),
is_system: true, is_self: false, message_id: None, timestamp: Local::now(),
});
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, message_id: None, timestamp: Local::now(),
});
return;
}
};
let our_pub = identity.public_identity();
let our_fp_str = our_pub.fingerprint.to_string();
self.add_message(ChatLine {
sender: "system".into(),
text: format!("Sending file '{}' ({} bytes, {} chunks)...", filename, file_size, total_chunks),
is_system: true, is_self: false, message_id: None, timestamp: Local::now(),
});
// Send FileHeader (unencrypted metadata — the chunks carry ratchet-encrypted data)
let header = WireMessage::FileHeader {
id: file_id.clone(),
sender_fingerprint: our_fp_str.clone(),
filename: filename.clone(),
file_size,
total_chunks,
sha256: sha256.clone(),
};
let encoded_header = match bincode::serialize(&header) {
Ok(e) => e,
Err(e) => {
self.add_message(ChatLine {
sender: "system".into(),
text: format!("Serialize header failed: {}", e),
is_system: true, is_self: false, message_id: None, timestamp: Local::now(),
});
return;
}
};
if let Err(e) = client.send_message(&peer, Some(&self.our_fp), &encoded_header).await {
self.add_message(ChatLine {
sender: "system".into(),
text: format!("Failed to send file header: {}", e),
is_system: true, is_self: false, message_id: None, timestamp: Local::now(),
});
return;
}
// Send each chunk: encrypt chunk data with ratchet, wrap in FileChunk
for i in 0..total_chunks {
let start = i as usize * CHUNK_SIZE;
let end = ((i as usize + 1) * CHUNK_SIZE).min(file_data.len());
let chunk_data = &file_data[start..end];
// Encrypt chunk data with ratchet
let mut ratchet = db.load_session(&peer_fp).ok().flatten();
let encrypted_data = if let Some(ref mut state) = ratchet {
match state.encrypt(chunk_data) {
Ok(encrypted) => {
let _ = db.save_session(&peer_fp, state);
match bincode::serialize(&encrypted) {
Ok(e) => e,
Err(e) => {
self.add_message(ChatLine {
sender: "system".into(),
text: format!("Serialize chunk failed: {}", e),
is_system: true, is_self: false, message_id: None, timestamp: Local::now(),
});
return;
}
}
}
Err(e) => {
self.add_message(ChatLine {
sender: "system".into(),
text: format!("Encrypt chunk {} failed: {}", i, e),
is_system: true, is_self: false, message_id: None, timestamp: Local::now(),
});
return;
}
}
} else {
self.add_message(ChatLine {
sender: "system".into(),
text: "No ratchet session. Send a text message first to establish one.".into(),
is_system: true, is_self: false, message_id: None, timestamp: Local::now(),
});
return;
};
let chunk_msg = WireMessage::FileChunk {
id: file_id.clone(),
sender_fingerprint: our_fp_str.clone(),
filename: filename.clone(),
chunk_index: i,
total_chunks,
data: encrypted_data,
};
let encoded = match bincode::serialize(&chunk_msg) {
Ok(e) => e,
Err(e) => {
self.add_message(ChatLine {
sender: "system".into(),
text: format!("Serialize chunk {} failed: {}", i, e),
is_system: true, is_self: false, message_id: None, timestamp: Local::now(),
});
return;
}
};
if let Err(e) = client.send_message(&peer, Some(&self.our_fp), &encoded).await {
self.add_message(ChatLine {
sender: "system".into(),
text: format!("Failed to send chunk {}/{}: {}", i + 1, total_chunks, e),
is_system: true, is_self: false, message_id: None, timestamp: Local::now(),
});
return;
}
self.add_message(ChatLine {
sender: "system".into(),
text: format!("Sent chunk [{}/{}] of {}", i + 1, total_chunks, filename),
is_system: true, is_self: false, message_id: None, timestamp: Local::now(),
});
}
self.add_message(ChatLine {
sender: self.our_fp[..12.min(self.our_fp.len())].to_string(),
text: format!("Sent file: {} ({} bytes)", filename, file_size),
is_system: false, is_self: true, message_id: None, timestamp: Local::now(),
});
}
}

View File

@@ -0,0 +1,377 @@
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use super::types::App;
impl App {
/// Handle a single key event. Returns true if the event was consumed.
pub fn handle_key_event(&mut self, key: KeyEvent) {
match key.code {
KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
self.should_quit = true;
}
// Alt+Backspace: delete word before cursor
KeyCode::Backspace if key.modifiers.contains(KeyModifiers::ALT) => {
if self.cursor_pos > 0 {
let before = &self.input[..self.cursor_pos];
let new_pos = before.trim_end().rfind(' ').map(|i| i + 1).unwrap_or(0);
self.input.drain(new_pos..self.cursor_pos);
self.cursor_pos = new_pos;
}
}
// Backspace: delete char before cursor
KeyCode::Backspace => {
if self.cursor_pos > 0 {
self.input.remove(self.cursor_pos - 1);
self.cursor_pos -= 1;
}
}
// Delete: delete char at cursor
KeyCode::Delete => {
if self.cursor_pos < self.input.len() {
self.input.remove(self.cursor_pos);
}
}
// Left arrow
KeyCode::Left => {
if key.modifiers.contains(KeyModifiers::ALT) {
// Alt+Left: word left
let before = &self.input[..self.cursor_pos];
self.cursor_pos = before.rfind(' ').unwrap_or(0);
} else if self.cursor_pos > 0 {
self.cursor_pos -= 1;
}
}
// Right arrow
KeyCode::Right => {
if key.modifiers.contains(KeyModifiers::ALT) {
// Alt+Right: word right
let after = &self.input[self.cursor_pos..];
self.cursor_pos += after.find(' ').map(|i| i + 1).unwrap_or(after.len());
} else if self.cursor_pos < self.input.len() {
self.cursor_pos += 1;
}
}
// Home / Ctrl+A
KeyCode::Home => { self.cursor_pos = 0; }
KeyCode::Char('a') if key.modifiers.contains(KeyModifiers::CONTROL) => {
self.cursor_pos = 0;
}
// End: cursor to end of input when typing, snap to bottom when input is empty.
// Ctrl+End always snaps to bottom.
KeyCode::End => {
if key.modifiers.contains(KeyModifiers::CONTROL) {
// Ctrl+End: always snap scroll to bottom
self.scroll_offset = 0;
} else if self.input.is_empty() {
// Plain End with empty input: snap scroll to bottom
self.scroll_offset = 0;
} else {
// Plain End with text: move cursor to end of input
self.cursor_pos = self.input.len();
}
}
KeyCode::Char('e') if key.modifiers.contains(KeyModifiers::CONTROL) => {
self.cursor_pos = self.input.len();
}
// Ctrl+U: clear line
KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => {
self.input.clear();
self.cursor_pos = 0;
}
// Ctrl+K: kill to end of line
KeyCode::Char('k') if key.modifiers.contains(KeyModifiers::CONTROL) => {
self.input.truncate(self.cursor_pos);
}
// Ctrl+W: delete word back
KeyCode::Char('w') if key.modifiers.contains(KeyModifiers::CONTROL) => {
let before = &self.input[..self.cursor_pos];
let new_pos = before.trim_end().rfind(' ').map(|i| i + 1).unwrap_or(0);
self.input.drain(new_pos..self.cursor_pos);
self.cursor_pos = new_pos;
}
// PageUp: scroll up by 10 messages
KeyCode::PageUp => {
let max = self.messages.lock().unwrap().len().saturating_sub(1);
self.scroll_offset = (self.scroll_offset + 10).min(max);
}
// PageDown: scroll down by 10 messages
KeyCode::PageDown => {
self.scroll_offset = self.scroll_offset.saturating_sub(10);
}
// Up arrow: scroll up by 1 (only when input is empty)
KeyCode::Up if self.input.is_empty() => {
let max = self.messages.lock().unwrap().len().saturating_sub(1);
self.scroll_offset = (self.scroll_offset + 1).min(max);
}
// Down arrow: scroll down by 1 (only when input is empty)
KeyCode::Down if self.input.is_empty() => {
self.scroll_offset = self.scroll_offset.saturating_sub(1);
}
// Regular char: insert at cursor
KeyCode::Char(c) => {
self.input.insert(self.cursor_pos, c);
self.cursor_pos += 1;
}
KeyCode::Esc => {
self.should_quit = true;
}
_ => {}
}
}
}
#[cfg(test)]
mod tests {
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use crate::tui::types::App;
/// Helper: create a key event with no modifiers.
fn key(code: KeyCode) -> KeyEvent {
KeyEvent::new(code, KeyModifiers::NONE)
}
/// Helper: create a key event with modifiers.
fn key_mod(code: KeyCode, modifiers: KeyModifiers) -> KeyEvent {
KeyEvent::new(code, modifiers)
}
/// Helper: create a fresh App for testing.
fn app() -> App {
App::new("aabbcc".into(), None, "http://localhost:7700".into())
}
/// Helper: type a string into the app one character at a time.
fn type_str(app: &mut App, s: &str) {
for c in s.chars() {
app.handle_key_event(key(KeyCode::Char(c)));
}
}
// ── Text editing tests ──────────────────────────────────────────
#[test]
fn char_insert() {
let mut app = app();
type_str(&mut app, "abc");
assert_eq!(app.input, "abc");
assert_eq!(app.cursor_pos, 3);
}
#[test]
fn backspace_deletes_char() {
let mut app = app();
type_str(&mut app, "abc");
app.handle_key_event(key(KeyCode::Backspace));
assert_eq!(app.input, "ab");
assert_eq!(app.cursor_pos, 2);
}
#[test]
fn backspace_at_start_does_nothing() {
let mut app = app();
assert!(app.input.is_empty());
assert_eq!(app.cursor_pos, 0);
app.handle_key_event(key(KeyCode::Backspace));
assert!(app.input.is_empty());
assert_eq!(app.cursor_pos, 0);
}
#[test]
fn delete_at_cursor() {
let mut app = app();
type_str(&mut app, "abc");
app.handle_key_event(key(KeyCode::Left));
app.handle_key_event(key(KeyCode::Delete));
assert_eq!(app.input, "ab");
assert_eq!(app.cursor_pos, 2);
}
#[test]
fn ctrl_u_clears_line() {
let mut app = app();
type_str(&mut app, "hello");
app.handle_key_event(key_mod(KeyCode::Char('u'), KeyModifiers::CONTROL));
assert!(app.input.is_empty());
assert_eq!(app.cursor_pos, 0);
}
#[test]
fn ctrl_k_kills_to_end() {
let mut app = app();
type_str(&mut app, "hello");
app.handle_key_event(key(KeyCode::Home));
app.handle_key_event(key(KeyCode::Right));
app.handle_key_event(key(KeyCode::Right));
app.handle_key_event(key_mod(KeyCode::Char('k'), KeyModifiers::CONTROL));
assert_eq!(app.input, "he");
assert_eq!(app.cursor_pos, 2);
}
#[test]
fn ctrl_w_deletes_word() {
let mut app = app();
type_str(&mut app, "hello world");
app.handle_key_event(key_mod(KeyCode::Char('w'), KeyModifiers::CONTROL));
assert_eq!(app.input, "hello ");
assert_eq!(app.cursor_pos, 6);
}
#[test]
fn alt_backspace_deletes_word() {
let mut app = app();
type_str(&mut app, "hello world");
app.handle_key_event(key_mod(KeyCode::Backspace, KeyModifiers::ALT));
assert_eq!(app.input, "hello ");
assert_eq!(app.cursor_pos, 6);
}
// ── Cursor movement tests ───────────────────────────────────────
#[test]
fn left_arrow_moves_cursor() {
let mut app = app();
type_str(&mut app, "abc");
app.handle_key_event(key(KeyCode::Left));
assert_eq!(app.cursor_pos, 2);
}
#[test]
fn right_arrow_moves_cursor() {
let mut app = app();
type_str(&mut app, "abc");
app.handle_key_event(key(KeyCode::Home));
app.handle_key_event(key(KeyCode::Right));
assert_eq!(app.cursor_pos, 1);
}
#[test]
fn home_moves_to_start() {
let mut app = app();
type_str(&mut app, "abc");
app.handle_key_event(key(KeyCode::Home));
assert_eq!(app.cursor_pos, 0);
}
#[test]
fn end_moves_to_end() {
let mut app = app();
type_str(&mut app, "abc");
app.handle_key_event(key(KeyCode::Home));
app.handle_key_event(key(KeyCode::End));
assert_eq!(app.cursor_pos, 3);
}
#[test]
fn ctrl_a_moves_to_start() {
let mut app = app();
type_str(&mut app, "abc");
app.handle_key_event(key_mod(KeyCode::Char('a'), KeyModifiers::CONTROL));
assert_eq!(app.cursor_pos, 0);
}
#[test]
fn ctrl_e_moves_to_end() {
let mut app = app();
type_str(&mut app, "abc");
app.handle_key_event(key(KeyCode::Home));
app.handle_key_event(key_mod(KeyCode::Char('e'), KeyModifiers::CONTROL));
assert_eq!(app.cursor_pos, 3);
}
#[test]
fn left_at_start_does_nothing() {
let mut app = app();
assert_eq!(app.cursor_pos, 0);
app.handle_key_event(key(KeyCode::Left));
assert_eq!(app.cursor_pos, 0);
}
// ── Quit tests ──────────────────────────────────────────────────
#[test]
fn ctrl_c_quits() {
let mut app = app();
app.handle_key_event(key_mod(KeyCode::Char('c'), KeyModifiers::CONTROL));
assert!(app.should_quit);
}
#[test]
fn esc_quits() {
let mut app = app();
app.handle_key_event(key(KeyCode::Esc));
assert!(app.should_quit);
}
// ── Scroll tests ────────────────────────────────────────────────
#[test]
fn page_up_increases_scroll_offset() {
let mut app = app();
// App::new creates 3 system messages, so max = 3 - 1 = 2
let msg_count = app.messages.lock().unwrap().len();
app.handle_key_event(key(KeyCode::PageUp));
// scroll_offset = min(10, msg_count - 1)
let expected = 10usize.min(msg_count.saturating_sub(1));
assert_eq!(app.scroll_offset, expected);
}
#[test]
fn page_down_decreases_scroll_offset() {
let mut app = app();
app.scroll_offset = 15;
app.handle_key_event(key(KeyCode::PageDown));
assert_eq!(app.scroll_offset, 5);
}
#[test]
fn page_down_clamps_to_zero() {
let mut app = app();
app.scroll_offset = 3;
app.handle_key_event(key(KeyCode::PageDown));
assert_eq!(app.scroll_offset, 0);
}
#[test]
fn up_arrow_scrolls_when_input_empty() {
let mut app = app();
assert!(app.input.is_empty());
app.handle_key_event(key(KeyCode::Up));
assert_eq!(app.scroll_offset, 1);
}
#[test]
fn up_arrow_ignored_when_input_not_empty() {
let mut app = app();
type_str(&mut app, "hi");
app.handle_key_event(key(KeyCode::Up));
assert_eq!(app.scroll_offset, 0);
}
#[test]
fn down_arrow_scrolls_when_input_empty() {
let mut app = app();
app.scroll_offset = 5;
assert!(app.input.is_empty());
app.handle_key_event(key(KeyCode::Down));
assert_eq!(app.scroll_offset, 4);
}
#[test]
fn down_arrow_at_zero_stays_zero() {
let mut app = app();
assert!(app.input.is_empty());
assert_eq!(app.scroll_offset, 0);
app.handle_key_event(key(KeyCode::Down));
assert_eq!(app.scroll_offset, 0);
}
#[test]
fn end_snaps_to_bottom_when_input_empty() {
let mut app = app();
app.scroll_offset = 10;
assert!(app.input.is_empty());
app.handle_key_event(key(KeyCode::End));
assert_eq!(app.scroll_offset, 0);
}
}

View File

@@ -1,3 +1,72 @@
pub mod app;
mod types;
mod draw;
mod commands;
mod file_transfer;
mod input;
mod network;
pub use app::run_tui;
pub use types::App;
use std::sync::Arc;
use std::time::Duration;
use anyhow::Result;
use crossterm::event::{self, Event, KeyCode};
use warzone_protocol::identity::{IdentityKeyPair, Seed};
use crate::net::ServerClient;
use crate::storage::LocalDb;
/// Run the TUI event loop.
pub async fn run_tui(
our_fp: String,
peer_fp: Option<String>,
server_url: String,
identity: IdentityKeyPair,
poll_seed: 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_receipts = app.receipts.clone();
let poll_pending_files = app.pending_files.clone();
let poll_last_dm = app.last_dm_peer.clone();
let poll_connected = app.connected.clone();
let poll_client = client.clone();
let poll_db = db.clone();
let poll_fp = our_fp.clone();
tokio::spawn(async move {
network::poll_loop(poll_messages, poll_receipts, poll_pending_files, poll_fp, poll_identity, poll_db, poll_client, poll_last_dm, poll_connected).await;
});
loop {
terminal.draw(|frame| app.draw(frame))?;
if event::poll(Duration::from_millis(100))? {
if let Event::Key(key) = event::read()? {
if key.code == KeyCode::Enter {
app.handle_send(&identity, &db, &client).await;
app.scroll_offset = 0;
} else {
app.handle_key_event(key);
}
}
}
if app.should_quit {
break;
}
}
ratatui::restore();
Ok(())
}

View File

@@ -0,0 +1,538 @@
use std::collections::HashMap;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use sha2::{Sha256, Digest};
use warzone_protocol::identity::IdentityKeyPair;
use warzone_protocol::message::{ReceiptType, WireMessage};
use warzone_protocol::ratchet::RatchetState;
use warzone_protocol::types::Fingerprint;
use warzone_protocol::x3dh;
use x25519_dalek::PublicKey;
use crate::net::ServerClient;
use crate::storage::LocalDb;
use chrono::Local;
use super::types::{ChatLine, PendingFileTransfer, ReceiptStatus, normfp};
/// Send a delivery receipt for a message back to its sender.
fn send_receipt(
our_fp: &str,
sender_fp: &str,
message_id: &str,
receipt_type: ReceiptType,
client: &ServerClient,
) {
let receipt = WireMessage::Receipt {
sender_fingerprint: our_fp.to_string(),
message_id: message_id.to_string(),
receipt_type,
};
let encoded = match bincode::serialize(&receipt) {
Ok(e) => e,
Err(_) => return,
};
let client = client.clone();
let to = sender_fp.to_string();
let from = our_fp.to_string();
tokio::spawn(async move {
let _ = client.send_message(&to, Some(&from), &encoded).await;
});
}
fn store_received(db: &LocalDb, sender_fp: &str, text: &str) {
let _ = db.touch_contact(sender_fp, None);
let _ = db.store_message(sender_fp, sender_fp, text, false);
}
/// Process a single incoming raw message (shared by WS and HTTP paths).
pub fn process_incoming(
raw: &[u8],
identity: &IdentityKeyPair,
db: &LocalDb,
messages: &Arc<Mutex<Vec<ChatLine>>>,
receipts: &Arc<Mutex<HashMap<String, ReceiptStatus>>>,
pending_files: &Arc<Mutex<HashMap<String, PendingFileTransfer>>>,
our_fp: &str,
client: &ServerClient,
last_dm_peer: &Arc<Mutex<Option<String>>>,
) {
match bincode::deserialize::<WireMessage>(raw) {
Ok(wire) => process_wire_message(wire, identity, db, messages, receipts, pending_files, our_fp, client, last_dm_peer),
Err(_) => {}
}
}
fn process_wire_message(
wire: WireMessage,
identity: &IdentityKeyPair,
db: &LocalDb,
messages: &Arc<Mutex<Vec<ChatLine>>>,
receipts: &Arc<Mutex<HashMap<String, ReceiptStatus>>>,
pending_files: &Arc<Mutex<HashMap<String, PendingFileTransfer>>>,
our_fp: &str,
client: &ServerClient,
last_dm_peer: &Arc<Mutex<Option<String>>>,
) {
match wire {
WireMessage::KeyExchange {
id,
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(_) => return,
};
let spk_secret = match db.load_signed_pre_key(1) {
Ok(Some(s)) => s,
_ => return,
};
let otpk_secret = if let Some(otpk_id) = used_one_time_pre_key_id {
db.take_one_time_pre_key(otpk_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(_) => return,
};
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);
*last_dm_peer.lock().unwrap() = Some(sender_fingerprint.clone());
store_received(db, &sender_fingerprint, &text);
messages.lock().unwrap().push(ChatLine {
sender: sender_fingerprint[..sender_fingerprint.len().min(12)].to_string(),
text,
is_system: false,
is_self: false,
message_id: None, timestamp: Local::now(),
});
send_receipt(our_fp, &sender_fingerprint, &id, ReceiptType::Delivered, client);
// Terminal bell for incoming DM
print!("\x07");
}
Err(e) => {
// Session auto-recovery: delete corrupted session, show warning
let _ = db.delete_session(&sender_fp);
messages.lock().unwrap().push(ChatLine {
sender: "system".into(),
text: format!(
"[session reset] Decryption failed for {}. Session cleared — next message will re-establish.",
&sender_fingerprint[..sender_fingerprint.len().min(12)]
),
is_system: true,
is_self: false,
message_id: None, timestamp: Local::now(),
});
tracing::warn!("Session auto-recovery: cleared session for {} after decrypt error: {}", sender_fingerprint, e);
}
}
}
WireMessage::Message {
id,
sender_fingerprint,
ratchet_message,
} => {
let sender_fp = match Fingerprint::from_hex(&sender_fingerprint) {
Ok(fp) => fp,
Err(_) => return,
};
let mut state = match db.load_session(&sender_fp) {
Ok(Some(s)) => s,
_ => return,
};
match state.decrypt(&ratchet_message) {
Ok(plaintext) => {
let text = String::from_utf8_lossy(&plaintext).to_string();
let _ = db.save_session(&sender_fp, &state);
*last_dm_peer.lock().unwrap() = Some(sender_fingerprint.clone());
store_received(db, &sender_fingerprint, &text);
messages.lock().unwrap().push(ChatLine {
sender: sender_fingerprint[..sender_fingerprint.len().min(12)].to_string(),
text,
is_system: false,
is_self: false,
message_id: None, timestamp: Local::now(),
});
send_receipt(our_fp, &sender_fingerprint, &id, ReceiptType::Delivered, client);
// Terminal bell for incoming DM
print!("\x07");
}
Err(e) => {
// Session auto-recovery: delete corrupted session, show warning
let _ = db.delete_session(&sender_fp);
messages.lock().unwrap().push(ChatLine {
sender: "system".into(),
text: format!(
"[session reset] Decryption failed for {}. Session cleared — next message will re-establish.",
&sender_fingerprint[..sender_fingerprint.len().min(12)]
),
is_system: true,
is_self: false,
message_id: None, timestamp: Local::now(),
});
tracing::warn!("Session auto-recovery: cleared session for {} after decrypt error: {}", sender_fingerprint, e);
}
}
}
WireMessage::Receipt {
sender_fingerprint: _,
message_id,
receipt_type,
} => {
// Update receipt status for the referenced message
let mut r = receipts.lock().unwrap();
let current = r.get(&message_id);
let should_update = match (&receipt_type, current) {
(ReceiptType::Read, _) => true,
(ReceiptType::Delivered, Some(ReceiptStatus::Sent)) => true,
(ReceiptType::Delivered, None) => true,
_ => false,
};
if should_update {
let new_status = match receipt_type {
ReceiptType::Delivered => ReceiptStatus::Delivered,
ReceiptType::Read => ReceiptStatus::Read,
};
r.insert(message_id, new_status);
}
}
WireMessage::FileHeader {
id,
sender_fingerprint,
filename,
file_size,
total_chunks,
sha256,
} => {
let short_sender = &sender_fingerprint[..sender_fingerprint.len().min(12)];
messages.lock().unwrap().push(ChatLine {
sender: "system".into(),
text: format!(
"Incoming file '{}' from {} ({} bytes, {} chunks)",
filename, short_sender, file_size, total_chunks
),
is_system: true,
is_self: false,
message_id: None, timestamp: Local::now(),
});
let transfer = PendingFileTransfer {
filename,
total_chunks,
received: 0,
chunks: vec![None; total_chunks as usize],
sha256,
file_size,
};
pending_files.lock().unwrap().insert(id, transfer);
}
WireMessage::FileChunk {
id,
sender_fingerprint,
filename: _,
chunk_index,
total_chunks: _,
data,
} => {
// Decrypt the chunk data using our ratchet session with the sender
let sender_fp = match Fingerprint::from_hex(&sender_fingerprint) {
Ok(fp) => fp,
Err(_) => return,
};
let mut state = match db.load_session(&sender_fp) {
Ok(Some(s)) => s,
_ => return,
};
// The data field is a bincode-serialized RatchetMessage
let ratchet_msg = match bincode::deserialize(&data) {
Ok(m) => m,
Err(_) => return,
};
let plaintext = match state.decrypt(&ratchet_msg) {
Ok(pt) => {
let _ = db.save_session(&sender_fp, &state);
pt
}
Err(_) => return,
};
let mut pf = pending_files.lock().unwrap();
if let Some(transfer) = pf.get_mut(&id) {
if (chunk_index as usize) < transfer.chunks.len() {
if transfer.chunks[chunk_index as usize].is_none() {
transfer.chunks[chunk_index as usize] = Some(plaintext);
transfer.received += 1;
}
messages.lock().unwrap().push(ChatLine {
sender: "system".into(),
text: format!(
"Receiving {} [{}/{}]...",
transfer.filename, transfer.received, transfer.total_chunks
),
is_system: true,
is_self: false,
message_id: None, timestamp: Local::now(),
});
// Check if all chunks received
if transfer.received == transfer.total_chunks {
let mut assembled = Vec::with_capacity(transfer.file_size as usize);
for chunk in &transfer.chunks {
if let Some(data) = chunk {
assembled.extend_from_slice(data);
}
}
// Verify SHA-256
let mut hasher = Sha256::new();
hasher.update(&assembled);
let computed_hash = format!("{:x}", hasher.finalize());
if computed_hash != transfer.sha256 {
messages.lock().unwrap().push(ChatLine {
sender: "system".into(),
text: format!(
"File '{}' integrity check FAILED (hash mismatch)",
transfer.filename
),
is_system: true,
is_self: false,
message_id: None, timestamp: Local::now(),
});
} else {
// Save to data_dir/downloads/
let download_dir = crate::keystore::data_dir().join("downloads");
let _ = std::fs::create_dir_all(&download_dir);
let save_path = download_dir.join(&transfer.filename);
match std::fs::write(&save_path, &assembled) {
Ok(_) => {
messages.lock().unwrap().push(ChatLine {
sender: "system".into(),
text: format!(
"File saved: {}",
save_path.display()
),
is_system: true,
is_self: false,
message_id: None, timestamp: Local::now(),
});
}
Err(e) => {
messages.lock().unwrap().push(ChatLine {
sender: "system".into(),
text: format!("Failed to save file: {}", e),
is_system: true,
is_self: false,
message_id: None, timestamp: Local::now(),
});
}
}
}
// Remove completed transfer
pf.remove(&id);
}
}
} else {
// Received chunk without header — ignore
}
}
WireMessage::GroupSenderKey {
id: _,
sender_fingerprint,
group_name,
generation,
counter,
ciphertext,
} => {
match db.load_sender_key(&sender_fingerprint, &group_name) {
Ok(Some(mut sender_key)) => {
let msg = warzone_protocol::sender_keys::SenderKeyMessage {
sender_fingerprint: sender_fingerprint.clone(),
group_name: group_name.clone(),
generation,
counter,
ciphertext,
};
match sender_key.decrypt(&msg) {
Ok(plaintext) => {
let text = String::from_utf8_lossy(&plaintext).to_string();
// Save updated sender key (counter advanced)
let _ = db.save_sender_key(&sender_fingerprint, &group_name, &sender_key);
store_received(db, &sender_fingerprint, &text);
messages.lock().unwrap().push(ChatLine {
sender: format!(
"{} [#{}]",
&sender_fingerprint[..sender_fingerprint.len().min(12)],
group_name
),
text,
is_system: false,
is_self: false,
message_id: None,
timestamp: Local::now(),
});
}
Err(e) => {
messages.lock().unwrap().push(ChatLine {
sender: "system".into(),
text: format!(
"[group #{}] decrypt failed from {}: {}",
group_name,
&sender_fingerprint[..sender_fingerprint.len().min(12)],
e
),
is_system: true,
is_self: false,
message_id: None,
timestamp: Local::now(),
});
}
}
}
_ => {
messages.lock().unwrap().push(ChatLine {
sender: "system".into(),
text: format!(
"[group #{}] no sender key for {} — key distribution needed",
group_name,
&sender_fingerprint[..sender_fingerprint.len().min(12)]
),
is_system: true,
is_self: false,
message_id: None,
timestamp: Local::now(),
});
}
}
}
WireMessage::SenderKeyDistribution {
sender_fingerprint,
group_name,
chain_key,
generation,
} => {
let dist = warzone_protocol::sender_keys::SenderKeyDistribution {
sender_fingerprint: sender_fingerprint.clone(),
group_name: group_name.clone(),
chain_key,
generation,
};
let sender_key = dist.into_sender_key();
let _ = db.save_sender_key(&sender_fingerprint, &group_name, &sender_key);
messages.lock().unwrap().push(ChatLine {
sender: "system".into(),
text: format!(
"Received sender key from {} for #{}",
&sender_fingerprint[..sender_fingerprint.len().min(12)],
group_name
),
is_system: true,
is_self: false,
message_id: None,
timestamp: Local::now(),
});
}
WireMessage::CallSignal {
id: _,
sender_fingerprint,
signal_type,
payload: _,
target: _,
} => {
let type_str = format!("{:?}", signal_type);
messages.lock().unwrap().push(ChatLine {
sender: sender_fingerprint[..sender_fingerprint.len().min(12)].to_string(),
text: format!("\u{1f4de} Call signal: {}", type_str),
is_system: false,
is_self: false,
message_id: None, timestamp: Local::now(),
});
}
}
}
/// Real-time message loop via WebSocket (falls back to HTTP polling).
pub async fn poll_loop(
messages: Arc<Mutex<Vec<ChatLine>>>,
receipts: Arc<Mutex<HashMap<String, ReceiptStatus>>>,
pending_files: Arc<Mutex<HashMap<String, PendingFileTransfer>>>,
our_fp: String,
identity: IdentityKeyPair,
db: Arc<LocalDb>,
client: ServerClient,
last_dm_peer: Arc<Mutex<Option<String>>>,
connected: Arc<AtomicBool>,
) {
let fp = normfp(&our_fp);
// Try WebSocket first
let ws_url = client.base_url
.replace("http://", "ws://")
.replace("https://", "wss://");
let ws_url = format!("{}/v1/ws/{}", ws_url, fp);
loop {
match tokio_tungstenite::connect_async(&ws_url).await {
Ok((ws_stream, _)) => {
connected.store(true, Ordering::Relaxed);
messages.lock().unwrap().push(ChatLine {
sender: "system".into(),
text: "Real-time connection established".into(),
is_system: true,
is_self: false,
message_id: None, timestamp: Local::now(),
});
use futures_util::StreamExt;
let (_, mut read) = ws_stream.split();
while let Some(Ok(msg)) = read.next().await {
if let tokio_tungstenite::tungstenite::Message::Binary(data) = msg {
process_incoming(&data, &identity, &db, &messages, &receipts, &pending_files, &our_fp, &client, &last_dm_peer);
}
}
connected.store(false, Ordering::Relaxed);
messages.lock().unwrap().push(ChatLine {
sender: "system".into(),
text: "Connection lost, reconnecting...".into(),
is_system: true,
is_self: false,
message_id: None, timestamp: Local::now(),
});
tokio::time::sleep(Duration::from_secs(3)).await;
}
Err(_) => {
connected.store(false, Ordering::Relaxed);
// Fallback to HTTP polling
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 {
process_incoming(raw, &identity, &db, &messages, &receipts, &pending_files, &our_fp, &client, &last_dm_peer);
}
}
}
}
}

View File

@@ -0,0 +1,220 @@
use std::collections::HashMap;
use std::sync::atomic::AtomicBool;
use std::sync::{Arc, Mutex};
use chrono::{DateTime, Local};
/// Maximum file size: 10 MB.
pub const MAX_FILE_SIZE: u64 = 10 * 1024 * 1024;
/// Chunk size: 64 KB.
pub const CHUNK_SIZE: usize = 64 * 1024;
/// State for tracking an incoming chunked file transfer.
#[derive(Clone)]
pub struct PendingFileTransfer {
pub filename: String,
pub total_chunks: u32,
pub received: u32,
pub chunks: Vec<Option<Vec<u8>>>,
pub sha256: String,
pub file_size: u64,
}
/// Receipt status for a sent message.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum ReceiptStatus {
Sent,
Delivered,
Read,
}
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,
pub cursor_pos: usize,
pub last_dm_peer: Arc<Mutex<Option<String>>>,
/// Track receipt status for messages we sent, keyed by message ID.
pub receipts: Arc<Mutex<HashMap<String, ReceiptStatus>>>,
/// Pending incoming file transfers, keyed by file ID.
pub pending_files: Arc<Mutex<HashMap<String, PendingFileTransfer>>>,
/// Scroll offset from bottom (0 = pinned to newest).
pub scroll_offset: usize,
/// Whether the WebSocket connection is active.
pub connected: Arc<AtomicBool>,
}
#[derive(Clone)]
pub struct ChatLine {
pub sender: String,
pub text: String,
pub is_system: bool,
pub is_self: bool,
/// Message ID (for sent messages, used to track receipts).
pub message_id: Option<String>,
/// When this message was created/received.
pub timestamp: DateTime<Local>,
}
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,
message_id: None,
timestamp: Local::now(),
}]));
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,
message_id: None,
timestamp: Local::now(),
});
} 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,
message_id: None,
timestamp: Local::now(),
});
}
messages.lock().unwrap().push(ChatLine {
sender: "system".into(),
text: "/alias /peer /g /gleave /gkick /gmembers /file /info /quit".into(),
is_system: true,
is_self: false,
message_id: None,
timestamp: Local::now(),
});
App {
input: String::new(),
messages,
our_fp,
peer_fp,
server_url,
should_quit: false,
last_dm_peer: Arc::new(Mutex::new(None)),
cursor_pos: 0,
receipts: Arc::new(Mutex::new(HashMap::new())),
pending_files: Arc::new(Mutex::new(HashMap::new())),
scroll_offset: 0,
connected: Arc::new(AtomicBool::new(false)),
}
}
pub fn add_message(&self, line: ChatLine) {
self.messages.lock().unwrap().push(line);
}
}
pub fn normfp(fp: &str) -> String {
fp.chars().filter(|c| c.is_ascii_hexdigit()).collect::<String>().to_lowercase()
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::Ordering;
#[test]
fn app_new_initializes_scroll_offset_to_zero() {
let app = App::new("aabbcc".into(), None, "http://localhost:7700".into());
assert_eq!(app.scroll_offset, 0);
}
#[test]
fn app_new_initializes_connected_to_false() {
let app = App::new("aabbcc".into(), None, "http://localhost:7700".into());
assert!(!app.connected.load(Ordering::Relaxed));
}
#[test]
fn app_new_creates_system_messages() {
let app = App::new("aabbcc".into(), None, "http://localhost:7700".into());
let msgs = app.messages.lock().unwrap();
assert!(msgs.len() >= 2);
assert!(msgs[0].is_system);
assert!(msgs[0].text.contains("aabbcc"));
}
#[test]
fn app_new_with_peer_shows_chatting_message() {
let app = App::new("aabbcc".into(), Some("ddeeff".into()), "http://localhost:7700".into());
let msgs = app.messages.lock().unwrap();
let has_chatting = msgs.iter().any(|m| m.text.contains("Chatting with") && m.text.contains("ddeeff"));
assert!(has_chatting);
}
#[test]
fn app_new_without_peer_shows_no_peer_message() {
let app = App::new("aabbcc".into(), None, "http://localhost:7700".into());
let msgs = app.messages.lock().unwrap();
let has_no_peer = msgs.iter().any(|m| m.text.contains("No peer set"));
assert!(has_no_peer);
}
#[test]
fn chatline_has_timestamp() {
let line = ChatLine {
sender: "test".into(),
text: "hello".into(),
is_system: false,
is_self: false,
message_id: None,
timestamp: Local::now(),
};
// Timestamp should be within the last second
let elapsed = Local::now().signed_duration_since(line.timestamp);
assert!(elapsed.num_seconds() < 2);
}
#[test]
fn add_message_appends_to_list() {
let app = App::new("aabbcc".into(), None, "http://localhost:7700".into());
let initial_count = app.messages.lock().unwrap().len();
app.add_message(ChatLine {
sender: "test".into(),
text: "new message".into(),
is_system: false,
is_self: false,
message_id: None,
timestamp: Local::now(),
});
let new_count = app.messages.lock().unwrap().len();
assert_eq!(new_count, initial_count + 1);
}
#[test]
fn normfp_strips_non_hex_and_lowercases() {
assert_eq!(normfp("AA-BB-CC"), "aabbcc");
assert_eq!(normfp("0x1234ABCD"), "01234abcd");
assert_eq!(normfp("hello"), "e"); // only 'e' is hex
assert_eq!(normfp("AABB"), "aabb");
}
#[test]
fn app_new_cursor_pos_zero() {
let app = App::new("aabbcc".into(), None, "http://localhost:7700".into());
assert_eq!(app.cursor_pos, 0);
assert!(app.input.is_empty());
}
#[test]
fn app_new_should_quit_false() {
let app = App::new("aabbcc".into(), None, "http://localhost:7700".into());
assert!(!app.should_quit);
}
}