1use crate::utils::wrap_output;
2
3use super::{
4 utils, ServiceInstallCtx, ServiceLevel, ServiceManager, ServiceStartCtx, ServiceStopCtx,
5 ServiceUninstallCtx,
6};
7use plist::{Dictionary, Value};
8use std::{
9 borrow::Cow,
10 ffi::OsStr,
11 io,
12 path::PathBuf,
13 process::{Command, Output, Stdio},
14};
15
16static LAUNCHCTL: &str = "launchctl";
17const PLIST_FILE_PERMISSIONS: u32 = 0o644;
18
19#[derive(Clone, Debug, Default, PartialEq, Eq)]
21pub struct LaunchdConfig {
22 pub install: LaunchdInstallConfig,
23}
24
25#[derive(Clone, Debug, PartialEq, Eq)]
27pub struct LaunchdInstallConfig {
28 pub keep_alive: bool,
30}
31
32impl Default for LaunchdInstallConfig {
33 fn default() -> Self {
34 Self { keep_alive: true }
35 }
36}
37
38#[derive(Clone, Debug, Default, PartialEq, Eq)]
40pub struct LaunchdServiceManager {
41 pub user: bool,
43
44 pub config: LaunchdConfig,
46}
47
48impl LaunchdServiceManager {
49 pub fn system() -> Self {
51 Self::default()
52 }
53
54 pub fn user() -> Self {
56 Self::default().into_user()
57 }
58
59 pub fn into_system(self) -> Self {
61 Self {
62 config: self.config,
63 user: false,
64 }
65 }
66
67 pub fn into_user(self) -> Self {
69 Self {
70 config: self.config,
71 user: true,
72 }
73 }
74
75 pub fn with_config(self, config: LaunchdConfig) -> Self {
77 Self {
78 config,
79 user: self.user,
80 }
81 }
82
83 fn get_plist_path(&self, qualified_name: String) -> PathBuf {
84 let dir_path = if self.user {
85 user_agent_dir_path().unwrap()
86 } else {
87 global_daemon_dir_path()
88 };
89
90 dir_path.join(format!("{}.plist", qualified_name))
91 }
92}
93
94impl ServiceManager for LaunchdServiceManager {
95 fn available(&self) -> io::Result<bool> {
96 match which::which(LAUNCHCTL) {
97 Ok(_) => Ok(true),
98 Err(which::Error::CannotFindBinaryPath) => Ok(false),
99 Err(x) => Err(io::Error::new(io::ErrorKind::Other, x)),
100 }
101 }
102
103 fn install(&self, ctx: ServiceInstallCtx) -> io::Result<()> {
104 let dir_path = if self.user {
105 user_agent_dir_path()?
106 } else {
107 global_daemon_dir_path()
108 };
109
110 std::fs::create_dir_all(&dir_path)?;
111
112 let qualified_name = ctx.label.to_qualified_name();
113 let plist_path = dir_path.join(format!("{}.plist", qualified_name));
114 let plist = match ctx.contents {
115 Some(contents) => contents,
116 _ => make_plist(
117 &self.config.install,
118 &qualified_name,
119 ctx.cmd_iter(),
120 ctx.username.clone(),
121 ctx.working_directory.clone(),
122 ctx.environment.clone(),
123 ctx.autostart,
124 ctx.disable_restart_on_failure
125 ),
126 };
127
128 if plist_path.exists() {
130 let _ = wrap_output(launchctl("remove", ctx.label.to_qualified_name().as_str())?);
131 }
132
133 utils::write_file(
134 plist_path.as_path(),
135 plist.as_bytes(),
136 PLIST_FILE_PERMISSIONS,
137 )?;
138
139 wrap_output(launchctl("load", plist_path.to_string_lossy().as_ref())?)?;
142
143 Ok(())
144 }
145
146 fn uninstall(&self, ctx: ServiceUninstallCtx) -> io::Result<()> {
147 let plist_path = self.get_plist_path(ctx.label.to_qualified_name());
148 let _ = wrap_output(launchctl("remove", ctx.label.to_qualified_name().as_str())?);
150 let _ = std::fs::remove_file(plist_path);
151 Ok(())
152 }
153
154 fn start(&self, ctx: ServiceStartCtx) -> io::Result<()> {
155 wrap_output(launchctl("start", ctx.label.to_qualified_name().as_str())?)?;
157 Ok(())
158 }
159
160 fn stop(&self, ctx: ServiceStopCtx) -> io::Result<()> {
164 wrap_output(launchctl("stop", ctx.label.to_qualified_name().as_str())?)?;
165 Ok(())
166 }
167
168 fn level(&self) -> ServiceLevel {
169 if self.user {
170 ServiceLevel::User
171 } else {
172 ServiceLevel::System
173 }
174 }
175
176 fn set_level(&mut self, level: ServiceLevel) -> io::Result<()> {
177 match level {
178 ServiceLevel::System => self.user = false,
179 ServiceLevel::User => self.user = true,
180 }
181
182 Ok(())
183 }
184
185 fn status(&self, ctx: crate::ServiceStatusCtx) -> io::Result<crate::ServiceStatus> {
186 let mut service_name = ctx.label.to_qualified_name();
187 let mut out: Cow<str> = Cow::Borrowed("");
191 for i in 0..2 {
192 let output = launchctl("print", &service_name)?;
193 if !output.status.success() {
194 if output.status.code() == Some(64) {
195 out = Cow::Owned(String::from_utf8_lossy(&output.stderr).to_string());
197 if out.trim().is_empty() {
198 out = Cow::Owned(String::from_utf8_lossy(&output.stdout).to_string());
199 }
200 if i == 0 {
201 let label = out.lines().find(|line| line.contains(&service_name));
202 match label {
203 Some(label) => {
204 service_name = label.trim().to_string();
205 continue;
206 }
207 None => return Ok(crate::ServiceStatus::NotInstalled),
208 }
209 } else {
210 return Err(io::Error::new(
212 io::ErrorKind::Other,
213 format!(
214 "Command failed with exit code {}: {}",
215 output.status.code().unwrap_or(-1),
216 out
217 ),
218 ));
219 }
220 } else {
221 return Err(io::Error::new(
222 io::ErrorKind::Other,
223 format!(
224 "Command failed with exit code {}: {}",
225 output.status.code().unwrap_or(-1),
226 String::from_utf8_lossy(&output.stderr)
227 ),
228 ));
229 }
230 }
231 out = Cow::Owned(String::from_utf8_lossy(&output.stdout).to_string());
232 }
233 let lines = out
234 .lines()
235 .map(|s| s.trim())
236 .filter(|s| s.contains("state"))
237 .collect::<Vec<&str>>();
238 if lines
239 .into_iter()
240 .any(|s| !s.contains("not running") && s.contains("running"))
241 {
242 Ok(crate::ServiceStatus::Running)
243 } else {
244 Ok(crate::ServiceStatus::Stopped(None))
245 }
246 }
247}
248
249fn launchctl(cmd: &str, label: &str) -> io::Result<Output> {
250 Command::new(LAUNCHCTL)
251 .stdin(Stdio::null())
252 .stdout(Stdio::piped())
253 .stderr(Stdio::piped())
254 .arg(cmd)
255 .arg(label)
256 .output()
257}
258
259#[inline]
260fn global_daemon_dir_path() -> PathBuf {
261 PathBuf::from("/Library/LaunchDaemons")
262}
263
264fn user_agent_dir_path() -> io::Result<PathBuf> {
265 Ok(dirs::home_dir()
266 .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "Unable to locate home directory"))?
267 .join("Library")
268 .join("LaunchAgents"))
269}
270
271fn make_plist<'a>(
272 config: &LaunchdInstallConfig,
273 label: &str,
274 args: impl Iterator<Item = &'a OsStr>,
275 username: Option<String>,
276 working_directory: Option<PathBuf>,
277 environment: Option<Vec<(String, String)>>,
278 autostart: bool,
279 disable_restart_on_failure: bool,
280) -> String {
281 let mut dict = Dictionary::new();
282
283 dict.insert("Label".to_string(), Value::String(label.to_string()));
284
285 let program_arguments: Vec<Value> = args
286 .map(|arg| Value::String(arg.to_string_lossy().into_owned()))
287 .collect();
288 dict.insert(
289 "ProgramArguments".to_string(),
290 Value::Array(program_arguments),
291 );
292
293 if !disable_restart_on_failure {
294 dict.insert("KeepAlive".to_string(), Value::Boolean(config.keep_alive));
295 }
296
297 if let Some(username) = username {
298 dict.insert("UserName".to_string(), Value::String(username));
299 }
300
301 if let Some(working_dir) = working_directory {
302 dict.insert(
303 "WorkingDirectory".to_string(),
304 Value::String(working_dir.to_string_lossy().to_string()),
305 );
306 }
307
308 if let Some(env_vars) = environment {
309 let env_dict: Dictionary = env_vars
310 .into_iter()
311 .map(|(k, v)| (k, Value::String(v)))
312 .collect();
313 dict.insert(
314 "EnvironmentVariables".to_string(),
315 Value::Dictionary(env_dict),
316 );
317 }
318
319 if autostart {
320 dict.insert("RunAtLoad".to_string(), Value::Boolean(true));
321 } else {
322 dict.insert("RunAtLoad".to_string(), Value::Boolean(false));
323 }
324
325 let plist = Value::Dictionary(dict);
326
327 let mut buffer = Vec::new();
328 plist.to_writer_xml(&mut buffer).unwrap();
329 String::from_utf8(buffer).unwrap()
330}