use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::Duration;
use anyhow::{bail, Context, Result};
pub const LAUNCHD_LABEL: &str = "com.github.rust-works.omni-dev.daemon";
pub fn plist_path() -> Result<PathBuf> {
let home = dirs::home_dir().context("could not determine the home directory")?;
Ok(home
.join("Library")
.join("LaunchAgents")
.join(format!("{LAUNCHD_LABEL}.plist")))
}
fn render_plist(exe: &Path, socket: &Path) -> Result<String> {
let exe = exe
.to_str()
.with_context(|| format!("executable path is not valid UTF-8: {}", exe.display()))?;
let socket = socket
.to_str()
.with_context(|| format!("socket path is not valid UTF-8: {}", socket.display()))?;
Ok(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>{exe}</string>
<string>daemon</string>
<string>run</string>
<string>--socket</string>
<string>{socket}</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<dict>
<key>SuccessfulExit</key>
<false/>
</dict>
<key>ProcessType</key>
<string>Interactive</string>
</dict>
</plist>
"#,
label = LAUNCHD_LABEL,
exe = quick_xml::escape::escape(exe),
socket = quick_xml::escape::escape(socket),
))
}
fn gui_domain() -> String {
format!("gui/{}", nix::unistd::getuid())
}
pub fn install_and_load(socket: &Path) -> Result<()> {
let exe = std::env::current_exe().context("could not resolve the current executable")?;
let plist = plist_path()?;
if let Some(parent) = plist.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("failed to create {}", parent.display()))?;
}
std::fs::write(&plist, render_plist(&exe, socket)?)
.with_context(|| format!("failed to write {}", plist.display()))?;
let domain = gui_domain();
bootout(&domain);
wait_until_unloaded(&domain);
let mut last_err = String::new();
for attempt in 0..5 {
if attempt > 0 {
std::thread::sleep(Duration::from_millis(200));
}
let output = Command::new("launchctl")
.arg("bootstrap")
.arg(&domain)
.arg(&plist)
.output()
.context("failed to run `launchctl bootstrap`")?;
if output.status.success() {
return Ok(());
}
last_err = String::from_utf8_lossy(&output.stderr).trim().to_string();
}
bail!("launchctl bootstrap failed: {last_err}");
}
fn wait_until_unloaded(domain: &str) {
let target = format!("{domain}/{LAUNCHD_LABEL}");
for _ in 0..50 {
let still_loaded = Command::new("launchctl")
.args(["print", &target])
.output()
.is_ok_and(|out| out.status.success());
if !still_loaded {
return;
}
std::thread::sleep(Duration::from_millis(100));
}
}
pub fn unload() -> Result<()> {
bootout(&gui_domain());
Ok(())
}
fn bootout(domain: &str) {
let target = format!("{domain}/{LAUNCHD_LABEL}");
if let Err(e) = Command::new("launchctl")
.args(["bootout", &target])
.output()
{
tracing::warn!("failed to run `launchctl bootout {target}`: {e}");
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
fn assert_well_formed(xml: &str) {
use quick_xml::events::Event;
use quick_xml::reader::Reader;
let mut reader = Reader::from_str(xml);
loop {
match reader.read_event() {
Ok(Event::Eof) => break,
Ok(_) => {}
Err(e) => panic!("plist XML is not well-formed: {e}\n---\n{xml}"),
}
}
}
#[test]
fn escapes_xml_metacharacters_in_paths() {
let plist = render_plist(
Path::new("/Users/A&B/bin/omni-dev"),
Path::new("/tmp/<sock>/daemon.sock"),
)
.expect("ASCII paths render");
assert!(plist.contains("/Users/A&B/bin/omni-dev"), "{plist}");
assert!(plist.contains("/tmp/<sock>/daemon.sock"), "{plist}");
assert!(!plist.contains("A&B"), "{plist}");
assert!(!plist.contains("<sock>"), "{plist}");
assert_well_formed(&plist);
}
#[test]
fn renders_plain_paths_verbatim() {
let plist = render_plist(
Path::new("/usr/local/bin/omni-dev"),
Path::new("/tmp/omni-dev/daemon.sock"),
)
.expect("ASCII paths render");
assert!(
plist.contains("<string>/usr/local/bin/omni-dev</string>"),
"{plist}"
);
assert!(
plist.contains("<string>/tmp/omni-dev/daemon.sock</string>"),
"{plist}"
);
assert!(
plist.contains(&format!("<string>{LAUNCHD_LABEL}</string>")),
"{plist}"
);
assert_well_formed(&plist);
}
#[test]
fn rejects_non_utf8_paths() {
use std::ffi::OsStr;
use std::os::unix::ffi::OsStrExt;
let bad = Path::new(OsStr::from_bytes(b"/tmp/\xFF/omni-dev"));
assert!(render_plist(bad, Path::new("/tmp/daemon.sock")).is_err());
assert!(render_plist(Path::new("/usr/bin/omni-dev"), bad).is_err());
}
}