Something went wrong. Try again.
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406//! User-level daemon lifecycle integration for Appa.
#[cfg(target_os = "linux")]use std::process::Stdio;use std::{ env, fs, path::{Path, PathBuf}, process::Command,};
#[cfg(target_os = "linux")]use anyhow::Context;use directories::BaseDirs;
use crate::storage::AppPaths;
#[cfg(target_os = "linux")]const UNIT_NAME: &str = "appa.service";#[cfg(target_os = "macos")]const LAUNCHD_LABEL: &str = "dev.appa.appad";#[cfg(target_os = "linux")]const JOURNAL_LOG_LINE_COUNT: &str = "100";#[cfg(target_os = "linux")]const RESTART_DELAY_SECONDS: u8 = 5;
#[cfg(target_os = "linux")]pub fn install() -> anyhow::Result<PathBuf> { install_systemd_service()}
#[cfg(target_os = "macos")]pub fn install() -> anyhow::Result<PathBuf> { install_launch_agent()}
#[cfg(not(any(target_os = "linux", target_os = "macos")))]pub fn install() -> anyhow::Result<PathBuf> { anyhow::bail!("Appa services are supported on Linux and macOS")}
#[cfg(target_os = "linux")]fn install_systemd_service() -> anyhow::Result<PathBuf> { let unit_file_path = unit_path()?; let executable = env::current_exe()?; let app_paths = AppPaths::discover()?; let unit_contents = unit_contents(&executable, &app_paths.data_directory)?; let previous_contents = read_optional_file(&unit_file_path)?;
let unit_directory = unit_file_path .parent() .ok_or_else(|| anyhow::anyhow!("systemd unit path has no parent directory"))?; fs::create_dir_all(unit_directory)?; fs::write(&unit_file_path, unit_contents)?;
if let Err(error) = install_and_start_unit() { restore_unit_file(&unit_file_path, previous_contents) .context("could not restore the previous Appa systemd unit after install failure")?; let _ = run_systemctl(&["daemon-reload"]); return Err(error.context("could not install the Appa user service")); } Ok(unit_file_path)}
#[cfg(target_os = "linux")]pub fn status() -> anyhow::Result<()> { status_systemd_service()}
#[cfg(target_os = "macos")]pub fn status() -> anyhow::Result<()> { let target = launchd_service_target()?; run_macos_command( Command::new("launchctl").args(["print", &target]), "launchctl reported that Appa is not running", )}
#[cfg(not(any(target_os = "linux", target_os = "macos")))]pub fn status() -> anyhow::Result<()> { anyhow::bail!("Appa services are supported on Linux and macOS")}
#[cfg(target_os = "linux")]fn status_systemd_service() -> anyhow::Result<()> { let status = Command::new("systemctl") .args(["--user", "status", UNIT_NAME]) .stdin(Stdio::null()) .status()?; if status.success() { return Ok(()); } anyhow::bail!("systemctl reported that Appa is not running")}
#[cfg(target_os = "linux")]pub fn restart() -> anyhow::Result<()> { run_systemctl(&["restart", UNIT_NAME])}
#[cfg(target_os = "macos")]pub fn restart() -> anyhow::Result<()> { let target = launchd_service_target()?; run_macos_command( Command::new("launchctl").args(["kickstart", "-k", &target]), "launchctl could not restart Appa", )}
#[cfg(not(any(target_os = "linux", target_os = "macos")))]pub fn restart() -> anyhow::Result<()> { anyhow::bail!("Appa services are supported on Linux and macOS")}
#[cfg(target_os = "linux")]pub fn logs() -> anyhow::Result<()> { logs_from_journal()}
#[cfg(target_os = "macos")]pub fn logs() -> anyhow::Result<()> { const MACOS_LOG_WINDOW: &str = "1h"; let predicate = format!("process == '{LAUNCHD_LABEL}'"); run_macos_command( Command::new("log").args([ "show", "--style", "compact", "--last", MACOS_LOG_WINDOW, "--predicate", &predicate, ]), "macOS log could not read Appa daemon logs", )}
#[cfg(not(any(target_os = "linux", target_os = "macos")))]pub fn logs() -> anyhow::Result<()> { anyhow::bail!("Appa services are supported on Linux and macOS")}
#[cfg(target_os = "linux")]fn logs_from_journal() -> anyhow::Result<()> { let status = Command::new("journalctl") .args([ "--user-unit", UNIT_NAME, "--no-pager", "--lines", JOURNAL_LOG_LINE_COUNT, ]) .status()?; if status.success() { return Ok(()); } anyhow::bail!("journalctl could not read Appa service logs")}
#[cfg(target_os = "linux")]pub fn uninstall() -> anyhow::Result<bool> { uninstall_systemd_service()}
#[cfg(target_os = "macos")]pub fn uninstall() -> anyhow::Result<bool> { uninstall_launch_agent()}
#[cfg(not(any(target_os = "linux", target_os = "macos")))]pub fn uninstall() -> anyhow::Result<bool> { anyhow::bail!("Appa services are supported on Linux and macOS")}
#[cfg(target_os = "linux")]fn uninstall_systemd_service() -> anyhow::Result<bool> { let unit_path = unit_path()?; if !unit_path.exists() { return Ok(false); } run_systemctl(&["disable", "--now", UNIT_NAME])?; fs::remove_file(&unit_path)?; run_systemctl(&["daemon-reload"])?; Ok(true)}
#[cfg(target_os = "linux")]fn unit_path() -> anyhow::Result<PathBuf> { let config_directory = match env::var_os("XDG_CONFIG_HOME") { Some(path) => PathBuf::from(path), None => BaseDirs::new() .ok_or_else(|| anyhow::anyhow!("could not determine the user config directory"))? .config_dir() .to_owned(), }; Ok(config_directory.join("systemd/user").join(UNIT_NAME))}
#[cfg(target_os = "linux")]fn run_systemctl(arguments: &[&str]) -> anyhow::Result<()> { let status = Command::new("systemctl") .arg("--user") .args(arguments) .status()?; if status.success() { return Ok(()); } anyhow::bail!("systemctl --user {} failed", arguments.join(" "))}
#[cfg(target_os = "linux")]fn install_and_start_unit() -> anyhow::Result<()> { run_systemctl(&["daemon-reload"])?; run_systemctl(&["enable", "--now", UNIT_NAME])}
#[cfg(target_os = "linux")]fn read_optional_file(path: &Path) -> anyhow::Result<Option<Vec<u8>>> { match fs::read(path) { Ok(contents) => Ok(Some(contents)), Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None), Err(error) => Err(error).with_context(|| format!("could not read {}", path.display())), }}
#[cfg(target_os = "linux")]fn restore_unit_file(path: &Path, previous_contents: Option<Vec<u8>>) -> anyhow::Result<()> { match previous_contents { Some(contents) => fs::write(path, contents) .with_context(|| format!("could not restore {}", path.display())), None => match fs::remove_file(path) { Ok(()) => Ok(()), Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), Err(error) => { Err(error).with_context(|| format!("could not remove {}", path.display())) } }, }}
#[cfg(target_os = "linux")]fn unit_contents(executable: &Path, data_directory: &Path) -> anyhow::Result<String> { let executable = systemd_argument(executable)?; let data_directory = systemd_argument(data_directory)?; let unit_header = "[Unit]\nDescription=Appa folder synchronization\nAfter=network-online.target\nWants=network-online.target"; let service = format!( "[Service]\nType=simple\nEnvironment=APPA_HOME={data_directory}\nExecStart={executable} daemon serve\nRestart=on-failure\nRestartSec={RESTART_DELAY_SECONDS}" ); let installation = "[Install]\nWantedBy=default.target"; Ok(format!("{unit_header}\n\n{service}\n\n{installation}\n"))}
#[cfg(target_os = "linux")]fn systemd_argument(path: &Path) -> anyhow::Result<String> { let path = path .to_str() .ok_or_else(|| anyhow::anyhow!("systemd requires a UTF-8 path: {}", path.display()))?; let mut escaped = String::new(); for byte in path.bytes() { if byte.is_ascii_alphanumeric() || matches!(byte, b'/' | b'.' | b'_' | b'-' | b':' | b'=') { escaped.push(char::from(byte)); } else { escaped.push_str(&format!("\\x{byte:02x}")); } } Ok(escaped)}
#[cfg(target_os = "macos")]fn install_launch_agent() -> anyhow::Result<PathBuf> { let agent_path = launch_agent_path()?; let executable = env::current_exe()?; let app_paths = AppPaths::discover()?; let contents = launch_agent_contents(&executable, &app_paths.data_directory)?; let agent_directory = agent_path .parent() .ok_or_else(|| anyhow::anyhow!("launch agent path has no parent directory"))?; fs::create_dir_all(agent_directory)?; fs::write(&agent_path, contents)?;
let user_domain = launchd_user_domain()?; let agent_path_string = agent_path .to_str() .ok_or_else(|| anyhow::anyhow!("launch agent path is not UTF-8"))?; let _ = Command::new("launchctl") .args(["bootout", &launchd_service_target()?]) .status(); let status = Command::new("launchctl") .args(["bootstrap", &user_domain, agent_path_string]) .status()?; if !status.success() { anyhow::bail!("launchctl could not install the Appa launch agent") } let status = Command::new("launchctl") .args(["kickstart", "-k", &launchd_service_target()?]) .status()?; if !status.success() { anyhow::bail!("launchctl could not start the Appa launch agent") } Ok(agent_path)}
#[cfg(target_os = "macos")]fn uninstall_launch_agent() -> anyhow::Result<bool> { let agent_path = launch_agent_path()?; if !agent_path.exists() { return Ok(false); } let _ = Command::new("launchctl") .args(["bootout", &launchd_service_target()?]) .status(); fs::remove_file(agent_path)?; Ok(true)}
#[cfg(target_os = "macos")]fn launch_agent_path() -> anyhow::Result<PathBuf> { let home_directory = BaseDirs::new() .ok_or_else(|| anyhow::anyhow!("could not determine the user home directory"))? .home_dir() .to_owned(); Ok(home_directory .join("Library") .join("LaunchAgents") .join(format!("{LAUNCHD_LABEL}.plist")))}
#[cfg(target_os = "macos")]fn launchd_service_target() -> anyhow::Result<String> { Ok(format!("{}/{}", launchd_user_domain()?, LAUNCHD_LABEL))}
#[cfg(target_os = "macos")]fn launchd_user_domain() -> anyhow::Result<String> { let output = Command::new("id").arg("-u").output()?; if !output.status.success() { anyhow::bail!("could not determine the current user id") } Ok(format!("gui/{}", String::from_utf8(output.stdout)?.trim()))}
#[cfg(target_os = "macos")]fn run_macos_command(command: &mut Command, failure_message: &str) -> anyhow::Result<()> { let status = command.status()?; if status.success() { return Ok(()); } anyhow::bail!("{failure_message}")}
#[cfg(target_os = "macos")]fn launch_agent_contents(executable: &Path, data_directory: &Path) -> anyhow::Result<String> { let executable = xml_escape(executable)?; let data_directory = xml_escape(data_directory)?; Ok(format!( "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n<plist version=\"1.0\"><dict><key>Label</key><string>{LAUNCHD_LABEL}</string><key>ProgramArguments</key><array><string>{executable}</string><string>daemon</string><string>serve</string></array><key>EnvironmentVariables</key><dict><key>APPA_HOME</key><string>{data_directory}</string></dict><key>RunAtLoad</key><true/><key>KeepAlive</key><true/></dict></plist>\n" ))}
#[cfg(target_os = "macos")]fn xml_escape(path: &Path) -> anyhow::Result<String> { let path = path .to_str() .ok_or_else(|| anyhow::anyhow!("launchd requires a UTF-8 path: {}", path.display()))?; Ok(path .replace('&', "&") .replace('<', "<") .replace('>', ">") .replace('"', """) .replace('\'', "'"))}
#[cfg(all(test, target_os = "linux"))]mod tests { use std::path::Path;
use tempfile::TempDir;
use super::{restore_unit_file, unit_contents};
#[test] fn escapes_paths_in_the_systemd_unit() { let contents = unit_contents( Path::new("/home/aly/.local/bin/appa sync"), Path::new("/home/aly/Appa state"), ) .unwrap();
assert!(contents.contains("ExecStart=/home/aly/.local/bin/appa\\x20sync daemon serve")); assert!(contents.contains("Environment=APPA_HOME=/home/aly/Appa\\x20state")); }
#[test] fn restores_the_previous_unit_contents() -> anyhow::Result<()> { let directory = TempDir::new()?; let unit_file = directory.path().join("appa.service"); std::fs::write(&unit_file, "old contents")?;
restore_unit_file(&unit_file, Some(b"previous contents".to_vec()))?; assert_eq!(std::fs::read_to_string(&unit_file)?, "previous contents");
restore_unit_file(&unit_file, None)?; assert!(!unit_file.exists()); Ok(()) }}