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