use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::process::Command;
const PREFIX: &str = "cctop";
pub const BIN: &str = "rmux";
const DAEMON_DEADLINE: std::time::Duration = std::time::Duration::from_secs(30);
fn on_daemon<T, F, Fut>(work: F) -> Result<T, String>
where
F: FnOnce(rmux_sdk::Rmux) -> Fut,
Fut: std::future::Future<Output = rmux_sdk::Result<T>>,
{
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.map_err(|error| format!("could not start a runtime for rmux: {error}"))?;
runtime.block_on(async move {
let rmux = rmux_sdk::Rmux::builder()
.default_timeout(DAEMON_DEADLINE)
.connect_or_start()
.await
.map_err(|error| format!("{error}"))?;
work(rmux).await.map_err(|error| format!("{error}"))
})
}
pub fn available() -> bool {
Command::new(BIN)
.arg("-V")
.output()
.is_ok_and(|out| out.status.success())
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Install {
pub manager: &'static str,
pub argv: Vec<String>,
}
impl Install {
pub fn shown(&self) -> &str {
self.argv.last().map(String::as_str).unwrap_or_default()
}
}
const MANAGERS: &[(&str, &str, &str)] = &[
("Homebrew", "brew", "brew install rmux"),
("cargo", "cargo", "cargo install rmux --locked"),
(
"rmux.io",
"curl",
"curl -fsSL https://rmux.io/install.sh | sh",
),
];
pub fn installer() -> Option<Install> {
let (manager, _, command) = MANAGERS
.iter()
.find(|(_, needs, _)| crate::shim::is_command(needs))?;
Some(Install {
manager,
argv: vec!["sh".to_string(), "-c".to_string(), command.to_string()],
})
}
pub fn attach_or_create(argv: &[String], name: &str, cwd: Option<&Path>) -> Vec<String> {
let mut out = vec![
BIN.to_string(),
"new-session".to_string(),
"-A".to_string(),
"-s".to_string(),
name.to_string(),
];
if let Some(dir) = cwd.filter(|d| d.is_dir()) {
out.push("-c".into());
out.push(dir.to_string_lossy().into_owned());
}
out.push("--".into());
out.extend(argv.iter().cloned());
out
}
const HISTORY_LINES: &str = "50000";
pub fn prepare(argv: &[String], name: &str, cwd: Option<&Path>) {
crate::elog::event(
"rmux",
"prepare",
serde_json::json!({ "session": name, "cmd": argv.join(" ") }),
);
if exists(name) {
return;
}
let dir = cwd.filter(|d| d.is_dir()).map(|d| d.to_string_lossy());
let mut create: Vec<String> = ["new-session", "-d", "-P", "-F", "#{window_id}", "-s", name]
.into_iter()
.map(str::to_string)
.collect();
if let Some(dir) = &dir {
create.extend(["-c", dir].map(str::to_string));
}
for (var, value) in crate::opencode::launch_env(argv) {
create.push("-e".into());
create.push(format!("{var}={value}"));
}
create.extend(["--", "sleep", "86400"].map(str::to_string));
let Ok(out) = Command::new(BIN).args(&create).output() else {
return;
};
if !out.status.success() {
return;
}
let placeholder = String::from_utf8_lossy(&out.stdout).trim().to_string();
let _ = Command::new(BIN)
.args(["set-option", "-t", name, "history-limit", HISTORY_LINES])
.args([";", "set-option", "-t", name, "mouse", "on"])
.args([";", "set-option", "-t", name, "status", "off"])
.args([";", "set-option", "-t", name, "window-size", "latest"])
.output();
let mut window = vec!["new-window", "-t", name];
if let Some(dir) = &dir {
window.extend(["-c", dir]);
}
window.push("--");
window.extend(argv.iter().map(String::as_str));
let made = Command::new(BIN)
.args(&window)
.output()
.is_ok_and(|out| out.status.success());
if !made {
let _ = kill(name);
return;
}
let _ = Command::new(BIN)
.args(["kill-window", "-t", &placeholder])
.output();
}
pub fn start_detached(argv: &[String], name: &str, cwd: Option<&Path>) -> Result<(), String> {
prepare(argv, name, cwd);
if exists(name) {
return Ok(());
}
let mut create = vec![
"new-session".to_string(),
"-d".to_string(),
"-s".to_string(),
name.to_string(),
];
if let Some(dir) = cwd.filter(|d| d.is_dir()) {
create.push("-c".into());
create.push(dir.to_string_lossy().into_owned());
}
create.push("--".into());
create.extend(argv.iter().cloned());
let out = Command::new(BIN)
.args(&create)
.output()
.map_err(|e| format!("rmux: {e}"))?;
if out.status.success() {
return Ok(());
}
Err(String::from_utf8_lossy(&out.stderr).trim().to_string())
}
pub fn attach(name: &str) -> Vec<String> {
vec![
BIN.to_string(),
"attach-session".to_string(),
"-t".to_string(),
format!("={name}"),
]
}
#[derive(Clone)]
pub struct Share {
pub operator: Option<String>,
pub pin: Option<String>,
}
fn web_share(name: &str, public: Option<&str>) -> Result<Share, String> {
share_with(name, false, None, public)
}
static PUBLIC: std::sync::Mutex<Option<(u16, crate::serve::tunnel::Tunnel)>> =
std::sync::Mutex::new(None);
fn public_origin() -> Result<String, String> {
let port = on_daemon(|rmux| async move { rmux.web_config().await })?.port;
let mut held = PUBLIC.lock().map_err(|_| "the share tunnel is poisoned")?;
if let Some((at, tunnel)) = held.as_ref()
&& *at == port
{
return Ok(tunnel.url.clone());
}
let tunnel = crate::serve::tunnel::start(port).map_err(|e| {
format!("{e}")
.lines()
.next()
.unwrap_or_default()
.to_string()
})?;
let url = tunnel.url.clone();
*held = Some((port, tunnel));
Ok(url)
}
pub fn share_link(
name: &str,
embedded: bool,
frontend: Option<&str>,
) -> Result<(Share, bool), String> {
share_link_with(name, embedded, frontend, false)
}
const SHARE_REUSE: std::time::Duration = std::time::Duration::from_secs(20 * 60);
const SHARE_TTL: std::time::Duration = std::time::Duration::from_secs(6 * 60 * 60);
pub fn share_link_with(
name: &str,
embedded: bool,
frontend: Option<&str>,
fresh: bool,
) -> Result<(Share, bool), String> {
type Reachable = (Share, bool, std::time::Instant);
type Key = (String, bool, Option<String>);
static CACHE: std::sync::Mutex<Option<HashMap<Key, Reachable>>> = std::sync::Mutex::new(None);
let key = (name.to_string(), embedded, frontend.map(str::to_string));
if !fresh
&& let Ok(cache) = CACHE.lock()
&& let Some((share, tunnelled, at)) = cache.as_ref().and_then(|c| c.get(&key))
&& at.elapsed() < SHARE_REUSE
{
return Ok((share.clone(), *tunnelled));
}
let mint = |public: Option<&str>| match embedded {
true => web_share_embedded(name, frontend, public),
false => web_share(name, public),
};
let made = match frontend.filter(|_| embedded) {
Some(origin) => {
let share = mint(None)?;
match is_loopback_url(origin) {
true => (share, false),
false => match share
.operator
.as_deref()
.and_then(|url| relay_link(url, origin))
{
Some(relayed) => (
Share {
operator: Some(relayed),
pin: share.pin,
},
true,
),
None => tunnelled(mint)?,
},
}
}
None => tunnelled(mint)?,
};
if let Ok(mut cache) = CACHE.lock() {
cache
.get_or_insert_with(HashMap::new)
.insert(key, (made.0.clone(), made.1, std::time::Instant::now()));
}
Ok(made)
}
fn tunnelled(
mint: impl Fn(Option<&str>) -> Result<Share, String>,
) -> Result<(Share, bool), String> {
match public_origin() {
Ok(origin) => Ok((mint(Some(&origin))?, true)),
Err(why) => Ok((mint(None).map_err(|_| why)?, false)),
}
}
static RELAYED: std::sync::Mutex<Option<std::collections::HashSet<u16>>> =
std::sync::Mutex::new(None);
pub fn relayable(port: u16) -> bool {
RELAYED
.lock()
.ok()
.and_then(|set| set.as_ref().map(|s| s.contains(&port)))
.unwrap_or(false)
}
fn relay_link(url: &str, origin: &str) -> Option<String> {
let (base, fragment) = url.split_once('#')?;
let (scheme, host) = match origin.split_once("://")? {
("https", rest) => ("wss", rest.split('/').next()?),
("http", rest) => ("ws", rest.split('/').next()?),
_ => return None,
};
let mut port_seen = None;
let params: Vec<String> = fragment
.split('&')
.map(|param| match param.strip_prefix("e=") {
Some(endpoint) => {
let rest = endpoint
.strip_prefix("ws://127.0.0.1:")
.or_else(|| endpoint.strip_prefix("ws://localhost:"));
match rest.and_then(|r| r.split_once('/')) {
Some((port, path)) if port.parse::<u16>().is_ok() => {
port_seen = port.parse::<u16>().ok();
format!("e={scheme}://{host}/rmux-ws/{port}/{path}")
}
_ => param.to_string(),
}
}
None => param.to_string(),
})
.collect();
let port = port_seen?;
if let Ok(mut set) = RELAYED.lock() {
set.get_or_insert_with(Default::default).insert(port);
}
Some(format!("{base}#{}", params.join("&")))
}
fn is_loopback_url(url: &str) -> bool {
let rest = url
.strip_prefix("https://")
.or_else(|| url.strip_prefix("http://"))
.unwrap_or(url);
let host = rest.split('/').next().unwrap_or_default();
let host = match host.strip_prefix('[') {
Some(v6) => v6.split(']').next().unwrap_or_default(),
None => host.rsplit_once(':').map_or(host, |(h, _)| h),
};
host == "localhost" || host == "::1" || host.starts_with("127.")
}
fn web_share_embedded(
name: &str,
frontend: Option<&str>,
public: Option<&str>,
) -> Result<Share, String> {
share_with(name, true, frontend, public)
}
fn share_with(
name: &str,
embedded: bool,
frontend: Option<&str>,
public: Option<&str>,
) -> Result<Share, String> {
let name = name.to_string();
let frontend = frontend.map(str::to_string);
let public = public.map(str::to_string);
on_daemon(move |rmux| async move {
let session = rmux.session(rmux_sdk::SessionName::new(name)?).await?;
let mut builder = session.share();
if let Some(public) = public {
builder = builder.tunnel_url(public);
}
if let Some(frontend) = frontend {
builder = builder.frontend_url(frontend);
}
builder = builder.ttl(SHARE_TTL);
if embedded {
builder = builder
.max_operators(8)
.operator_only()
.no_navbar()
.no_disclaimer()
.no_pin()
.dark_theme();
}
let handle = builder.await?;
Ok(Share {
operator: handle.operator_url().map(str::to_string),
pin: handle.operator_pairing_code().map(str::to_string),
})
})
.and_then(|share| match share.operator {
None => Err("the share came back without an operator link".to_string()),
Some(_) => Ok(share),
})
}
pub fn name_for_session(provider: &str, session_id: &str) -> String {
let id: String = session_id
.chars()
.map(|c| if c.is_ascii_alphanumeric() { c } else { '-' })
.collect();
sanitize(&format!("{PREFIX}-{provider}-{id}"))
}
pub fn free_name(label: &str) -> String {
let base = sanitize(&format!("{PREFIX}-{label}"));
if !exists(&base) {
return base;
}
(2..100)
.map(|n| format!("{base}-{n}"))
.find(|name| !exists(name))
.unwrap_or(base)
}
fn sanitize(text: &str) -> String {
let cleaned: String = text
.chars()
.map(|c| match c {
c if c.is_ascii_alphanumeric() || c == '-' || c == '_' => c,
_ => '-',
})
.collect();
let mut out = String::with_capacity(cleaned.len());
for c in cleaned.chars() {
if c == '-' && out.ends_with('-') {
continue;
}
out.push(c);
}
out.trim_matches('-').to_string()
}
pub fn exists(name: &str) -> bool {
Command::new(BIN)
.args(["has-session", "-t", &format!("={name}")])
.output()
.is_ok_and(|out| out.status.success())
}
pub fn mouse_wanted(name: &str) -> bool {
let Ok(out) = Command::new(BIN)
.args([
"list-panes",
"-a",
"-F",
"#{session_name} #{mouse_any_flag}",
])
.output()
else {
return false;
};
let want = format!("{name} 1");
String::from_utf8_lossy(&out.stdout)
.lines()
.any(|line| line == want)
}
pub fn kill(name: &str) -> Result<(), String> {
let out = Command::new(BIN)
.args(["kill-session", "-t", &format!("={name}")])
.output()
.map_err(|e| format!("rmux: {e}"))?;
crate::elog::event(
"rmux",
"kill",
serde_json::json!({ "session": name, "ok": out.status.success() }),
);
if out.status.success() {
return Ok(());
}
Err(String::from_utf8_lossy(&out.stderr).trim().to_string())
}
pub fn quiet(name: &str) {
let _ = Command::new(BIN)
.args(["set-option", "-t", &format!("={name}"), "status", "off"])
.output();
let _ = Command::new("rmux")
.args(["set-option", "-p", "-t", name, "allow-passthrough", "on"])
.output();
let _ = Command::new(BIN)
.args(["set-option", "-s", "set-clipboard", "on"])
.output();
}
pub fn set_label(name: &str, label: &str) {
let _ = Command::new("rmux")
.args(["set-option", "-t", name, "@cctop_label", label])
.output();
}
pub fn set_profile(name: &str, profile: &str) {
let _ = Command::new("rmux")
.args(["set-option", "-t", name, "@cctop_profile", profile])
.output();
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct State {
pub signal: crate::hook::Signal,
pub at: u64,
}
impl State {
pub fn is_current(&self, now: u64) -> bool {
let elapsed = std::time::Duration::from_secs(now.saturating_sub(self.at));
self.signal.is_current_after(elapsed)
}
fn encode(signal: crate::hook::Signal, at: u64) -> String {
format!("{}@{at}", signal.label())
}
fn decode(text: &str) -> Option<State> {
let (word, at) = text.rsplit_once('@')?;
Some(State {
signal: crate::hook::Signal::from_label(word)?,
at: at.parse().ok()?,
})
}
}
pub fn now_secs() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
pub fn set_state(name: &str, signal: crate::hook::Signal) {
let _ = Command::new(BIN)
.args([
"set-option",
"-t",
&format!("={name}"),
"@cctop_state",
&State::encode(signal, now_secs()),
])
.output();
}
pub fn set_order(name: &str, order: usize) {
let _ = Command::new(BIN)
.args(["set-option", "-t", name, "@cctop_order", &order.to_string()])
.output();
}
pub fn set_color(name: &str, color: &str) {
let _ = Command::new(BIN)
.args([
"set-option",
"-t",
&format!("={name}"),
"@cctop_color",
color,
])
.output();
}
pub fn set_tab(name: &str, tab: &str) {
let _ = Command::new(BIN)
.args(["set-option", "-t", &format!("={name}"), "@cctop_tab", tab])
.output();
}
pub fn set_pane(name: &str, pane: usize) {
let _ = Command::new(BIN)
.args([
"set-option",
"-t",
&format!("={name}"),
"@cctop_pane",
&pane.to_string(),
])
.output();
}
pub fn set_axis(names: &[&str], axis: Axis) {
let word = axis.as_str();
for name in names {
let _ = Command::new(BIN)
.args(["set-option", "-t", &format!("={name}"), "@cctop_axis", word])
.output();
}
}
pub fn running_in_tab_order() -> Vec<Running> {
in_tab_order(running())
}
pub fn in_tab_order_of(newest_first: Vec<Running>) -> Vec<Running> {
in_tab_order(newest_first)
}
fn in_tab_order(newest_first: Vec<Running>) -> Vec<Running> {
let mut out: Vec<Running> = newest_first.into_iter().rev().collect();
out.sort_by_key(|s| s.order.unwrap_or(u64::MAX));
out
}
pub fn mouse(name: &str) {
let _ = Command::new(BIN)
.args(["set-option", "-t", name, "mouse", "on"])
.output();
}
pub fn scroll_key(name: &str, code: crossterm::event::KeyCode) -> bool {
use crossterm::event::KeyCode;
let target = format!("={name}:");
let run = |args: &[&str]| {
Command::new(BIN)
.arg(args[0])
.args(["-t", &target])
.args(&args[1..])
.output()
};
let Ok(out) = run(&["display-message", "-p", "#{alternate_on} #{pane_in_mode}"]) else {
return false;
};
let (alternate, scrolled) = match String::from_utf8_lossy(&out.stdout).trim() {
"0 0" => (false, false),
"0 1" => (false, true),
_ => (true, false),
};
if alternate {
return false;
}
let args: &[&str] = match (code, scrolled) {
(KeyCode::PageUp, false) => &["copy-mode", "-eu"],
(KeyCode::Home, false) => {
return run(&["copy-mode", "-e"]).is_ok_and(|out| out.status.success())
&& run(&["send-keys", "-X", "history-top"]).is_ok_and(|out| out.status.success());
}
(KeyCode::Home, true) => &["send-keys", "-X", "history-top"],
(KeyCode::End, true) => &["send-keys", "-X", "cancel"],
_ => return false,
};
run(args).is_ok_and(|out| out.status.success())
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Running {
pub name: String,
pub pid: Option<u32>,
pub cwd: Option<PathBuf>,
pub attached: bool,
pub activity: Option<u64>,
pub label: Option<String>,
pub profile: Option<String>,
pub state: Option<State>,
pub order: Option<u64>,
pub color: Option<String>,
pub tab: Option<String>,
pub pane: Option<u64>,
pub axis: Option<String>,
pub window: Option<(u16, u16)>,
}
impl Running {
#[cfg(test)]
pub fn unrecorded(name: &str) -> Running {
Running {
name: name.to_string(),
pid: None,
cwd: None,
attached: false,
activity: None,
label: None,
profile: None,
order: None,
state: None,
color: None,
tab: None,
pane: None,
axis: None,
window: None,
}
}
pub fn axis(&self) -> Axis {
self.axis
.as_deref()
.and_then(Axis::parse)
.unwrap_or(Axis::Side)
}
}
pub fn tabbed(sessions: Vec<Running>) -> Vec<Vec<Running>> {
let named: Vec<String> = sessions.iter().filter_map(|s| s.tab.clone()).collect();
let mut tabs: Vec<(String, Vec<Running>)> = Vec::new();
for session in sessions {
let owner = match session.tab.clone() {
Some(tab) if named.iter().filter(|t| **t == tab).count() > 1 => tab,
_ => session.name.clone(),
};
match tabs.iter_mut().find(|(name, _)| *name == owner) {
Some((_, panes)) => panes.push(session),
None => tabs.push((owner, vec![session])),
}
}
for (_, panes) in &mut tabs {
panes.sort_by_key(|s| s.pane.unwrap_or(0));
}
tabs.into_iter().map(|(_, panes)| panes).collect()
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Axis {
Side,
Stacked,
}
impl Axis {
pub fn as_str(self) -> &'static str {
match self {
Axis::Side => "side",
Axis::Stacked => "stacked",
}
}
pub fn parse(word: &str) -> Option<Axis> {
match word {
"side" => Some(Axis::Side),
"stacked" => Some(Axis::Stacked),
_ => None,
}
}
}
pub fn running() -> Vec<Running> {
let Ok(out) = Command::new(BIN)
.args([
"list-panes",
"-a",
"-F",
"#{session_name}\t#{pane_pid}\t#{pane_current_path}\t#{session_attached}\t#{session_created}\t#{window_activity}\t#{@cctop_label}\t#{@cctop_profile}\t#{@cctop_order}\t#{@cctop_state}\t#{@cctop_color}\t#{@cctop_tab}\t#{@cctop_pane}\t#{@cctop_axis}\t#{window_width}\t#{window_height}",
])
.output()
else {
return Vec::new();
};
if !out.status.success() {
return Vec::new();
}
let prefix = format!("{PREFIX}-");
let mut found: Vec<(u64, Running)> = Vec::new();
for line in String::from_utf8_lossy(&out.stdout).lines() {
let mut parts = line.split('\t');
let Some(name) = parts
.next()
.map(str::trim)
.filter(|n| n.starts_with(&prefix))
else {
continue;
};
if found.iter().any(|(_, s)| s.name == name) {
continue;
}
let pid = parts.next().and_then(|p| p.trim().parse::<u32>().ok());
let cwd = parts
.next()
.map(str::trim)
.filter(|p| !p.is_empty())
.map(PathBuf::from);
let attached = parts.next().map(str::trim).is_some_and(|a| a != "0");
let created = parts
.next()
.and_then(|c| c.trim().parse::<u64>().ok())
.unwrap_or(0);
let activity = parts.next().and_then(|a| a.trim().parse::<u64>().ok());
let mut option = || {
parts
.next()
.map(str::trim)
.filter(|v| !v.is_empty())
.map(str::to_string)
};
let label = option();
let profile = option();
let order = option().and_then(|v| v.parse::<u64>().ok());
let state = option().as_deref().and_then(State::decode);
let color = option();
let tab = option();
let pane = option().and_then(|v| v.parse::<u64>().ok());
let axis = option();
let width = option().and_then(|v| v.parse::<u16>().ok());
let height = option().and_then(|v| v.parse::<u16>().ok());
let window = width.zip(height);
found.push((
created,
Running {
name: name.to_string(),
pid,
cwd,
attached,
activity,
label,
profile,
order,
state,
color,
tab,
pane,
axis,
window,
},
));
}
found.sort_by_key(|(created, _)| std::cmp::Reverse(*created));
found.into_iter().map(|(_, s)| s).collect()
}
pub fn window_size(name: &str) -> Option<(u16, u16)> {
let target = format!("={name}:");
let out = Command::new(BIN)
.args([
"display-message",
"-t",
&target,
"-p",
"#{window_width} #{window_height}",
])
.output()
.ok()?;
let text = String::from_utf8_lossy(&out.stdout);
let mut parts = text.split_whitespace().map(|v| v.parse::<u16>().ok());
parts.next().flatten().zip(parts.next().flatten())
}
pub fn clients(name: &str) -> Vec<(bool, u16, u16)> {
let Ok(out) = Command::new(BIN)
.args([
"list-clients",
"-t",
&format!("={name}"),
"-F",
"#{client_tty}\t#{client_width}\t#{client_height}",
])
.output()
else {
return Vec::new();
};
String::from_utf8_lossy(&out.stdout)
.lines()
.filter_map(|line| {
let mut parts = line.split('\t');
let tty = parts.next()?.trim();
let cols = parts.next()?.trim().parse().ok()?;
let rows = parts.next()?.trim().parse().ok()?;
Some((tty.is_empty(), cols, rows))
})
.collect()
}
pub fn sessions() -> Vec<String> {
running().into_iter().map(|s| s.name).collect()
}
pub fn agent_pid(name: &str) -> Option<u32> {
let out = Command::new(BIN)
.args([
"list-panes",
"-t",
name,
"-F",
"#{session_name}\t#{pane_pid}",
])
.output()
.ok()?;
if !out.status.success() {
return None;
}
String::from_utf8_lossy(&out.stdout)
.lines()
.filter_map(|line| line.split_once('\t'))
.find(|(session, _)| *session == name)
.and_then(|(_, pid)| pid.trim().parse().ok())
}
pub fn holding(pid: u32) -> Option<String> {
running()
.into_iter()
.find(|agent| agent.pid == Some(pid))
.map(|agent| agent.name)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Capture {
pub cols: u16,
pub rows: u16,
pub cursor: Option<(u16, u16)>,
pub bytes: Vec<u8>,
}
pub fn capture(name: &str) -> Option<Capture> {
let out = Command::new(BIN)
.args([
"list-panes",
"-t",
name,
"-F",
"#{session_name}\t#{pane_id}\t#{pane_width}\t#{pane_height}\t#{cursor_x}\t#{cursor_y}\t#{cursor_flag}",
])
.output()
.ok()?;
if !out.status.success() {
return None;
}
let listing = String::from_utf8_lossy(&out.stdout);
let fields: Vec<&str> = listing
.lines()
.map(|line| line.split('\t').collect::<Vec<_>>())
.find(|f| f.len() == 7 && f[0] == name)?;
let num = |i: usize| fields[i].trim().parse::<u16>().ok();
let (cols, rows) = (num(2)?, num(3)?);
let cursor = match fields[6].trim() {
"0" => None,
_ => Some((num(5)?, num(4)?)),
};
let out = Command::new(BIN)
.args(["capture-pane", "-p", "-e", "-t", fields[1]])
.output()
.ok()?;
if !out.status.success() {
return None;
}
Some(Capture {
cols,
rows,
cursor,
bytes: capture_bytes(&out.stdout),
})
}
fn capture_bytes(raw: &[u8]) -> Vec<u8> {
let raw = raw.strip_suffix(b"\n").unwrap_or(raw);
let mut bytes = Vec::with_capacity(raw.len() + raw.len() / 32);
for (i, line) in raw.split(|&b| b == b'\n').enumerate() {
if i > 0 {
bytes.extend_from_slice(b"\r\n");
}
bytes.extend_from_slice(line);
}
bytes
}
#[cfg(test)]
pub fn test_lock() -> std::sync::MutexGuard<'static, ()> {
static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
LOCK.lock().unwrap_or_else(|poisoned| poisoned.into_inner())
}
#[cfg(test)]
mod tests {
#[test]
fn a_share_for_a_page_elsewhere_goes_through_the_pages_own_origin() {
let url = "https://x.trycloudflare.com/term/#e=ws://127.0.0.1:45691/share&t=abc&navbar=off";
let relayed = super::relay_link(url, "https://x.trycloudflare.com/term/").expect("relayed");
assert_eq!(
relayed,
"https://x.trycloudflare.com/term/#e=wss://x.trycloudflare.com/rmux-ws/45691/share&t=abc&navbar=off"
);
assert!(super::relayable(45691));
assert!(!super::relayable(22), "only ports a share named");
let lan = super::relay_link(
"http://10.0.0.5:7778/term/#e=ws://127.0.0.1:5000/share&t=x",
"http://10.0.0.5:7778/term/",
);
assert_eq!(
lan.as_deref(),
Some("http://10.0.0.5:7778/term/#e=ws://10.0.0.5:7778/rmux-ws/5000/share&t=x")
);
assert!(
super::relay_link(
"https://h/term/#e=wss://a.trycloudflare.com/share&t=x",
"https://h/term/"
)
.is_none()
);
}
#[test]
fn a_page_on_this_machine_is_told_from_one_reached_from_elsewhere() {
use super::is_loopback_url;
assert!(is_loopback_url("http://127.0.0.1:7778/term/"));
assert!(is_loopback_url("http://localhost:7778/term/"));
assert!(is_loopback_url("http://[::1]:7778/term/"));
assert!(!is_loopback_url("http://192.168.1.20:7778/term/"));
assert!(!is_loopback_url(
"https://quiet-river.trycloudflare.com/term/"
));
assert!(!is_loopback_url("http://localhost.example.com/term/"));
}
#[test]
fn a_capture_replays_without_scrolling_or_losing_colour() {
let raw = b"one\n\x1b[31mtwo\nthree\x1b[39m\n";
assert_eq!(
super::capture_bytes(raw),
b"one\r\n\x1b[31mtwo\r\nthree\x1b[39m".to_vec()
);
let mut parser = vt100::Parser::new(3, 10, 0);
parser.process(&super::capture_bytes(raw));
assert_eq!(parser.screen().contents(), "one\ntwo\nthree");
assert_eq!(
parser.screen().cell(2, 0).unwrap().fgcolor(),
vt100::Color::Idx(1)
);
}
#[test]
fn a_state_survives_being_written_down_and_read_back() {
use crate::hook::Signal;
for signal in [
Signal::NeedsInput,
Signal::Idle,
Signal::Busy,
Signal::Acting,
Signal::Compacting,
Signal::Started,
Signal::Ended,
] {
let text = State::encode(signal, 1_700_000_000);
assert_eq!(
State::decode(&text),
Some(State {
signal,
at: 1_700_000_000
}),
"{text} did not survive the round trip"
);
}
assert_eq!(State::decode(""), None, "an unset option says nothing");
assert_eq!(
State::decode("asking"),
None,
"a state with no time on it cannot be aged out, so it is not a state"
);
assert_eq!(
State::decode("dreaming@1700000000"),
None,
"a signal from a newer cctop is not guessed at"
);
}
#[test]
fn a_recorded_question_keeps_but_a_recorded_claim_to_be_working_lapses() {
let now = 1_700_000_000;
let hour_ago = |signal| State {
signal,
at: now - 3_600,
};
assert!(
hour_ago(crate::hook::Signal::NeedsInput).is_current(now),
"somebody back from lunch still has a prompt to answer"
);
assert!(hour_ago(crate::hook::Signal::Idle).is_current(now));
assert!(!hour_ago(crate::hook::Signal::Busy).is_current(now));
assert!(!hour_ago(crate::hook::Signal::Acting).is_current(now));
assert!(
State {
signal: crate::hook::Signal::Busy,
at: now - 60
}
.is_current(now),
"a turn a minute in is an ordinary turn"
);
}
#[test]
fn a_split_comes_back_as_one_tab_in_the_order_it_was_left() {
let pane_of = |name: &str, tab: Option<&str>, pane: Option<u64>| Running {
tab: tab.map(str::to_string),
pane,
..Running::unrecorded(name)
};
let groups = tabbed(vec![
pane_of("cctop-shell", Some("cctop-claude"), Some(1)),
pane_of("cctop-claude", Some("cctop-claude"), Some(0)),
]);
assert_eq!(groups.len(), 1, "two sessions of one tab are one tab");
let names: Vec<&str> = groups[0].iter().map(|s| s.name.as_str()).collect();
assert_eq!(names, ["cctop-claude", "cctop-shell"], "and in pane order");
let groups = tabbed(vec![
pane_of("cctop-claude", None, None),
pane_of("cctop-codex", None, None),
]);
assert_eq!(groups.len(), 2);
assert_eq!(groups[0][0].axis(), Axis::Side, "nothing said");
let stacked = pane_of("cctop-stacked", Some("cctop-stacked"), Some(0));
let mut stacked = stacked;
stacked.axis = Some("stacked".into());
assert_eq!(stacked.axis(), Axis::Stacked);
let mut odd = pane_of("cctop-odd", None, None);
odd.axis = Some("diagonal".into());
assert_eq!(odd.axis(), Axis::Side);
let groups = tabbed(vec![pane_of("cctop-orphan", Some("cctop-gone"), Some(1))]);
assert_eq!(groups.len(), 1);
assert_eq!(groups[0][0].name, "cctop-orphan");
}
#[test]
#[ignore = "needs a real rmux server"]
fn a_state_written_onto_a_session_survives_in_it() {
if !available() {
eprintln!("skipping: rmux not installed");
return;
}
let _turn = test_lock();
let ours = format!("cctop-state-{}", std::process::id());
let dir = std::env::temp_dir();
start_session(&ours, Some(&dir), &["sh", "-c", "sleep 30"]);
let before = wait_for(|| running().into_iter().find(|s| s.name == ours));
set_state(&ours, crate::hook::Signal::NeedsInput);
let after = wait_for(|| {
running()
.into_iter()
.find(|s| s.name == ours && s.state.is_some())
});
end_session(&ours);
assert_eq!(
before.map(|s| s.state),
Some(None),
"born reporting nothing about itself"
);
assert_eq!(
after.and_then(|s| s.state).map(|s| s.signal),
Some(crate::hook::Signal::NeedsInput),
"the held question did not come back off the session"
);
}
#[test]
#[ignore = "needs a real rmux server"]
fn an_order_written_onto_a_session_survives_in_it() {
if !available() {
eprintln!("skipping: rmux not installed");
return;
}
let _turn = test_lock();
let ours = format!("cctop-order-{}", std::process::id());
let dir = std::env::temp_dir();
start_session(&ours, Some(&dir), &["sh", "-c", "sleep 30"]);
let before = wait_for(|| running().into_iter().find(|s| s.name == ours));
set_order(&ours, 3);
let after = wait_for(|| {
running()
.into_iter()
.find(|s| s.name == ours && s.order.is_some())
});
end_session(&ours);
assert_eq!(before.map(|s| s.order), Some(None), "born with an order");
assert_eq!(
after.map(|s| s.order),
Some(Some(3)),
"the order did not come back off the session"
);
}
#[test]
fn arranged_tabs_lead_and_the_rest_stay_oldest_first() {
let at = |name: &str, order: Option<u64>| Running {
order,
..Running::unrecorded(name)
};
let found = vec![
at("newest", None),
at("arranged-second", Some(1)),
at("middle", None),
at("arranged-first", Some(0)),
at("oldest", None),
];
let names: Vec<String> = in_tab_order(found).into_iter().map(|s| s.name).collect();
assert_eq!(
names,
[
"arranged-first",
"arranged-second",
"oldest",
"middle",
"newest"
]
);
let plain = vec![at("newest", None), at("middle", None), at("oldest", None)];
let names: Vec<String> = in_tab_order(plain).into_iter().map(|s| s.name).collect();
assert_eq!(names, ["oldest", "middle", "newest"]);
}
#[test]
#[ignore = "needs a real rmux server"]
fn a_real_rmux_session_comes_back_with_an_operator_link() {
let _guard = test_lock();
if !available() {
eprintln!("skipping: rmux not installed");
return;
}
let name = format!("cctop-share-{}", std::process::id());
start_session(&name, None, &["sleep", "30"]);
let share = web_share(&name, None);
end_session(&name);
let share = share.expect("rmux shared the session");
let operator = share.operator.expect("an operator link");
assert!(operator.starts_with("https://"), "not a link: {operator}");
let endpoint = operator
.split_once('#')
.and_then(|(_, fragment)| fragment.split('&').find_map(|p| p.strip_prefix("e=")));
assert!(
endpoint.is_none_or(|e| e.starts_with("ws://127.0.0.1:")),
"a public endpoint appeared: {operator}"
);
assert!(
operator.contains("#t=") || operator.contains("&t="),
"no share token in the link: {operator}"
);
assert!(
!operator.contains("spectator"),
"that is not the operator link: {operator}"
);
}
#[test]
#[ignore = "needs a real rmux server and the internet"]
fn a_share_with_no_page_upgrades_through_the_quick_tunnel() {
let _guard = test_lock();
if !available() {
eprintln!("skipping: rmux not installed");
return;
}
let name = format!("cctop-tunnel-{}", std::process::id());
start_session(&name, None, &["sleep", "120"]);
let minting = std::time::Instant::now();
let share = share_link(&name, false, None);
eprintln!("minted in {:?}", minting.elapsed());
let share = share.inspect_err(|_| end_session(&name));
let (share, reachable) = share.expect("rmux shared the session");
let operator = share.operator.expect("an operator link");
let endpoint = operator
.split_once('#')
.and_then(|(_, fragment)| fragment.split('&').find_map(|p| p.strip_prefix("e=")))
.unwrap_or_default()
.to_string();
let mut answer = String::new();
let asked = std::time::Instant::now();
for _ in 0..30 {
let out = Command::new("curl")
.args(["-sS", "--http1.1", "-o", "/dev/null", "-w", "%{http_code}"])
.args(["--max-time", "5"])
.args(["-H", "Connection: Upgrade", "-H", "Upgrade: websocket"])
.args(["-H", "Sec-WebSocket-Version: 13"])
.args(["-H", "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ=="])
.args(["-H", "Origin: https://share.rmux.io"])
.arg(endpoint.replacen("wss://", "https://", 1))
.output();
answer = out
.map(|o| String::from_utf8_lossy(&o.stdout).into_owned())
.unwrap_or_default();
if answer == "101" {
break;
}
std::thread::sleep(std::time::Duration::from_secs(2));
}
end_session(&name);
eprintln!(
"endpoint {endpoint} answered {answer} after {:?}",
asked.elapsed()
);
assert!(reachable, "came back on loopback: {operator}");
assert!(
endpoint.starts_with("wss://") && endpoint.contains(".trycloudflare.com/"),
"not a quick-tunnel endpoint: {endpoint}"
);
assert_eq!(answer, "101", "the upgrade did not survive the tunnel");
}
use super::*;
#[test]
fn an_offered_install_is_a_command_that_could_run() {
let Some(install) = installer() else {
return;
};
assert_eq!(install.argv[0], "sh");
assert_eq!(install.argv[1], "-c");
assert_eq!(install.shown(), install.argv[2]);
assert!(install.shown().contains("rmux"));
if let Some(rest) = install.shown().strip_prefix("sudo ") {
assert!(crate::shim::is_command("sudo"));
assert!(!rest.contains("&& apt-get install") || rest.contains("&& sudo"));
}
}
#[test]
fn the_wrapper_attaches_or_creates_and_passes_the_agent_through() {
let argv: Vec<String> = ["claude", "--resume", "abc"]
.iter()
.map(|s| s.to_string())
.collect();
let dir = std::env::temp_dir();
let out = attach_or_create(&argv, "cctop-claude-abc", Some(&dir));
assert_eq!(
&out[..5],
&["rmux", "new-session", "-A", "-s", "cctop-claude-abc"]
);
let sep = out.iter().position(|a| a == "--").expect("separator");
assert_eq!(&out[sep + 1..], &argv[..]);
assert!(out.contains(&"-c".to_string()));
let gone = attach_or_create(&argv, "n", Some(Path::new("/nonexistent/gone")));
assert!(!gone.contains(&"-c".to_string()));
}
#[test]
fn a_session_always_maps_to_the_same_rmux_name() {
let a = name_for_session("claude", "32cca860-b503-43f8-8b95-7b75880abb8b");
let b = name_for_session("claude", "32cca860-b503-43f8-8b95-7b75880abb8b");
assert_eq!(a, b);
assert_ne!(a, name_for_session("claude", "other-id"));
assert_ne!(
a,
name_for_session("codex", "32cca860-b503-43f8-8b95-7b75880abb8b")
);
}
#[test]
fn two_sessions_never_share_a_name() {
let first = name_for_session("codex", "rollout-2026-08-06T16-47-27-019fd565-d014-7b71");
let second = name_for_session("codex", "rollout-2026-08-06T16-47-59-019fd999-aaaa-8c62");
assert_ne!(
first, second,
"two Codex sessions from the same minute share a rmux session"
);
assert_ne!(
name_for_session("claude", "32cca860-b503-43f8-8b95-7b75880abb8a"),
name_for_session("claude", "32cca860-b503-43f8-8b95-7b75880abb8b")
);
assert_ne!(
name_for_session("code", "x-abc"),
name_for_session("codex", "abc")
);
}
#[test]
fn names_carry_nothing_rmux_would_read_as_an_address() {
for name in [
name_for_session("claude", "/home/x/proj:1.2"),
free_name("claude --resume /a/b"),
sanitize("--leading and trailing--"),
] {
assert!(
!name.contains(['.', ':', ' ', '/']),
"unsafe rmux name: {name}"
);
assert!(!name.starts_with('-') && !name.ends_with('-'), "{name}");
assert!(!name.contains("--"), "collapsed runs: {name}");
}
}
#[test]
fn names_stay_inside_the_cctop_namespace() {
assert!(name_for_session("claude", "abc").starts_with("cctop-"));
assert!(free_name("zsh").starts_with("cctop-"));
}
#[test]
#[ignore = "needs a real rmux server"]
fn a_session_reports_the_agent_inside_it() {
if !available() {
eprintln!("skipping: rmux not installed");
return;
}
let _turn = test_lock();
let ours = format!("cctop-probe-{}", std::process::id());
let theirs = format!("outside-probe-{}", std::process::id());
let dir = std::env::temp_dir();
for name in [&ours, &theirs] {
start_session(name, Some(&dir), &["sh", "-c", "sleep 30"]);
}
let found = wait_for(|| running().into_iter().find(|s| s.name == ours));
let pid = agent_pid(&ours);
let listed = sessions();
let outsider = running().into_iter().any(|s| s.name == theirs);
let back = pid.and_then(holding);
quiet(&ours);
let status = Command::new(BIN)
.args(["show-options", "-t", &ours, "status"])
.output()
.ok()
.map(|out| String::from_utf8_lossy(&out.stdout).trim().to_string());
for name in [&ours, &theirs] {
end_session(name);
}
let found = found.expect("the session cctop just created was not listed");
assert_eq!(found.pid, pid, "the two ways of asking disagreed");
assert!(found.pid.is_some_and(|p| p > 0), "no agent pid: {found:?}");
assert_eq!(found.cwd.as_deref(), Some(dir.as_path()));
assert!(!found.attached);
assert!(listed.contains(&ours));
assert_eq!(back.as_deref(), Some(ours.as_str()));
assert_eq!(status.as_deref(), Some("status off"), "status bar left on");
assert!(!outsider, "a session outside cctop's namespace was listed");
assert!(!listed.contains(&theirs));
assert_eq!(agent_pid(&ours), None);
assert!(!exists(&ours));
}
#[test]
#[ignore = "needs a real rmux server"]
fn a_session_remembers_which_account_its_agent_runs_as() {
if !available() {
eprintln!("skipping: rmux not installed");
return;
}
let _turn = test_lock();
let named = format!("cctop-probe-named-{}", std::process::id());
let plain = format!("cctop-probe-plain-{}", std::process::id());
for name in [&named, &plain] {
start_session(name, None, &["sh", "-c", "sleep 30"]);
}
set_profile(&named, "work");
let found = wait_for(|| running().into_iter().find(|s| s.name == named));
let bare = running().into_iter().find(|s| s.name == plain);
for name in [&named, &plain] {
end_session(name);
}
let found = found.expect("the session cctop just created was not listed");
assert_eq!(found.profile.as_deref(), Some("work"));
assert_eq!(found.label, None);
assert_eq!(bare.and_then(|s| s.profile), None);
}
#[test]
#[ignore = "needs a real rmux server"]
fn a_session_remembers_the_colour_its_tab_was_painted() {
if !available() {
eprintln!("skipping: rmux not installed");
return;
}
let _turn = test_lock();
let ours = format!("cctop-probe-color-{}", std::process::id());
start_session(&ours, None, &["sh", "-c", "sleep 30"]);
let before = wait_for(|| running().into_iter().find(|s| s.name == ours));
set_color(&ours, "violet");
let painted = wait_for(|| {
running()
.into_iter()
.find(|s| s.name == ours && s.color.is_some())
});
set_color(&ours, "");
let cleared = wait_for(|| {
running()
.into_iter()
.find(|s| s.name == ours && s.color.is_none())
});
end_session(&ours);
assert_eq!(
before
.expect("the session cctop just created was not listed")
.color,
None,
"a fresh session claimed a colour"
);
assert_eq!(
painted.and_then(|s| s.color).as_deref(),
Some("violet"),
"the colour written did not come back"
);
assert!(cleared.is_some(), "the cleared colour was still reported");
}
#[test]
#[ignore = "needs a real rmux server"]
fn a_prepared_session_holds_the_agent_with_room_to_scroll_back() {
if !available() {
eprintln!("skipping: rmux not installed");
return;
}
let _turn = test_lock();
let name = format!("cctop-prep-{}", std::process::id());
let dir = std::env::temp_dir();
let argv = vec!["sh".to_string(), "-c".to_string(), "sleep 30".to_string()];
for _ in 0..20 {
prepare(&argv, &name, Some(&dir));
if exists(&name) {
break;
}
std::thread::sleep(std::time::Duration::from_millis(100));
}
let ask = |fmt: &str| {
Command::new(BIN)
.args(["display-message", "-p", "-t", &name, fmt])
.output()
.ok()
.map(|out| String::from_utf8_lossy(&out.stdout).trim().to_string())
};
let history = wait_for(|| ask("#{history_limit}"));
let windows = ask("#{session_windows}");
let mouse = ask("#{mouse}");
let agent = agent_pid(&name);
end_session(&name);
assert_eq!(
history.as_deref(),
Some(HISTORY_LINES),
"the agent's pane kept rmux's default scrollback"
);
assert_eq!(mouse.as_deref(), Some("1"), "the wheel would do nothing");
assert_eq!(
windows.as_deref(),
Some("1"),
"placeholder window left over"
);
assert!(agent.is_some_and(|p| p > 0), "no agent in the session");
}
#[test]
#[ignore = "needs a real rmux server"]
fn mouse_wanted_is_the_pane_programs_own_answer() {
if !available() {
eprintln!("skipping: rmux not installed");
return;
}
let _turn = test_lock();
let asked = format!("cctop-mw-yes-{}", std::process::id());
let asleep = format!("cctop-mw-no-{}", std::process::id());
start_session(
&asked,
None,
&["sh", "-c", "printf '\\033[?1000h'; sleep 30"],
);
start_session(&asleep, None, &["sleep", "30"]);
let wanted = wait_for(|| mouse_wanted(&asked).then_some(()));
let quiet = mouse_wanted(&asleep);
end_session(&asked);
end_session(&asleep);
assert!(wanted.is_some(), "the pane's DECSET never reached rmux");
assert!(!quiet, "a pane that never asked read as asking");
}
#[test]
#[ignore = "needs a real rmux server"]
fn a_detached_session_that_is_printing_never_reads_as_quiet() {
if !available() {
eprintln!("skipping: rmux not installed");
return;
}
let _turn = test_lock();
let ours = format!("cctop-probe-busy-{}", std::process::id());
start_session(
&ours,
None,
&["sh", "-c", "while true; do date; sleep 0.2; done"],
);
let now = || {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
};
let reading = || {
running()
.into_iter()
.find(|s| s.name == ours)
.and_then(|s| s.activity)
};
let first = wait_for(reading);
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
let mut last = None;
let moved = first.and_then(|first| {
loop {
let asked_at = now();
let later = reading();
last = later.map(|later| (later, asked_at));
if let Some(later) = later
&& later >= first + 2
&& asked_at.saturating_sub(later) <= 2
{
break Some(later);
}
if std::time::Instant::now() >= deadline {
break None;
}
std::thread::sleep(std::time::Duration::from_millis(200));
}
});
end_session(&ours);
let first = first.expect("no activity reported for a session that is printing");
assert!(
moved.is_some(),
"the clock stood still or lagged while the agent printed: \
first {first}, last (reading, asked at) {last:?}"
);
}
fn start_session(name: &str, cwd: Option<&Path>, command: &[&str]) {
let mut last = String::new();
for attempt in 0..20 {
if attempt > 0 {
std::thread::sleep(std::time::Duration::from_millis(100));
}
let mut rmux = Command::new(BIN);
rmux.args(["new-session", "-d", "-s", name]);
if let Some(dir) = cwd {
rmux.args(["-c", &dir.to_string_lossy()]);
}
rmux.arg("--").args(command);
match rmux.output() {
Ok(out) if out.status.success() => return,
Ok(out) => last = String::from_utf8_lossy(&out.stderr).trim().to_string(),
Err(e) => last = e.to_string(),
}
}
panic!("could not start {name}: {last}");
}
fn end_session(name: &str) {
let _ = kill(name);
wait_for(|| (!exists(name)).then_some(()));
}
fn wait_for<T>(mut f: impl FnMut() -> Option<T>) -> Option<T> {
for _ in 0..50 {
if let Some(v) = f() {
return Some(v);
}
std::thread::sleep(std::time::Duration::from_millis(100));
}
None
}
}