use crate::utils::wrap_output;
use super::{
utils, RestartPolicy, ServiceInstallCtx, ServiceLevel, ServiceManager, ServiceStartCtx,
ServiceStopCtx, ServiceUninstallCtx,
};
use plist::{Dictionary, Value};
use std::{
borrow::Cow,
ffi::OsStr,
io,
path::PathBuf,
process::{Command, Output, Stdio},
};
static LAUNCHCTL: &str = "launchctl";
const PLIST_FILE_PERMISSIONS: u32 = 0o644;
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct LaunchdConfig {
pub install: LaunchdInstallConfig,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct LaunchdInstallConfig {
pub keep_alive: Option<bool>,
}
impl Default for LaunchdInstallConfig {
fn default() -> Self {
Self { keep_alive: None }
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct LaunchdServiceManager {
pub user: bool,
pub config: LaunchdConfig,
}
impl LaunchdServiceManager {
pub fn system() -> Self {
Self::default()
}
pub fn user() -> Self {
Self::default().into_user()
}
pub fn into_system(self) -> Self {
Self {
config: self.config,
user: false,
}
}
pub fn into_user(self) -> Self {
Self {
config: self.config,
user: true,
}
}
pub fn with_config(self, config: LaunchdConfig) -> Self {
Self {
config,
user: self.user,
}
}
fn get_plist_path(&self, qualified_name: String) -> PathBuf {
let dir_path = if self.user {
user_agent_dir_path().unwrap()
} else {
global_daemon_dir_path()
};
dir_path.join(format!("{}.plist", qualified_name))
}
}
impl ServiceManager for LaunchdServiceManager {
fn available(&self) -> io::Result<bool> {
match which::which(LAUNCHCTL) {
Ok(_) => Ok(true),
Err(which::Error::CannotFindBinaryPath) => Ok(false),
Err(x) => Err(io::Error::new(io::ErrorKind::Other, x)),
}
}
fn install(&self, ctx: ServiceInstallCtx) -> io::Result<()> {
let dir_path = if self.user {
user_agent_dir_path()?
} else {
global_daemon_dir_path()
};
std::fs::create_dir_all(&dir_path)?;
let qualified_name = ctx.label.to_qualified_name();
let plist_path = dir_path.join(format!("{}.plist", qualified_name));
let plist = match ctx.contents {
Some(contents) => contents,
_ => make_plist(
&self.config.install,
&qualified_name,
ctx.cmd_iter(),
ctx.username.clone(),
ctx.working_directory.clone(),
ctx.environment.clone(),
ctx.autostart,
ctx.restart_policy,
),
};
if plist_path.exists() {
let _ = wrap_output(launchctl("remove", ctx.label.to_qualified_name().as_str())?);
}
utils::write_file(
plist_path.as_path(),
plist.as_bytes(),
PLIST_FILE_PERMISSIONS,
)?;
wrap_output(launchctl("load", plist_path.to_string_lossy().as_ref())?)?;
Ok(())
}
fn uninstall(&self, ctx: ServiceUninstallCtx) -> io::Result<()> {
let plist_path = self.get_plist_path(ctx.label.to_qualified_name());
let _ = wrap_output(launchctl("remove", ctx.label.to_qualified_name().as_str())?);
let _ = std::fs::remove_file(plist_path);
Ok(())
}
fn start(&self, ctx: ServiceStartCtx) -> io::Result<()> {
let qualified_name = ctx.label.to_qualified_name();
let plist_path = self.get_plist_path(qualified_name.clone());
if !plist_path.exists() {
return Err(io::Error::new(
io::ErrorKind::NotFound,
format!("Service {} is not installed", qualified_name),
));
}
let plist_data = std::fs::read(&plist_path)?;
let mut plist: Value = plist::from_reader(std::io::Cursor::new(plist_data))
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
let is_disabled = if let Value::Dictionary(ref dict) = plist {
dict.get("Disabled")
.and_then(|v| v.as_boolean())
.unwrap_or(false)
} else {
false
};
if is_disabled {
if let Value::Dictionary(ref mut dict) = plist {
dict.remove("Disabled");
}
let mut buffer = Vec::new();
plist
.to_writer_xml(&mut buffer)
.map_err(|e| io::Error::new(io::ErrorKind::Other, e))?;
utils::write_file(plist_path.as_path(), &buffer, PLIST_FILE_PERMISSIONS)?;
let _ = launchctl("unload", plist_path.to_string_lossy().as_ref());
wrap_output(launchctl("load", plist_path.to_string_lossy().as_ref())?)?;
} else {
wrap_output(launchctl("start", qualified_name.as_str())?)?;
}
Ok(())
}
fn stop(&self, ctx: ServiceStopCtx) -> io::Result<()> {
wrap_output(launchctl("stop", ctx.label.to_qualified_name().as_str())?)?;
Ok(())
}
fn level(&self) -> ServiceLevel {
if self.user {
ServiceLevel::User
} else {
ServiceLevel::System
}
}
fn set_level(&mut self, level: ServiceLevel) -> io::Result<()> {
match level {
ServiceLevel::System => self.user = false,
ServiceLevel::User => self.user = true,
}
Ok(())
}
fn status(&self, ctx: crate::ServiceStatusCtx) -> io::Result<crate::ServiceStatus> {
let mut service_name = ctx.label.to_qualified_name();
let mut out: Cow<str> = Cow::Borrowed("");
for i in 0..2 {
let output = launchctl("print", &service_name)?;
if !output.status.success() {
if output.status.code() == Some(64) {
out = Cow::Owned(String::from_utf8_lossy(&output.stderr).to_string());
if out.trim().is_empty() {
out = Cow::Owned(String::from_utf8_lossy(&output.stdout).to_string());
}
if i == 0 {
let label = out.lines().find(|line| line.contains(&service_name));
match label {
Some(label) => {
service_name = label.trim().to_string();
continue;
}
None => return Ok(crate::ServiceStatus::NotInstalled),
}
} else {
return Err(io::Error::new(
io::ErrorKind::Other,
format!(
"Command failed with exit code {}: {}",
output.status.code().unwrap_or(-1),
out
),
));
}
} else {
return Err(io::Error::new(
io::ErrorKind::Other,
format!(
"Command failed with exit code {}: {}",
output.status.code().unwrap_or(-1),
String::from_utf8_lossy(&output.stderr)
),
));
}
}
out = Cow::Owned(String::from_utf8_lossy(&output.stdout).to_string());
}
let lines = out
.lines()
.map(|s| s.trim())
.filter(|s| s.contains("state"))
.collect::<Vec<&str>>();
if lines
.into_iter()
.any(|s| !s.contains("not running") && s.contains("running"))
{
Ok(crate::ServiceStatus::Running)
} else {
Ok(crate::ServiceStatus::Stopped(None))
}
}
}
fn launchctl(cmd: &str, label: &str) -> io::Result<Output> {
Command::new(LAUNCHCTL)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.arg(cmd)
.arg(label)
.output()
}
#[inline]
fn global_daemon_dir_path() -> PathBuf {
PathBuf::from("/Library/LaunchDaemons")
}
fn user_agent_dir_path() -> io::Result<PathBuf> {
Ok(dirs::home_dir()
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "Unable to locate home directory"))?
.join("Library")
.join("LaunchAgents"))
}
fn make_plist<'a>(
config: &LaunchdInstallConfig,
label: &str,
args: impl Iterator<Item = &'a OsStr>,
username: Option<String>,
working_directory: Option<PathBuf>,
environment: Option<Vec<(String, String)>>,
autostart: bool,
restart_policy: RestartPolicy,
) -> String {
let mut dict = Dictionary::new();
dict.insert("Label".to_string(), Value::String(label.to_string()));
let program_arguments: Vec<Value> = args
.map(|arg| Value::String(arg.to_string_lossy().into_owned()))
.collect();
dict.insert(
"ProgramArguments".to_string(),
Value::Array(program_arguments),
);
if let Some(keep_alive) = config.keep_alive {
if keep_alive {
dict.insert("KeepAlive".to_string(), Value::Boolean(true));
}
} else {
match restart_policy {
RestartPolicy::Never => {
}
RestartPolicy::Always { delay_secs } => {
dict.insert("KeepAlive".to_string(), Value::Boolean(true));
if delay_secs.is_some() {
log::warn!(
"Launchd does not support restart delays; delay_secs will be ignored for service '{}'",
label
);
}
}
RestartPolicy::OnFailure {
delay_secs,
max_retries: _, reset_after_secs: _, } => {
let mut keep_alive_dict = Dictionary::new();
keep_alive_dict.insert("SuccessfulExit".to_string(), Value::Boolean(false));
dict.insert("KeepAlive".to_string(), Value::Dictionary(keep_alive_dict));
if delay_secs.is_some() {
log::warn!(
"Launchd does not support restart delays; delay_secs will be ignored for service '{}'",
label
);
}
}
RestartPolicy::OnSuccess { delay_secs } => {
let mut keep_alive_dict = Dictionary::new();
keep_alive_dict.insert("SuccessfulExit".to_string(), Value::Boolean(true));
dict.insert("KeepAlive".to_string(), Value::Dictionary(keep_alive_dict));
if delay_secs.is_some() {
log::warn!(
"Launchd does not support restart delays; delay_secs will be ignored for service '{}'",
label
);
}
}
}
}
if let Some(username) = username {
dict.insert("UserName".to_string(), Value::String(username));
}
if let Some(working_dir) = working_directory {
dict.insert(
"WorkingDirectory".to_string(),
Value::String(working_dir.to_string_lossy().to_string()),
);
}
if let Some(env_vars) = environment {
let env_dict: Dictionary = env_vars
.into_iter()
.map(|(k, v)| (k, Value::String(v)))
.collect();
dict.insert(
"EnvironmentVariables".to_string(),
Value::Dictionary(env_dict),
);
}
if autostart {
dict.insert("RunAtLoad".to_string(), Value::Boolean(true));
} else {
dict.insert("RunAtLoad".to_string(), Value::Boolean(false));
}
let has_keep_alive = if let Some(keep_alive) = config.keep_alive {
keep_alive
} else {
!matches!(restart_policy, RestartPolicy::Never)
};
if has_keep_alive {
dict.insert("Disabled".to_string(), Value::Boolean(true));
}
let plist = Value::Dictionary(dict);
let mut buffer = Vec::new();
plist.to_writer_xml(&mut buffer).unwrap();
String::from_utf8(buffer).unwrap()
}