use std::fmt::Write as _;
use std::os::unix::fs::MetadataExt;
use std::path::{Path, PathBuf};
use std::process::Command;
use anyhow::{Context, Result, bail};
use super::Client;
use crate::paths::{self, Paths};
const LAUNCHD_LABEL: &str = "io.github.postvak_app.postvak";
const SYSTEMD_UNIT: &str = "postvak";
const UNSUPPORTED: &str = "postvak service supports macOS and Linux";
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum Target {
Launchd,
Systemd,
}
struct Step {
args: Vec<String>,
program: &'static str,
tolerate_failure: bool,
}
impl Step {
fn new(program: &'static str, args: &[&str], tolerate_failure: bool) -> Step {
Step {
args: args.iter().map(|arg| arg.to_string()).collect(),
program,
tolerate_failure,
}
}
fn shown(&self) -> String {
format!("{} {}", self.program, self.args.join(" "))
}
fn run(&self) -> Result<()> {
let status = Command::new(self.program)
.args(&self.args)
.status()
.with_context(|| format!("running `{}`", self.shown()))?;
if !status.success() && !self.tolerate_failure {
bail!("`{}` failed ({status})", self.shown());
}
Ok(())
}
}
impl Target {
pub fn current() -> Result<Target> {
if cfg!(target_os = "macos") {
Ok(Target::Launchd)
} else if cfg!(target_os = "linux") {
Ok(Target::Systemd)
} else {
bail!(UNSUPPORTED)
}
}
pub fn file_name(self) -> &'static str {
match self {
Target::Launchd => "io.github.postvak_app.postvak.plist",
Target::Systemd => "postvak.service",
}
}
pub fn file(self, home: &Path) -> PathBuf {
match self {
Target::Launchd => home.join("Library/LaunchAgents").join(self.file_name()),
Target::Systemd => home.join(".config/systemd/user").join(self.file_name()),
}
}
fn text(self, exe: &Path, log: &Path, postvak_home: Option<&Path>) -> String {
match self {
Target::Launchd => plist(exe, log, postvak_home),
Target::Systemd => unit(exe, postvak_home),
}
}
fn install_steps(self, home: &Path, file: &Path) -> Result<Vec<Step>> {
Ok(match self {
Target::Launchd => {
let domain = launchd_domain(home)?;
let file = file.to_string_lossy();
vec![
Step::new(
"launchctl",
&["bootout", &format!("{domain}/{LAUNCHD_LABEL}")],
true,
),
Step::new("launchctl", &["bootstrap", &domain, &file], false),
]
}
Target::Systemd => vec![
Step::new("systemctl", &["--user", "daemon-reload"], false),
Step::new(
"systemctl",
&["--user", "enable", "--now", SYSTEMD_UNIT],
false,
),
],
})
}
fn remove_steps(self, home: &Path) -> Result<Vec<Step>> {
Ok(match self {
Target::Launchd => vec![Step::new(
"launchctl",
&[
"bootout",
&format!("{}/{LAUNCHD_LABEL}", launchd_domain(home)?),
],
true,
)],
Target::Systemd => vec![
Step::new(
"systemctl",
&["--user", "disable", "--now", SYSTEMD_UNIT],
true,
),
Step::new("systemctl", &["--user", "daemon-reload"], true),
],
})
}
}
pub fn install(paths: &Paths, dry_run: bool) -> Result<String> {
if let Some(id) = paths::flatpak_id() {
return Ok(flatpak_steps(&id, true));
}
let target = Target::current()?;
let home = home_dir()?;
let file = target.file(&home);
let exe = paths::stable_exe().context("finding this postvak")?;
let text = target.text(&exe, &paths.daemon_log(), paths.home.as_deref());
let steps = target.install_steps(&home, &file)?;
if !dry_run {
stop_running_daemon(paths)?;
paths::write_atomic_keeping_dir_mode(&file, text.as_bytes())
.with_context(|| format!("writing {}", file.display()))?;
steps.iter().try_for_each(Step::run)?;
}
Ok(describe(
dry_run,
("would write", "wrote"),
&file,
Some(&text),
&steps,
))
}
pub fn remove(dry_run: bool) -> Result<String> {
if let Some(id) = paths::flatpak_id() {
return Ok(flatpak_steps(&id, false));
}
let target = Target::current()?;
let home = home_dir()?;
let file = target.file(&home);
let steps = target.remove_steps(&home)?;
if !dry_run {
steps[0].run()?;
match std::fs::remove_file(&file) {
Ok(()) => {}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => return Err(e).with_context(|| format!("deleting {}", file.display())),
}
steps[1..].iter().try_for_each(Step::run)?;
}
Ok(describe(
dry_run,
("would delete", "deleted"),
&file,
None,
&steps,
))
}
fn flatpak_steps(app_id: &str, install: bool) -> String {
let file = format!("~/.config/systemd/user/{}", Target::Systemd.file_name());
if install {
let text = unit_running(&format!("flatpak run {app_id}"), None);
format!(
"the Flatpak cannot write the host's systemd folder; save this as {file}:\n{}\nthen run: \
systemctl --user daemon-reload && systemctl --user enable --now {SYSTEMD_UNIT}",
text.trim_end()
)
} else {
format!(
"the Flatpak cannot change the host's systemd folder; run: systemctl --user disable --now {SYSTEMD_UNIT} \
&& rm {file} && systemctl --user daemon-reload"
)
}
}
fn stop_running_daemon(paths: &Paths) -> Result<()> {
if let Some(client) = Client::connect_any_version(paths)? {
client.stop_and_wait(paths)?;
}
Ok(())
}
fn describe(
dry_run: bool,
verbs: (&str, &str),
file: &Path,
text: Option<&str>,
steps: &[Step],
) -> String {
let (verb, run) = if dry_run {
(verbs.0, "would run")
} else {
(verbs.1, "ran")
};
let mut out = format!("{verb} {}", file.display());
if let Some(text) = text {
let _ = write!(out, ":\n{}", text.trim_end());
}
for step in steps {
let _ = write!(out, "\n{run}: {}", step.shown());
}
out
}
fn home_dir() -> Result<PathBuf> {
directories::BaseDirs::new()
.map(|dirs| dirs.home_dir().to_path_buf())
.context("no home directory found")
}
fn launchd_domain(home: &Path) -> Result<String> {
let uid = std::fs::metadata(home)
.with_context(|| format!("reading {}", home.display()))?
.uid();
Ok(format!("gui/{uid}"))
}
pub fn plist(exe: &Path, log: &Path, postvak_home: Option<&Path>) -> String {
plist_with_path(
exe,
log,
std::env::var("PATH").ok().as_deref(),
postvak_home,
)
}
fn plist_with_path(
exe: &Path,
log: &Path,
path: Option<&str>,
postvak_home: Option<&Path>,
) -> String {
let exe = crate::notify::escape_markup(&exe.to_string_lossy());
let log = crate::notify::escape_markup(&log.to_string_lossy());
let variables: Vec<(&str, String)> = [
path.map(|path| ("PATH", path.to_string())),
postvak_home.map(|home| ("POSTVAK_HOME", home.to_string_lossy().into_owned())),
]
.into_iter()
.flatten()
.collect();
let environment = if variables.is_empty() {
String::new()
} else {
let entries: String = variables
.iter()
.map(|(key, value)| {
format!(
" <key>{key}</key>\n <string>{}</string>\n",
crate::notify::escape_markup(value)
)
})
.collect();
format!(" <key>EnvironmentVariables</key>\n <dict>\n{entries} </dict>\n")
};
format!(
r#"<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>{LAUNCHD_LABEL}</string>
<key>ProgramArguments</key>
<array>
<string>{exe}</string>
<string>run</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>StandardOutPath</key>
<string>{log}</string>
<key>StandardErrorPath</key>
<string>{log}</string>
{environment}</dict>
</plist>
"#
)
}
pub fn unit(exe: &Path, postvak_home: Option<&Path>) -> String {
unit_running(&systemd_quoted(&exe.to_string_lossy()), postvak_home)
}
fn unit_running(command: &str, postvak_home: Option<&Path>) -> String {
let environment = postvak_home.map_or_else(String::new, |home| {
format!(
"Environment={}\n",
systemd_quoted(&format!("POSTVAK_HOME={}", home.to_string_lossy()))
)
});
format!(
"[Unit]\nDescription=Postvak mail sync\n\n[Service]\n{environment}ExecStart={command} run\nRestart=always\nRestartSec=10\n\n[Install]\nWantedBy=default.target\n"
)
}
fn systemd_quoted(text: &str) -> String {
let escaped = text
.replace('\\', "\\\\")
.replace('"', "\\\"")
.replace('%', "%%")
.replace('$', "$$");
format!("\"{escaped}\"")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_flatpak_prints_a_unit_that_runs_the_app_through_flatpak() {
let text = flatpak_steps("io.github.postvak_app.postvak", true);
assert!(
text.contains("ExecStart=flatpak run io.github.postvak_app.postvak run\n"),
"{text}"
);
assert!(
text.contains("~/.config/systemd/user/postvak.service"),
"{text}"
);
assert!(
text.contains("systemctl --user enable --now postvak"),
"{text}"
);
let text = flatpak_steps("io.github.postvak_app.postvak", false);
assert!(
text.contains("systemctl --user disable --now postvak"),
"{text}"
);
}
#[test]
fn the_launchd_plist_runs_the_binary_at_login_and_keeps_it_alive() {
let text = plist(
Path::new("/opt/homebrew/bin/postvak"),
Path::new("/Users/me/Library/Application Support/postvak/daemon.log"),
None,
);
for needle in [
"<string>io.github.postvak_app.postvak</string>",
"<string>/opt/homebrew/bin/postvak</string>",
"<string>run</string>",
"<key>RunAtLoad</key>",
"<key>KeepAlive</key>",
"<key>EnvironmentVariables</key>",
"daemon.log",
] {
assert!(text.contains(needle), "{needle} missing:\n{text}");
}
}
#[test]
fn the_plist_escapes_xml_in_paths() {
let text = plist(Path::new("/a&b/postvak"), Path::new("/x/<log>"), None);
assert!(text.contains("/a&b/postvak"), "{text}");
assert!(text.contains("/x/<log>"), "{text}");
}
#[test]
fn the_systemd_unit_always_restarts() {
let text = unit(Path::new("/home/me/.cargo/bin/postvak"), None);
assert!(text.contains("ExecStart=\"/home/me/.cargo/bin/postvak\" run"));
assert!(text.contains("Restart=always\nRestartSec=10\n"));
assert!(text.contains("WantedBy=default.target"));
}
#[test]
fn the_systemd_exec_start_survives_spaces_percent_and_dollar_signs() {
let text = unit(Path::new("/home/me/my bin/50%/$HOME/postvak"), None);
assert!(
text.contains("ExecStart=\"/home/me/my bin/50%%/$$HOME/postvak\" run"),
"{text}"
);
}
#[test]
fn the_plist_has_no_environment_without_a_path() {
let text = plist_with_path(Path::new("/bin/postvak"), Path::new("/x/log"), None, None);
assert!(!text.contains("EnvironmentVariables"), "{text}");
assert!(text.contains("</dict>\n</plist>"), "{text}");
}
#[test]
fn the_service_keeps_a_postvak_home() {
let home = Path::new("/tmp/50% <me>");
let text = plist_with_path(
Path::new("/bin/postvak"),
Path::new("/x/log"),
Some("/bin"),
Some(home),
);
assert!(
text.contains("<key>PATH</key>\n <string>/bin</string>\n <key>POSTVAK_HOME</key>\n <string>/tmp/50% <me></string>\n </dict>"),
"{text}"
);
let text = unit(Path::new("/bin/postvak"), Some(home));
assert!(
text.contains("Environment=\"POSTVAK_HOME=/tmp/50%% <me>\"\nExecStart="),
"{text}"
);
assert!(!unit(Path::new("/bin/postvak"), None).contains("Environment="));
}
#[test]
fn removing_the_systemd_unit_reloads_systemd_after_the_file_is_gone() {
let steps = Target::Systemd.remove_steps(Path::new("/home/me")).unwrap();
let shown: Vec<_> = steps.iter().map(Step::shown).collect();
assert_eq!(
shown,
[
"systemctl --user disable --now postvak",
"systemctl --user daemon-reload"
]
);
assert!(steps.iter().all(|step| step.tolerate_failure));
}
#[test]
fn a_dry_run_install_writes_nothing() {
let dir = tempfile::tempdir().unwrap();
let paths = Paths::under(dir.path());
let before = target_stamp();
let text = install(&paths, true).unwrap();
assert_eq!(target_stamp(), before);
assert!(
text.contains(Target::current().unwrap().file_name()),
"{text}"
);
assert!(
text.contains("launchctl") || text.contains("systemctl"),
"{text}"
);
assert!(text.contains("run"), "{text}");
}
#[test]
fn a_dry_run_remove_names_the_file_and_the_command() {
let before = target_stamp();
let text = remove(true).unwrap();
assert_eq!(target_stamp(), before);
assert!(
text.contains(Target::current().unwrap().file_name()),
"{text}"
);
assert!(
text.contains("bootout") || text.contains("disable"),
"{text}"
);
}
fn target_stamp() -> Option<std::time::SystemTime> {
let target = Target::current().unwrap();
std::fs::metadata(target.file(&home_dir().unwrap()))
.and_then(|meta| meta.modified())
.ok()
}
}