1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
use anyhow::{anyhow, Result};
use clap::Parser;
use libp2p::Multiaddr;
use tracing_appender::rolling;
use tracing_subscriber::EnvFilter;
use huddle_core::network::NetworkMode;
use huddle_core::storage::keychain;
mod app;
mod input;
mod ui;
#[derive(Parser)]
#[command(name = "huddle", version, about = "Huddle — decentralized encrypted chat (TUI)")]
struct Cli {
#[arg(long, help = "Override data directory")]
data_dir: Option<String>,
/// Connection mode. If omitted, you'll be asked at startup.
/// Values: `mdns` (LAN auto-discover) or `direct` (manual dial only).
#[arg(long, value_parser = parse_mode)]
mode: Option<NetworkMode>,
/// TCP port to listen on (0 = random). Pick a stable one if you want
/// people to be able to dial you reliably from outside the LAN.
#[arg(long, default_value_t = 0u16)]
port: u16,
/// Optional human-readable display name shown alongside your short
/// fingerprint in chat.
#[arg(long)]
name: Option<String>,
/// Skip the master passphrase prompt and run with an unencrypted
/// at-rest database (Phase 1 behavior). For testing only.
#[arg(long)]
no_master_passphrase: bool,
/// Phase D: register with a libp2p Circuit Relay v2 server so peers
/// behind NAT can dial us via `<this-addr>/p2p-circuit/p2p/<our-id>`.
/// Repeat to register with multiple relays. The multiaddr MUST
/// include `/p2p/<peer-id>` so the dial enforces pubkey match.
/// No defaults — you opt in.
#[arg(long = "relay", value_name = "MULTIADDR")]
relays: Vec<String>,
/// Phase D: opt out of relay registration even if `config.toml` has
/// entries. LAN-only operation.
#[arg(long)]
no_relay: bool,
}
fn parse_mode(s: &str) -> std::result::Result<NetworkMode, String> {
NetworkMode::from_str(s).ok_or_else(|| format!("unknown mode `{s}` (try mdns or direct)"))
}
#[tokio::main]
async fn main() -> Result<()> {
let cli = Cli::parse();
let log_path = huddle_core::config::log_path();
if let Some(parent) = log_path.parent() {
std::fs::create_dir_all(parent)?;
}
let file_appender = rolling::never(
log_path.parent().unwrap(),
log_path.file_name().unwrap(),
);
tracing_subscriber::fmt()
.with_env_filter(EnvFilter::from_default_env().add_directive("huddle=debug".parse()?))
.with_writer(file_appender)
.with_ansi(false)
.init();
// Restore the terminal on panic before the message prints, so a crash
// inside the TUI doesn't leave the shell in raw mode / alt screen.
app::install_panic_hook();
let mode = cli.mode.unwrap_or(NetworkMode::Mdns);
// Skip the welcome card if a mode was given explicitly — power users
// who script `--mode direct` don't want a prompt in the way.
if cli.mode.is_none() && !app::show_welcome()? {
return Ok(());
}
// Master passphrase: derives the SQLCipher DB key + Megolm persist
// subkey. `--no-master-passphrase` falls back to an unencrypted DB
// (Phase 1 behavior).
//
// First-launch detection happens BEFORE we create the salt — once
// the salt exists on disk, the file's there even if no DB is.
let (master_key, signup_name) = if cli.no_master_passphrase {
(None, None)
} else {
let salt_path = keychain::keychain_salt_path();
let salt_exists = salt_path.exists();
// A database with no keychain salt was created by a previous
// `--no-master-passphrase` run and is unencrypted. Taking the
// normal (encrypted) path here would derive a fresh key, fail to
// unlock that DB, and trap the user behind a cryptic SQLCipher
// error. Refuse early with actionable guidance instead.
if !salt_exists && huddle_core::config::db_path().exists() {
anyhow::bail!(
"found an existing database with no keychain salt — it was \
created with --no-master-passphrase and is unencrypted. \
Re-run with --no-master-passphrase, or move {} aside to \
start fresh.",
huddle_core::config::db_path().display()
);
}
let is_new = !salt_exists;
let prompt = app::prompt_master_passphrase(is_new)?;
if prompt.passphrase.is_empty() {
return Ok(());
}
// Only persist the salt once the user has committed a
// passphrase — otherwise pressing Esc on first launch leaves
// a salt file behind and a future launch would think the DB
// already existed.
let salt = keychain::load_or_create_salt().map_err(|e| anyhow!(e))?;
let key =
keychain::derive_master_key(&prompt.passphrase, &salt).map_err(|e| anyhow!(e))?;
(Some(key), prompt.username)
};
// Phase D: assemble the relay multiaddr list. CLI flags override
// config.toml. `--no-relay` wins over everything else.
let relays: Vec<Multiaddr> = if cli.no_relay {
Vec::new()
} else {
let mut from_cli: Vec<String> = cli.relays.clone();
if from_cli.is_empty() {
from_cli = huddle_core::config::load_relays()
.unwrap_or_default();
}
let mut parsed = Vec::new();
for s in &from_cli {
match s.parse::<Multiaddr>() {
Ok(m) => parsed.push(m),
Err(e) => tracing::warn!(%e, addr = %s, "ignoring invalid --relay addr"),
}
}
parsed
};
let handle = huddle_core::app::AppHandle::start_with_options(
mode,
cli.port,
master_key.as_ref(),
relays,
)
.await
.map_err(|e| anyhow!(e))?;
// CLI --name wins over the prompt-supplied username.
let name_to_set = cli.name.clone().or(signup_name);
if let Some(name) = name_to_set {
let trimmed = name.trim();
if !trimmed.is_empty() {
handle
.set_display_name(Some(trimmed))
.map_err(|e| anyhow!(e))?;
}
}
app::run_tui(handle).await
}