1use std::fmt::Write as _;
3use std::os::unix::fs::MetadataExt;
4use std::path::{Path, PathBuf};
5use std::process::Command;
6
7use anyhow::{Context, Result, bail};
8
9use super::Client;
10use crate::paths::{self, Paths};
11
12const LAUNCHD_LABEL: &str = "io.github.postvak_app.postvak";
13const SYSTEMD_UNIT: &str = "postvak";
14const UNSUPPORTED: &str = "postvak service supports macOS and Linux";
15
16#[derive(Clone, Copy, Debug, PartialEq)]
17pub enum Target {
18 Launchd,
19 Systemd,
20}
21
22struct Step {
24 args: Vec<String>,
25 program: &'static str,
26 tolerate_failure: bool,
27}
28
29impl Step {
30 fn new(program: &'static str, args: &[&str], tolerate_failure: bool) -> Step {
31 Step {
32 args: args.iter().map(|arg| arg.to_string()).collect(),
33 program,
34 tolerate_failure,
35 }
36 }
37
38 fn shown(&self) -> String {
39 format!("{} {}", self.program, self.args.join(" "))
40 }
41
42 fn run(&self) -> Result<()> {
43 let status = Command::new(self.program)
44 .args(&self.args)
45 .status()
46 .with_context(|| format!("running `{}`", self.shown()))?;
47 if !status.success() && !self.tolerate_failure {
48 bail!("`{}` failed ({status})", self.shown());
49 }
50 Ok(())
51 }
52}
53
54impl Target {
55 pub fn current() -> Result<Target> {
56 if cfg!(target_os = "macos") {
57 Ok(Target::Launchd)
58 } else if cfg!(target_os = "linux") {
59 Ok(Target::Systemd)
60 } else {
61 bail!(UNSUPPORTED)
62 }
63 }
64
65 pub fn file_name(self) -> &'static str {
66 match self {
67 Target::Launchd => "io.github.postvak_app.postvak.plist",
68 Target::Systemd => "postvak.service",
69 }
70 }
71
72 pub fn file(self, home: &Path) -> PathBuf {
73 match self {
74 Target::Launchd => home.join("Library/LaunchAgents").join(self.file_name()),
75 Target::Systemd => home.join(".config/systemd/user").join(self.file_name()),
76 }
77 }
78
79 fn text(self, exe: &Path, log: &Path, postvak_home: Option<&Path>) -> String {
81 match self {
82 Target::Launchd => plist(exe, log, postvak_home),
83 Target::Systemd => unit(exe, postvak_home),
84 }
85 }
86
87 fn install_steps(self, home: &Path, file: &Path) -> Result<Vec<Step>> {
88 Ok(match self {
89 Target::Launchd => {
90 let domain = launchd_domain(home)?;
91 let file = file.to_string_lossy();
92 vec![
93 Step::new(
94 "launchctl",
95 &["bootout", &format!("{domain}/{LAUNCHD_LABEL}")],
96 true,
97 ),
98 Step::new("launchctl", &["bootstrap", &domain, &file], false),
99 ]
100 }
101 Target::Systemd => vec![
102 Step::new("systemctl", &["--user", "daemon-reload"], false),
103 Step::new(
104 "systemctl",
105 &["--user", "enable", "--now", SYSTEMD_UNIT],
106 false,
107 ),
108 ],
109 })
110 }
111
112 fn remove_steps(self, home: &Path) -> Result<Vec<Step>> {
114 Ok(match self {
115 Target::Launchd => vec![Step::new(
116 "launchctl",
117 &[
118 "bootout",
119 &format!("{}/{LAUNCHD_LABEL}", launchd_domain(home)?),
120 ],
121 true,
122 )],
123 Target::Systemd => vec![
124 Step::new(
125 "systemctl",
126 &["--user", "disable", "--now", SYSTEMD_UNIT],
127 true,
128 ),
129 Step::new("systemctl", &["--user", "daemon-reload"], true),
130 ],
131 })
132 }
133}
134
135pub fn install(paths: &Paths, dry_run: bool) -> Result<String> {
137 if let Some(id) = paths::flatpak_id() {
138 return Ok(flatpak_steps(&id, true));
139 }
140 let target = Target::current()?;
141 let home = home_dir()?;
142 let file = target.file(&home);
143 let exe = paths::stable_exe().context("finding this postvak")?;
144 let text = target.text(&exe, &paths.daemon_log(), paths.home.as_deref());
145 let steps = target.install_steps(&home, &file)?;
146 if !dry_run {
147 stop_running_daemon(paths)?;
148 paths::write_atomic_keeping_dir_mode(&file, text.as_bytes())
149 .with_context(|| format!("writing {}", file.display()))?;
150 steps.iter().try_for_each(Step::run)?;
151 }
152 Ok(describe(
153 dry_run,
154 ("would write", "wrote"),
155 &file,
156 Some(&text),
157 &steps,
158 ))
159}
160
161pub fn remove(dry_run: bool) -> Result<String> {
163 if let Some(id) = paths::flatpak_id() {
164 return Ok(flatpak_steps(&id, false));
165 }
166 let target = Target::current()?;
167 let home = home_dir()?;
168 let file = target.file(&home);
169 let steps = target.remove_steps(&home)?;
170 if !dry_run {
171 steps[0].run()?;
172 match std::fs::remove_file(&file) {
173 Ok(()) => {}
174 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
175 Err(e) => return Err(e).with_context(|| format!("deleting {}", file.display())),
176 }
177 steps[1..].iter().try_for_each(Step::run)?;
178 }
179 Ok(describe(
180 dry_run,
181 ("would delete", "deleted"),
182 &file,
183 None,
184 &steps,
185 ))
186}
187
188fn flatpak_steps(app_id: &str, install: bool) -> String {
190 let file = format!("~/.config/systemd/user/{}", Target::Systemd.file_name());
191 if install {
192 let text = unit_running(&format!("flatpak run {app_id}"), None);
193 format!(
194 "the Flatpak cannot write the host's systemd folder; save this as {file}:\n{}\nthen run: \
195 systemctl --user daemon-reload && systemctl --user enable --now {SYSTEMD_UNIT}",
196 text.trim_end()
197 )
198 } else {
199 format!(
200 "the Flatpak cannot change the host's systemd folder; run: systemctl --user disable --now {SYSTEMD_UNIT} \
201 && rm {file} && systemctl --user daemon-reload"
202 )
203 }
204}
205
206fn stop_running_daemon(paths: &Paths) -> Result<()> {
208 if let Some(client) = Client::connect_any_version(paths)? {
209 client.stop_and_wait(paths)?;
210 }
211 Ok(())
212}
213
214fn describe(
216 dry_run: bool,
217 verbs: (&str, &str),
218 file: &Path,
219 text: Option<&str>,
220 steps: &[Step],
221) -> String {
222 let (verb, run) = if dry_run {
223 (verbs.0, "would run")
224 } else {
225 (verbs.1, "ran")
226 };
227 let mut out = format!("{verb} {}", file.display());
228 if let Some(text) = text {
229 let _ = write!(out, ":\n{}", text.trim_end());
230 }
231 for step in steps {
232 let _ = write!(out, "\n{run}: {}", step.shown());
233 }
234 out
235}
236
237fn home_dir() -> Result<PathBuf> {
238 directories::BaseDirs::new()
239 .map(|dirs| dirs.home_dir().to_path_buf())
240 .context("no home directory found")
241}
242
243fn launchd_domain(home: &Path) -> Result<String> {
245 let uid = std::fs::metadata(home)
246 .with_context(|| format!("reading {}", home.display()))?
247 .uid();
248 Ok(format!("gui/{uid}"))
249}
250
251pub fn plist(exe: &Path, log: &Path, postvak_home: Option<&Path>) -> String {
252 plist_with_path(
253 exe,
254 log,
255 std::env::var("PATH").ok().as_deref(),
256 postvak_home,
257 )
258}
259
260fn plist_with_path(
261 exe: &Path,
262 log: &Path,
263 path: Option<&str>,
264 postvak_home: Option<&Path>,
265) -> String {
266 let exe = crate::notify::escape_markup(&exe.to_string_lossy());
267 let log = crate::notify::escape_markup(&log.to_string_lossy());
268 let variables: Vec<(&str, String)> = [
269 path.map(|path| ("PATH", path.to_string())),
270 postvak_home.map(|home| ("POSTVAK_HOME", home.to_string_lossy().into_owned())),
271 ]
272 .into_iter()
273 .flatten()
274 .collect();
275 let environment = if variables.is_empty() {
276 String::new()
277 } else {
278 let entries: String = variables
279 .iter()
280 .map(|(key, value)| {
281 format!(
282 " <key>{key}</key>\n <string>{}</string>\n",
283 crate::notify::escape_markup(value)
284 )
285 })
286 .collect();
287 format!(" <key>EnvironmentVariables</key>\n <dict>\n{entries} </dict>\n")
288 };
289 format!(
290 r#"<?xml version="1.0" encoding="UTF-8"?>
291<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
292<plist version="1.0">
293<dict>
294 <key>Label</key>
295 <string>{LAUNCHD_LABEL}</string>
296 <key>ProgramArguments</key>
297 <array>
298 <string>{exe}</string>
299 <string>run</string>
300 </array>
301 <key>RunAtLoad</key>
302 <true/>
303 <key>KeepAlive</key>
304 <true/>
305 <key>StandardOutPath</key>
306 <string>{log}</string>
307 <key>StandardErrorPath</key>
308 <string>{log}</string>
309{environment}</dict>
310</plist>
311"#
312 )
313}
314
315pub fn unit(exe: &Path, postvak_home: Option<&Path>) -> String {
316 unit_running(&systemd_quoted(&exe.to_string_lossy()), postvak_home)
317}
318
319fn unit_running(command: &str, postvak_home: Option<&Path>) -> String {
321 let environment = postvak_home.map_or_else(String::new, |home| {
322 format!(
323 "Environment={}\n",
324 systemd_quoted(&format!("POSTVAK_HOME={}", home.to_string_lossy()))
325 )
326 });
327 format!(
328 "[Unit]\nDescription=Postvak mail sync\n\n[Service]\n{environment}ExecStart={command} run\nRestart=always\nRestartSec=10\n\n[Install]\nWantedBy=default.target\n"
329 )
330}
331
332fn systemd_quoted(text: &str) -> String {
335 let escaped = text
336 .replace('\\', "\\\\")
337 .replace('"', "\\\"")
338 .replace('%', "%%")
339 .replace('$', "$$");
340 format!("\"{escaped}\"")
341}
342
343#[cfg(test)]
344mod tests {
345 use super::*;
346
347 #[test]
348 fn a_flatpak_prints_a_unit_that_runs_the_app_through_flatpak() {
349 let text = flatpak_steps("io.github.postvak_app.postvak", true);
350 assert!(
351 text.contains("ExecStart=flatpak run io.github.postvak_app.postvak run\n"),
352 "{text}"
353 );
354 assert!(
355 text.contains("~/.config/systemd/user/postvak.service"),
356 "{text}"
357 );
358 assert!(
359 text.contains("systemctl --user enable --now postvak"),
360 "{text}"
361 );
362 let text = flatpak_steps("io.github.postvak_app.postvak", false);
363 assert!(
364 text.contains("systemctl --user disable --now postvak"),
365 "{text}"
366 );
367 }
368
369 #[test]
370 fn the_launchd_plist_runs_the_binary_at_login_and_keeps_it_alive() {
371 let text = plist(
372 Path::new("/opt/homebrew/bin/postvak"),
373 Path::new("/Users/me/Library/Application Support/postvak/daemon.log"),
374 None,
375 );
376 for needle in [
377 "<string>io.github.postvak_app.postvak</string>",
378 "<string>/opt/homebrew/bin/postvak</string>",
379 "<string>run</string>",
380 "<key>RunAtLoad</key>",
381 "<key>KeepAlive</key>",
382 "<key>EnvironmentVariables</key>",
383 "daemon.log",
384 ] {
385 assert!(text.contains(needle), "{needle} missing:\n{text}");
386 }
387 }
388
389 #[test]
390 fn the_plist_escapes_xml_in_paths() {
391 let text = plist(Path::new("/a&b/postvak"), Path::new("/x/<log>"), None);
392 assert!(text.contains("/a&b/postvak"), "{text}");
393 assert!(text.contains("/x/<log>"), "{text}");
394 }
395
396 #[test]
397 fn the_systemd_unit_always_restarts() {
398 let text = unit(Path::new("/home/me/.cargo/bin/postvak"), None);
399 assert!(text.contains("ExecStart=\"/home/me/.cargo/bin/postvak\" run"));
400 assert!(text.contains("Restart=always\nRestartSec=10\n"));
401 assert!(text.contains("WantedBy=default.target"));
402 }
403
404 #[test]
405 fn the_systemd_exec_start_survives_spaces_percent_and_dollar_signs() {
406 let text = unit(Path::new("/home/me/my bin/50%/$HOME/postvak"), None);
407 assert!(
408 text.contains("ExecStart=\"/home/me/my bin/50%%/$$HOME/postvak\" run"),
409 "{text}"
410 );
411 }
412
413 #[test]
414 fn the_plist_has_no_environment_without_a_path() {
415 let text = plist_with_path(Path::new("/bin/postvak"), Path::new("/x/log"), None, None);
416 assert!(!text.contains("EnvironmentVariables"), "{text}");
417 assert!(text.contains("</dict>\n</plist>"), "{text}");
418 }
419
420 #[test]
421 fn the_service_keeps_a_postvak_home() {
422 let home = Path::new("/tmp/50% <me>");
423 let text = plist_with_path(
424 Path::new("/bin/postvak"),
425 Path::new("/x/log"),
426 Some("/bin"),
427 Some(home),
428 );
429 assert!(
430 text.contains("<key>PATH</key>\n <string>/bin</string>\n <key>POSTVAK_HOME</key>\n <string>/tmp/50% <me></string>\n </dict>"),
431 "{text}"
432 );
433 let text = unit(Path::new("/bin/postvak"), Some(home));
434 assert!(
435 text.contains("Environment=\"POSTVAK_HOME=/tmp/50%% <me>\"\nExecStart="),
436 "{text}"
437 );
438 assert!(!unit(Path::new("/bin/postvak"), None).contains("Environment="));
439 }
440
441 #[test]
442 fn removing_the_systemd_unit_reloads_systemd_after_the_file_is_gone() {
443 let steps = Target::Systemd.remove_steps(Path::new("/home/me")).unwrap();
444 let shown: Vec<_> = steps.iter().map(Step::shown).collect();
445 assert_eq!(
446 shown,
447 [
448 "systemctl --user disable --now postvak",
449 "systemctl --user daemon-reload"
450 ]
451 );
452 assert!(steps.iter().all(|step| step.tolerate_failure));
453 }
454
455 #[test]
456 fn a_dry_run_install_writes_nothing() {
457 let dir = tempfile::tempdir().unwrap();
458 let paths = Paths::under(dir.path());
459 let before = target_stamp();
460 let text = install(&paths, true).unwrap();
461 assert_eq!(target_stamp(), before);
462 assert!(
463 text.contains(Target::current().unwrap().file_name()),
464 "{text}"
465 );
466 assert!(
467 text.contains("launchctl") || text.contains("systemctl"),
468 "{text}"
469 );
470 assert!(text.contains("run"), "{text}");
471 }
472
473 #[test]
474 fn a_dry_run_remove_names_the_file_and_the_command() {
475 let before = target_stamp();
476 let text = remove(true).unwrap();
477 assert_eq!(target_stamp(), before);
478 assert!(
479 text.contains(Target::current().unwrap().file_name()),
480 "{text}"
481 );
482 assert!(
483 text.contains("bootout") || text.contains("disable"),
484 "{text}"
485 );
486 }
487
488 fn target_stamp() -> Option<std::time::SystemTime> {
490 let target = Target::current().unwrap();
491 std::fs::metadata(target.file(&home_dir().unwrap()))
492 .and_then(|meta| meta.modified())
493 .ok()
494 }
495}