Skip to main content

mitosis/
service.rs

1//! Login service: a launchd user agent (macOS) or systemd user unit (Linux) that keeps
2//! `mitosis daemon` running.
3#[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('&', "&amp;")
37        .replace('<', "&lt;")
38        .replace('>', "&gt;")
39}
40
41#[cfg(target_os = "macos")]
42fn unesc(s: &str) -> String {
43    s.replace("&lt;", "<")
44        .replace("&gt;", ">")
45        .replace("&amp;", "&")
46}
47
48/// The launchd plist text. `home` is `$MITOSIS_HOME` when set; `path_env` is the caller's `PATH`.
49#[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/// Calls `cond` up to `tries` times, `pause` apart; true as soon as it holds.
141#[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/// True when `a` and `b` are the same folder, also through symlinks (`/tmp` and `/private/tmp`).
155#[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/// The text of the first `<string>` after `<key>key</key>`.
162#[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    // Reloading an already-loaded service would fail; "not loaded" on this first call is fine.
183    let _ = launchctl(&["bootout", &target()])?;
184    // bootout returns before launchd has dropped the job; a bootstrap in that gap fails with an
185    // I/O error, so wait (bounded) until it is gone.
186    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/// True when the installed, loaded service runs the daemon for `home`.
224#[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    // A service without MITOSIS_HOME serves the default home, i.e. a caller with the variable unset.
230    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/// Starts the loaded service now; false when launchd refused.
239#[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/// A warning to show when the daemon would stop at logout (Linux without lingering); none elsewhere.
278#[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&amp;h</string>",
303            "/tmp/m&amp;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        // Markup characters in the binary and PATH are escaped and read back.
311        let odd = plist(Path::new("/a<b>/m&m"), None, "/x<y>:/z");
312        assert!(
313            odd.contains("<string>/a&lt;b&gt;/m&amp;m</string>"),
314            "{odd}"
315        );
316        assert!(odd.contains("<string>/x&lt;y&gt;:/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        // Valid plist, when plutil exists (macOS).
331        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}