Skip to main content

lean_ctx/core/
update_scheduler.rs

1//! OS-specific auto-update scheduler management.
2//! Supports macOS LaunchAgent, Linux systemd/cron, Windows Task Scheduler.
3
4use std::path::PathBuf;
5
6#[cfg(target_os = "macos")]
7const LABEL: &str = "com.leanctx.autoupdate";
8
9#[derive(Debug, Clone)]
10pub struct ScheduleInfo {
11    pub enabled: bool,
12    pub mechanism: String,
13    pub interval_hours: u64,
14    pub scheduler_path: Option<PathBuf>,
15    pub last_check: Option<String>,
16}
17
18impl std::fmt::Display for ScheduleInfo {
19    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
20        if self.enabled {
21            write!(
22                f,
23                "Auto-update: enabled ({}, every {}h)",
24                self.mechanism, self.interval_hours
25            )?;
26            if let Some(ref path) = self.scheduler_path {
27                write!(f, "\n  Scheduler: {}", path.display())?;
28            }
29            if let Some(ref last) = self.last_check {
30                write!(f, "\n  Last check: {last}")?;
31            }
32        } else {
33            write!(f, "Auto-update: disabled")?;
34        }
35        Ok(())
36    }
37}
38
39/// Install the auto-update schedule. Refused for another instance (directory
40/// override, sandbox HOME): the LaunchAgent label, the systemd timer and the
41/// per-user crontab all belong to the default install, and `crontab` ignores
42/// HOME entirely (6 Nov 2026, see
43/// [`crate::daemon_autostart::controls_user_services`]).
44pub fn install_schedule(interval_hours: u64) -> Result<ScheduleInfo, String> {
45    if !crate::daemon_autostart::controls_user_services() {
46        return Err(crate::daemon_autostart::not_this_instance_message().to_string());
47    }
48    let binary = std::path::PathBuf::from(super::portable_binary::resolve_portable_binary());
49
50    #[cfg(target_os = "macos")]
51    return install_macos_launchagent(&binary, interval_hours * 3600, interval_hours);
52
53    #[cfg(target_os = "linux")]
54    return install_linux_scheduler(&binary, interval_hours);
55
56    #[cfg(target_os = "windows")]
57    return install_windows_task(&binary, interval_hours);
58
59    #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
60    {
61        let _ = binary;
62        Err("Auto-update scheduling not supported on this platform".to_string())
63    }
64}
65
66/// Remove the auto-update schedule. Refused for another instance, like
67/// [`install_schedule`]: otherwise `lean-ctx uninstall` with a sandbox HOME
68/// stripped the real user's `lean-ctx … update` crontab line.
69pub fn remove_schedule() -> Result<(), String> {
70    if !crate::daemon_autostart::controls_user_services() {
71        return Err(crate::daemon_autostart::not_this_instance_message().to_string());
72    }
73    #[cfg(target_os = "macos")]
74    return remove_macos_launchagent();
75
76    #[cfg(target_os = "linux")]
77    return remove_linux_scheduler();
78
79    #[cfg(target_os = "windows")]
80    return remove_windows_task();
81
82    #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
83    Ok(())
84}
85
86/// The schedule's state. "disabled" for another instance, without querying
87/// `launchctl`, `systemctl`, `crontab` or `schtasks`: the schedule is not its own.
88pub fn schedule_status() -> ScheduleInfo {
89    if !crate::daemon_autostart::controls_user_services() {
90        return ScheduleInfo {
91            enabled: false,
92            mechanism: "not this instance".into(),
93            interval_hours: 0,
94            scheduler_path: None,
95            last_check: None,
96        };
97    }
98    #[cfg(target_os = "macos")]
99    return macos_status();
100
101    #[cfg(target_os = "linux")]
102    return linux_status();
103
104    #[cfg(target_os = "windows")]
105    return windows_status();
106
107    #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
108    ScheduleInfo {
109        enabled: false,
110        mechanism: "unsupported".into(),
111        interval_hours: 0,
112        scheduler_path: None,
113        last_check: None,
114    }
115}
116
117// ─── macOS ───────────────────────────────────────────────
118
119#[cfg(target_os = "macos")]
120fn plist_path() -> PathBuf {
121    dirs::home_dir()
122        .unwrap_or_else(|| PathBuf::from("/tmp"))
123        .join("Library/LaunchAgents")
124        .join(format!("{LABEL}.plist"))
125}
126
127#[cfg(target_os = "macos")]
128fn install_macos_launchagent(
129    binary: &std::path::Path,
130    interval_secs: u64,
131    interval_hours: u64,
132) -> Result<ScheduleInfo, String> {
133    let path = plist_path();
134    if let Some(dir) = path.parent() {
135        std::fs::create_dir_all(dir).map_err(|e| e.to_string())?;
136    }
137
138    // GH #439: auto-update logs are STATE — route through the typed resolver so a
139    // post-split install writes to $XDG_STATE_HOME/lean-ctx, not a re-created
140    // ~/.lean-ctx. Legacy single-dir installs keep resolving to ~/.lean-ctx.
141    let log_dir =
142        crate::core::paths::state_dir().unwrap_or_else(|_| std::env::temp_dir().join("lean-ctx"));
143    let _ = std::fs::create_dir_all(&log_dir);
144
145    let binary_str = binary.to_string_lossy();
146    let stdout_log = log_dir.join("autoupdate-stdout.log");
147    let stderr_log = log_dir.join("autoupdate-stderr.log");
148
149    // #356: wrap the launchd invocation in a deny-~/Documents seatbelt sandbox
150    // so the scheduled updater (a TCC-standalone process) can never trip the
151    // privacy prompt.
152    let program_args = crate::core::tcc_guard_sandbox::program_args_xml(
153        &crate::core::tcc_guard_sandbox::wrap_launchd_args(
154            &binary_str,
155            &["update", "--quiet", "--scheduled"],
156        ),
157        "    ",
158    );
159
160    let plist = format!(
161        r#"<?xml version="1.0" encoding="UTF-8"?>
162<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
163<plist version="1.0">
164<dict>
165  <key>Label</key>
166  <string>{LABEL}</string>
167  <key>ProgramArguments</key>
168  <array>
169{program_args}
170  </array>
171  <key>StartInterval</key>
172  <integer>{interval_secs}</integer>
173  <key>RunAtLoad</key>
174  <false/>
175  <key>StandardOutPath</key>
176  <string>{}</string>
177  <key>StandardErrorPath</key>
178  <string>{}</string>
179</dict>
180</plist>"#,
181        stdout_log.display(),
182        stderr_log.display()
183    );
184
185    crate::core::launchd::bootout(LABEL, &path);
186
187    std::fs::write(&path, plist).map_err(|e| format!("Failed to write plist: {e}"))?;
188
189    if !crate::core::launchd::bootstrap(LABEL, &path) {
190        return Err("launchctl bootstrap failed; check: launchctl print gui/$(id -u)".into());
191    }
192
193    Ok(ScheduleInfo {
194        enabled: true,
195        mechanism: "LaunchAgent".into(),
196        interval_hours,
197        scheduler_path: Some(path),
198        last_check: None,
199    })
200}
201
202#[cfg(target_os = "macos")]
203fn remove_macos_launchagent() -> Result<(), String> {
204    let path = plist_path();
205    if path.exists() {
206        crate::core::launchd::bootout(LABEL, &path);
207        std::fs::remove_file(&path).map_err(|e| format!("Failed to remove plist: {e}"))?;
208    }
209    Ok(())
210}
211
212#[cfg(target_os = "macos")]
213fn macos_status() -> ScheduleInfo {
214    let path = plist_path();
215    let enabled = path.exists();
216    let interval_hours = if enabled {
217        std::fs::read_to_string(&path)
218            .ok()
219            .and_then(|content| {
220                let idx = content.find("<key>StartInterval</key>")?;
221                let after = &content[idx..];
222                let int_start = after.find("<integer>")? + 9;
223                let int_end = after.find("</integer>")?;
224                after[int_start..int_end].parse::<u64>().ok()
225            })
226            .map_or(6, |s| s / 3600)
227    } else {
228        0
229    };
230    ScheduleInfo {
231        enabled,
232        mechanism: "LaunchAgent".into(),
233        interval_hours,
234        scheduler_path: Some(path),
235        last_check: read_last_check_time(),
236    }
237}
238
239// ─── Linux ───────────────────────────────────────────────
240
241#[cfg(target_os = "linux")]
242fn has_systemd() -> bool {
243    std::path::Path::new("/run/systemd/system").exists()
244}
245
246#[cfg(target_os = "linux")]
247fn systemd_dir() -> PathBuf {
248    dirs::home_dir()
249        .unwrap_or_else(|| PathBuf::from("/tmp"))
250        .join(".config/systemd/user")
251}
252
253#[cfg(target_os = "linux")]
254fn install_linux_scheduler(
255    binary: &std::path::Path,
256    interval_hours: u64,
257) -> Result<ScheduleInfo, String> {
258    if has_systemd() {
259        install_linux_systemd(binary, interval_hours)
260    } else {
261        install_linux_cron(binary, interval_hours)
262    }
263}
264
265#[cfg(target_os = "linux")]
266fn install_linux_systemd(
267    binary: &std::path::Path,
268    interval_hours: u64,
269) -> Result<ScheduleInfo, String> {
270    let dir = systemd_dir();
271    std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
272
273    let binary_str = binary.to_string_lossy();
274
275    let service = format!(
276        "[Unit]\nDescription=lean-ctx auto-updater\n\n[Service]\nType=oneshot\nExecStart={binary_str} update --quiet --scheduled\n"
277    );
278    let timer = format!(
279        "[Unit]\nDescription=lean-ctx auto-update timer\n\n[Timer]\nOnBootSec=1h\nOnUnitActiveSec={interval_hours}h\nPersistent=true\n\n[Install]\nWantedBy=timers.target\n"
280    );
281
282    std::fs::write(dir.join("lean-ctx-autoupdate.service"), service).map_err(|e| e.to_string())?;
283    let timer_path = dir.join("lean-ctx-autoupdate.timer");
284    std::fs::write(&timer_path, timer).map_err(|e| e.to_string())?;
285
286    let _ = std::process::Command::new("systemctl")
287        .args(["--user", "daemon-reload"])
288        .output();
289    let out = std::process::Command::new("systemctl")
290        .args(["--user", "enable", "--now", "lean-ctx-autoupdate.timer"])
291        .output()
292        .map_err(|e| e.to_string())?;
293
294    if !out.status.success() {
295        return Err(format!(
296            "systemctl enable failed: {}",
297            String::from_utf8_lossy(&out.stderr)
298        ));
299    }
300
301    Ok(ScheduleInfo {
302        enabled: true,
303        mechanism: "systemd timer".into(),
304        interval_hours,
305        scheduler_path: Some(timer_path),
306        last_check: None,
307    })
308}
309
310#[cfg(target_os = "linux")]
311fn install_linux_cron(
312    binary: &std::path::Path,
313    interval_hours: u64,
314) -> Result<ScheduleInfo, String> {
315    let cron_expr = if interval_hours <= 1 {
316        "0 * * * *".to_string()
317    } else if interval_hours >= 24 {
318        "0 4 * * *".to_string()
319    } else {
320        format!("0 */{interval_hours} * * *")
321    };
322
323    let entry = format!(
324        "{cron_expr} {} update --quiet --scheduled",
325        binary.to_string_lossy()
326    );
327
328    let existing = std::process::Command::new("crontab")
329        .arg("-l")
330        .output()
331        .map(|o| String::from_utf8_lossy(&o.stdout).to_string())
332        .unwrap_or_default();
333
334    let filtered: String = existing
335        .lines()
336        .filter(|l| !l.contains("lean-ctx") || !l.contains("update"))
337        .chain(std::iter::once(entry.as_str()))
338        .collect::<Vec<_>>()
339        .join("\n")
340        + "\n";
341
342    let mut child = std::process::Command::new("crontab")
343        .arg("-")
344        .stdin(std::process::Stdio::piped())
345        .spawn()
346        .map_err(|e| e.to_string())?;
347
348    use std::io::Write;
349    child
350        .stdin
351        .take()
352        .ok_or_else(|| "failed to open crontab stdin".to_string())?
353        .write_all(filtered.as_bytes())
354        .map_err(|e| e.to_string())?;
355    child.wait().map_err(|e| e.to_string())?;
356
357    Ok(ScheduleInfo {
358        enabled: true,
359        mechanism: "cron".into(),
360        interval_hours,
361        scheduler_path: None,
362        last_check: None,
363    })
364}
365
366#[cfg(target_os = "linux")]
367#[allow(clippy::unnecessary_wraps)]
368fn remove_linux_scheduler() -> Result<(), String> {
369    let dir = systemd_dir();
370    let timer = dir.join("lean-ctx-autoupdate.timer");
371    let service = dir.join("lean-ctx-autoupdate.service");
372    if timer.exists() {
373        let _ = std::process::Command::new("systemctl")
374            .args(["--user", "disable", "--now", "lean-ctx-autoupdate.timer"])
375            .output();
376        let _ = std::fs::remove_file(&timer);
377        let _ = std::fs::remove_file(&service);
378        let _ = std::process::Command::new("systemctl")
379            .args(["--user", "daemon-reload"])
380            .output();
381    }
382
383    if let Ok(out) = std::process::Command::new("crontab").arg("-l").output() {
384        let existing = String::from_utf8_lossy(&out.stdout).to_string();
385        if existing.contains("lean-ctx") && existing.contains("update") {
386            let filtered: String = existing
387                .lines()
388                .filter(|l| !(l.contains("lean-ctx") && l.contains("update")))
389                .collect::<Vec<_>>()
390                .join("\n")
391                + "\n";
392            if let Ok(mut child) = std::process::Command::new("crontab")
393                .arg("-")
394                .stdin(std::process::Stdio::piped())
395                .spawn()
396            {
397                use std::io::Write;
398                if let Some(mut stdin) = child.stdin.take() {
399                    let _ = stdin.write_all(filtered.as_bytes());
400                }
401                let _ = child.wait();
402            }
403        }
404    }
405    Ok(())
406}
407
408#[cfg(target_os = "linux")]
409fn linux_status() -> ScheduleInfo {
410    let timer = systemd_dir().join("lean-ctx-autoupdate.timer");
411    if timer.exists() {
412        return ScheduleInfo {
413            enabled: true,
414            mechanism: "systemd timer".into(),
415            interval_hours: 6,
416            scheduler_path: Some(timer),
417            last_check: read_last_check_time(),
418        };
419    }
420    if let Ok(out) = std::process::Command::new("crontab").arg("-l").output() {
421        let crontab = String::from_utf8_lossy(&out.stdout);
422        if crontab.contains("lean-ctx") && crontab.contains("update") {
423            return ScheduleInfo {
424                enabled: true,
425                mechanism: "cron".into(),
426                interval_hours: 6,
427                scheduler_path: None,
428                last_check: read_last_check_time(),
429            };
430        }
431    }
432    ScheduleInfo {
433        enabled: false,
434        mechanism: "none".into(),
435        interval_hours: 0,
436        scheduler_path: None,
437        last_check: None,
438    }
439}
440
441// ─── Windows ─────────────────────────────────────────────
442
443#[cfg(target_os = "windows")]
444fn install_windows_task(
445    binary: &std::path::Path,
446    interval_hours: u64,
447) -> Result<ScheduleInfo, String> {
448    let binary_str = binary.to_string_lossy();
449    let out = std::process::Command::new("schtasks")
450        .args([
451            "/Create",
452            "/F",
453            "/TN",
454            "lean-ctx autoupdate",
455            "/TR",
456            &format!("\"{binary_str}\" update --quiet --scheduled"),
457            "/SC",
458            "HOURLY",
459            "/MO",
460            &interval_hours.to_string(),
461            "/RL",
462            "HIGHEST",
463        ])
464        .output()
465        .map_err(|e| e.to_string())?;
466
467    if !out.status.success() {
468        return Err(format!(
469            "schtasks failed: {}",
470            String::from_utf8_lossy(&out.stderr)
471        ));
472    }
473
474    Ok(ScheduleInfo {
475        enabled: true,
476        mechanism: "Task Scheduler".into(),
477        interval_hours,
478        scheduler_path: None,
479        last_check: None,
480    })
481}
482
483#[cfg(target_os = "windows")]
484fn remove_windows_task() -> Result<(), String> {
485    let _ = std::process::Command::new("schtasks")
486        .args(["/Delete", "/F", "/TN", "lean-ctx autoupdate"])
487        .output();
488    Ok(())
489}
490
491#[cfg(target_os = "windows")]
492fn windows_status() -> ScheduleInfo {
493    let out = std::process::Command::new("schtasks")
494        .args(["/Query", "/TN", "lean-ctx autoupdate", "/FO", "LIST"])
495        .output();
496
497    let enabled = out.as_ref().is_ok_and(|o| o.status.success());
498    ScheduleInfo {
499        enabled,
500        mechanism: "Task Scheduler".into(),
501        interval_hours: if enabled { 6 } else { 0 },
502        scheduler_path: None,
503        last_check: read_last_check_time(),
504    }
505}
506
507// ─── Shared ──────────────────────────────────────────────
508
509fn read_last_check_time() -> Option<String> {
510    let path = crate::core::paths::cache_dir()
511        .ok()?
512        .join("latest-version.json");
513    let content = std::fs::read_to_string(path).ok()?;
514    let v: serde_json::Value = serde_json::from_str(&content).ok()?;
515    let ts = v["checked_at"].as_u64()?;
516    let dt = chrono::DateTime::from_timestamp(ts as i64, 0)?;
517    Some(dt.format("%Y-%m-%d %H:%M UTC").to_string())
518}
519
520/// Check if the user has ever configured `auto_update` (the key exists in config.toml).
521pub(crate) fn has_user_decided() -> bool {
522    // Read the canonical config location (GH #408) — the hardcoded `~/.lean-ctx`
523    // path missed configs stored under `~/.config/lean-ctx`, the default for
524    // most installs, so the prompt could re-fire after the user had decided.
525    let Some(config_path) = crate::core::config::Config::path() else {
526        return false;
527    };
528    let content = std::fs::read_to_string(config_path).unwrap_or_default();
529    content.contains("auto_update")
530}
531
532/// Writes the `[updates]` settings to config.toml, preserving all comments,
533/// formatting, and unrelated keys.
534pub fn set_auto_update(enabled: bool, notify_only: bool, interval_hours: u64) {
535    let Some(config_path) = crate::core::config::Config::path() else {
536        return;
537    };
538    if let Some(dir) = config_path.parent() {
539        let _ = std::fs::create_dir_all(dir);
540    }
541
542    let mut doc = crate::config_io::load_toml_document(&config_path);
543    apply_auto_update(&mut doc, enabled, notify_only, interval_hours);
544    let _ = crate::config_io::write_toml_document(&config_path, &doc);
545}
546
547/// Applies the `[updates]` settings onto a TOML document in place. Pure helper
548/// so the merge behavior is unit-testable without touching the real home dir.
549fn apply_auto_update(
550    doc: &mut toml_edit::DocumentMut,
551    enabled: bool,
552    notify_only: bool,
553    interval_hours: u64,
554) {
555    let updates = doc["updates"].or_insert(toml_edit::table());
556    updates["auto_update"] = toml_edit::value(enabled);
557    updates["check_interval_hours"] = toml_edit::value(interval_hours as i64);
558    updates["notify_only"] = toml_edit::value(notify_only);
559}
560
561#[cfg(test)]
562mod tests {
563    use super::*;
564
565    #[test]
566    fn schedule_info_display_disabled() {
567        let info = ScheduleInfo {
568            enabled: false,
569            mechanism: "none".into(),
570            interval_hours: 0,
571            scheduler_path: None,
572            last_check: None,
573        };
574        assert!(info.to_string().contains("disabled"));
575    }
576
577    #[test]
578    fn schedule_info_display_enabled() {
579        let info = ScheduleInfo {
580            enabled: true,
581            mechanism: "LaunchAgent".into(),
582            interval_hours: 6,
583            scheduler_path: Some(PathBuf::from("/tmp/test.plist")),
584            last_check: Some("2026-05-17 10:00 UTC".into()),
585        };
586        let s = info.to_string();
587        assert!(s.contains("enabled"));
588        assert!(s.contains("LaunchAgent"));
589        assert!(s.contains("6h"));
590    }
591
592    #[test]
593    fn apply_auto_update_preserves_existing_keys_and_comments() {
594        let mut doc = "\
595# important user comment
596buddy_enabled = true
597"
598        .parse::<toml_edit::DocumentMut>()
599        .unwrap();
600
601        apply_auto_update(&mut doc, true, false, 12);
602
603        let result = doc.to_string();
604        assert!(result.contains("auto_update = true"));
605        assert!(result.contains("check_interval_hours = 12"));
606        assert!(result.contains("notify_only = false"));
607        // Existing key + comment survive.
608        assert!(result.contains("buddy_enabled = true"));
609        assert!(result.contains("# important user comment"));
610    }
611
612    #[test]
613    fn apply_auto_update_overwrites_only_updates_section() {
614        let mut doc = "\
615[updates]
616auto_update = false
617check_interval_hours = 99
618"
619        .parse::<toml_edit::DocumentMut>()
620        .unwrap();
621
622        apply_auto_update(&mut doc, true, true, 6);
623
624        let result = doc.to_string();
625        assert!(result.contains("auto_update = true"));
626        assert!(result.contains("check_interval_hours = 6"));
627        assert!(result.contains("notify_only = true"));
628        assert!(!result.contains("check_interval_hours = 99"));
629    }
630
631    #[test]
632    fn has_user_decided_false_by_default() {
633        // In test env, the config likely doesn't contain auto_update
634        // This tests the function doesn't panic
635        let _ = has_user_decided();
636    }
637}