//! Named session registry stored at ~/.local/state/ptyctl/sessions/. use std::fs; use std::path::PathBuf; use anyhow::{Context, Result, bail}; use serde::{Deserialize, Serialize}; /// Information about a registered session. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SessionInfo { pub port: u16, pub pid: Option, pub command: Vec, pub cwd: String, pub started_at: String, } /// Get the session registry directory. fn registry_dir() -> Result { if let Ok(dir) = std::env::var("PTYCTL_SESSION_DIR") { return Ok(PathBuf::from(dir)); } let state_dir = dirs::state_dir() .or_else(dirs::data_local_dir) .context("cannot determine state directory")?; Ok(state_dir.join("ptyctl").join("sessions")) } /// Register a named session. pub fn register_session(name: &str, info: &SessionInfo) -> Result<()> { let dir = registry_dir()?; fs::create_dir_all(&dir).context("failed to create session registry directory")?; let path = dir.join(format!("{name}.json")); let json = serde_json::to_string_pretty(info)?; // Atomic write: write to temp file, then rename. let tmp = dir.join(format!(".{name}.json.tmp")); fs::write(&tmp, &json).context("failed to write session file")?; fs::rename(&tmp, &path).context("failed to rename session file")?; Ok(()) } /// Look up a named session. pub fn lookup_session(name: &str) -> Result { let dir = registry_dir()?; let path = dir.join(format!("{name}.json")); if !path.exists() { bail!("session '{name}' not found"); } let json = fs::read_to_string(&path).context("failed to read session file")?; let info: SessionInfo = serde_json::from_str(&json).context("failed to parse session file")?; Ok(info) } /// Remove a named session. pub fn unregister_session(name: &str) -> Result<()> { let dir = registry_dir()?; let path = dir.join(format!("{name}.json")); if path.exists() { fs::remove_file(&path).context("failed to remove session file")?; } Ok(()) } /// Check whether a registered session's ptyctl server is reachable. /// /// Probes `GET /query/status` and requires a 200 with the ptyctl status body /// shape — a bare TCP connect (or bare 200) would misread an unrelated /// process on a recycled port as live. Not PID-based because the recorded /// PID is the child, which may exit while a `--linger` server is still up. pub async fn server_alive(port: u16) -> bool { let Ok(client) = reqwest::Client::builder() .timeout(std::time::Duration::from_millis(500)) .build() else { return false; }; match client .get(format!("http://127.0.0.1:{port}/query/status")) .send() .await { // Require the status body shape, not just a 200, so wildcard-200 servers read dead. Ok(resp) if resp.status() == reqwest::StatusCode::OK => resp .json::() .await .is_ok_and(|v| v.get("size").is_some()), _ => false, } } /// List all registered sessions. pub fn list_sessions() -> Result> { let dir = registry_dir()?; if !dir.exists() { return Ok(Vec::new()); } let mut sessions = Vec::new(); for entry in fs::read_dir(&dir).context("failed to read session directory")? { let entry = entry?; let path = entry.path(); if path.extension().and_then(|e| e.to_str()) == Some("json") { let name = path .file_stem() .and_then(|s| s.to_str()) .unwrap_or("") .to_string(); if name.starts_with('.') { continue; // skip temp files } if let Ok(json) = fs::read_to_string(&path) && let Ok(info) = serde_json::from_str::(&json) { sessions.push((name, info)); } } } Ok(sessions) }