Rust · 18477 bytes Raw Blame History
1 use std::{
2 error::Error,
3 fmt, fs,
4 path::{Path, PathBuf},
5 process::{Command, Stdio},
6 };
7
8 use directories::BaseDirs;
9
10 use crate::{
11 agenda::default_events_file,
12 reminders::{default_log_file, default_state_file},
13 };
14
15 const SERVICE_LABEL: &str = "com.tenseleyflow.rcal.reminders";
16 #[cfg(target_os = "linux")]
17 const SYSTEMD_SERVICE_NAME: &str = "rcal-reminders.service";
18 const WINDOWS_TASK_NAME: &str = "rcal-reminders";
19
20 #[derive(Debug, Clone, PartialEq, Eq)]
21 pub struct ServiceConfig {
22 pub executable: PathBuf,
23 pub events_file: PathBuf,
24 pub state_file: PathBuf,
25 pub log_file: PathBuf,
26 }
27
28 impl ServiceConfig {
29 pub fn new(events_file: PathBuf, state_file: PathBuf) -> Result<Self, ServiceError> {
30 let executable = std::env::current_exe().map_err(|err| ServiceError::CurrentExe {
31 reason: err.to_string(),
32 })?;
33 Ok(Self {
34 executable,
35 events_file,
36 state_file,
37 log_file: default_log_file(),
38 })
39 }
40 }
41
42 impl Default for ServiceConfig {
43 fn default() -> Self {
44 Self {
45 executable: PathBuf::from("rcal"),
46 events_file: default_events_file(),
47 state_file: default_state_file(),
48 log_file: default_log_file(),
49 }
50 }
51 }
52
53 pub trait CommandRunner {
54 fn run(&mut self, program: &str, args: &[String]) -> Result<(), ServiceError>;
55 fn status(&mut self, program: &str, args: &[String]) -> Result<bool, ServiceError>;
56 }
57
58 #[derive(Debug, Default)]
59 pub struct SystemCommandRunner;
60
61 impl CommandRunner for SystemCommandRunner {
62 fn run(&mut self, program: &str, args: &[String]) -> Result<(), ServiceError> {
63 let status =
64 Command::new(program)
65 .args(args)
66 .status()
67 .map_err(|err| ServiceError::Command {
68 program: program.to_string(),
69 reason: err.to_string(),
70 })?;
71 if status.success() {
72 Ok(())
73 } else {
74 Err(ServiceError::Command {
75 program: program.to_string(),
76 reason: format!("exited with status {status}"),
77 })
78 }
79 }
80
81 fn status(&mut self, program: &str, args: &[String]) -> Result<bool, ServiceError> {
82 let status = Command::new(program)
83 .args(args)
84 .stdout(Stdio::null())
85 .stderr(Stdio::null())
86 .status()
87 .map_err(|err| ServiceError::Command {
88 program: program.to_string(),
89 reason: err.to_string(),
90 })?;
91 Ok(status.success())
92 }
93 }
94
95 pub fn install_service(
96 config: &ServiceConfig,
97 runner: &mut dyn CommandRunner,
98 ) -> Result<(), ServiceError> {
99 platform_installer().install(config, runner)
100 }
101
102 pub fn uninstall_service(runner: &mut dyn CommandRunner) -> Result<(), ServiceError> {
103 platform_installer().uninstall(runner)
104 }
105
106 pub fn service_status(runner: &mut dyn CommandRunner) -> Result<ServiceStatus, ServiceError> {
107 platform_installer().status(runner)
108 }
109
110 fn platform_installer() -> Box<dyn ServiceInstaller> {
111 #[cfg(target_os = "macos")]
112 {
113 Box::new(MacLaunchAgent)
114 }
115 #[cfg(target_os = "linux")]
116 {
117 Box::new(LinuxSystemdUser)
118 }
119 #[cfg(target_os = "windows")]
120 {
121 Box::new(WindowsScheduledTask)
122 }
123 #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
124 {
125 Box::new(UnsupportedInstaller)
126 }
127 }
128
129 trait ServiceInstaller {
130 fn install(
131 &self,
132 config: &ServiceConfig,
133 runner: &mut dyn CommandRunner,
134 ) -> Result<(), ServiceError>;
135 fn uninstall(&self, runner: &mut dyn CommandRunner) -> Result<(), ServiceError>;
136 fn status(&self, runner: &mut dyn CommandRunner) -> Result<ServiceStatus, ServiceError>;
137 }
138
139 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
140 pub enum ServiceStatus {
141 Installed,
142 NotInstalled,
143 }
144
145 impl fmt::Display for ServiceStatus {
146 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
147 match self {
148 Self::Installed => write!(f, "installed"),
149 Self::NotInstalled => write!(f, "not installed"),
150 }
151 }
152 }
153
154 #[cfg(target_os = "macos")]
155 #[derive(Debug)]
156 struct MacLaunchAgent;
157
158 #[cfg(target_os = "macos")]
159 impl MacLaunchAgent {
160 fn plist_path() -> Result<PathBuf, ServiceError> {
161 Ok(home_dir()?
162 .join("Library")
163 .join("LaunchAgents")
164 .join(format!("{SERVICE_LABEL}.plist")))
165 }
166 }
167
168 #[cfg(target_os = "macos")]
169 impl ServiceInstaller for MacLaunchAgent {
170 fn install(
171 &self,
172 config: &ServiceConfig,
173 runner: &mut dyn CommandRunner,
174 ) -> Result<(), ServiceError> {
175 let path = Self::plist_path()?;
176 if let Some(parent) = config.log_file.parent() {
177 fs::create_dir_all(parent).map_err(|err| ServiceError::Write {
178 path: parent.to_path_buf(),
179 reason: err.to_string(),
180 })?;
181 }
182 write_file(&path, &mac_launch_agent_plist(config))?;
183 let domain = mac_launchctl_gui_domain()?;
184 let service_target = mac_launchctl_service_target(&domain);
185 if mac_launch_agent_loaded(runner, &service_target)? {
186 runner.run(
187 "launchctl",
188 &["bootout".to_string(), service_target.clone()],
189 )?;
190 }
191 runner.run(
192 "launchctl",
193 &["bootstrap".to_string(), domain.clone(), path_string(&path)],
194 )?;
195 runner.run("launchctl", &["enable".to_string(), service_target.clone()])?;
196 runner.run(
197 "launchctl",
198 &["kickstart".to_string(), "-k".to_string(), service_target],
199 )
200 }
201
202 fn uninstall(&self, runner: &mut dyn CommandRunner) -> Result<(), ServiceError> {
203 let path = Self::plist_path()?;
204 let domain = mac_launchctl_gui_domain()?;
205 let service_target = mac_launchctl_service_target(&domain);
206 if mac_launch_agent_loaded(runner, &service_target)? {
207 runner.run("launchctl", &["bootout".to_string(), service_target])?;
208 }
209 if path.exists() {
210 fs::remove_file(&path).map_err(|err| ServiceError::Write {
211 path: path.clone(),
212 reason: err.to_string(),
213 })?;
214 }
215 Ok(())
216 }
217
218 fn status(&self, runner: &mut dyn CommandRunner) -> Result<ServiceStatus, ServiceError> {
219 let path = Self::plist_path()?;
220 if !path.exists() {
221 return Ok(ServiceStatus::NotInstalled);
222 }
223 let domain = mac_launchctl_gui_domain()?;
224 let service_target = mac_launchctl_service_target(&domain);
225 if mac_launch_agent_loaded(runner, &service_target)? {
226 Ok(ServiceStatus::Installed)
227 } else {
228 Ok(ServiceStatus::NotInstalled)
229 }
230 }
231 }
232
233 #[cfg(target_os = "macos")]
234 fn mac_launchctl_gui_domain() -> Result<String, ServiceError> {
235 let output = Command::new("id")
236 .arg("-u")
237 .output()
238 .map_err(|err| ServiceError::Command {
239 program: "id".to_string(),
240 reason: err.to_string(),
241 })?;
242 if !output.status.success() {
243 return Err(ServiceError::Command {
244 program: "id".to_string(),
245 reason: format!("exited with status {}", output.status),
246 });
247 }
248
249 let uid = String::from_utf8_lossy(&output.stdout).trim().to_string();
250 Ok(format!("gui/{uid}"))
251 }
252
253 #[cfg(target_os = "macos")]
254 fn mac_launchctl_service_target(domain: &str) -> String {
255 format!("{domain}/{SERVICE_LABEL}")
256 }
257
258 #[cfg(target_os = "macos")]
259 fn mac_launch_agent_loaded(
260 runner: &mut dyn CommandRunner,
261 service_target: &str,
262 ) -> Result<bool, ServiceError> {
263 runner.status(
264 "launchctl",
265 &["print".to_string(), service_target.to_string()],
266 )
267 }
268
269 #[cfg(target_os = "linux")]
270 #[derive(Debug)]
271 struct LinuxSystemdUser;
272
273 #[cfg(target_os = "linux")]
274 impl LinuxSystemdUser {
275 fn unit_path() -> Result<PathBuf, ServiceError> {
276 let config_home = if let Some(config_home) = std::env::var_os("XDG_CONFIG_HOME") {
277 PathBuf::from(config_home)
278 } else {
279 home_dir()?.join(".config")
280 };
281 Ok(config_home
282 .join("systemd")
283 .join("user")
284 .join(SYSTEMD_SERVICE_NAME))
285 }
286 }
287
288 #[cfg(target_os = "linux")]
289 impl ServiceInstaller for LinuxSystemdUser {
290 fn install(
291 &self,
292 config: &ServiceConfig,
293 runner: &mut dyn CommandRunner,
294 ) -> Result<(), ServiceError> {
295 let path = Self::unit_path()?;
296 write_file(&path, &linux_systemd_unit(config))?;
297 runner.run(
298 "systemctl",
299 &["--user".to_string(), "daemon-reload".to_string()],
300 )?;
301 runner.run(
302 "systemctl",
303 &[
304 "--user".to_string(),
305 "enable".to_string(),
306 "--now".to_string(),
307 SYSTEMD_SERVICE_NAME.to_string(),
308 ],
309 )
310 }
311
312 fn uninstall(&self, runner: &mut dyn CommandRunner) -> Result<(), ServiceError> {
313 let path = Self::unit_path()?;
314 let _ = runner.run(
315 "systemctl",
316 &[
317 "--user".to_string(),
318 "disable".to_string(),
319 "--now".to_string(),
320 SYSTEMD_SERVICE_NAME.to_string(),
321 ],
322 );
323 if path.exists() {
324 fs::remove_file(&path).map_err(|err| ServiceError::Write {
325 path: path.clone(),
326 reason: err.to_string(),
327 })?;
328 }
329 runner.run(
330 "systemctl",
331 &["--user".to_string(), "daemon-reload".to_string()],
332 )
333 }
334
335 fn status(&self, runner: &mut dyn CommandRunner) -> Result<ServiceStatus, ServiceError> {
336 let path = Self::unit_path()?;
337 if !path.exists() {
338 return Ok(ServiceStatus::NotInstalled);
339 }
340 if runner.status(
341 "systemctl",
342 &[
343 "--user".to_string(),
344 "is-active".to_string(),
345 "--quiet".to_string(),
346 SYSTEMD_SERVICE_NAME.to_string(),
347 ],
348 )? {
349 Ok(ServiceStatus::Installed)
350 } else {
351 Ok(ServiceStatus::NotInstalled)
352 }
353 }
354 }
355
356 #[cfg(target_os = "windows")]
357 #[derive(Debug)]
358 struct WindowsScheduledTask;
359
360 #[cfg(target_os = "windows")]
361 impl ServiceInstaller for WindowsScheduledTask {
362 fn install(
363 &self,
364 config: &ServiceConfig,
365 runner: &mut dyn CommandRunner,
366 ) -> Result<(), ServiceError> {
367 runner.run("schtasks", &windows_schtasks_create_args(config))?;
368 runner.run(
369 "schtasks",
370 &[
371 "/Run".to_string(),
372 "/TN".to_string(),
373 WINDOWS_TASK_NAME.to_string(),
374 ],
375 )
376 }
377
378 fn uninstall(&self, runner: &mut dyn CommandRunner) -> Result<(), ServiceError> {
379 let _ = runner.run(
380 "schtasks",
381 &[
382 "/End".to_string(),
383 "/TN".to_string(),
384 WINDOWS_TASK_NAME.to_string(),
385 ],
386 );
387 runner.run(
388 "schtasks",
389 &[
390 "/Delete".to_string(),
391 "/TN".to_string(),
392 WINDOWS_TASK_NAME.to_string(),
393 "/F".to_string(),
394 ],
395 )
396 }
397
398 fn status(&self, runner: &mut dyn CommandRunner) -> Result<ServiceStatus, ServiceError> {
399 if runner.status(
400 "schtasks",
401 &[
402 "/Query".to_string(),
403 "/TN".to_string(),
404 WINDOWS_TASK_NAME.to_string(),
405 ],
406 )? {
407 Ok(ServiceStatus::Installed)
408 } else {
409 Ok(ServiceStatus::NotInstalled)
410 }
411 }
412 }
413
414 #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
415 #[derive(Debug)]
416 struct UnsupportedInstaller;
417
418 #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
419 impl ServiceInstaller for UnsupportedInstaller {
420 fn install(
421 &self,
422 _config: &ServiceConfig,
423 _runner: &mut dyn CommandRunner,
424 ) -> Result<(), ServiceError> {
425 Err(ServiceError::UnsupportedPlatform)
426 }
427
428 fn uninstall(&self, _runner: &mut dyn CommandRunner) -> Result<(), ServiceError> {
429 Err(ServiceError::UnsupportedPlatform)
430 }
431
432 fn status(&self, _runner: &mut dyn CommandRunner) -> Result<ServiceStatus, ServiceError> {
433 Err(ServiceError::UnsupportedPlatform)
434 }
435 }
436
437 pub fn mac_launch_agent_plist(config: &ServiceConfig) -> String {
438 let stdout = config.log_file.with_extension("out.log");
439 let stderr = config.log_file.with_extension("err.log");
440 format!(
441 r#"<?xml version="1.0" encoding="UTF-8"?>
442 <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
443 <plist version="1.0">
444 <dict>
445 <key>Label</key>
446 <string>{}</string>
447 <key>ProgramArguments</key>
448 <array>
449 <string>{}</string>
450 <string>reminders</string>
451 <string>run</string>
452 <string>--events-file</string>
453 <string>{}</string>
454 <string>--state-file</string>
455 <string>{}</string>
456 </array>
457 <key>RunAtLoad</key>
458 <true/>
459 <key>KeepAlive</key>
460 <true/>
461 <key>StandardOutPath</key>
462 <string>{}</string>
463 <key>StandardErrorPath</key>
464 <string>{}</string>
465 </dict>
466 </plist>
467 "#,
468 SERVICE_LABEL,
469 xml_escape(&path_string(&config.executable)),
470 xml_escape(&path_string(&config.events_file)),
471 xml_escape(&path_string(&config.state_file)),
472 xml_escape(&path_string(&stdout)),
473 xml_escape(&path_string(&stderr)),
474 )
475 }
476
477 pub fn linux_systemd_unit(config: &ServiceConfig) -> String {
478 format!(
479 "[Unit]\nDescription=rcal reminder notifications\n\n[Service]\nExecStart={} reminders run --events-file {} --state-file {}\nRestart=always\nRestartSec=5\n\n[Install]\nWantedBy=default.target\n",
480 systemd_escape(&path_string(&config.executable)),
481 systemd_escape(&path_string(&config.events_file)),
482 systemd_escape(&path_string(&config.state_file)),
483 )
484 }
485
486 pub fn windows_schtasks_create_args(config: &ServiceConfig) -> Vec<String> {
487 let command = format!(
488 "\"{}\" reminders run --events-file \"{}\" --state-file \"{}\"",
489 path_string(&config.executable),
490 path_string(&config.events_file),
491 path_string(&config.state_file),
492 );
493 vec![
494 "/Create".to_string(),
495 "/TN".to_string(),
496 WINDOWS_TASK_NAME.to_string(),
497 "/SC".to_string(),
498 "ONLOGON".to_string(),
499 "/TR".to_string(),
500 command,
501 "/F".to_string(),
502 ]
503 }
504
505 #[derive(Debug)]
506 pub enum ServiceError {
507 CurrentExe { reason: String },
508 MissingHome,
509 UnsupportedPlatform,
510 Write { path: PathBuf, reason: String },
511 Command { program: String, reason: String },
512 }
513
514 impl fmt::Display for ServiceError {
515 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
516 match self {
517 Self::CurrentExe { reason } => {
518 write!(f, "failed to locate current executable: {reason}")
519 }
520 Self::MissingHome => write!(f, "failed to locate a user home directory"),
521 Self::UnsupportedPlatform => {
522 write!(f, "reminder services are unsupported on this platform")
523 }
524 Self::Write { path, reason } => {
525 write!(f, "failed to write {}: {reason}", path.display())
526 }
527 Self::Command { program, reason } => write!(f, "{program} failed: {reason}"),
528 }
529 }
530 }
531
532 impl Error for ServiceError {}
533
534 fn write_file(path: &Path, body: &str) -> Result<(), ServiceError> {
535 if let Some(parent) = path.parent() {
536 fs::create_dir_all(parent).map_err(|err| ServiceError::Write {
537 path: parent.to_path_buf(),
538 reason: err.to_string(),
539 })?;
540 }
541
542 fs::write(path, body).map_err(|err| ServiceError::Write {
543 path: path.to_path_buf(),
544 reason: err.to_string(),
545 })
546 }
547
548 fn home_dir() -> Result<PathBuf, ServiceError> {
549 BaseDirs::new()
550 .map(|dirs| dirs.home_dir().to_path_buf())
551 .ok_or(ServiceError::MissingHome)
552 }
553
554 fn path_string(path: &Path) -> String {
555 path.to_string_lossy().into_owned()
556 }
557
558 fn xml_escape(value: &str) -> String {
559 value
560 .replace('&', "&amp;")
561 .replace('<', "&lt;")
562 .replace('>', "&gt;")
563 .replace('"', "&quot;")
564 .replace('\'', "&apos;")
565 }
566
567 fn systemd_escape(value: &str) -> String {
568 if value
569 .chars()
570 .all(|ch| !ch.is_whitespace() && ch != '\\' && ch != '"')
571 {
572 value.to_string()
573 } else {
574 format!("\"{}\"", value.replace('\\', "\\\\").replace('"', "\\\""))
575 }
576 }
577
578 #[cfg(test)]
579 mod tests {
580 use super::*;
581
582 fn config() -> ServiceConfig {
583 ServiceConfig {
584 executable: PathBuf::from("/usr/local/bin/rcal"),
585 events_file: PathBuf::from("/tmp/rcal/events.json"),
586 state_file: PathBuf::from("/tmp/rcal/state.json"),
587 log_file: PathBuf::from("/tmp/rcal/reminders.log"),
588 }
589 }
590
591 #[test]
592 fn mac_launch_agent_contains_reminder_run_command() {
593 let plist = mac_launch_agent_plist(&config());
594
595 assert!(plist.contains("com.tenseleyflow.rcal.reminders"));
596 assert!(plist.contains("<string>/usr/local/bin/rcal</string>"));
597 assert!(plist.contains("<string>reminders</string>"));
598 assert!(plist.contains("<string>--events-file</string>"));
599 }
600
601 #[test]
602 fn linux_unit_contains_reminder_run_command() {
603 let unit = linux_systemd_unit(&config());
604
605 assert!(unit.contains("Description=rcal reminder notifications"));
606 assert!(unit.contains("ExecStart=/usr/local/bin/rcal reminders run"));
607 assert!(unit.contains("Restart=always"));
608 }
609
610 #[test]
611 fn windows_task_command_contains_reminder_run_command() {
612 let args = windows_schtasks_create_args(&config());
613 let joined = args.join(" ");
614
615 assert!(joined.contains("/Create"));
616 assert!(joined.contains("rcal-reminders"));
617 assert!(joined.contains("\"/usr/local/bin/rcal\" reminders run"));
618 }
619 }
620