use crate::*;
pub(crate) fn format_bytes(b: u64) -> String {
bytesize::ByteSize(b).to_string()
}
pub(crate) fn print_error(title: &str, detail: &str, hint: Option<&str>) {
eprintln!(" {} {}", style::cross(), style::bold(title));
if !detail.is_empty() {
eprintln!(" {}", style::value(detail));
}
let hint = hint.map(str::to_string).or_else(|| infer_hint(detail));
if let Some(h) = hint {
eprintln!(" {} {}", style::label("hint"), style::faint(&h));
}
}
pub(crate) fn infer_hint(message: &str) -> Option<String> {
let m = message.to_lowercase();
if m.contains("daemon") && (m.contains("not running") || m.contains("connect")) {
Some("start the service: sudo ray up".into())
} else if m.contains("expired") || m.contains("invite") {
Some("ask the coordinator for a fresh code: ray invite <net>".into())
} else if m.contains("root") || m.contains("permission") || m.contains("operator") {
Some("run with sudo, or `sudo ray set-operator <you>` once".into())
} else if m.contains("hostname") && m.contains("collision") {
Some("pick another name: --hostname <name>".into())
} else {
None
}
}
pub(crate) fn print_next(steps: &[(&str, &str)]) {
let rows: Vec<Vec<layout::Cell>> = steps
.iter()
.enumerate()
.map(|(i, (cmd, blurb))| {
let label = if i == 0 { "next" } else { "" };
vec![
layout::Cell::new(label, style::label(label)),
layout::Cell::new(*cmd, style::rose(cmd)),
layout::Cell::new(*blurb, style::faint(blurb)),
]
})
.collect();
print!("{}", indent(&layout::columns(&rows, 2), 4));
}
pub(crate) fn table(headers: &[&str], rows: Vec<Vec<layout::Cell>>, pad: usize) -> String {
let header: Vec<layout::Cell> = headers
.iter()
.map(|h| layout::Cell::new(*h, style::faint(h)))
.collect();
let mut all = Vec::with_capacity(rows.len() + 1);
all.push(header);
all.extend(rows);
indent(&layout::columns(&all, 2), pad)
}
pub(crate) fn indent(block: &str, indent: usize) -> String {
let pad = " ".repeat(indent);
block
.lines()
.map(|l| format!("{pad}{l}"))
.collect::<Vec<_>>()
.join("\n")
}
pub(crate) fn pluralize(n: usize, noun: &str) -> String {
if n == 1 {
noun.to_string()
} else {
format!("{noun}s")
}
}
pub(crate) async fn ipc_status() -> Result<()> {
let Ok(mut stream) = ipc::connect().await else {
let app_config = config::load()?;
println!();
println!(" {}", style::red("✗ daemon not running"));
if app_config.networks.is_empty() {
println!(" {}", style::faint("no saved networks"));
println!();
return Ok(());
}
println!(" {}", style::faint("saved networks:"));
for net in &app_config.networks {
let ip_str = net
.my_ip
.map(|ip| ip.to_string())
.unwrap_or_else(|| "?".to_string());
println!(
" {} {} {}",
style::value(&net.name),
style::faint(&format!("({ip_str})")),
style::faint(&format!("{} members", net.members.len()))
);
}
println!();
return Ok(());
};
ipc::send(&mut stream, ipc::IpcMessage::Status).await?;
let resp = ipc::recv(&mut stream).await?;
match resp {
ipc::IpcMessage::StatusResponse {
endpoint_id,
mdns_enabled,
active,
contact_id,
daemon_version,
networks,
packets_rx,
packets_tx,
bytes_rx,
bytes_tx,
pending_files,
pending_connects,
} => {
if json_enabled() {
print_json(&serde_json::json!({
"endpoint": endpoint_id.to_string(),
"mdns": mdns_enabled,
"active": active,
"contact_id": contact_id,
"daemon_version": daemon_version,
"networks": networks,
"traffic": {
"packets_rx": packets_rx, "packets_tx": packets_tx,
"bytes_rx": bytes_rx, "bytes_tx": bytes_tx,
},
"pending": {
"files": pending_files,
"connects": pending_connects,
},
}));
return Ok(());
}
let _ = (packets_rx, packets_tx, bytes_rx, bytes_tx);
let state = if active {
format!("{} {}", style::dot_online(), style::value("up"))
} else {
format!("{} {}", style::dot_offline(), style::faint("standby"))
};
let mdns = if mdns_enabled {
format!("{} {}", style::label("mDNS"), style::green("on"))
} else {
format!("{} {}", style::label("mDNS"), style::faint("off"))
};
println!();
println!(
" {} {} {} {} {}",
style::bold("rayfish"),
state,
mdns,
style::label("endpoint"),
style::value(&endpoint_id.fmt_short().to_string()),
);
if !active {
println!(" {}", style::faint("run `ray up` to activate"));
}
if let Some(ref cid) = contact_id {
println!(" {} {}", style::label("contact"), style::rose(cid),);
}
if networks.is_empty() {
println!();
println!(" {}", style::faint("no active networks"));
} else {
for net in &networks {
print_network(net);
}
}
let active_names: std::collections::HashSet<&str> =
networks.iter().map(|n| n.name.as_str()).collect();
if let Ok(app_config) = config::load() {
let inactive: Vec<_> = app_config
.networks
.iter()
.filter(|n| !active_names.contains(n.name.as_str()))
.collect();
for net in &inactive {
println!();
println!(
" {} {}",
style::faint(&net.name),
style::marker("inactive")
);
}
}
print_pending_summary(&networks, pending_files, pending_connects);
let cli_version = env!("CARGO_PKG_VERSION");
if !daemon_version.is_empty() && daemon_version != cli_version {
println!();
println!(
" {} daemon is v{} but CLI is v{}",
style::red("!"),
daemon_version,
cli_version,
);
println!(
" {}",
style::faint("run `sudo ray update` to restart the daemon onto the new binary"),
);
}
println!();
}
ipc::IpcMessage::Error { message } => print_error("status failed", &message, None),
other => eprintln!("Unexpected response: {:?}", other),
}
Ok(())
}
fn print_network(net: &ipc::NetworkStatus) {
let role = net.role.to_string();
let dns_name = net
.my_hostname
.as_ref()
.map(|h| format!("{}.{}.{}", h, net.name, DNS_DOMAIN));
let online = net.peers.iter().filter(|p| p.connection.is_some()).count();
println!();
print!(" {} {}", style::bold(&net.name), style::marker(&role));
if let Some(ref dns) = dns_name {
print!(" {}", style::value(dns));
}
print!(" {}", style::faint(&net.my_ip.to_string()));
println!(
" {} {}",
style::label("members"),
style::value(&format!("{online}/{}", net.peers.len())),
);
let rows: Vec<Vec<layout::Cell>> =
net.peers.iter().map(|p| render_peer_row(&net.name, p)).collect();
if rows.is_empty() {
println!(" {}", style::faint("(no other members)"));
} else {
println!("{}", indent(&layout::columns(&rows, 3), 4));
}
if let Some(ref key) = net.network_key
&& !net.role.is_direct()
{
println!(" {} {}", style::label("join"), style::rose(key));
}
}
fn render_peer_row(net_name: &str, peer: &ipc::PeerStatus) -> Vec<layout::Cell> {
let host = peer
.hostname
.as_ref()
.map(|h| format!("{h}.{}.{}", net_name, DNS_DOMAIN))
.unwrap_or_else(|| peer.ip.to_string());
match &peer.connection {
Some(ci) => {
let via = match ci.conn_type {
ipc::ConnType::Direct => "direct",
ipc::ConnType::Relay => "relay",
ipc::ConnType::Tor => "tor",
ipc::ConnType::Unknown => "?",
};
let (rtt_plain, rtt_styled) = match ci.rtt_ms {
Some(ms) => (format!("{ms:.0}ms"), style::latency(ms)),
None => ("—".into(), style::faint("—")),
};
let traffic_plain = format!(
"↑ {} ↓ {}",
format_bytes(ci.bytes_tx),
format_bytes(ci.bytes_rx)
);
vec![
layout::Cell::new("●", style::dot_online()),
layout::Cell::new(host.clone(), style::value(&host)),
layout::Cell::new(peer.ip.to_string(), style::faint(&peer.ip.to_string())),
layout::Cell::new(via, style::faint(via)),
layout::Cell::right(rtt_plain, rtt_styled),
layout::Cell::new(traffic_plain.clone(), style::faint(&traffic_plain)),
]
}
None => vec![
layout::Cell::new("○", style::dot_offline()),
layout::Cell::new(host.clone(), style::faint(&host)),
layout::Cell::new(peer.ip.to_string(), style::faint(&peer.ip.to_string())),
layout::Cell::new("—", style::faint("—")),
layout::Cell::right("offline", style::faint("offline")),
layout::Cell::plain(""),
],
}
}
fn print_pending_summary(
networks: &[ipc::NetworkStatus],
pending_files: usize,
pending_connects: usize,
) {
let mut pending: Vec<(usize, String, String)> = Vec::new();
for net in networks {
if net.pending_suggestions > 0 {
pending.push((
net.pending_suggestions,
pluralize(net.pending_suggestions, "firewall suggestion"),
format!("ray firewall pending {}", net.name),
));
}
if net.pending_requests > 0 {
pending.push((
net.pending_requests,
pluralize(net.pending_requests, "join request"),
format!("ray requests {}", net.name),
));
}
}
if pending_files > 0 {
pending.push((
pending_files,
pluralize(pending_files, "file offer"),
"ray files".to_string(),
));
}
if pending_connects > 0 {
pending.push((
pending_connects,
pluralize(pending_connects, "connection request"),
"ray connections".to_string(),
));
}
if pending.is_empty() {
return;
}
println!();
println!(" {}", style::label("pending"));
let rows: Vec<Vec<layout::Cell>> = pending
.iter()
.map(|(n, what, cmd)| {
let count = format!("({n})");
vec![
layout::Cell::new(count.clone(), style::rose(&count)),
layout::Cell::new(what.clone(), style::value(what)),
layout::Cell::new(cmd.clone(), style::faint(cmd)),
]
})
.collect();
print!("{}", indent(&layout::columns(&rows, 3), 4));
}
pub(crate) async fn ipc_down() -> Result<()> {
let mut stream = ipc::connect().await?;
ipc::send(&mut stream, ipc::IpcMessage::Down).await?;
let resp = ipc::recv(&mut stream).await?;
match resp {
ipc::IpcMessage::Ok { message } => println!("{}", message),
ipc::IpcMessage::Error { message } => print_error("error", &message, None),
other => eprintln!("Unexpected response: {:?}", other),
}
Ok(())
}
pub(crate) const REPORT_REPO_URL: &str = "https://github.com/rayfish/rayfish";
pub(crate) async fn ipc_report() -> Result<()> {
let mut stream = ipc::connect().await?;
ipc::send(&mut stream, ipc::IpcMessage::Report).await?;
let resp = ipc::recv(&mut stream).await?;
match resp {
ipc::IpcMessage::ReportBundle {
path,
issue_title,
issue_body,
} => {
println!("Diagnostic bundle written to:\n {path}\n");
println!(
"Review it before sharing — it contains your logs, virtual IPs, and peer IDs,\n\
but no private keys."
);
let url = url::Url::parse_with_params(
&format!("{REPORT_REPO_URL}/issues/new"),
&[
("title", issue_title.as_str()),
("body", issue_body.as_str()),
],
)?;
println!("\nOpening a pre-filled GitHub issue — attach the bundle above.");
if !open_url(url.as_str()) {
println!("\nCouldn't open a browser. Open this URL manually:\n{url}");
}
}
ipc::IpcMessage::Error { message } => print_error("error", &message, None),
other => eprintln!("Unexpected response: {:?}", other),
}
Ok(())
}
pub(crate) fn open_url(url: &str) -> bool {
let opener = if cfg!(target_os = "macos") {
"open"
} else {
"xdg-open"
};
std::process::Command::new(opener)
.arg(url)
.status()
.map(|s| s.success())
.unwrap_or(false)
}
pub(crate) async fn ipc_set_hostname(network: &str, hostname: &str) -> Result<()> {
let mut stream = ipc::connect().await?;
ipc::send(
&mut stream,
ipc::IpcMessage::SetHostname {
network: network.to_string(),
hostname: hostname.to_string(),
},
)
.await?;
let resp = ipc::recv(&mut stream).await?;
match resp {
ipc::IpcMessage::Ok { message } => println!("{}", message),
ipc::IpcMessage::Error { message } => print_error("error", &message, None),
other => eprintln!("Unexpected response: {:?}", other),
}
Ok(())
}