use std::collections::{HashMap, HashSet};
use std::fs;
use std::path::{Path, PathBuf};
use kbolt_types::{KboltError, ScheduleDefinition, ScheduleIntervalUnit, ScheduleTrigger};
use super::{command_failure, write_if_changed, BackendInspection, CommandRunner};
use crate::schedule_support::{parse_canonical_schedule_time, schedule_id_sort_key};
use crate::Result;
const MANAGED_LABEL_PREFIX: &str = "com.kbolt.schedule.";
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct LaunchdPaths {
pub agents_dir: PathBuf,
pub log_dir: PathBuf,
pub domain: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct LaunchdJob {
pub schedule_id: String,
pub label: String,
pub plist_path: PathBuf,
pub plist_contents: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct LaunchdPlan {
pub jobs: Vec<LaunchdJob>,
pub stale_paths: Vec<PathBuf>,
}
pub(crate) fn plan_launchd(
paths: &LaunchdPaths,
schedules: &[ScheduleDefinition],
executable: &Path,
) -> Result<LaunchdPlan> {
let mut jobs = schedules
.iter()
.map(|schedule| {
let label = launchd_label(&schedule.id);
let plist_path = paths.agents_dir.join(format!("{label}.plist"));
let plist_contents = render_launchd_plist(schedule, executable, &paths.log_dir)?;
Ok(LaunchdJob {
schedule_id: schedule.id.clone(),
label,
plist_path,
plist_contents,
})
})
.collect::<Result<Vec<_>>>()?;
jobs.sort_by(|left, right| left.label.cmp(&right.label));
let desired_paths = jobs
.iter()
.map(|job| job.plist_path.clone())
.collect::<HashSet<_>>();
let mut stale_paths = Vec::new();
if let Ok(entries) = fs::read_dir(&paths.agents_dir) {
for entry in entries {
let entry = entry?;
let path = entry.path();
if !is_managed_launchd_plist(&path) {
continue;
}
if !desired_paths.contains(&path) {
stale_paths.push(path);
}
}
}
stale_paths.sort();
Ok(LaunchdPlan { jobs, stale_paths })
}
pub(crate) fn inspect_launchd(
paths: &LaunchdPaths,
schedules: &[ScheduleDefinition],
executable: &Path,
runner: &dyn CommandRunner,
) -> Result<BackendInspection> {
let plan = plan_launchd(paths, schedules, executable)?;
let mut drifted_ids = HashSet::new();
let planned_jobs = plan
.jobs
.iter()
.map(|job| (job.schedule_id.as_str(), job))
.collect::<HashMap<_, _>>();
for schedule in schedules {
let job = planned_jobs.get(schedule.id.as_str()).ok_or_else(|| {
KboltError::Internal(format!(
"planned launchd job missing for schedule {}",
schedule.id
))
})?;
if launchd_job_is_drifted(job, paths, runner)? {
drifted_ids.insert(schedule.id.clone());
}
}
let orphan_ids = plan
.stale_paths
.iter()
.filter_map(|path| schedule_id_from_launchd_path(path))
.collect::<Vec<_>>();
let mut orphan_ids = orphan_ids;
orphan_ids.sort_by_key(|id| schedule_id_sort_key(id));
Ok(BackendInspection {
drifted_ids,
orphan_ids,
})
}
pub(crate) fn reconcile_launchd(
paths: &LaunchdPaths,
schedules: &[ScheduleDefinition],
executable: &Path,
runner: &dyn CommandRunner,
) -> Result<()> {
fs::create_dir_all(&paths.agents_dir)?;
fs::create_dir_all(&paths.log_dir)?;
let plan = plan_launchd(paths, schedules, executable)?;
for stale_path in &plan.stale_paths {
if let Some(label) = launchd_label_from_path(stale_path) {
bootout_launchd_job(paths, runner, &label)?;
}
match fs::remove_file(stale_path) {
Ok(()) => {}
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
Err(err) => return Err(err.into()),
}
}
for job in &plan.jobs {
let changed = write_if_changed(&job.plist_path, &job.plist_contents)?;
let loaded = is_launchd_loaded(paths, runner, &job.label)?;
if loaded && changed {
bootout_launchd_job(paths, runner, &job.label)?;
}
if changed || !loaded {
bootstrap_launchd_job(paths, runner, &job.plist_path)?;
}
}
Ok(())
}
pub(crate) fn render_launchd_plist(
schedule: &ScheduleDefinition,
executable: &Path,
log_dir: &Path,
) -> Result<String> {
let label = launchd_label(&schedule.id);
let stdout_path = log_dir.join(format!("{label}.out.log"));
let stderr_path = log_dir.join(format!("{label}.err.log"));
let trigger_xml = match &schedule.trigger {
ScheduleTrigger::Every { interval } => {
let seconds = match interval.unit {
ScheduleIntervalUnit::Minutes => interval.value.checked_mul(60),
ScheduleIntervalUnit::Hours => interval.value.checked_mul(60 * 60),
}
.ok_or_else(|| {
KboltError::InvalidInput(format!(
"schedule interval is too large for launchd: {}",
schedule.id
))
})?;
format!(" <key>StartInterval</key>\n <integer>{seconds}</integer>\n")
}
ScheduleTrigger::Daily { time } => {
let (hour, minute) = parse_canonical_schedule_time(time)?;
format!(
" <key>StartCalendarInterval</key>\n <dict>\n <key>Hour</key>\n <integer>{hour}</integer>\n <key>Minute</key>\n <integer>{minute}</integer>\n </dict>\n"
)
}
ScheduleTrigger::Weekly { weekdays, time } => {
let (hour, minute) = parse_canonical_schedule_time(time)?;
let entries = weekdays
.iter()
.map(|weekday| {
format!(
" <dict>\n <key>Weekday</key>\n <integer>{}</integer>\n <key>Hour</key>\n <integer>{hour}</integer>\n <key>Minute</key>\n <integer>{minute}</integer>\n </dict>",
launchd_weekday(*weekday)
)
})
.collect::<Vec<_>>()
.join("\n");
format!(" <key>StartCalendarInterval</key>\n <array>\n{entries}\n </array>\n")
}
};
Ok(format!(
r#"<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>{label}</string>
<key>ProgramArguments</key>
<array>
<string>{program}</string>
<string>__schedule-run</string>
<string>{schedule_id}</string>
</array>
{trigger_xml} <key>StandardOutPath</key>
<string>{stdout_path}</string>
<key>StandardErrorPath</key>
<string>{stderr_path}</string>
</dict>
</plist>
"#,
program = xml_escape(&executable.display().to_string()),
schedule_id = xml_escape(&schedule.id),
stdout_path = xml_escape(&stdout_path.display().to_string()),
stderr_path = xml_escape(&stderr_path.display().to_string()),
))
}
fn launchd_label(schedule_id: &str) -> String {
format!("{MANAGED_LABEL_PREFIX}{schedule_id}")
}
fn is_managed_launchd_plist(path: &Path) -> bool {
path.extension().and_then(|ext| ext.to_str()) == Some("plist")
&& path
.file_stem()
.and_then(|stem| stem.to_str())
.is_some_and(|stem| stem.starts_with(MANAGED_LABEL_PREFIX))
}
fn launchd_job_is_drifted(
job: &LaunchdJob,
paths: &LaunchdPaths,
runner: &dyn CommandRunner,
) -> Result<bool> {
let existing = match fs::read_to_string(&job.plist_path) {
Ok(existing) => existing,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(true),
Err(err) => return Err(err.into()),
};
if existing != job.plist_contents {
return Ok(true);
}
Ok(!is_launchd_loaded(paths, runner, &job.label)?)
}
fn is_launchd_loaded(
paths: &LaunchdPaths,
runner: &dyn CommandRunner,
label: &str,
) -> Result<bool> {
let service_target = launchd_service_target(paths, label);
let output = runner.run("launchctl", &["print", &service_target])?;
if output.success {
return Ok(true);
}
if launchd_missing_service(&output.stdout) || launchd_missing_service(&output.stderr) {
return Ok(false);
}
Err(command_failure("launchctl", &["print", &service_target], &output).into())
}
fn bootstrap_launchd_job(
paths: &LaunchdPaths,
runner: &dyn CommandRunner,
plist_path: &Path,
) -> Result<()> {
let plist = plist_path.display().to_string();
let output = runner.run("launchctl", &["bootstrap", &paths.domain, &plist])?;
if output.success {
return Ok(());
}
Err(command_failure("launchctl", &["bootstrap", &paths.domain, &plist], &output).into())
}
fn bootout_launchd_job(
paths: &LaunchdPaths,
runner: &dyn CommandRunner,
label: &str,
) -> Result<()> {
if !is_launchd_loaded(paths, runner, label)? {
return Ok(());
}
let service_target = launchd_service_target(paths, label);
let output = runner.run("launchctl", &["bootout", &service_target])?;
if output.success {
return Ok(());
}
Err(command_failure("launchctl", &["bootout", &service_target], &output).into())
}
fn launchd_service_target(paths: &LaunchdPaths, label: &str) -> String {
format!("{}/{}", paths.domain, label)
}
fn launchd_missing_service(output: &str) -> bool {
output.contains("Could not find service")
}
fn launchd_label_from_path(path: &Path) -> Option<String> {
path.file_stem()
.and_then(|stem| stem.to_str())
.filter(|stem| stem.starts_with(MANAGED_LABEL_PREFIX))
.map(ToString::to_string)
}
fn schedule_id_from_launchd_path(path: &Path) -> Option<String> {
launchd_label_from_path(path).and_then(|label| {
label
.strip_prefix(MANAGED_LABEL_PREFIX)
.map(ToString::to_string)
})
}
fn launchd_weekday(weekday: kbolt_types::ScheduleWeekday) -> u32 {
match weekday {
kbolt_types::ScheduleWeekday::Sun => 0,
kbolt_types::ScheduleWeekday::Mon => 1,
kbolt_types::ScheduleWeekday::Tue => 2,
kbolt_types::ScheduleWeekday::Wed => 3,
kbolt_types::ScheduleWeekday::Thu => 4,
kbolt_types::ScheduleWeekday::Fri => 5,
kbolt_types::ScheduleWeekday::Sat => 6,
}
}
fn xml_escape(value: &str) -> String {
value
.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
.replace('"', """)
.replace('\'', "'")
}
#[cfg(test)]
mod tests {
use std::collections::HashSet;
use std::path::Path;
use std::sync::Mutex;
use tempfile::tempdir;
use super::{
inspect_launchd, plan_launchd, reconcile_launchd, render_launchd_plist, LaunchdPaths,
};
use crate::schedule_backend::{CommandOutput, CommandRunner};
use kbolt_types::{
ScheduleDefinition, ScheduleInterval, ScheduleIntervalUnit, ScheduleScope, ScheduleTrigger,
ScheduleWeekday,
};
#[test]
fn render_launchd_plist_for_interval_schedule_uses_start_interval() {
let plist = render_launchd_plist(
&ScheduleDefinition {
id: "s1".to_string(),
trigger: ScheduleTrigger::Every {
interval: ScheduleInterval {
value: 30,
unit: ScheduleIntervalUnit::Minutes,
},
},
scope: ScheduleScope::All,
},
Path::new("/usr/local/bin/kbolt"),
Path::new("/tmp/kbolt/logs"),
)
.expect("render plist");
assert!(plist.contains("<string>com.kbolt.schedule.s1</string>"));
assert!(plist.contains("<string>/usr/local/bin/kbolt</string>"));
assert!(plist.contains("<string>__schedule-run</string>"));
assert!(plist.contains("<string>s1</string>"));
assert!(plist.contains("<key>StartInterval</key>"));
assert!(plist.contains("<integer>1800</integer>"));
assert!(plist.contains("/tmp/kbolt/logs/com.kbolt.schedule.s1.out.log"));
}
#[test]
fn render_launchd_plist_for_weekly_schedule_uses_start_calendar_array() {
let plist = render_launchd_plist(
&ScheduleDefinition {
id: "s2".to_string(),
trigger: ScheduleTrigger::Weekly {
weekdays: vec![ScheduleWeekday::Mon, ScheduleWeekday::Fri],
time: "15:00".to_string(),
},
scope: ScheduleScope::Space {
space: "work".to_string(),
},
},
Path::new("/usr/local/bin/kbolt"),
Path::new("/tmp/kbolt/logs"),
)
.expect("render plist");
assert!(plist.contains("<key>StartCalendarInterval</key>"));
assert!(plist.contains("<array>"));
assert!(plist.contains("<integer>1</integer>"));
assert!(plist.contains("<integer>5</integer>"));
assert!(plist.contains("<integer>15</integer>"));
assert!(plist.contains("<integer>0</integer>"));
}
#[test]
fn plan_launchd_detects_stale_managed_plists() {
let tmp = tempdir().expect("create tempdir");
let paths = LaunchdPaths {
agents_dir: tmp.path().join("LaunchAgents"),
log_dir: tmp.path().join("logs"),
domain: "gui/test".to_string(),
};
std::fs::create_dir_all(&paths.agents_dir).expect("create agents dir");
std::fs::write(
paths.agents_dir.join("com.kbolt.schedule.s9.plist"),
"stale job",
)
.expect("write stale plist");
std::fs::write(
paths.agents_dir.join("com.example.other.plist"),
"foreign job",
)
.expect("write foreign plist");
let plan = plan_launchd(
&paths,
&[ScheduleDefinition {
id: "s1".to_string(),
trigger: ScheduleTrigger::Daily {
time: "09:00".to_string(),
},
scope: ScheduleScope::All,
}],
Path::new("/usr/local/bin/kbolt"),
)
.expect("plan launchd");
assert_eq!(plan.jobs.len(), 1);
assert_eq!(plan.jobs[0].label, "com.kbolt.schedule.s1");
assert_eq!(
plan.stale_paths,
vec![paths.agents_dir.join("com.kbolt.schedule.s9.plist")]
);
}
#[test]
fn inspect_launchd_marks_missing_jobs_as_drifted_and_extra_plists_as_orphans() {
let tmp = tempdir().expect("create tempdir");
let schedule = ScheduleDefinition {
id: "s1".to_string(),
trigger: ScheduleTrigger::Daily {
time: "09:00".to_string(),
},
scope: ScheduleScope::All,
};
let paths = LaunchdPaths {
agents_dir: tmp.path().join("LaunchAgents"),
log_dir: tmp.path().join("logs"),
domain: "gui/test".to_string(),
};
std::fs::create_dir_all(&paths.agents_dir).expect("create agents dir");
std::fs::write(
paths.agents_dir.join("com.kbolt.schedule.s9.plist"),
"orphan job",
)
.expect("write orphan job");
let inspection = inspect_launchd(
&paths,
&[schedule],
Path::new("/usr/local/bin/kbolt"),
&NoopRunner,
)
.expect("inspect launchd");
assert_eq!(inspection.drifted_ids, HashSet::from(["s1".to_string()]));
assert_eq!(inspection.orphan_ids, vec!["s9".to_string()]);
}
#[test]
fn inspect_launchd_matches_jobs_by_schedule_id_when_ids_reach_double_digits() {
let tmp = tempdir().expect("create tempdir");
let schedules = vec![
ScheduleDefinition {
id: "s2".to_string(),
trigger: ScheduleTrigger::Daily {
time: "09:00".to_string(),
},
scope: ScheduleScope::All,
},
ScheduleDefinition {
id: "s10".to_string(),
trigger: ScheduleTrigger::Daily {
time: "10:00".to_string(),
},
scope: ScheduleScope::All,
},
];
let paths = LaunchdPaths {
agents_dir: tmp.path().join("LaunchAgents"),
log_dir: tmp.path().join("logs"),
domain: "gui/test".to_string(),
};
std::fs::create_dir_all(&paths.agents_dir).expect("create agents dir");
let plan =
plan_launchd(&paths, &schedules, Path::new("/usr/local/bin/kbolt")).expect("plan");
let s2_job = plan
.jobs
.iter()
.find(|job| job.schedule_id == "s2")
.expect("s2 job");
std::fs::write(&s2_job.plist_path, &s2_job.plist_contents).expect("write s2 job");
let inspection = inspect_launchd(
&paths,
&schedules,
Path::new("/usr/local/bin/kbolt"),
&NoopRunner,
)
.expect("inspect launchd");
assert_eq!(inspection.drifted_ids, HashSet::from(["s10".to_string()]));
}
#[test]
fn reconcile_launchd_writes_jobs_and_bootstraps_missing_services() {
let tmp = tempdir().expect("create tempdir");
let schedule = ScheduleDefinition {
id: "s1".to_string(),
trigger: ScheduleTrigger::Every {
interval: ScheduleInterval {
value: 30,
unit: ScheduleIntervalUnit::Minutes,
},
},
scope: ScheduleScope::All,
};
let paths = LaunchdPaths {
agents_dir: tmp.path().join("LaunchAgents"),
log_dir: tmp.path().join("logs"),
domain: "gui/test".to_string(),
};
let runner = RecordingRunner::new();
reconcile_launchd(
&paths,
&[schedule],
Path::new("/usr/local/bin/kbolt"),
&runner,
)
.expect("reconcile launchd");
assert!(paths
.agents_dir
.join("com.kbolt.schedule.s1.plist")
.exists());
assert_eq!(
runner.commands(),
vec![
vec![
"launchctl".to_string(),
"print".to_string(),
"gui/test/com.kbolt.schedule.s1".to_string(),
],
vec![
"launchctl".to_string(),
"bootstrap".to_string(),
"gui/test".to_string(),
paths
.agents_dir
.join("com.kbolt.schedule.s1.plist")
.display()
.to_string(),
],
]
);
}
struct NoopRunner;
impl CommandRunner for NoopRunner {
fn run(&self, _program: &str, _args: &[&str]) -> crate::Result<CommandOutput> {
Ok(CommandOutput {
success: true,
stdout: String::new(),
stderr: String::new(),
})
}
}
struct RecordingRunner {
commands: Mutex<Vec<Vec<String>>>,
}
impl RecordingRunner {
fn new() -> Self {
Self {
commands: Mutex::new(Vec::new()),
}
}
fn commands(&self) -> Vec<Vec<String>> {
self.commands.lock().expect("lock runner").clone()
}
}
impl CommandRunner for RecordingRunner {
fn run(&self, program: &str, args: &[&str]) -> crate::Result<CommandOutput> {
self.commands.lock().expect("lock runner").push(
std::iter::once(program.to_string())
.chain(args.iter().map(|arg| arg.to_string()))
.collect(),
);
let success = !(program == "launchctl"
&& args.first() == Some(&"print")
&& args.last() == Some(&"gui/test/com.kbolt.schedule.s1"));
let stderr = if success {
String::new()
} else {
"Could not find service".to_string()
};
Ok(CommandOutput {
success,
stdout: String::new(),
stderr,
})
}
}
}