diff --git a/src/service.rs b/src/service.rs index 2755570..3abe047 100644 --- a/src/service.rs +++ b/src/service.rs @@ -1,4 +1,4 @@ -//! User-level systemd integration for Appa. +//! User-level daemon lifecycle integration for Appa. use std::{ env, fs, @@ -12,10 +12,16 @@ use directories::BaseDirs; use crate::storage::AppPaths; const UNIT_NAME: &str = "appa.service"; +#[cfg(target_os = "macos")] +const LAUNCHD_LABEL: &str = "dev.appa.appad"; const JOURNAL_LOG_LINE_COUNT: &str = "100"; const RESTART_DELAY_SECONDS: u8 = 5; pub fn install() -> anyhow::Result { + #[cfg(target_os = "macos")] + { + return install_launch_agent(); + } require_linux()?; let unit_file_path = unit_path()?; @@ -40,6 +46,10 @@ pub fn install() -> anyhow::Result { } pub fn is_installed() -> anyhow::Result { + #[cfg(target_os = "macos")] + { + return Ok(launch_agent_path()?.exists()); + } if !is_linux() { return Ok(false); } @@ -47,6 +57,20 @@ pub fn is_installed() -> anyhow::Result { } pub fn is_running() -> anyhow::Result { + #[cfg(target_os = "macos")] + { + let status = Command::new("launchctl") + .args(["print", &launchd_service_target()?]) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status(); + return match status { + Ok(status) => Ok(status.success()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false), + Err(error) => Err(error.into()), + }; + } if !is_linux() { return Ok(false); } @@ -64,6 +88,16 @@ pub fn is_running() -> anyhow::Result { } pub fn status() -> anyhow::Result<()> { + #[cfg(target_os = "macos")] + { + let status = Command::new("launchctl") + .args(["print", &launchd_service_target()?]) + .status()?; + if status.success() { + return Ok(()); + } + anyhow::bail!("launchctl reported that Appa is not running") + } require_linux()?; let status = Command::new("systemctl") .args(["--user", "status", UNIT_NAME]) @@ -76,11 +110,39 @@ pub fn status() -> anyhow::Result<()> { } pub fn restart() -> anyhow::Result<()> { + #[cfg(target_os = "macos")] + { + let status = Command::new("launchctl") + .args(["kickstart", "-k", &launchd_service_target()?]) + .status()?; + if status.success() { + return Ok(()); + } + anyhow::bail!("launchctl could not restart Appa") + } require_linux()?; run_systemctl(&["restart", UNIT_NAME]) } pub fn logs() -> anyhow::Result<()> { + #[cfg(target_os = "macos")] + { + let status = Command::new("log") + .args([ + "show", + "--style", + "compact", + "--last", + "1h", + "--predicate", + &format!("process == '{LAUNCHD_LABEL}'"), + ]) + .status()?; + if status.success() { + return Ok(()); + } + anyhow::bail!("macOS log could not read Appa daemon logs") + } require_linux()?; let status = Command::new("journalctl") .args([ @@ -98,6 +160,10 @@ pub fn logs() -> anyhow::Result<()> { } pub fn uninstall() -> anyhow::Result { + #[cfg(target_os = "macos")] + { + return uninstall_launch_agent(); + } require_linux()?; let unit_path = unit_path()?; @@ -196,6 +262,101 @@ fn systemd_argument(path: &Path) -> anyhow::Result { Ok(escaped) } +#[cfg(target_os = "macos")] +fn install_launch_agent() -> anyhow::Result { + let agent_path = launch_agent_path()?; + let executable = env::current_exe()?; + let app_paths = AppPaths::discover()?; + let contents = launch_agent_contents(&executable, &app_paths.data_directory)?; + let agent_directory = agent_path + .parent() + .ok_or_else(|| anyhow::anyhow!("launch agent path has no parent directory"))?; + fs::create_dir_all(agent_directory)?; + fs::write(&agent_path, contents)?; + + let user_domain = launchd_user_domain()?; + let agent_path_string = agent_path + .to_str() + .ok_or_else(|| anyhow::anyhow!("launch agent path is not UTF-8"))?; + let _ = Command::new("launchctl") + .args(["bootout", &launchd_service_target()?]) + .status(); + let status = Command::new("launchctl") + .args(["bootstrap", &user_domain, agent_path_string]) + .status()?; + if !status.success() { + anyhow::bail!("launchctl could not install the Appa launch agent") + } + let status = Command::new("launchctl") + .args(["kickstart", "-k", &launchd_service_target()?]) + .status()?; + if !status.success() { + anyhow::bail!("launchctl could not start the Appa launch agent") + } + Ok(agent_path) +} + +#[cfg(target_os = "macos")] +fn uninstall_launch_agent() -> anyhow::Result { + let agent_path = launch_agent_path()?; + if !agent_path.exists() { + return Ok(false); + } + let _ = Command::new("launchctl") + .args(["bootout", &launchd_service_target()?]) + .status(); + fs::remove_file(agent_path)?; + Ok(true) +} + +#[cfg(target_os = "macos")] +fn launch_agent_path() -> anyhow::Result { + let home_directory = BaseDirs::new() + .ok_or_else(|| anyhow::anyhow!("could not determine the user home directory"))? + .home_dir() + .to_owned(); + Ok(home_directory + .join("Library") + .join("LaunchAgents") + .join(format!("{LAUNCHD_LABEL}.plist"))) +} + +#[cfg(target_os = "macos")] +fn launchd_service_target() -> anyhow::Result { + Ok(format!("{}/{}", launchd_user_domain()?, LAUNCHD_LABEL)) +} + +#[cfg(target_os = "macos")] +fn launchd_user_domain() -> anyhow::Result { + let output = Command::new("id").arg("-u").output()?; + if !output.status.success() { + anyhow::bail!("could not determine the current user id") + } + Ok(format!("gui/{}", String::from_utf8(output.stdout)?.trim())) +} + +#[cfg(target_os = "macos")] +fn launch_agent_contents(executable: &Path, data_directory: &Path) -> anyhow::Result { + let executable = xml_escape(executable)?; + let data_directory = xml_escape(data_directory)?; + Ok(format!( + "\n\nLabel{LAUNCHD_LABEL}ProgramArguments{executable}daemonserveEnvironmentVariablesAPPA_HOME{data_directory}RunAtLoadKeepAlive\n" + )) +} + +#[cfg(target_os = "macos")] +fn xml_escape(path: &Path) -> anyhow::Result { + let path = path + .to_str() + .ok_or_else(|| anyhow::anyhow!("launchd requires a UTF-8 path: {}", path.display()))?; + Ok(path + .replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) + .replace('\'', "'")) +} + #[cfg(test)] mod tests { use std::path::Path;