use std::os::unix::io::{FromRawFd, RawFd};
use std::os::unix::net::UnixListener as StdUnixListener;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::Duration;
use anyhow::{bail, Context, Result};
use tokio::net::UnixListener;
use super::paths;
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>Sockets</key>
<dict>
<key>Listener</key>
<dict>
<key>SockPathName</key>
<string>{socket}</string>
<key>SockPathMode</key>
<integer>384</integer>
<key>SockFamily</key>
<string>Unix</string>
<key>SockType</key>
<string>stream</string>
</dict>
</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())
}
fn service_target(domain: &str) -> String {
format!("{domain}/{LAUNCHD_LABEL}")
}
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()))?;
if let Some(parent) = socket.parent() {
paths::ensure_dir_0700(parent)?;
}
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 = service_target(domain);
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 = service_target(domain);
if let Err(e) = Command::new("launchctl")
.args(["bootout", &target])
.output()
{
tracing::warn!("failed to run `launchctl bootout {target}`: {e}");
}
}
#[allow(unsafe_code)]
pub(crate) fn launchd_listener(name: &str) -> Result<Option<UnixListener>> {
use nix::libc::{self, c_char, c_int, size_t};
extern "C" {
fn launch_activate_socket(
name: *const c_char,
fds: *mut *mut RawFd,
cnt: *mut size_t,
) -> c_int;
}
let c_name = std::ffi::CString::new(name).context("launchd socket name had an interior NUL")?;
let mut fds: *mut RawFd = std::ptr::null_mut();
let mut cnt: size_t = 0;
let rc = unsafe { launch_activate_socket(c_name.as_ptr(), &mut fds, &mut cnt) };
if rc != 0 {
tracing::debug!("launch_activate_socket({name}) returned {rc}; not socket-activated");
return Ok(None);
}
if fds.is_null() || cnt == 0 {
if !fds.is_null() {
unsafe { libc::free(fds.cast()) };
}
return Ok(None);
}
let raw = unsafe { *fds };
if cnt > 1 {
tracing::warn!(
"launch_activate_socket({name}) returned {cnt} descriptors; adopting the first and \
closing {} extra",
cnt - 1
);
for i in 1..cnt {
let extra = unsafe { *fds.add(i) };
unsafe { libc::close(extra) };
}
}
unsafe { libc::free(fds.cast()) };
let std_listener = unsafe { StdUnixListener::from_raw_fd(raw) };
std_listener
.set_nonblocking(true)
.context("failed to set the launchd socket non-blocking")?;
let listener = UnixListener::from_std(std_listener)
.context("failed to adopt the launchd socket into the Tokio runtime")?;
Ok(Some(listener))
}
#[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 renders_a_socket_activated_agent() {
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("<key>Sockets</key>"), "{plist}");
assert!(plist.contains("<key>Listener</key>"), "{plist}");
assert!(
plist.contains(
"<key>SockPathName</key>\n <string>/tmp/omni-dev/daemon.sock</string>"
),
"{plist}"
);
assert!(
plist.contains("<key>SockPathMode</key>\n <integer>384</integer>"),
"{plist}"
);
assert!(plist.contains("<string>Unix</string>"), "{plist}");
assert!(plist.contains("<string>stream</string>"), "{plist}");
assert!(!plist.contains("RunAtLoad"), "{plist}");
assert!(!plist.contains("KeepAlive"), "{plist}");
assert_well_formed(&plist);
}
#[test]
fn launchd_listener_is_none_when_not_activated() {
let result = launchd_listener("Listener");
assert!(
matches!(result, Ok(None)),
"expected Ok(None) outside launchd, got {result:?}"
);
}
#[test]
fn service_target_joins_domain_and_label() {
assert_eq!(
service_target("gui/501"),
format!("gui/501/{LAUNCHD_LABEL}")
);
}
#[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());
}
}