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