chore: rename quicproquo → quicprochat in Rust workspace
Rename all crate directories, package names, binary names, proto package/module paths, ALPN strings, env var prefixes, config filenames, mDNS service names, and plugin ABI symbols from quicproquo/qpq to quicprochat/qpc.
This commit is contained in:
807
crates/quicprochat-client/src/client/tui/mod.rs
Normal file
807
crates/quicprochat-client/src/client/tui/mod.rs
Normal file
@@ -0,0 +1,807 @@
|
||||
//! Full-screen Ratatui TUI for quicprochat.
|
||||
//!
|
||||
//! Layout:
|
||||
//! ┌──────────────┬──────────────────────────────────────────┐
|
||||
//! │ Channels │ Messages │
|
||||
//! │ (20%) │ (80%) │
|
||||
//! │ │ │
|
||||
//! │ ├──────────────────────────────────────────┤
|
||||
//! │ │ Input bar │
|
||||
//! └──────────────┴──────────────────────────────────────────┘
|
||||
//!
|
||||
//! Keyboard:
|
||||
//! Enter — send message
|
||||
//! Up / Down — scroll message history
|
||||
//! Tab — next channel
|
||||
//! Shift+Tab — prev channel
|
||||
//! Ctrl+C / q — quit
|
||||
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::Context;
|
||||
use crossterm::{
|
||||
event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyModifiers},
|
||||
execute,
|
||||
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
|
||||
};
|
||||
use ratatui::{
|
||||
backend::CrosstermBackend,
|
||||
layout::{Constraint, Direction, Layout, Rect},
|
||||
style::{Color, Modifier, Style},
|
||||
text::{Line, Span},
|
||||
widgets::{Block, Borders, List, ListItem, ListState, Paragraph, Wrap},
|
||||
Frame, Terminal,
|
||||
};
|
||||
use tokio::sync::mpsc;
|
||||
use tokio::time::interval;
|
||||
|
||||
use crate::{ClientAuth, init_auth};
|
||||
use super::commands::{opaque_login, opaque_register};
|
||||
use super::conversation::{now_ms, ConversationId, StoredMessage};
|
||||
use super::rpc::{
|
||||
connect_node, enqueue, fetch_hybrid_key, fetch_wait, try_hybrid_decrypt, upload_hybrid_key,
|
||||
upload_key_package,
|
||||
};
|
||||
use super::session::SessionState;
|
||||
use super::state::load_or_init_state;
|
||||
use super::token_cache::{load_cached_session, save_cached_session};
|
||||
|
||||
use quicprochat_core::{
|
||||
AppMessage, DiskKeyStore, GroupMember, IdentityKeypair, ReceivedMessage,
|
||||
hybrid_encrypt, parse as parse_app_msg, serialize_chat,
|
||||
};
|
||||
use quicprochat_proto::node_capnp::node_service;
|
||||
|
||||
// ── App events ───────────────────────────────────────────────────────────────
|
||||
|
||||
/// Events sent from background tasks into the main TUI loop.
|
||||
enum TuiEvent {
|
||||
/// A key event from the terminal.
|
||||
Key(event::KeyEvent),
|
||||
/// New messages received from the server (conv_id, sender_short, body).
|
||||
NewMessages(Vec<(ConversationId, String, String)>),
|
||||
/// Tick — redraw periodically even if nothing happened.
|
||||
Tick,
|
||||
}
|
||||
|
||||
// ── Display message ───────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Clone)]
|
||||
struct DisplayMessage {
|
||||
sender: String,
|
||||
body: String,
|
||||
timestamp_ms: u64,
|
||||
is_outgoing: bool,
|
||||
}
|
||||
|
||||
// ── App state ─────────────────────────────────────────────────────────────────
|
||||
|
||||
struct App {
|
||||
/// Channel (conversation) names shown in the sidebar.
|
||||
channel_names: Vec<String>,
|
||||
/// Conversation IDs, parallel to `channel_names`.
|
||||
channel_ids: Vec<ConversationId>,
|
||||
/// Index of the selected channel in the sidebar.
|
||||
selected_channel: usize,
|
||||
/// Messages for the currently active channel.
|
||||
messages: Vec<DisplayMessage>,
|
||||
/// Current input buffer.
|
||||
input: String,
|
||||
/// Scroll offset (0 = bottom).
|
||||
scroll_offset: usize,
|
||||
/// Whether the user has requested quit.
|
||||
should_quit: bool,
|
||||
/// Short identity string for display.
|
||||
identity_short: String,
|
||||
}
|
||||
|
||||
impl App {
|
||||
fn new(session: &SessionState) -> anyhow::Result<Self> {
|
||||
let convs = session.conv_store.list_conversations()?;
|
||||
let channel_names: Vec<String> = convs.iter().map(|c| c.display_name.clone()).collect();
|
||||
let channel_ids: Vec<ConversationId> = convs.iter().map(|c| c.id.clone()).collect();
|
||||
|
||||
Ok(Self {
|
||||
channel_names,
|
||||
channel_ids,
|
||||
selected_channel: 0,
|
||||
messages: Vec::new(),
|
||||
input: String::new(),
|
||||
scroll_offset: 0,
|
||||
should_quit: false,
|
||||
identity_short: session.identity_short(),
|
||||
})
|
||||
}
|
||||
|
||||
fn active_conv_id(&self) -> Option<&ConversationId> {
|
||||
self.channel_ids.get(self.selected_channel)
|
||||
}
|
||||
|
||||
/// Reload messages for the currently selected channel from the session store.
|
||||
fn reload_messages(&mut self, session: &SessionState) -> anyhow::Result<()> {
|
||||
let conv_id = match self.active_conv_id() {
|
||||
Some(id) => id.clone(),
|
||||
None => {
|
||||
self.messages.clear();
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
let stored = session.conv_store.load_recent_messages(&conv_id, 200)?;
|
||||
self.messages = stored
|
||||
.into_iter()
|
||||
.map(|m| {
|
||||
let sender = if m.is_outgoing {
|
||||
format!("me({})", &self.identity_short)
|
||||
} else if let Some(name) = &m.sender_name {
|
||||
name.clone()
|
||||
} else {
|
||||
// Shorten sender key to 8 hex chars.
|
||||
let hex_short = hex::encode(&m.sender_key[..m.sender_key.len().min(4)]);
|
||||
format!("{hex_short}")
|
||||
};
|
||||
DisplayMessage {
|
||||
sender,
|
||||
body: m.body,
|
||||
timestamp_ms: m.timestamp_ms,
|
||||
is_outgoing: m.is_outgoing,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
// Reset scroll to bottom on channel switch.
|
||||
self.scroll_offset = 0;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn select_next_channel(&mut self, session: &SessionState) {
|
||||
if self.channel_names.is_empty() {
|
||||
return;
|
||||
}
|
||||
self.selected_channel = (self.selected_channel + 1) % self.channel_names.len();
|
||||
let _ = self.reload_messages(session);
|
||||
}
|
||||
|
||||
fn select_prev_channel(&mut self, session: &SessionState) {
|
||||
if self.channel_names.is_empty() {
|
||||
return;
|
||||
}
|
||||
if self.selected_channel == 0 {
|
||||
self.selected_channel = self.channel_names.len() - 1;
|
||||
} else {
|
||||
self.selected_channel -= 1;
|
||||
}
|
||||
let _ = self.reload_messages(session);
|
||||
}
|
||||
|
||||
fn scroll_up(&mut self) {
|
||||
self.scroll_offset = self.scroll_offset.saturating_add(1);
|
||||
}
|
||||
|
||||
fn scroll_down(&mut self) {
|
||||
self.scroll_offset = self.scroll_offset.saturating_sub(1);
|
||||
}
|
||||
|
||||
/// Append newly received messages to the in-memory list (no DB reload needed
|
||||
/// since we already have them from the poll task, but we also save them via
|
||||
/// the session so they appear on reload).
|
||||
fn append_messages(&mut self, msgs: Vec<(ConversationId, String, String)>) {
|
||||
let active = self.active_conv_id().cloned();
|
||||
for (conv_id, sender, body) in msgs {
|
||||
if active.as_ref() == Some(&conv_id) {
|
||||
self.messages.push(DisplayMessage {
|
||||
sender,
|
||||
body,
|
||||
timestamp_ms: now_ms(),
|
||||
is_outgoing: false,
|
||||
});
|
||||
// Snap to bottom if user wasn't scrolled.
|
||||
if self.scroll_offset == 0 {
|
||||
// Already at bottom — nothing to do.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Drawing ───────────────────────────────────────────────────────────────────
|
||||
|
||||
fn ui(frame: &mut Frame, app: &App) {
|
||||
let size = frame.area();
|
||||
|
||||
// Top-level split: sidebar | main area.
|
||||
let h_chunks = Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([Constraint::Percentage(20), Constraint::Percentage(80)])
|
||||
.split(size);
|
||||
|
||||
// Main area split: messages | input bar.
|
||||
let v_chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([Constraint::Min(3), Constraint::Length(3)])
|
||||
.split(h_chunks[1]);
|
||||
|
||||
draw_sidebar(frame, app, h_chunks[0]);
|
||||
draw_messages(frame, app, v_chunks[0]);
|
||||
draw_input(frame, app, v_chunks[1]);
|
||||
}
|
||||
|
||||
fn draw_sidebar(frame: &mut Frame, app: &App, area: Rect) {
|
||||
let items: Vec<ListItem> = app
|
||||
.channel_names
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, name)| {
|
||||
let style = if i == app.selected_channel {
|
||||
Style::default()
|
||||
.fg(Color::Cyan)
|
||||
.add_modifier(Modifier::BOLD | Modifier::REVERSED)
|
||||
} else {
|
||||
Style::default().fg(Color::Cyan)
|
||||
};
|
||||
ListItem::new(Line::from(Span::styled(name.clone(), style)))
|
||||
})
|
||||
.collect();
|
||||
|
||||
let block = Block::default()
|
||||
.title(" Channels ")
|
||||
.borders(Borders::ALL)
|
||||
.style(Style::default().fg(Color::DarkGray));
|
||||
|
||||
let mut list_state = ListState::default();
|
||||
if !app.channel_names.is_empty() {
|
||||
list_state.select(Some(app.selected_channel));
|
||||
}
|
||||
|
||||
frame.render_stateful_widget(
|
||||
List::new(items).block(block),
|
||||
area,
|
||||
&mut list_state,
|
||||
);
|
||||
}
|
||||
|
||||
fn draw_messages(frame: &mut Frame, app: &App, area: Rect) {
|
||||
let channel_title = app
|
||||
.channel_names
|
||||
.get(app.selected_channel)
|
||||
.map(|n| format!(" {n} "))
|
||||
.unwrap_or_else(|| " Messages ".to_string());
|
||||
|
||||
let block = Block::default()
|
||||
.title(channel_title)
|
||||
.borders(Borders::ALL)
|
||||
.style(Style::default().fg(Color::DarkGray));
|
||||
|
||||
let inner_height = area.height.saturating_sub(2) as usize;
|
||||
|
||||
// Build lines from messages (newest at bottom).
|
||||
let mut lines: Vec<Line> = app
|
||||
.messages
|
||||
.iter()
|
||||
.map(|m| {
|
||||
let ts = format_timestamp(m.timestamp_ms);
|
||||
let ts_span = Span::styled(ts, Style::default().fg(Color::DarkGray));
|
||||
|
||||
let sender_style = if m.is_outgoing {
|
||||
Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
Style::default().fg(Color::Green).add_modifier(Modifier::BOLD)
|
||||
};
|
||||
let sender_span = Span::styled(format!(" {} ", m.sender), sender_style);
|
||||
let body_span = Span::raw(m.body.clone());
|
||||
|
||||
Line::from(vec![ts_span, sender_span, body_span])
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Apply scroll: scroll_offset=0 means newest at bottom.
|
||||
let total = lines.len();
|
||||
let visible_start = if total > inner_height {
|
||||
let bottom = total - app.scroll_offset.min(total);
|
||||
bottom.saturating_sub(inner_height)
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let visible_end = if total > inner_height {
|
||||
total - app.scroll_offset.min(total)
|
||||
} else {
|
||||
total
|
||||
};
|
||||
let visible_lines: Vec<Line> = lines
|
||||
.drain(visible_start..visible_end.min(lines.len()))
|
||||
.collect();
|
||||
|
||||
let paragraph = Paragraph::new(visible_lines)
|
||||
.block(block)
|
||||
.wrap(Wrap { trim: false });
|
||||
|
||||
frame.render_widget(paragraph, area);
|
||||
}
|
||||
|
||||
fn draw_input(frame: &mut Frame, app: &App, area: Rect) {
|
||||
let block = Block::default()
|
||||
.title(" Input (Enter=send, Tab=switch channel, q/Ctrl+C=quit) ")
|
||||
.borders(Borders::ALL)
|
||||
.style(Style::default().fg(Color::DarkGray));
|
||||
|
||||
let input_text = Paragraph::new(app.input.as_str())
|
||||
.block(block)
|
||||
.style(Style::default().fg(Color::White));
|
||||
|
||||
frame.render_widget(input_text, area);
|
||||
|
||||
// Position cursor at end of input.
|
||||
let cursor_x = area.x + 1 + app.input.len() as u16;
|
||||
let cursor_y = area.y + 1;
|
||||
if cursor_x < area.x + area.width - 1 {
|
||||
frame.set_cursor_position((cursor_x, cursor_y));
|
||||
}
|
||||
}
|
||||
|
||||
fn format_timestamp(ms: u64) -> String {
|
||||
// Simple HH:MM format from epoch ms.
|
||||
let secs = ms / 1000;
|
||||
let hours = (secs / 3600) % 24;
|
||||
let minutes = (secs / 60) % 60;
|
||||
format!("[{:02}:{:02}] ", hours, minutes)
|
||||
}
|
||||
|
||||
// ── Message polling task ──────────────────────────────────────────────────────
|
||||
|
||||
/// Background task that polls the server for new messages and sends them via `tx`.
|
||||
async fn poll_task(
|
||||
mut session: SessionState,
|
||||
client: node_service::Client,
|
||||
tx: mpsc::Sender<TuiEvent>,
|
||||
) {
|
||||
let mut poll_interval = interval(Duration::from_millis(1000));
|
||||
poll_interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
|
||||
|
||||
loop {
|
||||
poll_interval.tick().await;
|
||||
|
||||
let identity_bytes = session.identity_bytes();
|
||||
let payloads = match fetch_wait(&client, &identity_bytes, 0).await {
|
||||
Ok(p) => p,
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
if payloads.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let mut new_msgs: Vec<(ConversationId, String, String)> = Vec::new();
|
||||
let my_key = session.identity_bytes();
|
||||
|
||||
let mut sorted = payloads;
|
||||
sorted.sort_by_key(|(seq, _)| *seq);
|
||||
|
||||
for (_seq, payload) in &sorted {
|
||||
let mls_payload = match try_hybrid_decrypt(session.hybrid_kp.as_ref(), payload) {
|
||||
Ok(b) => b,
|
||||
Err(_) => payload.clone(),
|
||||
};
|
||||
|
||||
let conv_ids: Vec<ConversationId> = session.members.keys().cloned().collect();
|
||||
|
||||
for conv_id in &conv_ids {
|
||||
let member = match session.members.get_mut(conv_id) {
|
||||
Some(m) => m,
|
||||
None => continue,
|
||||
};
|
||||
|
||||
match member.receive_message(&mls_payload) {
|
||||
Ok(ReceivedMessage::Application(plaintext)) => {
|
||||
let (sender_key, app_bytes) = {
|
||||
let after_unpad = quicprochat_core::padding::unpad(&plaintext)
|
||||
.unwrap_or_else(|_| plaintext.clone());
|
||||
|
||||
if quicprochat_core::sealed_sender::is_sealed(&after_unpad) {
|
||||
match quicprochat_core::sealed_sender::unseal(&after_unpad) {
|
||||
Ok((sk, inner)) => (sk.to_vec(), inner),
|
||||
Err(_) => (my_key.clone(), after_unpad),
|
||||
}
|
||||
} else {
|
||||
(my_key.clone(), after_unpad)
|
||||
}
|
||||
};
|
||||
|
||||
let (body, msg_id, msg_type, ref_msg_id) =
|
||||
match parse_app_msg(&app_bytes) {
|
||||
Ok((_, AppMessage::Chat { message_id, body })) => (
|
||||
String::from_utf8_lossy(&body).to_string(),
|
||||
Some(message_id),
|
||||
"chat",
|
||||
None,
|
||||
),
|
||||
Ok((_, AppMessage::Reply { ref_msg_id, body })) => (
|
||||
String::from_utf8_lossy(&body).to_string(),
|
||||
None,
|
||||
"reply",
|
||||
Some(ref_msg_id),
|
||||
),
|
||||
Ok((_, AppMessage::Reaction { ref_msg_id, emoji })) => (
|
||||
String::from_utf8_lossy(&emoji).to_string(),
|
||||
None,
|
||||
"reaction",
|
||||
Some(ref_msg_id),
|
||||
),
|
||||
_ => (
|
||||
String::from_utf8_lossy(&app_bytes).to_string(),
|
||||
None,
|
||||
"chat",
|
||||
None,
|
||||
),
|
||||
};
|
||||
|
||||
let stored = StoredMessage {
|
||||
conversation_id: conv_id.clone(),
|
||||
message_id: msg_id,
|
||||
sender_key: sender_key.clone(),
|
||||
sender_name: None,
|
||||
body: body.clone(),
|
||||
msg_type: msg_type.into(),
|
||||
ref_msg_id,
|
||||
timestamp_ms: now_ms(),
|
||||
is_outgoing: false,
|
||||
};
|
||||
|
||||
if session.conv_store.save_message(&stored).is_ok() {
|
||||
let sender_short = hex::encode(&sender_key[..sender_key.len().min(4)]);
|
||||
new_msgs.push((conv_id.clone(), sender_short, body));
|
||||
}
|
||||
|
||||
let _ = session.conv_store.update_activity(conv_id, now_ms());
|
||||
let _ = session.save_member(conv_id);
|
||||
break;
|
||||
}
|
||||
Ok(ReceivedMessage::StateChanged) | Ok(ReceivedMessage::SelfRemoved) => {
|
||||
let _ = session.save_member(conv_id);
|
||||
break;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !new_msgs.is_empty() {
|
||||
if tx.send(TuiEvent::NewMessages(new_msgs)).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Send message ──────────────────────────────────────────────────────────────
|
||||
|
||||
async fn send_message(
|
||||
session: &mut SessionState,
|
||||
client: &node_service::Client,
|
||||
conv_id: &ConversationId,
|
||||
text: &str,
|
||||
) -> anyhow::Result<()> {
|
||||
let my_key = session.identity_bytes();
|
||||
let identity = Arc::clone(&session.identity);
|
||||
|
||||
let member = session
|
||||
.members
|
||||
.get_mut(conv_id)
|
||||
.context("no GroupMember for this conversation")?;
|
||||
|
||||
// Wrap in structured AppMessage format.
|
||||
let app_payload = serialize_chat(text.as_bytes(), None)
|
||||
.context("serialize app message")?;
|
||||
|
||||
// Metadata protection: seal + pad.
|
||||
let sealed = quicprochat_core::sealed_sender::seal(&identity, &app_payload);
|
||||
let padded = quicprochat_core::padding::pad(&sealed);
|
||||
|
||||
let ct = member.send_message(&padded).context("MLS encrypt")?;
|
||||
|
||||
let recipients: Vec<Vec<u8>> = member
|
||||
.member_identities()
|
||||
.into_iter()
|
||||
.filter(|id| id.as_slice() != my_key.as_slice())
|
||||
.collect();
|
||||
|
||||
for recipient_key in &recipients {
|
||||
let peer_hybrid_pk = fetch_hybrid_key(client, recipient_key).await?;
|
||||
let payload = if let Some(ref pk) = peer_hybrid_pk {
|
||||
hybrid_encrypt(pk, &ct, b"", b"").context("hybrid encrypt")?
|
||||
} else {
|
||||
ct.clone()
|
||||
};
|
||||
enqueue(client, recipient_key, &payload).await?;
|
||||
}
|
||||
|
||||
// Extract message_id from what we just serialized.
|
||||
let msg_id = parse_app_msg(&app_payload)
|
||||
.ok()
|
||||
.and_then(|(_, m)| match m {
|
||||
AppMessage::Chat { message_id, .. } => Some(message_id),
|
||||
_ => None,
|
||||
});
|
||||
|
||||
// Save outgoing message.
|
||||
let stored = StoredMessage {
|
||||
conversation_id: conv_id.clone(),
|
||||
message_id: msg_id,
|
||||
sender_key: my_key,
|
||||
sender_name: Some("you".into()),
|
||||
body: text.to_string(),
|
||||
msg_type: "chat".into(),
|
||||
ref_msg_id: None,
|
||||
timestamp_ms: now_ms(),
|
||||
is_outgoing: true,
|
||||
};
|
||||
session.conv_store.save_message(&stored)?;
|
||||
session.conv_store.update_activity(conv_id, now_ms())?;
|
||||
session.save_member(conv_id)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ── TUI entry point ───────────────────────────────────────────────────────────
|
||||
|
||||
/// Entry point for `qpc tui`. Sets up the terminal, runs the event loop, and
|
||||
/// restores the terminal on exit.
|
||||
pub async fn run_tui(
|
||||
state_path: &Path,
|
||||
server: &str,
|
||||
ca_cert: &Path,
|
||||
server_name: &str,
|
||||
password: Option<&str>,
|
||||
username: Option<&str>,
|
||||
opaque_password: Option<&str>,
|
||||
access_token: &str,
|
||||
device_id: Option<&str>,
|
||||
) -> anyhow::Result<()> {
|
||||
// ── Auth ──────────────────────────────────────────────────────────────────
|
||||
let resolved_token = resolve_tui_access_token(
|
||||
state_path,
|
||||
server,
|
||||
ca_cert,
|
||||
server_name,
|
||||
password,
|
||||
username,
|
||||
opaque_password,
|
||||
access_token,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let token_bytes = hex::decode(&resolved_token)
|
||||
.unwrap_or_else(|_| resolved_token.into_bytes());
|
||||
let auth_ctx = ClientAuth::from_raw(token_bytes, device_id.map(String::from));
|
||||
init_auth(auth_ctx);
|
||||
|
||||
// ── Session + RPC ─────────────────────────────────────────────────────────
|
||||
let mut session = SessionState::load(state_path, password)?;
|
||||
let client = connect_node(server, ca_cert, server_name).await?;
|
||||
|
||||
// Auto-upload KeyPackage.
|
||||
let _ = auto_upload_keys_tui(&session, &client).await;
|
||||
|
||||
// ── Terminal setup ────────────────────────────────────────────────────────
|
||||
enable_raw_mode().context("enable raw mode")?;
|
||||
let mut stdout = std::io::stdout();
|
||||
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)
|
||||
.context("enter alternate screen")?;
|
||||
let backend = CrosstermBackend::new(stdout);
|
||||
let mut terminal = Terminal::new(backend).context("create terminal")?;
|
||||
|
||||
let result = tui_loop(&mut terminal, &mut session, client).await;
|
||||
|
||||
// ── Terminal cleanup (always restore, even on error) ───────────────────
|
||||
disable_raw_mode().ok();
|
||||
execute!(
|
||||
terminal.backend_mut(),
|
||||
LeaveAlternateScreen,
|
||||
DisableMouseCapture
|
||||
)
|
||||
.ok();
|
||||
terminal.show_cursor().ok();
|
||||
|
||||
session.save_all()?;
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
async fn tui_loop(
|
||||
terminal: &mut Terminal<CrosstermBackend<std::io::Stdout>>,
|
||||
session: &mut SessionState,
|
||||
client: node_service::Client,
|
||||
) -> anyhow::Result<()> {
|
||||
let mut app = App::new(session)?;
|
||||
app.reload_messages(session)?;
|
||||
|
||||
let (event_tx, mut event_rx) = mpsc::channel::<TuiEvent>(256);
|
||||
|
||||
// ── Keyboard event task ───────────────────────────────────────────────────
|
||||
let key_tx = event_tx.clone();
|
||||
tokio::task::spawn_local(async move {
|
||||
loop {
|
||||
// crossterm event polling — 50ms timeout so we can tick.
|
||||
match event::poll(Duration::from_millis(50)) {
|
||||
Ok(true) => {
|
||||
if let Ok(Event::Key(key)) = event::read() {
|
||||
if key_tx.send(TuiEvent::Key(key)).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(false) => {
|
||||
// No event — send a tick so the UI redraws.
|
||||
if key_tx.send(TuiEvent::Tick).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// ── Message poll task ─────────────────────────────────────────────────────
|
||||
// Clone session state for the poll task (it needs its own SessionState).
|
||||
let poll_session = SessionState::load(
|
||||
&session.state_path.clone(),
|
||||
session.password.as_ref().map(|p| p.as_str()),
|
||||
)?;
|
||||
let poll_tx = event_tx.clone();
|
||||
tokio::task::spawn_local(poll_task(poll_session, client.clone(), poll_tx));
|
||||
|
||||
// ── Main loop ─────────────────────────────────────────────────────────────
|
||||
loop {
|
||||
terminal.draw(|f| ui(f, &app)).context("draw")?;
|
||||
|
||||
match event_rx.recv().await {
|
||||
None => break,
|
||||
Some(TuiEvent::Tick) => {
|
||||
// Just redraw.
|
||||
}
|
||||
Some(TuiEvent::NewMessages(msgs)) => {
|
||||
app.append_messages(msgs);
|
||||
}
|
||||
Some(TuiEvent::Key(key)) => {
|
||||
match key.code {
|
||||
KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
|
||||
app.should_quit = true;
|
||||
}
|
||||
KeyCode::Char('q') if app.input.is_empty() => {
|
||||
app.should_quit = true;
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
let text = app.input.trim().to_string();
|
||||
if !text.is_empty() {
|
||||
app.input.clear();
|
||||
if let Some(conv_id) = app.active_conv_id().cloned() {
|
||||
match send_message(session, &client, &conv_id, &text).await {
|
||||
Ok(()) => {
|
||||
// Add to in-memory list immediately.
|
||||
app.messages.push(DisplayMessage {
|
||||
sender: format!("me({})", app.identity_short),
|
||||
body: text,
|
||||
timestamp_ms: now_ms(),
|
||||
is_outgoing: true,
|
||||
});
|
||||
}
|
||||
Err(_e) => {
|
||||
// Silently drop — user will see nothing happened.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
KeyCode::Char(c) => {
|
||||
app.input.push(c);
|
||||
}
|
||||
KeyCode::Backspace => {
|
||||
app.input.pop();
|
||||
}
|
||||
KeyCode::Up => {
|
||||
app.scroll_up();
|
||||
}
|
||||
KeyCode::Down => {
|
||||
app.scroll_down();
|
||||
}
|
||||
KeyCode::Tab => {
|
||||
if key.modifiers.contains(KeyModifiers::SHIFT) {
|
||||
app.select_prev_channel(session);
|
||||
} else {
|
||||
app.select_next_channel(session);
|
||||
}
|
||||
app.reload_messages(session)?;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if app.should_quit {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ── Startup helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
async fn auto_upload_keys_tui(
|
||||
session: &SessionState,
|
||||
client: &node_service::Client,
|
||||
) -> anyhow::Result<()> {
|
||||
let ks_path = session.state_path.with_extension("pending.ks");
|
||||
let ks = DiskKeyStore::persistent(&ks_path).unwrap_or_else(|_| DiskKeyStore::ephemeral());
|
||||
let mut member = GroupMember::new_with_state(
|
||||
Arc::clone(&session.identity),
|
||||
ks,
|
||||
None,
|
||||
false,
|
||||
);
|
||||
let kp_bytes = member.generate_key_package().context("generate KeyPackage")?;
|
||||
let id_key = session.identity.public_key_bytes();
|
||||
upload_key_package(client, &id_key, &kp_bytes).await?;
|
||||
if let Some(ref hkp) = session.hybrid_kp {
|
||||
upload_hybrid_key(client, &id_key, &hkp.public_key()).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn resolve_tui_access_token(
|
||||
state_path: &Path,
|
||||
server: &str,
|
||||
ca_cert: &Path,
|
||||
server_name: &str,
|
||||
state_password: Option<&str>,
|
||||
username: Option<&str>,
|
||||
opaque_password: Option<&str>,
|
||||
cli_access_token: &str,
|
||||
) -> anyhow::Result<String> {
|
||||
if !cli_access_token.is_empty() {
|
||||
return Ok(cli_access_token.to_string());
|
||||
}
|
||||
|
||||
if let Some(cached) = load_cached_session(state_path, state_password) {
|
||||
return Ok(cached.token_hex);
|
||||
}
|
||||
|
||||
let username = match username {
|
||||
Some(u) => u.to_string(),
|
||||
None => {
|
||||
use std::io::Write;
|
||||
eprint!("Username: ");
|
||||
std::io::stderr().flush().ok();
|
||||
let mut input = String::new();
|
||||
std::io::stdin()
|
||||
.read_line(&mut input)
|
||||
.context("failed to read username")?;
|
||||
let trimmed = input.trim().to_string();
|
||||
anyhow::ensure!(!trimmed.is_empty(), "username is required");
|
||||
trimmed
|
||||
}
|
||||
};
|
||||
|
||||
let opaque_password = match opaque_password {
|
||||
Some(p) => p.to_string(),
|
||||
None => rpassword::read_password().context("failed to read password")?,
|
||||
};
|
||||
|
||||
let state = load_or_init_state(state_path, state_password)?;
|
||||
let identity = IdentityKeypair::from_seed(state.identity_seed);
|
||||
let identity_key = identity.public_key_bytes().to_vec();
|
||||
|
||||
let node_client = connect_node(server, ca_cert, server_name).await?;
|
||||
|
||||
match opaque_register(&node_client, &username, &opaque_password, Some(&identity_key)).await {
|
||||
Ok(()) | Err(_) => {}
|
||||
}
|
||||
|
||||
let token_bytes = opaque_login(&node_client, &username, &opaque_password, &identity_key)
|
||||
.await
|
||||
.context("OPAQUE login failed")?;
|
||||
let token_hex = hex::encode(&token_bytes);
|
||||
|
||||
save_cached_session(state_path, &username, &token_hex, state_password)?;
|
||||
|
||||
Ok(token_hex)
|
||||
}
|
||||
Reference in New Issue
Block a user