use std::path::{Path, PathBuf};
use crate::error::OlError;
use super::{
Supervisor, SupervisorKind, SupervisorStatus, ERR_SUPERVISION_BOOTSTRAP_FAILED,
ERR_SUPERVISION_INSTALL_FAILED,
};
const LABEL: &str = "ai.openlatch.client";
#[cfg(unix)]
fn get_uid() -> u32 {
unsafe { libc::getuid() }
}
#[cfg(not(unix))]
fn get_uid() -> u32 {
0
}
enum BootstrapError {
AlreadyBootstrapped,
NoGuiSession,
Other(String),
}
fn bootstrap(plist: &Path, domain: &str) -> Result<(), BootstrapError> {
let out = std::process::Command::new("launchctl")
.args(["bootstrap", domain, &plist.display().to_string()])
.output();
match out {
Ok(o) if o.status.success() => Ok(()),
Ok(o) => {
let stderr = String::from_utf8_lossy(&o.stderr).to_string();
if stderr.contains("already bootstrapped") {
Err(BootstrapError::AlreadyBootstrapped)
} else if stderr.contains("Input/output error")
|| stderr.contains("Could not find domain")
{
Err(BootstrapError::NoGuiSession)
} else {
Err(BootstrapError::Other(stderr))
}
}
Err(e) => Err(BootstrapError::Other(format!("Cannot run launchctl: {e}"))),
}
}
pub struct LaunchdSupervisor {
plist_path: PathBuf,
}
impl Default for LaunchdSupervisor {
fn default() -> Self {
Self::new()
}
}
impl LaunchdSupervisor {
pub fn new() -> Self {
let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("/tmp"));
Self {
plist_path: home
.join("Library/LaunchAgents")
.join(format!("{LABEL}.plist")),
}
}
fn generate_plist(&self, binary_path: &Path) -> String {
let bin = binary_path.display();
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>{bin}</string>
<string>daemon</string>
<string>start</string>
<string>--foreground</string>
</array>
<key>KeepAlive</key>
<dict>
<key>SuccessfulExit</key>
<false/>
<key>Crashed</key>
<true/>
</dict>
<key>ThrottleInterval</key>
<integer>10</integer>
<key>ExitTimeOut</key>
<integer>20</integer>
<key>RunAtLoad</key>
<true/>
<key>StandardOutPath</key>
<string>/tmp/openlatch-stdout.log</string>
<key>StandardErrorPath</key>
<string>/tmp/openlatch-stderr.log</string>
</dict>
</plist>"#
)
}
}
impl Supervisor for LaunchdSupervisor {
fn kind(&self) -> SupervisorKind {
SupervisorKind::Launchd
}
fn install(&self, binary_path: &Path) -> Result<(), OlError> {
if let Some(parent) = self.plist_path.parent() {
std::fs::create_dir_all(parent).map_err(|e| {
OlError::new(
ERR_SUPERVISION_INSTALL_FAILED,
format!("Cannot create LaunchAgents directory: {e}"),
)
})?;
}
let plist = self.generate_plist(binary_path);
std::fs::write(&self.plist_path, &plist).map_err(|e| {
OlError::new(
ERR_SUPERVISION_INSTALL_FAILED,
format!("Cannot write plist: {e}"),
)
})?;
let uid = get_uid();
match bootstrap(&self.plist_path, &format!("gui/{uid}")) {
Ok(()) => Ok(()),
Err(BootstrapError::AlreadyBootstrapped) => Ok(()),
Err(BootstrapError::NoGuiSession) => {
match bootstrap(&self.plist_path, &format!("user/{uid}")) {
Ok(()) => Ok(()),
Err(BootstrapError::AlreadyBootstrapped) => Ok(()),
Err(BootstrapError::NoGuiSession) => Err(OlError::new(
ERR_SUPERVISION_BOOTSTRAP_FAILED,
"launchctl bootstrap failed in both gui/ and user/ domains (no session)"
.to_string(),
)),
Err(BootstrapError::Other(msg)) if msg.is_empty() => Err(OlError::new(
ERR_SUPERVISION_BOOTSTRAP_FAILED,
"launchctl bootstrap failed in both gui/ and user/ domains".to_string(),
)),
Err(BootstrapError::Other(msg)) => Err(OlError::new(
ERR_SUPERVISION_BOOTSTRAP_FAILED,
format!("launchctl bootstrap failed: {msg}"),
)),
}
}
Err(BootstrapError::Other(msg)) => Err(OlError::new(
ERR_SUPERVISION_BOOTSTRAP_FAILED,
format!("launchctl bootstrap failed: {msg}"),
)),
}
}
fn uninstall(&self) -> Result<(), OlError> {
let uid = get_uid();
let _ = std::process::Command::new("launchctl")
.args(["bootout", &format!("gui/{uid}/{LABEL}")])
.output();
let _ = std::process::Command::new("launchctl")
.args(["bootout", &format!("user/{uid}/{LABEL}")])
.output();
let _ = std::fs::remove_file(&self.plist_path);
Ok(())
}
fn status(&self) -> Result<SupervisorStatus, OlError> {
if !self.plist_path.exists() {
return Ok(SupervisorStatus {
installed: false,
running: false,
description: "not installed".into(),
});
}
let output = std::process::Command::new("launchctl")
.args(["list", LABEL])
.output();
match output {
Ok(o) if o.status.success() => Ok(SupervisorStatus {
installed: true,
running: true,
description: "launchd (KeepAlive active)".into(),
}),
_ => Ok(SupervisorStatus {
installed: true,
running: false,
description: "launchd (plist present, not running)".into(),
}),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn plist_uses_dict_keep_alive_not_bool() {
let sup = LaunchdSupervisor::new();
let plist = sup.generate_plist(Path::new("/opt/openlatch/bin/openlatch"));
assert!(plist.contains("<key>KeepAlive</key>"));
assert!(plist.contains("<key>SuccessfulExit</key>"));
assert!(plist.contains("<false/>"));
assert!(plist.contains("<key>Crashed</key>"));
assert!(
!plist.contains("<true/>\n</dict>\n <key>ThrottleInterval</key>"),
"KeepAlive must be a dict, not a bare <true/>"
);
}
#[test]
fn plist_has_throttle_interval() {
let sup = LaunchdSupervisor::new();
let plist = sup.generate_plist(Path::new("/opt/openlatch/bin/openlatch"));
assert!(plist.contains("<key>ThrottleInterval</key>"));
assert!(plist.contains("<integer>10</integer>"));
}
#[test]
fn plist_contains_label() {
let sup = LaunchdSupervisor::new();
let plist = sup.generate_plist(Path::new("/opt/openlatch/bin/openlatch"));
assert!(plist.contains(LABEL));
}
#[test]
fn plist_declares_utf8() {
let sup = LaunchdSupervisor::new();
let plist = sup.generate_plist(Path::new("/opt/openlatch/bin/openlatch"));
assert!(plist.starts_with("<?xml version=\"1.0\" encoding=\"UTF-8\"?>"));
}
}