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
9/// Whether this platform has a daemon autostart service (LaunchAgent on
10/// macOS, systemd user unit on Linux). Windows has none: the daemon starts
11/// through `lean-ctx daemon start` or on demand from an editor (#2176).
12#[must_use]
13pub const fn is_supported() -> bool {
14    cfg!(any(target_os = "macos", target_os = "linux"))
15}
16
17/// What `lean-ctx daemon enable|disable` reports where no autostart service
18/// exists — one line, never followed by a success claim (#2176).
19#[must_use]
20pub fn unsupported_message() -> &'static str {
21    "Daemon autostart is not available on this platform. \
22     Start the daemon with `lean-ctx daemon start` (editors also start it on demand)."
23}
24
25/// Platform service unit name (`systemctl --user <name>` / `launchctl print gui/$UID/<name>`).
26/// `None` on platforms without autostart support.
27pub fn service_name() -> Option<&'static str> {
28    #[cfg(target_os = "macos")]
29    {
30        Some(PLIST_LABEL)
31    }
32    #[cfg(target_os = "linux")]
33    {
34        Some(SYSTEMD_SERVICE)
35    }
36    #[cfg(not(any(target_os = "macos", target_os = "linux")))]
37    {
38        None
39    }
40}
41
42/// Path of the autostart service file lean-ctx writes on `daemon enable`
43/// (LaunchAgent plist on macOS, systemd user unit on Linux), regardless of
44/// whether it currently exists. `None` on unsupported platforms.
45pub fn service_file_path() -> Option<std::path::PathBuf> {
46    #[cfg(target_os = "macos")]
47    {
48        Some(launchagent_path())
49    }
50    #[cfg(target_os = "linux")]
51    {
52        Some(systemd_path())
53    }
54    #[cfg(not(any(target_os = "macos", target_os = "linux")))]
55    {
56        None
57    }
58}
59
60/// Whether this process may drive the user-wide autostart services (the
61/// systemd user unit, the LaunchAgent). They run the daemon of the default
62/// install: the account's own HOME and no directory override. The service
63/// manager addresses a unit by name (`systemctl --user stop
64/// lean-ctx-daemon`), whatever data dir the caller has, so a process with
65/// `LEAN_CTX_DATA_DIR` (or another directory override) or a sandbox HOME is
66/// another instance and never stops, starts, installs or removes them. On
67/// 5 Nov 2026 `serve --stop` with a scratch `LEAN_CTX_DATA_DIR` stopped the
68/// user's real daemon through exactly that call.
69#[must_use]
70pub fn controls_user_services() -> bool {
71    services_belong_to_instance(
72        crate::core::paths::dir_override_active(),
73        dirs::home_dir().as_deref(),
74        crate::core::config::shell_policy::real_user_home().as_deref(),
75    )
76}
77
78/// [`controls_user_services`] as a pure function of its inputs.
79fn services_belong_to_instance(
80    override_active: bool,
81    home: Option<&std::path::Path>,
82    account_home: Option<&std::path::Path>,
83) -> bool {
84    if override_active {
85        return false;
86    }
87    let (Some(home), Some(account_home)) = (home, account_home) else {
88        return false;
89    };
90    home == account_home
91        || std::fs::canonicalize(home)
92            .ok()
93            .zip(std::fs::canonicalize(account_home).ok())
94            .is_some_and(|(a, b)| a == b)
95}
96
97/// What a command prints when it leaves a user-wide service alone because it
98/// belongs to another instance (see [`controls_user_services`]): the daemon
99/// and proxy autostart units and the auto-update schedule (LaunchAgent,
100/// systemd timer, crontab entry, scheduled task).
101#[must_use]
102pub fn not_this_instance_message() -> &'static str {
103    "Not this instance: the user-wide service (daemon or proxy autostart, auto-update \
104     schedule) belongs to the default install (the account's HOME without LEAN_CTX_DATA_DIR \
105     or another directory override); this instance leaves it alone."
106}
107
108/// The PID of the daemon the autostart service runs right now, when the
109/// service belongs to this instance. `None` when it runs nothing, is not
110/// installed, or belongs to another instance. Read-only.
111#[must_use]
112pub fn supervised_pid() -> Option<u32> {
113    if !is_supported() || !controls_user_services() {
114        return None;
115    }
116    #[cfg(target_os = "macos")]
117    {
118        if !launchagent_path().exists() {
119            return None;
120        }
121        crate::core::launchd::service_pid(PLIST_LABEL)
122    }
123    #[cfg(target_os = "linux")]
124    {
125        if !systemd_path().exists() {
126            return None;
127        }
128        let output = std::process::Command::new("systemctl")
129            .args([
130                "--user",
131                "show",
132                "--property=MainPID",
133                "--value",
134                SYSTEMD_SERVICE,
135            ])
136            .output()
137            .ok()
138            .filter(|o| o.status.success())?;
139        String::from_utf8_lossy(&output.stdout)
140            .trim()
141            .parse::<u32>()
142            .ok()
143            .filter(|pid| *pid != 0)
144    }
145    #[cfg(not(any(target_os = "macos", target_os = "linux")))]
146    {
147        None
148    }
149}
150
151pub fn install(quiet: bool) {
152    let binary = crate::proxy_autostart::find_binary();
153    install_with_binary(&binary, quiet);
154}
155
156pub fn install_with_binary(binary: &str, quiet: bool) {
157    if is_supported() && !controls_user_services() {
158        if !quiet {
159            eprintln!("{}", not_this_instance_message());
160        }
161        return;
162    }
163    if binary.is_empty() {
164        if !quiet {
165            tracing::error!("Cannot find lean-ctx binary for daemon autostart");
166        }
167        return;
168    }
169
170    #[cfg(target_os = "macos")]
171    install_launchagent(binary, quiet);
172
173    #[cfg(target_os = "linux")]
174    install_systemd(binary, quiet);
175
176    #[cfg(not(any(target_os = "macos", target_os = "linux")))]
177    {
178        let _ = (binary, quiet);
179        println!("  Autostart not supported on this platform");
180        println!("  Run manually: lean-ctx serve -d");
181    }
182}
183
184/// Stop the autostart service. A no-op for another instance (see
185/// [`controls_user_services`]); that instance's daemon is stopped through
186/// its own PID file and endpoint by [`crate::daemon::stop_daemon`].
187pub fn stop() {
188    if !controls_user_services() {
189        return;
190    }
191    #[cfg(target_os = "macos")]
192    {
193        let plist_path = launchagent_path();
194        if plist_path.exists() {
195            crate::core::launchd::bootout(PLIST_LABEL, &plist_path);
196        }
197    }
198
199    #[cfg(target_os = "linux")]
200    {
201        let _ = std::process::Command::new("systemctl")
202            .args(["--user", "stop", SYSTEMD_SERVICE])
203            .output();
204    }
205}
206
207/// Start the autostart service; a no-op for another instance.
208pub fn start() {
209    if !controls_user_services() {
210        return;
211    }
212    #[cfg(target_os = "macos")]
213    {
214        let plist_path = launchagent_path();
215        if plist_path.exists() {
216            crate::core::launchd::bootstrap(PLIST_LABEL, &plist_path);
217        }
218    }
219
220    #[cfg(target_os = "linux")]
221    {
222        let _ = std::process::Command::new("systemctl")
223            .args(["--user", "start", SYSTEMD_SERVICE])
224            .output();
225    }
226}
227
228pub fn uninstall(quiet: bool) {
229    if is_supported() && !controls_user_services() {
230        if !quiet {
231            eprintln!("{}", not_this_instance_message());
232        }
233        return;
234    }
235    #[cfg(target_os = "macos")]
236    uninstall_launchagent(quiet);
237
238    #[cfg(target_os = "linux")]
239    uninstall_systemd(quiet);
240
241    #[cfg(not(any(target_os = "macos", target_os = "linux")))]
242    let _ = quiet;
243}
244
245/// What [`ensure_supervised`] did.
246#[derive(Debug, Clone, Copy, PartialEq, Eq)]
247pub enum Supervision {
248    /// The service was installed already (and is started if it was not).
249    Already,
250    /// The service is installed now; a daemon started by hand was handed over.
251    Installed,
252    /// Installing it did not work: the daemon runs without supervision.
253    Failed,
254    /// No supervisor on this platform, or a test / isolated instance (a
255    /// directory override), which never installs a user-wide service.
256    Skipped,
257}
258
259/// Make sure the daemon runs under the platform's supervisor (systemd user
260/// unit, LaunchAgent): it starts at login and restarts after a crash, so
261/// work that lives in the daemon (Hive's background sync, the outbox) never
262/// depends on someone starting it by hand. A daemon started by hand is
263/// stopped first so the supervised one can take its socket. `restart`: a
264/// supervised daemon already running is restarted (a new connection or a
265/// new build takes effect; the daemon reads both only at start).
266pub fn ensure_supervised(restart: bool) -> Supervision {
267    if !is_supported() || cfg!(test) || !controls_user_services() {
268        return Supervision::Skipped;
269    }
270    if is_installed() {
271        if restart {
272            stop();
273        }
274        if restart || !crate::daemon::is_daemon_running() {
275            start();
276        }
277        return Supervision::Already;
278    }
279    if crate::daemon::is_daemon_running() {
280        let _ = crate::daemon::stop_daemon();
281        std::thread::sleep(std::time::Duration::from_millis(500));
282    }
283    install(true);
284    if is_installed() {
285        Supervision::Installed
286    } else {
287        Supervision::Failed
288    }
289}
290
291/// The line a command shows for `supervision` (`None`: nothing to say).
292pub fn supervision_line(supervision: Supervision) -> Option<String> {
293    match supervision {
294        Supervision::Installed => Some(format!(
295            "The LeanCTX daemon now runs supervised ({}): it starts at login and restarts after a crash.{}",
296            service_name().unwrap_or("service"),
297            linger_hint()
298        )),
299        Supervision::Failed => Some(
300            "The LeanCTX daemon could not be set up to start by itself; run `lean-ctx daemon enable`."
301                .to_string(),
302        ),
303        Supervision::Already | Supervision::Skipped => None,
304    }
305}
306
307/// Linux without lingering stops user services at logout (a headless
308/// server): the hint, else nothing.
309fn linger_hint() -> String {
310    #[cfg(target_os = "linux")]
311    {
312        let user = whoami();
313        let lingering = std::process::Command::new("loginctl")
314            .args(["show-user", &user, "-p", "Linger", "--value"])
315            .output()
316            .is_ok_and(|o| String::from_utf8_lossy(&o.stdout).trim() == "yes");
317        if !lingering {
318            return format!(
319                " To keep it running while you are logged out: loginctl enable-linger {user}"
320            );
321        }
322    }
323    String::new()
324}
325
326/// Whether this instance's autostart service is installed. Another
327/// instance's service (see [`controls_user_services`]) never counts.
328pub fn is_installed() -> bool {
329    if !controls_user_services() {
330        return false;
331    }
332    #[cfg(target_os = "macos")]
333    {
334        launchagent_path().exists()
335    }
336    #[cfg(target_os = "linux")]
337    {
338        if !systemd_path().exists() {
339            return false;
340        }
341        std::process::Command::new("systemctl")
342            .args(["--user", "is-enabled", "--quiet", SYSTEMD_SERVICE])
343            .status()
344            .is_ok_and(|s| s.success())
345    }
346    #[cfg(not(any(target_os = "macos", target_os = "linux")))]
347    {
348        false
349    }
350}
351
352// ---------------------------------------------------------------------------
353// macOS LaunchAgent
354// ---------------------------------------------------------------------------
355
356#[cfg(target_os = "macos")]
357fn launchagent_path() -> PathBuf {
358    dirs::home_dir()
359        .unwrap_or_else(|| PathBuf::from("/tmp"))
360        .join("Library/LaunchAgents")
361        .join(format!("{PLIST_LABEL}.plist"))
362}
363
364#[cfg(target_os = "macos")]
365fn install_launchagent(binary: &str, quiet: bool) {
366    let la_dir = dirs::home_dir()
367        .unwrap_or_else(|| PathBuf::from("/tmp"))
368        .join("Library/LaunchAgents");
369    let _ = std::fs::create_dir_all(&la_dir);
370
371    let plist_path = la_dir.join(format!("{PLIST_LABEL}.plist"));
372    let data_dir = dirs::data_local_dir()
373        .unwrap_or_else(|| dirs::home_dir().unwrap_or_default().join(".local/share"))
374        .join("lean-ctx");
375
376    let _ = std::fs::create_dir_all(&data_dir);
377
378    // #356: wrap the launchd invocation in a deny-~/Documents seatbelt sandbox
379    // so the daemon (a TCC-standalone process) can never trip the privacy prompt.
380    let program_args = crate::core::tcc_guard_sandbox::program_args_xml(
381        &crate::core::tcc_guard_sandbox::wrap_launchd_args(
382            binary,
383            &["serve", "--_foreground-daemon"],
384        ),
385        "        ",
386    );
387
388    // #449: pin the directory layout so the launchd-spawned daemon resolves the
389    // same config/data dirs as the installing CLI (see `pinned_layout_env_xml`).
390    let env_vars = crate::core::tcc_guard_sandbox::pinned_layout_env_xml();
391
392    let plist = format!(
393        r#"<?xml version="1.0" encoding="UTF-8"?>
394<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
395<plist version="1.0">
396<dict>
397    <key>Label</key>
398    <string>{PLIST_LABEL}</string>
399    <key>ProgramArguments</key>
400    <array>
401{program_args}
402    </array>
403{env_vars}    <key>RunAtLoad</key>
404    <true/>
405    <key>KeepAlive</key>
406    <dict>
407        <key>SuccessfulExit</key>
408        <false/>
409    </dict>
410    <key>ThrottleInterval</key>
411    <integer>10</integer>
412    <key>StandardOutPath</key>
413    <string>{stdout}</string>
414    <key>StandardErrorPath</key>
415    <string>{stderr}</string>
416</dict>
417</plist>
418"#,
419        stdout = data_dir.join("daemon-stdout.log").display(),
420        stderr = data_dir.join("daemon-stderr.log").display(),
421    );
422
423    let _ = std::fs::write(&plist_path, &plist);
424
425    let ok = crate::core::launchd::bootstrap(PLIST_LABEL, &plist_path);
426
427    if !quiet {
428        if ok {
429            println!("  Installed LaunchAgent: {PLIST_LABEL}");
430            println!("  Service file: {}", plist_path.display());
431            println!("  Daemon will start on login and restart if stopped");
432        } else {
433            println!("  Created plist at {}", plist_path.display());
434            println!("  Load reported a problem; check: launchctl print {PLIST_LABEL}");
435        }
436    }
437}
438
439#[cfg(target_os = "macos")]
440fn uninstall_launchagent(quiet: bool) {
441    let plist_path = launchagent_path();
442    if !plist_path.exists() {
443        if !quiet {
444            println!("  Daemon LaunchAgent not installed, nothing to remove");
445        }
446        return;
447    }
448    crate::core::launchd::bootout(PLIST_LABEL, &plist_path);
449    let _ = std::fs::remove_file(&plist_path);
450    if !quiet {
451        println!("  Removed daemon LaunchAgent: {PLIST_LABEL}");
452        println!("  Service file: {}", plist_path.display());
453    }
454}
455
456// ---------------------------------------------------------------------------
457// Linux systemd
458// ---------------------------------------------------------------------------
459
460#[cfg(target_os = "linux")]
461fn systemd_path() -> PathBuf {
462    dirs::home_dir()
463        .unwrap_or_else(|| PathBuf::from("/tmp"))
464        .join(".config/systemd/user")
465        .join(format!("{SYSTEMD_SERVICE}.service"))
466}
467
468#[cfg(target_os = "linux")]
469fn install_systemd(binary: &str, quiet: bool) {
470    let service_dir = dirs::home_dir()
471        .unwrap_or_else(|| PathBuf::from("/tmp"))
472        .join(".config/systemd/user");
473    let _ = std::fs::create_dir_all(&service_dir);
474
475    let service_path = service_dir.join(format!("{SYSTEMD_SERVICE}.service"));
476
477    let unit = format!(
478        r"[Unit]
479Description=lean-ctx IPC Daemon
480After=network.target
481StartLimitIntervalSec=300
482StartLimitBurst=5
483
484[Service]
485Type=simple
486ExecStart={binary} serve --_foreground-daemon
487Restart=on-failure
488RestartSec=5
489StandardOutput=journal
490StandardError=journal
491Environment=RUST_LOG=info
492
493[Install]
494WantedBy=default.target
495"
496    );
497
498    let _ = std::fs::write(&service_path, &unit);
499
500    let _ = std::process::Command::new("systemctl")
501        .args(["--user", "daemon-reload"])
502        .output();
503
504    let result = std::process::Command::new("systemctl")
505        .args(["--user", "enable", "--now", SYSTEMD_SERVICE])
506        .output();
507
508    match result {
509        Ok(o) if o.status.success() => {
510            if !quiet {
511                println!("  Installed systemd user service: {SYSTEMD_SERVICE}");
512                println!("  Service file: {}", service_path.display());
513                println!("  Daemon will start on login and restart if stopped");
514            }
515        }
516        Ok(o) => {
517            let err = String::from_utf8_lossy(&o.stderr);
518            eprintln!("  Created service file but `systemctl enable` failed: {err}");
519            eprintln!("  Try manually: systemctl --user enable --now {SYSTEMD_SERVICE}");
520        }
521        Err(e) => {
522            eprintln!("  Created service file at {}", service_path.display());
523            eprintln!("  Could not run systemctl: {e}");
524        }
525    }
526
527    // Hint about linger for headless/server use (needed for boot-time start without login)
528    if !quiet
529        && let Ok(o) = std::process::Command::new("loginctl")
530            .args(["show-user", &whoami(), "-p", "Linger", "--value"])
531            .output()
532    {
533        let val = String::from_utf8_lossy(&o.stdout).trim().to_string();
534        if val != "yes" {
535            println!(
536                "  Note: for daemon to start at boot (without login), run:\n    \
537                 loginctl enable-linger {}",
538                whoami()
539            );
540        }
541    }
542}
543
544#[cfg(target_os = "linux")]
545fn whoami() -> String {
546    std::env::var("USER")
547        .or_else(|_| std::env::var("LOGNAME"))
548        .unwrap_or_else(|_| "$(whoami)".to_string())
549}
550
551#[cfg(target_os = "linux")]
552fn uninstall_systemd(quiet: bool) {
553    let service_path = systemd_path();
554    if !service_path.exists() {
555        if !quiet {
556            println!("  Daemon systemd service not installed, nothing to remove");
557        }
558        return;
559    }
560
561    let _ = std::process::Command::new("systemctl")
562        .args(["--user", "stop", SYSTEMD_SERVICE])
563        .output();
564    let _ = std::process::Command::new("systemctl")
565        .args(["--user", "disable", SYSTEMD_SERVICE])
566        .output();
567    let _ = std::fs::remove_file(&service_path);
568    let _ = std::process::Command::new("systemctl")
569        .args(["--user", "daemon-reload"])
570        .output();
571
572    if !quiet {
573        println!("  Removed daemon systemd service: {SYSTEMD_SERVICE}");
574        println!("  Service file: {}", service_path.display());
575    }
576}
577
578#[cfg(test)]
579mod tests {
580    use std::path::Path;
581
582    /// 5 Nov 2026: the user-wide service belongs to the default install
583    /// only. A directory override or a sandbox HOME is another instance.
584    #[test]
585    fn user_services_belong_to_the_default_install_only() {
586        let account = Path::new("/home/someone");
587        let sandbox = Path::new("/tmp/sandbox-home");
588        assert!(super::services_belong_to_instance(
589            false,
590            Some(account),
591            Some(account)
592        ));
593        assert!(!super::services_belong_to_instance(
594            true,
595            Some(account),
596            Some(account)
597        ));
598        assert!(!super::services_belong_to_instance(
599            false,
600            Some(sandbox),
601            Some(account)
602        ));
603        assert!(!super::services_belong_to_instance(
604            false,
605            None,
606            Some(account)
607        ));
608        assert!(!super::services_belong_to_instance(
609            false,
610            Some(account),
611            None
612        ));
613    }
614
615    /// Under a directory override nothing reaches the service manager:
616    /// `stop`/`start`/`uninstall` return before `systemctl`/`launchctl`.
617    #[test]
618    fn directory_override_never_controls_user_services() {
619        let _lock = crate::core::data_dir::test_env_lock();
620        let dir = tempfile::tempdir().expect("tempdir");
621        crate::test_env::set_var("LEAN_CTX_DATA_DIR", dir.path());
622        let controls = super::controls_user_services();
623        let installed = super::is_installed();
624        let supervised = super::supervised_pid();
625        crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
626        assert!(!controls);
627        assert!(!installed);
628        assert_eq!(supervised, None);
629    }
630
631    /// #2176: support and the service path agree, and the unsupported line
632    /// never claims success.
633    #[test]
634    fn autostart_support_matches_service_path() {
635        assert_eq!(super::is_supported(), super::service_file_path().is_some());
636        let msg = super::unsupported_message();
637        assert!(msg.contains("not available"));
638        assert!(msg.contains("lean-ctx daemon start"));
639        assert!(!msg.contains('✓') && !msg.contains("enabled"));
640    }
641
642    // GH #394: the service file path must be discoverable programmatically so
643    // enable/disable/status/doctor can print it.
644    #[cfg(any(target_os = "macos", target_os = "linux"))]
645    #[test]
646    fn service_file_path_matches_platform_conventions() {
647        let path = super::service_file_path().expect("supported platform");
648        let name = super::service_name().expect("supported platform");
649        let s = path.to_string_lossy();
650        #[cfg(target_os = "macos")]
651        {
652            assert!(s.contains("Library/LaunchAgents"), "got: {s}");
653            assert!(s.ends_with("com.leanctx.daemon.plist"), "got: {s}");
654            assert_eq!(name, "com.leanctx.daemon");
655        }
656        #[cfg(target_os = "linux")]
657        {
658            assert!(s.contains(".config/systemd/user"), "got: {s}");
659            assert!(s.ends_with("lean-ctx-daemon.service"), "got: {s}");
660            assert_eq!(name, "lean-ctx-daemon");
661        }
662    }
663}