1#[cfg(target_os = "macos")]
4use crate::error::{ErrorCode, MitosisError, Result};
5#[cfg(target_os = "macos")]
6use crate::paths::Home;
7use serde::Serialize;
8#[cfg(target_os = "macos")]
9use std::os::unix::fs::PermissionsExt;
10use std::path::Path;
11use std::path::PathBuf;
12#[cfg(target_os = "macos")]
13use std::process::Command;
14#[cfg(target_os = "macos")]
15use std::time::Duration;
16
17#[cfg(target_os = "linux")]
18mod systemd;
19#[cfg(target_os = "linux")]
20pub use systemd::{install, kickstart, linger_hint, manages, status, uninstall};
21
22#[cfg(target_os = "macos")]
23pub const SERVICE_LABEL: &str = "io.github.irfanfaraaz.mitosis";
24
25#[derive(Serialize, Clone, Debug)]
26pub struct ServiceStatus {
27 pub installed: bool,
28 pub loaded: bool,
29 pub running: bool,
30 pub binary: Option<PathBuf>,
31 pub binary_exists: bool,
32}
33
34#[cfg(target_os = "macos")]
35fn esc(s: &str) -> String {
36 s.replace('&', "&")
37 .replace('<', "<")
38 .replace('>', ">")
39}
40
41#[cfg(target_os = "macos")]
42fn unesc(s: &str) -> String {
43 s.replace("<", "<")
44 .replace(">", ">")
45 .replace("&", "&")
46}
47
48#[cfg(target_os = "macos")]
50pub fn plist(binary: &Path, home: Option<&Path>, path_env: &str) -> String {
51 let log_home = home.map(Path::to_path_buf).unwrap_or_else(|| {
52 let user = std::env::var_os("HOME").map_or(PathBuf::from("/tmp"), PathBuf::from);
53 user.join("Library/Application Support/mitosis")
54 });
55 let log = esc(&log_home.join("logs/service.log").to_string_lossy());
56 let home_env = home.map_or(String::new(), |h| {
57 format!(
58 " <key>MITOSIS_HOME</key>\n <string>{}</string>\n",
59 esc(&h.to_string_lossy())
60 )
61 });
62 format!(
63 r#"<?xml version="1.0" encoding="UTF-8"?>
64<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
65<plist version="1.0">
66<dict>
67 <key>Label</key>
68 <string>{SERVICE_LABEL}</string>
69 <key>ProgramArguments</key>
70 <array>
71 <string>{bin}</string>
72 <string>daemon</string>
73 </array>
74 <key>RunAtLoad</key>
75 <true/>
76 <key>KeepAlive</key>
77 <dict>
78 <key>SuccessfulExit</key>
79 <false/>
80 </dict>
81 <key>EnvironmentVariables</key>
82 <dict>
83 <key>MITOSIS_SERVICE</key>
84 <string>1</string>
85{home_env} <key>PATH</key>
86 <string>{path}</string>
87 </dict>
88 <key>StandardOutPath</key>
89 <string>{log}</string>
90 <key>StandardErrorPath</key>
91 <string>{log}</string>
92 <key>ThrottleInterval</key>
93 <integer>30</integer>
94 <key>ProcessType</key>
95 <string>Background</string>
96</dict>
97</plist>
98"#,
99 bin = esc(&binary.to_string_lossy()),
100 path = esc(path_env),
101 )
102}
103
104#[cfg(target_os = "macos")]
105pub fn plist_path() -> PathBuf {
106 let home = std::env::var_os("HOME").map_or(PathBuf::from("/"), PathBuf::from);
107 home.join("Library/LaunchAgents")
108 .join(format!("{SERVICE_LABEL}.plist"))
109}
110
111#[cfg(target_os = "macos")]
112fn target() -> String {
113 format!("gui/{}/{SERVICE_LABEL}", unsafe { libc::getuid() })
114}
115
116#[cfg(target_os = "macos")]
117fn domain() -> String {
118 format!("gui/{}", unsafe { libc::getuid() })
119}
120
121#[cfg(target_os = "macos")]
122fn launchctl(args: &[&str]) -> Result<std::process::Output> {
123 Command::new("launchctl")
124 .args(args)
125 .output()
126 .map_err(|e| MitosisError::new(ErrorCode::Internal, format!("run launchctl: {e}")))
127}
128
129#[cfg(target_os = "macos")]
130fn launchctl_failed(what: &str, o: &std::process::Output) -> MitosisError {
131 MitosisError::new(
132 ErrorCode::Internal,
133 format!(
134 "launchctl {what} failed: {}",
135 String::from_utf8_lossy(&o.stderr).trim()
136 ),
137 )
138}
139
140#[cfg(target_os = "macos")]
142fn wait_until(tries: u32, pause: Duration, mut cond: impl FnMut() -> bool) -> bool {
143 for i in 0..tries {
144 if cond() {
145 return true;
146 }
147 if i + 1 < tries {
148 std::thread::sleep(pause);
149 }
150 }
151 false
152}
153
154#[cfg(any(target_os = "macos", target_os = "linux"))]
156fn same_folder(a: &Path, b: &Path) -> bool {
157 let canon = |p: &Path| std::fs::canonicalize(p).unwrap_or_else(|_| p.to_path_buf());
158 a == b || canon(a) == canon(b)
159}
160
161#[cfg(target_os = "macos")]
163fn string_after(text: &str, key: &str) -> Option<String> {
164 let rest = &text[text.find(&format!("<key>{key}</key>"))?..];
165 let rest = &rest[rest.find("<string>")? + "<string>".len()..];
166 Some(unesc(&rest[..rest.find("</string>")?]))
167}
168
169#[cfg(target_os = "macos")]
170pub fn install() -> Result<()> {
171 let exe = std::env::current_exe()?;
172 let home = std::env::var_os("MITOSIS_HOME").filter(|h| !h.is_empty());
173 let home = home.map(std::path::absolute).transpose()?;
174 Home::from_env()?.ensure_dirs()?;
175 let path_env = std::env::var("PATH").unwrap_or_default();
176 let file = plist_path();
177 if let Some(dir) = file.parent() {
178 std::fs::create_dir_all(dir)?;
179 }
180 std::fs::write(&file, plist(&exe, home.as_deref(), &path_env))?;
181 std::fs::set_permissions(&file, std::fs::Permissions::from_mode(0o644))?;
182 let _ = launchctl(&["bootout", &target()])?;
184 wait_until(50, Duration::from_millis(100), || {
187 !launchctl(&["print", &target()]).is_ok_and(|o| o.status.success())
188 });
189 let o = launchctl(&["bootstrap", &domain(), &file.to_string_lossy()])?;
190 if !o.status.success() {
191 return Err(launchctl_failed("bootstrap", &o));
192 }
193 Ok(())
194}
195
196#[cfg(target_os = "macos")]
197pub fn uninstall() -> Result<()> {
198 let _ = launchctl(&["bootout", &target()])?;
199 match std::fs::remove_file(plist_path()) {
200 Err(e) if e.kind() != std::io::ErrorKind::NotFound => Err(e.into()),
201 _ => Ok(()),
202 }
203}
204
205#[cfg(target_os = "macos")]
206pub fn status() -> Result<ServiceStatus> {
207 let text = std::fs::read_to_string(plist_path()).ok();
208 let binary = text
209 .as_deref()
210 .and_then(|t| string_after(t, "ProgramArguments"))
211 .map(PathBuf::from);
212 let printed = launchctl(&["print", &target()])?;
213 let out = String::from_utf8_lossy(&printed.stdout);
214 Ok(ServiceStatus {
215 installed: text.is_some(),
216 loaded: printed.status.success(),
217 running: printed.status.success() && out.contains("state = running"),
218 binary_exists: binary.as_deref().is_some_and(Path::exists),
219 binary,
220 })
221}
222
223#[cfg(target_os = "macos")]
225pub fn manages(home: &Home) -> bool {
226 let Ok(text) = std::fs::read_to_string(plist_path()) else {
227 return false;
228 };
229 let theirs = string_after(&text, "MITOSIS_HOME");
231 let same = match theirs {
232 Some(h) => same_folder(Path::new(&h), &home.root),
233 None => std::env::var_os("MITOSIS_HOME").is_none_or(|h| h.is_empty()),
234 };
235 same && launchctl(&["print", &target()]).is_ok_and(|o| o.status.success())
236}
237
238#[cfg(target_os = "macos")]
240pub fn kickstart() -> bool {
241 launchctl(&["kickstart", &target()]).is_ok_and(|o| o.status.success())
242}
243
244#[cfg(not(any(target_os = "macos", target_os = "linux")))]
245mod other {
246 use super::ServiceStatus;
247 use crate::error::{ErrorCode, MitosisError, Result};
248 use crate::paths::Home;
249
250 fn not_built() -> MitosisError {
251 MitosisError::new(ErrorCode::Internal, "no login service on this platform")
252 }
253
254 pub fn install() -> Result<()> {
255 Err(not_built())
256 }
257
258 pub fn uninstall() -> Result<()> {
259 Err(not_built())
260 }
261
262 pub fn status() -> Result<ServiceStatus> {
263 Err(not_built())
264 }
265
266 pub fn manages(_home: &Home) -> bool {
267 false
268 }
269
270 pub fn kickstart() -> bool {
271 false
272 }
273}
274#[cfg(not(any(target_os = "macos", target_os = "linux")))]
275pub use other::{install, kickstart, manages, status, uninstall};
276
277#[cfg(not(target_os = "linux"))]
279pub fn linger_hint() -> Option<String> {
280 None
281}
282
283#[cfg(all(test, target_os = "macos"))]
284mod tests {
285 use super::*;
286
287 #[test]
288 fn plist_has_required_keys() {
289 let p = plist(
290 Path::new("/opt/bin/mitosis"),
291 Some(Path::new("/tmp/m&h")),
292 "/usr/bin:/opt/x",
293 );
294 for want in [
295 SERVICE_LABEL,
296 "<string>/opt/bin/mitosis</string>",
297 "<string>daemon</string>",
298 "RunAtLoad",
299 "SuccessfulExit",
300 "MITOSIS_SERVICE",
301 "<string>/usr/bin:/opt/x</string>",
302 "<string>/tmp/m&h</string>",
303 "/tmp/m&h/logs/service.log",
304 "Background",
305 "ThrottleInterval",
306 ] {
307 assert!(p.contains(want), "missing {want}:\n{p}");
308 }
309 assert!(!plist(Path::new("/b"), None, "/p").contains("MITOSIS_HOME"));
310 let odd = plist(Path::new("/a<b>/m&m"), None, "/x<y>:/z");
312 assert!(
313 odd.contains("<string>/a<b>/m&m</string>"),
314 "{odd}"
315 );
316 assert!(odd.contains("<string>/x<y>:/z</string>"), "{odd}");
317 assert_eq!(
318 string_after(&odd, "ProgramArguments").as_deref(),
319 Some("/a<b>/m&m")
320 );
321 assert_eq!(
322 string_after(&p, "ProgramArguments").as_deref(),
323 Some("/opt/bin/mitosis")
324 );
325 assert_eq!(
326 string_after(&p, "MITOSIS_HOME").as_deref(),
327 Some("/tmp/m&h")
328 );
329
330 let dir = tempfile::tempdir().unwrap();
332 let file = dir.path().join("t.plist");
333 std::fs::write(&file, &p).unwrap();
334 if let Ok(o) = Command::new("plutil").arg("-lint").arg(&file).output() {
335 assert!(o.status.success(), "{}", String::from_utf8_lossy(&o.stdout));
336 }
337 }
338
339 #[test]
340 fn wait_until_stops_early_and_gives_up() {
341 let mut calls = 0;
342 let ok = wait_until(5, Duration::ZERO, || {
343 calls += 1;
344 calls == 3
345 });
346 assert!(ok);
347 assert_eq!(calls, 3);
348 let mut calls = 0;
349 assert!(!wait_until(4, Duration::ZERO, || {
350 calls += 1;
351 false
352 }));
353 assert_eq!(calls, 4);
354 }
355
356 #[test]
357 fn homes_match_through_symlinks() {
358 let tmp = tempfile::tempdir().unwrap();
359 let real = tmp.path().join("real");
360 std::fs::create_dir(&real).unwrap();
361 let link = tmp.path().join("link");
362 std::os::unix::fs::symlink(&real, &link).unwrap();
363 assert!(same_folder(&real, &link));
364 assert!(same_folder(&real, &real));
365 assert!(!same_folder(&real, &tmp.path().join("other")));
366 }
367}