2026-04-17 14:43:04 +08:00
|
|
|
mod app;
|
|
|
|
|
mod transport;
|
|
|
|
|
mod ui;
|
|
|
|
|
mod utils;
|
|
|
|
|
|
2026-05-12 15:33:50 +02:00
|
|
|
use std::path::{Path, PathBuf};
|
2026-04-17 14:43:04 +08:00
|
|
|
|
|
|
|
|
use anyhow::{Context, Result};
|
2026-05-12 15:33:50 +02:00
|
|
|
use clap::{Parser, ValueEnum};
|
2026-06-11 10:08:07 +02:00
|
|
|
use crossbeam_channel::Receiver;
|
2026-06-22 10:38:17 -07:00
|
|
|
use logos_chat::{ChatClient, Event, HttpRegistry, RegistrationService, StorageConfig, Transport};
|
2026-04-27 13:22:16 +02:00
|
|
|
|
|
|
|
|
use app::ChatApp;
|
|
|
|
|
|
2026-05-12 15:33:50 +02:00
|
|
|
#[derive(Copy, Clone, Debug, ValueEnum)]
|
|
|
|
|
#[value(rename_all = "kebab-case")]
|
|
|
|
|
enum TransportKind {
|
|
|
|
|
File,
|
2026-05-19 11:54:54 -07:00
|
|
|
#[cfg(logos_delivery)]
|
2026-05-12 15:33:50 +02:00
|
|
|
LogosDelivery,
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-27 13:22:16 +02:00
|
|
|
#[derive(Parser, Debug)]
|
|
|
|
|
#[command(name = "chat-cli", about = "End-to-end encrypted terminal chat")]
|
|
|
|
|
struct Cli {
|
|
|
|
|
/// Your identity name.
|
|
|
|
|
#[arg(long, short)]
|
|
|
|
|
name: String,
|
|
|
|
|
|
2026-05-12 15:33:50 +02:00
|
|
|
/// Which delivery transport to use.
|
2026-05-19 11:54:54 -07:00
|
|
|
#[arg(long, value_enum, default_value_t = TransportKind::File)]
|
2026-05-12 15:33:50 +02:00
|
|
|
transport: TransportKind,
|
|
|
|
|
|
|
|
|
|
/// Data directory (used for UI state and the default SQLite path).
|
2026-04-27 13:22:16 +02:00
|
|
|
#[arg(long, default_value = "tmp/chat-cli-data")]
|
|
|
|
|
data: PathBuf,
|
|
|
|
|
|
2026-05-12 15:33:50 +02:00
|
|
|
/// Override the SQLite database path (defaults to `<data>/<name>.db`).
|
2026-04-27 13:22:16 +02:00
|
|
|
#[arg(long)]
|
|
|
|
|
db: Option<PathBuf>,
|
|
|
|
|
|
2026-05-12 15:33:50 +02:00
|
|
|
// ── logos-delivery transport options ──────────────────────────────────────
|
|
|
|
|
/// logos-delivery network preset (e.g. `logos.dev`).
|
2026-04-27 13:22:16 +02:00
|
|
|
#[arg(long, default_value = "logos.dev")]
|
|
|
|
|
preset: String,
|
|
|
|
|
|
|
|
|
|
/// TCP port for the embedded logos-delivery node.
|
|
|
|
|
#[arg(long, default_value_t = 60000)]
|
|
|
|
|
port: u16,
|
|
|
|
|
|
|
|
|
|
/// Write logs to a file instead of stderr (keeps TUI output clean).
|
|
|
|
|
#[arg(long)]
|
|
|
|
|
log_file: Option<PathBuf>,
|
|
|
|
|
|
|
|
|
|
/// Initialize and immediately exit without launching the TUI (for CI).
|
|
|
|
|
#[arg(long)]
|
|
|
|
|
smoketest: bool,
|
2026-06-04 10:09:29 +08:00
|
|
|
|
|
|
|
|
/// Optional KeyPackage registry base URL. When set, uses the HTTP-backed
|
|
|
|
|
/// registry instead of the in-memory `EphemeralRegistry`.
|
|
|
|
|
/// Example: `--registry-url http://localhost:8080`.
|
|
|
|
|
#[arg(long)]
|
|
|
|
|
registry_url: Option<String>,
|
2026-04-17 14:43:04 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn main() -> Result<()> {
|
2026-04-27 13:22:16 +02:00
|
|
|
let cli = Cli::parse();
|
|
|
|
|
setup_logging(cli.log_file.as_deref())?;
|
2026-04-17 14:43:04 +08:00
|
|
|
|
2026-04-27 13:22:16 +02:00
|
|
|
std::fs::create_dir_all(&cli.data).context("failed to create data directory")?;
|
2026-04-17 14:43:04 +08:00
|
|
|
|
2026-05-12 15:33:50 +02:00
|
|
|
match cli.transport {
|
|
|
|
|
TransportKind::File => {
|
|
|
|
|
let transport_dir = cli.data.join("transport");
|
2026-06-11 10:08:07 +02:00
|
|
|
let transport = transport::file::FileTransport::new(&transport_dir)
|
2026-05-12 15:33:50 +02:00
|
|
|
.context("failed to create file transport")?;
|
2026-06-11 10:08:07 +02:00
|
|
|
run(transport, &cli)
|
2026-05-12 15:33:50 +02:00
|
|
|
}
|
2026-05-19 11:54:54 -07:00
|
|
|
#[cfg(logos_delivery)]
|
2026-05-12 15:33:50 +02:00
|
|
|
TransportKind::LogosDelivery => {
|
|
|
|
|
use transport::logos_delivery::{Config, Service};
|
|
|
|
|
|
|
|
|
|
println!("Starting logos-delivery node (preset={})...", cli.preset);
|
|
|
|
|
println!("This may take a few seconds while connecting to the network.");
|
|
|
|
|
|
|
|
|
|
let cfg = Config {
|
|
|
|
|
preset: cli.preset.clone(),
|
|
|
|
|
tcp_port: cli.port,
|
|
|
|
|
..Default::default()
|
|
|
|
|
};
|
2026-06-11 10:08:07 +02:00
|
|
|
let transport = Service::start(cfg).context("failed to start logos-delivery")?;
|
2026-05-12 15:33:50 +02:00
|
|
|
|
|
|
|
|
println!("Node connected. Initializing chat client...");
|
2026-06-11 10:08:07 +02:00
|
|
|
run(transport, &cli)
|
2026-05-12 15:33:50 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-04-17 14:43:04 +08:00
|
|
|
|
2026-06-11 10:08:07 +02:00
|
|
|
fn run<T: Transport>(transport: T, cli: &Cli) -> Result<()> {
|
2026-05-12 15:33:50 +02:00
|
|
|
let db_path = cli
|
|
|
|
|
.db
|
|
|
|
|
.clone()
|
|
|
|
|
.unwrap_or_else(|| cli.data.join(format!("{}.db", cli.name)));
|
|
|
|
|
let db_str = db_path
|
|
|
|
|
.to_str()
|
|
|
|
|
.context("db path contains non-UTF-8 characters")?
|
|
|
|
|
.to_string();
|
2026-06-04 10:09:29 +08:00
|
|
|
let storage = StorageConfig::Encrypted {
|
|
|
|
|
path: db_str,
|
|
|
|
|
key: "chat-cli".to_string(),
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
match cli.registry_url.as_deref() {
|
|
|
|
|
Some(url) => {
|
|
|
|
|
let registry = HttpRegistry::new(url);
|
2026-06-11 10:08:07 +02:00
|
|
|
let (client, events) =
|
2026-06-04 10:09:29 +08:00
|
|
|
ChatClient::open_with_registry(cli.name.clone(), storage, transport, registry)
|
|
|
|
|
.map_err(|e| anyhow::anyhow!("{e:?}"))
|
|
|
|
|
.context("failed to open chat client with HTTP registry")?;
|
2026-06-11 10:08:07 +02:00
|
|
|
launch_tui(client, events, cli)
|
2026-06-04 10:09:29 +08:00
|
|
|
}
|
|
|
|
|
None => {
|
2026-06-11 10:08:07 +02:00
|
|
|
let (client, events) = ChatClient::open(cli.name.clone(), storage, transport)
|
2026-06-04 10:09:29 +08:00
|
|
|
.map_err(|e| anyhow::anyhow!("{e:?}"))
|
|
|
|
|
.context("failed to open chat client")?;
|
2026-06-11 10:08:07 +02:00
|
|
|
launch_tui(client, events, cli)
|
2026-06-04 10:09:29 +08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-04-17 14:43:04 +08:00
|
|
|
|
2026-06-11 10:08:07 +02:00
|
|
|
fn launch_tui<T, R>(client: ChatClient<T, R>, events: Receiver<Event>, cli: &Cli) -> Result<()>
|
2026-06-04 10:09:29 +08:00
|
|
|
where
|
2026-06-22 10:38:17 -07:00
|
|
|
T: Transport,
|
2026-06-11 10:08:07 +02:00
|
|
|
R: RegistrationService + Send + 'static,
|
2026-06-04 10:09:29 +08:00
|
|
|
{
|
2026-06-11 10:08:07 +02:00
|
|
|
let mut app = ChatApp::new(client, events, &cli.name, &cli.data)?;
|
2026-04-17 14:43:04 +08:00
|
|
|
|
2026-04-27 13:22:16 +02:00
|
|
|
if cli.smoketest {
|
|
|
|
|
return Ok(());
|
|
|
|
|
}
|
2026-04-17 14:43:04 +08:00
|
|
|
|
2026-04-27 13:22:16 +02:00
|
|
|
let mut terminal = ui::init().context("failed to initialize terminal")?;
|
|
|
|
|
let result = run_app(&mut terminal, &mut app);
|
|
|
|
|
ui::restore().context("failed to restore terminal")?;
|
2026-04-17 14:43:04 +08:00
|
|
|
result
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-19 11:54:54 -07:00
|
|
|
#[cfg_attr(not(logos_delivery), allow(dead_code, unused_variables))]
|
|
|
|
|
fn run_logos_delivery(cli: Cli) -> Result<()> {
|
|
|
|
|
#[cfg(logos_delivery)]
|
|
|
|
|
{
|
|
|
|
|
use transport::logos_delivery::{Config, Service};
|
|
|
|
|
|
|
|
|
|
eprintln!("Starting logos-delivery node (preset={})...", cli.preset);
|
|
|
|
|
eprintln!("This may take a few seconds while connecting to the network.");
|
|
|
|
|
|
|
|
|
|
let logos_cfg = Config {
|
|
|
|
|
preset: cli.preset.clone(),
|
|
|
|
|
tcp_port: cli.port,
|
|
|
|
|
..Default::default()
|
|
|
|
|
};
|
2026-06-11 10:08:07 +02:00
|
|
|
let delivery = Service::start(logos_cfg).context("failed to start logos-delivery")?;
|
2026-05-19 11:54:54 -07:00
|
|
|
|
|
|
|
|
eprintln!("Node connected. Initializing chat client...");
|
|
|
|
|
|
|
|
|
|
let data_dir = cli
|
|
|
|
|
.db
|
|
|
|
|
.as_ref()
|
|
|
|
|
.and_then(|p| p.parent())
|
|
|
|
|
.map(|p| p.to_path_buf())
|
|
|
|
|
.unwrap_or_else(|| cli.data.clone());
|
|
|
|
|
|
2026-06-11 10:08:07 +02:00
|
|
|
let (client, events) = match cli.db {
|
2026-05-19 11:54:54 -07:00
|
|
|
Some(ref path) => {
|
|
|
|
|
let db_str = path
|
|
|
|
|
.to_str()
|
|
|
|
|
.context("db path contains non-UTF-8 characters")?
|
|
|
|
|
.to_string();
|
2026-05-20 13:18:25 -07:00
|
|
|
logos_chat::ChatClient::open(
|
2026-05-19 11:54:54 -07:00
|
|
|
cli.name.clone(),
|
2026-05-20 13:18:25 -07:00
|
|
|
logos_chat::StorageConfig::Encrypted {
|
2026-05-19 11:54:54 -07:00
|
|
|
path: db_str,
|
|
|
|
|
key: "chat-cli".to_string(),
|
|
|
|
|
},
|
|
|
|
|
delivery,
|
|
|
|
|
)
|
|
|
|
|
.map_err(|e| anyhow::anyhow!("{e:?}"))
|
|
|
|
|
.context("failed to open persistent client")?
|
|
|
|
|
}
|
2026-05-20 13:18:25 -07:00
|
|
|
None => logos_chat::ChatClient::new(cli.name.clone(), delivery),
|
2026-05-19 11:54:54 -07:00
|
|
|
};
|
|
|
|
|
|
2026-06-11 10:08:07 +02:00
|
|
|
let mut app = ChatApp::new(client, events, &cli.name, &data_dir)?;
|
2026-05-19 11:54:54 -07:00
|
|
|
|
|
|
|
|
if cli.smoketest {
|
|
|
|
|
return Ok(());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let mut terminal = ui::init().context("failed to initialize terminal")?;
|
|
|
|
|
let result = run_app(&mut terminal, &mut app);
|
|
|
|
|
ui::restore().context("failed to restore terminal")?;
|
|
|
|
|
return result;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[cfg(not(logos_delivery))]
|
|
|
|
|
anyhow::bail!(
|
|
|
|
|
"logos-delivery transport is not available in this build.\n\
|
|
|
|
|
Build with LOGOS_DELIVERY_LIB_DIR set to enable it."
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-11 10:08:07 +02:00
|
|
|
fn run_app<T, R>(terminal: &mut ui::Tui, app: &mut ChatApp<T, R>) -> Result<()>
|
2026-06-04 10:09:29 +08:00
|
|
|
where
|
2026-06-22 10:38:17 -07:00
|
|
|
T: Transport,
|
2026-06-11 10:08:07 +02:00
|
|
|
R: RegistrationService + Send + 'static,
|
2026-06-04 10:09:29 +08:00
|
|
|
{
|
2026-04-17 14:43:04 +08:00
|
|
|
loop {
|
|
|
|
|
app.process_incoming()?;
|
|
|
|
|
terminal.draw(|frame| ui::draw(frame, app))?;
|
|
|
|
|
if !ui::handle_events(app)? {
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-04-27 13:22:16 +02:00
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-12 15:33:50 +02:00
|
|
|
fn setup_logging(log_file: Option<&Path>) -> Result<()> {
|
2026-04-27 13:22:16 +02:00
|
|
|
use tracing_subscriber::EnvFilter;
|
|
|
|
|
|
|
|
|
|
let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("warn"));
|
|
|
|
|
|
|
|
|
|
if let Some(path) = log_file {
|
|
|
|
|
let file = std::fs::File::create(path)
|
|
|
|
|
.with_context(|| format!("failed to create log file: {}", path.display()))?;
|
|
|
|
|
tracing_subscriber::fmt()
|
|
|
|
|
.with_env_filter(filter)
|
|
|
|
|
.with_writer(file)
|
|
|
|
|
.init();
|
|
|
|
|
} else {
|
|
|
|
|
tracing_subscriber::fmt()
|
|
|
|
|
.with_env_filter(EnvFilter::new("off"))
|
|
|
|
|
.init();
|
|
|
|
|
}
|
2026-04-17 14:43:04 +08:00
|
|
|
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|