use std::io::Read;
use std::net::SocketAddr;
use std::path::PathBuf;
use crate::predict::DisplayPreference;
use crate::transport_iroh::{
bind_endpoint, bind_endpoint_local, bind_endpoint_with_relay, direct_addr, format_endpoint_id,
load_or_create_secret_key, parse_endpoint_id, parse_relay_url, relay_addr,
};
use anyhow::Context;
use clap::{Args, ValueEnum};
use secrecy::SecretString;
use tokio::signal::unix::{signal, SignalKind};
use tokio::sync::mpsc;
use tokio_util::sync::CancellationToken;
use crate::client::{run_client, ClientTerminal, IrohConnector, TerminaTerminal};
#[derive(Copy, Clone, Debug, ValueEnum)]
enum PredictMode {
Always,
Never,
Adaptive,
}
impl From<PredictMode> for DisplayPreference {
fn from(m: PredictMode) -> Self {
match m {
PredictMode::Always => Self::Always,
PredictMode::Never => Self::Never,
PredictMode::Adaptive => Self::Adaptive,
}
}
}
#[derive(Args, Debug)]
pub struct ConnectArgs {
server: String,
#[arg(long)]
key_file: Option<PathBuf>,
#[arg(long, value_enum, default_value_t = PredictMode::Adaptive)]
predict: PredictMode,
#[arg(long, value_name = "IP:PORT", conflicts_with = "relay_url")]
direct: Option<SocketAddr>,
#[arg(long, value_name = "URL")]
relay_url: Option<String>,
#[arg(long)]
passphrase: Option<String>,
#[arg(long)]
clipboard: bool,
}
fn clipboard_opt_in(flag: bool) -> bool {
flag || std::env::var("KOH_CLIPBOARD").is_ok_and(|v| {
matches!(
v.trim().to_ascii_lowercase().as_str(),
"1" | "true" | "yes" | "on"
)
})
}
#[derive(Args, Debug)]
pub struct IdArgs {
#[arg(long)]
key_file: Option<PathBuf>,
}
fn default_key_file() -> PathBuf {
crate::transport_iroh::default_key_path("client")
}
fn spawn_signal_shutdown(shutdown: CancellationToken) -> anyhow::Result<()> {
let mut term = signal(SignalKind::terminate()).context("installing SIGTERM handler")?;
let mut intr = signal(SignalKind::interrupt()).context("installing SIGINT handler")?;
let mut hup = signal(SignalKind::hangup()).context("installing SIGHUP handler")?;
tokio::spawn(async move {
tokio::select! {
_ = term.recv() => {}
_ = intr.recv() => {}
_ = hup.recv() => {}
}
shutdown.cancel();
});
Ok(())
}
fn warn_if_locale_not_utf8() {
let locale = ["LC_ALL", "LC_CTYPE", "LANG"]
.iter()
.find_map(|k| std::env::var(k).ok().filter(|v| !v.is_empty()));
let looks_utf8 = locale.as_deref().is_some_and(|l| {
let l = l.to_ascii_lowercase();
l.contains("utf-8") || l.contains("utf8")
});
if !looks_utf8 {
let shown = locale.as_deref().unwrap_or("(unset)");
eprintln!(
"koh: warning: locale {shown} does not look UTF-8; non-ASCII output may be garbled. \
Set e.g. LANG=en_US.UTF-8."
);
}
}
pub fn run_id(args: IdArgs) -> anyhow::Result<()> {
let key_file = args.key_file.unwrap_or_else(default_key_file);
let secret = load_or_create_secret_key(&key_file).with_context(|| {
format!(
"loading client key from {} (pass --key-file to use a writable path)",
key_file.display()
)
})?;
println!("{}", format_endpoint_id(&secret.public()));
Ok(())
}
pub async fn connect(args: ConnectArgs) -> anyhow::Result<Option<u32>> {
if let Ok(path) = std::env::var("KOH_LOG") {
if let Ok(file) = std::fs::File::create(&path) {
tracing_subscriber::fmt()
.with_writer(std::sync::Mutex::new(file))
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| "koh=debug".into()),
)
.init();
}
}
warn_if_locale_not_utf8();
let key_file = args.key_file.clone().unwrap_or_else(default_key_file);
let secret = load_or_create_secret_key(&key_file).with_context(|| {
format!(
"loading client key from {} (pass --key-file to use a writable path)",
key_file.display()
)
})?;
let my_id = secret.public();
let server_id = parse_endpoint_id(&args.server).context("parsing server endpoint id")?;
eprintln!("koh id: {}", format_endpoint_id(&my_id));
eprintln!(" (add this to the server with --allow if it isn't already)");
eprintln!("connecting to {} …", format_endpoint_id(&server_id));
let (endpoint, target) = if let Some(addr) = args.direct {
let ep = bind_endpoint_local(secret, false)
.await
.context("binding endpoint")?;
(ep, direct_addr(server_id, addr))
} else if let Some(url) = &args.relay_url {
let relay = parse_relay_url(url)?;
let ep = bind_endpoint_with_relay(secret, false, relay.clone())
.await
.context("binding endpoint")?;
(ep, relay_addr(server_id, relay))
} else {
let ep = bind_endpoint(secret, false)
.await
.context("binding endpoint")?;
(ep, server_id.into())
};
let passphrase = std::sync::Arc::new(
args.passphrase
.clone()
.or_else(|| std::env::var("KOH_PASSPHRASE").ok())
.map(SecretString::from),
);
let connector = IrohConnector::new(endpoint.clone(), target, passphrase);
let channel = connector.connect().await?;
eprintln!("connected. (Ctrl-^ then . to disconnect)");
let shutdown = CancellationToken::new();
spawn_signal_shutdown(shutdown.clone())?;
let clipboard_enabled = clipboard_opt_in(args.clipboard);
let term =
TerminaTerminal::enter(clipboard_enabled).context("entering raw mode / alt screen")?;
let (rows, cols) = term.size().unwrap_or((24, 80));
let (input_tx, input_rx) = mpsc::channel::<Vec<u8>>(64);
std::thread::Builder::new()
.name("koh-stdin".into())
.spawn(move || {
let mut stdin = std::io::stdin();
let mut buf = [0u8; 1024];
loop {
match stdin.read(&mut buf) {
Ok(0) | Err(_) => break,
Ok(n) => {
let chunk = buf.get(..n).unwrap_or(&buf).to_vec();
if input_tx.blocking_send(chunk).is_err() {
break;
}
}
}
}
})
.context("spawning stdin reader")?;
let (resize_tx, resize_rx) = mpsc::channel::<()>(8);
let mut sigwinch =
signal(SignalKind::window_change()).context("installing SIGWINCH handler")?;
tokio::spawn(async move {
while sigwinch.recv().await.is_some() {
if resize_tx.send(()).await.is_err() {
break;
}
}
});
let result = run_client(
channel,
connector,
args.predict.into(),
(rows, cols),
input_rx,
resize_rx,
term,
shutdown,
)
.await;
let _ = tokio::time::timeout(std::time::Duration::from_secs(2), endpoint.close()).await;
result
}