//! The macOS backend: a launchd user agent.
use crate::{
SERVICE_LABEL, ServiceError, ServiceManager, ServiceSpec, ServiceStatus, is_stale, run,
write_definition,
};
use std::path::{Path, PathBuf};
/// Escapes text for inclusion in a plist string element.
///
/// Paths can contain ampersands and angle brackets, and an unescaped one produces a plist launchd
/// silently refuses to load.
fn escape_xml(value: &str) -> String {
value
.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
.replace('"', """)
.replace('\'', "'")
}
/// Renders the launch agent property list for a specification.
///
/// `KeepAlive` is what restarts the daemon after a crash, and `RunAtLoad` is what starts it at
/// login. Both are required by the resilience rules: a daemon that stays dead after a crash would
/// take every connection with it.
pub fn render_plist(spec: &ServiceSpec) -> String {
let mut arguments = String::new();
arguments.push_str(&format!(
"\t\t{}\n",
escape_xml(&spec.executable.display().to_string())
));
for argument in &spec.arguments {
arguments.push_str(&format!("\t\t{}\n", escape_xml(argument)));
}
format!(
"\n\
\n\
\n\
\n\
\tLabel\n\
\t{label}\n\
\tProgramArguments\n\
\t\n{arguments}\t\n\
\tRunAtLoad\n\
\t<{run_at_load}/>\n\
\tKeepAlive\n\
\t\n\
\tProcessType\n\
\tInteractive\n\
\n\
\n",
label = SERVICE_LABEL,
arguments = arguments,
run_at_load = if spec.start_at_login { "true" } else { "false" },
)
}
/// Returns the specification with the daemon told to write its log to `log_file`.
///
/// launchd discards a daemon's output unless the agent names a file, and never trims one it
/// does, so the daemon keeps its own log and rolls it over.
pub fn with_log_file(spec: &ServiceSpec, log_file: &Path) -> ServiceSpec {
let mut spec = spec.clone();
spec.arguments.push("--log-file".to_owned());
spec.arguments.push(log_file.display().to_string());
spec
}
/// Reads the executable path out of a rendered plist.
///
/// Used to detect a registration pointing at a binary that has since moved.
pub fn registered_executable(plist: &str) -> Option {
let array = plist.split("").nth(1)?;
let first = array.split("").nth(1)?;
let path = first.split("").next()?;
Some(PathBuf::from(unescape_xml(path)))
}
/// Reverses `escape_xml`, the ampersand last so an escaped entity is not unescaped twice.
fn unescape_xml(value: &str) -> String {
value
.replace("<", "<")
.replace(">", ">")
.replace(""", "\"")
.replace("'", "'")
.replace("&", "&")
}
/// Manages the daemon as a launchd user agent.
pub struct Launchd {
plist_path: PathBuf,
log_file: PathBuf,
domain: String,
}
impl Launchd {
/// Resolves the agent's location for the current user.
pub fn new() -> Result {
let home = directories::BaseDirs::new().ok_or(ServiceError::NoHome)?;
let plist_path = home
.home_dir()
.join("Library/LaunchAgents")
.join(format!("{SERVICE_LABEL}.plist"));
let log_file = home.home_dir().join("Library/Logs/midi-harbor/daemon.log");
// Modern launchctl addresses services by domain rather than by file.
let domain = format!("gui/{}", rustix::process::getuid().as_raw());
Ok(Self {
plist_path,
log_file,
domain,
})
}
/// Returns the service target used by launchctl subcommands.
fn target(&self) -> String {
format!("{}/{}", self.domain, SERVICE_LABEL)
}
/// Removes any existing registration, ignoring the error when none exists.
fn bootout(&self) {
let _ = run("launchctl", &["bootout", &self.target()]);
}
}
impl ServiceManager for Launchd {
fn install(&self, spec: &ServiceSpec) -> Result {
// Remove first so re-running install updates the registration in place rather than
// leaving launchd holding a definition we just overwrote.
self.bootout();
let spec = with_log_file(spec, &self.log_file);
write_definition(&self.plist_path, &render_plist(&spec))?;
let plist = self.plist_path.display().to_string();
run("launchctl", &["bootstrap", &self.domain, &plist])?;
Ok(self.plist_path.clone())
}
fn uninstall(&self) -> Result<(), ServiceError> {
self.bootout();
match std::fs::remove_file(&self.plist_path) {
Ok(()) => Ok(()),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(source) => Err(ServiceError::Io {
operation: "remove",
path: self.plist_path.clone(),
source,
}),
}
}
fn start(&self) -> Result<(), ServiceError> {
run("launchctl", &["kickstart", &self.target()]).map(|_| ())
}
fn stop(&self) -> Result<(), ServiceError> {
run("launchctl", &["kill", "SIGTERM", &self.target()]).map(|_| ())
}
fn status(&self) -> Result {
let Ok(plist) = std::fs::read_to_string(&self.plist_path) else {
return Ok(ServiceStatus::default());
};
let registered = registered_executable(&plist);
// launchctl print exits non-zero when the service is not loaded, which is a normal answer
// rather than a failure.
let running = run("launchctl", &["print", &self.target()])
.map(|output| output.contains("state = running"))
.unwrap_or(false);
Ok(ServiceStatus {
installed: true,
running,
definition_path: Some(self.plist_path.clone()),
stale: is_stale(registered.as_deref()),
registered_executable: registered,
})
}
fn name(&self) -> &'static str {
"launchd"
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Builds the specification `service install` writes for an application bundle.
fn spec() -> ServiceSpec {
ServiceSpec {
executable: PathBuf::from("/Applications/Midi Harbor.app/Contents/MacOS/midi-harbor"),
arguments: vec!["daemon".to_owned()],
start_at_login: true,
}
}
/// Locks the launchctl service target to `gui//