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        // If "KeepAlive" is set to true, the service will immediately start.
142        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        // Service might already be removed (if it has "KeepAlive")
150        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        // To start services that do not have "KeepAlive" set to true
157        wrap_output(launchctl("start", ctx.label.to_qualified_name().as_str())?)?;
158        Ok(())
159    }
160
161    /// Stops a service.
162    ///
163    /// To stop a service with "KeepAlive" enabled, call `uninstall` instead.
164    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        // Due to we could not get the status of a service via a service label, so we have to run this command twice
189        // in first time, if there is a service exists, the output will advice us a full service label with a prefix.
190        // Or it will return nothing, it means the service is not installed(not exists).
191        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                    // 64 is the exit code for a service not found
197                    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                        // We have access to the full service label, so it impossible to get the failed status, or it must be input error.
212                        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    // Handle restart configuration
295    // Priority: launchd-specific config > generic RestartPolicy
296    if let Some(keep_alive) = config.keep_alive {
297        // Use launchd-specific keep_alive configuration
298        if keep_alive {
299            dict.insert("KeepAlive".to_string(), Value::Boolean(true));
300        }
301    } else {
302        // Fall back to generic RestartPolicy
303        // Convert generic `RestartPolicy` to Launchd `KeepAlive`.
304        //
305        // Right now we are only supporting binary restart for Launchd, with `KeepAlive` either set or
306        // not.
307        //
308        // However, Launchd does support further options when `KeepAlive` is set, e.g.,
309        // `SuccessfulExit`. These could be extensions for the future.
310        match restart_policy {
311            RestartPolicy::Never => {
312                // Don't set KeepAlive
313            }
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                // Create KeepAlive dictionary with SuccessfulExit=false
338                // This means: restart when exit is successful (exit code 0)
339                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}