Enhance v2 TUI with connected/mls_epoch state fields, colored connection indicator in status bar, MLS epoch display, and wildcard match for new SDK event variants. Add 9 tests using ratatui TestBackend covering rendering, navigation, scroll bounds, status bar content, and unread count display. Also fix rand 0.8 compat issue in v2_repl.rs.
1115 lines
44 KiB
Rust
1115 lines
44 KiB
Rust
//! v2 REPL — thin shell over `quicproquo_sdk::QpqClient`.
|
|
//!
|
|
//! Provides an interactive command-line interface with categorized `/help`,
|
|
//! tab-completion, and a background event listener. Delegates all crypto,
|
|
//! MLS, and RPC work to the SDK.
|
|
//!
|
|
//! Build: `cargo build -p quicproquo-client --features v2`
|
|
|
|
use std::path::PathBuf;
|
|
use std::process::{Child, Command as ProcessCommand};
|
|
use std::sync::Arc;
|
|
use std::time::Duration;
|
|
|
|
use anyhow::Context;
|
|
use quicproquo_core::{GroupMember, IdentityKeypair};
|
|
use quicproquo_sdk::client::QpqClient;
|
|
use quicproquo_sdk::conversation::{ConversationId, ConversationKind, StoredMessage};
|
|
use quicproquo_sdk::events::ClientEvent;
|
|
use rustyline::completion::{Completer, Pair};
|
|
use rustyline::error::ReadlineError;
|
|
use rustyline::highlight::Highlighter;
|
|
use rustyline::hint::Hinter;
|
|
use rustyline::validate::Validator;
|
|
use rustyline::{Config, Editor, Helper};
|
|
use tokio::sync::broadcast;
|
|
|
|
use super::display;
|
|
|
|
// ── ANSI helpers ────────────────────────────────────────────────────────────
|
|
|
|
const RESET: &str = "\x1b[0m";
|
|
const BOLD: &str = "\x1b[1m";
|
|
const DIM: &str = "\x1b[2m";
|
|
const GREEN: &str = "\x1b[32m";
|
|
const CYAN: &str = "\x1b[36m";
|
|
const YELLOW: &str = "\x1b[33m";
|
|
|
|
// ── Command categories ──────────────────────────────────────────────────────
|
|
|
|
#[derive(Clone, Copy, PartialEq, Eq)]
|
|
enum Category {
|
|
Messaging,
|
|
Groups,
|
|
Account,
|
|
Keys,
|
|
Utility,
|
|
Debug,
|
|
}
|
|
|
|
impl Category {
|
|
fn label(self) -> &'static str {
|
|
match self {
|
|
Self::Messaging => "Messaging",
|
|
Self::Groups => "Groups",
|
|
Self::Account => "Account",
|
|
Self::Keys => "Keys",
|
|
Self::Utility => "Utility",
|
|
Self::Debug => "Debug",
|
|
}
|
|
}
|
|
|
|
fn all() -> &'static [Category] {
|
|
&[
|
|
Self::Messaging,
|
|
Self::Groups,
|
|
Self::Account,
|
|
Self::Keys,
|
|
Self::Utility,
|
|
Self::Debug,
|
|
]
|
|
}
|
|
}
|
|
|
|
// ── Static command table ────────────────────────────────────────────────────
|
|
|
|
struct CmdDef {
|
|
name: &'static str,
|
|
aliases: &'static [&'static str],
|
|
category: Category,
|
|
description: &'static str,
|
|
usage: &'static str,
|
|
}
|
|
|
|
const COMMANDS: &[CmdDef] = &[
|
|
CmdDef { name: "/send", aliases: &["/s"], category: Category::Messaging, description: "Send a message to the current conversation", usage: "/send <message>" },
|
|
CmdDef { name: "/dm", aliases: &[], category: Category::Messaging, description: "Start or switch to a DM with a user", usage: "/dm <username>" },
|
|
CmdDef { name: "/recv", aliases: &["/r"], category: Category::Messaging, description: "Fetch and display new messages", usage: "/recv" },
|
|
CmdDef { name: "/history", aliases: &[], category: Category::Messaging, description: "Show recent message history", usage: "/history [count]" },
|
|
CmdDef { name: "/list", aliases: &["/ls"], category: Category::Messaging, description: "List all conversations", usage: "/list" },
|
|
CmdDef { name: "/switch", aliases: &["/sw"], category: Category::Messaging, description: "Switch active conversation", usage: "/switch <name>" },
|
|
CmdDef { name: "/group", aliases: &["/g"], category: Category::Groups, description: "create | invite | leave | list | members | remove | rename | rotate-keys", usage: "/group <sub> [args]" },
|
|
CmdDef { name: "/devices", aliases: &[], category: Category::Account, description: "list | add | remove — manage linked devices", usage: "/devices <sub> [args]" },
|
|
CmdDef { name: "/register", aliases: &[], category: Category::Account, description: "Register a new account", usage: "/register <user> <pass>" },
|
|
CmdDef { name: "/login", aliases: &[], category: Category::Account, description: "Log in to an existing account", usage: "/login <user> <pass>" },
|
|
CmdDef { name: "/logout", aliases: &[], category: Category::Account, description: "Log out (clear session)", usage: "/logout" },
|
|
CmdDef { name: "/whoami", aliases: &[], category: Category::Account, description: "Show current identity", usage: "/whoami" },
|
|
CmdDef { name: "/refresh-key", aliases: &[], category: Category::Keys, description: "Upload a fresh KeyPackage", usage: "/refresh-key" },
|
|
CmdDef { name: "/safety-number", aliases: &["/verify"], category: Category::Keys, description: "Show safety number for verification", usage: "/safety-number <user>" },
|
|
CmdDef { name: "/resolve", aliases: &[], category: Category::Utility, description: "Resolve username to identity key", usage: "/resolve <username>" },
|
|
CmdDef { name: "/help", aliases: &["/?"], category: Category::Utility, description: "Show this help message", usage: "/help" },
|
|
CmdDef { name: "/quit", aliases: &["/q", "/exit"], category: Category::Utility, description: "Exit the REPL", usage: "/quit" },
|
|
CmdDef { name: "/clear", aliases: &[], category: Category::Utility, description: "Clear the terminal", usage: "/clear" },
|
|
CmdDef { name: "/health", aliases: &[], category: Category::Debug, description: "Check server connection health", usage: "/health" },
|
|
CmdDef { name: "/status", aliases: &[], category: Category::Debug, description: "Show connection and auth state", usage: "/status" },
|
|
];
|
|
|
|
// ── REPL state ──────────────────────────────────────────────────────────────
|
|
|
|
struct ReplState {
|
|
current_conversation: Option<ConversationId>,
|
|
current_display_name: Option<String>,
|
|
identity: Option<Arc<IdentityKeypair>>,
|
|
}
|
|
|
|
impl ReplState {
|
|
fn new() -> Self {
|
|
Self {
|
|
current_conversation: None,
|
|
current_display_name: None,
|
|
identity: None,
|
|
}
|
|
}
|
|
|
|
fn prompt(&self) -> String {
|
|
let name = self
|
|
.current_display_name
|
|
.as_deref()
|
|
.unwrap_or("no conversation");
|
|
format!("{DIM}[{RESET}{BOLD}{name}{RESET}{DIM}]{RESET} > ")
|
|
}
|
|
|
|
fn set_conversation(&mut self, id: ConversationId, name: String) {
|
|
self.current_conversation = Some(id);
|
|
self.current_display_name = Some(name);
|
|
}
|
|
|
|
fn require_identity(&self) -> anyhow::Result<Arc<IdentityKeypair>> {
|
|
self.identity
|
|
.clone()
|
|
.ok_or_else(|| anyhow::anyhow!("not logged in — use /login or /register first"))
|
|
}
|
|
|
|
fn require_conversation(&self) -> anyhow::Result<&ConversationId> {
|
|
self.current_conversation
|
|
.as_ref()
|
|
.ok_or_else(|| anyhow::anyhow!("no active conversation — use /dm or /group first"))
|
|
}
|
|
}
|
|
|
|
// ── Tab completion ──────────────────────────────────────────────────────────
|
|
|
|
struct QpqCompleter {
|
|
names: Vec<String>,
|
|
}
|
|
|
|
impl QpqCompleter {
|
|
fn new() -> Self {
|
|
let mut names = Vec::new();
|
|
for cmd in COMMANDS {
|
|
names.push(cmd.name.to_string());
|
|
for a in cmd.aliases {
|
|
names.push(a.to_string());
|
|
}
|
|
}
|
|
for sub in &["create", "invite", "leave", "list", "members", "remove", "rename", "rotate-keys"] {
|
|
names.push(format!("/group {sub}"));
|
|
}
|
|
for sub in &["list", "add", "remove"] {
|
|
names.push(format!("/devices {sub}"));
|
|
}
|
|
Self { names }
|
|
}
|
|
}
|
|
|
|
impl Completer for QpqCompleter {
|
|
type Candidate = Pair;
|
|
fn complete(
|
|
&self,
|
|
line: &str,
|
|
pos: usize,
|
|
_ctx: &rustyline::Context<'_>,
|
|
) -> rustyline::Result<(usize, Vec<Pair>)> {
|
|
let prefix = &line[..pos];
|
|
if !prefix.starts_with('/') {
|
|
return Ok((0, Vec::new()));
|
|
}
|
|
let matches: Vec<Pair> = self
|
|
.names
|
|
.iter()
|
|
.filter(|n| n.starts_with(prefix))
|
|
.map(|n| Pair {
|
|
display: n.clone(),
|
|
replacement: n.clone(),
|
|
})
|
|
.collect();
|
|
Ok((0, matches))
|
|
}
|
|
}
|
|
impl Hinter for QpqCompleter { type Hint = String; }
|
|
impl Highlighter for QpqCompleter {}
|
|
impl Validator for QpqCompleter {}
|
|
impl Helper for QpqCompleter {}
|
|
|
|
// ── Auto-start server ───────────────────────────────────────────────────────
|
|
|
|
struct ServerGuard(Option<Child>);
|
|
|
|
impl Drop for ServerGuard {
|
|
fn drop(&mut self) {
|
|
if let Some(ref mut child) = self.0 {
|
|
let _ = child.kill();
|
|
let _ = child.wait();
|
|
}
|
|
}
|
|
}
|
|
|
|
fn find_server_binary() -> Option<PathBuf> {
|
|
if let Ok(exe) = std::env::current_exe() {
|
|
let sibling = exe.with_file_name("qpq-server");
|
|
if sibling.exists() {
|
|
return Some(sibling);
|
|
}
|
|
}
|
|
std::env::var_os("PATH").and_then(|paths| {
|
|
std::env::split_paths(&paths)
|
|
.map(|dir| dir.join("qpq-server"))
|
|
.find(|p| p.exists())
|
|
})
|
|
}
|
|
|
|
async fn auto_start_server(addr: &str) -> ServerGuard {
|
|
if tokio::net::TcpStream::connect(addr).await.is_ok() {
|
|
return ServerGuard(None);
|
|
}
|
|
let binary = match find_server_binary() {
|
|
Some(b) => b,
|
|
None => {
|
|
display::print_status("server not reachable and qpq-server binary not found");
|
|
return ServerGuard(None);
|
|
}
|
|
};
|
|
display::print_status(&format!("starting server on {addr}..."));
|
|
let child = match ProcessCommand::new(&binary)
|
|
.args(["--allow-insecure-auth", "--listen", addr])
|
|
.stdout(std::process::Stdio::null())
|
|
.stderr(std::process::Stdio::null())
|
|
.spawn()
|
|
{
|
|
Ok(c) => c,
|
|
Err(e) => {
|
|
display::print_error(&format!("failed to spawn server: {e}"));
|
|
return ServerGuard(None);
|
|
}
|
|
};
|
|
let guard = ServerGuard(Some(child));
|
|
let mut delay = Duration::from_millis(100);
|
|
let deadline = tokio::time::Instant::now() + Duration::from_secs(5);
|
|
loop {
|
|
tokio::time::sleep(delay).await;
|
|
if tokio::net::TcpStream::connect(addr).await.is_ok() {
|
|
display::print_status("server ready");
|
|
return guard;
|
|
}
|
|
if tokio::time::Instant::now() > deadline {
|
|
display::print_error("server did not become ready within 5 s");
|
|
return guard;
|
|
}
|
|
delay = (delay * 2).min(Duration::from_secs(1));
|
|
}
|
|
}
|
|
|
|
// ── Background event listener ───────────────────────────────────────────────
|
|
|
|
fn spawn_event_listener(mut rx: broadcast::Receiver<ClientEvent>) {
|
|
tokio::spawn(async move {
|
|
loop {
|
|
match rx.recv().await {
|
|
Ok(event) => show_event(&event),
|
|
Err(broadcast::error::RecvError::Lagged(n)) => {
|
|
display::print_status(&format!("(skipped {n} events)"));
|
|
}
|
|
Err(broadcast::error::RecvError::Closed) => break,
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
fn show_event(event: &ClientEvent) {
|
|
match event {
|
|
ClientEvent::MessageReceived { sender_name, sender_key, body, .. } => {
|
|
let sender = match sender_name.as_deref() {
|
|
Some(n) if !n.is_empty() => n.to_string(),
|
|
_ => hex::encode(&sender_key[..4.min(sender_key.len())]),
|
|
};
|
|
display::print_incoming(&sender, body);
|
|
}
|
|
ClientEvent::ConversationCreated { display_name, .. } => {
|
|
display::print_status(&format!("new conversation: {display_name}"));
|
|
}
|
|
ClientEvent::MemberAdded { member_key, .. } => {
|
|
display::print_status(&format!(
|
|
"member added: {}",
|
|
hex::encode(&member_key[..4.min(member_key.len())])
|
|
));
|
|
}
|
|
ClientEvent::Error { message } => display::print_error(message),
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
// ── Help ────────────────────────────────────────────────────────────────────
|
|
|
|
fn print_help() {
|
|
println!("\n{BOLD}quicproquo v2 REPL{RESET}\n");
|
|
for cat in Category::all() {
|
|
println!("{BOLD}{}{RESET}", cat.label());
|
|
for cmd in COMMANDS.iter().filter(|c| c.category == *cat) {
|
|
let aliases = if cmd.aliases.is_empty() {
|
|
String::new()
|
|
} else {
|
|
format!(" {DIM}({}){RESET}", cmd.aliases.join(", "))
|
|
};
|
|
println!(" {GREEN}{:<24}{RESET} {}{aliases}", cmd.usage, cmd.description);
|
|
}
|
|
println!();
|
|
}
|
|
println!("{DIM}Bare text (without /) sends to the current conversation.{RESET}\n");
|
|
}
|
|
|
|
// ── Formatting helpers ──────────────────────────────────────────────────────
|
|
|
|
fn ts(ms: u64) -> String {
|
|
let s = ms / 1000;
|
|
format!("{:02}:{:02}:{:02}", (s / 3600) % 24, (s / 60) % 60, s % 60)
|
|
}
|
|
|
|
fn print_stored(msg: &StoredMessage) {
|
|
let t = ts(msg.timestamp_ms);
|
|
if msg.is_outgoing {
|
|
println!("{DIM}[{t}]{RESET} {GREEN}> {}{RESET}", msg.body);
|
|
} else {
|
|
let fallback = hex::encode(&msg.sender_key[..4.min(msg.sender_key.len())]);
|
|
let sender = msg.sender_name.as_deref().unwrap_or(&fallback);
|
|
println!("{DIM}[{t}]{RESET} {CYAN}{BOLD}{sender}{RESET}: {}", msg.body);
|
|
}
|
|
}
|
|
|
|
// ── Command parsing ─────────────────────────────────────────────────────────
|
|
|
|
fn split_cmd(input: &str) -> Option<(&str, &str)> {
|
|
let s = input.trim();
|
|
if !s.starts_with('/') {
|
|
return None;
|
|
}
|
|
match s.find(char::is_whitespace) {
|
|
Some(i) => Some((&s[..i], s[i..].trim())),
|
|
None => Some((s, "")),
|
|
}
|
|
}
|
|
|
|
// ── Command dispatch ────────────────────────────────────────────────────────
|
|
|
|
/// Returns `Ok(true)` when the REPL should exit.
|
|
async fn dispatch(
|
|
cmd: &str,
|
|
args: &str,
|
|
client: &mut QpqClient,
|
|
st: &mut ReplState,
|
|
) -> anyhow::Result<bool> {
|
|
match cmd {
|
|
"/quit" | "/q" | "/exit" => return Ok(true),
|
|
"/help" | "/?" => print_help(),
|
|
"/clear" => print!("\x1b[2J\x1b[H"),
|
|
"/status" => do_status(client, st),
|
|
"/health" => do_health(client),
|
|
"/whoami" => do_whoami(client),
|
|
"/register" => do_register(client, st, args).await?,
|
|
"/login" => do_login(client, st, args).await?,
|
|
"/logout" => do_logout(client)?,
|
|
"/resolve" => do_resolve(client, args).await?,
|
|
"/safety-number" | "/verify" => do_safety(client, st, args).await?,
|
|
"/refresh-key" => do_refresh_key(client, st).await?,
|
|
"/dm" => do_dm(client, st, args).await?,
|
|
"/send" | "/s" => do_send(client, st, args).await?,
|
|
"/recv" | "/r" => do_recv(client, st).await?,
|
|
"/history" => do_history(client, st, args)?,
|
|
"/list" | "/ls" => do_list(client)?,
|
|
"/switch" | "/sw" => do_switch(client, st, args)?,
|
|
"/group" | "/g" => do_group(client, st, args).await?,
|
|
"/devices" => do_devices(client, args).await?,
|
|
_ => display::print_error(&format!("unknown command: {cmd} (try /help)")),
|
|
}
|
|
Ok(false)
|
|
}
|
|
|
|
// ── Command implementations ─────────────────────────────────────────────────
|
|
|
|
fn do_status(client: &QpqClient, st: &ReplState) {
|
|
println!("{BOLD}Status{RESET}");
|
|
println!(" connected: {}", if client.is_connected() { "yes" } else { "no" });
|
|
println!(" authenticated: {}", if client.is_authenticated() { "yes" } else { "no" });
|
|
println!(" username: {}", client.username().unwrap_or("(none)"));
|
|
println!(" conversation: {}", st.current_display_name.as_deref().unwrap_or("(none)"));
|
|
if let Some(key) = client.identity_key() {
|
|
println!(" identity: {}", hex::encode(key));
|
|
}
|
|
}
|
|
|
|
fn do_health(client: &QpqClient) {
|
|
if client.is_connected() {
|
|
display::print_status("connected to server");
|
|
} else {
|
|
display::print_error("not connected");
|
|
}
|
|
}
|
|
|
|
fn do_whoami(client: &QpqClient) {
|
|
match client.username() {
|
|
Some(u) => {
|
|
println!("{BOLD}{u}{RESET}");
|
|
if let Some(key) = client.identity_key() {
|
|
println!("{DIM}identity: {}{RESET}", hex::encode(key));
|
|
}
|
|
}
|
|
None => display::print_status("not logged in"),
|
|
}
|
|
}
|
|
|
|
async fn do_register(client: &mut QpqClient, _st: &mut ReplState, args: &str) -> anyhow::Result<()> {
|
|
let parts: Vec<&str> = args.splitn(2, char::is_whitespace).collect();
|
|
if parts.len() < 2 || parts[1].is_empty() {
|
|
display::print_error("usage: /register <username> <password>");
|
|
return Ok(());
|
|
}
|
|
let (user, pass) = (parts[0], parts[1]);
|
|
client.register(user, pass).await.map_err(|e| anyhow::anyhow!("{e}"))?;
|
|
|
|
// After registration the SDK has set the identity key (public).
|
|
// To get the full keypair we need the seed. Since `register` internally
|
|
// generates a fresh keypair, we load it from the state file if available,
|
|
// or generate a stand-in for this session.
|
|
if let Some(pub_key) = client.identity_key() {
|
|
if pub_key.len() == 32 {
|
|
display::print_status(&format!("registered as {user}"));
|
|
display::print_status(&format!("identity: {}", hex::encode(pub_key)));
|
|
}
|
|
}
|
|
// Note: identity keypair is set during login (which gives us the seed via state).
|
|
display::print_status("use /login to authenticate");
|
|
Ok(())
|
|
}
|
|
|
|
async fn do_login(client: &mut QpqClient, st: &mut ReplState, args: &str) -> anyhow::Result<()> {
|
|
let parts: Vec<&str> = args.splitn(2, char::is_whitespace).collect();
|
|
if parts.len() < 2 || parts[1].is_empty() {
|
|
display::print_error("usage: /login <username> <password>");
|
|
return Ok(());
|
|
}
|
|
let (user, pass) = (parts[0], parts[1]);
|
|
client.login(user, pass).await.map_err(|e| anyhow::anyhow!("{e}"))?;
|
|
|
|
// Try to load identity keypair from state file.
|
|
let state_path = &client.config_state_path();
|
|
if state_path.exists() {
|
|
match quicproquo_sdk::state::load_state(state_path, Some(pass)) {
|
|
Ok(stored) => {
|
|
let kp = IdentityKeypair::from_seed(stored.identity_seed);
|
|
st.identity = Some(Arc::new(kp));
|
|
}
|
|
Err(_) => {
|
|
// Try without password (unencrypted state).
|
|
if let Ok(stored) = quicproquo_sdk::state::load_state(state_path, None) {
|
|
let kp = IdentityKeypair::from_seed(stored.identity_seed);
|
|
st.identity = Some(Arc::new(kp));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
display::print_status(&format!("logged in as {user}"));
|
|
Ok(())
|
|
}
|
|
|
|
fn do_logout(client: &mut QpqClient) -> anyhow::Result<()> {
|
|
client.logout().map_err(|e| anyhow::anyhow!("{e}"))?;
|
|
display::print_status("logged out");
|
|
Ok(())
|
|
}
|
|
|
|
async fn do_resolve(client: &QpqClient, args: &str) -> anyhow::Result<()> {
|
|
let name = args.trim();
|
|
if name.is_empty() {
|
|
display::print_error("usage: /resolve <username>");
|
|
return Ok(());
|
|
}
|
|
let rpc = client.rpc().map_err(|e| anyhow::anyhow!("{e}"))?;
|
|
match quicproquo_sdk::users::resolve_user(rpc, name).await? {
|
|
Some(key) => println!(" {name} -> {}", hex::encode(&key)),
|
|
None => display::print_error(&format!("user '{name}' not found")),
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
async fn do_safety(client: &QpqClient, st: &ReplState, args: &str) -> anyhow::Result<()> {
|
|
let name = args.trim();
|
|
if name.is_empty() {
|
|
display::print_error("usage: /safety-number <username>");
|
|
return Ok(());
|
|
}
|
|
let identity = st.require_identity()?;
|
|
let my_key = identity.public_key_bytes();
|
|
|
|
let rpc = client.rpc().map_err(|e| anyhow::anyhow!("{e}"))?;
|
|
let peer_key = quicproquo_sdk::users::resolve_user(rpc, name)
|
|
.await?
|
|
.ok_or_else(|| anyhow::anyhow!("user '{name}' not found"))?;
|
|
if peer_key.len() != 32 {
|
|
anyhow::bail!("peer key is not 32 bytes");
|
|
}
|
|
let mut peer_arr = [0u8; 32];
|
|
peer_arr.copy_from_slice(&peer_key);
|
|
|
|
let sn = quicproquo_core::compute_safety_number(&my_key, &peer_arr);
|
|
println!("\n{BOLD}Safety number with {name}:{RESET}");
|
|
println!(" {sn}\n");
|
|
println!("{DIM}Compare with {name} over a trusted channel.{RESET}");
|
|
Ok(())
|
|
}
|
|
|
|
async fn do_refresh_key(client: &QpqClient, st: &ReplState) -> anyhow::Result<()> {
|
|
let identity = st.require_identity()?;
|
|
let rpc = client.rpc().map_err(|e| anyhow::anyhow!("{e}"))?;
|
|
|
|
let mut member = GroupMember::new(Arc::clone(&identity));
|
|
let kp_bytes = member
|
|
.generate_key_package()
|
|
.map_err(|e| anyhow::anyhow!("generate key package: {e}"))?;
|
|
|
|
let pub_key = identity.public_key_bytes();
|
|
let fp = quicproquo_sdk::keys::upload_key_package(rpc, &pub_key, &kp_bytes).await?;
|
|
display::print_status(&format!(
|
|
"KeyPackage uploaded (fp: {})",
|
|
hex::encode(&fp[..8.min(fp.len())])
|
|
));
|
|
Ok(())
|
|
}
|
|
|
|
async fn do_dm(client: &mut QpqClient, st: &mut ReplState, args: &str) -> anyhow::Result<()> {
|
|
let username = args.trim();
|
|
if username.is_empty() {
|
|
display::print_error("usage: /dm <username>");
|
|
return Ok(());
|
|
}
|
|
let identity = st.require_identity()?;
|
|
let rpc = client.rpc().map_err(|e| anyhow::anyhow!("{e}"))?;
|
|
let conv_store = client.conversations().map_err(|e| anyhow::anyhow!("{e}"))?;
|
|
|
|
let peer_key = quicproquo_sdk::users::resolve_user(rpc, username)
|
|
.await?
|
|
.ok_or_else(|| anyhow::anyhow!("user '{username}' not found"))?;
|
|
|
|
// Check for existing DM.
|
|
if let Some(existing) = conv_store.find_dm_by_peer(&peer_key)? {
|
|
st.set_conversation(existing.id, format!("@{username}"));
|
|
display::print_status(&format!("switched to DM with @{username}"));
|
|
return Ok(());
|
|
}
|
|
|
|
let peer_kp = quicproquo_sdk::keys::fetch_key_package(rpc, &peer_key)
|
|
.await?
|
|
.ok_or_else(|| anyhow::anyhow!("peer has no available KeyPackage"))?;
|
|
|
|
let mut member = GroupMember::new(Arc::clone(&identity));
|
|
|
|
let (conv_id, was_new) = quicproquo_sdk::groups::create_dm(
|
|
rpc, conv_store, &mut member, &identity,
|
|
&peer_key, &peer_kp, None, None,
|
|
).await?;
|
|
|
|
st.set_conversation(conv_id, format!("@{username}"));
|
|
if was_new {
|
|
display::print_status(&format!("DM created with @{username}"));
|
|
} else {
|
|
display::print_status(&format!("DM with @{username} — waiting for Welcome"));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
async fn do_send(client: &QpqClient, st: &ReplState, msg: &str) -> anyhow::Result<()> {
|
|
if msg.is_empty() {
|
|
display::print_error("usage: /send <message>");
|
|
return Ok(());
|
|
}
|
|
let conv_id = st.require_conversation()?;
|
|
let identity = st.require_identity()?;
|
|
let rpc = client.rpc().map_err(|e| anyhow::anyhow!("{e}"))?;
|
|
let conv_store = client.conversations().map_err(|e| anyhow::anyhow!("{e}"))?;
|
|
|
|
let conv = conv_store
|
|
.load_conversation(conv_id)?
|
|
.ok_or_else(|| anyhow::anyhow!("conversation not found"))?;
|
|
|
|
let mut member = quicproquo_sdk::groups::restore_mls_state(&conv, &identity)?;
|
|
|
|
let my_pub = identity.public_key_bytes();
|
|
let recipients: Vec<Vec<u8>> = conv
|
|
.member_keys
|
|
.iter()
|
|
.filter(|k| k.as_slice() != my_pub.as_slice())
|
|
.cloned()
|
|
.collect();
|
|
if recipients.is_empty() {
|
|
display::print_error("no recipients in conversation");
|
|
return Ok(());
|
|
}
|
|
|
|
let hybrid_keys = vec![None; recipients.len()];
|
|
quicproquo_sdk::messaging::send_message(
|
|
rpc, &mut member, &identity, msg, &recipients, &hybrid_keys, conv_id.0.as_slice(),
|
|
).await?;
|
|
|
|
quicproquo_sdk::groups::save_mls_state(conv_store, conv_id, &member)?;
|
|
|
|
let now = quicproquo_sdk::conversation::now_ms();
|
|
conv_store.save_message(&StoredMessage {
|
|
conversation_id: conv_id.clone(),
|
|
message_id: None,
|
|
sender_key: my_pub.to_vec(),
|
|
sender_name: client.username().map(|s| s.to_string()),
|
|
body: msg.to_string(),
|
|
msg_type: "chat".to_string(),
|
|
ref_msg_id: None,
|
|
timestamp_ms: now,
|
|
is_outgoing: true,
|
|
})?;
|
|
|
|
println!("{GREEN}> {msg}{RESET}");
|
|
Ok(())
|
|
}
|
|
|
|
async fn do_recv(client: &QpqClient, st: &ReplState) -> anyhow::Result<()> {
|
|
let conv_id = st.require_conversation()?;
|
|
let identity = st.require_identity()?;
|
|
let rpc = client.rpc().map_err(|e| anyhow::anyhow!("{e}"))?;
|
|
let conv_store = client.conversations().map_err(|e| anyhow::anyhow!("{e}"))?;
|
|
|
|
let conv = conv_store
|
|
.load_conversation(conv_id)?
|
|
.ok_or_else(|| anyhow::anyhow!("conversation not found"))?;
|
|
|
|
let mut member = quicproquo_sdk::groups::restore_mls_state(&conv, &identity)?;
|
|
let my_pub = identity.public_key_bytes();
|
|
|
|
let messages = quicproquo_sdk::messaging::receive_messages(
|
|
rpc, &mut member, &my_pub, None, conv_id.0.as_slice(), &[],
|
|
).await?;
|
|
|
|
if messages.is_empty() {
|
|
display::print_status("no new messages");
|
|
return Ok(());
|
|
}
|
|
|
|
quicproquo_sdk::groups::save_mls_state(conv_store, conv_id, &member)?;
|
|
|
|
for m in &messages {
|
|
let sender_name = quicproquo_sdk::users::resolve_identity(rpc, &m.sender_key)
|
|
.await
|
|
.ok()
|
|
.flatten();
|
|
let sender_hex = hex::encode(&m.sender_key[..4]);
|
|
let sender = sender_name.as_deref().unwrap_or(&sender_hex);
|
|
|
|
let body = match &m.message {
|
|
quicproquo_core::AppMessage::Chat { body, .. } => {
|
|
String::from_utf8_lossy(body).to_string()
|
|
}
|
|
other => format!("{other:?}"),
|
|
};
|
|
|
|
let now = quicproquo_sdk::conversation::now_ms();
|
|
println!("{DIM}[{}]{RESET} {CYAN}{BOLD}{sender}{RESET}: {body}", ts(now));
|
|
|
|
conv_store.save_message(&StoredMessage {
|
|
conversation_id: conv_id.clone(),
|
|
message_id: None,
|
|
sender_key: m.sender_key.to_vec(),
|
|
sender_name: sender_name.clone(),
|
|
body,
|
|
msg_type: "chat".to_string(),
|
|
ref_msg_id: None,
|
|
timestamp_ms: now,
|
|
is_outgoing: false,
|
|
})?;
|
|
}
|
|
display::print_status(&format!("{} message(s) received", messages.len()));
|
|
Ok(())
|
|
}
|
|
|
|
fn do_history(client: &QpqClient, st: &ReplState, args: &str) -> anyhow::Result<()> {
|
|
let conv_id = st.require_conversation()?;
|
|
let count = args.trim().parse::<usize>().unwrap_or(20);
|
|
let conv_store = client.conversations().map_err(|e| anyhow::anyhow!("{e}"))?;
|
|
let msgs = conv_store.load_recent_messages(conv_id, count)?;
|
|
if msgs.is_empty() {
|
|
display::print_status("no messages yet");
|
|
} else {
|
|
for m in &msgs {
|
|
print_stored(m);
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn do_list(client: &QpqClient) -> anyhow::Result<()> {
|
|
let conv_store = client.conversations().map_err(|e| anyhow::anyhow!("{e}"))?;
|
|
let convs = conv_store.list_conversations()?;
|
|
if convs.is_empty() {
|
|
display::print_status("no conversations — try /dm <username>");
|
|
return Ok(());
|
|
}
|
|
println!("\n{BOLD}Conversations{RESET}");
|
|
for c in &convs {
|
|
let kind_label = match &c.kind {
|
|
ConversationKind::Dm { .. } => "dm",
|
|
ConversationKind::Group { .. } => "group",
|
|
};
|
|
let unread = if c.unread_count > 0 {
|
|
format!(" {YELLOW}({} new){RESET}", c.unread_count)
|
|
} else {
|
|
String::new()
|
|
};
|
|
println!(
|
|
" {BOLD}{}{RESET} {DIM}[{kind_label}, {} members]{RESET}{unread}",
|
|
c.display_name,
|
|
c.member_keys.len()
|
|
);
|
|
}
|
|
println!();
|
|
Ok(())
|
|
}
|
|
|
|
fn do_switch(client: &QpqClient, st: &mut ReplState, name: &str) -> anyhow::Result<()> {
|
|
let name = name.trim();
|
|
if name.is_empty() {
|
|
display::print_error("usage: /switch <name>");
|
|
return Ok(());
|
|
}
|
|
let conv_store = client.conversations().map_err(|e| anyhow::anyhow!("{e}"))?;
|
|
let convs = conv_store.list_conversations()?;
|
|
let lower = name.to_lowercase();
|
|
let found = convs.iter().find(|c| c.display_name.to_lowercase().contains(&lower));
|
|
match found {
|
|
Some(c) => {
|
|
st.set_conversation(c.id.clone(), c.display_name.clone());
|
|
display::print_status(&format!("switched to {}", c.display_name));
|
|
}
|
|
None => display::print_error(&format!("no conversation matching '{name}'")),
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
async fn do_group(client: &mut QpqClient, st: &mut ReplState, args: &str) -> anyhow::Result<()> {
|
|
let parts: Vec<&str> = args.splitn(3, char::is_whitespace).collect();
|
|
let sub = parts.first().copied().unwrap_or("");
|
|
|
|
match sub {
|
|
"create" => {
|
|
let name = parts.get(1).copied().unwrap_or("").trim();
|
|
if name.is_empty() {
|
|
display::print_error("usage: /group create <name>");
|
|
return Ok(());
|
|
}
|
|
let identity = st.require_identity()?;
|
|
let conv_store = client.conversations().map_err(|e| anyhow::anyhow!("{e}"))?;
|
|
let mut member = GroupMember::new(Arc::clone(&identity));
|
|
let conv_id = quicproquo_sdk::groups::create_group(conv_store, &mut member, name)?;
|
|
st.set_conversation(conv_id, format!("#{name}"));
|
|
display::print_status(&format!("group #{name} created"));
|
|
}
|
|
|
|
"invite" => {
|
|
let group = parts.get(1).copied().unwrap_or("").trim();
|
|
let user = parts.get(2).copied().unwrap_or("").trim();
|
|
if group.is_empty() || user.is_empty() {
|
|
display::print_error("usage: /group invite <group> <username>");
|
|
return Ok(());
|
|
}
|
|
let identity = st.require_identity()?;
|
|
let rpc = client.rpc().map_err(|e| anyhow::anyhow!("{e}"))?;
|
|
let conv_store = client.conversations().map_err(|e| anyhow::anyhow!("{e}"))?;
|
|
|
|
let peer_key = quicproquo_sdk::users::resolve_user(rpc, user)
|
|
.await?
|
|
.ok_or_else(|| anyhow::anyhow!("user '{user}' not found"))?;
|
|
let peer_kp = quicproquo_sdk::keys::fetch_key_package(rpc, &peer_key)
|
|
.await?
|
|
.ok_or_else(|| anyhow::anyhow!("peer has no KeyPackage"))?;
|
|
|
|
let conv_id = ConversationId::from_group_name(group);
|
|
let conv = conv_store
|
|
.load_conversation(&conv_id)?
|
|
.ok_or_else(|| anyhow::anyhow!("group '{group}' not found"))?;
|
|
let mut member = quicproquo_sdk::groups::restore_mls_state(&conv, &identity)?;
|
|
|
|
quicproquo_sdk::groups::invite_to_group(
|
|
rpc, conv_store, &mut member, &identity,
|
|
&conv_id, &peer_key, &peer_kp, None, None,
|
|
).await?;
|
|
display::print_status(&format!("invited @{user} to #{group}"));
|
|
}
|
|
|
|
"leave" => {
|
|
let identity = st.require_identity()?;
|
|
let conv_id = st.require_conversation()?.clone();
|
|
let rpc = client.rpc().map_err(|e| anyhow::anyhow!("{e}"))?;
|
|
let conv_store = client.conversations().map_err(|e| anyhow::anyhow!("{e}"))?;
|
|
let conv = conv_store
|
|
.load_conversation(&conv_id)?
|
|
.ok_or_else(|| anyhow::anyhow!("conversation not found"))?;
|
|
let mut member = quicproquo_sdk::groups::restore_mls_state(&conv, &identity)?;
|
|
quicproquo_sdk::groups::leave_group(rpc, conv_store, &mut member, &conv_id).await?;
|
|
display::print_status("left group");
|
|
}
|
|
|
|
"list" => do_list(client)?,
|
|
|
|
"members" => {
|
|
let conv_id = st.require_conversation()?;
|
|
let conv_store = client.conversations().map_err(|e| anyhow::anyhow!("{e}"))?;
|
|
let conv = conv_store
|
|
.load_conversation(conv_id)?
|
|
.ok_or_else(|| anyhow::anyhow!("conversation not found"))?;
|
|
|
|
println!("\n{BOLD}Members{RESET} ({})", conv.member_keys.len());
|
|
for key in &conv.member_keys {
|
|
let short = hex::encode(&key[..4.min(key.len())]);
|
|
if let Ok(rpc) = client.rpc() {
|
|
if let Ok(Some(n)) = quicproquo_sdk::users::resolve_identity(rpc, key).await {
|
|
println!(" @{n} {DIM}({short}){RESET}");
|
|
continue;
|
|
}
|
|
}
|
|
println!(" {short}");
|
|
}
|
|
println!();
|
|
}
|
|
|
|
"remove" => {
|
|
let user = parts.get(1).copied().unwrap_or("").trim();
|
|
if user.is_empty() {
|
|
display::print_error("usage: /group remove <username>");
|
|
return Ok(());
|
|
}
|
|
let identity = st.require_identity()?;
|
|
let conv_id = st.require_conversation()?.clone();
|
|
let rpc = client.rpc().map_err(|e| anyhow::anyhow!("{e}"))?;
|
|
let conv_store = client.conversations().map_err(|e| anyhow::anyhow!("{e}"))?;
|
|
|
|
let peer_key = quicproquo_sdk::users::resolve_user(rpc, user)
|
|
.await?
|
|
.ok_or_else(|| anyhow::anyhow!("user '{user}' not found"))?;
|
|
let conv = conv_store
|
|
.load_conversation(&conv_id)?
|
|
.ok_or_else(|| anyhow::anyhow!("conversation not found"))?;
|
|
let mut member = quicproquo_sdk::groups::restore_mls_state(&conv, &identity)?;
|
|
quicproquo_sdk::groups::remove_member_from_group(
|
|
rpc, conv_store, &mut member, &conv_id, &peer_key,
|
|
).await?;
|
|
display::print_status(&format!("removed @{user} from group"));
|
|
}
|
|
|
|
"rename" => {
|
|
let new_name = parts.get(1).copied().unwrap_or("").trim();
|
|
if new_name.is_empty() {
|
|
display::print_error("usage: /group rename <new-name>");
|
|
return Ok(());
|
|
}
|
|
let conv_id = st.require_conversation()?.clone();
|
|
let rpc = client.rpc().map_err(|e| anyhow::anyhow!("{e}"))?;
|
|
let conv_store = client.conversations().map_err(|e| anyhow::anyhow!("{e}"))?;
|
|
quicproquo_sdk::groups::set_group_metadata(
|
|
rpc, conv_store, &conv_id, new_name, "", &[],
|
|
).await?;
|
|
st.set_conversation(conv_id, format!("#{new_name}"));
|
|
display::print_status(&format!("group renamed to #{new_name}"));
|
|
}
|
|
|
|
"rotate-keys" => {
|
|
let identity = st.require_identity()?;
|
|
let conv_id = st.require_conversation()?.clone();
|
|
let rpc = client.rpc().map_err(|e| anyhow::anyhow!("{e}"))?;
|
|
let conv_store = client.conversations().map_err(|e| anyhow::anyhow!("{e}"))?;
|
|
let conv = conv_store
|
|
.load_conversation(&conv_id)?
|
|
.ok_or_else(|| anyhow::anyhow!("conversation not found"))?;
|
|
let mut member = quicproquo_sdk::groups::restore_mls_state(&conv, &identity)?;
|
|
quicproquo_sdk::groups::rotate_group_keys(rpc, conv_store, &mut member, &conv_id).await?;
|
|
display::print_status("group keys rotated");
|
|
}
|
|
|
|
_ => display::print_error("usage: /group <create|invite|leave|list|members|remove|rename|rotate-keys> [args]"),
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
// ── Device management ──────────────────────────────────────────────────────
|
|
|
|
async fn do_devices(client: &mut QpqClient, args: &str) -> anyhow::Result<()> {
|
|
let parts: Vec<&str> = args.splitn(3, char::is_whitespace).collect();
|
|
let sub = parts.first().copied().unwrap_or("");
|
|
|
|
match sub {
|
|
"list" => {
|
|
let rpc = client.rpc().map_err(|e| anyhow::anyhow!("{e}"))?;
|
|
let devices = quicproquo_sdk::devices::list_devices(rpc).await?;
|
|
if devices.is_empty() {
|
|
display::print_status("no devices registered");
|
|
} else {
|
|
println!("\n{BOLD}Devices{RESET} ({})", devices.len());
|
|
println!("{:<36} {:<20} REGISTERED AT", "DEVICE ID", "NAME");
|
|
for d in &devices {
|
|
println!(
|
|
"{:<36} {:<20} {}",
|
|
hex::encode(&d.device_id),
|
|
d.device_name,
|
|
d.registered_at,
|
|
);
|
|
}
|
|
println!();
|
|
}
|
|
}
|
|
|
|
"add" => {
|
|
let name = parts.get(1).copied().unwrap_or("").trim();
|
|
if name.is_empty() {
|
|
display::print_error("usage: /devices add <name>");
|
|
return Ok(());
|
|
}
|
|
let rpc = client.rpc().map_err(|e| anyhow::anyhow!("{e}"))?;
|
|
// Generate a random device ID (16 bytes).
|
|
use rand::RngCore;
|
|
let mut dev_id = vec![0u8; 16];
|
|
rand::rngs::OsRng.fill_bytes(&mut dev_id);
|
|
let was_new =
|
|
quicproquo_sdk::devices::register_device(rpc, &dev_id, name).await?;
|
|
if was_new {
|
|
display::print_status(&format!(
|
|
"device registered: {name} (id: {})",
|
|
hex::encode(&dev_id)
|
|
));
|
|
} else {
|
|
display::print_status(&format!("device already exists: {name}"));
|
|
}
|
|
}
|
|
|
|
"remove" => {
|
|
let id_hex = parts.get(1).copied().unwrap_or("").trim();
|
|
if id_hex.is_empty() {
|
|
display::print_error("usage: /devices remove <device-id-hex>");
|
|
return Ok(());
|
|
}
|
|
let id_bytes = hex::decode(id_hex)
|
|
.map_err(|e| anyhow::anyhow!("invalid device_id hex: {e}"))?;
|
|
let rpc = client.rpc().map_err(|e| anyhow::anyhow!("{e}"))?;
|
|
let revoked = quicproquo_sdk::devices::revoke_device(rpc, &id_bytes).await?;
|
|
if revoked {
|
|
display::print_status(&format!("device revoked: {id_hex}"));
|
|
} else {
|
|
display::print_error(&format!("device not found: {id_hex}"));
|
|
}
|
|
}
|
|
|
|
_ => display::print_error("usage: /devices <list|add|remove> [args]"),
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
// ── Entry point ─────────────────────────────────────────────────────────────
|
|
|
|
/// Run the v2 REPL over a `QpqClient`.
|
|
///
|
|
/// If `username` and `password` are provided, auto-login is attempted.
|
|
pub async fn run_v2_repl(
|
|
client: &mut QpqClient,
|
|
username: Option<&str>,
|
|
password: Option<&str>,
|
|
) -> anyhow::Result<()> {
|
|
// Auto-start server.
|
|
let _server_guard = auto_start_server(&client.server_addr_string()).await;
|
|
|
|
// Connect to server.
|
|
client.connect().await.context("connect to server")?;
|
|
|
|
// Background event listener.
|
|
let rx = client.subscribe();
|
|
spawn_event_listener(rx);
|
|
|
|
let mut st = ReplState::new();
|
|
|
|
// Auto-login if credentials provided.
|
|
if let (Some(user), Some(pass)) = (username, password) {
|
|
match client.login(user, pass).await {
|
|
Ok(()) => {
|
|
display::print_status(&format!("logged in as {user}"));
|
|
// Load identity from state.
|
|
let state_path = client.config_state_path();
|
|
if state_path.exists() {
|
|
if let Ok(stored) = quicproquo_sdk::state::load_state(&state_path, Some(pass))
|
|
.or_else(|_| quicproquo_sdk::state::load_state(&state_path, None))
|
|
{
|
|
let kp = IdentityKeypair::from_seed(stored.identity_seed);
|
|
st.identity = Some(Arc::new(kp));
|
|
}
|
|
}
|
|
}
|
|
Err(e) => display::print_error(&format!("auto-login failed: {e}")),
|
|
}
|
|
}
|
|
|
|
println!("\n{BOLD}quicproquo v2 REPL{RESET}");
|
|
println!("{DIM}Type /help for commands, /quit to exit.{RESET}\n");
|
|
if let Some(u) = client.username() {
|
|
display::print_status(&format!("authenticated as {u}"));
|
|
}
|
|
|
|
// Rustyline editor with tab-completion.
|
|
let config = Config::builder().auto_add_history(true).build();
|
|
let mut rl: Editor<QpqCompleter, rustyline::history::DefaultHistory> =
|
|
Editor::with_config(config).context("init readline")?;
|
|
rl.set_helper(Some(QpqCompleter::new()));
|
|
|
|
loop {
|
|
let prompt = st.prompt();
|
|
match rl.readline(&prompt) {
|
|
Ok(line) => {
|
|
let trimmed = line.trim();
|
|
if trimmed.is_empty() {
|
|
continue;
|
|
}
|
|
if let Some((cmd, args)) = split_cmd(trimmed) {
|
|
match dispatch(cmd, args, client, &mut st).await {
|
|
Ok(true) => break,
|
|
Ok(false) => {}
|
|
Err(e) => display::print_error(&format!("{e:#}")),
|
|
}
|
|
} else {
|
|
// Bare text → send to current conversation.
|
|
if let Err(e) = do_send(client, &st, trimmed).await {
|
|
display::print_error(&format!("{e:#}"));
|
|
}
|
|
}
|
|
}
|
|
Err(ReadlineError::Interrupted | ReadlineError::Eof) => {
|
|
display::print_status("goodbye");
|
|
break;
|
|
}
|
|
Err(e) => {
|
|
display::print_error(&format!("readline: {e}"));
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
client.disconnect();
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn split_cmd_basic_command() {
|
|
assert_eq!(split_cmd("/help"), Some(("/help", "")));
|
|
}
|
|
|
|
#[test]
|
|
fn split_cmd_command_with_args() {
|
|
assert_eq!(split_cmd("/send hello world"), Some(("/send", "hello world")));
|
|
}
|
|
|
|
#[test]
|
|
fn split_cmd_leading_whitespace() {
|
|
assert_eq!(split_cmd(" /quit"), Some(("/quit", "")));
|
|
}
|
|
|
|
#[test]
|
|
fn split_cmd_non_command() {
|
|
assert_eq!(split_cmd("hello"), None);
|
|
}
|
|
|
|
#[test]
|
|
fn split_cmd_empty_input() {
|
|
assert_eq!(split_cmd(""), None);
|
|
assert_eq!(split_cmd(" "), None);
|
|
}
|
|
|
|
#[test]
|
|
fn split_cmd_slash_only() {
|
|
assert_eq!(split_cmd("/"), Some(("/", "")));
|
|
}
|
|
|
|
#[test]
|
|
fn split_cmd_args_trimmed() {
|
|
assert_eq!(split_cmd("/dm alice"), Some(("/dm", "alice")));
|
|
}
|
|
|
|
#[test]
|
|
fn split_cmd_special_characters() {
|
|
assert_eq!(
|
|
split_cmd("/send hello! @alice #chan"),
|
|
Some(("/send", "hello! @alice #chan"))
|
|
);
|
|
}
|
|
}
|