//! 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//