From 999134657c4b510d2be572398d4115a8c57c7bfe Mon Sep 17 00:00:00 2001 From: Anders Olsson Date: Fri, 31 Jul 2026 23:35:36 +0200 Subject: [PATCH] feat(service): agent status plus launchctl load/unload --- crates/xy/src/service.rs | 154 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 153 insertions(+), 1 deletion(-) diff --git a/crates/xy/src/service.rs b/crates/xy/src/service.rs index e4077c4..67238c8 100644 --- a/crates/xy/src/service.rs +++ b/crates/xy/src/service.rs @@ -3,9 +3,10 @@ use anyhow::{Context, Result}; use service_manager::{ LaunchdServiceManager, RestartPolicy, ServiceInstallCtx, ServiceLabel, ServiceManager, - ServiceUninstallCtx, + ServiceStatus, ServiceStatusCtx, ServiceUninstallCtx, }; use std::path::{Component, Path, PathBuf}; +use std::time::SystemTime; pub(crate) const DEFAULT_LABEL: &str = "se.aceofba.xy"; @@ -115,6 +116,124 @@ pub(crate) fn uninstall(label: &str) -> Result<()> { .context("uninstall launchd agent") } +#[derive(Debug, PartialEq, Eq)] +pub(crate) enum AgentState { + NotInstalled, + Stopped, + Running, +} + +pub(crate) struct AgentStatus { + pub label: String, + pub plist: PathBuf, + pub state: AgentState, + pub program: Option, + pub path_env: Option, + pub snapshotted: Option, + pub pid: Option, +} + +pub(crate) fn read_pid(pidfile: &Path) -> Option { + std::fs::read_to_string(pidfile) + .ok()? + .trim() + .parse::() + .ok() +} + +pub(crate) fn status(label: &str, pidfile: &Path) -> Result { + let plist = plist_path_for(label)?; + + if !plist.exists() { + return Ok(AgentStatus { + label: label.to_string(), + plist, + state: AgentState::NotInstalled, + program: None, + path_env: None, + snapshotted: None, + pid: None, + }); + } + + let snapshotted = std::fs::metadata(&plist) + .and_then(|meta| meta.modified()) + .ok(); + + let parsed: ServiceLabel = label.parse().context("parse service label")?; + + let state = match manager().status(ServiceStatusCtx { label: parsed })? { + ServiceStatus::Running => AgentState::Running, + ServiceStatus::Stopped(_) => AgentState::Stopped, + ServiceStatus::NotInstalled => AgentState::NotInstalled, + }; + + let pid = if state == AgentState::Running { + read_pid(pidfile) + } else { + None + }; + + let (program, path_env) = read_plist_fields(&plist); + + Ok(AgentStatus { + label: label.to_string(), + plist, + state, + program, + path_env, + snapshotted, + pid, + }) +} + +fn read_plist_fields(plist: &Path) -> (Option, Option) { + let Ok(contents) = std::fs::read_to_string(plist) else { + return (None, None); + }; + + let program = contents + .split("ProgramArguments") + .nth(1) + .and_then(|rest| rest.split("").nth(1)) + .and_then(|rest| rest.split("").next()) + .map(PathBuf::from); + + let path_env = contents + .split("PATH") + .nth(1) + .and_then(|rest| rest.split("").nth(1)) + .and_then(|rest| rest.split("").next()) + .map(str::to_string); + + (program, path_env) +} + +fn launchctl(verb: &str, plist: &Path) -> Result<()> { + let output = std::process::Command::new("launchctl") + .arg(verb) + .arg(plist) + .output() + .with_context(|| format!("run launchctl {verb}"))?; + + if !output.status.success() { + anyhow::bail!( + "launchctl {verb} failed: {}", + String::from_utf8_lossy(&output.stderr).trim() + ); + } + + Ok(()) +} + +pub(crate) fn start(label: &str) -> Result<()> { + launchctl("load", &plist_path_for(label)?) +} + +pub(crate) fn stop(label: &str) -> Result<()> { + launchctl("unload", &plist_path_for(label)?) +} + #[cfg(test)] mod tests { use super::*; @@ -200,4 +319,37 @@ mod tests { assert!(err.contains("macOS-only")); } + + #[test] + fn status_reports_not_installed_when_plist_is_absent() { + let tmp = tempfile::tempdir().unwrap(); + + let status = status("se.aceofba.xy-absent", &tmp.path().join("xy.pid")).unwrap(); + + assert!(matches!(status.state, AgentState::NotInstalled)); + assert!(status.program.is_none()); + assert!(status.pid.is_none()); + } + + #[test] + fn read_pid_parses_a_pidfile() { + let tmp = tempfile::tempdir().unwrap(); + let pidfile = tmp.path().join("xy.pid"); + + std::fs::write(&pidfile, "4821\n").unwrap(); + + assert_eq!(read_pid(&pidfile), Some(4821)); + } + + #[test] + fn read_pid_returns_none_for_missing_or_garbage() { + let tmp = tempfile::tempdir().unwrap(); + let missing = tmp.path().join("nope.pid"); + let garbage = tmp.path().join("garbage.pid"); + + std::fs::write(&garbage, "not-a-pid").unwrap(); + + assert_eq!(read_pid(&missing), None); + assert_eq!(read_pid(&garbage), None); + } }