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())?)?;
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 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 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 wrap_output(launchctl("start", qualified_name.as_str())?)?;
199 }
200
201 Ok(())
202 }
203
204 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 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 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 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 if let Some(keep_alive) = config.keep_alive {
340 if keep_alive {
342 dict.insert("KeepAlive".to_string(), Value::Boolean(true));
343 }
344 } else {
345 match restart_policy {
354 RestartPolicy::Never => {
355 }
357 RestartPolicy::Always { delay_secs } => {
358 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 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 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 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}