use std::os::unix::fs::MetadataExt;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use anyhow::{Context, Result, bail};
use crate::{daemon, ipc, paths};
pub const LABEL: &str = "dev.galdr.daemon";
fn is_macos() -> bool {
cfg!(target_os = "macos")
}
fn ensure_macos() -> Result<()> {
if is_macos() {
Ok(())
} else {
bail!(
"launchd is macOS-only; `galdr daemon install`/`uninstall` are unavailable on this platform"
)
}
}
fn plist_path() -> Result<PathBuf> {
let home = paths::home_dir().context("could not determine the home directory")?;
Ok(home
.join("Library")
.join("LaunchAgents")
.join(format!("{LABEL}.plist")))
}
fn uid() -> Result<u32> {
let home = paths::home_dir().context("could not determine the home directory")?;
Ok(std::fs::metadata(&home)
.with_context(|| format!("could not stat {}", home.display()))?
.uid())
}
fn gui_domain() -> Result<String> {
Ok(format!("gui/{}", uid()?))
}
fn gui_service() -> Result<String> {
Ok(format!("gui/{}/{LABEL}", uid()?))
}
fn launchctl_bin() -> String {
std::env::var("GALDR_LAUNCHCTL").unwrap_or_else(|_| "launchctl".to_string())
}
fn launchctl(args: &[&str]) -> Result<bool> {
let status = Command::new(launchctl_bin())
.args(args)
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.context("could not run launchctl")?;
Ok(status.success())
}
fn xml_escape(s: &str) -> String {
s.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
.replace('"', """)
.replace('\'', "'")
}
fn render_plist(program: &Path, out_log: &Path, err_log: &Path) -> String {
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>{program}</string>
<string>daemon</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>StandardOutPath</key>
<string>{out}</string>
<key>StandardErrorPath</key>
<string>{err}</string>
</dict>
</plist>
"#,
label = LABEL,
program = xml_escape(&program.display().to_string()),
out = xml_escape(&out_log.display().to_string()),
err = xml_escape(&err_log.display().to_string()),
)
}
fn plist_exists() -> bool {
plist_path().map(|p| p.exists()).unwrap_or(false)
}
fn job_loaded() -> bool {
let Ok(service) = gui_service() else {
return false;
};
launchctl(&["print", &service]).unwrap_or(false)
}
pub fn is_managed() -> bool {
is_macos() && plist_exists() && job_loaded()
}
pub fn management() -> Option<bool> {
if is_macos() { Some(is_managed()) } else { None }
}
pub fn status_line() -> Option<String> {
if !is_macos() {
return None;
}
if !plist_exists() {
return Some("launchd: unmanaged — run galdr daemon install".to_string());
}
if job_loaded() {
Some("launchd: managed (auto-starts at login, restarts on crash)".to_string())
} else {
Some("launchd: installed but not loaded — run galdr daemon install".to_string())
}
}
pub fn kickstart() -> Result<()> {
ensure_macos()?;
let service = gui_service()?;
if !launchctl(&["kickstart", "-k", &service])? {
bail!("launchctl kickstart failed for {service}");
}
Ok(())
}
pub fn install() -> Result<()> {
ensure_macos()?;
let was_managed = is_managed();
let program = std::env::current_exe().context("could not find the galdr executable")?;
let out_log = paths::daemon_out_log()?;
let err_log = paths::daemon_err_log()?;
std::fs::create_dir_all(paths::logs_dir()?).context("could not create the daemon log dir")?;
let plist = plist_path()?;
if let Some(parent) = plist.parent() {
std::fs::create_dir_all(parent).context("could not create ~/Library/LaunchAgents")?;
}
std::fs::write(&plist, render_plist(&program, &out_log, &err_log))
.with_context(|| format!("could not write {}", plist.display()))?;
println!("wrote LaunchAgent: {}", plist.display());
let service = gui_service()?;
if was_managed {
if !launchctl(&["kickstart", "-k", &service])? {
bail!("launchctl kickstart failed for {service}");
}
println!("reloaded {LABEL} (launchctl kickstart -k)");
} else {
if matches!(
ipc::query(&ipc::Request::Ping),
Ok(ipc::Response::Pong { .. })
) {
daemon::stop_and_wait();
println!("stopped the running (unmanaged) daemon");
}
let domain = gui_domain()?;
let plist_str = plist
.to_str()
.context("plist path is not valid UTF-8")?
.to_string();
if !launchctl(&["bootstrap", &domain, &plist_str])? {
bail!(
"launchctl bootstrap failed for {service}; inspect it with `launchctl print {service}`"
);
}
println!("loaded {LABEL} (launchctl bootstrap {domain})");
}
println!("galdr daemon is now managed by launchd (auto-starts at login, restarts on crash)");
Ok(())
}
pub fn uninstall() -> Result<()> {
ensure_macos()?;
let service = gui_service()?;
let _ = launchctl(&["bootout", &service]);
println!("unloaded {LABEL} (launchctl bootout)");
let plist = plist_path()?;
if plist.exists() {
std::fs::remove_file(&plist)
.with_context(|| format!("could not remove {}", plist.display()))?;
println!("removed {}", plist.display());
} else {
println!("no LaunchAgent plist to remove ({})", plist.display());
}
println!("logs and recordings under ~/.galdr are left in place");
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn plist_has_the_required_keys_and_paths() {
let plist = render_plist(
Path::new("/Users/x/.cargo/bin/galdr"),
Path::new("/Users/x/.galdr/logs/daemon.out.log"),
Path::new("/Users/x/.galdr/logs/daemon.err.log"),
);
assert!(plist.contains("<key>Label</key>"));
assert!(plist.contains("<string>dev.galdr.daemon</string>"));
assert!(plist.contains("<string>/Users/x/.cargo/bin/galdr</string>"));
assert!(plist.contains("<string>daemon</string>"));
assert!(plist.contains("<key>RunAtLoad</key>\n <true/>"));
assert!(plist.contains("<key>KeepAlive</key>\n <true/>"));
assert!(plist.contains("<string>/Users/x/.galdr/logs/daemon.out.log</string>"));
assert!(plist.contains("<string>/Users/x/.galdr/logs/daemon.err.log</string>"));
assert!(plist.starts_with("<?xml version=\"1.0\""));
assert!(plist.contains("<!DOCTYPE plist PUBLIC"));
}
#[test]
fn plist_escapes_xml_metacharacters_in_paths() {
let plist = render_plist(
Path::new("/tmp/a&b/galdr"),
Path::new("/tmp/o<ut.log"),
Path::new("/tmp/err.log"),
);
assert!(plist.contains("/tmp/a&b/galdr"));
assert!(plist.contains("/tmp/o<ut.log"));
assert!(!plist.contains("/tmp/a&b/galdr"));
}
#[test]
fn label_is_the_reverse_dns_id() {
assert_eq!(LABEL, "dev.galdr.daemon");
}
}