1use crate::utils::wrap_output;
2
3use super::{
4 utils, RestartPolicy, ServiceInstallCtx, ServiceLevel, ServiceManager, ServiceStartCtx,
5 ServiceStopCtx, 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: Option<bool>,
31}
32
33impl Default for LaunchdInstallConfig {
34 fn default() -> Self {
35 Self { keep_alive: None }
36 }
37}
38
39#[derive(Clone, Debug, Default, PartialEq, Eq)]
41pub struct LaunchdServiceManager {
42 pub user: bool,
44
45 pub config: LaunchdConfig,
47}
48
49impl LaunchdServiceManager {
50 pub fn system() -> Self {
52 Self::default()
53 }
54
55 pub fn user() -> Self {
57 Self::default().into_user()
58 }
59
60 pub fn into_system(self) -> Self {
62 Self {
63 config: self.config,
64 user: false,
65 }
66 }
67
68 pub fn into_user(self) -> Self {
70 Self {
71 config: self.config,
72 user: true,
73 }
74 }
75
76 pub fn with_config(self, config: LaunchdConfig) -> Self {
78 Self {
79 config,
80 user: self.user,
81 }
82 }
83
84 fn get_plist_path(&self, qualified_name: String) -> PathBuf {
85 let dir_path = if self.user {
86 user_agent_dir_path().unwrap()
87 } else {
88 global_daemon_dir_path()
89 };
90
91 dir_path.join(format!("{}.plist", qualified_name))
92 }
93}
94
95impl ServiceManager for LaunchdServiceManager {
96 fn available(&self) -> io::Result<bool> {
97 match which::which(LAUNCHCTL) {
98 Ok(_) => Ok(true),
99 Err(which::Error::CannotFindBinaryPath) => Ok(false),
100 Err(x) => Err(io::Error::new(io::ErrorKind::Other, x)),
101 }
102 }
103
104 fn install(&self, ctx: ServiceInstallCtx) -> io::Result<()> {
105 let dir_path = if self.user {
106 user_agent_dir_path()?
107 } else {
108 global_daemon_dir_path()
109 };
110
111 std::fs::create_dir_all(&dir_path)?;
112
113 let qualified_name = ctx.label.to_qualified_name();
114 let plist_path = dir_path.join(format!("{}.plist", qualified_name));
115 let plist = match ctx.contents {
116 Some(contents) => contents,
117 _ => make_plist(
118 &self.config.install,
119 &qualified_name,
120 ctx.cmd_iter(),
121 ctx.username.clone(),
122 ctx.working_directory.clone(),
123 ctx.environment.clone(),
124 ctx.autostart,
125 ctx.restart_policy,
126 ),
127 };
128
129 if plist_path.exists() {
131 let _ = wrap_output(launchctl("remove", ctx.label.to_qualified_name().as_str())?);
132 }
133
134 utils::write_file(
135 plist_path.as_path(),
136 plist.as_bytes(),
137 PLIST_FILE_PERMISSIONS,
138 )?;
139
140 wrap_output(launchctl("load", plist_path.to_string_lossy().as_ref())?)?;
143
144 Ok(())
145 }
146
147 fn uninstall(&self, ctx: ServiceUninstallCtx) -> io::Result<()> {
148 let plist_path = self.get_plist_path(ctx.label.to_qualified_name());
149 let _ = wrap_output(launchctl("remove", ctx.label.to_qualified_name().as_str())?);
151 let _ = std::fs::remove_file(plist_path);
152 Ok(())
153 }
154
155 fn start(&self, ctx: ServiceStartCtx) -> io::Result<()> {
156 wrap_output(launchctl("start", ctx.label.to_qualified_name().as_str())?)?;
158 Ok(())
159 }
160
161 fn stop(&self, ctx: ServiceStopCtx) -> io::Result<()> {
165 wrap_output(launchctl("stop", ctx.label.to_qualified_name().as_str())?)?;
166 Ok(())
167 }
168
169 fn level(&self) -> ServiceLevel {
170 if self.user {
171 ServiceLevel::User
172 } else {
173 ServiceLevel::System
174 }
175 }
176
177 fn set_level(&mut self, level: ServiceLevel) -> io::Result<()> {
178 match level {
179 ServiceLevel::System => self.user = false,
180 ServiceLevel::User => self.user = true,
181 }
182
183 Ok(())
184 }
185
186 fn status(&self, ctx: crate::ServiceStatusCtx) -> io::Result<crate::ServiceStatus> {
187 let mut service_name = ctx.label.to_qualified_name();
188 let mut out: Cow<str> = Cow::Borrowed("");
192 for i in 0..2 {
193 let output = launchctl("print", &service_name)?;
194 if !output.status.success() {
195 if output.status.code() == Some(64) {
196 out = Cow::Owned(String::from_utf8_lossy(&output.stderr).to_string());
198 if out.trim().is_empty() {
199 out = Cow::Owned(String::from_utf8_lossy(&output.stdout).to_string());
200 }
201 if i == 0 {
202 let label = out.lines().find(|line| line.contains(&service_name));
203 match label {
204 Some(label) => {
205 service_name = label.trim().to_string();
206 continue;
207 }
208 None => return Ok(crate::ServiceStatus::NotInstalled),
209 }
210 } else {
211 return Err(io::Error::new(
213 io::ErrorKind::Other,
214 format!(
215 "Command failed with exit code {}: {}",
216 output.status.code().unwrap_or(-1),
217 out
218 ),
219 ));
220 }
221 } else {
222 return Err(io::Error::new(
223 io::ErrorKind::Other,
224 format!(
225 "Command failed with exit code {}: {}",
226 output.status.code().unwrap_or(-1),
227 String::from_utf8_lossy(&output.stderr)
228 ),
229 ));
230 }
231 }
232 out = Cow::Owned(String::from_utf8_lossy(&output.stdout).to_string());
233 }
234 let lines = out
235 .lines()
236 .map(|s| s.trim())
237 .filter(|s| s.contains("state"))
238 .collect::<Vec<&str>>();
239 if lines
240 .into_iter()
241 .any(|s| !s.contains("not running") && s.contains("running"))
242 {
243 Ok(crate::ServiceStatus::Running)
244 } else {
245 Ok(crate::ServiceStatus::Stopped(None))
246 }
247 }
248}
249
250fn launchctl(cmd: &str, label: &str) -> io::Result<Output> {
251 Command::new(LAUNCHCTL)
252 .stdin(Stdio::null())
253 .stdout(Stdio::piped())
254 .stderr(Stdio::piped())
255 .arg(cmd)
256 .arg(label)
257 .output()
258}
259
260#[inline]
261fn global_daemon_dir_path() -> PathBuf {
262 PathBuf::from("/Library/LaunchDaemons")
263}
264
265fn user_agent_dir_path() -> io::Result<PathBuf> {
266 Ok(dirs::home_dir()
267 .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "Unable to locate home directory"))?
268 .join("Library")
269 .join("LaunchAgents"))
270}
271
272fn make_plist<'a>(
273 config: &LaunchdInstallConfig,
274 label: &str,
275 args: impl Iterator<Item = &'a OsStr>,
276 username: Option<String>,
277 working_directory: Option<PathBuf>,
278 environment: Option<Vec<(String, String)>>,
279 autostart: bool,
280 restart_policy: RestartPolicy,
281) -> String {
282 let mut dict = Dictionary::new();
283
284 dict.insert("Label".to_string(), Value::String(label.to_string()));
285
286 let program_arguments: Vec<Value> = args
287 .map(|arg| Value::String(arg.to_string_lossy().into_owned()))
288 .collect();
289 dict.insert(
290 "ProgramArguments".to_string(),
291 Value::Array(program_arguments),
292 );
293
294 if let Some(keep_alive) = config.keep_alive {
297 if keep_alive {
299 dict.insert("KeepAlive".to_string(), Value::Boolean(true));
300 }
301 } else {
302 match restart_policy {
311 RestartPolicy::Never => {
312 }
314 RestartPolicy::Always { delay_secs } => {
315 dict.insert("KeepAlive".to_string(), Value::Boolean(true));
316 if delay_secs.is_some() {
317 log::warn!(
318 "Launchd does not support restart delays; delay_secs will be ignored for service '{}'",
319 label
320 );
321 }
322 }
323 RestartPolicy::OnFailure { delay_secs } => {
324 dict.insert("KeepAlive".to_string(), Value::Boolean(true));
325 log::warn!(
326 "Right now we don't have more granular restart support for Launchd so the service will always restart; using KeepAlive=true for service '{}'",
327 label
328 );
329 if delay_secs.is_some() {
330 log::warn!(
331 "Launchd does not support restart delays; delay_secs will be ignored for service '{}'",
332 label
333 );
334 }
335 }
336 RestartPolicy::OnSuccess { delay_secs } => {
337 let mut keep_alive_dict = Dictionary::new();
340 keep_alive_dict.insert("SuccessfulExit".to_string(), Value::Boolean(false));
341 dict.insert("KeepAlive".to_string(), Value::Dictionary(keep_alive_dict));
342
343 if delay_secs.is_some() {
344 log::warn!(
345 "Launchd does not support restart delays; delay_secs will be ignored for service '{}'",
346 label
347 );
348 }
349 }
350 }
351 }
352
353 if let Some(username) = username {
354 dict.insert("UserName".to_string(), Value::String(username));
355 }
356
357 if let Some(working_dir) = working_directory {
358 dict.insert(
359 "WorkingDirectory".to_string(),
360 Value::String(working_dir.to_string_lossy().to_string()),
361 );
362 }
363
364 if let Some(env_vars) = environment {
365 let env_dict: Dictionary = env_vars
366 .into_iter()
367 .map(|(k, v)| (k, Value::String(v)))
368 .collect();
369 dict.insert(
370 "EnvironmentVariables".to_string(),
371 Value::Dictionary(env_dict),
372 );
373 }
374
375 if autostart {
376 dict.insert("RunAtLoad".to_string(), Value::Boolean(true));
377 } else {
378 dict.insert("RunAtLoad".to_string(), Value::Boolean(false));
379 }
380
381 let plist = Value::Dictionary(dict);
382
383 let mut buffer = Vec::new();
384 plist.to_writer_xml(&mut buffer).unwrap();
385 String::from_utf8(buffer).unwrap()
386}