Skip to main content

lean_ctx/
daemon_autostart.rs

1#[cfg(any(target_os = "macos", target_os = "linux"))]
2use std::path::PathBuf;
3
4#[cfg(target_os = "macos")]
5const PLIST_LABEL: &str = "com.leanctx.daemon";
6#[cfg(target_os = "linux")]
7const SYSTEMD_SERVICE: &str = "lean-ctx-daemon";
8
9pub fn install(quiet: bool) {
10    let binary = crate::proxy_autostart::find_binary();
11    if binary.is_empty() {
12        if !quiet {
13            tracing::error!("Cannot find lean-ctx binary for daemon autostart");
14        }
15        return;
16    }
17
18    #[cfg(target_os = "macos")]
19    install_launchagent(&binary, quiet);
20
21    #[cfg(target_os = "linux")]
22    install_systemd(&binary, quiet);
23
24    #[cfg(not(any(target_os = "macos", target_os = "linux")))]
25    {
26        let _ = (&binary, quiet);
27        println!("  Autostart not supported on this platform");
28        println!("  Run manually: lean-ctx serve -d");
29    }
30}
31
32pub fn stop() {
33    #[cfg(target_os = "macos")]
34    {
35        let plist_path = launchagent_path();
36        if plist_path.exists() {
37            let _ = std::process::Command::new("launchctl")
38                .args(["unload", &plist_path.to_string_lossy()])
39                .output();
40        }
41    }
42
43    #[cfg(target_os = "linux")]
44    {
45        let _ = std::process::Command::new("systemctl")
46            .args(["--user", "stop", SYSTEMD_SERVICE])
47            .output();
48    }
49}
50
51pub fn start() {
52    #[cfg(target_os = "macos")]
53    {
54        let plist_path = launchagent_path();
55        if plist_path.exists() {
56            let _ = std::process::Command::new("launchctl")
57                .args(["load", &plist_path.to_string_lossy()])
58                .output();
59        }
60    }
61
62    #[cfg(target_os = "linux")]
63    {
64        let _ = std::process::Command::new("systemctl")
65            .args(["--user", "start", SYSTEMD_SERVICE])
66            .output();
67    }
68}
69
70pub fn uninstall(quiet: bool) {
71    #[cfg(target_os = "macos")]
72    uninstall_launchagent(quiet);
73
74    #[cfg(target_os = "linux")]
75    uninstall_systemd(quiet);
76
77    #[cfg(not(any(target_os = "macos", target_os = "linux")))]
78    let _ = quiet;
79}
80
81pub fn is_installed() -> bool {
82    #[cfg(target_os = "macos")]
83    {
84        launchagent_path().exists()
85    }
86    #[cfg(target_os = "linux")]
87    {
88        if !systemd_path().exists() {
89            return false;
90        }
91        std::process::Command::new("systemctl")
92            .args(["--user", "is-enabled", "--quiet", SYSTEMD_SERVICE])
93            .status()
94            .is_ok_and(|s| s.success())
95    }
96    #[cfg(not(any(target_os = "macos", target_os = "linux")))]
97    {
98        false
99    }
100}
101
102// ---------------------------------------------------------------------------
103// macOS LaunchAgent
104// ---------------------------------------------------------------------------
105
106#[cfg(target_os = "macos")]
107fn launchagent_path() -> PathBuf {
108    dirs::home_dir()
109        .unwrap_or_else(|| PathBuf::from("/tmp"))
110        .join("Library/LaunchAgents")
111        .join(format!("{PLIST_LABEL}.plist"))
112}
113
114#[cfg(target_os = "macos")]
115fn install_launchagent(binary: &str, quiet: bool) {
116    let la_dir = dirs::home_dir()
117        .unwrap_or_else(|| PathBuf::from("/tmp"))
118        .join("Library/LaunchAgents");
119    let _ = std::fs::create_dir_all(&la_dir);
120
121    let plist_path = la_dir.join(format!("{PLIST_LABEL}.plist"));
122    let data_dir = dirs::data_local_dir()
123        .unwrap_or_else(|| dirs::home_dir().unwrap_or_default().join(".local/share"))
124        .join("lean-ctx");
125
126    let _ = std::fs::create_dir_all(&data_dir);
127
128    let plist = format!(
129        r#"<?xml version="1.0" encoding="UTF-8"?>
130<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
131<plist version="1.0">
132<dict>
133    <key>Label</key>
134    <string>{PLIST_LABEL}</string>
135    <key>ProgramArguments</key>
136    <array>
137        <string>{binary}</string>
138        <string>serve</string>
139        <string>--_foreground-daemon</string>
140    </array>
141    <key>RunAtLoad</key>
142    <true/>
143    <key>KeepAlive</key>
144    <dict>
145        <key>SuccessfulExit</key>
146        <false/>
147    </dict>
148    <key>ThrottleInterval</key>
149    <integer>10</integer>
150    <key>StandardOutPath</key>
151    <string>{stdout}</string>
152    <key>StandardErrorPath</key>
153    <string>{stderr}</string>
154</dict>
155</plist>
156"#,
157        stdout = data_dir.join("daemon-stdout.log").display(),
158        stderr = data_dir.join("daemon-stderr.log").display(),
159    );
160
161    let _ = std::fs::write(&plist_path, &plist);
162
163    let _ = std::process::Command::new("launchctl")
164        .args(["unload", &plist_path.to_string_lossy()])
165        .output();
166    let result = std::process::Command::new("launchctl")
167        .args(["load", "-w", &plist_path.to_string_lossy()])
168        .output();
169
170    if !quiet {
171        match result {
172            Ok(o) if o.status.success() => {
173                println!("  Installed LaunchAgent: {PLIST_LABEL}");
174                println!("  Daemon will start on login and restart if stopped");
175            }
176            Ok(o) => {
177                let err = String::from_utf8_lossy(&o.stderr);
178                println!("  Created plist but load failed: {err}");
179            }
180            Err(e) => {
181                println!("  Created plist at {}", plist_path.display());
182                println!("  Could not load: {e}");
183            }
184        }
185    }
186}
187
188#[cfg(target_os = "macos")]
189fn uninstall_launchagent(quiet: bool) {
190    let plist_path = launchagent_path();
191    if !plist_path.exists() {
192        if !quiet {
193            println!("  Daemon LaunchAgent not installed, nothing to remove");
194        }
195        return;
196    }
197    let _ = std::process::Command::new("launchctl")
198        .args(["unload", &plist_path.to_string_lossy()])
199        .output();
200    let _ = std::fs::remove_file(&plist_path);
201    if !quiet {
202        println!("  Removed daemon LaunchAgent");
203    }
204}
205
206// ---------------------------------------------------------------------------
207// Linux systemd
208// ---------------------------------------------------------------------------
209
210#[cfg(target_os = "linux")]
211fn systemd_path() -> PathBuf {
212    dirs::home_dir()
213        .unwrap_or_else(|| PathBuf::from("/tmp"))
214        .join(".config/systemd/user")
215        .join(format!("{SYSTEMD_SERVICE}.service"))
216}
217
218#[cfg(target_os = "linux")]
219fn install_systemd(binary: &str, quiet: bool) {
220    let service_dir = dirs::home_dir()
221        .unwrap_or_else(|| PathBuf::from("/tmp"))
222        .join(".config/systemd/user");
223    let _ = std::fs::create_dir_all(&service_dir);
224
225    let service_path = service_dir.join(format!("{SYSTEMD_SERVICE}.service"));
226
227    let unit = format!(
228        r"[Unit]
229Description=lean-ctx IPC Daemon
230After=network.target
231StartLimitIntervalSec=300
232StartLimitBurst=5
233
234[Service]
235Type=simple
236ExecStart={binary} serve --_foreground-daemon
237Restart=on-failure
238RestartSec=5
239StandardOutput=journal
240StandardError=journal
241Environment=RUST_LOG=info
242
243[Install]
244WantedBy=default.target
245"
246    );
247
248    let _ = std::fs::write(&service_path, &unit);
249
250    let _ = std::process::Command::new("systemctl")
251        .args(["--user", "daemon-reload"])
252        .output();
253
254    let result = std::process::Command::new("systemctl")
255        .args(["--user", "enable", "--now", SYSTEMD_SERVICE])
256        .output();
257
258    match result {
259        Ok(o) if o.status.success() => {
260            if !quiet {
261                println!("  Installed systemd user service: {SYSTEMD_SERVICE}");
262                println!("  Daemon will start on login and restart if stopped");
263            }
264        }
265        Ok(o) => {
266            let err = String::from_utf8_lossy(&o.stderr);
267            eprintln!("  Created service file but `systemctl enable` failed: {err}");
268            eprintln!("  Try manually: systemctl --user enable --now {SYSTEMD_SERVICE}");
269        }
270        Err(e) => {
271            eprintln!("  Created service file at {}", service_path.display());
272            eprintln!("  Could not run systemctl: {e}");
273        }
274    }
275
276    // Hint about linger for headless/server use (needed for boot-time start without login)
277    if !quiet {
278        if let Ok(o) = std::process::Command::new("loginctl")
279            .args(["show-user", &whoami(), "-p", "Linger", "--value"])
280            .output()
281        {
282            let val = String::from_utf8_lossy(&o.stdout).trim().to_string();
283            if val != "yes" {
284                println!(
285                    "  Note: for daemon to start at boot (without login), run:\n    \
286                     loginctl enable-linger {}",
287                    whoami()
288                );
289            }
290        }
291    }
292}
293
294#[cfg(target_os = "linux")]
295fn whoami() -> String {
296    std::env::var("USER")
297        .or_else(|_| std::env::var("LOGNAME"))
298        .unwrap_or_else(|_| "$(whoami)".to_string())
299}
300
301#[cfg(target_os = "linux")]
302fn uninstall_systemd(quiet: bool) {
303    let service_path = systemd_path();
304    if !service_path.exists() {
305        if !quiet {
306            println!("  Daemon systemd service not installed, nothing to remove");
307        }
308        return;
309    }
310
311    let _ = std::process::Command::new("systemctl")
312        .args(["--user", "stop", SYSTEMD_SERVICE])
313        .output();
314    let _ = std::process::Command::new("systemctl")
315        .args(["--user", "disable", SYSTEMD_SERVICE])
316        .output();
317    let _ = std::fs::remove_file(&service_path);
318    let _ = std::process::Command::new("systemctl")
319        .args(["--user", "daemon-reload"])
320        .output();
321
322    if !quiet {
323        println!("  Removed daemon systemd service: {SYSTEMD_SERVICE}");
324    }
325}