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#[must_use]
13pub const fn is_supported() -> bool {
14 cfg!(any(target_os = "macos", target_os = "linux"))
15}
16
17#[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
25pub 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
42pub 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#[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
78fn 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#[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#[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
184pub 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
207pub 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
247pub enum Supervision {
248 Already,
250 Installed,
252 Failed,
254 Skipped,
257}
258
259pub 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
291pub 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
307fn 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
326pub 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#[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 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 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#[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 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 #[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 #[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 #[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 #[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}