//! Service management for GyanDoot — install, remove, and check system services. //! //! Supports: //! - **Linux**: systemd user services (`systemctl --user`) //! - **macOS**: launchd user agents (`launchctl`) //! //! Templates live in the resources/ directory of the repo and are rendered //! at install time with the actual binary path and home directory. use std::path::{Path, PathBuf}; use tracing::{info, warn}; use crate::{AgentError, AgentResult}; /// Supported init systems. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum InitSystem { Systemd, Launchd, Unknown, } impl InitSystem { /// Detect the current init system from the OS. pub fn detect() -> Self { if cfg!(target_os = "linux") { // Check if systemd is available if std::process::Command::new("systemctl") .arg("--version") .output() .is_ok() { Self::Systemd } else { Self::Unknown } } else if cfg!(target_os = "macos") { Self::Launchd } else { Self::Unknown } } /// Human-readable name. pub fn name(&self) -> &str { match self { Self::Systemd => "systemd", Self::Launchd => "launchd", Self::Unknown => "unknown", } } } /// Result of a service status check. #[derive(Debug, Clone)] pub struct ServiceStatus { pub name: String, pub init_system: String, pub installed: bool, pub running: bool, pub enabled: bool, pub details: String, } /// Install GyanDoot as a system service. /// /// Detects the init system, renders the appropriate template, installs it, /// and starts the service. pub fn install_service() -> AgentResult { let init = InitSystem::detect(); info!("Detected init system: {}", init.name()); match init { InitSystem::Systemd => install_systemd(), InitSystem::Launchd => install_launchd(), InitSystem::Unknown => Err(AgentError::Io(std::io::Error::new( std::io::ErrorKind::Unsupported, format!( "No supported init system detected. Supported: systemd (Linux), launchd (macOS).\n\ You can still run GyanDoot manually: `gyandoot run` or `gyandoot serve`" ), ))), } } /// Remove the GyanDoot system service (stop + disable + delete). pub fn remove_service() -> AgentResult<()> { let init = InitSystem::detect(); info!("Detected init system: {}", init.name()); match init { InitSystem::Systemd => remove_systemd(), InitSystem::Launchd => remove_launchd(), InitSystem::Unknown => Err(AgentError::Io(std::io::Error::new( std::io::ErrorKind::Unsupported, "No supported init system detected.", ))), } } /// Check the status of the GyanDoot service. pub fn check_service() -> AgentResult { let init = InitSystem::detect(); info!("Detected init system: {}", init.name()); match init { InitSystem::Systemd => check_systemd(), InitSystem::Launchd => check_launchd(), InitSystem::Unknown => Ok(ServiceStatus { name: "gyandoot".to_string(), init_system: "unknown".to_string(), installed: false, running: false, enabled: false, details: "Unknown init system. Supported: systemd (Linux), launchd (macOS).".to_string(), }), } } // ── systemd ────────────────────────────────────────────────────────────── /// Get the systemd user service directory. fn systemd_user_dir() -> PathBuf { if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME") { PathBuf::from(xdg).join("systemd").join("user") } else if let Some(home) = dirs::home_dir() { home.join(".config").join("systemd").join("user") } else { PathBuf::from("/tmp/gyandoot-systemd-user") } } fn install_systemd() -> AgentResult { let home = dirs::home_dir().ok_or_else(|| { AgentError::Io(std::io::Error::new(std::io::ErrorKind::NotFound, "Cannot determine home directory")) })?; let binary = std::env::current_exe().unwrap_or_else(|_| PathBuf::from("gyandoot")); let user = std::env::var("USER").unwrap_or_else(|_| "root".to_string()); let group = std::env::var("USER").unwrap_or_else(|_| "root".to_string()); let service_dir = systemd_user_dir(); std::fs::create_dir_all(&service_dir).map_err(|e| { AgentError::Io(std::io::Error::new( std::io::ErrorKind::Other, format!("Cannot create systemd user dir {}: {}", service_dir.display(), e), )) })?; let service_path = service_dir.join("gyandoot.service"); // Render template let template = include_str!(concat!( env!("CARGO_MANIFEST_DIR"), "/../../resources/systemd/gyandoot.service" )); let rendered = template .replace("%BINARY%", &binary.to_string_lossy()) .replace("%USER%", &user) .replace("%GROUP%", &group) .replace("%HOME%", &home.to_string_lossy()); std::fs::write(&service_path, &rendered).map_err(|e| { AgentError::Io(std::io::Error::new( std::io::ErrorKind::Other, format!("Cannot write service file {}: {}", service_path.display(), e), )) })?; info!("Wrote systemd service to {}", service_path.display()); // Reload systemd user daemon run_cmd("systemctl", &["--user", "daemon-reload"])?; // Enable and start run_cmd("systemctl", &["--user", "enable", "gyandoot.service"])?; run_cmd("systemctl", &["--user", "start", "gyandoot.service"])?; info!("GyanDoot systemd service installed and started"); check_systemd() } fn remove_systemd() -> AgentResult<()> { // Stop first let _ = run_cmd("systemctl", &["--user", "stop", "gyandoot.service"]); // Disable let _ = run_cmd("systemctl", &["--user", "disable", "gyandoot.service"]); // Remove file let service_path = systemd_user_dir().join("gyandoot.service"); if service_path.exists() { std::fs::remove_file(&service_path).map_err(|e| { AgentError::Io(std::io::Error::new( std::io::ErrorKind::Other, format!("Cannot remove {}: {}", service_path.display(), e), )) })?; info!("Removed systemd service file: {}", service_path.display()); } // Reload let _ = run_cmd("systemctl", &["--user", "daemon-reload"]); Ok(()) } fn check_systemd() -> AgentResult { let mut details = String::new(); // Check if unit file exists let service_path = systemd_user_dir().join("gyandoot.service"); let installed = service_path.exists(); let mut running = false; let mut enabled = false; if installed { // Check if running if let Ok(out) = run_cmd_for_output("systemctl", &["--user", "is-active", "gyandoot.service"]) { running = out.trim() == "active"; details.push_str(&format!("Active: {}", out.trim())); } if let Ok(out) = run_cmd_for_output("systemctl", &["--user", "is-enabled", "gyandoot.service"]) { enabled = out.trim() == "enabled"; details.push_str(&format!("\nEnabled: {}", out.trim())); } } else { details = "Not installed".to_string(); } Ok(ServiceStatus { name: "gyandoot".to_string(), init_system: "systemd".to_string(), installed, running, enabled, details, }) } // ── launchd (macOS) ──────────────────────────────────────────────────── fn launchd_plist_path() -> PathBuf { if let Some(home) = dirs::home_dir() { home.join("Library").join("LaunchAgents").join("com.gyandoot.agent.plist") } else { PathBuf::from("/tmp/com.gyandoot.agent.plist") } } fn install_launchd() -> AgentResult { let home = dirs::home_dir().ok_or_else(|| { AgentError::Io(std::io::Error::new(std::io::ErrorKind::NotFound, "Cannot determine home directory")) })?; let binary = std::env::current_exe().unwrap_or_else(|_| PathBuf::from("gyandoot")); let launch_agents_dir = home.join("Library").join("LaunchAgents"); std::fs::create_dir_all(&launch_agents_dir).map_err(|e| { AgentError::Io(std::io::Error::new( std::io::ErrorKind::Other, format!("Cannot create LaunchAgents dir {}: {}", launch_agents_dir.display(), e), )) })?; let plist_path = launchd_plist_path(); // Render template let template = include_str!(concat!( env!("CARGO_MANIFEST_DIR"), "/../../resources/launchd/com.gyandoot.agent.plist" )); let rendered = template .replace("%BINARY%", &binary.to_string_lossy()) .replace("%HOME%", &home.to_string_lossy()); std::fs::write(&plist_path, &rendered).map_err(|e| { AgentError::Io(std::io::Error::new( std::io::ErrorKind::Other, format!("Cannot write plist {}: {}", plist_path.display(), e), )) })?; info!("Wrote launchd plist to {}", plist_path.display()); // Unload first (in case it was already loaded) let _ = run_cmd("launchctl", &["unload", &plist_path.to_string_lossy()]); // Load the new plist run_cmd("launchctl", &["load", &plist_path.to_string_lossy()])?; // Start run_cmd("launchctl", &["start", "com.gyandoot.agent"])?; info!("GyanDoot launchd agent installed and started"); check_launchd() } fn remove_launchd() -> AgentResult<()> { let plist_path = launchd_plist_path(); // Stop let _ = run_cmd("launchctl", &["stop", "com.gyandoot.agent"]); // Unload if plist_path.exists() { let _ = run_cmd("launchctl", &["unload", &plist_path.to_string_lossy()]); std::fs::remove_file(&plist_path).map_err(|e| { AgentError::Io(std::io::Error::new( std::io::ErrorKind::Other, format!("Cannot remove plist {}: {}", plist_path.display(), e), )) })?; info!("Removed launchd plist: {}", plist_path.display()); } Ok(()) } fn check_launchd() -> AgentResult { let plist_path = launchd_plist_path(); let installed = plist_path.exists(); let mut running = false; let mut details = String::new(); if installed { // Check if loaded if let Ok(out) = run_cmd_for_output("launchctl", &["list", "com.gyandoot.agent"]) { running = !out.contains("PID = 0,") && !out.is_empty(); details = out; } } else { details = "Not installed".to_string(); } Ok(ServiceStatus { name: "com.gyandoot.agent".to_string(), init_system: "launchd".to_string(), installed, running, enabled: installed, // launchd agents are "enabled" if loaded details, }) } // ── Helpers ───────────────────────────────────────────────────────────── /// Run a command and return stdout as a String. fn run_cmd_for_output(cmd: &str, args: &[&str]) -> Result { let output = std::process::Command::new(cmd) .args(args) .output() .map_err(|e| format!("Failed to execute {}: {}", cmd, e))?; let stdout = String::from_utf8_lossy(&output.stdout).to_string(); let stderr = String::from_utf8_lossy(&output.stderr).to_string(); if output.status.success() { Ok(stdout) } else { Err(format!("{} failed: {}", cmd, stderr.trim())) } } /// Run a command and check for success. fn run_cmd(cmd: &str, args: &[&str]) -> AgentResult<()> { let status = std::process::Command::new(cmd) .args(args) .status() .map_err(|e| { AgentError::Io(std::io::Error::new( std::io::ErrorKind::Other, format!("Failed to execute {}: {}", cmd, e), )) })?; if status.success() { Ok(()) } else { Err(AgentError::Io(std::io::Error::new( std::io::ErrorKind::Other, format!("{} exited with status {:?}", cmd, status.code()), ))) } }