service_manager/
launchd.rs

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/// 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    /// If true, will include `KeepAlive` flag set to true
29    pub keep_alive: bool,
30}
31
32impl Default for LaunchdInstallConfig {
33    fn default() -> Self {
34        Self { keep_alive: true }
35    }
36}
37
38/// Implementation of [`ServiceManager`] for MacOS's [Launchd](https://en.wikipedia.org/wiki/Launchd)
39#[derive(Clone, Debug, Default, PartialEq, Eq)]
40pub struct LaunchdServiceManager {
41    /// Whether or not this manager is operating at the user-level
42    pub user: bool,
43
44    /// Configuration settings tied to launchd services
45    pub config: LaunchdConfig,
46}
47
48impl LaunchdServiceManager {
49    /// Creates a new manager instance working with system services
50    pub fn system() -> Self {
51        Self::default()
52    }
53
54    /// Creates a new manager instance working with user services
55    pub fn user() -> Self {
56        Self::default().into_user()
57    }
58
59    /// Change manager to work with system services
60    pub fn into_system(self) -> Self {
61        Self {
62            config: self.config,
63            user: false,
64        }
65    }
66
67    /// Change manager to work with user services
68    pub fn into_user(self) -> Self {
69        Self {
70            config: self.config,
71            user: true,
72        }
73    }
74
75    /// Update manager to use the specified config
76    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        // Unload old service first if it exists
129        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        // Load the service.
140        // If "KeepAlive" is set to true, the service will immediately start.
141        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        // Service might already be removed (if it has "KeepAlive")
149        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        // To start services that do not have "KeepAlive" set to true
156        wrap_output(launchctl("start", ctx.label.to_qualified_name().as_str())?)?;
157        Ok(())
158    }
159
160    /// Stops a service.
161    ///
162    /// To stop a service with "KeepAlive" enabled, call `uninstall` instead.
163    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        // Due to we could not get the status of a service via a service label, so we have to run this command twice
188        // in first time, if there is a service exists, the output will advice us a full service label with a prefix.
189        // Or it will return nothing, it means the service is not installed(not exists).
190        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                    // 64 is the exit code for a service not found
196                    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                        // We have access to the full service label, so it impossible to get the failed status, or it must be input error.
211                        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}