service_manager/
launchd.rs

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/// Configuration settings tied to launchd services
20#[derive(Clone, Debug, Default, PartialEq, Eq)]
21pub struct LaunchdConfig {
22    pub install: LaunchdInstallConfig,
23}
24
25/// Configuration settings tied to launchd services during installation
26#[derive(Clone, Debug, PartialEq, Eq)]
27pub struct LaunchdInstallConfig {
28    /// Launchd-specific keep-alive setting. If `Some`, this takes precedence over the generic
29    /// `RestartPolicy` in `ServiceInstallCtx`. If `None`, the generic policy is used.
30    pub keep_alive: Option<bool>,
31}
32
33impl Default for LaunchdInstallConfig {
34    fn default() -> Self {
35        Self { keep_alive: None }
36    }
37}
38
39/// Implementation of [`ServiceManager`] for MacOS's [Launchd](https://en.wikipedia.org/wiki/Launchd)
40#[derive(Clone, Debug, Default, PartialEq, Eq)]
41pub struct LaunchdServiceManager {
42    /// Whether or not this manager is operating at the user-level
43    pub user: bool,
44
45    /// Configuration settings tied to launchd services
46    pub config: LaunchdConfig,
47}
48
49impl LaunchdServiceManager {
50    /// Creates a new manager instance working with system services
51    pub fn system() -> Self {
52        Self::default()
53    }
54
55    /// Creates a new manager instance working with user services
56    pub fn user() -> Self {
57        Self::default().into_user()
58    }
59
60    /// Change manager to work with system services
61    pub fn into_system(self) -> Self {
62        Self {
63            config: self.config,
64            user: false,
65        }
66    }
67
68    /// Change manager to work with user services
69    pub fn into_user(self) -> Self {
70        Self {
71            config: self.config,
72            user: true,
73        }
74    }
75
76    /// Update manager to use the specified config
77    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        // Unload old service first if it exists
130        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        // Load the service.
141        // Services with KeepAlive configured will have Disabled=true set, preventing auto-start
142        // until explicitly started via start(). This provides cross-platform consistency where
143        // install() never auto-starts services.
144        wrap_output(launchctl("load", plist_path.to_string_lossy().as_ref())?)?;
145
146        Ok(())
147    }
148
149    fn uninstall(&self, ctx: ServiceUninstallCtx) -> io::Result<()> {
150        let plist_path = self.get_plist_path(ctx.label.to_qualified_name());
151        // Service might already be removed (if it has "KeepAlive")
152        let _ = wrap_output(launchctl("remove", ctx.label.to_qualified_name().as_str())?);
153        let _ = std::fs::remove_file(plist_path);
154        Ok(())
155    }
156
157    fn start(&self, ctx: ServiceStartCtx) -> io::Result<()> {
158        let qualified_name = ctx.label.to_qualified_name();
159        let plist_path = self.get_plist_path(qualified_name.clone());
160
161        if !plist_path.exists() {
162            return Err(io::Error::new(
163                io::ErrorKind::NotFound,
164                format!("Service {} is not installed", qualified_name),
165            ));
166        }
167
168        let plist_data = std::fs::read(&plist_path)?;
169        let mut plist: Value = plist::from_reader(std::io::Cursor::new(plist_data))
170            .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
171        let is_disabled = if let Value::Dictionary(ref dict) = plist {
172            dict.get("Disabled")
173                .and_then(|v| v.as_boolean())
174                .unwrap_or(false)
175        } else {
176            false
177        };
178
179        if is_disabled {
180            // Service was disable to prevent automatic start when KeepAlive is used. Now the
181            // disabled key will be removed. This makes the services behave in a more sane way like
182            // service managers on other platforms.
183            if let Value::Dictionary(ref mut dict) = plist {
184                dict.remove("Disabled");
185            }
186
187            let mut buffer = Vec::new();
188            plist
189                .to_writer_xml(&mut buffer)
190                .map_err(|e| io::Error::new(io::ErrorKind::Other, e))?;
191            utils::write_file(plist_path.as_path(), &buffer, PLIST_FILE_PERMISSIONS)?;
192
193            let _ = launchctl("unload", plist_path.to_string_lossy().as_ref());
194            wrap_output(launchctl("load", plist_path.to_string_lossy().as_ref())?)?;
195        } else {
196            // Service is not disabled, use regular start command
197            // This works for non-KeepAlive services
198            wrap_output(launchctl("start", qualified_name.as_str())?)?;
199        }
200
201        Ok(())
202    }
203
204    /// Stops a service.
205    ///
206    /// To stop a service with "KeepAlive" enabled, call `uninstall` instead.
207    fn stop(&self, ctx: ServiceStopCtx) -> io::Result<()> {
208        wrap_output(launchctl("stop", ctx.label.to_qualified_name().as_str())?)?;
209        Ok(())
210    }
211
212    fn level(&self) -> ServiceLevel {
213        if self.user {
214            ServiceLevel::User
215        } else {
216            ServiceLevel::System
217        }
218    }
219
220    fn set_level(&mut self, level: ServiceLevel) -> io::Result<()> {
221        match level {
222            ServiceLevel::System => self.user = false,
223            ServiceLevel::User => self.user = true,
224        }
225
226        Ok(())
227    }
228
229    fn status(&self, ctx: crate::ServiceStatusCtx) -> io::Result<crate::ServiceStatus> {
230        let mut service_name = ctx.label.to_qualified_name();
231        // Due to we could not get the status of a service via a service label, so we have to run this command twice
232        // in first time, if there is a service exists, the output will advice us a full service label with a prefix.
233        // Or it will return nothing, it means the service is not installed(not exists).
234        let mut out: Cow<str> = Cow::Borrowed("");
235        for i in 0..2 {
236            let output = launchctl("print", &service_name)?;
237            if !output.status.success() {
238                if output.status.code() == Some(64) {
239                    // 64 is the exit code for a service not found
240                    out = Cow::Owned(String::from_utf8_lossy(&output.stderr).to_string());
241                    if out.trim().is_empty() {
242                        out = Cow::Owned(String::from_utf8_lossy(&output.stdout).to_string());
243                    }
244                    if i == 0 {
245                        let label = out.lines().find(|line| line.contains(&service_name));
246                        match label {
247                            Some(label) => {
248                                service_name = label.trim().to_string();
249                                continue;
250                            }
251                            None => return Ok(crate::ServiceStatus::NotInstalled),
252                        }
253                    } else {
254                        // We have access to the full service label, so it impossible to get the failed status, or it must be input error.
255                        return Err(io::Error::new(
256                            io::ErrorKind::Other,
257                            format!(
258                                "Command failed with exit code {}: {}",
259                                output.status.code().unwrap_or(-1),
260                                out
261                            ),
262                        ));
263                    }
264                } else {
265                    return Err(io::Error::new(
266                        io::ErrorKind::Other,
267                        format!(
268                            "Command failed with exit code {}: {}",
269                            output.status.code().unwrap_or(-1),
270                            String::from_utf8_lossy(&output.stderr)
271                        ),
272                    ));
273                }
274            }
275            out = Cow::Owned(String::from_utf8_lossy(&output.stdout).to_string());
276        }
277        let lines = out
278            .lines()
279            .map(|s| s.trim())
280            .filter(|s| s.contains("state"))
281            .collect::<Vec<&str>>();
282        if lines
283            .into_iter()
284            .any(|s| !s.contains("not running") && s.contains("running"))
285        {
286            Ok(crate::ServiceStatus::Running)
287        } else {
288            Ok(crate::ServiceStatus::Stopped(None))
289        }
290    }
291}
292
293fn launchctl(cmd: &str, label: &str) -> io::Result<Output> {
294    Command::new(LAUNCHCTL)
295        .stdin(Stdio::null())
296        .stdout(Stdio::piped())
297        .stderr(Stdio::piped())
298        .arg(cmd)
299        .arg(label)
300        .output()
301}
302
303#[inline]
304fn global_daemon_dir_path() -> PathBuf {
305    PathBuf::from("/Library/LaunchDaemons")
306}
307
308fn user_agent_dir_path() -> io::Result<PathBuf> {
309    Ok(dirs::home_dir()
310        .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "Unable to locate home directory"))?
311        .join("Library")
312        .join("LaunchAgents"))
313}
314
315fn make_plist<'a>(
316    config: &LaunchdInstallConfig,
317    label: &str,
318    args: impl Iterator<Item = &'a OsStr>,
319    username: Option<String>,
320    working_directory: Option<PathBuf>,
321    environment: Option<Vec<(String, String)>>,
322    autostart: bool,
323    restart_policy: RestartPolicy,
324) -> String {
325    let mut dict = Dictionary::new();
326
327    dict.insert("Label".to_string(), Value::String(label.to_string()));
328
329    let program_arguments: Vec<Value> = args
330        .map(|arg| Value::String(arg.to_string_lossy().into_owned()))
331        .collect();
332    dict.insert(
333        "ProgramArguments".to_string(),
334        Value::Array(program_arguments),
335    );
336
337    // Handle restart configuration
338    // Priority: launchd-specific config > generic RestartPolicy
339    if let Some(keep_alive) = config.keep_alive {
340        // Use launchd-specific keep_alive configuration
341        if keep_alive {
342            dict.insert("KeepAlive".to_string(), Value::Boolean(true));
343        }
344    } else {
345        // Fall back to generic RestartPolicy
346        // Convert generic `RestartPolicy` to Launchd `KeepAlive`.
347        //
348        // Right now we are only supporting binary restart for Launchd, with `KeepAlive` either set or
349        // not.
350        //
351        // However, Launchd does support further options when `KeepAlive` is set, e.g.,
352        // `SuccessfulExit`. These could be extensions for the future.
353        match restart_policy {
354            RestartPolicy::Never => {
355                // Don't set KeepAlive
356            }
357            RestartPolicy::Always { delay_secs } => {
358                // KeepAlive *without* the SuccessfulExit construct will keep the service alive
359                // whether the process exits successfully or not.
360                dict.insert("KeepAlive".to_string(), Value::Boolean(true));
361                if delay_secs.is_some() {
362                    log::warn!(
363                        "Launchd does not support restart delays; delay_secs will be ignored for service '{}'",
364                        label
365                    );
366                }
367            }
368            RestartPolicy::OnFailure { delay_secs } => {
369                // Create KeepAlive dictionary with SuccessfulExit=false
370                // This means: restart when exit is NOT successful
371                let mut keep_alive_dict = Dictionary::new();
372                keep_alive_dict.insert("SuccessfulExit".to_string(), Value::Boolean(false));
373                dict.insert("KeepAlive".to_string(), Value::Dictionary(keep_alive_dict));
374
375                if delay_secs.is_some() {
376                    log::warn!(
377                        "Launchd does not support restart delays; delay_secs will be ignored for service '{}'",
378                        label
379                    );
380                }
381            }
382            RestartPolicy::OnSuccess { delay_secs } => {
383                // Create KeepAlive dictionary with SuccessfulExit=true
384                // This means: restart when exit is successful
385                let mut keep_alive_dict = Dictionary::new();
386                keep_alive_dict.insert("SuccessfulExit".to_string(), Value::Boolean(true));
387                dict.insert("KeepAlive".to_string(), Value::Dictionary(keep_alive_dict));
388
389                if delay_secs.is_some() {
390                    log::warn!(
391                        "Launchd does not support restart delays; delay_secs will be ignored for service '{}'",
392                        label
393                    );
394                }
395            }
396        }
397    }
398
399    if let Some(username) = username {
400        dict.insert("UserName".to_string(), Value::String(username));
401    }
402
403    if let Some(working_dir) = working_directory {
404        dict.insert(
405            "WorkingDirectory".to_string(),
406            Value::String(working_dir.to_string_lossy().to_string()),
407        );
408    }
409
410    if let Some(env_vars) = environment {
411        let env_dict: Dictionary = env_vars
412            .into_iter()
413            .map(|(k, v)| (k, Value::String(v)))
414            .collect();
415        dict.insert(
416            "EnvironmentVariables".to_string(),
417            Value::Dictionary(env_dict),
418        );
419    }
420
421    if autostart {
422        dict.insert("RunAtLoad".to_string(), Value::Boolean(true));
423    } else {
424        dict.insert("RunAtLoad".to_string(), Value::Boolean(false));
425    }
426
427    let has_keep_alive = if let Some(keep_alive) = config.keep_alive {
428        keep_alive
429    } else {
430        !matches!(restart_policy, RestartPolicy::Never)
431    };
432
433    // Set Disabled key to prevent the service automatically starting on load when KeepAlive is present.
434    // This provides consistent cross-platform behaviour which is much more intuitive.
435    // The service must be explicitly started via start().
436    if has_keep_alive {
437        dict.insert("Disabled".to_string(), Value::Boolean(true));
438    }
439
440    let plist = Value::Dictionary(dict);
441
442    let mut buffer = Vec::new();
443    plist.to_writer_xml(&mut buffer).unwrap();
444    String::from_utf8(buffer).unwrap()
445}