use anyhow::{bail, Context, Result};
use std::fs;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
const LABEL_PREFIX: &str = "com.reeve";
pub fn label(service: &str) -> String {
format!("{LABEL_PREFIX}.{service}")
}
pub struct ServiceSpec {
pub service: String,
pub program: PathBuf,
pub args: Vec<String>,
pub log: PathBuf,
pub keep_alive: bool,
pub run_at_load: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Status {
Running,
Stopped,
Error,
}
impl Status {
pub fn as_str(&self) -> &'static str {
match self {
Status::Running => "running",
Status::Stopped => "stopped",
Status::Error => "error",
}
}
}
fn launch_agents_dir() -> Result<PathBuf> {
let home = dirs::home_dir().context("Could not determine home directory")?;
Ok(home.join("Library/LaunchAgents"))
}
fn plist_path(service: &str) -> Result<PathBuf> {
Ok(launch_agents_dir()?.join(format!("{}.plist", label(service))))
}
fn render_plist(spec: &ServiceSpec) -> String {
let label = label(&spec.service);
let mut args_xml = String::new();
args_xml.push_str(&format!(
" <string>{}</string>\n",
xml_escape(&spec.program.display().to_string())
));
for arg in &spec.args {
args_xml.push_str(&format!(" <string>{}</string>\n", xml_escape(arg)));
}
let keep_alive = if spec.keep_alive {
" <key>KeepAlive</key>\n <dict>\n <key>SuccessfulExit</key>\n <false/>\n </dict>\n"
} else {
""
};
let run_at_load = if spec.run_at_load { "true" } else { "false" };
let log = xml_escape(&spec.log.display().to_string());
format!(
r#"<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>{label}</string>
<key>ProgramArguments</key>
<array>
{args_xml} </array>
<key>RunAtLoad</key>
<{run_at_load}/>
{keep_alive} <key>StandardOutPath</key>
<string>{log}</string>
<key>StandardErrorPath</key>
<string>{log}</string>
<key>ProcessType</key>
<string>Background</string>
</dict>
</plist>
"#
)
}
fn xml_escape(s: &str) -> String {
s.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
}
pub fn install(spec: &ServiceSpec) -> Result<()> {
let dir = launch_agents_dir()?;
fs::create_dir_all(&dir).with_context(|| format!("Failed to create {}", dir.display()))?;
if let Some(parent) = spec.log.parent() {
fs::create_dir_all(parent).ok();
}
let path = plist_path(&spec.service)?;
fs::write(&path, render_plist(spec))
.with_context(|| format!("Failed to write plist {}", path.display()))?;
Ok(())
}
pub fn load(service: &str) -> Result<()> {
let path = plist_path(service)?;
if !path.exists() {
bail!("No plist for service '{service}'. Install it first.");
}
let out = Command::new("launchctl")
.args(["load", "-w"])
.arg(&path)
.output()
.context("Failed to run launchctl load")?;
if !out.status.success() {
let stderr = String::from_utf8_lossy(&out.stderr);
if !stderr.contains("already loaded") && !stderr.trim().is_empty() {
bail!("launchctl load failed: {}", stderr.trim());
}
}
Ok(())
}
pub fn unload(service: &str) -> Result<()> {
let path = plist_path(service)?;
if !path.exists() {
return Ok(());
}
let out = Command::new("launchctl")
.arg("unload")
.arg(&path)
.output()
.context("Failed to run launchctl unload")?;
if !out.status.success() {
let stderr = String::from_utf8_lossy(&out.stderr);
if !stderr.contains("not loaded")
&& !stderr.contains("Could not find")
&& !stderr.trim().is_empty()
{
bail!("launchctl unload failed: {}", stderr.trim());
}
}
Ok(())
}
pub fn restart(service: &str) -> Result<()> {
unload(service).ok();
load(service)
}
pub fn uninstall(service: &str) -> Result<()> {
unload(service).ok();
let path = plist_path(service)?;
if path.exists() {
fs::remove_file(&path)
.with_context(|| format!("Failed to remove plist {}", path.display()))?;
}
Ok(())
}
pub fn status(service: &str) -> Status {
let out = Command::new("launchctl")
.args(["list", &label(service)])
.stdout(Stdio::piped())
.stderr(Stdio::null())
.output();
match out {
Ok(o) if o.status.success() => {
let s = String::from_utf8_lossy(&o.stdout);
if s.contains("\"PID\"") {
Status::Running
} else if s.contains("\"LastExitStatus\"") && !s.contains("\"LastExitStatus\" = 0") {
Status::Error
} else {
Status::Stopped
}
}
_ => Status::Stopped,
}
}
pub fn socket_alive(path: &Path) -> bool {
use std::os::unix::fs::FileTypeExt;
fs::metadata(path)
.map(|m| m.file_type().is_socket())
.unwrap_or(false)
}