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() })))
}
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);
}
}
pub fn connect(name: Option<&str>) -> Option<UnixStream> {
let want = name.map(sanitize);
let mut socks: Vec<(std::time::SystemTime, PathBuf)> = std::fs::read_dir(socket_dir())
.ok()?
.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));
for (_, path) in socks {
match UnixStream::connect(&path) {
Ok(s) => return Some(s),
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().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 {
if let Some(mut s) = connect(name) {
s.set_read_timeout(None)?;
writeln!(s, "follow")?;
for line in BufReader::new(s).lines() {
let Ok(line) = line else { break };
emit(&line)?;
}
}
emit(IDLE_JSON)?;
std::thread::sleep(Duration::from_secs(1));
}
}
pub fn ctl(name: Option<&str>, command: &crate::cli::CtlCommand) -> Result<(), String> {
let line = command.line()?;
let mut s = connect(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())?;
match reply.strip_prefix("error: ") {
Some(e) => Err(e.to_string()),
None => 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 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);
}
}