Skip to main content

postvak_core/daemon/
service.rs

1//! Installs `postvak run` as a login service: a launchd agent on macOS, a systemd user unit on Linux.
2use 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
22/// One command to run; a tolerated failure is not an error (booting out a service that is not loaded).
23struct 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    /// `postvak_home` is the `POSTVAK_HOME` the service's daemon has to use, when one is set.
80    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    /// The first step stops the service; the rest run after the file is deleted.
113    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
135/// The text to print; with `dry_run` nothing is written, stopped or run.
136pub 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
161/// The text to print; with `dry_run` nothing is stopped, run or deleted.
162pub 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
188/// What to do by hand: the sandbox cannot write the host's systemd folder, and the unit must go through `flatpak run`.
189fn 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
206/// A daemon already running holds the lock the service's daemon needs.
207fn 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
214/// `verbs` is the (dry run, done) wording of the file change.
215fn 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
243/// `gui/<uid>`; the uid is the home directory's owner, which avoids a libc call.
244fn 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
319/// `command` is already quoted for systemd.
320fn 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
332/// systemd expands `%` specifiers and `$` variables and unquotes `"` and `\` in ExecStart and Environment, so a value
333/// needs all four escaped inside its quotes.
334fn 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&amp;b/postvak"), "{text}");
393        assert!(text.contains("/x/&lt;log&gt;"), "{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% &lt;me&gt;</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    /// Whether the real service file exists and when it last changed; a dry run must leave both alone.
489    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}