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:
@@ -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
798
warzone/crates/warzone-client/src/tui/commands.rs
Normal file
798
warzone/crates/warzone-client/src/tui/commands.rs
Normal 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() }),
|
||||
}
|
||||
}
|
||||
}
|
||||
377
warzone/crates/warzone-client/src/tui/draw.rs
Normal file
377
warzone/crates/warzone-client/src/tui/draw.rs
Normal 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"
|
||||
);
|
||||
}
|
||||
}
|
||||
292
warzone/crates/warzone-client/src/tui/file_transfer.rs
Normal file
292
warzone/crates/warzone-client/src/tui/file_transfer.rs
Normal 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(),
|
||||
});
|
||||
}
|
||||
}
|
||||
377
warzone/crates/warzone-client/src/tui/input.rs
Normal file
377
warzone/crates/warzone-client/src/tui/input.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
538
warzone/crates/warzone-client/src/tui/network.rs
Normal file
538
warzone/crates/warzone-client/src/tui/network.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
220
warzone/crates/warzone-client/src/tui/types.rs
Normal file
220
warzone/crates/warzone-client/src/tui/types.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user