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