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
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
use anyhow::{anyhow, Result};
use clap::{Parser, Subcommand};
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 keybindings;
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,
#[command(subcommand)]
command: Option<Commands>,
}
#[derive(Subcommand)]
enum Commands {
/// huddle 0.6: print version, paths, and config for bug reports.
/// Useful for "what does my install look like?" — runs without
/// the TUI, doesn't touch the network, and never asks for the
/// master passphrase.
Doctor,
}
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();
// huddle 0.6: `huddle doctor` runs without the TUI, log appender,
// or network. Just a pretty diagnostic dump for bug reports.
if let Some(Commands::Doctor) = &cli.command {
return run_doctor();
}
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
}
/// huddle 0.6: print a diagnostic snapshot — version, build target,
/// data paths, file sizes, config contents. Output is plain text
/// (no TUI) so users can copy-paste into bug reports.
fn run_doctor() -> Result<()> {
use huddle_core::config;
use std::fs;
println!("huddle {}", env!("CARGO_PKG_VERSION"));
println!("tui version: 2.0 (sidebar + pane)");
println!("repository: https://github.com/richer-richard/huddle");
println!();
println!("paths:");
let data_dir = config::data_dir();
let db_path = config::db_path();
let log_path = config::log_path();
let config_path = config::config_path();
println!(" data dir: {}", data_dir.display());
println!(" database: {}", db_path.display());
println!(" log file: {}", log_path.display());
println!(" config: {}", config_path.display());
println!();
let exists = |p: &std::path::Path| {
if let Ok(meta) = fs::metadata(p) {
let kb = meta.len() / 1024;
format!("present ({} KB)", kb)
} else {
"absent".to_string()
}
};
println!("data files:");
for name in &[
"huddle.db",
"huddle.db-shm",
"huddle.db-wal",
"keychain.salt",
"identity.key",
"huddle.log",
] {
let p = data_dir.join(name);
println!(" {:<16} {}", format!("{}:", name), exists(&p));
}
println!();
// Config: just print the relay list if any.
match config::load_relays() {
Some(list) if !list.is_empty() => {
println!("relays configured (from config.toml):");
for r in list {
println!(" {}", r);
}
}
_ => {
println!("relays: none configured");
}
}
println!();
println!("for support, open an issue at:");
println!(" https://github.com/richer-richard/huddle/issues");
Ok(())
}