stackless_daemon/
launchd.rs1use std::path::PathBuf;
12use std::process::Command;
13use std::time::Duration;
14
15use stackless_core::paths::Paths;
16
17pub const LABEL: &str = "dev.stackless.daemon";
18
19pub fn persistence_status_path(paths: &Paths) -> PathBuf {
22 paths.persistence_marker()
23}
24
25fn plist_path() -> Option<PathBuf> {
26 std::env::var_os("HOME").map(PathBuf::from).map(|home| {
27 home.join("Library/LaunchAgents")
28 .join(format!("{LABEL}.plist"))
29 })
30}
31
32fn plist_xml(exe: &str) -> String {
36 let exe = xml_escape(exe);
37 format!(
38 r#"<?xml version="1.0" encoding="UTF-8"?>
39<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
40<plist version="1.0">
41<dict>
42 <key>Label</key>
43 <string>{LABEL}</string>
44 <key>ProgramArguments</key>
45 <array>
46 <string>{exe}</string>
47 <string>daemon</string>
48 <string>run</string>
49 </array>
50 <key>RunAtLoad</key>
51 <true/>
52 <key>KeepAlive</key>
53 <dict>
54 <key>SuccessfulExit</key>
55 <false/>
56 </dict>
57</dict>
58</plist>
59"#
60 )
61}
62
63fn xml_escape(s: &str) -> String {
64 s.replace('&', "&")
65 .replace('<', "<")
66 .replace('>', ">")
67}
68
69pub fn ensure_registered(paths: &Paths) {
73 let outcome = register();
74 let text = match &outcome {
75 Ok(()) => "registered".to_owned(),
76 Err(why) => why.clone(),
77 };
78 let marker = persistence_status_path(paths);
79 if let Some(dir) = marker.parent() {
80 let _ = std::fs::create_dir_all(dir);
81 }
82 let _ = std::fs::write(marker, text);
83}
84
85fn register() -> Result<(), String> {
87 let exe = std::env::current_exe()
88 .map(|p| p.display().to_string())
89 .map_err(|err| format!("cannot resolve the stackless binary path: {err}"))?;
90 let plist =
91 plist_path().ok_or_else(|| "HOME is unset; cannot locate LaunchAgents".to_owned())?;
92 if let Some(dir) = plist.parent() {
93 std::fs::create_dir_all(dir)
94 .map_err(|err| format!("cannot create {}: {err}", dir.display()))?;
95 }
96
97 let want = plist_xml(&exe);
101 let current = std::fs::read_to_string(&plist).ok();
102 if current.as_deref() != Some(want.as_str()) {
103 std::fs::write(&plist, &want)
104 .map_err(|err| format!("cannot write {}: {err}", plist.display()))?;
105 }
106
107 let uid = nix_getuid();
108 let domain = format!("gui/{uid}");
109 if service_loaded(&domain) {
115 return Ok(());
116 }
117 let output = bootstrap(&domain, &plist)?;
118 if output.status.success() || service_loaded(&domain) {
119 return Ok(());
120 }
121 if service_disabled(&domain) {
125 let _ = Command::new("launchctl")
126 .args(["enable", &format!("{domain}/{LABEL}")])
127 .output();
128 let retry = bootstrap(&domain, &plist)?;
129 if retry.status.success() || service_loaded(&domain) {
130 return Ok(());
131 }
132 let stderr = String::from_utf8_lossy(&retry.stderr);
133 return Err(format!(
134 "launchctl bootstrap {domain} failed after enabling the disabled service {LABEL}: {}",
135 stderr.trim()
136 ));
137 }
138 let stderr = String::from_utf8_lossy(&output.stderr);
139 Err(format!(
140 "launchctl bootstrap {domain} failed: {}",
141 stderr.trim()
142 ))
143}
144
145fn bootstrap(domain: &str, plist: &PathBuf) -> Result<std::process::Output, String> {
146 Command::new("launchctl")
147 .args(["bootstrap", domain])
148 .arg(plist)
149 .output()
150 .map_err(|err| format!("cannot run launchctl: {err}"))
151}
152
153fn service_disabled(domain: &str) -> bool {
157 Command::new("launchctl")
158 .args(["print-disabled", domain])
159 .output()
160 .ok()
161 .map(|out| parse_disabled(&String::from_utf8_lossy(&out.stdout)))
162 .unwrap_or(false)
163}
164
165fn parse_disabled(print_disabled: &str) -> bool {
166 print_disabled.lines().any(|line| {
167 let Some(rest) = line.trim().strip_prefix(&format!("\"{LABEL}\"")) else {
168 return false;
169 };
170 let Some(value) = rest.trim().strip_prefix("=>") else {
171 return false;
172 };
173 matches!(value.trim().trim_end_matches(';'), "disabled" | "true")
174 })
175}
176
177fn service_loaded(domain: &str) -> bool {
180 Command::new("launchctl")
181 .arg("print")
182 .arg(format!("{domain}/{LABEL}"))
183 .output()
184 .map(|out| out.status.success())
185 .unwrap_or(false)
186}
187
188pub fn kickstart_if_supervised() -> bool {
210 let Ok(exe) = std::env::current_exe() else {
211 return false;
212 };
213 let exe = exe.display().to_string();
214 let Some(plist) = plist_path() else {
215 return false;
216 };
217 match plist_program_binary(&plist) {
220 Some(named) if named == exe => {}
221 _ => return false,
222 }
223 let domain = format!("gui/{}", nix_getuid());
224 if !service_loaded(&domain) {
225 return false;
226 }
227 let mut cmd = Command::new("launchctl");
228 cmd.args(["kickstart", &format!("{domain}/{LABEL}")]);
229 match stackless_core::process::run_with_timeout(&mut cmd, Duration::from_secs(5)) {
230 stackless_core::process::TimedCommand::Finished(out) => out.status.success(),
231 _ => false,
233 }
234}
235
236fn plist_program_binary(plist: &PathBuf) -> Option<String> {
241 let xml = std::fs::read_to_string(plist).ok()?;
242 let after_key = xml.split("<key>ProgramArguments</key>").nth(1)?;
243 let array = after_key.split("<array>").nth(1)?;
244 let array = array.split("</array>").next()?;
245 let open = array.find("<string>")? + "<string>".len();
246 let close = array[open..].find("</string>")? + open;
247 Some(xml_unescape(array[open..close].trim()))
248}
249
250fn xml_unescape(s: &str) -> String {
251 s.replace("<", "<")
252 .replace(">", ">")
253 .replace("&", "&")
254}
255
256fn nix_getuid() -> u32 {
259 Command::new("id")
262 .arg("-u")
263 .output()
264 .ok()
265 .and_then(|out| String::from_utf8(out.stdout).ok())
266 .and_then(|s| s.trim().parse().ok())
267 .unwrap_or(0)
268}
269
270pub fn service_registered() -> bool {
274 service_loaded(&format!("gui/{}", nix_getuid()))
275}
276
277pub fn degradation_warning(paths: &Paths) -> Option<String> {
280 let status = std::fs::read_to_string(persistence_status_path(paths)).ok()?;
281 let status = status.trim();
282 if status.is_empty() || status == "registered" {
283 return None;
284 }
285 Some(format!(
286 "leases enforced only while the daemon happens to be running: {status}"
287 ))
288}
289
290#[cfg(test)]
291mod tests {
292 use super::parse_disabled;
293 use stackless_core::process::{TimedCommand, run_with_timeout};
294 use std::process::Command;
295 use std::time::{Duration, Instant};
296
297 #[test]
298 fn disable_record_current_macos() {
299 let out = "disabled services = {\n\t\t\"com.example.other\" => enabled\n\t\t\"dev.stackless.daemon\" => disabled\n\t}";
300 assert!(parse_disabled(out));
301 }
302
303 #[test]
304 fn disable_record_legacy_bool() {
305 let out = "\t\"dev.stackless.daemon\" => true;";
306 assert!(parse_disabled(out));
307 }
308
309 #[test]
310 fn enabled_or_absent_is_not_disabled() {
311 assert!(!parse_disabled("\t\"dev.stackless.daemon\" => enabled"));
312 assert!(!parse_disabled("\t\"com.example.other\" => disabled"));
313 assert!(!parse_disabled(""));
314 }
315
316 #[test]
317 fn kickstart_budget_kills_a_sleeper() {
318 let mut cmd = Command::new("sleep");
319 cmd.arg("30");
320 let started = Instant::now();
321 match run_with_timeout(&mut cmd, Duration::from_millis(200)) {
322 TimedCommand::TimedOut { .. } => {
323 assert!(started.elapsed() < Duration::from_secs(3));
324 }
325 other => panic!("expected timeout, got {other:?}"),
326 }
327 }
328}