#![cfg(target_os = "macos")]
use std::path::PathBuf;
use std::process::Command;
use anyhow::{Context, Result};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum KeepAlive {
Always,
OnSuccess,
}
#[derive(Debug, Clone)]
pub struct LaunchdConfig {
pub label: String,
pub exe_path: PathBuf,
pub args: Vec<String>,
pub log_dir: PathBuf,
pub keep_alive: KeepAlive,
pub throttle_interval: u32,
pub env_vars: Vec<(String, String)>,
}
impl LaunchdConfig {
pub fn render_plist(&self) -> Result<String> {
let exe = self
.exe_path
.to_str()
.context("exe_path is not valid UTF-8")?;
let stdout = self.log_dir.join("stdout.log");
let stderr = self.log_dir.join("stderr.log");
let stdout = stdout
.to_str()
.context("stdout log path is not valid UTF-8")?;
let stderr = stderr
.to_str()
.context("stderr log path is not valid UTF-8")?;
let mut s = String::new();
s.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
s.push_str(
"<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \
\"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n",
);
s.push_str("<plist version=\"1.0\">\n");
s.push_str("<dict>\n");
s.push_str(" <key>Label</key>\n");
s.push_str(&format!(" <string>{}</string>\n", xml_escape(&self.label)));
s.push_str(" <key>ProgramArguments</key>\n");
s.push_str(" <array>\n");
s.push_str(&format!(" <string>{}</string>\n", xml_escape(exe)));
for arg in &self.args {
s.push_str(&format!(" <string>{}</string>\n", xml_escape(arg)));
}
s.push_str(" </array>\n");
s.push_str(" <key>KeepAlive</key>\n");
match self.keep_alive {
KeepAlive::Always => s.push_str(" <true/>\n"),
KeepAlive::OnSuccess => {
s.push_str(" <dict>\n");
s.push_str(" <key>SuccessfulExit</key>\n");
s.push_str(" <false/>\n");
s.push_str(" </dict>\n");
}
}
s.push_str(" <key>RunAtLoad</key>\n");
s.push_str(" <true/>\n");
s.push_str(" <key>ThrottleInterval</key>\n");
s.push_str(&format!(
" <integer>{}</integer>\n",
self.throttle_interval
));
s.push_str(" <key>StandardOutPath</key>\n");
s.push_str(&format!(" <string>{}</string>\n", xml_escape(stdout)));
s.push_str(" <key>StandardErrorPath</key>\n");
s.push_str(&format!(" <string>{}</string>\n", xml_escape(stderr)));
if !self.env_vars.is_empty() {
s.push_str(" <key>EnvironmentVariables</key>\n");
s.push_str(" <dict>\n");
for (k, v) in &self.env_vars {
s.push_str(&format!(" <key>{}</key>\n", xml_escape(k)));
s.push_str(&format!(" <string>{}</string>\n", xml_escape(v)));
}
s.push_str(" </dict>\n");
}
s.push_str("</dict>\n");
s.push_str("</plist>\n");
Ok(s)
}
pub fn plist_path(&self) -> Result<PathBuf> {
let home = dirs::home_dir().context("could not resolve home directory")?;
Ok(home
.join("Library")
.join("LaunchAgents")
.join(format!("{}.plist", self.label)))
}
pub fn install(&self) -> Result<()> {
let plist = self.plist_path()?;
if let Some(parent) = plist.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("create LaunchAgents dir {}", parent.display()))?;
}
std::fs::create_dir_all(&self.log_dir)
.with_context(|| format!("create log dir {}", self.log_dir.display()))?;
let xml = self.render_plist()?;
std::fs::write(&plist, xml).with_context(|| format!("write plist {}", plist.display()))?;
Ok(())
}
pub fn bootstrap(&self) -> Result<()> {
let _ = self.bootout();
let plist = self.plist_path()?;
let domain = format!("gui/{}", current_uid());
let output = Command::new("launchctl")
.arg("bootstrap")
.arg(&domain)
.arg(&plist)
.output()
.context("spawn launchctl bootstrap")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!(
"launchctl bootstrap {} {} failed: {}",
domain,
plist.display(),
stderr.trim()
);
}
Ok(())
}
pub fn bootout(&self) -> Result<()> {
let domain_target = format!("gui/{}/{}", current_uid(), self.label);
let output = Command::new("launchctl")
.arg("bootout")
.arg(&domain_target)
.output()
.context("spawn launchctl bootout")?;
if output.status.success() {
return Ok(());
}
let stderr = String::from_utf8_lossy(&output.stderr).to_lowercase();
if stderr.contains("no such process")
|| stderr.contains("not find")
|| stderr.contains("not loaded")
|| stderr.contains("could not find specified service")
{
return Ok(());
}
anyhow::bail!(
"launchctl bootout {} failed: {}",
domain_target,
stderr.trim()
);
}
}
pub fn current_uid() -> u32 {
unsafe { libc::getuid() }
}
fn xml_escape(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
match c {
'&' => out.push_str("&"),
'<' => out.push_str("<"),
'>' => out.push_str(">"),
'"' => out.push_str("""),
'\'' => out.push_str("'"),
other => out.push(other),
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
fn sample(keep_alive: KeepAlive) -> LaunchdConfig {
LaunchdConfig {
label: "com.trusty.search".to_string(),
exe_path: PathBuf::from("/usr/local/bin/trusty-search"),
args: vec![
"daemon".to_string(),
"--port".to_string(),
"7777".to_string(),
],
log_dir: PathBuf::from("/Users/test/Library/Logs/trusty-search"),
keep_alive,
throttle_interval: 10,
env_vars: vec![],
}
}
#[test]
fn render_plist_contains_core_keys() {
let xml = sample(KeepAlive::Always).render_plist().unwrap();
assert!(xml.contains("<key>Label</key>"));
assert!(xml.contains("<string>com.trusty.search</string>"));
assert!(xml.contains("<string>/usr/local/bin/trusty-search</string>"));
assert!(xml.contains("<string>daemon</string>"));
assert!(xml.contains("<key>ThrottleInterval</key>"));
assert!(xml.contains("<integer>10</integer>"));
assert!(xml.contains("<key>RunAtLoad</key>"));
assert!(xml.contains("stdout.log"));
assert!(xml.contains("stderr.log"));
assert!(xml.trim_end().ends_with("</plist>"));
}
#[test]
fn render_plist_keepalive_always() {
let xml = sample(KeepAlive::Always).render_plist().unwrap();
assert!(xml.contains("<key>KeepAlive</key>\n <true/>"));
assert!(!xml.contains("SuccessfulExit"));
}
#[test]
fn render_plist_keepalive_on_success() {
let xml = sample(KeepAlive::OnSuccess).render_plist().unwrap();
assert!(xml.contains("<key>SuccessfulExit</key>"));
assert!(xml.contains("<false/>"));
}
#[test]
fn render_plist_includes_env_vars() {
let mut cfg = sample(KeepAlive::Always);
cfg.env_vars = vec![("RUST_LOG".to_string(), "info".to_string())];
let xml = cfg.render_plist().unwrap();
assert!(xml.contains("<key>EnvironmentVariables</key>"));
assert!(xml.contains("<key>RUST_LOG</key>"));
assert!(xml.contains("<string>info</string>"));
}
#[test]
fn render_plist_omits_env_block_when_empty() {
let xml = sample(KeepAlive::Always).render_plist().unwrap();
assert!(!xml.contains("EnvironmentVariables"));
}
#[test]
fn render_plist_escapes_xml() {
let mut cfg = sample(KeepAlive::Always);
cfg.args = vec!["--name".to_string(), "a&b<c>\"d\"".to_string()];
let xml = cfg.render_plist().unwrap();
assert!(xml.contains("a&b<c>"d""));
assert!(!xml.contains("a&b<c>"));
}
#[test]
fn plist_path_layout() {
let p = sample(KeepAlive::Always).plist_path().unwrap();
assert!(p.ends_with("Library/LaunchAgents/com.trusty.search.plist"));
}
#[test]
fn current_uid_is_nonzero_for_normal_user() {
assert_ne!(current_uid(), 0, "test suite should not run as root");
}
#[test]
fn xml_escape_handles_all_entities() {
assert_eq!(xml_escape("&<>\"'"), "&<>"'");
assert_eq!(xml_escape("plain"), "plain");
}
}