Skip to main content

pitchfork_cli/
boot_manager.rs

1use crate::{Result, env};
2#[cfg(target_os = "linux")]
3use auto_launcher::LinuxLaunchMode;
4#[cfg(target_os = "macos")]
5use auto_launcher::MacOSLaunchMode;
6use auto_launcher::{AutoLaunch, AutoLaunchBuilder};
7use miette::IntoDiagnostic;
8
9#[cfg(any(target_os = "macos", target_os = "linux"))]
10fn build_launcher(
11    app_path: &str,
12    #[cfg(target_os = "macos")] macos_mode: MacOSLaunchMode,
13    #[cfg(target_os = "linux")] linux_mode: LinuxLaunchMode,
14) -> Result<AutoLaunch> {
15    let mut builder = AutoLaunchBuilder::new();
16    builder
17        .set_app_name("pitchfork")
18        .set_app_path(app_path)
19        .set_args(&["supervisor", "run", "--boot"]);
20
21    #[cfg(target_os = "macos")]
22    builder.set_macos_launch_mode(macos_mode);
23
24    #[cfg(target_os = "linux")]
25    builder.set_linux_launch_mode(linux_mode);
26
27    builder.build().into_diagnostic()
28}
29
30pub struct BootManager {
31    /// The launcher matching the current privilege level (used for enable).
32    current: AutoLaunch,
33    /// The other level's launcher (used to detect cross-level registrations).
34    other: AutoLaunch,
35    /// Legacy macOS LaunchAgentSystem entry (pre-1.0.3 used /Library/LaunchAgents/
36    /// instead of /Library/LaunchDaemons/ for root). Kept only for migration/cleanup.
37    #[cfg(target_os = "macos")]
38    legacy: AutoLaunch,
39}
40
41impl BootManager {
42    pub fn new() -> Result<Self> {
43        let app_path = env::PITCHFORK_BIN.to_string_lossy().to_string();
44
45        #[cfg(target_os = "macos")]
46        let (current, other, legacy) = {
47            let is_root = nix::unistd::Uid::effective().is_root();
48            let (current_mode, other_mode) = if is_root {
49                (
50                    MacOSLaunchMode::LaunchDaemonSystem,
51                    MacOSLaunchMode::LaunchAgentUser,
52                )
53            } else {
54                (
55                    MacOSLaunchMode::LaunchAgentUser,
56                    MacOSLaunchMode::LaunchDaemonSystem,
57                )
58            };
59            (
60                build_launcher(&app_path, current_mode)?,
61                build_launcher(&app_path, other_mode)?,
62                build_launcher(&app_path, MacOSLaunchMode::LaunchAgentSystem)?,
63            )
64        };
65
66        #[cfg(target_os = "linux")]
67        let (current, other) = {
68            let is_root = nix::unistd::Uid::effective().is_root();
69            let (current_mode, other_mode) = if is_root {
70                (LinuxLaunchMode::SystemdSystem, LinuxLaunchMode::SystemdUser)
71            } else {
72                (LinuxLaunchMode::SystemdUser, LinuxLaunchMode::SystemdSystem)
73            };
74            (
75                build_launcher(&app_path, current_mode)?,
76                build_launcher(&app_path, other_mode)?,
77            )
78        };
79
80        // On Windows there is no root/user distinction; build two identical
81        // launchers (AutoLaunch does not implement Clone).
82        #[cfg(windows)]
83        let (current, other) = (
84            AutoLaunchBuilder::new()
85                .set_app_name("pitchfork")
86                .set_app_path(&app_path)
87                .set_args(&["supervisor", "run", "--boot"])
88                .build()
89                .into_diagnostic()?,
90            AutoLaunchBuilder::new()
91                .set_app_name("pitchfork")
92                .set_app_path(&app_path)
93                .set_args(&["supervisor", "run", "--boot"])
94                .build()
95                .into_diagnostic()?,
96        );
97
98        // Unsupported platforms: auto_launcher only supports macOS, Linux, and Windows.
99        #[cfg(not(any(target_os = "macos", target_os = "linux", windows)))]
100        compile_error!("pitchfork boot management is only supported on macOS, Linux, and Windows");
101
102        #[cfg(target_os = "macos")]
103        return Ok(Self {
104            current,
105            other,
106            legacy,
107        });
108
109        #[cfg(not(target_os = "macos"))]
110        Ok(Self { current, other })
111    }
112
113    /// Whether any registration (user- or system-level) exists.
114    pub fn is_enabled(&self) -> Result<bool> {
115        #[cfg(target_os = "macos")]
116        return Ok(self.current.is_enabled().into_diagnostic()?
117            || self.other.is_enabled().into_diagnostic()?
118            || self.legacy.is_enabled().into_diagnostic()?);
119
120        #[cfg(not(target_os = "macos"))]
121        Ok(self.current.is_enabled().into_diagnostic()?
122            || self.other.is_enabled().into_diagnostic()?)
123    }
124
125    /// Whether a registration at the *current* privilege level exists.
126    pub fn is_current_level_enabled(&self) -> Result<bool> {
127        self.current.is_enabled().into_diagnostic()
128    }
129
130    /// Whether a registration at the *other* privilege level exists.
131    /// Used to warn the user about cross-level mismatches.
132    /// On macOS, includes legacy entries for non-root callers (they are at a
133    /// different privilege level) but not for root callers (legacy is same level).
134    pub fn is_other_level_enabled(&self) -> Result<bool> {
135        #[cfg(target_os = "macos")]
136        return Ok(self.other.is_enabled().into_diagnostic()?
137            || (!nix::unistd::Uid::effective().is_root()
138                && self.legacy.is_enabled().into_diagnostic()?));
139
140        #[cfg(not(target_os = "macos"))]
141        self.other.is_enabled().into_diagnostic()
142    }
143
144    /// Remove legacy macOS LaunchAgentSystem entry if present and caller is root.
145    /// Idempotent — safe to call on every enable path, including retries after
146    /// partial migration (new entry written but legacy removal failed).
147    ///
148    /// `migrated`: true when called after writing a new LaunchDaemonSystem entry
149    /// (full migration); false when just removing a stale leftover.
150    #[cfg(target_os = "macos")]
151    pub fn cleanup_legacy(&self, migrated: bool) -> Result<()> {
152        if nix::unistd::Uid::effective().is_root() && self.legacy.is_enabled().into_diagnostic()? {
153            self.legacy.disable().into_diagnostic()?;
154            if migrated {
155                info!(
156                    "migrated legacy system-level launch entry from /Library/LaunchAgents/ to /Library/LaunchDaemons/"
157                );
158            } else {
159                info!("removed legacy system-level launch entry from /Library/LaunchAgents/");
160            }
161        }
162        Ok(())
163    }
164
165    /// Register at the current privilege level.
166    ///
167    /// Returns an error if a registration at the other privilege level already
168    /// exists, preventing user-level and system-level entries from coexisting.
169    ///
170    /// On macOS, migrates any legacy LaunchAgentSystem entry (from pre-1.0.3)
171    /// to the correct LaunchDaemonSystem entry.
172    pub fn enable(&self) -> Result<()> {
173        // For root, legacy will be migrated so only check non-legacy other level.
174        // For non-root, legacy cannot be migrated and is also a conflict.
175        #[cfg(target_os = "macos")]
176        let other_conflict = if nix::unistd::Uid::effective().is_root() {
177            self.other.is_enabled().into_diagnostic()?
178        } else {
179            self.is_other_level_enabled()?
180        };
181
182        #[cfg(not(target_os = "macos"))]
183        let other_conflict = self.other.is_enabled().into_diagnostic()?;
184
185        if other_conflict {
186            miette::bail!(
187                "boot start is already registered at the other privilege level; \
188                run `pitchfork boot disable` (with appropriate privileges) to remove \
189                it first"
190            );
191        }
192
193        self.current.enable().into_diagnostic()?;
194
195        #[cfg(target_os = "macos")]
196        self.cleanup_legacy(true)?;
197
198        Ok(())
199    }
200
201    /// Remove registrations at *both* levels so cross-level leftovers are also
202    /// cleaned up. Also removes legacy macOS LaunchAgentSystem entries when
203    /// running as root. Returns Ok even if some entries could not be removed
204    /// due to insufficient privileges — callers should check is_enabled()
205    /// afterwards to detect incomplete cleanup.
206    pub fn disable(&self) -> Result<()> {
207        if self.current.is_enabled().into_diagnostic()? {
208            self.current.disable().into_diagnostic()?;
209        }
210        if self.other.is_enabled().into_diagnostic()? {
211            self.other.disable().into_diagnostic()?;
212        }
213        #[cfg(target_os = "macos")]
214        if nix::unistd::Uid::effective().is_root() && self.legacy.is_enabled().into_diagnostic()? {
215            self.legacy.disable().into_diagnostic()?;
216        }
217        Ok(())
218    }
219}