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;
#[cfg(feature = "cli")]
use clap::Args;
use tokio::signal::unix::{signal, SignalKind};
use tokio::sync::mpsc;
use tokio_util::sync::CancellationToken;
use crate::client::{
run_client_with, BackendTerminal, ClientState, ClientTerminal, DefaultBackend, IrohConnector,
};
use crate::transport_iroh::TERMINAL_ALPN;
#[derive(Debug, Clone)]
pub struct ConnectConfig {
pub server: String,
pub key_file: Option<PathBuf>,
pub direct: Option<SocketAddr>,
pub relay_url: Option<String>,
pub clipboard: bool,
pub bell_command: Option<String>,
}
impl ConnectConfig {
pub fn new(server: impl Into<String>) -> Self {
Self {
server: server.into(),
key_file: None,
direct: None,
relay_url: None,
clipboard: false,
bell_command: None,
}
}
}
#[derive(Debug, Clone)]
pub struct BellHook {
command: String,
last_count: u64,
last_spawn_ms: Option<u64>,
primed: bool,
}
pub const BELL_HOOK_MIN_INTERVAL_MS: u64 = 1_000;
impl BellHook {
pub fn new(command: impl Into<String>) -> Self {
Self {
command: command.into(),
last_count: 0,
last_spawn_ms: None,
primed: false,
}
}
pub fn prime(&mut self, count: u64) {
if !self.primed {
self.last_count = count;
self.primed = true;
}
}
pub fn observe(&mut self, count: u64, now_ms: u64) -> bool {
let rose = count > self.last_count;
self.last_count = count;
self.primed = true;
if !rose {
return false;
}
let spaced = self
.last_spawn_ms
.is_none_or(|t| now_ms.saturating_sub(t) >= BELL_HOOK_MIN_INTERVAL_MS);
if spaced {
self.last_spawn_ms = Some(now_ms);
}
spaced
}
pub fn observe_and_fire(&mut self, count: u64, title: &str, now_ms: u64) {
if self.observe(count, now_ms) {
self.fire(count, title);
}
}
pub(crate) fn command(
&self,
count: u64,
title: &str,
parent_env: impl IntoIterator<Item = (std::ffi::OsString, std::ffi::OsString)>,
) -> std::process::Command {
let mut cmd = std::process::Command::new("sh");
cmd.arg("-c").arg(&self.command).env_clear();
for (k, v) in parent_env {
if !crate::pty::is_koh_env_key(&k) {
cmd.env(k, v);
}
}
cmd.env("KOH_BELL_COUNT", count.to_string())
.env("KOH_TITLE", title)
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null());
cmd
}
pub fn fire(&self, count: u64, title: &str) {
match self.command(count, title, std::env::vars_os()).spawn() {
Ok(mut child) => {
std::thread::Builder::new()
.name("koh-bell-hook".into())
.spawn(move || {
let _ = child.wait();
})
.ok();
}
Err(e) => tracing::warn!(error = %e, "bell hook spawn failed"),
}
}
}
#[derive(Debug, Clone, Default)]
pub struct IdConfig {
pub key_file: Option<PathBuf>,
}
#[cfg(feature = "cli")]
#[derive(Args, Debug)]
pub struct ConnectArgs {
server: String,
#[arg(long)]
key_file: Option<PathBuf>,
#[arg(long, value_name = "IP:PORT", conflicts_with = "relay_url")]
direct: Option<SocketAddr>,
#[arg(long, value_name = "URL")]
relay_url: Option<String>,
#[arg(long)]
clipboard: bool,
#[arg(long, value_name = "CMD")]
on_bell: Option<String>,
}
#[cfg(feature = "cli")]
impl From<ConnectArgs> for ConnectConfig {
fn from(a: ConnectArgs) -> Self {
Self {
server: a.server,
key_file: a.key_file,
direct: a.direct,
relay_url: a.relay_url,
clipboard: a.clipboard,
bell_command: a.on_bell,
}
}
}
#[cfg(feature = "cli")]
#[derive(Args, Debug)]
pub struct IdArgs {
#[arg(long)]
key_file: Option<PathBuf>,
}
#[cfg(feature = "cli")]
impl From<IdArgs> for IdConfig {
fn from(a: IdArgs) -> Self {
Self {
key_file: a.key_file,
}
}
}
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(config: impl Into<IdConfig>) -> anyhow::Result<()> {
let args: IdConfig = config.into();
let key_file = match args.key_file {
Some(p) => p,
None => crate::transport_iroh::default_key_path("client")?,
};
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(config: impl Into<ConnectConfig>) -> anyhow::Result<Option<u32>> {
let args: ConnectConfig = config.into();
if let Ok(path) = std::env::var("KOH_LOG") {
let created = {
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
std::fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.mode(0o600)
.open(&path)
}
#[cfg(not(unix))]
{
std::fs::File::create(&path)
}
};
if let Ok(file) = created {
let secured = {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let ok = file
.set_permissions(std::fs::Permissions::from_mode(0o600))
.is_ok();
if !ok {
eprintln!(
"koh: warning: could not set $KOH_LOG to 0600; file logging disabled"
);
}
ok
}
#[cfg(not(unix))]
{
true
}
};
if secured {
let _ = 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()),
)
.try_init();
}
}
}
warn_if_locale_not_utf8();
let (channels, tasks) = super::spawn_client_io()?;
let clipboard_enabled = args.clipboard;
let result = connect_with(
args,
TERMINAL_ALPN,
move || {
let backend = DefaultBackend::new().context("acquiring the terminal")?;
BackendTerminal::enter(backend, clipboard_enabled)
.context("entering raw mode / alt screen")
},
channels.input_rx,
channels.resize_rx,
)
.await;
let cleanup = tasks.shutdown().await;
match (result, cleanup) {
(Err(primary), Err(cleanup)) => {
tracing::warn!(error = ?cleanup, "client I/O cleanup also failed");
Err(primary)
}
(Err(primary), Ok(())) => Err(primary),
(Ok(_), Err(cleanup)) => Err(cleanup),
(Ok(value), Ok(())) => Ok(value),
}
}
#[expect(
clippy::future_not_send,
reason = "the future owns a terminal backend (`impl KohBackend`, deliberately not `Send`) and \
is driven on the caller's own task, never sent across threads; requiring `Send` \
would force every backend and embedder to be `Send` for no benefit"
)]
pub async fn connect_with<S: ClientState, T: ClientTerminal<S>>(
config: ConnectConfig,
alpn: &'static [u8],
make_term: impl FnOnce() -> anyhow::Result<T>,
input_rx: mpsc::Receiver<Vec<u8>>,
resize_rx: mpsc::Receiver<()>,
) -> anyhow::Result<Option<u32>> {
let args = config;
let key_file = match args.key_file {
Some(p) => p,
None => crate::transport_iroh::default_key_path("client")?,
};
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 connector = IrohConnector::with_alpn(endpoint.clone(), target, alpn);
let channel =
match tokio::time::timeout(super::RECONNECT_CONNECT_TIMEOUT, connector.connect()).await {
Ok(r) => r?,
Err(_) => anyhow::bail!(
"timed out connecting to {} (the server may be unreachable or not responding)",
format_endpoint_id(&server_id)
),
};
eprintln!("connected. (Ctrl-^ then . to disconnect)");
let shutdown = CancellationToken::new();
spawn_signal_shutdown(shutdown.clone())?;
let term = make_term()?;
let (rows, cols) = term.size().unwrap_or((24, 80));
let result = run_client_with(
channel,
connector,
DisplayPreference::Always,
(rows, cols),
input_rx,
resize_rx,
term,
shutdown,
args.bell_command.map(BellHook::new),
)
.await;
let _ = tokio::time::timeout(std::time::Duration::from_secs(2), endpoint.close()).await;
result
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn bell_hook_fires_on_a_rise_and_rate_limits_a_burst() {
let mut h = BellHook::new("true");
let counts = [0u64, 1, 1, 2, 3];
let times = [0u64, 0, 10, 20, 1500];
let fired: Vec<bool> = counts
.iter()
.zip(times.iter())
.map(|(&c, &t)| h.observe(c, t))
.collect();
assert_eq!(fired, [false, true, false, false, true]);
assert!(!h.observe(3, 10_000));
}
#[test]
fn bell_hook_command_scrubs_parent_koh_vars_and_exports_its_own() {
use std::ffi::OsString;
let dir = std::env::temp_dir().join(format!(
"koh-bell-env-{}-{:?}",
std::process::id(),
std::thread::current().id()
));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("temp dir");
let out = dir.join("env.txt");
let hook = BellHook::new(format!("env > '{}'", out.display()));
let parent_env = [
("KOH_KEY_PASSPHRASE", "secret"),
("KOH_LOG", "/tmp/x"),
("PATH", "/usr/bin:/bin"),
("HOME", "/nonexistent"),
]
.into_iter()
.map(|(k, v)| (OsString::from(k), OsString::from(v)));
let status = hook
.command(42, "a title", parent_env)
.status()
.expect("spawn env");
let content = std::fs::read_to_string(&out).unwrap_or_default();
let _ = std::fs::remove_dir_all(&dir);
assert!(status.success(), "{content}");
assert!(content.contains("KOH_BELL_COUNT=42"), "{content}");
assert!(content.contains("KOH_TITLE=a title"), "{content}");
assert!(content.contains("PATH=/usr/bin:/bin"), "{content}");
assert!(
!content.contains("KOH_KEY_PASSPHRASE"),
"the passphrase leaked into the hook's env: {content}"
);
assert!(!content.contains("KOH_LOG"), "{content}");
}
#[test]
fn bell_hook_prime_swallows_the_count_it_is_seeded_with_but_not_later_rises() {
let mut h = BellHook::new("true");
h.prime(5);
assert!(!h.observe(5, 0), "the primed count is not a rise");
assert!(h.observe(6, 0), "a rise past the primed count fires");
h.prime(100);
assert!(
h.observe(7, 5_000),
"a second prime is ignored once a count was seen"
);
let mut g = BellHook::new("true");
assert!(
g.observe(3, 0),
"with no prime, the first rise from 0 fires"
);
g.prime(50);
assert!(g.observe(4, 5_000), "prime after observe is a no-op");
}
#[test]
fn connect_config_default_has_no_bell_hook() {
assert!(ConnectConfig::new("abc").bell_command.is_none());
}
#[cfg(feature = "cli")]
#[test]
fn connect_args_map_on_bell_to_bell_command() {
use clap::Parser;
#[derive(Parser)]
struct Cli {
#[command(flatten)]
connect: ConnectArgs,
}
let cli = Cli::parse_from(["koh", "abc", "--on-bell", "termux-notification"]);
let c: ConnectConfig = cli.connect.into();
assert_eq!(c.bell_command.as_deref(), Some("termux-notification"));
}
}