use std::{
fmt::Write as _,
io::{self, BufRead, BufReader, Write},
os::unix::{
fs::PermissionsExt,
net::{UnixListener, UnixStream},
},
path::{Path, PathBuf},
time::Duration,
};
pub const IDLE_JSON: &str = r#"{"text":"","tooltip":"no timer running","class":"idle"}"#;
pub fn socket_dir() -> PathBuf {
const MAX_DIR_LEN: usize = 60;
std::env::var_os("XDG_RUNTIME_DIR")
.filter(|v| !v.is_empty())
.map(|dir| PathBuf::from(dir).join("tminus"))
.filter(|p| p.as_os_str().len() <= MAX_DIR_LEN)
.unwrap_or_else(|| PathBuf::from("/tmp").join(format!("tminus-{}", unsafe { libc::getuid() })))
}
const ADJECTIVES: &[&str] = &[
"agile", "amber", "bold", "brave", "bright", "brisk", "calm", "clever", "cosmic", "crisp", "daring", "eager",
"early", "fearless", "fiery", "gentle", "golden", "grand", "happy", "keen", "lively", "lucid", "lunar", "mellow",
"mighty", "nimble", "noble", "patient", "polar", "proud", "quick", "quiet", "radiant", "rapid", "ready", "serene",
"sharp", "silent", "silver", "solar", "steady", "stellar", "swift", "tidy", "vivid", "warm", "wise", "zesty",
];
const NOUNS: &[&str] = &[
"apogee",
"asteroid",
"aurora",
"beacon",
"booster",
"capsule",
"comet",
"cosmos",
"countdown",
"eclipse",
"equinox",
"galaxy",
"gantry",
"horizon",
"ignition",
"lander",
"liftoff",
"meteor",
"module",
"moon",
"nebula",
"nova",
"orbit",
"payload",
"perigee",
"planet",
"probe",
"pulsar",
"quasar",
"rocket",
"rover",
"satellite",
"shuttle",
"signal",
"solstice",
"star",
"station",
"sunrise",
"telescope",
"thruster",
"trajectory",
"vector",
"voyager",
"zenith",
"launchpad",
"starlight",
"spacewalk",
"stardust",
];
pub fn free_name(rng: &mut impl rand::Rng, base: Option<&str>) -> String {
pick_name(rng, base, |name| running_pid(name).is_some())
}
fn pick_name(rng: &mut impl rand::Rng, base: Option<&str>, taken: impl Fn(&str) -> bool) -> String {
use rand::seq::IndexedRandom;
let base = base.and_then(slug);
if let Some(b) = &base
&& !taken(b)
{
return b.clone();
}
let mut pick = || {
let adjective = ADJECTIVES.choose(rng).copied().unwrap_or("quick");
let noun = match &base {
Some(b) => b.as_str(),
None => NOUNS.choose(rng).copied().unwrap_or("timer"),
};
format!("{adjective}-{noun}")
};
for _ in 0..20 {
let name = pick();
if !taken(&name) {
return name;
}
}
format!("{}-{}", pick(), std::process::id())
}
pub fn running_pid(name: &str) -> Option<u32> {
sockets(Some(name)).into_iter().find_map(|path| {
let (_, pid) = sock_name_and_pid(&path)?;
if UnixStream::connect(&path).is_ok() {
Some(pid)
} else {
let _ = std::fs::remove_file(&path);
None
}
})
}
pub fn slug(text: &str) -> Option<String> {
let s = sanitize(text);
let mut out = String::new();
for part in s.split('-').filter(|p| !p.is_empty()) {
if out.len() + part.len() + 1 > 32 {
break;
}
if !out.is_empty() {
out.push('-');
}
out.push_str(part);
}
(!out.is_empty()).then_some(out)
}
pub fn sanitize(name: &str) -> String {
let s: String = name
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
c.to_ascii_lowercase()
} else {
'-'
}
})
.collect();
if s.is_empty() { "tminus".into() } else { s }
}
pub fn json_str(s: &str) -> String {
let mut out = String::with_capacity(s.len() + 2);
out.push('"');
for c in s.chars() {
match c {
'"' => out.push_str("\\\""),
'\\' => out.push_str("\\\\"),
'\n' => out.push_str("\\n"),
c if (c as u32) < 0x20 => {
let _ = write!(out, "\\u{:04x}", c as u32);
}
c => out.push(c),
}
}
out.push('"');
out
}
pub struct Request {
pub line: String,
pub stream: UnixStream,
}
pub struct Server {
listener: UnixListener,
path: PathBuf,
followers: Vec<UnixStream>,
last_sent: String,
}
impl Server {
pub fn start(name: &str) -> io::Result<Server> {
let dir = socket_dir();
std::fs::create_dir_all(&dir)?;
std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700))?;
let path = dir.join(format!("{}.{}.sock", sanitize(name), std::process::id()));
let _ = std::fs::remove_file(&path);
let listener = UnixListener::bind(&path)?;
listener.set_nonblocking(true)?;
Ok(Server {
listener,
path,
followers: vec![],
last_sent: String::new(),
})
}
pub fn accept(&mut self) -> Vec<Request> {
let mut out = vec![];
while let Ok((stream, _)) = self.listener.accept() {
let _ = stream.set_nonblocking(false);
let _ = stream.set_read_timeout(Some(Duration::from_millis(100)));
let _ = stream.set_write_timeout(Some(Duration::from_millis(100)));
let mut line = String::new();
if let Ok(reader) = stream.try_clone()
&& BufReader::new(reader).read_line(&mut line).is_ok()
{
out.push(Request {
line: line.trim().to_string(),
stream,
});
}
}
out
}
pub fn follow(&mut self, mut stream: UnixStream, status: &str) {
if writeln!(stream, "{status}").is_ok() {
self.followers.push(stream);
}
}
pub fn publish(&mut self, status: &str) {
if status == self.last_sent {
return;
}
self.last_sent = status.to_string();
self.followers.retain_mut(|s| writeln!(s, "{status}").is_ok());
}
}
impl Drop for Server {
fn drop(&mut self) {
let _ = std::fs::remove_file(&self.path);
}
}
fn sockets(name: Option<&str>) -> Vec<PathBuf> {
let want = name.map(sanitize);
let Ok(dir) = std::fs::read_dir(socket_dir()) else {
return vec![];
};
let mut socks: Vec<(std::time::SystemTime, PathBuf)> = dir
.filter_map(Result::ok)
.map(|e| e.path())
.filter(|p| {
let Some((sock_name, _pid)) = sock_name_and_pid(p) else {
return false;
};
want.as_deref().is_none_or(|w| w == sock_name)
})
.filter_map(|p| Some((p.metadata().ok()?.modified().ok()?, p)))
.collect();
socks.sort_by_key(|(modified, _)| std::cmp::Reverse(*modified));
socks.into_iter().map(|(_, path)| path).collect()
}
pub fn connect(name: Option<&str>) -> Option<UnixStream> {
connect_path(name).map(|(s, _)| s)
}
fn connect_path(name: Option<&str>) -> Option<(UnixStream, PathBuf)> {
for path in sockets(name) {
match UnixStream::connect(&path) {
Ok(s) => return Some((s, path)),
Err(_) => {
let _ = std::fs::remove_file(&path);
}
}
}
None
}
fn sock_name_and_pid(p: &Path) -> Option<(String, u32)> {
let stem = p.file_name()?.to_str()?.strip_suffix(".sock")?;
let (name, pid) = stem.rsplit_once('.')?;
Some((name.to_string(), pid.parse().ok()?))
}
pub fn request(stream: &mut UnixStream, line: &str) -> io::Result<String> {
stream.set_read_timeout(Some(Duration::from_secs(2)))?;
writeln!(stream, "{line}")?;
let mut reply = String::new();
BufReader::new(stream.try_clone()?).read_line(&mut reply)?;
Ok(reply.trim_end_matches(['\n', '\r']).to_string())
}
pub fn status(name: Option<&str>, follow: bool) -> io::Result<()> {
let mut out = io::stdout().lock();
if !follow {
let line = match connect(name) {
Some(mut s) => request(&mut s, "status")?,
None => IDLE_JSON.to_string(),
};
return writeln!(out, "{line}");
}
let mut last = String::new();
let mut emit = |line: &str| -> io::Result<()> {
if line != last {
writeln!(out, "{line}")?;
out.flush()?;
last = line.to_string();
}
Ok(())
};
loop {
let mut newer = false;
if let Some((mut s, path)) = connect_path(name) {
writeln!(s, "follow")?;
s.set_read_timeout(Some(Duration::from_secs(1)))?;
let mut reader = BufReader::new(s);
let mut buf = Vec::new();
let mut checked = std::time::Instant::now();
loop {
match reader.read_until(b'\n', &mut buf) {
Ok(_) if buf.ends_with(b"\n") => {
emit(String::from_utf8_lossy(&buf).trim_end())?;
buf.clear();
}
Err(e) if matches!(e.kind(), io::ErrorKind::WouldBlock | io::ErrorKind::TimedOut) => {}
Ok(_) | Err(_) => break,
}
if name.is_none() && checked.elapsed() >= Duration::from_secs(1) {
checked = std::time::Instant::now();
if sockets(None).first().is_some_and(|p| *p != path) {
newer = true;
break;
}
}
}
}
if newer || !sockets(name).is_empty() {
std::thread::sleep(Duration::from_millis(50));
} else {
emit(IDLE_JSON)?;
std::thread::sleep(Duration::from_secs(1));
}
}
}
#[derive(Debug, PartialEq, Eq)]
struct Listed {
name: String,
pid: u32,
info: Option<[String; 5]>,
}
impl Listed {
fn fields(&self) -> [&str; 5] {
match &self.info {
Some([mode, state, clock, title, seg]) => [mode, state, clock, title, seg],
None => ["?", "?", "", "(older t-, try t- ctl status)", ""],
}
}
}
fn parse_info(line: &str) -> Option<[String; 5]> {
if line.starts_with("error: ") {
return None;
}
let fields: Vec<String> = line.split('\t').map(str::to_string).collect();
fields.try_into().ok()
}
fn running(name: Option<&str>) -> Vec<Listed> {
sockets(name)
.into_iter()
.filter_map(|path| {
let (name, pid) = sock_name_and_pid(&path)?;
let Ok(mut s) = UnixStream::connect(&path) else {
let _ = std::fs::remove_file(&path);
return None;
};
let info = request(&mut s, "info").ok().as_deref().and_then(parse_info);
Some(Listed { name, pid, info })
})
.collect()
}
pub fn names_for_completion() -> Vec<(String, String)> {
running(None)
.into_iter()
.map(|t| {
let [mode, state, clock, title, seg] = t.fields();
let mut help = format!("{mode}, {state} {clock}").trim_end().to_string();
for extra in [title, seg] {
if !extra.is_empty() {
help += " · ";
help += extra;
}
}
(t.name, help)
})
.collect()
}
fn list(name: Option<&str>, json: bool) -> io::Result<()> {
let timers = running(name);
let mut out = io::stdout().lock();
if json {
let items: Vec<String> = timers
.iter()
.enumerate()
.map(|(i, t)| {
let [mode, state, clock, title, seg] = t.fields();
format!(
r#"{{"name":{},"pid":{},"mode":{},"state":{},"clock":{},"title":{},"segment":{},"default":{}}}"#,
json_str(&t.name),
t.pid,
json_str(mode),
json_str(state),
json_str(clock),
json_str(title),
json_str(seg),
i == 0
)
})
.collect();
return writeln!(out, "[{}]", items.join(","));
}
if timers.is_empty() {
return writeln!(out, "no running timer");
}
let header = ["NAME", "PID", "MODE", "STATE", "CLOCK", "TITLE"];
let rows: Vec<[String; 6]> = timers
.iter()
.map(|t| {
let [mode, state, clock, title, seg] = t.fields();
let title = match (title, seg) {
(t, "") => t.to_string(),
("", s) => s.to_string(),
(t, s) => format!("{t} · {s}"),
};
[
t.name.clone(),
t.pid.to_string(),
mode.into(),
state.into(),
clock.into(),
title,
]
})
.collect();
let width = |col: usize| {
rows.iter()
.map(|r| r[col].chars().count())
.chain([header[col].len()])
.max()
.unwrap_or(0)
};
let widths: Vec<usize> = (0..header.len()).map(width).collect();
let line = |marker: &str, cells: &[&str]| {
let mut l = String::from(marker);
for (i, cell) in cells.iter().enumerate() {
if i + 1 == cells.len() {
l += cell;
} else {
let _ = write!(l, "{cell:w$} ", w = widths[i]);
}
}
l.trim_end().to_string()
};
writeln!(out, "{}", line(" ", &header))?;
for (i, r) in rows.iter().enumerate() {
let cells: Vec<&str> = r.iter().map(String::as_str).collect();
writeln!(out, "{}", line(if i == 0 { "* " } else { " " }, &cells))?;
}
Ok(())
}
fn on_all(line: &str, done: &str) -> io::Result<()> {
let mut out = io::stdout().lock();
let mut found = false;
for path in sockets(None) {
let Some((name, pid)) = sock_name_and_pid(&path) else {
continue;
};
let Ok(mut s) = UnixStream::connect(&path) else {
let _ = std::fs::remove_file(&path);
continue;
};
found = true;
match request(&mut s, line) {
Ok(reply) if reply == "ok" => writeln!(out, "{done} {name} (pid {pid})")?,
Ok(reply) => writeln!(out, "couldn't {line} {name} (pid {pid}): {reply}")?,
Err(e) => writeln!(out, "couldn't {line} {name} (pid {pid}): {e}")?,
}
}
if !found {
writeln!(out, "no running timer")?;
}
Ok(())
}
pub fn ctl(name: Option<&str>, command: &crate::cli::CtlCommand) -> Result<(), String> {
use crate::cli::CtlCommand;
let printed = match command {
CtlCommand::Status { follow } => Some(status(name, *follow)),
CtlCommand::List { json } => Some(list(name, *json)),
CtlCommand::Pause { all: true } | CtlCommand::Resume { all: true } | CtlCommand::Stop { all: true }
if name.is_some() =>
{
return Err("--all acts on every timer; leave out --name (or --all)".into());
}
CtlCommand::Pause { all: true } => Some(on_all("pause", "paused")),
CtlCommand::Resume { all: true } => Some(on_all("resume", "resumed")),
CtlCommand::Stop { all: true } => Some(on_all("stop", "stopped")),
_ => None,
};
if let Some(result) = printed {
return match result {
Err(e) if e.kind() != io::ErrorKind::BrokenPipe => Err(e.to_string()),
_ => Ok(()),
};
}
let line = command.line()?;
let (mut s, path) = connect_path(name).ok_or_else(|| match name {
Some(n) => format!("no running timer called `{n}`"),
None => "no running timer".to_string(),
})?;
let reply = request(&mut s, &line).map_err(|e| e.to_string())?;
if let Some(e) = reply.strip_prefix("error: ") {
return Err(e.to_string());
}
if let Some((timer, pid)) = sock_name_and_pid(&path) {
println!("{} (pid {pid})", command.done(&timer));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn helpers() {
assert_eq!(sanitize("Deep Work!"), "deep-work-");
assert_eq!(sanitize(""), "tminus");
assert_eq!(json_str("a \"b\"\\\n"), r#""a \"b\"\\\n""#);
assert_eq!(
sock_name_and_pid(Path::new("/x/focus.123.sock")),
Some(("focus".into(), 123))
);
assert_eq!(sock_name_and_pid(Path::new("/x/other")), None);
}
#[test]
fn slugs() {
assert_eq!(slug("Tea break").as_deref(), Some("tea-break"));
assert_eq!(slug("Deep Work").as_deref(), Some("deep-work"));
assert_eq!(slug("pomodoro").as_deref(), Some("pomodoro"));
assert_eq!(slug("!? --"), None);
assert_eq!(
slug("a very long title that goes on and on and on").as_deref(),
Some("a-very-long-title-that-goes-on")
);
}
#[test]
fn names_for_unnamed_timers() {
for words in [ADJECTIVES, NOUNS] {
for w in words {
assert_eq!(sanitize(w), *w);
}
}
let rng = &mut rand::rng();
let none_taken = |_: &str| false;
let name = pick_name(rng, None, none_taken);
let (adjective, noun) = name.split_once('-').unwrap();
assert!(ADJECTIVES.contains(&adjective) && NOUNS.contains(&noun), "{name}");
assert_eq!(pick_name(rng, Some("Deep Work"), none_taken), "deep-work");
let name = pick_name(rng, Some("pomodoro"), |n| n == "pomodoro");
let (adjective, rest) = name.split_once('-').unwrap();
assert!(ADJECTIVES.contains(&adjective) && rest == "pomodoro", "{name}");
}
#[test]
fn info_replies() {
let fields = parse_info("headless\trunning\t02:41\ttea\t").unwrap();
assert_eq!(fields[0], "headless");
let t = Listed {
name: "tea".into(),
pid: 1,
info: Some(fields),
};
assert_eq!(t.fields(), ["headless", "running", "02:41", "tea", ""]);
assert_eq!(parse_info("error: unknown command `info` (try ...)"), None);
assert_eq!(parse_info("too\tfew"), None);
assert!(parse_info("window\trunning\t25:00\tdeep work\t").is_some());
}
#[test]
fn server_round_trip() {
let dir = std::env::temp_dir().join(format!("tminus-test-{}", std::process::id()));
unsafe { std::env::set_var("XDG_RUNTIME_DIR", &dir) };
let mut server = Server::start("unit test").unwrap();
let client = std::thread::spawn(|| {
let mut s = connect(Some("unit test")).expect("socket found");
request(&mut s, "toggle").unwrap()
});
let mut reqs = vec![];
for _ in 0..200 {
reqs = server.accept();
if !reqs.is_empty() {
break;
}
std::thread::sleep(Duration::from_millis(10));
}
let mut req = reqs.pop().expect("request received");
assert_eq!(req.line, "toggle");
writeln!(req.stream, "ok").unwrap();
assert_eq!(client.join().unwrap(), "ok");
drop(server);
let _ = std::fs::remove_dir_all(&dir);
}
}