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>Interactive</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(())
}
fn domain() -> String {
format!("gui/{}", unsafe { libc::getuid() })
}
fn service_target(service: &str) -> String {
format!("{}/{}", domain(), label(service))
}
pub fn load(service: &str) -> Result<()> {
let path = plist_path(service)?;
if !path.exists() {
bail!("No plist for service '{service}'. Install it first.");
}
let _ = Command::new("launchctl")
.args(["enable", &service_target(service)])
.output();
for attempt in 0..6 {
let out = Command::new("launchctl")
.arg("bootstrap")
.arg(domain())
.arg(&path)
.output()
.context("Failed to run launchctl bootstrap")?;
if out.status.success() {
return Ok(());
}
let stderr = String::from_utf8_lossy(&out.stderr);
if stderr.contains("already bootstrapped")
|| stderr.contains("already loaded")
|| stderr.contains("service already loaded")
|| stderr.contains("Operation already in progress")
{
return Ok(());
}
let retryable = stderr.contains("Input/output error") || stderr.contains(": 5:");
if retryable && attempt < 5 {
std::thread::sleep(std::time::Duration::from_millis(200));
continue;
}
if !stderr.trim().is_empty() {
bail!("launchctl bootstrap failed: {}", stderr.trim());
}
return Ok(());
}
Ok(())
}
pub fn unload(service: &str) -> Result<()> {
let path = plist_path(service)?;
if !path.exists() {
return Ok(());
}
let out = Command::new("launchctl")
.arg("bootout")
.arg(service_target(service))
.output()
.context("Failed to run launchctl bootout")?;
if !out.status.success() {
let stderr = String::from_utf8_lossy(&out.stderr);
let benign = stderr.contains("No such process")
|| stderr.contains("not loaded")
|| stderr.contains("Could not find")
|| stderr.contains("could not find");
if !benign && !stderr.trim().is_empty() {
bail!("launchctl bootout 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 pid(service: &str) -> Option<u32> {
let out = Command::new("launchctl")
.args(["list", &label(service)])
.stdout(Stdio::piped())
.stderr(Stdio::null())
.output()
.ok()?;
if !out.status.success() {
return None;
}
let s = String::from_utf8_lossy(&out.stdout);
for line in s.lines() {
let line = line.trim();
if let Some(rest) = line.strip_prefix("\"PID\" = ") {
return rest.trim_end_matches(';').trim().parse::<u32>().ok();
}
}
None
}
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)
}