use std::process::Command;
use std::sync::Mutex;
use crate::error::{Error, Result};
#[derive(Debug, Clone)]
pub struct LaunchctlOutput {
pub success: bool,
pub stdout: String,
pub stderr: String,
}
pub trait LaunchctlRunner: Send + Sync {
fn run(&self, args: &[&str]) -> Result<LaunchctlOutput>;
}
pub struct SystemLaunchctl;
impl LaunchctlRunner for SystemLaunchctl {
fn run(&self, args: &[&str]) -> Result<LaunchctlOutput> {
let out = Command::new("launchctl").args(args).output().map_err(|e| {
Error::Launchctl(format!("failed to spawn launchctl: {e}"))
})?;
Ok(LaunchctlOutput {
success: out.status.success(),
stdout: String::from_utf8_lossy(&out.stdout).into_owned(),
stderr: String::from_utf8_lossy(&out.stderr).into_owned(),
})
}
}
#[derive(Debug, Default)]
pub struct RecordingLaunchctl {
pub calls: Mutex<Vec<Vec<String>>>,
pub responses: Mutex<std::collections::HashMap<String, LaunchctlOutput>>,
}
impl RecordingLaunchctl {
pub fn new() -> Self {
Self::default()
}
pub fn set_response(&self, first_arg: &str, out: LaunchctlOutput) {
self.responses.lock().unwrap().insert(first_arg.to_string(), out);
}
pub fn calls(&self) -> Vec<Vec<String>> {
self.calls.lock().unwrap().clone()
}
}
impl LaunchctlRunner for RecordingLaunchctl {
fn run(&self, args: &[&str]) -> Result<LaunchctlOutput> {
let owned: Vec<String> = args.iter().map(|s| s.to_string()).collect();
self.calls.lock().unwrap().push(owned.clone());
if let Some(first) = args.first() {
if let Some(out) = self.responses.lock().unwrap().get(*first) {
return Ok(out.clone());
}
}
Ok(LaunchctlOutput {
success: true,
stdout: String::new(),
stderr: String::new(),
})
}
}
pub fn user_domain_target() -> String {
let uid = unsafe { libc::getuid() };
format!("gui/{uid}")
}
pub fn user_service_target(label: &str) -> String {
format!("{}/{}", user_domain_target(), label)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn user_service_target_concats_uid_and_label() {
let s = user_service_target("com.x.y");
assert!(s.starts_with("gui/"));
assert!(s.ends_with("/com.x.y"));
}
#[test]
fn recording_runner_captures_args() {
let r = RecordingLaunchctl::new();
r.run(&["bootstrap", "gui/501", "/path/to/plist"]).unwrap();
r.run(&["bootout", "gui/501/com.textlog.agent"]).unwrap();
let calls = r.calls();
assert_eq!(calls.len(), 2);
assert_eq!(calls[0], vec!["bootstrap", "gui/501", "/path/to/plist"]);
assert_eq!(calls[1], vec!["bootout", "gui/501/com.textlog.agent"]);
}
#[test]
fn recording_runner_returns_canned_response_for_print() {
let r = RecordingLaunchctl::new();
r.set_response(
"print",
LaunchctlOutput {
success: true,
stdout: "pid = 12345\nlast exit code = 0\n".into(),
stderr: String::new(),
},
);
let out = r.run(&["print", "gui/501/com.textlog.agent"]).unwrap();
assert!(out.stdout.contains("pid = 12345"));
let out2 = r.run(&["kickstart", "gui/501/com.textlog.agent"]).unwrap();
assert!(out2.success);
assert!(out2.stdout.is_empty());
}
}