use std::path::{Path, PathBuf};
use std::process::Command;
use anyhow::{Context, Result};
use super::Registration;
pub const SUPPORTED: bool = true;
const LABEL: &str = "dev.stoneware.ralon.supervisor";
pub fn install(executable: &Path, home: &Path) -> Result<Registration> {
let path = plist_path()?;
let directory = path.parent().expect("a plist path always has a parent");
std::fs::create_dir_all(directory)
.with_context(|| format!("failed to create {}", directory.display()))?;
std::fs::write(&path, describe_agent(executable, home))
.with_context(|| format!("failed to write {}", path.display()))?;
let _ = bootout();
let mut warnings = Vec::new();
if let Err(error) = bootstrap(&path) {
warnings.push(format!(
"{error:#} — the agent is written and will start at the next login; \
`ralon daemon --foreground` shows what it would do now"
));
}
Ok(Registration {
mechanism: "a launchd LaunchAgent",
path: Some(path),
warnings,
})
}
pub fn uninstall() -> Result<bool> {
let path = plist_path()?;
if !path.exists() {
return Ok(false);
}
let _ = bootout();
std::fs::remove_file(&path).with_context(|| format!("failed to remove {}", path.display()))?;
Ok(true)
}
pub fn installed() -> bool {
plist_path().map(|path| path.exists()).unwrap_or(false)
}
pub fn unsupported_reason() -> String {
String::new()
}
fn bootstrap(path: &Path) -> Result<()> {
let target = format!("gui/{}", uid());
let modern = Command::new("launchctl")
.arg("bootstrap")
.arg(&target)
.arg(path)
.output()
.context("failed to run launchctl")?;
if modern.status.success() {
return Ok(());
}
let legacy = Command::new("launchctl")
.args(["load", "-w"])
.arg(path)
.output()
.context("failed to run launchctl")?;
if legacy.status.success() {
return Ok(());
}
anyhow::bail!(
"launchctl would not start the supervisor: {}",
message(&modern)
)
}
fn bootout() -> Result<()> {
let target = format!("gui/{}/{LABEL}", uid());
let modern = Command::new("launchctl")
.arg("bootout")
.arg(&target)
.output()
.context("failed to run launchctl")?;
if modern.status.success() {
return Ok(());
}
let path = plist_path()?;
let legacy = Command::new("launchctl")
.args(["unload", "-w"])
.arg(&path)
.output()
.context("failed to run launchctl")?;
if legacy.status.success() {
return Ok(());
}
anyhow::bail!(
"launchctl would not stop the supervisor: {}",
message(&modern)
)
}
fn uid() -> u32 {
unsafe { libc::getuid() }
}
fn plist_path() -> Result<PathBuf> {
let home = crate::supervisor::registry::user_home()
.context("could not find the home directory to install a LaunchAgent into")?;
Ok(home
.join("Library/LaunchAgents")
.join(format!("{LABEL}.plist")))
}
fn describe_agent(executable: &Path, home: &Path) -> String {
let logs = home.join("launchd.log");
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>
<string>{executable}</string>
<string>daemon</string>
<string>--foreground</string>
<string>--home</string>
<string>{home}</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>ProcessType</key>
<string>Background</string>
<key>StandardOutPath</key>
<string>{logs}</string>
<key>StandardErrorPath</key>
<string>{logs}</string>
</dict>
</plist>
"#,
executable = escape(&executable.display().to_string()),
home = escape(&home.display().to_string()),
logs = escape(&logs.display().to_string()),
)
}
fn escape(text: &str) -> String {
text.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
}
fn message(output: &std::process::Output) -> String {
let combined = format!(
"{}{}",
String::from_utf8_lossy(&output.stderr),
String::from_utf8_lossy(&output.stdout)
);
let trimmed = combined.trim();
if trimmed.is_empty() {
format!("exit code {}", output.status.code().unwrap_or(-1))
} else {
trimmed.replace('\n', " ")
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_agent_restarts_and_starts_at_login() {
let plist = describe_agent(Path::new("/usr/local/bin/ralon"), Path::new("/state"));
assert!(plist.contains("<key>RunAtLoad</key>\n\t<true/>"), "{plist}");
assert!(plist.contains("<key>KeepAlive</key>\n\t<true/>"), "{plist}");
}
#[test]
fn the_agent_runs_in_the_foreground_because_launchd_supervises_it() {
let plist = describe_agent(Path::new("/usr/local/bin/ralon"), Path::new("/state"));
assert!(plist.contains("<string>--foreground</string>"), "{plist}");
}
#[test]
fn the_state_directory_is_passed_rather_than_inherited() {
let plist = describe_agent(Path::new("/usr/local/bin/ralon"), Path::new("/state"));
assert!(plist.contains("<string>--home</string>"), "{plist}");
assert!(plist.contains("<string>/state</string>"), "{plist}");
}
}