use crate::{ServiceConfig, ServiceManager, ServiceManagerError, ServiceStatus}; use once_cell::sync::Lazy; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::path::PathBuf; use tokio::process::Command; use tokio::runtime::Runtime; // Shared runtime for async operations - production-safe initialization static ASYNC_RUNTIME: Lazy> = Lazy::new(|| Runtime::new().ok()); /// Get the async runtime, creating a temporary one if the static runtime failed fn get_runtime() -> Result { // Try to use the static runtime first if let Some(_runtime) = ASYNC_RUNTIME.as_ref() { // We can't return a reference to the static runtime because we need ownership // for block_on, so we create a new one. This is a reasonable trade-off for safety. Runtime::new().map_err(|e| { ServiceManagerError::Other(format!("Failed to create async runtime: {}", e)) }) } else { // Static runtime failed, try to create a new one Runtime::new().map_err(|e| { ServiceManagerError::Other(format!("Failed to create async runtime: {}", e)) }) } } #[derive(Debug)] pub struct LaunchctlServiceManager { service_prefix: String, } #[derive(Serialize, Deserialize)] struct LaunchDaemon { #[serde(rename = "Label")] label: String, #[serde(rename = "ProgramArguments")] program_arguments: Vec, #[serde(rename = "WorkingDirectory", skip_serializing_if = "Option::is_none")] working_directory: Option, #[serde( rename = "EnvironmentVariables", skip_serializing_if = "Option::is_none" )] environment_variables: Option>, #[serde(rename = "KeepAlive", skip_serializing_if = "Option::is_none")] keep_alive: Option, #[serde(rename = "RunAtLoad")] run_at_load: bool, #[serde(rename = "StandardOutPath", skip_serializing_if = "Option::is_none")] standard_out_path: Option, #[serde(rename = "StandardErrorPath", skip_serializing_if = "Option::is_none")] standard_error_path: Option, } impl LaunchctlServiceManager { pub fn new() -> Self { Self { service_prefix: "tf.ourworld.circles".to_string(), } } fn get_service_label(&self, service_name: &str) -> String { format!("{}.{}", self.service_prefix, service_name) } fn get_plist_path(&self, service_name: &str) -> PathBuf { let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string()); PathBuf::from(home) .join("Library") .join("LaunchAgents") .join(format!("{}.plist", self.get_service_label(service_name))) } fn get_log_path(&self, service_name: &str) -> PathBuf { let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string()); PathBuf::from(home) .join("Library") .join("Logs") .join("circles") .join(format!("{}.log", service_name)) } async fn create_plist(&self, config: &ServiceConfig) -> Result<(), ServiceManagerError> { let label = self.get_service_label(&config.name); let plist_path = self.get_plist_path(&config.name); let log_path = self.get_log_path(&config.name); // Ensure the LaunchAgents directory exists if let Some(parent) = plist_path.parent() { tokio::fs::create_dir_all(parent).await?; } // Ensure the logs directory exists if let Some(parent) = log_path.parent() { tokio::fs::create_dir_all(parent).await?; } let mut program_arguments = vec![config.binary_path.clone()]; program_arguments.extend(config.args.clone()); let launch_daemon = LaunchDaemon { label: label.clone(), program_arguments, working_directory: config.working_directory.clone(), environment_variables: if config.environment.is_empty() { None } else { Some(config.environment.clone()) }, keep_alive: if config.auto_restart { Some(true) } else { None }, run_at_load: true, standard_out_path: Some(log_path.to_string_lossy().to_string()), standard_error_path: Some(log_path.to_string_lossy().to_string()), }; let mut plist_content = Vec::new(); plist::to_writer_xml(&mut plist_content, &launch_daemon) .map_err(|e| ServiceManagerError::Other(format!("Failed to serialize plist: {}", e)))?; let plist_content = String::from_utf8(plist_content).map_err(|e| { ServiceManagerError::Other(format!("Failed to convert plist to string: {}", e)) })?; tokio::fs::write(&plist_path, plist_content).await?; Ok(()) } async fn run_launchctl(&self, args: &[&str]) -> Result { let output = Command::new("launchctl").args(args).output().await?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); return Err(ServiceManagerError::Other(format!( "launchctl command failed: {}", stderr ))); } Ok(String::from_utf8_lossy(&output.stdout).to_string()) } async fn wait_for_service_status( &self, service_name: &str, timeout_secs: u64, ) -> Result<(), ServiceManagerError> { use tokio::time::{sleep, timeout, Duration}; let timeout_duration = Duration::from_secs(timeout_secs); let poll_interval = Duration::from_millis(500); let result = timeout(timeout_duration, async { loop { match self.status(service_name) { Ok(ServiceStatus::Running) => { return Ok(()); } Ok(ServiceStatus::Failed) => { // Service failed, get error details from logs let logs = self.logs(service_name, Some(20)).unwrap_or_default(); let error_msg = if logs.is_empty() { "Service failed to start (no logs available)".to_string() } else { // Extract error lines from logs let error_lines: Vec<&str> = logs .lines() .filter(|line| { line.to_lowercase().contains("error") || line.to_lowercase().contains("failed") }) .take(3) .collect(); if error_lines.is_empty() { format!( "Service failed to start. Recent logs:\n{}", logs.lines() .rev() .take(5) .collect::>() .into_iter() .rev() .collect::>() .join("\n") ) } else { format!( "Service failed to start. Errors:\n{}", error_lines.join("\n") ) } }; return Err(ServiceManagerError::StartFailed( service_name.to_string(), error_msg, )); } Ok(ServiceStatus::Stopped) | Ok(ServiceStatus::Unknown) => { // Still starting, continue polling sleep(poll_interval).await; } Err(ServiceManagerError::ServiceNotFound(_)) => { return Err(ServiceManagerError::ServiceNotFound( service_name.to_string(), )); } Err(e) => { return Err(e); } } } }) .await; match result { Ok(Ok(())) => Ok(()), Ok(Err(e)) => Err(e), Err(_) => Err(ServiceManagerError::StartFailed( service_name.to_string(), format!("Service did not start within {} seconds", timeout_secs), )), } } } impl ServiceManager for LaunchctlServiceManager { fn exists(&self, service_name: &str) -> Result { let plist_path = self.get_plist_path(service_name); Ok(plist_path.exists()) } fn start(&self, config: &ServiceConfig) -> Result<(), ServiceManagerError> { // Use production-safe runtime for async operations let runtime = get_runtime()?; runtime.block_on(async { let label = self.get_service_label(&config.name); // Check if service is already loaded let list_output = self.run_launchctl(&["list"]).await?; if list_output.contains(&label) { return Err(ServiceManagerError::ServiceAlreadyExists( config.name.clone(), )); } // Create the plist file self.create_plist(config).await?; // Load the service let plist_path = self.get_plist_path(&config.name); self.run_launchctl(&["load", &plist_path.to_string_lossy()]) .await .map_err(|e| { ServiceManagerError::StartFailed(config.name.clone(), e.to_string()) })?; Ok(()) }) } fn start_existing(&self, service_name: &str) -> Result<(), ServiceManagerError> { let runtime = get_runtime()?; runtime.block_on(async { let label = self.get_service_label(service_name); let plist_path = self.get_plist_path(service_name); // Check if plist file exists if !plist_path.exists() { return Err(ServiceManagerError::ServiceNotFound( service_name.to_string(), )); } // Check if service is already loaded and running let list_output = self.run_launchctl(&["list"]).await?; if list_output.contains(&label) { // Service is loaded, check if it's running match self.status(service_name)? { ServiceStatus::Running => { return Ok(()); // Already running, nothing to do } _ => { // Service is loaded but not running, try to start it self.run_launchctl(&["start", &label]).await.map_err(|e| { ServiceManagerError::StartFailed( service_name.to_string(), e.to_string(), ) })?; return Ok(()); } } } // Service is not loaded, load it self.run_launchctl(&["load", &plist_path.to_string_lossy()]) .await .map_err(|e| { ServiceManagerError::StartFailed(service_name.to_string(), e.to_string()) })?; Ok(()) }) } fn start_and_confirm( &self, config: &ServiceConfig, timeout_secs: u64, ) -> Result<(), ServiceManagerError> { // First start the service self.start(config)?; // Then wait for confirmation using production-safe runtime let runtime = get_runtime()?; runtime.block_on(async { self.wait_for_service_status(&config.name, timeout_secs) .await }) } fn start_existing_and_confirm( &self, service_name: &str, timeout_secs: u64, ) -> Result<(), ServiceManagerError> { // First start the existing service self.start_existing(service_name)?; // Then wait for confirmation using production-safe runtime let runtime = get_runtime()?; runtime.block_on(async { self.wait_for_service_status(service_name, timeout_secs) .await }) } fn stop(&self, service_name: &str) -> Result<(), ServiceManagerError> { let runtime = get_runtime()?; runtime.block_on(async { let _label = self.get_service_label(service_name); let plist_path = self.get_plist_path(service_name); // Unload the service self.run_launchctl(&["unload", &plist_path.to_string_lossy()]) .await .map_err(|e| { ServiceManagerError::StopFailed(service_name.to_string(), e.to_string()) })?; Ok(()) }) } fn restart(&self, service_name: &str) -> Result<(), ServiceManagerError> { // For launchctl, we stop and start if let Err(e) = self.stop(service_name) { // If stop fails because service doesn't exist, that's ok for restart if !matches!(e, ServiceManagerError::ServiceNotFound(_)) { return Err(ServiceManagerError::RestartFailed( service_name.to_string(), e.to_string(), )); } } // We need the config to restart, but we don't have it stored // For now, return an error - in a real implementation we might store configs Err(ServiceManagerError::RestartFailed( service_name.to_string(), "Restart requires re-providing service configuration".to_string(), )) } fn status(&self, service_name: &str) -> Result { let runtime = get_runtime()?; runtime.block_on(async { let label = self.get_service_label(service_name); let plist_path = self.get_plist_path(service_name); // First check if the plist file exists if !plist_path.exists() { return Err(ServiceManagerError::ServiceNotFound( service_name.to_string(), )); } let list_output = self.run_launchctl(&["list"]).await?; if !list_output.contains(&label) { return Ok(ServiceStatus::Stopped); } // Get detailed status match self.run_launchctl(&["list", &label]).await { Ok(output) => { if output.contains("\"PID\" = ") { Ok(ServiceStatus::Running) } else if output.contains("\"LastExitStatus\" = ") { Ok(ServiceStatus::Failed) } else { Ok(ServiceStatus::Unknown) } } Err(_) => Ok(ServiceStatus::Stopped), } }) } fn logs( &self, service_name: &str, lines: Option, ) -> Result { let runtime = get_runtime()?; runtime.block_on(async { let log_path = self.get_log_path(service_name); if !log_path.exists() { return Ok(String::new()); } match lines { Some(n) => { let output = Command::new("tail") .args(&["-n", &n.to_string(), &log_path.to_string_lossy()]) .output() .await?; Ok(String::from_utf8_lossy(&output.stdout).to_string()) } None => { let content = tokio::fs::read_to_string(&log_path).await?; Ok(content) } } }) } fn list(&self) -> Result, ServiceManagerError> { let runtime = get_runtime()?; runtime.block_on(async { let list_output = self.run_launchctl(&["list"]).await?; let services: Vec = list_output .lines() .filter_map(|line| { if line.contains(&self.service_prefix) { // Extract service name from label line.split_whitespace() .last() .and_then(|label| { label.strip_prefix(&format!("{}.", self.service_prefix)) }) .map(|s| s.to_string()) } else { None } }) .collect(); Ok(services) }) } fn remove(&self, service_name: &str) -> Result<(), ServiceManagerError> { // Try to stop the service first, but don't fail if it's already stopped or doesn't exist if let Err(e) = self.stop(service_name) { // Log the error but continue with removal log::warn!( "Failed to stop service '{}' before removal: {}", service_name, e ); } // Remove the plist file using production-safe runtime let runtime = get_runtime()?; runtime.block_on(async { let plist_path = self.get_plist_path(service_name); if plist_path.exists() { tokio::fs::remove_file(&plist_path).await?; } Ok(()) }) } }