Skip to main content

pitchfork_cli/
boot_manager.rs

1// ─── Supported platforms (macOS, Linux, Windows) ──────────────────────────
2
3#[cfg(any(target_os = "macos", target_os = "linux", windows))]
4mod imp {
5    use crate::{Result, env};
6    #[cfg(target_os = "linux")]
7    use auto_launcher::LinuxLaunchMode;
8    #[cfg(target_os = "macos")]
9    use auto_launcher::MacOSLaunchMode;
10    use auto_launcher::{AutoLaunch, AutoLaunchBuilder};
11    use miette::IntoDiagnostic;
12
13    #[cfg(any(target_os = "macos", target_os = "linux"))]
14    fn build_launcher(
15        app_path: &str,
16        #[cfg(target_os = "macos")] macos_mode: MacOSLaunchMode,
17        #[cfg(target_os = "linux")] linux_mode: LinuxLaunchMode,
18    ) -> Result<AutoLaunch> {
19        let mut builder = AutoLaunchBuilder::new();
20        builder
21            .set_app_name("pitchfork")
22            .set_app_path(app_path)
23            .set_args(&["supervisor", "run", "--boot"]);
24
25        #[cfg(target_os = "macos")]
26        builder.set_macos_launch_mode(macos_mode);
27
28        #[cfg(target_os = "linux")]
29        builder.set_linux_launch_mode(linux_mode);
30
31        builder.build().into_diagnostic()
32    }
33
34    pub struct BootManager {
35        /// The launcher matching the current privilege level (used for enable).
36        current: AutoLaunch,
37        /// The other level's launcher (used to detect cross-level registrations).
38        other: AutoLaunch,
39        /// Legacy macOS LaunchAgentSystem entry (pre-1.0.3 used /Library/LaunchAgents/
40        /// instead of /Library/LaunchDaemons/ for root). Kept only for migration/cleanup.
41        #[cfg(target_os = "macos")]
42        legacy: AutoLaunch,
43    }
44
45    impl BootManager {
46        pub fn new() -> Result<Self> {
47            let app_path = env::PITCHFORK_BIN.to_string_lossy().to_string();
48
49            #[cfg(target_os = "macos")]
50            let (current, other, legacy) = {
51                let is_root = nix::unistd::Uid::effective().is_root();
52                let (current_mode, other_mode) = if is_root {
53                    (
54                        MacOSLaunchMode::LaunchDaemonSystem,
55                        MacOSLaunchMode::LaunchAgentUser,
56                    )
57                } else {
58                    (
59                        MacOSLaunchMode::LaunchAgentUser,
60                        MacOSLaunchMode::LaunchDaemonSystem,
61                    )
62                };
63                (
64                    build_launcher(&app_path, current_mode)?,
65                    build_launcher(&app_path, other_mode)?,
66                    build_launcher(&app_path, MacOSLaunchMode::LaunchAgentSystem)?,
67                )
68            };
69
70            #[cfg(target_os = "linux")]
71            let (current, other) = {
72                let is_root = nix::unistd::Uid::effective().is_root();
73                let (current_mode, other_mode) = if is_root {
74                    (LinuxLaunchMode::SystemdSystem, LinuxLaunchMode::SystemdUser)
75                } else {
76                    (LinuxLaunchMode::SystemdUser, LinuxLaunchMode::SystemdSystem)
77                };
78                (
79                    build_launcher(&app_path, current_mode)?,
80                    build_launcher(&app_path, other_mode)?,
81                )
82            };
83
84            // On Windows there is no root/user distinction; build two identical
85            // launchers (AutoLaunch does not implement Clone).
86            #[cfg(windows)]
87            let (current, other) = (
88                AutoLaunchBuilder::new()
89                    .set_app_name("pitchfork")
90                    .set_app_path(&app_path)
91                    .set_args(&["supervisor", "run", "--boot"])
92                    .build()
93                    .into_diagnostic()?,
94                AutoLaunchBuilder::new()
95                    .set_app_name("pitchfork")
96                    .set_app_path(&app_path)
97                    .set_args(&["supervisor", "run", "--boot"])
98                    .build()
99                    .into_diagnostic()?,
100            );
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()
153                && self.legacy.is_enabled().into_diagnostic()?
154            {
155                self.legacy.disable().into_diagnostic()?;
156                if migrated {
157                    info!(
158                        "migrated legacy system-level launch entry from /Library/LaunchAgents/ to /Library/LaunchDaemons/"
159                    );
160                } else {
161                    info!("removed legacy system-level launch entry from /Library/LaunchAgents/");
162                }
163            }
164            Ok(())
165        }
166
167        /// Register at the current privilege level.
168        ///
169        /// Returns an error if a registration at the other privilege level already
170        /// exists, preventing user-level and system-level entries from coexisting.
171        ///
172        /// On macOS, migrates any legacy LaunchAgentSystem entry (from pre-1.0.3)
173        /// to the correct LaunchDaemonSystem entry.
174        pub fn enable(&self) -> Result<()> {
175            // For root, legacy will be migrated so only check non-legacy other level.
176            // For non-root, legacy cannot be migrated and is also a conflict.
177            #[cfg(target_os = "macos")]
178            let other_conflict = if nix::unistd::Uid::effective().is_root() {
179                self.other.is_enabled().into_diagnostic()?
180            } else {
181                self.is_other_level_enabled()?
182            };
183
184            #[cfg(not(target_os = "macos"))]
185            let other_conflict = self.other.is_enabled().into_diagnostic()?;
186
187            if other_conflict {
188                miette::bail!(
189                    "boot start is already registered at the other privilege level; \
190                    run `pitchfork boot disable` (with appropriate privileges) to remove \
191                    it first"
192                );
193            }
194
195            self.current.enable().into_diagnostic()?;
196
197            #[cfg(target_os = "macos")]
198            self.cleanup_legacy(true)?;
199
200            Ok(())
201        }
202
203        /// Remove registrations at *both* levels so cross-level leftovers are also
204        /// cleaned up. Also removes legacy macOS LaunchAgentSystem entries when
205        /// running as root. Returns Ok even if some entries could not be removed
206        /// due to insufficient privileges — callers should check is_enabled()
207        /// afterwards to detect incomplete cleanup.
208        pub fn disable(&self) -> Result<()> {
209            if self.current.is_enabled().into_diagnostic()? {
210                self.current.disable().into_diagnostic()?;
211            }
212            if self.other.is_enabled().into_diagnostic()? {
213                self.other.disable().into_diagnostic()?;
214            }
215            #[cfg(target_os = "macos")]
216            if nix::unistd::Uid::effective().is_root()
217                && self.legacy.is_enabled().into_diagnostic()?
218            {
219                self.legacy.disable().into_diagnostic()?;
220            }
221            Ok(())
222        }
223    }
224}
225
226// ─── Unsupported platforms ────────────────────────────────────────────────
227
228#[cfg(not(any(target_os = "macos", target_os = "linux", windows)))]
229mod imp {
230    use crate::Result;
231
232    pub struct BootManager;
233
234    impl BootManager {
235        pub fn new() -> Result<Self> {
236            miette::bail!(
237                "boot management is not supported on this platform; \
238                only macOS, Linux, and Windows are supported"
239            )
240        }
241
242        pub fn is_enabled(&self) -> Result<bool> {
243            miette::bail!(
244                "boot management is not supported on this platform; \
245                only macOS, Linux, and Windows are supported"
246            )
247        }
248
249        pub fn is_current_level_enabled(&self) -> Result<bool> {
250            miette::bail!(
251                "boot management is not supported on this platform; \
252                only macOS, Linux, and Windows are supported"
253            )
254        }
255
256        pub fn is_other_level_enabled(&self) -> Result<bool> {
257            miette::bail!(
258                "boot management is not supported on this platform; \
259                only macOS, Linux, and Windows are supported"
260            )
261        }
262
263        pub fn enable(&self) -> Result<()> {
264            miette::bail!(
265                "boot management is not supported on this platform; \
266                only macOS, Linux, and Windows are supported"
267            )
268        }
269
270        pub fn disable(&self) -> Result<()> {
271            miette::bail!(
272                "boot management is not supported on this platform; \
273                only macOS, Linux, and Windows are supported"
274            )
275        }
276    }
277}
278
279pub use imp::BootManager;