//! 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 { install_systemd_service() } #[cfg(target_os = "macos")] pub fn install() -> anyhow::Result { install_launch_agent() } #[cfg(not(any(target_os = "linux", target_os = "macos")))] pub fn install() -> anyhow::Result { anyhow::bail!("Appa services are supported on Linux and macOS") } #[cfg(target_os = "linux")] fn install_systemd_service() -> anyhow::Result { 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 { uninstall_systemd_service() } #[cfg(target_os = "macos")] pub fn uninstall() -> anyhow::Result { uninstall_launch_agent() } #[cfg(not(any(target_os = "linux", target_os = "macos")))] pub fn uninstall() -> anyhow::Result { anyhow::bail!("Appa services are supported on Linux and macOS") } #[cfg(target_os = "linux")] fn uninstall_systemd_service() -> anyhow::Result { 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 { 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>> { 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>) -> 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 { 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 { 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 { 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 { 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 { 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 { Ok(format!("{}/{}", launchd_user_domain()?, LAUNCHD_LABEL)) } #[cfg(target_os = "macos")] fn launchd_user_domain() -> anyhow::Result { 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 { let executable = xml_escape(executable)?; let data_directory = xml_escape(data_directory)?; Ok(format!( "\n\nLabel{LAUNCHD_LABEL}ProgramArguments{executable}daemonserveEnvironmentVariablesAPPA_HOME{data_directory}RunAtLoadKeepAlive\n" )) } #[cfg(target_os = "macos")] fn xml_escape(path: &Path) -> anyhow::Result { 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(()) } }