1use crate::record::write_atomic;
16use std::io;
17use std::path::{Path, PathBuf};
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
21#[non_exhaustive]
22pub enum RestartPolicy {
23 Never,
25 #[default]
27 OnFailure,
28 Always,
30}
31
32#[derive(Debug, Clone, PartialEq, Eq)]
34pub struct ServiceSpec {
35 pub name: String,
37 pub exec: PathBuf,
39 pub args: Vec<String>,
40 pub env: Vec<(String, String)>,
42 pub restart: RestartPolicy,
43}
44
45impl ServiceSpec {
46 pub fn new(name: impl Into<String>, exec: impl Into<PathBuf>) -> Self {
47 Self {
48 name: name.into(),
49 exec: exec.into(),
50 args: Vec::new(),
51 env: Vec::new(),
52 restart: RestartPolicy::default(),
53 }
54 }
55
56 fn validate(&self) -> io::Result<()> {
57 let bad = |what: &str| {
58 Err(io::Error::new(
59 io::ErrorKind::InvalidInput,
60 what.to_string(),
61 ))
62 };
63 if self.name.is_empty()
64 || self.name.len() > 128
65 || !self
66 .name
67 .bytes()
68 .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-'))
69 {
70 return bad("a service name is 1 to 128 characters of letters, digits, . _ -");
71 }
72 if !self.exec.is_absolute() {
73 return bad("the service program must be an absolute path");
74 }
75 let text_ok = |s: &str| !s.contains('\0') && !s.contains('\n') && !s.contains('\r');
76 if !text_ok(&self.exec.to_string_lossy())
77 || !self.args.iter().all(|a| text_ok(a))
78 || !self
79 .env
80 .iter()
81 .all(|(k, v)| text_ok(v) && !k.is_empty() && !k.contains('=') && text_ok(k))
82 {
83 return bad("service arguments and environment hold no NUL or line break");
84 }
85 Ok(())
86 }
87}
88
89#[derive(Debug, Clone, Copy, PartialEq, Eq)]
91#[non_exhaustive]
92pub enum ServiceState {
93 NotInstalled,
94 Stopped,
96 Running {
98 pid: Option<u32>,
99 },
100}
101
102#[derive(Debug, Clone, PartialEq, Eq)]
104pub struct CommandOutput {
105 pub code: i32,
106 pub stdout: String,
107 pub stderr: String,
108}
109
110impl CommandOutput {
111 pub fn success(&self) -> bool {
112 self.code == 0
113 }
114}
115
116pub trait CommandRunner: Send + Sync {
118 fn run(&self, program: &str, args: &[String]) -> io::Result<CommandOutput>;
119}
120
121#[derive(Debug, Clone, Copy, Default)]
123pub struct SystemRunner;
124
125impl CommandRunner for SystemRunner {
126 fn run(&self, program: &str, args: &[String]) -> io::Result<CommandOutput> {
127 let mut cmd = std::process::Command::new(program);
128 cmd.args(args);
129 #[cfg(windows)]
130 {
131 use std::os::windows::process::CommandExt;
132 cmd.creation_flags(0x0800_0000); }
134 let out = cmd.output()?;
135 Ok(CommandOutput {
136 code: out.status.code().unwrap_or(-1),
137 stdout: String::from_utf8_lossy(&out.stdout).into_owned(),
138 stderr: String::from_utf8_lossy(&out.stderr).into_owned(),
139 })
140 }
141}
142
143pub trait ServiceManager: Send + Sync {
145 fn install(&self, spec: &ServiceSpec) -> io::Result<()>;
147 fn start(&self, name: &str) -> io::Result<()>;
148 fn stop(&self, name: &str) -> io::Result<()>;
149 fn status(&self, name: &str) -> io::Result<ServiceState>;
150 fn uninstall(&self, name: &str) -> io::Result<()>;
153}
154
155fn check_name(name: &str) -> io::Result<()> {
156 ServiceSpec::new(name, "/x").validate()
157}
158
159fn failed(what: &str, out: &CommandOutput) -> io::Error {
160 io::Error::other(format!(
161 "{what} failed ({}): {}",
162 out.code,
163 out.stderr.trim()
164 ))
165}
166
167fn run_ok(runner: &dyn CommandRunner, what: &str, program: &str, args: &[&str]) -> io::Result<()> {
168 let args: Vec<String> = args.iter().map(|a| (*a).to_string()).collect();
169 let out = runner.run(program, &args)?;
170 if out.success() {
171 Ok(())
172 } else {
173 Err(failed(what, &out))
174 }
175}
176
177fn xml_escape(text: &str) -> String {
178 let mut out = String::with_capacity(text.len());
179 for c in text.chars() {
180 match c {
181 '&' => out.push_str("&"),
182 '<' => out.push_str("<"),
183 '>' => out.push_str(">"),
184 '"' => out.push_str("""),
185 '\'' => out.push_str("'"),
186 c => out.push(c),
187 }
188 }
189 out
190}
191
192pub struct LaunchAgent {
198 runner: Box<dyn CommandRunner>,
199 agents_dir: PathBuf,
200 uid: u32,
201}
202
203impl LaunchAgent {
204 #[cfg(unix)]
206 pub fn for_current_user() -> io::Result<Self> {
207 let home = std::env::var_os("HOME")
208 .map(PathBuf::from)
209 .filter(|h| h.is_absolute())
210 .ok_or_else(|| io::Error::other("no absolute HOME"))?;
211 Ok(Self::with_parts(
212 Box::new(SystemRunner),
213 home.join("Library").join("LaunchAgents"),
214 crate::transport::current_uid(),
215 ))
216 }
217
218 pub fn with_parts(runner: Box<dyn CommandRunner>, agents_dir: PathBuf, uid: u32) -> Self {
220 Self {
221 runner,
222 agents_dir,
223 uid,
224 }
225 }
226
227 fn plist_path(&self, name: &str) -> PathBuf {
228 self.agents_dir.join(format!("{name}.plist"))
229 }
230
231 fn domain(&self) -> String {
232 format!("gui/{}", self.uid)
233 }
234
235 fn target(&self, name: &str) -> String {
236 format!("gui/{}/{name}", self.uid)
237 }
238
239 pub fn plist(spec: &ServiceSpec) -> String {
241 let mut out = String::from(
242 "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n<plist version=\"1.0\">\n<dict>\n",
243 );
244 out.push_str(&format!(
245 " <key>Label</key>\n <string>{}</string>\n <key>ProgramArguments</key>\n <array>\n <string>{}</string>\n",
246 xml_escape(&spec.name),
247 xml_escape(&spec.exec.to_string_lossy())
248 ));
249 for arg in &spec.args {
250 out.push_str(&format!(" <string>{}</string>\n", xml_escape(arg)));
251 }
252 out.push_str(" </array>\n <key>RunAtLoad</key>\n <true/>\n");
253 if !spec.env.is_empty() {
254 out.push_str(" <key>EnvironmentVariables</key>\n <dict>\n");
255 for (k, v) in &spec.env {
256 out.push_str(&format!(
257 " <key>{}</key>\n <string>{}</string>\n",
258 xml_escape(k),
259 xml_escape(v)
260 ));
261 }
262 out.push_str(" </dict>\n");
263 }
264 match spec.restart {
265 RestartPolicy::Never => {}
266 RestartPolicy::Always => out.push_str(" <key>KeepAlive</key>\n <true/>\n"),
267 RestartPolicy::OnFailure => out.push_str(
268 " <key>KeepAlive</key>\n <dict>\n <key>SuccessfulExit</key>\n <false/>\n </dict>\n",
269 ),
270 }
271 out.push_str("</dict>\n</plist>\n");
272 out
273 }
274}
275
276impl ServiceManager for LaunchAgent {
277 fn install(&self, spec: &ServiceSpec) -> io::Result<()> {
278 spec.validate()?;
279 std::fs::create_dir_all(&self.agents_dir)?;
280 let path = self.plist_path(&spec.name);
281 write_atomic(&path, Self::plist(spec).as_bytes())?;
282 let _ = self
284 .runner
285 .run("launchctl", &["bootout".into(), self.target(&spec.name)]);
286 run_ok(
287 &*self.runner,
288 "launchctl bootstrap",
289 "launchctl",
290 &["bootstrap", &self.domain(), &path.to_string_lossy()],
291 )
292 }
293
294 fn start(&self, name: &str) -> io::Result<()> {
295 check_name(name)?;
296 run_ok(
297 &*self.runner,
298 "launchctl kickstart",
299 "launchctl",
300 &["kickstart", "-k", &self.target(name)],
301 )
302 }
303
304 fn stop(&self, name: &str) -> io::Result<()> {
305 check_name(name)?;
306 let out = self.runner.run(
309 "launchctl",
310 &["kill".into(), "SIGTERM".into(), self.target(name)],
311 )?;
312 if out.success() || !matches!(self.status(name)?, ServiceState::Running { .. }) {
313 Ok(())
314 } else {
315 Err(failed("launchctl kill", &out))
316 }
317 }
318
319 fn status(&self, name: &str) -> io::Result<ServiceState> {
320 check_name(name)?;
321 let out = self
322 .runner
323 .run("launchctl", &["print".into(), self.target(name)])?;
324 if !out.success() {
325 return Ok(if self.plist_path(name).exists() {
326 ServiceState::Stopped
327 } else {
328 ServiceState::NotInstalled
329 });
330 }
331 let pid = out
332 .stdout
333 .lines()
334 .filter_map(|l| l.trim().strip_prefix("pid = "))
335 .find_map(|p| p.trim().parse::<u32>().ok());
336 Ok(match pid {
337 Some(pid) => ServiceState::Running { pid: Some(pid) },
338 None => ServiceState::Stopped,
339 })
340 }
341
342 fn uninstall(&self, name: &str) -> io::Result<()> {
343 check_name(name)?;
344 let _ = self
345 .runner
346 .run("launchctl", &["bootout".into(), self.target(name)]);
347 match std::fs::remove_file(self.plist_path(name)) {
348 Ok(()) => Ok(()),
349 Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()),
350 Err(e) => Err(e),
351 }
352 }
353}
354
355pub struct SystemdUser {
361 runner: Box<dyn CommandRunner>,
362 unit_dir: PathBuf,
363}
364
365impl SystemdUser {
366 pub fn for_current_user() -> io::Result<Self> {
368 let base = std::env::var_os("XDG_CONFIG_HOME")
369 .map(PathBuf::from)
370 .filter(|p| p.is_absolute())
371 .or_else(|| {
372 std::env::var_os("HOME")
373 .map(PathBuf::from)
374 .filter(|h| h.is_absolute())
375 .map(|h| h.join(".config"))
376 })
377 .ok_or_else(|| io::Error::other("no absolute HOME or XDG_CONFIG_HOME"))?;
378 Ok(Self::with_parts(
379 Box::new(SystemRunner),
380 base.join("systemd").join("user"),
381 ))
382 }
383
384 pub fn with_parts(runner: Box<dyn CommandRunner>, unit_dir: PathBuf) -> Self {
385 Self { runner, unit_dir }
386 }
387
388 fn unit_path(&self, name: &str) -> PathBuf {
389 self.unit_dir.join(format!("{name}.service"))
390 }
391
392 fn quote(arg: &str) -> String {
393 let mut out = String::from("\"");
394 for c in arg.chars() {
395 match c {
396 '"' => out.push_str("\\\""),
397 '\\' => out.push_str("\\\\"),
398 '%' => out.push_str("%%"),
399 '$' => out.push_str("$$"),
400 c => out.push(c),
401 }
402 }
403 out.push('"');
404 out
405 }
406
407 pub fn unit(spec: &ServiceSpec) -> String {
409 let mut exec = Self::quote(&spec.exec.to_string_lossy());
410 for arg in &spec.args {
411 exec.push(' ');
412 exec.push_str(&Self::quote(arg));
413 }
414 let mut out = format!(
415 "[Unit]\nDescription={}\n\n[Service]\nExecStart={exec}\n",
416 spec.name
417 );
418 for (k, v) in &spec.env {
419 out.push_str(&format!(
420 "Environment={}\n",
421 Self::quote(&format!("{k}={v}"))
422 ));
423 }
424 out.push_str(match spec.restart {
425 RestartPolicy::Never => "Restart=no\n",
426 RestartPolicy::OnFailure => "Restart=on-failure\n",
427 RestartPolicy::Always => "Restart=always\n",
428 });
429 out.push_str("\n[Install]\nWantedBy=default.target\n");
430 out
431 }
432}
433
434impl ServiceManager for SystemdUser {
435 fn install(&self, spec: &ServiceSpec) -> io::Result<()> {
436 spec.validate()?;
437 std::fs::create_dir_all(&self.unit_dir)?;
438 write_atomic(&self.unit_path(&spec.name), Self::unit(spec).as_bytes())?;
439 run_ok(
440 &*self.runner,
441 "systemctl daemon-reload",
442 "systemctl",
443 &["--user", "daemon-reload"],
444 )?;
445 run_ok(
446 &*self.runner,
447 "systemctl enable",
448 "systemctl",
449 &["--user", "enable", &format!("{}.service", spec.name)],
450 )
451 }
452
453 fn start(&self, name: &str) -> io::Result<()> {
454 check_name(name)?;
455 run_ok(
456 &*self.runner,
457 "systemctl start",
458 "systemctl",
459 &["--user", "start", &format!("{name}.service")],
460 )
461 }
462
463 fn stop(&self, name: &str) -> io::Result<()> {
464 check_name(name)?;
465 run_ok(
466 &*self.runner,
467 "systemctl stop",
468 "systemctl",
469 &["--user", "stop", &format!("{name}.service")],
470 )
471 }
472
473 fn status(&self, name: &str) -> io::Result<ServiceState> {
474 check_name(name)?;
475 if !self.unit_path(name).exists() {
476 return Ok(ServiceState::NotInstalled);
477 }
478 let unit = format!("{name}.service");
479 let active = self.runner.run(
480 "systemctl",
481 &["--user".into(), "is-active".into(), unit.clone()],
482 )?;
483 if active.stdout.trim() != "active" {
484 return Ok(ServiceState::Stopped);
485 }
486 let pid = self
487 .runner
488 .run(
489 "systemctl",
490 &[
491 "--user".into(),
492 "show".into(),
493 "-p".into(),
494 "MainPID".into(),
495 "--value".into(),
496 unit,
497 ],
498 )
499 .ok()
500 .and_then(|o| o.stdout.trim().parse::<u32>().ok())
501 .filter(|p| *p != 0);
502 Ok(ServiceState::Running { pid })
503 }
504
505 fn uninstall(&self, name: &str) -> io::Result<()> {
506 check_name(name)?;
507 let unit = format!("{name}.service");
508 let _ = self
509 .runner
510 .run("systemctl", &["--user".into(), "stop".into(), unit.clone()]);
511 let _ = self
512 .runner
513 .run("systemctl", &["--user".into(), "disable".into(), unit]);
514 match std::fs::remove_file(self.unit_path(name)) {
515 Ok(()) => {}
516 Err(e) if e.kind() == io::ErrorKind::NotFound => {}
517 Err(e) => return Err(e),
518 }
519 let _ = self
520 .runner
521 .run("systemctl", &["--user".into(), "daemon-reload".into()]);
522 Ok(())
523 }
524}
525
526pub struct ScheduledTask {
534 runner: Box<dyn CommandRunner>,
535 xml_dir: PathBuf,
536}
537
538impl ScheduledTask {
539 pub fn new(xml_dir: impl Into<PathBuf>) -> Self {
541 Self::with_parts(Box::new(SystemRunner), xml_dir.into())
542 }
543
544 pub fn with_parts(runner: Box<dyn CommandRunner>, xml_dir: PathBuf) -> Self {
545 Self { runner, xml_dir }
546 }
547
548 fn quote(arg: &str) -> String {
550 if !arg.is_empty() && !arg.contains([' ', '\t', '"']) {
551 return arg.to_string();
552 }
553 let mut out = String::from("\"");
554 let mut backslashes = 0;
555 for c in arg.chars() {
556 match c {
557 '\\' => backslashes += 1,
558 '"' => {
559 out.push_str(&"\\".repeat(backslashes * 2 + 1));
560 out.push('"');
561 backslashes = 0;
562 }
563 c => {
564 out.push_str(&"\\".repeat(backslashes));
565 backslashes = 0;
566 out.push(c);
567 }
568 }
569 }
570 out.push_str(&"\\".repeat(backslashes * 2));
571 out.push('"');
572 out
573 }
574
575 pub fn task_xml(spec: &ServiceSpec) -> io::Result<String> {
578 spec.validate()?;
579 if !spec.env.is_empty() {
580 return Err(io::Error::new(
581 io::ErrorKind::InvalidInput,
582 "a scheduled task cannot set environment variables",
583 ));
584 }
585 let arguments = spec
586 .args
587 .iter()
588 .map(|a| Self::quote(a))
589 .collect::<Vec<_>>()
590 .join(" ");
591 let restart = match spec.restart {
592 RestartPolicy::Never => String::new(),
593 _ => "<RestartOnFailure><Interval>PT1M</Interval><Count>999</Count></RestartOnFailure>"
594 .to_string(),
595 };
596 Ok(format!(
597 "<?xml version=\"1.0\" encoding=\"UTF-16\"?>\n<Task version=\"1.2\" xmlns=\"http://schemas.microsoft.com/windows/2004/02/mit/task\">\n <RegistrationInfo><Description>{name}</Description></RegistrationInfo>\n <Triggers><LogonTrigger><Enabled>true</Enabled></LogonTrigger></Triggers>\n <Principals><Principal id=\"Author\"><LogonType>InteractiveToken</LogonType><RunLevel>LeastPrivilege</RunLevel></Principal></Principals>\n <Settings>\n <MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>\n <DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>\n <StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>\n <ExecutionTimeLimit>PT0S</ExecutionTimeLimit>\n <Hidden>true</Hidden>\n {restart}\n </Settings>\n <Actions Context=\"Author\"><Exec><Command>{exec}</Command><Arguments>{arguments}</Arguments></Exec></Actions>\n</Task>\n",
598 name = xml_escape(&spec.name),
599 exec = xml_escape(&spec.exec.to_string_lossy()),
600 arguments = xml_escape(&arguments),
601 ))
602 }
603}
604
605fn utf16_le_with_bom(text: &str) -> Vec<u8> {
608 let mut out = vec![0xFF, 0xFE];
609 for unit in text.encode_utf16() {
610 out.extend_from_slice(&unit.to_le_bytes());
611 }
612 out
613}
614
615impl ServiceManager for ScheduledTask {
616 fn install(&self, spec: &ServiceSpec) -> io::Result<()> {
617 let xml = Self::task_xml(spec)?;
618 std::fs::create_dir_all(&self.xml_dir)?;
619 let path = self.xml_dir.join(format!("{}.xml", spec.name));
620 write_atomic(&path, &utf16_le_with_bom(&xml))?;
621 let result = run_ok(
622 &*self.runner,
623 "schtasks /Create",
624 "schtasks",
625 &[
626 "/Create",
627 "/TN",
628 &spec.name,
629 "/XML",
630 &path.to_string_lossy(),
631 "/F",
632 ],
633 );
634 let _ = std::fs::remove_file(&path);
635 result
636 }
637
638 fn start(&self, name: &str) -> io::Result<()> {
639 check_name(name)?;
640 run_ok(
641 &*self.runner,
642 "schtasks /Run",
643 "schtasks",
644 &["/Run", "/TN", name],
645 )
646 }
647
648 fn stop(&self, name: &str) -> io::Result<()> {
649 check_name(name)?;
650 let out = self
651 .runner
652 .run("schtasks", &["/End".into(), "/TN".into(), name.to_string()])?;
653 if out.success() || !matches!(self.status(name)?, ServiceState::Running { .. }) {
655 Ok(())
656 } else {
657 Err(failed("schtasks /End", &out))
658 }
659 }
660
661 fn status(&self, name: &str) -> io::Result<ServiceState> {
662 check_name(name)?;
663 let out = self.runner.run(
664 "schtasks",
665 &[
666 "/Query".into(),
667 "/TN".into(),
668 name.to_string(),
669 "/FO".into(),
670 "LIST".into(),
671 "/V".into(),
672 ],
673 )?;
674 if !out.success() {
675 return Ok(ServiceState::NotInstalled);
676 }
677 let running = out.stdout.lines().any(|l| {
678 l.split_once(':').is_some_and(|(k, v)| {
679 k.trim().eq_ignore_ascii_case("Status") && v.trim().eq_ignore_ascii_case("Running")
680 })
681 });
682 Ok(if running {
683 ServiceState::Running { pid: None }
684 } else {
685 ServiceState::Stopped
686 })
687 }
688
689 fn uninstall(&self, name: &str) -> io::Result<()> {
690 check_name(name)?;
691 let _ = self
692 .runner
693 .run("schtasks", &["/End".into(), "/TN".into(), name.to_string()]);
694 let out = self.runner.run(
695 "schtasks",
696 &[
697 "/Delete".into(),
698 "/TN".into(),
699 name.to_string(),
700 "/F".into(),
701 ],
702 )?;
703 if out.success() || matches!(self.status(name)?, ServiceState::NotInstalled) {
704 Ok(())
705 } else {
706 Err(failed("schtasks /Delete", &out))
707 }
708 }
709}
710
711pub fn current_manager(xml_dir: &Path) -> io::Result<Box<dyn ServiceManager>> {
714 #[cfg(target_os = "macos")]
715 {
716 let _ = xml_dir;
717 Ok(Box::new(LaunchAgent::for_current_user()?))
718 }
719 #[cfg(all(unix, not(target_os = "macos")))]
720 {
721 let _ = xml_dir;
722 Ok(Box::new(SystemdUser::for_current_user()?))
723 }
724 #[cfg(windows)]
725 {
726 Ok(Box::new(ScheduledTask::new(xml_dir)))
727 }
728 #[cfg(not(any(unix, windows)))]
729 {
730 let _ = xml_dir;
731 Err(io::Error::new(
732 io::ErrorKind::Unsupported,
733 "no user service manager for this platform",
734 ))
735 }
736}