1use 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> {
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
66pub 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
86pub 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#[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 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 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#[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#[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
507fn 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
520pub(crate) fn has_user_decided() -> bool {
522 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
532pub 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
547fn 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 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 let _ = has_user_decided();
636 }
637}