use std::path::{Path, PathBuf};
use crate::error::OlError;
use super::{
Supervisor, SupervisorKind, SupervisorStatus, ERR_SUPERVISION_BOOTSTRAP_FAILED,
ERR_SUPERVISION_CONTROL_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();
let marker = super::unit_version_marker();
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">
<!-- {marker} -->
<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>
<true/>
<key>ThrottleInterval</key>
<integer>2</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,
unit_current: false,
description: "not installed".into(),
});
}
let unit_current = std::fs::read_to_string(&self.plist_path)
.map(|c| super::unit_is_current(&c))
.unwrap_or(false);
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,
unit_current,
description: if unit_current {
"launchd (KeepAlive active)".into()
} else {
"launchd (running an outdated plist — reinstall to get unconditional KeepAlive)"
.into()
},
}),
_ => Ok(SupervisorStatus {
installed: true,
running: false,
unit_current,
description: "launchd (plist present, not running)".into(),
}),
}
}
fn start(&self) -> Result<(), OlError> {
let uid = get_uid();
match bootstrap(&self.plist_path, &format!("gui/{uid}")) {
Ok(()) | Err(BootstrapError::AlreadyBootstrapped) => Ok(()),
Err(BootstrapError::NoGuiSession) => {
match bootstrap(&self.plist_path, &format!("user/{uid}")) {
Ok(()) | Err(BootstrapError::AlreadyBootstrapped) => Ok(()),
Err(BootstrapError::NoGuiSession) => Err(OlError::new(
ERR_SUPERVISION_CONTROL_FAILED,
"launchctl bootstrap failed in both gui/ and user/ domains (no session)"
.to_string(),
)),
Err(BootstrapError::Other(msg)) => Err(OlError::new(
ERR_SUPERVISION_CONTROL_FAILED,
format!("launchctl bootstrap failed: {msg}"),
)),
}
}
Err(BootstrapError::Other(msg)) => Err(OlError::new(
ERR_SUPERVISION_CONTROL_FAILED,
format!("launchctl bootstrap failed: {msg}"),
)),
}
}
fn stop(&self) -> Result<(), OlError> {
let uid = get_uid();
let mut last_err = String::new();
for domain in [format!("gui/{uid}"), format!("user/{uid}")] {
let out = std::process::Command::new("launchctl")
.args(["bootout", &format!("{domain}/{LABEL}")])
.output();
match out {
Ok(o) if o.status.success() => return Ok(()),
Ok(o) => last_err = String::from_utf8_lossy(&o.stderr).trim().to_string(),
Err(e) => last_err = format!("cannot run launchctl: {e}"),
}
}
Err(OlError::new(
ERR_SUPERVISION_CONTROL_FAILED,
format!("launchctl bootout failed in gui/ and user/ domains: {last_err}"),
))
}
fn restart(&self) -> Result<(), OlError> {
let uid = get_uid();
for domain in [format!("gui/{uid}"), format!("user/{uid}")] {
let out = std::process::Command::new("launchctl")
.args(["kickstart", "-k", &format!("{domain}/{LABEL}")])
.output();
if out.is_ok_and(|o| o.status.success()) {
return Ok(());
}
}
self.start()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn plist_keep_alive_is_unconditional() {
let sup = LaunchdSupervisor::new();
let plist = sup.generate_plist(Path::new("/opt/openlatch/bin/openlatch"));
assert!(
plist.contains("<key>KeepAlive</key>\n <true/>"),
"{plist}"
);
assert!(
!plist.contains("<key>SuccessfulExit</key>"),
"the conditional KeepAlive dict is what stopped relaunches:\n{plist}"
);
}
#[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>2</integer>"), "{plist}");
}
#[test]
fn plist_carries_the_version_marker() {
let sup = LaunchdSupervisor::new();
let plist = sup.generate_plist(Path::new("/opt/openlatch/bin/openlatch"));
assert!(super::super::unit_is_current(&plist), "{plist}");
}
#[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\"?>"));
}
}